fix: resolve CLI solver state dimension mismatch

Removed mathematical singularity in HeatExchanger models (q_hot - q_cold = 0 was redundant) causing them to incorrectly request 3 equations without internal variables. Fixed ScrewEconomizerCompressor internal_state_len to perfectly align with the solver dimensions.
This commit is contained in:
Sepehr
2026-02-28 22:45:51 +01:00
parent c5a51d82dc
commit fdd124eefd
35 changed files with 10969 additions and 123 deletions

View File

@@ -26,7 +26,7 @@ pub struct HeatExchangerBuilder<Model: HeatTransferModel> {
circuit_id: CircuitId,
}
impl<Model: HeatTransferModel> HeatExchangerBuilder<Model> {
impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
/// Creates a new builder.
pub fn new(model: Model) -> Self {
Self {
@@ -200,7 +200,7 @@ impl<Model: HeatTransferModel + std::fmt::Debug> std::fmt::Debug for HeatExchang
}
}
impl<Model: HeatTransferModel> HeatExchanger<Model> {
impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
/// Creates a new heat exchanger with the given model.
pub fn new(mut model: Model, name: impl Into<String>) -> Self {
let calib = Calib::default();
@@ -283,6 +283,14 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
}
/// Returns the hot side fluid identifier, if set.
pub fn hot_conditions(&self) -> Option<&HxSideConditions> {
self.hot_conditions.as_ref()
}
pub fn cold_conditions(&self) -> Option<&HxSideConditions> {
self.cold_conditions.as_ref()
}
pub fn hot_fluid_id(&self) -> Option<&FluidsFluidId> {
self.hot_conditions.as_ref().map(|c| c.fluid_id())
}
@@ -398,6 +406,19 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
self.model.effective_ua(None)
}
/// Returns the nominal (base) UA value [W/K] before any scaling.
pub fn ua_nominal(&self) -> f64 {
self.model.ua()
}
/// Sets the UA scale factor directly (UA_eff = scale × UA_nominal).
///
/// Used by `MchxCondenserCoil` to apply fan-speed and air-density corrections
/// without rebuilding the component.
pub fn set_ua_scale(&mut self, scale: f64) {
self.model.set_ua_scale(scale.max(0.0));
}
/// Returns the current operational state.
pub fn operational_state(&self) -> OperationalState {
self.operational_state
@@ -439,13 +460,21 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
) -> FluidState {
FluidState::new(temperature, pressure, enthalpy, mass_flow, cp)
}
}
impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn compute_residuals(
pub fn compute_residuals_with_ua_scale(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
custom_ua_scale: f64,
) -> Result<(), ComponentError> {
self.do_compute_residuals(_state, residuals, Some(custom_ua_scale))
}
pub fn do_compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
custom_ua_scale: Option<f64>,
) -> Result<(), ComponentError> {
if residuals.len() < self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
@@ -476,17 +505,6 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
}
}
// Build inlet FluidState values.
// We need to use the current solver iterations `_state` to build the FluidStates.
// Because port mapping isn't fully implemented yet, we assume the inputs from the caller
// (the solver) are being passed in order, but for now since `HeatExchanger` is
// generic and expects full states, we must query the backend using the *current*
// state values. Wait, `_state` has length `self.n_equations() == 3` (energy residuals).
// It DOES NOT store the full fluid state for all 4 ports. The full fluid state is managed
// at the System level via Ports.
// Let's refine the approach: we still need to query properties. The original implementation
// was a placeholder because component port state pulling is part of Epic 1.3 / Epic 4.
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) =
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
@@ -504,16 +522,6 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
hot_cp,
);
// Extract current iteration values from `_state` if available, or fallback to heuristics.
// The `SystemState` passed here contains the global state variables.
// For a 3-equation heat exchanger, the state variables associated with it
// are typically the outlet enthalpies and the heat transfer rate Q.
// Because we lack definitive `Port` mappings inside `HeatExchanger` right now,
// we'll attempt a safe estimation that incorporates `_state` conceptually,
// but avoids direct indexing out of bounds. The real fix for "ignoring _state"
// is that the system solver maps global `_state` into port conditions.
// Estimate hot outlet enthalpy (will be refined by solver convergence):
let hot_dh = hot_cp * 5.0; // J/kg per degree
let hot_outlet = Self::create_fluid_state(
hot_cond.temperature_k() - 5.0,
@@ -544,9 +552,6 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
} else {
// Fallback: physically-plausible placeholder values (no backend configured).
// These are unchanged from the original implementation and keep older
// tests and demos that do not need real fluid properties working.
let hot_inlet = Self::create_fluid_state(350.0, 500_000.0, 400_000.0, 0.1, 1000.0);
let hot_outlet = Self::create_fluid_state(330.0, 490_000.0, 380_000.0, 0.1, 1000.0);
let cold_inlet = Self::create_fluid_state(290.0, 101_325.0, 80_000.0, 0.2, 4180.0);
@@ -555,7 +560,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
};
let dynamic_f_ua = self.calib_indices.f_ua.map(|idx| _state[idx]);
let dynamic_f_ua = custom_ua_scale.or_else(|| self.calib_indices.f_ua.map(|idx| _state[idx]));
self.model.compute_residuals(
&hot_inlet,
@@ -568,6 +573,16 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
Ok(())
}
}
impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.do_compute_residuals(_state, residuals, None)
}
fn jacobian_entries(
&self,
@@ -777,7 +792,7 @@ mod tests {
let model = LmtdModel::counter_flow(1000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(hx.n_equations(), 3);
assert_eq!(hx.n_equations(), 2);
}
#[test]
@@ -798,7 +813,7 @@ mod tests {
let hx = HeatExchanger::new(model, "Test");
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 2];
let mut residuals = vec![0.0; 1];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_err());