Some checks failed
CI / check (push) Has been cancelled
Fixed/Free Probe calibration now emits embeddings[] (unknown + equation) instead of controls[], keeping SaturatedController for physical regulation only. Co-authored-by: Cursor <cursoragent@cursor.com>
1044 lines
34 KiB
TypeScript
1044 lines
34 KiB
TypeScript
/**
|
|
* 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<string, number | string | boolean>;
|
|
[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<string, number | string | boolean>;
|
|
components: Array<Record<string, unknown>>;
|
|
edges?: Array<{ from: string; to: string }>;
|
|
ports?: Record<string, string>;
|
|
}
|
|
|
|
export interface InstanceConfig {
|
|
of: string;
|
|
name: string;
|
|
circuit?: number;
|
|
params?: Record<string, number | string | boolean>;
|
|
}
|
|
|
|
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<Record<string, unknown>>;
|
|
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<string, SubsystemTemplate>;
|
|
/** 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<string, string[]> = {
|
|
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<string, number | string | boolean>,
|
|
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<string, number | string | boolean>,
|
|
): Record<string, number | string | boolean> {
|
|
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<EntropykNodeData> | 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<EntropykNodeData> | 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<string, Record<string, number>>;
|
|
/** Legacy compatibility: boundary nodes are preserved as explicit solver components. */
|
|
absorbed: Set<string>;
|
|
}
|
|
|
|
export function resolveSecondaryStreams(
|
|
nodes: Node<EntropykNodeData>[],
|
|
edges: Edge[],
|
|
): SecondaryResolution {
|
|
void nodes;
|
|
void edges;
|
|
return { overrides: new Map(), absorbed: new Set() };
|
|
}
|
|
|
|
export function buildScenarioConfig(
|
|
nodes: Node<EntropykNodeData>[],
|
|
edges: Edge[],
|
|
options: BuildOptions = {},
|
|
): ScenarioConfig {
|
|
const nodeById = new Map<string, Node<EntropykNodeData>>();
|
|
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<string, number | string | boolean> = {};
|
|
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<number, Node<EntropykNodeData>[]>();
|
|
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<string, unknown>;
|
|
});
|
|
|
|
// 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<string, number | string | boolean>,
|
|
): Record<string, number | string | boolean> {
|
|
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<string, number | string | boolean> = {};
|
|
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<string, number | string | boolean>,
|
|
rawParams: Record<string, number | string | boolean>,
|
|
): Record<string, number | string | boolean> {
|
|
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<string, number | string | boolean> = { ...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<string, number | string | boolean> = { ...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<string, number | string | boolean>,
|
|
rawParams: Record<string, number | string | boolean>,
|
|
): Record<string, number | string | boolean> {
|
|
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<EntropykNodeData>[],
|
|
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<EntropykNodeData>; 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<string, Set<string>>();
|
|
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<string>,
|
|
): 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<string>();
|
|
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<EntropykNodeData>[],
|
|
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<string, string> = {
|
|
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<string, unknown>): {
|
|
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<string, readonly string[]> = {
|
|
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<EntropykNodeData>): 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<string, unknown>;
|
|
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<string, ControlConfig>();
|
|
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<EntropykNodeData>[], 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<string>();
|
|
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<EntropykNodeData>,
|
|
key: string,
|
|
nodes: Node<EntropykNodeData>[],
|
|
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<string, number | string | boolean | undefined>,
|
|
): 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;
|
|
}
|