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

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

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

View File

@@ -0,0 +1,687 @@
/**
* 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;
}
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;
}>;
/** Steady-state control loops (co-solved). Mirrors crates/cli config `controls`. */
controls?: ControlConfig[];
/** 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";
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"],
};
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 {
// Group nodes by circuit id.
const circuitsMap = new Map<number, Node<EntropykNodeData>[]>();
for (const n of nodes) {
const c = n.data?.circuit ?? 0;
if (!circuitsMap.has(c)) circuitsMap.set(c, []);
circuitsMap.get(c)!.push(n);
}
const nodeById = new Map<string, Node<EntropykNodeData>>();
for (const n of nodes) nodeById.set(n.id, n);
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);
// The CLI flattens unknown keys as params, so we spread them at top level.
return { type, name, ...cleaned } as Record<string, unknown>;
});
// Solver edges: both endpoints in this circuit. Refrigerant and
// caloporteur branches are preserved explicitly; heat exchangers are
// real 4-port components, not reduced to hidden secondary parameters.
const circuitEdges = edges.filter((e) => {
const s = nodeById.get(e.source);
const t = nodeById.get(e.target);
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 };
});
const nodeControls = nodes
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
.map(controlNodeToConfig);
// Dymola/EES Fixed checkboxes → inverse calib pairs (FIX measure + FREE z_*)
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes);
const controls = mergeControls(
mergeControls(options.controls ?? [], nodeControls),
fixedFreeControls,
);
return {
schema_version: SCHEMA_VERSION,
fluid: options.fluid || "R410A",
fluid_backend: options.fluidBackend || "CoolProp",
circuits,
thermal_couplings: options.thermalCouplings || [],
...(controls.length > 0 ? { controls } : {}),
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 calib targets (calib_sst_c, …) — they become control setpoints
* - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
*/
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;
out[k] = v;
}
return applyBoundaryFixSemantics(type, out, params);
}
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 inverse-calibration controls from Fixed checkboxes (EES/Dymola style).
*
* - Param with `measureOutput` + Fixed ON → impose that measure (setpoint = value)
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
* Pairs on the same component: each free factor with the first fixed measure.
*/
export function buildFixedFreeCalibrationControls(
nodes: Node<EntropykNodeData>[],
): ControlConfig[] {
const controls: ControlConfig[] = [];
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 ?? {};
type Measure = { output: string; target: number; key: string };
type FreeAct = { factor: string; initial: number; min: number; max: number; key: string };
const measures: Measure[] = [];
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.measureOutput && fixed) {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n)) continue;
measures.push({
output: p.measureOutput,
target: measureSetpointSi(p, n),
key: p.key,
});
}
if (p.actuatorFactor && !fixed) {
const n = typeof raw === "number" ? raw : Number(raw);
const initial = Number.isFinite(n) ? n : 1.0;
freeActs.push({
factor: p.actuatorFactor,
initial,
min: p.freeMin ?? 0.1,
max: p.freeMax ?? 3.0,
key: p.key,
});
}
}
if (freeActs.length === 0 || measures.length === 0) continue;
for (let i = 0; i < freeActs.length; i++) {
const act = freeActs[i];
const meas = measures[Math.min(i, measures.length - 1)];
controls.push({
type: "SaturatedController",
id: `calib_${node.data.name}_${act.factor}`,
measure: {
component: node.data.name,
output: meas.output,
},
actuator: {
component: node.data.name,
factor: act.factor,
initial: act.initial,
min: act.min,
max: act.max,
},
target: meas.target,
// Negative gain: higher Z_UA → higher capacity / often higher T_sat for flooded
// — user can refine; default chosen for UA-style calibration.
gain: -0.5,
band: 2.0,
});
}
}
return controls;
}
/** 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 boundary conflicts are reported in the DoF ledger; emit-time
// `enforceModelicaBoundaryEmit` auto-corrects legalizable cases (Free P on
// MassFlowSource, Free ṁ when Fixed T_out). Hard-block only if emit cannot help.
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;
}