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();

View File

@@ -555,6 +555,10 @@ impl Component for BphxCondenser {
self.inner.energy_transfers(state)
}
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
self.inner.measure_output(kind, state)
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,

View File

@@ -36,7 +36,10 @@ impl BphxDpCorrelation {
/// Parse CLI/UI identifiers. Rejects unknown strings (no silent fallback).
pub fn parse(name: &str) -> Result<Self, String> {
let key = name.trim().to_ascii_lowercase().replace(['-', '_', ' '], "");
let key = name
.trim()
.to_ascii_lowercase()
.replace(['-', '_', ' '], "");
match key.as_str() {
"simplifiedchannel" | "simplified" | "channel" | "default" => {
Ok(Self::SimplifiedChannel)
@@ -104,13 +107,7 @@ fn simplified_channel_fanning(re: f64) -> (f64, f64) {
return (f_turb, df_turb);
}
let f = entropyk_core::smoothing::cubic_blend(
f_lam,
f_turb,
re,
RE_LAMINAR,
RE_TURBULENT,
);
let f = entropyk_core::smoothing::cubic_blend(f_lam, f_turb, re, RE_LAMINAR, RE_TURBULENT);
// d/dRe of blend(f_lam(Re), f_turb(Re), Re): product rule on cubic Hermite.
let df_blend_dx = entropyk_core::smoothing::cubic_blend_derivative(
f_lam,
@@ -178,12 +175,10 @@ fn martin1996_fanning(re: f64, chevron_angle_deg: f64) -> (f64, f64) {
let t = ((re_safe - RE_LO) / (RE_HI - RE_LO)).clamp(0.0, 1.0);
let w_b = t * t * (3.0 - 2.0 * t);
let w_a = 1.0 - w_b;
let df0_blend = entropyk_core::smoothing::cubic_blend_derivative(
f0_l, f0_t, re_safe, RE_LO, RE_HI,
);
let df1_blend = entropyk_core::smoothing::cubic_blend_derivative(
f1_l, f1_t, re_safe, RE_LO, RE_HI,
);
let df0_blend =
entropyk_core::smoothing::cubic_blend_derivative(f0_l, f0_t, re_safe, RE_LO, RE_HI);
let df1_blend =
entropyk_core::smoothing::cubic_blend_derivative(f1_l, f1_t, re_safe, RE_LO, RE_HI);
let df0 = df0_blend + w_a * df0_l + w_b * df0_t;
let df1 = df1_blend + w_a * df1_l + w_b * df1_t;
(f0, df0, f1, df1)
@@ -324,14 +319,9 @@ mod tests {
1.0,
)
.unwrap();
let martin = evaluate_channel_pressure_drop(
BphxDpCorrelation::Martin1996,
&geo(),
g,
rho,
1.0,
)
.unwrap();
let martin =
evaluate_channel_pressure_drop(BphxDpCorrelation::Martin1996, &geo(), g, rho, 1.0)
.unwrap();
assert!(
(simp.delta_p_pa - martin.delta_p_pa).abs() > 1.0,
"Martin and Simplified should differ: {} vs {}",
@@ -381,14 +371,9 @@ mod tests {
fn martin_rejects_invalid_chevron() {
let mut g = geo();
g.chevron_angle = f64::NAN;
let err = evaluate_channel_pressure_drop(
BphxDpCorrelation::Martin1996,
&g,
30.0,
1000.0,
1.0,
)
.unwrap_err();
let err =
evaluate_channel_pressure_drop(BphxDpCorrelation::Martin1996, &g, 30.0, 1000.0, 1.0)
.unwrap_err();
assert!(format!("{err}").contains("chevron"));
}
@@ -403,10 +388,8 @@ mod tests {
] {
let eval = evaluate_channel_pressure_drop(corr, &geo, g0, rho, 1.0).unwrap();
let h = 1e-4;
let plus =
evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
let minus =
evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
let plus = evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
let minus = evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
let fd = (plus.delta_p_pa - minus.delta_p_pa) / (2.0 * h);
let rel = (eval.d_delta_p_d_g - fd).abs() / fd.abs().max(1.0);
assert!(

View File

@@ -517,6 +517,10 @@ impl Component for BphxEvaporator {
self.inner.energy_transfers(state)
}
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
self.inner.measure_output(kind, state)
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,

View File

@@ -40,11 +40,14 @@ use super::correlation_registry::{
};
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::{HeatExchanger, HxSideConditions};
use super::phase_change_entu::{condenser_duty, evaporator_duty};
use super::sat_domain;
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
use entropyk_fluids::{FluidId, FluidState, Property, Quality};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
@@ -498,8 +501,7 @@ impl BphxExchanger {
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
let a_flow =
self.geometry.channel_flow_area() * self.geometry.n_channels_per_side() as f64;
let a_flow = self.geometry.channel_flow_area() * self.geometry.n_channels_per_side() as f64;
if a_flow <= 1e-30 {
return Err(ComponentError::InvalidState(
"BPHX channel flow area too small for pressure-drop Jacobian".into(),
@@ -528,6 +530,193 @@ impl BphxExchanger {
let ua = h * self.geometry.area * self.calib().z_ua;
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
}
/// Condenser / evaporator plate HX use Shah \(C^*\to 0\) duty instead of
/// two-stream sensible ε-NTU (see `phase_change_entu`).
fn uses_phase_change_duty(&self) -> bool {
matches!(
self.geometry.exchanger_type,
BphxType::Condenser | BphxType::Evaporator
) && self.fluid_backend.is_some()
}
/// \(T_{\mathrm{sat}}(P)\) [K] for the refrigerant fluid id, domain-clamped.
fn tsat_k(&self, p_pa: f64, refrigerant_id: &str) -> Result<f64, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"BphxExchanger: FluidBackend required for Tsat".into(),
)
})?;
let p_pa =
sat_domain::clamp_to_saturation_domain(backend, refrigerant_id, p_pa).unwrap_or(p_pa);
backend
.property(
FluidId::new(refrigerant_id),
Property::Temperature,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
)
.map_err(ComponentError::from_fluid_error)
}
/// Secondary inlet \((T [K], C_{\mathrm{sec}} [W/K])\) without querying refrigerant \(c_p\).
fn secondary_inlet_capacity(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
let edges = self.inner.four_port_edges().ok_or_else(|| {
ComponentError::InvalidState(
"BphxExchanger: phase-change duty needs live four-port edges".into(),
)
})?;
let (m_idx, p_idx, h_idx) = match self.geometry.exchanger_type {
// Condenser: cold = secondary. Evaporator (remapped): hot = secondary.
BphxType::Condenser => edges.cold_in,
BphxType::Evaporator => edges.hot_in,
BphxType::Generic => {
return Err(ComponentError::InvalidState(
"BphxExchanger: Generic type has no phase-change secondary stream".into(),
));
}
};
let max_idx = m_idx.max(p_idx).max(h_idx);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
let m = state[m_idx].max(0.0);
let p = state[p_idx];
let h = state[h_idx];
let (t, cp) = match self.geometry.exchanger_type {
BphxType::Condenser => (
self.inner.cold_side_temperature(p, h)?,
self.inner.cold_side_cp(p, h)?,
),
BphxType::Evaporator => (
self.inner.hot_side_temperature(p, h)?,
self.inner.hot_side_cp(p, h)?,
),
BphxType::Generic => unreachable!(),
};
Ok((t, m * cp))
}
/// Replace sensible ε-NTU energy rows with phase-change duty.
///
/// Layout (unchanged): `r0` = hot energy, `r1` = cold energy, then DP rows.
fn overwrite_energy_with_phase_change(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: 2,
actual: residuals.len(),
});
}
let edges = self.inner.four_port_edges().ok_or_else(|| {
ComponentError::InvalidState(
"BphxExchanger: phase-change duty needs live four-port edges".into(),
)
})?;
let (m_h, p_h_in, h_h_in) = edges.hot_in;
let (_, _, h_h_out) = edges.hot_out;
let (m_c, p_c_in, h_c_in) = edges.cold_in;
let (_, _, h_c_out) = edges.cold_out;
let max_idx = [m_h, p_h_in, h_h_in, h_h_out, m_c, p_c_in, h_c_in, h_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
let (t_sec, c_sec) = self.secondary_inlet_capacity(state)?;
// Calibration redesign (WS-4): when a z_ua calibration variable is
// wired, the residual must track `state[z_ua]` (plain embedding), not
// the cached parameter — otherwise ∂Q/∂z_ua = 0 and the calibration
// Jacobian is singular. Mirrors exchanger.rs's `dynamic_f_ua` pattern.
let ua = match self.inner.calib_indices_ref().z_ua {
Some(z_idx) if z_idx < state.len() => self.inner.ua_nominal() * state[z_idx],
_ => self.ua(),
};
let m_hot = state[m_h].max(0.0);
let m_cold = state[m_c].max(0.0);
let q = match self.geometry.exchanger_type {
BphxType::Condenser => {
// hot = refrigerant, cold = secondary
let t_sat = self.tsat_k(state[p_h_in], self.inner.hot_fluid_id_str())?;
condenser_duty(ua, c_sec, t_sat, t_sec)
}
BphxType::Evaporator => {
// remapped: hot = secondary, cold = refrigerant
let t_sat = self.tsat_k(state[p_c_in], self.inner.cold_fluid_id_str())?;
evaporator_duty(ua, c_sec, t_sat, t_sec)
}
BphxType::Generic => 0.0,
};
// Same sign convention as EpsNtuModel::compute_residuals.
residuals[0] = m_hot * (state[h_h_in] - state[h_h_out]) - q;
residuals[1] = m_cold * (state[h_c_out] - state[h_c_in]) - q;
Ok(())
}
/// Finite-difference Jacobian through `self.compute_residuals` so energy
/// rows see phase-change duty (inner HeatExchanger FD would stay sensible).
fn fd_jacobian_via_self(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
let Some(edges) = self.inner.four_port_edges() else {
return Ok(());
};
let (m_h, p_h_in, h_h_in) = edges.hot_in;
let (_, p_h_out, h_h_out) = edges.hot_out;
let (m_c, p_c_in, h_c_in) = edges.cold_in;
let (_, p_c_out, h_c_out) = edges.cold_out;
let mut cols = vec![
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
];
if let Some(z_idx) = self.inner.calib_indices_ref().z_dp {
cols.push(z_idx);
}
if let Some(z_idx) = self.inner.calib_indices_ref().z_ua {
cols.push(z_idx);
}
cols.sort_unstable();
cols.dedup();
cols.retain(|c| *c < state.len());
let n = self.n_equations();
let compute = |s: &[f64]| -> Result<Vec<f64>, ComponentError> {
let mut r = vec![0.0; n];
self.compute_residuals(s, &mut r)?;
Ok(r)
};
for &col in &cols {
let h = (state[col].abs() * 1e-6).max(1e-3);
let mut sp = state.to_vec();
sp[col] += h;
let rp = compute(&sp)?;
let mut sm = state.to_vec();
sm[col] -= h;
let rm = compute(&sm)?;
for row in 0..n {
let fd = (rp[row] - rm[row]) / (2.0 * h);
if fd.abs() > 1e-15 {
jacobian.add_entry(row, col, fd);
}
}
}
Ok(())
}
}
impl Component for BphxExchanger {
@@ -540,6 +729,23 @@ impl Component for BphxExchanger {
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if self.uses_phase_change_duty() {
// Skip HeatExchanger sensible ε-NTU (needs refrigerant cp, fails in
// the two-phase dome). Write Shah C*→0 energy + channel ΔP only.
let n = self.n_equations();
if residuals.len() < n {
return Err(ComponentError::InvalidResidualDimensions {
expected: n,
actual: residuals.len(),
});
}
for r in residuals.iter_mut().take(n) {
*r = 0.0;
}
self.overwrite_energy_with_phase_change(state, residuals)?;
self.overwrite_pressure_closures_with_dp(state, residuals)?;
return Ok(());
}
self.inner.compute_residuals(state, residuals)?;
// Replace isobaric P_out P_in with channel ΔP on both sides.
self.overwrite_pressure_closures_with_dp(state, residuals)
@@ -550,6 +756,10 @@ impl Component for BphxExchanger {
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
if self.uses_phase_change_duty() {
// FD must call this Component's residuals (phase-change energy + DP).
return self.fd_jacobian_via_self(state, jacobian);
}
self.inner.jacobian_entries(state, jacobian)?;
// Inner already wrote ∂r/∂P_out=+1, ∂r/∂P_in=1; add ṁ and z_dp terms.
self.append_pressure_dp_jacobian(state, jacobian)
@@ -587,6 +797,80 @@ impl Component for BphxExchanger {
self.inner.energy_transfers(state)
}
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
use crate::MeasuredOutput::*;
let edges = self.inner.four_port_edges()?;
let (m_h, p_h_in, h_h_in) = edges.hot_in;
let (_, p_h_out, h_h_out) = edges.hot_out;
let (m_c, p_c_in, h_c_in) = edges.cold_in;
let (_, _, h_c_out) = edges.cold_out;
let max_idx = [
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, h_c_out,
]
.into_iter()
.max()?;
if max_idx >= state.len() {
return None;
}
// Refrigerant side: hot for a condenser, cold (remapped) for an evaporator.
let (p_ref_in, p_ref_out, h_ref_out, m_ref, ref_fluid) = match self.geometry.exchanger_type
{
BphxType::Condenser => (p_h_in, p_h_out, h_h_out, m_h, self.inner.hot_fluid_id_str()),
BphxType::Evaporator => (p_c_in, p_c_in, h_c_out, m_c, self.inner.cold_fluid_id_str()),
BphxType::Generic => return None,
};
match kind {
SaturationTemperature => self.tsat_k(state[p_ref_in], ref_fluid).ok(),
Superheat | Subcooling => {
let backend = self.fluid_backend.as_ref()?;
let tsat = self.tsat_k(state[p_ref_out], ref_fluid).ok()?;
let t = backend
.property(
FluidId::new(ref_fluid),
Property::Temperature,
FluidState::from_ph(
entropyk_core::Pressure::from_pascals(state[p_ref_out]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
),
)
.ok()?;
if !t.is_finite() || t <= 0.0 {
return None;
}
match kind {
Superheat => Some(t - tsat),
_ => Some(tsat - t),
}
}
Capacity | HeatTransferRate => {
let q_hot = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
let q_cold = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
match (q_hot.is_finite(), q_cold.is_finite()) {
(true, true) => Some(0.5 * (q_hot + q_cold)),
(true, false) => Some(q_hot),
(false, true) => Some(q_cold),
_ => None,
}
}
MassFlowRate => Some(state[m_ref].abs()),
Pressure => Some(state[p_ref_in]),
Temperature => {
let backend = self.fluid_backend.as_ref()?;
backend
.property(
FluidId::new(ref_fluid),
Property::Temperature,
FluidState::from_ph(
entropyk_core::Pressure::from_pascals(state[p_ref_out]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
),
)
.ok()
.filter(|t| t.is_finite() && *t > 0.0)
}
}
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
@@ -983,8 +1267,8 @@ mod tests {
// State layout per port triple (ṁ, P, h):
// hot_in 0..3, hot_out 3..6, cold_in 6..9, cold_out 9..12
let mut hx = BphxExchanger::new(test_geometry())
.with_fluid_backend(Arc::new(TestBackend::new()));
let mut hx =
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
hx.set_hot_fluid("Water");
hx.set_cold_fluid("Water");
hx.set_port_context(&[
@@ -1027,8 +1311,8 @@ mod tests {
);
// z_dp scale: half calib → half residual contribution at same P_out=P_in
let mut hx_half = BphxExchanger::new(test_geometry())
.with_fluid_backend(Arc::new(TestBackend::new()));
let mut hx_half =
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
hx_half.set_hot_fluid("Water");
hx_half.set_cold_fluid("Water");
hx_half.set_port_context(&[
@@ -1078,8 +1362,8 @@ mod tests {
state[10] = 200_000.0;
state[11] = 60_000.0;
let mut simp = BphxExchanger::new(test_geometry())
.with_fluid_backend(Arc::new(TestBackend::new()));
let mut simp =
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
simp.set_hot_fluid("Water");
simp.set_cold_fluid("Water");
simp.set_port_context(&ports);
@@ -1102,4 +1386,110 @@ mod tests {
"Martin residual should differ from Simplified"
);
}
#[test]
fn test_bphx_condenser_energy_uses_phase_change_duty() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
// Condenser geometry → Shah C*→0 path (not sensible ṁ·cp).
let geo = test_geometry().with_exchanger_type(BphxType::Condenser);
let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
hx.set_hot_fluid("R134a");
hx.set_cold_fluid("Water");
hx.set_port_context(&[
Some((0, 1, 2)),
Some((3, 4, 5)),
Some((6, 7, 8)),
Some((9, 10, 11)),
]);
let mut state = vec![0.0; 12];
// Refrigerant ~10 bar, superheated-ish enthalpy table values
state[0] = 0.02;
state[1] = 1_000_000.0;
state[2] = 430_000.0;
state[3] = 0.02;
state[4] = 1_000_000.0;
state[5] = 250_000.0;
// Water secondary ~2 bar, ~30 °C
state[6] = 0.40;
state[7] = 200_000.0;
state[8] = 125_000.0;
state[9] = 0.40;
state[10] = 200_000.0;
state[11] = 140_000.0;
assert!(hx.uses_phase_change_duty());
let n = hx.n_equations();
let mut residuals = vec![0.0; n];
hx.compute_residuals(&state, &mut residuals).unwrap();
let t_sat = hx.tsat_k(state[1], "R134a").unwrap();
let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
let q = condenser_duty(hx.ua(), c_sec, t_sat, t_sec);
let q_hot = state[0] * (state[2] - state[5]);
let expected_r0 = q_hot - q;
assert!(
(residuals[0] - expected_r0).abs() < 1e-4 * expected_r0.abs().max(1.0),
"energy r0 must match condenser_duty: got {} expected {} (Q={}, Tsat={:.2}K)",
residuals[0],
expected_r0,
q,
t_sat
);
// Approach must use Tsat, not gas temperature — Q grows if Tsat rises.
let q_higher = condenser_duty(hx.ua(), c_sec, t_sat + 10.0, t_sec);
assert!(q_higher > q, "higher Tsat must increase condenser duty");
}
#[test]
fn test_bphx_evaporator_energy_uses_phase_change_duty() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let geo = test_geometry().with_exchanger_type(BphxType::Evaporator);
let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
// Same remapping as BphxEvaporator: hot = secondary, cold = refrigerant.
hx.set_hot_fluid("Water");
hx.set_cold_fluid("R134a");
hx.set_port_context(&[
Some((0, 1, 2)),
Some((3, 4, 5)),
Some((6, 7, 8)),
Some((9, 10, 11)),
]);
let mut state = vec![0.0; 12];
// Water (hot) ~12 °C
state[0] = 0.50;
state[1] = 300_000.0;
state[2] = 50_000.0;
state[3] = 0.50;
state[4] = 300_000.0;
state[5] = 40_000.0;
// Refrigerant (cold) ~3.5 bar
state[6] = 0.02;
state[7] = 350_000.0;
state[8] = 250_000.0;
state[9] = 0.02;
state[10] = 350_000.0;
state[11] = 400_000.0;
assert!(hx.uses_phase_change_duty());
let mut residuals = vec![0.0; hx.n_equations()];
hx.compute_residuals(&state, &mut residuals).unwrap();
let t_sat = hx.tsat_k(state[7], "R134a").unwrap();
let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
let q = evaporator_duty(hx.ua(), c_sec, t_sat, t_sec);
let q_cold = state[6] * (state[11] - state[8]);
let expected_r1 = q_cold - q;
assert!(
(residuals[1] - expected_r1).abs() < 1e-4 * expected_r1.abs().max(1.0),
"energy r1 must match evaporator_duty: got {} expected {}",
residuals[1],
expected_r1
);
}
}

View File

@@ -588,6 +588,34 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
})
}
/// Hot-side temperature [K] at `(P, h)` (live backend).
pub(crate) fn hot_side_temperature(
&self,
p_pa: f64,
h_jkg: f64,
) -> Result<f64, ComponentError> {
self.hot_temperature(p_pa, h_jkg)
}
/// Hot-side \(c_p\) [J/(kg·K)] at `(P, h)`.
pub(crate) fn hot_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
self.hot_cp(p_pa, h_jkg)
}
/// Cold-side temperature [K] at `(P, h)`.
pub(crate) fn cold_side_temperature(
&self,
p_pa: f64,
h_jkg: f64,
) -> Result<f64, ComponentError> {
self.cold_temperature(p_pa, h_jkg)
}
/// Cold-side \(c_p\) [J/(kg·K)] at `(P, h)`.
pub(crate) fn cold_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
self.cold_cp(p_pa, h_jkg)
}
fn live_state_required_error(&self) -> ComponentError {
ComponentError::InvalidState(format!(
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",

View File

@@ -72,6 +72,7 @@ pub mod lmtd;
pub mod mchx_condenser_coil;
pub mod model;
pub mod moving_boundary_hx;
pub mod phase_change_entu;
pub mod pool_boiling;
pub mod sat_domain;
pub mod shell_and_tube;
@@ -119,6 +120,9 @@ pub use gas_cooler::{is_supercritical, pettersen_htc, GasCooler, GasCoolerInput}
pub use lmtd::{FlowConfiguration, LmtdModel};
pub use mchx_condenser_coil::MchxCondenserCoil;
pub use model::HeatTransferModel;
pub use phase_change_entu::{
condenser_duty, d_eps_c_d_c, evaporator_duty, phase_change_effectiveness,
};
pub use pool_boiling::{
assess_cooper_domain, cooper_1984, cooper_metadata, flooded_shell_htc, mostinski_1963,
palen_bundle_factor, thome_robinson_oil_factor, ua_from_two_side_htc, PoolBoilingInput,

View File

@@ -0,0 +1,87 @@
//! Phase-change ε-NTU helpers (Shah / Incropera \(C^*\to 0\)).
//!
//! For condensers and evaporators where the refrigerant changes phase at
//! (ideally) constant \(T_{\mathrm{sat}}(P)\), the refrigerant heat-capacity
//! rate approaches infinity. Then \(C_{\min}=C_{\mathrm{sec}}\) and
//! \(\varepsilon = 1 - \exp(-\mathrm{UA}/C_{\mathrm{sec}})\).
//!
//! See Shah & Sekulić, *Fundamentals of Heat Exchanger Design* (Wiley 2003), §3.3.2.
/// Effectiveness for a single-stream (phase-change) exchanger:
/// \(\varepsilon = 1 - \exp(-\mathrm{UA}/C_{\mathrm{sec}})\).
#[inline]
pub fn phase_change_effectiveness(ua: f64, c_sec: f64) -> f64 {
if c_sec <= 1e-10 || ua <= 0.0 {
return 0.0;
}
1.0 - (-ua / c_sec).exp()
}
/// \(g'(C)\) where \(g(C)=C\cdot\varepsilon(C)=C\cdot(1-e^{-\mathrm{UA}/C})\).
///
/// Used for \(\partial Q/\partial\dot{m}_{\mathrm{sec}}\).
#[inline]
pub fn d_eps_c_d_c(ua: f64, c_sec: f64) -> f64 {
if c_sec <= 1e-10 || ua <= 0.0 {
return 0.0;
}
let e = (-ua / c_sec).exp();
(1.0 - e) - (ua / c_sec) * e
}
/// Condenser duty [W]: \(Q = \varepsilon\,C_{\mathrm{sec}}\,(T_{\mathrm{sat}}-T_{\mathrm{sec,in}})\).
///
/// Positive \(Q\) = heat rejected by the refrigerant into the secondary.
#[inline]
pub fn condenser_duty(ua: f64, c_sec: f64, t_sat_k: f64, t_sec_in_k: f64) -> f64 {
let eps = phase_change_effectiveness(ua, c_sec);
eps * c_sec * (t_sat_k - t_sec_in_k)
}
/// Evaporator duty [W]: \(Q = \varepsilon\,C_{\mathrm{sec}}\,(T_{\mathrm{sec,in}}-T_{\mathrm{sat}})\).
///
/// Positive \(Q\) = heat absorbed by the refrigerant from the secondary.
#[inline]
pub fn evaporator_duty(ua: f64, c_sec: f64, t_sat_k: f64, t_sec_in_k: f64) -> f64 {
let eps = phase_change_effectiveness(ua, c_sec);
eps * c_sec * (t_sec_in_k - t_sat_k)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effectiveness_cr_zero_matches_textbook() {
let ua = 2500.0;
let c_sec = 0.4 * 4180.0;
let eps = phase_change_effectiveness(ua, c_sec);
let expected = 1.0 - (-ua / c_sec).exp();
assert!((eps - expected).abs() < 1e-12);
assert!(eps > 0.7 && eps < 1.0);
}
#[test]
fn condenser_duty_zero_when_tsat_equals_tsec() {
let q = condenser_duty(5000.0, 1000.0, 303.15, 303.15);
assert!(q.abs() < 1e-9);
}
#[test]
fn evaporator_duty_positive_when_water_warmer_than_tsat() {
let q = evaporator_duty(2000.0, 2000.0, 278.15, 285.15);
assert!(q > 0.0);
}
#[test]
fn d_eps_c_matches_fd() {
let ua = 3000.0;
let c = 1500.0;
let h = 1e-3 * c;
let gp = (c + h) * phase_change_effectiveness(ua, c + h);
let gm = (c - h) * phase_change_effectiveness(ua, c - h);
let fd = (gp - gm) / (2.0 * h);
let an = d_eps_c_d_c(ua, c);
assert!((an - fd).abs() / fd.abs().max(1e-9) < 1e-5);
}
}

View File

@@ -97,6 +97,7 @@ pub mod params;
pub mod pipe;
pub mod polynomials;
pub mod port;
pub mod probe;
pub mod pump;
pub mod python_components;
pub mod refrigerant_boundary;
@@ -150,6 +151,7 @@ pub use port::{
validate_port_continuity, Connected, ConnectedPort, ConnectionError, Disconnected, FluidId,
Port, PortKind,
};
pub use probe::{Probe, ProbeMeasure};
pub use pump::{Pump, PumpCurves};
pub use python_components::{
PyAirSinkReal, PyAirSourceReal, PyBrineSinkReal, PyBrineSourceReal, PyCompressorReal,

View File

@@ -0,0 +1,907 @@
//! Probe — zero-residual measurement tap on a connection line.
//!
//! Per the calibration redesign (see
//! `_bmad-output/implementation-artifacts/calibration-probe-node-plain-embedding.md`),
//! **every** calibration measurement lives on a `Probe` node the user drops
//! onto a wire. The calibrated z-factor (`z_ua`, `z_dp`, `z_flow`, ...) on a
//! component is freed, and a `Probe` elsewhere on the graph supplies the
//! matching measurement via its [`Component::measure_output`] override.
//!
//! ## Design
//!
//! - **Zero residual, zero Jacobian** — `n_equations() == 0`. The Probe is a
//! measurement tap, not a physical element. The calibration `Constraint`
//! (one residual, one unknown z-factor) closes the system.
//! - **Two ports `inlet`/`outlet`** — same typestate pattern as `Pipe`/`Pump`/
//! `Node`. The Probe splices into an edge A→B (becomes A→Probe→B) exactly
//! like `Pipe` does in `apps/web/src/lib/edgeInsert.ts`, so the UI drop-on-
//! wire interaction reuses `insertOnEdge`.
//! - **Reads live edge state** via [`Component::set_system_context`]. The
//! Probe stores the `(ṁ, P, h)` triples for both its incident edges (the
//! two halves of the spliced wire) and uses them in `measure_output`.
//!
//! ## Measure kinds
//!
//! [`ProbeMeasure`] is the exhaustive list the user named. SST/SDT/DGT/DSH/
//! SH/SC all derive from `(P, T)` on the same edge via CoolProp — the user
//! picks the semantic kind for clarity, the math is P/T/Tsat:
//!
//! | Kind | Formula |
//! |------|---------|
//! | [`Sst`](ProbeMeasure::Sst) / [`Sdt`](ProbeMeasure::Sdt) | `Tsat(P_edge)` |
//! | [`Dgt`](ProbeMeasure::Dgt) / [`T`](ProbeMeasure::T) | `T_edge` |
//! | [`Dsh`](ProbeMeasure::Dsh) / [`Sh`](ProbeMeasure::Sh) | `T_edge Tsat(P_edge)` |
//! | [`Sc`](ProbeMeasure::Sc) | `Tsat(P_edge) T_edge` |
//! | [`P`](ProbeMeasure::P) | `P_edge` |
//! | [`MassFlow`](ProbeMeasure::MassFlow) | `ṁ_edge` |
//! | [`Enthalpy`](ProbeMeasure::Enthalpy) | `h_edge` |
//! | [`Capacity`](ProbeMeasure::Capacity) | `ṁ_edge · |h_inlet h_outlet|` |
//!
//! `Tsat(P)` reuses the `tsat_k` pattern from
//! `crates/components/src/heat_exchanger/bphx_exchanger.rs:543-559` (clamps
//! the query pressure into the detected saturation domain via
//! `sat_domain::clamp_to_saturation_domain`).
use crate::heat_exchanger::sat_domain;
use crate::port::{Connected, Disconnected, Port};
use crate::state_machine::StateManageable;
use crate::{
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput,
OperationalState, ResidualVector, StateSlice,
};
use entropyk_core::{CalibIndices, MassFlow, Power};
use entropyk_fluids::{FluidBackend, FluidId as BackendFluidId, FluidState, Property, Quality};
use std::marker::PhantomData;
use std::sync::Arc;
/// Semantic quantity a [`Probe`] extracts from its edge.
///
/// Exhaustive list per the user mandate (see module docs). SST/SDT and DSH/SH
/// share formulas by design — the user picks the label that matches the
/// physical sensor location (suction vs discharge).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProbeMeasure {
/// Saturated suction temperature = `Tsat(P_edge)`.
Sst,
/// Saturated discharge temperature = `Tsat(P_edge)` (same formula, label only).
Sdt,
/// Discharge gas temperature = `T_edge` (raw temperature at the probe).
Dgt,
/// Discharge superheat = `T_edge Tsat(P_edge)`.
Dsh,
/// (Suction) superheat = `T_edge Tsat(P_edge)` (same formula as Dsh).
Sh,
/// Subcooling = `Tsat(P_edge) T_edge`.
Sc,
/// Raw temperature = `T_edge`.
T,
/// Raw pressure = `P_edge`.
P,
/// Mass flow rate = `ṁ_edge`.
MassFlow,
/// Heat duty = `ṁ · |Δh|` across the probe (see [`Probe`] module docs).
Capacity,
/// Raw specific enthalpy = `h_edge`.
Enthalpy,
}
impl ProbeMeasure {
/// Parses a measure-kind string from the JSON config (case-insensitive).
/// Accepts the canonical names plus a few common aliases.
pub fn parse(s: &str) -> Result<Self, String> {
let lower = s.trim().to_ascii_lowercase();
let m = match lower.as_str() {
"sst" => ProbeMeasure::Sst,
"sdt" => ProbeMeasure::Sdt,
"dgt" => ProbeMeasure::Dgt,
"dsh" => ProbeMeasure::Dsh,
"sh" | "superheat" => ProbeMeasure::Sh,
"sc" | "subcooling" => ProbeMeasure::Sc,
"t" | "temperature" => ProbeMeasure::T,
"p" | "pressure" => ProbeMeasure::P,
"massflow" | "mass_flow" | "mass flow" | "massflowrate" | "mass_flow_rate" => {
ProbeMeasure::MassFlow
}
"capacity" | "heatduty" | "heat_transfer_rate" | "heatrate" | "duty" => {
ProbeMeasure::Capacity
}
"enthalpy" | "h" => ProbeMeasure::Enthalpy,
other => {
return Err(format!(
"unknown Probe measure '{other}' (expected one of: SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy)"
))
}
};
Ok(m)
}
/// Returns the canonical JSON name for this measure (round-trips through `parse`).
pub fn as_str(&self) -> &'static str {
match self {
ProbeMeasure::Sst => "SST",
ProbeMeasure::Sdt => "SDT",
ProbeMeasure::Dgt => "DGT",
ProbeMeasure::Dsh => "DSH",
ProbeMeasure::Sh => "SH",
ProbeMeasure::Sc => "SC",
ProbeMeasure::T => "T",
ProbeMeasure::P => "P",
ProbeMeasure::MassFlow => "MassFlow",
ProbeMeasure::Capacity => "Capacity",
ProbeMeasure::Enthalpy => "Enthalpy",
}
}
/// Maps this measure to the solver-side [`MeasuredOutput`] the calibration
/// constraint will ask for. SST/SDT → `SaturationTemperature`; DSH/SH →
/// `Superheat`; SC → `Subcooling`; DGT/T → `Temperature`; P → `Pressure`;
/// MassFlow → `MassFlowRate`; Capacity → `Capacity`; Enthalpy →
/// `HeatTransferRate` is NOT right, so Enthalpy returns `Temperature` as a
/// placeholder (the Probe still serves it via `measure_output(Temperature)`
/// returning T, and Enthalpy is read directly via the measure-output hook
/// below — see [`Probe::measure_output`] which honours the configured kind).
pub fn to_measured_output(&self) -> MeasuredOutput {
match self {
ProbeMeasure::Sst | ProbeMeasure::Sdt => MeasuredOutput::SaturationTemperature,
ProbeMeasure::Dsh | ProbeMeasure::Sh => MeasuredOutput::Superheat,
ProbeMeasure::Sc => MeasuredOutput::Subcooling,
ProbeMeasure::Dgt | ProbeMeasure::T => MeasuredOutput::Temperature,
ProbeMeasure::P => MeasuredOutput::Pressure,
ProbeMeasure::MassFlow => MeasuredOutput::MassFlowRate,
ProbeMeasure::Capacity => MeasuredOutput::Capacity,
// No dedicated Enthalpy variant in MeasuredOutput — collapse onto
// HeatTransferRate so the constraint resolves; measure_output
// detects the configured Enthalpy kind and returns h_edge.
ProbeMeasure::Enthalpy => MeasuredOutput::HeatTransferRate,
}
}
}
/// A zero-residual measurement tap on a connection line.
///
/// See the module docs for the calibration redesign context.
///
/// Like `Pipe` and `Pump`, `Probe` uses a typestate (`Disconnected` →
/// `Connected`) so the compiler enforces port connection before the component
/// is added to a system.
#[derive(Clone)]
pub struct Probe<State> {
measure: ProbeMeasure,
fluid_id_str: String,
port_inlet: Port<State>,
port_outlet: Port<State>,
fluid_backend: Option<Arc<dyn FluidBackend>>,
circuit_id: CircuitId,
operational_state: OperationalState,
calib_indices: CalibIndices,
// Live edge-state indices populated by `set_system_context`.
inlet_m_idx: Option<usize>,
inlet_p_idx: Option<usize>,
inlet_h_idx: Option<usize>,
outlet_m_idx: Option<usize>,
outlet_p_idx: Option<usize>,
outlet_h_idx: Option<usize>,
_state: PhantomData<State>,
}
impl<State> std::fmt::Debug for Probe<State> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Probe")
.field("measure", &self.measure)
.field("fluid", &self.fluid_id_str)
.field("has_backend", &self.fluid_backend.is_some())
.field("circuit_id", &self.circuit_id)
.field("operational_state", &self.operational_state)
.finish()
}
}
impl Probe<Disconnected> {
/// Creates a new disconnected Probe.
///
/// `fluid` is used for `Tsat`/`SH`/`SC` derivation via the configured
/// fluid backend. The backend itself is injected later by
/// [`Component::set_fluid_backend_from_builder`] when the system is built.
pub fn new(
measure: ProbeMeasure,
fluid: impl Into<String>,
port_inlet: Port<Disconnected>,
port_outlet: Port<Disconnected>,
) -> Self {
Self {
measure,
fluid_id_str: fluid.into(),
port_inlet,
port_outlet,
fluid_backend: None,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
calib_indices: CalibIndices::default(),
inlet_m_idx: None,
inlet_p_idx: None,
inlet_h_idx: None,
outlet_m_idx: None,
outlet_p_idx: None,
outlet_h_idx: None,
_state: PhantomData,
}
}
/// Attaches a fluid backend for property queries (Tsat, T from P,h).
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.fluid_backend = Some(backend);
self
}
/// Connects the Probe to its inlet and outlet ports, transitioning to
/// `Probe<Connected>`. Mirrors `Pipe::connect` / `Pump::connect`.
pub fn connect(
self,
inlet: Port<Disconnected>,
outlet: Port<Disconnected>,
) -> Result<Probe<Connected>, ComponentError> {
let (p_in, _) = self
.port_inlet
.connect(inlet)
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
let (p_out, _) = self
.port_outlet
.connect(outlet)
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
Ok(Probe {
measure: self.measure,
fluid_id_str: self.fluid_id_str,
port_inlet: p_in,
port_outlet: p_out,
fluid_backend: self.fluid_backend,
circuit_id: self.circuit_id,
operational_state: self.operational_state,
calib_indices: self.calib_indices,
inlet_m_idx: self.inlet_m_idx,
inlet_p_idx: self.inlet_p_idx,
inlet_h_idx: self.inlet_h_idx,
outlet_m_idx: self.outlet_m_idx,
outlet_p_idx: self.outlet_p_idx,
outlet_h_idx: self.outlet_h_idx,
_state: PhantomData,
})
}
}
impl Probe<Connected> {
/// Returns the configured measure kind.
pub fn measure(&self) -> ProbeMeasure {
self.measure
}
/// Returns the fluid identifier string the Probe derives properties for.
pub fn fluid_id_str(&self) -> &str {
&self.fluid_id_str
}
/// Returns the inlet port.
pub fn port_inlet(&self) -> &Port<Connected> {
&self.port_inlet
}
/// Returns the outlet port.
pub fn port_outlet(&self) -> &Port<Connected> {
&self.port_outlet
}
/// Returns both ports as a slice.
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
[&self.port_inlet, &self.port_outlet]
}
/// \(T_{\mathrm{sat}}(P)\) [K] for the configured fluid, domain-clamped.
/// Mirrors `BphxExchanger::tsat_k` (`bphx_exchanger.rs:544-559`).
fn tsat_k(&self, p_pa: f64) -> Result<f64, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"Probe: FluidBackend required for Tsat/SH/SC derivation".into(),
)
})?;
let p_pa =
sat_domain::clamp_to_saturation_domain(backend, &self.fluid_id_str, p_pa).unwrap_or(p_pa);
backend
.property(
BackendFluidId::new(&self.fluid_id_str),
Property::Temperature,
FluidState::from_px(
entropyk_core::Pressure::from_pascals(p_pa),
Quality::new(0.5),
),
)
.map_err(ComponentError::from_fluid_error)
}
/// Temperature at the probe edge from `(P, h)` via the fluid backend.
fn temperature_k(&self, p_pa: f64, h_j_kg: f64) -> Result<f64, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"Probe: FluidBackend required for temperature derivation".into(),
)
})?;
let t = backend
.property(
BackendFluidId::new(&self.fluid_id_str),
Property::Temperature,
FluidState::from_ph(
entropyk_core::Pressure::from_pascals(p_pa),
entropyk_core::Enthalpy::from_joules_per_kg(h_j_kg),
),
)
.map_err(ComponentError::from_fluid_error)?;
if !t.is_finite() || t <= 0.0 {
return Err(ComponentError::CalculationFailed(format!(
"Probe: non-physical temperature {t} for P={p_pa} Pa, h={h_j_kg} J/kg"
)));
}
Ok(t)
}
/// Reads `(ṁ, P, h)` of the inlet edge from the global state, or `None`
/// when the indices have not been wired yet.
fn inlet_state(&self, state: &StateSlice) -> Option<(f64, f64, f64)> {
let (m, p, h) = (
self.inlet_m_idx?,
self.inlet_p_idx?,
self.inlet_h_idx?,
);
if m >= state.len() || p >= state.len() || h >= state.len() {
return None;
}
Some((state[m], state[p], state[h]))
}
/// Reads the outlet-edge `(P, h)` from the global state, or `None`.
fn outlet_state(&self, state: &StateSlice) -> Option<(f64, f64)> {
let (p, h) = (self.outlet_p_idx?, self.outlet_h_idx?);
if p >= state.len() || h >= state.len() {
return None;
}
Some((state[p], state[h]))
}
}
impl Component for Probe<Connected> {
fn set_system_context(
&mut self,
_state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
// The Probe is a 2-port splice; layout mirrors Pipe/Pump:
// [0] = incoming edge (upstream→probe), [1] = outgoing edge (probe→downstream).
// The Probe measures at its location; for SST/SDT/DGT/DSH/SH/SC/T/P we
// use the inlet edge (the edge the user dropped the probe onto). For
// Capacity we use both edges (ṁ·|Δh| across the splice).
if let Some(&(m, p, h)) = external_edge_state_indices.first() {
self.inlet_m_idx = Some(m);
self.inlet_p_idx = Some(p);
self.inlet_h_idx = Some(h);
}
if let Some(&(m, p, h)) = external_edge_state_indices.get(1) {
self.outlet_m_idx = Some(m);
self.outlet_p_idx = Some(p);
self.outlet_h_idx = Some(h);
}
}
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Two topology residuals any 2-port splice must impose to keep the
// DoF balance closed (mirrors `Anchor` and edge-coupled `Pipe`):
// r0 = P_out P_in (zero-resistance pressure pass-through)
// r1 = h_out h_in (adiabatic enthalpy pass-through)
// These are NOT calibration residuals — they are the continuity that
// makes the Probe a transparent tap. The calibration `Constraint`
// (measure target) supplies the only calibration-related residual.
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: 2,
actual: residuals.len(),
});
}
let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
self.inlet_p_idx,
self.inlet_h_idx,
self.outlet_p_idx,
self.outlet_h_idx,
) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => {
return Err(ComponentError::InvalidState(
"Probe requires live inlet and outlet edge state indices".to_string(),
));
}
};
let max_idx = in_p_idx.max(in_h_idx).max(out_p_idx).max(out_h_idx);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
residuals[0] = state[out_p_idx] - state[in_p_idx];
residuals[1] = state[out_h_idx] - state[in_h_idx];
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// ∂r0/∂P_out = +1, ∂r0/∂P_in = 1 ; ∂r1/∂h_out = +1, ∂r1/∂h_in = 1
let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
self.inlet_p_idx,
self.inlet_h_idx,
self.outlet_p_idx,
self.outlet_h_idx,
) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => {
return Err(ComponentError::InvalidState(
"Probe Jacobian requires live inlet and outlet edge state indices".to_string(),
));
}
};
jacobian.add_entry(0, out_p_idx, 1.0);
jacobian.add_entry(0, in_p_idx, -1.0);
jacobian.add_entry(1, out_h_idx, 1.0);
jacobian.add_entry(1, in_h_idx, -1.0);
Ok(())
}
fn equation_roles(&self) -> Vec<crate::EquationRole> {
vec![
crate::EquationRole::Continuity { quantity: "P" },
crate::EquationRole::Continuity { quantity: "h" },
]
}
fn n_equations(&self) -> usize {
// 2 topology-continuity residuals (P, h) — see `compute_residuals`.
// NOT a calibration residual: the calibration Constraint supplies that.
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_names(&self) -> Vec<String> {
vec!["inlet".to_string(), "outlet".to_string()]
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
// Single series path through the tap.
vec![(0, 1)]
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<MassFlow>, ComponentError> {
let m_in = match self.inlet_m_idx {
Some(idx) if idx < state.len() => state[idx],
_ => 0.0,
};
let m_out = match self.outlet_m_idx {
Some(idx) if idx < state.len() => state[idx],
_ => m_in,
};
Ok(vec![
MassFlow::from_kg_per_s(m_in),
MassFlow::from_kg_per_s(-m_out),
])
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
let h_in = match self.inlet_h_idx {
Some(idx) if idx < state.len() => state[idx],
_ => self.port_inlet.enthalpy().to_joules_per_kg(),
};
let h_out = match self.outlet_h_idx {
Some(idx) if idx < state.len() => state[idx],
_ => self.port_outlet.enthalpy().to_joules_per_kg(),
};
Ok(vec![
entropyk_core::Enthalpy::from_joules_per_kg(h_in),
entropyk_core::Enthalpy::from_joules_per_kg(h_out),
])
}
fn set_calib_indices(&mut self, indices: CalibIndices) {
// A Probe owns no z-factors; the slot is kept only for trait
// compatibility with the System finalize path.
self.calib_indices = indices;
}
fn set_fluid_backend_from_builder(
&mut self,
backend: Arc<dyn FluidBackend>,
) {
if self.fluid_backend.is_none() {
self.fluid_backend = Some(Arc::clone(&backend));
}
}
fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
// A measurement tap is adiabatic.
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
}
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
// Resolve which edge we measure on. SST/SDT use the inlet edge's
// pressure; DGT/T/P/MassFlow/Enthalpy also use the inlet edge (the
// physical sensor location). For DSH/SH/SC we need (P, T) on the same
// edge. For Capacity we use ṁ and |Δh| across the splice.
let (m_in, p_in, h_in) = self.inlet_state(state)?;
// When the configured measure is Enthalpy, return h_edge regardless of
// the `kind` the constraint asks for (we map Enthalpy →
// HeatTransferRate at the routing layer because MeasuredOutput has no
// Enthalpy variant).
if self.measure == ProbeMeasure::Enthalpy {
return Some(h_in);
}
match kind {
MeasuredOutput::SaturationTemperature => Some(self.tsat_k(p_in).ok()?),
MeasuredOutput::Pressure => Some(p_in),
MeasuredOutput::MassFlowRate => Some(m_in.abs()),
MeasuredOutput::Temperature => {
// DGT/T → raw temperature at the probe. Falls back to None
// when the backend is unavailable or returns a non-physical T.
self.temperature_k(p_in, h_in).ok()
}
MeasuredOutput::Superheat | MeasuredOutput::Subcooling => {
let tsat = self.tsat_k(p_in).ok()?;
let t = self.temperature_k(p_in, h_in).ok()?;
if !t.is_finite() || t <= 0.0 {
return None;
}
match kind {
MeasuredOutput::Superheat => Some(t - tsat),
_ => Some(tsat - t),
}
}
MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => {
// Capacity = ṁ · |Δh| across the probe splice. Uses both edges
// when available; if the outlet index isn't wired (defensive),
// falls back to zero duty.
if let Some((_, h_out)) = self.outlet_state(state) {
let dh = (h_in - h_out).abs();
let q = m_in.abs() * dh;
if q.is_finite() {
return Some(q);
}
}
Some(0.0)
}
}
}
fn signature(&self) -> String {
format!(
"Probe(measure={}, fluid={}, circuit={})",
self.measure.as_str(),
self.fluid_id_str,
self.circuit_id.0
)
}
fn to_params(&self) -> crate::ComponentParams {
crate::ComponentParams::new("Probe")
.with_param("measure", self.measure.as_str().to_string())
.with_param("fluid", self.fluid_id_str.clone())
.with_param("circuitId", self.circuit_id.0)
}
}
impl StateManageable for Probe<Connected> {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
self.operational_state = state;
Ok(())
} else {
Err(ComponentError::InvalidStateTransition {
from: self.operational_state,
to: state,
reason: "Transition not allowed".to_string(),
})
}
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.operational_state.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::port::FluidId;
use entropyk_core::{Enthalpy, Pressure};
use entropyk_fluids::TestBackend;
fn build_probe(measure: ProbeMeasure) -> Probe<Connected> {
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
let mk = || {
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(3.0),
Enthalpy::from_joules_per_kg(400_000.0),
)
};
let probe_disc = Probe::new(measure, "R134a", mk(), mk()).with_fluid_backend(backend);
// The connect() call needs a second pair of ports (mirrors Pipe/Pump
// CLI idiom — both pairs carry identical nominal values).
probe_disc.connect(mk(), mk()).expect("probe connect")
}
/// Helper: equip a Probe with live edge indices pointing at a synthetic
/// state vector `[ṁ_in, P_in, h_in, ṁ_out, P_out, h_out]`.
fn wire_state_indices(probe: &mut Probe<Connected>) {
probe.inlet_m_idx = Some(0);
probe.inlet_p_idx = Some(1);
probe.inlet_h_idx = Some(2);
probe.outlet_m_idx = Some(3);
probe.outlet_p_idx = Some(4);
probe.outlet_h_idx = Some(5);
}
#[test]
fn probe_imposes_continuity_only() {
// The Probe is a zero-resistance tap: it contributes 2 topology
// residuals (P_out = P_in, h_out = h_in) so the spliced edge stays
// DoF-balanced. It does NOT contribute a calibration residual —
// the calibration Constraint (measure target) supplies that.
let probe = build_probe(ProbeMeasure::Sh);
assert_eq!(probe.n_equations(), 2);
let roles = probe.equation_roles();
assert_eq!(roles.len(), 2);
// Continuity residuals: inlet edge state (ṁ=0.1, P=5e5, h=300e3),
// outlet edge state (ṁ=0.1, P=5e5, h=300e3) → r0 = 0, r1 = 0.
let mut probe = build_probe(ProbeMeasure::Sh);
wire_state_indices(&mut probe);
let state = vec![0.1, 5.0e5, 300_000.0, 0.1, 5.0e5, 300_000.0];
let mut residuals = vec![0.0; 2];
probe
.compute_residuals(&state, &mut residuals)
.expect("residuals compute");
assert!(residuals[0].abs() < 1e-9, "P continuity: {}", residuals[0]);
assert!(residuals[1].abs() < 1e-9, "h continuity: {}", residuals[1]);
// Mismatched inlet/outlet → non-zero continuity residuals.
let state2 = vec![0.1, 5.0e5, 300_000.0, 0.1, 4.0e5, 290_000.0];
probe
.compute_residuals(&state2, &mut residuals)
.expect("residuals compute");
assert!((residuals[0] - (-1.0e5)).abs() < 1e-3);
assert!((residuals[1] - (-10_000.0)).abs() < 1e-6);
// Jacobian: ∂r0/∂P_out=+1, ∂r0/∂P_in=1, ∂r1/∂h_out=+1, ∂r1/∂h_in=1.
let mut jac = JacobianBuilder::new();
probe
.jacobian_entries(&state, &mut jac)
.expect("jacobian computes");
let entries = jac.entries();
assert!(entries.contains(&(0, 4, 1.0)), "missing ∂r0/∂P_out");
assert!(entries.contains(&(0, 1, -1.0)), "missing ∂r0/∂P_in");
assert!(entries.contains(&(1, 5, 1.0)), "missing ∂r1/∂h_out");
assert!(entries.contains(&(1, 2, -1.0)), "missing ∂r1/∂h_in");
}
#[test]
fn probe_port_names() {
let probe = build_probe(ProbeMeasure::Sh);
assert_eq!(probe.port_names(), vec!["inlet", "outlet"]);
assert_eq!(probe.flow_paths(), vec![(0, 1)]);
}
#[test]
fn probe_measure_parse_round_trip() {
for kind in [
ProbeMeasure::Sst,
ProbeMeasure::Sdt,
ProbeMeasure::Dgt,
ProbeMeasure::Dsh,
ProbeMeasure::Sh,
ProbeMeasure::Sc,
ProbeMeasure::T,
ProbeMeasure::P,
ProbeMeasure::MassFlow,
ProbeMeasure::Capacity,
ProbeMeasure::Enthalpy,
] {
let parsed = ProbeMeasure::parse(kind.as_str()).expect("parses");
assert_eq!(parsed, kind, "round-trip for {:?}", kind);
}
}
#[test]
fn probe_measure_parse_rejects_unknown() {
assert!(ProbeMeasure::parse("NOPE").is_err());
assert!(ProbeMeasure::parse("").is_err());
}
#[test]
fn probe_measure_parse_accepts_aliases() {
assert_eq!(
ProbeMeasure::parse("superheat").unwrap(),
ProbeMeasure::Sh
);
assert_eq!(
ProbeMeasure::parse("mass_flow_rate").unwrap(),
ProbeMeasure::MassFlow
);
assert_eq!(
ProbeMeasure::parse("subcooling").unwrap(),
ProbeMeasure::Sc
);
}
#[test]
fn probe_returns_none_without_state_indices() {
// No `set_system_context` call → indices are None → measure_output
// must return None gracefully instead of panicking.
let probe = build_probe(ProbeMeasure::Sh);
let state = vec![0.0; 6];
assert!(probe
.measure_output(MeasuredOutput::Superheat, &state)
.is_none());
assert!(probe
.measure_output(MeasuredOutput::Pressure, &state)
.is_none());
}
/// Sanity-check the Tsat derivation against TestBackend's R134a table at a
/// known pressure. R134a at 3 bar saturated → Tsat ≈ 273.4 K on TestBackend
/// (the table is approximate; we just assert the value is in a sane band).
#[test]
fn probe_saturation_temperature_in_sane_band() {
let mut probe = build_probe(ProbeMeasure::Sst);
wire_state_indices(&mut probe);
// 3 bar, h whatever — Tsat only uses P.
let state = vec![0.05, 3.0e5, 400_000.0, 0.05, 3.0e5, 400_000.0];
let tsat = probe
.measure_output(MeasuredOutput::SaturationTemperature, &state)
.expect("Tsat must resolve");
assert!(
(250.0..=320.0).contains(&tsat),
"Tsat out of sane band: {tsat}"
);
}
#[test]
fn probe_pressure_and_mass_flow_passthrough() {
let mut probe = build_probe(ProbeMeasure::P);
wire_state_indices(&mut probe);
let state = vec![0.42, 5.0e5, 250_000.0, 0.42, 5.0e5, 250_000.0];
let p = probe
.measure_output(MeasuredOutput::Pressure, &state)
.unwrap();
assert!((p - 5.0e5).abs() < 1e-6);
probe.measure = ProbeMeasure::MassFlow;
let m = probe
.measure_output(MeasuredOutput::MassFlowRate, &state)
.unwrap();
assert!((m - 0.42).abs() < 1e-9);
}
#[test]
fn probe_enthalpy_kind_returns_h_edge() {
let mut probe = build_probe(ProbeMeasure::Enthalpy);
wire_state_indices(&mut probe);
let state = vec![0.1, 3.0e5, 412_300.0, 0.1, 3.0e5, 412_300.0];
// Enthalpy maps to HeatTransferRate in to_measured_output, but the
// measure_output override detects the configured Enthalpy kind and
// returns h_edge regardless.
let h = probe
.measure_output(MeasuredOutput::HeatTransferRate, &state)
.unwrap();
assert!((h - 412_300.0).abs() < 1e-6);
}
#[test]
fn probe_capacity_is_mass_flow_times_delta_h() {
let mut probe = build_probe(ProbeMeasure::Capacity);
wire_state_indices(&mut probe);
// Inlet h = 420 kJ/kg, outlet h = 400 kJ/kg, ṁ = 0.5 kg/s → Q = 10 kW.
let state = vec![0.5, 3.0e5, 420_000.0, 0.5, 3.0e5, 400_000.0];
let q = probe
.measure_output(MeasuredOutput::Capacity, &state)
.unwrap();
assert!((q - 10_000.0).abs() < 1e-6, "expected 10 kW, got {q}");
}
#[test]
fn probe_superheat_subcooling_signs() {
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
let mk = || {
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(3.0),
Enthalpy::from_joules_per_kg(400_000.0),
)
};
// Probe with SH measure: should be T_edge Tsat(P_edge).
let mut sh_probe = Probe::new(ProbeMeasure::Sh, "R134a", mk(), mk())
.with_fluid_backend(Arc::clone(&backend))
.connect(mk(), mk())
.unwrap();
wire_state_indices(&mut sh_probe);
let tsat = sh_probe.tsat_k(3.0e5).expect("tsat resolves");
// Pick h that gives a known T via TestBackend. We can't know T without
// querying, so just assert SH and SC are negatives of each other for
// the same (P, h).
let state = vec![0.1, 3.0e5, 430_000.0, 0.1, 3.0e5, 430_000.0];
let sh = sh_probe
.measure_output(MeasuredOutput::Superheat, &state)
.expect("SH resolves");
let mut sc_probe = Probe::new(ProbeMeasure::Sc, "R134a", mk(), mk())
.with_fluid_backend(Arc::clone(&backend))
.connect(mk(), mk())
.unwrap();
wire_state_indices(&mut sc_probe);
let sc = sc_probe
.measure_output(MeasuredOutput::Subcooling, &state)
.expect("SC resolves");
assert!(
(sh + sc).abs() < 1e-6,
"SH and SC must be opposite signs for same (P,h): SH={sh}, SC={sc}, tsat={tsat}"
);
}
#[test]
fn probe_to_measured_output_mapping_is_exhaustive() {
// Every variant maps to a non-panic MeasuredOutput.
for kind in [
ProbeMeasure::Sst,
ProbeMeasure::Sdt,
ProbeMeasure::Dgt,
ProbeMeasure::Dsh,
ProbeMeasure::Sh,
ProbeMeasure::Sc,
ProbeMeasure::T,
ProbeMeasure::P,
ProbeMeasure::MassFlow,
ProbeMeasure::Capacity,
ProbeMeasure::Enthalpy,
] {
let _ = kind.to_measured_output();
}
}
#[test]
fn probe_signature_and_params_include_measure_and_fluid() {
let probe = build_probe(ProbeMeasure::Sst);
let sig = probe.signature();
assert!(sig.contains("SST"), "signature: {sig}");
assert!(sig.contains("R134a"));
let params = probe.to_params();
assert_eq!(params.component_type, "Probe");
}
}

