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:
@@ -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·(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.
|
||||
#[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, ¶ms).map_err(|missing| {
|
||||
CliError::Config(format!(
|
||||
"instance '{}' component '{}' references undefined parameter '${}'",
|
||||
inst.name, comp.name, missing
|
||||
))
|
||||
})?;
|
||||
new_components.push(c);
|
||||
}
|
||||
|
||||
let mut new_edges = Vec::with_capacity(tmpl.edges.len());
|
||||
for edge in &tmpl.edges {
|
||||
new_edges.push(EdgeConfig {
|
||||
from: prefix_internal_endpoint(&edge.from, &prefix),
|
||||
to: prefix_internal_endpoint(&edge.to, &prefix),
|
||||
});
|
||||
}
|
||||
|
||||
expansions.push(Expansion {
|
||||
circuit: inst.circuit,
|
||||
components: new_components,
|
||||
edges: new_edges,
|
||||
});
|
||||
}
|
||||
|
||||
for exp in expansions {
|
||||
let circuit = self.circuit_mut(exp.circuit);
|
||||
circuit.components.extend(exp.components);
|
||||
circuit.edges.extend(exp.edges);
|
||||
}
|
||||
|
||||
// Resolve external connections (instance port refs or literal component:port).
|
||||
let connections = std::mem::take(&mut self.connections);
|
||||
let mut resolved = Vec::with_capacity(connections.len());
|
||||
for conn in &connections {
|
||||
let (from_ep, from_circuit) = self.resolve_external_endpoint(&conn.from)?;
|
||||
let (to_ep, _to_circuit) = self.resolve_external_endpoint(&conn.to)?;
|
||||
resolved.push((from_circuit, from_ep, to_ep));
|
||||
}
|
||||
for (circuit, from, to) in resolved {
|
||||
self.circuit_mut(circuit)
|
||||
.edges
|
||||
.push(EdgeConfig { from, to });
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the circuit with `id`, creating it if absent.
|
||||
fn circuit_mut(&mut self, id: usize) -> &mut CircuitConfig {
|
||||
if let Some(pos) = self.circuits.iter().position(|c| c.id == id) {
|
||||
return &mut self.circuits[pos];
|
||||
}
|
||||
self.circuits.push(CircuitConfig {
|
||||
id,
|
||||
enabled: true,
|
||||
components: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
initial_state: None,
|
||||
});
|
||||
let last = self.circuits.len() - 1;
|
||||
&mut self.circuits[last]
|
||||
}
|
||||
|
||||
/// Resolve a connection endpoint to a concrete `"component:port"` string and the
|
||||
/// circuit id it belongs to.
|
||||
///
|
||||
/// - A literal `"component:port"` endpoint is returned unchanged; its circuit is
|
||||
/// inferred from the component name (falling back to circuit 0).
|
||||
/// - An external port reference `"{instance}.{external_port}"` (no colon) is
|
||||
/// resolved through the instance's template `ports` map into
|
||||
/// `"{instance}.{internal_component}:{port}"`.
|
||||
fn resolve_external_endpoint(&self, endpoint: &str) -> CliResult<(String, usize)> {
|
||||
if endpoint.contains(':') {
|
||||
let comp = endpoint.split(':').next().unwrap_or("");
|
||||
let circuit = self.circuit_of_component(comp).unwrap_or(0);
|
||||
return Ok((endpoint.to_string(), circuit));
|
||||
}
|
||||
|
||||
let (inst_name, port_name) = endpoint.split_once('.').ok_or_else(|| {
|
||||
CliError::Config(format!(
|
||||
"connection endpoint '{}' must be 'component:port' or 'instance.external_port'",
|
||||
endpoint
|
||||
))
|
||||
})?;
|
||||
|
||||
let inst = self
|
||||
.instances
|
||||
.iter()
|
||||
.find(|i| i.name == inst_name)
|
||||
.ok_or_else(|| {
|
||||
CliError::Config(format!(
|
||||
"connection endpoint '{}' references unknown instance '{}'",
|
||||
endpoint, inst_name
|
||||
))
|
||||
})?;
|
||||
|
||||
let tmpl = self.subsystems.get(&inst.template).ok_or_else(|| {
|
||||
CliError::Config(format!(
|
||||
"instance '{}' references unknown subsystem template '{}'",
|
||||
inst.name, inst.template
|
||||
))
|
||||
})?;
|
||||
|
||||
let internal = tmpl.ports.get(port_name).ok_or_else(|| {
|
||||
CliError::Config(format!(
|
||||
"instance '{}' (template '{}') exposes no external port '{}'. Available: {}",
|
||||
inst_name,
|
||||
inst.template,
|
||||
port_name,
|
||||
tmpl.ports.keys().cloned().collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok((format!("{}.{}", inst_name, internal), inst.circuit))
|
||||
}
|
||||
|
||||
/// Find which circuit contains a component (by name).
|
||||
fn circuit_of_component(&self, name: &str) -> Option<usize> {
|
||||
self.circuits
|
||||
.iter()
|
||||
.find(|c| c.components.iter().any(|comp| comp.name == name))
|
||||
.map(|c| c.id)
|
||||
}
|
||||
|
||||
/// Validate the configuration.
|
||||
pub fn validate(&self) -> CliResult<()> {
|
||||
if !SUPPORTED_SCHEMA_VERSIONS.contains(&self.schema_version.as_str()) {
|
||||
return Err(CliError::Config(format!(
|
||||
"unsupported schema_version '{}'. This build supports: {}",
|
||||
self.schema_version,
|
||||
SUPPORTED_SCHEMA_VERSIONS.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
if self.fluid.is_empty() {
|
||||
return Err(CliError::Config("fluid field is required".to_string()));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user