/** * Convert a React Flow graph (nodes + edges) into the ScenarioConfig JSON * expected by the Entropyk CLI / API (crates/cli/src/config.rs). * * ScenarioConfig schema: * { * "fluid": "R410A", * "fluid_backend": "CoolProp", * "circuits": [ * { * "id": 0, * "components": [ { "type": "...", "name": "...", ...params } ], * "edges": [ { "from": "comp:outlet", "to": "cond:inlet" } ] * } * ], * "thermal_couplings": [ { "hot_circuit": 0, "cold_circuit": 1, "ua": 6000, "efficiency": 0.95 } ], * "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 } * } */ import type { Edge, Node } from "@xyflow/react"; import { enforceModelicaBoundaryEmit, findConnectedSecondaryBoundary, isBoundaryParamFixed, } from "./boundaryFix"; import { COMPONENT_BY_TYPE, FIXED_FLAG_PREFIX, isParamFixed, isSecondaryPort, type ParamMeta, } from "./componentMeta"; // Re-export for existing imports (PropertiesPanel, dofLedger, tests). export { findConnectedSecondaryBoundary } from "./boundaryFix"; export interface EntropykNodeData { type: string; // Entropyk component type ("Condenser", ...) name: string; // unique name within circuit circuit: number; params: Record; [key: string]: unknown; } export interface ControlConfig { type?: string; id: string; measure: { component: string; output: string }; actuator: { component: string; factor: string; initial?: number; min: number; max: number }; target: number; gain?: number; band?: number; smooth_eps?: number; objectives?: ControlObjectiveConfig[]; alpha?: number; } /** Modelica Z-factor embedding (unknown + equation). Not a SaturatedController. */ export interface EmbeddingConfig { id: string; unknown: { component: string; factor: string; start: number; min: number; max: number; }; equation: { component: string; output: string; value: number; }; } export interface ControlObjectiveConfig { component: string; output: string; setpoint: number; gain: number; combine: "min" | "max"; } export interface SubsystemTemplate { params?: Record; components: Array>; edges?: Array<{ from: string; to: string }>; ports?: Record; } export interface InstanceConfig { of: string; name: string; circuit?: number; params?: Record; } export interface ScenarioConfig { /** * Model IR schema version. "1" is the legacy flat circuits/components/edges * graph; "2" adds controls/subsystems/instances/connections. The web UI emits * the current version so the CLI and every consumer read one unified IR. */ schema_version?: string; name?: string; fluid: string; fluid_backend?: string; circuits: Array<{ id: number; name?: string; components: Array>; edges: Array<{ from: string; to: string }>; }>; thermal_couplings?: Array<{ hot_circuit: number; cold_circuit: number; ua: number; efficiency: number; }>; /** System regulation loops (EXV/injection). Mirrors CLI `controls`. */ controls?: ControlConfig[]; /** Modelica Z-factor embeddings (unknown + equation). Mirrors CLI `embeddings`. */ embeddings?: EmbeddingConfig[]; /** Reusable subsystem templates (flattened by the CLI at load time). */ subsystems?: Record; /** Template instantiations. */ instances?: InstanceConfig[]; /** External connections between instance ports. */ connections?: Array<{ from: string; to: string }>; solver: { strategy: string; max_iterations: number; tolerance: number; }; } /** The Model IR schema version emitted by this UI build (kept in sync with the CLI). */ export const SCHEMA_VERSION = "2"; export const CONTROL_NODE_TYPE = "SaturatedController"; /** * Legacy auto-calib control ids (pre-embeddings). Still purged from canvas if present. */ export function isAutoCalibrationControlId(id: string): boolean { return id.startsWith("calib_") || id.startsWith("emb_"); } export interface BuildOptions { fluid?: string; fluidBackend?: string; solverStrategy?: string; maxIterations?: number; tolerance?: number; thermalCouplings?: Array<{ hot_circuit: number; cold_circuit: number; ua: number; efficiency: number; }>; /** Steady-state control loops to co-solve (emitted verbatim into the IR). */ controls?: ControlConfig[]; } const PARAM_ALIASES: Record = { t_set_c: ["temperature_c", "T"], m_flow_kg_s: ["mass_flow_kg_s", "mass_flow"], rh: ["relative_humidity"], p_set_bar: ["pressure_bar"], p_back_bar: ["pressure_bar"], dp_correlation: ["DpCorrelation", "dpCorrelation"], }; function getParam( params: Record, key: string, ): number | string | boolean | undefined { if (params[key] !== undefined) return params[key]; for (const alias of PARAM_ALIASES[key] ?? []) { if (params[alias] !== undefined) return params[alias]; } return undefined; } export function canonicalizeParams( type: string, params: Record, ): Record { const next = { ...params }; if (type === "BrineSource") { const t = getParam(next, "t_set_c"); const m = getParam(next, "m_flow_kg_s"); const p = getParam(next, "p_set_bar"); if (t !== undefined) next.t_set_c = t; if (m !== undefined) next.m_flow_kg_s = m; if (p !== undefined) next.p_set_bar = p; } if (type === "AirSource") { const t = getParam(next, "t_dry_c") ?? getParam(next, "t_set_c"); const m = getParam(next, "m_flow_kg_s"); const p = getParam(next, "p_set_bar"); const rh = getParam(next, "rh"); if (t !== undefined) next.t_dry_c = t; if (m !== undefined) next.m_flow_kg_s = m; if (p !== undefined) next.p_set_bar = p; if (rh !== undefined) next.rh = rh; } if (type === "BrineSink" || type === "AirSink" || type === "RefrigerantSink") { const p = getParam(next, "p_back_bar"); if (p !== undefined) next.p_back_bar = p; } return next; } /** * Each React Flow edge carries the source/target port id on its handle. * The CLI expects "componentName:port" strings, so we translate handle ids * (which are port names like "outlet") into "name:port". * * When the handle is missing, fall back by role: sources use an outlet-like * port, targets an inlet-like port — never both ends as `ports[0]` (inlet), * which breaks pipe splice / manual wires. */ function edgeRef( node: Node | undefined, handleId: string | null | undefined, role: "source" | "target" = "target", ): string { if (!node) return ""; const meta = COMPONENT_BY_TYPE[node.data.type]; const ports = meta?.ports ?? []; if (handleId && ports.includes(handleId)) { return `${node.data.name}:${handleId}`; } const port = role === "source" ? ports.find((p) => /outlet|discharge|out$/i.test(p)) ?? ports[ports.length - 1] ?? "outlet" : ports.find((p) => /inlet|suction|^in$/i.test(p)) ?? ports[0] ?? "inlet"; return `${node.data.name}:${port}`; } /** A boundary node (Source/Sink) supplies/absorbs a secondary stream. */ function isBoundaryNode(node: Node | undefined): boolean { return !!node && /(?:Source|Sink)$/.test(node.data.type); } export interface SecondaryResolution { /** Legacy compatibility: secondary streams are no longer reduced into hidden params. */ overrides: Map>; /** Legacy compatibility: boundary nodes are preserved as explicit solver components. */ absorbed: Set; } export function resolveSecondaryStreams( nodes: Node[], edges: Edge[], ): SecondaryResolution { void nodes; void edges; return { overrides: new Map(), absorbed: new Set() }; } export function buildScenarioConfig( nodes: Node[], edges: Edge[], options: BuildOptions = {}, ): ScenarioConfig { const nodeById = new Map>(); for (const n of nodes) nodeById.set(n.id, n); // Separate ModuleInstance nodes from flat atomic components const instances: InstanceConfig[] = []; const connections: Array<{ from: string; to: string }> = []; const moduleNodes = nodes.filter((n) => n.data.type === "ModuleInstance"); moduleNodes.forEach((n) => { const moduleName = String(n.data.params.module_name ?? ""); if (!moduleName) return; const params: Record = {}; for (const [k, v] of Object.entries(n.data.params)) { if (k !== "module_name" && k !== "module_ports" && typeof v !== "undefined") { params[k] = v; } } instances.push({ of: moduleName, name: n.data.name, circuit: n.data.circuit ?? 0, ...(Object.keys(params).length > 0 ? { params } : {}), }); }); // Group non-module component nodes by circuit id. const circuitsMap = new Map[]>(); for (const n of nodes) { if (n.data.type === "ModuleInstance") continue; const c = n.data?.circuit ?? 0; if (!circuitsMap.has(c)) circuitsMap.set(c, []); circuitsMap.get(c)!.push(n); } // Ensure circuit 0 exists if empty if (circuitsMap.size === 0) { circuitsMap.set(0, []); } const circuits = Array.from(circuitsMap.entries()) .sort(([a], [b]) => a - b) .map(([circuitId, cNodes]) => { const components = cNodes .filter((n) => n.data.type !== CONTROL_NODE_TYPE) .map((n) => { const { type, name, params } = n.data; const canonicalParams = canonicalizeParams(type, params); const cleaned = stripUiOnlyParams(type, canonicalParams); return { type, name, ...cleaned } as Record; }); // Pure circuit edges (neither endpoint is a ModuleInstance) const circuitEdges = edges.filter((e) => { const s = nodeById.get(e.source); const t = nodeById.get(e.target); if (!s || !t) return false; if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") return false; if (s.data.circuit !== circuitId || t.data.circuit !== circuitId) return false; return true; }); const edgeConfigs = circuitEdges.map((e) => ({ from: edgeRef(nodeById.get(e.source), e.sourceHandle, "source"), to: edgeRef(nodeById.get(e.target), e.targetHandle, "target"), })); enforceModelicaBoundaryEmit(components, cNodes, circuitEdges); return { id: circuitId, name: `Circuit ${circuitId}`, components, edges: edgeConfigs }; }); // Collect edges connected to ModuleInstances as `connections` edges.forEach((e) => { const s = nodeById.get(e.source); const t = nodeById.get(e.target); if (!s || !t) return; if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") { const fromRef = s.data.type === "ModuleInstance" ? `${s.data.name}.${e.sourceHandle ?? "outlet"}` : edgeRef(s, e.sourceHandle, "source"); const toRef = t.data.type === "ModuleInstance" ? `${t.data.name}.${e.targetHandle ?? "inlet"}` : edgeRef(t, e.targetHandle, "target"); connections.push({ from: fromRef, to: toRef }); } }); // Explicit Advanced-palette regulation controllers only (EXV/injection). // Fixed/Free Z-factors → embeddings[] (never controls[]/SaturatedController). const nodeControls = nodes .filter( (node) => node.data.type === CONTROL_NODE_TYPE && !isAutoCalibrationControlId(node.data.name), ) .map(controlNodeToConfig); const controls = mergeControls(options.controls ?? [], nodeControls); const embeddings = buildModelEmbeddings(nodes, edges); return { schema_version: SCHEMA_VERSION, fluid: options.fluid || "R410A", fluid_backend: options.fluidBackend || "CoolProp", circuits, ...(instances.length > 0 ? { instances } : {}), ...(connections.length > 0 ? { connections } : {}), thermal_couplings: options.thermalCouplings || [], ...(controls.length > 0 ? { controls } : {}), ...(embeddings.length > 0 ? { embeddings } : {}), solver: { strategy: options.solverStrategy || "newton", max_iterations: options.maxIterations ?? 300, tolerance: options.tolerance ?? 1e-6, }, }; } /** * Remove UI-only keys before sending to the CLI: * - `__fixed_*` Fixed checkbox flags * - measure-only targets (e.g. Probe `target`) — they become control setpoints * - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow` * - when Z_UA is Free (calibration), omit literal `ua` override — the CLI * otherwise bakes `ua` into a fixed Calib factor and ignores live `z_ua`, * which zeros ∂measure/∂z_ua and blows up Newton (singular J → bad Picard state) */ export function stripUiOnlyParams( type: string, params: Record, ): Record { const meta = COMPONENT_BY_TYPE[type]; const measureOnly = new Set( (meta?.params ?? []) .filter((p) => p.measureOutput && !p.actuatorFactor) .map((p) => p.key), ); const out: Record = {}; for (const [k, v] of Object.entries(params)) { if (k.startsWith(FIXED_FLAG_PREFIX)) continue; if (measureOnly.has(k)) continue; // Omit blank optional numerics (e.g. unset orifice_kv). if (v === "" || v === null || v === undefined) continue; out[k] = v; } // Free z_ua → live embedding owns UA scaling; drop absolute `ua` override. const zUaMeta = meta?.params.find((p) => p.key === "z_ua" && p.actuatorFactor === "z_ua"); if (zUaMeta && !isParamFixed(params, zUaMeta)) { delete out.ua; } return applyExvFixSemantics(type, applyBoundaryFixSemantics(type, out, params), params); } const EXV_TYPES = new Set(["IsenthalpicExpansionValve", "EXV"]); /** * Emit `fix_opening` when orifice_kv is set so the CLI can choose fixed vs * free orifice (never infer orifice from opening alone). */ export function applyExvFixSemantics( type: string, cleaned: Record, rawParams: Record, ): Record { if (!EXV_TYPES.has(type)) return cleaned; const kv = cleaned.orifice_kv; const kvNum = typeof kv === "number" ? kv : Number(kv); if (!Number.isFinite(kvNum) || kvNum <= 0) { const out: Record = { ...cleaned }; delete out.orifice_kv; delete out.fix_opening; return out; } const meta = COMPONENT_BY_TYPE[type]; const openMeta = meta?.params.find((p) => p.key === "opening"); const out: Record = { ...cleaned, orifice_kv: kvNum }; if (openMeta?.fixable) { out.fix_opening = isParamFixed(rawParams, openMeta); } else { out.fix_opening = true; } return out; } const BOUNDARY_FIX_TYPES = new Set([ "BrineSource", "BrineSink", "AirSource", "AirSink", ]); /** * Translate UI Fixed checkboxes into CLI `fix_*` flags for boundary nodes. * Free sink temperatures omit the Dirichlet key so legacy configs stay valid. */ export function applyBoundaryFixSemantics( type: string, cleaned: Record, rawParams: Record, ): Record { if (!BOUNDARY_FIX_TYPES.has(type)) return cleaned; const meta = COMPONENT_BY_TYPE[type]; if (!meta) return cleaned; const out = { ...cleaned }; const pMeta = meta.params.find((p) => p.key === "p_set_bar" || p.key === "p_back_bar"); const tMeta = meta.params.find( (p) => p.key === "t_set_c" || p.key === "t_dry_c" || p.key === "t_back_c", ); const mMeta = meta.params.find((p) => p.key === "m_flow_kg_s"); if (pMeta?.fixable) { out.fix_pressure = isBoundaryParamFixed(rawParams, pMeta); } if (tMeta?.fixable) { const fixedT = isBoundaryParamFixed(rawParams, tMeta); out.fix_temperature = fixedT; // Free T on sinks: omit the setpoint so CLI does not impose h (legacy presence rule). if (!fixedT && (type === "BrineSink" || type === "AirSink")) { delete out.t_set_c; delete out.t_back_c; } } if (mMeta?.fixable) { out.fix_mass_flow = isBoundaryParamFixed(rawParams, mMeta); } delete out.delta_t_k; return out; } /** * Build Modelica `embeddings[]`: Free Z-factor (unknown) + Fixed Probe (equation). * * Pairing (free factor → Probe Fixed physical params): * z_ua → Tsat, Tsh * z_dp → P * z_flow → Capacity * f_w → T * z_power → Capacity * z_etav → Capacity * opening → Tsh * * Never emits SaturatedController / controls[]. */ export function buildModelEmbeddings( nodes: Node[], edges: Edge[] = [], ): EmbeddingConfig[] { type ProbeMeasure = { nodeName: string; kind: string; output: string; target: number; factorCompat: readonly string[]; }; type FreeAct = { factor: string; initial: number; min: number; max: number; key: string; }; const embeddings: EmbeddingConfig[] = []; const probeMeasures: ProbeMeasure[] = []; const freeActsByComponent = new Map< string, { nodeName: string; node: Node; acts: FreeAct[] } >(); for (const node of nodes) { if (node.data.type === CONTROL_NODE_TYPE) continue; const meta = COMPONENT_BY_TYPE[node.data.type]; if (!meta) continue; const params = node.data.params ?? {}; if (node.data.type === "Probe") { let emitted = 0; for (const p of meta.params) { if (!p.fixable || !p.measureOutput) continue; if (!isParamFixed(params, p)) continue; const raw = params[p.key]; const n = typeof raw === "number" ? raw : Number(raw); if (!Number.isFinite(n)) continue; const kind = PROBE_PARAM_KIND[p.key] ?? p.key; let target = measureSetpointSi(p, n); if (p.measureOutput === "pressure" && (p.unit ?? "").toLowerCase() === "bar") { target = n * 1e5; } probeMeasures.push({ nodeName: node.data.name, kind, output: p.measureOutput === "auto" ? "temperature" : p.measureOutput, target, factorCompat: FACTOR_COMPATIBILITY[kind] ?? [], }); emitted += 1; } if (emitted === 0) { const legacy = legacyProbeMeasure(params); if (legacy) { probeMeasures.push({ nodeName: node.data.name, kind: legacy.kind, output: legacy.output, target: legacy.target, factorCompat: FACTOR_COMPATIBILITY[legacy.kind] ?? [], }); } } continue; } const freeActs: FreeAct[] = []; for (const p of meta.params) { if (!p.fixable) continue; const fixed = isParamFixed(params, p); const raw = params[p.key]; if (p.actuatorFactor && !fixed) { const n = typeof raw === "number" ? raw : Number(raw); let initial = Number.isFinite(n) ? n : 1.0; if ( (p.actuatorFactor === "z_ua" || p.actuatorFactor === "z_dp") && Math.abs(initial - 1.0) < 1e-12 ) { initial = 0.3; } freeActs.push({ factor: p.actuatorFactor, initial, min: p.freeMin ?? 0.1, max: p.freeMax ?? 3.0, key: p.key, }); } } if (freeActs.length > 0) { freeActsByComponent.set(node.data.name, { nodeName: node.data.name, node, acts: freeActs }); } } const adjacentProbes = new Map>(); const probeNodeIds = new Set(nodes.filter((n) => n.data.type === "Probe").map((n) => n.id)); for (const edge of edges) { const endpoints = [edge.source, edge.target]; for (const endpoint of endpoints) { if (!endpoint) continue; for (const other of endpoints) { if (other && other !== endpoint && probeNodeIds.has(other)) { const probeName = nodes.find((n) => n.id === other)?.data.name; if (probeName) { const endpointName = nodes.find((n) => n.id === endpoint)?.data.name; if (endpointName) { if (!adjacentProbes.has(endpointName)) { adjacentProbes.set(endpointName, new Set()); } adjacentProbes.get(endpointName)!.add(probeName); } } } } } } const findProbeFor = ( component: string, factor: string, usedKeys: Set, ): ProbeMeasure | undefined => { const compatible = probeMeasures.filter( (m) => m.factorCompat.includes(factor) && !usedKeys.has(`${m.nodeName}::${m.kind}`), ); if (compatible.length === 0) return undefined; const adjacent = adjacentProbes.get(component); const adjacentMatch = adjacent ? compatible.find((m) => adjacent.has(m.nodeName)) : undefined; const chosen = adjacentMatch ?? compatible[0]; usedKeys.add(`${chosen.nodeName}::${chosen.kind}`); return chosen; }; const usedKeys = new Set(); for (const { nodeName, acts } of freeActsByComponent.values()) { for (const act of acts) { const probe = findProbeFor(nodeName, act.factor, usedKeys); if (probe) { embeddings.push({ id: `emb_${nodeName}_${act.factor}`, unknown: { component: nodeName, factor: act.factor, start: act.initial, min: act.min, max: act.max, }, equation: { component: probe.nodeName, output: probe.output, value: probe.target, }, }); } } } return embeddings; } /** @deprecated Use {@link buildModelEmbeddings}. Kept for transitional tests. */ export function buildFixedFreeCalibrationControls( nodes: Node[], edges: Edge[] = [], ): ControlConfig[] { return buildModelEmbeddings(nodes, edges).map((emb) => ({ type: "SaturatedController", id: emb.id, measure: { component: emb.equation.component, output: emb.equation.output }, actuator: { component: emb.unknown.component, factor: emb.unknown.factor, initial: emb.unknown.start, min: emb.unknown.min, max: emb.unknown.max, }, target: emb.equation.value, gain: -0.5, band: 2.0, })); } /** Probe param key → semantic kind for pairing. */ const PROBE_PARAM_KIND: Record = { t_c: "T", tsat_c: "Tsat", p_bar: "P", x: "X", tsh_k: "Tsh", capacity_w: "Capacity", }; /** * Legacy Probe shape: `{ measure: "SST", target: 5.9, __fixed_target: true }`. * Absolute temperatures that look like °C (< 200) are converted to K. */ function legacyProbeMeasure(params: Record): { kind: string; output: string; target: number; } | null { const fixedFlag = params.__fixed_target; // defaultFixed was false for legacy target — require explicit Fixed ON if (fixedFlag !== true && fixedFlag !== "true") return null; const kindRaw = params.measure; const kind = typeof kindRaw === "string" ? kindRaw : ""; if (!kind) return null; const raw = params.target; const n = typeof raw === "number" ? raw : Number(raw); if (!Number.isFinite(n)) return null; const output = legacyProbeOutput(kind); let target = n; // SST/SDT/T/DGT: UI historically stored °C in `target` without a unit. if ( output === "saturationTemperature" || output === "temperature" ) { if (n > -100 && n < 200) { target = n + 273.15; } } // Pressure: bar → Pa when value looks like bar if (output === "pressure" && n > 0 && n < 200) { target = n * 1e5; } return { kind, output, target }; } function legacyProbeOutput(kind: string): string { switch (kind) { case "SST": case "SDT": return "saturationTemperature"; case "SH": case "DSH": return "superheat"; case "SC": return "subcooling"; case "DGT": case "T": return "temperature"; case "P": return "pressure"; case "MassFlow": return "massFlowRate"; case "Capacity": return "capacity"; default: return "temperature"; } } /** Probe physical kind → free factors it can calibrate. */ const FACTOR_COMPATIBILITY: Record = { Tsat: ["z_ua"], Tsh: ["z_ua", "opening"], T: ["f_w"], P: ["z_dp"], Capacity: ["z_flow", "z_power", "z_etav"], X: [], // Legacy kind names (older modules / tests) SST: ["z_ua"], SDT: ["z_ua"], SH: ["z_ua", "opening"], SC: ["z_ua"], DGT: ["f_w"], DSH: ["f_w"], MassFlow: ["z_flow", "z_etav"], }; /** Convert UI measure value to SI expected by the solver (temps → K). */ function measureSetpointSi(meta: ParamMeta, value: number): number { const unit = (meta.unit ?? "").toLowerCase(); if (unit === "°c" || unit === "c" || meta.key.endsWith("_c")) { return value + 273.15; } return value; } function controlNodeToConfig(node: Node): ControlConfig { const p = node.data.params; const cfg: ControlConfig = { type: "SaturatedController", id: node.data.name, measure: { component: stringParam(p.measure_component, "comp"), output: stringParam(p.measure_output, "temperature"), }, actuator: { component: stringParam(p.actuator_component, "comp"), factor: stringParam(p.actuator_factor, "injection"), initial: numberParam(p.initial, 0.15), min: numberParam(p.min, 0.0), max: numberParam(p.max, 0.3), }, target: numberParam(p.target, 330.0), gain: numberParam(p.gain, -0.5), band: numberParam(p.band, 5.0), }; if (typeof p.smooth_eps === "number" && Number.isFinite(p.smooth_eps)) { cfg.smooth_eps = p.smooth_eps; } const objectives = parseControlObjectives(p.objectives_json); if (objectives.length > 0) cfg.objectives = objectives; if (typeof p.alpha === "number" && Number.isFinite(p.alpha) && p.alpha > 0) { cfg.alpha = p.alpha; } return cfg; } export function parseControlObjectives(value: unknown): ControlObjectiveConfig[] { if (typeof value !== "string" || value.trim() === "") return []; try { const parsed: unknown = JSON.parse(value); if (!Array.isArray(parsed)) return []; return parsed.flatMap((objective): ControlObjectiveConfig[] => { if (!objective || typeof objective !== "object") return []; const candidate = objective as Record; if ( typeof candidate.component !== "string" || typeof candidate.output !== "string" || typeof candidate.setpoint !== "number" || !Number.isFinite(candidate.setpoint) || typeof candidate.gain !== "number" || !Number.isFinite(candidate.gain) || (candidate.combine !== "min" && candidate.combine !== "max") ) { return []; } return [{ component: candidate.component, output: candidate.output, setpoint: candidate.setpoint, gain: candidate.gain, combine: candidate.combine, }]; }); } catch { return []; } } function mergeControls(base: ControlConfig[], fromNodes: ControlConfig[]): ControlConfig[] { const merged = new Map(); for (const control of base) merged.set(control.id, control); for (const control of fromNodes) merged.set(control.id, control); return Array.from(merged.values()); } function stringParam(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value : fallback; } function numberParam(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } /** Validate the built config — returns a list of human-readable issues. */ export function validateConfig(nodes: Node[], edges: Edge[]): string[] { const issues: string[] = []; if (nodes.length === 0) { issues.push("Add at least one component."); } // Each circuit must have at least one component. const circuits = new Set(nodes.map((n) => n.data?.circuit ?? 0)); for (const c of circuits) { const cNodes = nodes.filter((n) => (n.data?.circuit ?? 0) === c); if (cNodes.length === 0) issues.push(`Circuit ${c} is empty.`); } // Duplicate names within a circuit. for (const c of circuits) { const names = nodes .filter((n) => (n.data?.circuit ?? 0) === c) .map((n) => n.data.name); const dupes = names.filter((n, i) => names.indexOf(n) !== i); if (dupes.length > 0) issues.push(`Duplicate component name(s) in circuit ${c}: ${[...new Set(dupes)].join(", ")}`); } // Required params present. for (const n of nodes) { const meta = COMPONENT_BY_TYPE[n.data.type]; if (!meta) { issues.push(`Unknown component type "${n.data.type}".`); continue; } for (const p of meta.params) { const params = canonicalizeParams(n.data.type, n.data.params); const supplied = params[p.key] !== undefined && params[p.key] !== ""; const fromSecondary = secondaryParamSuppliedByConnection(n, p.key, nodes, edges); // Free fixable params are not required (value is only an initial hint). const needFixed = BOUNDARY_FIX_TYPES.has(n.data.type) ? isBoundaryParamFixed(n.data.params, p) : !p.fixable || isParamFixed(n.data.params, p); if (p.required && needFixed && !supplied && !fromSecondary) { issues.push(`${n.data.name}: required parameter "${p.label}" is missing.`); } } // Dual-mode HX secondary: // system → both secondary_inlet + secondary_outlet wired (live edges) // rating → scalar T_sec + C_sec (or ṁ·cp) without live edges if (meta.ports.some(isSecondaryPort)) { const connected = new Set(); for (const e of edges) { if (e.source === n.id && e.sourceHandle) connected.add(e.sourceHandle); if (e.target === n.id && e.targetHandle) connected.add(e.targetHandle); } const hasIn = connected.has("secondary_inlet"); const hasOut = connected.has("secondary_outlet"); const liveOk = hasIn && hasOut; const params = canonicalizeParams(n.data.type, n.data.params); const ratingOk = hasRatingSecondaryScalars(params); if (!liveOk && !ratingOk) { issues.push( `${n.data.name}: secondary incomplete — wire secondary_inlet + secondary_outlet ` + `(system mode) OR set rating scalars (secondary_inlet_temp_c + mass flow/cp).`, ); } else if ((hasIn || hasOut) && !liveOk) { issues.push( `${n.data.name}: secondary ports partial (need both secondary_inlet and secondary_outlet).`, ); } } if ( n.data.type === "FloodedEvaporator" && (n.data.params?.quality_control === true || n.data.params?.quality_control === "true") ) { issues.push( `${n.data.name}: quality_control=true adds +1 FIX residual — free an actuator (EXV/level) ` + `or leave it off for compressor suction models.`, ); } } // Modelica balanced model: parameter(fixed=false) is an unknown → needs an // equation. Fixed Probe value supplies that equation. Free without equation // was silently ignored (still a constant) — reject it. const balancedUnknowns = new Set( buildModelEmbeddings(nodes, edges).map( (e) => `${e.unknown.component}::${e.unknown.factor}`, ), ); for (const n of nodes) { if (n.data.type === CONTROL_NODE_TYPE) continue; const meta = COMPONENT_BY_TYPE[n.data.type]; if (!meta) continue; const params = n.data.params ?? {}; for (const p of meta.params) { if (!p.actuatorFactor || !p.fixable) continue; if (isParamFixed(params, p)) continue; const key = `${n.data.name}::${p.actuatorFactor}`; if (!balancedUnknowns.has(key)) { issues.push( `${n.data.name}: ${p.label} is Free (unknown) without an equation — ` + `Modelica: parameter(fixed=false) needs a Fixed Probe value on the line, ` + `or leave ${p.label} Fixed.`, ); } } } return issues; } function secondaryParamSuppliedByConnection( node: Node, key: string, nodes: Node[], edges: Edge[], ): boolean { if (key !== "secondary_inlet_temp_c" && key !== "secondary_mass_flow_kg_s") return false; const sourceEdge = edges.find((edge) => edge.target === node.id && edge.targetHandle === "secondary_inlet"); if (!sourceEdge) return false; const source = nodes.find((candidate) => candidate.id === sourceEdge.source); if (!source || !isBoundaryNode(source)) return false; const params = canonicalizeParams(source.data.type, source.data.params); if (key === "secondary_inlet_temp_c") return params.t_set_c !== undefined || params.t_dry_c !== undefined; return params.m_flow_kg_s !== undefined; } /** * Rating-mode secondary stream is complete when T_sec,in and a positive capacity * rate are available: either C_sec directly, or ṁ·cp (with default cp assumed if * only mass flow is set — matches CLI `parse_secondary_stream` defaults). */ function hasRatingSecondaryScalars( params: Record, ): boolean { const t = numParam(params.secondary_inlet_temp_c) ?? numParam(params.secondary_inlet_temp_k); if (t === undefined) return false; const cDirect = numParam(params.secondary_capacity_rate_w_per_k); if (cDirect !== undefined && cDirect > 0) return true; const m = numParam(params.secondary_mass_flow_kg_s); if (m === undefined || m <= 0) return false; const cp = numParam(params.secondary_cp_j_per_kgk); // CLI supplies a fluid-dependent default cp when only mass flow is given. return cp === undefined || cp > 0; } function numParam(v: number | string | boolean | undefined): number | undefined { if (typeof v === "number" && Number.isFinite(v)) return v; if (typeof v === "string" && v.trim() !== "") { const n = Number(v); if (Number.isFinite(n)) return n; } return undefined; }