View File

@@ -115,10 +115,11 @@ pub use entropyk_components::{
IncompressibleSplitter, IsenthalpicExpansionValve, IsentropicCompressor, JacobianBuilder,
LmtdModel, MchxCondenserCoil, MockExternalModel, Node, NodeMeasurements, NodePhase,
OperationalState, PerformanceCurves, PhaseRegion, Pipe, PipeGeometry, Polynomial1D,
Polynomial2D, Pump, PumpCurves, RefrigerantSink, RefrigerantSource, RegistryError,
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, ShellAndTubeHx,
SstSdtCoefficients, StateHistory, StateManageable, StateTransitionError, SystemState,
ThermalLoad, ThreadSafeExternalModel, UaMode, ValveCharacteristics, ValveFlowModel,
Polynomial2D, Probe, ProbeMeasure, Pump, PumpCurves, RefrigerantSink, RefrigerantSource,
RegistryError, ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves,
ShellAndTubeHx, SstSdtCoefficients, StateHistory, StateManageable, StateTransitionError,
SystemState, ThermalLoad, ThreadSafeExternalModel, UaMode, ValveCharacteristics,
ValveFlowModel,
};
pub use entropyk_components::{ReversingMode, ReversingValve};
@@ -147,11 +148,11 @@ pub use entropyk_solver::{
AddEdgeError, AntoineCoefficients, CircuitConvergence, CircuitId as SolverCircuitId,
ComponentOutput, Constraint, ConstraintError, ConstraintId, ConvergedState,
ConvergenceCriteria, ConvergenceReason, ConvergenceReport, ConvergenceStatus, CyclePerformance,
DomainViolation, FallbackConfig, FallbackSolver, FlowEdge, HomotopyConfig, InitializerConfig,
InitializerError, JacobianFreezingConfig, JacobianMatrix, LinearSolver, MacroComponent,
MacroComponentSnapshot, NalgebraLuSolver, NewtonConfig, PicardConfig, PortMapping,
SmartInitializer, SolveOutcome, Solver, SolverError, SolverStrategy, System, ThermalCoupling,
TimeoutConfig, TopologyError,
DomainViolation, FaerLuSolver, FallbackConfig, FallbackSolver, FlowEdge, HomotopyConfig,
InitializerConfig, InitializerError, JacobianFreezingConfig, JacobianMatrix, LinearSolver,
MacroComponent, MacroComponentSnapshot, NalgebraLuSolver, NewtonConfig, PicardConfig,
PortMapping, SmartInitializer, SolveOutcome, Solver, SolverError, SolverStrategy, System,
ThermalCoupling, TimeoutConfig, TopologyError,
};
// =============================================================================

