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 {

View File

@@ -92,7 +92,8 @@ fn test_simulation_result_statuses() {
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: "fail.json".to_string(),
@@ -106,7 +107,8 @@ fn test_simulation_result_statuses() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "timeout.json".to_string(),
@@ -120,7 +122,8 @@ fn test_simulation_result_statuses() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 1000,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];
@@ -163,7 +166,8 @@ fn test_batch_aggregator_csv_output() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 150,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "scenario2.json".to_string(),
@@ -183,7 +187,8 @@ fn test_batch_aggregator_csv_output() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 200,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "scenario3.json".to_string(),
@@ -197,7 +202,8 @@ fn test_batch_aggregator_csv_output() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];
@@ -229,7 +235,8 @@ fn test_batch_aggregator_json_summary() {
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(),
@@ -243,7 +250,8 @@ fn test_batch_aggregator_json_summary() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 75,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
SimulationResult {
input: "test3.json".to_string(),
@@ -257,7 +265,8 @@ fn test_batch_aggregator_json_summary() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 5000,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
},
];
@@ -322,7 +331,8 @@ fn test_batch_summary_csv_with_convergence() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 300,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
}];
let summary = BatchSummary {

View File

@@ -0,0 +1,190 @@
//! Calibration redesign (WS-2/WS-3/WS-4) — AC#1: BPHX condenser SDT
//! calibration via plain inverse embedding converges and back-solves z_ua
//! (the case that diverged with a singular Jacobian before the redesign).
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
use tempfile::tempdir;
fn run_config(json: &str) -> SimulationResult {
let dir = tempdir().unwrap();
let path = dir.path().join("bphx_sdt_calib.json");
std::fs::write(&path, json).unwrap();
run_simulation(&path, None, false).unwrap()
}
#[test]
fn bphx_condenser_sdt_calibration_converges_and_solves_z_ua() {
let json = r#"
{
"name": "BPHX Water-Cooled Chiller R134a",
"description": "Full vapor-compression cycle with brazed-plate evaporator and condenser (4-port). Both Bphx units use emergent_pressure so outlet closures (superheat / subcooling) square the DoF like Condenser/Evaporator.",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Refrigerant + water loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.7,
"t_cond_k": 318.15,
"t_evap_k": 278.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 6.5e-05,
"speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "BphxCondenser",
"name": "cond",
"refrigerant": "R134a",
"secondary_fluid": "Water",
"n_plates": 40,
"plate_length_m": 0.4,
"plate_width_m": 0.12,
"target_subcooling_k": 5.0,
"emergent_pressure": true,
"correlation": "Longo2004",
"dp_correlation": "SimplifiedChannel",
"ua": 2500.0
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 278.15,
"emergent_pressure": true
},
{
"type": "BphxEvaporator",
"name": "evap",
"refrigerant": "R134a",
"secondary_fluid": "Water",
"n_plates": 40,
"plate_length_m": 0.4,
"plate_width_m": 0.12,
"target_superheat_k": 5.0,
"emergent_pressure": true,
"correlation": "Longo2004",
"dp_correlation": "SimplifiedChannel",
"ua": 2000.0
},
{
"type": "BrineSource",
"name": "cond_water_in",
"fluid": "Water",
"p_set_bar": 2.0,
"t_set_c": 30.0,
"m_flow_kg_s": 0.4,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": true
},
{
"type": "BrineSink",
"name": "cond_water_out",
"fluid": "Water",
"p_back_bar": 2.0,
"fix_pressure": true
},
{
"type": "BrineSource",
"name": "evap_water_in",
"fluid": "Water",
"p_set_bar": 3.0,
"t_set_c": 12.0,
"m_flow_kg_s": 0.5,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": true
},
{
"type": "BrineSink",
"name": "evap_water_out",
"fluid": "Water",
"p_back_bar": 3.0,
"fix_pressure": true
}
],
"edges": [
{
"from": "comp:outlet",
"to": "cond:inlet"
},
{
"from": "cond:outlet",
"to": "exv:inlet"
},
{
"from": "exv:outlet",
"to": "evap:inlet"
},
{
"from": "evap:outlet",
"to": "comp:inlet"
},
{
"from": "cond_water_in:outlet",
"to": "cond:secondary_inlet"
},
{
"from": "cond:secondary_outlet",
"to": "cond_water_out:inlet"
},
{
"from": "evap_water_in:outlet",
"to": "evap:secondary_inlet"
},
{
"from": "evap:secondary_outlet",
"to": "evap_water_out:inlet"
}
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 300,
"tolerance": 1e-06,
"timeout_ms": 60000
},
"controls": [
{
"type": "SaturatedController",
"id": "sdt_calib",
"measure": {
"component": "cond",
"output": "saturationTemperature"
},
"actuator": {
"component": "cond",
"factor": "z_ua",
"initial": 0.3,
"min": 0.05,
"max": 2.0
},
"target": 315.0
}
]
}
"#;
let result = run_config(json);
assert!(
matches!(result.status, SimulationStatus::Converged),
"BPHX SDT calibration must converge (was the singular-Jacobian case): {:?} ({:?})",
result.status,
result.error
);
let solved = result
.solved_variables
.iter()
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("cond"))
.expect("solved_variables must contain cond/z_ua");
assert!(
solved.value > 0.05 && solved.value < 2.0,
"z_ua must solve within bounds, got {}",
solved.value
);
}

