Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
1357 lines
51 KiB
Rust
1357 lines
51 KiB
Rust
//! Configuration parsing for CLI scenarios.
|
||
//!
|
||
//! 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 std::path::Path;
|
||
|
||
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, 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>,
|
||
/// Fluid name (e.g., "R134a", "R410A", "R744").
|
||
pub fluid: String,
|
||
/// Fluid backend to use (e.g., "CoolProp", "Test"). Defaults to "Test".
|
||
#[serde(default)]
|
||
pub fluid_backend: Option<String>,
|
||
/// Circuit configurations.
|
||
#[serde(default)]
|
||
pub circuits: Vec<CircuitConfig>,
|
||
/// Thermal couplings between circuits.
|
||
#[serde(default)]
|
||
pub thermal_couplings: Vec<ThermalCouplingConfig>,
|
||
/// 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>,
|
||
/// Workspace directory containing reusable `.ekmod` module files.
|
||
///
|
||
/// Each `.ekmod` file is a JSON [`SubsystemTemplate`] whose module name is
|
||
/// derived from the filename (e.g. `BaseChiller.ekmod` → `"BaseChiller"`).
|
||
/// Modules are loaded before [`Self::flatten_instances`] and injected into
|
||
/// every template's `subsystems` map so cross-file references resolve. If
|
||
/// absent, the CLI also checks `.entropyk/modules/` relative to the config
|
||
/// file as a fallback.
|
||
#[serde(default)]
|
||
pub workspace: Option<String>,
|
||
/// 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>,
|
||
/// Nested subsystem templates (for recursive composition). A template can
|
||
/// instantiate other templates declared in this map via [`Self::instances`].
|
||
/// Flattened recursively at load time — the solver never sees the nesting.
|
||
#[serde(default)]
|
||
pub subsystems: HashMap<String, SubsystemTemplate>,
|
||
/// Instantiations of nested [`Self::subsystems`] templates. Each instance is
|
||
/// expanded recursively, with component names prefixed by `"{name}."`.
|
||
#[serde(default)]
|
||
pub instances: Vec<InstanceConfig>,
|
||
/// External connections between nested instances (and/or the template's own
|
||
/// atomic components). Endpoints use `"instance.external_port"` or
|
||
/// `"component:port"` syntax, resolved during recursive flattening.
|
||
#[serde(default)]
|
||
pub connections: Vec<EdgeConfig>,
|
||
}
|
||
|
||
/// 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·(setpoint−measure)`.
|
||
#[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.
|
||
///
|
||
/// 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,
|
||
/// Cold circuit ID.
|
||
pub cold_circuit: usize,
|
||
/// Thermal conductance in W/K.
|
||
pub ua: f64,
|
||
/// 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, 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.
|
||
#[serde(default)]
|
||
pub edges: Vec<EdgeConfig>,
|
||
/// Initial state for edges.
|
||
#[serde(default)]
|
||
pub initial_state: Option<InitialStateConfig>,
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
/// Configuration for a component.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
pub struct ComponentConfig {
|
||
/// Component type (e.g., "Compressor", "Condenser", "Evaporator", "ExpansionValve", "HeatExchanger").
|
||
#[serde(rename = "type")]
|
||
pub component_type: String,
|
||
/// Component name for referencing in edges.
|
||
pub name: String,
|
||
|
||
// --- MchxCondenserCoil Specific Fields ---
|
||
/// Nominal UA value (kW/K). Maps to ua_nominal_kw_k.
|
||
#[serde(default)]
|
||
pub ua_nominal_kw_k: Option<f64>,
|
||
/// Fan speed ratio (0.0 to 1.0).
|
||
#[serde(default)]
|
||
pub fan_speed: Option<f64>,
|
||
/// Air inlet temperature in Celsius.
|
||
#[serde(default)]
|
||
pub air_inlet_temp_c: Option<f64>,
|
||
/// Air side heat transfer exponent.
|
||
#[serde(default)]
|
||
pub n_air_exponent: Option<f64>,
|
||
/// Condenser bank spec identifier (used for creating multiple instances).
|
||
#[serde(default)]
|
||
pub condenser_bank: Option<CondenserBankConfig>,
|
||
// -----------------------------------------
|
||
/// Component-specific parameters (catch-all).
|
||
#[serde(flatten)]
|
||
pub params: HashMap<String, serde_json::Value>,
|
||
}
|
||
|
||
/// Configuration for a condenser bank (multi-circuit, multi-coil).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
pub struct CondenserBankConfig {
|
||
/// Number of circuits.
|
||
pub circuits: usize,
|
||
/// Number of coils per circuit.
|
||
pub coils_per_circuit: usize,
|
||
}
|
||
|
||
/// Side conditions for a heat exchanger (hot or cold fluid).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
pub struct SideConditionsConfig {
|
||
/// Fluid name (e.g., "R134a", "Water", "Air").
|
||
pub fluid: String,
|
||
/// Inlet temperature in °C.
|
||
pub t_inlet_c: f64,
|
||
/// Pressure in bar.
|
||
#[serde(default = "default_pressure")]
|
||
pub pressure_bar: f64,
|
||
/// Mass flow rate in kg/s.
|
||
#[serde(default = "default_mass_flow")]
|
||
pub mass_flow_kg_s: f64,
|
||
}
|
||
|
||
fn default_pressure() -> f64 {
|
||
1.0
|
||
}
|
||
|
||
fn default_mass_flow() -> f64 {
|
||
0.1
|
||
}
|
||
|
||
/// Compressor AHRI 540 coefficients configuration.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
pub struct Ahri540Config {
|
||
/// Flow coefficient M1.
|
||
pub m1: f64,
|
||
/// Pressure ratio exponent M2.
|
||
pub m2: f64,
|
||
/// Power coefficients M3-M6 (cooling) and M7-M10 (heating).
|
||
#[serde(default)]
|
||
pub m3: Option<f64>,
|
||
#[serde(default)]
|
||
pub m4: Option<f64>,
|
||
#[serde(default)]
|
||
pub m5: Option<f64>,
|
||
#[serde(default)]
|
||
pub m6: Option<f64>,
|
||
#[serde(default)]
|
||
pub m7: Option<f64>,
|
||
#[serde(default)]
|
||
pub m8: Option<f64>,
|
||
#[serde(default)]
|
||
pub m9: Option<f64>,
|
||
#[serde(default)]
|
||
pub m10: Option<f64>,
|
||
}
|
||
|
||
/// Configuration for an edge between components.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
pub struct EdgeConfig {
|
||
/// Source component and port (e.g., "comp1:outlet").
|
||
pub from: String,
|
||
/// Target component and port (e.g., "cond1:inlet").
|
||
pub to: String,
|
||
}
|
||
|
||
/// Initial state configuration.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
pub struct InitialStateConfig {
|
||
/// Initial pressure in bar.
|
||
pub pressure_bar: Option<f64>,
|
||
/// Initial enthalpy in kJ/kg.
|
||
pub enthalpy_kj_kg: Option<f64>,
|
||
/// Initial temperature in Kelvin.
|
||
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, JsonSchema)]
|
||
pub struct SolverConfig {
|
||
/// Solver strategy: "newton" or "picard".
|
||
#[serde(default = "default_solver_strategy")]
|
||
pub strategy: String,
|
||
/// Maximum iterations.
|
||
#[serde(default = "default_max_iterations")]
|
||
pub max_iterations: usize,
|
||
/// Convergence tolerance.
|
||
#[serde(default = "default_tolerance")]
|
||
pub tolerance: f64,
|
||
/// Timeout in milliseconds (0 = no timeout).
|
||
#[serde(default)]
|
||
pub timeout_ms: u64,
|
||
/// Enable verbose output.
|
||
#[serde(default)]
|
||
pub verbose: bool,
|
||
}
|
||
|
||
fn default_solver_strategy() -> String {
|
||
"newton".to_string()
|
||
}
|
||
|
||
fn default_max_iterations() -> usize {
|
||
100
|
||
}
|
||
|
||
fn default_tolerance() -> f64 {
|
||
1e-6
|
||
}
|
||
|
||
impl Default for SolverConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
strategy: default_solver_strategy(),
|
||
max_iterations: default_max_iterations(),
|
||
tolerance: default_tolerance(),
|
||
timeout_ms: 0,
|
||
verbose: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ScenarioConfig {
|
||
/// Load a scenario configuration from a file.
|
||
pub fn from_file(path: &std::path::Path) -> CliResult<Self> {
|
||
let content = std::fs::read_to_string(path).map_err(|e| {
|
||
if e.kind() == std::io::ErrorKind::NotFound {
|
||
CliError::ConfigNotFound(path.to_path_buf())
|
||
} else {
|
||
CliError::Io(e)
|
||
}
|
||
})?;
|
||
|
||
let config: Self = serde_json::from_str(&content).map_err(CliError::InvalidConfig)?;
|
||
|
||
let mut config = config;
|
||
// Auto-load workspace modules (.ekmod files) before flattening.
|
||
config.load_workspace_auto(Some(path))?;
|
||
config.flatten_instances()?;
|
||
config.validate()?;
|
||
|
||
Ok(config)
|
||
}
|
||
|
||
/// 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;
|
||
// For from_json, only explicit `workspace` field is checked (no file
|
||
// path context for auto-discovery of .entropyk/modules/).
|
||
config.load_workspace_auto::<&std::path::Path>(None)?;
|
||
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}\"}}"))
|
||
}
|
||
|
||
/// Load reusable `.ekmod` module files from a workspace directory and merge
|
||
/// them into [`Self::subsystems`].
|
||
///
|
||
/// Each `.ekmod` file is a JSON [`SubsystemTemplate`]; the module name is the
|
||
/// filename without extension (e.g. `BaseChiller.ekmod` → `"BaseChiller"`).
|
||
/// If a subsystem with the same name already exists in the config, the
|
||
/// workspace module is skipped (inline definitions take precedence).
|
||
pub fn load_workspace(&mut self, dir: &Path) -> CliResult<()> {
|
||
if !dir.is_dir() {
|
||
return Err(CliError::Config(format!(
|
||
"workspace directory '{}' does not exist or is not a directory",
|
||
dir.display()
|
||
)));
|
||
}
|
||
let mut loaded = 0;
|
||
for entry in std::fs::read_dir(dir)? {
|
||
let path = entry?.path();
|
||
if path.extension().and_then(|e| e.to_str()) != Some("ekmod") {
|
||
continue;
|
||
}
|
||
let name = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
|
||
CliError::Config(format!("invalid .ekmod filename: '{}'", path.display()))
|
||
})?;
|
||
// Inline subsystems take precedence over workspace files.
|
||
if self.subsystems.contains_key(name) {
|
||
tracing::debug!(module = name, "workspace module skipped (inline override)");
|
||
continue;
|
||
}
|
||
let json = std::fs::read_to_string(&path)?;
|
||
let template: SubsystemTemplate = serde_json::from_str(&json).map_err(|e| {
|
||
CliError::Config(format!(
|
||
"failed to parse workspace module '{}': {}",
|
||
path.display(),
|
||
e
|
||
))
|
||
})?;
|
||
self.subsystems.insert(name.to_string(), template);
|
||
loaded += 1;
|
||
}
|
||
if loaded > 0 {
|
||
tracing::info!(loaded, dir = ?dir, "Loaded workspace modules");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Auto-discover and load a workspace directory. Checks, in order:
|
||
/// 1. The `workspace` field in the config (if set)
|
||
/// 2. `.entropyk/modules/` relative to `config_path` (if given)
|
||
///
|
||
/// Silently skips if neither exists (no workspace = no extra modules).
|
||
pub fn load_workspace_auto<P: AsRef<Path>>(&mut self, config_path: Option<P>) -> CliResult<()> {
|
||
// 1. Explicit workspace path
|
||
let ws_path = self.workspace.clone();
|
||
if let Some(ref ws) = ws_path {
|
||
return self.load_workspace(Path::new(ws));
|
||
}
|
||
// 2. Auto-discover .entropyk/modules/ next to the config file
|
||
if let Some(p) = config_path {
|
||
if let Some(parent) = p.as_ref().parent() {
|
||
let auto = parent.join(".entropyk").join("modules");
|
||
if auto.is_dir() {
|
||
return self.load_workspace(&auto);
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 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(());
|
||
}
|
||
|
||
// The top-level `subsystems` map serves as the workspace registry for
|
||
// recursive flattening: when a nested template's `instances` reference
|
||
// a subsystem by name, the resolver first checks the template's own
|
||
// `subsystems` map, then falls back to this top-level registry. This
|
||
// lets `.ekmod` files cross-reference each other without cloning.
|
||
|
||
// Recursively flatten nested templates first: a SubsystemTemplate may
|
||
// itself contain subsystems/instances/connections. After this pass,
|
||
// every template in self.subsystems is flat (only atomic components).
|
||
let workspace = self.subsystems.clone();
|
||
for tmpl in self.subsystems.values_mut() {
|
||
*tmpl = flatten_template_recursive_with_workspace(tmpl, &workspace)?;
|
||
}
|
||
|
||
// 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, ¶ms).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()));
|
||
}
|
||
|
||
for (i, circuit) in self.circuits.iter().enumerate() {
|
||
if circuit.components.is_empty() {
|
||
return Err(CliError::Config(format!("circuit {} has no components", i)));
|
||
}
|
||
|
||
let component_names: std::collections::HashSet<&str> =
|
||
circuit.components.iter().map(|c| c.name.as_str()).collect();
|
||
|
||
for edge in &circuit.edges {
|
||
let from_parts: Vec<&str> = edge.from.split(':').collect();
|
||
let to_parts: Vec<&str> = edge.to.split(':').collect();
|
||
|
||
if from_parts.len() != 2 || to_parts.len() != 2 {
|
||
return Err(CliError::Config(format!(
|
||
"invalid edge format '{} -> {}'. Expected 'component:port'",
|
||
edge.from, edge.to
|
||
)));
|
||
}
|
||
|
||
let from_component = from_parts[0];
|
||
let to_component = to_parts[0];
|
||
|
||
if !component_names.contains(from_component) {
|
||
return Err(CliError::Config(format!(
|
||
"edge references unknown component '{}' (in '{}'). Available: {}",
|
||
from_component,
|
||
edge.from,
|
||
component_names
|
||
.iter()
|
||
.cloned()
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
)));
|
||
}
|
||
|
||
if !component_names.contains(to_component) {
|
||
return Err(CliError::Config(format!(
|
||
"edge references unknown component '{}' (in '{}'). Available: {}",
|
||
to_component,
|
||
edge.to,
|
||
component_names
|
||
.iter()
|
||
.cloned()
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
}
|
||
}
|
||
|
||
/// Recursively flatten a [`SubsystemTemplate`]: expand nested `instances` into
|
||
/// concrete components and edges, producing a flat template with only atomic
|
||
/// components. After this pass, the template's `subsystems`/`instances`/
|
||
/// `connections` are consumed (emptied) and only `components`/`edges`/`ports`
|
||
/// remain. A no-op clone when the template has no nested instances.
|
||
///
|
||
/// # Recursive composition
|
||
///
|
||
/// A template can itself instantiate other templates (declared in its own
|
||
/// `subsystems` map). Each nested instance is expanded depth-first, with
|
||
/// component names prefixed cumulatively (`"parent.child.comp"`). Exposed
|
||
/// ports of child instances are resolved to their concrete internal endpoints
|
||
/// via the child template's `ports` map, then prefixed.
|
||
fn flatten_template_recursive(template: &SubsystemTemplate) -> CliResult<SubsystemTemplate> {
|
||
flatten_template_recursive_with_workspace(template, &HashMap::new())
|
||
}
|
||
|
||
fn flatten_template_recursive_with_workspace(
|
||
template: &SubsystemTemplate,
|
||
workspace: &HashMap<String, SubsystemTemplate>,
|
||
) -> CliResult<SubsystemTemplate> {
|
||
// Base case: no nested instances to expand.
|
||
if template.instances.is_empty() {
|
||
return Ok(template.clone());
|
||
}
|
||
|
||
let mut flat_components = template.components.clone();
|
||
let mut flat_edges = template.edges.clone();
|
||
let mut instance_port_map: HashMap<String, String> = HashMap::new();
|
||
|
||
for inst in &template.instances {
|
||
// Resolve child template: first check local subsystems, then workspace.
|
||
let child_template = template
|
||
.subsystems
|
||
.get(&inst.template)
|
||
.or_else(|| workspace.get(&inst.template))
|
||
.ok_or_else(|| {
|
||
CliError::Config(format!(
|
||
"nested instance '{}' references unknown subsystem template '{}'. Available locally: {} | workspace: {}",
|
||
inst.name,
|
||
inst.template,
|
||
template.subsystems.keys().cloned().collect::<Vec<_>>().join(", "),
|
||
workspace.keys().cloned().collect::<Vec<_>>().join(", "),
|
||
))
|
||
})?;
|
||
|
||
// Recurse: flatten the child template first (depth-first), passing the
|
||
// workspace so cross-file references resolve at every nesting level.
|
||
let flat_child = flatten_template_recursive_with_workspace(child_template, workspace)?;
|
||
|
||
// Resolve params: child defaults overlaid by instance overrides.
|
||
let mut params = flat_child.params.clone();
|
||
for (k, v) in &inst.params {
|
||
params.insert(k.clone(), v.clone());
|
||
}
|
||
|
||
let prefix = format!("{}.", inst.name);
|
||
|
||
// Expand child components with prefix + param substitution.
|
||
for comp in &flat_child.components {
|
||
let mut c = comp.clone();
|
||
c.name = format!("{}{}", prefix, comp.name);
|
||
substitute_component_params(&mut c, ¶ms).map_err(|missing| {
|
||
CliError::Config(format!(
|
||
"nested instance '{}' component '{}' references undefined parameter '${}'",
|
||
inst.name, comp.name, missing
|
||
))
|
||
})?;
|
||
flat_components.push(c);
|
||
}
|
||
|
||
// Expand child edges with prefix.
|
||
for edge in &flat_child.edges {
|
||
flat_edges.push(EdgeConfig {
|
||
from: prefix_internal_endpoint(&edge.from, &prefix),
|
||
to: prefix_internal_endpoint(&edge.to, &prefix),
|
||
});
|
||
}
|
||
|
||
// Map exposed ports: "instance.port" → "prefix.internal_comp:port".
|
||
for (port_name, internal_path) in &flat_child.ports {
|
||
let resolved = prefix_internal_endpoint(internal_path, &prefix);
|
||
instance_port_map.insert(format!("{}.{}", inst.name, port_name), resolved);
|
||
}
|
||
}
|
||
|
||
// Resolve the template's own connections (between nested instances and/or
|
||
// atomic components). Endpoints referencing "instance.port" are resolved
|
||
// via the map above; literal "component:port" endpoints are kept as-is.
|
||
for conn in &template.connections {
|
||
let from = instance_port_map
|
||
.get(&conn.from)
|
||
.cloned()
|
||
.unwrap_or_else(|| conn.from.clone());
|
||
let to = instance_port_map
|
||
.get(&conn.to)
|
||
.cloned()
|
||
.unwrap_or_else(|| conn.to.clone());
|
||
flat_edges.push(EdgeConfig { from, to });
|
||
}
|
||
|
||
Ok(SubsystemTemplate {
|
||
params: template.params.clone(),
|
||
components: flat_components,
|
||
edges: flat_edges,
|
||
ports: template.ports.clone(),
|
||
subsystems: HashMap::new(),
|
||
instances: Vec::new(),
|
||
connections: Vec::new(),
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_parse_minimal_config() {
|
||
let json = r#"{ "fluid": "R134a" }"#;
|
||
let config = ScenarioConfig::from_json(json).unwrap();
|
||
assert_eq!(config.fluid, "R134a");
|
||
assert_eq!(config.fluid_backend, None);
|
||
assert!(config.circuits.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_config_with_backend() {
|
||
let json = r#"{ "fluid": "R134a", "fluid_backend": "CoolProp" }"#;
|
||
let config = ScenarioConfig::from_json(json).unwrap();
|
||
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#"
|
||
{
|
||
"fluid": "R410A",
|
||
"circuits": [
|
||
{
|
||
"id": 0,
|
||
"components": [
|
||
{ "type": "Compressor", "name": "comp1", "ua": 5000.0 },
|
||
{ "type": "Condenser", "name": "cond1", "ua": 5000.0 }
|
||
],
|
||
"edges": [
|
||
{ "from": "comp1:outlet", "to": "cond1:inlet" }
|
||
],
|
||
"initial_state": {
|
||
"pressure_bar": 10.0,
|
||
"enthalpy_kj_kg": 400.0
|
||
}
|
||
}
|
||
],
|
||
"solver": {
|
||
"strategy": "newton",
|
||
"max_iterations": 50,
|
||
"tolerance": 1e-8
|
||
}
|
||
}"#;
|
||
let config = ScenarioConfig::from_json(json).unwrap();
|
||
assert_eq!(config.fluid, "R410A");
|
||
assert_eq!(config.circuits.len(), 1);
|
||
assert_eq!(config.circuits[0].components.len(), 2);
|
||
assert_eq!(config.solver.strategy, "newton");
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_missing_fluid() {
|
||
let json = r#"{ "fluid": "" }"#;
|
||
let result = ScenarioConfig::from_json(json);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_invalid_edge_format() {
|
||
let json = r#"
|
||
{
|
||
"fluid": "R134a",
|
||
"circuits": [{
|
||
"id": 0,
|
||
"components": [{ "type": "Compressor", "name": "comp1", "ua": 5000.0 }],
|
||
"edges": [{ "from": "invalid", "to": "also_invalid" }]
|
||
}]
|
||
}"#;
|
||
let result = ScenarioConfig::from_json(json);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_mchx_condenser_coil() {
|
||
let json = r#"
|
||
{
|
||
"fluid": "R134a",
|
||
"circuits": [{
|
||
"id": 0,
|
||
"components": [{
|
||
"type": "MchxCondenserCoil",
|
||
"name": "mchx_coil",
|
||
"ua_nominal_kw_k": 25.5,
|
||
"fan_speed": 0.8,
|
||
"air_inlet_temp_c": 35.0,
|
||
"condenser_bank": {
|
||
"circuits": 2,
|
||
"coils_per_circuit": 3
|
||
}
|
||
}],
|
||
"edges": []
|
||
}]
|
||
}"#;
|
||
let config = ScenarioConfig::from_json(json).unwrap();
|
||
let comp = &config.circuits[0].components[0];
|
||
|
||
assert_eq!(comp.component_type, "MchxCondenserCoil");
|
||
assert_eq!(comp.ua_nominal_kw_k, Some(25.5));
|
||
assert_eq!(comp.fan_speed, Some(0.8));
|
||
assert_eq!(comp.air_inlet_temp_c, Some(35.0));
|
||
|
||
let bank = comp.condenser_bank.as_ref().unwrap();
|
||
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());
|
||
}
|
||
}
|