View File

@@ -526,6 +526,42 @@ pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVar
}
}
// Calibration factors (z_ua, z_dp, z_flow, z_flow_eco, z_power, z_etav)
// promoted to free unknowns via control loops. Each `Some(idx)` slot means
// the solver computed a value for that factor at `state[idx]`. The generic
// `actuator` slot (physical opening / fan speed) is already covered by the
// bounded-variable path above, so we only surface the six named Z-factors.
for (name, indices) in system.calib_indices_by_name() {
for (factor, slot) in [
("z_flow", indices.z_flow),
("z_flow_eco", indices.z_flow_eco),
("z_dp", indices.z_dp),
("z_ua", indices.z_ua),
("z_power", indices.z_power),
("z_etav", indices.z_etav),
] {
if let Some(idx) = slot {
if idx < state.len() {
let id = format!("{}__{}", name, factor);
// Skip if already emitted by the bounded-variable path.
if out.iter().any(|v| v.id == id) {
continue;
}
out.push(SolvedVariable {
id,
component: Some(name.clone()),
variable: factor.to_string(),
value: state[idx],
// Calibration factors are bounded to [0.5, 2.0]
// (see entropyk_core::CalibValidationError).
min: 0.5,
max: 2.0,
});
}
}
}
}
if skipped > 0 {
tracing::debug!(
skipped,
@@ -547,9 +583,7 @@ pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVar
/// user-facing `"opening"` label.
fn derive_solved_variable_label(id: &str, component_id: Option<&str>) -> String {
let stripped = match component_id {
Some(c) => id
.strip_prefix(&format!("{}__", c))
.unwrap_or(id),
Some(c) => id.strip_prefix(&format!("{}__", c)).unwrap_or(id),
None => id.rfind("__").map(|i| &id[i + 2..]).unwrap_or(id),
};
match stripped {
@@ -781,6 +815,9 @@ mod tests {
// Component unknown → fall back to last "__"-separated segment.
assert_eq!(derive_solved_variable_label("glob__z_dp", None), "z_dp");
// No separator and no component → id itself.
assert_eq!(derive_solved_variable_label("global_var", None), "global_var");
assert_eq!(
derive_solved_variable_label("global_var", None),
"global_var"
);
}
}

View File

@@ -11,6 +11,7 @@ repository = "https://github.com/entropyk/entropyk"
entropyk-components = { path = "../components" }
entropyk-core = { path = "../core" }
entropyk-solver-core = { path = "../solver-core" }
faer = "0.24"
nalgebra = "0.33"
petgraph = "0.6"
thiserror = "1.0"
@@ -29,6 +30,10 @@ criterion = "0.5"
name = "lu_solve"
harness = false
[[bench]]
name = "lu_backends"
harness = false
[[bench]]
name = "residual_jacobian_assembly"
harness = false

View File

@@ -0,0 +1,85 @@
//! Story 1.5 comparative benchmark: nalgebra vs faer dense LU backends.
//!
//! Measures factorization (`set_linearisation`) and per-RHS solve
//! (`solve_in_place`) on the mock-cycle Jacobian (9×9) and a synthetic 50×50
//! dense Jacobian — the sizes Entropyk Newton actually sees.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use entropyk_solver::linear::NalgebraLuSolver;
use entropyk_solver::linear_faer::FaerLuSolver;
use entropyk_solver::LinearSolver;
mod common;
fn dense_entries(n: usize) -> Vec<(usize, usize, f64)> {
let mut entries = Vec::with_capacity(n * n);
for i in 0..n {
for j in 0..n {
let v = if i == j {
4.0 + 0.01 * (i as f64)
} else {
1.0 / (1.0 + (i as f64 - j as f64).abs())
};
entries.push((i, j, v));
}
}
entries
}
fn bench_pair(c: &mut Criterion, label: &str, entries: &[(usize, usize, f64)], n: usize) {
let mut group = c.benchmark_group(label);
let rhs: Vec<f64> = (0..n)
.map(|i| (i as f64 * 0.7 - 1.3).sin() * 100.0)
.collect();
let mut nal = NalgebraLuSolver::new();
nal.set_problem(n, n).unwrap();
let mut faer = FaerLuSolver::new();
faer.set_problem(n, n).unwrap();
group.bench_function(BenchmarkId::new("factor", "nalgebra"), |b| {
b.iter(|| {
black_box(nal.set_linearisation(black_box(entries))).unwrap();
});
});
group.bench_function(BenchmarkId::new("factor", "faer"), |b| {
b.iter(|| {
black_box(faer.set_linearisation(black_box(entries))).unwrap();
});
});
nal.set_linearisation(entries).unwrap();
faer.set_linearisation(entries).unwrap();
group.bench_function(BenchmarkId::new("solve", "nalgebra"), |b| {
b.iter(|| {
let mut rhs = rhs.clone();
let _ = black_box(nal.solve_in_place(black_box(&mut rhs)));
});
});
group.bench_function(BenchmarkId::new("solve", "faer"), |b| {
b.iter(|| {
let mut rhs = rhs.clone();
let _ = black_box(faer.solve_in_place(black_box(&mut rhs)));
});
});
group.finish();
}
fn bench_lu_backends(c: &mut Criterion) {
let (system, state) = common::build_mock_system();
let jacobian = common::assemble_jacobian(&system, &state);
let n = jacobian.nrows();
let jm = jacobian.as_matrix();
let entries: Vec<(usize, usize, f64)> = (0..n)
.flat_map(|i| (0..n).map(move |j| (i, j, jm[(i, j)])))
.collect();
bench_pair(c, "lu_mock_9x9", &entries, n);
let entries50 = dense_entries(50);
bench_pair(c, "lu_dense_50x50", &entries50, 50);
let _ = state;
}
criterion_group!(benches, bench_lu_backends);
criterion_main!(benches);

View File

@@ -34,6 +34,7 @@ pub mod initializer;
pub mod inverse;
pub mod jacobian;
pub mod linear;
pub mod linear_faer;
pub mod macro_component;
pub mod metadata;
pub mod scaling;
@@ -64,7 +65,8 @@ pub use initializer::{
};
pub use inverse::{ComponentOutput, Constraint, ConstraintError, ConstraintId};
pub use jacobian::JacobianMatrix;
pub use linear::NalgebraLuSolver;
pub use linear::{make_linear_backend, set_linear_backend_override, DenseLu, NalgebraLuSolver};
pub use linear_faer::FaerLuSolver;
pub use macro_component::{MacroComponent, MacroComponentSnapshot, PortMapping};
pub use metadata::SimulationMetadata;
pub use scaling::{equilibrate, unscale_dx};

View File

@@ -356,3 +356,139 @@ mod tests {
));
}
}
// ─── Strangler dispatch (Story 1.5) ─────────────────────────────────────────
/// Process-wide override for the default dense backend (set by the CLI from
/// `solver.linear_backend`). Precedence: explicit `make_linear_backend` name
/// → this override → `ENTROPYK_LINEAR_BACKEND` env → `"nalgebra"`.
static BACKEND_OVERRIDE: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
/// Sets the process-wide default dense backend (`Some("faer")` / `Some("nalgebra")`,
/// `None` to clear). Additive, additive-only configuration surface.
pub fn set_linear_backend_override(name: Option<&str>) {
if let Ok(mut guard) = BACKEND_OVERRIDE.write() {
*guard = name.map(str::to_string);
}
}
/// The dense LU backend in use: nalgebra (default, legacy path) or faer
/// (opt-in). Strategies hold this enum directly so the `set_matrix` fast path
/// stays available; it also implements [`LinearSolver`] for registry boxing.
pub enum DenseLu {
/// nalgebra dense LU (default).
Nalgebra(NalgebraLuSolver),
/// faer dense LU (Story 1.5 opt-in).
Faer(crate::linear_faer::FaerLuSolver),
}
impl DenseLu {
/// Inherent fast path shared by both backends (see each backend's docs).
pub fn set_matrix(&mut self, matrix: &DMatrix<f64>) -> Result<(), SolverCoreError> {
match self {
Self::Nalgebra(b) => b.set_matrix(matrix),
Self::Faer(b) => b.set_matrix(matrix),
}
}
}
impl LinearSolver for DenseLu {
fn set_problem(
&mut self,
n_equations: usize,
n_unknowns: usize,
) -> Result<(), SolverCoreError> {
match self {
Self::Nalgebra(b) => b.set_problem(n_equations, n_unknowns),
Self::Faer(b) => b.set_problem(n_equations, n_unknowns),
}
}
fn set_linearisation(
&mut self,
entries: &[(usize, usize, f64)],
) -> Result<(), SolverCoreError> {
match self {
Self::Nalgebra(b) => b.set_linearisation(entries),
Self::Faer(b) => b.set_linearisation(entries),
}
}
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
match self {
Self::Nalgebra(b) => b.solve_in_place(rhs),
Self::Faer(b) => b.solve_in_place(rhs),
}
}
fn n(&self) -> usize {
match self {
Self::Nalgebra(b) => b.n(),
Self::Faer(b) => b.n(),
}
}
}
/// Builds the dense backend selected by name (or by override/env/default).
pub fn make_linear_backend(name: Option<&str>) -> Result<DenseLu, SolverCoreError> {
let selected = name
.map(str::to_string)
.or_else(|| BACKEND_OVERRIDE.read().ok().and_then(|g| g.clone()))
.or_else(|| std::env::var("ENTROPYK_LINEAR_BACKEND").ok())
.unwrap_or_else(|| "nalgebra".to_string());
match selected.as_str() {
"nalgebra" => Ok(DenseLu::Nalgebra(NalgebraLuSolver::new())),
"faer" => Ok(DenseLu::Faer(crate::linear_faer::FaerLuSolver::new())),
other => Err(SolverCoreError::Usage {
message: format!("unknown linear backend '{other}' (use 'nalgebra' or 'faer')"),
}),
}
}
#[cfg(test)]
mod dispatch_tests {
use super::*;
#[test]
fn factory_builds_named_backends_and_rejects_unknown() {
assert!(matches!(
make_linear_backend(Some("nalgebra")).unwrap(),
DenseLu::Nalgebra(_)
));
assert!(matches!(
make_linear_backend(Some("faer")).unwrap(),
DenseLu::Faer(_)
));
assert!(make_linear_backend(Some("mumps")).is_err());
// Env-independent check: an explicit override always wins.
set_linear_backend_override(Some("nalgebra"));
assert!(matches!(
make_linear_backend(None).unwrap(),
DenseLu::Nalgebra(_)
));
set_linear_backend_override(None);
}
#[test]
fn override_selects_default_and_explicit_name_wins() {
set_linear_backend_override(Some("faer"));
assert!(matches!(
make_linear_backend(None).unwrap(),
DenseLu::Faer(_)
));
// Explicit name wins over the override.
assert!(matches!(
make_linear_backend(Some("nalgebra")).unwrap(),
DenseLu::Nalgebra(_)
));
set_linear_backend_override(None);
// After clearing, selection falls back to env-or-nalgebra: just check
// it builds a working backend.
let mut b = make_linear_backend(None).unwrap();
b.set_problem(1, 1).unwrap();
b.set_linearisation(&[(0, 0, 2.0)]).unwrap();
let mut rhs = [4.0];
b.solve_in_place(&mut rhs).unwrap();
assert!((rhs[0] - 2.0).abs() < 1e-9);
}
}

