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

@@ -163,10 +163,29 @@ pub struct Fan<State> {
air_density_kg_per_m3: f64,
/// Speed ratio (0.0 to 1.0)
speed_ratio: f64,
/// VFD part-load efficiency curve (quadratic in speed ratio), default ≈0.97.
vfd_eff_coeffs: [f64; 3],
/// Motor part-load efficiency curve (quadratic in speed ratio), default ≈0.92.
motor_eff_coeffs: [f64; 3],
/// When true, `fan_power` returns wire-to-air electrical power.
use_drive_chain: bool,
/// Circuit identifier
circuit_id: CircuitId,
/// Operational state
operational_state: OperationalState,
/// When true, the fan participates in the (P,h) graph solver as a 2-port
/// element imposing a design-point static pressure rise.
edge_coupled: bool,
/// Design volumetric flow (m³/s) at which the curve pressure rise is read.
design_flow_m3_s: f64,
/// Captured (m,P,h) global state indices of the inlet edge (incoming).
inlet_m_idx: Option<usize>,
inlet_p_idx: Option<usize>,
inlet_h_idx: Option<usize>,
/// Captured (m,P,h) global state indices of the outlet edge (outgoing).
outlet_m_idx: Option<usize>,
outlet_p_idx: Option<usize>,
outlet_h_idx: Option<usize>,
/// Phantom data for type state
_state: PhantomData<State>,
}
@@ -204,12 +223,41 @@ impl Fan<Disconnected> {
port_outlet,
air_density_kg_per_m3: air_density,
speed_ratio: 1.0,
vfd_eff_coeffs: [0.97, 0.0, 0.0],
motor_eff_coeffs: [0.92, 0.0, 0.0],
use_drive_chain: false,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
edge_coupled: false,
design_flow_m3_s: 0.0,
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,
})
}
/// Enables BernierBourret wire-to-air drive chain (η_VFD × η_motor × η_fan).
pub fn with_drive_chain(mut self, enabled: bool) -> Self {
self.use_drive_chain = enabled;
self
}
/// Sets quadratic VFD efficiency coefficients η = a0 + a1·N* + a2·N*².
pub fn with_vfd_efficiency(mut self, a0: f64, a1: f64, a2: f64) -> Self {
self.vfd_eff_coeffs = [a0, a1, a2];
self
}
/// Sets quadratic motor efficiency coefficients η = a0 + a1·N* + a2·N*².
pub fn with_motor_efficiency(mut self, a0: f64, a1: f64, a2: f64) -> Self {
self.motor_eff_coeffs = [a0, a1, a2];
self
}
/// Returns the fluid identifier.
pub fn fluid_id(&self) -> &FluidId {
self.port_inlet.fluid_id()
@@ -235,6 +283,35 @@ impl Fan<Disconnected> {
self.speed_ratio = ratio;
Ok(())
}
/// Connects the fan to inlet and outlet ports, transitioning the type-state
/// from `Disconnected` to `Connected` at compile time.
pub fn connect(
self,
inlet: Port<Disconnected>,
outlet: Port<Disconnected>,
) -> Result<Fan<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()))?;
let mut fan = Fan::<Connected>::from_connected_parts(
self.curves,
p_in,
p_out,
self.air_density_kg_per_m3,
)?;
fan.set_speed_ratio(self.speed_ratio)?;
fan.vfd_eff_coeffs = self.vfd_eff_coeffs;
fan.motor_eff_coeffs = self.motor_eff_coeffs;
fan.use_drive_chain = self.use_drive_chain;
Ok(fan)
}
}
impl Fan<Connected> {
@@ -256,12 +333,36 @@ impl Fan<Connected> {
port_outlet,
air_density_kg_per_m3: air_density,
speed_ratio: 1.0,
vfd_eff_coeffs: [0.97, 0.0, 0.0],
motor_eff_coeffs: [0.92, 0.0, 0.0],
use_drive_chain: false,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
edge_coupled: false,
design_flow_m3_s: 0.0,
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,
})
}
/// Enables the edge-coupled (P,h) solver model, imposing a design-point
/// static pressure rise read from the fan curve at `design_flow_m3_s`
/// (and the current speed ratio). Shaft power is added to the air stream as
/// an enthalpy rise so the coupled model satisfies the First Law.
pub fn with_edge_coupling(mut self, design_flow_m3_s: f64) -> Self {
self.design_flow_m3_s = design_flow_m3_s.max(0.0);
self.edge_coupled = true;
if self.operational_state == OperationalState::Off {
self.operational_state = OperationalState::On;
}
self
}
/// Returns the inlet port.
pub fn port_inlet(&self) -> &Port<Connected> {
&self.port_inlet
@@ -341,10 +442,8 @@ impl Fan<Connected> {
self.curves.efficiency_at_flow(equivalent_flow)
}
/// Calculates the fan power consumption.
///
/// P_fan = Q × P_s / η
pub fn fan_power(&self, flow_m3_per_s: f64) -> Power {
/// Shaft aerodynamic power `Q × ΔP / η_fan` [W].
pub fn shaft_power(&self, flow_m3_per_s: f64) -> Power {
if flow_m3_per_s <= 0.0 || self.speed_ratio <= 0.0 {
return Power::from_watts(0.0);
}
@@ -360,6 +459,64 @@ impl Fan<Connected> {
Power::from_watts(power_w)
}
/// Drive-chain efficiency η_VFD(N*) × η_motor(N*) at the current speed ratio.
pub fn drive_chain_efficiency(&self) -> f64 {
let n = self.speed_ratio.clamp(0.0, 1.0);
let eta_vfd = (self.vfd_eff_coeffs[0]
+ self.vfd_eff_coeffs[1] * n
+ self.vfd_eff_coeffs[2] * n * n)
.clamp(0.05, 1.0);
let eta_motor = (self.motor_eff_coeffs[0]
+ self.motor_eff_coeffs[1] * n
+ self.motor_eff_coeffs[2] * n * n)
.clamp(0.05, 1.0);
eta_vfd * eta_motor
}
/// Fan power consumption.
///
/// Shaft power by default; electrical wire-to-air power when drive chain is enabled:
/// `P_elec = P_shaft / (η_VFD · η_motor)`.
pub fn fan_power(&self, flow_m3_per_s: f64) -> Power {
let shaft = self.shaft_power(flow_m3_per_s);
if !self.use_drive_chain {
return shaft;
}
let eta_chain = self.drive_chain_efficiency();
if eta_chain <= 0.0 {
return Power::from_watts(0.0);
}
Power::from_watts(shaft.to_watts() / eta_chain)
}
/// Enables or disables the wire-to-air drive chain.
pub fn set_drive_chain(&mut self, enabled: bool) {
self.use_drive_chain = enabled;
}
fn edge_coupled_flow_and_power(
&self,
state: &StateSlice,
inlet_m_idx: usize,
) -> Result<(f64, f64, f64), ComponentError> {
let mass_flow_kg_s = state.get(inlet_m_idx).copied().ok_or_else(|| {
ComponentError::InvalidState(format!(
"Fan edge-coupled inlet mass-flow index {inlet_m_idx} is outside the state vector"
))
})?;
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
let power_w = match self.operational_state {
OperationalState::Off | OperationalState::Bypass => 0.0,
OperationalState::On => self.fan_power(flow_m3_s).to_watts(),
};
let enthalpy_rise_j_kg = if mass_flow_kg_s.abs() > 1e-12 {
power_w / mass_flow_kg_s
} else {
0.0
};
Ok((flow_m3_s, power_w, enthalpy_rise_j_kg))
}
/// Calculates mass flow from volumetric flow.
pub fn mass_flow_from_volumetric(&self, flow_m3_per_s: f64) -> MassFlow {
MassFlow::from_kg_per_s(flow_m3_per_s * self.air_density_kg_per_m3)
@@ -398,97 +555,99 @@ impl Fan<Connected> {
}
impl Component for Fan<Connected> {
fn set_system_context(
&mut self,
_state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
// Layout: [0] = incoming edge, [1] = outgoing edge.
// Triple: (m_idx, p_idx, h_idx)
if !external_edge_state_indices.is_empty() {
self.inlet_m_idx = Some(external_edge_state_indices[0].0);
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
}
if external_edge_state_indices.len() >= 2 {
self.outlet_m_idx = Some(external_edge_state_indices[1].0);
self.outlet_p_idx = Some(external_edge_state_indices[1].1);
self.outlet_h_idx = Some(external_edge_state_indices[1].2);
}
}
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() != self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
match self.operational_state {
OperationalState::Off => {
residuals[0] = state[0];
residuals[1] = 0.0;
// Edge-coupled (P,h) model: impose a design-point static pressure rise.
if self.edge_coupled {
if let (Some(in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
self.inlet_m_idx,
self.inlet_p_idx,
self.inlet_h_idx,
self.outlet_p_idx,
self.outlet_h_idx,
) {
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: 2,
actual: residuals.len(),
});
}
let dp = match self.operational_state {
OperationalState::Off => 0.0,
_ => {
let (flow_m3_s, _, _) = self.edge_coupled_flow_and_power(state, in_m)?;
self.static_pressure_rise(flow_m3_s)
}
};
let (_, _, enthalpy_rise_j_kg) = self.edge_coupled_flow_and_power(state, in_m)?;
// r0: imposed static pressure rise (fan adds pressure)
residuals[0] = state[out_p] - (state[in_p] + dp);
// r1: adiabatic fan casing, shaft power heats the air stream
residuals[1] = state[out_h] - (state[in_h] + enthalpy_rise_j_kg);
return Ok(());
}
OperationalState::Bypass => {
let p_in = self.port_inlet.pressure().to_pascals();
let p_out = self.port_outlet.pressure().to_pascals();
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
residuals[0] = p_in - p_out;
residuals[1] = h_in - h_out;
return Ok(());
}
OperationalState::On => {}
return Err(ComponentError::InvalidState(
"Fan edge-coupled model requires inlet and outlet edge state indices".to_string(),
));
}
if state.len() < 2 {
return Err(ComponentError::InvalidStateDimensions {
expected: 2,
actual: state.len(),
});
}
let mass_flow_kg_s = state[0];
let _power_w = state[1];
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
let delta_p_calc = self.static_pressure_rise(flow_m3_s);
let p_in = self.port_inlet.pressure().to_pascals();
let p_out = self.port_outlet.pressure().to_pascals();
let delta_p_actual = p_out - p_in;
residuals[0] = delta_p_calc - delta_p_actual;
let power_calc = self.fan_power(flow_m3_s).to_watts();
residuals[1] = power_calc - _power_w;
Ok(())
Err(ComponentError::InvalidState(
"Fan physical simulation requires edge-coupled inlet/outlet state indices".to_string(),
))
}
fn jacobian_entries(
&self,
state: &StateSlice,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
if state.len() < 2 {
return Err(ComponentError::InvalidStateDimensions {
expected: 2,
actual: state.len(),
});
// Edge-coupled (P,h) model.
if self.edge_coupled {
if let (Some(_in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
self.inlet_m_idx,
self.inlet_p_idx,
self.inlet_h_idx,
self.outlet_p_idx,
self.outlet_h_idx,
) {
// r0 = P_out - (P_in + dp)
jacobian.add_entry(0, out_p, 1.0);
jacobian.add_entry(0, in_p, -1.0);
// r1 = h_out - h_in
jacobian.add_entry(1, out_h, 1.0);
jacobian.add_entry(1, in_h, -1.0);
return Ok(());
}
return Err(ComponentError::InvalidState(
"Fan edge-coupled model requires inlet and outlet edge state indices".to_string(),
));
}
let mass_flow_kg_s = state[0];
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
let h = 0.001;
let p_plus = self.static_pressure_rise(flow_m3_s + h / self.air_density_kg_per_m3);
let p_minus = self.static_pressure_rise(flow_m3_s - h / self.air_density_kg_per_m3);
let dp_dm = (p_plus - p_minus) / (2.0 * h);
jacobian.add_entry(0, 0, dp_dm);
jacobian.add_entry(0, 1, 0.0);
let pow_plus = self
.fan_power(flow_m3_s + h / self.air_density_kg_per_m3)
.to_watts();
let pow_minus = self
.fan_power(flow_m3_s - h / self.air_density_kg_per_m3)
.to_watts();
let dpow_dm = (pow_plus - pow_minus) / (2.0 * h);
jacobian.add_entry(1, 0, dpow_dm);
jacobian.add_entry(1, 1, -1.0);
Ok(())
Err(ComponentError::InvalidState(
"Fan physical simulation requires edge-coupled inlet/outlet state indices".to_string(),
))
}
fn n_equations(&self) -> usize {
@@ -503,30 +662,57 @@ impl Component for Fan<Connected> {
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
if state.len() < 1 {
return Err(ComponentError::InvalidStateDimensions {
expected: 1,
actual: state.len(),
});
if self.edge_coupled {
let (Some(in_m), Some(out_m)) = (self.inlet_m_idx, self.outlet_m_idx) else {
return Err(ComponentError::InvalidState(
"Fan edge-coupled model requires inlet and outlet mass-flow indices"
.to_string(),
));
};
let max_idx = in_m.max(out_m);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
return Ok(vec![
entropyk_core::MassFlow::from_kg_per_s(state[in_m]),
entropyk_core::MassFlow::from_kg_per_s(-state[out_m]),
]);
}
// Fan has inlet and outlet with same mass flow (air is incompressible for HVAC applications)
let m = entropyk_core::MassFlow::from_kg_per_s(state[0]);
// Inlet (positive = entering), Outlet (negative = leaving)
Ok(vec![
m,
entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s()),
])
Err(ComponentError::InvalidState(
"Fan mass-flow reporting requires edge-coupled inlet/outlet indices".to_string(),
))
}
fn port_enthalpies(
&self,
_state: &StateSlice,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
// Fan uses internally simulated enthalpies
Ok(vec![
self.port_inlet.enthalpy(),
self.port_outlet.enthalpy(),
])
if self.edge_coupled {
let (Some(in_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) else {
return Err(ComponentError::InvalidState(
"Fan edge-coupled model requires inlet and outlet enthalpy indices".to_string(),
));
};
let max_idx = in_h.max(out_h);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
return Ok(vec![
entropyk_core::Enthalpy::from_joules_per_kg(state[in_h]),
entropyk_core::Enthalpy::from_joules_per_kg(state[out_h]),
]);
}
Err(ComponentError::InvalidState(
"Fan enthalpy reporting requires edge-coupled inlet/outlet indices".to_string(),
))
}
fn energy_transfers(
@@ -539,10 +725,21 @@ impl Component for Fan<Connected> {
entropyk_core::Power::from_watts(0.0),
)),
OperationalState::On => {
if state.is_empty() {
return None;
if self.edge_coupled {
let Some(in_m) = self.inlet_m_idx else {
return None;
};
let Ok((_, power_w, _)) = self.edge_coupled_flow_and_power(state, in_m) else {
return None;
};
return Some((
entropyk_core::Power::from_watts(0.0),
entropyk_core::Power::from_watts(-power_w),
));
}
let mass_flow_kg_s = state[0];
let in_m = self.inlet_m_idx?;
let mass_flow_kg_s = *state.get(in_m)?;
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
let power_calc = self.fan_power(flow_m3_s).to_watts();
Some((
@@ -632,12 +829,33 @@ mod tests {
port_outlet: outlet_conn,
air_density_kg_per_m3: 1.2,
speed_ratio: 1.0,
vfd_eff_coeffs: [0.97, 0.0, 0.0],
motor_eff_coeffs: [0.92, 0.0, 0.0],
use_drive_chain: false,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
edge_coupled: false,
design_flow_m3_s: 0.0,
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,
}
}
#[test]
fn test_wire_to_air_drive_chain_increases_power() {
let mut fan = create_test_fan_connected();
let shaft = fan.shaft_power(0.5).to_watts();
fan.set_drive_chain(true);
let elec = fan.fan_power(0.5).to_watts();
assert!(elec > shaft);
assert!((elec / shaft - 1.0 / (0.97 * 0.92)).abs() < 1e-6);
}
#[test]
fn test_fan_curves_creation() {
let curves = create_test_curves();