//! 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, /// 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, /// Circuit configurations. #[serde(default)] pub circuits: Vec, /// Thermal couplings between circuits. #[serde(default)] pub thermal_couplings: Vec, /// 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, /// Steady-state control loops (co-solved saturated-PI controllers). #[serde(default)] pub controls: Vec, /// 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, /// Instantiations of `subsystems` templates. Each instance is expanded into /// concrete, name-prefixed components/edges in its target circuit. #[serde(default)] pub instances: Vec, /// 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, /// 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, /// Optional metadata. #[serde(default)] pub metadata: Option>, } /// 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, /// Components inside the template (names are local to the template). pub components: Vec, /// Internal edges between the template's own components. #[serde(default)] pub edges: Vec, /// 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, /// 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, /// Instantiations of nested [`Self::subsystems`] templates. Each instance is /// expanded recursively, with component names prefixed by `"{name}."`. #[serde(default)] pub instances: Vec, /// 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, } /// 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, } /// 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, /// 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, /// Selector smoothing sharpness for the override network (`softMin`/`softMax`). /// Smaller ⇒ sharper selection, larger ⇒ smoother/more robust. Default `1e-3`. #[serde(default)] pub alpha: Option, } /// 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, /// Name of the cold-circuit `ThermalLoad` component that receives the heat. #[serde(default)] pub cold_component: Option, } 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, /// Edge connections between components. #[serde(default)] pub edges: Vec, /// Initial state for edges. #[serde(default)] pub initial_state: Option, } 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, /// Fan speed ratio (0.0 to 1.0). #[serde(default)] pub fan_speed: Option, /// Air inlet temperature in Celsius. #[serde(default)] pub air_inlet_temp_c: Option, /// Air side heat transfer exponent. #[serde(default)] pub n_air_exponent: Option, /// Condenser bank spec identifier (used for creating multiple instances). #[serde(default)] pub condenser_bank: Option, // ----------------------------------------- /// Component-specific parameters (catch-all). #[serde(flatten)] pub params: HashMap, } /// 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, #[serde(default)] pub m4: Option, #[serde(default)] pub m5: Option, #[serde(default)] pub m6: Option, #[serde(default)] pub m7: Option, #[serde(default)] pub m8: Option, #[serde(default)] pub m9: Option, #[serde(default)] pub m10: Option, } /// 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, /// Initial enthalpy in kJ/kg. pub enthalpy_kj_kg: Option, /// Initial temperature in Kelvin. pub temperature_k: Option, } /// 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, /// Initial evaporating saturation temperature guess [K]. #[serde(default)] pub t_evap_guess_k: Option, /// Initial suction superheat guess [K]. #[serde(default)] pub superheat_guess_k: Option, } /// 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 { 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 { 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>(&mut self, config_path: Option

) -> 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, edges: Vec, } 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::>() .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::>().join(", ") )) })?; Ok((format!("{}.{}", inst_name, internal), inst.circuit)) } /// Find which circuit contains a component (by name). fn circuit_of_component(&self, name: &str) -> Option { 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::>() .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::>() .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, ) -> 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 { flatten_template_recursive_with_workspace(template, &HashMap::new()) } fn flatten_template_recursive_with_workspace( template: &SubsystemTemplate, workspace: &HashMap, ) -> CliResult { // 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 = 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::>().join(", "), workspace.keys().cloned().collect::>().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()); } }