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

@@ -311,6 +311,9 @@ fn process_single_file(
state: None,
performance: None,
error: Some(e.to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
}
}
@@ -417,6 +420,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -427,6 +433,9 @@ mod tests {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -467,6 +476,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -477,6 +489,9 @@ mod tests {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -497,11 +512,17 @@ mod tests {
convergence: Some(crate::run::ConvergenceInfo {
final_residual: 1e-8,
tolerance: 1e-6,
iterations: Some(10),
strategy: None,
iteration_history: vec![],
}),
iterations: Some(10),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -512,6 +533,9 @@ mod tests {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -536,6 +560,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -546,6 +573,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 1000,
},
];
@@ -600,6 +630,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 100,
}],
};
@@ -620,6 +653,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -630,6 +666,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 60,
},
SimulationResult {
@@ -640,6 +679,9 @@ mod tests {
state: None,
performance: None,
error: Some("fail".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];

View File

@@ -3,14 +3,35 @@
//! This module defines the JSON schema for scenario configuration files
//! and provides utilities for loading and validating them.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::error::{CliError, CliResult};
/// The current Model IR schema version emitted and accepted by this build.
///
/// The IR is versioned so the schema (and any embedded standard/norm references)
/// can evolve without silently misreading older files. See [`SUPPORTED_SCHEMA_VERSIONS`].
pub const CURRENT_SCHEMA_VERSION: &str = "2";
/// Schema versions this build can load. `"1"` is the original flat
/// `circuits/components/edges` schema; `"2"` adds `controls`, `subsystems`,
/// `instances` and `connections`. Both are read by the same loader.
pub const SUPPORTED_SCHEMA_VERSIONS: &[&str] = &["1", "2"];
fn default_schema_version() -> String {
// Absent `schema_version` means a legacy v1 document.
"1".to_string()
}
/// Root configuration for a simulation scenario.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ScenarioConfig {
/// Model IR schema version (`"1"` legacy flat graph, `"2"` adds
/// controls/subsystems/instances/connections). Absent ⇒ `"1"`.
#[serde(default = "default_schema_version")]
pub schema_version: String,
/// Scenario name.
#[serde(default)]
pub name: Option<String>,
@@ -28,13 +49,190 @@ pub struct ScenarioConfig {
/// Solver configuration.
#[serde(default)]
pub solver: SolverConfig,
/// Optional solver start-value guesses. These are numerical initialization
/// hints only; they do not impose thermodynamic boundary conditions.
#[serde(default)]
pub initialization: Option<InitializationConfig>,
/// Steady-state control loops (co-solved saturated-PI controllers).
#[serde(default)]
pub controls: Vec<ControlConfig>,
/// Reusable subsystem templates (parameterized assemblies of components +
/// internal edges, exposing a reduced external port set). Flattened into
/// `circuits` at load time — the solver never sees the hierarchy.
#[serde(default)]
pub subsystems: HashMap<String, SubsystemTemplate>,
/// Instantiations of `subsystems` templates. Each instance is expanded into
/// concrete, name-prefixed components/edges in its target circuit.
#[serde(default)]
pub instances: Vec<InstanceConfig>,
/// External connections between instance ports (and/or literal `component:port`
/// endpoints). Resolved and routed into the appropriate circuit during flattening.
#[serde(default)]
pub connections: Vec<EdgeConfig>,
/// Optional metadata.
#[serde(default)]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
/// A reusable subsystem template: a parameterized assembly of components and
/// internal edges, exposing a reduced set of external ports.
///
/// This is Modelica-style `model` composition. Templates are declared once under
/// `subsystems` and instantiated any number of times via `instances`, each with
/// its own parameter overrides. At load time every instance is **flattened** into
/// the flat `circuits/components/edges` graph (component names prefixed by
/// `"{instance}."`), so the solver and every downstream consumer are unchanged.
///
/// # Parameter substitution
/// Any component parameter whose JSON value is a string of the form `"$name"` is
/// replaced by the resolved value of parameter `name` (instance override, falling
/// back to the template default in `params`). Because `ua`, `secondary_*`,
/// `isentropic_efficiency`, `t_cond_k`, … all flow through the component
/// `params` catch-all, this covers essentially every physical knob.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SubsystemTemplate {
/// Default parameter values, overridable per instance.
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
/// Components inside the template (names are local to the template).
pub components: Vec<ComponentConfig>,
/// Internal edges between the template's own components.
#[serde(default)]
pub edges: Vec<EdgeConfig>,
/// External ports: maps an exposed port name to an internal `"component:port"`
/// endpoint. `connections` reference these as `"{instance}.{external_port}"`.
#[serde(default)]
pub ports: HashMap<String, String>,
}
/// An instantiation of a [`SubsystemTemplate`].
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InstanceConfig {
/// Name of the template to instantiate (key in `subsystems`).
#[serde(rename = "of")]
pub template: String,
/// Unique instance name; becomes the `"{name}."` prefix on expanded components.
pub name: String,
/// Target circuit id the expanded components/edges are placed into (default 0).
#[serde(default)]
pub circuit: usize,
/// Parameter overrides for this instance (override template defaults).
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
}
/// A steady-state control loop declaration.
///
/// Currently supports the `SaturatedController` type: a saturated-PI loop with
/// anti-windup that is co-solved inside the Newton system. It drives a measured
/// plant output (`measure`) to `target` by manipulating an actuator factor
/// (`actuator`) within `[min, max]` bounds.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ControlConfig {
/// Controller type. Only `"SaturatedController"` is supported today.
#[serde(rename = "type", default = "default_control_type")]
pub control_type: String,
/// Unique identifier for this control loop.
pub id: String,
/// The measured plant output to regulate.
pub measure: MeasureConfig,
/// The manipulated actuator.
pub actuator: ActuatorConfig,
/// Target value for the measured output (SI units: W, K, Pa, kg/s).
pub target: f64,
/// Loop gain `K` (sign must match actuator→output sensitivity).
#[serde(default = "default_control_gain")]
pub gain: f64,
/// Saturation band half-width `Q > 0`.
#[serde(default = "default_control_band")]
pub band: f64,
/// If set, uses the C¹ smooth saturation with this `eps`; otherwise hard clamp.
#[serde(default)]
pub smooth_eps: Option<f64>,
/// Optional **override / selector** objectives. When non-empty, the primary
/// `measure`/`target`/`gain` above becomes the first objective and each entry
/// here is folded in via its `combine` (`min`/`max`) selector — one actuator
/// driven by several competing objectives (setpoint + envelope protections).
/// The fold order encodes priority (later = higher priority).
#[serde(default)]
pub objectives: Vec<ObjectiveConfig>,
/// Selector smoothing sharpness for the override network (`softMin`/`softMax`).
/// Smaller ⇒ sharper selection, larger ⇒ smoother/more robust. Default `1e-3`.
#[serde(default)]
pub alpha: Option<f64>,
}
/// One objective in an override/selector control network.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ObjectiveConfig {
/// Name of the component whose output is measured.
pub component: String,
/// Output kind: one of `capacity`, `heatTransferRate`, `superheat`,
/// `subcooling`, `saturationTemperature`, `massFlowRate`, `pressure`,
/// `temperature`.
pub output: String,
/// Target value for this objective (SI units: W, K, Pa, kg/s).
pub setpoint: f64,
/// Normalization/sign gain for this objective's error `gain·(setpointmeasure)`.
#[serde(default = "default_control_gain")]
pub gain: f64,
/// Selector applied between the running accumulator and this objective:
/// `"min"` or `"max"`.
pub combine: String,
}
/// Reference to a measurable component output.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MeasureConfig {
/// Name of the component whose output is measured.
pub component: String,
/// Output kind: one of `capacity`, `heatTransferRate`, `superheat`,
/// `subcooling`, `saturationTemperature`, `massFlowRate`, `pressure`,
/// `temperature`.
pub output: String,
}
/// Reference to a manipulated actuator on a component.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ActuatorConfig {
/// Name of the component carrying the actuator.
pub component: String,
/// Calibration Z-factor to manipulate: `z_flow`, `z_dp`, `z_ua`, `z_power`, or `z_etav`
/// (legacy `f_*` names and BOLT `Z_*` spellings are also accepted).
pub factor: String,
/// Initial actuator value (nominal, e.g. 1.0).
#[serde(default = "default_actuator_initial")]
pub initial: f64,
/// Lower bound for the actuator.
pub min: f64,
/// Upper bound for the actuator.
pub max: f64,
}
fn default_control_type() -> String {
"SaturatedController".to_string()
}
fn default_control_gain() -> f64 {
1.0
}
fn default_control_band() -> f64 {
1.0
}
fn default_actuator_initial() -> f64 {
1.0
}
/// Thermal coupling configuration between two circuits.
#[derive(Debug, Clone, Serialize, Deserialize)]
///
/// A coupling is **physical** only when both `hot_component` and
/// `cold_component` are set: the hot component's measured duty (e.g. a real
/// ε-NTU `Condenser` capacity) is transferred, scaled by `efficiency` and
/// `duty_scale`, into the cold circuit's receiver energy balance. Without them the coupling
/// is an inert legacy stub (Q pinned to 0) and the CLI warns.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ThermalCouplingConfig {
/// Hot circuit ID.
pub hot_circuit: usize,
@@ -45,18 +243,43 @@ pub struct ThermalCouplingConfig {
/// Heat exchanger efficiency (0.0 to 1.0).
#[serde(default = "default_efficiency")]
pub efficiency: f64,
/// Signed multiplier applied to the measured duty before injection.
///
/// Use `1.0` for condenser water heating and `-1.0` for chilled-water
/// cooling from an evaporator duty.
#[serde(default = "default_duty_scale")]
pub duty_scale: f64,
/// Name of the hot-circuit component whose measured duty is transferred
/// (e.g. a `Condenser` with a configured secondary stream).
#[serde(default)]
pub hot_component: Option<String>,
/// Name of the cold-circuit `ThermalLoad` component that receives the heat.
#[serde(default)]
pub cold_component: Option<String>,
}
fn default_efficiency() -> f64 {
0.95
}
fn default_duty_scale() -> f64 {
1.0
}
/// Configuration for a single circuit.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CircuitConfig {
/// Circuit ID (default: 0).
#[serde(default)]
pub id: usize,
/// Whether this circuit is energized (default: true). A disabled circuit is
/// staged OFF: its components, edges and any thermal coupling that references
/// it are excluded from the solve entirely (zero mass flow, zero duty), which
/// is the physically correct steady-state behaviour for an unenergized
/// refrigerant circuit. Used for multi-circuit part-load staging (e.g. running
/// only circuit A of a dual-circuit chiller).
#[serde(default = "default_true")]
pub enabled: bool,
/// Components in this circuit.
pub components: Vec<ComponentConfig>,
/// Edge connections between components.
@@ -67,8 +290,12 @@ pub struct CircuitConfig {
pub initial_state: Option<InitialStateConfig>,
}
fn default_true() -> bool {
true
}
/// Configuration for a component.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ComponentConfig {
/// Component type (e.g., "Compressor", "Condenser", "Evaporator", "ExpansionValve", "HeatExchanger").
#[serde(rename = "type")]
@@ -99,7 +326,7 @@ pub struct ComponentConfig {
}
/// Configuration for a condenser bank (multi-circuit, multi-coil).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CondenserBankConfig {
/// Number of circuits.
pub circuits: usize,
@@ -108,7 +335,7 @@ pub struct CondenserBankConfig {
}
/// Side conditions for a heat exchanger (hot or cold fluid).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SideConditionsConfig {
/// Fluid name (e.g., "R134a", "Water", "Air").
pub fluid: String,
@@ -131,7 +358,7 @@ fn default_mass_flow() -> f64 {
}
/// Compressor AHRI 540 coefficients configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Ahri540Config {
/// Flow coefficient M1.
pub m1: f64,
@@ -157,7 +384,7 @@ pub struct Ahri540Config {
}
/// Configuration for an edge between components.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EdgeConfig {
/// Source component and port (e.g., "comp1:outlet").
pub from: String,
@@ -166,7 +393,7 @@ pub struct EdgeConfig {
}
/// Initial state configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InitialStateConfig {
/// Initial pressure in bar.
pub pressure_bar: Option<f64>,
@@ -176,10 +403,28 @@ pub struct InitialStateConfig {
pub temperature_k: Option<f64>,
}
/// Explicit numerical start-value guesses for system initialization.
///
/// These fields are intentionally top-level so design/start guesses do not look
/// like physical component inputs. Legacy component-level fields (`t_cond_k`,
/// `t_evap_k`, `superheat_k`) are still accepted as compatibility shims.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct InitializationConfig {
/// Initial condensing saturation temperature guess [K].
#[serde(default)]
pub t_cond_guess_k: Option<f64>,
/// Initial evaporating saturation temperature guess [K].
#[serde(default)]
pub t_evap_guess_k: Option<f64>,
/// Initial suction superheat guess [K].
#[serde(default)]
pub superheat_guess_k: Option<f64>,
}
/// Solver configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SolverConfig {
/// Solver strategy: "newton", "picard", or "fallback".
/// Solver strategy: "newton" or "picard".
#[serde(default = "default_solver_strategy")]
pub strategy: String,
/// Maximum iterations.
@@ -197,7 +442,7 @@ pub struct SolverConfig {
}
fn default_solver_strategy() -> String {
"fallback".to_string()
"newton".to_string()
}
fn default_max_iterations() -> usize {
@@ -233,6 +478,8 @@ impl ScenarioConfig {
let config: Self = serde_json::from_str(&content).map_err(CliError::InvalidConfig)?;
let mut config = config;
config.flatten_instances()?;
config.validate()?;
Ok(config)
@@ -241,12 +488,207 @@ impl ScenarioConfig {
/// Load a scenario configuration from a JSON string.
pub fn from_json(json: &str) -> CliResult<Self> {
let config: Self = serde_json::from_str(json).map_err(CliError::InvalidConfig)?;
let mut config = config;
config.flatten_instances()?;
config.validate()?;
Ok(config)
}
/// Emit the canonical Model IR JSON Schema (draft-07) as a pretty JSON string.
///
/// The schema is derived directly from the Rust `ScenarioConfig` structs via
/// `schemars`, so it can never drift from what the loader accepts. This is the
/// single source of truth the UI, external tooling, and future Python/WASM
/// bindings validate against. Exposed through the CLI `schema` command.
pub fn json_schema() -> String {
let schema = schemars::schema_for!(ScenarioConfig);
serde_json::to_string_pretty(&schema)
.unwrap_or_else(|e| format!("{{\"error\":\"failed to serialize schema: {e}\"}}"))
}
/// Flatten every [`InstanceConfig`] into concrete circuits.
///
/// Each instance's components are cloned with names prefixed by `"{instance}."`,
/// parameter references (`"$name"`) are substituted, internal edges are rewritten
/// with the prefixed names, and the result is merged into the target circuit.
/// External `connections` referencing `"{instance}.{external_port}"` are resolved
/// via each template's `ports` map and routed into the circuit of their source
/// endpoint. After this pass, `subsystems`/`instances`/`connections` are consumed
/// and the config is an ordinary flat `circuits` graph — the solver never sees the
/// hierarchy. A no-op when `instances` and `connections` are both empty.
pub fn flatten_instances(&mut self) -> CliResult<()> {
if self.instances.is_empty() && self.connections.is_empty() {
return Ok(());
}
// Expand each instance into its target circuit. Collect first (immutable
// borrow of templates), then apply (mutable borrow of circuits).
struct Expansion {
circuit: usize,
components: Vec<ComponentConfig>,
edges: Vec<EdgeConfig>,
}
let mut expansions = Vec::with_capacity(self.instances.len());
for inst in &self.instances {
let tmpl = self.subsystems.get(&inst.template).ok_or_else(|| {
CliError::Config(format!(
"instance '{}' references unknown subsystem template '{}'. Available: {}",
inst.name,
inst.template,
self.subsystems
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
))
})?;
// Resolve params: template defaults overlaid by instance overrides.
let mut params = tmpl.params.clone();
for (k, v) in &inst.params {
params.insert(k.clone(), v.clone());
}
let prefix = format!("{}.", inst.name);
let mut new_components = Vec::with_capacity(tmpl.components.len());
for comp in &tmpl.components {
let mut c = comp.clone();
c.name = format!("{}{}", prefix, comp.name);
substitute_component_params(&mut c, &params).map_err(|missing| {
CliError::Config(format!(
"instance '{}' component '{}' references undefined parameter '${}'",
inst.name, comp.name, missing
))
})?;
new_components.push(c);
}
let mut new_edges = Vec::with_capacity(tmpl.edges.len());
for edge in &tmpl.edges {
new_edges.push(EdgeConfig {
from: prefix_internal_endpoint(&edge.from, &prefix),
to: prefix_internal_endpoint(&edge.to, &prefix),
});
}
expansions.push(Expansion {
circuit: inst.circuit,
components: new_components,
edges: new_edges,
});
}
for exp in expansions {
let circuit = self.circuit_mut(exp.circuit);
circuit.components.extend(exp.components);
circuit.edges.extend(exp.edges);
}
// Resolve external connections (instance port refs or literal component:port).
let connections = std::mem::take(&mut self.connections);
let mut resolved = Vec::with_capacity(connections.len());
for conn in &connections {
let (from_ep, from_circuit) = self.resolve_external_endpoint(&conn.from)?;
let (to_ep, _to_circuit) = self.resolve_external_endpoint(&conn.to)?;
resolved.push((from_circuit, from_ep, to_ep));
}
for (circuit, from, to) in resolved {
self.circuit_mut(circuit)
.edges
.push(EdgeConfig { from, to });
}
Ok(())
}
/// Get a mutable reference to the circuit with `id`, creating it if absent.
fn circuit_mut(&mut self, id: usize) -> &mut CircuitConfig {
if let Some(pos) = self.circuits.iter().position(|c| c.id == id) {
return &mut self.circuits[pos];
}
self.circuits.push(CircuitConfig {
id,
enabled: true,
components: Vec::new(),
edges: Vec::new(),
initial_state: None,
});
let last = self.circuits.len() - 1;
&mut self.circuits[last]
}
/// Resolve a connection endpoint to a concrete `"component:port"` string and the
/// circuit id it belongs to.
///
/// - A literal `"component:port"` endpoint is returned unchanged; its circuit is
/// inferred from the component name (falling back to circuit 0).
/// - An external port reference `"{instance}.{external_port}"` (no colon) is
/// resolved through the instance's template `ports` map into
/// `"{instance}.{internal_component}:{port}"`.
fn resolve_external_endpoint(&self, endpoint: &str) -> CliResult<(String, usize)> {
if endpoint.contains(':') {
let comp = endpoint.split(':').next().unwrap_or("");
let circuit = self.circuit_of_component(comp).unwrap_or(0);
return Ok((endpoint.to_string(), circuit));
}
let (inst_name, port_name) = endpoint.split_once('.').ok_or_else(|| {
CliError::Config(format!(
"connection endpoint '{}' must be 'component:port' or 'instance.external_port'",
endpoint
))
})?;
let inst = self
.instances
.iter()
.find(|i| i.name == inst_name)
.ok_or_else(|| {
CliError::Config(format!(
"connection endpoint '{}' references unknown instance '{}'",
endpoint, inst_name
))
})?;
let tmpl = self.subsystems.get(&inst.template).ok_or_else(|| {
CliError::Config(format!(
"instance '{}' references unknown subsystem template '{}'",
inst.name, inst.template
))
})?;
let internal = tmpl.ports.get(port_name).ok_or_else(|| {
CliError::Config(format!(
"instance '{}' (template '{}') exposes no external port '{}'. Available: {}",
inst_name,
inst.template,
port_name,
tmpl.ports.keys().cloned().collect::<Vec<_>>().join(", ")
))
})?;
Ok((format!("{}.{}", inst_name, internal), inst.circuit))
}
/// Find which circuit contains a component (by name).
fn circuit_of_component(&self, name: &str) -> Option<usize> {
self.circuits
.iter()
.find(|c| c.components.iter().any(|comp| comp.name == name))
.map(|c| c.id)
}
/// Validate the configuration.
pub fn validate(&self) -> CliResult<()> {
if !SUPPORTED_SCHEMA_VERSIONS.contains(&self.schema_version.as_str()) {
return Err(CliError::Config(format!(
"unsupported schema_version '{}'. This build supports: {}",
self.schema_version,
SUPPORTED_SCHEMA_VERSIONS.join(", ")
)));
}
if self.fluid.is_empty() {
return Err(CliError::Config("fluid field is required".to_string()));
}
@@ -305,6 +747,32 @@ impl ScenarioConfig {
}
}
/// Substitute `"$name"` parameter references in a component's `params` map with the
/// resolved parameter values. Returns `Err(name)` if a referenced parameter is
/// undefined. Values that are not `"$..."` strings are left untouched.
fn substitute_component_params(
comp: &mut ComponentConfig,
params: &HashMap<String, serde_json::Value>,
) -> Result<(), String> {
for value in comp.params.values_mut() {
if let Some(name) = value.as_str().and_then(|s| s.strip_prefix('$')) {
let resolved = params.get(name).ok_or_else(|| name.to_string())?.clone();
*value = resolved;
}
}
Ok(())
}
/// Prefix the component part of an internal `"component:port"` endpoint with `prefix`.
/// Endpoints without a `:` are prefixed wholesale (defensive; internal edges should
/// always use `component:port`).
fn prefix_internal_endpoint(endpoint: &str, prefix: &str) -> String {
match endpoint.split_once(':') {
Some((comp, port)) => format!("{}{}:{}", prefix, comp, port),
None => format!("{}{}", prefix, endpoint),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -325,6 +793,23 @@ mod tests {
assert_eq!(config.fluid_backend.as_deref(), Some("CoolProp"));
}
#[test]
fn test_parse_initialization_guesses() {
let json = r#"{
"fluid": "R134a",
"initialization": {
"t_cond_guess_k": 318.15,
"t_evap_guess_k": 278.15,
"superheat_guess_k": 7.0
}
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
let init = config.initialization.expect("initialization config");
assert_eq!(init.t_cond_guess_k, Some(318.15));
assert_eq!(init.t_evap_guess_k, Some(278.15));
assert_eq!(init.superheat_guess_k, Some(7.0));
}
#[test]
fn test_parse_full_config() {
let json = r#"
@@ -414,4 +899,234 @@ mod tests {
assert_eq!(bank.circuits, 2);
assert_eq!(bank.coils_per_circuit, 3);
}
// ---- arch-4: subsystem templates + instances (flattening) ----
const TWO_CIRCUIT_TEMPLATE: &str = r#"
{
"fluid": "R134a",
"subsystems": {
"SimpleCircuit": {
"params": { "ua_cond": 5000.0, "ua_evap": 6000.0, "t_sec_evap_c": 12.0 },
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.7 },
{ "type": "Condenser", "name": "cond", "ua": "$ua_cond", "subcooling_k": 5.0 },
{ "type": "IsenthalpicExpansionValve", "name": "exv" },
{ "type": "Evaporator", "name": "evap", "ua": "$ua_evap", "secondary_inlet_temp_c": "$t_sec_evap_c" }
],
"edges": [
{ "from": "comp:outlet", "to": "cond:inlet" },
{ "from": "cond:outlet", "to": "exv:inlet" },
{ "from": "exv:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "comp:inlet" }
],
"ports": { "suction": "evap:outlet", "discharge": "comp:outlet" }
}
},
"instances": [
{ "of": "SimpleCircuit", "name": "A", "circuit": 0, "params": { "ua_cond": 7000.0 } },
{ "of": "SimpleCircuit", "name": "B", "circuit": 1, "params": { "t_sec_evap_c": 7.0 } }
]
}"#;
#[test]
fn test_flatten_instances_expands_and_prefixes() {
let config = ScenarioConfig::from_json(TWO_CIRCUIT_TEMPLATE).unwrap();
// Two instances -> two circuits, each with 4 prefixed components + 4 edges.
assert_eq!(config.circuits.len(), 2);
let circ_a = config.circuits.iter().find(|c| c.id == 0).unwrap();
let circ_b = config.circuits.iter().find(|c| c.id == 1).unwrap();
assert_eq!(circ_a.components.len(), 4);
assert_eq!(circ_a.edges.len(), 4);
assert_eq!(circ_b.components.len(), 4);
let names_a: Vec<&str> = circ_a.components.iter().map(|c| c.name.as_str()).collect();
assert!(names_a.contains(&"A.comp"));
assert!(names_a.contains(&"A.evap"));
let names_b: Vec<&str> = circ_b.components.iter().map(|c| c.name.as_str()).collect();
assert!(names_b.contains(&"B.comp"));
// Edges are rewritten with the instance prefix.
assert!(circ_a
.edges
.iter()
.any(|e| e.from == "A.comp:outlet" && e.to == "A.cond:inlet"));
}
#[test]
fn test_flatten_param_substitution_and_override() {
let config = ScenarioConfig::from_json(TWO_CIRCUIT_TEMPLATE).unwrap();
let circ_a = config.circuits.iter().find(|c| c.id == 0).unwrap();
let circ_b = config.circuits.iter().find(|c| c.id == 1).unwrap();
// Instance A overrides ua_cond -> 7000; ua_evap keeps template default 6000.
let cond_a = circ_a
.components
.iter()
.find(|c| c.name == "A.cond")
.unwrap();
assert_eq!(cond_a.params.get("ua").unwrap().as_f64(), Some(7000.0));
let evap_a = circ_a
.components
.iter()
.find(|c| c.name == "A.evap")
.unwrap();
assert_eq!(evap_a.params.get("ua").unwrap().as_f64(), Some(6000.0));
// Template default t_sec_evap_c 12.0 for A.
assert_eq!(
evap_a
.params
.get("secondary_inlet_temp_c")
.unwrap()
.as_f64(),
Some(12.0)
);
// Instance B overrides t_sec_evap_c -> 7.0, keeps ua_cond default 5000.
let cond_b = circ_b
.components
.iter()
.find(|c| c.name == "B.cond")
.unwrap();
assert_eq!(cond_b.params.get("ua").unwrap().as_f64(), Some(5000.0));
let evap_b = circ_b
.components
.iter()
.find(|c| c.name == "B.evap")
.unwrap();
assert_eq!(
evap_b
.params
.get("secondary_inlet_temp_c")
.unwrap()
.as_f64(),
Some(7.0)
);
}
#[test]
fn test_flatten_external_connection_resolves_port() {
// Two instances in the SAME circuit, wired together via external ports.
let json = r#"
{
"fluid": "R134a",
"subsystems": {
"Half": {
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.7 },
{ "type": "Condenser", "name": "cond", "ua": 5000.0 }
],
"edges": [ { "from": "comp:outlet", "to": "cond:inlet" } ],
"ports": { "out": "cond:outlet", "in": "comp:inlet" }
}
},
"instances": [
{ "of": "Half", "name": "A", "circuit": 0 },
{ "of": "Half", "name": "B", "circuit": 0 }
],
"connections": [
{ "from": "A.out", "to": "B.in" }
]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.circuits.len(), 1);
let circ = &config.circuits[0];
assert_eq!(circ.components.len(), 4);
// A.out -> cond:outlet, B.in -> comp:inlet, both prefixed.
assert!(circ
.edges
.iter()
.any(|e| e.from == "A.cond:outlet" && e.to == "B.comp:inlet"));
}
#[test]
fn test_flatten_unknown_template_errors() {
let json = r#"
{
"fluid": "R134a",
"instances": [ { "of": "DoesNotExist", "name": "A" } ]
}"#;
let err = ScenarioConfig::from_json(json).unwrap_err();
assert!(format!("{err}").contains("unknown subsystem template"));
}
#[test]
fn test_flatten_undefined_param_errors() {
let json = r#"
{
"fluid": "R134a",
"subsystems": {
"T": {
"components": [ { "type": "Condenser", "name": "cond", "ua": "$missing" } ]
}
},
"instances": [ { "of": "T", "name": "A" } ]
}"#;
let err = ScenarioConfig::from_json(json).unwrap_err();
assert!(format!("{err}").contains("undefined parameter"));
}
#[test]
fn test_flatten_no_op_without_instances() {
// A plain config with no instances/connections is untouched.
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [{ "type": "Condenser", "name": "cond", "ua": 5000.0 }],
"edges": []
}]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.circuits.len(), 1);
assert_eq!(config.circuits[0].components.len(), 1);
assert_eq!(config.circuits[0].components[0].name, "cond");
}
// ---- arch-5: schema_version + unified IR schema ----
#[test]
fn test_schema_version_defaults_to_v1() {
let config = ScenarioConfig::from_json(r#"{ "fluid": "R134a" }"#).unwrap();
assert_eq!(config.schema_version, "1");
}
#[test]
fn test_schema_version_v2_accepted() {
let json = r#"{ "schema_version": "2", "fluid": "R134a" }"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.schema_version, "2");
}
#[test]
fn test_schema_version_unknown_rejected() {
let json = r#"{ "schema_version": "99", "fluid": "R134a" }"#;
let err = ScenarioConfig::from_json(json).unwrap_err();
assert!(format!("{err}").contains("unsupported schema_version"));
}
#[test]
fn test_json_schema_emits_ir_fields() {
let schema = ScenarioConfig::json_schema();
// The emitted schema must be the single source of truth covering every IR
// pillar: circuits (v1) + controls/subsystems/instances/connections (v2).
for field in [
"schema_version",
"circuits",
"controls",
"subsystems",
"instances",
"connections",
] {
assert!(
schema.contains(field),
"schema should document IR field '{field}'"
);
}
// Valid JSON, draft-07.
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert!(parsed.get("$schema").is_some());
}
}