View File

@@ -0,0 +1,310 @@
//! `FaerLuSolver`: dense faer LU backend behind the [`LinearSolver`] lifecycle
//! (FR3, Story 1.5 — strangler migration; nalgebra stays the default).
//!
//! Same contract and pipeline as [`crate::linear::NalgebraLuSolver`] (post-1.4
//! audit): identical triplet assembly (zero-fill + `+=`), identical Ruiz
//! equilibration (`crate::scaling::equilibrate`), factorization computed once
//! in `set_linearisation`, bounds validated BEFORE mutation with the factor
//! invalidated on out-of-bounds, zero-panic error paths, zero heap allocation
//! in `solve_in_place` (faer solves in place on the stored factorization,
//! over a column-major view of the pre-allocated scratch). The only
//! intentional difference is the factorization engine: faer's partial-pivot
//! LU instead of nalgebra's LU — results match within tolerance, not bitwise.
use entropyk_solver_core::{LinearSolver, SolverCoreError};
use faer::linalg::solvers::{PartialPivLu, SolveCore};
use nalgebra::DMatrix;
/// Stored faer factorization plus the equilibration scalings used to build it.
struct Factorized {
d_r: Vec<f64>,
d_c: Vec<f64>,
lu: PartialPivLu<f64>,
}
/// Dense LU backend (faer) behind the object-safe [`LinearSolver`] trait.
///
/// Scratch buffers are allocated once in `set_problem` so `solve_in_place`
/// performs zero heap allocation.
pub struct FaerLuSolver {
n_rows: usize,
n_cols: usize,
/// Assembled (unscaled) matrix (nalgebra storage, shared with the
/// equilibration pipeline).
matrix: Option<DMatrix<f64>>,
factor: Option<Factorized>,
/// Scratch: scaled rhs / unscaled step (allocated in `set_problem`).
delta: Vec<f64>,
}
impl Default for FaerLuSolver {
fn default() -> Self {
Self::new()
}
}
impl FaerLuSolver {
/// Creates an unconfigured backend (call [`LinearSolver::set_problem`]).
pub fn new() -> Self {
Self {
n_rows: 0,
n_cols: 0,
matrix: None,
factor: None,
delta: Vec::new(),
}
}
/// Inherent fast path: copy an already-assembled dense matrix and factor
/// it (one `copy_from`, no triplet conversion).
pub fn set_matrix(&mut self, matrix: &DMatrix<f64>) -> Result<(), SolverCoreError> {
let stored = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
message: "set_problem must be called before set_matrix".to_string(),
})?;
if stored.nrows() != matrix.nrows() || stored.ncols() != matrix.ncols() {
return Err(SolverCoreError::Usage {
message: format!(
"matrix shape {}x{} does not match problem {}x{}",
matrix.nrows(),
matrix.ncols(),
stored.nrows(),
stored.ncols()
),
});
}
stored.copy_from(matrix);
self.factorize()
}
/// Equilibrate + scale + factorize the stored matrix (square path).
fn factorize(&mut self) -> Result<(), SolverCoreError> {
let Some(matrix) = self.matrix.as_ref() else {
self.factor = None;
return Err(SolverCoreError::Usage {
message: "set_problem must be called before factorizing".to_string(),
});
};
if matrix.nrows() != matrix.ncols() {
// Non-square problems have no LU path; solve_in_place reports Usage.
self.factor = None;
return Ok(());
}
let n = matrix.nrows();
let (d_r, d_c) = crate::scaling::equilibrate(matrix);
// Scaled matrix as a faer Mat (column-major, one copy at factor time).
let scaled = faer::Mat::from_fn(n, n, |i, j| matrix[(i, j)] * d_r[i] * d_c[j]);
self.factor = Some(Factorized {
d_r,
d_c,
lu: PartialPivLu::new(scaled.as_ref()),
});
Ok(())
}
}
impl LinearSolver for FaerLuSolver {
fn set_problem(
&mut self,
n_equations: usize,
n_unknowns: usize,
) -> Result<(), SolverCoreError> {
if n_equations == 0 || n_unknowns == 0 {
return Err(SolverCoreError::Usage {
message: format!("zero-sized problem {n_equations}x{n_unknowns}"),
});
}
self.n_rows = n_equations;
self.n_cols = n_unknowns;
self.matrix = Some(DMatrix::zeros(n_equations, n_unknowns));
self.factor = None;
// Scratch buffer for the zero-allocation solve path.
self.delta = vec![0.0; n_unknowns];
Ok(())
}
fn set_linearisation(
&mut self,
entries: &[(usize, usize, f64)],
) -> Result<(), SolverCoreError> {
let (n_rows, n_cols) = (self.n_rows, self.n_cols);
if self.matrix.is_none() {
return Err(SolverCoreError::Usage {
message: "set_problem must be called before set_linearisation".to_string(),
});
}
// Validate bounds BEFORE touching the matrix (1.4 audit contract).
for &(row, col, _) in entries {
if row >= n_rows || col >= n_cols {
self.factor = None;
return Err(SolverCoreError::Usage {
message: format!("entry ({row},{col}) out of bounds {n_rows}x{n_cols}"),
});
}
}
let matrix = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
message: "set_problem must be called before set_linearisation".to_string(),
})?;
matrix.fill(0.0);
for &(row, col, value) in entries {
matrix[(row, col)] += value;
}
self.factorize()
}
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
if rhs.len() != self.n_rows || self.n_rows != self.n_cols {
return Err(SolverCoreError::Usage {
message: format!(
"solve_in_place needs a square rhs of len {}, got {} (problem {}x{})",
self.n_rows,
rhs.len(),
self.n_rows,
self.n_cols
),
});
}
let n = self.n_rows;
// Disjoint field borrows: factorization + scratch (zero alloc).
let Self { factor, delta, .. } = self;
let factor = factor.as_ref().ok_or_else(|| SolverCoreError::Usage {
message: "set_linearisation must be called before solve_in_place".to_string(),
})?;
// Scaled right-hand side: D_r · b (into the scratch).
for i in 0..n {
delta[i] = rhs[i] * factor.d_r[i];
}
// faer solves in place on a column-major view of the scratch.
let rhs_view = faer::MatMut::from_column_major_slice_mut(&mut delta[..n], n, 1);
factor.lu.solve_in_place_with_conj(faer::Conj::No, rhs_view);
// Undo the column scaling in place: x_i = D_c,i · y_i.
for i in 0..n {
delta[i] *= factor.d_c[i];
}
if delta[..n].iter().all(|v| v.is_finite()) {
rhs.copy_from_slice(&delta[..n]);
Ok(())
} else {
tracing::warn!(
"faer LU solve produced a non-finite step - Jacobian may contain NaN/Inf"
);
Err(SolverCoreError::InvalidSystem {
message: "linear solve produced a non-finite step".to_string(),
})
}
}
fn n(&self) -> usize {
self.n_rows
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jacobian::JacobianMatrix;
/// Deterministic dense system (well-conditioned, diagonally dominant).
fn dense_entries(n: usize) -> Vec<(usize, usize, f64)> {
let mut entries = Vec::new();
for i in 0..n {
for j in 0..n {
let v = if i == j {
4.0 + 0.1 * (i as f64)
} else {
1.0 / (1.0 + (i as f64 - j as f64).abs())
};
entries.push((i, j, v));
}
}
entries
}
#[test]
fn parity_with_jacobian_matrix_solve() {
for n in [1, 2, 5, 12] {
let entries = dense_entries(n);
let jm = JacobianMatrix::from_builder(&entries, n, n);
let residuals: Vec<f64> = (0..n)
.map(|i| (i as f64 * 0.7 - 1.3).sin() * 100.0)
.collect();
let legacy = jm.solve(&residuals).expect("legacy solve");
let mut backend = FaerLuSolver::new();
backend.set_problem(n, n).unwrap();
backend.set_linearisation(&entries).unwrap();
let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
backend.solve_in_place(&mut rhs).unwrap();
for i in 0..n {
// faer ≠ nalgebra bitwise: tolerance, not exact equality.
assert!(
(legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
"n={n} i={i}: legacy={} faer={}",
legacy[i],
rhs[i]
);
}
}
}
#[test]
fn factorization_reuse_across_rhs() {
let n = 6;
let entries = dense_entries(n);
let jm = JacobianMatrix::from_builder(&entries, n, n);
let mut backend = FaerLuSolver::new();
backend.set_problem(n, n).unwrap();
backend.set_matrix(jm.as_matrix()).unwrap();
for k in 0..2 {
let residuals: Vec<f64> = (0..n).map(|i| (i + k) as f64 * 3.1 - 5.0).collect();
let legacy = jm.solve(&residuals).expect("legacy solve");
let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
backend.solve_in_place(&mut rhs).unwrap();
for i in 0..n {
assert!(
(legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
"reuse k={k} i={i}"
);
}
}
}
#[test]
fn error_paths_and_stale_factor_invalidation() {
let mut backend = FaerLuSolver::new();
assert!(backend.set_problem(0, 2).is_err());
backend.set_problem(2, 2).unwrap();
// Solve before set_linearisation → Usage.
assert!(matches!(
backend.solve_in_place(&mut [1.0, 2.0]),
Err(SolverCoreError::Usage { .. })
));
// Factor a good matrix, then an OOB entry: the stale factorization
// must be invalidated (1.4 audit regression).
backend
.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])
.unwrap();
assert!(backend.set_linearisation(&[(2, 0, 1.0)]).is_err());
assert!(matches!(
backend.solve_in_place(&mut [1.0, 2.0]),
Err(SolverCoreError::Usage { .. })
));
assert_eq!(backend.n(), 2);
}
#[test]
fn non_finite_matrix_entries_are_rejected() {
let mut backend = FaerLuSolver::new();
backend.set_problem(2, 2).unwrap();
backend
.set_linearisation(&[(0, 0, f64::NAN), (1, 1, 1.0)])
.unwrap();
let mut rhs = vec![1.0, 2.0];
assert!(matches!(
backend.solve_in_place(&mut rhs),
Err(SolverCoreError::InvalidSystem { .. })
));
}
}

