Add model embeddings for Z-factor DoF, separate from SaturatedController.
Some checks failed
CI / check (push) Has been cancelled
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:
@@ -177,21 +177,18 @@ export default function PropertiesPanel() {
|
||||
Modelica / Dymola.
|
||||
</p>
|
||||
<div className="rounded border border-[var(--line)] bg-[var(--chrome-2)] p-2 text-[10px]">
|
||||
<p className="mb-1 font-semibold text-[var(--ink)]">Rappel rapide</p>
|
||||
<p className="mb-1 font-semibold text-[var(--ink)]">DoF Modelica</p>
|
||||
<ul className="list-inside list-disc space-y-0.5 text-[var(--ink-dim)]">
|
||||
<li>
|
||||
<strong>Fixed ☑</strong> = valeur imposée
|
||||
<strong>Fixed ☑</strong> = <code>parameter</code> (connu)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Fixed ☐</strong> = le solveur calcule (ex. Z_UA libre)
|
||||
<strong>Fixed ☐</strong> = inconnue → il faut une équation de plus
|
||||
</li>
|
||||
<li>
|
||||
Calibration simple : Fixed sur SST + Z_UA non Fixed (défaut Z_UA = 1)
|
||||
</li>
|
||||
<li>
|
||||
Le bloc « Regulation loop » (palette Advanced) est optionnel — pas besoin pour
|
||||
calibrer Z_UA
|
||||
Calib : Probe Tsat Fixed (= équation) + Z_UA Free (= inconnue)
|
||||
</li>
|
||||
<li>n_eq = n_unk. Pas de boucle de régulation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -551,15 +548,15 @@ export default function PropertiesPanel() {
|
||||
{activeTab === "Calibration" && (
|
||||
<div className="space-y-1.5 border-t border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1.5 text-[10px] leading-snug text-[var(--ink-dim)]">
|
||||
<p>
|
||||
<strong>Calibration :</strong> pose une <strong>Probe</strong> (sonde) sur la
|
||||
ligne à mesurer, coche Fixed sur sa cible, puis décoche Fixed sur le facteur
|
||||
(Z_UA) ici. La valeur résolue s’affiche ci-dessous après simulation.
|
||||
<strong>Modelica :</strong> Probe Fixed → équation (
|
||||
<code>Tsat = …</code>). Facteur Free → inconnue (
|
||||
<code>parameter z_ua(fixed=false)</code>). Le start est la valeur affichée.
|
||||
</p>
|
||||
<p className="text-[var(--ink-faint)]">
|
||||
Tu n’as pas besoin du nœud « Regulation loop » pour ça.
|
||||
Laisse UA override vide. n_eq doit égaler n_unk.
|
||||
</p>
|
||||
{solvedVars.length > 0 && (
|
||||
<SolvedVariablesBlock items={solvedVars} title="Facteurs résolus" />
|
||||
<SolvedVariablesBlock items={solvedVars} title="Inconnues résolues" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -28,24 +28,24 @@ export interface ParamMeta {
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
advanced?: boolean;
|
||||
/**
|
||||
* Dymola/EES-style Fixed checkbox.
|
||||
* Fixed ON = value imposed (parameter or measured target).
|
||||
* Fixed OFF = free for the solver (calibration tuner / free unknown).
|
||||
* Modelica `fixed` attribute (Dymola-style checkbox).
|
||||
* Fixed ON = `parameter` / binding equation (known).
|
||||
* Fixed OFF = unknown — needs another equation to keep the model balanced.
|
||||
*/
|
||||
fixable?: boolean;
|
||||
/** Default for the Fixed checkbox when `fixable` (true = Fixed). */
|
||||
defaultFixed?: boolean;
|
||||
/**
|
||||
* When Fixed ON: use this param value as a control setpoint for the named
|
||||
* measure output (e.g. `saturationTemperature`, `superheat`).
|
||||
* When Fixed ON on a Probe: adds equation `output = value`
|
||||
* (e.g. Tsat = 5 °C). Internal wire name for the solver output.
|
||||
*/
|
||||
measureOutput?: string;
|
||||
/**
|
||||
* When Fixed OFF: free this actuator/calibration factor (e.g. `z_ua`).
|
||||
* Requires a Fixed measure on the same component to stay DoF-balanced.
|
||||
* When Fixed OFF: this factor becomes a Newton unknown (Modelica
|
||||
* `parameter ...(fixed=false)`). Pair with a Fixed Probe equation.
|
||||
*/
|
||||
actuatorFactor?: string;
|
||||
/** Bounds used when the factor is free (actuator min/max). */
|
||||
/** Bounds / start guess when the factor is an unknown. */
|
||||
freeMin?: number;
|
||||
freeMax?: number;
|
||||
/**
|
||||
@@ -220,41 +220,20 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
label: "Probe (sonde)",
|
||||
category: "Instrumentation",
|
||||
description:
|
||||
"Sonde de mesure posée sur une ligne. Lit P/T/SH/SC/SST/SDT/DGT/DSH/capacité/ṁ/h à cet endroit. Toute calibration impose sa cible sur un Probe.",
|
||||
"Sonde sur une ligne : fige T, Tsat, P, X ou Tsh (= T−Tsat, SC négatif) pour calibrer.",
|
||||
help:
|
||||
"Sonde de calibration (Probe)\n" +
|
||||
"Toute calibration se fait via un Probe posé sur une ligne (règle dure).\n\n" +
|
||||
"1. Glisse le Probe sur un fil du schéma (comme pour insérer un Pipe).\n" +
|
||||
"2. Choisis la grandeur mesurée (SST, SDT, DGT, DSH, SH, SC, T, P, débit, capacité, enthalpie) et le fluide.\n" +
|
||||
"3. Coche Fixed sur « Cible » et tape la valeur mesurée.\n" +
|
||||
"4. Libère le Z-factor correspondant (Z_UA, Z_dP, Z_flow…) sur le composant à calibrer.\n" +
|
||||
"Le solveur appaire automatiquement le Probe (mesure) et le Z-factor libre (inconnue).",
|
||||
"1. Glisse le Probe sur un fil.\n" +
|
||||
"2. Coche Fixed sur les grandeurs physiques mesurées (T, Tsat, P, X, Tsh).\n" +
|
||||
"3. Libère le facteur correspondant sur le composant :\n" +
|
||||
" • Tsat / Tsh → z_ua (HX) ou opening (EXV)\n" +
|
||||
" • T (DGT refoulement) → f_w (rétention énergie carter)\n" +
|
||||
" • Capacity → z_flow (capacité machine)\n" +
|
||||
" • P → z_dp\n" +
|
||||
"Tsh = T − Tsat (surchauffe > 0, sous-refroidissement < 0).",
|
||||
ports: ["inlet", "outlet"],
|
||||
color: "#0ea5e9",
|
||||
params: [
|
||||
{
|
||||
key: "measure",
|
||||
label: "Grandeur mesurée",
|
||||
kind: "string",
|
||||
default: "SH",
|
||||
required: true,
|
||||
section: "Mesure",
|
||||
description:
|
||||
"Grandeur physique mesurée à l'emplacement du Probe. SST/SDT = Tsat(P), SH/DSH = T−Tsat(P), SC = Tsat(P)−T.",
|
||||
options: [
|
||||
{ value: "SST", label: "SST (Tsat aspiration)" },
|
||||
{ value: "SDT", label: "SDT (Tsat refoulement)" },
|
||||
{ value: "DGT", label: "DGT (T° gaz refoulement)" },
|
||||
{ value: "DSH", label: "DSH (surch. refoulement)" },
|
||||
{ value: "SH", label: "SH (surchauffe)" },
|
||||
{ value: "SC", label: "SC (sous-refroid.)" },
|
||||
{ value: "T", label: "T (température)" },
|
||||
{ value: "P", label: "P (pression)" },
|
||||
{ value: "MassFlow", label: "Débit massique" },
|
||||
{ value: "Capacity", label: "Capacité (kW)" },
|
||||
{ value: "Enthalpy", label: "Enthalpie" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "fluid",
|
||||
label: "Fluide",
|
||||
@@ -262,22 +241,81 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
default: "R134a",
|
||||
required: true,
|
||||
section: "Mesure",
|
||||
description: "Fluide frigorigène pour le calcul de Tsat/SH/SC (via CoolProp).",
|
||||
description: "Fluide pour Tsat / Tsh / X (CoolProp).",
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "Cible (Fixed = impose)",
|
||||
key: "t_c",
|
||||
label: "T (température)",
|
||||
kind: "number",
|
||||
section: "Calibration",
|
||||
unit: "°C",
|
||||
default: 50,
|
||||
section: "Paramètres physiques",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
// Auto: resolved from `measure` at config-build time
|
||||
// (SST/SDT → saturationTemperature, SH/DSH → superheat, SC → subcooling,
|
||||
// DGT/T → temperature, P → pressure, MassFlow → massFlowRate,
|
||||
// Capacity → capacity, Enthalpy → heatTransferRate as carrier).
|
||||
measureOutput: "auto",
|
||||
measureOutput: "temperature",
|
||||
description: "Température de ligne. Sur refoulement = DGT → pairer avec f_w.",
|
||||
},
|
||||
{
|
||||
key: "tsat_c",
|
||||
label: "Tsat",
|
||||
kind: "number",
|
||||
unit: "°C",
|
||||
default: 5,
|
||||
section: "Paramètres physiques",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "saturationTemperature",
|
||||
description: "Tsat(P) à la sonde. Aspiration = SST, liquide = SDT → z_ua.",
|
||||
},
|
||||
{
|
||||
key: "p_bar",
|
||||
label: "P",
|
||||
kind: "number",
|
||||
unit: "bar",
|
||||
default: 5,
|
||||
section: "Paramètres physiques",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "pressure",
|
||||
description: "Pression absolue → pairer avec z_dp.",
|
||||
},
|
||||
{
|
||||
key: "x",
|
||||
label: "X (titre)",
|
||||
kind: "number",
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
section: "Paramètres physiques",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "quality",
|
||||
description: "Titre vapeur [0–1]. Usage avancé.",
|
||||
},
|
||||
{
|
||||
key: "tsh_k",
|
||||
label: "Tsh (= T − Tsat)",
|
||||
kind: "number",
|
||||
unit: "K",
|
||||
default: 5,
|
||||
section: "Paramètres physiques",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "superheat",
|
||||
description:
|
||||
"Coche Fixed et tape la valeur mesurée (K pour températures, K pour SH/SC, Pa pour P, kg/s pour débit, W pour capacité, J/kg pour h).",
|
||||
"Écart à la saturation : >0 surchauffe, <0 sous-refroidissement. Remplace SH/SC sur l’échangeur.",
|
||||
},
|
||||
{
|
||||
key: "capacity_w",
|
||||
label: "Capacité",
|
||||
kind: "number",
|
||||
unit: "W",
|
||||
default: 10000,
|
||||
section: "Paramètres physiques",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "capacity",
|
||||
description: "Capacité (bilan secondaire). Pairer avec z_flow du compresseur.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -475,6 +513,36 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
default: 5.0,
|
||||
section: "Init / design",
|
||||
},
|
||||
{
|
||||
key: "z_flow",
|
||||
label: "z_flow (capacité)",
|
||||
kind: "number",
|
||||
default: 1.0,
|
||||
section: "Calibration",
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_flow",
|
||||
freeMin: 0.5,
|
||||
freeMax: 2.0,
|
||||
description:
|
||||
"Multiplicateur de débit / capacité machine. Fixed OFF + Probe Capacity Fixed.",
|
||||
},
|
||||
{
|
||||
key: "f_w",
|
||||
label: "f_w (rétention énergie)",
|
||||
kind: "number",
|
||||
default: 1.0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
section: "Calibration",
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "f_w",
|
||||
freeMin: 0.0,
|
||||
freeMax: 1.0,
|
||||
description:
|
||||
"Fraction du travail conservée dans le fluide : 1 = adiabatique, 0.98 ≈ 2 % perdu, 0 = tout perdu. Fixed OFF + Probe T (DGT) sur refoulement.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -819,7 +887,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_ua",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
description: "Fixed = valeur figée. Décocher Fixed = libre (calibration).",
|
||||
},
|
||||
@@ -834,7 +902,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_dp",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
},
|
||||
],
|
||||
@@ -870,7 +938,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_ua",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
description: "Fixed = figé. Décocher Fixed = libre pour le solveur.",
|
||||
},
|
||||
@@ -885,7 +953,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_dp",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
},
|
||||
{
|
||||
@@ -1145,7 +1213,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_ua",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
},
|
||||
{
|
||||
@@ -1158,7 +1226,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_dp",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
},
|
||||
],
|
||||
@@ -1234,7 +1302,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_ua",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
},
|
||||
{
|
||||
@@ -1247,7 +1315,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_dp",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
},
|
||||
],
|
||||
@@ -1303,7 +1371,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_ua",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
description: "Fixed = figé. Décocher Fixed = le solveur ajuste Z_UA.",
|
||||
},
|
||||
@@ -1318,7 +1386,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
actuatorFactor: "z_dp",
|
||||
freeMin: 0.2,
|
||||
freeMin: 0.05,
|
||||
freeMax: 3.0,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
applyBoundaryFixSemantics,
|
||||
buildScenarioConfig,
|
||||
buildModelEmbeddings,
|
||||
buildFixedFreeCalibrationControls,
|
||||
resolveSecondaryStreams,
|
||||
stripUiOnlyParams,
|
||||
@@ -483,43 +484,45 @@ describe("caloporteur (secondary stream) resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
||||
it("pairs a Fixed Probe measure with a Free Z_UA on the HX", () => {
|
||||
describe("Modelica embeddings (Fixed / Free)", () => {
|
||||
it("pairs Fixed Probe Tsat with Free Z_UA into embeddings[] (not controls[])", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, {
|
||||
ua: 9000,
|
||||
z_ua: 1.0,
|
||||
// Fixed OFF for Z_UA → free actuator
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
node("p1", "Probe", "sst_probe", 0, {
|
||||
measure: "SST",
|
||||
fluid: "R134a",
|
||||
target: 278.15,
|
||||
// Fixed ON for the Probe target → impose the measure
|
||||
[fixedFlagKey("target")]: true,
|
||||
tsat_c: 5.0,
|
||||
[fixedFlagKey("tsat_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const controls = buildFixedFreeCalibrationControls(nodes, edges);
|
||||
expect(controls).toHaveLength(1);
|
||||
expect(controls[0]).toMatchObject({
|
||||
measure: { component: "sst_probe", output: "saturationTemperature" },
|
||||
actuator: { component: "evap", factor: "z_ua" },
|
||||
target: 278.15,
|
||||
const embeddings = buildModelEmbeddings(nodes, edges);
|
||||
expect(embeddings).toHaveLength(1);
|
||||
expect(embeddings[0]).toMatchObject({
|
||||
id: "emb_evap_z_ua",
|
||||
unknown: { component: "evap", factor: "z_ua", start: 0.3, min: 0.05 },
|
||||
equation: {
|
||||
component: "sst_probe",
|
||||
output: "saturationTemperature",
|
||||
value: 278.15,
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.controls?.length).toBe(1);
|
||||
// UI-only keys stripped from component JSON; Z_UA still emitted as initial value.
|
||||
expect(cfg.embeddings).toHaveLength(1);
|
||||
expect(cfg.controls).toBeUndefined();
|
||||
const comp = cfg.circuits[0].components[0];
|
||||
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
||||
expect(comp.z_ua).toBe(1.0);
|
||||
expect(comp.ua).toBeUndefined(); // Free z_ua strips absolute ua
|
||||
});
|
||||
|
||||
it("emits no control when Z_UA stays Fixed even with a Probe present", () => {
|
||||
it("emits no embedding when Z_UA stays Fixed even with a Probe present", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, {
|
||||
ua: 6000,
|
||||
@@ -527,18 +530,15 @@ describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
||||
[fixedFlagKey("z_ua")]: true,
|
||||
}),
|
||||
node("p1", "Probe", "sst_probe", 0, {
|
||||
measure: "SST",
|
||||
fluid: "R134a",
|
||||
target: 278.15,
|
||||
[fixedFlagKey("target")]: true,
|
||||
tsat_c: 5.0,
|
||||
[fixedFlagKey("tsat_c")]: true,
|
||||
}),
|
||||
];
|
||||
expect(buildModelEmbeddings(nodes)).toHaveLength(0);
|
||||
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits nothing for a freed Z_UA with no matching Probe (HARD RULE)", () => {
|
||||
// A freed z-factor without a Probe to measure against must not emit a
|
||||
// control — the user must place a Probe. No legacy same-component fallback.
|
||||
it("emits nothing for a freed Z_UA with no matching Probe", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, {
|
||||
ua: 6000,
|
||||
@@ -546,6 +546,39 @@ describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
];
|
||||
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||
expect(buildModelEmbeddings(nodes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects Free Z_UA without a Fixed Probe equation (Modelica balance)", () => {
|
||||
const nodes = [
|
||||
node("e", "BphxEvaporator", "evap", 0, {
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((m) => m.includes("fixed=false") || m.includes("without an equation"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts legacy Probe measure+target (°C → K)", () => {
|
||||
const nodes = [
|
||||
node("e", "BphxEvaporator", "evap", 0, {
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
node("p1", "Probe", "sst_probe", 0, {
|
||||
measure: "SST",
|
||||
target: 4.4,
|
||||
__fixed_target: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const embeddings = buildModelEmbeddings(nodes, edges);
|
||||
expect(embeddings).toHaveLength(1);
|
||||
expect(embeddings[0].equation.value).toBeCloseTo(277.55, 10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -29,13 +29,19 @@ export function useScenarioSimulation() {
|
||||
const setLastConfig = useDiagramStore((s) => s.setLastConfig);
|
||||
const setResult = useDiagramStore((s) => s.setResult);
|
||||
const setSimulating = useDiagramStore((s) => s.setSimulating);
|
||||
const purgeAutoCalibrationControlNodes = useDiagramStore(
|
||||
(s) => s.purgeAutoCalibrationControlNodes,
|
||||
);
|
||||
const simulating = useDiagramStore((s) => s.simulating);
|
||||
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const problems = validateConfig(nodes, edges);
|
||||
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
|
||||
// Fixed/Free calib must never leave SaturatedController blocks on the canvas.
|
||||
purgeAutoCalibrationControlNodes();
|
||||
const { nodes: liveNodes, edges: liveEdges } = useDiagramStore.getState();
|
||||
const problems = validateConfig(liveNodes, liveEdges);
|
||||
const ledger = computeDofLedger(liveNodes as Node<EntropykNodeData>[], liveEdges);
|
||||
if (ledger.balance === "over-constrained") {
|
||||
problems.push(
|
||||
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
|
||||
@@ -45,7 +51,7 @@ export function useScenarioSimulation() {
|
||||
setIssues(problems);
|
||||
if (problems.length > 0) return false;
|
||||
|
||||
const scenarioConfig = buildScenarioConfig(nodes, edges, {
|
||||
const scenarioConfig = buildScenarioConfig(liveNodes, liveEdges, {
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
@@ -85,6 +91,7 @@ export function useScenarioSimulation() {
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
purgeAutoCalibrationControlNodes,
|
||||
setLastConfig,
|
||||
setResult,
|
||||
setSimulating,
|
||||
|
||||
@@ -25,6 +25,88 @@ function reset() {
|
||||
beforeEach(reset);
|
||||
|
||||
describe("controls import", () => {
|
||||
it("does not materialise Fixed/Free calib_* as canvas SaturatedController nodes", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "BphxEvaporator", name: "evap", z_ua: 1 },
|
||||
{ type: "Probe", name: "SST probe", tsat_c: 4.4 },
|
||||
],
|
||||
edges: [{ from: "evap:outlet", to: "SST probe:inlet" }],
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "calib_evap_z_ua",
|
||||
measure: { component: "SST probe", output: "saturationTemperature" },
|
||||
actuator: { component: "evap", factor: "z_ua", initial: 1, min: 0.1, max: 3 },
|
||||
target: 277.55,
|
||||
},
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "dgt_limiter",
|
||||
measure: { component: "evap", output: "temperature" },
|
||||
actuator: { component: "evap", factor: "injection", initial: 0.1, min: 0, max: 0.3 },
|
||||
target: 300,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes.map((n) => n.data.name)).not.toContain("calib_evap_z_ua");
|
||||
expect(st.nodes.map((n) => n.data.name)).toContain("dgt_limiter");
|
||||
expect(st.controls.map((c) => c.id)).toEqual(["dgt_limiter"]);
|
||||
});
|
||||
|
||||
it("purgeAutoCalibrationControlNodes removes leftover calib_* canvas blocks", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [{ type: "BphxEvaporator", name: "evap" }],
|
||||
edges: [],
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
id: "calib_evap_z_ua",
|
||||
measure: { component: "evap", output: "saturationTemperature" },
|
||||
actuator: { component: "evap", factor: "z_ua", min: 0.1, max: 3 },
|
||||
target: 277.55,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Simulate a stale canvas block from an older import path.
|
||||
useDiagramStore.setState((s) => ({
|
||||
nodes: [
|
||||
...s.nodes,
|
||||
{
|
||||
id: "stale-calib",
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: "SaturatedController",
|
||||
name: "calib_evap_z_ua",
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
useDiagramStore.getState().purgeAutoCalibrationControlNodes();
|
||||
expect(useDiagramStore.getState().nodes.map((n) => n.data.name)).not.toContain(
|
||||
"calib_evap_z_ua",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves imported co-solved controls for the next simulation run", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
|
||||
@@ -5,7 +5,11 @@ import type { Edge, Node, OnNodesChange, OnEdgesChange, OnConnect } from "@xyflo
|
||||
import { applyNodeChanges, applyEdgeChanges, addEdge } from "@xyflow/react";
|
||||
import { hydrateBoundaryFixFlags } from "@/lib/boundaryFix";
|
||||
import { defaultParams } from "@/lib/componentMeta";
|
||||
import { CONTROL_NODE_TYPE, canonicalizeParams } from "@/lib/configBuilder";
|
||||
import {
|
||||
CONTROL_NODE_TYPE,
|
||||
canonicalizeParams,
|
||||
isAutoCalibrationControlId,
|
||||
} from "@/lib/configBuilder";
|
||||
import type { ControlConfig } from "@/lib/configBuilder";
|
||||
import type { SimulationResult } from "@/lib/api";
|
||||
import {
|
||||
@@ -147,6 +151,8 @@ interface DiagramState {
|
||||
setResult: (result: SimulationResult | null, error?: string | null) => void;
|
||||
setSimulating: (v: boolean) => void;
|
||||
loadFromConfig: (config: unknown) => void;
|
||||
/** Remove leftover canvas nodes from Fixed/Free auto-calib (not real controllers). */
|
||||
purgeAutoCalibrationControlNodes: () => void;
|
||||
clear: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
@@ -736,7 +742,12 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
x += 280;
|
||||
}
|
||||
|
||||
for (const [index, control] of (cfg.controls ?? []).entries()) {
|
||||
// Explicit Advanced controllers only. Fixed/Free calib (`calib_*`) is a
|
||||
// solver DoF swap regenerated from Probe/component Fixed flags — never a
|
||||
// canvas SaturatedController block.
|
||||
let explicitControlIndex = 0;
|
||||
for (const control of cfg.controls ?? []) {
|
||||
if (isAutoCalibrationControlId(control.id)) continue;
|
||||
const measuredNode = nodes.find((node) => node.data.name === control.measure.component);
|
||||
const id = crypto.randomUUID();
|
||||
nodes.push({
|
||||
@@ -744,7 +755,7 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
type: "entropykNode",
|
||||
position: measuredNode
|
||||
? { x: measuredNode.position.x + 140, y: Math.max(40, measuredNode.position.y - 72) }
|
||||
: { x: 100 + index * 150, y: 40 },
|
||||
: { x: 100 + explicitControlIndex * 150, y: 40 },
|
||||
data: {
|
||||
type: CONTROL_NODE_TYPE,
|
||||
name: control.id,
|
||||
@@ -755,6 +766,7 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
params: controlParams(control),
|
||||
},
|
||||
});
|
||||
explicitControlIndex += 1;
|
||||
}
|
||||
|
||||
const edges: Edge[] = [];
|
||||
@@ -785,7 +797,7 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
solverStrategy: cfg.solver?.strategy || "newton",
|
||||
maxIterations: cfg.solver?.max_iterations ?? 300,
|
||||
tolerance: cfg.solver?.tolerance ?? 1e-6,
|
||||
controls: cfg.controls ?? [],
|
||||
controls: (cfg.controls ?? []).filter((c) => !isAutoCalibrationControlId(c.id)),
|
||||
result: null,
|
||||
lastConfig: cfg,
|
||||
simError: null,
|
||||
@@ -794,6 +806,28 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
get().recordHistory();
|
||||
},
|
||||
|
||||
purgeAutoCalibrationControlNodes: () => {
|
||||
const { nodes, edges, selectedNodeId } = get();
|
||||
const removeIds = new Set(
|
||||
nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.data.type === CONTROL_NODE_TYPE &&
|
||||
isAutoCalibrationControlId(n.data.name),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
if (removeIds.size === 0) return;
|
||||
set({
|
||||
nodes: nodes.filter((n) => !removeIds.has(n.id)),
|
||||
edges: edges.filter((e) => !removeIds.has(e.source) && !removeIds.has(e.target)),
|
||||
selectedNodeId:
|
||||
selectedNodeId && removeIds.has(selectedNodeId) ? null : selectedNodeId,
|
||||
controls: get().controls.filter((c) => !isAutoCalibrationControlId(c.id)),
|
||||
});
|
||||
get().recordHistory();
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
const empty = { nodes: [], edges: [], selectedNodeId: null };
|
||||
set({
|
||||
|
||||
Reference in New Issue
Block a user