//! 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, /// 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, /// Built-in climate id (e.g. `"en_14825_average"`). #[serde(default)] pub climate_name: Option, /// Which heat-exchanger side the outdoor temperature drives. Defaults by mode /// (SCOP → evaporator, SEER → condenser). #[serde(default)] pub outdoor_side: Option, /// 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, /// Compressor power at full capacity [W]. pub power_w: Option, /// Full-capacity COP at this bin. pub full_cop: Option, /// Capacity ratio `demand / capacity` (before clamping). pub capacity_ratio: Option, /// 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, /// 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, } /// 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, /// The integrated seasonal metric, `None` if any demanded bin failed. pub integrated_value: Option, } 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 { 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 { let side = config .outdoor_side .unwrap_or_else(|| mode.default_outdoor_side()); let mut bins: Vec = 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 = 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::() / 1000.0; let seasonal_electric_kwh: f64 = perf .iter() .map(|p| p.bin.hours * p.demand_w / p.cop) .sum::() / 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 { 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)); } }