Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
Some checks failed
CI / check (push) Has been cancelled

Checkpoint incomplete calibration work (cond SDT green, evap SST failing) plus related solver/UI changes so the next pass can fix and extend safely.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 21:44:01 +02:00
parent 2f1f7ecb80
commit 3808e0f11b
39 changed files with 3648 additions and 265 deletions

View File

@@ -315,7 +315,8 @@ fn process_single_file(
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
}
}
}
@@ -425,7 +426,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "test2.json".to_string(),
@@ -439,7 +441,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];
@@ -483,7 +486,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "test2.json".to_string(),
@@ -497,7 +501,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];
@@ -529,7 +534,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "test2.json".to_string(),
@@ -543,7 +549,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];
@@ -571,7 +578,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "test2.json".to_string(),
@@ -585,7 +593,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 1000,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];
@@ -643,7 +652,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 100,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
}],
};
@@ -667,7 +677,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "ok2.json".to_string(),
@@ -681,7 +692,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 60,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "fail.json".to_string(),
@@ -695,7 +707,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];

View File

@@ -452,6 +452,9 @@ pub struct SolverConfig {
/// Solver strategy: "newton" or "picard".
#[serde(default = "default_solver_strategy")]
pub strategy: String,
/// Dense linear backend: "nalgebra" (default) or "faer" (Story 1.5 opt-in).
#[serde(default = "default_linear_backend")]
pub linear_backend: String,
/// Maximum iterations.
#[serde(default = "default_max_iterations")]
pub max_iterations: usize,
@@ -470,6 +473,10 @@ fn default_solver_strategy() -> String {
"newton".to_string()
}
fn default_linear_backend() -> String {
"nalgebra".to_string()
}
fn default_max_iterations() -> usize {
100
}
@@ -482,6 +489,7 @@ impl Default for SolverConfig {
fn default() -> Self {
Self {
strategy: default_solver_strategy(),
linear_backend: default_linear_backend(),
max_iterations: default_max_iterations(),
tolerance: default_tolerance(),
timeout_ms: 0,

View File

@@ -372,7 +372,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
@@ -407,7 +408,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
};
@@ -717,7 +719,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
},
@@ -737,7 +740,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
}
@@ -782,7 +786,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
},
@@ -823,7 +828,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
},
@@ -867,7 +873,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
}
@@ -887,7 +894,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
}
@@ -957,7 +965,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
}
@@ -983,7 +992,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
let find_component = |name: &str| {
config
@@ -1042,7 +1052,63 @@ fn execute_simulation(
// Register declared control loops (saturated-PI, co-solved). Must happen
// BEFORE finalize() so the actuator is wired to the component's CalibIndices.
//
// Routing (calibration redesign): controls whose actuator is a z-factor
// (z_flow/z_flow_eco/z_dp/z_ua/z_power/z_etav) go through PLAIN inverse
// embedding (+1 residual measuredtarget, +1 unknown z) — not the
// SaturatedController (2+2 with integrator, meant for physical actuators).
let control_error = |msg: String| SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(msg),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None,
solved_variables: Vec::new(),
};
for control in &config.controls {
let factor = control.actuator.factor.trim();
// Plain embedding applies to single-point z-factor calibration only.
// A control WITH `objectives` is a supervisory override network
// (selector semantics) and always keeps the SaturatedController path,
// regardless of the actuator factor.
if entropyk_core::normalize_factor_name(factor).is_some() && control.objectives.is_empty() {
match build_plain_z_embedding(control, factor) {
Ok((constraint, bounded_var, actuator_id)) => {
if let Err(e) = system.add_constraint(constraint) {
return control_error(format!(
"Failed to add constraint for control '{}': {:?}",
control.id, e
));
}
if let Err(e) = system.add_bounded_variable(bounded_var) {
return control_error(format!(
"Failed to add actuator for control '{}': {:?}",
control.id, e
));
}
if let Err(e) = system.link_constraint_to_control(
&entropyk_solver::inverse::ConstraintId::new(control.id.clone()),
&actuator_id,
) {
return control_error(format!(
"Failed to link control '{}': {:?}",
control.id, e
));
}
}
Err(msg) => {
return control_error(format!("Invalid control '{}': {}", control.id, msg));
}
}
continue;
}
match build_saturated_control(control) {
Ok((bounded_var, controller)) => {
if let Err(e) = system.add_bounded_variable(bounded_var) {
@@ -1061,7 +1127,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
system.add_saturated_controller(controller);
@@ -1079,7 +1146,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
}
@@ -1253,7 +1321,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
system.add_free_actuator(var_id);
@@ -1275,7 +1344,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
}
@@ -1296,7 +1366,8 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: None,
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
@@ -1524,10 +1595,35 @@ fn execute_simulation(
initialization_diagnostics: None,
dof: Some(dof_summary),
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
// Dense linear backend dispatch (Story 1.5): "nalgebra" (default) or
// "faer"; env ENTROPYK_LINEAR_BACKEND overrides for the test harness.
if !matches!(config.solver.linear_backend.as_str(), "nalgebra" | "faer") {
return SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!(
"Unsupported linear backend '{}'. Use 'nalgebra' or 'faer'.",
config.solver.linear_backend
)),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: Some(dof_summary),
elapsed_ms,
raw_state_vector: None,
solved_variables: Vec::new(),
};
}
entropyk_solver::linear::set_linear_backend_override(Some(&config.solver.linear_backend));
// Build + run the configured strategy for a single solve. CLI
// `solver.max_iterations` / `tolerance` are now honoured by every stage
// (previously the defaults capped Newton at 100 iterations). Reused by the
@@ -1799,7 +1895,8 @@ fn execute_simulation(
initialization_diagnostics: Some(initialization_diagnostics),
dof: Some(dof_summary),
elapsed_ms,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
}
}
}
@@ -2968,9 +3065,8 @@ fn bphx_dp_correlation_from_params(
"{component_name}: dp_correlation must be a string (got {value})"
)));
};
BphxDpCorrelation::parse(raw).map_err(|msg| {
CliError::Config(format!("{component_name}: {msg}"))
})
BphxDpCorrelation::parse(raw)
.map_err(|msg| CliError::Config(format!("{component_name}: {msg}")))
}
/// Extract calibration Z-factors for BphxEvaporator/BphxCondenser from JSON params.
@@ -3134,6 +3230,43 @@ fn parse_component_output(
}
/// Builds a bounded actuator + saturated controller from a control config.
/// Plain inverse embedding for z-factor calibration controls (calibration
/// redesign, WS-2): one `Constraint` (measured target) + one `BoundedVariable`
/// (the z-factor), linked 1:1. The bounded var id reuses the
/// `{component}__{z_factor}` convention so `finalize()` wires it to the
/// component's matching `CalibIndices` slot (system.rs).
fn build_plain_z_embedding(
control: &crate::config::ControlConfig,
factor: &str,
) -> Result<
(
entropyk_solver::inverse::Constraint,
entropyk_solver::inverse::BoundedVariable,
entropyk_solver::inverse::BoundedVariableId,
),
String,
> {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};
let output = parse_component_output(&control.measure.component, &control.measure.output)?;
let actuator_id =
BoundedVariableId::new(saturated_actuator_id(&control.actuator.component, factor));
let bounded_var = BoundedVariable::with_component(
actuator_id.clone(),
&control.actuator.component,
control.actuator.initial,
control.actuator.min,
control.actuator.max,
)
.map_err(|e| format!("invalid actuator bounds: {e:?}"))?;
let constraint = Constraint::new(
ConstraintId::new(control.id.clone()),
output,
control.target,
);
Ok((constraint, bounded_var, actuator_id))
}
fn build_saturated_control(
control: &crate::config::ControlConfig,
) -> Result<
@@ -5249,6 +5382,55 @@ fn create_component(
Ok(Box::new(load))
}
"Probe" => {
use entropyk::{ComponentFluidId, Enthalpy, Port, Probe, ProbeMeasure, Pressure};
// Calibration-redesign measurement tap (zero-residual, 2-port splice).
// The user drops it onto a wire (UI `insertOnEdge`) and picks a
// measure kind; the freed z-factor on a calibrated component
// elsewhere is paired with this Probe via a calibration control
// whose `measure.component` names the Probe.
let measure_str = params
.get("measure")
.and_then(|v| v.as_str())
.unwrap_or("SH");
let measure = ProbeMeasure::parse(measure_str).map_err(CliError::Config)?;
let fluid = params
.get("fluid")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| _primary_fluid.as_str())
.to_string();
// Nominal edge state for the port pair (mirrors Pipe's idiom —
// both pairs carry identical nominal values, the live solver state
// overrides them via `set_system_context`).
let p_bar = params.get("p_bar").and_then(|v| v.as_f64()).unwrap_or(5.0);
let h_j = params
.get("h_j_kg")
.and_then(|v| v.as_f64())
.or_else(|| {
params
.get("h_kj_kg")
.and_then(|v| v.as_f64())
.map(|k| k * 1000.0)
})
.unwrap_or(250_000.0);
let mk = || {
Port::new(
ComponentFluidId::new(&fluid),
Pressure::from_bar(p_bar),
Enthalpy::from_joules_per_kg(h_j),
)
};
let probe = Probe::new(measure, &fluid, mk(), mk())
.with_fluid_backend(backend)
.connect(mk(), mk())
.map_err(CliError::Component)?;
Ok(Box::new(probe))
}
"Anchor" | "RefrigerantNode" => {
use entropyk::{Anchor, AnchorConstraint};
// BOLT `BoundaryNode.Refrigerant.Node` equivalent: inline spec node
@@ -5362,7 +5544,7 @@ fn create_component(
}
_ => Err(CliError::Config(format!(
"Unknown component type: '{}'. Supported: IsentropicCompressor, IsenthalpicExpansionValve, ScrewEconomizerCompressor, CentrifugalCompressor, CapillaryTube, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, FreeCoolingExchanger, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, ReversingValve, Pump, Fan, Pipe, RefrigerantPipe, WaterPipe, AirDuct, Placeholder",
"Unknown component type: '{}'. Supported: IsentropicCompressor, IsenthalpicExpansionValve, ScrewEconomizerCompressor, CentrifugalCompressor, CapillaryTube, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, FreeCoolingExchanger, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, ReversingValve, Pump, Fan, Pipe, RefrigerantPipe, WaterPipe, AirDuct, Probe, Anchor, Placeholder",
component_type
))),
}
@@ -5672,7 +5854,8 @@ mod tests {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
let json = serde_json::to_string_pretty(&result).unwrap();
@@ -5839,10 +6022,8 @@ mod tests {
.unwrap(),
BphxDpCorrelation::SimplifiedChannel
);
let params = std::collections::HashMap::from([(
"DpCorrelation".to_string(),
json!("Martin1996"),
)]);
let params =
std::collections::HashMap::from([("DpCorrelation".to_string(), json!("Martin1996"))]);
assert_eq!(
bphx_dp_correlation_from_params(&params, "BphxCondenser").unwrap(),
BphxDpCorrelation::Martin1996
@@ -5853,10 +6034,8 @@ mod tests {
fn test_bphx_dp_correlation_rejects_unknown() {
use serde_json::json;
let params = std::collections::HashMap::from([(
"dp_correlation".to_string(),
json!("Amalfi2016"),
)]);
let params =
std::collections::HashMap::from([("dp_correlation".to_string(), json!("Amalfi2016"))]);
let error = bphx_dp_correlation_from_params(&params, "BphxEvaporator").unwrap_err();
assert!(
error.to_string().contains("unsupported dp_correlation"),
@@ -5868,15 +6047,9 @@ mod tests {
fn test_bphx_dp_correlation_rejects_non_string() {
use serde_json::json;
let params = std::collections::HashMap::from([(
"dp_correlation".to_string(),
json!(42),
)]);
let params = std::collections::HashMap::from([("dp_correlation".to_string(), json!(42))]);
let error = bphx_dp_correlation_from_params(&params, "BphxEvaporator").unwrap_err();
assert!(
error.to_string().contains("must be a string"),
"{error}"
);
assert!(error.to_string().contains("must be a string"), "{error}");
}
fn comp_from_json(v: serde_json::Value) -> crate::config::ComponentConfig {