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:
@@ -24,10 +24,8 @@
|
|||||||
"flipV": false,
|
"flipV": false,
|
||||||
"name": "cond",
|
"name": "cond",
|
||||||
"params": {
|
"params": {
|
||||||
"__fixed_calib_sdt_c": false,
|
|
||||||
"__fixed_z_dp": true,
|
"__fixed_z_dp": true,
|
||||||
"__fixed_z_ua": true,
|
"__fixed_z_ua": true,
|
||||||
"calib_sdt_c": 40,
|
|
||||||
"channel_spacing_mm": 1.5,
|
"channel_spacing_mm": 1.5,
|
||||||
"chevron_angle_deg": 60,
|
"chevron_angle_deg": 60,
|
||||||
"correlation": "Longo2004",
|
"correlation": "Longo2004",
|
||||||
@@ -43,7 +41,6 @@
|
|||||||
"refrigerant": "R134a",
|
"refrigerant": "R134a",
|
||||||
"secondary_fluid": "Water",
|
"secondary_fluid": "Water",
|
||||||
"target_subcooling_k": 5,
|
"target_subcooling_k": 5,
|
||||||
"ua": 2500,
|
|
||||||
"z_dp": 1,
|
"z_dp": 1,
|
||||||
"z_ua": 1
|
"z_ua": 1
|
||||||
},
|
},
|
||||||
@@ -70,10 +67,8 @@
|
|||||||
"flipV": false,
|
"flipV": false,
|
||||||
"name": "evap",
|
"name": "evap",
|
||||||
"params": {
|
"params": {
|
||||||
"__fixed_calib_sst_c": false,
|
|
||||||
"__fixed_z_dp": true,
|
"__fixed_z_dp": true,
|
||||||
"__fixed_z_ua": false,
|
"__fixed_z_ua": false,
|
||||||
"calib_sst_c": 5,
|
|
||||||
"channel_spacing_mm": 1.5,
|
"channel_spacing_mm": 1.5,
|
||||||
"chevron_angle_deg": 60,
|
"chevron_angle_deg": 60,
|
||||||
"correlation": "Longo2004",
|
"correlation": "Longo2004",
|
||||||
@@ -89,7 +84,6 @@
|
|||||||
"refrigerant": "R134a",
|
"refrigerant": "R134a",
|
||||||
"secondary_fluid": "Water",
|
"secondary_fluid": "Water",
|
||||||
"target_superheat_k": 5,
|
"target_superheat_k": 5,
|
||||||
"ua": 2000,
|
|
||||||
"z_dp": 1,
|
"z_dp": 1,
|
||||||
"z_ua": 1
|
"z_ua": 1
|
||||||
},
|
},
|
||||||
@@ -166,10 +160,9 @@
|
|||||||
"flipV": false,
|
"flipV": false,
|
||||||
"name": "SST probe",
|
"name": "SST probe",
|
||||||
"params": {
|
"params": {
|
||||||
"__fixed_target": true,
|
"__fixed_tsat_c": true,
|
||||||
"fluid": "R134a",
|
"fluid": "R134a",
|
||||||
"measure": "SST",
|
"tsat_c": 4.4
|
||||||
"target": 5.9
|
|
||||||
},
|
},
|
||||||
"rotation": 0,
|
"rotation": 0,
|
||||||
"type": "Probe"
|
"type": "Probe"
|
||||||
|
|||||||
@@ -177,21 +177,18 @@ export default function PropertiesPanel() {
|
|||||||
Modelica / Dymola.
|
Modelica / Dymola.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded border border-[var(--line)] bg-[var(--chrome-2)] p-2 text-[10px]">
|
<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)]">
|
<ul className="list-inside list-disc space-y-0.5 text-[var(--ink-dim)]">
|
||||||
<li>
|
<li>
|
||||||
<strong>Fixed ☑</strong> = valeur imposée
|
<strong>Fixed ☑</strong> = <code>parameter</code> (connu)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Fixed ☐</strong> = le solveur calcule (ex. Z_UA libre)
|
<strong>Fixed ☐</strong> = inconnue → il faut une équation de plus
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Calibration simple : Fixed sur SST + Z_UA non Fixed (défaut Z_UA = 1)
|
Calib : Probe Tsat Fixed (= équation) + Z_UA Free (= inconnue)
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Le bloc « Regulation loop » (palette Advanced) est optionnel — pas besoin pour
|
|
||||||
calibrer Z_UA
|
|
||||||
</li>
|
</li>
|
||||||
|
<li>n_eq = n_unk. Pas de boucle de régulation.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,15 +548,15 @@ export default function PropertiesPanel() {
|
|||||||
{activeTab === "Calibration" && (
|
{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)]">
|
<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>
|
<p>
|
||||||
<strong>Calibration :</strong> pose une <strong>Probe</strong> (sonde) sur la
|
<strong>Modelica :</strong> Probe Fixed → équation (
|
||||||
ligne à mesurer, coche Fixed sur sa cible, puis décoche Fixed sur le facteur
|
<code>Tsat = …</code>). Facteur Free → inconnue (
|
||||||
(Z_UA) ici. La valeur résolue s’affiche ci-dessous après simulation.
|
<code>parameter z_ua(fixed=false)</code>). Le start est la valeur affichée.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[var(--ink-faint)]">
|
<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>
|
</p>
|
||||||
{solvedVars.length > 0 && (
|
{solvedVars.length > 0 && (
|
||||||
<SolvedVariablesBlock items={solvedVars} title="Facteurs résolus" />
|
<SolvedVariablesBlock items={solvedVars} title="Inconnues résolues" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,24 +28,24 @@ export interface ParamMeta {
|
|||||||
options?: Array<{ value: string | number; label: string }>;
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
/**
|
/**
|
||||||
* Dymola/EES-style Fixed checkbox.
|
* Modelica `fixed` attribute (Dymola-style checkbox).
|
||||||
* Fixed ON = value imposed (parameter or measured target).
|
* Fixed ON = `parameter` / binding equation (known).
|
||||||
* Fixed OFF = free for the solver (calibration tuner / free unknown).
|
* Fixed OFF = unknown — needs another equation to keep the model balanced.
|
||||||
*/
|
*/
|
||||||
fixable?: boolean;
|
fixable?: boolean;
|
||||||
/** Default for the Fixed checkbox when `fixable` (true = Fixed). */
|
/** Default for the Fixed checkbox when `fixable` (true = Fixed). */
|
||||||
defaultFixed?: boolean;
|
defaultFixed?: boolean;
|
||||||
/**
|
/**
|
||||||
* When Fixed ON: use this param value as a control setpoint for the named
|
* When Fixed ON on a Probe: adds equation `output = value`
|
||||||
* measure output (e.g. `saturationTemperature`, `superheat`).
|
* (e.g. Tsat = 5 °C). Internal wire name for the solver output.
|
||||||
*/
|
*/
|
||||||
measureOutput?: string;
|
measureOutput?: string;
|
||||||
/**
|
/**
|
||||||
* When Fixed OFF: free this actuator/calibration factor (e.g. `z_ua`).
|
* When Fixed OFF: this factor becomes a Newton unknown (Modelica
|
||||||
* Requires a Fixed measure on the same component to stay DoF-balanced.
|
* `parameter ...(fixed=false)`). Pair with a Fixed Probe equation.
|
||||||
*/
|
*/
|
||||||
actuatorFactor?: string;
|
actuatorFactor?: string;
|
||||||
/** Bounds used when the factor is free (actuator min/max). */
|
/** Bounds / start guess when the factor is an unknown. */
|
||||||
freeMin?: number;
|
freeMin?: number;
|
||||||
freeMax?: number;
|
freeMax?: number;
|
||||||
/**
|
/**
|
||||||
@@ -220,41 +220,20 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
label: "Probe (sonde)",
|
label: "Probe (sonde)",
|
||||||
category: "Instrumentation",
|
category: "Instrumentation",
|
||||||
description:
|
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:
|
help:
|
||||||
"Sonde de calibration (Probe)\n" +
|
"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.\n" +
|
||||||
"1. Glisse le Probe sur un fil du schéma (comme pour insérer un Pipe).\n" +
|
"2. Coche Fixed sur les grandeurs physiques mesurées (T, Tsat, P, X, Tsh).\n" +
|
||||||
"2. Choisis la grandeur mesurée (SST, SDT, DGT, DSH, SH, SC, T, P, débit, capacité, enthalpie) et le fluide.\n" +
|
"3. Libère le facteur correspondant sur le composant :\n" +
|
||||||
"3. Coche Fixed sur « Cible » et tape la valeur mesurée.\n" +
|
" • Tsat / Tsh → z_ua (HX) ou opening (EXV)\n" +
|
||||||
"4. Libère le Z-factor correspondant (Z_UA, Z_dP, Z_flow…) sur le composant à calibrer.\n" +
|
" • T (DGT refoulement) → f_w (rétention énergie carter)\n" +
|
||||||
"Le solveur appaire automatiquement le Probe (mesure) et le Z-factor libre (inconnue).",
|
" • Capacity → z_flow (capacité machine)\n" +
|
||||||
|
" • P → z_dp\n" +
|
||||||
|
"Tsh = T − Tsat (surchauffe > 0, sous-refroidissement < 0).",
|
||||||
ports: ["inlet", "outlet"],
|
ports: ["inlet", "outlet"],
|
||||||
color: "#0ea5e9",
|
color: "#0ea5e9",
|
||||||
params: [
|
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",
|
key: "fluid",
|
||||||
label: "Fluide",
|
label: "Fluide",
|
||||||
@@ -262,22 +241,81 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
default: "R134a",
|
default: "R134a",
|
||||||
required: true,
|
required: true,
|
||||||
section: "Mesure",
|
section: "Mesure",
|
||||||
description: "Fluide frigorigène pour le calcul de Tsat/SH/SC (via CoolProp).",
|
description: "Fluide pour Tsat / Tsh / X (CoolProp).",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "target",
|
key: "t_c",
|
||||||
label: "Cible (Fixed = impose)",
|
label: "T (température)",
|
||||||
kind: "number",
|
kind: "number",
|
||||||
section: "Calibration",
|
unit: "°C",
|
||||||
|
default: 50,
|
||||||
|
section: "Paramètres physiques",
|
||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: false,
|
defaultFixed: false,
|
||||||
// Auto: resolved from `measure` at config-build time
|
measureOutput: "temperature",
|
||||||
// (SST/SDT → saturationTemperature, SH/DSH → superheat, SC → subcooling,
|
description: "Température de ligne. Sur refoulement = DGT → pairer avec f_w.",
|
||||||
// DGT/T → temperature, P → pressure, MassFlow → massFlowRate,
|
},
|
||||||
// Capacity → capacity, Enthalpy → heatTransferRate as carrier).
|
{
|
||||||
measureOutput: "auto",
|
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:
|
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,
|
default: 5.0,
|
||||||
section: "Init / design",
|
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,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_ua",
|
actuatorFactor: "z_ua",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
description: "Fixed = valeur figée. Décocher Fixed = libre (calibration).",
|
description: "Fixed = valeur figée. Décocher Fixed = libre (calibration).",
|
||||||
},
|
},
|
||||||
@@ -834,7 +902,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_dp",
|
actuatorFactor: "z_dp",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -870,7 +938,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_ua",
|
actuatorFactor: "z_ua",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
description: "Fixed = figé. Décocher Fixed = libre pour le solveur.",
|
description: "Fixed = figé. Décocher Fixed = libre pour le solveur.",
|
||||||
},
|
},
|
||||||
@@ -885,7 +953,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_dp",
|
actuatorFactor: "z_dp",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1145,7 +1213,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_ua",
|
actuatorFactor: "z_ua",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1158,7 +1226,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_dp",
|
actuatorFactor: "z_dp",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1234,7 +1302,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_ua",
|
actuatorFactor: "z_ua",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1247,7 +1315,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_dp",
|
actuatorFactor: "z_dp",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1303,7 +1371,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_ua",
|
actuatorFactor: "z_ua",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
description: "Fixed = figé. Décocher Fixed = le solveur ajuste Z_UA.",
|
description: "Fixed = figé. Décocher Fixed = le solveur ajuste Z_UA.",
|
||||||
},
|
},
|
||||||
@@ -1318,7 +1386,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
fixable: true,
|
fixable: true,
|
||||||
defaultFixed: true,
|
defaultFixed: true,
|
||||||
actuatorFactor: "z_dp",
|
actuatorFactor: "z_dp",
|
||||||
freeMin: 0.2,
|
freeMin: 0.05,
|
||||||
freeMax: 3.0,
|
freeMax: 3.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Edge, Node } from "@xyflow/react";
|
|||||||
import {
|
import {
|
||||||
applyBoundaryFixSemantics,
|
applyBoundaryFixSemantics,
|
||||||
buildScenarioConfig,
|
buildScenarioConfig,
|
||||||
|
buildModelEmbeddings,
|
||||||
buildFixedFreeCalibrationControls,
|
buildFixedFreeCalibrationControls,
|
||||||
resolveSecondaryStreams,
|
resolveSecondaryStreams,
|
||||||
stripUiOnlyParams,
|
stripUiOnlyParams,
|
||||||
@@ -483,43 +484,45 @@ describe("caloporteur (secondary stream) resolution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
describe("Modelica embeddings (Fixed / Free)", () => {
|
||||||
it("pairs a Fixed Probe measure with a Free Z_UA on the HX", () => {
|
it("pairs Fixed Probe Tsat with Free Z_UA into embeddings[] (not controls[])", () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
node("e", "FloodedEvaporator", "evap", 0, {
|
node("e", "FloodedEvaporator", "evap", 0, {
|
||||||
ua: 9000,
|
ua: 9000,
|
||||||
z_ua: 1.0,
|
z_ua: 1.0,
|
||||||
// Fixed OFF for Z_UA → free actuator
|
|
||||||
[fixedFlagKey("z_ua")]: false,
|
[fixedFlagKey("z_ua")]: false,
|
||||||
}),
|
}),
|
||||||
node("p1", "Probe", "sst_probe", 0, {
|
node("p1", "Probe", "sst_probe", 0, {
|
||||||
measure: "SST",
|
|
||||||
fluid: "R134a",
|
fluid: "R134a",
|
||||||
target: 278.15,
|
tsat_c: 5.0,
|
||||||
// Fixed ON for the Probe target → impose the measure
|
[fixedFlagKey("tsat_c")]: true,
|
||||||
[fixedFlagKey("target")]: true,
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const edges: Edge[] = [
|
const edges: Edge[] = [
|
||||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||||
];
|
];
|
||||||
const controls = buildFixedFreeCalibrationControls(nodes, edges);
|
const embeddings = buildModelEmbeddings(nodes, edges);
|
||||||
expect(controls).toHaveLength(1);
|
expect(embeddings).toHaveLength(1);
|
||||||
expect(controls[0]).toMatchObject({
|
expect(embeddings[0]).toMatchObject({
|
||||||
measure: { component: "sst_probe", output: "saturationTemperature" },
|
id: "emb_evap_z_ua",
|
||||||
actuator: { component: "evap", factor: "z_ua" },
|
unknown: { component: "evap", factor: "z_ua", start: 0.3, min: 0.05 },
|
||||||
target: 278.15,
|
equation: {
|
||||||
|
component: "sst_probe",
|
||||||
|
output: "saturationTemperature",
|
||||||
|
value: 278.15,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = buildScenarioConfig(nodes, edges);
|
const cfg = buildScenarioConfig(nodes, edges);
|
||||||
expect(cfg.controls?.length).toBe(1);
|
expect(cfg.embeddings).toHaveLength(1);
|
||||||
// UI-only keys stripped from component JSON; Z_UA still emitted as initial value.
|
expect(cfg.controls).toBeUndefined();
|
||||||
const comp = cfg.circuits[0].components[0];
|
const comp = cfg.circuits[0].components[0];
|
||||||
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
||||||
expect(comp.z_ua).toBe(1.0);
|
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 = [
|
const nodes = [
|
||||||
node("e", "Evaporator", "evap", 0, {
|
node("e", "Evaporator", "evap", 0, {
|
||||||
ua: 6000,
|
ua: 6000,
|
||||||
@@ -527,18 +530,15 @@ describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
|||||||
[fixedFlagKey("z_ua")]: true,
|
[fixedFlagKey("z_ua")]: true,
|
||||||
}),
|
}),
|
||||||
node("p1", "Probe", "sst_probe", 0, {
|
node("p1", "Probe", "sst_probe", 0, {
|
||||||
measure: "SST",
|
tsat_c: 5.0,
|
||||||
fluid: "R134a",
|
[fixedFlagKey("tsat_c")]: true,
|
||||||
target: 278.15,
|
|
||||||
[fixedFlagKey("target")]: true,
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
expect(buildModelEmbeddings(nodes)).toHaveLength(0);
|
||||||
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits nothing for a freed Z_UA with no matching Probe (HARD RULE)", () => {
|
it("emits nothing for a freed Z_UA with no matching Probe", () => {
|
||||||
// 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.
|
|
||||||
const nodes = [
|
const nodes = [
|
||||||
node("e", "Evaporator", "evap", 0, {
|
node("e", "Evaporator", "evap", 0, {
|
||||||
ua: 6000,
|
ua: 6000,
|
||||||
@@ -546,6 +546,39 @@ describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
|||||||
[fixedFlagKey("z_ua")]: false,
|
[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;
|
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 {
|
export interface ControlObjectiveConfig {
|
||||||
component: string;
|
component: string;
|
||||||
output: string;
|
output: string;
|
||||||
@@ -100,8 +117,10 @@ export interface ScenarioConfig {
|
|||||||
ua: number;
|
ua: number;
|
||||||
efficiency: number;
|
efficiency: number;
|
||||||
}>;
|
}>;
|
||||||
/** Steady-state control loops (co-solved). Mirrors crates/cli config `controls`. */
|
/** System regulation loops (EXV/injection). Mirrors CLI `controls`. */
|
||||||
controls?: ControlConfig[];
|
controls?: ControlConfig[];
|
||||||
|
/** Modelica Z-factor embeddings (unknown + equation). Mirrors CLI `embeddings`. */
|
||||||
|
embeddings?: EmbeddingConfig[];
|
||||||
/** Reusable subsystem templates (flattened by the CLI at load time). */
|
/** Reusable subsystem templates (flattened by the CLI at load time). */
|
||||||
subsystems?: Record<string, SubsystemTemplate>;
|
subsystems?: Record<string, SubsystemTemplate>;
|
||||||
/** Template instantiations. */
|
/** Template instantiations. */
|
||||||
@@ -119,6 +138,13 @@ export interface ScenarioConfig {
|
|||||||
export const SCHEMA_VERSION = "2";
|
export const SCHEMA_VERSION = "2";
|
||||||
export const CONTROL_NODE_TYPE = "SaturatedController";
|
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 {
|
export interface BuildOptions {
|
||||||
fluid?: string;
|
fluid?: string;
|
||||||
fluidBackend?: 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
|
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);
|
.map(controlNodeToConfig);
|
||||||
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes, edges);
|
const controls = mergeControls(options.controls ?? [], nodeControls);
|
||||||
const controls = mergeControls(
|
const embeddings = buildModelEmbeddings(nodes, edges);
|
||||||
mergeControls(options.controls ?? [], nodeControls),
|
|
||||||
fixedFreeControls,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
@@ -351,6 +380,7 @@ export function buildScenarioConfig(
|
|||||||
...(connections.length > 0 ? { connections } : {}),
|
...(connections.length > 0 ? { connections } : {}),
|
||||||
thermal_couplings: options.thermalCouplings || [],
|
thermal_couplings: options.thermalCouplings || [],
|
||||||
...(controls.length > 0 ? { controls } : {}),
|
...(controls.length > 0 ? { controls } : {}),
|
||||||
|
...(embeddings.length > 0 ? { embeddings } : {}),
|
||||||
solver: {
|
solver: {
|
||||||
strategy: options.solverStrategy || "newton",
|
strategy: options.solverStrategy || "newton",
|
||||||
max_iterations: options.maxIterations ?? 300,
|
max_iterations: options.maxIterations ?? 300,
|
||||||
@@ -364,6 +394,9 @@ export function buildScenarioConfig(
|
|||||||
* - `__fixed_*` Fixed checkbox flags
|
* - `__fixed_*` Fixed checkbox flags
|
||||||
* - measure-only targets (e.g. Probe `target`) — 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`
|
* - 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(
|
export function stripUiOnlyParams(
|
||||||
type: string,
|
type: string,
|
||||||
@@ -383,6 +416,13 @@ export function stripUiOnlyParams(
|
|||||||
if (v === "" || v === null || v === undefined) continue;
|
if (v === "" || v === null || v === undefined) continue;
|
||||||
out[k] = v;
|
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);
|
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)
|
* Pairing (free factor → Probe Fixed physical params):
|
||||||
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
|
* z_ua → Tsat, Tsh
|
||||||
*
|
|
||||||
* 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_dp → P
|
||||||
* z_flow → MassFlow, Capacity
|
* z_flow → Capacity
|
||||||
|
* f_w → T
|
||||||
* z_power → Capacity
|
* z_power → Capacity
|
||||||
* z_etav → MassFlow
|
* z_etav → Capacity
|
||||||
|
* opening → Tsh
|
||||||
|
*
|
||||||
|
* Never emits SaturatedController / controls[].
|
||||||
*/
|
*/
|
||||||
export function buildFixedFreeCalibrationControls(
|
export function buildModelEmbeddings(
|
||||||
nodes: Node<EntropykNodeData>[],
|
nodes: Node<EntropykNodeData>[],
|
||||||
edges: Edge[] = [],
|
edges: Edge[] = [],
|
||||||
): ControlConfig[] {
|
): EmbeddingConfig[] {
|
||||||
const controls: ControlConfig[] = [];
|
|
||||||
|
|
||||||
// ── Pass 1: collect Probe measures and per-component freed z-factors ──
|
|
||||||
type ProbeMeasure = {
|
type ProbeMeasure = {
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
kind: string; // SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy
|
kind: string;
|
||||||
|
output: string;
|
||||||
target: number;
|
target: number;
|
||||||
factorCompat: readonly string[]; // z-factors this Probe kind can pair with
|
factorCompat: readonly string[];
|
||||||
};
|
};
|
||||||
type FreeAct = {
|
type FreeAct = {
|
||||||
factor: string;
|
factor: string;
|
||||||
@@ -503,6 +538,7 @@ export function buildFixedFreeCalibrationControls(
|
|||||||
key: string;
|
key: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const embeddings: EmbeddingConfig[] = [];
|
||||||
const probeMeasures: ProbeMeasure[] = [];
|
const probeMeasures: ProbeMeasure[] = [];
|
||||||
const freeActsByComponent = new Map<
|
const freeActsByComponent = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -515,30 +551,43 @@ export function buildFixedFreeCalibrationControls(
|
|||||||
if (!meta) continue;
|
if (!meta) continue;
|
||||||
const params = node.data.params ?? {};
|
const params = node.data.params ?? {};
|
||||||
|
|
||||||
// Probe node: read the `target` Fixed param + the configured `measure` kind.
|
|
||||||
if (node.data.type === "Probe") {
|
if (node.data.type === "Probe") {
|
||||||
const measureParam = meta.params.find((p) => p.key === "measure");
|
let emitted = 0;
|
||||||
const targetParam = meta.params.find((p) => p.key === "target");
|
for (const p of meta.params) {
|
||||||
if (!measureParam || !targetParam) continue;
|
if (!p.fixable || !p.measureOutput) continue;
|
||||||
const kindRaw = params.measure;
|
if (!isParamFixed(params, p)) continue;
|
||||||
const kind =
|
const raw = params[p.key];
|
||||||
typeof kindRaw === "string" ? kindRaw : String(measureParam.default ?? "SH");
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
const targetMeta = targetParam;
|
if (!Number.isFinite(n)) continue;
|
||||||
const targetFixed = isParamFixed(params, targetParam);
|
const kind = PROBE_PARAM_KIND[p.key] ?? p.key;
|
||||||
if (!targetFixed) continue;
|
let target = measureSetpointSi(p, n);
|
||||||
const rawTarget = params.target;
|
if (p.measureOutput === "pressure" && (p.unit ?? "").toLowerCase() === "bar") {
|
||||||
const n = typeof rawTarget === "number" ? rawTarget : Number(rawTarget);
|
target = n * 1e5;
|
||||||
if (!Number.isFinite(n)) continue;
|
}
|
||||||
probeMeasures.push({
|
probeMeasures.push({
|
||||||
nodeName: node.data.name,
|
nodeName: node.data.name,
|
||||||
kind,
|
kind,
|
||||||
target: measureSetpointSi(targetMeta, n),
|
output: p.measureOutput === "auto" ? "temperature" : p.measureOutput,
|
||||||
factorCompat: FACTOR_COMPATIBILITY[kind] ?? [],
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-Probe node: collect freed z-factors (actuators to calibrate).
|
|
||||||
const freeActs: FreeAct[] = [];
|
const freeActs: FreeAct[] = [];
|
||||||
for (const p of meta.params) {
|
for (const p of meta.params) {
|
||||||
if (!p.fixable) continue;
|
if (!p.fixable) continue;
|
||||||
@@ -547,7 +596,13 @@ export function buildFixedFreeCalibrationControls(
|
|||||||
|
|
||||||
if (p.actuatorFactor && !fixed) {
|
if (p.actuatorFactor && !fixed) {
|
||||||
const n = typeof raw === "number" ? raw : Number(raw);
|
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({
|
freeActs.push({
|
||||||
factor: p.actuatorFactor,
|
factor: p.actuatorFactor,
|
||||||
initial,
|
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 adjacentProbes = new Map<string, Set<string>>();
|
||||||
const probeNodeIds = new Set(
|
const probeNodeIds = new Set(nodes.filter((n) => n.data.type === "Probe").map((n) => n.id));
|
||||||
nodes.filter((n) => n.data.type === "Probe").map((n) => n.id),
|
|
||||||
);
|
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const endpoints = [edge.source, edge.target];
|
const endpoints = [edge.source, edge.target];
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
if (!endpoint) continue;
|
if (!endpoint) continue;
|
||||||
// Probe directly adjacent to this endpoint
|
|
||||||
for (const other of endpoints) {
|
for (const other of endpoints) {
|
||||||
if (other && other !== endpoint && probeNodeIds.has(other)) {
|
if (other && other !== endpoint && probeNodeIds.has(other)) {
|
||||||
const probeName = nodes.find((n) => n.id === other)?.data.name;
|
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 = (
|
const findProbeFor = (
|
||||||
component: string,
|
component: string,
|
||||||
factor: string,
|
factor: string,
|
||||||
usedProbeNames: Set<string>,
|
usedKeys: Set<string>,
|
||||||
): ProbeMeasure | undefined => {
|
): ProbeMeasure | undefined => {
|
||||||
const compatible = probeMeasures.filter(
|
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;
|
if (compatible.length === 0) return undefined;
|
||||||
const adjacent = adjacentProbes.get(component);
|
const adjacent = adjacentProbes.get(component);
|
||||||
@@ -610,50 +655,107 @@ export function buildFixedFreeCalibrationControls(
|
|||||||
? compatible.find((m) => adjacent.has(m.nodeName))
|
? compatible.find((m) => adjacent.has(m.nodeName))
|
||||||
: undefined;
|
: undefined;
|
||||||
const chosen = adjacentMatch ?? compatible[0];
|
const chosen = adjacentMatch ?? compatible[0];
|
||||||
usedProbeNames.add(chosen.nodeName);
|
usedKeys.add(`${chosen.nodeName}::${chosen.kind}`);
|
||||||
return chosen;
|
return chosen;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Pass 2: emit controls ──
|
const usedKeys = new Set<string>();
|
||||||
// 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 { nodeName, acts } of freeActsByComponent.values()) {
|
||||||
for (const act of acts) {
|
for (const act of acts) {
|
||||||
const probe = findProbeFor(nodeName, act.factor, usedProbeNames);
|
const probe = findProbeFor(nodeName, act.factor, usedKeys);
|
||||||
if (probe) {
|
if (probe) {
|
||||||
controls.push({
|
embeddings.push({
|
||||||
type: "SaturatedController",
|
id: `emb_${nodeName}_${act.factor}`,
|
||||||
id: `calib_${nodeName}_${act.factor}`,
|
unknown: {
|
||||||
measure: {
|
|
||||||
component: probe.nodeName,
|
|
||||||
output: probeOutputFor(probe.kind),
|
|
||||||
},
|
|
||||||
actuator: {
|
|
||||||
component: nodeName,
|
component: nodeName,
|
||||||
factor: act.factor,
|
factor: act.factor,
|
||||||
initial: act.initial,
|
start: act.initial,
|
||||||
min: act.min,
|
min: act.min,
|
||||||
max: act.max,
|
max: act.max,
|
||||||
},
|
},
|
||||||
target: probe.target,
|
equation: {
|
||||||
gain: -0.5,
|
component: probe.nodeName,
|
||||||
band: 2.0,
|
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
|
* Legacy Probe shape: `{ measure: "SST", target: 5.9, __fixed_target: true }`.
|
||||||
* accepted by the CLI (`parse_component_output` in run.rs).
|
* 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) {
|
switch (kind) {
|
||||||
case "SST":
|
case "SST":
|
||||||
case "SDT":
|
case "SDT":
|
||||||
@@ -672,28 +774,27 @@ function probeOutputFor(kind: string): string {
|
|||||||
return "massFlowRate";
|
return "massFlowRate";
|
||||||
case "Capacity":
|
case "Capacity":
|
||||||
return "capacity";
|
return "capacity";
|
||||||
case "Enthalpy":
|
|
||||||
// No dedicated Enthalpy ComponentOutput; carry it on heatTransferRate.
|
|
||||||
return "heatTransferRate";
|
|
||||||
default:
|
default:
|
||||||
return "temperature";
|
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[]> = {
|
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"],
|
SST: ["z_ua"],
|
||||||
SDT: ["z_ua"],
|
SDT: ["z_ua"],
|
||||||
SH: ["z_ua"],
|
SH: ["z_ua", "opening"],
|
||||||
DSH: ["z_ua"],
|
|
||||||
SC: ["z_ua"],
|
SC: ["z_ua"],
|
||||||
Capacity: ["z_ua", "z_flow", "z_power"],
|
DGT: ["f_w"],
|
||||||
P: ["z_dp"],
|
DSH: ["f_w"],
|
||||||
MassFlow: ["z_flow", "z_etav"],
|
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). */
|
/** 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
|
// Modelica balanced model: parameter(fixed=false) is an unknown → needs an
|
||||||
// `enforceModelicaBoundaryEmit` auto-corrects legalizable cases (Free P on
|
// equation. Fixed Probe value supplies that equation. Free without equation
|
||||||
// MassFlowSource, Free ṁ when Fixed T_out). Hard-block only if emit cannot help.
|
// 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;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,19 @@ export function useScenarioSimulation() {
|
|||||||
const setLastConfig = useDiagramStore((s) => s.setLastConfig);
|
const setLastConfig = useDiagramStore((s) => s.setLastConfig);
|
||||||
const setResult = useDiagramStore((s) => s.setResult);
|
const setResult = useDiagramStore((s) => s.setResult);
|
||||||
const setSimulating = useDiagramStore((s) => s.setSimulating);
|
const setSimulating = useDiagramStore((s) => s.setSimulating);
|
||||||
|
const purgeAutoCalibrationControlNodes = useDiagramStore(
|
||||||
|
(s) => s.purgeAutoCalibrationControlNodes,
|
||||||
|
);
|
||||||
const simulating = useDiagramStore((s) => s.simulating);
|
const simulating = useDiagramStore((s) => s.simulating);
|
||||||
|
|
||||||
const [issues, setIssues] = useState<string[]>([]);
|
const [issues, setIssues] = useState<string[]>([]);
|
||||||
|
|
||||||
const run = useCallback(async () => {
|
const run = useCallback(async () => {
|
||||||
const problems = validateConfig(nodes, edges);
|
// Fixed/Free calib must never leave SaturatedController blocks on the canvas.
|
||||||
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
|
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") {
|
if (ledger.balance === "over-constrained") {
|
||||||
problems.push(
|
problems.push(
|
||||||
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
|
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
|
||||||
@@ -45,7 +51,7 @@ export function useScenarioSimulation() {
|
|||||||
setIssues(problems);
|
setIssues(problems);
|
||||||
if (problems.length > 0) return false;
|
if (problems.length > 0) return false;
|
||||||
|
|
||||||
const scenarioConfig = buildScenarioConfig(nodes, edges, {
|
const scenarioConfig = buildScenarioConfig(liveNodes, liveEdges, {
|
||||||
fluid,
|
fluid,
|
||||||
fluidBackend,
|
fluidBackend,
|
||||||
solverStrategy,
|
solverStrategy,
|
||||||
@@ -85,6 +91,7 @@ export function useScenarioSimulation() {
|
|||||||
solverStrategy,
|
solverStrategy,
|
||||||
maxIterations,
|
maxIterations,
|
||||||
tolerance,
|
tolerance,
|
||||||
|
purgeAutoCalibrationControlNodes,
|
||||||
setLastConfig,
|
setLastConfig,
|
||||||
setResult,
|
setResult,
|
||||||
setSimulating,
|
setSimulating,
|
||||||
|
|||||||
@@ -25,6 +25,88 @@ function reset() {
|
|||||||
beforeEach(reset);
|
beforeEach(reset);
|
||||||
|
|
||||||
describe("controls import", () => {
|
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", () => {
|
it("preserves imported co-solved controls for the next simulation run", () => {
|
||||||
useDiagramStore.getState().loadFromConfig({
|
useDiagramStore.getState().loadFromConfig({
|
||||||
fluid: "R134a",
|
fluid: "R134a",
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import type { Edge, Node, OnNodesChange, OnEdgesChange, OnConnect } from "@xyflo
|
|||||||
import { applyNodeChanges, applyEdgeChanges, addEdge } from "@xyflow/react";
|
import { applyNodeChanges, applyEdgeChanges, addEdge } from "@xyflow/react";
|
||||||
import { hydrateBoundaryFixFlags } from "@/lib/boundaryFix";
|
import { hydrateBoundaryFixFlags } from "@/lib/boundaryFix";
|
||||||
import { defaultParams } from "@/lib/componentMeta";
|
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 { ControlConfig } from "@/lib/configBuilder";
|
||||||
import type { SimulationResult } from "@/lib/api";
|
import type { SimulationResult } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -147,6 +151,8 @@ interface DiagramState {
|
|||||||
setResult: (result: SimulationResult | null, error?: string | null) => void;
|
setResult: (result: SimulationResult | null, error?: string | null) => void;
|
||||||
setSimulating: (v: boolean) => void;
|
setSimulating: (v: boolean) => void;
|
||||||
loadFromConfig: (config: unknown) => void;
|
loadFromConfig: (config: unknown) => void;
|
||||||
|
/** Remove leftover canvas nodes from Fixed/Free auto-calib (not real controllers). */
|
||||||
|
purgeAutoCalibrationControlNodes: () => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
@@ -736,7 +742,12 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
|||||||
x += 280;
|
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 measuredNode = nodes.find((node) => node.data.name === control.measure.component);
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
nodes.push({
|
nodes.push({
|
||||||
@@ -744,7 +755,7 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
|||||||
type: "entropykNode",
|
type: "entropykNode",
|
||||||
position: measuredNode
|
position: measuredNode
|
||||||
? { x: measuredNode.position.x + 140, y: Math.max(40, measuredNode.position.y - 72) }
|
? { 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: {
|
data: {
|
||||||
type: CONTROL_NODE_TYPE,
|
type: CONTROL_NODE_TYPE,
|
||||||
name: control.id,
|
name: control.id,
|
||||||
@@ -755,6 +766,7 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
|||||||
params: controlParams(control),
|
params: controlParams(control),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
explicitControlIndex += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const edges: Edge[] = [];
|
const edges: Edge[] = [];
|
||||||
@@ -785,7 +797,7 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
|||||||
solverStrategy: cfg.solver?.strategy || "newton",
|
solverStrategy: cfg.solver?.strategy || "newton",
|
||||||
maxIterations: cfg.solver?.max_iterations ?? 300,
|
maxIterations: cfg.solver?.max_iterations ?? 300,
|
||||||
tolerance: cfg.solver?.tolerance ?? 1e-6,
|
tolerance: cfg.solver?.tolerance ?? 1e-6,
|
||||||
controls: cfg.controls ?? [],
|
controls: (cfg.controls ?? []).filter((c) => !isAutoCalibrationControlId(c.id)),
|
||||||
result: null,
|
result: null,
|
||||||
lastConfig: cfg,
|
lastConfig: cfg,
|
||||||
simError: null,
|
simError: null,
|
||||||
@@ -794,6 +806,28 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
|||||||
get().recordHistory();
|
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: () => {
|
clear: () => {
|
||||||
const empty = { nodes: [], edges: [], selectedNodeId: null };
|
const empty = { nodes: [], edges: [], selectedNodeId: null };
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use crate::error::{CliError, CliResult};
|
|||||||
pub const CURRENT_SCHEMA_VERSION: &str = "2";
|
pub const CURRENT_SCHEMA_VERSION: &str = "2";
|
||||||
|
|
||||||
/// Schema versions this build can load. `"1"` is the original flat
|
/// Schema versions this build can load. `"1"` is the original flat
|
||||||
/// `circuits/components/edges` schema; `"2"` adds `controls`, `subsystems`,
|
/// `circuits/components/edges` schema; `"2"` adds `controls`, `embeddings`,
|
||||||
/// `instances` and `connections`. Both are read by the same loader.
|
/// `subsystems`, `instances` and `connections`. Both are read by the same loader.
|
||||||
pub const SUPPORTED_SCHEMA_VERSIONS: &[&str] = &["1", "2"];
|
pub const SUPPORTED_SCHEMA_VERSIONS: &[&str] = &["1", "2"];
|
||||||
|
|
||||||
fn default_schema_version() -> String {
|
fn default_schema_version() -> String {
|
||||||
@@ -30,7 +30,7 @@ fn default_schema_version() -> String {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct ScenarioConfig {
|
pub struct ScenarioConfig {
|
||||||
/// Model IR schema version (`"1"` legacy flat graph, `"2"` adds
|
/// Model IR schema version (`"1"` legacy flat graph, `"2"` adds
|
||||||
/// controls/subsystems/instances/connections). Absent ⇒ `"1"`.
|
/// controls/embeddings/subsystems/instances/connections). Absent ⇒ `"1"`.
|
||||||
#[serde(default = "default_schema_version")]
|
#[serde(default = "default_schema_version")]
|
||||||
pub schema_version: String,
|
pub schema_version: String,
|
||||||
/// Scenario name.
|
/// Scenario name.
|
||||||
@@ -54,9 +54,15 @@ pub struct ScenarioConfig {
|
|||||||
/// hints only; they do not impose thermodynamic boundary conditions.
|
/// hints only; they do not impose thermodynamic boundary conditions.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub initialization: Option<InitializationConfig>,
|
pub initialization: Option<InitializationConfig>,
|
||||||
/// Steady-state control loops (co-solved saturated-PI controllers).
|
/// Steady-state **system regulation** loops (co-solved saturated-PI).
|
||||||
|
/// For EXV opening, injection, fan speed, etc. — **not** Z-factor calibration.
|
||||||
|
/// Z-factors use [`Self::embeddings`] (Modelica parameter(fixed=false) + equation).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub controls: Vec<ControlConfig>,
|
pub controls: Vec<ControlConfig>,
|
||||||
|
/// Modelica-style Z-factor embeddings: one free unknown + one equation
|
||||||
|
/// (`output = value`). Distinct from [`Self::controls`] (no SaturatedController).
|
||||||
|
#[serde(default)]
|
||||||
|
pub embeddings: Vec<EmbeddingConfig>,
|
||||||
/// Reusable subsystem templates (parameterized assemblies of components +
|
/// Reusable subsystem templates (parameterized assemblies of components +
|
||||||
/// internal edges, exposing a reduced external port set). Flattened into
|
/// internal edges, exposing a reduced external port set). Flattened into
|
||||||
/// `circuits` at load time — the solver never sees the hierarchy.
|
/// `circuits` at load time — the solver never sees the hierarchy.
|
||||||
@@ -146,12 +152,56 @@ pub struct InstanceConfig {
|
|||||||
pub params: HashMap<String, serde_json::Value>,
|
pub params: HashMap<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A steady-state control loop declaration.
|
/// Modelica-style Z-factor embedding: free unknown + binding equation.
|
||||||
///
|
///
|
||||||
/// Currently supports the `SaturatedController` type: a saturated-PI loop with
|
/// Equivalent to:
|
||||||
/// anti-windup that is co-solved inside the Newton system. It drives a measured
|
/// ```text
|
||||||
/// plant output (`measure`) to `target` by manipulating an actuator factor
|
/// parameter Real z(fixed = false, start = …);
|
||||||
/// (`actuator`) within `[min, max]` bounds.
|
/// equation
|
||||||
|
/// component.output = value;
|
||||||
|
/// ```
|
||||||
|
/// Registered as plain `Constraint` + `BoundedVariable` — never a SaturatedController.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct EmbeddingConfig {
|
||||||
|
/// Unique id (also used as the constraint id).
|
||||||
|
pub id: String,
|
||||||
|
/// Free Z-factor unknown on a component.
|
||||||
|
pub unknown: EmbeddingUnknownConfig,
|
||||||
|
/// Binding equation: `component.output = value`.
|
||||||
|
pub equation: EmbeddingEquationConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free Z-factor unknown (`parameter ...(fixed=false)`).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct EmbeddingUnknownConfig {
|
||||||
|
/// Component owning the Z-factor.
|
||||||
|
pub component: String,
|
||||||
|
/// Factor name: `z_ua`, `z_dp`, `z_flow`, `z_power`, `z_etav`, `f_w`, …
|
||||||
|
pub factor: String,
|
||||||
|
/// Start / guess value.
|
||||||
|
#[serde(default = "default_actuator_initial")]
|
||||||
|
pub start: f64,
|
||||||
|
/// Lower bound.
|
||||||
|
pub min: f64,
|
||||||
|
/// Upper bound.
|
||||||
|
pub max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binding equation residual: `component.output − value = 0`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct EmbeddingEquationConfig {
|
||||||
|
/// Component providing the measured output (typically a Probe).
|
||||||
|
pub component: String,
|
||||||
|
/// Output kind: `saturationTemperature`, `temperature`, `pressure`, …
|
||||||
|
pub output: String,
|
||||||
|
/// Right-hand side value (SI).
|
||||||
|
pub value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A steady-state **system regulation** loop (EXV, injection, fan, …).
|
||||||
|
///
|
||||||
|
/// Supports `SaturatedController`: saturated-PI with anti-windup, co-solved
|
||||||
|
/// inside Newton. **Not** for Z-factor calibration — use [`EmbeddingConfig`].
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct ControlConfig {
|
pub struct ControlConfig {
|
||||||
/// Controller type. Only `"SaturatedController"` is supported today.
|
/// Controller type. Only `"SaturatedController"` is supported today.
|
||||||
@@ -217,13 +267,13 @@ pub struct MeasureConfig {
|
|||||||
pub output: String,
|
pub output: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reference to a manipulated actuator on a component.
|
/// Reference to a manipulated **physical** actuator on a component.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct ActuatorConfig {
|
pub struct ActuatorConfig {
|
||||||
/// Name of the component carrying the actuator.
|
/// Name of the component carrying the actuator.
|
||||||
pub component: String,
|
pub component: String,
|
||||||
/// Calibration Z-factor to manipulate: `z_flow`, `z_dp`, `z_ua`, `z_power`, or `z_etav`
|
/// Physical actuator factor for regulation: `opening`, `injection`, …
|
||||||
/// (legacy `f_*` names and BOLT `Z_*` spellings are also accepted).
|
/// Z-factors (`z_ua`, …) must use [`EmbeddingConfig`], not controls.
|
||||||
pub factor: String,
|
pub factor: String,
|
||||||
/// Initial actuator value (nominal, e.g. 1.0).
|
/// Initial actuator value (nominal, e.g. 1.0).
|
||||||
#[serde(default = "default_actuator_initial")]
|
#[serde(default = "default_actuator_initial")]
|
||||||
@@ -1343,11 +1393,12 @@ mod tests {
|
|||||||
fn test_json_schema_emits_ir_fields() {
|
fn test_json_schema_emits_ir_fields() {
|
||||||
let schema = ScenarioConfig::json_schema();
|
let schema = ScenarioConfig::json_schema();
|
||||||
// The emitted schema must be the single source of truth covering every IR
|
// The emitted schema must be the single source of truth covering every IR
|
||||||
// pillar: circuits (v1) + controls/subsystems/instances/connections (v2).
|
// pillar: circuits (v1) + controls/embeddings/subsystems/instances/connections (v2).
|
||||||
for field in [
|
for field in [
|
||||||
"schema_version",
|
"schema_version",
|
||||||
"circuits",
|
"circuits",
|
||||||
"controls",
|
"controls",
|
||||||
|
"embeddings",
|
||||||
"subsystems",
|
"subsystems",
|
||||||
"instances",
|
"instances",
|
||||||
"connections",
|
"connections",
|
||||||
@@ -1361,4 +1412,23 @@ mod tests {
|
|||||||
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
|
||||||
assert!(parsed.get("$schema").is_some());
|
assert!(parsed.get("$schema").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_embeddings() {
|
||||||
|
let json = r#"{
|
||||||
|
"schema_version": "2",
|
||||||
|
"fluid": "R134a",
|
||||||
|
"embeddings": [{
|
||||||
|
"id": "emb_evap_z_ua",
|
||||||
|
"unknown": { "component": "evap", "factor": "z_ua", "start": 0.3, "min": 0.05, "max": 3.0 },
|
||||||
|
"equation": { "component": "SST probe", "output": "saturationTemperature", "value": 278.15 }
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let config = ScenarioConfig::from_json(json).unwrap();
|
||||||
|
assert_eq!(config.embeddings.len(), 1);
|
||||||
|
assert_eq!(config.embeddings[0].id, "emb_evap_z_ua");
|
||||||
|
assert_eq!(config.embeddings[0].unknown.factor, "z_ua");
|
||||||
|
assert!((config.embeddings[0].equation.value - 278.15).abs() < 1e-9);
|
||||||
|
assert!(config.controls.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,6 +414,33 @@ fn execute_simulation(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fail fast: Z-factors belong in embeddings[], never controls[].
|
||||||
|
for control in &config.controls {
|
||||||
|
let factor = control.actuator.factor.trim();
|
||||||
|
if entropyk_core::normalize_factor_name(factor).is_some() {
|
||||||
|
return SimulationResult {
|
||||||
|
input: input_name.to_string(),
|
||||||
|
status: SimulationStatus::Error,
|
||||||
|
convergence: None,
|
||||||
|
iterations: None,
|
||||||
|
state: None,
|
||||||
|
performance: None,
|
||||||
|
error: Some(format!(
|
||||||
|
"control '{}': factor '{}' is a Z-factor — use embeddings[] \
|
||||||
|
(Modelica parameter/equation), not controls[]/SaturatedController. \
|
||||||
|
controls[] is for physical regulation (opening, injection, …).",
|
||||||
|
control.id, factor
|
||||||
|
)),
|
||||||
|
failure_diagnostics: None,
|
||||||
|
initialization_diagnostics: None,
|
||||||
|
dof: None,
|
||||||
|
elapsed_ms,
|
||||||
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut system = System::new();
|
let mut system = System::new();
|
||||||
|
|
||||||
// Track component name -> (node index, component type) mapping per circuit
|
// Track component name -> (node index, component type) mapping per circuit
|
||||||
@@ -619,6 +646,27 @@ fn execute_simulation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free z_ua embedding: drop absolute `ua` overrides on the unknown's
|
||||||
|
// component. Otherwise `ua` freezes Calib and ∂Q/∂z_ua → 0 (singular J).
|
||||||
|
for emb in &config.embeddings {
|
||||||
|
if entropyk_core::normalize_factor_name(emb.unknown.factor.trim())
|
||||||
|
== Some(entropyk_core::Z_UA)
|
||||||
|
{
|
||||||
|
if let Some(comp) = expanded_components
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.name == emb.unknown.component)
|
||||||
|
{
|
||||||
|
if comp.params.remove("ua").is_some() {
|
||||||
|
tracing::info!(
|
||||||
|
component = %comp.name,
|
||||||
|
embedding = %emb.id,
|
||||||
|
"Dropped 'ua' override — z_ua embedding owns UA scaling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fixed EXV orifice opening sets ṁ via the valve — skip compressor
|
// Fixed EXV orifice opening sets ṁ via the valve — skip compressor
|
||||||
// displacement ṁ closure so DoF stays square (ṁ follows the valve).
|
// displacement ṁ closure so DoF stays square (ṁ follows the valve).
|
||||||
let meter_mass_flow_via_exv = expanded_components.iter().any(|c| {
|
let meter_mass_flow_via_exv = expanded_components.iter().any(|c| {
|
||||||
@@ -1050,13 +1098,12 @@ fn execute_simulation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register declared control loops (saturated-PI, co-solved). Must happen
|
// Register model embeddings (Z-factor unknowns + equations) and system
|
||||||
// BEFORE finalize() so the actuator is wired to the component's CalibIndices.
|
// regulation controls. Must happen BEFORE finalize() so unknowns wire to
|
||||||
|
// CalibIndices.
|
||||||
//
|
//
|
||||||
// Routing (calibration redesign): controls whose actuator is a z-factor
|
// - embeddings[] → plain Constraint + BoundedVariable (Modelica fixed=false)
|
||||||
// (z_flow/z_flow_eco/z_dp/z_ua/z_power/z_etav) go through PLAIN inverse
|
// - controls[] → SaturatedController only (EXV/injection/…); z-factors forbidden
|
||||||
// embedding (+1 residual measured−target, +1 unknown z) — not the
|
|
||||||
// SaturatedController (2+2 with integrator, meant for physical actuators).
|
|
||||||
let control_error = |msg: String| SimulationResult {
|
let control_error = |msg: String| SimulationResult {
|
||||||
input: input_name.to_string(),
|
input: input_name.to_string(),
|
||||||
status: SimulationStatus::Error,
|
status: SimulationStatus::Error,
|
||||||
@@ -1072,83 +1119,51 @@ fn execute_simulation(
|
|||||||
raw_state_vector: None,
|
raw_state_vector: None,
|
||||||
solved_variables: Vec::new(),
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
for control in &config.controls {
|
|
||||||
let factor = control.actuator.factor.trim();
|
for emb in &config.embeddings {
|
||||||
// Plain embedding applies to single-point z-factor calibration only.
|
match build_model_embedding(emb) {
|
||||||
// A control WITH `objectives` is a supervisory override network
|
Ok((constraint, bounded_var, unknown_id)) => {
|
||||||
// (selector semantics) and always keeps the SaturatedController path,
|
if let Err(e) = system.add_constraint(constraint) {
|
||||||
// regardless of the actuator factor.
|
return control_error(format!(
|
||||||
if entropyk_core::normalize_factor_name(factor).is_some() && control.objectives.is_empty() {
|
"Failed to add equation for embedding '{}': {:?}",
|
||||||
match build_plain_z_embedding(control, factor) {
|
emb.id, e
|
||||||
Ok((constraint, bounded_var, actuator_id)) => {
|
));
|
||||||
if let Err(e) = system.add_constraint(constraint) {
|
|
||||||
return control_error(format!(
|
|
||||||
"Failed to add constraint for control '{}': {:?}",
|
|
||||||
control.id, e
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Err(e) = system.add_bounded_variable(bounded_var) {
|
|
||||||
return control_error(format!(
|
|
||||||
"Failed to add actuator for control '{}': {:?}",
|
|
||||||
control.id, e
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Err(e) = system.link_constraint_to_control(
|
|
||||||
&entropyk_solver::inverse::ConstraintId::new(control.id.clone()),
|
|
||||||
&actuator_id,
|
|
||||||
) {
|
|
||||||
return control_error(format!(
|
|
||||||
"Failed to link control '{}': {:?}",
|
|
||||||
control.id, e
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(msg) => {
|
if let Err(e) = system.add_bounded_variable(bounded_var) {
|
||||||
return control_error(format!("Invalid control '{}': {}", control.id, msg));
|
return control_error(format!(
|
||||||
|
"Failed to add unknown for embedding '{}': {:?}",
|
||||||
|
emb.id, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(e) = system.link_constraint_to_control(
|
||||||
|
&entropyk_solver::inverse::ConstraintId::new(emb.id.clone()),
|
||||||
|
&unknown_id,
|
||||||
|
) {
|
||||||
|
return control_error(format!(
|
||||||
|
"Failed to link embedding '{}': {:?}",
|
||||||
|
emb.id, e
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
Err(msg) => {
|
||||||
|
return control_error(format!("Invalid embedding '{}': {}", emb.id, msg));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for control in &config.controls {
|
||||||
match build_saturated_control(control) {
|
match build_saturated_control(control) {
|
||||||
Ok((bounded_var, controller)) => {
|
Ok((bounded_var, controller)) => {
|
||||||
if let Err(e) = system.add_bounded_variable(bounded_var) {
|
if let Err(e) = system.add_bounded_variable(bounded_var) {
|
||||||
return SimulationResult {
|
return control_error(format!(
|
||||||
input: input_name.to_string(),
|
"Failed to add actuator for control '{}': {:?}",
|
||||||
status: SimulationStatus::Error,
|
control.id, e
|
||||||
convergence: None,
|
));
|
||||||
iterations: None,
|
|
||||||
state: None,
|
|
||||||
performance: None,
|
|
||||||
error: Some(format!(
|
|
||||||
"Failed to add actuator for control '{}': {:?}",
|
|
||||||
control.id, e
|
|
||||||
)),
|
|
||||||
failure_diagnostics: None,
|
|
||||||
initialization_diagnostics: None,
|
|
||||||
dof: None,
|
|
||||||
elapsed_ms,
|
|
||||||
raw_state_vector: None,
|
|
||||||
solved_variables: Vec::new(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
system.add_saturated_controller(controller);
|
system.add_saturated_controller(controller);
|
||||||
}
|
}
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
return SimulationResult {
|
return control_error(format!("Invalid control '{}': {}", control.id, msg));
|
||||||
input: input_name.to_string(),
|
|
||||||
status: SimulationStatus::Error,
|
|
||||||
convergence: None,
|
|
||||||
iterations: None,
|
|
||||||
state: None,
|
|
||||||
performance: None,
|
|
||||||
error: Some(format!("Invalid control '{}': {}", control.id, msg)),
|
|
||||||
failure_diagnostics: None,
|
|
||||||
initialization_diagnostics: None,
|
|
||||||
dof: None,
|
|
||||||
elapsed_ms,
|
|
||||||
raw_state_vector: None,
|
|
||||||
solved_variables: Vec::new(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1498,10 +1513,20 @@ fn execute_simulation(
|
|||||||
state
|
state
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seed control-actuator slots at their nominal value (the physical seed only
|
// Seed embedding / control unknowns at their start/initial (physical seed
|
||||||
// fills edge/component states, leaving control unknowns at 0.0 which is a poor
|
// leaves them at 0.0 — a poor start for Z-factors whose nominal is ~0.3–1).
|
||||||
// start for e.g. an f_m mass-flow factor whose nominal is 1.0).
|
|
||||||
let mut initial_state = initial_state;
|
let mut initial_state = initial_state;
|
||||||
|
for emb in &config.embeddings {
|
||||||
|
let unknown_id = entropyk_solver::inverse::BoundedVariableId::new(saturated_actuator_id(
|
||||||
|
&emb.unknown.component,
|
||||||
|
&emb.unknown.factor,
|
||||||
|
));
|
||||||
|
if let Some(u_idx) = system.control_variable_state_index(&unknown_id) {
|
||||||
|
if u_idx < initial_state.len() {
|
||||||
|
initial_state[u_idx] = emb.unknown.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for control in &config.controls {
|
for control in &config.controls {
|
||||||
let actuator_id = entropyk_solver::inverse::BoundedVariableId::new(saturated_actuator_id(
|
let actuator_id = entropyk_solver::inverse::BoundedVariableId::new(saturated_actuator_id(
|
||||||
&control.actuator.component,
|
&control.actuator.component,
|
||||||
@@ -1571,6 +1596,7 @@ fn execute_simulation(
|
|||||||
let solve_time_budget = (config.solver.timeout_ms > 0)
|
let solve_time_budget = (config.solver.timeout_ms > 0)
|
||||||
.then(|| std::time::Duration::from_millis(config.solver.timeout_ms));
|
.then(|| std::time::Duration::from_millis(config.solver.timeout_ms));
|
||||||
let needs_guarded_newton = !config.controls.is_empty()
|
let needs_guarded_newton = !config.controls.is_empty()
|
||||||
|
|| !config.embeddings.is_empty()
|
||||||
|| config.circuits.iter().any(|c| {
|
|| config.circuits.iter().any(|c| {
|
||||||
c.enabled
|
c.enabled
|
||||||
&& c.components.iter().any(|comp| {
|
&& c.components.iter().any(|comp| {
|
||||||
@@ -3085,29 +3111,33 @@ fn bphx_calib_from_params(
|
|||||||
.or_else(|| params.get("f_ua"))
|
.or_else(|| params.get("f_ua"))
|
||||||
.and_then(|v| v.as_f64());
|
.and_then(|v| v.as_f64());
|
||||||
|
|
||||||
|
// Precedence (DoF-safe): live `z_ua` wins over absolute `ua`.
|
||||||
|
// Baking `ua` into a frozen Calib factor while an embedding frees `z_ua`
|
||||||
|
// zeros ∂Q/∂z_ua → singular Jacobian. Modelica-style: modifier owns the unknown.
|
||||||
if config_ua.is_some() && explicit_z_ua.is_some() {
|
if config_ua.is_some() && explicit_z_ua.is_some() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"BphxExchanger: both 'ua' and 'z_ua' provided — 'ua' takes precedence, 'z_ua' ignored"
|
"BphxExchanger: both 'ua' and 'z_ua' provided — 'z_ua' takes precedence, 'ua' ignored"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let z_ua = match config_ua {
|
let z_ua = if let Some(z) = explicit_z_ua {
|
||||||
Some(u) => {
|
z
|
||||||
if u < 0.0 {
|
} else if let Some(u) = config_ua {
|
||||||
return Err(CliError::Config(format!(
|
if u < 0.0 {
|
||||||
"BphxExchanger: ua must be >= 0 (got {:.2} W/K)",
|
return Err(CliError::Config(format!(
|
||||||
u
|
"BphxExchanger: ua must be >= 0 (got {:.2} W/K)",
|
||||||
)));
|
u
|
||||||
}
|
)));
|
||||||
if ua_nominal > 0.0 {
|
|
||||||
u / ua_nominal
|
|
||||||
} else {
|
|
||||||
return Err(CliError::Config(
|
|
||||||
"BphxExchanger: ua_nominal is zero — cannot compute z_ua from explicit 'ua' override. Check geometry parameters.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => explicit_z_ua.unwrap_or(1.0),
|
if ua_nominal > 0.0 {
|
||||||
|
u / ua_nominal
|
||||||
|
} else {
|
||||||
|
return Err(CliError::Config(
|
||||||
|
"BphxExchanger: ua_nominal is zero — cannot compute z_ua from explicit 'ua' override. Check geometry parameters.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
};
|
};
|
||||||
|
|
||||||
if z_ua <= 0.0 {
|
if z_ua <= 0.0 {
|
||||||
@@ -3137,6 +3167,7 @@ fn bphx_calib_from_params(
|
|||||||
z_ua,
|
z_ua,
|
||||||
z_power: 1.0,
|
z_power: 1.0,
|
||||||
z_etav: 1.0,
|
z_etav: 1.0,
|
||||||
|
f_w: 1.0,
|
||||||
calibration_source: None,
|
calibration_source: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3229,15 +3260,11 @@ fn parse_component_output(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a bounded actuator + saturated controller from a control config.
|
/// Modelica Z-factor embedding: one `Constraint` (output − value) + one
|
||||||
/// Plain inverse embedding for z-factor calibration controls (calibration
|
/// `BoundedVariable` (the free Z-factor). Bounded var id uses
|
||||||
/// redesign, WS-2): one `Constraint` (measured − target) + one `BoundedVariable`
|
/// `{component}__{factor}` so `finalize()` wires `CalibIndices`.
|
||||||
/// (the z-factor), linked 1:1. The bounded var id reuses the
|
fn build_model_embedding(
|
||||||
/// `{component}__{z_factor}` convention so `finalize()` wires it to the
|
emb: &crate::config::EmbeddingConfig,
|
||||||
/// component's matching `CalibIndices` slot (system.rs).
|
|
||||||
fn build_plain_z_embedding(
|
|
||||||
control: &crate::config::ControlConfig,
|
|
||||||
factor: &str,
|
|
||||||
) -> Result<
|
) -> Result<
|
||||||
(
|
(
|
||||||
entropyk_solver::inverse::Constraint,
|
entropyk_solver::inverse::Constraint,
|
||||||
@@ -3248,23 +3275,29 @@ fn build_plain_z_embedding(
|
|||||||
> {
|
> {
|
||||||
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};
|
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};
|
||||||
|
|
||||||
let output = parse_component_output(&control.measure.component, &control.measure.output)?;
|
let factor = emb.unknown.factor.trim();
|
||||||
let actuator_id =
|
if entropyk_core::normalize_factor_name(factor).is_none() {
|
||||||
BoundedVariableId::new(saturated_actuator_id(&control.actuator.component, factor));
|
return Err(format!(
|
||||||
|
"unknown Z-factor '{factor}' (expected z_ua, z_dp, z_flow, z_power, z_etav, f_w, …)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let output = parse_component_output(&emb.equation.component, &emb.equation.output)?;
|
||||||
|
let unknown_id =
|
||||||
|
BoundedVariableId::new(saturated_actuator_id(&emb.unknown.component, factor));
|
||||||
let bounded_var = BoundedVariable::with_component(
|
let bounded_var = BoundedVariable::with_component(
|
||||||
actuator_id.clone(),
|
unknown_id.clone(),
|
||||||
&control.actuator.component,
|
&emb.unknown.component,
|
||||||
control.actuator.initial,
|
emb.unknown.start,
|
||||||
control.actuator.min,
|
emb.unknown.min,
|
||||||
control.actuator.max,
|
emb.unknown.max,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("invalid actuator bounds: {e:?}"))?;
|
.map_err(|e| format!("invalid unknown bounds: {e:?}"))?;
|
||||||
let constraint = Constraint::new(
|
let constraint = Constraint::new(
|
||||||
ConstraintId::new(control.id.clone()),
|
ConstraintId::new(emb.id.clone()),
|
||||||
output,
|
output,
|
||||||
control.target,
|
emb.equation.value,
|
||||||
);
|
);
|
||||||
Ok((constraint, bounded_var, actuator_id))
|
Ok((constraint, bounded_var, unknown_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_saturated_control(
|
fn build_saturated_control(
|
||||||
@@ -3288,13 +3321,14 @@ fn build_saturated_control(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let factor = control.actuator.factor.trim();
|
let factor = control.actuator.factor.trim();
|
||||||
if entropyk_core::normalize_factor_name(factor).is_none()
|
if entropyk_core::normalize_factor_name(factor).is_some() {
|
||||||
&& factor != "injection"
|
|
||||||
&& factor != "opening"
|
|
||||||
{
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"unknown actuator factor '{factor}' (expected z_flow, z_dp, z_ua, z_power, z_etav, \
|
"factor '{factor}' is a Z-factor — use embeddings[], not SaturatedController"
|
||||||
injection, opening — legacy f_* and BOLT Z_* names accepted)"
|
));
|
||||||
|
}
|
||||||
|
if factor != "injection" && factor != "opening" {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown regulation actuator '{factor}' (expected opening, injection)"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4060,6 +4094,18 @@ fn create_component(
|
|||||||
.with_refrigerant(&refrigerant)
|
.with_refrigerant(&refrigerant)
|
||||||
.with_fluid_backend(Arc::clone(&backend));
|
.with_fluid_backend(Arc::clone(&backend));
|
||||||
|
|
||||||
|
// Energy-retention factor f_w: fraction of shaft work kept in the
|
||||||
|
// refrigerant. h_dis = h_suc + f_w·Δh_is/η_is.
|
||||||
|
// Default 1 = adiabatic; 0.98 ≈ 2% shell loss; 0 = all lost.
|
||||||
|
if let Some(f_w) = params.get("f_w").and_then(|v| v.as_f64()).or_else(|| {
|
||||||
|
params
|
||||||
|
.get("fw")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.or_else(|| params.get("f_q").and_then(|v| v.as_f64()))
|
||||||
|
}) {
|
||||||
|
comp = comp.with_f_w(f_w);
|
||||||
|
}
|
||||||
|
|
||||||
// Emergent-pressure mode: the compressor no longer pins the discharge
|
// Emergent-pressure mode: the compressor no longer pins the discharge
|
||||||
// pressure to P_sat(t_cond_k). Normally ṁ is closed by the volumetric
|
// pressure to P_sat(t_cond_k). Normally ṁ is closed by the volumetric
|
||||||
// displacement model. When a sibling EXV uses a *fixed* orifice opening,
|
// displacement model. When a sibling EXV uses a *fixed* orifice opening,
|
||||||
|
|||||||
@@ -150,22 +150,21 @@ fn bphx_condenser_sdt_calibration_converges_and_solves_z_ua() {
|
|||||||
"tolerance": 1e-06,
|
"tolerance": 1e-06,
|
||||||
"timeout_ms": 60000
|
"timeout_ms": 60000
|
||||||
},
|
},
|
||||||
"controls": [
|
"embeddings": [
|
||||||
{
|
{
|
||||||
"type": "SaturatedController",
|
"id": "emb_cond_z_ua",
|
||||||
"id": "sdt_calib",
|
"unknown": {
|
||||||
"measure": {
|
|
||||||
"component": "cond",
|
|
||||||
"output": "saturationTemperature"
|
|
||||||
},
|
|
||||||
"actuator": {
|
|
||||||
"component": "cond",
|
"component": "cond",
|
||||||
"factor": "z_ua",
|
"factor": "z_ua",
|
||||||
"initial": 0.3,
|
"start": 0.3,
|
||||||
"min": 0.05,
|
"min": 0.05,
|
||||||
"max": 2.0
|
"max": 2.0
|
||||||
},
|
},
|
||||||
"target": 315.0
|
"equation": {
|
||||||
|
"component": "cond",
|
||||||
|
"output": "saturationTemperature",
|
||||||
|
"value": 315.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
40
crates/cli/tests/embeddings_reject_control_z.rs
Normal file
40
crates/cli/tests/embeddings_reject_control_z.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//! Z-factors must use embeddings[], not controls[]/SaturatedController.
|
||||||
|
|
||||||
|
use entropyk_cli::run::{run_simulation, SimulationStatus};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controls_with_z_ua_are_rejected() {
|
||||||
|
// Minimal config — reject happens before component graph build.
|
||||||
|
let json = r#"
|
||||||
|
{
|
||||||
|
"fluid": "R134a",
|
||||||
|
"fluid_backend": "Test",
|
||||||
|
"circuits": [],
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"type": "SaturatedController",
|
||||||
|
"id": "bad_calib",
|
||||||
|
"measure": { "component": "sst", "output": "saturationTemperature" },
|
||||||
|
"actuator": { "component": "evap", "factor": "z_ua", "initial": 1, "min": 0.05, "max": 3 },
|
||||||
|
"target": 278.15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"solver": { "strategy": "newton", "max_iterations": 10, "tolerance": 1e-6 }
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("bad.json");
|
||||||
|
std::fs::write(&path, json).unwrap();
|
||||||
|
let result = run_simulation(&path, None, false).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(result.status, SimulationStatus::Error),
|
||||||
|
"expected Error, got {:?}",
|
||||||
|
result.status
|
||||||
|
);
|
||||||
|
let err = result.error.unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
err.contains("embeddings") && err.contains("z_ua"),
|
||||||
|
"error should point to embeddings[], got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration
|
//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration
|
||||||
//! test: a `Probe` node measuring SDT on the condenser refrigerant inlet edge
|
//! test: a `Probe` measuring SDT + free `cond/z_ua` via model `embeddings[]`
|
||||||
//! drives the plain inverse embedding for `z_ua`. The Probe is the measurement
|
//! (Modelica unknown + equation — not SaturatedController / controls[]).
|
||||||
//! source (`control.measure.component` names the Probe); the freed z-factor
|
|
||||||
//! lives on the BPHX condenser (`control.actuator.component`). The two are
|
|
||||||
//! linked 1:1 (+1 residual on Probe SDT target, +1 unknown z_ua).
|
|
||||||
//!
|
|
||||||
//! This is the Probe-based variant of `calibration_sdt.rs`. It proves the
|
|
||||||
//! end-to-end path the UI emits after the calibration redesign.
|
|
||||||
|
|
||||||
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
|
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
@@ -139,22 +133,21 @@ fn probe_based_sdt_calibration_converges_and_solves_z_ua() {
|
|||||||
"tolerance": 1e-06,
|
"tolerance": 1e-06,
|
||||||
"timeout_ms": 60000
|
"timeout_ms": 60000
|
||||||
},
|
},
|
||||||
"controls": [
|
"embeddings": [
|
||||||
{
|
{
|
||||||
"type": "SaturatedController",
|
"id": "emb_cond_z_ua",
|
||||||
"id": "probe_sdt_calib",
|
"unknown": {
|
||||||
"measure": {
|
|
||||||
"component": "cond_sdt_probe",
|
|
||||||
"output": "saturationTemperature"
|
|
||||||
},
|
|
||||||
"actuator": {
|
|
||||||
"component": "cond",
|
"component": "cond",
|
||||||
"factor": "z_ua",
|
"factor": "z_ua",
|
||||||
"initial": 0.3,
|
"start": 0.3,
|
||||||
"min": 0.05,
|
"min": 0.05,
|
||||||
"max": 2.0
|
"max": 2.0
|
||||||
},
|
},
|
||||||
"target": 315.0
|
"equation": {
|
||||||
|
"component": "cond_sdt_probe",
|
||||||
|
"output": "saturationTemperature",
|
||||||
|
"value": 315.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -185,6 +178,22 @@ fn probe_based_sdt_calibration_converges_and_solves_z_ua() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut sdt_c = None;
|
||||||
|
if let Some(state) = result.state.as_ref() {
|
||||||
|
for e in state.iter() {
|
||||||
|
if e.target.as_deref() == Some("cond") || e.source.as_deref() == Some("cond_sdt_probe")
|
||||||
|
{
|
||||||
|
if let Some(ts) = e.saturation_temperature_c {
|
||||||
|
sdt_c = Some(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sdt_k = sdt_c.expect("must read SDT near condenser inlet probe") + 273.15;
|
||||||
|
assert!(
|
||||||
|
(sdt_k - 315.0).abs() < 0.5,
|
||||||
|
"SDT must hit target 315.0 K within 0.5 K, got {sdt_k}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
solved.value > 0.05 && solved.value < 2.0,
|
solved.value > 0.05 && solved.value < 2.0,
|
||||||
"z_ua must solve within bounds, got {}",
|
"z_ua must solve within bounds, got {}",
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration
|
//! Probe Fixed Tsat on suction line + free `evap/z_ua` (model embedding).
|
||||||
//! test: a `Probe` node measuring SDT on the condenser refrigerant inlet edge
|
|
||||||
//! drives the plain inverse embedding for `z_ua`. The Probe is the measurement
|
|
||||||
//! source (`control.measure.component` names the Probe); the freed z-factor
|
|
||||||
//! lives on the BPHX condenser (`control.actuator.component`). The two are
|
|
||||||
//! linked 1:1 (+1 residual on Probe SDT target, +1 unknown z_ua).
|
|
||||||
//!
|
//!
|
||||||
//! This is the Probe-based variant of `calibration_sdt.rs`. It proves the
|
//! HVAC placement: SST lives on the suction line (`evap:outlet → probe → comp:inlet`),
|
||||||
//! end-to-end path the UI emits after the calibration redesign.
|
//! never on the two-phase EXV→evap inlet.
|
||||||
|
|
||||||
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
|
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn run_config(json: &str) -> SimulationResult {
|
fn run_config(json: &str) -> SimulationResult {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let path = dir.path().join("probe_sdt_calib.json");
|
let path = dir.path().join("probe_sst_calib.json");
|
||||||
std::fs::write(&path, json).unwrap();
|
std::fs::write(&path, json).unwrap();
|
||||||
run_simulation(&path, None, false).unwrap()
|
run_simulation(&path, None, false).unwrap()
|
||||||
}
|
}
|
||||||
@@ -22,8 +17,8 @@ fn run_config(json: &str) -> SimulationResult {
|
|||||||
fn probe_based_sst_evap_calibration() {
|
fn probe_based_sst_evap_calibration() {
|
||||||
let json = r#"
|
let json = r#"
|
||||||
{
|
{
|
||||||
"name": "Probe-based SDT calibration (R134a BPHX chiller)",
|
"name": "Probe-based SST calibration (R134a BPHX chiller)",
|
||||||
"description": "Same vapor-compression cycle as calibration_sdt.rs, but the SDT measurement lives on a Probe node spliced into the condenser refrigerant inlet edge. control.measure.component = the Probe name.",
|
"description": "Suction Probe Fixed Tsat drives evap/z_ua via model embedding.",
|
||||||
"fluid": "R134a",
|
"fluid": "R134a",
|
||||||
"fluid_backend": "CoolProp",
|
"fluid_backend": "CoolProp",
|
||||||
"circuits": [
|
"circuits": [
|
||||||
@@ -60,7 +55,7 @@ fn probe_based_sst_evap_calibration() {
|
|||||||
{
|
{
|
||||||
"type": "Probe",
|
"type": "Probe",
|
||||||
"name": "evap_sst_probe",
|
"name": "evap_sst_probe",
|
||||||
"measure": "SDT",
|
"measure": "SST",
|
||||||
"fluid": "R134a"
|
"fluid": "R134a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,7 +76,8 @@ fn probe_based_sst_evap_calibration() {
|
|||||||
"emergent_pressure": true,
|
"emergent_pressure": true,
|
||||||
"correlation": "Longo2004",
|
"correlation": "Longo2004",
|
||||||
"dp_correlation": "SimplifiedChannel",
|
"dp_correlation": "SimplifiedChannel",
|
||||||
"ua": 2000.0
|
"ua": 2000.0,
|
||||||
|
"z_ua": 1.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "BrineSource",
|
"type": "BrineSource",
|
||||||
@@ -121,12 +117,11 @@ fn probe_based_sst_evap_calibration() {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"edges": [
|
"edges": [
|
||||||
{ "from": "exv:outlet", "to": "evap_sst_probe:inlet" },
|
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||||
{ "from": "evap_sst_probe:outlet","to": "cond:inlet" },
|
|
||||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||||
{ "from": "evap:outlet", "to": "evap_sst_probe:inlet" },
|
{ "from": "evap:outlet", "to": "evap_sst_probe:inlet" },
|
||||||
{ "from": "evap_sst_probe:outlet", "to": "comp:inlet" },
|
{ "from": "evap_sst_probe:outlet","to": "comp:inlet" },
|
||||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||||
{ "from": "cond:secondary_outlet","to": "cond_water_out:inlet" },
|
{ "from": "cond:secondary_outlet","to": "cond_water_out:inlet" },
|
||||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||||
@@ -140,22 +135,21 @@ fn probe_based_sst_evap_calibration() {
|
|||||||
"tolerance": 1e-06,
|
"tolerance": 1e-06,
|
||||||
"timeout_ms": 60000
|
"timeout_ms": 60000
|
||||||
},
|
},
|
||||||
"controls": [
|
"embeddings": [
|
||||||
{
|
{
|
||||||
"type": "SaturatedController",
|
"id": "emb_evap_z_ua",
|
||||||
"id": "probe_sdt_calib",
|
"unknown": {
|
||||||
"measure": {
|
|
||||||
"component": "evap_sst_probe",
|
|
||||||
"output": "saturationTemperature"
|
|
||||||
},
|
|
||||||
"actuator": {
|
|
||||||
"component": "evap",
|
"component": "evap",
|
||||||
"factor": "z_ua",
|
"factor": "z_ua",
|
||||||
"initial": 0.3,
|
"start": 0.3,
|
||||||
"min": 0.05,
|
"min": 0.05,
|
||||||
"max": 2.0
|
"max": 2.0
|
||||||
},
|
},
|
||||||
"target": 277.55
|
"equation": {
|
||||||
|
"component": "evap_sst_probe",
|
||||||
|
"output": "saturationTemperature",
|
||||||
|
"value": 277.55
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -163,7 +157,7 @@ fn probe_based_sst_evap_calibration() {
|
|||||||
let result = run_config(json);
|
let result = run_config(json);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result.status, SimulationStatus::Converged),
|
matches!(result.status, SimulationStatus::Converged),
|
||||||
"Probe-based SDT calibration must converge: {:?} ({:?})",
|
"Probe-based SST calibration must converge: {:?} ({:?})",
|
||||||
result.status,
|
result.status,
|
||||||
result.error
|
result.error
|
||||||
);
|
);
|
||||||
@@ -171,21 +165,40 @@ fn probe_based_sst_evap_calibration() {
|
|||||||
.solved_variables
|
.solved_variables
|
||||||
.iter()
|
.iter()
|
||||||
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("evap"))
|
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("evap"))
|
||||||
.expect("solved_variables must contain cond/z_ua");
|
.expect("solved_variables must contain evap/z_ua");
|
||||||
eprintln!("DIAG z_ua résolu = {}", solved.value);
|
eprintln!("DIAG z_ua résolu = {}", solved.value);
|
||||||
eprintln!("DIAG cible SST = 277.55 K (41.85°C)");
|
eprintln!("DIAG cible SST = 277.55 K (4.4°C)");
|
||||||
|
|
||||||
|
let mut sst_c = None;
|
||||||
if let Some(state) = result.state.as_ref() {
|
if let Some(state) = result.state.as_ref() {
|
||||||
for e in state.iter() {
|
for e in state.iter() {
|
||||||
if e.target.as_deref() == Some("evap") || e.source.as_deref() == Some("evap") {
|
if e.source.as_deref() == Some("evap")
|
||||||
eprintln!("DIAG edge {}→{} P={}bar T_sat={}°C T={}°C",
|
|| e.target.as_deref() == Some("evap_sst_probe")
|
||||||
|
|| e.source.as_deref() == Some("evap_sst_probe")
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"DIAG edge {}→{} P={}bar T_sat={}°C T={}°C",
|
||||||
e.source.as_deref().unwrap_or("?"),
|
e.source.as_deref().unwrap_or("?"),
|
||||||
e.target.as_deref().unwrap_or("?"),
|
e.target.as_deref().unwrap_or("?"),
|
||||||
e.pressure_bar,
|
e.pressure_bar,
|
||||||
e.saturation_temperature_c.unwrap_or(f64::NAN),
|
e.saturation_temperature_c.unwrap_or(f64::NAN),
|
||||||
e.temperature_c.unwrap_or(f64::NAN));
|
e.temperature_c.unwrap_or(f64::NAN)
|
||||||
|
);
|
||||||
|
if e.source.as_deref() == Some("evap_sst_probe")
|
||||||
|
|| e.target.as_deref() == Some("evap_sst_probe")
|
||||||
|
{
|
||||||
|
if let Some(ts) = e.saturation_temperature_c {
|
||||||
|
sst_c = Some(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let sst_k = sst_c.expect("must read SST from suction probe edge") + 273.15;
|
||||||
|
assert!(
|
||||||
|
(sst_k - 277.55).abs() < 0.5,
|
||||||
|
"SST must hit target 277.55 K within 0.5 K, got {sst_k}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
solved.value > 0.05 && solved.value < 2.0,
|
solved.value > 0.05 && solved.value < 2.0,
|
||||||
"z_ua must solve within bounds, got {}",
|
"z_ua must solve within bounds, got {}",
|
||||||
|
|||||||
@@ -1083,6 +1083,23 @@ impl Compressor<Connected> {
|
|||||||
pub fn set_operational_state(&mut self, state: OperationalState) {
|
pub fn set_operational_state(&mut self, state: OperationalState) {
|
||||||
self.operational_state = state;
|
self.operational_state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live energy-retention factor \(f_w\): fraction of shaft work kept in the
|
||||||
|
/// refrigerant (`1` = adiabatic, `0` = all work lost to ambient).
|
||||||
|
///
|
||||||
|
/// Reads `state[calib_indices.f_w]` when free; otherwise stored calib.
|
||||||
|
/// Clamped to [0, 1].
|
||||||
|
fn live_f_w(&self, state: Option<&StateSlice>) -> f64 {
|
||||||
|
let raw = if let Some(st) = state {
|
||||||
|
self.calib_indices
|
||||||
|
.f_w
|
||||||
|
.map(|idx| st.get(idx).copied().unwrap_or(self.calib.f_w))
|
||||||
|
.unwrap_or(self.calib.f_w)
|
||||||
|
} else {
|
||||||
|
self.calib.f_w
|
||||||
|
};
|
||||||
|
raw.clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for Compressor<Connected> {
|
impl Component for Compressor<Connected> {
|
||||||
@@ -1194,9 +1211,13 @@ impl Component for Compressor<Connected> {
|
|||||||
// ṁ_calc - ṁ_state = 0
|
// ṁ_calc - ṁ_state = 0
|
||||||
residuals[0] = mass_flow_calc - mass_flow_state;
|
residuals[0] = mass_flow_calc - mass_flow_state;
|
||||||
|
|
||||||
// Residual 1: Energy balance
|
// Residual 1: Energy balance with retention factor f_w
|
||||||
// Power_calc - ṁ × (h_discharge - h_suction) / η_mech = 0
|
// (fraction of shaft work kept in the refrigerant):
|
||||||
|
// ṁ·Δh = Ẇ·f_w / η_mech ⇒ Ẇ·f_w − ṁ·Δh/η_mech = 0
|
||||||
|
// so h_dis ≈ h_suc + f_w·Ẇ/(ṁ·η_mech).
|
||||||
|
// f_w = 1 → adiabatic (default); f_w = 0 → all work lost to ambient.
|
||||||
let enthalpy_change = h_discharge - h_suction;
|
let enthalpy_change = h_discharge - h_suction;
|
||||||
|
let f_w = self.live_f_w(Some(state));
|
||||||
|
|
||||||
// Prevent division by zero
|
// Prevent division by zero
|
||||||
if self.mechanical_efficiency.abs() < 1e-10 {
|
if self.mechanical_efficiency.abs() < 1e-10 {
|
||||||
@@ -1205,7 +1226,8 @@ impl Component for Compressor<Connected> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
residuals[1] = power_calc - mass_flow_state * enthalpy_change / self.mechanical_efficiency;
|
residuals[1] =
|
||||||
|
power_calc * f_w - mass_flow_state * enthalpy_change / self.mechanical_efficiency;
|
||||||
|
|
||||||
// r2: ṁ_discharge − ṁ_suction = 0 (mass conservation, CM1.3)
|
// r2: ṁ_discharge − ṁ_suction = 0 (mass conservation, CM1.3)
|
||||||
// CM1.4: skip when same_branch_m — ṁ_dis == ṁ_suc (same state index),
|
// CM1.4: skip when same_branch_m — ṁ_dis == ṁ_suc (same state index),
|
||||||
@@ -1265,7 +1287,8 @@ impl Component for Compressor<Connected> {
|
|||||||
)?;
|
)?;
|
||||||
jacobian.add_entry(0, suc_h_idx, dr0_dh_suction);
|
jacobian.add_entry(0, suc_h_idx, dr0_dh_suction);
|
||||||
|
|
||||||
// Row 1: Energy residual r1 = power_calc − ṁ × Δh / η_mech
|
// Row 1: Energy residual r1 = power·f_w − ṁ·Δh/η_mech
|
||||||
|
let f_w = self.live_f_w(Some(state));
|
||||||
// ∂r1/∂ṁ_suction = −(h_discharge − h_suction) / η_mech
|
// ∂r1/∂ṁ_suction = −(h_discharge − h_suction) / η_mech
|
||||||
let dr1_dm = -(h_discharge - h_suction) / self.mechanical_efficiency;
|
let dr1_dm = -(h_discharge - h_suction) / self.mechanical_efficiency;
|
||||||
jacobian.add_entry(1, suc_m_idx, dr1_dm);
|
jacobian.add_entry(1, suc_m_idx, dr1_dm);
|
||||||
@@ -1280,7 +1303,7 @@ impl Component for Compressor<Connected> {
|
|||||||
Temperature::from_kelvin(t),
|
Temperature::from_kelvin(t),
|
||||||
Temperature::from_kelvin(t_discharge),
|
Temperature::from_kelvin(t_discharge),
|
||||||
None,
|
None,
|
||||||
))
|
) * f_w)
|
||||||
},
|
},
|
||||||
h_suction,
|
h_suction,
|
||||||
1.0,
|
1.0,
|
||||||
@@ -1296,7 +1319,7 @@ impl Component for Compressor<Connected> {
|
|||||||
Temperature::from_kelvin(t_suction),
|
Temperature::from_kelvin(t_suction),
|
||||||
Temperature::from_kelvin(t),
|
Temperature::from_kelvin(t),
|
||||||
None,
|
None,
|
||||||
))
|
) * f_w)
|
||||||
},
|
},
|
||||||
h_discharge,
|
h_discharge,
|
||||||
1.0,
|
1.0,
|
||||||
@@ -1326,7 +1349,18 @@ impl Component for Compressor<Connected> {
|
|||||||
Temperature::from_kelvin(t_discharge_k),
|
Temperature::from_kelvin(t_discharge_k),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
jacobian.add_entry(1, z_power_idx, p_nominal);
|
// r1 = (z_power·Ẇ_nom)·f_w − … ⇒ ∂r1/∂z_power = Ẇ_nom·f_w
|
||||||
|
jacobian.add_entry(1, z_power_idx, p_nominal * f_w);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(f_w_idx) = self.calib_indices.f_w {
|
||||||
|
let p_live = self.power_consumption_cooling(
|
||||||
|
Temperature::from_kelvin(t_suction_k),
|
||||||
|
Temperature::from_kelvin(t_discharge_k),
|
||||||
|
Some(state),
|
||||||
|
);
|
||||||
|
// r1 = Ẇ·f_w − … ⇒ ∂r1/∂f_w = +Ẇ
|
||||||
|
jacobian.add_entry(1, f_w_idx, p_live);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ∂r0/∂f_etav (AHRI 540 only): ṁ_calc = f_m · f_etav · base with
|
// ∂r0/∂f_etav (AHRI 540 only): ṁ_calc = f_m · f_etav · base with
|
||||||
|
|||||||
@@ -797,17 +797,30 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
|||||||
h_jkg: f64,
|
h_jkg: f64,
|
||||||
) -> Result<f64, ComponentError> {
|
) -> Result<f64, ComponentError> {
|
||||||
if !p_pa.is_finite() || p_pa <= 0.0 {
|
if !p_pa.is_finite() || p_pa <= 0.0 {
|
||||||
return Err(ComponentError::InvalidState(format!(
|
return Err(ComponentError::DomainViolation(DomainViolation {
|
||||||
"{} {} side has invalid pressure: {} Pa",
|
component: Some(self.name.clone()),
|
||||||
self.name, side, p_pa
|
detail: format!(
|
||||||
)));
|
"{} {} side has invalid pressure: {} Pa",
|
||||||
|
self.name, side, p_pa
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if !h_jkg.is_finite() {
|
if !h_jkg.is_finite() {
|
||||||
return Err(ComponentError::InvalidState(format!(
|
return Err(ComponentError::DomainViolation(DomainViolation {
|
||||||
"{} {} side has invalid enthalpy: {} J/kg",
|
component: Some(self.name.clone()),
|
||||||
self.name, side, h_jkg
|
detail: format!(
|
||||||
)));
|
"{} {} side has invalid enthalpy: {} J/kg",
|
||||||
|
self.name, side, h_jkg
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
// Clamp wild Newton trial enthalpies into a broad physical envelope so
|
||||||
|
// CoolProp is not queried with absurd (P,h) that yield T=inf. Exact
|
||||||
|
// result is preserved for states already inside the clamp.
|
||||||
|
const H_MIN_JKG: f64 = -5.0e5;
|
||||||
|
const H_MAX_JKG: f64 = 3.0e6;
|
||||||
|
let h_query = h_jkg.clamp(H_MIN_JKG, H_MAX_JKG);
|
||||||
|
let p_query = p_pa.clamp(1.0e3, 5.0e7);
|
||||||
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
|
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
|
||||||
ComponentError::InvalidState(format!(
|
ComponentError::InvalidState(format!(
|
||||||
"{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
|
"{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
|
||||||
@@ -819,15 +832,19 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
|||||||
FluidsFluidId::new(fluid_id),
|
FluidsFluidId::new(fluid_id),
|
||||||
property,
|
property,
|
||||||
entropyk_fluids::FluidState::PressureEnthalpy(
|
entropyk_fluids::FluidState::PressureEnthalpy(
|
||||||
Pressure::from_pascals(p_pa),
|
Pressure::from_pascals(p_query),
|
||||||
entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
|
entropyk_core::Enthalpy::from_joules_per_kg(h_query),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ComponentError::CalculationFailed(format!(
|
// Off-envelope CoolProp failures during Newton are recoverable.
|
||||||
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
|
ComponentError::DomainViolation(DomainViolation {
|
||||||
self.name, property, side, fluid_id, e
|
component: Some(self.name.clone()),
|
||||||
))
|
detail: format!(
|
||||||
|
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
|
||||||
|
self.name, property, side, fluid_id, e
|
||||||
|
),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -641,10 +641,22 @@ impl FloodedEvaporator {
|
|||||||
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
|
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live UA = UA_nominal × z_ua (reads `state[calib_indices.z_ua]` when free).
|
||||||
|
fn live_ua(&self, state: Option<&StateSlice>) -> f64 {
|
||||||
|
let z = state
|
||||||
|
.and_then(|st| {
|
||||||
|
self.inner
|
||||||
|
.calib_indices_ref()
|
||||||
|
.z_ua
|
||||||
|
.and_then(|idx| st.get(idx).copied())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| self.calib().z_ua);
|
||||||
|
self.inner.ua_nominal() * z.max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Effectiveness for a phase-changing refrigerant: `C_min = C_sec`, `C_r → 0`,
|
/// Effectiveness for a phase-changing refrigerant: `C_min = C_sec`, `C_r → 0`,
|
||||||
/// so `ε = 1 − exp(−UA / C_sec)`.
|
/// so `ε = 1 − exp(−UA / C_sec)`.
|
||||||
fn effectiveness(&self, c_sec: f64) -> f64 {
|
fn effectiveness(&self, c_sec: f64, ua: f64) -> f64 {
|
||||||
let ua = self.ua();
|
|
||||||
if c_sec <= 1e-10 || ua <= 0.0 {
|
if c_sec <= 1e-10 || ua <= 0.0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
@@ -659,9 +671,10 @@ impl FloodedEvaporator {
|
|||||||
p_in_pa: f64,
|
p_in_pa: f64,
|
||||||
t_sec_in: f64,
|
t_sec_in: f64,
|
||||||
c_sec: f64,
|
c_sec: f64,
|
||||||
|
ua: f64,
|
||||||
) -> Result<f64, ComponentError> {
|
) -> Result<f64, ComponentError> {
|
||||||
let t_evap = self.evap_temperature(p_in_pa)?;
|
let t_evap = self.evap_temperature(p_in_pa)?;
|
||||||
let eps = self.effectiveness(c_sec);
|
let eps = self.effectiveness(c_sec, ua);
|
||||||
Ok(eps * c_sec * (t_sec_in - t_evap))
|
Ok(eps * c_sec * (t_sec_in - t_evap))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +690,7 @@ impl FloodedEvaporator {
|
|||||||
"coupled_duty requires rating-mode secondary inlet temperature".into(),
|
"coupled_duty requires rating-mode secondary inlet temperature".into(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
self.coupled_duty_with(p_in_pa, t_sec_in, c_sec)
|
self.coupled_duty_with(p_in_pa, t_sec_in, c_sec, self.live_ua(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rates the evaporator at a fixed refrigerant regime (constant evaporating
|
/// Rates the evaporator at a fixed refrigerant regime (constant evaporating
|
||||||
@@ -717,7 +730,8 @@ impl FloodedEvaporator {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let t_evap = self.evap_temperature(p_in_pa)?;
|
let t_evap = self.evap_temperature(p_in_pa)?;
|
||||||
let eps = self.effectiveness(c_sec);
|
let ua = self.live_ua(None);
|
||||||
|
let eps = self.effectiveness(c_sec, ua);
|
||||||
let q = self.coupled_duty(p_in_pa)?;
|
let q = self.coupled_duty(p_in_pa)?;
|
||||||
let secondary_outlet_k = if c_sec > 1e-10 {
|
let secondary_outlet_k = if c_sec > 1e-10 {
|
||||||
t_sec_in - q / c_sec
|
t_sec_in - q / c_sec
|
||||||
@@ -1089,7 +1103,8 @@ impl Component for FloodedEvaporator {
|
|||||||
let h_in = state[inlet_h_idx];
|
let h_in = state[inlet_h_idx];
|
||||||
let h_out = state[outlet_h_idx];
|
let h_out = state[outlet_h_idx];
|
||||||
let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
|
let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
|
||||||
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec)?;
|
let ua = self.live_ua(Some(state));
|
||||||
|
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec, ua)?;
|
||||||
|
|
||||||
// System mode: C∞ zero-flow gating on both streams (staging / Newton trials).
|
// System mode: C∞ zero-flow gating on both streams (staging / Newton trials).
|
||||||
// Rating mode: secondary is a fixed boundary (C_sec > 0). Do NOT multiply Q by
|
// Rating mode: secondary is a fixed boundary (C_sec > 0). Do NOT multiply Q by
|
||||||
@@ -1188,10 +1203,10 @@ impl Component for FloodedEvaporator {
|
|||||||
let h_in = state[inlet_h_idx];
|
let h_in = state[inlet_h_idx];
|
||||||
let h_out = state[outlet_h_idx];
|
let h_out = state[outlet_h_idx];
|
||||||
let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
|
let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
|
||||||
let eps = self.effectiveness(c_sec);
|
let ua = self.live_ua(Some(state));
|
||||||
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec)?;
|
let eps = self.effectiveness(c_sec, ua);
|
||||||
|
let q = self.coupled_duty_with(p_in, t_sec_in, c_sec, ua)?;
|
||||||
let t_evap = self.evap_temperature(p_in)?;
|
let t_evap = self.evap_temperature(p_in)?;
|
||||||
let ua = self.ua();
|
|
||||||
|
|
||||||
// System mode exposes secondary edge unknowns; rating mode freezes (T,C).
|
// System mode exposes secondary edge unknowns; rating mode freezes (T,C).
|
||||||
let system = self.system_secondary_ready();
|
let system = self.system_secondary_ready();
|
||||||
@@ -1262,6 +1277,22 @@ impl Component for FloodedEvaporator {
|
|||||||
jacobian.add_entry(row, m_s, -d_qeff_dm_sec);
|
jacobian.add_entry(row, m_s, -d_qeff_dm_sec);
|
||||||
jacobian.add_entry(row, h_s, -d_qeff_dh_sec);
|
jacobian.add_entry(row, h_s, -d_qeff_dh_sec);
|
||||||
}
|
}
|
||||||
|
// Live z_ua column: UA = UA_nom·z_ua, ε = 1−e^(−UA/C), Q = ε·C·ΔT
|
||||||
|
if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
|
||||||
|
let d_eps_dua = if c_sec > 1e-12 {
|
||||||
|
(-ua / c_sec).exp() / c_sec
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let d_q_dua = d_eps_dua * c_sec * (t_sec_in - t_evap);
|
||||||
|
let alpha_r = if system {
|
||||||
|
flow_activity(m_ref, DEFAULT_M_EPS_KG_S)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let d_qeff_dz = alpha_r * alpha_s * d_q_dua * self.inner.ua_nominal();
|
||||||
|
jacobian.add_entry(row, z_ua_idx, -d_qeff_dz);
|
||||||
|
}
|
||||||
row += 1;
|
row += 1;
|
||||||
|
|
||||||
// r2 outlet closure
|
// r2 outlet closure
|
||||||
|
|||||||
@@ -210,7 +210,11 @@ pub struct IsentropicCompressor {
|
|||||||
/// Inverse-control calibration state indices. When `f_m` is `Some(i)`, the
|
/// Inverse-control calibration state indices. When `f_m` is `Some(i)`, the
|
||||||
/// volumetric mass-flow closure is scaled by the control variable at
|
/// volumetric mass-flow closure is scaled by the control variable at
|
||||||
/// `state[i]`, turning the compressor into a capacity/mass-flow actuator.
|
/// `state[i]`, turning the compressor into a capacity/mass-flow actuator.
|
||||||
|
/// When `f_w` is `Some(i)`, discharge enthalpy uses the live energy-
|
||||||
|
/// retention factor at `state[i]` (`1` = adiabatic, `0` = all lost).
|
||||||
calib_indices: CalibIndices,
|
calib_indices: CalibIndices,
|
||||||
|
/// Nominal energy-retention factor when not a free unknown. Default 1.
|
||||||
|
f_w: f64,
|
||||||
/// When `true`, a screw-compressor slide valve modulates the effective swept
|
/// When `true`, a screw-compressor slide valve modulates the effective swept
|
||||||
/// volume to hold a target suction saturated temperature (SST). The slide
|
/// volume to hold a target suction saturated temperature (SST). The slide
|
||||||
/// position `σ ∈ [σ_min, 1]` is a free actuator that scales the displacement
|
/// position `σ ∈ [σ_min, 1]` is a free actuator that scales the displacement
|
||||||
@@ -279,12 +283,19 @@ impl IsentropicCompressor {
|
|||||||
circuit_id: CircuitId::default(),
|
circuit_id: CircuitId::default(),
|
||||||
operational_state: OperationalState::default(),
|
operational_state: OperationalState::default(),
|
||||||
calib_indices: CalibIndices::default(),
|
calib_indices: CalibIndices::default(),
|
||||||
|
f_w: 1.0,
|
||||||
slide_valve: false,
|
slide_valve: false,
|
||||||
sst_target_k: None,
|
sst_target_k: None,
|
||||||
liquid_injection: false,
|
liquid_injection: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the nominal energy-retention factor `f_w` (`1` = adiabatic).
|
||||||
|
pub fn with_f_w(mut self, f_w: f64) -> Self {
|
||||||
|
self.f_w = f_w.clamp(0.0, 1.0);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Attaches a refrigerant identifier for property lookups.
|
/// Attaches a refrigerant identifier for property lookups.
|
||||||
pub fn with_refrigerant(mut self, refrigerant: &str) -> Self {
|
pub fn with_refrigerant(mut self, refrigerant: &str) -> Self {
|
||||||
self.refrigerant_id = refrigerant.to_string();
|
self.refrigerant_id = refrigerant.to_string();
|
||||||
@@ -597,6 +608,17 @@ impl IsentropicCompressor {
|
|||||||
/// solver state when this compressor is used as an actuator, or `1.0` when
|
/// solver state when this compressor is used as an actuator, or `1.0` when
|
||||||
/// no control variable is linked. Non-finite or non-positive values fall
|
/// no control variable is linked. Non-finite or non-positive values fall
|
||||||
/// back to `1.0` to keep the closure well-posed during early iterations.
|
/// back to `1.0` to keep the closure well-posed during early iterations.
|
||||||
|
fn control_f_w(&self, state: &StateSlice) -> f64 {
|
||||||
|
match self.calib_indices.f_w {
|
||||||
|
Some(idx) => state
|
||||||
|
.get(idx)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(self.f_w)
|
||||||
|
.clamp(0.0, 1.0),
|
||||||
|
None => self.f_w.clamp(0.0, 1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn control_f_m(&self, state: &StateSlice) -> f64 {
|
fn control_f_m(&self, state: &StateSlice) -> f64 {
|
||||||
match self.calib_indices.z_flow {
|
match self.calib_indices.z_flow {
|
||||||
Some(i) if i < state.len() => {
|
Some(i) if i < state.len() => {
|
||||||
@@ -642,6 +664,7 @@ impl IsentropicCompressor {
|
|||||||
p_suc_pa: f64,
|
p_suc_pa: f64,
|
||||||
h_suc_jkg: f64,
|
h_suc_jkg: f64,
|
||||||
p_dis_pa: f64,
|
p_dis_pa: f64,
|
||||||
|
state: &StateSlice,
|
||||||
) -> Result<f64, ComponentError> {
|
) -> Result<f64, ComponentError> {
|
||||||
let s_suc = backend
|
let s_suc = backend
|
||||||
.property(
|
.property(
|
||||||
@@ -660,7 +683,11 @@ impl IsentropicCompressor {
|
|||||||
FluidState::PressureEntropy(Pressure::from_pascals(p_dis_pa), Entropy(s_suc)),
|
FluidState::PressureEntropy(Pressure::from_pascals(p_dis_pa), Entropy(s_suc)),
|
||||||
)
|
)
|
||||||
.map_err(|e| ComponentError::CalculationFailed(format!("H_dis_isen: {}", e)))?;
|
.map_err(|e| ComponentError::CalculationFailed(format!("H_dis_isen: {}", e)))?;
|
||||||
Ok(h_suc_jkg + (h_dis_isen - h_suc_jkg) / self.effective_isentropic_efficiency())
|
// Retention: h_dis = h_suc + f_w·Δh_is/η_is
|
||||||
|
// f_w = 1 → adiabatic; f_w = 0 → all work lost (h_dis → h_suc).
|
||||||
|
let dh = (h_dis_isen - h_suc_jkg) / self.effective_isentropic_efficiency();
|
||||||
|
let f_w = self.control_f_w(state);
|
||||||
|
Ok(h_suc_jkg + f_w * dh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,7 +756,7 @@ impl Component for IsentropicCompressor {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let h_dis =
|
let h_dis =
|
||||||
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis)?;
|
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis, state)?;
|
||||||
residuals[0] = state[dis_h] - h_dis;
|
residuals[0] = state[dis_h] - h_dis;
|
||||||
if !self.same_branch_m {
|
if !self.same_branch_m {
|
||||||
residuals[1] = match (self.suction_m_idx, self.discharge_m_idx) {
|
residuals[1] = match (self.suction_m_idx, self.discharge_m_idx) {
|
||||||
@@ -776,7 +803,7 @@ impl Component for IsentropicCompressor {
|
|||||||
let m_calc =
|
let m_calc =
|
||||||
self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis)?;
|
self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis)?;
|
||||||
let h_dis =
|
let h_dis =
|
||||||
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis)?;
|
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis, state)?;
|
||||||
// Inverse-control actuator: scale the swept mass flow by the
|
// Inverse-control actuator: scale the swept mass flow by the
|
||||||
// linked control variable f_m (1.0 when no control is attached)
|
// linked control variable f_m (1.0 when no control is attached)
|
||||||
// and the slide-valve position σ (1.0 when no slide valve).
|
// and the slide-valve position σ (1.0 when no slide valve).
|
||||||
@@ -824,9 +851,15 @@ impl Component for IsentropicCompressor {
|
|||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
return Err(ComponentError::InvalidState(
|
// Soft domain penalty — never abort the whole system residual
|
||||||
"IsentropicCompressor displacement closure requires live physical suction and discharge states".to_string(),
|
// evaluation. A hard InvalidState here kills Picard/homotopy recovery
|
||||||
));
|
// after a singular-J Newton step wanders into P≈0 / h≈0.
|
||||||
|
residuals[0] = 1.0e6;
|
||||||
|
residuals[1] = 1.0e6;
|
||||||
|
if !self.same_branch_m && residuals.len() > 2 {
|
||||||
|
residuals[2] = 1.0e6;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let (Some(backend), Some(dis_p), Some(dis_h)) = (
|
if let (Some(backend), Some(dis_p), Some(dis_h)) = (
|
||||||
@@ -859,7 +892,7 @@ impl Component for IsentropicCompressor {
|
|||||||
p_suc,
|
p_suc,
|
||||||
h_suc,
|
h_suc,
|
||||||
p_cond_sat,
|
p_cond_sat,
|
||||||
)?
|
state)?
|
||||||
} else {
|
} else {
|
||||||
return Err(ComponentError::InvalidState(
|
return Err(ComponentError::InvalidState(
|
||||||
"IsentropicCompressor requires physical live suction pressure/enthalpy"
|
"IsentropicCompressor requires physical live suction pressure/enthalpy"
|
||||||
@@ -931,7 +964,7 @@ impl Component for IsentropicCompressor {
|
|||||||
let dph = h_suc * 1e-4 + 10.0;
|
let dph = h_suc * 1e-4 + 10.0;
|
||||||
let dpd = p_dis * 1e-4 + 100.0;
|
let dpd = p_dis * 1e-4 + 100.0;
|
||||||
let h = |ps: f64, hs: f64, pd: f64| {
|
let h = |ps: f64, hs: f64, pd: f64| {
|
||||||
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd)
|
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd, state)
|
||||||
};
|
};
|
||||||
if let (Ok(a), Ok(b)) =
|
if let (Ok(a), Ok(b)) =
|
||||||
(h(p_suc + dpp, h_suc, p_dis), h(p_suc - dpp, h_suc, p_dis))
|
(h(p_suc + dpp, h_suc, p_dis), h(p_suc - dpp, h_suc, p_dis))
|
||||||
@@ -1030,7 +1063,7 @@ impl Component for IsentropicCompressor {
|
|||||||
let inj_ready = self.injection_ready();
|
let inj_ready = self.injection_ready();
|
||||||
let hd = |ps: f64, hs: f64, pd: f64| -> Result<f64, ComponentError> {
|
let hd = |ps: f64, hs: f64, pd: f64| -> Result<f64, ComponentError> {
|
||||||
let h =
|
let h =
|
||||||
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd)?;
|
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd, state)?;
|
||||||
if inj_ready {
|
if inj_ready {
|
||||||
let h_liq = self.liquid_enthalpy(pd)?;
|
let h_liq = self.liquid_enthalpy(pd)?;
|
||||||
Ok(h - phi_inj * (h - h_liq))
|
Ok(h - phi_inj * (h - h_liq))
|
||||||
@@ -1053,6 +1086,22 @@ impl Component for IsentropicCompressor {
|
|||||||
{
|
{
|
||||||
jacobian.add_entry(1, dis_p, -(a - b) / (2.0 * dpd));
|
jacobian.add_entry(1, dis_p, -(a - b) / (2.0 * dpd));
|
||||||
}
|
}
|
||||||
|
// ∂r1/∂f_w = −dh (h_dis = h_suc + f_w·dh ⇒ r = h − h_dis)
|
||||||
|
if let Some(f_w_idx) = self.calib_indices.f_w {
|
||||||
|
if let Ok(h_full) = self.compute_h_dis_from_state(
|
||||||
|
backend.as_ref(),
|
||||||
|
fluid.clone(),
|
||||||
|
p_suc,
|
||||||
|
h_suc,
|
||||||
|
p_dis,
|
||||||
|
state,
|
||||||
|
) {
|
||||||
|
// Reconstruct dh from h_full at current f_w: h = h_suc + fw·dh
|
||||||
|
let fw = self.control_f_w(state).max(1e-9);
|
||||||
|
let dh = (h_full - h_suc) / fw;
|
||||||
|
jacobian.add_entry(1, f_w_idx, -dh);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ∂r1/∂φ_inj = +(h_dis − h_liq): the injection actuator couples
|
// ∂r1/∂φ_inj = +(h_dis − h_liq): the injection actuator couples
|
||||||
// into the energy balance so a controls[] loop has a plant to act
|
// into the energy balance so a controls[] loop has a plant to act
|
||||||
// on (higher injection ⇒ lower discharge enthalpy ⇒ lower DGT).
|
// on (higher injection ⇒ lower discharge enthalpy ⇒ lower DGT).
|
||||||
@@ -1064,7 +1113,7 @@ impl Component for IsentropicCompressor {
|
|||||||
p_suc,
|
p_suc,
|
||||||
h_suc,
|
h_suc,
|
||||||
p_dis,
|
p_dis,
|
||||||
);
|
state);
|
||||||
let h_liq = self.liquid_enthalpy(p_dis);
|
let h_liq = self.liquid_enthalpy(p_dis);
|
||||||
if let (Ok(h_dis), Ok(h_liq)) = (h_dis, h_liq) {
|
if let (Ok(h_dis), Ok(h_liq)) = (h_dis, h_liq) {
|
||||||
jacobian.add_entry(1, inj_idx, h_dis - h_liq);
|
jacobian.add_entry(1, inj_idx, h_dis - h_liq);
|
||||||
@@ -1123,14 +1172,14 @@ impl Component for IsentropicCompressor {
|
|||||||
p_suc + dp,
|
p_suc + dp,
|
||||||
h_suc,
|
h_suc,
|
||||||
p_cond_sat,
|
p_cond_sat,
|
||||||
);
|
state);
|
||||||
let hm = self.compute_h_dis_from_state(
|
let hm = self.compute_h_dis_from_state(
|
||||||
backend.as_ref(),
|
backend.as_ref(),
|
||||||
fluid.clone(),
|
fluid.clone(),
|
||||||
p_suc - dp,
|
p_suc - dp,
|
||||||
h_suc,
|
h_suc,
|
||||||
p_cond_sat,
|
p_cond_sat,
|
||||||
);
|
state);
|
||||||
if let (Ok(hp), Ok(hm)) = (hp, hm) {
|
if let (Ok(hp), Ok(hm)) = (hp, hm) {
|
||||||
jacobian.add_entry(1, suc_p, -(hp - hm) / (2.0 * dp));
|
jacobian.add_entry(1, suc_p, -(hp - hm) / (2.0 * dp));
|
||||||
}
|
}
|
||||||
@@ -1142,14 +1191,14 @@ impl Component for IsentropicCompressor {
|
|||||||
p_suc,
|
p_suc,
|
||||||
h_suc + dh,
|
h_suc + dh,
|
||||||
p_cond_sat,
|
p_cond_sat,
|
||||||
);
|
state);
|
||||||
let hm = self.compute_h_dis_from_state(
|
let hm = self.compute_h_dis_from_state(
|
||||||
backend.as_ref(),
|
backend.as_ref(),
|
||||||
fluid,
|
fluid,
|
||||||
p_suc,
|
p_suc,
|
||||||
h_suc - dh,
|
h_suc - dh,
|
||||||
p_cond_sat,
|
p_cond_sat,
|
||||||
);
|
state);
|
||||||
if let (Ok(hp), Ok(hm)) = (hp, hm) {
|
if let (Ok(hp), Ok(hm)) = (hp, hm) {
|
||||||
jacobian.add_entry(1, suc_h, -(hp - hm) / (2.0 * dh));
|
jacobian.add_entry(1, suc_h, -(hp - hm) / (2.0 * dh));
|
||||||
}
|
}
|
||||||
@@ -1259,7 +1308,7 @@ impl Component for IsentropicCompressor {
|
|||||||
state[sp],
|
state[sp],
|
||||||
h_suc,
|
h_suc,
|
||||||
state[dp],
|
state[dp],
|
||||||
)
|
state)
|
||||||
.unwrap_or(h_dis)
|
.unwrap_or(h_dis)
|
||||||
}
|
}
|
||||||
_ => h_dis,
|
_ => h_dis,
|
||||||
|
|||||||
@@ -10,19 +10,25 @@
|
|||||||
//!
|
//!
|
||||||
//! | Entropyk field | Legacy `f_*` | BOLT equivalent | Effect |
|
//! | Entropyk field | Legacy `f_*` | BOLT equivalent | Effect |
|
||||||
//! |---|---|---|---|
|
//! |---|---|---|---|
|
||||||
//! | `z_flow` | `f_m` | `Z_flow_suc` | ṁ_suc,eff = z_flow × ṁ_suc,nominal |
|
//! | `z_flow` | `f_m` | `Z_flow_suc` | ṁ_suc,eff = z_flow × ṁ_suc,nominal (machine **capacity**) |
|
||||||
//! | `z_flow_eco` | — | `Z_flow_eco` | ṁ_eco,eff = z_flow_eco × ṁ_eco,nominal |
|
//! | `z_flow_eco` | — | `Z_flow_eco` | ṁ_eco,eff = z_flow_eco × ṁ_eco,nominal |
|
||||||
//! | `z_dp` | `f_dp` | `Z_dpc`, `Z_dp_ref` | ΔP_eff = z_dp × ΔP_nominal |
|
//! | `z_dp` | `f_dp` | `Z_dpc`, `Z_dp_ref` | ΔP_eff = z_dp × ΔP_nominal |
|
||||||
//! | `z_ua` | `f_ua` | `Z_UA`, `Z_Ucd`, `Z_Uev` | UA_eff = z_ua × UA_nominal |
|
//! | `z_ua` | `f_ua` | `Z_UA`, `Z_Ucd`, `Z_Uev` | UA_eff = z_ua × UA_nominal |
|
||||||
//! | `z_power` | `f_power` | `Z_power` | Ẇ_eff = z_power × Ẇ_nominal |
|
//! | `z_power` | `f_power` | `Z_power` | Ẇ_eff = z_power × Ẇ_nominal |
|
||||||
//! | `z_etav` | `f_etav` | — (η_v correction) | η_v,eff = z_etav × η_v,nominal |
|
//! | `z_etav` | `f_etav` | — (η_v correction) | η_v,eff = z_etav × η_v,nominal |
|
||||||
|
//! | `f_w` | `f_w` | energy retention | \(h_{dis}=h_{suc}+f_w\cdot\dot W/\dot m\) (**DGT**) |
|
||||||
|
//!
|
||||||
|
//! `f_w` is the fraction of compressor work **retained** in the refrigerant
|
||||||
|
//! (not a loss fraction): `f_w = 1` → adiabatic (no shell loss);
|
||||||
|
//! `f_w = 0.98` → ~2% lost to ambient; `f_w = 0` → all work lost (Δh → 0).
|
||||||
//!
|
//!
|
||||||
//! ## Recommended calibration order
|
//! ## Recommended calibration order
|
||||||
//!
|
//!
|
||||||
//! 1. **z_flow** — mass flow (compressor power + ṁ measurements)
|
//! 1. **z_flow** — machine capacity via ṁ (pair with Capacity probe)
|
||||||
//! 2. **z_dp** — pressure drops (inlet/outlet pressures)
|
//! 2. **f_w** — compressor energy retention / shell loss (pair with discharge T / DGT)
|
||||||
//! 3. **z_ua** — heat transfer (superheat, subcooling, capacity)
|
//! 3. **z_dp** — pressure drops (pair with P)
|
||||||
//! 4. **z_power** — compressor power (if z_flow insufficient)
|
//! 4. **z_ua** — heat transfer (pair with Tsat / Tsh)
|
||||||
|
//! 5. **z_power** — compressor power map (if needed)
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -42,11 +48,17 @@ pub const Z_UA: &str = "z_ua";
|
|||||||
pub const Z_POWER: &str = "z_power";
|
pub const Z_POWER: &str = "z_power";
|
||||||
/// Canonical name for the volumetric-efficiency Z-factor (`z_etav`).
|
/// Canonical name for the volumetric-efficiency Z-factor (`z_etav`).
|
||||||
pub const Z_ETAV: &str = "z_etav";
|
pub const Z_ETAV: &str = "z_etav";
|
||||||
|
/// Canonical name for the compressor energy-retention factor (`f_w`).
|
||||||
|
///
|
||||||
|
/// Fraction of shaft work delivered to the refrigerant enthalpy rise.
|
||||||
|
/// `1.0` = adiabatic; `0.0` = all work lost to ambient.
|
||||||
|
pub const F_W: &str = "f_w";
|
||||||
|
|
||||||
/// Normalizes a user/config factor string to a canonical Z-factor name.
|
/// Normalizes a user/config factor string to a canonical Z-factor name.
|
||||||
///
|
///
|
||||||
/// Accepts legacy `f_*` names, canonical `z_*` names, and common BOLT spellings
|
/// Accepts legacy `f_*` names, canonical `z_*` names, and common BOLT spellings
|
||||||
/// (`Z_power`, `Z_flow_suc`, `Z_Ucd`, …).
|
/// (`Z_power`, `Z_flow_suc`, `Z_Ucd`, …). Also accepts heat-loss aliases
|
||||||
|
/// (`f_w`, `fw`, `f_q`, `z_fw`).
|
||||||
pub fn normalize_factor_name(factor: &str) -> Option<&'static str> {
|
pub fn normalize_factor_name(factor: &str) -> Option<&'static str> {
|
||||||
match factor.trim().to_ascii_lowercase().replace('_', "").as_str() {
|
match factor.trim().to_ascii_lowercase().replace('_', "").as_str() {
|
||||||
"fm" | "zflow" | "zflowsuc" => Some(Z_FLOW),
|
"fm" | "zflow" | "zflowsuc" => Some(Z_FLOW),
|
||||||
@@ -55,6 +67,7 @@ pub fn normalize_factor_name(factor: &str) -> Option<&'static str> {
|
|||||||
"fua" | "zua" | "zucd" | "zuev" => Some(Z_UA),
|
"fua" | "zua" | "zucd" | "zuev" => Some(Z_UA),
|
||||||
"fpower" | "zpower" => Some(Z_POWER),
|
"fpower" | "zpower" => Some(Z_POWER),
|
||||||
"fetav" | "zetav" => Some(Z_ETAV),
|
"fetav" | "zetav" => Some(Z_ETAV),
|
||||||
|
"fw" | "fq" | "zfw" | "heatloss" => Some(F_W),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +87,9 @@ pub fn id_ends_with_calib_suffix(id: &str) -> Option<&'static str> {
|
|||||||
"z_uev",
|
"z_uev",
|
||||||
"z_dpc",
|
"z_dpc",
|
||||||
"z_dp",
|
"z_dp",
|
||||||
|
"f_w",
|
||||||
|
"f_q",
|
||||||
|
"z_fw",
|
||||||
"f_m",
|
"f_m",
|
||||||
"f_dp",
|
"f_dp",
|
||||||
"f_ua",
|
"f_ua",
|
||||||
@@ -146,6 +162,22 @@ pub struct Calib {
|
|||||||
/// z_etav: η_v,eff = z_etav × η_v,nominal (compressor displacement correction)
|
/// z_etav: η_v,eff = z_etav × η_v,nominal (compressor displacement correction)
|
||||||
#[serde(default = "one", rename = "z_etav", alias = "zEtav", alias = "f_etav")]
|
#[serde(default = "one", rename = "z_etav", alias = "zEtav", alias = "f_etav")]
|
||||||
pub z_etav: f64,
|
pub z_etav: f64,
|
||||||
|
/// Compressor energy-retention factor: fraction of shaft work kept in the
|
||||||
|
/// refrigerant (\(h_{dis}=h_{suc}+f_w\cdot\dot W/\dot m\)).
|
||||||
|
///
|
||||||
|
/// - `1.0` — adiabatic (nothing lost to ambient) — **default**
|
||||||
|
/// - `0.98` — ~2% shell loss
|
||||||
|
/// - `0.0` — all work lost (no discharge enthalpy rise)
|
||||||
|
#[serde(
|
||||||
|
default = "one",
|
||||||
|
rename = "f_w",
|
||||||
|
alias = "fw",
|
||||||
|
alias = "f_q",
|
||||||
|
alias = "fQ",
|
||||||
|
alias = "z_fw",
|
||||||
|
alias = "heat_loss"
|
||||||
|
)]
|
||||||
|
pub f_w: f64,
|
||||||
/// Traceability: identifier or hash of the test data used to derive these factors.
|
/// Traceability: identifier or hash of the test data used to derive these factors.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub calibration_source: Option<String>,
|
pub calibration_source: Option<String>,
|
||||||
@@ -160,6 +192,7 @@ impl Default for Calib {
|
|||||||
z_ua: 1.0,
|
z_ua: 1.0,
|
||||||
z_power: 1.0,
|
z_power: 1.0,
|
||||||
z_etav: 1.0,
|
z_etav: 1.0,
|
||||||
|
f_w: 1.0,
|
||||||
calibration_source: None,
|
calibration_source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +216,8 @@ pub struct CalibIndices {
|
|||||||
pub z_power: Option<usize>,
|
pub z_power: Option<usize>,
|
||||||
/// State index for z_etav multiplier
|
/// State index for z_etav multiplier
|
||||||
pub z_etav: Option<usize>,
|
pub z_etav: Option<usize>,
|
||||||
|
/// State index for compressor energy-retention factor `f_w`
|
||||||
|
pub f_w: Option<usize>,
|
||||||
/// State index for a *physical actuator* free variable (dimensioned, not a
|
/// State index for a *physical actuator* free variable (dimensioned, not a
|
||||||
/// multiplier). Interpreted per component: EXV/orifice opening [0..1], fan
|
/// multiplier). Interpreted per component: EXV/orifice opening [0..1], fan
|
||||||
/// speed ratio, screw slide position, injection-valve opening, condenser
|
/// speed ratio, screw slide position, injection-valve opening, condenser
|
||||||
@@ -201,18 +236,25 @@ pub struct CalibValidationError {
|
|||||||
|
|
||||||
impl std::fmt::Display for CalibValidationError {
|
impl std::fmt::Display for CalibValidationError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let range = if self.factor == F_W {
|
||||||
|
"[0.0, 1.0]"
|
||||||
|
} else {
|
||||||
|
"[0.2, 3.0]"
|
||||||
|
};
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
"calib {} = {} is outside allowed range [0.5, 2.0]",
|
"calib {} = {} is outside allowed range {}",
|
||||||
self.factor, self.value
|
self.factor, self.value, range
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for CalibValidationError {}
|
impl std::error::Error for CalibValidationError {}
|
||||||
|
|
||||||
const MIN_Z: f64 = 0.5;
|
const MIN_Z: f64 = 0.2;
|
||||||
const MAX_Z: f64 = 2.0;
|
const MAX_Z: f64 = 3.0;
|
||||||
|
const MIN_F_W: f64 = 0.0;
|
||||||
|
const MAX_F_W: f64 = 1.0;
|
||||||
|
|
||||||
impl Calib {
|
impl Calib {
|
||||||
/// Updates a single factor by name (accepts canonical `z_*`, legacy `f_*`, BOLT `Z_*`).
|
/// Updates a single factor by name (accepts canonical `z_*`, legacy `f_*`, BOLT `Z_*`).
|
||||||
@@ -242,14 +284,18 @@ impl Calib {
|
|||||||
self.z_etav = value;
|
self.z_etav = value;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
Some(F_W) => {
|
||||||
|
self.f_w = value;
|
||||||
|
true
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates that all factors lie in [0.5, 2.0]. Returns `Ok(())` or the first invalid factor.
|
/// Validates multiplicative Z-factors in [0.2, 3.0] and `f_w` in [0, 1].
|
||||||
pub fn validate(&self) -> Result<(), CalibValidationError> {
|
pub fn validate(&self) -> Result<(), CalibValidationError> {
|
||||||
let check = |name: &'static str, value: f64| {
|
let check = |name: &'static str, value: f64, min: f64, max: f64| {
|
||||||
if !(MIN_Z..=MAX_Z).contains(&value) {
|
if !(min..=max).contains(&value) {
|
||||||
Err(CalibValidationError {
|
Err(CalibValidationError {
|
||||||
factor: name,
|
factor: name,
|
||||||
value,
|
value,
|
||||||
@@ -258,12 +304,13 @@ impl Calib {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
check(Z_FLOW, self.z_flow)?;
|
check(Z_FLOW, self.z_flow, MIN_Z, MAX_Z)?;
|
||||||
check(Z_FLOW_ECO, self.z_flow_eco)?;
|
check(Z_FLOW_ECO, self.z_flow_eco, MIN_Z, MAX_Z)?;
|
||||||
check(Z_DP, self.z_dp)?;
|
check(Z_DP, self.z_dp, MIN_Z, MAX_Z)?;
|
||||||
check(Z_UA, self.z_ua)?;
|
check(Z_UA, self.z_ua, MIN_Z, MAX_Z)?;
|
||||||
check(Z_POWER, self.z_power)?;
|
check(Z_POWER, self.z_power, MIN_Z, MAX_Z)?;
|
||||||
check(Z_ETAV, self.z_etav)?;
|
check(Z_ETAV, self.z_etav, MIN_Z, MAX_Z)?;
|
||||||
|
check(F_W, self.f_w, MIN_F_W, MAX_F_W)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,6 +328,7 @@ mod tests {
|
|||||||
assert_eq!(c.z_ua, 1.0);
|
assert_eq!(c.z_ua, 1.0);
|
||||||
assert_eq!(c.z_power, 1.0);
|
assert_eq!(c.z_power, 1.0);
|
||||||
assert_eq!(c.z_etav, 1.0);
|
assert_eq!(c.z_etav, 1.0);
|
||||||
|
assert_eq!(c.f_w, 1.0); // retention: 1 = adiabatic
|
||||||
assert!(c.validate().is_ok());
|
assert!(c.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,23 +341,31 @@ mod tests {
|
|||||||
z_ua: 2.0,
|
z_ua: 2.0,
|
||||||
z_power: 1.0,
|
z_power: 1.0,
|
||||||
z_etav: 1.0,
|
z_etav: 1.0,
|
||||||
|
f_w: 0.98, // ~2% shell loss
|
||||||
calibration_source: None,
|
calibration_source: None,
|
||||||
};
|
};
|
||||||
assert!(ok.validate().is_ok());
|
assert!(ok.validate().is_ok());
|
||||||
|
|
||||||
let bad_m = Calib {
|
let bad_m = Calib {
|
||||||
z_flow: 0.4,
|
z_flow: 0.1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let err = bad_m.validate().unwrap_err();
|
let err = bad_m.validate().unwrap_err();
|
||||||
assert_eq!(err.factor, Z_FLOW);
|
assert_eq!(err.factor, Z_FLOW);
|
||||||
|
|
||||||
let bad_high = Calib {
|
let bad_high = Calib {
|
||||||
z_ua: 2.1,
|
z_ua: 3.1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let err2 = bad_high.validate().unwrap_err();
|
let err2 = bad_high.validate().unwrap_err();
|
||||||
assert_eq!(err2.factor, Z_UA);
|
assert_eq!(err2.factor, Z_UA);
|
||||||
|
|
||||||
|
let bad_fw = Calib {
|
||||||
|
f_w: 1.1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err3 = bad_fw.validate().unwrap_err();
|
||||||
|
assert_eq!(err3.factor, F_W);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -321,6 +377,7 @@ mod tests {
|
|||||||
z_ua: 1.0,
|
z_ua: 1.0,
|
||||||
z_power: 1.05,
|
z_power: 1.05,
|
||||||
z_etav: 1.0,
|
z_etav: 1.0,
|
||||||
|
f_w: 0.98,
|
||||||
calibration_source: None,
|
calibration_source: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&c).unwrap();
|
let json = serde_json::to_string(&c).unwrap();
|
||||||
@@ -361,6 +418,8 @@ mod tests {
|
|||||||
assert_eq!(normalize_factor_name("Z_flow_eco"), Some(Z_FLOW_ECO));
|
assert_eq!(normalize_factor_name("Z_flow_eco"), Some(Z_FLOW_ECO));
|
||||||
assert_eq!(normalize_factor_name("Z_power"), Some(Z_POWER));
|
assert_eq!(normalize_factor_name("Z_power"), Some(Z_POWER));
|
||||||
assert_eq!(normalize_factor_name("Z_Ucd"), Some(Z_UA));
|
assert_eq!(normalize_factor_name("Z_Ucd"), Some(Z_UA));
|
||||||
|
assert_eq!(normalize_factor_name("f_w"), Some(F_W));
|
||||||
|
assert_eq!(normalize_factor_name("f_q"), Some(F_W));
|
||||||
assert_eq!(normalize_factor_name("unknown"), None);
|
assert_eq!(normalize_factor_name("unknown"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ pub use types::{
|
|||||||
|
|
||||||
// Re-export calibration types
|
// Re-export calibration types
|
||||||
pub use calib::{
|
pub use calib::{
|
||||||
id_ends_with_calib_suffix, normalize_factor_name, Calib, CalibIndices, CalibValidationError,
|
id_ends_with_calib_suffix, normalize_factor_name, Calib, CalibIndices, CalibValidationError, F_W,
|
||||||
Z_DP, Z_ETAV, Z_FLOW, Z_FLOW_ECO, Z_POWER, Z_UA,
|
Z_DP, Z_ETAV, Z_FLOW, Z_FLOW_ECO, Z_POWER, Z_UA,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -526,19 +526,18 @@ pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calibration factors (z_ua, z_dp, z_flow, z_flow_eco, z_power, z_etav)
|
// Calibration factors promoted to free unknowns via control loops.
|
||||||
// promoted to free unknowns via control loops. Each `Some(idx)` slot means
|
// Each `Some(idx)` slot means the solver computed a value at `state[idx]`.
|
||||||
// the solver computed a value for that factor at `state[idx]`. The generic
|
// The generic `actuator` slot is already covered by the bounded-variable path.
|
||||||
// `actuator` slot (physical opening / fan speed) is already covered by the
|
|
||||||
// bounded-variable path above, so we only surface the six named Z-factors.
|
|
||||||
for (name, indices) in system.calib_indices_by_name() {
|
for (name, indices) in system.calib_indices_by_name() {
|
||||||
for (factor, slot) in [
|
for (factor, slot, min, max) in [
|
||||||
("z_flow", indices.z_flow),
|
("z_flow", indices.z_flow, 0.5, 2.0),
|
||||||
("z_flow_eco", indices.z_flow_eco),
|
("z_flow_eco", indices.z_flow_eco, 0.5, 2.0),
|
||||||
("z_dp", indices.z_dp),
|
("z_dp", indices.z_dp, 0.5, 2.0),
|
||||||
("z_ua", indices.z_ua),
|
("z_ua", indices.z_ua, 0.5, 2.0),
|
||||||
("z_power", indices.z_power),
|
("z_power", indices.z_power, 0.5, 2.0),
|
||||||
("z_etav", indices.z_etav),
|
("z_etav", indices.z_etav, 0.5, 2.0),
|
||||||
|
("f_w", indices.f_w, 0.0, 1.0),
|
||||||
] {
|
] {
|
||||||
if let Some(idx) = slot {
|
if let Some(idx) = slot {
|
||||||
if idx < state.len() {
|
if idx < state.len() {
|
||||||
@@ -552,10 +551,8 @@ pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVar
|
|||||||
component: Some(name.clone()),
|
component: Some(name.clone()),
|
||||||
variable: factor.to_string(),
|
variable: factor.to_string(),
|
||||||
value: state[idx],
|
value: state[idx],
|
||||||
// Calibration factors are bounded to [0.5, 2.0]
|
min,
|
||||||
// (see entropyk_core::CalibValidationError).
|
max,
|
||||||
min: 0.5,
|
|
||||||
max: 2.0,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -621,6 +621,7 @@ impl System {
|
|||||||
entropyk_core::Z_UA => indices.z_ua = Some(state_idx),
|
entropyk_core::Z_UA => indices.z_ua = Some(state_idx),
|
||||||
entropyk_core::Z_POWER => indices.z_power = Some(state_idx),
|
entropyk_core::Z_POWER => indices.z_power = Some(state_idx),
|
||||||
entropyk_core::Z_ETAV => indices.z_etav = Some(state_idx),
|
entropyk_core::Z_ETAV => indices.z_etav = Some(state_idx),
|
||||||
|
entropyk_core::F_W => indices.f_w = Some(state_idx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +646,7 @@ impl System {
|
|||||||
entropyk_core::Z_UA => indices.z_ua = Some(state_idx),
|
entropyk_core::Z_UA => indices.z_ua = Some(state_idx),
|
||||||
entropyk_core::Z_POWER => indices.z_power = Some(state_idx),
|
entropyk_core::Z_POWER => indices.z_power = Some(state_idx),
|
||||||
entropyk_core::Z_ETAV => indices.z_etav = Some(state_idx),
|
entropyk_core::Z_ETAV => indices.z_etav = Some(state_idx),
|
||||||
|
entropyk_core::F_W => indices.f_w = Some(state_idx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else if id_str.ends_with("injection") || id_str.ends_with("actuator") {
|
} else if id_str.ends_with("injection") || id_str.ends_with("actuator") {
|
||||||
|
|||||||
Reference in New Issue
Block a user