/** * 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; } 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 = { 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[], 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[], ): 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[], 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[], 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> }>; }; 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; 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>; }>; }; 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> = [{}]; for (const axis of axes) { const next: Array> = []; 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 { 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 = { 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 = { 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; 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; }