Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -92,9 +92,9 @@ pub struct FreeCoolingExchanger {
/// Fluid backend for property calculations
fluid_backend: Option<Arc<dyn FluidBackend>>,
/// Calibration factor for UA scaling (default 1.0)
f_ua: f64,
z_ua: f64,
/// Calibration factor for pressure drop scaling (default 1.0)
f_dp: f64,
z_dp: f64,
/// Calibration indices for inverse calibration
calib_indices: CalibIndices,
}
@@ -109,8 +109,8 @@ impl std::fmt::Debug for FreeCoolingExchanger {
.field("outdoor_temp", &self.outdoor_temp)
.field("heat_transfer_rate", &self.heat_transfer_rate)
.field("current_effectiveness", &self.current_effectiveness)
.field("f_ua", &self.f_ua)
.field("f_dp", &self.f_dp)
.field("f_ua", &self.z_ua)
.field("f_dp", &self.z_dp)
.field("fluid_backend", &"<FluidBackend>")
.finish()
}
@@ -150,13 +150,18 @@ impl FreeCoolingExchanger {
circuit_id,
config,
mode: FreeCoolingMode::Bypass,
ports: [port_cold_inlet, port_cold_outlet, port_hot_inlet, port_hot_outlet],
ports: [
port_cold_inlet,
port_cold_outlet,
port_hot_inlet,
port_hot_outlet,
],
outdoor_temp: None,
heat_transfer_rate: None,
current_effectiveness,
fluid_backend: None,
f_ua: 1.0,
f_dp: 1.0,
z_ua: 1.0,
z_dp: 1.0,
calib_indices: CalibIndices::default(),
})
}
@@ -229,7 +234,8 @@ impl FreeCoolingExchanger {
}
_ => {
if t_outdoor.0
> (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis)
> (t_cold_in - self.config.min_outdoor_temp
+ self.config.hysteresis)
{
self.mode = FreeCoolingMode::Bypass;
}
@@ -245,24 +251,24 @@ impl FreeCoolingExchanger {
Ok(())
}
/// Sets the f_ua calibration factor
pub fn set_f_ua(&mut self, f_ua: f64) {
self.f_ua = f_ua;
/// Sets the z_ua calibration factor (BOLT `Z_UA`).
pub fn set_z_ua(&mut self, z_ua: f64) {
self.z_ua = z_ua;
}
/// Returns the f_ua calibration factor
pub fn f_ua(&self) -> f64 {
self.f_ua
/// Returns the z_ua calibration factor.
pub fn z_ua(&self) -> f64 {
self.z_ua
}
/// Sets the f_dp calibration factor
pub fn set_f_dp(&mut self, f_dp: f64) {
self.f_dp = f_dp;
/// Sets the z_dp calibration factor (BOLT `Z_dpc`).
pub fn set_z_dp(&mut self, z_dp: f64) {
self.z_dp = z_dp;
}
/// Returns the f_dp calibration factor
pub fn f_dp(&self) -> f64 {
self.f_dp
/// Returns the z_dp calibration factor.
pub fn z_dp(&self) -> f64 {
self.z_dp
}
/// Returns the current operational state
@@ -414,7 +420,7 @@ impl Component for FreeCoolingExchanger {
let c_min = c_cold.min(c_hot);
// UA with calibration scaling
let ua_eff = self.f_ua * self.config.ua;
let ua_eff = self.z_ua * self.config.ua;
// ε-NTU effectiveness
let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);
@@ -442,8 +448,7 @@ impl Component for FreeCoolingExchanger {
residuals[0] = m_cold * dh_cold - q;
residuals[1] = m_hot * dh_hot + q;
residuals[2] = m_cold * dh_cold + m_hot * dh_hot;
residuals[3] =
(p_cold_in - p_cold_out) - self.f_dp * self.config.cold_dp_nominal;
residuals[3] = (p_cold_in - p_cold_out) - self.z_dp * self.config.cold_dp_nominal;
}
}
@@ -456,8 +461,8 @@ impl Component for FreeCoolingExchanger {
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// Jacobian entries for calibration variable sensitivities
if let Some(f_ua_idx) = self.calib_indices.f_ua {
// ∂r[0]/∂f_ua: cold-side energy balance sensitivity
if let Some(z_ua_idx) = self.calib_indices.z_ua {
// ∂r[0]/∂z_ua: cold-side energy balance sensitivity
// r[0] = m_cold * (h_cold_out - h_cold_in) - Q(f_ua)
// ∂r[0]/∂f_ua = -∂Q/∂f_ua = -ε × C_min × (T_hot_in - T_cold_in) × UA_nominal
let m_cold = self.config.cold_mass_flow;
@@ -468,28 +473,26 @@ impl Component for FreeCoolingExchanger {
let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
let h_hot_in = self.port_enthalpy_raw(HOT_INLET);
let t_cold_in =
self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);
let t_hot_in =
self.temperature_from_enthalpy(h_hot_in, self.config.hot_cp);
let t_cold_in = self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);
let t_hot_in = self.temperature_from_enthalpy(h_hot_in, self.config.hot_cp);
let dt = t_hot_in - t_cold_in;
// Approximate ∂Q/∂f_ua ≈ C_min × dt × (∂ε/∂f_ua) × UA_nominal
// For small variations: ∂Q/∂f_ua ≈ Q / f_ua when linearized
let ua_eff = self.f_ua * self.config.ua;
let ua_eff = self.z_ua * self.config.ua;
let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);
let q_per_f_ua = eps * c_min * dt; // Q / f_ua at current operating point
jacobian.add_entry(0, f_ua_idx, -q_per_f_ua);
jacobian.add_entry(1, f_ua_idx, q_per_f_ua);
jacobian.add_entry(0, z_ua_idx, -q_per_f_ua);
jacobian.add_entry(1, z_ua_idx, q_per_f_ua);
// r[2] = r[0] + r[1], so ∂r[2]/∂f_ua = ∂r[0]/∂f_ua + ∂r[1]/∂f_ua = 0
jacobian.add_entry(2, f_ua_idx, 0.0);
jacobian.add_entry(2, z_ua_idx, 0.0);
}
if let Some(f_dp_idx) = self.calib_indices.f_dp {
if let Some(z_dp_idx) = self.calib_indices.z_dp {
// r[3] = (P_cold_in - P_cold_out) - f_dp × ΔP_nominal
// ∂r[3]/∂f_dp = -ΔP_nominal
jacobian.add_entry(3, f_dp_idx, -self.config.cold_dp_nominal);
jacobian.add_entry(3, z_dp_idx, -self.config.cold_dp_nominal);
}
Ok(())
@@ -499,10 +502,7 @@ impl Component for FreeCoolingExchanger {
&self.ports
}
fn set_fluid_backend_from_builder(
&mut self,
backend: Arc<dyn FluidBackend>,
) {
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
if self.fluid_backend.is_none() {
self.fluid_backend = Some(backend);
}
@@ -517,10 +517,7 @@ impl Component for FreeCoolingExchanger {
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
}
fn port_enthalpies(
&self,
_state: &[f64],
) -> Result<Vec<Enthalpy>, ComponentError> {
fn port_enthalpies(&self, _state: &[f64]) -> Result<Vec<Enthalpy>, ComponentError> {
Ok(vec![
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_INLET)),
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_OUTLET)),
@@ -532,7 +529,7 @@ impl Component for FreeCoolingExchanger {
fn signature(&self) -> String {
format!(
"FreeCoolingExchanger(id={},eff={},ua={},mode={:?},f_ua={},f_dp={})",
self.id, self.config.effectiveness, self.config.ua, self.mode, self.f_ua, self.f_dp
self.id, self.config.effectiveness, self.config.ua, self.mode, self.z_ua, self.z_dp
)
}
@@ -547,19 +544,19 @@ impl Component for FreeCoolingExchanger {
.with_param("coldCp", self.config.cold_cp)
.with_param("hotCp", self.config.hot_cp)
.with_param("bypassFraction", self.config.bypass_fraction)
.with_param("f_ua", self.f_ua)
.with_param("f_dp", self.f_dp)
.with_param("f_ua", self.z_ua)
.with_param("f_dp", self.z_dp)
.with_param("mode", format!("{:?}", self.mode))
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
match factor {
"f_ua" => {
self.f_ua = value;
self.z_ua = value;
true
}
"f_dp" => {
self.f_dp = value;
self.z_dp = value;
true
}
_ => false,
@@ -575,7 +572,7 @@ impl Default for FreeCoolingConfig {
min_outdoor_temp: 285.15,
hysteresis: 2.0,
control_mode: FreeCoolingControlMode::AutoTemperature,
ua: 10_000.0, // 10 kW/K typical for plate HX
ua: 10_000.0, // 10 kW/K typical for plate HX
cold_mass_flow: 0.5,
hot_mass_flow: 0.5,
cold_cp: CP_WATER,
@@ -712,9 +709,7 @@ mod tests {
bypass_fraction: 0.3,
};
let expected = 85.0 * 0.3;
assert!(
(exchanger.energy_savings_percent() - expected).abs() < 1e-10
);
assert!((exchanger.energy_savings_percent() - expected).abs() < 1e-10);
}
#[test]
@@ -754,11 +749,8 @@ mod tests {
#[test]
fn test_residuals_bypass_mode() {
let (ci, co, hi, ho) = make_connected_ports_with(
Pressure::from_pascals(3e5),
50_000.0,
105_000.0,
);
let (ci, co, hi, ho) =
make_connected_ports_with(Pressure::from_pascals(3e5), 50_000.0, 105_000.0);
let fc = FreeCoolingExchanger::new(
"fc_test",
CircuitId(0),
@@ -794,7 +786,7 @@ mod tests {
// With f_ua calib index
let mut fc = fc;
fc.calib_indices.f_ua = Some(100);
fc.calib_indices.z_ua = Some(100);
let mut jb = JacobianBuilder::new();
fc.jacobian_entries(&[], &mut jb).unwrap();
assert!(!jb.entries().is_empty(), "Should have f_ua entries");
@@ -811,7 +803,7 @@ mod tests {
#[test]
fn test_jacobian_with_f_dp() {
let mut fc = make_exchanger_active();
fc.calib_indices.f_dp = Some(200);
fc.calib_indices.z_dp = Some(200);
fc.config.cold_dp_nominal = 5000.0;
let mut jb = JacobianBuilder::new();
@@ -843,7 +835,7 @@ mod tests {
fn test_calibration_scaling() {
let fc1 = make_exchanger_active();
let mut fc2 = make_exchanger_active();
fc2.f_ua = 1.5; // 50% higher UA
fc2.z_ua = 1.5; // 50% higher UA
let mut r1 = vec![0.0; N_EQUATIONS];
let mut r2 = vec![0.0; N_EQUATIONS];
@@ -875,13 +867,13 @@ mod tests {
fn test_set_calib_indices() {
let mut fc = make_exchanger_active();
let indices = CalibIndices {
f_ua: Some(10),
f_dp: Some(20),
z_ua: Some(10),
z_dp: Some(20),
..Default::default()
};
fc.set_calib_indices(indices);
assert_eq!(fc.calib_indices.f_ua, Some(10));
assert_eq!(fc.calib_indices.f_dp, Some(20));
assert_eq!(fc.calib_indices.z_ua, Some(10));
assert_eq!(fc.calib_indices.z_dp, Some(20));
}
#[test]