diff --git a/.entropyk/modules/calibration.ekmod b/.entropyk/modules/calibration.ekmod index a1264cb..a210ed5 100644 --- a/.entropyk/modules/calibration.ekmod +++ b/.entropyk/modules/calibration.ekmod @@ -24,10 +24,8 @@ "flipV": false, "name": "cond", "params": { - "__fixed_calib_sdt_c": false, "__fixed_z_dp": true, "__fixed_z_ua": true, - "calib_sdt_c": 40, "channel_spacing_mm": 1.5, "chevron_angle_deg": 60, "correlation": "Longo2004", @@ -43,7 +41,6 @@ "refrigerant": "R134a", "secondary_fluid": "Water", "target_subcooling_k": 5, - "ua": 2500, "z_dp": 1, "z_ua": 1 }, @@ -70,10 +67,8 @@ "flipV": false, "name": "evap", "params": { - "__fixed_calib_sst_c": false, "__fixed_z_dp": true, "__fixed_z_ua": false, - "calib_sst_c": 5, "channel_spacing_mm": 1.5, "chevron_angle_deg": 60, "correlation": "Longo2004", @@ -89,7 +84,6 @@ "refrigerant": "R134a", "secondary_fluid": "Water", "target_superheat_k": 5, - "ua": 2000, "z_dp": 1, "z_ua": 1 }, @@ -166,10 +160,9 @@ "flipV": false, "name": "SST probe", "params": { - "__fixed_target": true, + "__fixed_tsat_c": true, "fluid": "R134a", - "measure": "SST", - "target": 5.9 + "tsat_c": 4.4 }, "rotation": 0, "type": "Probe" diff --git a/apps/web/src/components/panels/PropertiesPanel.tsx b/apps/web/src/components/panels/PropertiesPanel.tsx index e882b6a..1640bc5 100644 --- a/apps/web/src/components/panels/PropertiesPanel.tsx +++ b/apps/web/src/components/panels/PropertiesPanel.tsx @@ -177,21 +177,18 @@ export default function PropertiesPanel() { Modelica / Dymola.

-

Rappel rapide

+

DoF Modelica

@@ -551,15 +548,15 @@ export default function PropertiesPanel() { {activeTab === "Calibration" && (

- Calibration : pose une Probe (sonde) sur la - ligne à mesurer, coche Fixed sur sa cible, puis décoche Fixed sur le facteur - (Z_UA) ici. La valeur résolue s’affiche ci-dessous après simulation. + Modelica : Probe Fixed → équation ( + Tsat = …). Facteur Free → inconnue ( + parameter z_ua(fixed=false)). Le start est la valeur affichée.

- Tu n’as pas besoin du nœud « Regulation loop » pour ça. + Laisse UA override vide. n_eq doit égaler n_unk.

{solvedVars.length > 0 && ( - + )}
)} diff --git a/apps/web/src/lib/componentMeta.ts b/apps/web/src/lib/componentMeta.ts index abe05eb..fa61c28 100644 --- a/apps/web/src/lib/componentMeta.ts +++ b/apps/web/src/lib/componentMeta.ts @@ -28,24 +28,24 @@ export interface ParamMeta { options?: Array<{ value: string | number; label: string }>; advanced?: boolean; /** - * Dymola/EES-style Fixed checkbox. - * Fixed ON = value imposed (parameter or measured target). - * Fixed OFF = free for the solver (calibration tuner / free unknown). + * Modelica `fixed` attribute (Dymola-style checkbox). + * Fixed ON = `parameter` / binding equation (known). + * Fixed OFF = unknown — needs another equation to keep the model balanced. */ fixable?: boolean; /** Default for the Fixed checkbox when `fixable` (true = Fixed). */ defaultFixed?: boolean; /** - * When Fixed ON: use this param value as a control setpoint for the named - * measure output (e.g. `saturationTemperature`, `superheat`). + * When Fixed ON on a Probe: adds equation `output = value` + * (e.g. Tsat = 5 °C). Internal wire name for the solver output. */ measureOutput?: string; /** - * When Fixed OFF: free this actuator/calibration factor (e.g. `z_ua`). - * Requires a Fixed measure on the same component to stay DoF-balanced. + * When Fixed OFF: this factor becomes a Newton unknown (Modelica + * `parameter ...(fixed=false)`). Pair with a Fixed Probe equation. */ actuatorFactor?: string; - /** Bounds used when the factor is free (actuator min/max). */ + /** Bounds / start guess when the factor is an unknown. */ freeMin?: number; freeMax?: number; /** @@ -220,41 +220,20 @@ export const COMPONENTS: ComponentMeta[] = [ label: "Probe (sonde)", category: "Instrumentation", description: - "Sonde de mesure posée sur une ligne. Lit P/T/SH/SC/SST/SDT/DGT/DSH/capacité/ṁ/h à cet endroit. Toute calibration impose sa cible sur un Probe.", + "Sonde sur une ligne : fige T, Tsat, P, X ou Tsh (= T−Tsat, SC négatif) pour calibrer.", help: "Sonde de calibration (Probe)\n" + - "Toute calibration se fait via un Probe posé sur une ligne (règle dure).\n\n" + - "1. Glisse le Probe sur un fil du schéma (comme pour insérer un Pipe).\n" + - "2. Choisis la grandeur mesurée (SST, SDT, DGT, DSH, SH, SC, T, P, débit, capacité, enthalpie) et le fluide.\n" + - "3. Coche Fixed sur « Cible » et tape la valeur mesurée.\n" + - "4. Libère le Z-factor correspondant (Z_UA, Z_dP, Z_flow…) sur le composant à calibrer.\n" + - "Le solveur appaire automatiquement le Probe (mesure) et le Z-factor libre (inconnue).", + "1. Glisse le Probe sur un fil.\n" + + "2. Coche Fixed sur les grandeurs physiques mesurées (T, Tsat, P, X, Tsh).\n" + + "3. Libère le facteur correspondant sur le composant :\n" + + " • Tsat / Tsh → z_ua (HX) ou opening (EXV)\n" + + " • T (DGT refoulement) → f_w (rétention énergie carter)\n" + + " • Capacity → z_flow (capacité machine)\n" + + " • P → z_dp\n" + + "Tsh = T − Tsat (surchauffe > 0, sous-refroidissement < 0).", ports: ["inlet", "outlet"], color: "#0ea5e9", params: [ - { - key: "measure", - label: "Grandeur mesurée", - kind: "string", - default: "SH", - required: true, - section: "Mesure", - description: - "Grandeur physique mesurée à l'emplacement du Probe. SST/SDT = Tsat(P), SH/DSH = T−Tsat(P), SC = Tsat(P)−T.", - options: [ - { value: "SST", label: "SST (Tsat aspiration)" }, - { value: "SDT", label: "SDT (Tsat refoulement)" }, - { value: "DGT", label: "DGT (T° gaz refoulement)" }, - { value: "DSH", label: "DSH (surch. refoulement)" }, - { value: "SH", label: "SH (surchauffe)" }, - { value: "SC", label: "SC (sous-refroid.)" }, - { value: "T", label: "T (température)" }, - { value: "P", label: "P (pression)" }, - { value: "MassFlow", label: "Débit massique" }, - { value: "Capacity", label: "Capacité (kW)" }, - { value: "Enthalpy", label: "Enthalpie" }, - ], - }, { key: "fluid", label: "Fluide", @@ -262,22 +241,81 @@ export const COMPONENTS: ComponentMeta[] = [ default: "R134a", required: true, section: "Mesure", - description: "Fluide frigorigène pour le calcul de Tsat/SH/SC (via CoolProp).", + description: "Fluide pour Tsat / Tsh / X (CoolProp).", }, { - key: "target", - label: "Cible (Fixed = impose)", + key: "t_c", + label: "T (température)", kind: "number", - section: "Calibration", + unit: "°C", + default: 50, + section: "Paramètres physiques", fixable: true, defaultFixed: false, - // Auto: resolved from `measure` at config-build time - // (SST/SDT → saturationTemperature, SH/DSH → superheat, SC → subcooling, - // DGT/T → temperature, P → pressure, MassFlow → massFlowRate, - // Capacity → capacity, Enthalpy → heatTransferRate as carrier). - measureOutput: "auto", + measureOutput: "temperature", + description: "Température de ligne. Sur refoulement = DGT → pairer avec f_w.", + }, + { + key: "tsat_c", + label: "Tsat", + kind: "number", + unit: "°C", + default: 5, + section: "Paramètres physiques", + fixable: true, + defaultFixed: false, + measureOutput: "saturationTemperature", + description: "Tsat(P) à la sonde. Aspiration = SST, liquide = SDT → z_ua.", + }, + { + key: "p_bar", + label: "P", + kind: "number", + unit: "bar", + default: 5, + section: "Paramètres physiques", + fixable: true, + defaultFixed: false, + measureOutput: "pressure", + description: "Pression absolue → pairer avec z_dp.", + }, + { + key: "x", + label: "X (titre)", + kind: "number", + default: 1, + min: 0, + max: 1, + section: "Paramètres physiques", + fixable: true, + defaultFixed: false, + measureOutput: "quality", + description: "Titre vapeur [0–1]. Usage avancé.", + }, + { + key: "tsh_k", + label: "Tsh (= T − Tsat)", + kind: "number", + unit: "K", + default: 5, + section: "Paramètres physiques", + fixable: true, + defaultFixed: false, + measureOutput: "superheat", description: - "Coche Fixed et tape la valeur mesurée (K pour températures, K pour SH/SC, Pa pour P, kg/s pour débit, W pour capacité, J/kg pour h).", + "Écart à la saturation : >0 surchauffe, <0 sous-refroidissement. Remplace SH/SC sur l’échangeur.", + }, + { + key: "capacity_w", + label: "Capacité", + kind: "number", + unit: "W", + default: 10000, + section: "Paramètres physiques", + fixable: true, + defaultFixed: false, + measureOutput: "capacity", + description: "Capacité (bilan secondaire). Pairer avec z_flow du compresseur.", }, ], }, @@ -475,6 +513,36 @@ export const COMPONENTS: ComponentMeta[] = [ default: 5.0, section: "Init / design", }, + { + key: "z_flow", + label: "z_flow (capacité)", + kind: "number", + default: 1.0, + section: "Calibration", + fixable: true, + defaultFixed: true, + actuatorFactor: "z_flow", + freeMin: 0.5, + freeMax: 2.0, + description: + "Multiplicateur de débit / capacité machine. Fixed OFF + Probe Capacity Fixed.", + }, + { + key: "f_w", + label: "f_w (rétention énergie)", + kind: "number", + default: 1.0, + min: 0, + max: 1, + section: "Calibration", + fixable: true, + defaultFixed: true, + actuatorFactor: "f_w", + freeMin: 0.0, + freeMax: 1.0, + description: + "Fraction du travail conservée dans le fluide : 1 = adiabatique, 0.98 ≈ 2 % perdu, 0 = tout perdu. Fixed OFF + Probe T (DGT) sur refoulement.", + }, ], }, { @@ -819,7 +887,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_ua", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, description: "Fixed = valeur figée. Décocher Fixed = libre (calibration).", }, @@ -834,7 +902,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_dp", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, }, ], @@ -870,7 +938,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_ua", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, description: "Fixed = figé. Décocher Fixed = libre pour le solveur.", }, @@ -885,7 +953,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_dp", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, }, { @@ -1145,7 +1213,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_ua", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, }, { @@ -1158,7 +1226,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_dp", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, }, ], @@ -1234,7 +1302,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_ua", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, }, { @@ -1247,7 +1315,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_dp", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, }, ], @@ -1303,7 +1371,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_ua", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, description: "Fixed = figé. Décocher Fixed = le solveur ajuste Z_UA.", }, @@ -1318,7 +1386,7 @@ export const COMPONENTS: ComponentMeta[] = [ fixable: true, defaultFixed: true, actuatorFactor: "z_dp", - freeMin: 0.2, + freeMin: 0.05, freeMax: 3.0, }, { diff --git a/apps/web/src/lib/configBuilder.test.ts b/apps/web/src/lib/configBuilder.test.ts index 9d7c9b9..ee70b24 100644 --- a/apps/web/src/lib/configBuilder.test.ts +++ b/apps/web/src/lib/configBuilder.test.ts @@ -3,6 +3,7 @@ import type { Edge, Node } from "@xyflow/react"; import { applyBoundaryFixSemantics, buildScenarioConfig, + buildModelEmbeddings, buildFixedFreeCalibrationControls, resolveSecondaryStreams, stripUiOnlyParams, @@ -483,43 +484,45 @@ describe("caloporteur (secondary stream) resolution", () => { }); }); -describe("Fixed / Free calibration (Probe-based pairing)", () => { - it("pairs a Fixed Probe measure with a Free Z_UA on the HX", () => { +describe("Modelica embeddings (Fixed / Free)", () => { + it("pairs Fixed Probe Tsat with Free Z_UA into embeddings[] (not controls[])", () => { const nodes = [ node("e", "FloodedEvaporator", "evap", 0, { ua: 9000, z_ua: 1.0, - // Fixed OFF for Z_UA → free actuator [fixedFlagKey("z_ua")]: false, }), node("p1", "Probe", "sst_probe", 0, { - measure: "SST", fluid: "R134a", - target: 278.15, - // Fixed ON for the Probe target → impose the measure - [fixedFlagKey("target")]: true, + tsat_c: 5.0, + [fixedFlagKey("tsat_c")]: true, }), ]; const edges: Edge[] = [ { id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" }, ]; - const controls = buildFixedFreeCalibrationControls(nodes, edges); - expect(controls).toHaveLength(1); - expect(controls[0]).toMatchObject({ - measure: { component: "sst_probe", output: "saturationTemperature" }, - actuator: { component: "evap", factor: "z_ua" }, - target: 278.15, + const embeddings = buildModelEmbeddings(nodes, edges); + expect(embeddings).toHaveLength(1); + expect(embeddings[0]).toMatchObject({ + id: "emb_evap_z_ua", + unknown: { component: "evap", factor: "z_ua", start: 0.3, min: 0.05 }, + equation: { + component: "sst_probe", + output: "saturationTemperature", + value: 278.15, + }, }); const cfg = buildScenarioConfig(nodes, edges); - expect(cfg.controls?.length).toBe(1); - // UI-only keys stripped from component JSON; Z_UA still emitted as initial value. + expect(cfg.embeddings).toHaveLength(1); + expect(cfg.controls).toBeUndefined(); const comp = cfg.circuits[0].components[0]; expect(comp[fixedFlagKey("z_ua")]).toBeUndefined(); expect(comp.z_ua).toBe(1.0); + expect(comp.ua).toBeUndefined(); // Free z_ua strips absolute ua }); - it("emits no control when Z_UA stays Fixed even with a Probe present", () => { + it("emits no embedding when Z_UA stays Fixed even with a Probe present", () => { const nodes = [ node("e", "Evaporator", "evap", 0, { ua: 6000, @@ -527,18 +530,15 @@ describe("Fixed / Free calibration (Probe-based pairing)", () => { [fixedFlagKey("z_ua")]: true, }), node("p1", "Probe", "sst_probe", 0, { - measure: "SST", - fluid: "R134a", - target: 278.15, - [fixedFlagKey("target")]: true, + tsat_c: 5.0, + [fixedFlagKey("tsat_c")]: true, }), ]; + expect(buildModelEmbeddings(nodes)).toHaveLength(0); expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0); }); - it("emits nothing for a freed Z_UA with no matching Probe (HARD RULE)", () => { - // A freed z-factor without a Probe to measure against must not emit a - // control — the user must place a Probe. No legacy same-component fallback. + it("emits nothing for a freed Z_UA with no matching Probe", () => { const nodes = [ node("e", "Evaporator", "evap", 0, { ua: 6000, @@ -546,6 +546,39 @@ describe("Fixed / Free calibration (Probe-based pairing)", () => { [fixedFlagKey("z_ua")]: false, }), ]; - expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0); + expect(buildModelEmbeddings(nodes)).toHaveLength(0); + }); + + it("rejects Free Z_UA without a Fixed Probe equation (Modelica balance)", () => { + const nodes = [ + node("e", "BphxEvaporator", "evap", 0, { + z_ua: 1.0, + [fixedFlagKey("z_ua")]: false, + }), + ]; + const issues = validateConfig(nodes, []); + expect(issues.some((m) => m.includes("fixed=false") || m.includes("without an equation"))).toBe( + true, + ); + }); + + it("accepts legacy Probe measure+target (°C → K)", () => { + const nodes = [ + node("e", "BphxEvaporator", "evap", 0, { + z_ua: 1.0, + [fixedFlagKey("z_ua")]: false, + }), + node("p1", "Probe", "sst_probe", 0, { + measure: "SST", + target: 4.4, + __fixed_target: true, + }), + ]; + const edges: Edge[] = [ + { id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" }, + ]; + const embeddings = buildModelEmbeddings(nodes, edges); + expect(embeddings).toHaveLength(1); + expect(embeddings[0].equation.value).toBeCloseTo(277.55, 10); }); }); diff --git a/apps/web/src/lib/configBuilder.ts b/apps/web/src/lib/configBuilder.ts index c0465eb..7affe84 100644 --- a/apps/web/src/lib/configBuilder.ts +++ b/apps/web/src/lib/configBuilder.ts @@ -56,6 +56,23 @@ export interface ControlConfig { alpha?: number; } +/** Modelica Z-factor embedding (unknown + equation). Not a SaturatedController. */ +export interface EmbeddingConfig { + id: string; + unknown: { + component: string; + factor: string; + start: number; + min: number; + max: number; + }; + equation: { + component: string; + output: string; + value: number; + }; +} + export interface ControlObjectiveConfig { component: string; output: string; @@ -100,8 +117,10 @@ export interface ScenarioConfig { ua: number; efficiency: number; }>; - /** Steady-state control loops (co-solved). Mirrors crates/cli config `controls`. */ + /** System regulation loops (EXV/injection). Mirrors CLI `controls`. */ controls?: ControlConfig[]; + /** Modelica Z-factor embeddings (unknown + equation). Mirrors CLI `embeddings`. */ + embeddings?: EmbeddingConfig[]; /** Reusable subsystem templates (flattened by the CLI at load time). */ subsystems?: Record; /** Template instantiations. */ @@ -119,6 +138,13 @@ export interface ScenarioConfig { export const SCHEMA_VERSION = "2"; export const CONTROL_NODE_TYPE = "SaturatedController"; +/** + * Legacy auto-calib control ids (pre-embeddings). Still purged from canvas if present. + */ +export function isAutoCalibrationControlId(id: string): boolean { + return id.startsWith("calib_") || id.startsWith("emb_"); +} + export interface BuildOptions { fluid?: string; fluidBackend?: string; @@ -333,14 +359,17 @@ export function buildScenarioConfig( } }); + // Explicit Advanced-palette regulation controllers only (EXV/injection). + // Fixed/Free Z-factors → embeddings[] (never controls[]/SaturatedController). const nodeControls = nodes - .filter((node) => node.data.type === CONTROL_NODE_TYPE) + .filter( + (node) => + node.data.type === CONTROL_NODE_TYPE && + !isAutoCalibrationControlId(node.data.name), + ) .map(controlNodeToConfig); - const fixedFreeControls = buildFixedFreeCalibrationControls(nodes, edges); - const controls = mergeControls( - mergeControls(options.controls ?? [], nodeControls), - fixedFreeControls, - ); + const controls = mergeControls(options.controls ?? [], nodeControls); + const embeddings = buildModelEmbeddings(nodes, edges); return { schema_version: SCHEMA_VERSION, @@ -351,6 +380,7 @@ export function buildScenarioConfig( ...(connections.length > 0 ? { connections } : {}), thermal_couplings: options.thermalCouplings || [], ...(controls.length > 0 ? { controls } : {}), + ...(embeddings.length > 0 ? { embeddings } : {}), solver: { strategy: options.solverStrategy || "newton", max_iterations: options.maxIterations ?? 300, @@ -364,6 +394,9 @@ export function buildScenarioConfig( * - `__fixed_*` Fixed checkbox flags * - measure-only targets (e.g. Probe `target`) — they become control setpoints * - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow` + * - when Z_UA is Free (calibration), omit literal `ua` override — the CLI + * otherwise bakes `ua` into a fixed Calib factor and ignores live `z_ua`, + * which zeros ∂measure/∂z_ua and blows up Newton (singular J → bad Picard state) */ export function stripUiOnlyParams( type: string, @@ -383,6 +416,13 @@ export function stripUiOnlyParams( if (v === "" || v === null || v === undefined) continue; out[k] = v; } + + // Free z_ua → live embedding owns UA scaling; drop absolute `ua` override. + const zUaMeta = meta?.params.find((p) => p.key === "z_ua" && p.actuatorFactor === "z_ua"); + if (zUaMeta && !isParamFixed(params, zUaMeta)) { + delete out.ua; + } + return applyExvFixSemantics(type, applyBoundaryFixSemantics(type, out, params), params); } @@ -466,34 +506,29 @@ export function applyBoundaryFixSemantics( } /** - * Build the `controls[]` array for calibration (Probe-based model, HARD RULE): + * Build Modelica `embeddings[]`: Free Z-factor (unknown) + Fixed Probe (equation). * - * - Probe with Fixed `target` → impose that measure (setpoint = value) - * - Param with `actuatorFactor` + Fixed OFF → free that Z-factor - * - * Every freed Z-factor is paired with a semantically-compatible Probe - * (preferring one adjacent on an edge). A freed factor with no matching - * Probe emits nothing — the user must place a Probe to calibrate. - * - * Pairing matrix (factor → compatible Probe measures): - * z_ua → SST, SDT, SH, DSH, SC, Capacity + * Pairing (free factor → Probe Fixed physical params): + * z_ua → Tsat, Tsh * z_dp → P - * z_flow → MassFlow, Capacity + * z_flow → Capacity + * f_w → T * z_power → Capacity - * z_etav → MassFlow + * z_etav → Capacity + * opening → Tsh + * + * Never emits SaturatedController / controls[]. */ -export function buildFixedFreeCalibrationControls( +export function buildModelEmbeddings( nodes: Node[], edges: Edge[] = [], -): ControlConfig[] { - const controls: ControlConfig[] = []; - - // ── Pass 1: collect Probe measures and per-component freed z-factors ── +): EmbeddingConfig[] { type ProbeMeasure = { nodeName: string; - kind: string; // SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy + kind: string; + output: string; target: number; - factorCompat: readonly string[]; // z-factors this Probe kind can pair with + factorCompat: readonly string[]; }; type FreeAct = { factor: string; @@ -503,6 +538,7 @@ export function buildFixedFreeCalibrationControls( key: string; }; + const embeddings: EmbeddingConfig[] = []; const probeMeasures: ProbeMeasure[] = []; const freeActsByComponent = new Map< string, @@ -515,30 +551,43 @@ export function buildFixedFreeCalibrationControls( if (!meta) continue; const params = node.data.params ?? {}; - // Probe node: read the `target` Fixed param + the configured `measure` kind. if (node.data.type === "Probe") { - const measureParam = meta.params.find((p) => p.key === "measure"); - const targetParam = meta.params.find((p) => p.key === "target"); - if (!measureParam || !targetParam) continue; - const kindRaw = params.measure; - const kind = - typeof kindRaw === "string" ? kindRaw : String(measureParam.default ?? "SH"); - const targetMeta = targetParam; - const targetFixed = isParamFixed(params, targetParam); - if (!targetFixed) continue; - const rawTarget = params.target; - const n = typeof rawTarget === "number" ? rawTarget : Number(rawTarget); - if (!Number.isFinite(n)) continue; - probeMeasures.push({ - nodeName: node.data.name, - kind, - target: measureSetpointSi(targetMeta, n), - factorCompat: FACTOR_COMPATIBILITY[kind] ?? [], - }); + let emitted = 0; + for (const p of meta.params) { + if (!p.fixable || !p.measureOutput) continue; + if (!isParamFixed(params, p)) continue; + const raw = params[p.key]; + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n)) continue; + const kind = PROBE_PARAM_KIND[p.key] ?? p.key; + let target = measureSetpointSi(p, n); + if (p.measureOutput === "pressure" && (p.unit ?? "").toLowerCase() === "bar") { + target = n * 1e5; + } + probeMeasures.push({ + nodeName: node.data.name, + kind, + output: p.measureOutput === "auto" ? "temperature" : p.measureOutput, + target, + factorCompat: FACTOR_COMPATIBILITY[kind] ?? [], + }); + emitted += 1; + } + if (emitted === 0) { + const legacy = legacyProbeMeasure(params); + if (legacy) { + probeMeasures.push({ + nodeName: node.data.name, + kind: legacy.kind, + output: legacy.output, + target: legacy.target, + factorCompat: FACTOR_COMPATIBILITY[legacy.kind] ?? [], + }); + } + } continue; } - // Non-Probe node: collect freed z-factors (actuators to calibrate). const freeActs: FreeAct[] = []; for (const p of meta.params) { if (!p.fixable) continue; @@ -547,7 +596,13 @@ export function buildFixedFreeCalibrationControls( if (p.actuatorFactor && !fixed) { const n = typeof raw === "number" ? raw : Number(raw); - const initial = Number.isFinite(n) ? n : 1.0; + let initial = Number.isFinite(n) ? n : 1.0; + if ( + (p.actuatorFactor === "z_ua" || p.actuatorFactor === "z_dp") && + Math.abs(initial - 1.0) < 1e-12 + ) { + initial = 0.3; + } freeActs.push({ factor: p.actuatorFactor, initial, @@ -563,16 +618,12 @@ export function buildFixedFreeCalibrationControls( } } - // ── Adjacency index: for each component node, which Probe nodes share an edge ── const adjacentProbes = new Map>(); - const probeNodeIds = new Set( - nodes.filter((n) => n.data.type === "Probe").map((n) => n.id), - ); + const probeNodeIds = new Set(nodes.filter((n) => n.data.type === "Probe").map((n) => n.id)); for (const edge of edges) { const endpoints = [edge.source, edge.target]; for (const endpoint of endpoints) { if (!endpoint) continue; - // Probe directly adjacent to this endpoint for (const other of endpoints) { if (other && other !== endpoint && probeNodeIds.has(other)) { const probeName = nodes.find((n) => n.id === other)?.data.name; @@ -590,19 +641,13 @@ export function buildFixedFreeCalibrationControls( } } - /** - * Find the best Probe to pair with a freed z-factor on `component`: - * 1. Must be semantically compatible (FACTOR_COMPATIBILITY). - * 2. Prefer Probes adjacent to the component (share an edge endpoint). - * 3. Fall back to any compatible Probe. - */ const findProbeFor = ( component: string, factor: string, - usedProbeNames: Set, + usedKeys: Set, ): ProbeMeasure | undefined => { const compatible = probeMeasures.filter( - (m) => m.factorCompat.includes(factor) && !usedProbeNames.has(m.nodeName), + (m) => m.factorCompat.includes(factor) && !usedKeys.has(`${m.nodeName}::${m.kind}`), ); if (compatible.length === 0) return undefined; const adjacent = adjacentProbes.get(component); @@ -610,50 +655,107 @@ export function buildFixedFreeCalibrationControls( ? compatible.find((m) => adjacent.has(m.nodeName)) : undefined; const chosen = adjacentMatch ?? compatible[0]; - usedProbeNames.add(chosen.nodeName); + usedKeys.add(`${chosen.nodeName}::${chosen.kind}`); return chosen; }; - // ── Pass 2: emit controls ── - // Each freed z-factor is paired with a compatible Probe (HARD RULE). A - // freed actuator with no matching Probe emits nothing — the user must - // place a Probe to calibrate. - const usedProbeNames = new Set(); - + const usedKeys = new Set(); for (const { nodeName, acts } of freeActsByComponent.values()) { for (const act of acts) { - const probe = findProbeFor(nodeName, act.factor, usedProbeNames); + const probe = findProbeFor(nodeName, act.factor, usedKeys); if (probe) { - controls.push({ - type: "SaturatedController", - id: `calib_${nodeName}_${act.factor}`, - measure: { - component: probe.nodeName, - output: probeOutputFor(probe.kind), - }, - actuator: { + embeddings.push({ + id: `emb_${nodeName}_${act.factor}`, + unknown: { component: nodeName, factor: act.factor, - initial: act.initial, + start: act.initial, min: act.min, max: act.max, }, - target: probe.target, - gain: -0.5, - band: 2.0, + equation: { + component: probe.nodeName, + output: probe.output, + value: probe.target, + }, }); } } } - return controls; + return embeddings; } +/** @deprecated Use {@link buildModelEmbeddings}. Kept for transitional tests. */ +export function buildFixedFreeCalibrationControls( + nodes: Node[], + 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 = { + t_c: "T", + tsat_c: "Tsat", + p_bar: "P", + x: "X", + tsh_k: "Tsh", + capacity_w: "Capacity", +}; + /** - * Maps a Probe `measure` kind to the solver-side `ComponentOutput` string - * accepted by the CLI (`parse_component_output` in run.rs). + * Legacy Probe shape: `{ measure: "SST", target: 5.9, __fixed_target: true }`. + * Absolute temperatures that look like °C (< 200) are converted to K. */ -function probeOutputFor(kind: string): string { +function legacyProbeMeasure(params: Record): { + kind: string; + output: string; + target: number; +} | null { + const fixedFlag = params.__fixed_target; + // defaultFixed was false for legacy target — require explicit Fixed ON + if (fixedFlag !== true && fixedFlag !== "true") return null; + const kindRaw = params.measure; + const kind = typeof kindRaw === "string" ? kindRaw : ""; + if (!kind) return null; + const raw = params.target; + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n)) return null; + + const output = legacyProbeOutput(kind); + let target = n; + // SST/SDT/T/DGT: UI historically stored °C in `target` without a unit. + if ( + output === "saturationTemperature" || + output === "temperature" + ) { + if (n > -100 && n < 200) { + target = n + 273.15; + } + } + // Pressure: bar → Pa when value looks like bar + if (output === "pressure" && n > 0 && n < 200) { + target = n * 1e5; + } + return { kind, output, target }; +} + +function legacyProbeOutput(kind: string): string { switch (kind) { case "SST": case "SDT": @@ -672,28 +774,27 @@ function probeOutputFor(kind: string): string { return "massFlowRate"; case "Capacity": return "capacity"; - case "Enthalpy": - // No dedicated Enthalpy ComponentOutput; carry it on heatTransferRate. - return "heatTransferRate"; default: return "temperature"; } } -/** Probe `measure` kind → list of z-factors it can calibrate (pairing matrix). */ +/** Probe physical kind → free factors it can calibrate. */ const FACTOR_COMPATIBILITY: Record = { + Tsat: ["z_ua"], + Tsh: ["z_ua", "opening"], + T: ["f_w"], + P: ["z_dp"], + Capacity: ["z_flow", "z_power", "z_etav"], + X: [], + // Legacy kind names (older modules / tests) SST: ["z_ua"], SDT: ["z_ua"], - SH: ["z_ua"], - DSH: ["z_ua"], + SH: ["z_ua", "opening"], SC: ["z_ua"], - Capacity: ["z_ua", "z_flow", "z_power"], - P: ["z_dp"], + DGT: ["f_w"], + DSH: ["f_w"], MassFlow: ["z_flow", "z_etav"], - // Kinds not used for z-factor calibration (raw T, Enthalpy) — no compat. - DGT: [], - T: [], - Enthalpy: [], }; /** Convert UI measure value to SI expected by the solver (temps → K). */ @@ -864,9 +965,32 @@ export function validateConfig(nodes: Node[], edges: Edge[]): } } - // Modelica boundary conflicts are reported in the DoF ledger; emit-time - // `enforceModelicaBoundaryEmit` auto-corrects legalizable cases (Free P on - // MassFlowSource, Free ṁ when Fixed T_out). Hard-block only if emit cannot help. + // Modelica balanced model: parameter(fixed=false) is an unknown → needs an + // equation. Fixed Probe value supplies that equation. Free without equation + // was silently ignored (still a constant) — reject it. + const balancedUnknowns = new Set( + buildModelEmbeddings(nodes, edges).map( + (e) => `${e.unknown.component}::${e.unknown.factor}`, + ), + ); + for (const n of nodes) { + if (n.data.type === CONTROL_NODE_TYPE) continue; + const meta = COMPONENT_BY_TYPE[n.data.type]; + if (!meta) continue; + const params = n.data.params ?? {}; + for (const p of meta.params) { + if (!p.actuatorFactor || !p.fixable) continue; + if (isParamFixed(params, p)) continue; + const key = `${n.data.name}::${p.actuatorFactor}`; + if (!balancedUnknowns.has(key)) { + issues.push( + `${n.data.name}: ${p.label} is Free (unknown) without an equation — ` + + `Modelica: parameter(fixed=false) needs a Fixed Probe value on the line, ` + + `or leave ${p.label} Fixed.`, + ); + } + } + } return issues; } diff --git a/apps/web/src/lib/useScenarioSimulation.ts b/apps/web/src/lib/useScenarioSimulation.ts index 6767fdb..5c966c5 100644 --- a/apps/web/src/lib/useScenarioSimulation.ts +++ b/apps/web/src/lib/useScenarioSimulation.ts @@ -29,13 +29,19 @@ export function useScenarioSimulation() { const setLastConfig = useDiagramStore((s) => s.setLastConfig); const setResult = useDiagramStore((s) => s.setResult); const setSimulating = useDiagramStore((s) => s.setSimulating); + const purgeAutoCalibrationControlNodes = useDiagramStore( + (s) => s.purgeAutoCalibrationControlNodes, + ); const simulating = useDiagramStore((s) => s.simulating); const [issues, setIssues] = useState([]); const run = useCallback(async () => { - const problems = validateConfig(nodes, edges); - const ledger = computeDofLedger(nodes as Node[], edges); + // Fixed/Free calib must never leave SaturatedController blocks on the canvas. + purgeAutoCalibrationControlNodes(); + const { nodes: liveNodes, edges: liveEdges } = useDiagramStore.getState(); + const problems = validateConfig(liveNodes, liveEdges); + const ledger = computeDofLedger(liveNodes as Node[], liveEdges); if (ledger.balance === "over-constrained") { problems.push( `DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` + @@ -45,7 +51,7 @@ export function useScenarioSimulation() { setIssues(problems); if (problems.length > 0) return false; - const scenarioConfig = buildScenarioConfig(nodes, edges, { + const scenarioConfig = buildScenarioConfig(liveNodes, liveEdges, { fluid, fluidBackend, solverStrategy, @@ -85,6 +91,7 @@ export function useScenarioSimulation() { solverStrategy, maxIterations, tolerance, + purgeAutoCalibrationControlNodes, setLastConfig, setResult, setSimulating, diff --git a/apps/web/src/store/diagramStore.test.ts b/apps/web/src/store/diagramStore.test.ts index 035b365..93eddce 100644 --- a/apps/web/src/store/diagramStore.test.ts +++ b/apps/web/src/store/diagramStore.test.ts @@ -25,6 +25,88 @@ function reset() { beforeEach(reset); describe("controls import", () => { + it("does not materialise Fixed/Free calib_* as canvas SaturatedController nodes", () => { + useDiagramStore.getState().loadFromConfig({ + fluid: "R134a", + circuits: [ + { + id: 0, + components: [ + { type: "BphxEvaporator", name: "evap", z_ua: 1 }, + { type: "Probe", name: "SST probe", tsat_c: 4.4 }, + ], + edges: [{ from: "evap:outlet", to: "SST probe:inlet" }], + }, + ], + controls: [ + { + type: "SaturatedController", + id: "calib_evap_z_ua", + measure: { component: "SST probe", output: "saturationTemperature" }, + actuator: { component: "evap", factor: "z_ua", initial: 1, min: 0.1, max: 3 }, + target: 277.55, + }, + { + type: "SaturatedController", + id: "dgt_limiter", + measure: { component: "evap", output: "temperature" }, + actuator: { component: "evap", factor: "injection", initial: 0.1, min: 0, max: 0.3 }, + target: 300, + }, + ], + }); + + const st = useDiagramStore.getState(); + expect(st.nodes.map((n) => n.data.name)).not.toContain("calib_evap_z_ua"); + expect(st.nodes.map((n) => n.data.name)).toContain("dgt_limiter"); + expect(st.controls.map((c) => c.id)).toEqual(["dgt_limiter"]); + }); + + it("purgeAutoCalibrationControlNodes removes leftover calib_* canvas blocks", () => { + useDiagramStore.getState().loadFromConfig({ + fluid: "R134a", + circuits: [ + { + id: 0, + components: [{ type: "BphxEvaporator", name: "evap" }], + edges: [], + }, + ], + controls: [ + { + id: "calib_evap_z_ua", + measure: { component: "evap", output: "saturationTemperature" }, + actuator: { component: "evap", factor: "z_ua", min: 0.1, max: 3 }, + target: 277.55, + }, + ], + }); + // Simulate a stale canvas block from an older import path. + useDiagramStore.setState((s) => ({ + nodes: [ + ...s.nodes, + { + id: "stale-calib", + type: "entropykNode", + position: { x: 0, y: 0 }, + data: { + type: "SaturatedController", + name: "calib_evap_z_ua", + circuit: 0, + rotation: 0, + flipH: false, + flipV: false, + params: {}, + }, + }, + ], + })); + useDiagramStore.getState().purgeAutoCalibrationControlNodes(); + expect(useDiagramStore.getState().nodes.map((n) => n.data.name)).not.toContain( + "calib_evap_z_ua", + ); + }); + it("preserves imported co-solved controls for the next simulation run", () => { useDiagramStore.getState().loadFromConfig({ fluid: "R134a", diff --git a/apps/web/src/store/diagramStore.ts b/apps/web/src/store/diagramStore.ts index b6026f4..ae291dd 100644 --- a/apps/web/src/store/diagramStore.ts +++ b/apps/web/src/store/diagramStore.ts @@ -5,7 +5,11 @@ import type { Edge, Node, OnNodesChange, OnEdgesChange, OnConnect } from "@xyflo import { applyNodeChanges, applyEdgeChanges, addEdge } from "@xyflow/react"; import { hydrateBoundaryFixFlags } from "@/lib/boundaryFix"; import { defaultParams } from "@/lib/componentMeta"; -import { CONTROL_NODE_TYPE, canonicalizeParams } from "@/lib/configBuilder"; +import { + CONTROL_NODE_TYPE, + canonicalizeParams, + isAutoCalibrationControlId, +} from "@/lib/configBuilder"; import type { ControlConfig } from "@/lib/configBuilder"; import type { SimulationResult } from "@/lib/api"; import { @@ -147,6 +151,8 @@ interface DiagramState { setResult: (result: SimulationResult | null, error?: string | null) => void; setSimulating: (v: boolean) => void; loadFromConfig: (config: unknown) => void; + /** Remove leftover canvas nodes from Fixed/Free auto-calib (not real controllers). */ + purgeAutoCalibrationControlNodes: () => void; clear: () => void; undo: () => void; redo: () => void; @@ -736,7 +742,12 @@ export const useDiagramStore = create((set, get) => ({ x += 280; } - for (const [index, control] of (cfg.controls ?? []).entries()) { + // Explicit Advanced controllers only. Fixed/Free calib (`calib_*`) is a + // solver DoF swap regenerated from Probe/component Fixed flags — never a + // canvas SaturatedController block. + let explicitControlIndex = 0; + for (const control of cfg.controls ?? []) { + if (isAutoCalibrationControlId(control.id)) continue; const measuredNode = nodes.find((node) => node.data.name === control.measure.component); const id = crypto.randomUUID(); nodes.push({ @@ -744,7 +755,7 @@ export const useDiagramStore = create((set, get) => ({ type: "entropykNode", position: measuredNode ? { x: measuredNode.position.x + 140, y: Math.max(40, measuredNode.position.y - 72) } - : { x: 100 + index * 150, y: 40 }, + : { x: 100 + explicitControlIndex * 150, y: 40 }, data: { type: CONTROL_NODE_TYPE, name: control.id, @@ -755,6 +766,7 @@ export const useDiagramStore = create((set, get) => ({ params: controlParams(control), }, }); + explicitControlIndex += 1; } const edges: Edge[] = []; @@ -785,7 +797,7 @@ export const useDiagramStore = create((set, get) => ({ solverStrategy: cfg.solver?.strategy || "newton", maxIterations: cfg.solver?.max_iterations ?? 300, tolerance: cfg.solver?.tolerance ?? 1e-6, - controls: cfg.controls ?? [], + controls: (cfg.controls ?? []).filter((c) => !isAutoCalibrationControlId(c.id)), result: null, lastConfig: cfg, simError: null, @@ -794,6 +806,28 @@ export const useDiagramStore = create((set, get) => ({ get().recordHistory(); }, + purgeAutoCalibrationControlNodes: () => { + const { nodes, edges, selectedNodeId } = get(); + const removeIds = new Set( + nodes + .filter( + (n) => + n.data.type === CONTROL_NODE_TYPE && + isAutoCalibrationControlId(n.data.name), + ) + .map((n) => n.id), + ); + if (removeIds.size === 0) return; + set({ + nodes: nodes.filter((n) => !removeIds.has(n.id)), + edges: edges.filter((e) => !removeIds.has(e.source) && !removeIds.has(e.target)), + selectedNodeId: + selectedNodeId && removeIds.has(selectedNodeId) ? null : selectedNodeId, + controls: get().controls.filter((c) => !isAutoCalibrationControlId(c.id)), + }); + get().recordHistory(); + }, + clear: () => { const empty = { nodes: [], edges: [], selectedNodeId: null }; set({ diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index ed29bd0..beabcee 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -17,8 +17,8 @@ use crate::error::{CliError, CliResult}; pub const CURRENT_SCHEMA_VERSION: &str = "2"; /// Schema versions this build can load. `"1"` is the original flat -/// `circuits/components/edges` schema; `"2"` adds `controls`, `subsystems`, -/// `instances` and `connections`. Both are read by the same loader. +/// `circuits/components/edges` schema; `"2"` adds `controls`, `embeddings`, +/// `subsystems`, `instances` and `connections`. Both are read by the same loader. pub const SUPPORTED_SCHEMA_VERSIONS: &[&str] = &["1", "2"]; fn default_schema_version() -> String { @@ -30,7 +30,7 @@ fn default_schema_version() -> String { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ScenarioConfig { /// 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")] pub schema_version: String, /// Scenario name. @@ -54,9 +54,15 @@ pub struct ScenarioConfig { /// hints only; they do not impose thermodynamic boundary conditions. #[serde(default)] pub initialization: Option, - /// 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)] pub controls: Vec, + /// Modelica-style Z-factor embeddings: one free unknown + one equation + /// (`output = value`). Distinct from [`Self::controls`] (no SaturatedController). + #[serde(default)] + pub embeddings: Vec, /// Reusable subsystem templates (parameterized assemblies of components + /// internal edges, exposing a reduced external port set). Flattened into /// `circuits` at load time — the solver never sees the hierarchy. @@ -146,12 +152,56 @@ pub struct InstanceConfig { pub params: HashMap, } -/// 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 -/// anti-windup that is co-solved inside the Newton system. It drives a measured -/// plant output (`measure`) to `target` by manipulating an actuator factor -/// (`actuator`) within `[min, max]` bounds. +/// Equivalent to: +/// ```text +/// parameter Real z(fixed = false, start = …); +/// 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)] pub struct ControlConfig { /// Controller type. Only `"SaturatedController"` is supported today. @@ -217,13 +267,13 @@ pub struct MeasureConfig { 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)] pub struct ActuatorConfig { /// Name of the component carrying the actuator. pub component: String, - /// Calibration Z-factor to manipulate: `z_flow`, `z_dp`, `z_ua`, `z_power`, or `z_etav` - /// (legacy `f_*` names and BOLT `Z_*` spellings are also accepted). + /// Physical actuator factor for regulation: `opening`, `injection`, … + /// Z-factors (`z_ua`, …) must use [`EmbeddingConfig`], not controls. pub factor: String, /// Initial actuator value (nominal, e.g. 1.0). #[serde(default = "default_actuator_initial")] @@ -1343,11 +1393,12 @@ mod tests { fn test_json_schema_emits_ir_fields() { let schema = ScenarioConfig::json_schema(); // 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 [ "schema_version", "circuits", "controls", + "embeddings", "subsystems", "instances", "connections", @@ -1361,4 +1412,23 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap(); 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()); + } } diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index ab35884..345b5d1 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -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(); // 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 // displacement ṁ closure so DoF stays square (ṁ follows the valve). 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 - // BEFORE finalize() so the actuator is wired to the component's CalibIndices. + // Register model embeddings (Z-factor unknowns + equations) and system + // regulation controls. Must happen BEFORE finalize() so unknowns wire to + // CalibIndices. // - // Routing (calibration redesign): controls whose actuator is a z-factor - // (z_flow/z_flow_eco/z_dp/z_ua/z_power/z_etav) go through PLAIN inverse - // embedding (+1 residual measured−target, +1 unknown z) — not the - // SaturatedController (2+2 with integrator, meant for physical actuators). + // - embeddings[] → plain Constraint + BoundedVariable (Modelica fixed=false) + // - controls[] → SaturatedController only (EXV/injection/…); z-factors forbidden let control_error = |msg: String| SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, @@ -1072,83 +1119,51 @@ fn execute_simulation( raw_state_vector: None, solved_variables: Vec::new(), }; - for control in &config.controls { - let factor = control.actuator.factor.trim(); - // Plain embedding applies to single-point z-factor calibration only. - // A control WITH `objectives` is a supervisory override network - // (selector semantics) and always keeps the SaturatedController path, - // regardless of the actuator factor. - if entropyk_core::normalize_factor_name(factor).is_some() && control.objectives.is_empty() { - match build_plain_z_embedding(control, factor) { - 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 - )); - } + + for emb in &config.embeddings { + match build_model_embedding(emb) { + Ok((constraint, bounded_var, unknown_id)) => { + if let Err(e) = system.add_constraint(constraint) { + return control_error(format!( + "Failed to add equation for embedding '{}': {:?}", + emb.id, e + )); } - Err(msg) => { - return control_error(format!("Invalid control '{}': {}", control.id, msg)); + if let Err(e) = system.add_bounded_variable(bounded_var) { + 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) { Ok((bounded_var, controller)) => { if let Err(e) = system.add_bounded_variable(bounded_var) { - return SimulationResult { - input: input_name.to_string(), - status: SimulationStatus::Error, - 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(), - }; + return control_error(format!( + "Failed to add actuator for control '{}': {:?}", + control.id, e + )); } system.add_saturated_controller(controller); } Err(msg) => { - return SimulationResult { - 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(), - }; + return control_error(format!("Invalid control '{}': {}", control.id, msg)); } } } @@ -1498,10 +1513,20 @@ fn execute_simulation( state }; - // Seed control-actuator slots at their nominal value (the physical seed only - // fills edge/component states, leaving control unknowns at 0.0 which is a poor - // start for e.g. an f_m mass-flow factor whose nominal is 1.0). + // Seed embedding / control unknowns at their start/initial (physical seed + // leaves them at 0.0 — a poor start for Z-factors whose nominal is ~0.3–1). 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 { let actuator_id = entropyk_solver::inverse::BoundedVariableId::new(saturated_actuator_id( &control.actuator.component, @@ -1571,6 +1596,7 @@ fn execute_simulation( let solve_time_budget = (config.solver.timeout_ms > 0) .then(|| std::time::Duration::from_millis(config.solver.timeout_ms)); let needs_guarded_newton = !config.controls.is_empty() + || !config.embeddings.is_empty() || config.circuits.iter().any(|c| { c.enabled && c.components.iter().any(|comp| { @@ -3085,29 +3111,33 @@ fn bphx_calib_from_params( .or_else(|| params.get("f_ua")) .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() { 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 { - Some(u) => { - if u < 0.0 { - return Err(CliError::Config(format!( - "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(), - )); - } + let z_ua = if let Some(z) = explicit_z_ua { + z + } else if let Some(u) = config_ua { + if u < 0.0 { + return Err(CliError::Config(format!( + "BphxExchanger: ua must be >= 0 (got {:.2} W/K)", + u + ))); } - 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 { @@ -3137,6 +3167,7 @@ fn bphx_calib_from_params( z_ua, z_power: 1.0, z_etav: 1.0, + f_w: 1.0, calibration_source: None, }) } @@ -3229,15 +3260,11 @@ fn parse_component_output( Ok(out) } -/// Builds a bounded actuator + saturated controller from a control config. -/// Plain inverse embedding for z-factor calibration controls (calibration -/// redesign, WS-2): one `Constraint` (measured − target) + one `BoundedVariable` -/// (the z-factor), linked 1:1. The bounded var id reuses the -/// `{component}__{z_factor}` convention so `finalize()` wires it to the -/// component's matching `CalibIndices` slot (system.rs). -fn build_plain_z_embedding( - control: &crate::config::ControlConfig, - factor: &str, +/// Modelica Z-factor embedding: one `Constraint` (output − value) + one +/// `BoundedVariable` (the free Z-factor). Bounded var id uses +/// `{component}__{factor}` so `finalize()` wires `CalibIndices`. +fn build_model_embedding( + emb: &crate::config::EmbeddingConfig, ) -> Result< ( entropyk_solver::inverse::Constraint, @@ -3248,23 +3275,29 @@ fn build_plain_z_embedding( > { use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId}; - let output = parse_component_output(&control.measure.component, &control.measure.output)?; - let actuator_id = - BoundedVariableId::new(saturated_actuator_id(&control.actuator.component, factor)); + let factor = emb.unknown.factor.trim(); + if entropyk_core::normalize_factor_name(factor).is_none() { + 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( - actuator_id.clone(), - &control.actuator.component, - control.actuator.initial, - control.actuator.min, - control.actuator.max, + unknown_id.clone(), + &emb.unknown.component, + emb.unknown.start, + emb.unknown.min, + emb.unknown.max, ) - .map_err(|e| format!("invalid actuator bounds: {e:?}"))?; + .map_err(|e| format!("invalid unknown bounds: {e:?}"))?; let constraint = Constraint::new( - ConstraintId::new(control.id.clone()), + ConstraintId::new(emb.id.clone()), output, - control.target, + emb.equation.value, ); - Ok((constraint, bounded_var, actuator_id)) + Ok((constraint, bounded_var, unknown_id)) } fn build_saturated_control( @@ -3288,13 +3321,14 @@ fn build_saturated_control( } let factor = control.actuator.factor.trim(); - if entropyk_core::normalize_factor_name(factor).is_none() - && factor != "injection" - && factor != "opening" - { + if entropyk_core::normalize_factor_name(factor).is_some() { return Err(format!( - "unknown actuator factor '{factor}' (expected z_flow, z_dp, z_ua, z_power, z_etav, \ - injection, opening — legacy f_* and BOLT Z_* names accepted)" + "factor '{factor}' is a Z-factor — use embeddings[], not SaturatedController" + )); + } + 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_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 // pressure to P_sat(t_cond_k). Normally ṁ is closed by the volumetric // displacement model. When a sibling EXV uses a *fixed* orifice opening, diff --git a/crates/cli/tests/calibration_sdt.rs b/crates/cli/tests/calibration_sdt.rs index 92bead0..764494e 100644 --- a/crates/cli/tests/calibration_sdt.rs +++ b/crates/cli/tests/calibration_sdt.rs @@ -150,22 +150,21 @@ fn bphx_condenser_sdt_calibration_converges_and_solves_z_ua() { "tolerance": 1e-06, "timeout_ms": 60000 }, - "controls": [ + "embeddings": [ { - "type": "SaturatedController", - "id": "sdt_calib", - "measure": { - "component": "cond", - "output": "saturationTemperature" - }, - "actuator": { + "id": "emb_cond_z_ua", + "unknown": { "component": "cond", "factor": "z_ua", - "initial": 0.3, + "start": 0.3, "min": 0.05, "max": 2.0 }, - "target": 315.0 + "equation": { + "component": "cond", + "output": "saturationTemperature", + "value": 315.0 + } } ] } diff --git a/crates/cli/tests/embeddings_reject_control_z.rs b/crates/cli/tests/embeddings_reject_control_z.rs new file mode 100644 index 0000000..a256239 --- /dev/null +++ b/crates/cli/tests/embeddings_reject_control_z.rs @@ -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}" + ); +} diff --git a/crates/cli/tests/probe_calibration.rs b/crates/cli/tests/probe_calibration.rs index 0d3ef56..9423de3 100644 --- a/crates/cli/tests/probe_calibration.rs +++ b/crates/cli/tests/probe_calibration.rs @@ -1,12 +1,6 @@ //! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration -//! 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 -//! end-to-end path the UI emits after the calibration redesign. +//! test: a `Probe` measuring SDT + free `cond/z_ua` via model `embeddings[]` +//! (Modelica unknown + equation — not SaturatedController / controls[]). use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus}; use tempfile::tempdir; @@ -139,22 +133,21 @@ fn probe_based_sdt_calibration_converges_and_solves_z_ua() { "tolerance": 1e-06, "timeout_ms": 60000 }, - "controls": [ + "embeddings": [ { - "type": "SaturatedController", - "id": "probe_sdt_calib", - "measure": { - "component": "cond_sdt_probe", - "output": "saturationTemperature" - }, - "actuator": { + "id": "emb_cond_z_ua", + "unknown": { "component": "cond", "factor": "z_ua", - "initial": 0.3, + "start": 0.3, "min": 0.05, "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!( solved.value > 0.05 && solved.value < 2.0, "z_ua must solve within bounds, got {}", diff --git a/crates/cli/tests/probe_sst_evap.rs b/crates/cli/tests/probe_sst_evap.rs index ce37ce1..dc9d714 100644 --- a/crates/cli/tests/probe_sst_evap.rs +++ b/crates/cli/tests/probe_sst_evap.rs @@ -1,19 +1,14 @@ -//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration -//! 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). +//! Probe Fixed Tsat on suction line + free `evap/z_ua` (model embedding). //! -//! This is the Probe-based variant of `calibration_sdt.rs`. It proves the -//! end-to-end path the UI emits after the calibration redesign. +//! HVAC placement: SST lives on the suction line (`evap:outlet → probe → comp:inlet`), +//! never on the two-phase EXV→evap inlet. use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus}; use tempfile::tempdir; fn run_config(json: &str) -> SimulationResult { 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(); run_simulation(&path, None, false).unwrap() } @@ -22,8 +17,8 @@ fn run_config(json: &str) -> SimulationResult { fn probe_based_sst_evap_calibration() { let json = r#" { - "name": "Probe-based SDT 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.", + "name": "Probe-based SST calibration (R134a BPHX chiller)", + "description": "Suction Probe Fixed Tsat drives evap/z_ua via model embedding.", "fluid": "R134a", "fluid_backend": "CoolProp", "circuits": [ @@ -60,7 +55,7 @@ fn probe_based_sst_evap_calibration() { { "type": "Probe", "name": "evap_sst_probe", - "measure": "SDT", + "measure": "SST", "fluid": "R134a" }, { @@ -81,7 +76,8 @@ fn probe_based_sst_evap_calibration() { "emergent_pressure": true, "correlation": "Longo2004", "dp_correlation": "SimplifiedChannel", - "ua": 2000.0 + "ua": 2000.0, + "z_ua": 1.0 }, { "type": "BrineSource", @@ -121,12 +117,11 @@ fn probe_based_sst_evap_calibration() { } ], "edges": [ - { "from": "exv:outlet", "to": "evap_sst_probe:inlet" }, - { "from": "evap_sst_probe:outlet","to": "cond:inlet" }, + { "from": "comp:outlet", "to": "cond:inlet" }, { "from": "cond:outlet", "to": "exv:inlet" }, { "from": "exv:outlet", "to": "evap: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:secondary_outlet","to": "cond_water_out:inlet" }, { "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" }, @@ -140,22 +135,21 @@ fn probe_based_sst_evap_calibration() { "tolerance": 1e-06, "timeout_ms": 60000 }, - "controls": [ + "embeddings": [ { - "type": "SaturatedController", - "id": "probe_sdt_calib", - "measure": { - "component": "evap_sst_probe", - "output": "saturationTemperature" - }, - "actuator": { + "id": "emb_evap_z_ua", + "unknown": { "component": "evap", "factor": "z_ua", - "initial": 0.3, + "start": 0.3, "min": 0.05, "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); assert!( matches!(result.status, SimulationStatus::Converged), - "Probe-based SDT calibration must converge: {:?} ({:?})", + "Probe-based SST calibration must converge: {:?} ({:?})", result.status, result.error ); @@ -171,21 +165,40 @@ fn probe_based_sst_evap_calibration() { .solved_variables .iter() .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 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() { for e in state.iter() { - if e.target.as_deref() == Some("evap") || e.source.as_deref() == Some("evap") { - eprintln!("DIAG edge {}→{} P={}bar T_sat={}°C T={}°C", + if e.source.as_deref() == Some("evap") + || 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.target.as_deref().unwrap_or("?"), e.pressure_bar, 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!( solved.value > 0.05 && solved.value < 2.0, "z_ua must solve within bounds, got {}", diff --git a/crates/components/src/compressor.rs b/crates/components/src/compressor.rs index e965505..1b025f4 100644 --- a/crates/components/src/compressor.rs +++ b/crates/components/src/compressor.rs @@ -1083,6 +1083,23 @@ impl Compressor { pub fn set_operational_state(&mut self, state: OperationalState) { 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 { @@ -1194,9 +1211,13 @@ impl Component for Compressor { // ṁ_calc - ṁ_state = 0 residuals[0] = mass_flow_calc - mass_flow_state; - // Residual 1: Energy balance - // Power_calc - ṁ × (h_discharge - h_suction) / η_mech = 0 + // Residual 1: Energy balance with retention factor f_w + // (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 f_w = self.live_f_w(Some(state)); // Prevent division by zero if self.mechanical_efficiency.abs() < 1e-10 { @@ -1205,7 +1226,8 @@ impl Component for Compressor { )); } - 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) // CM1.4: skip when same_branch_m — ṁ_dis == ṁ_suc (same state index), @@ -1265,7 +1287,8 @@ impl Component for Compressor { )?; 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 let dr1_dm = -(h_discharge - h_suction) / self.mechanical_efficiency; jacobian.add_entry(1, suc_m_idx, dr1_dm); @@ -1280,7 +1303,7 @@ impl Component for Compressor { Temperature::from_kelvin(t), Temperature::from_kelvin(t_discharge), None, - )) + ) * f_w) }, h_suction, 1.0, @@ -1296,7 +1319,7 @@ impl Component for Compressor { Temperature::from_kelvin(t_suction), Temperature::from_kelvin(t), None, - )) + ) * f_w) }, h_discharge, 1.0, @@ -1326,7 +1349,18 @@ impl Component for Compressor { Temperature::from_kelvin(t_discharge_k), 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 diff --git a/crates/components/src/heat_exchanger/exchanger.rs b/crates/components/src/heat_exchanger/exchanger.rs index 1122423..11ad997 100644 --- a/crates/components/src/heat_exchanger/exchanger.rs +++ b/crates/components/src/heat_exchanger/exchanger.rs @@ -797,17 +797,30 @@ impl HeatExchanger { h_jkg: f64, ) -> Result { if !p_pa.is_finite() || p_pa <= 0.0 { - return Err(ComponentError::InvalidState(format!( - "{} {} side has invalid pressure: {} Pa", - self.name, side, p_pa - ))); + return Err(ComponentError::DomainViolation(DomainViolation { + component: Some(self.name.clone()), + detail: format!( + "{} {} side has invalid pressure: {} Pa", + self.name, side, p_pa + ), + })); } if !h_jkg.is_finite() { - return Err(ComponentError::InvalidState(format!( - "{} {} side has invalid enthalpy: {} J/kg", - self.name, side, h_jkg - ))); + return Err(ComponentError::DomainViolation(DomainViolation { + component: Some(self.name.clone()), + 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(|| { ComponentError::InvalidState(format!( "{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed", @@ -819,15 +832,19 @@ impl HeatExchanger { FluidsFluidId::new(fluid_id), property, entropyk_fluids::FluidState::PressureEnthalpy( - Pressure::from_pascals(p_pa), - entropyk_core::Enthalpy::from_joules_per_kg(h_jkg), + Pressure::from_pascals(p_query), + entropyk_core::Enthalpy::from_joules_per_kg(h_query), ), ) .map_err(|e| { - ComponentError::CalculationFailed(format!( - "{} failed to evaluate {:?} for {} side fluid '{}': {}", - self.name, property, side, fluid_id, e - )) + // Off-envelope CoolProp failures during Newton are recoverable. + ComponentError::DomainViolation(DomainViolation { + component: Some(self.name.clone()), + detail: format!( + "{} failed to evaluate {:?} for {} side fluid '{}': {}", + self.name, property, side, fluid_id, e + ), + }) }) } diff --git a/crates/components/src/heat_exchanger/flooded_evaporator.rs b/crates/components/src/heat_exchanger/flooded_evaporator.rs index 211b352..1df75cc 100644 --- a/crates/components/src/heat_exchanger/flooded_evaporator.rs +++ b/crates/components/src/heat_exchanger/flooded_evaporator.rs @@ -641,10 +641,22 @@ impl FloodedEvaporator { .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`, /// so `ε = 1 − exp(−UA / C_sec)`. - fn effectiveness(&self, c_sec: f64) -> f64 { - let ua = self.ua(); + fn effectiveness(&self, c_sec: f64, ua: f64) -> f64 { if c_sec <= 1e-10 || ua <= 0.0 { return 0.0; } @@ -659,9 +671,10 @@ impl FloodedEvaporator { p_in_pa: f64, t_sec_in: f64, c_sec: f64, + ua: f64, ) -> Result { 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)) } @@ -677,7 +690,7 @@ impl FloodedEvaporator { "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 @@ -717,7 +730,8 @@ impl FloodedEvaporator { )); } 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 secondary_outlet_k = if c_sec > 1e-10 { t_sec_in - q / c_sec @@ -1089,7 +1103,8 @@ impl Component for FloodedEvaporator { let h_in = state[inlet_h_idx]; let h_out = state[outlet_h_idx]; 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). // 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_out = state[outlet_h_idx]; let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?; - let eps = self.effectiveness(c_sec); - let q = self.coupled_duty_with(p_in, t_sec_in, c_sec)?; + let ua = self.live_ua(Some(state)); + 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 ua = self.ua(); // System mode exposes secondary edge unknowns; rating mode freezes (T,C). 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, 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; // r2 outlet closure diff --git a/crates/components/src/isentropic_compressor.rs b/crates/components/src/isentropic_compressor.rs index 7b77113..d304474 100644 --- a/crates/components/src/isentropic_compressor.rs +++ b/crates/components/src/isentropic_compressor.rs @@ -210,7 +210,11 @@ pub struct IsentropicCompressor { /// Inverse-control calibration state indices. When `f_m` is `Some(i)`, the /// volumetric mass-flow closure is scaled by the control variable at /// `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, + /// 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 /// volume to hold a target suction saturated temperature (SST). The slide /// position `σ ∈ [σ_min, 1]` is a free actuator that scales the displacement @@ -279,12 +283,19 @@ impl IsentropicCompressor { circuit_id: CircuitId::default(), operational_state: OperationalState::default(), calib_indices: CalibIndices::default(), + f_w: 1.0, slide_valve: false, sst_target_k: None, 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. pub fn with_refrigerant(mut self, refrigerant: &str) -> Self { 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 /// 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. + 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 { match self.calib_indices.z_flow { Some(i) if i < state.len() => { @@ -642,6 +664,7 @@ impl IsentropicCompressor { p_suc_pa: f64, h_suc_jkg: f64, p_dis_pa: f64, + state: &StateSlice, ) -> Result { let s_suc = backend .property( @@ -660,7 +683,11 @@ impl IsentropicCompressor { FluidState::PressureEntropy(Pressure::from_pascals(p_dis_pa), Entropy(s_suc)), ) .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 = - 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; if !self.same_branch_m { residuals[1] = match (self.suction_m_idx, self.discharge_m_idx) { @@ -776,7 +803,7 @@ impl Component for IsentropicCompressor { let m_calc = self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_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 // linked control variable f_m (1.0 when no control is attached) // and the slide-valve position σ (1.0 when no slide valve). @@ -824,9 +851,15 @@ impl Component for IsentropicCompressor { } return Ok(()); } - return Err(ComponentError::InvalidState( - "IsentropicCompressor displacement closure requires live physical suction and discharge states".to_string(), - )); + // Soft domain penalty — never abort the whole system residual + // 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)) = ( @@ -859,7 +892,7 @@ impl Component for IsentropicCompressor { p_suc, h_suc, p_cond_sat, - )? + state)? } else { return Err(ComponentError::InvalidState( "IsentropicCompressor requires physical live suction pressure/enthalpy" @@ -931,7 +964,7 @@ impl Component for IsentropicCompressor { let dph = h_suc * 1e-4 + 10.0; let dpd = p_dis * 1e-4 + 100.0; 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)) = (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 hd = |ps: f64, hs: f64, pd: f64| -> Result { 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 { let h_liq = self.liquid_enthalpy(pd)?; 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)); } + // ∂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 // into the energy balance so a controls[] loop has a plant to act // on (higher injection ⇒ lower discharge enthalpy ⇒ lower DGT). @@ -1064,7 +1113,7 @@ impl Component for IsentropicCompressor { p_suc, h_suc, p_dis, - ); + state); let h_liq = self.liquid_enthalpy(p_dis); if let (Ok(h_dis), Ok(h_liq)) = (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, h_suc, p_cond_sat, - ); + state); let hm = self.compute_h_dis_from_state( backend.as_ref(), fluid.clone(), p_suc - dp, h_suc, p_cond_sat, - ); + state); if let (Ok(hp), Ok(hm)) = (hp, hm) { jacobian.add_entry(1, suc_p, -(hp - hm) / (2.0 * dp)); } @@ -1142,14 +1191,14 @@ impl Component for IsentropicCompressor { p_suc, h_suc + dh, p_cond_sat, - ); + state); let hm = self.compute_h_dis_from_state( backend.as_ref(), fluid, p_suc, h_suc - dh, p_cond_sat, - ); + state); if let (Ok(hp), Ok(hm)) = (hp, hm) { jacobian.add_entry(1, suc_h, -(hp - hm) / (2.0 * dh)); } @@ -1259,7 +1308,7 @@ impl Component for IsentropicCompressor { state[sp], h_suc, state[dp], - ) + state) .unwrap_or(h_dis) } _ => h_dis, diff --git a/crates/core/src/calib.rs b/crates/core/src/calib.rs index f47892e..f5626a9 100644 --- a/crates/core/src/calib.rs +++ b/crates/core/src/calib.rs @@ -10,19 +10,25 @@ //! //! | 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_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_power` | `f_power` | `Z_power` | Ẇ_eff = z_power × Ẇ_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 //! -//! 1. **z_flow** — mass flow (compressor power + ṁ measurements) -//! 2. **z_dp** — pressure drops (inlet/outlet pressures) -//! 3. **z_ua** — heat transfer (superheat, subcooling, capacity) -//! 4. **z_power** — compressor power (if z_flow insufficient) +//! 1. **z_flow** — machine capacity via ṁ (pair with Capacity probe) +//! 2. **f_w** — compressor energy retention / shell loss (pair with discharge T / DGT) +//! 3. **z_dp** — pressure drops (pair with P) +//! 4. **z_ua** — heat transfer (pair with Tsat / Tsh) +//! 5. **z_power** — compressor power map (if needed) use serde::{Deserialize, Serialize}; @@ -42,11 +48,17 @@ pub const Z_UA: &str = "z_ua"; pub const Z_POWER: &str = "z_power"; /// Canonical name for the volumetric-efficiency Z-factor (`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. /// /// 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> { match factor.trim().to_ascii_lowercase().replace('_', "").as_str() { "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), "fpower" | "zpower" => Some(Z_POWER), "fetav" | "zetav" => Some(Z_ETAV), + "fw" | "fq" | "zfw" | "heatloss" => Some(F_W), _ => None, } } @@ -74,6 +87,9 @@ pub fn id_ends_with_calib_suffix(id: &str) -> Option<&'static str> { "z_uev", "z_dpc", "z_dp", + "f_w", + "f_q", + "z_fw", "f_m", "f_dp", "f_ua", @@ -146,6 +162,22 @@ pub struct Calib { /// z_etav: η_v,eff = z_etav × η_v,nominal (compressor displacement correction) #[serde(default = "one", rename = "z_etav", alias = "zEtav", alias = "f_etav")] 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. #[serde(default, skip_serializing_if = "Option::is_none")] pub calibration_source: Option, @@ -160,6 +192,7 @@ impl Default for Calib { z_ua: 1.0, z_power: 1.0, z_etav: 1.0, + f_w: 1.0, calibration_source: None, } } @@ -183,6 +216,8 @@ pub struct CalibIndices { pub z_power: Option, /// State index for z_etav multiplier pub z_etav: Option, + /// State index for compressor energy-retention factor `f_w` + pub f_w: Option, /// State index for a *physical actuator* free variable (dimensioned, not a /// multiplier). Interpreted per component: EXV/orifice opening [0..1], fan /// speed ratio, screw slide position, injection-valve opening, condenser @@ -201,18 +236,25 @@ pub struct CalibValidationError { impl std::fmt::Display for CalibValidationError { 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!( f, - "calib {} = {} is outside allowed range [0.5, 2.0]", - self.factor, self.value + "calib {} = {} is outside allowed range {}", + self.factor, self.value, range ) } } impl std::error::Error for CalibValidationError {} -const MIN_Z: f64 = 0.5; -const MAX_Z: f64 = 2.0; +const MIN_Z: f64 = 0.2; +const MAX_Z: f64 = 3.0; +const MIN_F_W: f64 = 0.0; +const MAX_F_W: f64 = 1.0; impl Calib { /// Updates a single factor by name (accepts canonical `z_*`, legacy `f_*`, BOLT `Z_*`). @@ -242,14 +284,18 @@ impl Calib { self.z_etav = value; true } + Some(F_W) => { + self.f_w = value; + true + } _ => 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> { - let check = |name: &'static str, value: f64| { - if !(MIN_Z..=MAX_Z).contains(&value) { + let check = |name: &'static str, value: f64, min: f64, max: f64| { + if !(min..=max).contains(&value) { Err(CalibValidationError { factor: name, value, @@ -258,12 +304,13 @@ impl Calib { Ok(()) } }; - check(Z_FLOW, self.z_flow)?; - check(Z_FLOW_ECO, self.z_flow_eco)?; - check(Z_DP, self.z_dp)?; - check(Z_UA, self.z_ua)?; - check(Z_POWER, self.z_power)?; - check(Z_ETAV, self.z_etav)?; + check(Z_FLOW, self.z_flow, MIN_Z, MAX_Z)?; + check(Z_FLOW_ECO, self.z_flow_eco, MIN_Z, MAX_Z)?; + check(Z_DP, self.z_dp, MIN_Z, MAX_Z)?; + check(Z_UA, self.z_ua, MIN_Z, MAX_Z)?; + check(Z_POWER, self.z_power, MIN_Z, MAX_Z)?; + check(Z_ETAV, self.z_etav, MIN_Z, MAX_Z)?; + check(F_W, self.f_w, MIN_F_W, MAX_F_W)?; Ok(()) } } @@ -281,6 +328,7 @@ mod tests { assert_eq!(c.z_ua, 1.0); assert_eq!(c.z_power, 1.0); assert_eq!(c.z_etav, 1.0); + assert_eq!(c.f_w, 1.0); // retention: 1 = adiabatic assert!(c.validate().is_ok()); } @@ -293,23 +341,31 @@ mod tests { z_ua: 2.0, z_power: 1.0, z_etav: 1.0, + f_w: 0.98, // ~2% shell loss calibration_source: None, }; assert!(ok.validate().is_ok()); let bad_m = Calib { - z_flow: 0.4, + z_flow: 0.1, ..Default::default() }; let err = bad_m.validate().unwrap_err(); assert_eq!(err.factor, Z_FLOW); let bad_high = Calib { - z_ua: 2.1, + z_ua: 3.1, ..Default::default() }; let err2 = bad_high.validate().unwrap_err(); 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] @@ -321,6 +377,7 @@ mod tests { z_ua: 1.0, z_power: 1.05, z_etav: 1.0, + f_w: 0.98, calibration_source: None, }; 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_power"), Some(Z_POWER)); 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); } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 22fd4e1..a863372 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -65,7 +65,7 @@ pub use types::{ // Re-export calibration types 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, }; diff --git a/crates/entropyk/src/result.rs b/crates/entropyk/src/result.rs index 7841584..f33be84 100644 --- a/crates/entropyk/src/result.rs +++ b/crates/entropyk/src/result.rs @@ -526,19 +526,18 @@ pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec Vec indices.z_ua = 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::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_POWER => indices.z_power = 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") {