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>
691 lines
24 KiB
Rust
691 lines
24 KiB
Rust
//! 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));
|
||
}
|
||
}
|