View File

@@ -9,7 +9,10 @@
pub mod batch;
pub mod config;
pub mod error;
pub mod qualify;
pub mod rate;
pub mod run;
pub mod seasonal;
pub use batch::{BatchAggregator, BatchSummary, OutputFormat};
pub use config::ScenarioConfig;

View File

@@ -18,6 +18,7 @@ use tracing::Level;
use tracing_subscriber::EnvFilter;
use entropyk_cli::error::{CliError, ExitCode};
use entropyk_cli::seasonal::SeasonalMode;
#[derive(Parser)]
#[command(name = "entropyk-cli")]
@@ -79,6 +80,58 @@ enum Commands {
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
},
/// Qualify a single heat exchanger at a fixed refrigerant regime while
/// sweeping the secondary (air/water/brine) inlet temperature and flow.
Qualify {
/// Path to the JSON qualification configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Rate a full cycle across standardized part-load points and integrate the
/// seasonal efficiency metric (IPLV per AHRI 550/590 or ESEER per Eurovent).
Rate {
/// Path to the JSON rating configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Compute the seasonal heating performance (SCOP) by the EN 14825 bin method,
/// re-solving the coupled cycle at each climate temperature bin.
Scop {
/// Path to the JSON seasonal configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Compute the seasonal cooling performance (SEER) by the EN 14825 bin method,
/// re-solving the coupled cycle at each climate temperature bin.
Seer {
/// Path to the JSON seasonal configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Emit the canonical Model IR JSON Schema (single source of truth for the
/// CLI, the web UI, and external tooling). Derived from the Rust config types.
Schema {
/// Path to write the JSON Schema (default: stdout)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
}
fn main() {
@@ -119,6 +172,15 @@ fn main() {
cli.verbose,
),
Commands::Validate { config } => validate_config(config),
Commands::Qualify { config, output } => run_qualify(config, output, cli.quiet),
Commands::Rate { config, output } => run_rate(config, output, cli.quiet),
Commands::Scop { config, output } => {
run_seasonal(config, output, cli.quiet, SeasonalMode::Scop)
}
Commands::Seer { config, output } => {
run_seasonal(config, output, cli.quiet, SeasonalMode::Seer)
}
Commands::Schema { output } => emit_schema(output),
};
match result {
@@ -193,6 +255,16 @@ fn print_result(result: &entropyk_cli::run::SimulationResult) {
println!(" Time: {} ms", result.elapsed_ms);
if let Some(ref dof) = result.dof {
let tag = if dof.n_equations == dof.n_unknowns {
format!("{} = {}", dof.n_equations, dof.n_unknowns).green()
} else {
format!("{}{}", dof.n_equations, dof.n_unknowns).red()
};
println!(" DoF: {} eqs / {} unk [{}]", dof.n_equations, dof.n_unknowns, tag);
println!(" balance: {}", dof.balance);
}
if let Some(ref error) = result.error {
println!();
println!(" {} {}", "Error:".red(), error);
@@ -209,6 +281,23 @@ fn print_result(result: &entropyk_cli::run::SimulationResult) {
}
}
if let Some(ref perf) = result.performance {
println!();
println!(" {}", "Performance:".cyan());
if let Some(q) = perf.q_cooling_kw {
println!(" Cooling capacity: {:.3} kW", q);
}
if let Some(q) = perf.q_heating_kw {
println!(" Heating capacity: {:.3} kW", q);
}
if let Some(w) = perf.compressor_power_kw {
println!(" Power input: {:.3} kW", w);
}
if let Some(cop) = perf.cop {
println!(" COP / EER: {:.3}", cop);
}
}
println!("{}", "".repeat(40).white());
}
@@ -263,8 +352,239 @@ fn run_batch(
}
}
fn run_qualify(config: PathBuf, output: Option<PathBuf>, quiet: bool) -> Result<(), CliError> {
use entropyk_cli::qualify::run_qualify as run_qualify_impl;
if !quiet {
println!("{}", "".repeat(60).cyan());
println!(
"{}",
" ENTROPYK CLI - Heat Exchanger Qualification"
.cyan()
.bold()
);
println!("{}", "".repeat(60).cyan());
println!();
}
let report = run_qualify_impl(&config, output.as_deref())?;
if quiet {
if output.is_none() {
let json = serde_json::to_string(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
println!("{}", json);
}
return Ok(());
}
let kind_label = match report.exchanger.as_str() {
"evaporator" => "Evaporator (refrigerant absorbs heat)",
"condenser" => "Condenser (refrigerant rejects heat)",
other => other,
};
println!(" Exchanger: {}", kind_label.bold());
println!(" Refrigerant: {}", report.refrigerant);
println!(" UA: {:.0} W/K", report.ua_w_per_k);
println!(
" Regime: {:.3} bar (T_sat = {:.2} °C, held constant)",
report.refrigerant_pressure_bar, report.refrigerant_sat_temp_c
);
println!();
println!(
" {:>8} {:>10} {:>12} {:>8} {:>10} {:>10}",
"T_in[°C]", "C[W/K]", "Q[kW]", "ε[-]", "appr[K]", "T_out[°C]"
);
println!(" {}", "".repeat(66).white());
for row in &report.rows {
println!(
" {:>8.2} {:>10.0} {:>12.3} {:>8.3} {:>10.2} {:>10.2}",
row.secondary_inlet_temp_c,
row.secondary_capacity_rate_w_per_k,
row.q_w / 1000.0,
row.effectiveness,
row.approach_k,
row.secondary_outlet_temp_c,
);
}
println!(" {}", "".repeat(66).white());
if let Some(out) = output {
println!();
println!(" Report written to: {}", out.display());
}
Ok(())
}
fn run_rate(config: PathBuf, output: Option<PathBuf>, quiet: bool) -> Result<(), CliError> {
use entropyk_cli::rate::run_rate as run_rate_impl;
if !quiet {
println!("{}", "".repeat(60).cyan());
println!(
"{}",
" ENTROPYK CLI - Seasonal Rating (IPLV / ESEER)"
.cyan()
.bold()
);
println!("{}", "".repeat(60).cyan());
println!();
}
let report = run_rate_impl(&config, output.as_deref())?;
if quiet {
if output.is_none() {
let json = serde_json::to_string(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
println!("{}", json);
}
return Ok(());
}
println!(" Base config: {}", report.base_config);
println!(" Standard: {}", report.metric.bold());
if !report.standard_reference.is_empty() {
println!(" Reference: {}", report.standard_reference);
}
println!();
println!(
" {:>8} {:>12} {:>10} {:>10} {:>10}",
"Load[%]", "Status", "Q_cool[kW]", "Power[kW]", "EER[-]"
);
println!(" {}", "".repeat(58).white());
for row in &report.points {
let eer = row
.eer
.map(|v| format!("{:.3}", v))
.unwrap_or_else(|| "".to_string());
let q = row
.q_cooling_kw
.map(|v| format!("{:.3}", v))
.unwrap_or_else(|| "".to_string());
let p = row
.power_kw
.map(|v| format!("{:.3}", v))
.unwrap_or_else(|| "".to_string());
println!(
" {:>8.0} {:>12} {:>10} {:>10} {:>10}",
row.load_fraction * 100.0,
row.status,
q,
p,
eer,
);
}
println!(" {}", "".repeat(58).white());
println!();
match report.integrated_value {
Some(v) => println!(
" {} = {}",
report.metric.bold(),
format!("{:.3}", v).green().bold()
),
None => println!(
" {}",
"Integrated value unavailable (a part-load point did not converge)".yellow()
),
}
if let Some(out) = output {
println!();
println!(" Report written to: {}", out.display());
}
Ok(())
}
fn run_seasonal(
config: PathBuf,
output: Option<PathBuf>,
quiet: bool,
mode: SeasonalMode,
) -> Result<(), CliError> {
use entropyk_cli::seasonal::run_seasonal as run_seasonal_impl;
let title = match mode {
SeasonalMode::Scop => " ENTROPYK CLI - Seasonal Heating (SCOP, EN 14825)",
SeasonalMode::Seer => " ENTROPYK CLI - Seasonal Cooling (SEER, EN 14825)",
};
if !quiet {
println!("{}", "".repeat(60).cyan());
println!("{}", title.cyan().bold());
println!("{}", "".repeat(60).cyan());
println!();
}
let report = run_seasonal_impl(&config, output.as_deref(), mode)?;
if quiet {
if output.is_none() {
let json = serde_json::to_string(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
println!("{}", json);
}
return Ok(());
}
println!(" Base config: {}", report.base_config);
println!(" Climate: {}", report.climate.bold());
if !report.climate_reference.is_empty() {
println!(" Reference: {}", report.climate_reference);
}
println!(" Total hours: {:.0} h", report.total_hours);
println!();
println!(
" {:>8} {:>7} {:>11} {:>11} {:>8} {:>7} {:>8}",
"T[°C]", "h", "Demand[kW]", "Cap[kW]", "COP_full", "backup", "COP_eff"
);
println!(" {}", "".repeat(72).white());
for b in &report.bins {
let fmt = |v: Option<f64>, p: usize| {
v.map(|x| format!("{:.*}", p, x))
.unwrap_or_else(|| "".to_string())
};
println!(
" {:>8.1} {:>7.0} {:>11.3} {:>11} {:>8} {:>6.0}% {:>8}",
b.temperature_c,
b.hours,
b.demand_w / 1000.0,
fmt(b.capacity_w.map(|w| w / 1000.0), 3),
fmt(b.full_cop, 3),
b.backup_fraction * 100.0,
fmt(b.effective_cop, 3),
);
}
println!(" {}", "".repeat(72).white());
println!();
println!(
" Seasonal useful: {:.1} kWh Seasonal electric: {:.1} kWh",
report.seasonal_useful_kwh, report.seasonal_electric_kwh
);
match report.integrated_value {
Some(v) => println!(
" {} = {}",
report.metric.bold(),
format!("{:.3}", v).green().bold()
),
None => println!(
" {}",
"Seasonal value unavailable (a demanded bin did not converge)".yellow()
),
}
if let Some(out) = output {
println!();
println!(" Report written to: {}", out.display());
}
Ok(())
}
fn validate_config(config: PathBuf) -> Result<(), CliError> {
use entropyk_cli::config::ScenarioConfig;
use entropyk_cli::run::run_simulation;
println!("{}", "".repeat(60).cyan());
println!(
@@ -275,9 +595,85 @@ fn validate_config(config: PathBuf) -> Result<(), CliError> {
println!();
let _cfg = ScenarioConfig::from_file(&config)?;
println!(" {} Configuration is valid", "".green());
println!(" {} Schema / file parse OK", "".green());
println!(" File: {}", config.display());
// Build + finalize the system (and attempt solve) to surface DoF imbalance.
// Real-machine configs must be square: n_equations == n_unknowns.
println!();
println!(" {}", "DoF / topology check (build + solve):".cyan());
let result = run_simulation(&config, None, false)?;
if let Some(ref dof) = result.dof {
let ok = dof.n_equations == dof.n_unknowns;
if ok {
println!(
" {} DoF balanced: {} equations = {} unknowns",
"".green(),
dof.n_equations,
dof.n_unknowns
);
} else {
println!(
" {} DoF imbalance: {} equations vs {} unknowns ({})",
"".red(),
dof.n_equations,
dof.n_unknowns,
dof.balance
);
println!();
for line in dof.summary.lines() {
println!(" {}", line);
}
return Err(CliError::Simulation(format!(
"DoF imbalance: {} eqs vs {} unknowns",
dof.n_equations, dof.n_unknowns
)));
}
// Print compact component ledger
for line in dof.summary.lines().skip(1).take(24) {
println!(" {}", line);
}
}
match result.status {
entropyk_cli::run::SimulationStatus::Converged => {
println!(" {} Solver converged (config is executable)", "".green());
}
entropyk_cli::run::SimulationStatus::Error => {
if let Some(ref err) = result.error {
if err.contains("DoF imbalance") {
return Err(CliError::Simulation(err.clone()));
}
println!(
" {} Schema OK but solve failed: {}",
"!".yellow(),
err.lines().next().unwrap_or(err)
);
}
}
_ => {
println!(
" {} Schema/DoF OK but solver did not converge",
"!".yellow()
);
}
}
Ok(())
}
/// Emit the canonical Model IR JSON Schema, derived from the Rust config types.
fn emit_schema(output: Option<PathBuf>) -> Result<(), CliError> {
use entropyk_cli::config::ScenarioConfig;
let schema = ScenarioConfig::json_schema();
match output {
Some(path) => {
std::fs::write(&path, &schema).map_err(CliError::Io)?;
eprintln!("{} Schema written to {}", "".green(), path.display());
}
None => println!("{}", schema),
}
Ok(())
}

333
crates/cli/src/qualify.rs Normal file
View File

@@ -0,0 +1,333 @@
//! Heat-exchanger qualification harness.
//!
//! Qualifies a single heat exchanger (evaporator or condenser) at a **fixed
//! refrigerant regime** (constant evaporating/condensing pressure) while sweeping
//! the secondary (air/water/brine) inlet temperature and flow. Every output is
//! solved from the genuine ε-NTU coupled model — nothing is imposed.
//!
//! This is the entry point that lets the user "calculer les performances des
//! machines pour différentes conditions d'air et eau" without running a full
//! cycle: the refrigerant side is held constant, the secondary side varies, and
//! the resulting duty / effectiveness / approach respond physically.
//!
//! # Example configuration
//!
//! ```json
//! {
//! "exchanger": "evaporator",
//! "refrigerant": "R134a",
//! "fluid_backend": "Test",
//! "ua_w_per_k": 8000.0,
//! "refrigerant_pressure_bar": 3.5,
//! "sweep": {
//! "secondary_inlet_temp_c": [10.0, 12.0, 15.0],
//! "secondary_mass_flow_kg_s": [1.0, 1.5, 2.0],
//! "secondary_cp_j_per_kgk": 4180.0
//! }
//! }
//! ```
use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use entropyk_components::heat_exchanger::{Condenser, FloodedEvaporator};
use crate::error::{CliError, CliResult};
/// Which exchanger to qualify.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExchangerKind {
Evaporator,
Condenser,
}
impl ExchangerKind {
fn parse(s: &str) -> CliResult<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"evaporator" | "flooded_evaporator" | "evap" => Ok(ExchangerKind::Evaporator),
"condenser" | "cond" => Ok(ExchangerKind::Condenser),
other => Err(CliError::Config(format!(
"Unknown exchanger '{}'. Supported: 'evaporator', 'condenser'",
other
))),
}
}
}
/// Qualification configuration (JSON).
#[derive(Debug, Clone, Deserialize)]
pub struct QualifyConfig {
/// Exchanger to qualify: "evaporator" or "condenser".
pub exchanger: String,
/// Refrigerant fluid id (e.g. "R134a", "R410A").
pub refrigerant: String,
/// Fluid backend: "Test" (default) or "CoolProp".
#[serde(default)]
pub fluid_backend: Option<String>,
/// Overall conductance UA [W/K].
pub ua_w_per_k: f64,
/// Fixed refrigerant regime: evaporating/condensing pressure [bar].
pub refrigerant_pressure_bar: f64,
/// Secondary-stream sweep definition.
pub sweep: SweepConfig,
}
/// Secondary-stream sweep: cartesian product of inlet temperatures × capacity rates.
#[derive(Debug, Clone, Deserialize)]
pub struct SweepConfig {
/// Secondary inlet temperatures to sweep [°C].
pub secondary_inlet_temp_c: Vec<f64>,
/// Secondary heat-capacity rates ṁ·cp to sweep [W/K]. Mutually exclusive with
/// `secondary_mass_flow_kg_s` + `secondary_cp_j_per_kgk`.
#[serde(default)]
pub secondary_capacity_rate_w_per_k: Vec<f64>,
/// Secondary mass flows to sweep [kg/s] (combined with `secondary_cp_j_per_kgk`).
#[serde(default)]
pub secondary_mass_flow_kg_s: Vec<f64>,
/// Secondary specific heat [J/(kg·K)] used with `secondary_mass_flow_kg_s`.
#[serde(default)]
pub secondary_cp_j_per_kgk: Option<f64>,
}
/// A single qualification result row.
#[derive(Debug, Clone, Serialize)]
pub struct QualifyRow {
/// Secondary inlet temperature [°C].
pub secondary_inlet_temp_c: f64,
/// Secondary heat-capacity rate ṁ·cp [W/K].
pub secondary_capacity_rate_w_per_k: f64,
/// Heat duty [W] (>0 absorbed by evaporator, >0 rejected by condenser).
pub q_w: f64,
/// Effectiveness ε = 1 exp(UA/C_sec) [-].
pub effectiveness: f64,
/// Refrigerant saturation temperature [°C] (constant across the sweep).
pub refrigerant_sat_temp_c: f64,
/// Approach |T_sat T_sec,in| [K].
pub approach_k: f64,
/// Secondary outlet temperature [°C].
pub secondary_outlet_temp_c: f64,
}
/// Full qualification report.
#[derive(Debug, Clone, Serialize)]
pub struct QualifyReport {
/// Exchanger kind ("evaporator" / "condenser").
pub exchanger: String,
/// Refrigerant fluid id.
pub refrigerant: String,
/// Conductance UA [W/K].
pub ua_w_per_k: f64,
/// Fixed refrigerant pressure [bar].
pub refrigerant_pressure_bar: f64,
/// Refrigerant saturation temperature [°C] at the fixed regime.
pub refrigerant_sat_temp_c: f64,
/// One row per swept secondary condition.
pub rows: Vec<QualifyRow>,
}
fn build_backend(name: Option<&str>) -> CliResult<Arc<dyn entropyk_fluids::FluidBackend>> {
match name {
Some("CoolProp") => Ok(Arc::new(entropyk_fluids::CoolPropBackend::new())),
Some("Test") | None => Ok(Arc::new(entropyk_fluids::TestBackend::new())),
Some(other) => Err(CliError::Config(format!(
"Unknown fluid backend: '{}'. Supported: 'CoolProp', 'Test'",
other
))),
}
}
/// Resolves the list of secondary capacity rates [W/K] from the sweep config.
fn capacity_rates(sweep: &SweepConfig) -> CliResult<Vec<f64>> {
if !sweep.secondary_capacity_rate_w_per_k.is_empty() {
return Ok(sweep.secondary_capacity_rate_w_per_k.clone());
}
if !sweep.secondary_mass_flow_kg_s.is_empty() {
let cp = sweep.secondary_cp_j_per_kgk.ok_or_else(|| {
CliError::Config(
"sweep.secondary_cp_j_per_kgk is required when using secondary_mass_flow_kg_s"
.to_string(),
)
})?;
return Ok(sweep
.secondary_mass_flow_kg_s
.iter()
.map(|m| m * cp)
.collect());
}
Err(CliError::Config(
"sweep must define either 'secondary_capacity_rate_w_per_k' or 'secondary_mass_flow_kg_s'"
.to_string(),
))
}
/// Runs the qualification sweep and returns a structured report.
pub fn qualify(config: &QualifyConfig) -> CliResult<QualifyReport> {
let kind = ExchangerKind::parse(&config.exchanger)?;
let backend = build_backend(config.fluid_backend.as_deref())?;
let p_pa = config.refrigerant_pressure_bar * 1e5;
if config.sweep.secondary_inlet_temp_c.is_empty() {
return Err(CliError::Config(
"sweep.secondary_inlet_temp_c must contain at least one temperature".to_string(),
));
}
let rates = capacity_rates(&config.sweep)?;
let mut rows = Vec::new();
let mut sat_temp_c = f64::NAN;
for &t_c in &config.sweep.secondary_inlet_temp_c {
let t_k = t_c + 273.15;
for &c_sec in &rates {
let row = match kind {
ExchangerKind::Evaporator => {
let evap = FloodedEvaporator::new(config.ua_w_per_k)
.with_refrigerant(&config.refrigerant)
.with_fluid_backend(Arc::clone(&backend))
.with_secondary_stream(t_k, c_sec);
let r = evap.rate(p_pa).map_err(|e| {
CliError::Simulation(format!("Evaporator rating failed: {}", e))
})?;
sat_temp_c = r.t_evap_k - 273.15;
QualifyRow {
secondary_inlet_temp_c: t_c,
secondary_capacity_rate_w_per_k: c_sec,
q_w: r.q_w,
effectiveness: r.effectiveness,
refrigerant_sat_temp_c: r.t_evap_k - 273.15,
approach_k: r.approach_k.abs(),
secondary_outlet_temp_c: r.secondary_outlet_k - 273.15,
}
}
ExchangerKind::Condenser => {
let cond = Condenser::new(config.ua_w_per_k)
.with_refrigerant(&config.refrigerant)
.with_fluid_backend(Arc::clone(&backend))
.with_secondary_stream(t_k, c_sec);
let r = cond.rate(p_pa).map_err(|e| {
CliError::Simulation(format!("Condenser rating failed: {}", e))
})?;
sat_temp_c = r.t_cond_k - 273.15;
QualifyRow {
secondary_inlet_temp_c: t_c,
secondary_capacity_rate_w_per_k: c_sec,
q_w: r.q_w,
effectiveness: r.effectiveness,
refrigerant_sat_temp_c: r.t_cond_k - 273.15,
approach_k: r.approach_k.abs(),
secondary_outlet_temp_c: r.secondary_outlet_k - 273.15,
}
}
};
rows.push(row);
}
}
Ok(QualifyReport {
exchanger: match kind {
ExchangerKind::Evaporator => "evaporator".to_string(),
ExchangerKind::Condenser => "condenser".to_string(),
},
refrigerant: config.refrigerant.clone(),
ua_w_per_k: config.ua_w_per_k,
refrigerant_pressure_bar: config.refrigerant_pressure_bar,
refrigerant_sat_temp_c: sat_temp_c,
rows,
})
}
/// Loads a qualification config from a JSON file, runs the sweep, and optionally
/// writes the JSON report. Returns the report for further inspection.
pub fn run_qualify(config_path: &Path, output: Option<&Path>) -> CliResult<QualifyReport> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
})?;
let config: QualifyConfig = serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid qualify config: {}", e)))?;
let report = qualify(&config)?;
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 base_config() -> QualifyConfig {
QualifyConfig {
exchanger: "evaporator".to_string(),
refrigerant: "R134a".to_string(),
fluid_backend: Some("Test".to_string()),
ua_w_per_k: 8000.0,
refrigerant_pressure_bar: 3.5,
sweep: SweepConfig {
secondary_inlet_temp_c: vec![10.0, 15.0],
secondary_capacity_rate_w_per_k: vec![],
secondary_mass_flow_kg_s: vec![1.0, 2.0],
secondary_cp_j_per_kgk: Some(4180.0),
},
}
}
#[test]
fn test_evaporator_sweep_produces_cartesian_grid() {
let cfg = base_config();
let report = qualify(&cfg).unwrap();
// 2 temperatures × 2 flows = 4 rows
assert_eq!(report.rows.len(), 4);
// Fixed refrigerant regime ⇒ constant saturation temperature.
for row in &report.rows {
assert!((row.refrigerant_sat_temp_c - report.refrigerant_sat_temp_c).abs() < 1e-9);
assert!(row.q_w > 0.0, "evaporator must absorb heat from warm water");
assert!(row.effectiveness > 0.0 && row.effectiveness < 1.0);
}
}
#[test]
fn test_warmer_water_increases_evaporator_duty() {
let cfg = base_config();
let report = qualify(&cfg).unwrap();
// Rows are ordered temp-major; compare same flow, two temperatures.
let cold = &report.rows[0]; // 10 °C, flow 1.0
let warm = &report.rows[2]; // 15 °C, flow 1.0
assert!(
(cold.secondary_capacity_rate_w_per_k - warm.secondary_capacity_rate_w_per_k).abs()
< 1e-9
);
assert!(
warm.q_w > cold.q_w,
"warmer secondary ⇒ more evaporator duty"
);
}
#[test]
fn test_condenser_sweep_rejects_heat() {
let mut cfg = base_config();
cfg.exchanger = "condenser".to_string();
cfg.refrigerant_pressure_bar = 12.0; // ~46 °C condensing for R134a
let report = qualify(&cfg).unwrap();
assert_eq!(report.rows.len(), 4);
for row in &report.rows {
assert!(row.q_w > 0.0, "condenser must reject heat to cooler water");
assert!(row.approach_k > 0.0);
}
}
#[test]
fn test_missing_secondary_definition_errors() {
let mut cfg = base_config();
cfg.sweep.secondary_mass_flow_kg_s = vec![];
cfg.sweep.secondary_capacity_rate_w_per_k = vec![];
assert!(qualify(&cfg).is_err());
}
}

643
crates/cli/src/rate.rs Normal file
View File

@@ -0,0 +1,643 @@
//! Standardized part-load / seasonal rating harness.
//!
//! Re-solves a full cycle configuration at each standardized part-load point and
//! aggregates the resulting efficiencies into the seasonal metrics used to
//! *qualify* chillers and heat pumps — **IPLV/NPLV** (AHRI 550/590) and **ESEER**
//! (Eurovent). Unlike [`crate::qualify`], which holds the refrigerant regime
//! fixed and sweeps a single heat exchanger, `rate` runs the genuine coupled
//! cycle at every load point: the condensing/evaporating pressures, capacity and
//! power all emerge from the heat-exchanger ↔ secondary balance, so the reported
//! EERs — and hence the integrated value — are real simulation output, not
//! imposed design points.
//!
//! # How a part-load point is defined
//!
//! Each of the four points (100 / 75 / 50 / 25 % load) overrides a handful of
//! operating conditions on the *base* configuration:
//!
//! - `condenser_secondary_inlet_c` — condenser entering water/air temperature
//! (the AHRI/Eurovent condenser schedule; warmer at full load).
//! - `evaporator_secondary_inlet_c` — evaporator entering fluid temperature.
//! - `condenser_secondary_mass_flow_kg_s` / `evaporator_secondary_mass_flow_kg_s`
//! — secondary flows, if they vary with load.
//! - `compressor_speed_hz` — compressor unloading (a variable-speed drive turns
//! the speed down to shed capacity). Combined with a `vsd_reference_speed_hz`
//! map on the compressor this genuinely degrades part-load efficiency.
//!
//! Everything not overridden is inherited from the base config, so the four
//! points share the same machine and only the operating envelope changes.
//!
//! # Example configuration
//!
//! ```json
//! {
//! "base_config": "chiller_r134a_emergent_pressure.json",
//! "metric": "iplv",
//! "points": [
//! { "load_fraction": 1.00, "condenser_secondary_inlet_c": 29.4, "compressor_speed_hz": 50.0 },
//! { "load_fraction": 0.75, "condenser_secondary_inlet_c": 24.4, "compressor_speed_hz": 38.0 },
//! { "load_fraction": 0.50, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 26.0 },
//! { "load_fraction": 0.25, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 14.0 }
//! ]
//! }
//! ```
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use entropyk::rating::PartLoadStandard;
use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};
use crate::run::{simulate_from_json, SimulationStatus};
/// Which built-in seasonal weighting standard to integrate the part-load EERs
/// into, when no explicit custom standard is supplied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RateMetric {
/// Integrated Part Load Value — AHRI 550/590 weighting (default).
#[default]
Iplv,
/// European Seasonal Energy Efficiency Ratio — Eurovent weighting.
Eseer,
}
impl RateMetric {
/// The corresponding built-in [`PartLoadStandard`].
fn standard(self) -> PartLoadStandard {
match self {
RateMetric::Iplv => PartLoadStandard::ahri_550_590_iplv(),
RateMetric::Eseer => PartLoadStandard::eurovent_eseer(),
}
}
}
/// A single standardized part-load point: a set of operating overrides applied
/// to the base configuration before re-solving.
#[derive(Debug, Clone, Deserialize)]
pub struct RatePoint {
/// Fraction of full load this point represents (1.0 / 0.75 / 0.50 / 0.25).
/// Used to order the points and to weight the seasonal metric.
pub load_fraction: f64,
/// Condenser secondary (water/air) entering temperature [°C].
#[serde(default)]
pub condenser_secondary_inlet_c: Option<f64>,
/// Evaporator secondary (water/brine) entering temperature [°C].
#[serde(default)]
pub evaporator_secondary_inlet_c: Option<f64>,
/// Condenser secondary mass flow [kg/s].
#[serde(default)]
pub condenser_secondary_mass_flow_kg_s: Option<f64>,
/// Evaporator secondary mass flow [kg/s].
#[serde(default)]
pub evaporator_secondary_mass_flow_kg_s: Option<f64>,
/// Compressor rotational speed [Hz] (unloading via a variable-speed drive).
#[serde(default)]
pub compressor_speed_hz: Option<f64>,
}
/// Rating configuration (JSON).
///
/// The seasonal weighting standard is selected — in order of precedence — by an
/// inline [`standard`](Self::standard) object, a [`standard_file`](Self::standard_file)
/// path (a JSON [`PartLoadStandard`]), a [`standard_name`](Self::standard_name)
/// built-in id (`"iplv"`, `"nplv"`, `"eseer"`), or finally the
/// [`metric`](Self::metric) enum (default IPLV). Supplying a custom standard lets
/// you track a revised norm without recompiling.
#[derive(Debug, Clone, Deserialize)]
pub struct RateConfig {
/// Path to the base cycle configuration (a `run` scenario file). Resolved
/// relative to the rating config file's directory when not absolute.
pub base_config: PathBuf,
/// Built-in seasonal metric to compute: `"iplv"` (default) or `"eseer"`.
/// Ignored when a more specific standard selector below is present.
#[serde(default)]
pub metric: RateMetric,
/// Built-in standard id (e.g. `"iplv"`, `"nplv"`, `"eseer"`). Overrides
/// [`metric`](Self::metric) when set.
#[serde(default)]
pub standard_name: Option<String>,
/// Path to a custom [`PartLoadStandard`] JSON file, resolved relative to this
/// config's directory. Overrides [`standard_name`](Self::standard_name).
#[serde(default)]
pub standard_file: Option<PathBuf>,
/// Inline custom [`PartLoadStandard`]. Highest precedence.
#[serde(default)]
pub standard: Option<PartLoadStandard>,
/// The standardized part-load points (typically four: 100/75/50/25 %).
pub points: Vec<RatePoint>,
}
impl RateConfig {
/// Resolve the effective seasonal weighting standard, honouring the
/// precedence `standard` > `standard_file` > `standard_name` > `metric`.
///
/// `config_dir` is used to resolve a relative `standard_file` path.
pub fn resolve_standard(&self, config_dir: Option<&Path>) -> CliResult<PartLoadStandard> {
let standard = if let Some(inline) = &self.standard {
inline.clone()
} else if let Some(file) = &self.standard_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 standard file {}: {}",
path.display(),
e
))
})?;
serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid standard file: {}", e)))?
} else if let Some(name) = &self.standard_name {
PartLoadStandard::builtin(name).ok_or_else(|| {
CliError::Config(format!(
"Unknown built-in standard '{}'. Known ids: {}",
name,
PartLoadStandard::builtin_ids().join(", ")
))
})?
} else {
self.metric.standard()
};
standard
.validate()
.map_err(|e| CliError::Config(format!("Invalid rating standard: {}", e)))?;
Ok(standard)
}
}
/// Result of re-solving one part-load point.
#[derive(Debug, Clone, Serialize)]
pub struct RatePointResult {
/// Fraction of full load.
pub load_fraction: f64,
/// Solver status string ("converged", "non_converged", "error", ...).
pub status: String,
/// Energy efficiency ratio (cooling COP) at this point, if converged.
pub eer: Option<f64>,
/// Cooling capacity [kW], if available.
pub q_cooling_kw: Option<f64>,
/// Power input [kW], if available.
pub power_kw: Option<f64>,
/// Error message when the point failed to solve.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Full rating report.
#[derive(Debug, Clone, Serialize)]
pub struct RateReport {
/// Base configuration path used for every point.
pub base_config: String,
/// Name of the seasonal standard applied (e.g. "AHRI 550/590 IPLV").
pub metric: String,
/// Citation / provenance of the standard's coefficients.
pub standard_reference: String,
/// One result row per part-load point (ordered by descending load fraction).
pub points: Vec<RatePointResult>,
/// Integrated seasonal value, `None` if a required load point failed.
pub integrated_value: Option<f64>,
}
/// Applies a part-load point's overrides to a clone of the base configuration.
///
/// The overrides are written into the flattened `params` map of every matching
/// component so that [`crate::run`] picks them up exactly as if the user had
/// authored them in the JSON. Overriding *all* condensers/evaporators/compressors
/// keeps single-circuit chillers (the common case) simple while remaining
/// well-defined for multi-instance machines.
fn apply_overrides(base: &ScenarioConfig, point: &RatePoint) -> ScenarioConfig {
let mut config = base.clone();
for circuit in &mut config.circuits {
for comp in &mut circuit.components {
match comp.component_type.as_str() {
"BrineSource" | "AirSource" => {
let name_lower = comp.name.to_lowercase();
let is_condenser = name_lower.contains("cond");
let is_evaporator = name_lower.contains("evap");
let temp_key = if comp.component_type == "BrineSource" {
"t_set_c"
} else {
"t_dry_c"
};
if is_condenser {
if let Some(t) = point.condenser_secondary_inlet_c {
comp.params.insert(temp_key.into(), json_num(t));
}
if let Some(m) = point.condenser_secondary_mass_flow_kg_s {
comp.params.insert("m_flow_kg_s".into(), json_num(m));
}
}
if is_evaporator {
if let Some(t) = point.evaporator_secondary_inlet_c {
comp.params.insert(temp_key.into(), json_num(t));
}
if let Some(m) = point.evaporator_secondary_mass_flow_kg_s {
comp.params.insert("m_flow_kg_s".into(), json_num(m));
}
}
}
"IsentropicCompressor" | "Compressor" => {
if let Some(hz) = point.compressor_speed_hz {
comp.params.insert("speed_hz".into(), json_num(hz));
}
}
_ => {}
}
}
}
config
}
/// Builds a JSON number value, falling back to null for non-finite inputs.
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)
}
/// Re-solves a single part-load point and extracts its EER.
fn solve_point(base: &ScenarioConfig, point: &RatePoint) -> RatePointResult {
let config = apply_overrides(base, point);
let json = match serde_json::to_string(&config) {
Ok(j) => j,
Err(e) => {
return RatePointResult {
load_fraction: point.load_fraction,
status: "error".to_string(),
eer: None,
q_cooling_kw: None,
power_kw: None,
error: Some(format!("Failed to serialize overridden config: {}", e)),
}
}
};
match simulate_from_json(&json) {
Ok(result) => {
let status = status_label(&result.status);
let perf = result.performance.as_ref();
RatePointResult {
load_fraction: point.load_fraction,
status,
eer: perf.and_then(|p| p.cop),
q_cooling_kw: perf.and_then(|p| p.q_cooling_kw),
power_kw: perf.and_then(|p| p.compressor_power_kw),
error: result.error,
}
}
Err(e) => RatePointResult {
load_fraction: point.load_fraction,
status: "error".to_string(),
eer: None,
q_cooling_kw: None,
power_kw: None,
error: Some(format!("{}", e)),
},
}
}
fn status_label(status: &SimulationStatus) -> String {
match status {
SimulationStatus::Converged => "converged",
SimulationStatus::Timeout => "timeout",
SimulationStatus::NonConverged => "non_converged",
SimulationStatus::Error => "error",
}
.to_string()
}
/// Tolerance for matching a part-load point to a standard's load fraction.
const LOAD_FRACTION_MATCH_TOL: f64 = 0.02;
/// Integrates the part-load EERs into the seasonal value defined by `standard`.
///
/// The standard drives everything: for each of its `load_fractions` (in order)
/// the nearest part-load point (within [`LOAD_FRACTION_MATCH_TOL`]) supplies the
/// EER, and the weighted sum is formed with the standard's `weights`. Returns
/// `None` unless every load point of the standard is matched by a converged point
/// with a finite EER — so a revised standard with a different number of points or
/// different fractions works with no code change, provided the config supplies
/// matching points.
fn integrate(standard: &PartLoadStandard, points: &[RatePointResult]) -> Option<f64> {
let mut efficiencies = Vec::with_capacity(standard.load_fractions.len());
for &target in &standard.load_fractions {
let best = points
.iter()
.filter(|p| p.eer.map(|v| v.is_finite()).unwrap_or(false))
.min_by(|a, b| {
(a.load_fraction - target)
.abs()
.partial_cmp(&(b.load_fraction - target).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})?;
if (best.load_fraction - target).abs() > LOAD_FRACTION_MATCH_TOL {
return None;
}
efficiencies.push(best.eer?);
}
standard.integrate(&efficiencies).ok()
}
/// Runs the full rating: re-solves every part-load point (in parallel) and
/// aggregates the seasonal metric defined by `standard`.
pub fn rate(
config: &RateConfig,
base: &ScenarioConfig,
standard: &PartLoadStandard,
) -> CliResult<RateReport> {
if config.points.is_empty() {
return Err(CliError::Config(
"rate config must define at least one part-load point".to_string(),
));
}
// Each point is an independent solve → evaluate them in parallel.
let mut points: Vec<RatePointResult> = config
.points
.par_iter()
.map(|p| solve_point(base, p))
.collect();
points.sort_by(|a, b| {
b.load_fraction
.partial_cmp(&a.load_fraction)
.unwrap_or(std::cmp::Ordering::Equal)
});
let integrated_value = integrate(standard, &points);
Ok(RateReport {
base_config: config.base_config.display().to_string(),
metric: standard.name.clone(),
standard_reference: standard.reference.clone(),
points,
integrated_value,
})
}
/// Loads a rating config from a JSON file, resolves and loads the referenced base
/// scenario, resolves the seasonal standard, runs the rating, and optionally
/// writes the JSON report.
pub fn run_rate(config_path: &Path, output: Option<&Path>) -> CliResult<RateReport> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
})?;
let config: RateConfig = serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid rate config: {}", e)))?;
let config_dir = config_path.parent();
// Resolve base_config relative to the rating file's directory when needed.
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 standard = config.resolve_standard(config_dir)?;
let report = rate(&config, &base, &standard)?;
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 base_config() -> ScenarioConfig {
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [
{ "type": "Condenser", "name": "cond", "ua": 766.0, "secondary_fluid": "Water" },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "secondary_fluid": "Water" },
{ "type": "IsentropicCompressor", "name": "comp",
"isentropic_efficiency": 0.7, "t_cond_k": 318.15, "t_evap_k": 278.15,
"superheat_k": 5.0, "speed_hz": 50.0 },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.36 },
{ "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.48 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": []
}],
"solver": { "strategy": "fallback", "max_iterations": 200, "tolerance": 1e-6 }
}"#;
ScenarioConfig::from_json(json).unwrap()
}
#[test]
fn test_apply_overrides_sets_component_params() {
let base = base_config();
let point = RatePoint {
load_fraction: 0.5,
condenser_secondary_inlet_c: Some(18.3),
evaporator_secondary_inlet_c: Some(10.0),
condenser_secondary_mass_flow_kg_s: None,
evaporator_secondary_mass_flow_kg_s: None,
compressor_speed_hz: Some(25.0),
};
let cfg = apply_overrides(&base, &point);
let comps = &cfg.circuits[0].components;
let cond_src = comps.iter().find(|c| c.name == "cond_water_in").unwrap();
let evap_src = comps.iter().find(|c| c.name == "evap_water_in").unwrap();
let comp = comps
.iter()
.find(|c| c.component_type == "IsentropicCompressor")
.unwrap();
assert_eq!(cond_src.params.get("t_set_c").unwrap().as_f64(), Some(18.3));
assert_eq!(evap_src.params.get("t_set_c").unwrap().as_f64(), Some(10.0));
assert_eq!(comp.params.get("speed_hz").unwrap().as_f64(), Some(25.0));
// Untouched fields keep their base value.
assert_eq!(
cond_src.params.get("m_flow_kg_s").unwrap().as_f64(),
Some(0.36)
);
}
#[test]
fn test_apply_overrides_does_not_mutate_base() {
let base = base_config();
let point = RatePoint {
load_fraction: 1.0,
condenser_secondary_inlet_c: Some(40.0),
evaporator_secondary_inlet_c: None,
condenser_secondary_mass_flow_kg_s: None,
evaporator_secondary_mass_flow_kg_s: None,
compressor_speed_hz: None,
};
let _ = apply_overrides(&base, &point);
let cond_src = base.circuits[0]
.components
.iter()
.find(|c| c.name == "cond_water_in")
.unwrap();
assert_eq!(
cond_src.params.get("t_set_c").unwrap().as_f64(),
Some(30.0),
"base config must be left unmodified"
);
}
#[test]
fn test_integrate_iplv_matches_weighted_formula() {
let points = vec![
mk_point(1.00, 4.0),
mk_point(0.75, 5.0),
mk_point(0.50, 6.0),
mk_point(0.25, 5.5),
];
let std = PartLoadStandard::ahri_550_590_iplv();
let value = integrate(&std, &points).unwrap();
let expected = 0.01 * 4.0 + 0.42 * 5.0 + 0.45 * 6.0 + 0.12 * 5.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_eseer_uses_eurovent_weights() {
let points = vec![
mk_point(1.00, 3.0),
mk_point(0.75, 4.0),
mk_point(0.50, 5.0),
mk_point(0.25, 4.5),
];
let std = PartLoadStandard::eurovent_eseer();
let value = integrate(&std, &points).unwrap();
let expected = 0.03 * 3.0 + 0.33 * 4.0 + 0.41 * 5.0 + 0.23 * 4.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_orders_points_by_load_fraction() {
// Points supplied out of order must still map correctly to 100/75/50/25.
let points = vec![
mk_point(0.25, 5.5),
mk_point(1.00, 4.0),
mk_point(0.50, 6.0),
mk_point(0.75, 5.0),
];
let std = PartLoadStandard::ahri_550_590_iplv();
let value = integrate(&std, &points).unwrap();
let expected = 0.01 * 4.0 + 0.42 * 5.0 + 0.45 * 6.0 + 0.12 * 5.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_with_custom_standard() {
// A custom standard (different fractions/weights) drives the aggregation
// with no code change — matched to the supplied points by nearest fraction.
let std = PartLoadStandard::new(
"Custom SEER",
"illustrative",
vec![1.0, 0.74, 0.47, 0.21],
vec![0.03, 0.27, 0.41, 0.29],
)
.unwrap();
let points = vec![
mk_point(1.00, 3.0),
mk_point(0.74, 4.0),
mk_point(0.47, 5.0),
mk_point(0.21, 4.5),
];
let value = integrate(&std, &points).unwrap();
let expected = 0.03 * 3.0 + 0.27 * 4.0 + 0.41 * 5.0 + 0.29 * 4.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_none_when_point_missing_or_failed() {
let std = PartLoadStandard::ahri_550_590_iplv();
// Only three points → a standard fraction (0.25) has no match within tol.
let three = vec![mk_point(1.0, 4.0), mk_point(0.75, 5.0), mk_point(0.5, 6.0)];
assert!(integrate(&std, &three).is_none());
// Four points but one failed to produce an EER.
let mut four = vec![
mk_point(1.0, 4.0),
mk_point(0.75, 5.0),
mk_point(0.5, 6.0),
mk_point(0.25, 5.5),
];
four[2].eer = None;
assert!(integrate(&std, &four).is_none());
}
#[test]
fn test_resolve_standard_precedence() {
// Inline standard wins over everything.
let inline = PartLoadStandard::new("inline", "", vec![1.0, 0.5], vec![0.5, 0.5]).unwrap();
let cfg = RateConfig {
base_config: PathBuf::from("x.json"),
metric: RateMetric::Eseer,
standard_name: Some("eseer".to_string()),
standard_file: None,
standard: Some(inline.clone()),
points: vec![],
};
assert_eq!(cfg.resolve_standard(None).unwrap().name, "inline");
// standard_name selects a built-in over the metric enum.
let cfg2 = RateConfig {
standard: None,
standard_name: Some("eseer".to_string()),
metric: RateMetric::Iplv,
..cfg.clone()
};
assert_eq!(
cfg2.resolve_standard(None).unwrap().weights,
entropyk::rating::ESEER_WEIGHTS.to_vec()
);
// Falls back to the metric enum (default IPLV) when nothing else is set.
let cfg3 = RateConfig {
standard: None,
standard_name: None,
metric: RateMetric::Iplv,
..cfg.clone()
};
assert_eq!(
cfg3.resolve_standard(None).unwrap().weights,
entropyk::rating::IPLV_WEIGHTS.to_vec()
);
// Unknown built-in id is a clear error.
let cfg4 = RateConfig {
standard: None,
standard_name: Some("nope".to_string()),
..cfg
};
assert!(cfg4.resolve_standard(None).is_err());
}
fn mk_point(load: f64, eer: f64) -> RatePointResult {
RatePointResult {
load_fraction: load,
status: "converged".to_string(),
eer: Some(eer),
q_cooling_kw: Some(eer * 2.0),
power_kw: Some(2.0),
error: None,
}
}
}

File diff suppressed because it is too large Load Diff

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));
}
}