Wire BPHX channel pressure drop on both sides with selectable correlations.
Some checks failed
CI / check (push) Has been cancelled

Replace isobaric 4-port closures with SimplifiedChannel (default) and Martin1996 DP models so z_dp and UI dp_correlation actually affect the Newton solve.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 16:52:25 +02:00
parent 5bd180b5b8
commit 44f793a583
19 changed files with 1287 additions and 106 deletions

View File

@@ -174,8 +174,9 @@ pub use builder::{SystemBuilder, SystemBuilderError};
mod result;
pub use result::{
extract_simulation_result, ComponentResult, ConvergenceSummary, EdgeResult, EnergyResult,
PortState, SimulationOutcome, SimulationResult, SystemSummary,
extract_simulation_result, extract_solved_variables, ComponentResult, ConvergenceSummary,
EdgeResult, EnergyResult, PortState, SimulationOutcome, SimulationResult, SolvedVariable,
SystemSummary,
};
// =============================================================================

View File

@@ -173,6 +173,36 @@ pub struct SystemSummary {
pub cop_heating: Option<f64>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Solved unknown (free actuator / calibration factor)
// ─────────────────────────────────────────────────────────────────────────────
/// A named solver-computed unknown with its converged value and physical bounds.
///
/// These correspond to bounded control variables that the Newton solve treats
/// as unknowns — free actuators (e.g. expansion-valve `opening`, condenser
/// `fan_speed`), hard inverse-control links, and saturated-controller
/// actuators (calibration factors like `z_ua`, `z_dp`, `z_flow`). They ride
/// inside the flat `raw_state_vector` returned by the solver; this struct
/// attaches a stable id, owning component, short label, and `[min, max]`
/// bounds so the UI can surface them without parsing the raw vector.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SolvedVariable {
/// Stable id from [`BoundedVariableId`] (e.g. `"exv__opening"`).
pub id: String,
/// Owning component name, when known (`None` for globals).
pub component: Option<String>,
/// Short human label, e.g. `"opening"`, `"z_ua"`, `"z_dp"`.
pub variable: String,
/// Converged value of the unknown.
pub value: f64,
/// Lower bound (inclusive).
pub min: f64,
/// Upper bound (inclusive).
pub max: f64,
}
// ─────────────────────────────────────────────────────────────────────────────
// Top-level SimulationResult
// ─────────────────────────────────────────────────────────────────────────────
@@ -195,6 +225,12 @@ pub struct SimulationResult {
pub edges: Vec<EdgeResult>,
/// Aggregated system performance summary.
pub summary: SystemSummary,
/// Named solver-computed unknowns (free actuators + calibration factors).
///
/// Empty for systems with no bounded control variables. Skipped on
/// serialization when empty so older consumers keep working.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub solved_variables: Vec<SolvedVariable>,
}
impl SimulationResult {
@@ -422,6 +458,9 @@ pub fn extract_simulation_result(system: &System, converged: &ConvergedState) ->
status,
};
// --- Solved unknowns (free actuators + calibration factors) ---
let solved_variables = extract_solved_variables(system, state);
SimulationResult {
status,
convergence,
@@ -429,6 +468,93 @@ pub fn extract_simulation_result(system: &System, converged: &ConvergedState) ->
components,
edges,
summary,
solved_variables,
}
}
/// Extracts named solver unknowns (bounded variables / free actuators) from a
/// solved state vector.
///
/// Every bounded variable registered on `system` whose value lives in the
/// Newton state vector — as a physical free actuator, a hard inverse-control
/// link, or a saturated-controller actuator — is materialized into a
/// [`SolvedVariable`] carrying its converged value and `[min, max]` bounds.
///
/// Bounded variables with no resolvable state slot (purely registered, never
/// wired into the solve) are silently skipped; a single `tracing::debug!`
/// line summarizes how many were skipped so coverage gaps are diagnosable
/// without panic risk. Out-of-range indices (state shorter than expected,
/// e.g. a truncated warm-start vector) are likewise skipped.
///
/// # Arguments
///
/// * `system` - The solved system (must be finalized).
/// * `state` - Converged Newton state vector slice (length =
/// `system.full_state_vector_len()` when complete).
pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVariable> {
let mut out = Vec::new();
let mut skipped = 0usize;
for bv in system.bounded_variables() {
let id = bv.id();
// Free actuators are laid out in their own block at the tail of the
// state vector; everything else (hard-control links and saturated-
// controller actuators) is resolvable via control_variable_state_index.
let idx = system
.free_actuators()
.position(|fid| fid == id)
.map(|i| system.free_actuator_index(i))
.or_else(|| system.control_variable_state_index(id));
match idx {
Some(i) if i < state.len() => {
let id_str = id.as_str();
let component = bv.component_id().map(|s| s.to_string());
let variable = derive_solved_variable_label(id_str, bv.component_id());
out.push(SolvedVariable {
id: id_str.to_string(),
component,
variable,
value: state[i],
min: bv.min(),
max: bv.max(),
});
}
_ => {
skipped += 1;
}
}
}
if skipped > 0 {
tracing::debug!(
skipped,
total = system.bounded_variable_count(),
"Bounded variables skipped when extracting solved variables (no resolvable state slot)"
);
}
out
}
/// Derives the short user-facing variable label from a bounded-variable id.
///
/// Bounded-variable ids follow the convention `"{component}__{factor}"`, e.g.
/// `"exv__opening"`, `"cond__z_ua"`. When the component is known we strip the
/// `"{component}__"` prefix; otherwise we fall back to the segment after the
/// last `__`. The solver-internal `"actuator"` suffix (used for saturated
/// controllers driving an `opening`/`injection` factor) is remapped to the
/// 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),
None => id.rfind("__").map(|i| &id[i + 2..]).unwrap_or(id),
};
match stripped {
"actuator" => "opening".to_string(),
other => other.to_string(),
}
}
@@ -572,6 +698,7 @@ mod tests {
components: vec![],
edges: vec![],
summary: SystemSummary::default(),
solved_variables: vec![],
};
let json = result.to_json().unwrap();
assert!(json.contains("\"status\": \"converged\""));
@@ -595,4 +722,65 @@ mod tests {
assert_eq!(result.edges, de.edges);
assert_eq!(result.summary, de.summary);
}
#[test]
fn test_solved_variable_serialization() {
let sv = SolvedVariable {
id: "exv__opening".to_string(),
component: Some("exv".to_string()),
variable: "opening".to_string(),
value: 0.62,
min: 0.02,
max: 1.0,
};
let json = serde_json::to_string(&sv).unwrap();
let de: SolvedVariable = serde_json::from_str(&json).unwrap();
assert_eq!(sv, de);
assert!(json.contains("\"component\":\"exv\""));
assert!(json.contains("\"variable\":\"opening\""));
}
#[test]
fn test_solved_variables_skipped_when_empty() {
// Empty solved_variables should be omitted from serialized JSON
// (skip_serializing_if = "Vec::is_empty") for backward compatibility.
let result = SimulationResult {
status: SimulationOutcome::Converged,
convergence: ConvergenceSummary {
iterations: 1,
final_residual: 0.0,
converged: true,
status: SimulationOutcome::Converged,
},
metadata: entropyk_solver::SimulationMetadata::new("h".to_string()),
components: vec![],
edges: vec![],
summary: SystemSummary::default(),
solved_variables: vec![],
};
let json = result.to_json().unwrap();
assert!(!json.contains("solvedVariables"));
}
#[test]
fn test_derive_solved_variable_label() {
// Component known → strip "{component}__" prefix.
assert_eq!(
derive_solved_variable_label("exv__opening", Some("exv")),
"opening"
);
assert_eq!(
derive_solved_variable_label("cond__z_ua", Some("cond")),
"z_ua"
);
// Solver-internal "actuator" suffix remapped to user-facing "opening".
assert_eq!(
derive_solved_variable_label("exv__actuator", Some("exv")),
"opening"
);
// 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");
}
}