Add model embeddings for Z-factor DoF, separate from SaturatedController.
Some checks failed
CI / check (push) Has been cancelled

Fixed/Free Probe calibration now emits embeddings[] (unknown + equation) instead of controls[], keeping SaturatedController for physical regulation only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 22:42:31 +02:00
parent 3808e0f11b
commit 5425685a48
22 changed files with 1189 additions and 485 deletions

View File

@@ -1083,6 +1083,23 @@ impl Compressor<Connected> {
pub fn set_operational_state(&mut self, state: OperationalState) {
self.operational_state = state;
}
/// Live energy-retention factor \(f_w\): fraction of shaft work kept in the
/// refrigerant (`1` = adiabatic, `0` = all work lost to ambient).
///
/// Reads `state[calib_indices.f_w]` when free; otherwise stored calib.
/// Clamped to [0, 1].
fn live_f_w(&self, state: Option<&StateSlice>) -> f64 {
let raw = if let Some(st) = state {
self.calib_indices
.f_w
.map(|idx| st.get(idx).copied().unwrap_or(self.calib.f_w))
.unwrap_or(self.calib.f_w)
} else {
self.calib.f_w
};
raw.clamp(0.0, 1.0)
}
}
impl Component for Compressor<Connected> {
@@ -1194,9 +1211,13 @@ impl Component for Compressor<Connected> {
// ṁ_calc - ṁ_state = 0
residuals[0] = mass_flow_calc - mass_flow_state;
// Residual 1: Energy balance
// Power_calc - ṁ × (h_discharge - h_suction) / η_mech = 0
// Residual 1: Energy balance with retention factor f_w
// (fraction of shaft work kept in the refrigerant):
// ṁ·Δh = Ẇ·f_w / η_mech ⇒ Ẇ·f_w ṁ·Δh/η_mech = 0
// so h_dis ≈ h_suc + f_w·Ẇ/(ṁ·η_mech).
// f_w = 1 → adiabatic (default); f_w = 0 → all work lost to ambient.
let enthalpy_change = h_discharge - h_suction;
let f_w = self.live_f_w(Some(state));
// Prevent division by zero
if self.mechanical_efficiency.abs() < 1e-10 {
@@ -1205,7 +1226,8 @@ impl Component for Compressor<Connected> {
));
}
residuals[1] = power_calc - mass_flow_state * enthalpy_change / self.mechanical_efficiency;
residuals[1] =
power_calc * f_w - mass_flow_state * enthalpy_change / self.mechanical_efficiency;
// r2: ṁ_discharge ṁ_suction = 0 (mass conservation, CM1.3)
// CM1.4: skip when same_branch_m — ṁ_dis == ṁ_suc (same state index),
@@ -1265,7 +1287,8 @@ impl Component for Compressor<Connected> {
)?;
jacobian.add_entry(0, suc_h_idx, dr0_dh_suction);
// Row 1: Energy residual r1 = power_calc × Δh / η_mech
// Row 1: Energy residual r1 = power·f_w ṁ·Δh/η_mech
let f_w = self.live_f_w(Some(state));
// ∂r1/∂ṁ_suction = (h_discharge h_suction) / η_mech
let dr1_dm = -(h_discharge - h_suction) / self.mechanical_efficiency;
jacobian.add_entry(1, suc_m_idx, dr1_dm);
@@ -1280,7 +1303,7 @@ impl Component for Compressor<Connected> {
Temperature::from_kelvin(t),
Temperature::from_kelvin(t_discharge),
None,
))
) * f_w)
},
h_suction,
1.0,
@@ -1296,7 +1319,7 @@ impl Component for Compressor<Connected> {
Temperature::from_kelvin(t_suction),
Temperature::from_kelvin(t),
None,
))
) * f_w)
},
h_discharge,
1.0,
@@ -1326,7 +1349,18 @@ impl Component for Compressor<Connected> {
Temperature::from_kelvin(t_discharge_k),
None,
);
jacobian.add_entry(1, z_power_idx, p_nominal);
// r1 = (z_power·Ẇ_nom)·f_w … ⇒ ∂r1/∂z_power = Ẇ_nom·f_w
jacobian.add_entry(1, z_power_idx, p_nominal * f_w);
}
if let Some(f_w_idx) = self.calib_indices.f_w {
let p_live = self.power_consumption_cooling(
Temperature::from_kelvin(t_suction_k),
Temperature::from_kelvin(t_discharge_k),
Some(state),
);
// r1 = Ẇ·f_w … ⇒ ∂r1/∂f_w = +Ẇ
jacobian.add_entry(1, f_w_idx, p_live);
}
// ∂r0/∂f_etav (AHRI 540 only): ṁ_calc = f_m · f_etav · base with

