Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Checkpoint incomplete calibration work (cond SDT green, evap SST failing) plus related solver/UI changes so the next pass can fix and extend safely. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
||||
} from "@/lib/mediaStyle";
|
||||
import {
|
||||
findNearestEdge,
|
||||
isPipeType,
|
||||
isInsertableOnEdge,
|
||||
pipeMediaKind,
|
||||
} from "@/lib/edgeInsert";
|
||||
import { defaultParams } from "@/lib/componentMeta";
|
||||
@@ -154,17 +154,24 @@ export default function Canvas({ onOpenModuleEditor }: CanvasProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pipe / duct dropped on a wire → splice into the circuit (Modelica-style).
|
||||
if (isPipeType(type)) {
|
||||
const prefer = pipeMediaKind(type, defaultParams(type));
|
||||
// Pipe / duct / Probe dropped on a wire → splice into the circuit
|
||||
// (Modelica-style). Probe is a zero-residual measurement tap that uses
|
||||
// the same insertOnEdge path.
|
||||
if (isInsertableOnEdge(type)) {
|
||||
// A Probe can tap ANY medium (refrigerant / water / air) — a sensor sits
|
||||
// wherever the physics is, so we don't filter by media for it. Pipes
|
||||
// still get media filtering so a water pipe doesn't splice into a
|
||||
// refrigerant line.
|
||||
const prefer = type === "Probe" ? undefined : pipeMediaKind(type, defaultParams(type));
|
||||
const hit = findNearestEdge(position, nodes, edges, {
|
||||
maxDistance: 64,
|
||||
// Probes are small targets — be more forgiving on the drop distance.
|
||||
maxDistance: type === "Probe" ? 96 : 64,
|
||||
preferMedia: prefer,
|
||||
});
|
||||
if (hit) {
|
||||
const result = insertOnEdge(type, hit.midpoint, hit.edge.id);
|
||||
if (result.ok) return;
|
||||
// Media mismatch: fall through to free placement.
|
||||
// Media mismatch / splice failed: fall through to free placement.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,22 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
|
||||
<path d="M16 60 C26 34 38 34 48 60 S 70 78 84 52" {...accent(c, 2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Probe — small measurement tap on a connection line ─────────
|
||||
// A filled circle with a thin ring + a small dot inside. Deliberately
|
||||
// compact (the 36×36 node footprint makes it read as a tap, not a block).
|
||||
// The four short ticks hint at a sensor crosshair.
|
||||
probe: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="34" {...line(2)} />
|
||||
<circle cx="50" cy="50" r="20" {...accent(c, 1.6)} />
|
||||
<circle cx="50" cy="50" r="6" fill={c} stroke="none" />
|
||||
<path
|
||||
d="M50 8 V22 M50 78 V92 M8 50 H22 M78 50 H92"
|
||||
{...line(1.4)}
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
function glyphKey(type?: string, opts?: HxVisualOpts): keyof typeof GLYPHS {
|
||||
@@ -314,6 +330,7 @@ function glyphKey(type?: string, opts?: HxVisualOpts): keyof typeof GLYPHS {
|
||||
if (hx && hx in GLYPHS) return hx as keyof typeof GLYPHS;
|
||||
|
||||
if (t === "SaturatedController") return "control";
|
||||
if (t === "Probe") return "probe";
|
||||
if (t.includes("Compressor")) return "compressor";
|
||||
if (t === "HeatExchanger") return "hx";
|
||||
if (t === "ReversingValve") return "reversing";
|
||||
|
||||
@@ -427,17 +427,25 @@ export default function PropertiesPanel() {
|
||||
: isParamFixed(params, p);
|
||||
const free = Boolean(p.fixable && !fixed);
|
||||
const displayVal = effectiveParamValue(params, p);
|
||||
// Probe: label the target row with the chosen measurement kind
|
||||
// (SST / SDT / SH / SC / …) instead of the generic "Cible".
|
||||
const label =
|
||||
node.data.type === "Probe" && p.key === "target"
|
||||
? (COMPONENT_BY_TYPE["Probe"]?.params
|
||||
.find((mp) => mp.key === "measure")
|
||||
?.options?.find((o) => o.value === params.measure)?.label ?? p.label)
|
||||
: p.label;
|
||||
return (
|
||||
<tr
|
||||
key={p.key}
|
||||
className="border-b border-[var(--line)]/70 hover:bg-[var(--chrome-2)]/80"
|
||||
title={p.description ?? p.label}
|
||||
title={p.description ?? label}
|
||||
>
|
||||
<td
|
||||
className="max-w-[140px] truncate px-2 py-1 text-[var(--ink-dim)]"
|
||||
title={p.description ?? p.label}
|
||||
title={p.description ?? label}
|
||||
>
|
||||
{p.label}
|
||||
{label}
|
||||
{p.required ? <span className="text-[var(--hot)]"> *</span> : null}
|
||||
</td>
|
||||
<td className="px-1 py-0.5">
|
||||
@@ -543,9 +551,9 @@ 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>Comment calibrer :</strong> coche Fixed sur la cible (SST/SDT), décoche
|
||||
Fixed sur le facteur (Z_UA). Z_UA vaut <strong>1</strong> par défaut (pas de
|
||||
correction).
|
||||
<strong>Calibration :</strong> pose une <strong>Probe</strong> (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.
|
||||
</p>
|
||||
<p className="text-[var(--ink-faint)]">
|
||||
Tu n’as pas besoin du nœud « Regulation loop » pour ça.
|
||||
|
||||
@@ -158,6 +158,7 @@ export function portRole(port: string): "source" | "target" {
|
||||
*/
|
||||
export function nodeSize(type: string): { w: number; h: number } {
|
||||
const t = String(type || "");
|
||||
if (t === "Probe") return { w: 36, h: 36 };
|
||||
if (t === "SaturatedController") return { w: 184, h: 96 };
|
||||
if (t.includes("Compressor")) return { w: 76, h: 76 };
|
||||
if (t === "HeatExchanger") return { w: 92, h: 92 };
|
||||
@@ -207,11 +208,80 @@ export const COMPONENT_CATEGORIES = [
|
||||
"Piping",
|
||||
"Hydraulics",
|
||||
"Boundaries",
|
||||
"Instrumentation",
|
||||
"Advanced",
|
||||
"Generic",
|
||||
] as const;
|
||||
|
||||
export const COMPONENTS: ComponentMeta[] = [
|
||||
// ── Instrumentation: measurement taps for calibration (Probe = all measurements) ──
|
||||
{
|
||||
type: "Probe",
|
||||
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.",
|
||||
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).",
|
||||
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",
|
||||
kind: "string",
|
||||
default: "R134a",
|
||||
required: true,
|
||||
section: "Mesure",
|
||||
description: "Fluide frigorigène pour le calcul de Tsat/SH/SC (via CoolProp).",
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "Cible (Fixed = impose)",
|
||||
kind: "number",
|
||||
section: "Calibration",
|
||||
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",
|
||||
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).",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Advanced regulation (optional — most users use Fixed on Calibration tab) ──
|
||||
{
|
||||
type: "SaturatedController",
|
||||
@@ -739,19 +809,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "fan_head_pressure_target_c", label: "Fan head-pressure target", kind: "number", unit: "°C", section: "Controls", advanced: true },
|
||||
{ key: "flooded_head_pressure_target_c", label: "Flooded head-pressure target", kind: "number", unit: "°C", section: "Controls", advanced: true },
|
||||
{ key: "skip_pressure_eq", label: "Skip pressure equation", kind: "boolean", default: false, section: "Solver structure", advanced: true },
|
||||
// Fixed checkbox: ON = impose SDT; OFF = ignore as measure
|
||||
{
|
||||
key: "calib_sdt_c",
|
||||
label: "SDT (calib target)",
|
||||
kind: "number",
|
||||
unit: "°C",
|
||||
default: 40.0,
|
||||
section: "Calibration",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "saturationTemperature",
|
||||
description: "Cocher Fixed pour imposer la SDT (mesure). Libérer Z_UA en même temps.",
|
||||
},
|
||||
{
|
||||
key: "z_ua",
|
||||
label: "Z_UA (UA factor)",
|
||||
@@ -803,18 +860,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 6000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
||||
{ key: "t_sat_k", label: "Fixed saturation temperature", kind: "number", unit: "K", default: 275.15, section: "Thermal model", description: "Used only in fixed-pressure mode." },
|
||||
{ key: "superheat_k", label: "Outlet superheat closure", kind: "number", unit: "K", default: 5.0, section: "Thermal model", min: 0.0 },
|
||||
{
|
||||
key: "calib_sst_c",
|
||||
label: "SST (calib target)",
|
||||
kind: "number",
|
||||
unit: "°C",
|
||||
default: 5.0,
|
||||
section: "Calibration",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "saturationTemperature",
|
||||
description: "Cocher Fixed pour imposer la SST. Décocher Fixed sur Z_UA pour calibrer.",
|
||||
},
|
||||
{
|
||||
key: "z_ua",
|
||||
label: "Z_UA (UA factor)",
|
||||
@@ -1090,17 +1135,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
],
|
||||
},
|
||||
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
||||
{
|
||||
key: "calib_sst_c",
|
||||
label: "SST (calib target)",
|
||||
kind: "number",
|
||||
unit: "°C",
|
||||
default: 5.0,
|
||||
section: "Calibration",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "saturationTemperature",
|
||||
},
|
||||
{
|
||||
key: "z_ua",
|
||||
label: "Z_UA (UA factor)",
|
||||
@@ -1190,17 +1224,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
],
|
||||
},
|
||||
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
||||
{
|
||||
key: "calib_sdt_c",
|
||||
label: "SDT (calib target)",
|
||||
kind: "number",
|
||||
unit: "°C",
|
||||
default: 40.0,
|
||||
section: "Calibration",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "saturationTemperature",
|
||||
},
|
||||
{
|
||||
key: "z_ua",
|
||||
label: "Z_UA (UA factor)",
|
||||
@@ -1270,18 +1293,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
section: "Secondary hydraulics",
|
||||
min: 0.0,
|
||||
},
|
||||
{
|
||||
key: "calib_sst_c",
|
||||
label: "SST (calib target)",
|
||||
kind: "number",
|
||||
unit: "°C",
|
||||
default: 5.0,
|
||||
section: "Calibration",
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
measureOutput: "saturationTemperature",
|
||||
description: "Cocher Fixed pour imposer la SST mesurée. Décocher Fixed sur Z_UA.",
|
||||
},
|
||||
{
|
||||
key: "z_ua",
|
||||
label: "Z_UA (UA factor)",
|
||||
|
||||
@@ -483,44 +483,68 @@ describe("caloporteur (secondary stream) resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fixed / Free calibration (Dymola-style checkboxes)", () => {
|
||||
it("emits a control when SST is Fixed and Z_UA is Free", () => {
|
||||
describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
||||
it("pairs a Fixed Probe measure with a Free Z_UA on the HX", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, {
|
||||
ua: 9000,
|
||||
calib_sst_c: 5,
|
||||
z_ua: 1.0,
|
||||
// Fixed ON for SST, Fixed OFF for Z_UA
|
||||
[fixedFlagKey("calib_sst_c")]: true,
|
||||
// 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,
|
||||
}),
|
||||
];
|
||||
const controls = buildFixedFreeCalibrationControls(nodes);
|
||||
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: "evap", output: "saturationTemperature" },
|
||||
measure: { component: "sst_probe", output: "saturationTemperature" },
|
||||
actuator: { component: "evap", factor: "z_ua" },
|
||||
target: 5 + 273.15,
|
||||
target: 278.15,
|
||||
});
|
||||
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.controls?.length).toBe(1);
|
||||
// UI-only keys stripped from component JSON
|
||||
// UI-only keys stripped from component JSON; Z_UA still emitted as initial value.
|
||||
const comp = cfg.circuits[0].components[0];
|
||||
expect(comp.calib_sst_c).toBeUndefined();
|
||||
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
||||
expect(comp.z_ua).toBe(1.0);
|
||||
});
|
||||
|
||||
it("emits no control when Z_UA stays Fixed", () => {
|
||||
it("emits no control when Z_UA stays Fixed even with a Probe present", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, {
|
||||
ua: 6000,
|
||||
calib_sst_c: 5,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("calib_sst_c")]: true,
|
||||
[fixedFlagKey("z_ua")]: true,
|
||||
}),
|
||||
node("p1", "Probe", "sst_probe", 0, {
|
||||
measure: "SST",
|
||||
fluid: "R134a",
|
||||
target: 278.15,
|
||||
[fixedFlagKey("target")]: true,
|
||||
}),
|
||||
];
|
||||
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.
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, {
|
||||
ua: 6000,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
];
|
||||
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -336,7 +336,7 @@ export function buildScenarioConfig(
|
||||
const nodeControls = nodes
|
||||
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
|
||||
.map(controlNodeToConfig);
|
||||
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes);
|
||||
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes, edges);
|
||||
const controls = mergeControls(
|
||||
mergeControls(options.controls ?? [], nodeControls),
|
||||
fixedFreeControls,
|
||||
@@ -362,7 +362,7 @@ export function buildScenarioConfig(
|
||||
/**
|
||||
* Remove UI-only keys before sending to the CLI:
|
||||
* - `__fixed_*` Fixed checkbox flags
|
||||
* - measure-only calib targets (calib_sst_c, …) — they become control setpoints
|
||||
* - measure-only targets (e.g. Probe `target`) — they become control setpoints
|
||||
* - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
|
||||
*/
|
||||
export function stripUiOnlyParams(
|
||||
@@ -466,44 +466,85 @@ export function applyBoundaryFixSemantics(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inverse-calibration controls from Fixed checkboxes (EES/Dymola style).
|
||||
* Build the `controls[]` array for calibration (Probe-based model, HARD RULE):
|
||||
*
|
||||
* - Param with `measureOutput` + Fixed ON → impose that measure (setpoint = value)
|
||||
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
|
||||
* Pairs on the same component: each free factor with the first fixed measure.
|
||||
* - 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
|
||||
* z_dp → P
|
||||
* z_flow → MassFlow, Capacity
|
||||
* z_power → Capacity
|
||||
* z_etav → MassFlow
|
||||
*/
|
||||
export function buildFixedFreeCalibrationControls(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[] = [],
|
||||
): ControlConfig[] {
|
||||
const controls: ControlConfig[] = [];
|
||||
|
||||
// ── Pass 1: collect Probe measures and per-component freed z-factors ──
|
||||
type ProbeMeasure = {
|
||||
nodeName: string;
|
||||
kind: string; // SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy
|
||||
target: number;
|
||||
factorCompat: readonly string[]; // z-factors this Probe kind can pair with
|
||||
};
|
||||
type FreeAct = {
|
||||
factor: string;
|
||||
initial: number;
|
||||
min: number;
|
||||
max: number;
|
||||
key: string;
|
||||
};
|
||||
|
||||
const probeMeasures: ProbeMeasure[] = [];
|
||||
const freeActsByComponent = new Map<
|
||||
string,
|
||||
{ nodeName: string; node: Node<EntropykNodeData>; acts: FreeAct[] }
|
||||
>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.data.type === CONTROL_NODE_TYPE) continue;
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
if (!meta) continue;
|
||||
const params = node.data.params ?? {};
|
||||
|
||||
type Measure = { output: string; target: number; key: string };
|
||||
type FreeAct = { factor: string; initial: number; min: number; max: number; key: string };
|
||||
// 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] ?? [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const measures: Measure[] = [];
|
||||
// Non-Probe node: collect freed z-factors (actuators to calibrate).
|
||||
const freeActs: FreeAct[] = [];
|
||||
|
||||
for (const p of meta.params) {
|
||||
if (!p.fixable) continue;
|
||||
const fixed = isParamFixed(params, p);
|
||||
const raw = params[p.key];
|
||||
|
||||
if (p.measureOutput && fixed) {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
measures.push({
|
||||
output: p.measureOutput,
|
||||
target: measureSetpointSi(p, n),
|
||||
key: p.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (p.actuatorFactor && !fixed) {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
const initial = Number.isFinite(n) ? n : 1.0;
|
||||
@@ -517,37 +558,144 @@ export function buildFixedFreeCalibrationControls(
|
||||
}
|
||||
}
|
||||
|
||||
if (freeActs.length === 0 || measures.length === 0) continue;
|
||||
if (freeActs.length > 0) {
|
||||
freeActsByComponent.set(node.data.name, { nodeName: node.data.name, node, acts: freeActs });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < freeActs.length; i++) {
|
||||
const act = freeActs[i];
|
||||
const meas = measures[Math.min(i, measures.length - 1)];
|
||||
controls.push({
|
||||
type: "SaturatedController",
|
||||
id: `calib_${node.data.name}_${act.factor}`,
|
||||
measure: {
|
||||
component: node.data.name,
|
||||
output: meas.output,
|
||||
},
|
||||
actuator: {
|
||||
component: node.data.name,
|
||||
factor: act.factor,
|
||||
initial: act.initial,
|
||||
min: act.min,
|
||||
max: act.max,
|
||||
},
|
||||
target: meas.target,
|
||||
// Negative gain: higher Z_UA → higher capacity / often higher T_sat for flooded
|
||||
// — user can refine; default chosen for UA-style calibration.
|
||||
gain: -0.5,
|
||||
band: 2.0,
|
||||
});
|
||||
// ── Adjacency index: for each component node, which Probe nodes share an edge ──
|
||||
const adjacentProbes = new Map<string, Set<string>>();
|
||||
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;
|
||||
if (probeName) {
|
||||
const endpointName = nodes.find((n) => n.id === endpoint)?.data.name;
|
||||
if (endpointName) {
|
||||
if (!adjacentProbes.has(endpointName)) {
|
||||
adjacentProbes.set(endpointName, new Set());
|
||||
}
|
||||
adjacentProbes.get(endpointName)!.add(probeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>,
|
||||
): ProbeMeasure | undefined => {
|
||||
const compatible = probeMeasures.filter(
|
||||
(m) => m.factorCompat.includes(factor) && !usedProbeNames.has(m.nodeName),
|
||||
);
|
||||
if (compatible.length === 0) return undefined;
|
||||
const adjacent = adjacentProbes.get(component);
|
||||
const adjacentMatch = adjacent
|
||||
? compatible.find((m) => adjacent.has(m.nodeName))
|
||||
: undefined;
|
||||
const chosen = adjacentMatch ?? compatible[0];
|
||||
usedProbeNames.add(chosen.nodeName);
|
||||
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<string>();
|
||||
|
||||
for (const { nodeName, acts } of freeActsByComponent.values()) {
|
||||
for (const act of acts) {
|
||||
const probe = findProbeFor(nodeName, act.factor, usedProbeNames);
|
||||
if (probe) {
|
||||
controls.push({
|
||||
type: "SaturatedController",
|
||||
id: `calib_${nodeName}_${act.factor}`,
|
||||
measure: {
|
||||
component: probe.nodeName,
|
||||
output: probeOutputFor(probe.kind),
|
||||
},
|
||||
actuator: {
|
||||
component: nodeName,
|
||||
factor: act.factor,
|
||||
initial: act.initial,
|
||||
min: act.min,
|
||||
max: act.max,
|
||||
},
|
||||
target: probe.target,
|
||||
gain: -0.5,
|
||||
band: 2.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Probe `measure` kind to the solver-side `ComponentOutput` string
|
||||
* accepted by the CLI (`parse_component_output` in run.rs).
|
||||
*/
|
||||
function probeOutputFor(kind: string): string {
|
||||
switch (kind) {
|
||||
case "SST":
|
||||
case "SDT":
|
||||
return "saturationTemperature";
|
||||
case "SH":
|
||||
case "DSH":
|
||||
return "superheat";
|
||||
case "SC":
|
||||
return "subcooling";
|
||||
case "DGT":
|
||||
case "T":
|
||||
return "temperature";
|
||||
case "P":
|
||||
return "pressure";
|
||||
case "MassFlow":
|
||||
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). */
|
||||
const FACTOR_COMPATIBILITY: Record<string, readonly string[]> = {
|
||||
SST: ["z_ua"],
|
||||
SDT: ["z_ua"],
|
||||
SH: ["z_ua"],
|
||||
DSH: ["z_ua"],
|
||||
SC: ["z_ua"],
|
||||
Capacity: ["z_ua", "z_flow", "z_power"],
|
||||
P: ["z_dp"],
|
||||
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). */
|
||||
function measureSetpointSi(meta: ParamMeta, value: number): number {
|
||||
const unit = (meta.unit ?? "").toLowerCase();
|
||||
|
||||
@@ -25,10 +25,26 @@ export const PIPE_TYPES = new Set([
|
||||
"PipeAir",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 2-port pass-through components that splice into an existing wire via
|
||||
* `insertOnEdge`. Includes the Pipe family (above) plus the `Probe`
|
||||
* measurement tap (calibration redesign — every measurement lives on a Probe
|
||||
* the user drops onto a line).
|
||||
*/
|
||||
export const INSERTABLE_ON_EDGE_TYPES: ReadonlySet<string> = new Set([
|
||||
...PIPE_TYPES,
|
||||
"Probe",
|
||||
]);
|
||||
|
||||
export function isPipeType(type: string): boolean {
|
||||
return PIPE_TYPES.has(type) || type.includes("Pipe") || type === "AirDuct";
|
||||
}
|
||||
|
||||
/** True for any component the canvas should splice onto a wire on drop. */
|
||||
export function isInsertableOnEdge(type: string): boolean {
|
||||
return isPipeType(type) || type === "Probe";
|
||||
}
|
||||
|
||||
export function pipeMediaKind(type: string, params?: Record<string, unknown>): MediaKind {
|
||||
if (type === "WaterPipe" || type === "PipeWater") return "water";
|
||||
if (type === "AirDuct" || type === "PipeAir") return "air";
|
||||
|
||||
Reference in New Issue
Block a user