diff --git a/apps/web/src/components/panels/PropertiesPanel.tsx b/apps/web/src/components/panels/PropertiesPanel.tsx
index 80ce19c..c8cebf6 100644
--- a/apps/web/src/components/panels/PropertiesPanel.tsx
+++ b/apps/web/src/components/panels/PropertiesPanel.tsx
@@ -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,20 +351,37 @@ export default function PropertiesPanel() {
{h.message}
))}
- {calibActuatorPatches.length > 0 && (
-
- )}
+
+ {tripleCalibPatches.length > 0 && (
+
+ )}
+ {calibActuatorPatches.length > 0 && (
+
+ )}
+
)}
diff --git a/apps/web/src/lib/calibAssist.test.ts b/apps/web/src/lib/calibAssist.test.ts
index 36e5374..f195957 100644
--- a/apps/web/src/lib/calibAssist.test.ts
+++ b/apps/web/src/lib/calibAssist.test.ts
@@ -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[] = [
diff --git a/apps/web/src/lib/calibAssist.ts b/apps/web/src/lib/calibAssist.ts
index d262890..ac31c6c 100644
--- a/apps/web/src/lib/calibAssist.ts
+++ b/apps/web/src/lib/calibAssist.ts
@@ -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 n’a 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[],
+ edges: Edge[],
+): CalibParamPatch[] {
+ const probes = nodes.filter((n) => n.data.type === "Probe");
+ let suction: Node | undefined;
+ let discharge: Node | undefined;
+ let evap: Node | undefined;
+ let cond: Node | undefined;
+ let comp: Node | 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.
*/
diff --git a/apps/web/src/lib/componentMeta.ts b/apps/web/src/lib/componentMeta.ts
index fa61c28..2d35048 100644
--- a/apps/web/src/lib/componentMeta.ts
+++ b/apps/web/src/lib/componentMeta.ts
@@ -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.98 ≈ 2 % perdu, 0 = tout perdu. Fixed OFF + Probe T (DGT) sur refoulement.",
+ "Fraction du travail conservée dans le fluide : 1 = adiabatique, 0.95 ≈ 5 % perdu. Fixed OFF + Probe T (DGT) sur refoulement. Ne pas partir à 1.0 (borne).",
},
],
},
diff --git a/apps/web/src/lib/configBuilder.ts b/apps/web/src/lib/configBuilder.ts
index 7fe99b0..8a12e82 100644
--- a/apps/web/src/lib/configBuilder.ts
+++ b/apps/web/src/lib/configBuilder.ts
@@ -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,