Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
Some checks failed
CI / check (push) Has been cancelled

Checkpoint incomplete calibration work (cond SDT green, evap SST failing) plus related solver/UI changes so the next pass can fix and extend safely.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 21:44:01 +02:00
parent 2f1f7ecb80
commit 3808e0f11b
39 changed files with 3648 additions and 265 deletions

View File

@@ -336,7 +336,7 @@ export function buildScenarioConfig(
const nodeControls = nodes
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
.map(controlNodeToConfig);
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes);
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes, edges);
const controls = mergeControls(
mergeControls(options.controls ?? [], nodeControls),
fixedFreeControls,
@@ -362,7 +362,7 @@ export function buildScenarioConfig(
/**
* Remove UI-only keys before sending to the CLI:
* - `__fixed_*` Fixed checkbox flags
* - measure-only calib targets (calib_sst_c, …) — they become control setpoints
* - measure-only targets (e.g. Probe `target`) — they become control setpoints
* - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
*/
export function stripUiOnlyParams(
@@ -466,44 +466,85 @@ export function applyBoundaryFixSemantics(
}
/**
* Build inverse-calibration controls from Fixed checkboxes (EES/Dymola style).
* Build the `controls[]` array for calibration (Probe-based model, HARD RULE):
*
* - 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.
* - 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
* z_dp → P
* z_flow → MassFlow, Capacity
* z_power → Capacity
* z_etav → MassFlow
*/
export function buildFixedFreeCalibrationControls(
nodes: Node<EntropykNodeData>[],
edges: Edge[] = [],
): ControlConfig[] {
const controls: ControlConfig[] = [];
// ── Pass 1: collect Probe measures and per-component freed z-factors ──
type ProbeMeasure = {
nodeName: string;
kind: string; // SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy
target: number;
factorCompat: readonly string[]; // z-factors this Probe kind can pair with
};
type FreeAct = {
factor: string;
initial: number;
min: number;
max: number;
key: string;
};
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 ?? {};
type Measure = { output: string; target: number; key: string };
type FreeAct = { factor: string; initial: number; min: number; max: number; key: string };
// 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] ?? [],
});
continue;
}
const measures: Measure[] = [];
// Non-Probe node: collect freed z-factors (actuators to calibrate).
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;
@@ -517,37 +558,144 @@ export function buildFixedFreeCalibrationControls(
}
}
if (freeActs.length === 0 || measures.length === 0) continue;
if (freeActs.length > 0) {
freeActsByComponent.set(node.data.name, { nodeName: node.data.name, node, acts: freeActs });
}
}
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,
});
// ── 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),
);
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;
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);
}
}
}
}
}
}
/**
* 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>,
): ProbeMeasure | undefined => {
const compatible = probeMeasures.filter(
(m) => m.factorCompat.includes(factor) && !usedProbeNames.has(m.nodeName),
);
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];
usedProbeNames.add(chosen.nodeName);
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>();
for (const { nodeName, acts } of freeActsByComponent.values()) {
for (const act of acts) {
const probe = findProbeFor(nodeName, act.factor, usedProbeNames);
if (probe) {
controls.push({
type: "SaturatedController",
id: `calib_${nodeName}_${act.factor}`,
measure: {
component: probe.nodeName,
output: probeOutputFor(probe.kind),
},
actuator: {
component: nodeName,
factor: act.factor,
initial: act.initial,
min: act.min,
max: act.max,
},
target: probe.target,
gain: -0.5,
band: 2.0,
});
}
}
}
return controls;
}
/**
* Maps a Probe `measure` kind to the solver-side `ComponentOutput` string
* accepted by the CLI (`parse_component_output` in run.rs).
*/
function probeOutputFor(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";
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). */
const FACTOR_COMPATIBILITY: Record<string, readonly string[]> = {
SST: ["z_ua"],
SDT: ["z_ua"],
SH: ["z_ua"],
DSH: ["z_ua"],
SC: ["z_ua"],
Capacity: ["z_ua", "z_flow", "z_power"],
P: ["z_dp"],
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). */
function measureSetpointSi(meta: ParamMeta, value: number): number {
const unit = (meta.unit ?? "").toLowerCase();