Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
581 lines
18 KiB
TypeScript
581 lines
18 KiB
TypeScript
/**
|
|
* Parallel multi-run helpers: discover sweepable params from the diagram,
|
|
* clone ScenarioConfig, and solve cases in parallel.
|
|
*/
|
|
|
|
import { simulate, type SimulationResult } from "./api";
|
|
import { COMPONENT_BY_TYPE, isParamFixed } from "./componentMeta";
|
|
import type { EntropykNodeData } from "./configBuilder";
|
|
import type { Node } from "@xyflow/react";
|
|
|
|
export type SweepKind = "scalar" | "fluid";
|
|
|
|
export interface SweepSpec {
|
|
/** `fluid` or `componentName.param` (CLI-flattened JSON). */
|
|
path: string;
|
|
label: string;
|
|
kind: SweepKind;
|
|
/** Comma / newline separated values. */
|
|
valuesText: string;
|
|
}
|
|
|
|
export interface SweepTarget {
|
|
path: string;
|
|
label: string;
|
|
kind: SweepKind;
|
|
group: "boundaries" | "thermal" | "machine" | "global";
|
|
/** Current value on the diagram (for suggested ranges). */
|
|
current?: number | string | boolean;
|
|
unit?: string;
|
|
/** Component display name (empty for global). */
|
|
componentName?: string;
|
|
componentType?: string;
|
|
paramKey?: string;
|
|
}
|
|
|
|
export interface MultiRunCase {
|
|
id: string;
|
|
label: string;
|
|
config: unknown;
|
|
overrides: Record<string, string | number>;
|
|
}
|
|
|
|
export interface MultiRunResult {
|
|
case: MultiRunCase;
|
|
ok: boolean;
|
|
result?: SimulationResult;
|
|
error?: string;
|
|
durationMs: number;
|
|
}
|
|
|
|
/** Params engineers typically sweep on a cycle.
|
|
*
|
|
* Sweepability is now declared per-param in `componentMeta.ts` (`ParamMeta.sweepable` /
|
|
* `ParamMeta.sweepGroup`). This constant only carries the 4-group sort order used to
|
|
* arrange the Multi-run dropdown (boundaries first, then thermal, machine, global). */
|
|
const SWEEP_GROUP_ORDER: Record<SweepTarget["group"], number> = {
|
|
boundaries: 0,
|
|
thermal: 1,
|
|
machine: 2,
|
|
global: 3,
|
|
};
|
|
|
|
const BOUNDARY_TYPES = new Set([
|
|
"BrineSource",
|
|
"AirSource",
|
|
"RefrigerantSource",
|
|
]);
|
|
|
|
/** Parse "1, 2, 3" or multiline into string tokens. */
|
|
export function parseValueList(text: string): string[] {
|
|
return text
|
|
.split(/[\n,;]+/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function humanParamLabel(type: string, paramKey: string, unit?: string): string {
|
|
const meta = COMPONENT_BY_TYPE[type]?.params.find((p) => p.key === paramKey);
|
|
const base = meta?.label ?? paramKey;
|
|
const u = unit ?? meta?.unit;
|
|
return u ? `${base} (${u})` : base;
|
|
}
|
|
|
|
function friendlyComponentRole(type: string, name: string): string {
|
|
if (type === "BrineSource") {
|
|
if (/evap/i.test(name)) return "Eau évaporateur";
|
|
if (/cond/i.test(name)) return "Eau condenseur";
|
|
return `Source eau « ${name} »`;
|
|
}
|
|
if (type === "AirSource") {
|
|
if (/cond|oat|outdoor/i.test(name)) return "Air extérieur";
|
|
if (/evap|indoor/i.test(name)) return "Air intérieur";
|
|
return `Source air « ${name} »`;
|
|
}
|
|
const label = COMPONENT_BY_TYPE[type]?.label ?? type;
|
|
return `${label} « ${name} »`;
|
|
}
|
|
|
|
/**
|
|
* Discover sweepable parameters from the live diagram.
|
|
* A param is admitted iff its `ParamMeta` declares `sweepable: true`. The
|
|
* `sweepGroup` on the meta drives both the optgroup and the sort order
|
|
* (boundaries < thermal < machine < global); ties break by label.
|
|
* Boundaries (water/air T) come first — that's what engineers sweep for ratings.
|
|
*/
|
|
export function discoverSweepTargets(
|
|
nodes: Node<EntropykNodeData>[],
|
|
fluid?: string,
|
|
): SweepTarget[] {
|
|
const targets: SweepTarget[] = [
|
|
{
|
|
path: "fluid",
|
|
label: "Fluide frigorigène",
|
|
kind: "fluid",
|
|
group: "global",
|
|
current: fluid,
|
|
},
|
|
];
|
|
|
|
for (const node of nodes) {
|
|
const type = node.data.type;
|
|
const name = node.data.name;
|
|
if (!type || !name || type === "SaturatedController") continue;
|
|
const params = node.data.params ?? {};
|
|
const meta = COMPONENT_BY_TYPE[type];
|
|
|
|
for (const [key, val] of Object.entries(params)) {
|
|
if (key.startsWith("__")) continue;
|
|
const paramMeta = meta?.params.find((p) => p.key === key);
|
|
if (!paramMeta?.sweepable) continue;
|
|
// Prefer live boundary setpoints over rating scalars on HX when both exist.
|
|
if (
|
|
(key === "secondary_inlet_temp_c" || key === "secondary_mass_flow_kg_s") &&
|
|
!BOUNDARY_TYPES.has(type)
|
|
) {
|
|
continue;
|
|
}
|
|
const group = paramMeta.sweepGroup ?? "global";
|
|
const role = friendlyComponentRole(type, name);
|
|
const paramLabel = humanParamLabel(type, key, paramMeta?.unit);
|
|
targets.push({
|
|
path: `${name}.${key}`,
|
|
label: `${role} — ${paramLabel}`,
|
|
kind: typeof val === "string" && key === "fluid" ? "fluid" : "scalar",
|
|
group: BOUNDARY_TYPES.has(type) && group === "global" ? "boundaries" : group,
|
|
current: val,
|
|
unit: paramMeta?.unit,
|
|
componentName: name,
|
|
componentType: type,
|
|
paramKey: key,
|
|
});
|
|
}
|
|
}
|
|
|
|
targets.sort((a, b) => {
|
|
const ga = SWEEP_GROUP_ORDER[a.group];
|
|
const gb = SWEEP_GROUP_ORDER[b.group];
|
|
if (ga !== gb) return ga - gb;
|
|
return a.label.localeCompare(b.label);
|
|
});
|
|
|
|
return targets;
|
|
}
|
|
|
|
/**
|
|
* Resolve an arbitrary pinned path (`componentName.paramKey`) to a SweepTarget,
|
|
* even when the param is NOT flagged `sweepable` in metadata. Lets engineers
|
|
* sweep ANY numeric param (e.g. `n_plates`, `air_density_kg_m3`) for optimization
|
|
* studies via the properties-panel pin button. Returns null when the node or
|
|
* param is missing, or the param is non-numeric (string/boolean are rejected).
|
|
*/
|
|
export function resolvePinTarget(
|
|
path: string,
|
|
nodes: Node<EntropykNodeData>[],
|
|
): SweepTarget | null {
|
|
const dot = path.indexOf(".");
|
|
if (dot <= 0) return null;
|
|
const name = path.slice(0, dot);
|
|
const key = path.slice(dot + 1);
|
|
if (!key || key.includes(".")) return null;
|
|
const node = nodes.find((n) => n.data.name === name);
|
|
if (!node) return null;
|
|
const type = node.data.type;
|
|
const meta = COMPONENT_BY_TYPE[type];
|
|
const paramMeta = meta?.params.find((p) => p.key === key);
|
|
if (!paramMeta || paramMeta.kind !== "number") return null;
|
|
const current = node.data.params?.[key];
|
|
const role = friendlyComponentRole(type, name);
|
|
const paramLabel = humanParamLabel(type, key, paramMeta.unit);
|
|
const group = paramMeta.sweepGroup ?? "machine";
|
|
return {
|
|
path,
|
|
label: `${role} — ${paramLabel}`,
|
|
kind: "scalar",
|
|
group,
|
|
current,
|
|
unit: paramMeta.unit,
|
|
componentName: name,
|
|
componentType: type,
|
|
paramKey: key,
|
|
};
|
|
}
|
|
|
|
/** Suggest a small sweep around the current numeric value. */
|
|
export function suggestValues(current: number | string | boolean | undefined, paramKey?: string): string {
|
|
if (typeof current !== "number" || !Number.isFinite(current)) {
|
|
if (paramKey === "fluid" || current === undefined) return "R134a, R410A";
|
|
return String(current ?? "");
|
|
}
|
|
if (paramKey === "t_set_c" || paramKey === "t_dry_c") {
|
|
const step = 2;
|
|
return [current - step, current, current + step].map((v) => String(v)).join(", ");
|
|
}
|
|
if (paramKey === "oat_k") {
|
|
return [current - 5, current, current + 5].map((v) => String(v)).join(", ");
|
|
}
|
|
if (paramKey === "ua") {
|
|
return [current * 0.8, current, current * 1.2]
|
|
.map((v) => String(Math.round(v)))
|
|
.join(", ");
|
|
}
|
|
if (paramKey === "m_flow_kg_s" || paramKey === "opening" || paramKey === "speed_ratio") {
|
|
const a = Math.max(current * 0.8, 0);
|
|
const b = current;
|
|
const c = current * 1.2;
|
|
return [a, b, c].map((v) => (Number.isInteger(v) ? String(v) : v.toFixed(3))).join(", ");
|
|
}
|
|
return String(current);
|
|
}
|
|
|
|
/** Default axes when opening Multi-run: both water temperatures if present. */
|
|
export function defaultSweepsFromDiagram(
|
|
nodes: Node<EntropykNodeData>[],
|
|
fluid?: string,
|
|
): SweepSpec[] {
|
|
const targets = discoverSweepTargets(nodes, fluid);
|
|
const waterTemps = targets.filter(
|
|
(t) =>
|
|
t.componentType === "BrineSource" &&
|
|
t.paramKey === "t_set_c",
|
|
);
|
|
if (waterTemps.length >= 1) {
|
|
return waterTemps.slice(0, 2).map((t) => ({
|
|
path: t.path,
|
|
label: t.label,
|
|
kind: t.kind,
|
|
valuesText: suggestValues(t.current, t.paramKey),
|
|
}));
|
|
}
|
|
const airTemps = targets.filter(
|
|
(t) => t.componentType === "AirSource" && t.paramKey === "t_dry_c",
|
|
);
|
|
if (airTemps.length >= 1) {
|
|
return airTemps.slice(0, 2).map((t) => ({
|
|
path: t.path,
|
|
label: t.label,
|
|
kind: t.kind,
|
|
valuesText: suggestValues(t.current, t.paramKey),
|
|
}));
|
|
}
|
|
const first = targets.find((t) => t.path !== "fluid") ?? targets[0];
|
|
return [
|
|
{
|
|
path: first.path,
|
|
label: first.label,
|
|
kind: first.kind,
|
|
valuesText: suggestValues(first.current, first.paramKey),
|
|
},
|
|
];
|
|
}
|
|
|
|
/** EXV types whose `opening` only has a physical effect when paired with an `orifice_kv`.
|
|
* Narrowed to `IsenthalpicExpansionValve`: it is the sole type that carries `orifice_kv`
|
|
* and a `fixable` opening. `ExpansionValve` models flow via `flow_model` + `beta_m2`
|
|
* (opening always has effect there) and must NOT trigger the orifice warning. */
|
|
const ORIFICE_EXV_TYPES = new Set(["IsenthalpicExpansionValve"]);
|
|
|
|
/**
|
|
* Human-readable French warnings for a sweep axis that would produce identical
|
|
* results across cases (silent no-op). Returns an empty array when the axis is
|
|
* healthy. The caller renders these inline on the axis row — they never block
|
|
* the run (engineers may legitimately sweep a Free actuator to probe sensitivity).
|
|
*/
|
|
export function getSweepWarnings(
|
|
target: SweepTarget,
|
|
nodes: Node<EntropykNodeData>[],
|
|
baseConfig: unknown,
|
|
): string[] {
|
|
const warnings: string[] = [];
|
|
const name = target.componentName;
|
|
const key = target.paramKey;
|
|
|
|
// Global fluid axis — nothing to warn about.
|
|
if (!name || !key) return warnings;
|
|
|
|
const isExvOpening = key === "opening" && !!target.componentType && ORIFICE_EXV_TYPES.has(target.componentType);
|
|
|
|
// (c) Axis path resolves to no component in any circuit of baseConfig.
|
|
const cfg = baseConfig as {
|
|
circuits?: Array<{ components?: Array<Record<string, unknown>> }>;
|
|
};
|
|
const existsInConfig = (cfg?.circuits ?? []).some((c) =>
|
|
(c.components ?? []).some((c2) => c2.name === name),
|
|
);
|
|
if (!existsInConfig) {
|
|
warnings.push(`« ${name} » introuvable dans les circuits du schéma`);
|
|
return warnings;
|
|
}
|
|
|
|
if (isExvOpening) {
|
|
const node = nodes.find((n) => n.data.name === name);
|
|
const liveParams = node?.data.params ?? {};
|
|
const orificeRaw = liveParams.orifice_kv;
|
|
const orificeNum = typeof orificeRaw === "number" ? orificeRaw : Number(orificeRaw);
|
|
// (a) No usable orifice → opening has no physical effect on mass flow.
|
|
if (!Number.isFinite(orificeNum) || orificeNum <= 0) {
|
|
warnings.push("opening n'a aucun effet sans orifice_kv (orifice absent ou ≤ 0)");
|
|
}
|
|
// (b) opening left Free → swept value ignored, solver picks it.
|
|
const openingMeta = COMPONENT_BY_TYPE[target.componentType!]?.params.find((p) => p.key === "opening");
|
|
if (openingMeta && !isParamFixed(liveParams, openingMeta)) {
|
|
warnings.push(
|
|
"opening est libre (solver) — la valeur balayée sera ignorée. Passe-le en Fixed pour imposer la valeur.",
|
|
);
|
|
}
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
/**
|
|
* Set a parameter on a named component: `evap_water_in.t_set_c`.
|
|
* Component names may contain underscores.
|
|
*/
|
|
export function applyOverride(
|
|
config: unknown,
|
|
path: string,
|
|
raw: string,
|
|
kind: SweepKind,
|
|
): unknown {
|
|
const value: string | number =
|
|
kind === "fluid" ? raw : Number.isFinite(Number(raw)) ? Number(raw) : raw;
|
|
|
|
if (path === "fluid") {
|
|
const c = structuredClone(config) as Record<string, unknown>;
|
|
c.fluid = value;
|
|
return c;
|
|
}
|
|
|
|
const dot = path.indexOf(".");
|
|
if (dot > 0) {
|
|
const name = path.slice(0, dot);
|
|
const param = path.slice(dot + 1);
|
|
if (name && param && !param.includes(".")) {
|
|
const c = structuredClone(config) as {
|
|
circuits?: Array<{
|
|
components?: Array<Record<string, unknown>>;
|
|
}>;
|
|
};
|
|
let hit = false;
|
|
for (const circuit of c.circuits ?? []) {
|
|
for (const comp of circuit.components ?? []) {
|
|
if (comp.name === name) {
|
|
comp[param] = value;
|
|
hit = true;
|
|
}
|
|
}
|
|
}
|
|
if (!hit) {
|
|
// Keep config but caller may surface a warning via empty hit.
|
|
}
|
|
return c;
|
|
}
|
|
}
|
|
|
|
return structuredClone(config);
|
|
}
|
|
|
|
/** Cartesian product of sweep axes → run cases. */
|
|
export function buildSweepCases(
|
|
baseConfig: unknown,
|
|
sweeps: SweepSpec[],
|
|
): MultiRunCase[] {
|
|
const axes = sweeps
|
|
.map((s) => ({
|
|
...s,
|
|
values: parseValueList(s.valuesText),
|
|
}))
|
|
.filter((s) => s.values.length > 0);
|
|
|
|
if (axes.length === 0) {
|
|
return [
|
|
{
|
|
id: "base",
|
|
label: "base",
|
|
config: structuredClone(baseConfig),
|
|
overrides: {},
|
|
},
|
|
];
|
|
}
|
|
|
|
let combos: Array<Record<string, string>> = [{}];
|
|
for (const axis of axes) {
|
|
const next: Array<Record<string, string>> = [];
|
|
for (const prev of combos) {
|
|
for (const v of axis.values) {
|
|
next.push({ ...prev, [axis.path]: v });
|
|
}
|
|
}
|
|
combos = next;
|
|
}
|
|
|
|
return combos.map((overrides, i) => {
|
|
let cfg = structuredClone(baseConfig);
|
|
const labels: string[] = [];
|
|
for (const axis of axes) {
|
|
const raw = overrides[axis.path];
|
|
cfg = applyOverride(cfg, axis.path, raw, axis.kind);
|
|
labels.push(`${axis.label || axis.path}=${raw}`);
|
|
}
|
|
return {
|
|
id: `case-${i + 1}`,
|
|
label: labels.join(" · "),
|
|
config: cfg,
|
|
overrides,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Run cases with a concurrency limit (default 4). */
|
|
export async function runParallel(
|
|
cases: MultiRunCase[],
|
|
options?: { concurrency?: number; onProgress?: (done: number, total: number) => void },
|
|
): Promise<MultiRunResult[]> {
|
|
const concurrency = Math.max(1, options?.concurrency ?? 4);
|
|
const total = cases.length;
|
|
const results: MultiRunResult[] = new Array(total);
|
|
let next = 0;
|
|
let done = 0;
|
|
|
|
async function worker() {
|
|
while (next < total) {
|
|
const idx = next++;
|
|
const c = cases[idx];
|
|
const t0 = performance.now();
|
|
try {
|
|
const resp = await simulate(c.config);
|
|
results[idx] = {
|
|
case: c,
|
|
ok: !!resp.ok && !!resp.result,
|
|
result: resp.result,
|
|
error: resp.error,
|
|
durationMs: performance.now() - t0,
|
|
};
|
|
} catch (e) {
|
|
results[idx] = {
|
|
case: c,
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
durationMs: performance.now() - t0,
|
|
};
|
|
}
|
|
done++;
|
|
options?.onProgress?.(done, total);
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()));
|
|
return results;
|
|
}
|
|
|
|
/** Compact KPIs for comparison tables. */
|
|
export function extractKpis(result?: SimulationResult): {
|
|
status: string;
|
|
cop: number | null;
|
|
qCoolKw: number | null;
|
|
powerKw: number | null;
|
|
iterations: number | null;
|
|
} {
|
|
if (!result) {
|
|
return { status: "error", cop: null, qCoolKw: null, powerKw: null, iterations: null };
|
|
}
|
|
const p = result.performance;
|
|
const cop =
|
|
p?.cop ??
|
|
p?.cop_cooling ??
|
|
(p?.cooling_capacity_w != null && p?.compressor_power_w
|
|
? p.cooling_capacity_w / p.compressor_power_w
|
|
: null);
|
|
const qCoolKw =
|
|
p?.q_cooling_kw ??
|
|
(p?.cooling_capacity_w != null ? p.cooling_capacity_w / 1000 : null);
|
|
const powerKw =
|
|
p?.compressor_power_kw ??
|
|
(p?.compressor_power_w != null ? p.compressor_power_w / 1000 : null);
|
|
return {
|
|
status: result.status,
|
|
cop: cop != null && Number.isFinite(cop) ? cop : null,
|
|
qCoolKw: qCoolKw != null && Number.isFinite(qCoolKw) ? qCoolKw : null,
|
|
powerKw: powerKw != null && Number.isFinite(powerKw) ? powerKw : null,
|
|
iterations: result.iterations ?? result.convergence?.iterations ?? null,
|
|
};
|
|
}
|
|
|
|
// ─── Generic series extraction (literature-aligned: trace ANY output) ─────────
|
|
// A multirun should let the engineer plot any model output vs any swept param,
|
|
// not just the refrigeration-cycle KPIs. This walks `performance` (cycle KPIs)
|
|
// AND every edge of `state` (temperatures, enthalpies, mass flows, pressures),
|
|
// producing a flat list of plottable series with human-readable labels.
|
|
|
|
export interface ResultSeries {
|
|
/** Stable key (used as chart dataKey / select value). */
|
|
key: string;
|
|
/** Human-readable label shown in the Y selector + legend. */
|
|
label: string;
|
|
value: number | null;
|
|
unit?: string;
|
|
}
|
|
|
|
const PERF_LABELS: Record<string, { label: string; unit?: string }> = {
|
|
cop: { label: "COP" },
|
|
cop_cooling: { label: "COP froid" },
|
|
cop_heating: { label: "COP chaud" },
|
|
cooling_capacity_w: { label: "Puissance froide", unit: "W" },
|
|
heating_capacity_w: { label: "Puissance chaude", unit: "W" },
|
|
compressor_power_w: { label: "Puissance (compresseur)", unit: "W" },
|
|
q_cooling_kw: { label: "Puissance froide", unit: "kW" },
|
|
q_heating_kw: { label: "Puissance chaude", unit: "kW" },
|
|
compressor_power_kw: { label: "Puissance (compresseur)", unit: "kW" },
|
|
};
|
|
|
|
const EDGE_FIELDS: Record<string, { label: string; unit?: string }> = {
|
|
temperature_c: { label: "Température", unit: "°C" },
|
|
enthalpy_kj_kg: { label: "Enthalpie", unit: "kJ/kg" },
|
|
mass_flow_kg_s: { label: "Débit massique", unit: "kg/s" },
|
|
pressure_bar: { label: "Pression", unit: "bar" },
|
|
};
|
|
|
|
/**
|
|
* Flatten a SimulationResult into every plottable numeric series.
|
|
* Performance fields first (cycle KPIs), then every edge state variable
|
|
* labelled by its connection (`source → target · grandeur`).
|
|
*/
|
|
export function extractAllSeries(result?: SimulationResult | null): ResultSeries[] {
|
|
const out: ResultSeries[] = [];
|
|
if (!result) return out;
|
|
|
|
const p = result.performance ?? {};
|
|
for (const [k, v] of Object.entries(p)) {
|
|
if (typeof v === "number" && Number.isFinite(v)) {
|
|
const meta = PERF_LABELS[k] ?? { label: k };
|
|
out.push({ key: `perf.${k}`, label: meta.label, value: v, unit: meta.unit });
|
|
}
|
|
}
|
|
|
|
const st = result.state as unknown;
|
|
if (Array.isArray(st)) {
|
|
st.forEach((edge, i) => {
|
|
if (!edge || typeof edge !== "object") return;
|
|
const e = edge as Record<string, unknown>;
|
|
const who =
|
|
typeof e.source === "string" && typeof e.target === "string"
|
|
? `${e.source} → ${e.target}`
|
|
: `edge ${i}`;
|
|
for (const [fk, fmeta] of Object.entries(EDGE_FIELDS)) {
|
|
const v = e[fk];
|
|
if (typeof v === "number" && Number.isFinite(v)) {
|
|
out.push({
|
|
key: `edge.${i}.${fk}`,
|
|
label: `${who} · ${fmeta.label}`,
|
|
value: v,
|
|
unit: fmeta.unit,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return out;
|
|
}
|