View File

@@ -797,17 +797,30 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
h_jkg: f64,
) -> Result<f64, ComponentError> {
if !p_pa.is_finite() || p_pa <= 0.0 {
return Err(ComponentError::InvalidState(format!(
"{} {} side has invalid pressure: {} Pa",
self.name, side, p_pa
)));
return Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!(
"{} {} side has invalid pressure: {} Pa",
self.name, side, p_pa
),
}));
}
if !h_jkg.is_finite() {
return Err(ComponentError::InvalidState(format!(
"{} {} side has invalid enthalpy: {} J/kg",
self.name, side, h_jkg
)));
return Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!(
"{} {} side has invalid enthalpy: {} J/kg",
self.name, side, h_jkg
),
}));
}
// Clamp wild Newton trial enthalpies into a broad physical envelope so
// CoolProp is not queried with absurd (P,h) that yield T=inf. Exact
// result is preserved for states already inside the clamp.
const H_MIN_JKG: f64 = -5.0e5;
const H_MAX_JKG: f64 = 3.0e6;
let h_query = h_jkg.clamp(H_MIN_JKG, H_MAX_JKG);
let p_query = p_pa.clamp(1.0e3, 5.0e7);
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::InvalidState(format!(
"{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
@@ -819,15 +832,19 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
FluidsFluidId::new(fluid_id),
property,
entropyk_fluids::FluidState::PressureEnthalpy(
Pressure::from_pascals(p_pa),
entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
Pressure::from_pascals(p_query),
entropyk_core::Enthalpy::from_joules_per_kg(h_query),
),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
self.name, property, side, fluid_id, e
))
// Off-envelope CoolProp failures during Newton are recoverable.
ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!(
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
self.name, property, side, fluid_id, e
),
})
})
}

View File

@@ -641,10 +641,22 @@ impl FloodedEvaporator {
.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`,
/// so `ε = 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;
}
@@ -659,9 +671,10 @@ impl FloodedEvaporator {
p_in_pa: f64,
t_sec_in: f64,
c_sec: f64,
ua: f64,
) -> Result<f64, ComponentError> {
let t_evap = self.evap_temperature(p_in_pa)?;
let eps = self.effectiveness(c_sec);
let eps = self.effectiveness(c_sec, ua);
Ok(eps * c_sec * (t_sec_in - t_evap))
}
@@ -677,7 +690,7 @@ impl FloodedEvaporator {
"coupled_duty requires rating-mode secondary inlet temperature".into(),
)
})?;
self.coupled_duty_with(p_in_pa, t_sec_in, c_sec)
self.coupled_duty_with(p_in_pa, t_sec_in, c_sec, self.live_ua(None))
}
/// Rates the evaporator at a fixed refrigerant regime (constant evaporating
@@ -717,7 +730,8 @@ impl FloodedEvaporator {
));
}
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);
let q = self.coupled_duty(p_in_pa)?;
let secondary_outlet_k = if c_sec > 1e-10 {
t_sec_in - q / c_sec
@@ -1089,7 +1103,8 @@ impl Component for FloodedEvaporator {
let h_in = state[inlet_h_idx];
let h_out = state[outlet_h_idx];
let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec)?;
let ua = self.live_ua(Some(state));
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec, ua)?;
// System mode: C∞ zero-flow gating on both streams (staging / Newton trials).
// Rating mode: secondary is a fixed boundary (C_sec > 0). Do NOT multiply Q by
@@ -1188,10 +1203,10 @@ impl Component for FloodedEvaporator {
let h_in = state[inlet_h_idx];
let h_out = state[outlet_h_idx];
let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
let eps = self.effectiveness(c_sec);
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec)?;
let ua = self.live_ua(Some(state));
let eps = self.effectiveness(c_sec, ua);
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec, ua)?;
let t_evap = self.evap_temperature(p_in)?;
let ua = self.ua();
// System mode exposes secondary edge unknowns; rating mode freezes (T,C).
let system = self.system_secondary_ready();
@@ -1262,6 +1277,22 @@ impl Component for FloodedEvaporator {
jacobian.add_entry(row, m_s, -d_qeff_dm_sec);
jacobian.add_entry(row, h_s, -d_qeff_dh_sec);
}
// Live z_ua column: UA = UA_nom·z_ua, ε = 1e^(UA/C), Q = ε·C·ΔT
if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
let d_eps_dua = if c_sec > 1e-12 {
(-ua / c_sec).exp() / c_sec
} else {
0.0
};
let d_q_dua = d_eps_dua * c_sec * (t_sec_in - t_evap);
let alpha_r = if system {
flow_activity(m_ref, DEFAULT_M_EPS_KG_S)
} else {
1.0
};
let d_qeff_dz = alpha_r * alpha_s * d_q_dua * self.inner.ua_nominal();
jacobian.add_entry(row, z_ua_idx, -d_qeff_dz);
}
row += 1;
// r2 outlet closure

