Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

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