Wire BPHX channel pressure drop on both sides with selectable correlations.
Some checks failed
CI / check (push) Has been cancelled
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:
@@ -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,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user