Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -0,0 +1,411 @@
/**
* 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 } 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. */
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 },
};
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.
* 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 prio = SWEEP_PARAM_PRIORITY[key];
if (!prio) 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 paramMeta = meta?.params.find((p) => p.key === key);
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,
current: val,
unit: paramMeta?.unit,
componentName: name,
componentType: type,
paramKey: key,
});
}
}
targets.sort((a, b) => {
const order = { boundaries: 0, thermal: 1, machine: 2, global: 3 };
const ga = order[a.group];
const gb = 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;
}
/** 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),
},
];
}
/**
* 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,
};
}