View File

@@ -345,10 +345,13 @@ impl Solver for NewtonConfig {
let mut frozen_count: usize = 0;
let mut force_recompute: bool = true;
// LinearSolver backend (FR3): factorizes at each fresh assembly and
// reuses the factorization (with cached scalings) across frozen
// iterations and repeated solves — bit-identical to JacobianMatrix::solve.
let mut linear_backend = crate::linear::NalgebraLuSolver::new();
// LinearSolver backend (FR3/Story 1.5): nalgebra by default, faer via
// config/env dispatch. Factorizes at each fresh assembly and reuses
// the factorization (with cached scalings) across frozen iterations.
let mut linear_backend =
crate::linear::make_linear_backend(None).map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to select linear backend: {e}"),
})?;
linear_backend
.set_problem(n_equations, n_state)
.map_err(|e| SolverError::InvalidSystem {

View File

@@ -122,9 +122,12 @@ impl Solver for PtcConfig {
let mut jacobian_builder = JacobianBuilder::new();
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
// LinearSolver backend (FR3): factorizes at each fresh assembly;
// bit-identical to JacobianMatrix::solve.
let mut linear_backend = crate::linear::NalgebraLuSolver::new();
// LinearSolver backend (FR3/Story 1.5): nalgebra by default, faer via
// config/env dispatch; factorizes at each fresh assembly.
let mut linear_backend =
crate::linear::make_linear_backend(None).map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to select linear backend: {e}"),
})?;
linear_backend
.set_problem(n_equations, n_state)
.map_err(|e| SolverError::InvalidSystem {

View File

@@ -335,6 +335,26 @@ impl Solver for PicardConfig {
let mut divergence_count: usize = 0;
let mut previous_norm: f64;
// Solver bounds mask (pressure floor + bounded control variables):
// Picard's relaxed update has no line search, so without clipping it
// can drive a calibration z-factor far outside its bounds (observed:
// z_ua → 2.8 on an SDT calibration), then crash the components.
// Saturated-controller actuator slots are EXCLUDED: they carry their
// own saturation semantics (anti-windup integrator + S(x) inside the
// controller residual) and hard-clamping them here breaks that dynamic.
let mut clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
.map(|i| system.get_solver_bounds_for_state_index(i))
.collect();
{
let n_sat = system.saturated_controllers_mut().count();
for i in 0..n_sat {
let u_idx = system.saturated_u_index(i);
if u_idx < clipping_mask.len() {
clipping_mask[u_idx] = None;
}
}
}
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
let mut best_state: Vec<f64> = vec![0.0; n_state];
let mut best_residual: f64;
@@ -425,6 +445,16 @@ impl Solver for PicardConfig {
} else {
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
}
// Clamp the relaxed/extrapolated iterate into solver bounds (see
// the mask comment above): bounded control variables (calibration
// z-factors, openings) and pressure floors.
for (i, s) in state.iter_mut().enumerate() {
if let Some((min, max)) = &clipping_mask[i] {
if min.is_finite() && max.is_finite() && min <= max {
*s = s.clamp(*min, *max);
}
}
}
// Compute new residuals. Recoverable domain violation → typed
// outcome; fatal → InvalidSystem flattening.

View File

@@ -224,6 +224,11 @@ pub struct System {
/// Registry of component names for constraint validation.
/// Maps human-readable names (e.g., "evaporator") to NodeIndex.
component_names: HashMap<String, NodeIndex>,
/// Per-component calibration indices snapshot, captured at `finalize()` time.
/// Lets external readers (e.g. result extraction) learn which Z-factors
/// (z_ua, z_dp, z_flow, …) were promoted to free unknowns and where they
/// live in the state vector, so the solved values can be surfaced to the user.
calib_indices_by_name: HashMap<String, entropyk_core::CalibIndices>,
finalized: bool,
total_state_len: usize,
/// When `true` (default), `finalize` rejects **over-constrained** systems.
@@ -246,6 +251,7 @@ impl System {
saturated_controllers: Vec::new(),
free_actuators: Vec::new(),
component_names: HashMap::new(),
calib_indices_by_name: HashMap::new(),
finalized: false,
total_state_len: 0,
enforce_dof_gate: true,
@@ -682,6 +688,10 @@ impl System {
}
}
// Persist the per-component calib map so external readers (result
// extraction) can surface solved Z-factor values (z_ua, z_dp, …).
self.calib_indices_by_name = comp_calib_indices;
// Wire physical thermal couplings (Story 3.4 completion): each coupling
// owns one state unknown Q [W] at `coupling_state_index(i)`. The
// cold-side receiver component (e.g. `ThermalLoad`) reads Q in its
@@ -1145,6 +1155,15 @@ impl System {
self.component_names.keys().map(|s| s.as_str())
}
/// Per-component `CalibIndices` snapshot captured at `finalize()`.
/// Each `Some(idx)` slot (z_ua, z_dp, z_flow, z_flow_eco, z_power, z_etav,
/// actuator) means that factor was promoted to a free solver unknown and
/// its solved value lives at `state[idx]`. Used by result extraction to
/// surface solved calibration/control values to the user.
pub fn calib_indices_by_name(&self) -> &HashMap<String, entropyk_core::CalibIndices> {
&self.calib_indices_by_name
}
/// Returns a reference to the component stored at the given node index.
///
/// # Panics
@@ -2514,11 +2533,11 @@ impl System {
self.total_state_len + self.inverse_control.mapping_count() + i
}
fn saturated_base_index(&self) -> usize {
pub fn saturated_base_index(&self) -> usize {
self.total_state_len + self.inverse_control.mapping_count() + self.coupling_residual_count()
}
fn saturated_u_index(&self, i: usize) -> usize {
pub fn saturated_u_index(&self, i: usize) -> usize {
self.saturated_base_index() + 2 * i
}

View File

@@ -0,0 +1,83 @@
//! Story 1.5 AC#2/#3: the faer dense backend solves the reference cycles with
//! results matching the nalgebra baseline within tolerance (not bitwise —
//! different factorization engines), via the strangler dispatch.
mod common;
use entropyk_solver::linear::{make_linear_backend, set_linear_backend_override, DenseLu};
use entropyk_solver::{LinearSolver, Solver};
/// Per-value tolerance matching the golden-snapshot comparison policy.
const STATE_RTOL: f64 = 1e-6;
const STATE_ATOL: f64 = 1.0; // Pa / J/kg absolute floor
fn solve_all_cycles() -> Vec<Vec<f64>> {
[
common::build_reference_cycle_a(),
common::build_reference_cycle_b(),
common::build_reference_cycle_c(),
]
.into_iter()
.map(|mut system| {
common::solve_reference_system_with_state(&mut system)
.state
.clone()
})
.collect()
}
#[test]
fn faer_path_dispatch_and_reference_cycle_parity() {
// 1. Unknown override must fail the solve with the dispatch error (the
// dispatch is on the strategy path, not dead code).
set_linear_backend_override(Some("definitely-not-a-backend"));
let mut system = common::build_reference_cycle_a();
let result = entropyk_solver::FallbackSolver::default_solver().solve(&mut system);
assert!(result.is_err(), "unknown backend must fail the solve");
// 2. Baseline (nalgebra) on the three reference cycles.
set_linear_backend_override(Some("nalgebra"));
let baseline = solve_all_cycles();
// 3. Same cycles on faer; converged states must match within tolerance.
set_linear_backend_override(Some("faer"));
let faer = solve_all_cycles();
set_linear_backend_override(None);
assert_eq!(baseline.len(), faer.len());
for (cycle_idx, (base, alt)) in baseline.iter().zip(faer.iter()).enumerate() {
assert_eq!(
base.len(),
alt.len(),
"cycle {cycle_idx}: state length differs"
);
for (i, (b, a)) in base.iter().zip(alt.iter()).enumerate() {
let scale = b.abs().max(a.abs()).max(STATE_ATOL);
assert!(
(b - a).abs() <= STATE_RTOL * scale,
"cycle {cycle_idx} state[{i}]: nalgebra={b} faer={a} (rel diff {})",
(b - a).abs() / scale
);
}
}
}
#[test]
fn dense_lu_enum_dispatches_both_engines() {
for name in ["nalgebra", "faer"] {
let mut backend = make_linear_backend(Some(name)).unwrap();
backend.set_problem(2, 2).unwrap();
backend
.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])
.unwrap();
let mut rhs = vec![4.0, 3.0];
backend.solve_in_place(&mut rhs).unwrap();
assert!((rhs[0] - 2.0).abs() < 1e-9, "{name}: {}", rhs[0]);
assert!((rhs[1] - 3.0).abs() < 1e-9, "{name}: {}", rhs[1]);
}
assert!(matches!(
make_linear_backend(Some("faer")).unwrap(),
DenseLu::Faer(_)
));
}