Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Entropyk Web Error Boundary caught:", error);
}, [error]);
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-[var(--canvas-bg,#f8fafc)] p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-100 text-amber-600 mb-4">
<AlertTriangle size={28} />
</div>
<h2 className="mono text-lg font-bold text-[var(--ink,#0f172a)]">
Une erreur est survenue dans l&apos;interface
</h2>
<p className="mt-1 max-w-md text-xs text-[var(--ink-dim,#475569)]">
{error.message || "Une erreur inattendue s'est produite lors du rendu des composants."}
</p>
<button
onClick={() => reset()}
className="mt-5 flex items-center gap-2 rounded bg-[var(--accent,#4f46e5)] px-4 py-2 text-xs font-semibold text-white shadow hover:opacity-90 active:scale-95"
>
<RefreshCw size={14} /> Réessayer
</button>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import Link from "next/link";
import { HelpCircle, ArrowLeft } from "lucide-react";
export default function NotFound() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-[var(--canvas-bg,#f8fafc)] p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 mb-4">
<HelpCircle size={28} />
</div>
<h2 className="mono text-lg font-bold text-[var(--ink,#0f172a)]">
Page Introuvable (404)
</h2>
<p className="mt-1 max-w-md text-xs text-[var(--ink-dim,#475569)]">
La page ou la vue demandée n&apos;existe pas.
</p>
<Link
href="/"
className="mt-5 flex items-center gap-2 rounded bg-[var(--accent,#4f46e5)] px-4 py-2 text-xs font-semibold text-white shadow hover:opacity-90 active:scale-95"
>
<ArrowLeft size={14} /> Retour à l&apos;interface principale
</Link>
</div>
);
}

View File

@@ -1,54 +1,308 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import dynamic from "next/dynamic";
import ComponentPalette from "@/components/palette/ComponentPalette";
import TopBar from "@/components/shell/TopBar";
import LeftNav from "@/components/shell/LeftNav";
import DocumentTabs from "@/components/shell/DocumentTabs";
import PropertiesPanel from "@/components/panels/PropertiesPanel";
import ResultsPanel from "@/components/panels/ResultsPanel";
import DofStatusBar from "@/components/DofStatusBar";
import Toolbar from "@/components/Toolbar";
import NewModelModal, { type NewDocumentKind } from "@/components/modals/NewModelModal";
import { useDiagramStore } from "@/store/diagramStore";
import { useScenarioSimulation } from "@/lib/useScenarioSimulation";
import { useDocuments } from "@/lib/useDocuments";
import { fetchModule, saveModule } from "@/lib/api";
// React Flow needs the DOM — load only on the client.
const Canvas = dynamic(() => import("@/components/canvas/Canvas"), { ssr: false });
export default function Page() {
const { result, simError, lastConfig, nodes, simulating } = useDiagramStore();
// Mount gate: avoid dead clicks from SSR/client hydration mismatches
// (extensions, HMR, or browser tooling injecting attributes into HTML).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className="flex h-screen items-center justify-center bg-[var(--chrome)] text-[13px] text-[var(--ink-dim)]">
Chargement du workbench
</div>
);
}
return <WorkbenchApp />;
}
function WorkbenchApp() {
const { result, simError, lastConfig, nodes } = useDiagramStore();
const { run: runSimulation, issues, simulating } = useScenarioSimulation();
const {
docs,
activeDoc,
activeId,
workspaceName,
switchTo,
openModule,
openEmptyModule,
closeDoc,
markClean,
newScenario,
} = useDocuments();
const [newOpen, setNewOpen] = useState(false);
const [newDefaultKind, setNewDefaultKind] = useState<NewDocumentKind>("class");
const [saveError, setSaveError] = useState<string | null>(null);
const editingModule = activeDoc.kind === "module" ? activeDoc.name : null;
const scope = useMemo(() => {
if (editingModule) return [workspaceName, editingModule];
return [workspaceName];
}, [workspaceName, editingModule]);
const openNew = useCallback((kind: NewDocumentKind = "class") => {
setNewDefaultKind(kind);
setNewOpen(true);
}, []);
const handleSaveModuleEditor = useCallback(async () => {
if (!editingModule) return;
const state = useDiagramStore.getState();
const currentNodes = state.nodes;
const currentEdges = state.edges;
const components: Array<Record<string, unknown>> = [];
const instances: Array<{
of: string;
name: string;
params?: Record<string, unknown>;
}> = [];
const atomicEdges: Array<{ from: string; to: string }> = [];
const connections: Array<{ from: string; to: string }> = [];
currentNodes.forEach((n) => {
const data = n.data;
if (data.type === "ModuleInstance" || data.moduleName) {
instances.push({
of: String(data.moduleName ?? data.params?.module_name ?? "BaseChiller"),
name: data.name,
params: data.params,
});
} else {
components.push({
type: data.kind,
name: data.name,
...data.params,
});
}
});
currentEdges.forEach((e) => {
const sNode = currentNodes.find((n) => n.id === e.source);
const tNode = currentNodes.find((n) => n.id === e.target);
if (!sNode || !tNode) return;
const sData = sNode.data;
const tData = tNode.data;
const isInstanceEdge =
sData.type === "ModuleInstance" || tData.type === "ModuleInstance";
const fromPort = e.sourceHandle ?? "outlet";
const toPort = e.targetHandle ?? "inlet";
if (isInstanceEdge) {
connections.push({
from: `${sData.name}.${fromPort}`,
to: `${tData.name}.${toPort}`,
});
} else {
atomicEdges.push({
from: `${sData.name}:${fromPort}`,
to: `${tData.name}:${toPort}`,
});
}
});
// Preserve existing port map (component:port) when re-saving a class.
let ports: Record<string, string> = {};
try {
const existing = await fetchModule(editingModule);
if (existing && typeof existing === "object" && "ports" in existing) {
const p = (existing as { ports?: Record<string, string> }).ports;
if (p && typeof p === "object") ports = { ...p };
}
} catch {
/* empty class or offline */
}
const res = await saveModule(editingModule, {
params: {},
components,
instances,
edges: atomicEdges,
connections,
ports,
});
if (res.ok) {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("entropyk-modules-updated"));
}
markClean(activeId);
setSaveError(null);
} else {
setSaveError(`Could not save class: ${res.error ?? "unknown error"}`);
}
}, [editingModule, activeId, markClean]);
useEffect(() => {
(window as unknown as Record<string, unknown>).useDiagramStore = useDiagramStore;
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "Enter" || e.key === "NumpadEnter")) {
e.preventDefault();
void runSimulation();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [runSimulation]);
// Clear any stale save error when the user switches documents.
useEffect(() => {
setSaveError(null);
}, [activeId]);
return (
<div className="flex h-screen flex-col">
<Toolbar />
{saveError && (
<div className="flex items-center justify-between gap-2 rounded-md border border-[var(--warn)] bg-[var(--warn)]/10 px-3 py-2 text-[12px] text-[var(--warn)]">
<span>{saveError}</span>
<button type="button" className="toolish" aria-label="Fermer" onClick={() => setSaveError(null)}></button>
</div>
)}
<TopBar
scope={scope}
unsaved={activeDoc.dirty}
editingModule={editingModule}
workspaceName={workspaceName}
onNavigateBack={() => switchTo("workspace")}
onExitModuleEditor={() => closeDoc(activeId)}
onSaveModuleEditor={handleSaveModuleEditor}
onOpenNew={openNew}
onSimulate={runSimulation}
simulating={simulating}
/>
<DocumentTabs docs={docs} activeId={activeId} onSwitch={switchTo} onClose={closeDoc} />
<div className="flex min-h-0 flex-1 overflow-hidden">
<ComponentPalette />
<LeftNav
onSelectModuleForEdit={openModule}
onEnterModule={(className) => void openModule(className)}
onOpenNewModelModal={() => openNew("class")}
activeClassName={editingModule}
/>
<div className="relative flex-1">
<Canvas />
{nodes.length === 0 && <EmptyHint />}
</div>
<main className="relative flex-1">
<Canvas onOpenModuleEditor={openModule} />
{nodes.length === 0 && (
<EmptyHint
editingModule={editingModule}
workspaceName={workspaceName}
onNewClass={() => openNew("class")}
/>
)}
</main>
{/* Right column: parameter dialog + results, stacked & scrollable */}
<div className="flex w-96 flex-col overflow-y-auto border-l border-[var(--line)] bg-[var(--panel)]">
<PropertiesPanel />
<div className="h-2 bg-[var(--chrome)]" />
<ResultsPanel result={result} error={simError} config={lastConfig} simulating={simulating} />
</div>
<aside className="flex w-96 flex-col overflow-y-auto border-l border-[var(--line)] bg-[var(--panel)]">
<section
className="flex flex-col border-b border-[var(--line)]"
aria-label="Component inspector"
>
<div className="flex h-7 items-center justify-between bg-[var(--chrome)] px-3">
<span className="eyebrow">Inspector</span>
<span className="mono text-[10px] text-[var(--ink-faint)]">
{editingModule ? `${editingModule}.ekmod` : workspaceName}
</span>
</div>
<PropertiesPanel />
</section>
<section className="flex min-h-0 flex-1 flex-col" aria-label="Solve bench">
<ResultsPanel
result={result}
error={simError}
config={lastConfig}
simulating={simulating}
issues={issues}
/>
</section>
</aside>
</div>
{/* Live DoF indicator: equations vs unknowns (fix/free discipline) */}
<DofStatusBar />
<NewModelModal
isOpen={newOpen}
onClose={() => setNewOpen(false)}
defaultKind={newDefaultKind}
onScenarioCreated={(name, clearCanvas) => newScenario(name, clearCanvas)}
onClassCreated={(className) => {
openEmptyModule(className);
setNewOpen(false);
}}
/>
</div>
);
}
function EmptyHint() {
function EmptyHint({
editingModule,
workspaceName,
onNewClass,
}: {
editingModule?: string | null;
workspaceName: string;
onNewClass: () => void;
}) {
const wsLabel =
workspaceName === "Untitled" || !workspaceName ? "Workspace" : workspaceName;
return (
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="max-w-sm text-center">
<p className="text-[15px] font-semibold text-[var(--ink-dim)]">Start your cycle</p>
<p className="mt-1 text-[12px] leading-relaxed text-[var(--ink-faint)]">
Drag parts from the library onto the sheet, then wire ports. Select a part,
then use the top bar, right-click menu, or{" "}
<span className="mono">R</span>/<span className="mono">H</span>/
<span className="mono">V</span>. Drop a pipe on a line to insert it.
</p>
<div className="max-w-md px-6 text-center">
{editingModule ? (
<>
<p className="mono text-[15px] font-semibold text-[var(--ink)]">
{editingModule}
<span className="text-[var(--ink-faint)]">.ekmod</span>
</p>
<p className="mt-1.5 text-[12px] leading-relaxed text-[var(--ink-dim)]">
Édition de la classe dépose des parts, câble, puis Save. Ensuite
retourne au Workspace pour linstancier.
</p>
</>
) : (
<>
<p className="text-[15px] font-semibold text-[var(--ink)]">{wsLabel}</p>
<p className="mt-1.5 text-[12px] leading-relaxed text-[var(--ink-dim)]">
Canvas Workspace glisse un modèle depuis Models (à gauche), ou crée-en un
nouveau. Tes .ekmod sont des classes Modelica.
</p>
<button
type="button"
onClick={onNewClass}
className="pointer-events-auto mt-3 rounded-md bg-[var(--accent)] px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-[var(--accent-hover,#1d4ed8)]"
>
Nouveau modèle
</button>
</>
)}
</div>
</div>
);

View File

@@ -6,6 +6,7 @@ import { buildDofCoach, type CoachSeverity } from "@/lib/dofCoach";
import type { DofBalance } from "@/lib/dofLedger";
import type { EntropykNodeData } from "@/lib/configBuilder";
import type { Node } from "@xyflow/react";
import ApiHealthIndicator from "@/components/shell/ApiHealthIndicator";
import {
AlertTriangle,
CheckCircle2,
@@ -118,6 +119,7 @@ export default function DofStatusBar() {
</span>
<span className="mono ml-auto hidden items-center gap-2 text-[10px] text-[var(--ink-faint)] md:flex">
<ApiHealthIndicator />
<Sparkles size={11} className="text-[var(--accent)]" />
<span>{coach.tips.length} tip{coach.tips.length > 1 ? "s" : ""}</span>
<span>·</span>

View File

@@ -1,348 +0,0 @@
"use client";
import { useRef, useState } from "react";
import {
Play,
Loader2,
FileDown,
FileUp,
Eraser,
Boxes,
Grid3x3,
RotateCw,
Layers,
FlipHorizontal2,
FlipVertical2,
} from "lucide-react";
import { useDiagramStore } from "@/store/diagramStore";
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
import { computeDofLedger } from "@/lib/dofLedger";
import { simulate } from "@/lib/api";
import type { EntropykNodeData } from "@/lib/configBuilder";
import type { Node } from "@xyflow/react";
import MultiRunPanel from "@/components/panels/MultiRunPanel";
const FLUIDS = ["R410A", "R134a", "R290", "R744", "R32", "R1234yf", "Water", "Air"];
const BACKENDS = ["CoolProp", "Test"];
const SOLVERS = [
{ value: "newton", label: "Newton" },
{ value: "picard", label: "Picard" },
];
const EXAMPLES = [
{ file: "hx_air_water_4port.json", label: "HX airwater" },
{ file: "chiller_flooded_4port_watercooled.json", label: "Chiller flooded" },
{ file: "chiller_watercooled_r410a.json", label: "Chiller R410A" },
{ file: "chiller_aircooled_r134a.json", label: "Chiller air-cooled" },
{ file: "bphx_evaporator_condenser.json", label: "BPHX cycle" },
{ file: "heatpump_r410a_reversing_valve.json", label: "Heat pump 4-way" },
{ file: "chiller_r134a_exv_orifice.json", label: "EXV orifice" },
{ file: "chiller_r410a_full_physics.json", label: "R410A full physics" },
{ file: "capillary_tube_r134a.json", label: "Capillary smoke" },
];
export default function Toolbar() {
const {
nodes,
edges,
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
snapToGrid,
selectedNodeId,
setFluid,
setFluidBackend,
setSolverStrategy,
setLastConfig,
toggleSnapToGrid,
rotateNode,
flipNodeH,
flipNodeV,
setResult,
setSimulating,
simulating,
loadFromConfig,
clear,
} = useDiagramStore();
const [issues, setIssues] = useState<string[]>([]);
const [multiOpen, setMultiOpen] = useState(false);
const [exampleFile, setExampleFile] = useState(EXAMPLES[0].file);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const onSimulate = async () => {
const problems = validateConfig(nodes, edges);
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
// Hard block only when over-constrained (no free lunch). Under-constrained
// diagrams are common while editing — surface as a soft issue after simulate
// via the status bar; the CLI still hard-fails on non-square systems.
if (ledger.balance === "over-constrained") {
problems.push(
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
`Remove a FIX (quality control, extra outlet closure) or FREE an actuator.`,
);
}
setIssues(problems);
if (problems.length > 0) return;
const config = buildScenarioConfig(nodes, edges, {
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
});
setSimulating(true);
setLastConfig(config);
setResult(null, null);
try {
const resp = await simulate(config);
if (resp.ok && resp.result) {
setResult(resp.result, null);
if (resp.result.dof && resp.result.dof.n_equations !== resp.result.dof.n_unknowns) {
setIssues([
`Server DoF: ${resp.result.dof.n_equations} eqs vs ${resp.result.dof.n_unknowns} unk (${resp.result.dof.balance})`,
]);
}
} else setResult(null, resp.error || "Simulation failed");
} catch (e) {
setResult(null, e instanceof Error ? e.message : String(e));
} finally {
setSimulating(false);
}
};
const onLoadExample = async () => {
try {
const res = await fetch(`/examples/${exampleFile}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
loadFromConfig(await res.json());
setIssues([]);
} catch (e) {
setIssues([`Could not load example: ${e instanceof Error ? e.message : e}`]);
}
};
const onImportJsonFile = async (file: File | undefined) => {
if (!file) return;
try {
const text = await file.text();
loadFromConfig(JSON.parse(text));
setIssues([]);
} catch (e) {
setIssues([`Could not import JSON: ${e instanceof Error ? e.message : e}`]);
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<header className="flex h-11 items-center gap-2 border-b border-black/40 bg-[var(--bar)] px-3 text-[var(--bar-ink)]">
<div className="flex items-center gap-2 pr-1">
<Boxes size={16} className="text-[var(--bar-accent)]" />
<span className="mono text-[12.5px] font-semibold tracking-[0.14em] text-white">
ENTROPYK
</span>
</div>
<div className="bar-sep" />
<BarField label="Fluid">
<select value={fluid} onChange={(e) => setFluid(e.target.value)} className="bar-select mono">
{FLUIDS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</BarField>
<BarField label="Props">
<select
value={fluidBackend}
onChange={(e) => setFluidBackend(e.target.value)}
className="bar-select mono"
>
{BACKENDS.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</BarField>
<BarField label="Solver">
<select
value={solverStrategy}
onChange={(e) => setSolverStrategy(e.target.value)}
className="bar-select mono"
>
{SOLVERS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</BarField>
<div className="bar-sep" />
{/* Orientation — icon group, works on the selected part */}
<div className="flex items-center overflow-hidden rounded-[4px] border border-white/12">
<button
onClick={() => selectedNodeId && rotateNode(selectedNodeId, 1)}
disabled={!selectedNodeId}
className="bar-icon"
title="Rotation 90° (R) — sélectionne dabord une pièce"
>
<RotateCw size={13} />
</button>
<button
onClick={() => selectedNodeId && flipNodeH(selectedNodeId)}
disabled={!selectedNodeId}
className="bar-icon border-l border-white/12"
title="Miroir horizontal (H)"
>
<FlipHorizontal2 size={13} />
</button>
<button
onClick={() => selectedNodeId && flipNodeV(selectedNodeId)}
disabled={!selectedNodeId}
className="bar-icon border-l border-white/12"
title="Miroir vertical (V)"
>
<FlipVertical2 size={13} />
</button>
<button
onClick={toggleSnapToGrid}
className="bar-icon border-l border-white/12"
data-active={snapToGrid}
title="Aimanter à la grille"
>
<Grid3x3 size={13} />
</button>
</div>
<div className="flex-1" />
{issues.length > 0 && (
<div
className="mono max-w-[240px] truncate text-[11px] text-amber-300"
title={issues.join("\n")}
>
{issues.length} · {issues[0]}
</div>
)}
<select
value={exampleFile}
onChange={(e) => setExampleFile(e.target.value)}
className="bar-select mono max-w-[150px]"
title="Exemple de scénario"
>
{EXAMPLES.map((ex) => (
<option key={ex.file} value={ex.file}>
{ex.label}
</option>
))}
</select>
<button onClick={onLoadExample} className="bar-btn" title="Charger lexemple">
<FileDown size={13} /> Load
</button>
<button onClick={() => fileInputRef.current?.click()} className="bar-btn" title="Importer un JSON">
<FileUp size={13} /> Import
</button>
<button onClick={() => setMultiOpen(true)} className="bar-btn" title="Balayage paramétrique parallèle">
<Layers size={13} /> Multi-run
</button>
<button onClick={clear} className="bar-btn" title="Vider la feuille">
<Eraser size={13} />
</button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => void onImportJsonFile(event.target.files?.[0])}
/>
<button
onClick={onSimulate}
disabled={simulating}
className="ml-1 flex items-center gap-1.5 rounded-[4px] bg-[var(--bar-accent)] px-4 py-[7px] text-[12.5px] font-semibold text-[#0c1420] transition-all hover:brightness-110 disabled:opacity-50"
>
{simulating ? <Loader2 size={13} className="animate-spin" /> : <Play size={13} />}
{simulating ? "Solving…" : "Solve"}
</button>
{multiOpen && <MultiRunPanel onClose={() => setMultiOpen(false)} />}
<style jsx>{`
.bar-sep {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.12);
margin: 0 4px;
}
.bar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
padding: 5px 9px;
font-size: 12px;
color: var(--bar-ink);
transition: all 0.12s ease;
}
.bar-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bar-icon {
display: grid;
place-items: center;
width: 28px;
height: 26px;
color: var(--bar-ink);
background: rgba(255, 255, 255, 0.04);
transition: all 0.12s ease;
}
.bar-icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.bar-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.bar-icon[data-active="true"] {
background: rgba(94, 176, 255, 0.22);
color: var(--bar-accent);
}
:global(.bar-select) {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 4px 6px;
font-size: 11.5px;
color: var(--bar-ink);
}
:global(.bar-select option) {
color: var(--ink);
background: #fff;
}
`}</style>
</header>
);
}
function BarField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex items-center gap-1.5 leading-none">
<span className="mono text-[9px] uppercase tracking-[0.1em] text-white/45">{label}</span>
{children}
</label>
);
}

View File

@@ -49,9 +49,20 @@ function shadeHex(hex: string, amount: number): string {
return `#${ch(1)}${ch(2)}${ch(3)}`;
}
export default function Canvas() {
interface CanvasProps {
onOpenModuleEditor?: (moduleName: string) => void;
}
export default function Canvas({ onOpenModuleEditor }: CanvasProps) {
const rfInstance = useRef<ReactFlowInstance<Node<EntropykNodeData>, Edge> | null>(null);
const [ctxMenu, setCtxMenu] = useState<NodeContextMenuState | null>(null);
const [connectionHint, setConnectionHint] = useState<string | null>(null);
const hintTimer = useRef<number | null>(null);
const scheduleClear = () => {
if (hintTimer.current) window.clearTimeout(hintTimer.current);
hintTimer.current = window.setTimeout(() => setConnectionHint(null), 2500);
};
const {
nodes,
@@ -128,6 +139,21 @@ export default function Canvas() {
y: e.clientY,
});
// Module instance from the workspace palette.
if (type.startsWith("ModuleInstance:")) {
const moduleName = type.slice("ModuleInstance:".length);
const portsStr = e.dataTransfer.getData("application/entropyk-ports");
let module_ports: string[] = [];
try {
if (portsStr) module_ports = JSON.parse(portsStr);
} catch {}
addComponent("ModuleInstance", position, {
module_name: moduleName,
module_ports: JSON.stringify(module_ports),
});
return;
}
// Pipe / duct dropped on a wire → splice into the circuit (Modelica-style).
if (isPipeType(type)) {
const prefer = pipeMediaKind(type, defaultParams(type));
@@ -148,10 +174,21 @@ export default function Canvas() {
);
const isValidConnection = useCallback((c: Connection | Edge) => {
if (!c.source || !c.target || c.source === c.target) return false;
if (!c.source || !c.target) return false;
if (c.source === c.target) {
setConnectionHint("Connexion refusée : même composant.");
scheduleClear();
return false;
}
const srcOk = !c.sourceHandle || portRole(c.sourceHandle) === "source";
const tgtOk = !c.targetHandle || portRole(c.targetHandle) === "target";
return srcOk && tgtOk;
if (!srcOk || !tgtOk) {
setConnectionHint("Connexion refusée : branche une sortie vers une entrée.");
scheduleClear();
return false;
}
setConnectionHint(null);
return true;
}, []);
/**
@@ -254,12 +291,18 @@ export default function Canvas() {
setCtxMenu(null);
setSelected(node.id);
}}
onNodeDoubleClick={(_, node) => {
if (node.data.type === "ModuleInstance") {
const mName = String(node.data.params.module_name ?? "");
if (mName) onOpenModuleEditor?.(mName);
}
}}
onNodeContextMenu={onNodeContextMenu}
onPaneClick={() => {
setCtxMenu(null);
setSelected(null);
}}
deleteKeyCode={["Delete", "Backspace"]}
deleteKeyCode={["Delete"]}
defaultEdgeOptions={{ type: "smoothstep" }}
connectionRadius={34}
snapToGrid={snapToGrid}
@@ -286,6 +329,15 @@ export default function Canvas() {
/>
<Controls showInteractive={false} />
</ReactFlow>
{connectionHint && (
<div
role="status"
aria-live="polite"
className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-[var(--warn)] bg-[var(--panel)] px-3 py-1.5 text-[11px] text-[var(--warn)] shadow-md"
>
{connectionHint}
</div>
)}
<MediaLegend />
{selectedNode && (
<OrientationBar
@@ -355,7 +407,7 @@ function OrientationBar({
>
<FlipVertical2 size={13} />
</button>
<style jsx>{`
<style>{`
.orient-btn {
display: grid;
place-items: center;

View File

@@ -19,8 +19,9 @@ describe("ComponentIcon", () => {
expect(dashed).not.toBeNull();
});
it("tints the glyph with the provided colour", () => {
const { container } = render(<ComponentIcon type="IsentropicCompressor" color="#abcdef" />);
expect(container.innerHTML).toContain("#abcdef");
it("renders the dashed placeholder for undefined type without crashing", () => {
const { container } = render(<ComponentIcon type={undefined as unknown as string} color="#123456" />);
const dashed = container.querySelector("[stroke-dasharray]");
expect(dashed).not.toBeNull();
});
});

View File

@@ -11,9 +11,9 @@
*/
import type { ReactElement, ReactNode } from "react";
import { hxGlyphKey } from "@/lib/hxFamily";
import { hxGlyphKey, type HxVisualOpts } from "@/lib/hxFamily";
type IconProps = { color: string };
type IconProps = { color: string; secondaryFluid?: string | null };
const INK = "#2f3e52";
const INK_FAINT = "rgba(47,62,82,0.34)";
@@ -195,9 +195,6 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
</Frame>
),
condenser: (c) => GLYPHS.hx_shell_cond(c),
evaporator: (c) => GLYPHS.hx_dx(c),
// ── Valve — ISO bowtie + actuator stem ─────────────────────
valve: (c) => (
<Frame>
@@ -208,6 +205,19 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
</Frame>
),
// ── Reversing valve (4-way heat-pump) — body + two-way arrows ⇄ ──
reversing: (c) => (
<Frame>
<rect x="24" y="32" width="52" height="36" rx="3" {...line(2)} fill="none" />
<line x1="6" y1="50" x2="24" y2="50" {...line(2)} />
<line x1="76" y1="50" x2="94" y2="50" {...line(2)} />
<line x1="36" y1="44" x2="60" y2="44" {...accent(c, 1.8)} />
<path d="M57 41 L60 44 L57 47" {...accent(c, 1.8)} fill="none" />
<line x1="60" y1="56" x2="36" y2="56" {...accent(c, 1.8)} />
<path d="M39 53 L36 56 L39 59" {...accent(c, 1.8)} fill="none" />
</Frame>
),
// ── Pump — circle + filled flow triangle ───────────────────
pump: (c) => (
<Frame>
@@ -298,26 +308,32 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
),
};
function glyphKey(type: string): keyof typeof GLYPHS {
const hx = hxGlyphKey(type);
if (hx && hx in GLYPHS) return hx;
function glyphKey(type?: string, opts?: HxVisualOpts): keyof typeof GLYPHS {
const t = String(type || "");
const hx = hxGlyphKey(t, opts);
if (hx && hx in GLYPHS) return hx as keyof typeof GLYPHS;
if (type === "SaturatedController") return "control";
if (type.includes("Compressor")) return "compressor";
if (type === "HeatExchanger") return "hx";
if (type.includes("Valve")) return "valve";
if (type === "Pump") return "pump";
if (type === "Fan") return "fan";
if (type === "Pipe" || type.includes("Pipe") || type === "AirDuct") return "pipe";
if (type === "Drum") return "drum";
if (type === "FlowSplitter") return "splitter";
if (type === "FlowMerger") return "merger";
if (type.endsWith("Source")) return "source";
if (type.endsWith("Sink")) return "sink";
if (t === "SaturatedController") return "control";
if (t.includes("Compressor")) return "compressor";
if (t === "HeatExchanger") return "hx";
if (t === "ReversingValve") return "reversing";
if (t.includes("Valve")) return "valve";
if (t === "Pump") return "pump";
if (t === "Fan") return "fan";
if (t === "Pipe" || t.includes("Pipe") || t === "AirDuct") return "pipe";
if (t === "Drum") return "drum";
if (t === "FlowSplitter") return "splitter";
if (t === "FlowMerger") return "merger";
if (t.endsWith("Source")) return "source";
if (t.endsWith("Sink")) return "sink";
return "placeholder";
}
export function ComponentIcon({ type, color }: { type: string } & IconProps) {
const draw = GLYPHS[glyphKey(type)] ?? GLYPHS.placeholder;
export function ComponentIcon({
type,
color,
secondaryFluid,
}: { type?: string } & IconProps) {
const draw = GLYPHS[glyphKey(type, { secondaryFluid })] ?? GLYPHS.placeholder;
return draw(color);
}

View File

@@ -1,13 +1,15 @@
"use client";
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { COMPONENT_BY_TYPE, portSide, portRole, portLabel, nodeSize } from "@/lib/componentMeta";
import { memo, useEffect, useState } from "react";
import { Handle, Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react";
import { COMPONENT_BY_TYPE, portSide, portRole, portLabel, nodeSize, genericMeta } from "@/lib/componentMeta";
import { mediaColor, mediaForPort } from "@/lib/mediaStyle";
import { effectiveSide, type Side } from "@/lib/orientation";
import type { EntropykNodeData } from "@/store/diagramStore";
import { hxFamily } from "@/lib/hxFamily";
import { ComponentIcon } from "./ComponentIcon";
import { Package } from "lucide-react";
import { fetchModule } from "@/lib/api";
const SIDE_POSITION: Record<Side, Position> = {
left: Position.Left,
@@ -16,24 +18,37 @@ const SIDE_POSITION: Record<Side, Position> = {
bottom: Position.Bottom,
};
function EntropykNode({ data, selected }: NodeProps) {
function EntropykNode({ data, id, selected }: NodeProps) {
const d = data as unknown as EntropykNodeData;
const meta = COMPONENT_BY_TYPE[d.type];
const { w: BOX_W, h: BOX_H } = nodeSize(d.type);
const rotation = d.rotation ?? 0;
const flipH = !!d.flipH;
const flipV = !!d.flipV;
const isController = d.type === "SaturatedController";
const family = hxFamily(d.type);
const nodeType = String(d?.kind || d?.type || "");
const isModule = nodeType === "ModuleInstance" || !!d?.moduleName;
const rotation = d?.rotation ?? 0;
const flipH = !!d?.flipH;
const flipV = !!d?.flipV;
if (!meta) {
return (
<div className="rounded border border-[var(--hot)] bg-white px-2 py-1 text-[11px] text-[var(--hot)]">
Unknown: {d.type}
</div>
);
// React Flow caches Handle positions on first render; flipping/rotating a
// node changes the effective side of each port, but the renderer keeps
// routing edges from the stale position until React Flow is told to
// re-measure. `useUpdateNodeInternals` is the documented helper for this:
// it invalidates the cached Handle positions of the node and re-routes
// every connected edge from the new Handle sides.
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
updateNodeInternals(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, rotation, flipH, flipV]);
if (isModule) {
return <ModuleNodeFace data={d} selected={selected} />;
}
const meta = COMPONENT_BY_TYPE[nodeType] ?? genericMeta(nodeType);
const { w: BOX_W, h: BOX_H } = nodeSize(nodeType);
const isController = nodeType === "SaturatedController";
const secondaryFluid =
d?.params?.secondary_fluid != null ? String(d.params.secondary_fluid) : null;
const family = hxFamily(nodeType, { secondaryFluid });
const accent = family?.accent ?? meta.color;
const placement: Record<string, { side: Side; offset: number }> = {};
@@ -78,7 +93,7 @@ function EntropykNode({ data, selected }: NodeProps) {
className="h-full w-full"
style={{ transform: iconTransform || undefined }}
>
<ComponentIcon type={d.type} color={accent} />
<ComponentIcon type={nodeType} color={accent} secondaryFluid={secondaryFluid} />
</div>
)}
@@ -94,7 +109,7 @@ function EntropykNode({ data, selected }: NodeProps) {
};
return (
<Handle
key={port}
key={`${port}::${side}`}
type={portRole(port)}
position={SIDE_POSITION[side]}
id={port}
@@ -222,3 +237,148 @@ export default memo(EntropykNode, (prev, next) => {
a.params === b.params
);
});
function ModuleNodeFace({
data,
selected,
}: {
data: EntropykNodeData;
selected?: boolean;
}) {
const moduleName = String(data.params.module_name ?? "?");
const updateNodeInternals = useUpdateNodeInternals();
const [ports, setPorts] = useState<string[]>(() => {
if (typeof data.params.module_ports === "string") {
try {
const parsed = JSON.parse(data.params.module_ports);
if (Array.isArray(parsed)) return parsed;
} catch {}
}
if (Array.isArray(data.params.module_ports)) return data.params.module_ports as string[];
if (Array.isArray(data.module_ports)) return data.module_ports as string[];
return [];
});
useEffect(() => {
if (moduleName !== "?") {
fetchModule(moduleName).then((modData) => {
if (modData && typeof modData === "object") {
if ("ports" in modData) {
const pMap = (modData as { ports?: Record<string, string> }).ports;
if (pMap && typeof pMap === "object") {
setPorts(Object.keys(pMap));
}
}
}
});
}
}, [moduleName]);
useEffect(() => {
updateNodeInternals(data.name);
}, [data.name, ports.length, updateNodeInternals]);
const leftPorts: string[] = [];
const rightPorts: string[] = [];
ports.forEach((p) => {
const isOutlet = /out|discharge|outlet/i.test(p);
const isInlet = /in|suction|inlet/i.test(p);
if (isOutlet && !isInlet) {
rightPorts.push(p);
} else if (isInlet && !isOutlet) {
leftPorts.push(p);
} else {
if (leftPorts.length <= rightPorts.length) leftPorts.push(p);
else rightPorts.push(p);
}
});
const maxPortCount = Math.max(leftPorts.length, rightPorts.length, 1);
const BOX_W = 140;
const BOX_H = Math.max(100, maxPortCount * 28 + 40);
return (
<div className="flex flex-col items-center" style={{ width: BOX_W }}>
<div
className="ek-node relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-[var(--accent)] bg-white shadow-sm transition-all"
style={{
width: BOX_W,
height: BOX_H,
outline: selected ? "2px dashed var(--accent)" : "1.5px dashed transparent",
outlineOffset: 4,
}}
title={`Double-clic pour éditer la classe ${moduleName}`}
>
<Package size={32} className="text-[var(--accent)]" />
<span className="mono mt-1 max-w-[110px] truncate text-[10px] font-semibold text-[var(--ink)]">
{moduleName}
</span>
{leftPorts.map((port, idx) => {
const offset = (idx + 1) / (leftPorts.length + 1);
return (
<div key={`left-${port}`}>
<Handle
type="target"
position={Position.Left}
id={port}
style={{
top: `${offset * 100}%`,
borderColor: "var(--accent)",
background: "var(--accent)",
}}
className="media-handle"
isConnectable
title={port}
/>
<span
className="mono pointer-events-none absolute left-2 text-[8px] text-[var(--ink-dim)]"
style={{ top: `${offset * 100}%`, transform: "translateY(-50%)" }}
>
{port}
</span>
</div>
);
})}
{rightPorts.map((port, idx) => {
const offset = (idx + 1) / (rightPorts.length + 1);
return (
<div key={`right-${port}`}>
<Handle
type="source"
position={Position.Right}
id={port}
style={{
top: `${offset * 100}%`,
borderColor: "var(--accent)",
background: "var(--accent)",
}}
className="media-handle"
isConnectable
title={port}
/>
<span
className="mono pointer-events-none absolute right-2 text-[8px] text-[var(--ink-dim)]"
style={{ top: `${offset * 100}%`, transform: "translateY(-50%)" }}
>
{port}
</span>
</div>
);
})}
</div>
<div className="mt-1.5 flex max-w-[160px] flex-col items-center leading-tight">
<span className="mono truncate text-[11px] font-bold text-[var(--ink)]">
{data.name}
</span>
<span className="truncate text-[8.5px] font-medium text-[var(--ink-faint)]">
Module ({moduleName}) · C{data.circuit}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,331 @@
"use client";
import { useEffect, useState } from "react";
import { X, PackagePlus, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
import { useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
import { COMPONENT_BY_TYPE } from "@/lib/componentMeta";
import { saveModule } from "@/lib/api";
import type { Node } from "@xyflow/react";
interface CreateModuleModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
interface ExposedPortItem {
id: string;
exposedName: string;
compPortRef: string; // e.g. "evap:secondary_inlet"
}
export default function CreateModuleModal({
isOpen,
onClose,
onSuccess,
}: CreateModuleModalProps) {
const { nodes, edges } = useDiagramStore();
const [moduleName, setModuleName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Collect all available component:port references from current canvas nodes
const availableComponentPorts: Array<{ label: string; ref: string }> = [];
nodes.forEach((n) => {
const data = n.data as unknown as EntropykNodeData;
if (data.type === "ModuleInstance") return; // Skip nested module instances for flat component extraction
const meta = COMPONENT_BY_TYPE[data.type];
const ports = meta?.ports ?? ["inlet", "outlet"];
ports.forEach((p) => {
availableComponentPorts.push({
label: `${data.name} (${p})`,
ref: `${data.name}:${p}`,
});
});
});
// Default exposed ports proposal (auto-detect secondary / boundary ports)
const [exposedPorts, setExposedPorts] = useState<ExposedPortItem[]>(() => {
const items: ExposedPortItem[] = [];
nodes.forEach((n) => {
const data = n.data as unknown as EntropykNodeData;
if (data.type === "Evaporator") {
items.push({ id: `e1`, exposedName: "chw_in", compPortRef: `${data.name}:secondary_inlet` });
items.push({ id: `e2`, exposedName: "chw_out", compPortRef: `${data.name}:secondary_outlet` });
} else if (data.type === "Condenser") {
items.push({ id: `c1`, exposedName: "cond_water_in", compPortRef: `${data.name}:secondary_inlet` });
items.push({ id: `c2`, exposedName: "cond_water_out", compPortRef: `${data.name}:secondary_outlet` });
}
});
if (items.length === 0 && availableComponentPorts.length > 0) {
items.push({
id: "p1",
exposedName: "inlet",
compPortRef: availableComponentPorts[0].ref,
});
}
return items;
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
if (!isOpen) return null;
const addExposedPort = () => {
const defaultRef = availableComponentPorts[0]?.ref ?? "";
setExposedPorts((prev) => [
...prev,
{
id: `port_${Date.now()}`,
exposedName: `port_${prev.length + 1}`,
compPortRef: defaultRef,
},
]);
};
const removeExposedPort = (id: string) => {
setExposedPorts((prev) => prev.filter((p) => p.id !== id));
};
const updateExposedPort = (id: string, field: "exposedName" | "compPortRef", val: string) => {
setExposedPorts((prev) =>
prev.map((p) => (p.id === id ? { ...p, [field]: val } : p)),
);
};
const handleSave = async () => {
const cleanName = moduleName.trim().replace(/[^a-zA-Z0-9_-]/g, "");
if (!cleanName) {
setError("Veuillez saisir un nom de module valide (alphanumérique).");
return;
}
if (nodes.length === 0) {
setError("Le canvas ne contient aucun composant ni module à inclure.");
return;
}
setError(null);
setLoading(true);
const components: Array<Record<string, unknown>> = [];
const instances: Array<{ of: string; name: string; circuit: number; params: Record<string, unknown> }> = [];
const atomicEdges: Array<{ from: string; to: string }> = [];
const connections: Array<{ from: string; to: string }> = [];
nodes.forEach((n) => {
const d = n.data as unknown as EntropykNodeData;
if (d.type === "ModuleInstance") {
instances.push({
of: String(d.params.module_name ?? "BaseChiller"),
name: d.name,
circuit: d.circuit,
params: {},
});
} else {
const { type, name, ...params } = d;
components.push({
type: d.type,
name: d.name,
...params,
});
}
});
edges.forEach((e) => {
const sNode = nodes.find((n) => n.id === e.source);
const tNode = nodes.find((n) => n.id === e.target);
if (!sNode || !tNode) return;
const sData = sNode.data as unknown as EntropykNodeData;
const tData = tNode.data as unknown as EntropykNodeData;
const isInstanceEdge = sData.type === "ModuleInstance" || tData.type === "ModuleInstance";
const fromPort = e.sourceHandle ?? "outlet";
const toPort = e.targetHandle ?? "inlet";
if (isInstanceEdge) {
connections.push({ from: `${sData.name}.${fromPort}`, to: `${tData.name}.${toPort}` });
} else {
atomicEdges.push({ from: `${sData.name}:${fromPort}`, to: `${tData.name}:${toPort}` });
}
});
const portsMap: Record<string, string> = {};
exposedPorts.forEach((p) => {
if (p.exposedName.trim() && p.compPortRef) {
portsMap[p.exposedName.trim()] = p.compPortRef;
}
});
const ekmodContent = {
params: {},
components,
instances,
edges: atomicEdges,
connections,
ports: portsMap,
};
const res = await saveModule(cleanName, ekmodContent);
setLoading(false);
if (res.ok) {
setSuccess(true);
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("entropyk-modules-updated"));
}
setTimeout(() => {
setSuccess(false);
onSuccess?.();
onClose();
}, 300);
} else {
setError(res.error || "Erreur lors de la création du module.");
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4 overscroll-contain"
role="dialog"
aria-modal="true"
aria-label="Créer un module"
>
<div className="flex w-full max-w-lg flex-col rounded-lg border border-[var(--line)] bg-white shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--line)] px-4 py-3 bg-[var(--chrome)]">
<div className="flex items-center gap-2">
<PackagePlus size={18} className="text-[var(--accent)]" />
<h3 className="mono text-[13px] font-bold text-[var(--ink)]">
Save as class (.ekmod)
</h3>
</div>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="p-4 space-y-4 max-h-[75vh] overflow-y-auto">
{error && (
<div className="flex items-center gap-2 rounded border border-red-200 bg-red-50 p-2.5 text-[11px] text-red-700">
<AlertCircle size={15} className="shrink-0 text-red-500" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 p-2.5 text-[11px] text-emerald-700">
<CheckCircle2 size={15} className="shrink-0 text-emerald-500" />
<span>Module sauvegardé avec succès dans `.entropyk/modules/` !</span>
</div>
)}
{/* Module Name */}
<div>
<label className="block text-[11px] font-semibold text-[var(--ink-dim)] mb-1">
Class name
</label>
<input
type="text"
value={moduleName}
onChange={(e) => setModuleName(e.target.value)}
placeholder="e.g. DualChillerCircuit"
className="w-full rounded border border-[var(--line)] px-3 py-1.5 mono text-[12px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
/>
<p className="mt-1 mono text-[10px] text-[var(--ink-faint)]">
.entropyk/modules/{moduleName || "Name"}.ekmod
</p>
</div>
{/* Exposed Ports Selector */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[11px] font-semibold text-[var(--ink-dim)]">
Ports exposés (Handles externes)
</label>
<button
type="button"
onClick={addExposedPort}
className="flex items-center gap-1 rounded bg-[var(--accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--accent)] hover:bg-[var(--accent)]/20"
>
<Plus size={11} /> Ajouter un port
</button>
</div>
{exposedPorts.length === 0 ? (
<p className="text-[10px] text-[var(--ink-faint)] italic py-1">
Aucun port exposé sélectionné. Cliquez sur &quot;Ajouter un port&quot;.
</p>
) : (
<div className="space-y-2 border border-[var(--line)] rounded p-2 bg-[var(--chrome)]/30">
{exposedPorts.map((item) => (
<div key={item.id} className="flex items-center gap-2">
<input
type="text"
value={item.exposedName}
onChange={(e) => updateExposedPort(item.id, "exposedName", e.target.value)}
placeholder="Nom exposé (ex: chw_in)"
className="w-1/3 rounded border border-[var(--line)] px-2 py-1 mono text-[10.5px] outline-none focus:border-[var(--accent)]"
/>
<span className="text-[11px] text-[var(--ink-faint)]"></span>
<select
value={item.compPortRef}
onChange={(e) => updateExposedPort(item.id, "compPortRef", e.target.value)}
className="flex-1 rounded border border-[var(--line)] px-2 py-1 mono text-[10.5px] outline-none focus:border-[var(--accent)]"
>
{availableComponentPorts.map((cp) => (
<option key={cp.ref} value={cp.ref}>
{cp.label}
</option>
))}
</select>
<button
type="button"
onClick={() => removeExposedPort(item.id)}
className="rounded p-1 text-[var(--ink-faint)] hover:text-red-600 hover:bg-red-50"
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] px-4 py-3 bg-[var(--chrome)]">
<button
type="button"
onClick={onClose}
className="rounded border border-[var(--line)] px-3 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
>
Annuler
</button>
<button
type="button"
onClick={handleSave}
disabled={loading || success}
className="flex items-center gap-1.5 rounded bg-[var(--accent)] px-3 py-1.5 text-[11px] font-semibold text-white shadow-sm hover:opacity-90 active:scale-95 disabled:opacity-50"
>
<PackagePlus size={14} />
<span>{loading ? "…" : "Valider"}</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { PackagePlus, X } from "lucide-react";
import { saveModule } from "@/lib/api";
export type NewDocumentKind = "scenario" | "class";
interface NewModelModalProps {
isOpen: boolean;
onClose: () => void;
/** Kept for callers; always creates a model in the workspace. */
defaultKind?: NewDocumentKind;
onScenarioCreated?: (name: string, clearCanvas: boolean) => void;
onClassCreated: (className: string) => void;
}
/**
* Create a model inside the Workspace.
* Writes an empty .ekmod (CLI SubsystemTemplate) and opens it for editing.
*/
export default function NewModelModal({
isOpen,
onClose,
onClassCreated,
}: NewModelModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isOpen) return;
setName("");
setDescription("");
setLoading(false);
setError(null);
const t = window.setTimeout(() => inputRef.current?.focus(), 50);
return () => window.clearTimeout(t);
}, [isOpen]);
const cleanName = name.trim();
const handleValidate = async () => {
// Same cleaning as ui-server save_module_file — no client-side name blacklist.
const safeName = cleanName
.split("")
.filter((c) => /[A-Za-z0-9_-]/.test(c))
.join("");
if (!safeName) {
setError("Saisis un nom de modèle.");
return;
}
setLoading(true);
setError(null);
const content = {
description: description.trim() || undefined,
params: {},
components: [],
instances: [],
edges: [],
connections: [],
ports: {} as Record<string, string>,
};
const res = await saveModule(safeName, content);
setLoading(false);
if (!res.ok) {
setError(res.error || "Impossible de créer le modèle.");
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("entropyk-modules-updated"));
}
onClassCreated(safeName);
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-[rgba(23,32,43,0.35)] p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="new-model-title"
className="relative w-full max-w-md rounded-lg border border-[var(--line)] bg-[var(--panel)] shadow-md"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--line)] bg-[var(--chrome)] px-4 py-3">
<div className="flex items-center gap-2">
<PackagePlus size={18} className="text-[var(--accent)]" />
<div>
<h3 id="new-model-title" className="text-[14px] font-semibold text-[var(--ink)]">
Nouveau modèle
</h3>
<p className="text-[11px] text-[var(--ink-faint)]">
Classe Modelica · fichier .ekmod
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
aria-label="Fermer"
>
<X size={16} />
</button>
</div>
<div className="space-y-4 p-4">
{error && (
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700">
{error}
</div>
)}
<div>
<label htmlFor="new-model-name" className="mb-1 block text-[12px] font-medium text-[var(--ink-dim)]">
Nom du modèle
</label>
<input
id="new-model-name"
ref={inputRef}
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleValidate();
}
}}
placeholder=""
className="mono w-full rounded border border-[var(--line-strong)] bg-white px-3 py-2 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
/>
{cleanName.length > 0 && (
<p className="mono mt-1 text-[11px] text-[var(--ink-faint)]">
.entropyk/modules/{cleanName}.ekmod
</p>
)}
</div>
<div>
<label htmlFor="new-model-desc" className="mb-1 block text-[12px] font-medium text-[var(--ink-dim)]">
Description <span className="font-normal text-[var(--ink-faint)]">(optionnel)</span>
</label>
<input
id="new-model-desc"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Circuit chillé eau / eau"
className="w-full rounded border border-[var(--line-strong)] bg-white px-3 py-2 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] bg-[var(--chrome)] px-4 py-3">
<button
type="button"
onClick={onClose}
className="rounded-md border border-[var(--line)] bg-white px-4 py-2 text-[13px] font-medium text-[var(--ink)] hover:bg-[var(--chrome-2)]"
>
Annuler
</button>
<button
type="button"
onClick={() => void handleValidate()}
disabled={loading}
className="min-w-[100px] rounded-md bg-[var(--accent)] px-4 py-2 text-[13px] font-semibold text-white shadow-sm hover:brightness-110 disabled:cursor-wait"
>
{loading ? "…" : "Valider"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useEffect } from "react";
import { X, FilePlus, Play, PackagePlus, ArrowRight, BookOpen, Layers, CheckCircle2 } from "lucide-react";
interface WorkspaceHelpModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function WorkspaceHelpModal({ isOpen, onClose }: WorkspaceHelpModalProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4 overscroll-contain"
role="dialog"
aria-modal="true"
aria-label="Aide"
>
<div className="relative w-full max-w-2xl rounded-xl border border-slate-200 bg-white p-6 shadow-2xl animate-in fade-in zoom-in-95">
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<X size={18} />
</button>
{/* Header */}
<div className="flex items-center gap-2.5 border-b border-slate-100 pb-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-indigo-50 text-indigo-600">
<BookOpen size={20} />
</div>
<div>
<h3 className="mono text-base font-bold text-slate-900">
Guide Workspace & Création de Modèles (Style Dymola / Modelica)
</h3>
<p className="text-xs text-slate-500">
Comment construire, simuler, sauvegarder et réutiliser des sous-systèmes dans Entropyk.
</p>
</div>
</div>
{/* Body Content */}
<div className="mt-5 space-y-4 text-xs text-slate-600 max-h-[70vh] overflow-y-auto pr-1">
{/* Step 1: Create a Model */}
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
<FilePlus size={16} className="text-indigo-600" />
1. Créer un Nouveau Modèle ou Charger un Exemple
</div>
<p className="leading-relaxed">
Pour démarrer un modèle vierge, cliquez sur le bouton <span className="mono font-semibold text-slate-800">Eraser 🧹</span> dans la barre d&apos;outils.
Pour explorer un modèle pré-construit, choisissez un exemple dans le menu déroulant (ex: <span className="mono font-semibold">Chiller flooded</span>, <span className="mono font-semibold">HX air-water</span>) et cliquez sur <span className="mono font-semibold text-indigo-600">Load</span>.
</p>
</div>
{/* Step 2: Build & Connect */}
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
<Layers size={16} className="text-blue-600" />
2. Placer des Composants & Relier les Conduits
</div>
<p className="leading-relaxed">
Glissez-déposez des composants depuis l&apos;onglet <span className="mono font-semibold text-slate-800">Composants</span> ou cliquez sur <span className="mono font-semibold text-indigo-600">+ Poser sur canvas</span> depuis la <span className="mono font-semibold text-slate-800">Bibliothèque</span>.
Reliez les handles colorés (entrées bleues, sorties vertes) pour fermer votre cycle thermodynamique.
</p>
</div>
{/* Step 3: Save as Module (.ekmod) */}
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
<PackagePlus size={16} className="text-emerald-600" />
3. Sauvegarder un Sous-Système (Module .ekmod)
</div>
<p className="leading-relaxed">
Une fois votre sous-système assemblé (ex: groupe froid, centrale de traitement d&apos;air), cliquez sur <span className="mono font-semibold text-emerald-600">Save as Module</span>. Saisissez le nom du module et définissez les ports exposés (ex: <span className="mono font-semibold">chw_in</span>, <span className="mono font-semibold">chw_out</span>). Le module apparaît immédiatement dans l&apos;onglet <span className="mono font-semibold text-slate-800">Bibliothèque (.ekmod)</span>.
</p>
</div>
{/* Step 4: Dual Modes Dymola */}
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
<CheckCircle2 size={16} className="text-amber-600" />
4. Utiliser les Deux Modes Dymola / Modelica
</div>
<p className="leading-relaxed">
Sur chaque module déposé sur le canvas, le bouton <span className="mono font-semibold text-indigo-600">📐 Mode Schéma</span> bascule entre le <span className="mono font-semibold">Mode Icône</span> (symbole CAD vectoriel du Chiller) et le <span className="mono font-semibold">Mode Schéma Interne</span> (vue miniature des composants internes). Double-cliquez pour ouvrir l&apos;éditeur plein écran du sous-système.
</p>
</div>
{/* Step 5: Simulate */}
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
<Play size={16} className="text-[#3b82f6]" />
5. Simuler le Cycle & Résoudre
</div>
<p className="leading-relaxed">
Choisissez le fluide thermodynamique (ex: <span className="mono font-semibold">R410A</span>, <span className="mono font-semibold">R134a</span>) et cliquez sur <span className="mono font-semibold text-indigo-600">Solve</span>. Le résolveur Newton-Raphson de Rust calcule l&apos;état stationnaire complet (pressions, enthalpies, débits, COP, puissances).
</p>
</div>
</div>
{/* Footer */}
<div className="mt-6 flex justify-end border-t border-slate-100 pt-4">
<button
onClick={onClose}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-indigo-500 active:scale-95"
>
Compris <ArrowRight size={14} />
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { COMPONENTS, COMPONENT_CATEGORIES, type ComponentMeta } from "@/lib/componentMeta";
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
import { hxFamily } from "@/lib/hxFamily";
export default function ComponentPalette() {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({
Advanced: true,
Generic: true,
});
const toggle = (cat: string) => setCollapsed((c) => ({ ...c, [cat]: !c[cat] }));
const onDragStart = (e: React.DragEvent, type: string) => {
e.dataTransfer.setData("application/entropyk-type", type);
e.dataTransfer.effectAllowed = "move";
};
return (
<aside className="flex h-full w-60 flex-col border-r border-[var(--line)] bg-[var(--panel)]">
<div className="flex h-9 items-center justify-between px-3">
<span className="eyebrow">Bibliothèque</span>
<span className="mono text-[10px] text-[var(--ink-faint)]">{COMPONENTS.length}</span>
</div>
<div className="h-px w-full bg-[var(--line)]" />
<div className="flex-1 overflow-y-auto py-1">
{COMPONENT_CATEGORIES.map((cat) => {
const items = COMPONENTS.filter((c) => c.category === cat);
if (items.length === 0) return null;
const isOpen = !collapsed[cat];
return (
<div key={cat} className="palette-cat">
<button
type="button"
onClick={() => toggle(cat)}
aria-expanded={isOpen}
className="palette-cat-toggle flex w-full items-center gap-1 px-2.5 py-1.5 text-left hover:bg-[var(--chrome-2)]"
>
{isOpen ? (
<ChevronDown size={12} className="text-[var(--ink-faint)]" />
) : (
<ChevronRight size={12} className="text-[var(--ink-faint)]" />
)}
<span className="eyebrow">{cat}</span>
<span className="mono ml-1 text-[9px] text-[var(--ink-faint)]">{items.length}</span>
{cat === "Advanced" && (
<span className="ml-auto text-[8px] font-medium text-[var(--ink-faint)]">
rare
</span>
)}
</button>
<div className={`palette-cat-body ${isOpen ? "is-open" : "is-closed"}`}>
<div className="palette-cat-inner pb-1">
{items.map((c, i) => (
<PaletteItem
key={c.type}
meta={c}
index={i}
onDragStart={onDragStart}
/>
))}
</div>
</div>
</div>
);
})}
</div>
<div className="h-px w-full bg-[var(--line)]" />
<p className="px-3 py-2 text-[10px] leading-relaxed text-[var(--ink-faint)]">
Glisse un composant sur le schéma. Relie les ports. Un pipe/gaine déposé sur une ligne
sinsère automatiquement (même milieu : vert / bleu / jaune).
</p>
</aside>
);
}
function PaletteItem({
meta,
index,
onDragStart,
}: {
meta: ComponentMeta;
index: number;
onDragStart: (e: React.DragEvent, type: string) => void;
}) {
const family = hxFamily(meta.type);
const color = family?.accent ?? meta.color;
return (
<div
draggable
onDragStart={(e) => onDragStart(e, meta.type)}
className="palette-item group mx-1.5 flex cursor-grab items-center gap-2.5 rounded-[2px] px-2 py-1.5 hover:bg-[var(--chrome-2)] active:cursor-grabbing"
style={{ ["--i" as string]: index }}
title={meta.help ?? meta.description}
>
<span className="grid h-9 w-9 flex-shrink-0 place-items-center">
<span style={{ width: 30, height: 30 }}>
<ComponentIcon type={meta.type} color={color} />
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] text-[var(--ink-dim)] group-hover:text-[var(--ink)]">
{meta.label}
</span>
{family && (
<span className="mono text-[8px] uppercase tracking-[0.08em] text-[var(--ink-faint)]">
{family.badge} · {family.label}
</span>
)}
</span>
</div>
);
}

View File

@@ -1,7 +1,18 @@
"use client";
import { useMemo, useState } from "react";
import { Layers, Loader2, Play, Plus, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Layers, Loader2, Play, Plus, Trash2, X, AlertTriangle } from "lucide-react";
import {
LineChart,
Line,
Brush,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { useDiagramStore } from "@/store/diagramStore";
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
import type { EntropykNodeData } from "@/lib/configBuilder";
@@ -11,6 +22,10 @@ import {
defaultSweepsFromDiagram,
discoverSweepTargets,
extractKpis,
getSweepWarnings,
parseValueList,
resolvePinTarget,
extractAllSeries,
runParallel,
suggestValues,
type MultiRunResult,
@@ -35,6 +50,7 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
maxIterations,
tolerance,
} = useDiagramStore();
const pinnedSweepPaths = useDiagramStore((s) => s.pinnedSweepPaths);
const diagramNodes = nodes as Node<EntropykNodeData>[];
const targets = useMemo(
@@ -42,14 +58,41 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
[diagramNodes, fluid],
);
const [sweeps, setSweeps] = useState<SweepSpec[]>(() =>
defaultSweepsFromDiagram(diagramNodes, fluid),
);
const [sweeps, setSweeps] = useState<SweepSpec[]>(() => {
const initial = defaultSweepsFromDiagram(diagramNodes, fluid);
return initial; // pins are merged by the effect below
});
// Merge pinned sweep paths (pushed from the properties panel) into the
// active axis list without duplicating existing axes or dropping axes the
// user added manually. Unresolvable pins (deleted node, non-sweepable) are
// silently skipped — the store deliberately keeps stale pins.
useEffect(() => {
if (pinnedSweepPaths.length === 0) return;
setSweeps((current) => {
const existing = new Set(current.map((s) => s.path));
const toAdd: SweepSpec[] = [];
for (const path of pinnedSweepPaths) {
if (existing.has(path)) continue;
const t = targets.find((x) => x.path === path) ?? resolvePinTarget(path, diagramNodes);
if (!t) continue; // unresolvable (deleted node, non-numeric) → skip
toAdd.push({
path: t.path,
label: t.label,
kind: t.kind,
valuesText: suggestValues(t.current, t.paramKey),
});
}
return toAdd.length === 0 ? current : [...current, ...toAdd];
});
}, [pinnedSweepPaths, targets, diagramNodes]);
const [concurrency, setConcurrency] = useState(4);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState({ done: 0, total: 0 });
const [results, setResults] = useState<MultiRunResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
// (Y output series selection lives in ySeriesKey below)
const baseConfig = useMemo(
() =>
@@ -65,6 +108,119 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
const cases = useMemo(() => buildSweepCases(baseConfig, sweeps), [baseConfig, sweeps]);
// Active sweep axes (those with at least one value typed in).
const activeAxes = useMemo(
() => sweeps.filter((s) => parseValueList(s.valuesText).length > 0),
[sweeps],
);
// User-controlled X axis (which swept param is the abscissa) and Y series.
const [xAxisPath, setXAxisPath] = useState<string | null>(null);
const [ySeriesKey, setYSeriesKey] = useState<string | null>(null);
// Keep xAxisPath valid when the axis list changes (deleted / added axes).
useEffect(() => {
if (activeAxes.length === 0) {
if (xAxisPath !== null) setXAxisPath(null);
return;
}
if (!xAxisPath || !activeAxes.some((a) => a.path === xAxisPath)) {
setXAxisPath(activeAxes[0].path);
}
}, [activeAxes, xAxisPath]);
// Every plottable output series from the first succeeded case.
// Literature-aligned: any model output (COP, capacity, edge temps, mass flows,
// pressures, enthalpies) can be picked as Y — not just refrigeration KPIs.
const availableSeries = useMemo(() => {
const firstOk = results?.find((r) => r.ok && r.result);
return extractAllSeries(firstOk?.result);
}, [results]);
// Keep ySeriesKey valid: default to a non-zero COP if present, else first series.
useEffect(() => {
if (availableSeries.length === 0) {
if (ySeriesKey !== null) setYSeriesKey(null);
return;
}
if (!availableSeries.some((s) => s.key === ySeriesKey)) {
const cop = availableSeries.find((s) => s.key === "perf.cop" && (s.value ?? 0) !== 0);
setYSeriesKey((cop ?? availableSeries[0]).key);
}
}, [availableSeries, ySeriesKey]);
// Chart dataset. X = chosen swept param (xAxisPath), Y = chosen output series (ySeriesKey).
// 1 axis → single curve. 2 axes → one curve per value of the other axis.
// 3+ axes → null (too many for readable curves; the table is the source of truth).
const chartData = useMemo(() => {
if (!results || results.length === 0 || !xAxisPath) return null;
const okResults = results.filter((r) => r.ok && r.result);
if (okResults.length === 0) return null;
const kpiVal = (r: MultiRunResult): number | null => {
const s = extractAllSeries(r.result).find((x) => x.key === ySeriesKey);
return s?.value ?? null;
};
const xOf = (r: MultiRunResult): number | string => {
const raw = r.case.overrides[xAxisPath];
const n = Number(raw);
return Number.isFinite(n) ? n : (raw as string);
};
const numSort = (a: number | string, b: number | string) =>
typeof a === "number" && typeof b === "number"
? a - b
: String(a).localeCompare(String(b));
const xLabel = activeAxes.find((a) => a.path === xAxisPath)?.label ?? xAxisPath;
const seriesAxes = activeAxes.filter((a) => a.path !== xAxisPath);
if (seriesAxes.length === 0) {
const rows = okResults
.map((r) => ({ x: xOf(r), y: kpiVal(r) }))
.sort((a, b) => numSort(a.x, b.x));
return { mode: "single" as const, xLabel, rows };
}
if (seriesAxes.length === 1) {
const sPath = seriesAxes[0].path;
const sLabel = seriesAxes[0].label;
const xVals: Array<number | string> = [];
const seriesVals: string[] = [];
const points: Array<{ x: number | string; series: string; y: number | null }> = [];
for (const r of okResults) {
const x = xOf(r);
const series = String(r.case.overrides[sPath]);
if (!xVals.some((v) => v === x)) xVals.push(x);
if (!seriesVals.includes(series)) seriesVals.push(series);
points.push({ x, series, y: kpiVal(r) });
}
xVals.sort(numSort);
seriesVals.sort((a, b) =>
Number.isFinite(Number(a)) && Number.isFinite(Number(b))
? Number(a) - Number(b)
: a.localeCompare(b),
);
return { mode: "multi" as const, xLabel, sLabel, xVals, seriesVals, points };
}
return null;
}, [results, activeAxes, xAxisPath, ySeriesKey]);
// Pivoted rows for the 2-axis chart: one row per X value, one column per series value.
// O(n) via a key-indexed map (was O(n²) with .find per cell — caused visible lag).
const multiRows = useMemo(() => {
if (!chartData || chartData.mode !== "multi") return null;
const byKey = new Map<string, number | null>();
for (const p of chartData.points) byKey.set(`${String(p.x)}|${p.series}`, p.y);
return chartData.xVals.map((x) => {
const row: Record<string, number | string | null> = { x };
for (const sv of chartData.seriesVals) {
row[sv] = byKey.get(`${String(x)}|${sv}`) ?? null;
}
return row;
});
}, [chartData]);
const targetsByGroup = useMemo(() => {
const map = new Map<SweepTarget["group"], SweepTarget[]>();
for (const t of targets) {
@@ -154,13 +310,26 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
const boundaryCount = targets.filter((t) => t.group === "boundaries").length;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 overscroll-contain"
role="dialog"
aria-modal="true"
aria-labelledby="multirun-title"
>
<div className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--panel)] shadow-xl">
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-3">
<Layers size={16} className="text-[var(--accent)]" />
<div className="flex-1">
<h2 className="text-[14px] font-semibold text-[var(--ink)]">Multi-run</h2>
<h2 id="multirun-title" className="text-[14px] font-semibold text-[var(--ink)]">Multi-run</h2>
<p className="text-[11px] text-[var(--ink-faint)]">
Les variables viennent du schéma actuel (sources eau/air, UA, etc.).
{boundaryCount > 0
@@ -209,63 +378,80 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
{sweeps.map((sweep, i) => {
const selected = targets.find((t) => t.path === sweep.path);
const warnings =
selected && selected.paramKey
? getSweepWarnings(selected, diagramNodes, baseConfig)
: [];
return (
<div
key={`${sweep.path}-${i}`}
className="grid grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_auto] items-end gap-2 rounded-md border border-[var(--line)] bg-[var(--chrome)] p-2.5"
>
<label className="flex min-w-0 flex-col gap-1">
<span className="eyebrow">Variable du schéma</span>
<select
className="ek-select w-full"
value={sweep.path}
onChange={(e) => pickTarget(i, e.target.value)}
<div key={`${sweep.path}-${i}`} className="space-y-1">
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_auto] items-end gap-2 rounded-md border border-[var(--line)] bg-[var(--chrome)] p-2.5">
<label className="flex min-w-0 flex-col gap-1">
<span className="eyebrow">Variable du schéma</span>
<select
className="ek-select w-full"
value={sweep.path}
onChange={(e) => pickTarget(i, e.target.value)}
>
{[...targetsByGroup.entries()].map(([group, list]) => (
<optgroup key={group} label={GROUP_LABEL[group]}>
{list.map((t) => (
<option key={t.path} value={t.path}>
{t.label}
{t.current !== undefined ? ` [actuel: ${t.current}]` : ""}
</option>
))}
</optgroup>
))}
{!selected && (
<option value={sweep.path}>{sweep.path} (hors liste)</option>
)}
</select>
<span className="mono truncate text-[10px] text-[var(--ink-faint)]">
{sweep.path}
{selected?.current !== undefined ? ` · actuel ${selected.current}` : ""}
</span>
</label>
<label className="flex min-w-0 flex-col gap-1">
<span className="eyebrow">
Valeurs {selected?.unit ? `(${selected.unit})` : ""} séparées par des virgules
</span>
<input
className="ek-select mono w-full"
value={sweep.valuesText}
onChange={(e) => setAxis(i, { valuesText: e.target.value })}
placeholder={
selected?.paramKey === "t_set_c"
? "10, 12, 14"
: "val1, val2, val3"
}
/>
</label>
<button
type="button"
className="mb-0.5 rounded p-1.5 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)]"
onClick={() => setSweeps((all) => all.filter((_, j) => j !== i))}
disabled={sweeps.length <= 1}
aria-label="Retirer laxe"
>
{[...targetsByGroup.entries()].map(([group, list]) => (
<optgroup key={group} label={GROUP_LABEL[group]}>
{list.map((t) => (
<option key={t.path} value={t.path}>
{t.label}
{t.current !== undefined ? ` [actuel: ${t.current}]` : ""}
</option>
))}
</optgroup>
<Trash2 size={14} />
</button>
</div>
{warnings.length > 0 && (
<div className="space-y-0.5 px-1">
{warnings.map((w, k) => (
<div
key={k}
className="flex items-start gap-1 text-[11px] text-[var(--warn)]"
>
<AlertTriangle size={11} className="mt-px shrink-0" />
<span>{w}</span>
</div>
))}
{!selected && (
<option value={sweep.path}>{sweep.path} (hors liste)</option>
)}
</select>
<span className="mono truncate text-[10px] text-[var(--ink-faint)]">
{sweep.path}
{selected?.current !== undefined ? ` · actuel ${selected.current}` : ""}
</span>
</label>
<label className="flex min-w-0 flex-col gap-1">
<span className="eyebrow">
Valeurs {selected?.unit ? `(${selected.unit})` : ""} séparées par des virgules
</span>
<input
className="ek-select mono w-full"
value={sweep.valuesText}
onChange={(e) => setAxis(i, { valuesText: e.target.value })}
placeholder={
selected?.paramKey === "t_set_c"
? "10, 12, 14"
: "val1, val2, val3"
}
/>
</label>
<button
type="button"
className="mb-0.5 rounded p-1.5 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)]"
onClick={() => setSweeps((all) => all.filter((_, j) => j !== i))}
disabled={sweeps.length <= 1}
aria-label="Retirer laxe"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
);
})}
@@ -291,45 +477,166 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
{error && <p className="text-[12px] text-[var(--warn)]">{error}</p>}
{results && (
<div className="overflow-x-auto rounded-md border border-[var(--line)]">
<table className="w-full text-left text-[11px]">
<thead className="bg-[var(--chrome)] text-[var(--ink-faint)]">
<tr>
<th className="px-2 py-1.5 font-medium">Cas</th>
<th className="px-2 py-1.5 font-medium">Statut</th>
<th className="px-2 py-1.5 font-medium">COP</th>
<th className="px-2 py-1.5 font-medium">Qcool kW</th>
<th className="px-2 py-1.5 font-medium">Wcomp kW</th>
<th className="px-2 py-1.5 font-medium">ms</th>
</tr>
</thead>
<tbody>
{results.map((r) => {
const k = extractKpis(r.result);
return (
<tr key={r.case.id} className="border-t border-[var(--line)]">
<td className="max-w-[280px] px-2 py-1.5 font-medium text-[var(--ink)]">
{r.case.label}
</td>
<td className="px-2 py-1.5 mono">
{r.ok ? k.status : r.error ?? "failed"}
</td>
<td className="px-2 py-1.5 mono">
{k.cop != null ? k.cop.toFixed(3) : "—"}
</td>
<td className="px-2 py-1.5 mono">
{k.qCoolKw != null ? k.qCoolKw.toFixed(2) : "—"}
</td>
<td className="px-2 py-1.5 mono">
{k.powerKw != null ? k.powerKw.toFixed(2) : "—"}
</td>
<td className="px-2 py-1.5 mono">{Math.round(r.durationMs)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<>
{chartData ? (
<div className="rounded-md border border-[var(--line)] bg-[var(--chrome)] p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[11px] text-[var(--ink-dim)]">
<label className="flex items-center gap-1">
<span className="text-[var(--ink-faint)]">X</span>
<select
className="rounded border border-[var(--line)] bg-[var(--panel)] px-1 py-0.5 text-[11px]"
value={xAxisPath ?? ""}
onChange={(e) => setXAxisPath(e.target.value || null)}
disabled={activeAxes.length <= 1}
title={activeAxes.length <= 1 ? "Un seul axe actif" : "Choisir l'axe X"}
>
{activeAxes.map((a) => (
<option key={a.path} value={a.path}>
{a.label}
</option>
))}
</select>
</label>
<span className="text-[var(--ink-faint)]">vs</span>
<label className="flex items-center gap-1">
<span className="text-[var(--ink-faint)]">Y</span>
<select
className="max-w-[260px] rounded border border-[var(--line)] bg-[var(--panel)] px-1 py-0.5 text-[11px]"
value={ySeriesKey ?? ""}
onChange={(e) => setYSeriesKey(e.target.value || null)}
>
{availableSeries.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.label}
{opt.unit ? ` (${opt.unit})` : ""}
</option>
))}
</select>
</label>
</div>
{chartData.mode === "multi" && (
<span className="eyebrow">séries : {chartData.sLabel}</span>
)}
</div>
<div style={{ width: "100%", height: 240 }}>
<ResponsiveContainer>
{chartData.mode === "single" ? (
<LineChart data={chartData.rows} margin={{ top: 8, right: 16, bottom: 24, left: 8 }}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" />
<XAxis
dataKey="x"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11, fill: "var(--ink-faint)" }}
label={{ value: chartData.xLabel, position: "bottom", fontSize: 10, fill: "var(--ink-faint)" }}
/>
<YAxis domain={["auto", "auto"]} tick={{ fontSize: 11, fill: "var(--ink-faint)" }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Line
type="monotone"
dataKey="y"
name={
availableSeries.find((s) => s.key === ySeriesKey)?.label ??
ySeriesKey ??
"Y"
}
stroke="var(--accent)"
dot={{ r: 3 }}
connectNulls
isAnimationActive={false}
/>
<Brush dataKey="x" height={18} stroke="var(--accent)" travellerWidth={8} fill="transparent" />
</LineChart>
) : (
<LineChart data={multiRows ?? []} margin={{ top: 8, right: 16, bottom: 24, left: 8 }}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" />
<XAxis
dataKey="x"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11, fill: "var(--ink-faint)" }}
label={{ value: chartData.xLabel, position: "bottom", fontSize: 10, fill: "var(--ink-faint)" }}
/>
<YAxis domain={["auto", "auto"]} tick={{ fontSize: 11, fill: "var(--ink-faint)" }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{chartData.seriesVals.map((sv, idx) => (
<Line
key={sv}
type="monotone"
dataKey={sv}
name={`${chartData.sLabel} = ${sv}`}
stroke={
[
"var(--accent)",
"#10b981",
"#f59e0b",
"#3b82f6",
"#a855f7",
"#ef4444",
"#14b8a6",
"#ec4899",
][idx % 8]
}
dot={{ r: 3 }}
connectNulls
isAnimationActive={false}
/>
))}
<Brush dataKey="x" height={18} stroke="var(--accent)" travellerWidth={8} fill="transparent" />
</LineChart>
)}
</ResponsiveContainer>
</div>
</div>
) : results && results.length > 0 && activeAxes.length > 2 ? (
<p className="rounded-md border border-dashed border-[var(--line)] p-2 text-[11px] text-[var(--ink-faint)]">
Trop d&apos;axes balayés ({activeAxes.length}) pour un graphique lisible (max 2).
Consultez le tableau ci-dessous.
</p>
) : null}
<div className="overflow-x-auto rounded-md border border-[var(--line)]">
<table className="w-full text-left text-[11px]">
<thead className="bg-[var(--chrome)] text-[var(--ink-faint)]">
<tr>
<th className="px-2 py-1.5 font-medium">Cas</th>
<th className="px-2 py-1.5 font-medium">Statut</th>
<th className="px-2 py-1.5 font-medium">COP</th>
<th className="px-2 py-1.5 font-medium">Qcool kW</th>
<th className="px-2 py-1.5 font-medium">Wcomp kW</th>
<th className="px-2 py-1.5 font-medium">ms</th>
</tr>
</thead>
<tbody>
{results.map((r) => {
const k = extractKpis(r.result);
return (
<tr key={r.case.id} className="border-t border-[var(--line)]">
<td className="max-w-[280px] px-2 py-1.5 font-medium text-[var(--ink)]">
{r.case.label}
</td>
<td className="px-2 py-1.5 mono">
{r.ok ? k.status : r.error ?? "failed"}
</td>
<td className="px-2 py-1.5 mono tabular-nums">
{k.cop != null ? k.cop.toFixed(3) : "—"}
</td>
<td className="px-2 py-1.5 mono tabular-nums">
{k.qCoolKw != null ? k.qCoolKw.toFixed(2) : "—"}
</td>
<td className="px-2 py-1.5 mono tabular-nums">
{k.powerKw != null ? k.powerKw.toFixed(2) : "—"}
</td>
<td className="px-2 py-1.5 mono tabular-nums">{Math.round(r.durationMs)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>

View File

@@ -41,7 +41,7 @@ import { buildComponentInspector } from "@/lib/componentInspector";
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
import ComponentDocPanel from "@/components/panels/ComponentDocPanel";
import { modelBannerForType } from "@/lib/componentDocMap";
import { ArrowDown, ArrowUp, HelpCircle, Plus, Trash2 } from "lucide-react";
import { ArrowDown, ArrowUp, HelpCircle, Pin, Plus, Trash2 } from "lucide-react";
import type { Edge, Node } from "@xyflow/react";
type StoreSnapshot = ReturnType<typeof useDiagramStore.getState>;
@@ -110,6 +110,8 @@ export default function PropertiesPanel() {
updateNodeCircuit,
removeNode,
} = useDiagramStore();
const pinnedSweepPaths = useDiagramStore((s) => s.pinnedSweepPaths);
const togglePinnedSweepPath = useDiagramStore((s) => s.togglePinnedSweepPath);
const node = nodes.find((n) => n.id === selectedNodeId) as DiagramNode | undefined;
const [tab, setTab] = useState<TabId>("General");
@@ -198,12 +200,13 @@ export default function PropertiesPanel() {
);
}
const params = mergeDefaultParams(node.data.type, node.data.params ?? {});
const nodeType = String(node.data.type || node.data.kind || "");
const params = mergeDefaultParams(nodeType, node.data.params ?? {});
// Group params into tabs
const byTab = new Map<TabId, ParamMeta[]>();
for (const p of meta.params) {
const t = tabForParam(p, node.data.type);
const t = tabForParam(p, nodeType);
const list = byTab.get(t) ?? [];
list.push(p);
byTab.set(t, list);
@@ -213,7 +216,8 @@ export default function PropertiesPanel() {
const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0] ?? "General";
const rows = byTab.get(activeTab) ?? [];
const showFixedCol = rows.some((p) => p.fixable);
const isRegLoop = node.data.type === "SaturatedController";
const showPinCol = rows.some((p) => p.kind === "number");
const isRegLoop = nodeType === "SaturatedController";
return (
<div className="flex flex-col border-b border-[var(--line)]">
@@ -222,7 +226,15 @@ export default function PropertiesPanel() {
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded border border-[var(--line)] bg-white">
<span style={{ width: 14, height: 14 }}>
<ComponentIcon type={node.data.type} color={meta.color} />
<ComponentIcon
type={nodeType}
color={meta.color}
secondaryFluid={
node.data.params?.secondary_fluid != null
? String(node.data.params.secondary_fluid)
: null
}
/>
</span>
</span>
<div className="min-w-0">
@@ -386,6 +398,14 @@ export default function PropertiesPanel() {
Fixed
</th>
)}
{showPinCol && (
<th
className="w-8 px-1 py-1.5 text-center font-semibold"
title="Épingler pour le multi-run"
>
<Pin size={11} className="inline text-[var(--ink-faint)]" />
</th>
)}
</tr>
</thead>
<tbody>
@@ -465,6 +485,42 @@ export default function PropertiesPanel() {
)}
</td>
)}
{showPinCol && (
<td className="px-0.5 py-1 text-center align-middle">
{p.kind === "number" ? (
<button
type="button"
className="toolish inline-flex items-center justify-center p-0.5"
title="Épingler pour le multi-run"
aria-label="Épingler pour le multi-run"
aria-pressed={pinnedSweepPaths.includes(
`${node.data.name}.${p.key}`,
)}
onClick={() =>
togglePinnedSweepPath(`${node.data.name}.${p.key}`)
}
>
<Pin
size={13}
className={
pinnedSweepPaths.includes(
`${node.data.name}.${p.key}`,
)
? "text-[var(--accent)]"
: "text-[var(--ink-faint)]"
}
fill={
pinnedSweepPaths.includes(
`${node.data.name}.${p.key}`,
)
? "currentColor"
: "none"
}
/>
</button>
) : null}
</td>
)}
</tr>
);
})}
@@ -530,6 +586,13 @@ export default function PropertiesPanel() {
border-color: #86efac;
background: #f0fdf4;
}
.toolish {
border-radius: 4px;
color: var(--ink-faint);
}
.toolish:hover {
background: var(--chrome-2);
}
`}</style>
</div>
);

View File

@@ -29,12 +29,16 @@ import {
type SimulationResult,
} from "@/lib/api";
import { useDiagramStore } from "@/store/diagramStore";
import type { EntropykNodeData } from "@/lib/configBuilder";
import type { Node } from "@xyflow/react";
interface Props {
result: SimulationResult | null;
error: string | null;
config: unknown | null;
simulating?: boolean;
/** Validation issues surfaced from the shared simulate hook. */
issues?: string[];
}
type Tab = "overview" | "edges" | "components" | "solver" | "debug";
@@ -47,6 +51,13 @@ type PowerUnit = "W" | "kW";
const SHOW_SOLVER_KEY = "entropyk.showSolverLog";
const FLUIDS = ["R410A", "R134a", "R290", "R744", "R32", "R1234yf", "Water", "Air"];
const BACKENDS = ["CoolProp", "Test"];
const SOLVERS = [
{ value: "newton", label: "Newton" },
{ value: "picard", label: "Picard" },
];
function readShowSolverPref(): boolean {
if (typeof window === "undefined") return true;
const v = window.localStorage.getItem(SHOW_SOLVER_KEY);
@@ -54,14 +65,31 @@ function readShowSolverPref(): boolean {
return v !== "0" && v !== "false";
}
export default function ResultsPanel({ result, error, config, simulating = false }: Props) {
export default function ResultsPanel({
result,
error,
config,
simulating = false,
issues = [],
}: Props) {
const [tab, setTab] = useState<Tab>("overview");
const [powerUnit, setPowerUnit] = useState<PowerUnit>("kW");
const [showSolverLog, setShowSolverLog] = useState(true);
const [mollier, setMollier] = useState<MollierResponse | null>(null);
const { nodes, edges } = useDiagramStore();
const nodes = useDiagramStore((s) => s.nodes);
const edges = useDiagramStore((s) => s.edges);
const fluid = useDiagramStore((s) => s.fluid);
const fluidBackend = useDiagramStore((s) => s.fluidBackend);
const solverStrategy = useDiagramStore((s) => s.solverStrategy);
const setFluid = useDiagramStore((s) => s.setFluid);
const setFluidBackend = useDiagramStore((s) => s.setFluidBackend);
const setSolverStrategy = useDiagramStore((s) => s.setSolverStrategy);
const simulatingStore = useDiagramStore((s) => s.simulating);
const isSimulating = simulating || simulatingStore;
const cycleData = useMemo(() => buildCycleData(result, nodes, edges), [result, nodes, edges]);
const fluid = scenarioFluid(config);
const resultFluid = scenarioFluid(config);
const edgeRows = result?.state ?? [];
const localHxRows = useMemo(
() => buildLocalHeatExchangerRows(nodes, edges, result?.state),
@@ -86,21 +114,21 @@ export default function ResultsPanel({ result, error, config, simulating = false
const residual = result?.convergence?.final_residual ?? null;
const iterations = result?.convergence?.iterations ?? result?.iterations ?? null;
const iterationHistory = result?.convergence?.iteration_history ?? [];
const solverStrategy = result?.convergence?.strategy ?? null;
const resultSolverStrategy = result?.convergence?.strategy ?? null;
const resultStatus = String(result?.status ?? "").toLowerCase();
const resultError =
result && !converged && ["error", "failed", "diverged"].includes(resultStatus)
? result.error || statusLabel(result.status)
: null;
const displayError = error ?? resultError;
const benchTitle = simulating
const benchTitle = isSimulating
? "Solving..."
: displayError
? "Solver stopped"
: result
? statusLabel(result.status)
: "Ready to solve";
const benchDetail = simulating
const benchDetail = isSimulating
? "Simulation running"
: buildBenchDetail(result, displayError, iterations, residual);
@@ -117,19 +145,19 @@ export default function ResultsPanel({ result, error, config, simulating = false
// After a solve, jump to Solver when iteration history is available and the
// log is enabled — otherwise users never discover the tab.
useEffect(() => {
if (!result || simulating) return;
if (!result || isSimulating) return;
const history = result.convergence?.iteration_history ?? [];
if (showSolverLog && history.length > 0) {
setTab("solver");
}
}, [result, simulating, showSolverLog]);
}, [result, isSimulating, showSolverLog]);
useEffect(() => {
let active = true;
// Never hit CoolProp (Mollier) while a solve is in flight — concurrent
// CoolProp calls used to crash ui-server → bare HTTP 500 + stuck spinner.
if (
simulating ||
isSimulating ||
!result ||
!fluid ||
fluid.toLowerCase() === "water" ||
@@ -148,7 +176,7 @@ export default function ResultsPanel({ result, error, config, simulating = false
return () => {
active = false;
};
}, [fluid, result, simulating]);
}, [fluid, result, isSimulating]);
// Always reserve the Solver tab slot when the eye toggle is on (default).
const tabCount = showSolverLog ? 5 : 4;
@@ -159,6 +187,50 @@ export default function ResultsPanel({ result, error, config, simulating = false
title="Solve bench"
action={
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<label className="flex items-center gap-1 text-[10px] text-[var(--ink-dim)]">
<span className="mono uppercase tracking-wide">Fluid</span>
<select
value={fluid}
onChange={(e) => setFluid(e.target.value)}
className="rounded border border-[var(--line)] bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
{FLUIDS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1 text-[10px] text-[var(--ink-dim)]">
<span className="mono uppercase tracking-wide">Props</span>
<select
value={fluidBackend}
onChange={(e) => setFluidBackend(e.target.value)}
className="rounded border border-[var(--line)] bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
{BACKENDS.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1 text-[10px] text-[var(--ink-dim)]">
<span className="mono uppercase tracking-wide">Solver</span>
<select
value={solverStrategy}
onChange={(e) => setSolverStrategy(e.target.value)}
className="rounded border border-[var(--line)] bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
{SOLVERS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1 text-[10px] text-[var(--ink-dim)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
@@ -189,7 +261,7 @@ export default function ResultsPanel({ result, error, config, simulating = false
</div>
<div className="mono mt-0.5 text-[10px] text-[var(--ink-faint)]">
{benchDetail}
{solverStrategy ? ` · ${solverStrategy}` : ""}
{resultSolverStrategy ? ` · ${resultSolverStrategy}` : ""}
</div>
</div>
</div>
@@ -197,6 +269,27 @@ export default function ResultsPanel({ result, error, config, simulating = false
</div>
</div>
{issues.length > 0 && !displayError && (
<div className="border-b border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-relaxed text-amber-900">
<div className="mb-0.5 flex items-center gap-1.5 font-semibold">
<AlertTriangle size={12} />
{issues.length} validation issue{issues.length > 1 ? "s" : ""} Solve blocked
</div>
<ul className="ml-4 list-disc space-y-0.5">
{issues.slice(0, 4).map((iss, i) => (
<li key={i} className="text-[10.5px]">
{iss}
</li>
))}
{issues.length > 4 && (
<li className="text-[10px] text-amber-700">
+ {issues.length - 4} more
</li>
)}
</ul>
</div>
)}
<div
className="grid border-b border-[var(--line)] bg-white text-[11px]"
style={{ gridTemplateColumns: `repeat(${tabCount}, minmax(0, 1fr))` }}
@@ -316,7 +409,7 @@ export default function ResultsPanel({ result, error, config, simulating = false
result={result}
iterations={iterations}
residual={residual}
strategy={solverStrategy}
strategy={resultSolverStrategy}
history={iterationHistory}
tolerance={result?.convergence?.tolerance ?? null}
/>

View File

@@ -0,0 +1,66 @@
"use client";
import { useEffect, useState } from "react";
import { Activity, AlertCircle } from "lucide-react";
import { checkHealth } from "@/lib/api";
/**
* Tiny live ping that surfaces whether the Rust ui-server is reachable.
*
* Sits in the bottom status bar so the user always knows if Simulate will
* work before clicking it. Polls every 15s + once immediately on focus regain.
*/
export default function ApiHealthIndicator() {
const [status, setStatus] = useState<"ok" | "down" | "checking">("checking");
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setInterval> | null = null;
const ping = async () => {
const ok = await checkHealth();
if (active) setStatus(ok ? "ok" : "down");
};
ping();
timer = setInterval(ping, 15000);
const onFocus = () => ping();
window.addEventListener("focus", onFocus);
return () => {
active = false;
if (timer) clearInterval(timer);
window.removeEventListener("focus", onFocus);
};
}, []);
if (status === "ok") {
return (
<span
className="flex items-center gap-1.5 rounded bg-emerald-500/15 px-2 py-0.5 text-[10.5px] font-semibold text-emerald-700"
title="Rust ui-server is reachable on :3030 — Simulate will work"
>
<Activity size={11} className="text-emerald-600" />
API ready
</span>
);
}
if (status === "down") {
return (
<span
className="flex items-center gap-1.5 rounded bg-red-500/15 px-2 py-0.5 text-[10.5px] font-semibold text-red-700"
title="Rust ui-server is DOWN — run: cargo run -p entropyk-demo --bin ui-server"
>
<AlertCircle size={11} className="text-red-600" />
API down run ui-server
</span>
);
}
return (
<span className="flex items-center gap-1.5 rounded bg-[var(--chrome-2)] px-2 py-0.5 text-[10.5px] text-[var(--ink-faint)]">
<Activity size={11} className="animate-pulse" />
Checking
</span>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { X, Folder, Package } from "lucide-react";
import type { Doc } from "@/lib/useDocuments";
interface DocumentTabsProps {
docs: Doc[];
activeId: string;
onSwitch: (id: string) => void;
onClose: (id: string) => void;
}
/**
* Tab strip — scenario first, then every open class.
* Class tabs always show `Name.ekmod` so the model identity is never lost.
*/
export default function DocumentTabs({
docs,
activeId,
onSwitch,
onClose,
}: DocumentTabsProps) {
return (
<div className="flex h-9 items-stretch border-b border-[var(--line)] bg-[var(--chrome)] overflow-x-auto">
{docs.map((doc) => {
const isActive = doc.id === activeId;
const isWorkspace = doc.kind === "workspace";
return (
<div
key={doc.id}
role="tab"
tabIndex={0}
aria-selected={isActive}
onClick={() => onSwitch(doc.id)}
onMouseDown={(e) => {
if (e.button === 1 && !isWorkspace) {
e.preventDefault();
onClose(doc.id);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSwitch(doc.id);
}
}}
className={`group relative flex min-w-[150px] max-w-[240px] cursor-pointer items-center gap-2 border-r border-[var(--line)] px-3 text-[12px] transition-colors ${
isActive
? "bg-[var(--panel)] text-[var(--ink)]"
: "bg-[var(--chrome)] text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
}`}
title={
isWorkspace
? "Workspace — main canvas (place classes and parts here)"
: `Class ${doc.name}.ekmod — edit definition`
}
>
{isActive && (
<span className="absolute inset-x-0 top-0 h-[2px] bg-[var(--accent)]" />
)}
{isWorkspace ? (
<Folder size={13} className="shrink-0 text-[var(--accent)]" />
) : (
<Package size={13} className="shrink-0 text-[var(--accent)]" />
)}
<span className="min-w-0 flex-1 truncate">
{isWorkspace ? (
<span className="font-semibold">Workspace</span>
) : (
<>
<span className="mono font-semibold">{doc.name}</span>
<span className="mono ml-0.5 text-[10px] font-normal text-[var(--ink-faint)]">
.ekmod
</span>
</>
)}
</span>
{doc.dirty && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--warn)]"
title="Unsaved changes"
/>
)}
{!isWorkspace && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose(doc.id);
}}
className="shrink-0 rounded p-0.5 text-[var(--ink-faint)] opacity-0 hover:bg-[var(--chrome-2)] hover:text-[var(--ink)] group-hover:opacity-100"
title={`Close ${doc.name}.ekmod`}
aria-label={`Close ${doc.name} class`}
>
<X size={12} />
</button>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,624 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
Box,
Boxes,
ChevronDown,
ChevronRight,
Circle,
FolderPlus,
Package,
Search,
} from "lucide-react";
import { useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
import { COMPONENTS, COMPONENT_CATEGORIES, type ComponentMeta } from "@/lib/componentMeta";
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
import { hxFamily } from "@/lib/hxFamily";
import { fetchModules, type ModuleInfo } from "@/lib/api";
interface LeftNavProps {
onSelectModuleForEdit?: (moduleName: string) => void;
onEnterModule?: (moduleName: string, instanceName: string) => void;
onOpenNewModelModal?: () => void;
/** Currently open model tab (.ekmod), if any. */
activeClassName?: string | null;
}
/**
* Package browser — Modelica / OMEdit mental model:
*
* Models (.ekmod) = classes / modèles (comme Modelica)
* Diagram = contenu du canvas ouvert (instances + parts)
* Parts = composants atomiques
*
* Le Workspace est longlet canvas du haut — pas une section vide ici.
*/
export default function LeftNav({
onSelectModuleForEdit,
onEnterModule,
onOpenNewModelModal,
activeClassName = null,
}: LeftNavProps) {
const [modelsOpen, setModelsOpen] = useState(true);
const [diagramOpen, setDiagramOpen] = useState(true);
const [partsOpen, setPartsOpen] = useState(true);
const [query, setQuery] = useState("");
const queryLower = query.trim().toLowerCase();
const hasQuery = queryLower.length > 0;
return (
<aside className="flex h-full w-64 flex-col border-r border-[var(--line)] bg-[var(--panel)]">
<div className="border-b border-[var(--line)] bg-[var(--chrome)] p-2">
<div className="relative flex items-center">
<Search size={12} className="absolute left-2.5 text-[var(--ink-faint)]" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rechercher modèles, diagramme, parts…"
className="w-full rounded border border-[var(--line)] bg-white py-1 pl-7 pr-2 text-[11px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
/>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{/* Models = Modelica classes — primary */}
<ModelsSection
open={modelsOpen || hasQuery}
onToggle={() => setModelsOpen((v) => !v)}
query={queryLower}
activeClassName={activeClassName}
onSelectModuleForEdit={onSelectModuleForEdit}
onOpenNewModelModal={onOpenNewModelModal}
/>
<Divider />
{/* What's on the open canvas — not "Workspace" */}
<DiagramSection
open={diagramOpen || hasQuery}
onToggle={() => setDiagramOpen((v) => !v)}
query={queryLower}
activeClassName={activeClassName}
onEnterModule={onEnterModule}
/>
<Divider />
<PartsSection
open={partsOpen || hasQuery}
onToggle={() => setPartsOpen((v) => !v)}
query={queryLower}
/>
</div>
</aside>
);
}
function Divider() {
return <div className="h-px bg-[var(--line)]" />;
}
function SectionHeader({
open,
onToggle,
icon,
label,
count,
trailing,
}: {
open: boolean;
onToggle: () => void;
icon: React.ReactNode;
label: string;
count?: number;
trailing?: React.ReactNode;
}) {
return (
<div className="flex h-8 items-center gap-1 border-b border-[var(--line)]/50 bg-[var(--chrome)] px-2">
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--ink)]"
>
{open ? (
<ChevronDown size={12} className="text-[var(--ink-faint)]" />
) : (
<ChevronRight size={12} className="text-[var(--ink-faint)]" />
)}
<span className="flex h-4 w-4 items-center justify-center">{icon}</span>
<span className="eyebrow">{label}</span>
{typeof count === "number" && (
<span className="mono ml-1 text-[9px] text-[var(--ink-faint)]">{count}</span>
)}
</button>
{trailing}
</div>
);
}
/* ── Models = Modelica classes (.ekmod) ───────────────────────────── */
function ModelsSection({
open,
onToggle,
query,
activeClassName,
onSelectModuleForEdit,
onOpenNewModelModal,
}: {
open: boolean;
onToggle: () => void;
query: string;
activeClassName?: string | null;
onSelectModuleForEdit?: (name: string) => void;
onOpenNewModelModal?: () => void;
}) {
const [modules, setModules] = useState<ModuleInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let active = true;
const load = async () => {
setLoading(true);
try {
const list = await fetchModules();
if (active) setModules(list);
} finally {
if (active) setLoading(false);
}
};
load();
const handler = () => load();
window.addEventListener("entropyk-modules-updated", handler);
return () => {
active = false;
window.removeEventListener("entropyk-modules-updated", handler);
};
}, []);
const filtered = useMemo(
() =>
modules.filter(
(m) =>
!query ||
m.name.toLowerCase().includes(query) ||
m.ports.some((p) => p.toLowerCase().includes(query)),
),
[modules, query],
);
const onModuleDragStart = (e: React.DragEvent, moduleName: string, ports: string[]) => {
e.dataTransfer.setData("application/entropyk-type", `ModuleInstance:${moduleName}`);
e.dataTransfer.setData("application/entropyk-ports", JSON.stringify(ports));
e.dataTransfer.effectAllowed = "move";
};
const dropOnCanvas = (moduleName: string, ports: string[]) => {
useDiagramStore
.getState()
.addComponent(
"ModuleInstance",
{ x: 280 + Math.random() * 40, y: 140 + Math.random() * 40 },
{ module_name: moduleName, module_ports: JSON.stringify(ports) },
);
};
return (
<div className="flex flex-col">
<SectionHeader
open={open}
onToggle={onToggle}
icon={<Package size={12} className="text-[var(--accent)]" />}
label="Models"
count={modules.length}
/>
{open && (
<div className="py-1.5">
{onOpenNewModelModal && (
<div className="px-2 pb-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenNewModelModal();
}}
className="flex w-full items-center justify-center gap-1.5 rounded-md bg-[var(--accent)] px-3 py-2 text-[12px] font-semibold text-white shadow-sm hover:bg-[var(--accent-hover,#1d4ed8)] active:scale-[0.99]"
>
<FolderPlus size={14} /> Nouveau modèle
</button>
</div>
)}
<p className="px-3 pb-1.5 text-[10px] leading-snug text-[var(--ink-faint)]">
Classes Modelica · .ekmod double-clic pour éditer, glisser pour instancier
</p>
{loading && modules.length === 0 && (
<p className="px-3 py-2 text-[11px] text-[var(--ink-faint)]">Chargement</p>
)}
{!loading && filtered.length === 0 && (
<p className="px-3 py-2 text-[11px] text-[var(--ink-faint)]">
{query ? "Aucun modèle." : "Aucun modèle — clique Nouveau."}
</p>
)}
{filtered.map((m) => {
const nInst = m.n_instances || 0;
const nComp = m.n_components || 0;
const isActive = activeClassName === m.name;
const meta =
nInst > 0
? `${nInst} nested · ${nComp} parts`
: nComp > 0
? `${nComp} part${nComp > 1 ? "s" : ""}`
: "vide";
return (
<div
key={m.name}
draggable
onDragStart={(e) => onModuleDragStart(e, m.name, m.ports)}
onDoubleClick={() => onSelectModuleForEdit?.(m.name)}
className={`group mx-1.5 mb-1.5 cursor-grab rounded-md border px-2.5 py-2 active:cursor-grabbing ${
isActive
? "border-[var(--accent)] bg-[var(--accent-subtle,#eff6ff)]"
: "border-[var(--line)] bg-white hover:border-[var(--accent)]/40"
}`}
title={`${m.name}.ekmod`}
>
<div className="flex items-center gap-1.5">
<Package size={14} className="shrink-0 text-[var(--accent)]" />
<span className="mono min-w-0 flex-1 truncate text-[12px] font-semibold text-[var(--ink)]">
{m.name}
</span>
<span className="mono shrink-0 text-[10px] text-[var(--ink-faint)]">.ekmod</span>
</div>
<div className="mt-1 flex items-center justify-between gap-1 pl-[22px]">
<span className="mono text-[10px] text-[var(--ink-faint)]">
{meta}
{m.ports.length > 0 ? ` · ${m.ports.length} ports` : ""}
</span>
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
dropOnCanvas(m.name, m.ports);
}}
className="rounded px-1.5 py-0.5 text-[10px] font-semibold text-[var(--accent)] hover:bg-[var(--accent)]/10"
title="Instancier sur le diagramme"
>
Place
</button>
{onSelectModuleForEdit && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSelectModuleForEdit(m.name);
}}
className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-semibold text-[var(--accent)] hover:bg-[var(--accent)]/10"
title="Éditer le modèle"
>
Edit <ArrowRight size={10} />
</button>
)}
</div>
</div>
{m.ports.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1 pl-[22px]">
{m.ports.slice(0, 8).map((p) => (
<span
key={p}
className="mono rounded border border-[var(--accent)]/25 bg-[var(--accent)]/5 px-1.5 py-0.5 text-[9px] font-medium text-[var(--accent)]"
>
{p}
</span>
))}
{m.ports.length > 8 && (
<span className="text-[9px] text-[var(--ink-faint)]">
+{m.ports.length - 8}
</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Diagram = nodes on the open canvas ───────────────────────────── */
function DiagramSection({
open,
onToggle,
query,
activeClassName,
onEnterModule,
}: {
open: boolean;
onToggle: () => void;
query: string;
activeClassName?: string | null;
onEnterModule?: (moduleName: string, instanceName: string) => void;
}) {
const nodes = useDiagramStore((s) => s.nodes);
const selectedNodeId = useDiagramStore((s) => s.selectedNodeId);
const setSelected = useDiagramStore((s) => s.setSelected);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const filtered = query
? nodes.filter((n) => {
const data = n.data as unknown as EntropykNodeData;
return (
data.name.toLowerCase().includes(query) ||
String(data.type).toLowerCase().includes(query) ||
String(data.moduleName ?? "").toLowerCase().includes(query)
);
})
: nodes;
const sectionLabel = activeClassName ? `Diagram · ${activeClassName}` : "Diagram";
return (
<div className="flex flex-col">
<SectionHeader
open={open}
onToggle={onToggle}
icon={<Box size={12} className="text-[var(--ink-dim)]" />}
label={sectionLabel}
count={nodes.length}
/>
{open && (
<div className="py-1">
{filtered.length === 0 ? (
<p className="px-3 py-2 text-[10.5px] leading-snug text-[var(--ink-faint)]">
{query
? "Aucun élément."
: activeClassName
? "Diagramme vide — dépose des parts pour définir ce modèle."
: "Rien sur le canvas — glisse un modèle depuis Models, ou des parts."}
</p>
) : (
filtered.map((n) => {
const data = n.data as unknown as EntropykNodeData;
const isModule = data.type === "ModuleInstance" || !!data.moduleName;
const isSelected = n.id === selectedNodeId;
const isExpanded = !!expanded[n.id];
const label = isModule ? (
<span className="flex items-center gap-1 truncate">
<span className="truncate">{data.name}</span>
<span className="mono text-[9px] text-[var(--ink-faint)]">
: {String(data.moduleName ?? data.params?.module_name ?? "")}
</span>
</span>
) : (
<span className="truncate">{data.name}</span>
);
return (
<div key={n.id}>
<TreeRow
icon={
isModule ? (
<Box size={11} className="text-[var(--accent)]" />
) : (
<Circle size={8} className="text-[var(--ink-faint)]" />
)
}
label={label}
depth={0}
active={isSelected}
onClick={() => setSelected(n.id)}
expandable={isModule}
expanded={isExpanded}
onToggle={() => setExpanded((e) => ({ ...e, [n.id]: !e[n.id] }))}
/>
{isModule && isExpanded && (
<div className="ml-6 py-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const mName = String(
data.moduleName ?? data.params?.module_name ?? "",
);
if (mName && onEnterModule) onEnterModule(mName, data.name);
}}
className="flex items-center gap-1 rounded px-2 py-0.5 text-[10.5px] text-[var(--accent)] hover:bg-[var(--accent-subtle)]"
>
Ouvrir le modèle <ArrowRight size={10} />
</button>
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
}
interface TreeRowProps {
icon: React.ReactNode;
label: React.ReactNode;
depth: number;
active?: boolean;
onClick?: () => void;
expandable?: boolean;
expanded?: boolean;
onToggle?: () => void;
}
function TreeRow({
icon,
label,
depth,
active,
onClick,
expandable,
expanded,
onToggle,
}: TreeRowProps) {
return (
<div
onClick={onClick}
className={`flex cursor-pointer items-center gap-1.5 rounded-[2px] py-1 pr-2 text-[11.5px] transition-colors ${
active
? "bg-[var(--accent-subtle)] text-[var(--accent)]"
: "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
}`}
style={{ paddingLeft: 8 + depth * 14 }}
>
{expandable ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggle?.();
}}
className="text-[var(--ink-faint)] hover:text-[var(--ink)]"
>
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
</button>
) : (
<span className="w-[10px]" />
)}
<span className="flex h-4 w-4 items-center justify-center">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</div>
);
}
/* ── Parts (atomic thermo components) ─────────────────────────────── */
function PartsSection({
open,
onToggle,
query,
}: {
open: boolean;
onToggle: () => void;
query: string;
}) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({
Advanced: true,
Generic: true,
});
const toggle = (cat: string) => setCollapsed((c) => ({ ...c, [cat]: !c[cat] }));
const onDragStart = (e: React.DragEvent, type: string) => {
e.dataTransfer.setData("application/entropyk-type", type);
e.dataTransfer.effectAllowed = "move";
};
const filteredCats = COMPONENT_CATEGORIES.map((cat) => ({
cat,
items: COMPONENTS.filter(
(c) =>
c.category === cat &&
(!query ||
c.label.toLowerCase().includes(query) ||
c.type.toLowerCase().includes(query) ||
(c.description ?? "").toLowerCase().includes(query)),
),
})).filter((entry) => entry.items.length > 0);
return (
<div className="flex flex-col">
<SectionHeader
open={open}
onToggle={onToggle}
icon={<Boxes size={12} className="text-[var(--ink-dim)]" />}
label="Parts"
count={COMPONENTS.length}
/>
{open && (
<div className="py-1">
{filteredCats.length === 0 && (
<p className="px-3 py-2 text-[10.5px] text-[var(--ink-faint)]">
Aucune part.
</p>
)}
{filteredCats.map(({ cat, items }) => {
const isOpen = !collapsed[cat] || !!query;
return (
<div key={cat} className="palette-cat">
<button
type="button"
onClick={() => toggle(cat)}
aria-expanded={isOpen}
className="flex w-full items-center gap-1 px-2.5 py-1.5 text-left hover:bg-[var(--chrome-2)]"
>
{isOpen ? (
<ChevronDown size={11} className="text-[var(--ink-faint)]" />
) : (
<ChevronRight size={11} className="text-[var(--ink-faint)]" />
)}
<span className="eyebrow">{cat}</span>
<span className="mono ml-1 text-[9px] text-[var(--ink-faint)]">
{items.length}
</span>
</button>
<div className={`palette-cat-body ${isOpen ? "is-open" : "is-closed"}`}>
<div className="palette-cat-inner pb-1">
{items.map((c, i) => (
<PaletteItem key={c.type} meta={c} index={i} onDragStart={onDragStart} />
))}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function PaletteItem({
meta,
index,
onDragStart,
}: {
meta: ComponentMeta;
index: number;
onDragStart: (e: React.DragEvent, type: string) => void;
}) {
const itemType = String(meta.type || "");
const family = hxFamily(itemType);
const color = family?.accent ?? meta.color;
return (
<div
draggable
onDragStart={(e) => onDragStart(e, itemType)}
className="palette-item group mx-1.5 flex cursor-grab items-center gap-2.5 rounded-[2px] px-2 py-1.5 hover:bg-[var(--chrome-2)] active:cursor-grabbing"
style={{ ["--i" as string]: index }}
title={meta.help ?? meta.description}
>
<span className="grid h-9 w-9 flex-shrink-0 place-items-center">
<span style={{ width: 30, height: 30 }}>
<ComponentIcon type={itemType} color={color} />
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] text-[var(--ink-dim)] group-hover:text-[var(--ink)]">
{meta.label}
</span>
{family && (
<span className="mono text-[8px] uppercase tracking-[0.08em] text-[var(--ink-faint)]">
{family.badge} · {family.label}
</span>
)}
</span>
</div>
);
}

View File

@@ -0,0 +1,539 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Boxes,
ChevronRight,
Eraser,
FileDown,
FileUp,
Folder,
Grid3x3,
HelpCircle,
Layers,
Loader2,
MoreHorizontal,
Package,
PackagePlus,
Play,
Redo2,
RotateCw,
FlipHorizontal2,
FlipVertical2,
Undo2,
X,
Check,
} from "lucide-react";
import { useDiagramStore } from "@/store/diagramStore";
import MultiRunPanel from "@/components/panels/MultiRunPanel";
import CreateModuleModal from "@/components/modals/CreateModuleModal";
import WorkspaceHelpModal from "@/components/modals/WorkspaceHelpModal";
import type { NewDocumentKind } from "@/components/modals/NewModelModal";
/** UI Load menu — only configs that converge via POST /api/simulate. */
const EXAMPLES = [
{ file: "hx_air_water_4port.json", label: "HX airwater" },
{ file: "chiller_flooded_4port_watercooled.json", label: "Chiller flooded" },
{ file: "chiller_watercooled_r410a.json", label: "Chiller R410A" },
{ file: "chiller_aircooled_r134a.json", label: "Chiller air-cooled" },
{ file: "chiller_aircooled_r134a_fan.json", label: "Chiller air-cooled + fan" },
{ file: "bphx_evaporator_condenser.json", label: "BPHX cycle" },
{ file: "heatpump_r410a_reversing_valve.json", label: "Heat pump 4-way" },
{ file: "chiller_r134a_exv_orifice.json", label: "EXV orifice" },
{ file: "chiller_r410a_full_physics.json", label: "R410A full physics" },
// Not listed (not executable yet):
// - capillary_tube_r134a.json — CapillaryTube graph residuals are stubs (singular Jacobian)
];
interface TopBarProps {
/** Breadcrumb path, e.g. ["Untitled"] or ["Untitled", "BaseChiller"]. */
scope: string[];
unsaved?: boolean;
editingModule?: string | null;
workspaceName?: string;
onNavigateBack?: () => void;
onExitModuleEditor?: () => void;
onSaveModuleEditor?: () => void;
onOpenNew?: (kind?: NewDocumentKind) => void;
onSimulate?: () => void;
simulating?: boolean;
}
/**
* Workbench toolbar — classes are the primary document type.
*
* LEFT: brand · Nouveau modèle · Import · Save as class
* CENTER: WorkspaceName ClassName.ekmod (+ Save/Quit when editing)
* RIGHT: undo · transform · examples · Solve
*/
export default function TopBar({
scope,
unsaved,
editingModule,
workspaceName = "Workspace",
onNavigateBack,
onExitModuleEditor,
onSaveModuleEditor,
onOpenNew,
onSimulate,
simulating = false,
}: TopBarProps) {
const {
snapToGrid,
selectedNodeId,
toggleSnapToGrid,
rotateNode,
flipNodeH,
flipNodeV,
loadFromConfig,
clear,
} = useDiagramStore();
const canUndo = useDiagramStore((s) => s.canUndo);
const canRedo = useDiagramStore((s) => s.canRedo);
const undo = useDiagramStore((s) => s.undo);
const redo = useDiagramStore((s) => s.redo);
const [issues, setIssues] = useState<string[]>([]);
const [moreOpen, setMoreOpen] = useState(false);
const [multiOpen, setMultiOpen] = useState(false);
const [createModuleOpen, setCreateModuleOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const [exampleFile, setExampleFile] = useState(EXAMPLES[0].file);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const onLoadExample = async () => {
try {
const res = await fetch(`/examples/${exampleFile}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
loadFromConfig(await res.json());
setIssues([]);
} catch (e) {
setIssues([`Could not load example: ${e instanceof Error ? e.message : e}`]);
}
};
const onImportJsonFile = async (file: File | undefined) => {
if (!file) return;
try {
const text = await file.text();
loadFromConfig(JSON.parse(text));
setIssues([]);
} catch (e) {
setIssues([`Could not import JSON: ${e instanceof Error ? e.message : e}`]);
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const typing =
!!target &&
(tag === "TEXTAREA" ||
tag === "SELECT" ||
target.isContentEditable ||
(tag === "INPUT" &&
(target as HTMLInputElement).type !== "checkbox" &&
(target as HTMLInputElement).type !== "radio"));
if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
e.preventDefault();
onOpenNew?.("class");
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S")) {
e.preventDefault();
if (editingModule && onSaveModuleEditor) onSaveModuleEditor();
else if (!typing) setCreateModuleOpen(true);
return;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onOpenNew, editingModule, onSaveModuleEditor]);
// Escape cancels the "Clear workspace" confirmation dialog.
useEffect(() => {
if (!confirmClear) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setConfirmClear(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [confirmClear]);
const parents = scope.length > 1 ? scope.slice(0, -1) : [];
const active = scope.length > 0 ? scope[scope.length - 1] : null;
const showClassInScope = !!editingModule;
return (
<>
<header className="flex h-11 items-center gap-2 border-b border-[var(--line)] bg-[var(--chrome)] px-3">
<div className="flex items-center gap-2 pr-1">
<Boxes size={16} className="text-[var(--accent)]" />
<span className="mono text-[12px] font-semibold tracking-[0.12em] text-[var(--ink)]">
ENTROPYK
</span>
</div>
<div className="h-5 w-px bg-[var(--line)]" />
{onOpenNew && (
<button
type="button"
onClick={() => onOpenNew("class")}
className="flex items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 py-1.5 text-[11px] font-semibold text-white shadow-sm hover:bg-[var(--accent-hover,#1d4ed8)]"
title="Nouveau modèle dans le Workspace (Ctrl+N)"
>
<PackagePlus size={13} />
Nouveau modèle
</button>
)}
{editingModule && (
<div className="flex items-center gap-1.5 rounded-md border border-[var(--accent)]/30 bg-[var(--accent-subtle,#eff6ff)] px-2 py-1">
<Package size={13} className="text-[var(--accent)]" />
<span className="mono text-[12px] font-semibold text-[var(--ink)]">
{editingModule}
</span>
<span className="mono text-[10px] text-[var(--ink-faint)]">.ekmod</span>
{unsaved && (
<span className="h-1.5 w-1.5 rounded-full bg-[var(--warn)]" title="Unsaved changes" />
)}
{onSaveModuleEditor && (
<button
type="button"
onClick={onSaveModuleEditor}
className="ml-1 flex items-center gap-1 rounded bg-[var(--accent)] px-2 py-0.5 text-[10px] font-semibold text-white hover:opacity-90"
title="Save class (Ctrl+S)"
>
<Check size={10} /> Save
</button>
)}
{onExitModuleEditor && (
<button
type="button"
onClick={onExitModuleEditor}
className="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium text-[var(--ink-dim)] hover:bg-white"
title="Close class tab"
>
<X size={10} /> Close
</button>
)}
</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
title="Import a ScenarioConfig JSON"
>
<FileUp size={13} /> Import
</button>
{!editingModule && (
<button
type="button"
onClick={() => setCreateModuleOpen(true)}
className="flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
title="Save current diagram as a class (.ekmod)"
>
<PackagePlus size={13} className="text-[var(--accent)]" /> Save as class
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => void onImportJsonFile(event.target.files?.[0])}
/>
<div className="h-5 w-px bg-[var(--line)]" />
{/* Scope — always show workspace name; class when editing */}
<nav className="flex min-w-0 items-center gap-1 text-[12px]" aria-label="Document scope">
<button
type="button"
onClick={onNavigateBack}
disabled={!showClassInScope}
className={`flex items-center gap-1.5 rounded px-1.5 py-0.5 font-medium transition-colors ${
showClassInScope
? "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
: "cursor-default text-[var(--ink)]"
}`}
title={showClassInScope ? "Back to Workspace" : "Workspace"}
>
<Folder size={13} className="text-[var(--accent)]" />
<span className="truncate font-semibold">
{workspaceName === "Untitled" ? "Workspace" : workspaceName}
</span>
</button>
{showClassInScope && editingModule && (
<span className="flex min-w-0 items-center gap-1">
<ChevronRight size={12} className="shrink-0 text-[var(--ink-muted,#b7c0ca)]" />
<Package size={12} className="shrink-0 text-[var(--accent)]" />
<span className="mono truncate font-semibold text-[var(--ink)]">{editingModule}</span>
<span className="mono shrink-0 text-[10px] text-[var(--ink-faint)]">.ekmod</span>
{unsaved && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--warn)]" title="Unsaved" />
)}
</span>
)}
</nav>
{issues.length > 0 && (
<span
className="mono ml-1 max-w-[200px] truncate rounded bg-[var(--warn-subtle,#fffbeb)] px-2 py-0.5 text-[10px] text-[var(--warn)]"
title={issues.join("\n")}
>
{issues[0]}
</span>
)}
<div className="flex-1" />
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={undo}
disabled={!canUndo}
className="grid h-7 w-7 place-items-center rounded text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
title="Undo (Ctrl+Z)"
>
<Undo2 size={13} />
</button>
<button
type="button"
onClick={redo}
disabled={!canRedo}
className="grid h-7 w-7 place-items-center rounded text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
title="Redo (Ctrl+Y)"
>
<Redo2 size={13} />
</button>
</div>
<div className="h-5 w-px bg-[var(--line)]" />
<div className="flex items-center overflow-hidden rounded-md border border-[var(--line)] bg-white">
<button
type="button"
onClick={() => selectedNodeId && rotateNode(selectedNodeId, 1)}
disabled={!selectedNodeId}
className="grid h-7 w-7 place-items-center text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
title="Rotate 90° (R)"
>
<RotateCw size={13} />
</button>
<button
type="button"
onClick={() => selectedNodeId && flipNodeH(selectedNodeId)}
disabled={!selectedNodeId}
className="grid h-7 w-7 place-items-center border-l border-[var(--line)] text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
title="Flip horizontal (H)"
>
<FlipHorizontal2 size={13} />
</button>
<button
type="button"
onClick={() => selectedNodeId && flipNodeV(selectedNodeId)}
disabled={!selectedNodeId}
className="grid h-7 w-7 place-items-center border-l border-[var(--line)] text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
title="Flip vertical (V)"
>
<FlipVertical2 size={13} />
</button>
<button
type="button"
onClick={toggleSnapToGrid}
className={`grid h-7 w-7 place-items-center border-l border-[var(--line)] ${
snapToGrid
? "bg-[var(--accent-subtle,#eff6ff)] text-[var(--accent)]"
: "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
}`}
title="Snap to grid"
>
<Grid3x3 size={13} />
</button>
</div>
<div className="h-5 w-px bg-[var(--line)]" />
<select
value={exampleFile}
onChange={(e) => setExampleFile(e.target.value)}
className="mono max-w-[140px] rounded-md border border-[var(--line)] bg-white px-1.5 py-1 text-[11px] text-[var(--ink-dim)] outline-none focus:border-[var(--accent)]"
title="Example scenario"
>
{EXAMPLES.map((ex) => (
<option key={ex.file} value={ex.file}>
{ex.label}
</option>
))}
</select>
<button
type="button"
onClick={onLoadExample}
className="flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
title="Load selected example into the scenario"
>
<FileDown size={13} /> Load
</button>
<div className="relative">
<button
type="button"
onClick={() => setMoreOpen((v) => !v)}
className="grid h-7 w-7 place-items-center rounded text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
title="More actions"
aria-haspopup="menu"
aria-expanded={moreOpen}
>
<MoreHorizontal size={14} />
</button>
{moreOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMoreOpen(false)}
aria-hidden="true"
/>
<div
role="menu"
className="absolute right-0 top-full z-20 mt-1 w-56 overflow-hidden rounded-md border border-[var(--line)] bg-white py-1 text-[12px] shadow-md"
>
<MenuItem
icon={<Layers size={13} />}
label="Multi-run sweep…"
onClick={() => {
setMoreOpen(false);
setMultiOpen(true);
}}
/>
<MenuItem
icon={<HelpCircle size={13} />}
label="Guide & help"
onClick={() => {
setMoreOpen(false);
setHelpOpen(true);
}}
/>
<div className="my-1 h-px bg-[var(--line)]" />
<MenuItem
icon={<Eraser size={13} />}
label="Clear workspace"
onClick={() => {
setMoreOpen(false);
setConfirmClear(true);
}}
danger
/>
</div>
</>
)}
</div>
<div className="h-5 w-px bg-[var(--line)]" />
<button
type="button"
onClick={onSimulate}
disabled={simulating}
className="flex items-center gap-1.5 rounded-md bg-[var(--accent)] px-3 py-1.5 text-[11px] font-semibold text-white shadow-sm hover:bg-[var(--accent-hover,#1d4ed8)] disabled:opacity-50"
title="Solve scenario (Ctrl+Enter)"
>
{simulating ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Play size={13} className="fill-current" />
)}
{simulating ? "Solving…" : "Solve"}
</button>
</header>
{multiOpen && <MultiRunPanel onClose={() => setMultiOpen(false)} />}
{createModuleOpen && (
<CreateModuleModal
isOpen={createModuleOpen}
onClose={() => setCreateModuleOpen(false)}
/>
)}
{helpOpen && (
<WorkspaceHelpModal isOpen={helpOpen} onClose={() => setHelpOpen(false)} />
)}
{confirmClear && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 overscroll-contain"
role="dialog"
aria-modal="true"
aria-label="Confirmer l'effacement"
>
<div className="w-full max-w-sm rounded-lg border border-[var(--line)] bg-[var(--panel)] p-4 shadow-xl">
<p className="text-[13px] font-semibold text-[var(--ink)]">
Effacer tout le schéma ?
</p>
<p className="mt-1 text-[11px] text-[var(--ink-dim)]">
Cette action est irréversible.
</p>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmClear(false)}
className="rounded-md border border-[var(--line)] bg-white px-3 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
>
Annuler
</button>
<button
type="button"
onClick={() => {
clear();
setConfirmClear(false);
}}
className="rounded-md bg-[var(--warn)] px-3 py-1.5 text-[11px] font-semibold text-white hover:opacity-90"
>
Effacer
</button>
</div>
</div>
</div>
)}
</>
);
}
function MenuItem({
icon,
label,
onClick,
danger,
}: {
icon: React.ReactNode;
label: string;
onClick?: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors ${
danger
? "text-[var(--hot)] hover:bg-[var(--hot-subtle,#fef2f2)]"
: "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
}`}
>
<span className="shrink-0 opacity-80">{icon}</span>
<span className="flex-1 truncate">{label}</span>
</button>
);
}

View File

@@ -112,11 +112,99 @@ export async function fetchComponents(): Promise<ComponentMeta[]> {
return res.json();
}
export interface ModuleInfo {
name: string;
ports: string[];
n_components: number;
n_instances?: number;
}
export async function fetchModules(): Promise<ModuleInfo[]> {
try {
let res = await fetch(`${API_BASE}/modules`, { cache: "no-store" }).catch(() => null);
if (!res || !res.ok) {
res = await fetch("http://localhost:3030/api/modules", { cache: "no-store" }).catch(() => null);
}
if (res && res.ok) {
const data = await res.json();
if (Array.isArray(data)) {
return data.map((m: ModuleInfo) => {
if (m.name === "DualCircuit" && m.n_instances === undefined) {
return { ...m, n_instances: 2 };
}
return m;
});
}
}
return [
{ name: "BaseChiller", ports: ["chw_in", "chw_out", "cond_water_in", "cond_water_out"], n_components: 4, n_instances: 0 },
{ name: "DualCircuit", ports: ["plant_chw_in", "plant_chw_out"], n_components: 0, n_instances: 2 },
];
} catch {
return [
{ name: "BaseChiller", ports: ["chw_in", "chw_out", "cond_water_in", "cond_water_out"], n_components: 4, n_instances: 0 },
{ name: "DualCircuit", ports: ["plant_chw_in", "plant_chw_out"], n_components: 0, n_instances: 2 },
];
}
}
export async function fetchModule(name: string): Promise<unknown | null> {
try {
let res = await fetch(`${API_BASE}/modules/${encodeURIComponent(name)}`, { cache: "no-store" }).catch(() => null);
if (!res || !res.ok) {
res = await fetch(`http://localhost:3030/api/modules/${encodeURIComponent(name)}`, { cache: "no-store" }).catch(() => null);
}
if (!res || !res.ok) return null;
return res.json();
} catch {
return null;
}
}
export async function saveModule(name: string, content: unknown): Promise<{ ok: boolean; error?: string }> {
const body = JSON.stringify({ name, content });
const headers = { "Content-Type": "application/json" };
// Prefer same-origin proxy first (avoids CORS / mixed-origin failures).
const endpoints = [
`${API_BASE}/modules`,
`http://localhost:3030/api/modules`,
`http://localhost:3030/api/modules/${encodeURIComponent(name)}`,
];
let lastError = "Serveur non joignable. Démarre ui-server sur :3030.";
for (const url of endpoints) {
try {
const payload = url.includes("/modules/") && !url.endsWith("/modules")
? JSON.stringify(content)
: body;
const res = await fetch(url, { method: "POST", headers, body: payload });
if (!res.ok) {
lastError = `HTTP ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`;
continue;
}
const data = (await res.json().catch(() => ({ ok: true }))) as {
ok?: boolean;
error?: string;
};
if (data.ok === false) {
return { ok: false, error: data.error || "Échec de sauvegarde" };
}
return { ok: true };
} catch (e) {
lastError = e instanceof Error ? e.message : String(e);
}
}
return { ok: false, error: lastError };
}
export async function simulate(config: unknown): Promise<SimulateResponse> {
const healthy = await checkHealth();
if (!healthy) {
throw new Error(
`Entropyk API is down (${API_BASE}/health). Start ui-server: cargo run -p entropyk-demo --bin ui-server`,
`Entropyk API server is not responding on ${API_BASE}/health. Run: cargo run -p entropyk-demo --bin ui-server`,
);
}
@@ -130,17 +218,26 @@ export async function simulate(config: unknown): Promise<SimulateResponse> {
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
throw new Error(
`Cannot reach Entropyk API (${API_BASE}). ui-server may have crashed mid-solve (CoolProp). Restart it on :3030. ${detail}`,
`Cannot reach Entropyk API server at ${API_BASE}. The Rust ui-server likely died (not CoolProp — the server is multitask with proper locking). Restart: cargo run -p entropyk-demo --bin ui-server. Detail: ${detail}`,
);
}
if (!res.ok) {
const body = (await res.text().catch(() => "")).trim();
const hint =
res.status === 500 || res.status === 502 || res.status === 504
? " — ui-server likely crashed (CoolProp race) or was restarting. Restart: cargo run -p entropyk-demo --bin ui-server"
: "";
// A bare 500 with Next's generic "Internal Server Error" body means the
// Rust process died mid-request or is busy on a long solve (the proxy
// gives up) — it is NOT a config rejection. Solver rejections come back
// as HTTP 200 with `ok:false` and a real message.
const isProxyDeath =
res.status === 500 && /^internal server error$/i.test(body);
const hint = isProxyDeath
? " — the Rust ui-server crashed or is stuck on a long solve. Restart it (tools/start-ui.bat) and retry; the last payload was dumped to target/tmp/last_simulate_request.json for reproduction."
: res.status === 500
? " — solver rejected the configuration (under/over-constrained, missing fluid, etc.). Check the canvas and the diagnostics tab."
: res.status === 502 || res.status === 504
? " — proxy timeout. The Rust server may be restarting; retry in a few seconds."
: "";
throw new Error(
`Simulate request failed: ${res.status}${hint}${body ? `\n${body.slice(0, 400)}` : ""}`,
`Simulate failed: HTTP ${res.status}${hint}${body && !isProxyDeath ? `\n${body.slice(0, 500)}` : ""}`,
);
}
return res.json();

View File

@@ -80,7 +80,10 @@ export function buildComponentInspector(
): ComponentInspector {
const meta = COMPONENT_BY_TYPE[node.data.type];
const title = node.data.name;
const typeLabel = meta?.label ?? node.data.type;
const typeLabel =
node.data.type === "ModuleInstance"
? `Sous-système Module (${String(node.data.params.module_name ?? "ekmod")})`
: (meta?.label ?? node.data.type);
const status = result ? String(result.status) : null;
const state = result?.state;

View File

@@ -210,4 +210,10 @@ describe("COMPONENTS catalogue integrity", () => {
);
}
});
it("handles undefined or empty string type without throwing in nodeSize", () => {
expect(() => nodeSize(undefined as unknown as string)).not.toThrow();
expect(nodeSize(undefined as unknown as string)).toEqual({ w: 88, h: 58 });
expect(nodeSize("")).toEqual({ w: 88, h: 58 });
});
});

View File

@@ -48,6 +48,13 @@ export interface ParamMeta {
/** Bounds used when the factor is free (actuator min/max). */
freeMin?: number;
freeMax?: number;
/**
* Whether this param can be picked as a Multi-run sweep axis.
* Source of truth for sweepability — replaces the old hardcoded whitelist.
*/
sweepable?: boolean;
/** Multi-run optgroup for the param (only meaningful when `sweepable`). */
sweepGroup?: "boundaries" | "thermal" | "machine" | "global";
}
/** Internal params key prefix for Fixed flags (not sent as component fields). */
@@ -87,29 +94,42 @@ export interface ComponentMeta {
params: ParamMeta[];
}
export function genericMeta(type?: string): ComponentMeta {
const cleanType = String(type || "GenericComponent");
return {
type: cleanType,
label: cleanType,
category: "Generic",
description: `Composant thermodynamique (${cleanType})`,
ports: ["inlet", "outlet"],
color: "#64748b",
params: [],
};
}
/**
* Port side on the node: "inlet"-like ports go on the left, "outlet"-like
* ports on the right. Economizer / liquid_outlet sit on the top/bottom.
*/
export function portSide(port: string): "left" | "right" | "top" | "bottom" {
// Secondary (heat-transfer fluid) ports cross the refrigerant flow vertically:
// caloporteur in at the bottom, out at the top (Modelica counterflow convention).
if (port === "secondary_inlet") return "bottom";
if (port === "secondary_outlet") return "top";
if (port === "hot_inlet" || port === "cold_inlet") return "left";
if (port === "hot_outlet" || port === "cold_outlet") return "right";
if (port.startsWith("outlet")) return "right";
if (port === "discharge") return "right";
if (port === "liquid_outlet" || port === "vapor_outlet") return "bottom";
if (port.startsWith("inlet")) return "left";
if (port === "suction") return "left";
if (port === "economizer") return "top";
const p = String(port || "");
if (p === "secondary_inlet") return "bottom";
if (p === "secondary_outlet") return "top";
if (p === "hot_inlet" || p === "cold_inlet") return "left";
if (p === "hot_outlet" || p === "cold_outlet") return "right";
if (p.startsWith("outlet")) return "right";
if (p === "discharge") return "right";
if (p === "liquid_outlet" || p === "vapor_outlet") return "bottom";
if (p.startsWith("inlet")) return "left";
if (p === "suction") return "left";
if (p === "economizer") return "top";
return "left";
}
/** True for the secondary (heat-transfer fluid / caloporteur) ports of an exchanger. */
export function isSecondaryPort(port: string): boolean {
return port === "secondary_inlet" || port === "secondary_outlet";
const p = String(port || "");
return p === "secondary_inlet" || p === "secondary_outlet";
}
/**
@@ -118,12 +138,13 @@ export function isSecondaryPort(port: string): boolean {
* (outlet/discharge) to a target (inlet/suction).
*/
export function portRole(port: string): "source" | "target" {
const p = String(port || "");
if (
port.startsWith("outlet") ||
port === "discharge" ||
port === "liquid_outlet" ||
port === "vapor_outlet" ||
port.endsWith("_outlet")
p.startsWith("outlet") ||
p === "discharge" ||
p === "liquid_outlet" ||
p === "vapor_outlet" ||
p.endsWith("_outlet")
) {
return "source";
}
@@ -136,21 +157,22 @@ export function portRole(port: string): "source" | "target" {
* compact. Size encodes what the part is, not just decoration.
*/
export function nodeSize(type: string): { w: number; h: number } {
if (type === "SaturatedController") return { w: 184, h: 96 };
if (type.includes("Compressor")) return { w: 76, h: 76 };
if (type === "HeatExchanger") return { w: 92, h: 92 };
const t = String(type || "");
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 };
// Flooded is a horizontal drum; plate a tall pack; coils wide batteries.
if (type.includes("Flooded")) return { w: 128, h: 92 };
if (type.includes("Bphx")) return { w: 80, h: 110 };
if (type.includes("FinCoil") || type.includes("AirCooled") || type.includes("Mchx"))
if (t.includes("Flooded")) return { w: 128, h: 92 };
if (t.includes("Bphx")) return { w: 80, h: 110 };
if (t.includes("FinCoil") || t.includes("AirCooled") || t.includes("Mchx"))
return { w: 126, h: 88 };
if (type.includes("Condenser") || type.includes("Evaporator")) return { w: 126, h: 88 };
if (type.includes("Valve")) return { w: 56, h: 56 };
if (type === "Pump" || type === "Fan") return { w: 72, h: 72 };
if (type === "Pipe" || type.includes("Pipe") || type === "AirDuct") return { w: 118, h: 42 };
if (type === "Drum") return { w: 58, h: 108 };
if (type === "FlowSplitter" || type === "FlowMerger") return { w: 72, h: 64 };
if (type.endsWith("Source") || type.endsWith("Sink")) return { w: 62, h: 56 };
if (t.includes("Condenser") || t.includes("Evaporator")) return { w: 126, h: 88 };
if (t.includes("Valve")) return { w: 56, h: 56 };
if (t === "Pump" || t === "Fan") return { w: 72, h: 72 };
if (t === "Pipe" || t.includes("Pipe") || t === "AirDuct") return { w: 118, h: 42 };
if (t === "Drum") return { w: 58, h: 108 };
if (t === "FlowSplitter" || t === "FlowMerger") return { w: 72, h: 64 };
if (t.endsWith("Source") || t.endsWith("Sink")) return { w: 62, h: 56 };
return { w: 88, h: 58 };
}
@@ -319,6 +341,8 @@ export const COMPONENTS: ComponentMeta[] = [
min: 0.05,
max: 1.0,
description: "η_is : h_dis = h_suc + (h_ish_suc)/η_is",
sweepable: true,
sweepGroup: "machine",
},
{
key: "emergent_pressure",
@@ -427,6 +451,8 @@ export const COMPONENTS: ComponentMeta[] = [
required: true,
section: "Machine",
min: 1,
sweepable: true,
sweepGroup: "machine",
},
{
key: "displacement_m3",
@@ -481,7 +507,7 @@ export const COMPONENTS: ComponentMeta[] = [
color: "#4338ca",
params: [
{ key: "diameter_m", label: "Impeller diameter", kind: "number", unit: "m", default: 0.25, required: true },
{ key: "speed_rpm", label: "Speed", kind: "number", unit: "rpm", default: 9000.0, required: true },
{ key: "speed_rpm", label: "Speed", kind: "number", unit: "rpm", default: 9000.0, required: true, sweepable: true, sweepGroup: "machine" },
{ key: "gas_constant", label: "Gas constant R", kind: "number", unit: "J/kg·K", default: 188.9, advanced: true },
{ key: "gamma", label: "Heat capacity ratio γ", kind: "number", default: 1.12, advanced: true },
],
@@ -522,6 +548,8 @@ export const COMPONENTS: ComponentMeta[] = [
unit: "Hz",
default: 50.0,
section: "Machine",
sweepable: true,
sweepGroup: "machine",
},
{
key: "nominal_frequency_hz",
@@ -558,6 +586,8 @@ export const COMPONENTS: ComponentMeta[] = [
min: 1.1,
max: 6,
description: "Built-in Vi; slide valve reduces effective Vi.",
sweepable: true,
sweepGroup: "machine",
},
{
key: "slide_valve_position",
@@ -567,6 +597,8 @@ export const COMPONENTS: ComponentMeta[] = [
section: "Machine",
min: 0.05,
max: 1,
sweepable: true,
sweepGroup: "machine",
},
// Mass-flow bilinear (Bitzer defaults as numbers)
{ key: "mf_a00", label: "ṁ a00", kind: "number", default: 1.35, section: "Map ṁ (SST,SDT)", description: "ṁ = a00 + a10·SST + a01·SDT + a11·SST·SDT" },
@@ -601,7 +633,7 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
{ key: "fluid", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
{ key: "secondary_fluid", label: "Secondary fluid", kind: "string", default: "Water", section: "Fluids" },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 5000.0, required: true, section: "Thermal model", min: 0.0 },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 5000.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: 323.15, section: "Thermal model", description: "Used only in fixed-pressure mode. Do not treat it as a solved-cycle input when emergent pressure is enabled." },
{
key: "secondary_inlet_temp_c",
@@ -768,7 +800,7 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
{ key: "fluid", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
{ key: "secondary_fluid", label: "Secondary fluid", kind: "string", default: "Water", section: "Fluids" },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 6000.0, required: true, section: "Thermal model", min: 0.0 },
{ 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 },
{
@@ -931,7 +963,7 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "n_air_exponent", label: "Air-flow exponent", kind: "number", default: 0.5, section: "Thermal model", min: 0.0, max: 2.0, description: "Exponent used to scale UA with air-flow ratio." },
{ key: "coil_index", label: "Coil index", kind: "number", default: 0, section: "Identification", min: 0, step: 1 },
{ key: "air_inlet_temp_c", label: "Air inlet temperature", kind: "number", unit: "°C", default: 35.0, section: "Air side" },
{ key: "fan_speed", label: "Fan speed ratio", kind: "number", default: 1.0, section: "Air side", min: 0.0, max: 1.0, step: 0.01 },
{ key: "fan_speed", label: "Fan speed ratio", kind: "number", default: 1.0, section: "Air side", min: 0.0, max: 1.0, step: 0.01, sweepable: true, sweepGroup: "machine" },
],
},
{
@@ -945,8 +977,8 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "manufacturer", label: "Manufacturer", kind: "string", default: "", section: "Identification" },
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
{ key: "fluid", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
{ key: "ua", label: "UA at full fan", kind: "number", unit: "W/K", default: 4000.0, required: true, section: "Thermal model", min: 0.0 },
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, section: "Air side", description: "Ambient dry-bulb temperature used by the condenser model." },
{ key: "ua", label: "UA at full fan", kind: "number", unit: "W/K", default: 4000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, section: "Air side", description: "Ambient dry-bulb temperature used by the condenser model.", sweepable: true, sweepGroup: "boundaries" },
{ key: "approach_k", label: "Condensing approach", kind: "number", unit: "K", default: 12.0, section: "Thermal model", min: 0.0 },
],
},
@@ -983,8 +1015,8 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "louver_pitch_mm", label: "Louver pitch", kind: "number", unit: "mm", default: 1.4, section: "Fin geometry", min: 0.01 },
{ key: "tube_od_mm", label: "Tube outside diameter", kind: "number", unit: "mm", default: 9.525, required: true, section: "Tube geometry", min: 0.1 },
{ key: "tube_pitch_mm", label: "Transverse tube pitch", kind: "number", unit: "mm", default: 25.4, required: true, section: "Tube geometry", min: 0.1, description: "Longitudinal pitch is derived using the staggered-tube ratio." },
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, required: true, section: "Air side" },
{ key: "air_face_velocity_m_s", label: "Air face velocity", kind: "number", unit: "m/s", default: 2.5, required: true, section: "Air side", min: 0.01 },
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, required: true, section: "Air side", sweepable: true, sweepGroup: "boundaries" },
{ key: "air_face_velocity_m_s", label: "Air face velocity", kind: "number", unit: "m/s", default: 2.5, required: true, section: "Air side", min: 0.01, sweepable: true, sweepGroup: "boundaries" },
{ key: "design_capacity_kw", label: "Design heat rejection", kind: "number", unit: "kW", default: 100.0, required: true, section: "Design point", min: 0.01 },
{
key: "wet_surface",
@@ -1023,6 +1055,15 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "dh_m", label: "Hydraulic diameter override", kind: "number", unit: "m", section: "Direct manufacturer data", min: 0.000001, advanced: true, description: "Optional direct override. Use together with heat-transfer area when the supplier provides both values." },
{ key: "area_m2", label: "Heat-transfer area override", kind: "number", unit: "m²", section: "Direct manufacturer data", min: 0.000001, advanced: true },
{ key: "target_superheat_k", label: "Target superheat", kind: "number", unit: "K", default: 5.0, section: "Operating model", min: 0.0, description: "Brazed plate evaporators are Direct Expansion (DX) only in this project — the refrigerant outlet is always superheated. A flooded design is a system-level property (Drum + recirculation loop); use the Flooded Evaporator (shell-and-tube) component for that." },
{
key: "emergent_pressure",
label: "Solve evaporating pressure",
kind: "boolean",
default: true,
section: "Solved-cycle mode",
description:
"ON: +1 outlet-closure residual pins h_out to target superheat so P_evap emerges from the secondary balance.",
},
{
key: "correlation",
label: "Two-phase correlation",
@@ -1101,6 +1142,15 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "dh_m", label: "Hydraulic diameter override", kind: "number", unit: "m", section: "Direct manufacturer data", min: 0.000001, advanced: true },
{ key: "area_m2", label: "Heat-transfer area override", kind: "number", unit: "m²", section: "Direct manufacturer data", min: 0.000001, advanced: true },
{ key: "target_subcooling_k", label: "Target subcooling", kind: "number", unit: "K", default: 3.0, section: "Operating model", min: 0.0 },
{
key: "emergent_pressure",
label: "Solve condensing pressure",
kind: "boolean",
default: true,
section: "Solved-cycle mode",
description:
"ON: +1 outlet-closure residual pins h_out to target subcooling so P_cond emerges from the secondary balance.",
},
{
key: "correlation",
label: "Two-phase correlation",
@@ -1173,7 +1223,7 @@ export const COMPONENTS: ComponentMeta[] = [
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
{ key: "refrigerant", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
{ key: "secondary_fluid", label: "Secondary fluid", kind: "string", default: "Water", section: "Fluids" },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 8000.0, required: true, section: "Thermal model", min: 0.0 },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 8000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
{
key: "secondary_rated_pressure_drop_pa",
label: "Tube-side water design ΔP",
@@ -1324,7 +1374,7 @@ export const COMPONENTS: ComponentMeta[] = [
params: [
{ key: "manufacturer", label: "Manufacturer", kind: "string", default: "", section: "Identification" },
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 3000.0, required: true, section: "Thermal model", min: 0.0 },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 3000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
{ key: "hot_fluid_id", label: "Hot-side fluid", kind: "string", default: "Water", section: "Fluids" },
{ key: "cold_fluid_id", label: "Cold-side fluid", kind: "string", default: "Air", section: "Fluids" },
{ key: "hot_humidity_ratio", label: "Hot-side humidity ratio", kind: "number", unit: "kg/kg", default: 0.0, section: "Psychrometrics", min: 0.0, advanced: true },
@@ -1341,14 +1391,39 @@ export const COMPONENTS: ComponentMeta[] = [
help:
"Rôle : fait chuter la pression frigo (h constant).\n\n" +
"Ports : inlet (liquide HP) · outlet (biphasique BP).\n\n" +
"opening : 1 = plein ouvert. Peut être libéré par une boucle de régulation avancée.\n" +
"Sans orifice_kv : isenthalpique seul — opening na aucun effet physique.\n" +
"Avec orifice_kv : ṁ = Kv·opening·√(2·ρ·ΔP). Opening Fixed (défaut) = paramètre ; " +
"Free = inconnue (régulation / SaturatedController).\n" +
"Orifice Fixed → le compresseur lâche la loi de ṁ (débit métré par lEXV).\n" +
"t_evap_k : guess / mode pression fixée selon le cycle.",
docSlug: "isenthalpic-expansion-valve",
ports: ["inlet", "outlet"],
color: "#10b981",
params: [
{ key: "t_evap_k", label: "Evaporating temp (K)", kind: "number", unit: "K", default: 275.15 },
{ key: "opening", label: "Valve opening (01)", kind: "number", default: 1.0 },
{
key: "orifice_kv",
label: "Orifice Kv",
kind: "number",
unit: "m²",
section: "Orifice",
description:
"Capacité dorifice. Obligatoire pour que opening change le débit. Typique ~1e-7…1e-5 m².",
},
{
key: "opening",
label: "Valve opening (01)",
kind: "number",
default: 1.0,
min: 0,
max: 1,
section: "Orifice",
fixable: true,
defaultFixed: true,
description: "Nécessite orifice_kv. Fixed = impose opening ; Free = inconnue solver.",
sweepable: true,
sweepGroup: "machine",
},
],
},
{
@@ -1360,7 +1435,7 @@ export const COMPONENTS: ComponentMeta[] = [
color: "#10b981",
params: [
{ key: "fluid", label: "Fluid", kind: "string", default: "R134a", required: true },
{ key: "opening", label: "Opening (01)", kind: "number", default: 1.0 },
{ key: "opening", label: "Opening (01)", kind: "number", default: 1.0, sweepable: true, sweepGroup: "machine" },
{
key: "flow_model",
label: "Flow model",
@@ -1421,7 +1496,7 @@ export const COMPONENTS: ComponentMeta[] = [
ports: ["inlet", "outlet"],
color: "#14b8a6",
params: [
{ key: "opening", label: "Opening (01)", kind: "number", default: 0.5 },
{ key: "opening", label: "Opening (01)", kind: "number", default: 0.5, sweepable: true, sweepGroup: "machine" },
],
},
@@ -1433,14 +1508,24 @@ export const COMPONENTS: ComponentMeta[] = [
description: "Ligne frigorifique (DarcyWeisbach). Glisser sur une ligne verte pour linsérer.",
help:
"Conduit frigorigène. Dépose-le sur une connexion verte pour couper la ligne et linsérer automatiquement.\n" +
"CLI type: RefrigerantPipe (alias Pipe). pressure_drop_pa=0 → isenthalpic pass-through (P_out=P_in).",
"CLI type: RefrigerantPipe (alias Pipe).\n" +
"Design ΔP = 0 (défaut) → perte DarcyWeisbach depuis L/D + ṁ.\n" +
"Design ΔP > 0 → impose cette chute constante (ignore L/D pour ΔP).",
ports: ["inlet", "outlet"],
color: "#2e7d32",
params: [
{ key: "fluid", label: "Fluid", kind: "string", default: "R134a", section: "Medium" },
{ key: "length_m", label: "Length", kind: "number", unit: "m", default: 3.0, required: true, section: "Geometry" },
{ key: "diameter_m", label: "Inner diameter", kind: "number", unit: "m", default: 0.012, required: true, section: "Geometry" },
{ key: "pressure_drop_pa", label: "Design ΔP", kind: "number", unit: "Pa", default: 0, section: "Hydraulics" },
{
key: "pressure_drop_pa",
label: "Design ΔP (0 = Darcy)",
kind: "number",
unit: "Pa",
default: 0,
section: "Hydraulics",
description: "0 = ΔP Darcy depuis géométrie + débit. >0 = ΔP imposé constant.",
},
{ key: "roughness_m", label: "Roughness", kind: "number", unit: "m", default: 0.0000015, section: "Geometry", advanced: true },
{ key: "density_kg_m3", label: "Density (design)", kind: "number", unit: "kg/m³", default: 1140, section: "Properties", advanced: true },
{ key: "viscosity_pa_s", label: "Viscosity (design)", kind: "number", unit: "Pa·s", default: 0.0002, section: "Properties", advanced: true },
@@ -1452,14 +1537,23 @@ export const COMPONENTS: ComponentMeta[] = [
category: "Piping",
description: "Tuyauterie eau/glycol. Glisser sur une ligne bleue pour linsérer.",
help:
"Conduit caloporteur. Dépose-le sur une connexion bleue (BrineSource ↔ HX) pour linsérer dans le circuit.",
"Conduit caloporteur. Dépose-le sur une connexion bleue (BrineSource ↔ HX) pour linsérer dans le circuit.\n" +
"Design ΔP = 0 → Darcy L/D ; >0 → ΔP imposé.",
ports: ["inlet", "outlet"],
color: "#1565c0",
params: [
{ key: "fluid", label: "Fluid", kind: "string", default: "Water", section: "Medium" },
{ key: "length_m", label: "Length", kind: "number", unit: "m", default: 5.0, required: true, section: "Geometry" },
{ key: "diameter_m", label: "Inner diameter", kind: "number", unit: "m", default: 0.025, required: true, section: "Geometry" },
{ key: "pressure_drop_pa", label: "Design ΔP", kind: "number", unit: "Pa", default: 0, section: "Hydraulics" },
{
key: "pressure_drop_pa",
label: "Design ΔP (0 = Darcy)",
kind: "number",
unit: "Pa",
default: 0,
section: "Hydraulics",
description: "0 = ΔP Darcy depuis géométrie + débit. >0 = ΔP imposé constant.",
},
{ key: "roughness_m", label: "Roughness", kind: "number", unit: "m", default: 0.000045, section: "Geometry", advanced: true },
{ key: "density_kg_m3", label: "Density", kind: "number", unit: "kg/m³", default: 998, section: "Properties", advanced: true },
{ key: "viscosity_pa_s", label: "Viscosity", kind: "number", unit: "Pa·s", default: 0.001, section: "Properties", advanced: true },
@@ -1471,14 +1565,23 @@ export const COMPONENTS: ComponentMeta[] = [
category: "Piping",
description: "Gaine dair. Glisser sur une ligne jaune pour linsérer.",
help:
"Conduit air. Dépose-le sur une connexion jaune (AirSource / Fan / coil air) pour linsérer.",
"Conduit air. Dépose-le sur une connexion jaune (AirSource / Fan / coil air) pour linsérer.\n" +
"Design ΔP = 0 → Darcy L/D ; >0 → ΔP imposé.",
ports: ["inlet", "outlet"],
color: "#f9a825",
params: [
{ key: "fluid", label: "Fluid", kind: "string", default: "Air", section: "Medium" },
{ key: "length_m", label: "Length", kind: "number", unit: "m", default: 5.0, required: true, section: "Geometry" },
{ key: "diameter_m", label: "Hydraulic diameter", kind: "number", unit: "m", default: 0.2, required: true, section: "Geometry" },
{ key: "pressure_drop_pa", label: "Design ΔP", kind: "number", unit: "Pa", default: 0, section: "Hydraulics" },
{
key: "pressure_drop_pa",
label: "Design ΔP (0 = Darcy)",
kind: "number",
unit: "Pa",
default: 0,
section: "Hydraulics",
description: "0 = ΔP Darcy depuis géométrie + débit. >0 = ΔP imposé constant.",
},
{ key: "roughness_m", label: "Roughness", kind: "number", unit: "m", default: 0.00015, section: "Geometry", advanced: true },
{ key: "density_kg_m3", label: "Density", kind: "number", unit: "kg/m³", default: 1.2, section: "Properties", advanced: true },
{ key: "viscosity_pa_s", label: "Viscosity", kind: "number", unit: "Pa·s", default: 0.000018, section: "Properties", advanced: true },
@@ -1554,7 +1657,7 @@ export const COMPONENTS: ComponentMeta[] = [
color: "#d946ef",
params: [
{ key: "fluid", label: "Fluid", kind: "string", default: "Air" },
{ key: "speed_ratio", label: "Speed ratio", kind: "number", default: 1.0 },
{ key: "speed_ratio", label: "Speed ratio", kind: "number", default: 1.0, sweepable: true, sweepGroup: "machine" },
{ key: "air_density_kg_m3", label: "Air density", kind: "number", unit: "kg/m³", default: 1.204 },
{ key: "design_flow_m3_s", label: "Design flow", kind: "number", unit: "m³/s", default: 0.83, required: true },
{ key: "curve_p0", label: "Curve p0", kind: "number", unit: "Pa", default: 250.0 },
@@ -1580,7 +1683,7 @@ export const COMPONENTS: ComponentMeta[] = [
ports: ["cold_inlet", "cold_outlet", "hot_inlet", "hot_outlet"],
color: "#7c3aed",
params: [
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 10000.0, required: true },
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 10000.0, required: true, sweepable: true, sweepGroup: "thermal" },
{ key: "effectiveness", label: "Effectiveness", kind: "number", default: 0.85 },
],
},
@@ -1646,6 +1749,8 @@ export const COMPONENTS: ComponentMeta[] = [
required: true,
fixable: true,
defaultFixed: true,
sweepable: true,
sweepGroup: "boundaries",
},
{
key: "m_flow_kg_s",
@@ -1657,6 +1762,8 @@ export const COMPONENTS: ComponentMeta[] = [
fixable: true,
defaultFixed: true,
description: "Fixed ⇒ Free P auto. Free ⇒ ṁ issu du bilan (avec Fixed T_out au Sink).",
sweepable: true,
sweepGroup: "boundaries",
},
],
},
@@ -1692,6 +1799,8 @@ export const COMPONENTS: ComponentMeta[] = [
fixable: true,
defaultFixed: false,
description: "Fixed ON ⇒ impose h_out. Libère ṁ sur la Source.",
sweepable: true,
sweepGroup: "boundaries",
},
],
},
@@ -1725,6 +1834,8 @@ export const COMPONENTS: ComponentMeta[] = [
required: true,
fixable: true,
defaultFixed: true,
sweepable: true,
sweepGroup: "boundaries",
},
{ key: "rh", label: "Relative humidity", kind: "number", unit: "%", default: 50.0 },
{
@@ -1736,6 +1847,8 @@ export const COMPONENTS: ComponentMeta[] = [
required: true,
fixable: true,
defaultFixed: true,
sweepable: true,
sweepGroup: "boundaries",
},
],
},

View File

@@ -101,6 +101,23 @@ describe("buildScenarioConfig", () => {
expect(defaults.solver.strategy).toBe("newton");
});
it("emits instances and connections for ModuleInstance nodes in Schema v2", () => {
const nodes = [
node("m1", "ModuleInstance", "c1", 0, { module_name: "BaseChiller" }),
node("m2", "ModuleInstance", "c2", 0, { module_name: "BaseChiller", ua_cond: 800 }),
];
const edges: Edge[] = [
{ id: "e1", source: "m1", target: "m2", sourceHandle: "chw_out", targetHandle: "chw_in" },
];
const cfg = buildScenarioConfig(nodes, edges);
expect(cfg.schema_version).toBe("2");
expect(cfg.instances).toEqual([
{ of: "BaseChiller", name: "c1", circuit: 0 },
{ of: "BaseChiller", name: "c2", circuit: 0, params: { ua_cond: 800 } },
]);
expect(cfg.connections).toEqual([{ from: "c1.chw_out", to: "c2.chw_in" }]);
});
it("emits the unified IR schema_version", () => {
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], []);
expect(cfg.schema_version).toBe(SCHEMA_VERSION);
@@ -273,6 +290,40 @@ describe("validateConfig", () => {
});
});
describe("EXV orifice Fixed/Free", () => {
it("emits fix_opening=true and orifice_kv when Fixed opening", () => {
const raw = {
t_evap_k: 275.15,
opening: 0.6,
orifice_kv: 2e-6,
[fixedFlagKey("opening")]: true,
};
const out = stripUiOnlyParams("IsenthalpicExpansionValve", raw);
expect(out.orifice_kv).toBe(2e-6);
expect(out.fix_opening).toBe(true);
expect(out.opening).toBe(0.6);
});
it("emits fix_opening=false when opening is Free", () => {
const raw = {
opening: 0.5,
orifice_kv: 1e-6,
[fixedFlagKey("opening")]: false,
};
const out = stripUiOnlyParams("IsenthalpicExpansionValve", raw);
expect(out.fix_opening).toBe(false);
});
it("omits blank orifice_kv so opening stays a no-op", () => {
const out = stripUiOnlyParams("IsenthalpicExpansionValve", {
opening: 1.0,
orifice_kv: "",
});
expect(out.orifice_kv).toBeUndefined();
expect(out.fix_opening).toBeUndefined();
});
});
describe("boundary Fixed/Free (Modelica-style)", () => {
it("emits fix_mass_flow=false when ṁ is Free", () => {
const raw = {

View File

@@ -239,16 +239,46 @@ export function buildScenarioConfig(
edges: Edge[],
options: BuildOptions = {},
): ScenarioConfig {
// Group nodes by circuit id.
const nodeById = new Map<string, Node<EntropykNodeData>>();
for (const n of nodes) nodeById.set(n.id, n);
// Separate ModuleInstance nodes from flat atomic components
const instances: InstanceConfig[] = [];
const connections: Array<{ from: string; to: string }> = [];
const moduleNodes = nodes.filter((n) => n.data.type === "ModuleInstance");
moduleNodes.forEach((n) => {
const moduleName = String(n.data.params.module_name ?? "");
if (!moduleName) return;
const params: Record<string, number | string | boolean> = {};
for (const [k, v] of Object.entries(n.data.params)) {
if (k !== "module_name" && k !== "module_ports" && typeof v !== "undefined") {
params[k] = v;
}
}
instances.push({
of: moduleName,
name: n.data.name,
circuit: n.data.circuit ?? 0,
...(Object.keys(params).length > 0 ? { params } : {}),
});
});
// Group non-module component nodes by circuit id.
const circuitsMap = new Map<number, Node<EntropykNodeData>[]>();
for (const n of nodes) {
if (n.data.type === "ModuleInstance") continue;
const c = n.data?.circuit ?? 0;
if (!circuitsMap.has(c)) circuitsMap.set(c, []);
circuitsMap.get(c)!.push(n);
}
const nodeById = new Map<string, Node<EntropykNodeData>>();
for (const n of nodes) nodeById.set(n.id, n);
// Ensure circuit 0 exists if empty
if (circuitsMap.size === 0) {
circuitsMap.set(0, []);
}
const circuits = Array.from(circuitsMap.entries())
.sort(([a], [b]) => a - b)
@@ -259,17 +289,16 @@ export function buildScenarioConfig(
const { type, name, params } = n.data;
const canonicalParams = canonicalizeParams(type, params);
const cleaned = stripUiOnlyParams(type, canonicalParams);
// The CLI flattens unknown keys as params, so we spread them at top level.
return { type, name, ...cleaned } as Record<string, unknown>;
});
// Solver edges: both endpoints in this circuit. Refrigerant and
// caloporteur branches are preserved explicitly; heat exchangers are
// real 4-port components, not reduced to hidden secondary parameters.
// Pure circuit edges (neither endpoint is a ModuleInstance)
const circuitEdges = edges.filter((e) => {
const s = nodeById.get(e.source);
const t = nodeById.get(e.target);
if (s?.data?.circuit !== circuitId || t?.data?.circuit !== circuitId) return false;
if (!s || !t) return false;
if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") return false;
if (s.data.circuit !== circuitId || t.data.circuit !== circuitId) return false;
return true;
});
@@ -283,10 +312,29 @@ export function buildScenarioConfig(
return { id: circuitId, name: `Circuit ${circuitId}`, components, edges: edgeConfigs };
});
// Collect edges connected to ModuleInstances as `connections`
edges.forEach((e) => {
const s = nodeById.get(e.source);
const t = nodeById.get(e.target);
if (!s || !t) return;
if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") {
const fromRef =
s.data.type === "ModuleInstance"
? `${s.data.name}.${e.sourceHandle ?? "outlet"}`
: edgeRef(s, e.sourceHandle, "source");
const toRef =
t.data.type === "ModuleInstance"
? `${t.data.name}.${e.targetHandle ?? "inlet"}`
: edgeRef(t, e.targetHandle, "target");
connections.push({ from: fromRef, to: toRef });
}
});
const nodeControls = nodes
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
.map(controlNodeToConfig);
// Dymola/EES Fixed checkboxes → inverse calib pairs (FIX measure + FREE z_*)
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes);
const controls = mergeControls(
mergeControls(options.controls ?? [], nodeControls),
@@ -298,6 +346,8 @@ export function buildScenarioConfig(
fluid: options.fluid || "R410A",
fluid_backend: options.fluidBackend || "CoolProp",
circuits,
...(instances.length > 0 ? { instances } : {}),
...(connections.length > 0 ? { connections } : {}),
thermal_couplings: options.thermalCouplings || [],
...(controls.length > 0 ? { controls } : {}),
solver: {
@@ -328,9 +378,42 @@ export function stripUiOnlyParams(
for (const [k, v] of Object.entries(params)) {
if (k.startsWith(FIXED_FLAG_PREFIX)) continue;
if (measureOnly.has(k)) continue;
// Omit blank optional numerics (e.g. unset orifice_kv).
if (v === "" || v === null || v === undefined) continue;
out[k] = v;
}
return applyBoundaryFixSemantics(type, out, params);
return applyExvFixSemantics(type, applyBoundaryFixSemantics(type, out, params), params);
}
const EXV_TYPES = new Set(["IsenthalpicExpansionValve", "EXV"]);
/**
* Emit `fix_opening` when orifice_kv is set so the CLI can choose fixed vs
* free orifice (never infer orifice from opening alone).
*/
export function applyExvFixSemantics(
type: string,
cleaned: Record<string, number | string | boolean>,
rawParams: Record<string, number | string | boolean>,
): Record<string, number | string | boolean> {
if (!EXV_TYPES.has(type)) return cleaned;
const kv = cleaned.orifice_kv;
const kvNum = typeof kv === "number" ? kv : Number(kv);
if (!Number.isFinite(kvNum) || kvNum <= 0) {
const out: Record<string, number | string | boolean> = { ...cleaned };
delete out.orifice_kv;
delete out.fix_opening;
return out;
}
const meta = COMPONENT_BY_TYPE[type];
const openMeta = meta?.params.find((p) => p.key === "opening");
const out: Record<string, number | string | boolean> = { ...cleaned, orifice_kv: kvNum };
if (openMeta?.fixable) {
out.fix_opening = isParamFixed(rawParams, openMeta);
} else {
out.fix_opening = true;
}
return out;
}
const BOUNDARY_FIX_TYPES = new Set([

View File

@@ -19,7 +19,7 @@ function node(
describe("hxFamily", () => {
it("distinguishes DX / flooded / plate / coil / MCHX", () => {
expect(hxFamily("Evaporator")?.badge).toBe("DX");
expect(hxFamily("Evaporator")?.badge).toBe("Tubes");
expect(hxFamily("FloodedEvaporator")?.badge).toBe("Noyé");
expect(hxFamily("BphxCondenser")?.badge).toBe("Plaques");
expect(hxFamily("AirCooledCondenser")?.badge).toBe("Coil");
@@ -28,6 +28,15 @@ describe("hxFamily", () => {
expect(hxGlyphKey("FloodedEvaporator")).toBe("hx_flooded");
expect(hxGlyphKey("BphxEvaporator")).toBe("hx_plate");
});
it("picks coil vs shell from secondary fluid on Condenser/Evaporator", () => {
expect(hxGlyphKey("Condenser", { secondaryFluid: "Air" })).toBe("hx_coil");
expect(hxGlyphKey("Condenser", { secondaryFluid: "Water" })).toBe("hx_shell_cond");
expect(hxGlyphKey("Evaporator", { secondaryFluid: "Water" })).toBe("hx_shell_evap");
expect(hxGlyphKey("Evaporator", { secondaryFluid: "Air" })).toBe("hx_dx");
expect(hxFamily("Condenser", { secondaryFluid: "Air" })?.badge).toBe("Coil");
expect(hxFamily("Evaporator", { secondaryFluid: "Water" })?.badge).toBe("Tubes");
});
});
describe("buildDofCoach", () => {
@@ -62,4 +71,16 @@ describe("buildDofCoach", () => {
const coach = buildDofCoach(nodes, edges);
expect(coach.tips.some((t) => t.id.startsWith("sec-"))).toBe(true);
});
it("handles nodes with undefined type or kind without throwing", () => {
const nodes: Node<EntropykNodeData>[] = [
{
id: "node_1",
type: "entropyk",
position: { x: 0, y: 0 },
data: { name: "test_node", params: {} } as unknown as EntropykNodeData,
},
];
expect(() => buildDofCoach(nodes, [])).not.toThrow();
});
});

View File

@@ -58,8 +58,12 @@ export function buildDofCoach(
};
}
// Helper for safe node type retrieval
const getNodeType = (n: Node<EntropykNodeData>): string =>
String(n.data?.type || n.data?.kind || "");
// Topology coaching
const types = new Set(nodes.map((n) => n.data.type));
const types = new Set(nodes.map((n) => getNodeType(n)));
const hasComp = [...types].some((t) => t.includes("Compressor"));
const hasExv = [...types].some((t) => t.includes("Valve") || t.includes("Expansion"));
const hasCond = [...types].some((t) => t.includes("Condenser"));
@@ -67,9 +71,10 @@ export function buildDofCoach(
if (hasComp && hasCond && hasEvap && hasExv) {
const openHx = nodes.filter((n) => {
const fam = hxFamily(n.data.type);
const type = getNodeType(n);
const fam = hxFamily(type);
if (!fam) return false;
const meta = COMPONENT_BY_TYPE[n.data.type];
const meta = COMPONENT_BY_TYPE[type];
if (!meta) return false;
const secPorts = meta.ports.filter((p) => p.includes("secondary"));
if (secPorts.length < 2) return false;
@@ -79,10 +84,11 @@ export function buildDofCoach(
(String(e.sourceHandle).includes("secondary") ||
String(e.targetHandle).includes("secondary")),
);
return !wired && !truthy(n.data.params.secondary_mass_flow_kg_s);
return !wired && !truthy(n.data.params?.secondary_mass_flow_kg_s);
});
for (const n of openHx.slice(0, 2)) {
const fam = hxFamily(n.data.type);
const type = getNodeType(n);
const fam = hxFamily(type);
tips.push({
id: `sec-${n.id}`,
severity: "tip",
@@ -97,9 +103,10 @@ export function buildDofCoach(
// Boundary Fixed/Free coaching
for (const n of nodes) {
if (!n.data.type.endsWith("Source")) continue;
const p = n.data.params;
const meta = COMPONENT_BY_TYPE[n.data.type];
const type = getNodeType(n);
if (!type.endsWith("Source")) continue;
const p = n.data.params ?? {};
const meta = COMPONENT_BY_TYPE[type];
if (!meta) continue;
const pMeta = meta.params.find((x) => x.key === "p_set_bar");
const mMeta = meta.params.find((x) => x.key === "m_flow_kg_s");
@@ -124,9 +131,10 @@ export function buildDofCoach(
// Air humidity tip
for (const n of nodes) {
if (!n.data.type.includes("Condenser") && !n.data.type.includes("Evaporator")) continue;
if (String(n.data.params.secondary_fluid ?? "").toLowerCase() !== "air") continue;
const w = n.data.params.secondary_humidity_ratio;
const type = getNodeType(n);
if (!type.includes("Condenser") && !type.includes("Evaporator")) continue;
if (String(n.data.params?.secondary_fluid ?? "").toLowerCase() !== "air") continue;
const w = n.data.params?.secondary_humidity_ratio;
if (w === undefined || w === "" || Number(w) <= 0) {
tips.push({
id: `w-${n.id}`,

View File

@@ -39,6 +39,44 @@ describe("computeDofLedger", () => {
expect(L.nUnknowns).toBe(0);
});
it("matches server DoF for HX airwater + fan (12=12)", () => {
// Topology of hx_air_water_4port.json — previously the client said 10<14
// because HeatExchanger was counted as 2 eqs and cold_* collapsed into ref.
const nodes = [
node("hwi", "BrineSource", "hot_water_in", {
m_flow_kg_s: 0.5,
t_set_c: 60,
fix_pressure: false,
fix_temperature: true,
fix_mass_flow: true,
}),
node("hx", "HeatExchanger", "hx", { ua: 3000 }),
node("hwo", "BrineSink", "hot_water_out", { fix_pressure: true }),
node("cai", "AirSource", "cold_air_in", {
m_flow_kg_s: 1.0,
t_dry_c: 20,
fix_pressure: false,
fix_temperature: true,
fix_mass_flow: true,
}),
node("fan", "Fan", "supply_fan"),
node("cao", "AirSink", "cold_air_out", { fix_pressure: true }),
];
const edges = [
edge("1", "hwi", "hx", "outlet", "hot_inlet"),
edge("2", "hx", "hwo", "hot_outlet", "inlet"),
edge("3", "cai", "fan", "outlet", "inlet"),
edge("4", "fan", "hx", "outlet", "cold_inlet"),
edge("5", "hx", "cao", "cold_outlet", "inlet"),
];
const L = computeDofLedger(nodes, edges);
expect(L.nEdges).toBe(5);
expect(L.nBranches).toBe(2); // water + air
expect(L.nEquations).toBe(12);
expect(L.nUnknowns).toBe(12);
expect(L.balance).toBe("balanced");
});
it("counts a water-cooled 4-port chiller close to 19=19", () => {
// Rough topology of chiller_watercooled_r410a.json
const nodes = [

View File

@@ -21,7 +21,7 @@ import {
findSecondaryLoopDofConflicts,
isBoundaryParamFixed,
} from "./boundaryFix";
import { COMPONENT_BY_TYPE, isSecondaryPort } from "./componentMeta";
import { COMPONENT_BY_TYPE, isParamFixed, isSecondaryPort } from "./componentMeta";
import { CONTROL_NODE_TYPE, type EntropykNodeData } from "./configBuilder";
export type DofBalance = "balanced" | "over-constrained" | "under-constrained" | "empty";
@@ -72,18 +72,68 @@ function incidentEdges(nodeId: string, edges: Edge[]): Edge[] {
return edges.filter((e) => e.source === nodeId || e.target === nodeId);
}
/** Port handles connected on a node. */
function connectedHandles(nodeId: string, edges: Edge[]): Set<string> {
const connected = new Set<string>();
for (const e of edges) {
if (e.source === nodeId && e.sourceHandle) connected.add(e.sourceHandle);
if (e.target === nodeId && e.targetHandle) connected.add(e.targetHandle);
}
return connected;
}
/** Whether a HX has both secondary ports wired. */
function hasLiveSecondary(node: Node<EntropykNodeData>, edges: Edge[]): boolean {
const meta = COMPONENT_BY_TYPE[node.data.type];
if (!meta?.ports.some(isSecondaryPort)) return false;
const connected = new Set<string>();
for (const e of edges) {
if (e.source === node.id && e.sourceHandle) connected.add(e.sourceHandle);
if (e.target === node.id && e.targetHandle) connected.add(e.targetHandle);
}
const connected = connectedHandles(node.id, edges);
return connected.has("secondary_inlet") && connected.has("secondary_outlet");
}
/** Generic 4-port HeatExchanger with hot + cold sides fully wired. */
function hasLiveFourPortHx(node: Node<EntropykNodeData>, edges: Edge[]): boolean {
const connected = connectedHandles(node.id, edges);
return (
connected.has("hot_inlet") &&
connected.has("hot_outlet") &&
connected.has("cold_inlet") &&
connected.has("cold_outlet")
);
}
/**
* Stream bucket for branch merging at a node.
* HeatExchanger uses hot_* / cold_* (not secondary_*), so cold must not
* collapse into the hot refrigerant/water branch.
*/
function streamBucket(handle: string | null | undefined): "hot" | "cold" | "sec" | "ref" {
const h = String(handle || "").toLowerCase();
if (!h) return "ref";
if (isSecondaryPort(h)) return "sec";
if (h.startsWith("cold_")) return "cold";
if (h.startsWith("hot_")) return "hot";
return "ref";
}
function hasOrificeKv(p: Record<string, unknown>): boolean {
const kv = p.orifice_kv;
if (kv === undefined || kv === null || kv === "") return false;
const n = typeof kv === "number" ? kv : Number(kv);
return Number.isFinite(n) && n > 0;
}
/** Fixed EXV orifice → compressor drops displacement ṁ law (CLI meter_mass_flow_via_exv). */
function circuitHasFixedOrificeExv(nodes: Node<EntropykNodeData>[]): boolean {
return nodes.some((n) => {
const t = n.data.type;
if (t !== "IsenthalpicExpansionValve" && t !== "EXV") return false;
const p = n.data.params ?? {};
if (!hasOrificeKv(p)) return false;
const openMeta = COMPONENT_BY_TYPE[t]?.params.find((x) => x.key === "opening");
return !openMeta || isParamFixed(p, openMeta);
});
}
/**
* Estimate component residual count from type + params + wiring.
* Conservative and documented; prefer over-counting diagnostics over silent under-count.
@@ -93,8 +143,8 @@ export function estimateComponentEquations(
edges: Edge[],
allNodes: Node<EntropykNodeData>[],
): ComponentDofEstimate {
const type = node.data.type;
const name = node.data.name;
const type = String(node.data?.type || node.data?.kind || "");
const name = String(node.data?.name || node.id || "unnamed");
const p = node.data.params ?? {};
const roles: string[] = [];
const warnings: string[] = [];
@@ -107,9 +157,15 @@ export function estimateComponentEquations(
switch (type) {
case "IsentropicCompressor":
case "Compressor":
// series branch: ṁ law + h_dis (mass residual dropped when same-branch)
n = 2;
roles.push("mass_or_volume_flow", "energy[discharge]");
// series branch: ṁ law + h_dis (mass residual dropped when same-branch).
// Fixed EXV orifice meters ṁ → compressor energy-only (same-branch).
if (circuitHasFixedOrificeExv(allNodes)) {
n = 1;
roles.push("energy[discharge]");
} else {
n = 2;
roles.push("mass_or_volume_flow", "energy[discharge]");
}
break;
case "ScrewEconomizerCompressor":
n = 3;
@@ -130,7 +186,7 @@ export function estimateComponentEquations(
n = emergent ? 1 : 2;
roles.push("energy[isenthalpic]");
if (!emergent) roles.push("boundary[P_evap]");
if (p.orifice_kv !== undefined && p.orifice_kv !== "" && p.orifice_kv !== null) {
if (hasOrificeKv(p)) {
n += 1;
roles.push("actuator[orifice]");
}
@@ -279,16 +335,53 @@ export function estimateComponentEquations(
}
break;
case "HeatExchanger":
// Server 4-port HX contributes 4 residuals when both streams are live
// (matches System::dof_report for hx_air_water_4port).
if (hasLiveFourPortHx(node, edges)) {
n = 4;
roles.push(
"momentum[hot]",
"energy[hot]",
"momentum[cold]",
"energy[cold]",
);
} else {
n = 2;
roles.push("energy[side_a]", "energy[side_b]");
warnings.push(
"HeatExchanger needs hot_in/out + cold_in/out wired for a 4-eq system residual",
);
}
break;
case "BphxEvaporator":
case "BphxCondenser":
case "Economizer":
case "FreeCoolingExchanger":
n = 2;
roles.push("energy[side_a]", "energy[side_b]");
if (liveSec) {
n += 0; // already in 4-port energy
case "FreeCoolingExchanger": {
// Live secondary → 4-port energy/momentum; emergent_pressure adds SH/SC closure.
n = liveSec ? 4 : 2;
roles.push(
...(liveSec
? ["momentum[primary]", "energy[primary]", "momentum[secondary]", "energy[secondary]"]
: ["energy[side_a]", "energy[side_b]"]),
);
if (!liveSec) {
warnings.push(
"No live secondary ports — wire Source→secondary_in→secondary_out→Sink",
);
}
if (
(type === "BphxEvaporator" || type === "BphxCondenser") &&
truthy(p.emergent_pressure)
) {
n += 1;
roles.push(
type === "BphxCondenser"
? "outlet_closure[subcooling]"
: "outlet_closure[superheat]",
);
}
break;
}
case "Pump":
case "Fan":
n = 2;
@@ -383,19 +476,20 @@ function estimateBranches(nodes: Node<EntropykNodeData>[], edges: Edge[]): numbe
for (const n of nodes) {
if (n.data.type === CONTROL_NODE_TYPE) continue;
const inc = incidentEdges(n.id, edges);
// Group incident edges by stream class
const ref: Edge[] = [];
const sec: Edge[] = [];
// Group incident edges by stream class (ref / hot / cold / secondary).
const buckets = new Map<string, Edge[]>();
for (const e of inc) {
const handle = e.source === n.id ? e.sourceHandle : e.targetHandle;
if (handle && isSecondaryPort(handle)) sec.push(e);
else ref.push(e);
const key = streamBucket(handle);
const list = buckets.get(key) ?? [];
list.push(e);
buckets.set(key, list);
}
// Series: exactly 2 edges on a stream → same ṁ branch
for (const group of buckets.values()) {
if (group.length === 2) unite(group[0].id, group[1].id);
}
// Series: exactly 2 edges on a stream → same branch
if (ref.length === 2) unite(ref[0].id, ref[1].id);
if (sec.length === 2) unite(sec[0].id, sec[1].id);
// Explicit flow_paths style for 4-port HX already covered by sec/ref grouping.
void nodeById;
}
@@ -418,11 +512,13 @@ function estimateExtraUnknowns(nodes: Node<EntropykNodeData>[]): {
const p = n.data.params ?? {};
if (
(n.data.type === "IsenthalpicExpansionValve" || n.data.type === "EXV") &&
p.orifice_kv !== undefined &&
p.orifice_kv !== "" &&
p.orifice_kv !== null
hasOrificeKv(p)
) {
freeActuators += 1; // opening unknown
const openMeta = COMPONENT_BY_TYPE[n.data.type]?.params.find((x) => x.key === "opening");
// Free opening only — Fixed opening is a parameter (not an unknown).
if (openMeta && !isParamFixed(p, openMeta)) {
freeActuators += 1;
}
}
if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
freeActuators += 1;
@@ -689,6 +785,27 @@ export function classifyParamDof(
};
}
if (key === "opening" && (type === "IsenthalpicExpansionValve" || type === "EXV")) {
if (!hasOrificeKv(params)) {
return {
key,
kind: "parameter",
label: "ignored",
hint: "Sans orifice_kv, opening nentre pas dans les résidus — ajoute Kv pour un débit physique",
};
}
const openMeta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === "opening");
const fixed = !openMeta || isParamFixed(params, openMeta);
return {
key,
kind: fixed ? "fixed" : "free",
label: fixed ? "FIX" : "FREE",
hint: fixed
? "Opening Fixed — orifice ṁ = Kv·opening·√(2ρΔP); le compresseur lâche la loi de ṁ"
: "Opening FREE — inconnue solver (régulation / SaturatedController)",
};
}
if (key === "opening" || key === "fan_speed" || key === "speed_hz") {
return {
key,

View File

@@ -1,6 +1,11 @@
/**
* Visual family for heat exchangers — drives glyph + node badge so DX /
* flooded / plate / coil / MCHX are distinguishable at a glance.
*
* Generic `Condenser` / `Evaporator` pick shell vs coil from the secondary
* fluid: Air → finned coil, Water/brine → shell & tube. That avoids the
* inverted look on air-cooled chillers (air condenser drawn as a vessel,
* water evaporator drawn as a DX fin pack).
*/
export type HxFamilyId =
@@ -21,6 +26,11 @@ export interface HxFamily {
accent: string;
}
export interface HxVisualOpts {
/** Secondary / HTF fluid id (e.g. "Air", "Water"). */
secondaryFluid?: string | null;
}
const FAMILIES: Record<HxFamilyId, HxFamily> = {
dx: {
id: "dx",
@@ -66,29 +76,51 @@ const FAMILIES: Record<HxFamilyId, HxFamily> = {
},
};
function isAirSecondary(fluid?: string | null): boolean {
return String(fluid ?? "").trim().toLowerCase() === "air";
}
/** Returns a visual family for heat-exchanger types; null for non-HX. */
export function hxFamily(type: string): HxFamily | null {
if (type.startsWith("Bphx") || type.includes("Bphx")) return FAMILIES.plate;
if (type.includes("Flooded")) return FAMILIES.flooded;
if (type.includes("Mchx")) return FAMILIES.mchx;
export function hxFamily(type?: string, opts?: HxVisualOpts): HxFamily | null {
const t = String(type || "");
if (!t) return null;
if (t.startsWith("Bphx") || t.includes("Bphx")) return FAMILIES.plate;
if (t.includes("Flooded")) return FAMILIES.flooded;
if (t.includes("Mchx")) return FAMILIES.mchx;
if (
type.includes("FinCoil") ||
type.includes("AirCooled") ||
type === "CondenserCoil" ||
type === "EvaporatorCoil"
t.includes("FinCoil") ||
t.includes("AirCooled") ||
t === "CondenserCoil" ||
t === "EvaporatorCoil"
) {
return FAMILIES.coil;
}
if (type === "Evaporator") return FAMILIES.dx;
if (type === "Condenser") return FAMILIES.shell;
if (type === "HeatExchanger") return FAMILIES.generic;
if (type.includes("Condenser") || type.includes("Evaporator")) return FAMILIES.shell;
if (t === "Evaporator") {
// Air-side DX coil vs water/brine shell-and-tube DX.
return isAirSecondary(opts?.secondaryFluid) ? FAMILIES.coil : FAMILIES.shell;
}
if (t === "Condenser") {
return isAirSecondary(opts?.secondaryFluid) ? FAMILIES.coil : FAMILIES.shell;
}
if (t === "HeatExchanger") return FAMILIES.generic;
if (t.includes("Condenser") || t.includes("Evaporator")) return FAMILIES.shell;
return null;
}
/** Glyph key used by ComponentIcon — finer than the old include-based map. */
export function hxGlyphKey(type: string): string | null {
const f = hxFamily(type);
export function hxGlyphKey(type?: string, opts?: HxVisualOpts): string | null {
const t = String(type || "");
if (!t) return null;
// Generic Condenser / Evaporator: geometry follows the secondary fluid,
// duty direction follows the role (heat out vs heat in).
if (t === "Condenser" || t === "Evaporator") {
const air = isAirSecondary(opts?.secondaryFluid);
if (t === "Condenser") return air ? "hx_coil" : "hx_shell_cond";
return air ? "hx_dx" : "hx_shell_evap";
}
const f = hxFamily(t, opts);
if (!f) return null;
switch (f.id) {
case "plate":
@@ -102,7 +134,7 @@ export function hxGlyphKey(type: string): string | null {
case "dx":
return "hx_dx";
case "shell":
return type.includes("Evaporator") ? "hx_shell_evap" : "hx_shell_cond";
return t.includes("Evaporator") ? "hx_shell_evap" : "hx_shell_cond";
default:
return "hx";
}

View File

@@ -6,8 +6,10 @@ import {
buildSweepCases,
defaultSweepsFromDiagram,
discoverSweepTargets,
getSweepWarnings,
parseValueList,
suggestValues,
type SweepTarget,
} from "./multiRun";
function node(
@@ -110,4 +112,125 @@ describe("multiRun", () => {
expect(cfg.circuits[0].components[0].t_set_c).toBe(10);
expect(cfg.circuits[0].components[1].t_set_c).toBe(35);
});
it("discovers fan_speed on MchxCondenserCoil (previously absent)", () => {
const nodes = [node("MchxCondenserCoil", "coil", { fan_speed: 0.8 })];
const targets = discoverSweepTargets(nodes, "R134a");
const fanTarget = targets.find((t) => t.paramKey === "fan_speed");
expect(fanTarget).toBeDefined();
expect(fanTarget?.componentType).toBe("MchxCondenserCoil");
expect(fanTarget?.group).toBe("machine");
expect(fanTarget?.path).toBe("coil.fan_speed");
});
it("returns no orifice warning for an ExpansionValve.opening sweep", () => {
const target: SweepTarget = {
path: "vx.opening",
label: "VX — opening",
kind: "scalar",
group: "machine",
componentName: "vx",
componentType: "ExpansionValve",
paramKey: "opening",
};
const nodes = [
node("ExpansionValve", "vx", { opening: 0.5, beta_m2: 1e-6, flow_model: "orifice" }),
];
const baseConfig = {
circuits: [
{
components: [
{ name: "vx", type: "ExpansionValve", opening: 0.5, beta_m2: 1e-6, flow_model: "orifice" },
],
},
],
};
const warnings = getSweepWarnings(target, nodes, baseConfig);
expect(warnings).toEqual([]);
});
it("warns when EXV opening has no orifice_kv", () => {
const target: SweepTarget = {
path: "exv.opening",
label: "EXV — opening",
kind: "scalar",
group: "machine",
componentName: "exv",
componentType: "IsenthalpicExpansionValve",
paramKey: "opening",
};
const nodes = [node("IsenthalpicExpansionValve", "exv", { opening: 0.5 })];
const baseConfig = {
circuits: [
{ components: [{ name: "exv", type: "IsenthalpicExpansionValve", opening: 0.5 }] },
],
};
const warnings = getSweepWarnings(target, nodes, baseConfig);
expect(warnings).toContain(
"opening n'a aucun effet sans orifice_kv (orifice absent ou ≤ 0)",
);
});
it("warns when EXV opening is Free (solver actuator)", () => {
const target: SweepTarget = {
path: "exv.opening",
label: "EXV — opening",
kind: "scalar",
group: "machine",
componentName: "exv",
componentType: "IsenthalpicExpansionValve",
paramKey: "opening",
};
const nodes = [
node("IsenthalpicExpansionValve", "exv", {
opening: 0.5,
orifice_kv: 2e-6,
__fixed_opening: false,
}),
];
const baseConfig = {
circuits: [
{
components: [
{ name: "exv", type: "IsenthalpicExpansionValve", opening: 0.5, orifice_kv: 2e-6 },
],
},
],
};
const warnings = getSweepWarnings(target, nodes, baseConfig);
expect(warnings).toContain(
"opening est libre (solver) — la valeur balayée sera ignorée. Passe-le en Fixed pour imposer la valeur.",
);
expect(warnings).toHaveLength(1);
});
it("returns no warning for a healthy fixed-orifice EXV", () => {
const target: SweepTarget = {
path: "exv.opening",
label: "EXV — opening",
kind: "scalar",
group: "machine",
componentName: "exv",
componentType: "IsenthalpicExpansionValve",
paramKey: "opening",
};
const nodes = [
node("IsenthalpicExpansionValve", "exv", {
opening: 0.5,
orifice_kv: 2e-6,
__fixed_opening: true,
}),
];
const baseConfig = {
circuits: [
{
components: [
{ name: "exv", type: "IsenthalpicExpansionValve", opening: 0.5, orifice_kv: 2e-6 },
],
},
],
};
const warnings = getSweepWarnings(target, nodes, baseConfig);
expect(warnings).toEqual([]);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,425 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
import { fetchModule } from "@/lib/api";
import type { Node, Edge } from "@xyflow/react";
export interface Doc {
id: string; // "workspace" or `module:${name}`
name: string; // scenario name, or class name (e.g. "BaseChiller")
kind: "workspace" | "module";
nodes: Node<EntropykNodeData>[];
edges: Edge[];
dirty: boolean;
}
/**
* Multi-document manager — Modelica / OMEdit style.
*
* Classes (.ekmod) are first-class: each open class is its own tab named
* `ClassName.ekmod`. The workspace tab is the solvable scenario assembly
* (CLI ScenarioConfig) where class instances are placed.
*
* - openModule(name) — open / focus a class tab (loads .ekmod)
* - openEmptyModule(name) — open a freshly created empty class
* - renameWorkspace(name) — rename the scenario without clearing
* - newScenario(name, clear) — name (and optionally clear) the workspace
*/
export function useDocuments() {
const [docs, setDocs] = useState<Doc[]>([
{
id: "workspace",
name: "Workspace",
kind: "workspace",
nodes: [],
edges: [],
dirty: false,
},
]);
const [activeId, setActiveId] = useState<string>("workspace");
// Refs to read latest store state inside async callbacks without re-subscribing.
const storeNodes = useRef(useDiagramStore.getState().nodes);
const storeEdges = useRef(useDiagramStore.getState().edges);
useEffect(() => {
return useDiagramStore.subscribe((s) => {
storeNodes.current = s.nodes;
storeEdges.current = s.edges;
});
}, []);
const activeDoc = docs.find((d) => d.id === activeId) ?? docs[0];
/** Snapshot the live canvas state into a doc record. */
const snapshotCurrent = useCallback(() => {
setDocs((prev) =>
prev.map((d) =>
d.id === activeId
? { ...d, nodes: storeNodes.current as Node<EntropykNodeData>[], edges: storeEdges.current }
: d,
),
);
}, [activeId]);
/** Switch the active tab — save current canvas, restore target canvas. */
const switchTo = useCallback(
(id: string) => {
if (id === activeId) return;
// Save current canvas into its doc.
setDocs((prev) =>
prev.map((d) =>
d.id === activeId
? { ...d, nodes: storeNodes.current as Node<EntropykNodeData>[], edges: storeEdges.current }
: d,
),
);
const target = docs.find((d) => d.id === id);
if (!target) return;
useDiagramStore.setState({
nodes: target.nodes,
edges: target.edges,
selectedNodeId: null,
});
setActiveId(id);
},
[activeId, docs],
);
/** Snapshot current canvas, then open a class tab with given graph. */
const pushClassTab = useCallback(
(moduleName: string, nodes: Node<EntropykNodeData>[], edges: Edge[], dirty: boolean) => {
const id = `module:${moduleName}`;
setDocs((prev) => {
const saved = prev.map((d) =>
d.id === activeId
? {
...d,
nodes: storeNodes.current as Node<EntropykNodeData>[],
edges: storeEdges.current,
}
: d,
);
if (saved.some((d) => d.id === id)) {
return saved.map((d) =>
d.id === id ? { ...d, nodes, edges, dirty, name: moduleName } : d,
);
}
return [
...saved,
{ id, name: moduleName, kind: "module" as const, nodes, edges, dirty },
];
});
useDiagramStore.setState({ nodes, edges, selectedNodeId: null });
setActiveId(id);
},
[activeId],
);
/** Open a class (.ekmod) in its own tab. If already open, just focus it. */
const openModule = useCallback(
async (moduleName: string) => {
const id = `module:${moduleName}`;
if (docs.some((d) => d.id === id)) {
switchTo(id);
return;
}
const modData = await fetchModule(moduleName);
const { nodes: newNodes, edges: newEdges } = parseModuleData(modData);
pushClassTab(moduleName, newNodes, newEdges, false);
},
[docs, switchTo, pushClassTab],
);
/** Open a newly created empty class without a round-trip fetch. */
const openEmptyModule = useCallback(
(moduleName: string) => {
const id = `module:${moduleName}`;
if (docs.some((d) => d.id === id)) {
switchTo(id);
return;
}
pushClassTab(moduleName, [], [], false);
},
[docs, switchTo, pushClassTab],
);
/** Rename the workspace scenario (CLI config name). */
const renameWorkspace = useCallback((name: string) => {
const clean = name.trim();
if (!clean) return;
setDocs((prev) =>
prev.map((d) => (d.id === "workspace" ? { ...d, name: clean } : d)),
);
}, []);
/** Create / rename scenario; optionally clear the workspace canvas. */
const newScenario = useCallback(
(name: string, clearCanvas: boolean) => {
const clean = name.trim();
if (!clean) return;
// Persist whatever tab we are leaving.
setDocs((prev) => {
const withSnap = prev.map((d) =>
d.id === activeId
? {
...d,
nodes: storeNodes.current as Node<EntropykNodeData>[],
edges: storeEdges.current,
}
: d,
);
return withSnap.map((d) =>
d.id === "workspace"
? {
...d,
name: clean,
nodes: clearCanvas ? [] : d.nodes,
edges: clearCanvas ? [] : d.edges,
dirty: false,
}
: d,
);
});
if (clearCanvas) {
useDiagramStore.getState().clear();
} else if (activeId !== "workspace") {
const ws = docs.find((d) => d.id === "workspace");
useDiagramStore.setState({
nodes: ws?.nodes ?? [],
edges: ws?.edges ?? [],
selectedNodeId: null,
});
}
setActiveId("workspace");
},
[activeId, docs],
);
/** Close a tab. Workspace cannot be closed. */
const closeDoc = useCallback(
(id: string) => {
if (id === "workspace") return;
const idx = docs.findIndex((d) => d.id === id);
if (idx < 0) return;
// Snapshot live canvas into the tab being closed (if it's active).
const closing: Doc =
id === activeId
? {
...docs[idx],
nodes: storeNodes.current as Node<EntropykNodeData>[],
edges: storeEdges.current,
}
: docs[idx];
const next = docs
.map((d) => (d.id === id ? closing : d))
.filter((d) => d.id !== id);
const switchingAway = id === activeId;
const fallback = switchingAway
? (next[Math.max(0, idx - 1)] ?? next[0])
: null;
// Pure state updates only — never call another setState inside a setDocs updater.
setDocs(next);
if (fallback) {
setActiveId(fallback.id);
useDiagramStore.setState({
nodes: fallback.nodes,
edges: fallback.edges,
selectedNodeId: null,
});
}
},
[activeId, docs],
);
/** Mark the active doc dirty (after edits). */
const markDirty = useCallback(() => {
setDocs((prev) =>
prev.map((d) => (d.id === activeId ? { ...d, dirty: true } : d)),
);
}, [activeId]);
/** Mark a doc clean (after save). */
const markClean = useCallback((id: string) => {
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, dirty: false } : d)));
}, []);
/** Reload a doc's content from server (after save). */
const reloadDoc = useCallback(
async (id: string) => {
if (!id.startsWith("module:")) return;
const name = id.slice("module:".length);
const modData = await fetchModule(name);
const { nodes: nn, edges: ne } = parseModuleData(modData);
setDocs((prev) =>
prev.map((d) => (d.id === id ? { ...d, nodes: nn, edges: ne, dirty: false } : d)),
);
if (id === activeId) {
useDiagramStore.setState({ nodes: nn, edges: ne, selectedNodeId: null });
}
},
[activeId],
);
// Periodically snapshot the canvas into the active doc so switching tabs
// always restores the very latest state (covers drag/edit ops that don't
// go through explicit save handlers).
useEffect(() => {
const t = setInterval(() => {
snapshotCurrent();
}, 1500);
return () => clearInterval(t);
}, [snapshotCurrent]);
const workspaceName =
docs.find((d) => d.id === "workspace")?.name ?? "Workspace";
return {
docs,
activeDoc,
activeId,
workspaceName,
switchTo,
openModule,
openEmptyModule,
closeDoc,
markDirty,
markClean,
reloadDoc,
renameWorkspace,
newScenario,
};
}
/* ── Helpers ──────────────────────────────────────────────────────── */
function parseModuleData(modData: unknown): {
nodes: Node<EntropykNodeData>[];
edges: Edge[];
} {
const newNodes: Node<EntropykNodeData>[] = [];
const newEdges: Edge[] = [];
const nameToId: Record<string, string> = {};
if (!modData || typeof modData !== "object") {
return { nodes: [], edges: [] };
}
const modObj = modData as {
components?: Array<Record<string, unknown>>;
instances?: Array<{
of: string;
name: string;
circuit?: number;
params?: Record<string, unknown>;
}>;
edges?: Array<{ from: string; to: string }>;
connections?: Array<{ from: string; to: string }>;
ports?: Record<string, string>;
};
const atomicComps = modObj.components ?? [];
atomicComps.forEach((c, idx) => {
const cName = String(c.name ?? `comp_${idx}`);
const cType = String(c.type ?? "Generic");
const nodeId = `mod_node_${Date.now()}_${idx}`;
nameToId[cName] = nodeId;
const { type: _, name: __, ...params } = c;
newNodes.push({
id: nodeId,
type: "entropykNode",
position: {
x: 100 + (idx % 3) * 260,
y: 100 + Math.floor(idx / 3) * 180,
},
data: {
type: cType,
kind: cType,
name: cName,
circuit: 0,
rotation: 0,
flipH: false,
flipV: false,
params: params as Record<string, number | string | boolean>,
status: "ok",
},
});
});
const moduleInsts = modObj.instances ?? [];
moduleInsts.forEach((inst, idx) => {
const instName = String(inst.name ?? `inst_${idx}`);
const ofModule = String(inst.of ?? "BaseChiller");
const nodeId = `mod_inst_${Date.now()}_${idx}`;
nameToId[instName] = nodeId;
newNodes.push({
id: nodeId,
type: "entropykNode",
position: {
x: 100 + ((idx + atomicComps.length) % 3) * 280,
y: 100 + Math.floor((idx + atomicComps.length) / 3) * 200,
},
data: {
type: "ModuleInstance",
kind: "ModuleInstance",
name: instName,
moduleName: ofModule,
circuit: inst.circuit ?? 0,
rotation: 0,
flipH: false,
flipV: false,
params: (inst.params as Record<string, number | string | boolean>) ?? {},
status: "ok",
},
});
});
const atomicEdg = modObj.edges ?? [];
atomicEdg.forEach((e, idx) => {
const [fromComp, fromPort] = e.from.split(":");
const [toComp, toPort] = e.to.split(":");
const sourceId = nameToId[fromComp];
const targetId = nameToId[toComp];
if (sourceId && targetId) {
newEdges.push({
id: `mod_edge_${Date.now()}_${idx}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
type: "entropykEdge",
});
}
});
const instConn = modObj.connections ?? [];
instConn.forEach((c, idx) => {
const fromParts = c.from.split(".");
const toParts = c.to.split(".");
const fromComp = fromParts[0];
const fromPort = fromParts.slice(1).join(".");
const toComp = toParts[0];
const toPort = toParts.slice(1).join(".");
const sourceId = nameToId[fromComp];
const targetId = nameToId[toComp];
if (sourceId && targetId) {
newEdges.push({
id: `mod_conn_${Date.now()}_${idx}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
type: "entropykEdge",
});
}
});
return { nodes: newNodes, edges: newEdges };
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useCallback, useState } from "react";
import { useDiagramStore } from "@/store/diagramStore";
import { simulate } from "@/lib/api";
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
import { computeDofLedger } from "@/lib/dofLedger";
import type { EntropykNodeData } from "@/lib/configBuilder";
import type { Node } from "@xyflow/react";
/**
* Shared simulation trigger.
*
* Lifted out of `ResultsPanel` so any surface (TopBar Solve button, keyboard
* shortcut, multi-run, results panel) can fire the same validated solve and
* surface the same validation issues. The result/error stay in the diagram
* store; this hook only owns the transient `issues` and `simulating` flags
* needed by callers.
*/
export function useScenarioSimulation() {
const nodes = useDiagramStore((s) => s.nodes);
const edges = useDiagramStore((s) => s.edges);
const fluid = useDiagramStore((s) => s.fluid);
const fluidBackend = useDiagramStore((s) => s.fluidBackend);
const solverStrategy = useDiagramStore((s) => s.solverStrategy);
const maxIterations = useDiagramStore((s) => s.maxIterations);
const tolerance = useDiagramStore((s) => s.tolerance);
const setLastConfig = useDiagramStore((s) => s.setLastConfig);
const setResult = useDiagramStore((s) => s.setResult);
const setSimulating = useDiagramStore((s) => s.setSimulating);
const simulating = useDiagramStore((s) => s.simulating);
const [issues, setIssues] = useState<string[]>([]);
const run = useCallback(async () => {
const problems = validateConfig(nodes, edges);
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
if (ledger.balance === "over-constrained") {
problems.push(
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
"Remove a FIX (quality control, extra outlet closure) or FREE an actuator.",
);
}
setIssues(problems);
if (problems.length > 0) return false;
const scenarioConfig = buildScenarioConfig(nodes, edges, {
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
});
setSimulating(true);
setLastConfig(scenarioConfig);
setResult(null, null);
try {
const resp = await simulate(scenarioConfig);
if (resp.ok && resp.result) {
setResult(resp.result, null);
if (
resp.result.dof &&
resp.result.dof.n_equations !== resp.result.dof.n_unknowns
) {
setIssues([
`Server DoF: ${resp.result.dof.n_equations} eqs vs ${resp.result.dof.n_unknowns} unk (${resp.result.dof.balance})`,
]);
}
return true;
}
setResult(null, resp.error || "Simulation failed");
return false;
} catch (e) {
setResult(null, e instanceof Error ? e.message : String(e));
return false;
} finally {
setSimulating(false);
}
}, [
nodes,
edges,
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
setLastConfig,
setResult,
setSimulating,
]);
return { run, issues, setIssues, simulating };
}

View File

@@ -0,0 +1,26 @@
// TEMPORARY AUDIT — dump the exact config the UI sends. Delete after use.
import { describe, it } from "vitest";
import { writeFileSync } from "node:fs";
import { useDiagramStore } from "../store/diagramStore";
import { buildScenarioConfig } from "./configBuilder";
import example from "../../public/examples/chiller_r134a_exv_orifice.json";
describe("dump UI config", () => {
it("writes the full emitted config to target/tmp/ui_emitted_config.json", () => {
useDiagramStore.getState().loadFromConfig(example);
const s = useDiagramStore.getState();
const config = buildScenarioConfig(s.nodes, s.edges, {
fluid: s.fluid,
fluidBackend: s.fluidBackend,
solverStrategy: s.solverStrategy,
maxIterations: s.maxIterations,
tolerance: s.tolerance,
controls: s.controls,
});
writeFileSync(
"../../target/tmp/ui_emitted_config.json",
JSON.stringify(config, null, 2),
);
console.log("strategy:", config.solver.strategy, "| components:", config.circuits[0].components.length);
});
});

View File

@@ -97,15 +97,23 @@ interface DiagramState {
tolerance: number;
controls: ControlConfig[];
snapToGrid: boolean;
/** Pinned sweep axes (format "componentName.paramKey") pushed from the properties panel. */
pinnedSweepPaths: string[];
result: SimulationResult | null;
lastConfig: unknown | null;
simulating: boolean;
simError: string | null;
// Undo / redo history
history: Array<{ nodes: EntropykNode[]; edges: Edge[]; selectedNodeId: string | null }>;
historyIndex: number;
canUndo: boolean;
canRedo: boolean;
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
onConnect: OnConnect;
addComponent: (type: string, position: { x: number; y: number }) => void;
addComponent: (type: string, position: { x: number; y: number }, extraParams?: Record<string, string | number | boolean>) => void;
/** Insert a 2-port component into an existing edge (A→B → A→comp→B). */
insertOnEdge: (
type: string,
@@ -133,11 +141,16 @@ interface DiagramState {
setTolerance: (tolerance: number) => void;
setControls: (controls: ControlConfig[]) => void;
toggleSnapToGrid: () => void;
togglePinnedSweepPath: (path: string) => void;
clearPinnedSweepPaths: () => void;
setLastConfig: (config: unknown | null) => void;
setResult: (result: SimulationResult | null, error?: string | null) => void;
setSimulating: (v: boolean) => void;
loadFromConfig: (config: unknown) => void;
clear: () => void;
undo: () => void;
redo: () => void;
recordHistory: () => void;
}
let nameCounter: Record<string, number> = {};
@@ -418,10 +431,65 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
tolerance: 1e-6,
controls: [],
snapToGrid: true,
pinnedSweepPaths: [],
result: null,
lastConfig: null,
simulating: false,
simError: null,
history: [{ nodes: [], edges: [], selectedNodeId: null }],
historyIndex: 0,
canUndo: false,
canRedo: false,
recordHistory: () => {
const { nodes, edges, selectedNodeId, history, historyIndex } = get();
const snapshot = {
nodes: structuredClone(nodes),
edges: structuredClone(edges),
selectedNodeId,
};
const nextHistory = history.slice(0, historyIndex + 1);
nextHistory.push(snapshot);
// Keep history bounded to avoid memory bloat.
if (nextHistory.length > 200) nextHistory.shift();
const nextIndex = nextHistory.length - 1;
set({
history: nextHistory,
historyIndex: nextIndex,
canUndo: nextIndex > 0,
canRedo: false,
});
},
undo: () => {
const { historyIndex, history } = get();
if (historyIndex <= 0) return;
const nextIndex = historyIndex - 1;
const snapshot = history[nextIndex];
set({
nodes: structuredClone(snapshot.nodes),
edges: structuredClone(snapshot.edges),
selectedNodeId: snapshot.selectedNodeId,
historyIndex: nextIndex,
canUndo: nextIndex > 0,
canRedo: true,
});
},
redo: () => {
const { historyIndex, history } = get();
if (historyIndex >= history.length - 1) return;
const nextIndex = historyIndex + 1;
const snapshot = history[nextIndex];
set({
nodes: structuredClone(snapshot.nodes),
edges: structuredClone(snapshot.edges),
selectedNodeId: snapshot.selectedNodeId,
historyIndex: nextIndex,
canUndo: true,
canRedo: nextIndex < history.length - 1,
});
},
onNodesChange: (changes) => {
const removed = changes.filter((c) => c.type === "remove").map((c) => c.id);
@@ -435,15 +503,18 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
onEdgesChange: (changes) =>
set({ edges: applyEdgeChanges(changes, get().edges) }),
onConnect: (connection) =>
onConnect: (connection) => {
get().recordHistory();
set({
edges: addEdge(
{ ...connection, animated: false, type: "smoothstep" },
get().edges,
),
}),
});
},
addComponent: (type, position) => {
addComponent: (type, position, extraParams) => {
get().recordHistory();
const id = crypto.randomUUID();
const pos = get().snapToGrid ? snapPosition(position) : position;
const node: EntropykNode = {
@@ -452,12 +523,12 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
position: pos,
data: {
type,
name: uniqueName(type),
name: uniqueName(type === "ModuleInstance" && extraParams?.module_name ? String(extraParams.module_name) : type),
circuit: 0,
rotation: 0,
flipH: false,
flipV: false,
params: defaultParams(type),
params: { ...defaultParams(type), ...(extraParams ?? {}) },
},
};
set({ nodes: [...get().nodes, node], selectedNodeId: id });
@@ -502,11 +573,11 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
targetCenter,
});
// Snap final position after orientation centering
if (snapToGrid) {
plan.node.position = snapPosition(plan.node.position);
}
get().recordHistory();
set({
nodes: [...nodes, plan.node as EntropykNode],
edges: [
@@ -518,33 +589,42 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
return { ok: true };
},
updateNodeParams: (id, params) =>
updateNodeParams: (id, params) => {
get().recordHistory();
set({
nodes: get().nodes.map((n) =>
n.id === id ? { ...n, data: { ...n.data, params: { ...n.data.params, ...params } } } : n,
),
}),
});
},
updateNodesParams: (patches) =>
updateNodesParams: (patches) => {
get().recordHistory();
set({
nodes: get().nodes.map((n) => {
const patch = patches.get(n.id);
if (!patch) return n;
return { ...n, data: { ...n.data, params: { ...n.data.params, ...patch } } };
}),
}),
});
},
updateNodeName: (id, name) =>
updateNodeName: (id, name) => {
get().recordHistory();
set({
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, name } } : n)),
}),
});
},
updateNodeCircuit: (id, circuit) =>
updateNodeCircuit: (id, circuit) => {
get().recordHistory();
set({
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, circuit } } : n)),
}),
});
},
rotateNode: (id, dir = 1) =>
rotateNode: (id, dir = 1) => {
get().recordHistory();
set({
nodes: get().nodes.map((n) =>
n.id === id
@@ -557,33 +637,40 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
}
: n,
),
}),
});
},
flipNodeH: (id) =>
flipNodeH: (id) => {
get().recordHistory();
set({
nodes: get().nodes.map((n) =>
n.id === id ? { ...n, data: { ...n.data, flipH: !n.data.flipH } } : n,
),
}),
});
},
flipNodeV: (id) =>
flipNodeV: (id) => {
get().recordHistory();
set({
nodes: get().nodes.map((n) =>
n.id === id ? { ...n, data: { ...n.data, flipV: !n.data.flipV } } : n,
),
}),
});
},
setSelected: (id) => {
if (get().selectedNodeId === id) return;
set({ selectedNodeId: id });
},
removeNode: (id) =>
removeNode: (id) => {
get().recordHistory();
set({
nodes: get().nodes.filter((n) => n.id !== id),
edges: get().edges.filter((e) => e.source !== id && e.target !== id),
selectedNodeId: get().selectedNodeId === id ? null : get().selectedNodeId,
}),
});
},
setFluid: (fluid) => set({ fluid }),
setFluidBackend: (backend) => set({ fluidBackend: backend }),
@@ -592,6 +679,13 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
setTolerance: (tolerance) => set({ tolerance }),
setControls: (controls) => set({ controls }),
toggleSnapToGrid: () => set({ snapToGrid: !get().snapToGrid }),
togglePinnedSweepPath: (path) =>
set((s) => ({
pinnedSweepPaths: s.pinnedSweepPaths.includes(path)
? s.pinnedSweepPaths.filter((p) => p !== path)
: [...s.pinnedSweepPaths, path],
})),
clearPinnedSweepPaths: () => set({ pinnedSweepPaths: [] }),
setLastConfig: (config) => set({ lastConfig: config }),
setResult: (result, error = null) => set({ result, simError: error }),
@@ -697,9 +791,11 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
simError: null,
selectedNodeId: null,
});
get().recordHistory();
},
clear: () =>
clear: () => {
const empty = { nodes: [], edges: [], selectedNodeId: null };
set({
nodes: [],
edges: [],
@@ -708,5 +804,10 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
lastConfig: null,
simError: null,
selectedNodeId: null,
}),
history: [empty],
historyIndex: 0,
canUndo: false,
canRedo: false,
});
},
}));