View File

@@ -0,0 +1,193 @@
//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration
//! test: a `Probe` node measuring SDT on the condenser refrigerant inlet edge
//! drives the plain inverse embedding for `z_ua`. The Probe is the measurement
//! source (`control.measure.component` names the Probe); the freed z-factor
//! lives on the BPHX condenser (`control.actuator.component`). The two are
//! linked 1:1 (+1 residual on Probe SDT target, +1 unknown z_ua).
//!
//! This is the Probe-based variant of `calibration_sdt.rs`. It proves the
//! end-to-end path the UI emits after the calibration redesign.
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
use tempfile::tempdir;
fn run_config(json: &str) -> SimulationResult {
let dir = tempdir().unwrap();
let path = dir.path().join("probe_sdt_calib.json");
std::fs::write(&path, json).unwrap();
run_simulation(&path, None, false).unwrap()
}
#[test]
fn probe_based_sdt_calibration_converges_and_solves_z_ua() {
let json = r#"
{
"name": "Probe-based SDT calibration (R134a BPHX chiller)",
"description": "Same vapor-compression cycle as calibration_sdt.rs, but the SDT measurement lives on a Probe node spliced into the condenser refrigerant inlet edge. control.measure.component = the Probe name.",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Refrigerant + water loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.7,
"t_cond_k": 318.15,
"t_evap_k": 278.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 6.5e-05,
"speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "BphxCondenser",
"name": "cond",
"refrigerant": "R134a",
"secondary_fluid": "Water",
"n_plates": 40,
"plate_length_m": 0.4,
"plate_width_m": 0.12,
"target_subcooling_k": 5.0,
"emergent_pressure": true,
"correlation": "Longo2004",
"dp_correlation": "SimplifiedChannel",
"ua": 2500.0
},
{
"type": "Probe",
"name": "cond_sdt_probe",
"measure": "SDT",
"fluid": "R134a"
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 278.15,
"emergent_pressure": true
},
{
"type": "BphxEvaporator",
"name": "evap",
"refrigerant": "R134a",
"secondary_fluid": "Water",
"n_plates": 40,
"plate_length_m": 0.4,
"plate_width_m": 0.12,
"target_superheat_k": 5.0,
"emergent_pressure": true,
"correlation": "Longo2004",
"dp_correlation": "SimplifiedChannel",
"ua": 2000.0
},
{
"type": "BrineSource",
"name": "cond_water_in",
"fluid": "Water",
"p_set_bar": 2.0,
"t_set_c": 30.0,
"m_flow_kg_s": 0.4,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": true
},
{
"type": "BrineSink",
"name": "cond_water_out",
"fluid": "Water",
"p_back_bar": 2.0,
"fix_pressure": true
},
{
"type": "BrineSource",
"name": "evap_water_in",
"fluid": "Water",
"p_set_bar": 3.0,
"t_set_c": 12.0,
"m_flow_kg_s": 0.5,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": true
},
{
"type": "BrineSink",
"name": "evap_water_out",
"fluid": "Water",
"p_back_bar": 3.0,
"fix_pressure": true
}
],
"edges": [
{ "from": "comp:outlet", "to": "cond_sdt_probe:inlet" },
{ "from": "cond_sdt_probe:outlet","to": "cond:inlet" },
{ "from": "cond:outlet", "to": "exv:inlet" },
{ "from": "exv:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "comp:inlet" },
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
{ "from": "cond:secondary_outlet","to": "cond_water_out:inlet" },
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet","to": "evap_water_out:inlet" }
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 300,
"tolerance": 1e-06,
"timeout_ms": 60000
},
"controls": [
{
"type": "SaturatedController",
"id": "probe_sdt_calib",
"measure": {
"component": "cond_sdt_probe",
"output": "saturationTemperature"
},
"actuator": {
"component": "cond",
"factor": "z_ua",
"initial": 0.3,
"min": 0.05,
"max": 2.0
},
"target": 315.0
}
]
}
"#;
let result = run_config(json);
assert!(
matches!(result.status, SimulationStatus::Converged),
"Probe-based SDT calibration must converge: {:?} ({:?})",
result.status,
result.error
);
let solved = result
.solved_variables
.iter()
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("cond"))
.expect("solved_variables must contain cond/z_ua");
eprintln!("DIAG z_ua résolu = {}", solved.value);
eprintln!("DIAG cible SDT = 315.0 K (41.85°C)");
if let Some(state) = result.state.as_ref() {
for e in state.iter() {
if e.target.as_deref() == Some("cond") || e.source.as_deref() == Some("cond") {
eprintln!("DIAG edge {}{} P={}bar T_sat={}°C T={}°C",
e.source.as_deref().unwrap_or("?"),
e.target.as_deref().unwrap_or("?"),
e.pressure_bar,
e.saturation_temperature_c.unwrap_or(f64::NAN),
e.temperature_c.unwrap_or(f64::NAN));
}
}
}
assert!(
solved.value > 0.05 && solved.value < 2.0,
"z_ua must solve within bounds, got {}",
solved.value
);
}

