Files
Entropyk/crates/cli/src/seasonal.rs
sepehr 3358b74342 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>
2026-07-17 22:46:46 +02:00

691 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}
}