Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
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>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { simulate, type SimulationResult } from "./api";
|
||||
import { COMPONENT_BY_TYPE } from "./componentMeta";
|
||||
import { COMPONENT_BY_TYPE, isParamFixed } from "./componentMeta";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
@@ -48,21 +48,16 @@ export interface MultiRunResult {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Params engineers typically sweep on a cycle. */
|
||||
const SWEEP_PARAM_PRIORITY: Record<string, { group: SweepTarget["group"]; rank: number }> = {
|
||||
t_set_c: { group: "boundaries", rank: 10 },
|
||||
t_dry_c: { group: "boundaries", rank: 11 },
|
||||
oat_k: { group: "boundaries", rank: 12 },
|
||||
m_flow_kg_s: { group: "boundaries", rank: 20 },
|
||||
air_face_velocity_m_s: { group: "boundaries", rank: 21 },
|
||||
ua: { group: "thermal", rank: 30 },
|
||||
opening: { group: "machine", rank: 40 },
|
||||
speed_ratio: { group: "machine", rank: 41 },
|
||||
frequency_hz: { group: "machine", rank: 42 },
|
||||
speed_rpm: { group: "machine", rank: 43 },
|
||||
slide_valve_position: { group: "machine", rank: 44 },
|
||||
volume_index: { group: "machine", rank: 45 },
|
||||
isentropic_efficiency: { group: "machine", rank: 46 },
|
||||
/** 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([
|
||||
@@ -103,6 +98,9 @@ function friendlyComponentRole(type: string, name: string): string {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
@@ -128,8 +126,8 @@ export function discoverSweepTargets(
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (key.startsWith("__")) continue;
|
||||
const prio = SWEEP_PARAM_PRIORITY[key];
|
||||
if (!prio) 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") &&
|
||||
@@ -137,14 +135,14 @@ export function discoverSweepTargets(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const paramMeta = meta?.params.find((p) => p.key === key);
|
||||
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) ? "boundaries" : prio.group,
|
||||
group: BOUNDARY_TYPES.has(type) && group === "global" ? "boundaries" : group,
|
||||
current: val,
|
||||
unit: paramMeta?.unit,
|
||||
componentName: name,
|
||||
@@ -155,19 +153,54 @@ export function discoverSweepTargets(
|
||||
}
|
||||
|
||||
targets.sort((a, b) => {
|
||||
const order = { boundaries: 0, thermal: 1, machine: 2, global: 3 };
|
||||
const ga = order[a.group];
|
||||
const gb = order[b.group];
|
||||
const ga = SWEEP_GROUP_ORDER[a.group];
|
||||
const gb = SWEEP_GROUP_ORDER[b.group];
|
||||
if (ga !== gb) return ga - gb;
|
||||
const ra = a.paramKey ? (SWEEP_PARAM_PRIORITY[a.paramKey]?.rank ?? 99) : 0;
|
||||
const rb = b.paramKey ? (SWEEP_PARAM_PRIORITY[b.paramKey]?.rank ?? 99) : 0;
|
||||
if (ra !== rb) return ra - rb;
|
||||
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)) {
|
||||
@@ -236,6 +269,65 @@ export function defaultSweepsFromDiagram(
|
||||
];
|
||||
}
|
||||
|
||||
/** 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.
|
||||
@@ -409,3 +501,80 @@ export function extractKpis(result?: SimulationResult): {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user