Files
Entropyk/crates/cli/src/config.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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>
2026-07-19 16:35:31 +02:00

1357 lines
51 KiB
Rust
Raw Blame History

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