Add model embeddings for Z-factor DoF, separate from SaturatedController.
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>
This commit is contained in:
2026-07-19 22:42:31 +02:00
parent 3808e0f11b
commit 5425685a48
22 changed files with 1189 additions and 485 deletions

View File

@@ -56,6 +56,23 @@ export interface ControlConfig {
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;
@@ -100,8 +117,10 @@ export interface ScenarioConfig {
ua: number;
efficiency: number;
}>;
/** Steady-state control loops (co-solved). Mirrors crates/cli config `controls`. */
/** 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. */
@@ -119,6 +138,13 @@ export interface ScenarioConfig {
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;
@@ -333,14 +359,17 @@ export function buildScenarioConfig(
}
});
// 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)
.filter(
(node) =>
node.data.type === CONTROL_NODE_TYPE &&
!isAutoCalibrationControlId(node.data.name),
)
.map(controlNodeToConfig);
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes, edges);
const controls = mergeControls(
mergeControls(options.controls ?? [], nodeControls),
fixedFreeControls,
);
const controls = mergeControls(options.controls ?? [], nodeControls);
const embeddings = buildModelEmbeddings(nodes, edges);
return {
schema_version: SCHEMA_VERSION,
@@ -351,6 +380,7 @@ export function buildScenarioConfig(
...(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,
@@ -364,6 +394,9 @@ export function buildScenarioConfig(
* - `__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,
@@ -383,6 +416,13 @@ export function stripUiOnlyParams(
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);
}
@@ -466,34 +506,29 @@ export function applyBoundaryFixSemantics(
}
/**
* Build the `controls[]` array for calibration (Probe-based model, HARD RULE):
* Build Modelica `embeddings[]`: Free Z-factor (unknown) + Fixed Probe (equation).
*
* - Probe with Fixed `target` → impose that measure (setpoint = value)
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
*
* Every freed Z-factor is paired with a semantically-compatible Probe
* (preferring one adjacent on an edge). A freed factor with no matching
* Probe emits nothing — the user must place a Probe to calibrate.
*
* Pairing matrix (factor → compatible Probe measures):
* z_ua → SST, SDT, SH, DSH, SC, Capacity
* Pairing (free factor → Probe Fixed physical params):
* z_ua → Tsat, Tsh
* z_dp → P
* z_flow → MassFlow, Capacity
* z_flow → Capacity
* f_w → T
* z_power → Capacity
* z_etav → MassFlow
* z_etav → Capacity
* opening → Tsh
*
* Never emits SaturatedController / controls[].
*/
export function buildFixedFreeCalibrationControls(
export function buildModelEmbeddings(
nodes: Node<EntropykNodeData>[],
edges: Edge[] = [],
): ControlConfig[] {
const controls: ControlConfig[] = [];
// ── Pass 1: collect Probe measures and per-component freed z-factors ──
): EmbeddingConfig[] {
type ProbeMeasure = {
nodeName: string;
kind: string; // SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy
kind: string;
output: string;
target: number;
factorCompat: readonly string[]; // z-factors this Probe kind can pair with
factorCompat: readonly string[];
};
type FreeAct = {
factor: string;
@@ -503,6 +538,7 @@ export function buildFixedFreeCalibrationControls(
key: string;
};
const embeddings: EmbeddingConfig[] = [];
const probeMeasures: ProbeMeasure[] = [];
const freeActsByComponent = new Map<
string,
@@ -515,30 +551,43 @@ export function buildFixedFreeCalibrationControls(
if (!meta) continue;
const params = node.data.params ?? {};
// Probe node: read the `target` Fixed param + the configured `measure` kind.
if (node.data.type === "Probe") {
const measureParam = meta.params.find((p) => p.key === "measure");
const targetParam = meta.params.find((p) => p.key === "target");
if (!measureParam || !targetParam) continue;
const kindRaw = params.measure;
const kind =
typeof kindRaw === "string" ? kindRaw : String(measureParam.default ?? "SH");
const targetMeta = targetParam;
const targetFixed = isParamFixed(params, targetParam);
if (!targetFixed) continue;
const rawTarget = params.target;
const n = typeof rawTarget === "number" ? rawTarget : Number(rawTarget);
if (!Number.isFinite(n)) continue;
probeMeasures.push({
nodeName: node.data.name,
kind,
target: measureSetpointSi(targetMeta, n),
factorCompat: FACTOR_COMPATIBILITY[kind] ?? [],
});
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;
}
// Non-Probe node: collect freed z-factors (actuators to calibrate).
const freeActs: FreeAct[] = [];
for (const p of meta.params) {
if (!p.fixable) continue;
@@ -547,7 +596,13 @@ export function buildFixedFreeCalibrationControls(
if (p.actuatorFactor && !fixed) {
const n = typeof raw === "number" ? raw : Number(raw);
const initial = Number.isFinite(n) ? n : 1.0;
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,
@@ -563,16 +618,12 @@ export function buildFixedFreeCalibrationControls(
}
}
// ── Adjacency index: for each component node, which Probe nodes share an edge ──
const adjacentProbes = new Map<string, Set<string>>();
const probeNodeIds = new Set(
nodes.filter((n) => n.data.type === "Probe").map((n) => n.id),
);
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;
// Probe directly adjacent to this endpoint
for (const other of endpoints) {
if (other && other !== endpoint && probeNodeIds.has(other)) {
const probeName = nodes.find((n) => n.id === other)?.data.name;
@@ -590,19 +641,13 @@ export function buildFixedFreeCalibrationControls(
}
}
/**
* Find the best Probe to pair with a freed z-factor on `component`:
* 1. Must be semantically compatible (FACTOR_COMPATIBILITY).
* 2. Prefer Probes adjacent to the component (share an edge endpoint).
* 3. Fall back to any compatible Probe.
*/
const findProbeFor = (
component: string,
factor: string,
usedProbeNames: Set<string>,
usedKeys: Set<string>,
): ProbeMeasure | undefined => {
const compatible = probeMeasures.filter(
(m) => m.factorCompat.includes(factor) && !usedProbeNames.has(m.nodeName),
(m) => m.factorCompat.includes(factor) && !usedKeys.has(`${m.nodeName}::${m.kind}`),
);
if (compatible.length === 0) return undefined;
const adjacent = adjacentProbes.get(component);
@@ -610,50 +655,107 @@ export function buildFixedFreeCalibrationControls(
? compatible.find((m) => adjacent.has(m.nodeName))
: undefined;
const chosen = adjacentMatch ?? compatible[0];
usedProbeNames.add(chosen.nodeName);
usedKeys.add(`${chosen.nodeName}::${chosen.kind}`);
return chosen;
};
// ── Pass 2: emit controls ──
// Each freed z-factor is paired with a compatible Probe (HARD RULE). A
// freed actuator with no matching Probe emits nothing — the user must
// place a Probe to calibrate.
const usedProbeNames = new Set<string>();
const usedKeys = new Set<string>();
for (const { nodeName, acts } of freeActsByComponent.values()) {
for (const act of acts) {
const probe = findProbeFor(nodeName, act.factor, usedProbeNames);
const probe = findProbeFor(nodeName, act.factor, usedKeys);
if (probe) {
controls.push({
type: "SaturatedController",
id: `calib_${nodeName}_${act.factor}`,
measure: {
component: probe.nodeName,
output: probeOutputFor(probe.kind),
},
actuator: {
embeddings.push({
id: `emb_${nodeName}_${act.factor}`,
unknown: {
component: nodeName,
factor: act.factor,
initial: act.initial,
start: act.initial,
min: act.min,
max: act.max,
},
target: probe.target,
gain: -0.5,
band: 2.0,
equation: {
component: probe.nodeName,
output: probe.output,
value: probe.target,
},
});
}
}
}
return controls;
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",
};
/**
* Maps a Probe `measure` kind to the solver-side `ComponentOutput` string
* accepted by the CLI (`parse_component_output` in run.rs).
* Legacy Probe shape: `{ measure: "SST", target: 5.9, __fixed_target: true }`.
* Absolute temperatures that look like °C (< 200) are converted to K.
*/
function probeOutputFor(kind: string): string {
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":
@@ -672,28 +774,27 @@ function probeOutputFor(kind: string): string {
return "massFlowRate";
case "Capacity":
return "capacity";
case "Enthalpy":
// No dedicated Enthalpy ComponentOutput; carry it on heatTransferRate.
return "heatTransferRate";
default:
return "temperature";
}
}
/** Probe `measure` kind → list of z-factors it can calibrate (pairing matrix). */
/** 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"],
DSH: ["z_ua"],
SH: ["z_ua", "opening"],
SC: ["z_ua"],
Capacity: ["z_ua", "z_flow", "z_power"],
P: ["z_dp"],
DGT: ["f_w"],
DSH: ["f_w"],
MassFlow: ["z_flow", "z_etav"],
// Kinds not used for z-factor calibration (raw T, Enthalpy) — no compat.
DGT: [],
T: [],
Enthalpy: [],
};
/** Convert UI measure value to SI expected by the solver (temps → K). */
@@ -864,9 +965,32 @@ export function validateConfig(nodes: Node<EntropykNodeData>[], edges: Edge[]):
}
}
// 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.
// 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;
}