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

@@ -63,6 +63,12 @@ pub struct ScenarioConfig {
/// (`output = value`). Distinct from [`Self::controls`] (no SaturatedController).
#[serde(default)]
pub embeddings: Vec<EmbeddingConfig>,
/// Algebraic model equations: `lhs - rhs = 0` with `+ - * / min max exp ln`.
///
/// Free unknown is either inferred from `lhs` when it is `component.z_*`,
/// or declared via [`EquationConfig::unknown`] (Probe target form).
#[serde(default)]
pub equations: Vec<EquationConfig>,
/// Reusable subsystem templates (parameterized assemblies of components +
/// internal edges, exposing a reduced external port set). Flattened into
/// `circuits` at load time — the solver never sees the hierarchy.
@@ -198,6 +204,56 @@ pub struct EmbeddingEquationConfig {
pub value: f64,
}
/// Algebraic model equation with a minimal expression language.
///
/// ```text
/// lhs = rhs; // residual: lhs rhs = 0
/// ```
///
/// Supported ops: `+ - * /`, parentheses, unary `-`, `min`, `max`, `exp`, `ln`/`log`.
/// References: `component.field` or `"Component Name".field`
/// (`field` = `z_ua`, `T`, `Tsat`, `P`, …).
///
/// **Form A** — free Z-factor on the left:
/// ```json
/// { "id": "eq1", "lhs": "evap.z_ua", "rhs": "min(1, exp(probe.T/300))",
/// "start": 0.3, "min": 0.05, "max": 2.0 }
/// ```
///
/// **Form B** — Probe target with explicit unknown (same role as embeddings[]):
/// ```json
/// { "id": "eq2", "lhs": "probe.Tsat", "rhs": "278.15",
/// "unknown": { "component": "evap", "factor": "z_ua", "start": 0.3, "min": 0.05, "max": 2.0 } }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EquationConfig {
/// Unique id (also used as the residual / link id).
pub id: String,
/// Left-hand side expression.
pub lhs: String,
/// Right-hand side expression.
pub rhs: String,
/// Explicit free unknown (required when `lhs` is not itself a Z-factor ref).
#[serde(default)]
pub unknown: Option<EmbeddingUnknownConfig>,
/// Start guess when the unknown is inferred from `lhs` (Form A).
#[serde(default = "default_actuator_initial")]
pub start: f64,
/// Lower bound when the unknown is inferred from `lhs` (Form A).
#[serde(default = "default_equation_min")]
pub min: f64,
/// Upper bound when the unknown is inferred from `lhs` (Form A).
#[serde(default = "default_equation_max")]
pub max: f64,
}
fn default_equation_min() -> f64 {
0.05
}
fn default_equation_max() -> f64 {
2.0
}
/// A steady-state **system regulation** loop (EXV, injection, fan, …).
///
/// Supports `SaturatedController`: saturated-PI with anti-windup, co-solved
@@ -1399,6 +1455,7 @@ mod tests {
"circuits",
"controls",
"embeddings",
"equations",
"subsystems",
"instances",
"connections",
@@ -1431,4 +1488,26 @@ mod tests {
assert!((config.embeddings[0].equation.value - 278.15).abs() < 1e-9);
assert!(config.controls.is_empty());
}
#[test]
fn test_parse_equations() {
let json = r#"{
"schema_version": "2",
"fluid": "R134a",
"equations": [{
"id": "eq_evap_z_ua",
"lhs": "evap.z_ua",
"rhs": "min(1.0, exp(\"SST probe\".T / 300))",
"start": 0.3,
"min": 0.05,
"max": 2.0
}]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.equations.len(), 1);
assert_eq!(config.equations[0].id, "eq_evap_z_ua");
assert_eq!(config.equations[0].lhs, "evap.z_ua");
assert!(config.equations[0].rhs.contains("min"));
assert!(config.equations[0].unknown.is_none());
}
}

View File

