Stabilize Modelica Fixed/Free calibration and stop flaky Newton embeds.
Some checks failed
CI / check (push) Has been cancelled

Sort constraint/control assembly for deterministic Jacobians, keep live z_ua on legacy HX, and add generic Fixed/Free UI assist without removing Z factors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 23:23:35 +02:00
parent 5425685a48
commit 9b4b7b58b0
15 changed files with 1726 additions and 84 deletions

View File

@@ -617,7 +617,19 @@ impl Condenser {
FLOOD_LAMBDA_HI,
FLOOD_LAMBDA_WIDTH,
);
jacobian.add_entry(row, act_idx, self.ua() * c.delta_t * c.e_exp * dlam);
let ua_nom = self.inner.ua_nominal() * self.live_z_ua(state);
jacobian.add_entry(row, act_idx, ua_nom * c.delta_t * c.e_exp * dlam);
}
// Live z_ua on secondary energy: r = ṁ·Δh Q ⇒ ∂r/∂z = ∂Q/∂z
if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
let flood_scale = if self.flood_ready() {
1.0 - self.flooded_level(state)
} else {
1.0
};
let d_q_dz =
self.inner.ua_nominal() * flood_scale * c.e_exp * c.delta_t;
jacobian.add_entry(row, z_ua_idx, -d_q_dz);
}
}
None => {
@@ -782,14 +794,27 @@ impl Condenser {
}
}
/// Effective conductance `UA_eff` [W/K] used by the coupled duty. With an
/// active flooding actuator this is `(1 λ)·UA_nominal`; otherwise the
/// nominal `UA`.
/// Live calibration factor `z_ua` from Newton state when free, else calib param.
fn live_z_ua(&self, state: &StateSlice) -> f64 {
self.inner
.calib_indices_ref()
.z_ua
.and_then(|idx| state.get(idx).copied())
.unwrap_or_else(|| self.calib().z_ua)
.max(0.0)
}
/// Effective conductance `UA_eff` [W/K] used by the coupled duty.
///
/// `UA_eff = UA_nominal · z_ua · (1 λ)` when flooding is active, else
/// `UA_nominal · z_ua`. Reads live `state[z_ua]` when an embedding frees it
/// (otherwise ∂Q/∂z_ua = 0 → singular Jacobian).
fn effective_ua(&self, state: &StateSlice) -> f64 {
let ua = self.inner.ua_nominal() * self.live_z_ua(state);
if self.flood_ready() {
self.ua() * (1.0 - self.flooded_level(state))
ua * (1.0 - self.flooded_level(state))
} else {
self.ua()
ua
}
}
@@ -1592,7 +1617,7 @@ impl Component for Condenser {
// ∂r1/∂λ = +UA_nom·(T_cond T_sec,in)·e · dλ_eff/dλ.
if self.flood_ready() {
if let Some(lvl_idx) = self.fan_actuator_idx {
let ua_nom = self.ua();
let ua_nom = self.inner.ua_nominal() * self.live_z_ua(state);
let e = if c_sec > 1e-10 {
(-ua_eff / c_sec).exp()
} else {
@@ -1608,6 +1633,24 @@ impl Component for Condenser {
}
}
// Live z_ua: UA = UA_nom·z_ua·(1λ), ε = 1e^(UA/C), Q = ε·C·ΔT
// ⇒ ∂Q/∂z_ua = UA_nom·(1λ)·e·ΔT ⇒ ∂r_energy/∂z_ua = ∂Q/∂z_ua
if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
let e = if c_sec > 1e-10 {
(-ua_eff / c_sec).exp()
} else {
0.0
};
let flood_scale = if self.flood_ready() {
1.0 - self.flooded_level(state)
} else {
1.0
};
let d_q_dz =
self.inner.ua_nominal() * flood_scale * e * (t_cond - t_sec_in);
jacobian.add_entry(row, z_ua_idx, -d_q_dz);
}
// r2 (emergent) = H_out h_target(P_in): ∂/∂H_out = 1,
// ∂/∂P_in = dh_target/dP via central finite difference.
if self.emergent_pressure {

View File

@@ -551,6 +551,18 @@ impl Evaporator {
);
// ∂r/∂P_ref_in = ∂Q/∂P = g·dT_evap/dP.
jacobian.add_entry(row, c.ref_p_in_idx, -c.g * c.dtevap_dp);
// Live z_ua: secondary r = ṁ·Δh + Q ⇒ ∂r/∂z = +∂Q/∂z
if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
let ua = self.live_ua(Some(state));
let c_sec = state[m_in].abs() * c.cp_sec;
let e = if c_sec > 1e-10 && ua > 0.0 {
(-ua / c_sec).exp()
} else {
0.0
};
let d_q_dz = self.inner.ua_nominal() * e * c.delta_t;
jacobian.add_entry(row, z_ua_idx, d_q_dz);
}
}
None => {
jacobian.add_entry(row, h_out, 1.0);
@@ -797,10 +809,22 @@ impl Evaporator {
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
}
/// Live UA = UA_nominal × z_ua (reads `state[calib_indices.z_ua]` when free).
fn live_ua(&self, state: Option<&StateSlice>) -> f64 {
let z = state
.and_then(|st| {
self.inner
.calib_indices_ref()
.z_ua
.and_then(|idx| st.get(idx).copied())
})
.unwrap_or_else(|| self.calib().z_ua);
self.inner.ua_nominal() * z.max(0.0)
}
/// Effectiveness for a phase-changing refrigerant (`C_min = C_sec`, `C_r → 0`):
/// `ε = 1 exp(UA / C_sec)`.
fn effectiveness(&self, c_sec: f64) -> f64 {
let ua = self.ua();
fn effectiveness(&self, c_sec: f64, ua: f64) -> f64 {
if c_sec <= 1e-10 || ua <= 0.0 {
return 0.0;
}
@@ -815,7 +839,8 @@ impl Evaporator {
let c_sec = self.secondary_capacity_rate.unwrap_or(0.0);
let t_sec_in = self.secondary_inlet_temp_k.unwrap_or(0.0);
let t_evap = self.evap_temperature(p_in_pa)?;
let eps = self.effectiveness(c_sec);
let ua = self.live_ua(None);
let eps = self.effectiveness(c_sec, ua);
Ok(eps * c_sec * (t_sec_in - t_evap))
}
@@ -1053,7 +1078,8 @@ impl Component for Evaporator {
// Live secondary stream: edge-driven in 4-port mode (Modelica).
let (t_sec_in, c_sec) = self.live_secondary_stream(state)?;
let t_evap = self.evap_temperature(p_in)?;
let eps = self.effectiveness(c_sec);
let ua = self.live_ua(Some(state));
let eps = self.effectiveness(c_sec, ua);
let q = eps * c_sec * (t_sec_in - t_evap);
// r0: refrigerant pressure drop (tube MSH/Friedel + accel, or
@@ -1266,7 +1292,8 @@ impl Component for Evaporator {
// ∂r1/∂P_in = ∂Q/∂P_in = ε·C_sec·dT_evap/dP_in (T_sec,in constant),
// dT_evap/dP via central finite difference.
let (t_sec_in, c_sec) = self.live_secondary_stream(state)?;
let eps = self.effectiveness(c_sec);
let ua = self.live_ua(Some(state));
let eps = self.effectiveness(c_sec, ua);
let g = eps * c_sec;
let t_evap = self.evap_temperature(p_in)?;
let dp = p_in * 1e-4 + 100.0;
@@ -1275,6 +1302,17 @@ impl Component for Evaporator {
let dt_dp = (t_plus - t_minus) / (2.0 * dp);
jacobian.add_entry(row, inlet_p_idx, g * dt_dp);
// Live z_ua: UA = UA_nom·z_ua, Q = ε·C·ΔT ⇒ ∂r_energy/∂z = ∂Q/∂z
if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
let e = if c_sec > 1e-10 {
(-ua / c_sec).exp()
} else {
0.0
};
let d_q_dz = self.inner.ua_nominal() * e * (t_sec_in - t_evap);
jacobian.add_entry(row, z_ua_idx, -d_q_dz);
}
// 4-port cross-derivatives of r1 to the secondary edge state:
// ∂r1/∂h_sec,in = ∂Q/∂h_sec,in = g·dT_sec/dh (exact 1/cp),
// ∂r1/∂ṁ_sec = ∂Q/∂ṁ_sec = g'(C_sec)·cp·(T_sec,in T_evap).
@@ -1282,7 +1320,6 @@ impl Component for Evaporator {
let (m_s, p_s, h_s) = self.sec_in_idx.unwrap();
let cp_sec = self.sec_cp(state[p_s], state[h_s])?;
let dt_dh = 1.0 / cp_sec;
let ua = self.ua();
let g_prime = if c_sec <= 1e-10 || ua <= 0.0 {
0.0
} else {
@@ -2084,7 +2121,7 @@ mod tests {
let c_sec = state[6] * cp_air;
let t_air_in = (state[8] - 2_501_000.0 * w) / cp_air + 273.15;
let t_evap = evap.evap_temperature(state[1]).unwrap();
let eps = evap.effectiveness(c_sec);
let eps = evap.effectiveness(c_sec, evap.ua());
let q = eps * c_sec * (t_air_in - t_evap);
assert!(q > 0.0, "evaporator must absorb heat: q={q}");
let expected = state[6] * (state[11] - state[8]) + q;