Stabilize Modelica Fixed/Free calibration and stop flaky Newton embeds.
Some checks failed
CI / check (push) Has been cancelled
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user