@@ -646,23 +646,46 @@ fn execute_simulation(
}
}
// Free z_ua embedding: drop absolute `ua` overrides on the unknown's
// component. Otherwise `ua` freezes Calib and ∂Q/∂z_ua → 0 (singular J).
for emb in &config.embeddings {
if entropyk_core::normalize_factor_name(emb.unknown.factor.trim())
== Some(entropyk_core::Z_UA)
{
if let Some(comp) = expanded_components
.iter_mut()
.find(|c| c.name == emb.unknown.component)
// Free z_ua on BPHX only: drop absolute `ua` override (geometry = UA_nominal).
// Legacy Condenser/Evaporator keep `ua` — it is the constructor nominal.
let mut free_z_ua: Vec<(String, String)> = config
.embeddings
.iter()
.filter(|e| {
entropyk_core::normalize_factor_name(e.unknown.factor.trim())
== Some(entropyk_core::Z_UA)
})
.map(|e| (e.unknown.component.clone(), e.id.clone()))
.collect();
for eq in &config.equations {
if let Some(u) = eq.unknown.as_ref() {
if entropyk_core::normalize_factor_name(u.factor.trim()) == Some(entropyk_core::Z_UA)
{
if comp.params.remove("ua").is_some() {
tracing::info!(
component = %comp.name,
embedding = %emb.id,
"Dropped 'ua' override — z_ua embedding owns UA scaling"
);
}
free_z_ua.push((u.component.clone(), eq.id.clone()));
}
} else if let Ok(entropyk_solver::inverse::Expr::Ref { component, field }) =
entropyk_solver::inverse::parse_expr(&eq.lhs)
{
if entropyk_core::normalize_factor_name(&field) == Some(entropyk_core::Z_UA) {
free_z_ua.push((component, eq.id.clone()));
}
}
}
for (comp_name, src_id) in &free_z_ua {
if let Some(comp) = expanded_components
.iter_mut()
.find(|c| c.name == *comp_name)
{
let is_bphx = matches!(
comp.component_type.as_str(),
"BphxCondenser" | "BphxEvaporator" | "BphxExchanger"
);
if is_bphx && comp.params.remove("ua").is_some() {
tracing::info!(
component = %comp.name,
source = %src_id,
"Dropped BPHX 'ua' override — free z_ua owns UA scaling"
);
}
}
}
@@ -1151,6 +1174,34 @@ fn execute_simulation(
}
}
for eq in &config.equations {
match build_model_equation(eq) {
Ok((algebraic, bounded_var, unknown_id)) => {
if let Err(e) = system.add_algebraic_equation(algebraic) {
return control_error(format!(
"Failed to add algebraic equation '{}': {:?}",
eq.id, e
));
}
if let Err(e) = system.add_bounded_variable(bounded_var) {
return control_error(format!(
"Failed to add unknown for equation '{}': {:?}",
eq.id, e
));
}
if let Err(e) = system.link_constraint_to_control(
&entropyk_solver::inverse::ConstraintId::new(eq.id.clone()),
&unknown_id,
) {
return control_error(format!("Failed to link equation '{}': {:?}", eq.id, e));
}
}
Err(msg) => {
return control_error(format!("Invalid equation '{}': {}", eq.id, msg));
}
}
}
for control in &config.controls {
match build_saturated_control(control) {
Ok((bounded_var, controller)) => {
@@ -1527,6 +1578,20 @@ fn execute_simulation(
}
}
}
for eq in &config.equations {
if let Ok((_, _, unknown_id)) = build_model_equation(eq) {
let start = eq
.unknown
.as_ref()
.map(|u| u.start)
.unwrap_or(eq.start);
if let Some(u_idx) = system.control_variable_state_index(&unknown_id) {
if u_idx < initial_state.len() {
initial_state[u_idx] = start;
}
}
}
}
for control in &config.controls {
let actuator_id = entropyk_solver::inverse::BoundedVariableId::new(saturated_actuator_id(
&control.actuator.component,
@@ -1597,6 +1662,7 @@ fn execute_simulation(
.then(|| std::time::Duration::from_millis(config.solver.timeout_ms));
let needs_guarded_newton = !config.controls.is_empty()
|| !config.embeddings.is_empty()
|| !config.equations.is_empty()
|| config.circuits.iter().any(|c| {
c.enabled
&& c.components.iter().any(|comp| {
@@ -3172,6 +3238,47 @@ fn bphx_calib_from_params(
})
}
/// Calibration factors for legacy Condenser / Evaporator (`ua` is nominal, not override).
fn hx_calib_from_params(
params: &std::collections::HashMap<String, serde_json::Value>,
) -> CliResult<entropyk_core::Calib> {
use entropyk_core::Calib;
let z_ua = params
.get("z_ua")
.or_else(|| params.get("Z_UA"))
.or_else(|| params.get("f_ua"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
if z_ua <= 0.0 {
return Err(CliError::Config(format!(
"z_ua must be > 0 (got {:.4})",
z_ua
)));
}
let z_dp = params
.get("z_dp")
.or_else(|| params.get("Z_dpc"))
.or_else(|| params.get("f_dp"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
if z_dp <= 0.0 {
return Err(CliError::Config(format!(
"z_dp must be > 0 (got {:.4})",
z_dp
)));
}
Ok(Calib {
z_flow: 1.0,
z_flow_eco: 1.0,
z_dp,
z_ua,
z_power: 1.0,
z_etav: 1.0,
f_w: 1.0,
calibration_source: None,
})
}
/// Creates a pair of connected ports for components that need them (screw, MCHX, fan...).
///
/// Ports are initialised at the given pressure and enthalpy. Both ports are connected
@@ -3300,6 +3407,73 @@ fn build_model_embedding(
Ok((constraint, bounded_var, unknown_id))
}
/// Algebraic model equation (`lhs rhs = 0`) + free Z-factor unknown.
///
/// Form A: `lhs` is `component.z_*` → unknown inferred, bounds from `start/min/max`.
/// Form B: explicit `unknown` block (Probe target: `lhs=probe.Tsat`, `rhs=315`).
fn build_model_equation(
eq: &crate::config::EquationConfig,
) -> Result<
(
entropyk_solver::inverse::AlgebraicEquation,
entropyk_solver::inverse::BoundedVariable,
entropyk_solver::inverse::BoundedVariableId,
),
String,
> {
use entropyk_solver::inverse::{
parse_expr, AlgebraicEquation, BoundedVariable, BoundedVariableId, Expr,
};
let lhs = parse_expr(&eq.lhs).map_err(|e| format!("lhs: {e}"))?;
let rhs = parse_expr(&eq.rhs).map_err(|e| format!("rhs: {e}"))?;
let (comp, factor, start, min, max) = if let Some(u) = eq.unknown.as_ref() {
let factor = u.factor.trim();
if entropyk_core::normalize_factor_name(factor).is_none() {
return Err(format!(
"unknown Z-factor '{factor}' (expected z_ua, z_dp, z_flow, z_power, z_etav, f_w, …)"
));
}
(
u.component.clone(),
factor.to_string(),
u.start,
u.min,
u.max,
)
} else if let Expr::Ref { component, field } = &lhs {
let canon = entropyk_core::normalize_factor_name(field).ok_or_else(|| {
format!(
"lhs '{component}.{field}' is not a Z-factor — set equations[].unknown \
(e.g. Probe target form) or use lhs = component.z_ua"
)
})?;
(
component.clone(),
canon.to_string(),
eq.start,
eq.min,
eq.max,
)
} else {
return Err(
"lhs must be component.z_* or provide equations[].unknown for the free Z-factor"
.into(),
);
};
let unknown_id = BoundedVariableId::new(saturated_actuator_id(&comp, &factor));
let bounded_var = BoundedVariable::with_component(unknown_id.clone(), &comp, start, min, max)
.map_err(|e| format!("invalid unknown bounds: {e:?}"))?;
let algebraic = AlgebraicEquation {
id: eq.id.clone(),
lhs,
rhs,
};
Ok((algebraic, bounded_var, unknown_id))
}
fn build_saturated_control(
control: &crate::config::ControlConfig,
) -> Result<
@@ -3596,7 +3770,17 @@ fn create_component(
"FloodedEvaporator" => {
use entropyk::FloodedEvaporator;
let ua = get_param_f64(params, "ua")?;
// UA is the nominal base (required for construction). Default matches
// the UI catalogue when the canvas field was left empty during calib.
let ua = params
.get("ua")
.and_then(|v| v.as_f64())
.unwrap_or(8000.0);
if ua < 0.0 {
return Err(CliError::Config(format!(
"FloodedEvaporator: ua must be >= 0 (got {ua})"
)));
}
let target_quality = params
.get("target_quality")
.and_then(|v| v.as_f64())
@@ -3931,6 +4115,12 @@ fn create_component(
cond = cond.with_flooded_head_pressure(target_k);
}
// Fixed z_ua (or start hint): scale UA_eff = z_ua · UA. Live Free
// embeddings override via CalibIndices during solve.
if let Ok(calib) = hx_calib_from_params(params) {
cond.set_calib(calib);
}
Ok(Box::new(cond))
}
@@ -4024,6 +4214,10 @@ fn create_component(
evap = evap.with_regulated_superheat();
}
if let Ok(calib) = hx_calib_from_params(params) {
evap.set_calib(calib);
}
Ok(Box::new(evap))
}

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;

View File

@@ -42,7 +42,7 @@ use thiserror::Error;
/// Type-safe identifier for a bounded control variable.
///
/// Uses a string internally but provides type safety and clear intent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BoundedVariableId(String);
impl BoundedVariableId {

View File

@@ -16,7 +16,7 @@ use thiserror::Error;
/// Type-safe identifier for a constraint.
///
/// Uses a string internally but provides type safety and clear intent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ConstraintId(String);
impl ConstraintId {

View File

@@ -334,19 +334,24 @@ impl InverseControlConfig {
self.control_to_constraint.get(bounded_variable_id)
}
/// Returns an iterator over all constraint-to-control mappings.
/// Returns constraintcontrol mappings in **stable** order (sorted by constraint id).
///
/// HashMap iteration is randomized per process; residual rows, Jacobian rows/columns,
/// and control state indices must share one deterministic order or Newton flakes.
pub fn mappings(&self) -> impl Iterator<Item = (&ConstraintId, &BoundedVariableId)> {
self.constraint_to_control.iter()
let mut items: Vec<_> = self.constraint_to_control.iter().collect();
items.sort_by(|a, b| a.0.cmp(b.0));
items.into_iter()
}
/// Returns an iterator over linked constraint IDs.
/// Linked constraint IDs in the same order as [`Self::mappings`].
pub fn linked_constraints(&self) -> impl Iterator<Item = &ConstraintId> {
self.constraint_to_control.keys()
self.mappings().map(|(c, _)| c)
}
/// Returns an iterator over linked control variable IDs.
/// Linked control IDs in the same order as [`Self::mappings`] (column / state layout).
pub fn linked_controls(&self) -> impl Iterator<Item = &BoundedVariableId> {
self.control_to_constraint.keys()
self.mappings().map(|(_, c)| c)
}
/// Checks if a constraint is linked.
@@ -583,15 +588,18 @@ mod tests {
fn test_inverse_control_config_mappings_iterator() {
let mut config = InverseControlConfig::new();
// Insert out of alphabetical order — iteration must still be sorted.
config
.link(make_constraint_id("c1"), make_bounded_var_id("v1"))
.link(make_constraint_id("z_probe"), make_bounded_var_id("z_ua_cond"))
.unwrap();
config
.link(make_constraint_id("c2"), make_bounded_var_id("v2"))
.link(make_constraint_id("a_probe"), make_bounded_var_id("z_ua_evap"))
.unwrap();
let mappings: Vec<_> = config.mappings().collect();
assert_eq!(mappings.len(), 2);
let ids: Vec<&str> = config.mappings().map(|(c, _)| c.as_str()).collect();
assert_eq!(ids, vec!["a_probe", "z_probe"]);
let controls: Vec<&str> = config.linked_controls().map(|c| c.as_str()).collect();
assert_eq!(controls, vec!["z_ua_evap", "z_ua_cond"]);
}
#[test]

View File

@@ -0,0 +1,401 @@
//! Minimal Modelica-style algebraic expression AST for model equations.
//!
//! Supports: `+ - * /`, parentheses, unary `-`, `min`, `max`, `exp`, `ln`/`log`,
//! numeric literals, and references `component.field` or `"Component Name".field`.
use std::fmt;
/// Parsed expression node.
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Const(f64),
/// `component` + `field` (field is temperature, z_ua, …).
Ref { component: String, field: String },
Neg(Box<Expr>),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Div(Box<Expr>, Box<Expr>),
Min(Box<Expr>, Box<Expr>),
Max(Box<Expr>, Box<Expr>),
Exp(Box<Expr>),
Ln(Box<Expr>),
}
impl Expr {
/// Evaluate with a resolver for `component.field` references.
pub fn eval<F>(&self, resolve: &mut F) -> Result<f64, String>
where
F: FnMut(&str, &str) -> Result<f64, String>,
{
match self {
Expr::Const(v) => Ok(*v),
Expr::Ref { component, field } => resolve(component, field),
Expr::Neg(a) => Ok(-a.eval(resolve)?),
Expr::Add(a, b) => Ok(a.eval(resolve)? + b.eval(resolve)?),
Expr::Sub(a, b) => Ok(a.eval(resolve)? - b.eval(resolve)?),
Expr::Mul(a, b) => Ok(a.eval(resolve)? * b.eval(resolve)?),
Expr::Div(a, b) => {
let den = b.eval(resolve)?;
if den.abs() < 1e-30 {
return Err("division by zero in model equation".into());
}
Ok(a.eval(resolve)? / den)
}
Expr::Min(a, b) => Ok(a.eval(resolve)?.min(b.eval(resolve)?)),
Expr::Max(a, b) => Ok(a.eval(resolve)?.max(b.eval(resolve)?)),
Expr::Exp(a) => {
let x = a.eval(resolve)?;
let y = x.exp();
if y.is_finite() {
Ok(y)
} else {
Err(format!("exp({x}) overflow"))
}
}
Expr::Ln(a) => {
let x = a.eval(resolve)?;
if x <= 0.0 {
return Err(format!("ln({x}) domain error"));
}
Ok(x.ln())
}
}
}
/// Collect all `component.field` references.
pub fn collect_refs(&self, out: &mut Vec<(String, String)>) {
match self {
Expr::Const(_) => {}
Expr::Ref { component, field } => out.push((component.clone(), field.clone())),
Expr::Neg(a) | Expr::Exp(a) | Expr::Ln(a) => a.collect_refs(out),
Expr::Add(a, b)
| Expr::Sub(a, b)
| Expr::Mul(a, b)
| Expr::Div(a, b)
| Expr::Min(a, b)
| Expr::Max(a, b) => {
a.collect_refs(out);
b.collect_refs(out);
}
}
}
}
/// Parse an expression string into an [`Expr`].
pub fn parse_expr(input: &str) -> Result<Expr, String> {
let mut p = Parser::new(input);
let e = p.parse_expr()?;
p.skip_ws();
if p.pos < p.chars.len() {
return Err(format!(
"trailing junk at '{}'",
p.chars[p.pos..].iter().collect::<String>()
));
}
Ok(e)
}
struct Parser {
chars: Vec<char>,
pos: usize,
}
impl Parser {
fn new(s: &str) -> Self {
Self {
chars: s.chars().collect(),
pos: 0,
}
}
fn skip_ws(&mut self) {
while self.pos < self.chars.len() && self.chars[self.pos].is_whitespace() {
self.pos += 1;
}
}
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn bump(&mut self) -> Option<char> {
let c = self.peek()?;
self.pos += 1;
Some(c)
}
fn parse_expr(&mut self) -> Result<Expr, String> {
let mut left = self.parse_term()?;
loop {
self.skip_ws();
match self.peek() {
Some('+') => {
self.bump();
let right = self.parse_term()?;
left = Expr::Add(Box::new(left), Box::new(right));
}
Some('-') => {
self.bump();
let right = self.parse_term()?;
left = Expr::Sub(Box::new(left), Box::new(right));
}
_ => break,
}
}
Ok(left)
}
fn parse_term(&mut self) -> Result<Expr, String> {
let mut left = self.parse_unary()?;
loop {
self.skip_ws();
match self.peek() {
Some('*') => {
self.bump();
let right = self.parse_unary()?;
left = Expr::Mul(Box::new(left), Box::new(right));
}
Some('/') => {
self.bump();
let right = self.parse_unary()?;
left = Expr::Div(Box::new(left), Box::new(right));
}
_ => break,
}
}
Ok(left)
}
fn parse_unary(&mut self) -> Result<Expr, String> {
self.skip_ws();
if self.peek() == Some('-') {
self.bump();
let e = self.parse_unary()?;
return Ok(Expr::Neg(Box::new(e)));
}
if self.peek() == Some('+') {
self.bump();
return self.parse_unary();
}
self.parse_primary()
}
fn parse_primary(&mut self) -> Result<Expr, String> {
self.skip_ws();
match self.peek() {
Some('(') => {
self.bump();
let e = self.parse_expr()?;
self.skip_ws();
if self.bump() != Some(')') {
return Err("expected ')'".into());
}
Ok(e)
}
Some('"') => self.parse_quoted_ref(),
Some(c) if c.is_ascii_digit() || c == '.' => self.parse_number(),
Some(c) if is_ident_start(c) => self.parse_ident_or_call(),
Some(c) => Err(format!("unexpected '{c}'")),
None => Err("unexpected end of expression".into()),
}
}
fn parse_number(&mut self) -> Result<Expr, String> {
let start = self.pos;
while let Some(c) = self.peek() {
if c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' {
self.bump();
if (c == 'e' || c == 'E') && matches!(self.peek(), Some('+') | Some('-')) {
self.bump();
}
} else {
break;
}
}
let s: String = self.chars[start..self.pos].iter().collect();
s.parse::<f64>()
.map(Expr::Const)
.map_err(|_| format!("invalid number '{s}'"))
}
fn parse_quoted_ref(&mut self) -> Result<Expr, String> {
self.bump(); // "
let start = self.pos;
while let Some(c) = self.peek() {
if c == '"' {
break;
}
self.bump();
}
let name: String = self.chars[start..self.pos].iter().collect();
if self.bump() != Some('"') {
return Err("unterminated quoted component name".into());
}
self.skip_ws();
if self.bump() != Some('.') {
return Err("expected '.' after quoted component name".into());
}
let field = self.parse_ident_string()?;
Ok(Expr::Ref {
component: name,
field,
})
}
fn parse_ident_string(&mut self) -> Result<String, String> {
self.skip_ws();
let start = self.pos;
if !self.peek().is_some_and(is_ident_start) {
return Err("expected identifier".into());
}
self.bump();
while self.peek().is_some_and(is_ident_cont) {
self.bump();
}
Ok(self.chars[start..self.pos].iter().collect())
}
fn parse_ident_or_call(&mut self) -> Result<Expr, String> {
let ident = self.parse_ident_string()?;
self.skip_ws();
if self.peek() == Some('(') {
return self.parse_call(&ident);
}
// component.field[.more] — last segment is field, rest is component
let mut parts = vec![ident];
while self.peek() == Some('.') {
self.bump();
parts.push(self.parse_ident_string()?);
self.skip_ws();
}
if parts.len() < 2 {
return Err(format!(
"bare identifier '{}' — expected component.field or function call",
parts[0]
));
}
let field = parts.pop().unwrap();
let component = parts.join(".");
Ok(Expr::Ref { component, field })
}
fn parse_call(&mut self, name: &str) -> Result<Expr, String> {
self.bump(); // (
self.skip_ws();
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"min" | "max" => {
let a = self.parse_expr()?;
self.skip_ws();
if self.bump() != Some(',') {
return Err(format!("expected ',' in {lower}(...)"));
}
let b = self.parse_expr()?;
self.skip_ws();
if self.bump() != Some(')') {
return Err(format!("expected ')' after {lower}"));
}
Ok(if lower == "min" {
Expr::Min(Box::new(a), Box::new(b))
} else {
Expr::Max(Box::new(a), Box::new(b))
})
}
"exp" => {
let a = self.parse_expr()?;
self.skip_ws();
if self.bump() != Some(')') {
return Err("expected ')' after exp".into());
}
Ok(Expr::Exp(Box::new(a)))
}
"ln" | "log" => {
let a = self.parse_expr()?;
self.skip_ws();
if self.bump() != Some(')') {
return Err("expected ')' after ln".into());
}
Ok(Expr::Ln(Box::new(a)))
}
other => Err(format!(
"unknown function '{other}' (supported: min, max, exp, ln)"
)),
}
}
}
fn is_ident_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn is_ident_cont(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
impl fmt::Display for Expr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Expr::Const(v) => write!(f, "{v}"),
Expr::Ref { component, field } => write!(f, "{component}.{field}"),
Expr::Neg(a) => write!(f, "-({a})"),
Expr::Add(a, b) => write!(f, "({a} + {b})"),
Expr::Sub(a, b) => write!(f, "({a} - {b})"),
Expr::Mul(a, b) => write!(f, "({a} * {b})"),
Expr::Div(a, b) => write!(f, "({a} / {b})"),
Expr::Min(a, b) => write!(f, "min({a}, {b})"),
Expr::Max(a, b) => write!(f, "max({a}, {b})"),
Expr::Exp(a) => write!(f, "exp({a})"),
Expr::Ln(a) => write!(f, "ln({a})"),
}
}
}
/// Algebraic model equation: `lhs - rhs = 0`.
#[derive(Debug, Clone)]
pub struct AlgebraicEquation {
pub id: String,
pub lhs: Expr,
pub rhs: Expr,
}
impl AlgebraicEquation {
pub fn residual<F>(&self, mut resolve: F) -> Result<f64, String>
where
F: FnMut(&str, &str) -> Result<f64, String>,
{
Ok(self.lhs.eval(&mut resolve)? - self.rhs.eval(&mut resolve)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn eval_const(e: &Expr) -> f64 {
e.eval(&mut |_, _| Err("no refs".into())).unwrap()
}
#[test]
fn arithmetic_and_funcs() {
assert!((eval_const(&parse_expr("1 + 2 * 3").unwrap()) - 7.0).abs() < 1e-12);
assert!((eval_const(&parse_expr("min(2, 5)").unwrap()) - 2.0).abs() < 1e-12);
assert!((eval_const(&parse_expr("max(2, 5)").unwrap()) - 5.0).abs() < 1e-12);
assert!((eval_const(&parse_expr("exp(0)").unwrap()) - 1.0).abs() < 1e-12);
assert!((eval_const(&parse_expr("ln(exp(1))").unwrap()) - 1.0).abs() < 1e-9);
assert!((eval_const(&parse_expr("-(1+2)").unwrap()) + 3.0).abs() < 1e-12);
}
#[test]
fn refs_and_quoted() {
let e = parse_expr(r#"evap.z_ua + "SST probe".T"#).unwrap();
let v = e
.eval(&mut |c, f| match (c, f) {
("evap", "z_ua") => Ok(0.1),
("SST probe", "T") => Ok(280.0),
_ => Err("bad".into()),
})
.unwrap();
assert!((v - 280.1).abs() < 1e-12);
}
}

View File

@@ -45,6 +45,7 @@ pub mod bounded;
pub mod calibration;
pub mod constraint;
pub mod embedding;
pub mod expr;
pub mod override_network;
pub mod saturated_control;
@@ -58,5 +59,6 @@ pub use calibration::{
};
pub use constraint::{ComponentOutput, Constraint, ConstraintError, ConstraintId};
pub use embedding::{ControlMapping, DoFError, InverseControlConfig};
pub use expr::{parse_expr, AlgebraicEquation, Expr};
pub use override_network::{eval_error_signal, eval_error_weights, Combine, Objective};
pub use saturated_control::{SaturatedControlError, SaturatedController, Saturation};

View File

@@ -16,7 +16,7 @@ use petgraph::algo;
use petgraph::graph::{EdgeIndex, Graph, NodeIndex};
use petgraph::visit::EdgeRef;
use petgraph::Directed;
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use crate::coupling::{has_circular_dependencies, ThermalCoupling};
use crate::dof::{
@@ -208,6 +208,8 @@ pub struct System {
thermal_couplings: Vec<ThermalCoupling>,
/// Constraints for inverse control (output - target = 0)
constraints: HashMap<ConstraintId, Constraint>,
/// Modelica algebraic equations (`lhs - rhs = 0`), e.g. `evap.z_ua = f(...)`.
algebraic_equations: Vec<crate::inverse::AlgebraicEquation>,
/// Bounded control variables for inverse control (with box constraints)
bounded_variables: HashMap<BoundedVariableId, BoundedVariable>,
/// Inverse control configuration (constraint → control variable mappings)
@@ -246,6 +248,7 @@ impl System {
node_to_circuit: HashMap::new(),
thermal_couplings: Vec::new(),
constraints: HashMap::new(),
algebraic_equations: Vec::new(),
bounded_variables: HashMap::new(),
inverse_control: InverseControlConfig::new(),
saturated_controllers: Vec::new(),
@@ -735,18 +738,20 @@ impl System {
}
}
if !self.constraints.is_empty() {
if !self.constraints.is_empty() || !self.algebraic_equations.is_empty() {
match self.validate_inverse_control_dof() {
Ok(()) => {
tracing::debug!(
constraint_count = self.constraints.len(),
constraint_count =
self.constraints.len() + self.algebraic_equations.len(),
control_count = self.inverse_control.mapping_count(),
"Inverse control DoF validation passed"
);
}
Err(DoFError::UnderConstrainedSystem { .. }) => {
tracing::warn!(
constraint_count = self.constraints.len(),
constraint_count =
self.constraints.len() + self.algebraic_equations.len(),
control_count = self.inverse_control.mapping_count(),
"Under-constrained inverse control system - solver may still converge"
);
@@ -1240,6 +1245,21 @@ impl System {
Ok(())
}
/// Adds a Modelica algebraic equation residual (`lhs rhs = 0`).
pub fn add_algebraic_equation(
&mut self,
equation: crate::inverse::AlgebraicEquation,
) -> Result<(), ConstraintError> {
let id = ConstraintId::new(equation.id.clone());
if self.constraints.contains_key(&id)
|| self.algebraic_equations.iter().any(|e| e.id == equation.id)
{
return Err(ConstraintError::DuplicateId { id });
}
self.algebraic_equations.push(equation);
Ok(())
}
/// Removes a constraint by ID.
///
/// # Arguments
@@ -1259,9 +1279,16 @@ impl System {
self.constraints.len()
}
/// Returns a reference to all constraints.
/// Number of Modelica algebraic equations (`lhs rhs = 0`).
pub fn algebraic_equation_count(&self) -> usize {
self.algebraic_equations.len()
}
/// Returns all constraints in **stable** order (sorted by constraint id).
pub fn constraints(&self) -> impl Iterator<Item = &Constraint> {
self.constraints.values()
let mut items: Vec<_> = self.constraints.values().collect();
items.sort_by(|a, b| a.id().cmp(b.id()));
items.into_iter()
}
/// Returns a reference to a specific constraint by ID.
@@ -1317,8 +1344,12 @@ impl System {
return Ok(0);
}
// Must match Jacobian row order (`inverse_control.mappings()` / sorted ids).
let mut sorted: Vec<_> = self.constraints.values().collect();
sorted.sort_by(|a, b| a.id().cmp(b.id()));
let mut count = 0;
for constraint in self.constraints.values() {
for constraint in sorted {
let measured = match measured_values.get(constraint.id()).copied() {
Some(v) => v,
None => {
@@ -2076,7 +2107,12 @@ impl System {
constraint_id: &ConstraintId,
bounded_variable_id: &BoundedVariableId,
) -> Result<(), DoFError> {
if !self.constraints.contains_key(constraint_id) {
let has_constraint = self.constraints.contains_key(constraint_id);
let has_algebraic = self
.algebraic_equations
.iter()
.any(|e| e.id == constraint_id.as_str());
if !has_constraint && !has_algebraic {
return Err(DoFError::ConstraintNotFound {
constraint_id: constraint_id.clone(),
});
@@ -2191,7 +2227,7 @@ impl System {
pub fn validate_inverse_control_dof(&self) -> Result<(), DoFError> {
let n_edge_unknowns = self.total_state_len;
let n_controls = self.inverse_control.mapping_count();
let n_constraints = self.constraints.len();
let n_constraints = self.constraints.len() + self.algebraic_equations.len();
let n_unknowns = n_edge_unknowns + n_controls;
let n_edge_eqs: usize = self
@@ -2242,6 +2278,7 @@ impl System {
.sum();
n_comp
+ self.constraints.len()
+ self.algebraic_equations.len()
+ self.coupling_residual_count()
+ 2 * self.saturated_controllers.len()
}
@@ -2319,6 +2356,11 @@ impl System {
name: id.to_string(),
});
}
for eq in &self.algebraic_equations {
system_equations.push(EquationRole::ControlTracking {
name: eq.id.clone(),
});
}
for _ in 0..self.coupling_residual_count() {
system_equations.push(EquationRole::CouplingDuty);
}
@@ -2701,6 +2743,7 @@ impl System {
.map(|(_, c, _)| c.n_equations())
.sum();
total_eqs += self.constraints.len()
+ self.algebraic_equations.len()
+ self.coupling_residual_count()
+ 2 * self.saturated_controllers.len();
@@ -2734,6 +2777,15 @@ impl System {
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
eq_offset += n_constraints;
// Modelica algebraic equations: lhs rhs = 0
for (i, eq) in self.algebraic_equations.iter().enumerate() {
let r = eq
.residual(|comp, field| self.resolve_expr_ref(comp, field, state))
.map_err(ComponentError::CalculationFailed)?;
residuals[eq_offset + i] = r;
}
eq_offset += self.algebraic_equations.len();
// Add couplings. Each coupling owns one unknown Q = state[coupling_state_index(i)].
//
// * Physical mode (`hot_component`/`cold_component` set): the residual
@@ -2836,12 +2888,19 @@ impl System {
jacobian.add_entry(r, c, v);
}
let algebraic_row_offset = row_offset + self.constraints.len();
let algebraic_jac = self.compute_algebraic_equation_jacobian(state, algebraic_row_offset);
for (r, c, v) in algebraic_jac {
jacobian.add_entry(r, c, v);
}
// Thermal coupling rows: r_i = Q_i η·duty_hot(state).
// ∂r/∂Q = 1 always (also fixes the legacy stub's singular row); in
// physical mode the plant coupling ∂r/∂col = η·∂duty/∂col is formed by
// central finite differences over the hot component's incident states
// (same pattern as the saturated-controller measurement Jacobian).
let coupling_row_offset = row_offset + self.constraints.len();
let coupling_row_offset =
row_offset + self.constraints.len() + self.algebraic_equations.len();
if !self.thermal_couplings.is_empty() {
let eps = self.inverse_control.finite_diff_epsilon();
let mut state_mut = state.to_vec();
@@ -2882,8 +2941,10 @@ impl System {
}
}
let saturated_row_offset =
row_offset + self.constraints.len() + self.coupling_residual_count();
let saturated_row_offset = row_offset
+ self.constraints.len()
+ self.algebraic_equations.len()
+ self.coupling_residual_count();
let saturated_jac = self.compute_saturated_control_jacobian(state, saturated_row_offset);
for (r, c, v) in saturated_jac {
jacobian.add_entry(r, c, v);
@@ -2891,6 +2952,147 @@ impl System {
Ok(())
}
/// Resolve `component.field` for algebraic model equations.
///
/// Fields may be Z-factors (`z_ua`, …) or measured outputs (`T`, `Tsat`, …).
pub fn resolve_expr_ref(
&self,
component: &str,
field: &str,
state: &StateSlice,
) -> Result<f64, String> {
if let Some(canon) = entropyk_core::normalize_factor_name(field) {
let indices = self.calib_indices_by_name.get(component).ok_or_else(|| {
format!("unknown component '{component}' for Z-factor '{field}'")
})?;
let idx = match canon {
entropyk_core::Z_FLOW => indices.z_flow,
entropyk_core::Z_FLOW_ECO => indices.z_flow_eco,
entropyk_core::Z_DP => indices.z_dp,
entropyk_core::Z_UA => indices.z_ua,
entropyk_core::Z_POWER => indices.z_power,
entropyk_core::Z_ETAV => indices.z_etav,
entropyk_core::F_W => indices.f_w,
_ => None,
}
.ok_or_else(|| {
format!("Z-factor '{field}' is not a free unknown on component '{component}'")
})?;
return state.get(idx).copied().ok_or_else(|| {
format!("state index {idx} out of range for {component}.{field}")
});
}
let kind = Self::measured_output_from_field(field).ok_or_else(|| {
format!(
"unknown field '{field}' on '{component}' \
(expected z_*, T/temperature, Tsat/SST/SDT, P/pressure, …)"
)
})?;
let &node_idx = self.component_names.get(component).ok_or_else(|| {
format!("unknown component '{component}'")
})?;
let component_ref = self.graph.node_weight(node_idx).ok_or_else(|| {
format!("component '{component}' missing from graph")
})?;
component_ref
.measure_output(kind, state)
.filter(|v| v.is_finite())
.ok_or_else(|| {
format!("measure '{field}' unavailable on component '{component}'")
})
}
fn measured_output_from_field(field: &str) -> Option<entropyk_components::MeasuredOutput> {
use entropyk_components::MeasuredOutput as MO;
let n = field.trim().to_ascii_lowercase().replace(['_', '-'], "");
Some(match n.as_str() {
"t" | "temperature" => MO::Temperature,
"tsat" | "sst" | "sdt" | "saturationtemperature" => MO::SaturationTemperature,
"p" | "pressure" => MO::Pressure,
"m" | "mdot" | "massflow" | "massflowrate" => MO::MassFlowRate,
"q" | "capacity" => MO::Capacity,
"duty" | "heattransferrate" | "heatrate" => MO::HeatTransferRate,
"superheat" | "tsh" | "sh" => MO::Superheat,
"subcooling" | "tsc" | "sc" => MO::Subcooling,
_ => return None,
})
}
fn compute_algebraic_equation_jacobian(
&self,
state: &StateSlice,
row_offset: usize,
) -> Vec<(usize, usize, f64)> {
let mut entries = Vec::new();
if self.algebraic_equations.is_empty() {
return entries;
}
let eps = self.inverse_control.finite_diff_epsilon();
if state.len() < self.full_state_vector_len() {
tracing::error!(
state_len = state.len(),
required = self.full_state_vector_len(),
"compute_algebraic_equation_jacobian: state slice too short"
);
return entries;
}
let mut state_mut = state.to_vec();
for (i, eq) in self.algebraic_equations.iter().enumerate() {
let row = row_offset + i;
let mut refs = Vec::new();
eq.lhs.collect_refs(&mut refs);
eq.rhs.collect_refs(&mut refs);
let mut cols = BTreeSet::new();
for (comp, field) in &refs {
if let Some(canon) = entropyk_core::normalize_factor_name(field) {
if let Some(indices) = self.calib_indices_by_name.get(comp) {
let idx = match canon {
entropyk_core::Z_FLOW => indices.z_flow,
entropyk_core::Z_FLOW_ECO => indices.z_flow_eco,
entropyk_core::Z_DP => indices.z_dp,
entropyk_core::Z_UA => indices.z_ua,
entropyk_core::Z_POWER => indices.z_power,
entropyk_core::Z_ETAV => indices.z_etav,
entropyk_core::F_W => indices.f_w,
_ => None,
};
if let Some(idx) = idx {
cols.insert(idx);
}
}
}
if let Some(&node_idx) = self.component_names.get(comp) {
for col in self.incident_state_indices_for_component(node_idx) {
cols.insert(col);
}
}
}
for col in cols {
let orig = state_mut[col];
state_mut[col] = orig + eps;
let r_plus = eq
.residual(|c, f| self.resolve_expr_ref(c, f, &state_mut))
.unwrap_or(f64::NAN);
state_mut[col] = orig - eps;
let r_minus = eq
.residual(|c, f| self.resolve_expr_ref(c, f, &state_mut))
.unwrap_or(f64::NAN);
state_mut[col] = orig;
if r_plus.is_finite() && r_minus.is_finite() {
let dr = (r_plus - r_minus) / (2.0 * eps);
if dr.abs() > 1e-10 {
entries.push((row, col, dr));
}
}
}
}
entries
}
/// Tolerance for mass balance validation [kg/s].
///
/// This value (1e-9 kg/s) is tight enough to catch numerical issues