Stabilize Modelica Fixed/Free calibration and stop flaky Newton embeds.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Sort constraint/control assembly for deterministic Jacobians, keep live z_ua on legacy HX, and add generic Fixed/Free UI assist without removing Z factors. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -39,6 +39,12 @@ import {
|
||||
} from "@/lib/configBuilder";
|
||||
import { buildComponentInspector, getSolvedVariablesForComponent } from "@/lib/componentInspector";
|
||||
import type { SolvedVariable } from "@/lib/api";
|
||||
import {
|
||||
calibrateAdjacentActuatorPatches,
|
||||
collectCalibHints,
|
||||
isCalibRelevantNode,
|
||||
withDerivedUaEff,
|
||||
} from "@/lib/calibAssist";
|
||||
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
|
||||
import ComponentDocPanel from "@/components/panels/ComponentDocPanel";
|
||||
import { modelBannerForType } from "@/lib/componentDocMap";
|
||||
@@ -138,11 +144,23 @@ export default function PropertiesPanel() {
|
||||
|
||||
// Solver-computed unknowns owned by this component (free actuators +
|
||||
// calibration factors). Empty until a solve produces them.
|
||||
const solvedVars = useMemo<SolvedVariable[]>(
|
||||
() => (node ? getSolvedVariablesForComponent(result, node.data.name) : []),
|
||||
[node, result],
|
||||
const solvedVars = useMemo<SolvedVariable[]>(() => {
|
||||
if (!node) return [];
|
||||
const base = getSolvedVariablesForComponent(result, node.data.name);
|
||||
return withDerivedUaEff(node.data.name, nodes, base);
|
||||
}, [node, nodes, result]);
|
||||
|
||||
const calibHints = useMemo(
|
||||
() => (node ? collectCalibHints(nodes, edges, node) : []),
|
||||
[node, nodes, edges],
|
||||
);
|
||||
|
||||
/** One-click Free on adjacent actuators compatible with this Probe's Fixed measures. */
|
||||
const calibActuatorPatches = useMemo(() => {
|
||||
if (!node || node.data.type !== "Probe") return [];
|
||||
return calibrateAdjacentActuatorPatches(node, nodes, edges);
|
||||
}, [node, nodes, edges]);
|
||||
|
||||
// After a successful solve, jump to Results when selecting a part (Dymola-like).
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
@@ -186,7 +204,8 @@ export default function PropertiesPanel() {
|
||||
<strong>Fixed ☐</strong> = inconnue → il faut une équation de plus
|
||||
</li>
|
||||
<li>
|
||||
Calib : Probe Tsat Fixed (= équation) + Z_UA Free (= inconnue)
|
||||
Calib : Probe mesure <strong>Fixed</strong> (= équation) + facteur{" "}
|
||||
<strong>Free</strong> (= inconnue) — z_ua, z_dp, z_flow, f_w…
|
||||
</li>
|
||||
<li>n_eq = n_unk. Pas de boucle de régulation.</li>
|
||||
</ul>
|
||||
@@ -305,6 +324,44 @@ export default function PropertiesPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Always visible on Probe / Z-factor components (also in Results after solve). */}
|
||||
{!isRegLoop &&
|
||||
isCalibRelevantNode(node) &&
|
||||
(calibHints.length > 0 || calibActuatorPatches.length > 0) && (
|
||||
<div className="space-y-1 border-b border-sky-200 bg-sky-50 px-2.5 py-2 text-[10px] leading-snug">
|
||||
<p className="font-semibold text-sky-950">Fixed / Free (Modelica)</p>
|
||||
{calibHints.map((h, i) => (
|
||||
<p
|
||||
key={`${h.level}-${i}`}
|
||||
className={
|
||||
h.level === "warn"
|
||||
? "text-amber-900"
|
||||
: h.level === "ok"
|
||||
? "text-emerald-800"
|
||||
: "text-sky-900"
|
||||
}
|
||||
>
|
||||
{h.level === "ok" ? "✓ " : h.level === "warn" ? "⚠ " : "💡 "}
|
||||
{h.message}
|
||||
</p>
|
||||
))}
|
||||
{calibActuatorPatches.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 rounded border border-sky-600 bg-white px-2 py-1 text-[10px] font-medium text-sky-800 hover:bg-sky-100"
|
||||
onClick={() => {
|
||||
const map = new Map(
|
||||
calibActuatorPatches.map((p) => [p.nodeId, p.params]),
|
||||
);
|
||||
updateNodesParams(map);
|
||||
}}
|
||||
>
|
||||
Libérer facteurs compatibles (Free)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRegLoop && panelMode === "results" && (
|
||||
<ModelicaResultsView
|
||||
inspector={inspector}
|
||||
@@ -317,8 +374,8 @@ export default function PropertiesPanel() {
|
||||
<>
|
||||
{isRegLoop && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-2.5 py-1.5 text-[10px] text-amber-950">
|
||||
Optionnel. Calibration SST + Z_UA → onglet <strong>Calibration</strong> du HX (cases
|
||||
Fixed), pas ce nœud.
|
||||
Optionnel. Calibration générique → onglet <strong>Calibration</strong> (Fixed/Free
|
||||
sur mesures Probe + facteurs Z), pas ce nœud.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -548,12 +605,12 @@ export default function PropertiesPanel() {
|
||||
{activeTab === "Calibration" && (
|
||||
<div className="space-y-1.5 border-t border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1.5 text-[10px] leading-snug text-[var(--ink-dim)]">
|
||||
<p>
|
||||
<strong>Modelica :</strong> Probe Fixed → équation (
|
||||
<code>Tsat = …</code>). Facteur Free → inconnue (
|
||||
<code>parameter z_ua(fixed=false)</code>). Le start est la valeur affichée.
|
||||
<strong>Fixed</strong> = parameter connu (équation si Probe).{" "}
|
||||
<strong>Free</strong> = inconnue Newton. Pairing : Tsat/Tsh↔z_ua,
|
||||
P↔z_dp, Capacity↔z_flow, T↔f_w. Tous les Z restent disponibles.
|
||||
</p>
|
||||
<p className="text-[var(--ink-faint)]">
|
||||
Laisse UA override vide. n_eq doit égaler n_unk.
|
||||
<p>
|
||||
Ex. UA nominale × Z_UA → <code>UA_eff</code>. Start = valeur affichée.
|
||||
</p>
|
||||
{solvedVars.length > 0 && (
|
||||
<SolvedVariablesBlock items={solvedVars} title="Inconnues résolues" />
|
||||
|
||||
226
apps/web/src/lib/calibAssist.test.ts
Normal file
226
apps/web/src/lib/calibAssist.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { fixedFlagKey } from "./componentMeta";
|
||||
import {
|
||||
adjacentProbeNames,
|
||||
calibrateAdjacentActuatorPatches,
|
||||
collectCalibHints,
|
||||
withDerivedUaEff,
|
||||
type CalibNodeData,
|
||||
} from "./calibAssist";
|
||||
import type { SolvedVariable } from "./api";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean> = {},
|
||||
): Node<CalibNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("calibAssist — Fixed/Free générique", () => {
|
||||
it("lists adjacent Probe names for any component", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", { ua: 6000, z_ua: 1 }),
|
||||
node("p1", "Probe", "sst", { tsat_c: 5, [fixedFlagKey("tsat_c")]: true }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
expect(adjacentProbeNames("evap", nodes, edges)).toEqual(["sst"]);
|
||||
});
|
||||
|
||||
it("one-click Free Z_UA when Probe Tsat Fixed", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", {
|
||||
ua: 9000,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: true,
|
||||
}),
|
||||
node("p1", "Probe", "sst", {
|
||||
tsat_c: 5,
|
||||
[fixedFlagKey("tsat_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const patches = calibrateAdjacentActuatorPatches(nodes[1], nodes, edges);
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0]).toMatchObject({
|
||||
nodeId: "e",
|
||||
params: { [fixedFlagKey("z_ua")]: false, z_ua: 1.0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("one-click Free z_dp when Probe P Fixed", () => {
|
||||
const nodes = [
|
||||
node("c", "Condenser", "cond", {
|
||||
ua: 5000,
|
||||
z_dp: 1.0,
|
||||
[fixedFlagKey("z_dp")]: true,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: true,
|
||||
}),
|
||||
node("p1", "Probe", "p_probe", {
|
||||
p_bar: 12,
|
||||
[fixedFlagKey("p_bar")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "c", target: "p1", sourceHandle: "inlet", targetHandle: "outlet" },
|
||||
];
|
||||
const patches = calibrateAdjacentActuatorPatches(nodes[1], nodes, edges);
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].params[fixedFlagKey("z_dp")]).toBe(false);
|
||||
// z_ua not compatible with P — stays Fixed
|
||||
expect(patches[0].params[fixedFlagKey("z_ua")]).toBe(true);
|
||||
});
|
||||
|
||||
it("one-click Free z_flow when Probe Capacity Fixed (compressor)", () => {
|
||||
const nodes = [
|
||||
node("comp", "IsentropicCompressor", "comp", {
|
||||
z_flow: 1.0,
|
||||
[fixedFlagKey("z_flow")]: true,
|
||||
f_w: 0.1,
|
||||
[fixedFlagKey("f_w")]: true,
|
||||
}),
|
||||
node("p1", "Probe", "cap", {
|
||||
capacity_w: 12000,
|
||||
[fixedFlagKey("capacity_w")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "comp", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const patches = calibrateAdjacentActuatorPatches(nodes[1], nodes, edges);
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].params[fixedFlagKey("z_flow")]).toBe(false);
|
||||
expect(patches[0].params[fixedFlagKey("f_w")]).toBe(true);
|
||||
});
|
||||
|
||||
it("one-click Free f_w when Probe T Fixed", () => {
|
||||
const nodes = [
|
||||
node("comp", "IsentropicCompressor", "comp", {
|
||||
f_w: 0.05,
|
||||
[fixedFlagKey("f_w")]: true,
|
||||
z_flow: 1.0,
|
||||
[fixedFlagKey("z_flow")]: true,
|
||||
}),
|
||||
node("p1", "Probe", "dgt", {
|
||||
t_c: 75,
|
||||
[fixedFlagKey("t_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "comp", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const patches = calibrateAdjacentActuatorPatches(nodes[1], nodes, edges);
|
||||
expect(patches[0].params[fixedFlagKey("f_w")]).toBe(false);
|
||||
expect(patches[0].params[fixedFlagKey("z_flow")]).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty when compatible actuators already Free", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", {
|
||||
ua: 6000,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
node("p1", "Probe", "sst", {
|
||||
tsat_c: 5,
|
||||
[fixedFlagKey("tsat_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
expect(calibrateAdjacentActuatorPatches(nodes[1], nodes, edges)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tips when Probe Fixed but compatible factor still Fixed", () => {
|
||||
const nodes = [
|
||||
node("e", "Condenser", "cond", {
|
||||
ua: 5000,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: true,
|
||||
}),
|
||||
node("p1", "Probe", "sdt", {
|
||||
tsat_c: 40,
|
||||
[fixedFlagKey("tsat_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "inlet", targetHandle: "outlet" },
|
||||
];
|
||||
const hints = collectCalibHints(nodes, edges, nodes[1]);
|
||||
expect(hints.some((h) => h.level === "tip" && h.message.includes("décoche Fixed"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ok when Probe Fixed + compatible factor Free", () => {
|
||||
const nodes = [
|
||||
node("e", "Condenser", "cond", {
|
||||
ua: 5000,
|
||||
z_dp: 1.0,
|
||||
[fixedFlagKey("z_dp")]: false,
|
||||
}),
|
||||
node("p1", "Probe", "p_probe", {
|
||||
p_bar: 12,
|
||||
[fixedFlagKey("p_bar")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "inlet", targetHandle: "outlet" },
|
||||
];
|
||||
const hints = collectCalibHints(nodes, edges, nodes[0]);
|
||||
expect(hints.some((h) => h.level === "ok" && h.message.includes("z_dp"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns Free factor without adjacent compatible Probe", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", {
|
||||
ua: 6000,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
];
|
||||
const hints = collectCalibHints(nodes, [], nodes[0]);
|
||||
expect(hints.some((h) => h.level === "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("always shows a baseline tip on Probe / HX when nothing paired yet", () => {
|
||||
const probe = node("p1", "Probe", "sst", { tsat_c: 5 });
|
||||
const hx = node("e", "Evaporator", "evap", { ua: 6000, z_ua: 1.0 });
|
||||
expect(collectCalibHints([probe], [], probe).some((h) => h.level === "tip")).toBe(true);
|
||||
expect(collectCalibHints([hx], [], hx).some((h) => h.level === "tip")).toBe(true);
|
||||
});
|
||||
|
||||
it("appends derived UA_eff = UA × z_ua", () => {
|
||||
const nodes = [node("e", "Evaporator", "evap", { ua: 8000, z_ua: 1 })];
|
||||
const solved: SolvedVariable[] = [
|
||||
{
|
||||
id: "evap__z_ua",
|
||||
component: "evap",
|
||||
variable: "z_ua",
|
||||
value: 0.85,
|
||||
min: 0.05,
|
||||
max: 3,
|
||||
},
|
||||
];
|
||||
const out = withDerivedUaEff("evap", nodes, solved);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[1]).toMatchObject({
|
||||
variable: "UA_eff",
|
||||
value: 6800,
|
||||
component: "evap",
|
||||
});
|
||||
});
|
||||
});
|
||||
331
apps/web/src/lib/calibAssist.ts
Normal file
331
apps/web/src/lib/calibAssist.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Generic Modelica Fixed / Free (Unfixed) calibration assistants.
|
||||
*
|
||||
* Engine already emits embeddings for ANY Free `actuatorFactor` + Fixed Probe.
|
||||
* This module only explains DoF balance and offers one-click Free on compatible
|
||||
* adjacent actuators. Nothing is removed from the catalogue (all Z stay).
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
COMPONENT_BY_TYPE,
|
||||
fixedFlagKey,
|
||||
isParamFixed,
|
||||
type ParamMeta,
|
||||
} from "./componentMeta";
|
||||
import { FACTOR_COMPATIBILITY, PROBE_PARAM_KIND } from "./configBuilder";
|
||||
import type { SolvedVariable } from "./api";
|
||||
|
||||
/** Minimal node data shape (avoids heavy coupling beyond pairing tables). */
|
||||
export type CalibNodeData = {
|
||||
type: string;
|
||||
name: string;
|
||||
params: Record<string, number | string | boolean>;
|
||||
circuit?: number;
|
||||
};
|
||||
|
||||
export type CalibHintLevel = "ok" | "tip" | "warn";
|
||||
|
||||
export interface CalibHint {
|
||||
level: CalibHintLevel;
|
||||
/** Short UI message (French). */
|
||||
message: string;
|
||||
component?: string;
|
||||
}
|
||||
|
||||
export interface CalibParamPatch {
|
||||
nodeId: string;
|
||||
params: Record<string, number | string | boolean>;
|
||||
}
|
||||
|
||||
type FixedProbeMeasure = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
factors: readonly string[];
|
||||
};
|
||||
|
||||
function actuatorParams(type: string): ParamMeta[] {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
if (!meta) return [];
|
||||
return meta.params.filter((p) => p.fixable && p.actuatorFactor);
|
||||
}
|
||||
|
||||
function fixedProbeMeasures(probe: Node<CalibNodeData>): FixedProbeMeasure[] {
|
||||
const meta = COMPONENT_BY_TYPE.Probe;
|
||||
if (!meta || probe.data.type !== "Probe") return [];
|
||||
const params = probe.data.params ?? {};
|
||||
const out: FixedProbeMeasure[] = [];
|
||||
|
||||
for (const p of meta.params) {
|
||||
if (!p.fixable || !p.measureOutput) continue;
|
||||
if (!isParamFixed(params, p)) continue;
|
||||
const raw = params[p.key];
|
||||
if (raw === undefined || raw === "") continue;
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
const kind = PROBE_PARAM_KIND[p.key] ?? p.key;
|
||||
const factors = FACTOR_COMPATIBILITY[kind] ?? [];
|
||||
if (factors.length === 0) continue;
|
||||
out.push({ key: p.key, label: p.label, kind, factors });
|
||||
}
|
||||
|
||||
// Legacy measure+target
|
||||
if (out.length === 0) {
|
||||
const fixedFlag = params.__fixed_target;
|
||||
if (fixedFlag === true || fixedFlag === "true") {
|
||||
const kind = String(params.measure ?? "");
|
||||
const factors = FACTOR_COMPATIBILITY[kind] ?? [];
|
||||
if (kind && params.target != null && factors.length > 0) {
|
||||
out.push({
|
||||
key: "target",
|
||||
label: kind,
|
||||
kind,
|
||||
factors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Neighbours connected by an edge (any component type). */
|
||||
export function adjacentNodes(
|
||||
node: Node<CalibNodeData>,
|
||||
nodes: Node<CalibNodeData>[],
|
||||
edges: Edge[],
|
||||
): Node<CalibNodeData>[] {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const out: Node<CalibNodeData>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of edges) {
|
||||
const otherId = e.source === node.id ? e.target : e.target === node.id ? e.source : null;
|
||||
if (!otherId || seen.has(otherId)) continue;
|
||||
const other = byId.get(otherId);
|
||||
if (!other) continue;
|
||||
seen.add(otherId);
|
||||
out.push(other);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Probe names adjacent to a named component. */
|
||||
export function adjacentProbeNames(
|
||||
componentName: string,
|
||||
nodes: Node<CalibNodeData>[],
|
||||
edges: Edge[],
|
||||
): string[] {
|
||||
const byName = new Map(nodes.map((n) => [n.data.name, n]));
|
||||
const comp = byName.get(componentName);
|
||||
if (!comp) return [];
|
||||
return adjacentNodes(comp, nodes, edges)
|
||||
.filter((n) => n.data.type === "Probe")
|
||||
.map((n) => n.data.name);
|
||||
}
|
||||
|
||||
/** True if the node participates in Fixed/Free calibration UX. */
|
||||
export function isCalibRelevantNode(node: Node<CalibNodeData> | null | undefined): boolean {
|
||||
if (!node) return false;
|
||||
if (node.data.type === "Probe") return true;
|
||||
return actuatorParams(node.data.type).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect Modelica Fixed/Free tips for the selected node (or whole diagram).
|
||||
* Does not replace hard Free-without-equation errors from validateConfig.
|
||||
* Always returns at least a baseline tip for Probe / actuator components.
|
||||
*/
|
||||
export function collectCalibHints(
|
||||
nodes: Node<CalibNodeData>[],
|
||||
edges: Edge[],
|
||||
focus?: Node<CalibNodeData> | null,
|
||||
): CalibHint[] {
|
||||
const hints: CalibHint[] = [];
|
||||
|
||||
for (const probe of nodes) {
|
||||
if (probe.data.type !== "Probe") continue;
|
||||
const measures = fixedProbeMeasures(probe);
|
||||
if (measures.length === 0) continue;
|
||||
|
||||
const neighbours = adjacentNodes(probe, nodes, edges).filter(
|
||||
(n) => n.data.type !== "Probe",
|
||||
);
|
||||
|
||||
for (const measure of measures) {
|
||||
for (const neigh of neighbours) {
|
||||
if (
|
||||
focus &&
|
||||
focus.id !== probe.id &&
|
||||
focus.id !== neigh.id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const ap of actuatorParams(neigh.data.type)) {
|
||||
const factor = ap.actuatorFactor!;
|
||||
if (!measure.factors.includes(factor)) continue;
|
||||
const free = !isParamFixed(neigh.data.params ?? {}, ap);
|
||||
if (free) {
|
||||
hints.push({
|
||||
level: "ok",
|
||||
component: neigh.data.name,
|
||||
message:
|
||||
`OK Modelica : Probe « ${probe.data.name} ».${measure.label} Fixed ` +
|
||||
`(équation) + ${factor} Free sur « ${neigh.data.name} » (inconnue).`,
|
||||
});
|
||||
} else {
|
||||
hints.push({
|
||||
level: "tip",
|
||||
component: neigh.data.name,
|
||||
message:
|
||||
`Pour calibrer : décoche Fixed sur ${factor} de « ${neigh.data.name} » ` +
|
||||
`(Probe « ${probe.data.name} ».${measure.label} impose déjà la mesure).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focus) {
|
||||
for (const ap of actuatorParams(focus.data.type)) {
|
||||
if (isParamFixed(focus.data.params ?? {}, ap)) continue;
|
||||
const factor = ap.actuatorFactor!;
|
||||
const probes = adjacentNodes(focus, nodes, edges).filter((n) => n.data.type === "Probe");
|
||||
const hasEquation = probes.some((pr) =>
|
||||
fixedProbeMeasures(pr).some((m) => m.factors.includes(factor)),
|
||||
);
|
||||
if (!hasEquation) {
|
||||
hints.push({
|
||||
level: "warn",
|
||||
component: focus.data.name,
|
||||
message:
|
||||
`${factor} est Free (inconnue) : ajoute une Probe Fixed compatible ` +
|
||||
`(${compatibleMeasureLabels(factor).join(", ") || "mesure"}) ` +
|
||||
`sur le circuit, ou remets ${factor} Fixed.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Baseline always visible — otherwise users see nothing when all Fixed (default).
|
||||
if (hints.length === 0 && isCalibRelevantNode(focus)) {
|
||||
if (focus.data.type === "Probe") {
|
||||
const measures = fixedProbeMeasures(focus);
|
||||
if (measures.length === 0) {
|
||||
hints.push({
|
||||
level: "tip",
|
||||
component: focus.data.name,
|
||||
message:
|
||||
"Fixed/Free : coche Fixed sur une mesure (Tsat, P, T, Capacity…) " +
|
||||
"puis libère le facteur compatible sur le composant adjacent " +
|
||||
"(z_ua, z_dp, z_flow, f_w).",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const factors = actuatorParams(focus.data.type)
|
||||
.map((p) => p.actuatorFactor)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
hints.push({
|
||||
level: "tip",
|
||||
component: focus.data.name,
|
||||
message:
|
||||
`Fixed/Free : facteurs ${factors || "Z"} — Fixed = connu, Free = inconnue. ` +
|
||||
`Pour calibrer : Probe Fixed adjacent + décoche Fixed sur le facteur.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
function compatibleMeasureLabels(factor: string): string[] {
|
||||
const labels: string[] = [];
|
||||
for (const [kind, factors] of Object.entries(FACTOR_COMPATIBILITY)) {
|
||||
if (factors.includes(factor)) labels.push(kind);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click: Free every Fixed actuator on neighbours that is compatible with
|
||||
* this Probe's Fixed measures. Returns patches for `updateNodesParams`.
|
||||
*/
|
||||
export function calibrateAdjacentActuatorPatches(
|
||||
probeNode: Node<CalibNodeData>,
|
||||
nodes: Node<CalibNodeData>[],
|
||||
edges: Edge[],
|
||||
): CalibParamPatch[] {
|
||||
if (probeNode.data.type !== "Probe") return [];
|
||||
const measures = fixedProbeMeasures(probeNode);
|
||||
if (measures.length === 0) return [];
|
||||
|
||||
const wanted = new Set(measures.flatMap((m) => [...m.factors]));
|
||||
const byId = new Map<string, CalibParamPatch>();
|
||||
|
||||
for (const neigh of adjacentNodes(probeNode, nodes, edges)) {
|
||||
if (neigh.data.type === "Probe") continue;
|
||||
let next = { ...(neigh.data.params ?? {}) };
|
||||
let changed = false;
|
||||
for (const ap of actuatorParams(neigh.data.type)) {
|
||||
const factor = ap.actuatorFactor!;
|
||||
if (!wanted.has(factor)) continue;
|
||||
if (!isParamFixed(next, ap)) continue;
|
||||
next = {
|
||||
...next,
|
||||
[fixedFlagKey(ap.key)]: false,
|
||||
[ap.key]:
|
||||
typeof next[ap.key] === "number"
|
||||
? next[ap.key]
|
||||
: (ap.default as number | undefined) ?? 1.0,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
byId.set(neigh.id, { nodeId: neigh.id, params: next });
|
||||
}
|
||||
}
|
||||
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** @deprecated Prefer {@link calibrateAdjacentActuatorPatches}. */
|
||||
export function calibrateAdjacentZUaPatch(
|
||||
probeNode: Node<CalibNodeData>,
|
||||
nodes: Node<CalibNodeData>[],
|
||||
edges: Edge[],
|
||||
): CalibParamPatch | null {
|
||||
const patches = calibrateAdjacentActuatorPatches(probeNode, nodes, edges);
|
||||
const zUa = patches.find((p) => p.params[fixedFlagKey("z_ua")] === false);
|
||||
return zUa ?? patches[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append derived UA_eff = UA × z_ua when both are known after solve.
|
||||
*/
|
||||
export function withDerivedUaEff(
|
||||
componentName: string,
|
||||
nodes: Node<CalibNodeData>[],
|
||||
solved: SolvedVariable[],
|
||||
): SolvedVariable[] {
|
||||
const node = nodes.find((n) => n.data.name === componentName);
|
||||
if (!node) return solved;
|
||||
const z = solved.find((s) => s.variable === "z_ua");
|
||||
if (!z) return solved;
|
||||
const uaRaw = node.data.params?.ua;
|
||||
const ua = typeof uaRaw === "number" ? uaRaw : Number(uaRaw);
|
||||
if (!Number.isFinite(ua) || ua <= 0) return solved;
|
||||
if (solved.some((s) => s.variable === "UA_eff")) return solved;
|
||||
return [
|
||||
...solved,
|
||||
{
|
||||
id: `${componentName}__UA_eff`,
|
||||
component: componentName,
|
||||
variable: "UA_eff",
|
||||
value: ua * z.value,
|
||||
min: 0,
|
||||
max: ua * z.max,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -505,7 +505,7 @@ describe("Modelica embeddings (Fixed / Free)", () => {
|
||||
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 },
|
||||
unknown: { component: "evap", factor: "z_ua", start: 1.0, min: 0.05 },
|
||||
equation: {
|
||||
component: "sst_probe",
|
||||
output: "saturationTemperature",
|
||||
@@ -519,7 +519,50 @@ describe("Modelica embeddings (Fixed / Free)", () => {
|
||||
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
|
||||
// Legacy HX: ua is the nominal base — keep it when Free z_ua.
|
||||
expect(comp.ua).toBe(9000);
|
||||
});
|
||||
|
||||
it("strips BPHX ua override when Z_UA is Free (geometry = UA_nominal)", () => {
|
||||
const nodes = [
|
||||
node("c", "BphxCondenser", "cond", 0, {
|
||||
n_plates: 40,
|
||||
ua: 2500,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
node("p1", "Probe", "sdt_probe", 0, {
|
||||
fluid: "R134a",
|
||||
tsat_c: 42.0,
|
||||
[fixedFlagKey("tsat_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "pe", source: "c", target: "p1", sourceHandle: "inlet", targetHandle: "outlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.embeddings).toHaveLength(1);
|
||||
expect(cfg.circuits[0].components[0].ua).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fills missing required ua on FloodedEvaporator when Z_UA is Free", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, {
|
||||
// ua intentionally missing / cleared in the canvas
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
node("p1", "Probe", "sst_probe", 0, {
|
||||
fluid: "R134a",
|
||||
tsat_c: 5.0,
|
||||
[fixedFlagKey("tsat_c")]: true,
|
||||
}),
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, [
|
||||
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
]);
|
||||
expect(cfg.circuits[0].components[0].ua).toBe(8000);
|
||||
expect(cfg.embeddings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits no embedding when Z_UA stays Fixed even with a Probe present", () => {
|
||||
|
||||
@@ -310,8 +310,11 @@ export function buildScenarioConfig(
|
||||
const circuits = Array.from(circuitsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([circuitId, cNodes]) => {
|
||||
// Stable name order — React Flow node array order changes on drag/select.
|
||||
const components = cNodes
|
||||
.filter((n) => n.data.type !== CONTROL_NODE_TYPE)
|
||||
.slice()
|
||||
.sort((a, b) => a.data.name.localeCompare(b.data.name))
|
||||
.map((n) => {
|
||||
const { type, name, params } = n.data;
|
||||
const canonicalParams = canonicalizeParams(type, params);
|
||||
@@ -394,9 +397,8 @@ 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)
|
||||
* - BPHX only: when Z_UA is Free, omit absolute `ua` override (geometry = UA_nominal)
|
||||
* - fill missing *required* params from catalogue defaults (e.g. FloodedEvaporator.ua)
|
||||
*/
|
||||
export function stripUiOnlyParams(
|
||||
type: string,
|
||||
@@ -417,12 +419,25 @@ export function stripUiOnlyParams(
|
||||
out[k] = v;
|
||||
}
|
||||
|
||||
// Free z_ua → live embedding owns UA scaling; drop absolute `ua` override.
|
||||
// Free z_ua on BPHX: drop absolute `ua` override (geometry supplies UA_nominal).
|
||||
// Legacy Condenser / Evaporator / Flooded*: keep `ua` — constructor nominal.
|
||||
const zUaMeta = meta?.params.find((p) => p.key === "z_ua" && p.actuatorFactor === "z_ua");
|
||||
if (zUaMeta && !isParamFixed(params, zUaMeta)) {
|
||||
const bphxUaIsOverride =
|
||||
type === "BphxCondenser" || type === "BphxEvaporator" || type === "BphxExchanger";
|
||||
if (bphxUaIsOverride && zUaMeta && !isParamFixed(params, zUaMeta)) {
|
||||
delete out.ua;
|
||||
}
|
||||
|
||||
// Required catalogue defaults (safety net when the canvas field was cleared).
|
||||
for (const p of meta?.params ?? []) {
|
||||
if (!p.required || p.default === undefined) continue;
|
||||
if (bphxUaIsOverride && p.key === "ua") continue;
|
||||
const cur = out[p.key];
|
||||
if (cur === undefined || cur === "") {
|
||||
out[p.key] = p.default;
|
||||
}
|
||||
}
|
||||
|
||||
return applyExvFixSemantics(type, applyBoundaryFixSemantics(type, out, params), params);
|
||||
}
|
||||
|
||||
@@ -596,13 +611,9 @@ export function buildModelEmbeddings(
|
||||
|
||||
if (p.actuatorFactor && !fixed) {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
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;
|
||||
}
|
||||
// Keep the UI value as Newton start (do not rewrite 1.0 → 0.3 — that
|
||||
// pushed Free Z into a fragile basin and amplified HashMap-order flakes).
|
||||
const initial = Number.isFinite(n) ? n : 1.0;
|
||||
freeActs.push({
|
||||
factor: p.actuatorFactor,
|
||||
initial,
|
||||
@@ -614,6 +625,7 @@ export function buildModelEmbeddings(
|
||||
}
|
||||
|
||||
if (freeActs.length > 0) {
|
||||
freeActs.sort((a, b) => a.factor.localeCompare(b.factor));
|
||||
freeActsByComponent.set(node.data.name, { nodeName: node.data.name, node, acts: freeActs });
|
||||
}
|
||||
}
|
||||
@@ -660,7 +672,10 @@ export function buildModelEmbeddings(
|
||||
};
|
||||
|
||||
const usedKeys = new Set<string>();
|
||||
for (const { nodeName, acts } of freeActsByComponent.values()) {
|
||||
const freeEntries = [...freeActsByComponent.values()].sort((a, b) =>
|
||||
a.nodeName.localeCompare(b.nodeName),
|
||||
);
|
||||
for (const { nodeName, acts } of freeEntries) {
|
||||
for (const act of acts) {
|
||||
const probe = findProbeFor(nodeName, act.factor, usedKeys);
|
||||
if (probe) {
|
||||
@@ -683,6 +698,7 @@ export function buildModelEmbeddings(
|
||||
}
|
||||
}
|
||||
|
||||
embeddings.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
@@ -709,7 +725,7 @@ export function buildFixedFreeCalibrationControls(
|
||||
}
|
||||
|
||||
/** Probe param key → semantic kind for pairing. */
|
||||
const PROBE_PARAM_KIND: Record<string, string> = {
|
||||
export const PROBE_PARAM_KIND: Record<string, string> = {
|
||||
t_c: "T",
|
||||
tsat_c: "Tsat",
|
||||
p_bar: "P",
|
||||
@@ -779,8 +795,11 @@ function legacyProbeOutput(kind: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe physical kind → free factors it can calibrate. */
|
||||
const FACTOR_COMPATIBILITY: Record<string, readonly string[]> = {
|
||||
/**
|
||||
* Probe physical kind → free factors it can calibrate (Modelica Fixed ↔ Free).
|
||||
* Shared by emit (`buildModelEmbeddings`) and UI aides (`calibAssist`).
|
||||
*/
|
||||
export const FACTOR_COMPATIBILITY: Record<string, readonly string[]> = {
|
||||
Tsat: ["z_ua"],
|
||||
Tsh: ["z_ua", "opening"],
|
||||
T: ["f_w"],
|
||||
|
||||
Reference in New Issue
Block a user