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))
}