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

690
crates/cli/src/seasonal.rs Normal file
View File

@@ -0,0 +1,690 @@
//! Seasonal bin-method ratings — **SCOP** (heating) and **SEER** (cooling).
//!
//! Implements the EN 14825 temperature-bin method on top of the genuinely coupled
//! cycle solve. For each temperature bin of a climate, the machine is re-solved
//! with its *outdoor* heat-exchanger secondary inlet driven to the bin
//! temperature, yielding a real full-capacity duty and COP at that condition. A
//! linear building load line gives the demand at each bin; part-load cycling
//! degradation and (for heating) an electric backup heater are applied; and the
//! bins are aggregated into the seasonal metric
//!
//! ```text
//! SCOP or SEER = Σ (hoursⱼ · demandⱼ) / Σ (hoursⱼ · demandⱼ / COPⱼ)
//! ```
//!
//! Nothing is imposed: every bin's efficiency emerges from the coupled
//! heat-exchanger ↔ secondary balance. The climate (bin table), the building load
//! line and the degradation/backup coefficients are all data, so a revised norm or
//! a different climate is a config change, not a code change.
//!
//! # Physics summary (per bin `j` at outdoor temperature `Tⱼ`)
//!
//! 1. **Demand** from the linear building load line
//! (heating: `demand = design_load · (T_threshold Tⱼ)/(T_threshold T_design)`;
//! cooling: `demand = design_load · (Tⱼ T_threshold)/(T_design T_threshold)`),
//! clamped to `≥ 0`.
//! 2. **Full-capacity solve** with the outdoor secondary inlet set to `Tⱼ` gives
//! the machine's useful duty `Q_full` and compressor power `W_full`, hence
//! `COP_full = Q_full / W_full`.
//! 3. **Capacity ratio** `CR = demand / Q_full`.
//! - `CR ≤ 1`: the machine modulates/cycles to match demand. Cycling losses are
//! captured by a part-load factor `PLF = 1 Cd·(1 CR)`, giving
//! `COP_bin = COP_full · PLF`.
//! - `CR > 1` (heating only): the machine runs full and an electric backup of
//! efficiency `backup_cop` covers the deficit, so
//! `COP_bin = demand / (W_full + (demand Q_full)/backup_cop)`.
//! For cooling the load is capped at capacity (`CR = 1`, no backup).
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use entropyk::rating::{scop, BinClimateStandard, BinPerformance};
use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};
use crate::run::{simulate_from_json, SimulationStatus};
/// Which seasonal bin metric to compute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeasonalMode {
/// Seasonal Coefficient Of Performance (heating). Useful duty = heating.
Scop,
/// Seasonal Energy Efficiency Ratio (cooling). Useful duty = cooling.
Seer,
}
impl SeasonalMode {
fn label(self) -> &'static str {
match self {
SeasonalMode::Scop => "SCOP",
SeasonalMode::Seer => "SEER",
}
}
/// The heat-exchanger side whose secondary inlet is driven by the outdoor bin
/// temperature, by default (heat pump: outdoor = evaporator; chiller: outdoor
/// = condenser).
fn default_outdoor_side(self) -> OutdoorSide {
match self {
SeasonalMode::Scop => OutdoorSide::Evaporator,
SeasonalMode::Seer => OutdoorSide::Condenser,
}
}
}
/// Which heat exchanger the outdoor (ambient) temperature drives.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutdoorSide {
/// The evaporator secondary inlet follows the outdoor temperature.
Evaporator,
/// The condenser secondary inlet follows the outdoor temperature.
Condenser,
}
impl OutdoorSide {
/// Component types matched when applying the outdoor temperature override.
#[allow(dead_code)]
fn matches(self, component_type: &str) -> bool {
match self {
OutdoorSide::Evaporator => {
matches!(component_type, "Evaporator" | "FloodedEvaporator")
}
OutdoorSide::Condenser => component_type == "Condenser",
}
}
}
/// Seasonal rating configuration (JSON).
///
/// The climate (bin table) is selected — in order of precedence — by an inline
/// [`climate`](Self::climate) object, a [`climate_file`](Self::climate_file) path,
/// a [`climate_name`](Self::climate_name) built-in id, or (for SCOP) the default
/// EN 14825 average heating season.
#[derive(Debug, Clone, Deserialize)]
pub struct SeasonalConfig {
/// Path to the base cycle configuration (a `run` scenario file), resolved
/// relative to this config's directory when not absolute.
pub base_config: PathBuf,
/// Inline custom [`BinClimateStandard`]. Highest precedence.
#[serde(default)]
pub climate: Option<BinClimateStandard>,
/// Path to a custom [`BinClimateStandard`] JSON file (resolved relative to
/// this config's directory). Overrides [`climate_name`](Self::climate_name).
#[serde(default)]
pub climate_file: Option<PathBuf>,
/// Built-in climate id (e.g. `"en_14825_average"`).
#[serde(default)]
pub climate_name: Option<String>,
/// Which heat-exchanger side the outdoor temperature drives. Defaults by mode
/// (SCOP → evaporator, SEER → condenser).
#[serde(default)]
pub outdoor_side: Option<OutdoorSide>,
/// Building design load [W] at the design outdoor temperature.
pub design_load_w: f64,
/// Design outdoor temperature [°C] where demand equals `design_load_w`.
pub design_outdoor_c: f64,
/// Outdoor temperature [°C] at which building demand reaches zero
/// (heating/cooling threshold). Default 16 °C.
#[serde(default = "default_threshold_c")]
pub threshold_outdoor_c: f64,
/// Cycling degradation coefficient `Cd` for part-load operation
/// (`PLF = 1 Cd·(1 CR)`). Default 0.25; set per the applicable standard or
/// measurement.
#[serde(default = "default_cd")]
pub cd: f64,
/// Efficiency (COP) of the electric backup heater used when demand exceeds
/// capacity (heating only). Default 1.0 (resistive).
#[serde(default = "default_backup_cop")]
pub backup_cop: f64,
}
fn default_threshold_c() -> f64 {
16.0
}
fn default_cd() -> f64 {
0.25
}
fn default_backup_cop() -> f64 {
1.0
}
/// Per-bin result row.
#[derive(Debug, Clone, Serialize)]
pub struct BinResult {
/// Outdoor bin temperature [°C].
pub temperature_c: f64,
/// Annual hours in this bin.
pub hours: f64,
/// Building demand at this bin [W].
pub demand_w: f64,
/// Machine full-capacity useful duty at this bin [W] (heating or cooling).
pub capacity_w: Option<f64>,
/// Compressor power at full capacity [W].
pub power_w: Option<f64>,
/// Full-capacity COP at this bin.
pub full_cop: Option<f64>,
/// Capacity ratio `demand / capacity` (before clamping).
pub capacity_ratio: Option<f64>,
/// Fraction of demand supplied by the electric backup heater (heating only).
pub backup_fraction: f64,
/// Effective COP after part-load degradation / backup.
pub effective_cop: Option<f64>,
/// Solver status ("converged", "non_converged", "error", "no_demand").
pub status: String,
/// Error message when the bin solve failed.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Full seasonal rating report.
#[derive(Debug, Clone, Serialize)]
pub struct SeasonalReport {
/// Base configuration path.
pub base_config: String,
/// Metric label ("SCOP" or "SEER").
pub metric: String,
/// Climate name.
pub climate: String,
/// Climate citation / provenance.
pub climate_reference: String,
/// Total annual hours across the climate's bins.
pub total_hours: f64,
/// Total seasonal useful energy delivered [kWh].
pub seasonal_useful_kwh: f64,
/// Total seasonal electrical energy consumed [kWh].
pub seasonal_electric_kwh: f64,
/// Per-bin rows (ascending temperature).
pub bins: Vec<BinResult>,
/// The integrated seasonal metric, `None` if any demanded bin failed.
pub integrated_value: Option<f64>,
}
impl SeasonalConfig {
/// Resolve the effective climate, honouring precedence `climate` >
/// `climate_file` > `climate_name` > default (EN 14825 average).
pub fn resolve_climate(
&self,
mode: SeasonalMode,
config_dir: Option<&Path>,
) -> CliResult<BinClimateStandard> {
let climate = if let Some(inline) = &self.climate {
inline.clone()
} else if let Some(file) = &self.climate_file {
let path = if file.is_absolute() {
file.clone()
} else {
config_dir
.map(|d| d.join(file))
.unwrap_or_else(|| file.clone())
};
let raw = std::fs::read_to_string(&path).map_err(|e| {
CliError::Config(format!(
"Failed to read climate file {}: {}",
path.display(),
e
))
})?;
serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid climate file: {}", e)))?
} else if let Some(name) = &self.climate_name {
BinClimateStandard::builtin(name).ok_or_else(|| {
CliError::Config(format!(
"Unknown built-in climate '{}'. Known ids: {}",
name,
BinClimateStandard::builtin_ids().join(", ")
))
})?
} else {
// SEER has no shipped cooling-season preset; require an explicit climate.
if mode == SeasonalMode::Seer {
return Err(CliError::Config(
"SEER requires an explicit cooling-season climate (set 'climate', \
'climate_file' or 'climate_name')"
.to_string(),
));
}
BinClimateStandard::en_14825_average()
};
climate
.validate()
.map_err(|e| CliError::Config(format!("Invalid climate: {}", e)))?;
Ok(climate)
}
/// Building demand [W] at outdoor temperature `t_c`, from the linear load line
/// (clamped to `≥ 0`).
fn demand_at(&self, mode: SeasonalMode, t_c: f64) -> f64 {
let span = self.design_outdoor_c - self.threshold_outdoor_c;
if span.abs() < f64::EPSILON {
return 0.0;
}
let ratio = match mode {
// Heating: colder ⇒ more demand (design is the cold extreme).
SeasonalMode::Scop => {
(self.threshold_outdoor_c - t_c)
/ (self.threshold_outdoor_c - self.design_outdoor_c)
}
// Cooling: hotter ⇒ more demand (design is the hot extreme).
SeasonalMode::Seer => {
(t_c - self.threshold_outdoor_c)
/ (self.design_outdoor_c - self.threshold_outdoor_c)
}
};
(self.design_load_w * ratio).max(0.0)
}
}
/// Applies the outdoor-temperature override to a clone of the base config.
fn apply_outdoor_temp(base: &ScenarioConfig, side: OutdoorSide, temp_c: f64) -> ScenarioConfig {
let target_keyword = match side {
OutdoorSide::Evaporator => "evap",
OutdoorSide::Condenser => "cond",
};
let mut config = base.clone();
for circuit in &mut config.circuits {
for comp in &mut circuit.components {
if !comp.name.to_lowercase().contains(target_keyword) {
continue;
}
match comp.component_type.as_str() {
"BrineSource" => {
comp.params.insert("t_set_c".into(), json_num(temp_c));
}
"AirSource" => {
comp.params.insert("t_dry_c".into(), json_num(temp_c));
}
_ => {}
}
}
}
config
}
fn json_num(x: f64) -> serde_json::Value {
serde_json::Number::from_f64(x)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}
fn status_label(status: &SimulationStatus) -> &'static str {
match status {
SimulationStatus::Converged => "converged",
SimulationStatus::Timeout => "timeout",
SimulationStatus::NonConverged => "non_converged",
SimulationStatus::Error => "error",
}
}
/// Solves one bin at full capacity and derives its effective COP.
fn solve_bin(
base: &ScenarioConfig,
config: &SeasonalConfig,
mode: SeasonalMode,
side: OutdoorSide,
temperature_c: f64,
hours: f64,
) -> BinResult {
let demand_w = config.demand_at(mode, temperature_c);
// Bins with no building demand contribute no energy — skip the solve.
if demand_w <= 0.0 {
return BinResult {
temperature_c,
hours,
demand_w,
capacity_w: None,
power_w: None,
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status: "no_demand".to_string(),
error: None,
};
}
let scenario = apply_outdoor_temp(base, side, temperature_c);
let json = match serde_json::to_string(&scenario) {
Ok(j) => j,
Err(e) => return bin_error(temperature_c, hours, demand_w, format!("serialize: {e}")),
};
let result = match simulate_from_json(&json) {
Ok(r) => r,
Err(e) => return bin_error(temperature_c, hours, demand_w, format!("{e}")),
};
let status = status_label(&result.status).to_string();
if result.status != SimulationStatus::Converged {
return BinResult {
temperature_c,
hours,
demand_w,
capacity_w: None,
power_w: None,
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status,
error: result.error,
};
}
let perf = result.performance.as_ref();
let useful_kw = match mode {
SeasonalMode::Scop => perf.and_then(|p| p.q_heating_kw),
SeasonalMode::Seer => perf.and_then(|p| p.q_cooling_kw),
};
let power_kw = perf.and_then(|p| p.compressor_power_kw);
let (capacity_w, power_w) = match (useful_kw, power_kw) {
(Some(q), Some(w)) if q > 0.0 && w > 0.0 => (q * 1000.0, w * 1000.0),
_ => {
return BinResult {
temperature_c,
hours,
demand_w,
capacity_w: useful_kw.map(|q| q * 1000.0),
power_w: power_kw.map(|w| w * 1000.0),
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status,
error: Some("non-physical capacity or power at this bin".to_string()),
}
}
};
let full_cop = capacity_w / power_w;
let capacity_ratio = demand_w / capacity_w;
let (effective_cop, backup_fraction) = if capacity_ratio <= 1.0 {
// Part load: cycling degradation, no backup.
let plf = 1.0 - config.cd * (1.0 - capacity_ratio);
(full_cop * plf, 0.0)
} else if mode == SeasonalMode::Scop {
// Demand exceeds capacity: machine runs full, electric backup covers the rest.
let deficit = demand_w - capacity_w;
let elec = power_w + deficit / config.backup_cop.max(f64::EPSILON);
(demand_w / elec, deficit / demand_w)
} else {
// Cooling: cap at capacity (no backup); treat as full load.
(full_cop, 0.0)
};
BinResult {
temperature_c,
hours,
demand_w,
capacity_w: Some(capacity_w),
power_w: Some(power_w),
full_cop: Some(full_cop),
capacity_ratio: Some(capacity_ratio),
backup_fraction,
effective_cop: Some(effective_cop),
status,
error: None,
}
}
fn bin_error(temperature_c: f64, hours: f64, demand_w: f64, msg: String) -> BinResult {
BinResult {
temperature_c,
hours,
demand_w,
capacity_w: None,
power_w: None,
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status: "error".to_string(),
error: Some(msg),
}
}
/// Runs the seasonal rating: solves every demanded bin (in parallel) and
/// aggregates the SCOP/SEER.
pub fn seasonal(
config: &SeasonalConfig,
base: &ScenarioConfig,
climate: &BinClimateStandard,
mode: SeasonalMode,
) -> CliResult<SeasonalReport> {
let side = config
.outdoor_side
.unwrap_or_else(|| mode.default_outdoor_side());
let mut bins: Vec<BinResult> = climate
.bins
.par_iter()
.map(|b| solve_bin(base, config, mode, side, b.temperature_c, b.hours))
.collect();
bins.sort_by(|a, b| {
a.temperature_c
.partial_cmp(&b.temperature_c)
.unwrap_or(std::cmp::Ordering::Equal)
});
// A demanded bin that failed to converge makes the seasonal value unreliable.
let any_demanded_failed = bins
.iter()
.any(|b| b.demand_w > 0.0 && b.effective_cop.is_none());
let perf: Vec<BinPerformance> = bins
.iter()
.filter_map(|b| {
b.effective_cop.map(|cop| BinPerformance {
bin: entropyk::rating::TemperatureBin {
temperature_c: b.temperature_c,
hours: b.hours,
},
demand_w: b.demand_w,
cop,
})
})
.collect();
let integrated_value = if any_demanded_failed {
None
} else {
scop(&perf)
};
// Seasonal energies (kWh): Σ h·demand and Σ h·demand/cop.
let seasonal_useful_kwh: f64 =
perf.iter().map(|p| p.bin.hours * p.demand_w).sum::<f64>() / 1000.0;
let seasonal_electric_kwh: f64 = perf
.iter()
.map(|p| p.bin.hours * p.demand_w / p.cop)
.sum::<f64>()
/ 1000.0;
Ok(SeasonalReport {
base_config: config.base_config.display().to_string(),
metric: mode.label().to_string(),
climate: climate.name.clone(),
climate_reference: climate.reference.clone(),
total_hours: climate.total_hours(),
seasonal_useful_kwh,
seasonal_electric_kwh,
bins,
integrated_value,
})
}
/// Loads a seasonal config from JSON, resolves the base scenario and climate,
/// runs the rating, and optionally writes the JSON report.
pub fn run_seasonal(
config_path: &Path,
output: Option<&Path>,
mode: SeasonalMode,
) -> CliResult<SeasonalReport> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
})?;
let config: SeasonalConfig = serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid seasonal config: {}", e)))?;
let config_dir = config_path.parent();
let base_path = if config.base_config.is_absolute() {
config.base_config.clone()
} else {
config_dir
.map(|d| d.join(&config.base_config))
.unwrap_or_else(|| config.base_config.clone())
};
let base = ScenarioConfig::from_file(&base_path)?;
let climate = config.resolve_climate(mode, config_dir)?;
let report = seasonal(&config, &base, &climate, mode)?;
if let Some(out) = output {
let json = serde_json::to_string_pretty(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
std::fs::write(out, json)
.map_err(|e| CliError::Config(format!("Failed to write {}: {}", out.display(), e)))?;
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> SeasonalConfig {
SeasonalConfig {
base_config: PathBuf::from("x.json"),
climate: None,
climate_file: None,
climate_name: None,
outdoor_side: None,
design_load_w: 10_000.0,
design_outdoor_c: -10.0,
threshold_outdoor_c: 16.0,
cd: 0.25,
backup_cop: 1.0,
}
}
#[test]
fn demand_line_is_linear_and_clamped_for_heating() {
let c = cfg();
// At design temperature, demand == design load.
assert!((c.demand_at(SeasonalMode::Scop, -10.0) - 10_000.0).abs() < 1e-9);
// At threshold, demand == 0.
assert!(c.demand_at(SeasonalMode::Scop, 16.0).abs() < 1e-9);
// Above threshold, clamped to 0 (no heating needed).
assert_eq!(c.demand_at(SeasonalMode::Scop, 20.0), 0.0);
// Midpoint (3 °C) → half load.
assert!((c.demand_at(SeasonalMode::Scop, 3.0) - 5_000.0).abs() < 1e-6);
}
#[test]
fn demand_line_reverses_for_cooling() {
let mut c = cfg();
c.design_outdoor_c = 35.0;
c.threshold_outdoor_c = 16.0;
// Hot design → full load; threshold → zero; below threshold clamped.
assert!((c.demand_at(SeasonalMode::Seer, 35.0) - 10_000.0).abs() < 1e-9);
assert!(c.demand_at(SeasonalMode::Seer, 16.0).abs() < 1e-9);
assert_eq!(c.demand_at(SeasonalMode::Seer, 10.0), 0.0);
}
#[test]
fn part_load_degrades_cop_via_cd() {
// With CR=0.5 and Cd=0.25, PLF = 1 - 0.25*0.5 = 0.875.
let c = cfg();
let cr = 0.5;
let plf = 1.0 - c.cd * (1.0 - cr);
assert!((plf - 0.875).abs() < 1e-12);
}
#[test]
fn default_outdoor_side_follows_mode() {
assert_eq!(
SeasonalMode::Scop.default_outdoor_side(),
OutdoorSide::Evaporator
);
assert_eq!(
SeasonalMode::Seer.default_outdoor_side(),
OutdoorSide::Condenser
);
}
#[test]
fn seer_requires_explicit_climate() {
let c = cfg();
assert!(c.resolve_climate(SeasonalMode::Seer, None).is_err());
// SCOP falls back to the EN 14825 average season.
let climate = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
assert!((climate.total_hours() - 4910.0).abs() < 1e-6);
}
#[test]
fn resolve_climate_precedence_and_builtin() {
let mut c = cfg();
c.climate_name = Some("average".to_string());
let climate = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
assert_eq!(climate.total_hours(), 4910.0);
// Inline wins over name.
c.climate = Some(BinClimateStandard {
name: "tiny".to_string(),
reference: String::new(),
bins: vec![entropyk::rating::TemperatureBin {
temperature_c: 0.0,
hours: 100.0,
}],
});
let climate2 = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
assert_eq!(climate2.name, "tiny");
}
#[test]
fn outdoor_side_matches_expected_components() {
assert!(OutdoorSide::Evaporator.matches("Evaporator"));
assert!(OutdoorSide::Evaporator.matches("FloodedEvaporator"));
assert!(!OutdoorSide::Evaporator.matches("Condenser"));
assert!(OutdoorSide::Condenser.matches("Condenser"));
assert!(!OutdoorSide::Condenser.matches("Evaporator"));
}
#[test]
fn apply_outdoor_temp_sets_only_matching_side() {
let base = ScenarioConfig::from_json(
r#"{
"fluid": "R134a",
"circuits": [{ "id": 0, "components": [
{ "type": "Condenser", "name": "cond", "ua": 700.0, "secondary_fluid": "Water" },
{ "type": "Evaporator", "name": "evap", "ua": 1400.0, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.4 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
], "edges": [] }],
"solver": { "strategy": "fallback", "max_iterations": 100, "tolerance": 1e-6 }
}"#,
)
.unwrap();
let out = apply_outdoor_temp(&base, OutdoorSide::Evaporator, -7.0);
let comps = &out.circuits[0].components;
let evap_src = comps.iter().find(|c| c.name == "evap_water_in").unwrap();
let cond_src = comps.iter().find(|c| c.name == "cond_water_in").unwrap();
assert_eq!(evap_src.params.get("t_set_c").unwrap().as_f64(), Some(-7.0));
// Condenser (indoor) is untouched.
assert_eq!(cond_src.params.get("t_set_c").unwrap().as_f64(), Some(30.0));
}
}