View File

@@ -210,7 +210,11 @@ pub struct IsentropicCompressor {
/// Inverse-control calibration state indices. When `f_m` is `Some(i)`, the
/// volumetric mass-flow closure is scaled by the control variable at
/// `state[i]`, turning the compressor into a capacity/mass-flow actuator.
/// When `f_w` is `Some(i)`, discharge enthalpy uses the live energy-
/// retention factor at `state[i]` (`1` = adiabatic, `0` = all lost).
calib_indices: CalibIndices,
/// Nominal energy-retention factor when not a free unknown. Default 1.
f_w: f64,
/// When `true`, a screw-compressor slide valve modulates the effective swept
/// volume to hold a target suction saturated temperature (SST). The slide
/// position `σ ∈ [σ_min, 1]` is a free actuator that scales the displacement
@@ -279,12 +283,19 @@ impl IsentropicCompressor {
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
calib_indices: CalibIndices::default(),
f_w: 1.0,
slide_valve: false,
sst_target_k: None,
liquid_injection: false,
}
}
/// Sets the nominal energy-retention factor `f_w` (`1` = adiabatic).
pub fn with_f_w(mut self, f_w: f64) -> Self {
self.f_w = f_w.clamp(0.0, 1.0);
self
}
/// Attaches a refrigerant identifier for property lookups.
pub fn with_refrigerant(mut self, refrigerant: &str) -> Self {
self.refrigerant_id = refrigerant.to_string();
@@ -597,6 +608,17 @@ impl IsentropicCompressor {
/// solver state when this compressor is used as an actuator, or `1.0` when
/// no control variable is linked. Non-finite or non-positive values fall
/// back to `1.0` to keep the closure well-posed during early iterations.
fn control_f_w(&self, state: &StateSlice) -> f64 {
match self.calib_indices.f_w {
Some(idx) => state
.get(idx)
.copied()
.unwrap_or(self.f_w)
.clamp(0.0, 1.0),
None => self.f_w.clamp(0.0, 1.0),
}
}
fn control_f_m(&self, state: &StateSlice) -> f64 {
match self.calib_indices.z_flow {
Some(i) if i < state.len() => {
@@ -642,6 +664,7 @@ impl IsentropicCompressor {
p_suc_pa: f64,
h_suc_jkg: f64,
p_dis_pa: f64,
state: &StateSlice,
) -> Result<f64, ComponentError> {
let s_suc = backend
.property(
@@ -660,7 +683,11 @@ impl IsentropicCompressor {
FluidState::PressureEntropy(Pressure::from_pascals(p_dis_pa), Entropy(s_suc)),
)
.map_err(|e| ComponentError::CalculationFailed(format!("H_dis_isen: {}", e)))?;
Ok(h_suc_jkg + (h_dis_isen - h_suc_jkg) / self.effective_isentropic_efficiency())
// Retention: h_dis = h_suc + f_w·Δh_is/η_is
// f_w = 1 → adiabatic; f_w = 0 → all work lost (h_dis → h_suc).
let dh = (h_dis_isen - h_suc_jkg) / self.effective_isentropic_efficiency();
let f_w = self.control_f_w(state);
Ok(h_suc_jkg + f_w * dh)
}
}
@@ -729,7 +756,7 @@ impl Component for IsentropicCompressor {
));
}
let h_dis =
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis)?;
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis, state)?;
residuals[0] = state[dis_h] - h_dis;
if !self.same_branch_m {
residuals[1] = match (self.suction_m_idx, self.discharge_m_idx) {
@@ -776,7 +803,7 @@ impl Component for IsentropicCompressor {
let m_calc =
self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis)?;
let h_dis =
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis)?;
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis, state)?;
// Inverse-control actuator: scale the swept mass flow by the
// linked control variable f_m (1.0 when no control is attached)
// and the slide-valve position σ (1.0 when no slide valve).
@@ -824,9 +851,15 @@ impl Component for IsentropicCompressor {
}
return Ok(());
}
return Err(ComponentError::InvalidState(
"IsentropicCompressor displacement closure requires live physical suction and discharge states".to_string(),
));
// Soft domain penalty — never abort the whole system residual
// evaluation. A hard InvalidState here kills Picard/homotopy recovery
// after a singular-J Newton step wanders into P≈0 / h≈0.
residuals[0] = 1.0e6;
residuals[1] = 1.0e6;
if !self.same_branch_m && residuals.len() > 2 {
residuals[2] = 1.0e6;
}
return Ok(());
}
if let (Some(backend), Some(dis_p), Some(dis_h)) = (
@@ -859,7 +892,7 @@ impl Component for IsentropicCompressor {
p_suc,
h_suc,
p_cond_sat,
)?
state)?
} else {
return Err(ComponentError::InvalidState(
"IsentropicCompressor requires physical live suction pressure/enthalpy"
@@ -931,7 +964,7 @@ impl Component for IsentropicCompressor {
let dph = h_suc * 1e-4 + 10.0;
let dpd = p_dis * 1e-4 + 100.0;
let h = |ps: f64, hs: f64, pd: f64| {
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd)
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd, state)
};
if let (Ok(a), Ok(b)) =
(h(p_suc + dpp, h_suc, p_dis), h(p_suc - dpp, h_suc, p_dis))
@@ -1030,7 +1063,7 @@ impl Component for IsentropicCompressor {
let inj_ready = self.injection_ready();
let hd = |ps: f64, hs: f64, pd: f64| -> Result<f64, ComponentError> {
let h =
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd)?;
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd, state)?;
if inj_ready {
let h_liq = self.liquid_enthalpy(pd)?;
Ok(h - phi_inj * (h - h_liq))
@@ -1053,6 +1086,22 @@ impl Component for IsentropicCompressor {
{
jacobian.add_entry(1, dis_p, -(a - b) / (2.0 * dpd));
}
// ∂r1/∂f_w = dh (h_dis = h_suc + f_w·dh ⇒ r = h h_dis)
if let Some(f_w_idx) = self.calib_indices.f_w {
if let Ok(h_full) = self.compute_h_dis_from_state(
backend.as_ref(),
fluid.clone(),
p_suc,
h_suc,
p_dis,
state,
) {
// Reconstruct dh from h_full at current f_w: h = h_suc + fw·dh
let fw = self.control_f_w(state).max(1e-9);
let dh = (h_full - h_suc) / fw;
jacobian.add_entry(1, f_w_idx, -dh);
}
}
// ∂r1/∂φ_inj = +(h_dis h_liq): the injection actuator couples
// into the energy balance so a controls[] loop has a plant to act
// on (higher injection ⇒ lower discharge enthalpy ⇒ lower DGT).
@@ -1064,7 +1113,7 @@ impl Component for IsentropicCompressor {
p_suc,
h_suc,
p_dis,
);
state);
let h_liq = self.liquid_enthalpy(p_dis);
if let (Ok(h_dis), Ok(h_liq)) = (h_dis, h_liq) {
jacobian.add_entry(1, inj_idx, h_dis - h_liq);
@@ -1123,14 +1172,14 @@ impl Component for IsentropicCompressor {
p_suc + dp,
h_suc,
p_cond_sat,
);
state);
let hm = self.compute_h_dis_from_state(
backend.as_ref(),
fluid.clone(),
p_suc - dp,
h_suc,
p_cond_sat,
);
state);
if let (Ok(hp), Ok(hm)) = (hp, hm) {
jacobian.add_entry(1, suc_p, -(hp - hm) / (2.0 * dp));
}
@@ -1142,14 +1191,14 @@ impl Component for IsentropicCompressor {
p_suc,
h_suc + dh,
p_cond_sat,
);
state);
let hm = self.compute_h_dis_from_state(
backend.as_ref(),
fluid,
p_suc,
h_suc - dh,
p_cond_sat,
);
state);
if let (Ok(hp), Ok(hm)) = (hp, hm) {
jacobian.add_entry(1, suc_h, -(hp - hm) / (2.0 * dh));
}
@@ -1259,7 +1308,7 @@ impl Component for IsentropicCompressor {
state[sp],
h_suc,
state[dp],
)
state)
.unwrap_or(h_dis)
}
_ => h_dis,