View File

@@ -0,0 +1,194 @@
//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration
//! test: a `Probe` node measuring SDT on the condenser refrigerant inlet edge
//! drives the plain inverse embedding for `z_ua`. The Probe is the measurement
//! source (`control.measure.component` names the Probe); the freed z-factor
//! lives on the BPHX condenser (`control.actuator.component`). The two are
//! linked 1:1 (+1 residual on Probe SDT target, +1 unknown z_ua).
//!
//! This is the Probe-based variant of `calibration_sdt.rs`. It proves the
//! end-to-end path the UI emits after the calibration redesign.
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
use tempfile::tempdir;
fn run_config(json: &str) -> SimulationResult {
let dir = tempdir().unwrap();
let path = dir.path().join("probe_sdt_calib.json");
std::fs::write(&path, json).unwrap();
run_simulation(&path, None, false).unwrap()
}
#[test]
fn probe_based_sst_evap_calibration() {
let json = r#"
{
"name": "Probe-based SDT calibration (R134a BPHX chiller)",
"description": "Same vapor-compression cycle as calibration_sdt.rs, but the SDT measurement lives on a Probe node spliced into the condenser refrigerant inlet edge. control.measure.component = the Probe name.",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Refrigerant + water loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.7,
"t_cond_k": 318.15,
"t_evap_k": 278.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 6.5e-05,
"speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "BphxCondenser",
"name": "cond",
"refrigerant": "R134a",
"secondary_fluid": "Water",
"n_plates": 40,
"plate_length_m": 0.4,
"plate_width_m": 0.12,
"target_subcooling_k": 5.0,
"emergent_pressure": true,
"correlation": "Longo2004",
"dp_correlation": "SimplifiedChannel",
"ua": 2500.0
},
{
"type": "Probe",
"name": "evap_sst_probe",
"measure": "SDT",
"fluid": "R134a"
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 278.15,
"emergent_pressure": true
},
{
"type": "BphxEvaporator",
"name": "evap",
"refrigerant": "R134a",
"secondary_fluid": "Water",
"n_plates": 40,
"plate_length_m": 0.4,
"plate_width_m": 0.12,
"target_superheat_k": 5.0,
"emergent_pressure": true,
"correlation": "Longo2004",
"dp_correlation": "SimplifiedChannel",
"ua": 2000.0
},
{
"type": "BrineSource",
"name": "cond_water_in",
"fluid": "Water",
"p_set_bar": 2.0,
"t_set_c": 30.0,
"m_flow_kg_s": 0.4,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": true
},
{
"type": "BrineSink",
"name": "cond_water_out",
"fluid": "Water",
"p_back_bar": 2.0,
"fix_pressure": true
},
{
"type": "BrineSource",
"name": "evap_water_in",
"fluid": "Water",
"p_set_bar": 3.0,
"t_set_c": 12.0,
"m_flow_kg_s": 0.5,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": true
},
{
"type": "BrineSink",
"name": "evap_water_out",
"fluid": "Water",
"p_back_bar": 3.0,
"fix_pressure": true
}
],
"edges": [
{ "from": "exv:outlet", "to": "evap_sst_probe:inlet" },
{ "from": "evap_sst_probe:outlet","to": "cond:inlet" },
{ "from": "cond:outlet", "to": "exv:inlet" },
{ "from": "exv:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "evap_sst_probe:inlet" },
{ "from": "evap_sst_probe:outlet", "to": "comp:inlet" },
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
{ "from": "cond:secondary_outlet","to": "cond_water_out:inlet" },
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet","to": "evap_water_out:inlet" }
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 300,
"tolerance": 1e-06,
"timeout_ms": 60000
},
"controls": [
{
"type": "SaturatedController",
"id": "probe_sdt_calib",
"measure": {
"component": "evap_sst_probe",
"output": "saturationTemperature"
},
"actuator": {
"component": "evap",
"factor": "z_ua",
"initial": 0.3,
"min": 0.05,
"max": 2.0
},
"target": 277.55
}
]
}
"#;
let result = run_config(json);
assert!(
matches!(result.status, SimulationStatus::Converged),
"Probe-based SDT calibration must converge: {:?} ({:?})",
result.status,
result.error
);
let solved = result
.solved_variables
.iter()
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("evap"))
.expect("solved_variables must contain cond/z_ua");
eprintln!("DIAG z_ua résolu = {}", solved.value);
eprintln!("DIAG cible SST = 277.55 K (41.85°C)");
if let Some(state) = result.state.as_ref() {
for e in state.iter() {
if e.target.as_deref() == Some("evap") || e.source.as_deref() == Some("evap") {
eprintln!("DIAG edge {}{} P={}bar T_sat={}°C T={}°C",
e.source.as_deref().unwrap_or("?"),
e.target.as_deref().unwrap_or("?"),
e.pressure_bar,
e.saturation_temperature_c.unwrap_or(f64::NAN),
e.temperature_c.unwrap_or(f64::NAN));
}
}
}
assert!(
solved.value > 0.05 && solved.value < 2.0,
"z_ua must solve within bounds, got {}",
solved.value
);
}

View File

@@ -29,7 +29,8 @@ fn test_simulation_result_serialization() {
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();
@@ -70,7 +71,8 @@ fn test_error_result_serialization() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
let json = serde_json::to_string(&result).unwrap();
@@ -96,7 +98,8 @@ fn test_error_result_serializes_failure_diagnostics() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
let json = serde_json::to_string(&result).unwrap();
@@ -2303,7 +2306,8 @@ fn test_structural_failure_serializes_without_diagnostics() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
let json = serde_json::to_string(&result).unwrap();
@@ -2348,7 +2352,8 @@ fn test_success_result_does_not_include_failure_diagnostics() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 120,
raw_state_vector: None, solved_variables: Vec::new(),
raw_state_vector: None,
solved_variables: Vec::new(),
};
let json = serde_json::to_string(&result).unwrap();