Add one-click triple calib setup and warn on orphan Probe Fixed.
Some checks failed
CI / check (push) Has been cancelled

Guide SST/SDT/DGT pairing for evap z_ua, cond z_ua, and f_w; avoid bound f_w=1 and Fixed P/X that confuse DoF.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 23:34:03 +02:00
parent 9b4b7b58b0
commit c034f20549
5 changed files with 210 additions and 23 deletions

View File

@@ -43,6 +43,7 @@ import {
calibrateAdjacentActuatorPatches,
collectCalibHints,
isCalibRelevantNode,
setupTripleCycleCalibPatches,
withDerivedUaEff,
} from "@/lib/calibAssist";
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
@@ -161,6 +162,11 @@ export default function PropertiesPanel() {
return calibrateAdjacentActuatorPatches(node, nodes, edges);
}, [node, nodes, edges]);
const tripleCalibPatches = useMemo(
() => setupTripleCycleCalibPatches(nodes, edges),
[nodes, edges],
);
// After a successful solve, jump to Results when selecting a part (Dymola-like).
useEffect(() => {
if (!node) return;
@@ -345,10 +351,26 @@ export default function PropertiesPanel() {
{h.message}
</p>
))}
<div className="flex flex-wrap gap-1 pt-0.5">
{tripleCalibPatches.length > 0 && (
<button
type="button"
className="rounded border border-sky-700 bg-sky-700 px-2 py-1 text-[10px] font-medium text-white hover:bg-sky-800"
title="probe_1 Tsat→evap z_ua · probe_2 T→f_w · probe_2 Tsat→cond z_ua"
onClick={() => {
const map = new Map(
tripleCalibPatches.map((p) => [p.nodeId, p.params]),
);
updateNodesParams(map);
}}
>
Configurer f_w + z_ua evap/cond
</button>
)}
{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"
className="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]),
@@ -360,6 +382,7 @@ export default function PropertiesPanel() {
</button>
)}
</div>
</div>
)}
{!isRegLoop && panelMode === "results" && (

View File

@@ -5,6 +5,7 @@ import {
adjacentProbeNames,
calibrateAdjacentActuatorPatches,
collectCalibHints,
setupTripleCycleCalibPatches,
withDerivedUaEff,
type CalibNodeData,
} from "./calibAssist";
@@ -203,6 +204,41 @@ describe("calibAssist — Fixed/Free générique", () => {
expect(collectCalibHints([hx], [], hx).some((h) => h.level === "tip")).toBe(true);
});
it("setupTripleCycleCalibPatches wires SST/SDT/DGT to z_ua/f_w", () => {
const nodes = [
node("e", "FloodedEvaporator", "evap", { ua: 9000, z_ua: 1 }),
node("p1", "Probe", "probe_1", { tsat_c: 4.4 }),
node("c", "IsentropicCompressor", "comp", { f_w: 1, z_flow: 1 }),
node("p2", "Probe", "probe_2", {
t_c: 44,
tsat_c: 36,
p_bar: 5,
x: 1,
[fixedFlagKey("p_bar")]: true,
[fixedFlagKey("x")]: true,
}),
node("d", "Condenser", "cond", { ua: 2200, z_ua: 1 }),
];
const edges: Edge[] = [
{ id: "e1", source: "e", target: "p1" },
{ id: "e2", source: "p1", target: "c" },
{ id: "e3", source: "c", target: "p2" },
{ id: "e4", source: "p2", target: "d" },
];
const patches = setupTripleCycleCalibPatches(nodes, edges);
expect(patches.length).toBe(5);
const byId = Object.fromEntries(patches.map((p) => [p.nodeId, p.params]));
expect(byId.p1[fixedFlagKey("tsat_c")]).toBe(true);
expect(byId.p2[fixedFlagKey("t_c")]).toBe(true);
expect(byId.p2[fixedFlagKey("tsat_c")]).toBe(true);
expect(byId.p2[fixedFlagKey("p_bar")]).toBe(false);
expect(byId.p2[fixedFlagKey("x")]).toBe(false);
expect(byId.e[fixedFlagKey("z_ua")]).toBe(false);
expect(byId.d[fixedFlagKey("z_ua")]).toBe(false);
expect(byId.c[fixedFlagKey("f_w")]).toBe(false);
expect(byId.c.f_w).toBe(0.95);
});
it("appends derived UA_eff = UA × z_ua", () => {
const nodes = [node("e", "Evaporator", "evap", { ua: 8000, z_ua: 1 })];
const solved: SolvedVariable[] = [

View File

@@ -207,6 +207,53 @@ export function collectCalibHints(
}
}
// Orphan Fixed measures (no compatible factor, or no Free partner) confuse DoF.
if (focus.data.type === "Probe") {
for (const m of fixedProbeMeasures(focus)) {
if (m.factors.length === 0) {
hints.push({
level: "warn",
component: focus.data.name,
message:
`${m.label} Fixed na pas de facteur compatible — décoche Fixed ` +
`(ex. X/titre ne calibre rien).`,
});
continue;
}
const neigh = adjacentNodes(focus, nodes, edges).filter((n) => n.data.type !== "Probe");
const hasPartner = neigh.some((n) =>
actuatorParams(n.data.type).some(
(ap) => ap.actuatorFactor && m.factors.includes(ap.actuatorFactor),
),
);
if (!hasPartner) {
hints.push({
level: "warn",
component: focus.data.name,
message:
`${m.label} Fixed sans composant adjacent avec ${m.factors.join("/")}` +
`décoche Fixed ou place la Probe sur le bon fil.`,
});
} else if (m.kind === "P") {
const zDpFree = neigh.some((n) =>
actuatorParams(n.data.type).some(
(ap) =>
ap.actuatorFactor === "z_dp" && !isParamFixed(n.data.params ?? {}, ap),
),
);
if (!zDpFree) {
hints.push({
level: "warn",
component: focus.data.name,
message:
`P Fixed sans z_dp Free adjacent — décoche P (sinon Jacobien / DoF confus). ` +
`Pour ΔP : Free z_dp sur le HX.`,
});
}
}
}
}
// Baseline always visible — otherwise users see nothing when all Fixed (default).
if (hints.length === 0 && isCalibRelevantNode(focus)) {
if (focus.data.type === "Probe") {
@@ -216,9 +263,9 @@ export function collectCalibHints(
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).",
"Calib cycle : probe aspiration → Fixed Tsat (SST) + Free z_ua évap ; " +
"probe refoulement → Fixed T (DGT) + Free f_w, Fixed Tsat (SDT) + Free z_ua cond. " +
"Ne coche pas P ni X.",
});
}
} else {
@@ -301,6 +348,84 @@ export function calibrateAdjacentZUaPatch(
return zUa ?? patches[0] ?? null;
}
/**
* One-click DoF for a standard chiller/HP cycle:
* probe suction → Fixed Tsat → Free evap z_ua
* probe discharge → Fixed T + Fixed Tsat → Free comp f_w + Free cond z_ua
* Unfixes orphan P/X on those probes.
*/
export function setupTripleCycleCalibPatches(
nodes: Node<CalibNodeData>[],
edges: Edge[],
): CalibParamPatch[] {
const probes = nodes.filter((n) => n.data.type === "Probe");
let suction: Node<CalibNodeData> | undefined;
let discharge: Node<CalibNodeData> | undefined;
let evap: Node<CalibNodeData> | undefined;
let cond: Node<CalibNodeData> | undefined;
let comp: Node<CalibNodeData> | undefined;
for (const n of nodes) {
const t = n.data.type;
if (!evap && t.includes("Evaporator")) evap = n;
if (!cond && t.includes("Condenser")) cond = n;
if (!comp && t.includes("Compressor")) comp = n;
}
for (const p of probes) {
const adj = adjacentNodes(p, nodes, edges);
const nearComp = adj.some((a) => a.data.type.includes("Compressor"));
const nearEvap = adj.some((a) => a.data.type.includes("Evaporator"));
const nearCond = adj.some((a) => a.data.type.includes("Condenser"));
if (nearComp && nearEvap) suction = p;
if (nearComp && nearCond) discharge = p;
}
if (!suction || !discharge || !evap || !cond || !comp) return [];
const patches: CalibParamPatch[] = [];
const suctionParams = { ...(suction.data.params ?? {}) };
suctionParams[fixedFlagKey("tsat_c")] = true;
suctionParams[fixedFlagKey("t_c")] = false;
suctionParams[fixedFlagKey("p_bar")] = false;
suctionParams[fixedFlagKey("x")] = false;
suctionParams[fixedFlagKey("tsh_k")] = false;
suctionParams[fixedFlagKey("capacity_w")] = false;
if (suctionParams.tsat_c === undefined) suctionParams.tsat_c = 4.4;
patches.push({ nodeId: suction.id, params: suctionParams });
const dischargeParams = { ...(discharge.data.params ?? {}) };
dischargeParams[fixedFlagKey("t_c")] = true;
dischargeParams[fixedFlagKey("tsat_c")] = true;
dischargeParams[fixedFlagKey("p_bar")] = false;
dischargeParams[fixedFlagKey("x")] = false;
dischargeParams[fixedFlagKey("tsh_k")] = false;
dischargeParams[fixedFlagKey("capacity_w")] = false;
if (dischargeParams.t_c === undefined) dischargeParams.t_c = 44;
if (dischargeParams.tsat_c === undefined) dischargeParams.tsat_c = 36;
patches.push({ nodeId: discharge.id, params: dischargeParams });
const evapParams = { ...(evap.data.params ?? {}) };
evapParams[fixedFlagKey("z_ua")] = false;
if (typeof evapParams.z_ua !== "number") evapParams.z_ua = 1.0;
patches.push({ nodeId: evap.id, params: evapParams });
const condParams = { ...(cond.data.params ?? {}) };
condParams[fixedFlagKey("z_ua")] = false;
condParams[fixedFlagKey("z_dp")] = true;
if (typeof condParams.z_ua !== "number") condParams.z_ua = 1.0;
patches.push({ nodeId: cond.id, params: condParams });
const compParams = { ...(comp.data.params ?? {}) };
compParams[fixedFlagKey("f_w")] = false;
compParams[fixedFlagKey("z_flow")] = true;
compParams.f_w = 0.95;
patches.push({ nodeId: comp.id, params: compParams });
return patches;
}
/**
* Append derived UA_eff = UA × z_ua when both are known after solve.
*/

View File

@@ -531,17 +531,17 @@ export const COMPONENTS: ComponentMeta[] = [
key: "f_w",
label: "f_w (rétention énergie)",
kind: "number",
default: 1.0,
default: 0.95,
min: 0,
max: 1,
section: "Calibration",
fixable: true,
defaultFixed: true,
actuatorFactor: "f_w",
freeMin: 0.0,
freeMin: 0.5,
freeMax: 1.0,
description:
"Fraction du travail conservée dans le fluide : 1 = adiabatique, 0.982 % perdu, 0 = tout perdu. Fixed OFF + Probe T (DGT) sur refoulement.",
"Fraction du travail conservée dans le fluide : 1 = adiabatique, 0.955 % perdu. Fixed OFF + Probe T (DGT) sur refoulement. Ne pas partir à 1.0 (borne).",
},
],
},

View File

@@ -611,9 +611,12 @@ export function buildModelEmbeddings(
if (p.actuatorFactor && !fixed) {
const n = typeof raw === "number" ? raw : Number(raw);
// 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;
// Keep the UI value as Newton start. For f_w, avoid the upper bound 1.0
// (adiabatic) which saturates the unknown and can singularize J.
let initial = Number.isFinite(n) ? n : (p.default as number) ?? 1.0;
if (p.actuatorFactor === "f_w" && Math.abs(initial - 1.0) < 1e-12) {
initial = 0.95;
}
freeActs.push({
factor: p.actuatorFactor,
initial,