Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
224
apps/web/src/app/globals.css
Normal file
224
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,224 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
Entropyk — Modelica/Dymola diagram aesthetic.
|
||||
Light engineering canvas: white diagram sheet, dotted grid, schematic
|
||||
component icons with square fluid connectors and orthogonal connections.
|
||||
Chrome is a quiet CAD-tool grey so the white diagram sheet is the focus.
|
||||
*/
|
||||
:root {
|
||||
--sheet: #ffffff; /* diagram canvas */
|
||||
--sheet-grid: #e7ecf2; /* grid dots */
|
||||
--chrome: #eef1f5; /* app chrome / panels */
|
||||
--chrome-2: #f6f8fb; /* raised inputs */
|
||||
--panel: #ffffff;
|
||||
|
||||
--line: #d6dde6; /* hairline borders */
|
||||
--line-strong: #b9c3cf;
|
||||
|
||||
--ink: #1a2330; /* primary text */
|
||||
--ink-dim: #5d6b7c; /* secondary */
|
||||
--ink-faint: #93a0b0; /* tertiary */
|
||||
|
||||
--connector: #1565c0; /* legacy default (water) */
|
||||
--wire: #36475a; /* unknown medium */
|
||||
--wire-hi: #1565c0;
|
||||
|
||||
/* TIL / Modelica HVAC medium colours */
|
||||
--media-refrigerant: #2e7d32;
|
||||
--media-water: #1565c0;
|
||||
--media-air: #f9a825;
|
||||
|
||||
--accent: #1b6fe0; /* primary action */
|
||||
--hot: #e23b2e; /* condenser / high pressure */
|
||||
--cold: #1f86e0; /* evaporator / low pressure */
|
||||
--liquid: #e0902b; /* liquid line */
|
||||
--ok: #1f9d57;
|
||||
--warn: #c9821a;
|
||||
|
||||
/* Top command bar — dark CAD chrome */
|
||||
--bar: #1c2634;
|
||||
--bar-ink: #c4cede;
|
||||
--bar-accent: #5eb0ff;
|
||||
|
||||
--font-display: var(--font-display), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono), ui-monospace, "SFMono-Regular", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background: var(--chrome);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--chrome);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── React Flow — Modelica diagram sheet ────────────────────── */
|
||||
.react-flow {
|
||||
background: var(--sheet);
|
||||
}
|
||||
.react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Square fluid connectors (Modelica convention); fill set per medium in JS */
|
||||
.react-flow__handle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 0;
|
||||
background: var(--connector);
|
||||
border: 1px solid rgba(15, 28, 45, 0.55);
|
||||
transition: transform 0.1s ease, filter 0.1s ease;
|
||||
}
|
||||
.react-flow__handle:hover,
|
||||
.react-flow__handle.connectingfrom,
|
||||
.react-flow__handle.connectingto {
|
||||
transform: scale(1.25);
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
.react-flow__handle-left {
|
||||
left: -6px;
|
||||
}
|
||||
.react-flow__handle-right {
|
||||
right: -6px;
|
||||
}
|
||||
.react-flow__handle-top {
|
||||
top: -6px;
|
||||
}
|
||||
.react-flow__handle-bottom {
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: var(--wire);
|
||||
stroke-width: 1.75;
|
||||
}
|
||||
.react-flow__edge.selected .react-flow__edge-path,
|
||||
.react-flow__edge:hover .react-flow__edge-path {
|
||||
stroke: var(--wire-hi);
|
||||
stroke-width: 2.25;
|
||||
}
|
||||
.react-flow__connection-path {
|
||||
stroke: var(--connector);
|
||||
stroke-width: 1.75;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(20, 40, 70, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.react-flow__controls-button {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--ink-dim);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.react-flow__controls-button:hover {
|
||||
background: var(--chrome-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.react-flow__controls-button svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.react-flow__node.selected {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ── Bibliothèque — accordion + stagger reveal ──────────────── */
|
||||
.palette-cat-body {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.palette-cat-body.is-open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.palette-cat-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@keyframes palette-item-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.palette-cat-body.is-open .palette-item {
|
||||
animation: palette-item-in 200ms ease-out both;
|
||||
animation-delay: calc(var(--i, 0) * 28ms);
|
||||
}
|
||||
|
||||
.palette-cat-toggle {
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.ek-node {
|
||||
transition: box-shadow 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.palette-cat-body {
|
||||
transition: none;
|
||||
}
|
||||
.palette-cat-body.is-open .palette-item {
|
||||
animation: none;
|
||||
}
|
||||
.ek-node {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
32
apps/web/src/app/layout.tsx
Normal file
32
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Space_Grotesk, IBM_Plex_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const display = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-display",
|
||||
});
|
||||
|
||||
const mono = IBM_Plex_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
variable: "--font-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Entropyk — Cycle Workbench",
|
||||
description: "Design and solve refrigeration, heat-pump and HVAC cycles on a live schematic.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${display.variable} ${mono.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/app/page.tsx
Normal file
55
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import ComponentPalette from "@/components/palette/ComponentPalette";
|
||||
import PropertiesPanel from "@/components/panels/PropertiesPanel";
|
||||
import ResultsPanel from "@/components/panels/ResultsPanel";
|
||||
import DofStatusBar from "@/components/DofStatusBar";
|
||||
import Toolbar from "@/components/Toolbar";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
|
||||
// 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();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<Toolbar />
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ComponentPalette />
|
||||
|
||||
<div className="relative flex-1">
|
||||
<Canvas />
|
||||
{nodes.length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
{/* Live DoF indicator: equations vs unknowns (fix/free discipline) */}
|
||||
<DofStatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
apps/web/src/components/DofStatusBar.tsx
Normal file
230
apps/web/src/components/DofStatusBar.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
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 {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronUp,
|
||||
Equal,
|
||||
Lightbulb,
|
||||
MinusCircle,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
function balanceStyle(balance: DofBalance): {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
Icon: typeof CheckCircle2;
|
||||
} {
|
||||
switch (balance) {
|
||||
case "balanced":
|
||||
return {
|
||||
bg: "bg-emerald-50",
|
||||
fg: "text-emerald-800",
|
||||
border: "border-emerald-200",
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
case "over-constrained":
|
||||
return {
|
||||
bg: "bg-red-50",
|
||||
fg: "text-red-800",
|
||||
border: "border-red-200",
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case "under-constrained":
|
||||
return {
|
||||
bg: "bg-amber-50",
|
||||
fg: "text-amber-900",
|
||||
border: "border-amber-200",
|
||||
Icon: MinusCircle,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-[var(--chrome-2)]",
|
||||
fg: "text-[var(--ink-dim)]",
|
||||
border: "border-[var(--line)]",
|
||||
Icon: Equal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function tipIcon(sev: CoachSeverity) {
|
||||
switch (sev) {
|
||||
case "ok":
|
||||
return <CheckCircle2 size={12} className="mt-0.5 shrink-0 text-emerald-600" />;
|
||||
case "block":
|
||||
return <AlertTriangle size={12} className="mt-0.5 shrink-0 text-red-600" />;
|
||||
case "warn":
|
||||
return <AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-600" />;
|
||||
default:
|
||||
return <Lightbulb size={12} className="mt-0.5 shrink-0 text-[var(--accent)]" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DofStatusBar() {
|
||||
const nodes = useDiagramStore((s) => s.nodes) as Node<EntropykNodeData>[];
|
||||
const edges = useDiagramStore((s) => s.edges);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"guide" | "ledger">("guide");
|
||||
|
||||
const coach = useMemo(() => buildDofCoach(nodes, edges), [nodes, edges]);
|
||||
const { ledger } = coach;
|
||||
const style = balanceStyle(ledger.balance);
|
||||
const Icon = style.Icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t ${style.border} ${style.bg} text-[11px] ${style.fg}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-3 py-1.5 text-left transition-colors hover:brightness-[0.98]"
|
||||
title="Guide de balance DoF — cliquer pour les étapes"
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="font-semibold tracking-tight">
|
||||
{ledger.balance === "balanced"
|
||||
? "Balance OK"
|
||||
: ledger.balance === "over-constrained"
|
||||
? "Trop de Fixed"
|
||||
: ledger.balance === "under-constrained"
|
||||
? "Manque de Fixed"
|
||||
: "Balance"}
|
||||
</span>
|
||||
|
||||
<span className="mono flex items-center gap-1.5 rounded border border-black/5 bg-white/70 px-2 py-0.5 font-medium">
|
||||
<span title="Équations">{ledger.nEquations}</span>
|
||||
<span className="text-[var(--ink-faint)]">
|
||||
{ledger.balance === "balanced"
|
||||
? "="
|
||||
: ledger.balance === "over-constrained"
|
||||
? ">"
|
||||
: "<"}
|
||||
</span>
|
||||
<span title="Inconnues">{ledger.nUnknowns}</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden min-w-0 flex-1 truncate sm:inline text-[10px] opacity-90">
|
||||
{coach.headline}
|
||||
</span>
|
||||
|
||||
<span className="mono ml-auto hidden items-center gap-2 text-[10px] text-[var(--ink-faint)] md:flex">
|
||||
<Sparkles size={11} className="text-[var(--accent)]" />
|
||||
<span>{coach.tips.length} tip{coach.tips.length > 1 ? "s" : ""}</span>
|
||||
<span>·</span>
|
||||
<span>{ledger.nEdges} edges</span>
|
||||
<span>·</span>
|
||||
<span>{ledger.nBranches} ṁ</span>
|
||||
</span>
|
||||
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className={`ml-1 shrink-0 transition-transform ${open ? "" : "rotate-180"}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="max-h-64 overflow-y-auto border-t border-black/5 bg-white/85 px-3 py-2 text-[10px] leading-relaxed text-[var(--ink-dim)]">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("guide")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
tab === "guide"
|
||||
? "bg-[var(--ink)] text-white"
|
||||
: "bg-[var(--chrome-2)] text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Guide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("ledger")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
tab === "ledger"
|
||||
? "bg-[var(--ink)] text-white"
|
||||
: "bg-[var(--chrome-2)] text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Ledger
|
||||
</button>
|
||||
<span className="ml-auto text-[9px] text-[var(--ink-faint)]">
|
||||
Estimation UI — le CLI valide le ledger Rust exact
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tab === "guide" ? (
|
||||
<>
|
||||
<p className="mb-2 text-[10px] text-[var(--ink-faint)]">
|
||||
Chaque <strong className="text-[var(--ink)]">Fixed</strong> doit libérer une
|
||||
inconnue ailleurs (Free P, actionneur, pression émergente…). Suit les étapes dans
|
||||
l’ordre.
|
||||
</p>
|
||||
<ol className="space-y-1.5">
|
||||
{coach.tips.map((tip, i) => (
|
||||
<li
|
||||
key={tip.id}
|
||||
className="flex gap-2 rounded border border-[var(--line)] bg-white px-2 py-1.5"
|
||||
>
|
||||
<span className="mono mt-0.5 w-4 shrink-0 text-[9px] font-bold text-[var(--ink-faint)]">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{tipIcon(tip.severity)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[var(--ink)]">{tip.title}</div>
|
||||
<div className="text-[var(--ink-dim)]">{tip.action}</div>
|
||||
{tip.focus && (
|
||||
<div className="mono mt-0.5 text-[9px] text-[var(--accent)]">
|
||||
→ {tip.focus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ledger.diagnostics.length > 0 && (
|
||||
<ul className="mb-2 space-y-0.5">
|
||||
{ledger.diagnostics.slice(0, 12).map((d, i) => (
|
||||
<li key={i} className="flex gap-1.5">
|
||||
<span className="text-amber-600">!</span>
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ledger.components.map((c) => (
|
||||
<div
|
||||
key={`${c.name}-${c.type}`}
|
||||
className="rounded border border-[var(--line)] bg-white px-2 py-1"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="mono font-semibold text-[var(--ink)]">{c.name}</span>
|
||||
<span className="mono text-[var(--ink-faint)]">{c.nEquations} eq</span>
|
||||
</div>
|
||||
<div className="mono truncate text-[9px] text-[var(--ink-faint)]">{c.type}</div>
|
||||
<div className="mt-0.5 truncate text-[9px] text-[var(--ink-dim)]">
|
||||
{c.roles.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
apps/web/src/components/Toolbar.tsx
Normal file
348
apps/web/src/components/Toolbar.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
"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 air–water" },
|
||||
{ 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 d’abord 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 l’exemple">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
399
apps/web/src/components/canvas/Canvas.tsx
Normal file
399
apps/web/src/components/canvas/Canvas.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
ReactFlow,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
type ReactFlowInstance,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
type OnConnect,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCw } from "lucide-react";
|
||||
|
||||
import { GRID_SIZE, useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
|
||||
import { portRole } from "@/lib/componentMeta";
|
||||
import {
|
||||
MEDIA_COLOR,
|
||||
MEDIA_LABEL,
|
||||
mediaColor,
|
||||
mediaForEdge,
|
||||
type MediaKind,
|
||||
} from "@/lib/mediaStyle";
|
||||
import {
|
||||
findNearestEdge,
|
||||
isPipeType,
|
||||
pipeMediaKind,
|
||||
} from "@/lib/edgeInsert";
|
||||
import { defaultParams } from "@/lib/componentMeta";
|
||||
import EntropykNode from "./EntropykNode";
|
||||
import NodeContextMenu, { type NodeContextMenuState } from "./NodeContextMenu";
|
||||
|
||||
const nodeTypes = { entropykNode: EntropykNode };
|
||||
|
||||
/** Slightly darken / lighten a hex colour for HP vs LP within the same medium. */
|
||||
function shadeHex(hex: string, amount: number): string {
|
||||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!m) return hex;
|
||||
const ch = (i: number) =>
|
||||
Math.max(0, Math.min(255, parseInt(m[i], 16) + amount))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${ch(1)}${ch(2)}${ch(3)}`;
|
||||
}
|
||||
|
||||
export default function Canvas() {
|
||||
const rfInstance = useRef<ReactFlowInstance<Node<EntropykNodeData>, Edge> | null>(null);
|
||||
const [ctxMenu, setCtxMenu] = useState<NodeContextMenuState | null>(null);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
result,
|
||||
snapToGrid,
|
||||
selectedNodeId,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
addComponent,
|
||||
insertOnEdge,
|
||||
setSelected,
|
||||
rotateNode,
|
||||
flipNodeH,
|
||||
flipNodeV,
|
||||
removeNode,
|
||||
} = useDiagramStore();
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((n) => n.id === selectedNodeId) ?? null,
|
||||
[nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
const onInit = useCallback((instance: ReactFlowInstance<Node<EntropykNodeData>, Edge>) => {
|
||||
rfInstance.current = instance;
|
||||
instance.fitView({ padding: 0.25 });
|
||||
}, []);
|
||||
|
||||
// Modelica/Dymola: R = rotate, H = flip horizontal, V = flip vertical.
|
||||
// Prefer canvas focus — skip only when typing text in a field.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const typingText =
|
||||
!!target &&
|
||||
(tag === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
(tag === "INPUT" &&
|
||||
(target as HTMLInputElement).type !== "checkbox" &&
|
||||
(target as HTMLInputElement).type !== "radio" &&
|
||||
(target as HTMLInputElement).type !== "button"));
|
||||
// Allow R/H/V from number fields only with Alt — otherwise canvas after click.
|
||||
if (typingText && !e.altKey) return;
|
||||
if (!selectedNodeId) return;
|
||||
if (e.key === "r" || e.key === "R") {
|
||||
e.preventDefault();
|
||||
rotateNode(selectedNodeId, e.shiftKey ? -1 : 1);
|
||||
} else if (e.key === "h" || e.key === "H") {
|
||||
e.preventDefault();
|
||||
flipNodeH(selectedNodeId);
|
||||
} else if (e.key === "v" || e.key === "V") {
|
||||
e.preventDefault();
|
||||
flipNodeV(selectedNodeId);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [selectedNodeId, rotateNode, flipNodeH, flipNodeV]);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const type = e.dataTransfer.getData("application/entropyk-type");
|
||||
if (!type || !rfInstance.current) return;
|
||||
const position = rfInstance.current.screenToFlowPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
|
||||
// Pipe / duct dropped on a wire → splice into the circuit (Modelica-style).
|
||||
if (isPipeType(type)) {
|
||||
const prefer = pipeMediaKind(type, defaultParams(type));
|
||||
const hit = findNearestEdge(position, nodes, edges, {
|
||||
maxDistance: 64,
|
||||
preferMedia: prefer,
|
||||
});
|
||||
if (hit) {
|
||||
const result = insertOnEdge(type, hit.midpoint, hit.edge.id);
|
||||
if (result.ok) return;
|
||||
// Media mismatch: fall through to free placement.
|
||||
}
|
||||
}
|
||||
|
||||
addComponent(type, position);
|
||||
},
|
||||
[addComponent, insertOnEdge, nodes, edges],
|
||||
);
|
||||
|
||||
const isValidConnection = useCallback((c: Connection | Edge) => {
|
||||
if (!c.source || !c.target || c.source === c.target) return false;
|
||||
const srcOk = !c.sourceHandle || portRole(c.sourceHandle) === "source";
|
||||
const tgtOk = !c.targetHandle || portRole(c.targetHandle) === "target";
|
||||
return srcOk && tgtOk;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Colour wires by Modelica/TIL medium (refrigerant / water / air).
|
||||
* After a solve, keep the medium hue and only shade HP vs LP slightly;
|
||||
* pressure is shown as a label.
|
||||
*/
|
||||
const styledEdges = useMemo<Edge[]>(() => {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const nameById = new Map(nodes.map((n) => [n.id, n.data.name]));
|
||||
|
||||
const pByPair = new Map<string, number>();
|
||||
let pMin = Infinity;
|
||||
let pMax = -Infinity;
|
||||
if (result?.state) {
|
||||
for (const s of result.state) {
|
||||
if (s.source && s.target && typeof s.pressure_pa === "number") {
|
||||
pByPair.set(`${s.source}→${s.target}`, s.pressure_pa);
|
||||
pMin = Math.min(pMin, s.pressure_pa);
|
||||
pMax = Math.max(pMax, s.pressure_pa);
|
||||
}
|
||||
}
|
||||
}
|
||||
const mid = (pMin + pMax) / 2;
|
||||
const solved = pByPair.size > 0 && pMax > pMin;
|
||||
|
||||
return edges.map((e) => {
|
||||
const kind = mediaForEdge(
|
||||
nodeById.get(e.source),
|
||||
e.sourceHandle,
|
||||
nodeById.get(e.target),
|
||||
e.targetHandle,
|
||||
);
|
||||
let color = mediaColor(kind);
|
||||
let label: string | undefined;
|
||||
if (solved) {
|
||||
const p = pByPair.get(`${nameById.get(e.source)}→${nameById.get(e.target)}`);
|
||||
if (p !== undefined) {
|
||||
// Within one medium: darker = high pressure, lighter = low pressure.
|
||||
color = p >= mid ? shadeHex(color, -28) : shadeHex(color, 36);
|
||||
label = `${(p / 1e5).toFixed(1)} bar`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...e,
|
||||
type: "smoothstep",
|
||||
animated: solved,
|
||||
style: { stroke: color, strokeWidth: solved ? 2.4 : 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 },
|
||||
label,
|
||||
labelStyle: { fill: color, fontSize: 10, fontFamily: "var(--font-mono)", fontWeight: 600 },
|
||||
labelBgStyle: { fill: "#ffffff", fillOpacity: 0.92 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
labelBgBorderRadius: 3,
|
||||
data: { ...(e.data as object), media: kind },
|
||||
};
|
||||
});
|
||||
}, [edges, nodes, result]);
|
||||
|
||||
const onNodeContextMenu = useCallback(
|
||||
(e: React.MouseEvent, node: Node<EntropykNodeData>) => {
|
||||
e.preventDefault();
|
||||
setSelected(node.id);
|
||||
setCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
nodeId: node.id,
|
||||
nodeName: node.data.name,
|
||||
});
|
||||
},
|
||||
[setSelected],
|
||||
);
|
||||
|
||||
// Keep RF `selected` in sync, preserving node identity when the flag is unchanged
|
||||
// (`undefined` and `false` both mean unselected — avoid remapping every render).
|
||||
const displayNodes = useMemo(
|
||||
() =>
|
||||
nodes.map((n) => {
|
||||
const selected = n.id === selectedNodeId;
|
||||
if (!!n.selected === selected) return n;
|
||||
return { ...n, selected };
|
||||
}),
|
||||
[nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={styledEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange as OnNodesChange<Node<EntropykNodeData>>}
|
||||
onEdgesChange={onEdgesChange as OnEdgesChange<Edge>}
|
||||
onConnect={onConnect as OnConnect}
|
||||
onInit={onInit}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeClick={(_, node) => {
|
||||
setCtxMenu(null);
|
||||
setSelected(node.id);
|
||||
}}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onPaneClick={() => {
|
||||
setCtxMenu(null);
|
||||
setSelected(null);
|
||||
}}
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
defaultEdgeOptions={{ type: "smoothstep" }}
|
||||
connectionRadius={34}
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={[GRID_SIZE, GRID_SIZE]}
|
||||
minZoom={0.2}
|
||||
maxZoom={2.5}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
{/* Dymola-style diagram sheet: fine grid + coarse major lines. */}
|
||||
<Background
|
||||
id="minor"
|
||||
variant={BackgroundVariant.Lines}
|
||||
gap={GRID_SIZE}
|
||||
lineWidth={0.5}
|
||||
color="#eef2f7"
|
||||
/>
|
||||
<Background
|
||||
id="major"
|
||||
variant={BackgroundVariant.Lines}
|
||||
gap={GRID_SIZE * 5}
|
||||
lineWidth={0.8}
|
||||
color="#dde5ee"
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
<MediaLegend />
|
||||
{selectedNode && (
|
||||
<OrientationBar
|
||||
name={selectedNode.data.name}
|
||||
rotation={selectedNode.data.rotation ?? 0}
|
||||
flipH={!!selectedNode.data.flipH}
|
||||
flipV={!!selectedNode.data.flipV}
|
||||
onRotate={() => rotateNode(selectedNode.id, 1)}
|
||||
onFlipH={() => flipNodeH(selectedNode.id)}
|
||||
onFlipV={() => flipNodeV(selectedNode.id)}
|
||||
/>
|
||||
)}
|
||||
<NodeContextMenu
|
||||
menu={ctxMenu}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
onRotate={(dir) => ctxMenu && rotateNode(ctxMenu.nodeId, dir)}
|
||||
onFlipH={() => ctxMenu && flipNodeH(ctxMenu.nodeId)}
|
||||
onFlipV={() => ctxMenu && flipNodeV(ctxMenu.nodeId)}
|
||||
onDelete={() => ctxMenu && removeNode(ctxMenu.nodeId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrientationBar({
|
||||
name,
|
||||
rotation,
|
||||
flipH,
|
||||
flipV,
|
||||
onRotate,
|
||||
onFlipH,
|
||||
onFlipV,
|
||||
}: {
|
||||
name: string;
|
||||
rotation: number;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
onRotate: () => void;
|
||||
onFlipH: () => void;
|
||||
onFlipV: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute left-1/2 top-3 z-10 flex -translate-x-1/2 items-center gap-1 rounded-md border border-[var(--line)] bg-white/95 px-2 py-1 shadow-sm">
|
||||
<span className="mono mr-1 max-w-[100px] truncate text-[10px] text-[var(--ink-faint)]">
|
||||
{name} · {rotation}°
|
||||
{flipH ? " · ↔" : ""}
|
||||
{flipV ? " · ↕" : ""}
|
||||
</span>
|
||||
<button type="button" className="orient-btn" title="Rotate 90° (R)" onClick={onRotate}>
|
||||
<RotateCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orient-btn"
|
||||
data-on={flipH}
|
||||
title="Flip horizontal (H)"
|
||||
onClick={onFlipH}
|
||||
>
|
||||
<FlipHorizontal2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orient-btn"
|
||||
data-on={flipV}
|
||||
title="Flip vertical (V)"
|
||||
onClick={onFlipV}
|
||||
>
|
||||
<FlipVertical2 size={13} />
|
||||
</button>
|
||||
<style jsx>{`
|
||||
.orient-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.orient-btn:hover {
|
||||
background: var(--chrome-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
.orient-btn[data-on="true"] {
|
||||
background: rgba(27, 111, 224, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaLegend() {
|
||||
const items: MediaKind[] = ["refrigerant", "water", "air"];
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-3 left-3 z-10 flex items-center gap-3 rounded-md border border-[var(--line)] bg-white/95 px-2.5 py-1.5 shadow-sm"
|
||||
title="Couleurs milieu — convention TIL / Modelica HVAC"
|
||||
>
|
||||
<span className="eyebrow">Milieux</span>
|
||||
{items.map((k) => (
|
||||
<span key={k} className="flex items-center gap-1.5 text-[10px] text-[var(--ink-dim)]">
|
||||
<span
|
||||
className="inline-block h-0.5 w-4 rounded-full"
|
||||
style={{ background: MEDIA_COLOR[k] }}
|
||||
/>
|
||||
{MEDIA_LABEL[k]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/canvas/ComponentIcon.test.tsx
Normal file
26
apps/web/src/components/canvas/ComponentIcon.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ComponentIcon } from "./ComponentIcon";
|
||||
import { COMPONENTS } from "@/lib/componentMeta";
|
||||
|
||||
describe("ComponentIcon", () => {
|
||||
it("renders an svg glyph for every catalogue component", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
const { container, unmount } = render(<ComponentIcon type={c.type} color={c.color} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg, `no svg for ${c.type}`).not.toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the dashed placeholder for an unknown type", () => {
|
||||
const { container } = render(<ComponentIcon type="TotallyUnknown" color="#123456" />);
|
||||
const dashed = container.querySelector("[stroke-dasharray]");
|
||||
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");
|
||||
});
|
||||
});
|
||||
323
apps/web/src/components/canvas/ComponentIcon.tsx
Normal file
323
apps/web/src/components/canvas/ComponentIcon.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Technical-drawing glyphs (ISO / P&ID linework).
|
||||
*
|
||||
* Design rules — one language for every part:
|
||||
* - structure drawn in a single ink, one stroke weight, butt caps
|
||||
* - no colour blobs: liquid = section hatch, valves/pumps = open triangles
|
||||
* - the `color` prop is a functional accent only (duty arrows, fluid detail)
|
||||
* - each HX family has a distinct, standards-inspired silhouette
|
||||
*/
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { hxGlyphKey } from "@/lib/hxFamily";
|
||||
|
||||
type IconProps = { color: string };
|
||||
|
||||
const INK = "#2f3e52";
|
||||
const INK_FAINT = "rgba(47,62,82,0.34)";
|
||||
|
||||
function Frame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="none"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const line = (w = 2) => ({
|
||||
stroke: INK,
|
||||
strokeWidth: w,
|
||||
strokeLinecap: "butt" as const,
|
||||
strokeLinejoin: "miter" as const,
|
||||
});
|
||||
|
||||
const accent = (c: string, w = 2) => ({
|
||||
stroke: c,
|
||||
strokeWidth: w,
|
||||
strokeLinecap: "butt" as const,
|
||||
strokeLinejoin: "miter" as const,
|
||||
});
|
||||
|
||||
/** Section hatch — the engineering way to say "liquid here". */
|
||||
function Hatch({ xs, yBottom, yTop, run = 9 }: { xs: number[]; yBottom: number; yTop: number; run?: number }) {
|
||||
return (
|
||||
<g stroke={INK} strokeWidth={1}>
|
||||
{xs.map((x) => (
|
||||
<line key={x} x1={x} y1={yBottom} x2={x + run} y2={yTop} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Up arrow (heat duty). */
|
||||
function DutyArrow({ x, y1, y2, c }: { x: number; y1: number; y2: number; c: string }) {
|
||||
const head = y2 < y1 ? 4 : -4;
|
||||
return (
|
||||
<g {...accent(c, 1.8)}>
|
||||
<line x1={x} y1={y1} x2={x} y2={y2} />
|
||||
<path d={`M${x - 3.2} ${y2 + head} L${x} ${y2} L${x + 3.2} ${y2 + head}`} fill="none" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPHS: Record<string, (c: string) => ReactElement> = {
|
||||
// ── Compressor — ISO circle + converging flow lines ────────
|
||||
compressor: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="34" {...line(2.2)} />
|
||||
<path d="M28 30 L74 44 M28 70 L74 56" {...line(2)} />
|
||||
<path d="M74 44 V56" {...accent(c, 2.2)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Shell & tube condenser — vessel, tubesheets, heat out ──
|
||||
hx_shell_cond: (c) => (
|
||||
<Frame>
|
||||
<rect x="8" y="34" width="84" height="36" rx="18" {...line(2.2)} />
|
||||
<line x1="22" y1="36" x2="22" y2="68" {...line(1.4)} />
|
||||
<line x1="78" y1="36" x2="78" y2="68" {...line(1.4)} />
|
||||
<path d="M22 44 H78 M22 52 H78 M22 60 H78" {...line(1.2)} />
|
||||
<DutyArrow x={35} y1={28} y2={14} c={c} />
|
||||
<DutyArrow x={50} y1={28} y2={12} c={c} />
|
||||
<DutyArrow x={65} y1={28} y2={14} c={c} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Shell & tube evaporator — heat in from below ───────────
|
||||
hx_shell_evap: (c) => (
|
||||
<Frame>
|
||||
<rect x="8" y="30" width="84" height="36" rx="18" {...line(2.2)} />
|
||||
<line x1="22" y1="32" x2="22" y2="64" {...line(1.4)} />
|
||||
<line x1="78" y1="32" x2="78" y2="64" {...line(1.4)} />
|
||||
<path d="M22 40 H78 M22 48 H78 M22 56 H78" {...line(1.2)} />
|
||||
<DutyArrow x={35} y1={88} y2={74} c={c} />
|
||||
<DutyArrow x={50} y1={90} y2={72} c={c} />
|
||||
<DutyArrow x={65} y1={88} y2={74} c={c} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Flooded evaporator — horizontal drum, level + hatch ────
|
||||
hx_flooded: (c) => (
|
||||
<Frame>
|
||||
{/* vapour outlet stub */}
|
||||
<path d="M46 20 V28 M54 20 V28" {...line(1.4)} />
|
||||
<rect x="10" y="28" width="80" height="44" rx="22" {...line(2.2)} />
|
||||
{/* liquid level */}
|
||||
<line x1="13" y1="42" x2="87" y2="42" {...accent(c, 1.6)} />
|
||||
{/* section hatch = liquid */}
|
||||
<Hatch xs={[20, 27, 34, 41, 48, 55, 62, 69, 76]} yBottom={66} yTop={46} />
|
||||
{/* submerged tube nest */}
|
||||
{[26, 38, 50, 62, 74].map((x) => (
|
||||
<circle key={`r1-${x}`} cx={x} cy="52" r="4" fill="#fff" {...line(1.3)} />
|
||||
))}
|
||||
{[32, 44, 56, 68].map((x) => (
|
||||
<circle key={`r2-${x}`} cx={x} cy="61" r="4" fill="#fff" {...line(1.3)} />
|
||||
))}
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Brazed plate — plate pack diagonals + 4 ports ──────────
|
||||
hx_plate: (c) => (
|
||||
<Frame>
|
||||
<rect x="30" y="10" width="40" height="80" {...line(2.2)} />
|
||||
<line x1="35" y1="10" x2="35" y2="90" {...line(1.2)} />
|
||||
<line x1="65" y1="10" x2="65" y2="90" {...line(1.2)} />
|
||||
{[32, 41, 50, 59, 68, 77, 86].map((y) => (
|
||||
<line key={y} x1="35" y1={y} x2="65" y2={y - 18} {...line(1.1)} />
|
||||
))}
|
||||
<circle cx="41" cy="19" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
<circle cx="59" cy="19" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
<circle cx="41" cy="81" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
<circle cx="59" cy="81" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── DX coil — serpentine through fin pack ──────────────────
|
||||
hx_dx: (c) => (
|
||||
<Frame>
|
||||
<rect x="14" y="26" width="72" height="50" {...line(2.2)} />
|
||||
{[22, 30, 38, 46, 54, 62, 70, 78].map((x) => (
|
||||
<line key={x} x1={x} y1="26" x2={x} y2="76" stroke={INK_FAINT} strokeWidth={1} />
|
||||
))}
|
||||
<path
|
||||
d="M6 36 H72 a8 8 0 0 1 0 15 H28 a8 8 0 0 0 0 15 H94"
|
||||
{...accent(c, 2.2)}
|
||||
fill="none"
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Fin coil — radiator zigzag + air through ───────────────
|
||||
hx_coil: (c) => (
|
||||
<Frame>
|
||||
<rect x="12" y="28" width="76" height="48" {...line(2.2)} />
|
||||
<path
|
||||
d="M12 52 L21 36 L30 68 L39 36 L48 68 L57 36 L66 68 L75 36 L84 68 L88 52"
|
||||
{...line(1.6)}
|
||||
fill="none"
|
||||
/>
|
||||
<DutyArrow x={30} y1={92} y2={82} c={c} />
|
||||
<DutyArrow x={50} y1={92} y2={82} c={c} />
|
||||
<DutyArrow x={70} y1={92} y2={82} c={c} />
|
||||
<DutyArrow x={30} y1={22} y2={12} c={c} />
|
||||
<DutyArrow x={50} y1={22} y2={12} c={c} />
|
||||
<DutyArrow x={70} y1={22} y2={12} c={c} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── MCHX — headers + flat multiport tubes ──────────────────
|
||||
hx_mchx: (c) => (
|
||||
<Frame>
|
||||
<rect x="12" y="26" width="76" height="50" {...line(2.2)} />
|
||||
<line x1="21" y1="26" x2="21" y2="76" {...line(1.4)} />
|
||||
<line x1="79" y1="26" x2="79" y2="76" {...line(1.4)} />
|
||||
{[33, 40, 47, 54, 61, 68].map((y) => (
|
||||
<line key={y} x1="21" y1={y} x2="79" y2={y} {...line(1.1)} />
|
||||
))}
|
||||
<path d="M6 32 H12 M88 32 H94" {...accent(c, 2)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Generic HX — ISO circle with wavy element ──────────────
|
||||
hx: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="32" {...line(2.2)} />
|
||||
<path d="M18 50 C28 36 39 64 50 50 S 72 36 82 50" {...accent(c, 2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
condenser: (c) => GLYPHS.hx_shell_cond(c),
|
||||
evaporator: (c) => GLYPHS.hx_dx(c),
|
||||
|
||||
// ── Valve — ISO bowtie + actuator stem ─────────────────────
|
||||
valve: (c) => (
|
||||
<Frame>
|
||||
<path d="M12 32 L12 68 L50 50 Z" {...line(2)} fill="none" />
|
||||
<path d="M88 32 L88 68 L50 50 Z" {...line(2)} fill="none" />
|
||||
<line x1="50" y1="50" x2="50" y2="28" {...line(1.6)} />
|
||||
<line x1="38" y1="28" x2="62" y2="28" {...accent(c, 2.2)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Pump — circle + filled flow triangle ───────────────────
|
||||
pump: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="32" {...line(2.2)} />
|
||||
<path d="M38 32 L70 50 L38 68 Z" fill={c} stroke="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Fan — circle + two blades ──────────────────────────────
|
||||
fan: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="32" {...line(2.2)} />
|
||||
<path d="M50 50 C36 42 38 24 56 28 M50 50 C64 58 62 76 44 72" {...accent(c, 2.2)} fill="none" />
|
||||
<circle cx="50" cy="50" r="3" fill={INK} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Pipe — double line + flanges ───────────────────────────
|
||||
pipe: (c) => (
|
||||
<Frame>
|
||||
<path d="M6 44 H94 M6 56 H94" {...line(1.8)} />
|
||||
<path d="M14 38 V62 M86 38 V62" {...line(1.6)} />
|
||||
<path d="M42 50 H58 M53 45 L58 50 L53 55" {...accent(c, 1.6)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Separator drum — vertical vessel, level + hatch ────────
|
||||
drum: (c) => (
|
||||
<Frame>
|
||||
<rect x="30" y="8" width="40" height="84" rx="20" {...line(2.2)} />
|
||||
<line x1="32" y1="56" x2="68" y2="56" {...accent(c, 1.6)} />
|
||||
<Hatch xs={[36, 44, 52]} yBottom={82} yTop={62} run={8} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
splitter: (c) => (
|
||||
<Frame>
|
||||
<path d="M8 50 H42 M42 50 L88 26 M42 50 L88 74" {...line(2.2)} />
|
||||
<circle cx="42" cy="50" r="4" fill={INK} />
|
||||
<path d="M80 30 L88 26 L86 35" {...accent(c, 1.6)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
merger: (c) => (
|
||||
<Frame>
|
||||
<path d="M12 26 L58 50 M12 74 L58 50 M58 50 H92" {...line(2.2)} />
|
||||
<circle cx="58" cy="50" r="4" fill={INK} />
|
||||
<path d="M84 45 L92 50 L84 55" {...accent(c, 1.6)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Boundary source — circle + hatched ground + arrow ──────
|
||||
source: (c) => (
|
||||
<Frame>
|
||||
<circle cx="40" cy="50" r="24" {...line(2.2)} />
|
||||
<Hatch xs={[26, 33, 40]} yBottom={62} yTop={40} run={8} />
|
||||
<path d="M66 50 H92 M84 42 L92 50 L84 58" {...accent(c, 2.2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
sink: (c) => (
|
||||
<Frame>
|
||||
<circle cx="60" cy="50" r="24" {...line(2.2)} />
|
||||
<Hatch xs={[46, 53, 60]} yBottom={62} yTop={40} run={8} />
|
||||
<path d="M8 50 H34 M26 42 L34 50 L26 58" {...accent(c, 2.2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
placeholder: (c) => (
|
||||
<Frame>
|
||||
<rect
|
||||
x="14"
|
||||
y="24"
|
||||
width="72"
|
||||
height="52"
|
||||
stroke={c}
|
||||
strokeWidth={1.8}
|
||||
strokeDasharray="5 4"
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
|
||||
control: (c) => (
|
||||
<Frame>
|
||||
<rect x="8" y="24" width="84" height="52" {...line(2)} />
|
||||
<path d="M16 60 C26 34 38 34 48 60 S 70 78 84 52" {...accent(c, 2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
function glyphKey(type: string): keyof typeof GLYPHS {
|
||||
const hx = hxGlyphKey(type);
|
||||
if (hx && hx in GLYPHS) return hx;
|
||||
|
||||
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";
|
||||
return "placeholder";
|
||||
}
|
||||
|
||||
export function ComponentIcon({ type, color }: { type: string } & IconProps) {
|
||||
const draw = GLYPHS[glyphKey(type)] ?? GLYPHS.placeholder;
|
||||
return draw(color);
|
||||
}
|
||||
224
apps/web/src/components/canvas/EntropykNode.tsx
Normal file
224
apps/web/src/components/canvas/EntropykNode.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"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 { 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";
|
||||
|
||||
const SIDE_POSITION: Record<Side, Position> = {
|
||||
left: Position.Left,
|
||||
right: Position.Right,
|
||||
top: Position.Top,
|
||||
bottom: Position.Bottom,
|
||||
};
|
||||
|
||||
function EntropykNode({ data, 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);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const accent = family?.accent ?? meta.color;
|
||||
|
||||
const placement: Record<string, { side: Side; offset: number }> = {};
|
||||
for (const port of meta.ports) {
|
||||
const side = effectiveSide(portSide(port) as Side, rotation, flipH, flipV);
|
||||
const peers = meta.ports.filter(
|
||||
(p) => effectiveSide(portSide(p) as Side, rotation, flipH, flipV) === side,
|
||||
);
|
||||
const idx = peers.indexOf(port);
|
||||
placement[port] = {
|
||||
side,
|
||||
offset: peers.length === 1 ? 0.5 : (idx + 1) / (peers.length + 1),
|
||||
};
|
||||
}
|
||||
|
||||
const iconTransform = [
|
||||
rotation ? `rotate(${rotation}deg)` : "",
|
||||
flipH ? "scaleX(-1)" : "",
|
||||
flipV ? "scaleY(-1)" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ width: BOX_W }}>
|
||||
{/* Symbol IS the node — selection is a CAD marquee, not a card */}
|
||||
<div
|
||||
className="ek-node relative"
|
||||
style={{
|
||||
width: BOX_W,
|
||||
height: BOX_H,
|
||||
outline: selected ? "1.5px dashed var(--accent)" : "1.5px dashed transparent",
|
||||
outlineOffset: 4,
|
||||
}}
|
||||
>
|
||||
{isController ? (
|
||||
<div className="h-full w-full overflow-hidden rounded-[3px] border border-[var(--line-strong)] bg-white">
|
||||
<ControlNodeFace data={d} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{ transform: iconTransform || undefined }}
|
||||
>
|
||||
<ComponentIcon type={d.type} color={accent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.ports.map((port) => {
|
||||
const { side, offset } = placement[port];
|
||||
const horizontal = side === "left" || side === "right";
|
||||
const kind = mediaForPort({ data: d }, port);
|
||||
const c = mediaColor(kind);
|
||||
const style: React.CSSProperties = {
|
||||
...(horizontal ? { top: `${offset * 100}%` } : { left: `${offset * 100}%` }),
|
||||
borderColor: c,
|
||||
background: c,
|
||||
};
|
||||
return (
|
||||
<Handle
|
||||
key={port}
|
||||
type={portRole(port)}
|
||||
position={SIDE_POSITION[side]}
|
||||
id={port}
|
||||
style={style}
|
||||
isConnectable
|
||||
title={`${port} · ${kind}`}
|
||||
className="media-handle"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Port tags sit INSIDE the symbol bounds — never collide with names */}
|
||||
{meta.ports.length > 2 &&
|
||||
meta.ports.map((port) => {
|
||||
const { side, offset } = placement[port];
|
||||
const horizontal = side === "left" || side === "right";
|
||||
const style: React.CSSProperties = horizontal
|
||||
? { top: `${offset * 100}%`, transform: "translateY(-50%)", [side]: 8 }
|
||||
: { left: `${offset * 100}%`, transform: "translateX(-50%)", [side]: 7 };
|
||||
return (
|
||||
<span
|
||||
key={`t-${port}`}
|
||||
className="mono pointer-events-none absolute text-[6.5px] uppercase tracking-wide text-[var(--ink-faint)]"
|
||||
style={style}
|
||||
>
|
||||
{portLabel(port)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex max-w-[200px] flex-col items-center leading-tight">
|
||||
{!isController && (
|
||||
<span className="mono truncate text-[10.5px] text-[var(--ink)]">{d.name}</span>
|
||||
)}
|
||||
<span className="truncate text-[8.5px] text-[var(--ink-faint)]">
|
||||
{isController ? "override control" : meta.label} · C{d.circuit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlNodeFace({ data }: { data: EntropykNodeData }) {
|
||||
const p = data.params;
|
||||
const measure = `${String(p.measure_component ?? "—")}.${shortOutput(String(p.measure_output ?? "—"))}`;
|
||||
const actuator = `${String(p.actuator_component ?? "—")}.${String(p.actuator_factor ?? "—")}`;
|
||||
const target = formatControlNumber(p.target);
|
||||
const bounds = `${formatControlNumber(p.min)}…${formatControlNumber(p.max)}`;
|
||||
const objectiveCount = controlObjectiveCount(p.objectives_json);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden text-left">
|
||||
<div className="flex items-center justify-between bg-[#4c1d95] px-2 py-1 text-white">
|
||||
<span className="mono max-w-[122px] truncate text-[10px] font-semibold">{data.name}</span>
|
||||
<span className="rounded bg-white/20 px-1 py-0.5 text-[7px] font-bold uppercase tracking-wide">
|
||||
SS
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-[1fr_44px_1fr] items-stretch bg-[#f5f3ff]">
|
||||
<div className="flex min-w-0 flex-col justify-center px-2">
|
||||
<span className="text-[7px] font-semibold uppercase tracking-[0.08em] text-[#6b5b8c]">
|
||||
Controlled
|
||||
</span>
|
||||
<span className="mono truncate text-[9px] font-semibold text-[#2e1f4a]">{measure}</span>
|
||||
<span className="mono text-[8px] text-[#6b5b8c]">SP {target}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center border-x border-[#ddd6fe] bg-[#ede9fe]">
|
||||
<span className="text-[7px] font-bold uppercase text-[#5b21b6]">Select</span>
|
||||
<span className="mono mt-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-[#5b21b6] px-1 text-[9px] font-bold text-white">
|
||||
{objectiveCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center px-2">
|
||||
<span className="text-[7px] font-semibold uppercase tracking-[0.08em] text-[#6b5b8c]">
|
||||
Actuator
|
||||
</span>
|
||||
<span className="mono truncate text-[9px] font-semibold text-[#2e1f4a]">{actuator}</span>
|
||||
<span className="mono text-[8px] text-[#6b5b8c]">{bounds}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function controlObjectiveCount(value: number | string | boolean | undefined): number {
|
||||
if (typeof value !== "string") return 0;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function shortOutput(output: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
saturationTemperature: "Tsat",
|
||||
heatTransferRate: "Qdot",
|
||||
massFlowRate: "mdot",
|
||||
temperature: "T",
|
||||
pressure: "P",
|
||||
capacity: "Q",
|
||||
superheat: "SH",
|
||||
subcooling: "SC",
|
||||
};
|
||||
return labels[output] ?? output;
|
||||
}
|
||||
|
||||
function formatControlNumber(value: number | string | boolean | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Number(value.toPrecision(4)).toString() : "—";
|
||||
}
|
||||
|
||||
export default memo(EntropykNode, (prev, next) => {
|
||||
const a = prev.data as unknown as EntropykNodeData;
|
||||
const b = next.data as unknown as EntropykNodeData;
|
||||
return (
|
||||
prev.selected === next.selected &&
|
||||
a.type === b.type &&
|
||||
a.name === b.name &&
|
||||
a.circuit === b.circuit &&
|
||||
(a.rotation ?? 0) === (b.rotation ?? 0) &&
|
||||
!!a.flipH === !!b.flipH &&
|
||||
!!a.flipV === !!b.flipV &&
|
||||
a.params === b.params
|
||||
);
|
||||
});
|
||||
90
apps/web/src/components/canvas/NodeContextMenu.tsx
Normal file
90
apps/web/src/components/canvas/NodeContextMenu.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw, Trash2 } from "lucide-react";
|
||||
|
||||
export interface NodeContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
menu: NodeContextMenuState | null;
|
||||
onClose: () => void;
|
||||
onRotate: (dir: 1 | -1) => void;
|
||||
onFlipH: () => void;
|
||||
onFlipV: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/** Right-click menu — Modelica-style orientation + delete. */
|
||||
export default function NodeContextMenu({
|
||||
menu,
|
||||
onClose,
|
||||
onRotate,
|
||||
onFlipH,
|
||||
onFlipV,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as HTMLElement)) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
if (!menu) return null;
|
||||
|
||||
const item = (
|
||||
label: string,
|
||||
shortcut: string,
|
||||
icon: React.ReactNode,
|
||||
action: () => void,
|
||||
) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink)] hover:bg-[var(--chrome-2)]"
|
||||
onClick={() => {
|
||||
action();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="text-[var(--ink-faint)]">{icon}</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="mono text-[10px] text-[var(--ink-faint)]">{shortcut}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-50 min-w-[200px] overflow-hidden rounded-md border border-[var(--line)] bg-white py-1 shadow-lg"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
>
|
||||
<div className="border-b border-[var(--line)] px-3 py-1.5">
|
||||
<div className="mono truncate text-[11px] font-semibold text-[var(--ink)]">{menu.nodeName}</div>
|
||||
<div className="text-[10px] text-[var(--ink-faint)]">Orientation (Modelica)</div>
|
||||
</div>
|
||||
{item("Rotate 90° CW", "R", <RotateCw size={14} />, () => onRotate(1))}
|
||||
{item("Rotate 90° CCW", "Shift+R", <RotateCcw size={14} />, () => onRotate(-1))}
|
||||
{item("Flip horizontal", "H", <FlipHorizontal2 size={14} />, onFlipH)}
|
||||
{item("Flip vertical", "V", <FlipVertical2 size={14} />, onFlipV)}
|
||||
<div className="my-1 h-px bg-[var(--line)]" />
|
||||
{item("Delete", "Del", <Trash2 size={14} />, onDelete)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
apps/web/src/components/palette/ComponentPalette.tsx
Normal file
118
apps/web/src/components/palette/ComponentPalette.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"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
|
||||
s’insè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>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/components/panels/ComponentDocPanel.tsx
Normal file
141
apps/web/src/components/panels/ComponentDocPanel.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { docSlugForType, docUrlForType } from "@/lib/componentDocMap";
|
||||
import MarkdownDoc from "./MarkdownDoc";
|
||||
|
||||
type Lang = "FR" | "EN" | "ALL";
|
||||
|
||||
/**
|
||||
* Extract ## EN / ## FR sections from bilingual component docs.
|
||||
* Keeps tables and code fences intact (no aggressive trimming of structure).
|
||||
*/
|
||||
function extractLang(md: string, lang: Lang): string {
|
||||
if (lang === "ALL") return md;
|
||||
const title = titleFrom(md);
|
||||
const normalized = md.replace(/\r\n/g, "\n");
|
||||
|
||||
const enIdx = normalized.search(/^## EN\s*$/m);
|
||||
const frIdx = normalized.search(/^## FR\s*$/m);
|
||||
|
||||
if (lang === "EN" && enIdx >= 0) {
|
||||
const end = frIdx > enIdx ? frIdx : normalized.length;
|
||||
const body = normalized.slice(enIdx, end).replace(/^## EN\s*\n?/, "").trimEnd();
|
||||
return `# ${title}\n\n${body}\n`;
|
||||
}
|
||||
if (lang === "FR" && frIdx >= 0) {
|
||||
const body = normalized.slice(frIdx).replace(/^## FR\s*\n?/, "").trimEnd();
|
||||
return `# ${title}\n\n${body}\n`;
|
||||
}
|
||||
// Fallback: full document
|
||||
return md;
|
||||
}
|
||||
|
||||
function titleFrom(md: string): string {
|
||||
const m = /^#\s+(.+)$/m.exec(md);
|
||||
return m ? m[1].trim() : "Documentation";
|
||||
}
|
||||
|
||||
export default function ComponentDocPanel({
|
||||
componentType,
|
||||
fallbackHelp,
|
||||
}: {
|
||||
componentType: string;
|
||||
fallbackHelp?: string;
|
||||
}) {
|
||||
const [raw, setRaw] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lang, setLang] = useState<Lang>("FR");
|
||||
|
||||
const slug = docSlugForType(componentType);
|
||||
const url = docUrlForType(componentType);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setRaw(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`Doc introuvable (${res.status})`);
|
||||
return res.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (!cancelled) {
|
||||
setRaw(text);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) {
|
||||
setRaw(null);
|
||||
setError(e.message);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<div className="px-2.5 py-2 text-[10px] text-[var(--ink-faint)]">
|
||||
{fallbackHelp ? (
|
||||
<p className="whitespace-pre-line text-[var(--ink-dim)]">{fallbackHelp}</p>
|
||||
) : (
|
||||
<p>Pas de fiche technique liée à ce type.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[min(42vh,360px)] flex-col">
|
||||
<div className="flex items-center justify-end gap-1 border-b border-[var(--line)] bg-[var(--chrome-2)] px-2 py-1">
|
||||
{(["FR", "EN", "ALL"] as Lang[]).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setLang(l)}
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold ${
|
||||
lang === l
|
||||
? "bg-white text-[var(--ink)] shadow-sm"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-1 text-[9px] text-[var(--accent)] underline"
|
||||
title="Ouvrir le fichier markdown"
|
||||
>
|
||||
.md
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 py-2">
|
||||
{loading && <p className="text-[10px] text-[var(--ink-faint)]">Chargement…</p>}
|
||||
{error && (
|
||||
<div className="text-[10px] text-[var(--hot)]">
|
||||
<p>{error}</p>
|
||||
{fallbackHelp && (
|
||||
<p className="mt-2 whitespace-pre-line text-[var(--ink-dim)]">{fallbackHelp}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && raw && <MarkdownDoc source={extractLang(raw, lang)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
apps/web/src/components/panels/MarkdownDoc.tsx
Normal file
373
apps/web/src/components/panels/MarkdownDoc.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer for component technical docs.
|
||||
* Robust tables (GFM), fenced code, lists, headings.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
type Block =
|
||||
| { kind: "h"; level: number; text: string }
|
||||
| { kind: "p"; text: string }
|
||||
| { kind: "code"; text: string }
|
||||
| { kind: "ul"; items: string[] }
|
||||
| { kind: "ol"; items: string[] }
|
||||
| { kind: "table"; headers: string[]; rows: string[][] }
|
||||
| { kind: "hr" }
|
||||
| { kind: "blockquote"; text: string };
|
||||
|
||||
function isSepLine(line: string): boolean {
|
||||
const t = line.trim();
|
||||
if (!t.includes("-") && !t.includes(":")) return false;
|
||||
// GFM separator: | --- | :---: | ---: |
|
||||
return /^\|?[\s|:\-]+$/.test(t) && /-+/.test(t);
|
||||
}
|
||||
|
||||
function splitCells(row: string): string[] {
|
||||
let r = row.trim();
|
||||
if (r.startsWith("|")) r = r.slice(1);
|
||||
if (r.endsWith("|")) r = r.slice(0, -1);
|
||||
return r.split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
function parseBlocks(md: string): Block[] {
|
||||
const lines = md.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith("```")) {
|
||||
const body: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length) i += 1;
|
||||
blocks.push({ kind: "code", text: body.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hm = /^(#{1,4})\s+(.+)$/.exec(line);
|
||||
if (hm) {
|
||||
blocks.push({ kind: "h", level: hm[1].length, text: hm[2].trim() });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^---+$/.test(line.trim()) || /^\*\*\*+$/.test(line.trim())) {
|
||||
blocks.push({ kind: "hr" });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(">")) {
|
||||
const parts: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith(">")) {
|
||||
parts.push(lines[i].replace(/^>\s?/, ""));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "blockquote", text: parts.join(" ") });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: header line with | and next line is separator
|
||||
if (line.includes("|") && i + 1 < lines.length && isSepLine(lines[i + 1])) {
|
||||
const headers = splitCells(line);
|
||||
i += 2;
|
||||
const rows: string[][] = [];
|
||||
while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "" && !isSepLine(lines[i])) {
|
||||
const cells = splitCells(lines[i]);
|
||||
// pad / trim to header width
|
||||
while (cells.length < headers.length) cells.push("");
|
||||
rows.push(cells.slice(0, headers.length));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "table", headers, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "ul", items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*\d+\.\s+/, ""));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "ol", items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// paragraph — stop before tables/lists/headings/code
|
||||
const parts: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const L = lines[i];
|
||||
if (L.trim() === "") break;
|
||||
if (L.startsWith("#") || L.startsWith("```") || L.startsWith(">")) break;
|
||||
if (/^\s*[-*]\s+/.test(L) || /^\s*\d+\.\s+/.test(L)) break;
|
||||
if (L.includes("|") && i + 1 < lines.length && isSepLine(lines[i + 1])) break;
|
||||
if (isSepLine(L)) break;
|
||||
parts.push(L.trim());
|
||||
i += 1;
|
||||
}
|
||||
if (parts.length) blocks.push({ kind: "p", text: parts.join(" ") });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function Inline({ text }: { text: string }) {
|
||||
const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\))/g);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, idx) => {
|
||||
if (!part) return null;
|
||||
if (part.startsWith("`") && part.endsWith("`") && part.length >= 2) {
|
||||
return (
|
||||
<code key={idx} className="doc-code-inline">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
if (part.startsWith("**") && part.endsWith("**") && part.length >= 4) {
|
||||
return <strong key={idx}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
const lm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part);
|
||||
if (lm) {
|
||||
return (
|
||||
<a key={idx} href={lm[2]} className="doc-link" target="_blank" rel="noreferrer">
|
||||
{lm[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={idx}>{part}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarkdownDoc({ source }: { source: string }) {
|
||||
const blocks = useMemo(() => parseBlocks(source), [source]);
|
||||
|
||||
return (
|
||||
<div className="doc-md">
|
||||
{blocks.map((b, i) => {
|
||||
switch (b.kind) {
|
||||
case "h":
|
||||
if (b.level <= 1)
|
||||
return (
|
||||
<h1 key={i} className="doc-h1">
|
||||
<Inline text={b.text} />
|
||||
</h1>
|
||||
);
|
||||
if (b.level === 2)
|
||||
return (
|
||||
<h2 key={i} className="doc-h2">
|
||||
<Inline text={b.text} />
|
||||
</h2>
|
||||
);
|
||||
if (b.level === 3)
|
||||
return (
|
||||
<h3 key={i} className="doc-h3">
|
||||
<Inline text={b.text} />
|
||||
</h3>
|
||||
);
|
||||
return (
|
||||
<h4 key={i} className="doc-h4">
|
||||
<Inline text={b.text} />
|
||||
</h4>
|
||||
);
|
||||
case "p":
|
||||
return (
|
||||
<p key={i} className="doc-p">
|
||||
<Inline text={b.text} />
|
||||
</p>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<pre key={i} className="doc-pre">
|
||||
<code>{b.text}</code>
|
||||
</pre>
|
||||
);
|
||||
case "ul":
|
||||
return (
|
||||
<ul key={i} className="doc-ul">
|
||||
{b.items.map((it, j) => (
|
||||
<li key={j}>
|
||||
<Inline text={it} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "ol":
|
||||
return (
|
||||
<ol key={i} className="doc-ol">
|
||||
{b.items.map((it, j) => (
|
||||
<li key={j}>
|
||||
<Inline text={it} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
case "table":
|
||||
return (
|
||||
<div key={i} className="doc-table-wrap">
|
||||
<table className="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{b.headers.map((h, j) => (
|
||||
<th key={j}>
|
||||
<Inline text={h} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci}>
|
||||
<Inline text={cell} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
case "hr":
|
||||
return <hr key={i} className="doc-hr" />;
|
||||
case "blockquote":
|
||||
return (
|
||||
<blockquote key={i} className="doc-quote">
|
||||
<Inline text={b.text} />
|
||||
</blockquote>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
<style jsx>{`
|
||||
:global(.doc-md) {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
:global(.doc-h1) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0.4em 0 0.3em;
|
||||
}
|
||||
:global(.doc-h2) {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0.75em 0 0.3em;
|
||||
padding-bottom: 0.15em;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
:global(.doc-h3) {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
color: var(--ink);
|
||||
margin: 0.55em 0 0.2em;
|
||||
}
|
||||
:global(.doc-h4) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin: 0.45em 0 0.15em;
|
||||
}
|
||||
:global(.doc-p) {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
:global(.doc-pre) {
|
||||
margin: 0.35em 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
:global(.doc-code-inline) {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
background: var(--chrome-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
color: var(--ink);
|
||||
}
|
||||
:global(.doc-ul),
|
||||
:global(.doc-ol) {
|
||||
margin: 0.3em 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
:global(.doc-table-wrap) {
|
||||
overflow-x: auto;
|
||||
margin: 0.4em 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
:global(.doc-table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
table-layout: auto;
|
||||
}
|
||||
:global(.doc-table th),
|
||||
:global(.doc-table td) {
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
:global(.doc-table th) {
|
||||
background: var(--chrome-2);
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
:global(.doc-hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
:global(.doc-quote) {
|
||||
margin: 0.35em 0;
|
||||
padding: 6px 8px;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--chrome-2);
|
||||
}
|
||||
:global(.doc-link) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
374
apps/web/src/components/panels/MultiRunPanel.tsx
Normal file
374
apps/web/src/components/panels/MultiRunPanel.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Layers, Loader2, Play, Plus, Trash2, X } from "lucide-react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import {
|
||||
buildSweepCases,
|
||||
defaultSweepsFromDiagram,
|
||||
discoverSweepTargets,
|
||||
extractKpis,
|
||||
runParallel,
|
||||
suggestValues,
|
||||
type MultiRunResult,
|
||||
type SweepSpec,
|
||||
type SweepTarget,
|
||||
} from "@/lib/multiRun";
|
||||
|
||||
const GROUP_LABEL: Record<SweepTarget["group"], string> = {
|
||||
boundaries: "Conditions limites (eau / air)",
|
||||
thermal: "Échangeurs (UA…)",
|
||||
machine: "Machine (compresseur / détendeur)",
|
||||
global: "Global",
|
||||
};
|
||||
|
||||
export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
} = useDiagramStore();
|
||||
|
||||
const diagramNodes = nodes as Node<EntropykNodeData>[];
|
||||
const targets = useMemo(
|
||||
() => discoverSweepTargets(diagramNodes, fluid),
|
||||
[diagramNodes, fluid],
|
||||
);
|
||||
|
||||
const [sweeps, setSweeps] = useState<SweepSpec[]>(() =>
|
||||
defaultSweepsFromDiagram(diagramNodes, fluid),
|
||||
);
|
||||
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);
|
||||
|
||||
const baseConfig = useMemo(
|
||||
() =>
|
||||
buildScenarioConfig(diagramNodes, edges, {
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
}),
|
||||
[diagramNodes, edges, fluid, fluidBackend, solverStrategy, maxIterations, tolerance],
|
||||
);
|
||||
|
||||
const cases = useMemo(() => buildSweepCases(baseConfig, sweeps), [baseConfig, sweeps]);
|
||||
|
||||
const targetsByGroup = useMemo(() => {
|
||||
const map = new Map<SweepTarget["group"], SweepTarget[]>();
|
||||
for (const t of targets) {
|
||||
const list = map.get(t.group) ?? [];
|
||||
list.push(t);
|
||||
map.set(t.group, list);
|
||||
}
|
||||
return map;
|
||||
}, [targets]);
|
||||
|
||||
const setAxis = (i: number, patch: Partial<SweepSpec>) => {
|
||||
setSweeps((all) => all.map((s, j) => (j === i ? { ...s, ...patch } : s)));
|
||||
};
|
||||
|
||||
const pickTarget = (i: number, path: string) => {
|
||||
const t = targets.find((x) => x.path === path);
|
||||
if (!t) {
|
||||
setAxis(i, { path });
|
||||
return;
|
||||
}
|
||||
setAxis(i, {
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
});
|
||||
};
|
||||
|
||||
const addAxis = (path?: string) => {
|
||||
const t =
|
||||
(path ? targets.find((x) => x.path === path) : undefined) ??
|
||||
targets.find(
|
||||
(x) =>
|
||||
x.group === "boundaries" &&
|
||||
!sweeps.some((s) => s.path === x.path),
|
||||
) ??
|
||||
targets.find((x) => !sweeps.some((s) => s.path === x.path)) ??
|
||||
targets[0];
|
||||
if (!t) return;
|
||||
setSweeps((s) => [
|
||||
...s,
|
||||
{
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const resetToWaterTemps = () => {
|
||||
setSweeps(defaultSweepsFromDiagram(diagramNodes, fluid));
|
||||
setResults(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onRun = async () => {
|
||||
setError(null);
|
||||
const problems = validateConfig(nodes, edges);
|
||||
if (problems.length > 0) {
|
||||
setError(problems[0]);
|
||||
return;
|
||||
}
|
||||
if (cases.length === 0) {
|
||||
setError("Aucun cas à lancer — renseigne des valeurs sur chaque axe.");
|
||||
return;
|
||||
}
|
||||
if (cases.length > 64) {
|
||||
setError(`Trop de cas (${cases.length}). Maximum 64 — réduis les listes de valeurs.`);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setProgress({ done: 0, total: cases.length });
|
||||
setResults(null);
|
||||
try {
|
||||
const out = await runParallel(cases, {
|
||||
concurrency,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResults(out);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const boundaryCount = targets.filter((t) => t.group === "boundaries").length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<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>
|
||||
<p className="text-[11px] text-[var(--ink-faint)]">
|
||||
Les variables viennent du schéma actuel (sources eau/air, UA, etc.).
|
||||
{boundaryCount > 0
|
||||
? ` ${boundaryCount} condition(s) limite détectée(s).`
|
||||
: " Aucune source eau/air — ajoute des BrineSource pour balayer T eau."}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="toolish" onClick={onClose} aria-label="Fermer">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1 text-[11px] text-[var(--ink-dim)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
onClick={resetToWaterTemps}
|
||||
>
|
||||
Préremplir T eau évap + cond
|
||||
</button>
|
||||
<span className="text-[10px] text-[var(--ink-faint)]">
|
||||
Ex. : balayer <span className="mono">evap_water_in.t_set_c</span> et{" "}
|
||||
<span className="mono">cond_water_in.t_set_c</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="eyebrow">Axes à balayer</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[12px] text-[var(--accent)]"
|
||||
onClick={() => addAxis()}
|
||||
disabled={targets.length === 0}
|
||||
>
|
||||
<Plus size={12} /> Ajouter un axe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{targets.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-[var(--line)] p-3 text-[12px] text-[var(--warn)]">
|
||||
Schéma vide — charge un exemple ou place des composants avant le multi-run.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sweeps.map((sweep, i) => {
|
||||
const selected = targets.find((t) => t.path === sweep.path);
|
||||
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)}
|
||||
>
|
||||
{[...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 l’axe"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-[12px] text-[var(--ink-dim)]">
|
||||
Parallélisme
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
className="ek-select mono w-16"
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</label>
|
||||
<span className="mono text-[11px] text-[var(--ink-faint)]">
|
||||
{cases.length} cas · produit cartésien des axes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[var(--line)] px-4 py-3">
|
||||
<span className="mono text-[11px] text-[var(--ink-faint)]">
|
||||
{running
|
||||
? `Calcul ${progress.done}/${progress.total}…`
|
||||
: "Chaque cas = un POST /api/simulate (moteur CLI)"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={running || nodes.length === 0}
|
||||
onClick={() => void onRun()}
|
||||
className="flex items-center gap-1.5 rounded-md bg-[var(--accent)] px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{running ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{running ? "En cours" : "Lancer tous les cas"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.toolish {
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.toolish:hover {
|
||||
background: var(--chrome-2);
|
||||
}
|
||||
:global(.ek-select) {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--chrome-2);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
907
apps/web/src/components/panels/PropertiesPanel.tsx
Normal file
907
apps/web/src/components/panels/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,907 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Parameter dialog — Dymola / OMEdit style.
|
||||
*
|
||||
* Layout (simulation tools convention):
|
||||
* ┌ Identity (name, type) ─────────────┐
|
||||
* │ [General] [Model] [Secondary] … │ tabs
|
||||
* │ Name Value Unit Fixed│ table header
|
||||
* │ UA [8000 ] W/K — │
|
||||
* │ SST [5 ] °C [✓] │
|
||||
* │ Z_UA [1.0 ] — [ ] │
|
||||
* └────────────────────────────────────┘
|
||||
*
|
||||
* Fixed checkbox (EES / Dymola): ON = imposed, OFF = free for solver.
|
||||
* Descriptions are tooltips only — no walls of text.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import {
|
||||
boundaryFixPairingPatches,
|
||||
isBoundaryParamFixed,
|
||||
} from "@/lib/boundaryFix";
|
||||
import {
|
||||
COMPONENT_BY_TYPE,
|
||||
effectiveParamValue,
|
||||
fixedFlagKey,
|
||||
isParamFixed,
|
||||
isSecondaryPort,
|
||||
mergeDefaultParams,
|
||||
type ParamMeta,
|
||||
} from "@/lib/componentMeta";
|
||||
import {
|
||||
parseControlObjectives,
|
||||
CONTROL_NODE_TYPE,
|
||||
type ControlObjectiveConfig,
|
||||
type EntropykNodeData,
|
||||
} from "@/lib/configBuilder";
|
||||
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 type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
type StoreSnapshot = ReturnType<typeof useDiagramStore.getState>;
|
||||
type DiagramNode = StoreSnapshot["nodes"][number];
|
||||
|
||||
const BOUNDARY_TYPES = new Set(["BrineSource", "BrineSink", "AirSource", "AirSink"]);
|
||||
|
||||
/** Map raw sections → a small set of clean tabs (like Dymola dialog groups). */
|
||||
const TAB_ORDER = ["General", "Model", "Secondary", "Calibration", "Control", "Advanced"] as const;
|
||||
type TabId = (typeof TAB_ORDER)[number];
|
||||
|
||||
function tabForParam(p: ParamMeta, componentType: string): TabId {
|
||||
const s = (p.section ?? "").toLowerCase();
|
||||
// Calibration Fixed/Free only on HX factors & measure targets
|
||||
if (p.fixable && (p.measureOutput || p.actuatorFactor || s.includes("calibration"))) {
|
||||
return "Calibration";
|
||||
}
|
||||
if (s.includes("calibration")) return "Calibration";
|
||||
if (s.includes("secondary") || s.includes("rating") || s.includes("caloporteur")) return "Secondary";
|
||||
// "Control" tab only for the optional regulation-loop component
|
||||
if (componentType === "SaturatedController") {
|
||||
if (s.includes("mesure") || s.includes("measure") || s.includes("primary")) return "General";
|
||||
if (s.includes("actionneur") || s.includes("actuator") || s.includes("bounded")) return "Model";
|
||||
return "Advanced";
|
||||
}
|
||||
if (s.includes("control") && !s.includes("quality")) return "Advanced";
|
||||
if (
|
||||
p.advanced ||
|
||||
s.includes("solver") ||
|
||||
s.includes("hydraulic") ||
|
||||
s.includes("advanced") ||
|
||||
s.includes("manufacturer data") ||
|
||||
s.includes("réglages fins")
|
||||
) {
|
||||
return "Advanced";
|
||||
}
|
||||
if (
|
||||
s.includes("identification") ||
|
||||
s.includes("fluid") ||
|
||||
s === "" ||
|
||||
s.includes("component parameter")
|
||||
) {
|
||||
return "General";
|
||||
}
|
||||
return "Model";
|
||||
}
|
||||
|
||||
function hasLiveSecondaryPorts(node: DiagramNode, edges: Edge[]): boolean {
|
||||
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);
|
||||
}
|
||||
return connected.has("secondary_inlet") && connected.has("secondary_outlet");
|
||||
}
|
||||
|
||||
export default function PropertiesPanel() {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
result,
|
||||
selectedNodeId,
|
||||
updateNodeParams,
|
||||
updateNodesParams,
|
||||
updateNodeName,
|
||||
updateNodeCircuit,
|
||||
removeNode,
|
||||
} = useDiagramStore();
|
||||
|
||||
const node = nodes.find((n) => n.id === selectedNodeId) as DiagramNode | undefined;
|
||||
const [tab, setTab] = useState<TabId>("General");
|
||||
/** Modelica: Parameters vs Results (Variable Browser). */
|
||||
const [panelMode, setPanelMode] = useState<"parameters" | "results">("parameters");
|
||||
// Doc technique repliée par défaut — les paramètres passent toujours en premier
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const liveSecondary = useMemo(
|
||||
() => (node ? hasLiveSecondaryPorts(node, edges) : false),
|
||||
[node, edges],
|
||||
);
|
||||
|
||||
const inspector = useMemo(() => {
|
||||
if (!node) return null;
|
||||
return buildComponentInspector(
|
||||
node as Node<EntropykNodeData>,
|
||||
nodes as Node<EntropykNodeData>[],
|
||||
edges,
|
||||
result,
|
||||
);
|
||||
}, [node, nodes, edges, result]);
|
||||
|
||||
// After a successful solve, jump to Results when selecting a part (Dymola-like).
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
if (result && String(result.status).toLowerCase() === "converged") {
|
||||
setPanelMode((mode) => (mode === "results" ? mode : "results"));
|
||||
}
|
||||
}, [node?.id, result?.status, result?.iterations]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Always fill missing defaults (Z_UA=1, etc.) when a node is selected — once per id.
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
const merged = mergeDefaultParams(node.data.type, node.data.params ?? {});
|
||||
const missing = Object.keys(merged).some(
|
||||
(k) => node.data.params[k] === undefined && merged[k] !== undefined,
|
||||
);
|
||||
if (missing) {
|
||||
updateNodeParams(node.id, merged);
|
||||
}
|
||||
// Intentionally only when selection / type changes — not on every params update.
|
||||
}, [node?.id, node?.data.type]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex flex-col border-b border-[var(--line)]">
|
||||
<div className="flex h-9 items-center px-3">
|
||||
<span className="text-[11px] font-semibold text-[var(--ink-dim)]">Parameters</span>
|
||||
</div>
|
||||
<div className="space-y-2 px-3 pb-4 text-[12px] leading-relaxed text-[var(--ink-dim)]">
|
||||
<p>Sélectionne un composant sur le schéma pour voir ses paramètres.</p>
|
||||
<p className="text-[11px] text-[var(--ink-faint)]">
|
||||
Après Simulate, clique un composant pour ouvrir ses variables (P, T, ṁ, ΔP…) comme dans
|
||||
Modelica / Dymola.
|
||||
</p>
|
||||
<div className="rounded border border-[var(--line)] bg-[var(--chrome-2)] p-2 text-[10px]">
|
||||
<p className="mb-1 font-semibold text-[var(--ink)]">Rappel rapide</p>
|
||||
<ul className="list-inside list-disc space-y-0.5 text-[var(--ink-dim)]">
|
||||
<li>
|
||||
<strong>Fixed ☑</strong> = valeur imposée
|
||||
</li>
|
||||
<li>
|
||||
<strong>Fixed ☐</strong> = le solveur calcule (ex. Z_UA libre)
|
||||
</li>
|
||||
<li>
|
||||
Calibration simple : Fixed sur SST + Z_UA non Fixed (défaut Z_UA = 1)
|
||||
</li>
|
||||
<li>
|
||||
Le bloc « Regulation loop » (palette Advanced) est optionnel — pas besoin pour
|
||||
calibrer Z_UA
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-[12px] text-[var(--hot)]">
|
||||
Unknown component type: {node.data.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = mergeDefaultParams(node.data.type, 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 list = byTab.get(t) ?? [];
|
||||
list.push(p);
|
||||
byTab.set(t, list);
|
||||
}
|
||||
// Never show empty "Control" tab for normal components
|
||||
const availableTabs = TAB_ORDER.filter((t) => (byTab.get(t)?.length ?? 0) > 0);
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b border-[var(--line)]">
|
||||
{/* ── Title bar ── */}
|
||||
<div className="flex h-9 items-center justify-between gap-2 border-b border-[var(--line)] bg-[var(--chrome-2)] px-2.5">
|
||||
<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} />
|
||||
</span>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-[var(--ink)]">
|
||||
{node.data.name}
|
||||
<span className="ml-1 font-normal text-[var(--ink-faint)]">· {meta.label}</span>
|
||||
</div>
|
||||
<div className="truncate text-[9px] text-[var(--ink-faint)]">
|
||||
{panelMode === "results" && inspector?.summaryLines[0]
|
||||
? inspector.summaryLines.slice(0, 2).join(" · ")
|
||||
: meta.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelp((v) => !v)}
|
||||
className={`rounded p-1 ${showHelp ? "bg-white text-[var(--accent)]" : "text-[var(--ink-faint)] hover:bg-white"}`}
|
||||
title="Documentation technique (bas du panneau)"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNode(node.id)}
|
||||
className="rounded p-1 text-[var(--ink-faint)] hover:bg-red-50 hover:text-[var(--hot)]"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modelica: Parameters ↔ Results (Variable Browser) */}
|
||||
{!isRegLoop && (
|
||||
<div className="grid grid-cols-2 border-b border-[var(--line)] bg-white text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelMode("parameters")}
|
||||
className={`py-1.5 font-medium ${
|
||||
panelMode === "parameters"
|
||||
? "border-b-2 border-[var(--accent)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Parameters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelMode("results")}
|
||||
className={`py-1.5 font-medium ${
|
||||
panelMode === "results"
|
||||
? "border-b-2 border-[var(--accent)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Results
|
||||
{result ? (
|
||||
<span className="ml-1 mono text-[9px] text-[var(--ok)]">●</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRegLoop && panelMode === "results" && (
|
||||
<ModelicaResultsView inspector={inspector} hasResult={!!result} />
|
||||
)}
|
||||
|
||||
{(isRegLoop || panelMode === "parameters") && (
|
||||
<>
|
||||
{isRegLoop && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-2.5 py-1.5 text-[10px] text-amber-950">
|
||||
Optionnel. Calibration SST + Z_UA → onglet <strong>Calibration</strong> du HX (cases
|
||||
Fixed), pas ce nœud.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model / correlation banner (compressors, HX, …) */}
|
||||
{modelBannerForType(node.data.type) && (
|
||||
<div className="border-b border-[var(--line)] bg-[#eff6ff] px-2.5 py-1.5 text-[10px] leading-snug text-[#1e3a5f]">
|
||||
<span className="font-semibold">Modèle : </span>
|
||||
<span className="mono">{modelBannerForType(node.data.type)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Identity row (always first after header) ── */}
|
||||
<div className="grid grid-cols-[1fr_72px] gap-2 border-b border-[var(--line)] px-2.5 py-2">
|
||||
<label className="min-w-0">
|
||||
<span className="mb-0.5 block text-[9px] text-[var(--ink-faint)]">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={node.data.name}
|
||||
onChange={(e) => updateNodeName(node.id, e.target.value)}
|
||||
className="param-input mono w-full"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-0.5 block text-[9px] text-[var(--ink-faint)]">Circuit</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={node.data.circuit}
|
||||
onChange={(e) => updateNodeCircuit(node.id, parseInt(e.target.value || "0", 10))}
|
||||
className="param-input mono w-full text-right"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Secondary status — one line only */}
|
||||
{meta.ports?.some(isSecondaryPort) && (
|
||||
<div
|
||||
className={`border-b px-2.5 py-1.5 text-[10px] ${
|
||||
liveSecondary
|
||||
? "border-emerald-100 bg-emerald-50/80 text-emerald-900"
|
||||
: "border-amber-100 bg-amber-50/80 text-amber-950"
|
||||
}`}
|
||||
>
|
||||
{liveSecondary
|
||||
? "Secondary loop connected"
|
||||
: "Secondary ports open — connect Source / Sink, or use rating values"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
{availableTabs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0 border-b border-[var(--line)] bg-[var(--chrome-2)] px-1">
|
||||
{availableTabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
|
||||
activeTab === t
|
||||
? "border-b-2 border-[var(--accent)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Parameter table ── */}
|
||||
<div className="max-h-[min(48vh,380px)] overflow-y-auto">
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-3 py-4 text-[11px] text-[var(--ink-faint)]">No parameters in this tab.</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead className="sticky top-0 z-[1] bg-[var(--chrome-2)]">
|
||||
<tr className="border-b border-[var(--line)] text-left text-[9px] font-semibold uppercase tracking-wide text-[var(--ink-faint)]">
|
||||
<th className="px-2 py-1.5 font-semibold">Parameter</th>
|
||||
<th className="w-[108px] px-1 py-1.5 font-semibold">Value</th>
|
||||
<th className="w-12 px-1 py-1.5 font-semibold">Unit</th>
|
||||
{showFixedCol && (
|
||||
<th
|
||||
className="w-12 px-1 py-1.5 text-center font-semibold"
|
||||
title="Fixed = imposed · Free = solved"
|
||||
>
|
||||
Fixed
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((p) => {
|
||||
const fixed = BOUNDARY_TYPES.has(node.data.type)
|
||||
? isBoundaryParamFixed(params, p)
|
||||
: isParamFixed(params, p);
|
||||
const free = Boolean(p.fixable && !fixed);
|
||||
const displayVal = effectiveParamValue(params, p);
|
||||
return (
|
||||
<tr
|
||||
key={p.key}
|
||||
className="border-b border-[var(--line)]/70 hover:bg-[var(--chrome-2)]/80"
|
||||
title={p.description ?? p.label}
|
||||
>
|
||||
<td
|
||||
className="max-w-[140px] truncate px-2 py-1 text-[var(--ink-dim)]"
|
||||
title={p.description ?? p.label}
|
||||
>
|
||||
{p.label}
|
||||
{p.required ? <span className="text-[var(--hot)]"> *</span> : null}
|
||||
</td>
|
||||
<td className="px-1 py-0.5">
|
||||
<ParamCell
|
||||
node={node}
|
||||
param={p}
|
||||
nodes={nodes}
|
||||
free={free}
|
||||
displayValue={displayVal}
|
||||
updateNodeParams={updateNodeParams}
|
||||
/>
|
||||
</td>
|
||||
<td className="mono px-1 py-1 text-[10px] text-[var(--ink-faint)]">
|
||||
{p.unit ?? ""}
|
||||
</td>
|
||||
{showFixedCol && (
|
||||
<td className="px-1 py-1 text-center">
|
||||
{p.fixable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fixed}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
const seed =
|
||||
node.data.params[p.key] === undefined &&
|
||||
p.default !== undefined
|
||||
? { [p.key]: p.default }
|
||||
: {};
|
||||
|
||||
if (BOUNDARY_TYPES.has(node.data.type)) {
|
||||
const patches = boundaryFixPairingPatches(
|
||||
node.id,
|
||||
p.key,
|
||||
checked,
|
||||
nodes as Node<EntropykNodeData>[],
|
||||
edges,
|
||||
);
|
||||
const self = patches.get(node.id) ?? {};
|
||||
patches.set(node.id, { ...seed, ...self });
|
||||
updateNodesParams(patches);
|
||||
} else {
|
||||
updateNodeParams(node.id, {
|
||||
[fixedFlagKey(p.key)]: checked,
|
||||
...seed,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="h-3.5 w-3.5 accent-[var(--accent)]"
|
||||
title={
|
||||
fixed
|
||||
? "Fixed : valeur imposée"
|
||||
: "Free : le solveur calcule (valeur = départ)"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--ink-faint)]">·</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{activeTab === "Calibration" && (
|
||||
<div className="space-y-1 border-t border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1.5 text-[10px] leading-snug text-[var(--ink-dim)]">
|
||||
<p>
|
||||
<strong>Comment calibrer :</strong> coche Fixed sur la cible (SST/SDT), décoche
|
||||
Fixed sur le facteur (Z_UA). Z_UA vaut <strong>1</strong> par défaut (pas de
|
||||
correction).
|
||||
</p>
|
||||
<p className="text-[var(--ink-faint)]">
|
||||
Tu n’as pas besoin du nœud « Regulation loop » pour ça.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRegLoop && (
|
||||
<div className="border-t border-[var(--line)] p-2">
|
||||
<OverrideEditor node={node} nodes={nodes} updateNodeParams={updateNodeParams} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Doc technique (collapsed by default — never above params) ── */}
|
||||
<div className="border-t border-[var(--line)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelp((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HelpCircle size={12} />
|
||||
Documentation technique
|
||||
</span>
|
||||
<span className="text-[var(--ink-faint)]">{showHelp ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showHelp && (
|
||||
<ComponentDocPanel componentType={node.data.type} fallbackHelp={meta.help} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
:global(.param-input) {
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--ink);
|
||||
}
|
||||
:global(.param-input:focus) {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
:global(.param-input.free) {
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamCell({
|
||||
node,
|
||||
param,
|
||||
nodes,
|
||||
free,
|
||||
displayValue,
|
||||
updateNodeParams,
|
||||
}: {
|
||||
node: DiagramNode;
|
||||
param: ParamMeta;
|
||||
nodes: DiagramNode[];
|
||||
free: boolean;
|
||||
displayValue: number | string | boolean | undefined;
|
||||
updateNodeParams: (id: string, params: Record<string, number | string | boolean>) => void;
|
||||
}) {
|
||||
const update = (next: number | string | boolean) => updateNodeParams(node.id, { [param.key]: next });
|
||||
const selectOptions = controllerSelectOptions(node, param.key, nodes);
|
||||
const options = selectOptions ?? param.options;
|
||||
const cls = free ? "param-input free mono" : "param-input mono";
|
||||
const value = displayValue;
|
||||
|
||||
if (options) {
|
||||
return (
|
||||
<select
|
||||
value={String(value ?? param.default ?? options[0]?.value ?? "")}
|
||||
onChange={(e) => update(e.target.value)}
|
||||
className="param-input w-full"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.kind === "boolean") {
|
||||
return (
|
||||
<div className="flex justify-end pr-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => update(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numOrText =
|
||||
value === undefined || value === null
|
||||
? ""
|
||||
: typeof value === "number" && !Number.isFinite(value)
|
||||
? ""
|
||||
: String(value);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={param.kind === "number" ? "number" : "text"}
|
||||
min={param.kind === "number" ? param.min : undefined}
|
||||
max={param.kind === "number" ? param.max : undefined}
|
||||
step={param.kind === "number" ? (param.step ?? "any") : undefined}
|
||||
value={numOrText}
|
||||
onChange={(e) => {
|
||||
if (param.kind === "number") {
|
||||
const n = parseFloat(e.target.value);
|
||||
update(Number.isFinite(n) ? n : (param.default as number) ?? 0);
|
||||
} else {
|
||||
update(e.target.value);
|
||||
}
|
||||
}}
|
||||
className={`${cls} w-full text-right`}
|
||||
title={
|
||||
free
|
||||
? "Point de départ (paramètre libre)"
|
||||
: param.default !== undefined
|
||||
? `Défaut : ${param.default}`
|
||||
: undefined
|
||||
}
|
||||
placeholder={param.default !== undefined ? String(param.default) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Saturated controller overrides (kept compact) ───────────────────── */
|
||||
|
||||
const CONTROL_OUTPUT_OPTIONS = [
|
||||
{ value: "temperature", label: "Temperature" },
|
||||
{ value: "pressure", label: "Pressure" },
|
||||
{ value: "massFlowRate", label: "Mass flow rate" },
|
||||
{ value: "capacity", label: "Capacity" },
|
||||
{ value: "heatTransferRate", label: "Heat transfer rate" },
|
||||
{ value: "superheat", label: "Superheat" },
|
||||
{ value: "subcooling", label: "Subcooling" },
|
||||
{ value: "saturationTemperature", label: "Saturation temperature" },
|
||||
] as const;
|
||||
|
||||
function OverrideEditor({
|
||||
node,
|
||||
nodes,
|
||||
updateNodeParams,
|
||||
}: {
|
||||
node: DiagramNode;
|
||||
nodes: DiagramNode[];
|
||||
updateNodeParams: (id: string, params: Record<string, number | string | boolean>) => void;
|
||||
}) {
|
||||
const objectives = parseControlObjectives(node.data.params.objectives_json);
|
||||
const componentNodes = nodes.filter((c) => c.data.type !== "SaturatedController");
|
||||
const write = (next: ControlObjectiveConfig[]) =>
|
||||
updateNodeParams(node.id, { objectives_json: JSON.stringify(next) });
|
||||
const updateObjective = (index: number, patch: Partial<ControlObjectiveConfig>) =>
|
||||
write(objectives.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= objectives.length) return;
|
||||
const next = [...objectives];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
write(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded border border-[var(--line)] bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[var(--line)] px-2 py-1.5">
|
||||
<span className="text-[10px] font-semibold text-[var(--ink-dim)]">Override chain</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
write([
|
||||
...objectives,
|
||||
{
|
||||
component: componentNodes[0]?.data.name ?? "component",
|
||||
output: "temperature",
|
||||
setpoint: 0,
|
||||
gain: 1,
|
||||
combine: "min",
|
||||
},
|
||||
])
|
||||
}
|
||||
className="flex items-center gap-0.5 text-[10px] font-medium text-[var(--accent)]"
|
||||
>
|
||||
<Plus size={12} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{objectives.length === 0 ? (
|
||||
<p className="px-2 py-2 text-[10px] text-[var(--ink-faint)]">Primary objective only.</p>
|
||||
) : (
|
||||
<div className="space-y-1 p-1.5">
|
||||
{objectives.map((objective, index) => (
|
||||
<div key={index} className="rounded border border-[var(--line)] p-1.5">
|
||||
<div className="mb-1 flex justify-end gap-0.5">
|
||||
<button type="button" onClick={() => move(index, -1)} className="p-0.5 text-[var(--ink-faint)]">
|
||||
<ArrowUp size={11} />
|
||||
</button>
|
||||
<button type="button" onClick={() => move(index, 1)} className="p-0.5 text-[var(--ink-faint)]">
|
||||
<ArrowDown size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => write(objectives.filter((_, i) => i !== index))}
|
||||
className="p-0.5 text-[var(--ink-faint)] hover:text-[var(--hot)]"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<select
|
||||
value={objective.component}
|
||||
onChange={(e) => updateObjective(index, { component: e.target.value })}
|
||||
className="param-input mono"
|
||||
>
|
||||
{componentNodes.map((c) => (
|
||||
<option key={c.id} value={c.data.name}>
|
||||
{c.data.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={objective.output}
|
||||
onChange={(e) => updateObjective(index, { output: e.target.value })}
|
||||
className="param-input"
|
||||
>
|
||||
{CONTROL_OUTPUT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={objective.setpoint}
|
||||
onChange={(e) => updateObjective(index, { setpoint: Number(e.target.value) })}
|
||||
className="param-input mono text-right"
|
||||
title="Setpoint"
|
||||
/>
|
||||
<select
|
||||
value={objective.combine}
|
||||
onChange={(e) =>
|
||||
updateObjective(index, { combine: e.target.value as "min" | "max" })
|
||||
}
|
||||
className="param-input mono"
|
||||
>
|
||||
<option value="min">MIN</option>
|
||||
<option value="max">MAX</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function controllerSelectOptions(
|
||||
node: DiagramNode,
|
||||
key: string,
|
||||
nodes: DiagramNode[],
|
||||
): Array<{ value: string; label: string }> | null {
|
||||
if (node.data.type !== "SaturatedController") return null;
|
||||
const componentNodes = nodes.filter((c) => c.data.type !== "SaturatedController");
|
||||
|
||||
if (key === "measure_component" || key === "actuator_component") {
|
||||
return componentNodes.map((c) => ({
|
||||
value: c.data.name,
|
||||
label: `${c.data.name} (${c.data.type})`,
|
||||
}));
|
||||
}
|
||||
if (key === "measure_output") return [...CONTROL_OUTPUT_OPTIONS];
|
||||
if (key === "actuator_factor") {
|
||||
return [
|
||||
{ value: "injection", label: "injection" },
|
||||
{ value: "opening", label: "opening" },
|
||||
{ value: "z_flow", label: "z_flow" },
|
||||
{ value: "z_dp", label: "z_dp" },
|
||||
{ value: "z_ua", label: "z_ua" },
|
||||
{ value: "z_power", label: "z_power" },
|
||||
{ value: "z_etav", label: "z_etav" },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Modelica / Dymola Variable Browser for one component ─────────────── */
|
||||
|
||||
function ModelicaResultsView({
|
||||
inspector,
|
||||
hasResult,
|
||||
}: {
|
||||
inspector: ReturnType<typeof buildComponentInspector> | null;
|
||||
hasResult: boolean;
|
||||
}) {
|
||||
if (!hasResult || !inspector) {
|
||||
return (
|
||||
<div className="space-y-2 px-3 py-4 text-[11px] text-[var(--ink-dim)]">
|
||||
<p className="font-semibold text-[var(--ink)]">Pas encore de résultats</p>
|
||||
<p>
|
||||
Lance <strong>Simulate</strong>, puis reclique ce composant — tu verras P, T, Tsat, ṁ, ΔP,
|
||||
Q̇ comme dans le Variable Browser Modelica.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { title, typeLabel, summaryLines, ports } = inspector;
|
||||
|
||||
return (
|
||||
<div className="max-h-[min(56vh,480px)] space-y-2 overflow-y-auto px-2.5 py-2.5">
|
||||
<div className="rounded-md border border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-2">
|
||||
<div className="mono text-[12px] font-semibold text-[var(--ink)]">{title}</div>
|
||||
<div className="text-[10px] text-[var(--ink-faint)]">{typeLabel}</div>
|
||||
{summaryLines.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{summaryLines.map((line) => (
|
||||
<span
|
||||
key={line}
|
||||
className="mono rounded bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink-dim)] ring-1 ring-[var(--line)]"
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{inspector.delta_p_bar != null && (
|
||||
<MiniMetric label="ΔP" value={`${inspector.delta_p_bar.toFixed(3)} bar`} />
|
||||
)}
|
||||
{inspector.delta_p_sec_bar != null && (
|
||||
<MiniMetric label="ΔP sec" value={`${inspector.delta_p_sec_bar.toFixed(3)} bar`} />
|
||||
)}
|
||||
{inspector.q_primary_kw != null && (
|
||||
<MiniMetric label="Q̇" value={`${Math.abs(inspector.q_primary_kw).toFixed(2)} kW`} />
|
||||
)}
|
||||
{inspector.q_secondary_kw != null && (
|
||||
<MiniMetric
|
||||
label="Q̇ sec"
|
||||
value={`${Math.abs(inspector.q_secondary_kw).toFixed(2)} kW`}
|
||||
/>
|
||||
)}
|
||||
{inspector.work_kw != null && (
|
||||
<MiniMetric label="Ẇ" value={`${inspector.work_kw.toFixed(2)} kW`} />
|
||||
)}
|
||||
{inspector.superheat_k != null && (
|
||||
<MiniMetric label="Superheat" value={`${inspector.superheat_k.toFixed(2)} K`} />
|
||||
)}
|
||||
{inspector.subcooling_k != null && (
|
||||
<MiniMetric label="Subcooling" value={`${inspector.subcooling_k.toFixed(2)} K`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border border-[var(--line)]">
|
||||
<div className="border-b border-[var(--line)] bg-[var(--chrome-2)] px-2 py-1 text-[9px] font-semibold uppercase tracking-wide text-[var(--ink-faint)]">
|
||||
Ports / variables
|
||||
</div>
|
||||
{ports.length === 0 ? (
|
||||
<p className="px-2 py-3 text-[10px] text-[var(--ink-faint)]">Aucun port câblé.</p>
|
||||
) : (
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="text-left text-[9px] text-[var(--ink-faint)]">
|
||||
<th className="px-1.5 py-1 font-medium">Port</th>
|
||||
<th className="px-1 py-1 font-medium">P</th>
|
||||
<th className="px-1 py-1 font-medium">T</th>
|
||||
<th className="px-1 py-1 font-medium">Tsat</th>
|
||||
<th className="px-1 py-1 font-medium">h</th>
|
||||
<th className="px-1 py-1 font-medium">ṁ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ports.map((p) => (
|
||||
<tr key={`${p.port}-${p.edgeIndex}`} className="border-t border-[var(--line)] mono">
|
||||
<td className="px-1.5 py-1 text-[var(--ink)]">
|
||||
{p.port}
|
||||
<span className="ml-1 text-[8px] text-[var(--ink-faint)]">
|
||||
{p.stream === "secondary" ? "sec" : p.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.pressure_bar, 3)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.temperature_c, 1)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.tsat_c, 1)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.enthalpy_kj_kg, 1)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.mass_flow_kg_s, 4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="border-t border-[var(--line)] px-2 py-1 text-[8px] text-[var(--ink-faint)]">
|
||||
Unités : P [bar] · T/Tsat [°C] · h [kJ/kg] · ṁ [kg/s]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded border border-[var(--line)] bg-[var(--chrome-2)] px-1.5 py-1">
|
||||
<div className="text-[8px] uppercase tracking-wide text-[var(--ink-faint)]">{label}</div>
|
||||
<div className="mono text-[11px] text-[var(--ink)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtOpt(v: number | null | undefined, digits: number): string {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "—";
|
||||
}
|
||||
1234
apps/web/src/components/panels/ResultsPanel.tsx
Normal file
1234
apps/web/src/components/panels/ResultsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
162
apps/web/src/lib/api.ts
Normal file
162
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Client for the Entropyk REST API (Axum server in demo/).
|
||||
*
|
||||
* In development, requests go through the Next.js rewrite proxy
|
||||
* (/api/entropyk/* → http://localhost:3030/api/* by default; see next.config.mjs).
|
||||
*/
|
||||
|
||||
import type { ComponentMeta } from "./componentMeta";
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL || "/api/entropyk";
|
||||
|
||||
export interface IterationInfo {
|
||||
iteration: number;
|
||||
residual_norm: number;
|
||||
delta_norm: number;
|
||||
alpha?: number | null;
|
||||
max_residual_index?: number | null;
|
||||
max_residual?: number;
|
||||
}
|
||||
|
||||
export interface SimulationResult {
|
||||
input: string;
|
||||
status: "converged" | "failed" | "error" | string;
|
||||
convergence?: {
|
||||
iterations?: number;
|
||||
final_residual?: number;
|
||||
tolerance?: number;
|
||||
strategy?: string;
|
||||
iteration_history?: IterationInfo[];
|
||||
converged?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
iterations?: number;
|
||||
state?: Array<{
|
||||
edge?: number;
|
||||
edge_id?: number;
|
||||
pressure_bar?: number;
|
||||
pressure_pa?: number;
|
||||
enthalpy_kj_kg?: number;
|
||||
enthalpy_j_kg?: number;
|
||||
mass_flow_kg_s?: number;
|
||||
source?: string;
|
||||
target?: string;
|
||||
source_port?: string;
|
||||
target_port?: string;
|
||||
temperature_c?: number;
|
||||
saturation_temperature_c?: number;
|
||||
}>;
|
||||
performance?: {
|
||||
q_cooling_kw?: number | null;
|
||||
q_heating_kw?: number | null;
|
||||
compressor_power_kw?: number | null;
|
||||
cop?: number | null;
|
||||
cooling_capacity_w?: number | null;
|
||||
heating_capacity_w?: number | null;
|
||||
compressor_power_w?: number | null;
|
||||
pump_power_w?: number | null;
|
||||
cop_cooling?: number | null;
|
||||
cop_heating?: number | null;
|
||||
};
|
||||
components?: Array<{
|
||||
name: string;
|
||||
component_type: string;
|
||||
circuit: number;
|
||||
inlet?: { pressure_pa: number; enthalpy_j_kg: number };
|
||||
outlet?: { pressure_pa: number; enthalpy_j_kg: number };
|
||||
energy?: { heat_transfer_w: number; work_w: number };
|
||||
}>;
|
||||
error?: string | null;
|
||||
failure_diagnostics?: {
|
||||
final_residual_norm?: number;
|
||||
last_residual_norm?: number;
|
||||
dominant_residual_index?: number;
|
||||
dominant_residual_value?: number;
|
||||
} | null;
|
||||
/** Degrees-of-freedom summary after topology finalize (CLI hard gate). */
|
||||
dof?: DofSummary | null;
|
||||
}
|
||||
|
||||
/** Degrees-of-freedom summary returned by the CLI after finalize. */
|
||||
export interface DofSummary {
|
||||
n_equations: number;
|
||||
n_unknowns: number;
|
||||
balance: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface SimulateResponse {
|
||||
ok: boolean;
|
||||
result?: SimulationResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MollierPoint {
|
||||
t_k: number;
|
||||
p_bar: number;
|
||||
h_liq_kj_kg: number;
|
||||
h_vap_kj_kg: number;
|
||||
}
|
||||
|
||||
export interface MollierResponse {
|
||||
ok: boolean;
|
||||
fluid: string;
|
||||
saturation: MollierPoint[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchComponents(): Promise<ComponentMeta[]> {
|
||||
const res = await fetch(`${API_BASE}/components`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to fetch components: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/simulate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
} 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}`,
|
||||
);
|
||||
}
|
||||
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"
|
||||
: "";
|
||||
throw new Error(
|
||||
`Simulate request failed: ${res.status}${hint}${body ? `\n${body.slice(0, 400)}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMollier(fluid: string): Promise<MollierResponse> {
|
||||
const res = await fetch(`${API_BASE}/mollier?fluid=${encodeURIComponent(fluid)}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to fetch Mollier data: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/health`, { cache: "no-store" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
148
apps/web/src/lib/boundaryFix.test.ts
Normal file
148
apps/web/src/lib/boundaryFix.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
boundaryFixPairingPatches,
|
||||
enforceModelicaBoundaryEmit,
|
||||
findSecondaryLoopDofConflicts,
|
||||
hydrateBoundaryFixFlags,
|
||||
isBoundaryParamFixed,
|
||||
type BoundaryNodeData,
|
||||
} from "./boundaryFix";
|
||||
import { COMPONENT_BY_TYPE, fixedFlagKey } from "./componentMeta";
|
||||
import { buildScenarioConfig, stripUiOnlyParams, type EntropykNodeData } from "./configBuilder";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Node<BoundaryNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropyk",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, params },
|
||||
};
|
||||
}
|
||||
|
||||
function waterLoop(
|
||||
srcParams: Record<string, number | string | boolean>,
|
||||
sinkParams: Record<string, number | string | boolean>,
|
||||
) {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", { ua: 8000 }),
|
||||
node("s", "BrineSource", "src", srcParams),
|
||||
node("k", "BrineSink", "sink", sinkParams),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "1", source: "s", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
describe("Modelica MassFlowSource_T defaults", () => {
|
||||
it("meta defaultFixed P is false (Free P)", () => {
|
||||
const metaP = COMPONENT_BY_TYPE.BrineSource!.params.find((p) => p.key === "p_set_bar")!;
|
||||
expect(metaP.defaultFixed).toBe(false);
|
||||
});
|
||||
|
||||
it("hydrate Free P when ṁ present without explicit fix_pressure", () => {
|
||||
const metaP = COMPONENT_BY_TYPE.BrineSource!.params.find((p) => p.key === "p_set_bar")!;
|
||||
const hydrated = hydrateBoundaryFixFlags("BrineSource", {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
});
|
||||
expect(hydrated[fixedFlagKey("p_set_bar")]).toBe(false);
|
||||
expect(isBoundaryParamFixed(hydrated, metaP)).toBe(false);
|
||||
});
|
||||
|
||||
it("pairing: Fixed ṁ frees P on source", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{ m_flow_kg_s: 0.55, [fixedFlagKey("p_set_bar")]: true },
|
||||
{ p_back_bar: 3 },
|
||||
);
|
||||
const patches = boundaryFixPairingPatches("s", "m_flow_kg_s", true, nodes, edges);
|
||||
expect(patches.get("s")?.[fixedFlagKey("p_set_bar")]).toBe(false);
|
||||
});
|
||||
|
||||
it("pairing: Fixed P frees ṁ (Boundary_pT)", () => {
|
||||
const { nodes, edges } = waterLoop({ m_flow_kg_s: 0.55 }, { p_back_bar: 3 });
|
||||
const patches = boundaryFixPairingPatches("s", "p_set_bar", true, nodes, edges);
|
||||
expect(patches.get("s")?.[fixedFlagKey("m_flow_kg_s")]).toBe(false);
|
||||
});
|
||||
|
||||
it("detects Fixed P + Fixed ṁ on source", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
},
|
||||
{ p_back_bar: 3, [fixedFlagKey("p_back_bar")]: true },
|
||||
);
|
||||
const conflicts = findSecondaryLoopDofConflicts(nodes, edges);
|
||||
expect(conflicts.some((c) => c.message.includes("MassFlowSource_T"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforceModelicaBoundaryEmit", () => {
|
||||
it("MassFlowSource emit: Free P + Fixed ṁ", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
},
|
||||
{ p_back_bar: 3, [fixedFlagKey("p_back_bar")]: true },
|
||||
);
|
||||
const components = nodes.map((n) => ({
|
||||
type: n.data.type,
|
||||
name: n.data.name,
|
||||
...stripUiOnlyParams(n.data.type, n.data.params),
|
||||
})) as Record<string, unknown>[];
|
||||
enforceModelicaBoundaryEmit(components, nodes, edges);
|
||||
const src = components.find((c) => c.name === "src")!;
|
||||
expect(src.fix_mass_flow).toBe(true);
|
||||
expect(src.fix_pressure).toBe(false);
|
||||
});
|
||||
|
||||
it("isobaric + Fixed T_out: Free ṁ and Free P on source", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
},
|
||||
{
|
||||
p_back_bar: 3,
|
||||
t_set_c: 7,
|
||||
[fixedFlagKey("p_back_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
},
|
||||
);
|
||||
const rfNodes: Node<EntropykNodeData>[] = nodes.map((n) => ({
|
||||
...n,
|
||||
data: {
|
||||
type: n.data.type,
|
||||
name: n.data.name,
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: n.data.params,
|
||||
},
|
||||
}));
|
||||
const cfg = buildScenarioConfig(rfNodes, edges);
|
||||
const src = cfg.circuits[0].components.find((c) => c.name === "src")!;
|
||||
expect(src.fix_mass_flow).toBe(false);
|
||||
expect(src.fix_pressure).toBe(false);
|
||||
});
|
||||
});
|
||||
423
apps/web/src/lib/boundaryFix.ts
Normal file
423
apps/web/src/lib/boundaryFix.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Modelica.Fluid.Sources Fixed/Free — single source of truth.
|
||||
*
|
||||
* PROOF (official MSL + Modelica community):
|
||||
* 1) Package index:
|
||||
* https://doc.modelica.org/om/Modelica.Fluid.Sources.html
|
||||
* — Boundary_pT | Boundary_ph | MassFlowSource_T | MassFlowSource_h
|
||||
* 2) Boundary_pT (prescribes P + T, NOT ṁ):
|
||||
* https://doc.modelica.org/om/Modelica.Fluid.Sources.Boundary_pT.html
|
||||
* 3) MassFlowSource_T (prescribes ṁ + T, NOT P):
|
||||
* https://doc.modelica.org/Modelica%204.0.0/Resources/helpWSM/Modelica/Modelica.Fluid.Sources.MassFlowSource_T.html
|
||||
* 4) MSL example SimplePipeline: Boundary_pT → StaticPipe(ΔP) → Boundary_pT
|
||||
* (Context7 /modelica/modelicastandardlibrary) — double Fixed P ONLY with hydraulic ΔP.
|
||||
* 5) Legal pipe-end combinations (Rene Just Nielsen):
|
||||
* https://stackoverflow.com/questions/79349553/how-pressure-and-flow-ports-are-different-in-modelica
|
||||
* — Boundary_pT+Boundary_pT | Boundary_pT+MassFlowSource | MassFlowSource+Boundary_pT
|
||||
* — ILLEGAL: MassFlowSource + MassFlowSource
|
||||
* — "no boundary component specifying both mass flow rate and pressure"
|
||||
*
|
||||
* Entropyk mapping (secondary water/air branch):
|
||||
* MassFlowSource_T → Source: Fixed T, Fixed ṁ, Free P ; Sink: Fixed P (anchor)
|
||||
* Boundary_pT → Source: Fixed P, Fixed T, Free ṁ ; Sink: Fixed P if ΔP else one P free
|
||||
* Illegal → Fixed ṁ + Fixed P on same Source
|
||||
* → Fixed ṁ + Fixed T_out on partner Sink
|
||||
* → Fixed P both ends + Fixed ṁ (MassFlowSource + double P)
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
COMPONENT_BY_TYPE,
|
||||
FIXED_FLAG_PREFIX,
|
||||
fixedFlagKey,
|
||||
type ParamMeta,
|
||||
} from "./componentMeta";
|
||||
|
||||
export interface BoundaryNodeData {
|
||||
type: string;
|
||||
name: string;
|
||||
params: Record<string, number | string | boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const CLI_FIX_FLAG: Record<string, string> = {
|
||||
p_set_bar: "fix_pressure",
|
||||
p_back_bar: "fix_pressure",
|
||||
t_set_c: "fix_temperature",
|
||||
t_dry_c: "fix_temperature",
|
||||
t_back_c: "fix_temperature",
|
||||
m_flow_kg_s: "fix_mass_flow",
|
||||
};
|
||||
|
||||
export function cliFixFlagForParam(paramKey: string): string | undefined {
|
||||
return CLI_FIX_FLAG[paramKey];
|
||||
}
|
||||
|
||||
export function isBoundaryParamFixed(
|
||||
params: Record<string, number | string | boolean | undefined>,
|
||||
meta: ParamMeta,
|
||||
): boolean {
|
||||
if (!meta.fixable) return true;
|
||||
|
||||
const uiFlag = params[fixedFlagKey(meta.key)];
|
||||
if (uiFlag === true || uiFlag === "true") return true;
|
||||
if (uiFlag === false || uiFlag === "false") return false;
|
||||
|
||||
const cliKey = cliFixFlagForParam(meta.key);
|
||||
if (cliKey && params[cliKey] !== undefined) {
|
||||
const v = params[cliKey];
|
||||
if (v === true || v === "true") return true;
|
||||
if (v === false || v === "false") return false;
|
||||
}
|
||||
|
||||
return meta.defaultFixed !== false;
|
||||
}
|
||||
|
||||
export function hydrateBoundaryFixFlags(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
if (!meta) return params;
|
||||
const out: Record<string, number | string | boolean> = { ...params };
|
||||
|
||||
for (const p of meta.params) {
|
||||
if (!p.fixable) continue;
|
||||
const cliKey = cliFixFlagForParam(p.key);
|
||||
if (!cliKey || out[cliKey] === undefined) continue;
|
||||
const v = out[cliKey];
|
||||
out[fixedFlagKey(p.key)] = v === true || v === "true";
|
||||
delete out[cliKey];
|
||||
}
|
||||
|
||||
// Legacy sink: t_set present without flag ⇒ Fixed T_out.
|
||||
if (type === "BrineSink" || type === "AirSink") {
|
||||
const tKey = type === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
const flagKey = fixedFlagKey(tKey);
|
||||
if (out[flagKey] === undefined && out[tKey] !== undefined && out[tKey] !== "") {
|
||||
out[flagKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Modelica MassFlowSource_T: cannot Fixed P and Fixed ṁ together.
|
||||
// If imported JSON has ṁ without fix_pressure=false, Free P on source.
|
||||
if (type === "BrineSource" || type === "AirSource") {
|
||||
const mMeta = meta.params.find((p) => p.key === "m_flow_kg_s");
|
||||
const pMeta = meta.params.find((p) => p.key === "p_set_bar");
|
||||
if (mMeta && pMeta && isBoundaryParamFixed(out, mMeta)) {
|
||||
const pFlag = fixedFlagKey("p_set_bar");
|
||||
if (out[pFlag] === undefined && out.fix_pressure === undefined) {
|
||||
// Default MassFlowSource: Free P unless explicitly Fixed without ṁ.
|
||||
out[pFlag] = false;
|
||||
}
|
||||
if (isBoundaryParamFixed(out, mMeta) && isBoundaryParamFixed(out, pMeta)) {
|
||||
out[pFlag] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const SECONDARY_TRAVERSABLE = (type: string): boolean =>
|
||||
type === "BrineSource" ||
|
||||
type === "BrineSink" ||
|
||||
type === "AirSource" ||
|
||||
type === "AirSink" ||
|
||||
type.includes("Pipe") ||
|
||||
type.includes("Pump") ||
|
||||
type.includes("Fan") ||
|
||||
type.includes("Condenser") ||
|
||||
type.includes("Evaporator") ||
|
||||
type.includes("HeatExchanger") ||
|
||||
type.includes("Bphx") ||
|
||||
type === "FloodedEvaporator" ||
|
||||
type === "ThermalLoad";
|
||||
|
||||
/** HX types whose secondary side is typically isobaric (no water ΔP residual). */
|
||||
const ISOBARIC_SECONDARY = (type: string): boolean =>
|
||||
type === "FloodedEvaporator" ||
|
||||
type === "FloodedCondenser" ||
|
||||
(type.includes("Evaporator") && !type.includes("Bphx")) ||
|
||||
(type.includes("Condenser") && type !== "BphxCondenser" && !type.includes("Mchx"));
|
||||
|
||||
export function findConnectedSecondaryBoundary(
|
||||
startId: string,
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
targetType: "BrineSource" | "BrineSink" | "AirSource" | "AirSink",
|
||||
): Node<BoundaryNodeData> | null {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!adj.has(e.source)) adj.set(e.source, []);
|
||||
if (!adj.has(e.target)) adj.set(e.target, []);
|
||||
adj.get(e.source)!.push(e.target);
|
||||
adj.get(e.target)!.push(e.source);
|
||||
}
|
||||
const seen = new Set<string>([startId]);
|
||||
const queue = [startId];
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
const n = nodeById.get(next);
|
||||
if (!n) continue;
|
||||
if (n.data.type === targetType) return n;
|
||||
if (SECONDARY_TRAVERSABLE(n.data.type)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True if path Source→Sink crosses an isobaric secondary HX (no water ΔP). */
|
||||
function pathHasIsobaricSecondary(
|
||||
sourceId: string,
|
||||
sinkId: string,
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): boolean {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!adj.has(e.source)) adj.set(e.source, []);
|
||||
if (!adj.has(e.target)) adj.set(e.target, []);
|
||||
adj.get(e.source)!.push(e.target);
|
||||
adj.get(e.target)!.push(e.source);
|
||||
}
|
||||
const seen = new Set<string>([sourceId]);
|
||||
const queue = [sourceId];
|
||||
let isobaric = false;
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
if (id === sinkId) return isobaric;
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
const n = nodeById.get(next);
|
||||
if (!n) continue;
|
||||
if (ISOBARIC_SECONDARY(n.data.type)) isobaric = true;
|
||||
if (SECONDARY_TRAVERSABLE(n.data.type) || n.id === sinkId) queue.push(next);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface SecondaryLoopDofConflict {
|
||||
sourceName: string;
|
||||
sinkName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function findSecondaryLoopDofConflicts(
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): SecondaryLoopDofConflict[] {
|
||||
const conflicts: SecondaryLoopDofConflict[] = [];
|
||||
|
||||
for (const source of nodes) {
|
||||
if (source.data.type !== "BrineSource" && source.data.type !== "AirSource") continue;
|
||||
const pMeta = COMPONENT_BY_TYPE[source.data.type]?.params.find((x) => x.key === "p_set_bar");
|
||||
const mMeta = COMPONENT_BY_TYPE[source.data.type]?.params.find((x) => x.key === "m_flow_kg_s");
|
||||
if (!pMeta || !mMeta) continue;
|
||||
|
||||
// MassFlowSource cannot Fixed P and Fixed ṁ (Modelica local balance).
|
||||
if (isBoundaryParamFixed(source.data.params, pMeta) && isBoundaryParamFixed(source.data.params, mMeta)) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: "",
|
||||
message:
|
||||
`${source.data.name}: Fixed P + Fixed ṁ illegal (Modelica MassFlowSource_T ` +
|
||||
`prescribes ṁ+T only — Free P). See Modelica.Fluid.Sources.MassFlowSource_T.`,
|
||||
});
|
||||
}
|
||||
|
||||
const sinkType = source.data.type === "AirSource" ? "AirSink" : "BrineSink";
|
||||
const sink = findConnectedSecondaryBoundary(source.id, nodes, edges, sinkType);
|
||||
if (!sink) continue;
|
||||
|
||||
const sinkP = COMPONENT_BY_TYPE[sinkType]?.params.find((x) => x.key === "p_back_bar");
|
||||
const tKey = sinkType === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
const sinkT = COMPONENT_BY_TYPE[sinkType]?.params.find((x) => x.key === tKey);
|
||||
|
||||
// Fixed ṁ + Fixed T_out
|
||||
if (
|
||||
sinkT &&
|
||||
isBoundaryParamFixed(source.data.params, mMeta) &&
|
||||
isBoundaryParamFixed(sink.data.params, sinkT) &&
|
||||
sink.data.params[tKey] !== undefined &&
|
||||
sink.data.params[tKey] !== ""
|
||||
) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: sink.data.name,
|
||||
message:
|
||||
`${sink.data.name}: Fixed T_out + ${source.data.name} Fixed ṁ — ` +
|
||||
`use Boundary_pT (Free ṁ on source).`,
|
||||
});
|
||||
}
|
||||
|
||||
// Fixed P both ends + Fixed ṁ, or Fixed P both ends on isobaric secondary
|
||||
if (
|
||||
sinkP &&
|
||||
isBoundaryParamFixed(source.data.params, pMeta) &&
|
||||
isBoundaryParamFixed(sink.data.params, sinkP)
|
||||
) {
|
||||
const mFixed = isBoundaryParamFixed(source.data.params, mMeta);
|
||||
const isobaric = pathHasIsobaricSecondary(source.id, sink.id, nodes, edges);
|
||||
if (mFixed) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: sink.data.name,
|
||||
message:
|
||||
`${source.data.name}/${sink.data.name}: Fixed P both ends + Fixed ṁ — ` +
|
||||
`Modelica: MassFlowSource_T + Boundary_p (Free P on source, Fixed P on sink only).`,
|
||||
});
|
||||
} else if (isobaric) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: sink.data.name,
|
||||
message:
|
||||
`${source.data.name}/${sink.data.name}: Fixed P both ends on isobaric secondary — ` +
|
||||
`Modelica allows Boundary_pT×2 only with hydraulic ΔP (pipe/friction). ` +
|
||||
`Free P on source OR add WaterPipe ΔP.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing patches (atomic store apply).
|
||||
* Modelica rules when toggling Fixed.
|
||||
*/
|
||||
export function boundaryFixPairingPatches(
|
||||
nodeId: string,
|
||||
paramKey: string,
|
||||
fixed: boolean,
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): Map<string, Record<string, number | string | boolean>> {
|
||||
const patches = new Map<string, Record<string, number | string | boolean>>();
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return patches;
|
||||
|
||||
const self: Record<string, number | string | boolean> = {
|
||||
[fixedFlagKey(paramKey)]: fixed,
|
||||
};
|
||||
patches.set(nodeId, self);
|
||||
|
||||
const isSource = node.data.type === "BrineSource" || node.data.type === "AirSource";
|
||||
const isSink = node.data.type === "BrineSink" || node.data.type === "AirSink";
|
||||
|
||||
// MassFlowSource_T: Fixed ṁ ⇒ Free P on same source (cannot Fixed both).
|
||||
if (isSource && paramKey === "m_flow_kg_s" && fixed) {
|
||||
self[fixedFlagKey("p_set_bar")] = false;
|
||||
const sinkType = node.data.type === "AirSource" ? "AirSink" : "BrineSink";
|
||||
const partner = findConnectedSecondaryBoundary(nodeId, nodes, edges, sinkType);
|
||||
if (partner) {
|
||||
const tKey = sinkType === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
patches.set(partner.id, { [fixedFlagKey(tKey)]: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Boundary_pT on source: Fixed P ⇒ Free ṁ (cannot Fixed both).
|
||||
if (isSource && paramKey === "p_set_bar" && fixed) {
|
||||
self[fixedFlagKey("m_flow_kg_s")] = false;
|
||||
}
|
||||
|
||||
// Fixed T_out on Sink ⇒ Free ṁ on Source (energy closes ṁ).
|
||||
if (isSink && (paramKey === "t_set_c" || paramKey === "t_back_c") && fixed) {
|
||||
const srcType = node.data.type === "AirSink" ? "AirSource" : "BrineSource";
|
||||
const partner = findConnectedSecondaryBoundary(nodeId, nodes, edges, srcType);
|
||||
if (partner) {
|
||||
patches.set(partner.id, {
|
||||
[fixedFlagKey("m_flow_kg_s")]: false,
|
||||
// Boundary_pT mode: keep / restore Fixed P on source when freeing ṁ
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed P on Sink while Source has Fixed ṁ ⇒ ensure Source Free P (MassFlowSource+Boundary_p).
|
||||
if (isSink && paramKey === "p_back_bar" && fixed) {
|
||||
const srcType = node.data.type === "AirSink" ? "AirSource" : "BrineSource";
|
||||
const partner = findConnectedSecondaryBoundary(nodeId, nodes, edges, srcType);
|
||||
if (partner) {
|
||||
const mMeta = COMPONENT_BY_TYPE[srcType]?.params.find((p) => p.key === "m_flow_kg_s");
|
||||
if (mMeta && isBoundaryParamFixed(partner.data.params, mMeta)) {
|
||||
const prev = patches.get(partner.id) ?? {};
|
||||
patches.set(partner.id, { ...prev, [fixedFlagKey("p_set_bar")]: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return patches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit-time compiler: force Modelica-legal fix_* on the JSON sent to CLI.
|
||||
*
|
||||
* MassFlowSource_T: Fixed ṁ + Fixed T + Free P ; Sink Fixed P
|
||||
* Boundary_pT: Fixed P + Fixed T + Free ṁ ; one P-anchor if isobaric
|
||||
*/
|
||||
export function enforceModelicaBoundaryEmit(
|
||||
components: Record<string, unknown>[],
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): void {
|
||||
const byName = new Map(components.map((c) => [String(c.name), c]));
|
||||
|
||||
for (const sourceNode of nodes) {
|
||||
if (sourceNode.data.type !== "BrineSource" && sourceNode.data.type !== "AirSource") {
|
||||
continue;
|
||||
}
|
||||
const sourceComp = byName.get(sourceNode.data.name);
|
||||
if (!sourceComp) continue;
|
||||
|
||||
const sinkType = sourceNode.data.type === "AirSource" ? "AirSink" : "BrineSink";
|
||||
const sinkNode = findConnectedSecondaryBoundary(sourceNode.id, nodes, edges, sinkType);
|
||||
const sinkComp = sinkNode ? byName.get(sinkNode.data.name) : undefined;
|
||||
const isobaric =
|
||||
!!sinkNode && pathHasIsobaricSecondary(sourceNode.id, sinkNode.id, nodes, edges);
|
||||
|
||||
// Fixed T_out on sink ⇒ Free ṁ (energy closes ṁ)
|
||||
if (sinkComp?.fix_temperature === true) {
|
||||
sourceComp.fix_mass_flow = false;
|
||||
}
|
||||
|
||||
// Never Fixed P and Fixed ṁ on the same source (Modelica local balance)
|
||||
if (sourceComp.fix_mass_flow === true) {
|
||||
sourceComp.fix_pressure = false;
|
||||
}
|
||||
|
||||
// Sink is the pressure anchor when present
|
||||
if (sinkComp) {
|
||||
if (sinkComp.fix_pressure === undefined) sinkComp.fix_pressure = true;
|
||||
|
||||
// Isobaric secondary: only one P Dirichlet (sink). Source Free P.
|
||||
// (Boundary_pT×2 requires hydraulic ΔP — MSL SimplePipeline + StaticPipe.)
|
||||
if (isobaric && sinkComp.fix_pressure !== false) {
|
||||
sourceComp.fix_pressure = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stripCliFixFlags(
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const out: Record<string, number | string | boolean> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (k === "fix_pressure" || k === "fix_temperature" || k === "fix_mass_flow") continue;
|
||||
if (k.startsWith(FIXED_FLAG_PREFIX)) {
|
||||
out[k] = v;
|
||||
continue;
|
||||
}
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
94
apps/web/src/lib/componentDocMap.ts
Normal file
94
apps/web/src/lib/componentDocMap.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Maps Entropyk component types → docs/components/<slug>.md
|
||||
* Served in the web app from /docs/components/<slug>.md
|
||||
*/
|
||||
|
||||
export const COMPONENT_DOC_SLUG: Record<string, string> = {
|
||||
IsentropicCompressor: "isentropic-compressor",
|
||||
Compressor: "compressor-ahri540",
|
||||
ScrewEconomizerCompressor: "screw-economizer-compressor",
|
||||
ScrewCompressor: "screw-economizer-compressor",
|
||||
Condenser: "condenser",
|
||||
CondenserCoil: "condenser",
|
||||
Evaporator: "evaporator",
|
||||
EvaporatorCoil: "evaporator",
|
||||
FloodedEvaporator: "flooded-evaporator",
|
||||
FloodedCondenser: "flooded-condenser",
|
||||
BphxEvaporator: "bphx",
|
||||
BphxCondenser: "bphx",
|
||||
AirCooledCondenser: "air-cooled-condenser",
|
||||
FinCoilCondenser: "fin-coil-condenser",
|
||||
MchxCondenserCoil: "mchx-condenser-coil",
|
||||
MchxCoil: "mchx-condenser-coil",
|
||||
HeatExchanger: "heat-exchanger-generic",
|
||||
FreeCoolingExchanger: "free-cooling-exchanger",
|
||||
FreeCooling: "free-cooling-exchanger",
|
||||
Economizer: "economizer",
|
||||
MovingBoundaryHX: "moving-boundary-hx",
|
||||
IsenthalpicExpansionValve: "isenthalpic-expansion-valve",
|
||||
EXV: "isenthalpic-expansion-valve",
|
||||
ExpansionValve: "expansion-valve",
|
||||
BypassValve: "bypass-valve",
|
||||
ReversingValve: "reversing-valve",
|
||||
FourWayValve: "reversing-valve",
|
||||
Pipe: "pipe",
|
||||
Drum: "drum",
|
||||
FlowSplitter: "flow-splitter",
|
||||
FlowMerger: "flow-merger",
|
||||
Pump: "pump",
|
||||
Fan: "fan",
|
||||
RefrigerantSource: "boundaries",
|
||||
RefrigerantSink: "boundaries",
|
||||
BrineSource: "boundaries",
|
||||
BrineSink: "boundaries",
|
||||
AirSource: "boundaries",
|
||||
AirSink: "boundaries",
|
||||
Anchor: "anchor-heat-source",
|
||||
RefrigerantNode: "anchor-heat-source",
|
||||
HeatSource: "anchor-heat-source",
|
||||
ThermalLoad: "thermal-load",
|
||||
// Master index of correlations & compressor maps
|
||||
__correlations: "correlations-and-maps",
|
||||
};
|
||||
|
||||
/** Short model formula shown in the parameter panel header. */
|
||||
export function modelBannerForType(type: string): string | undefined {
|
||||
switch (type) {
|
||||
case "IsentropicCompressor":
|
||||
return "Modèle : h_dis = h_suc+(h_is−h_suc)/η_is · ṁ = ρ·V·N·η_vol (émergent)";
|
||||
case "Compressor":
|
||||
return "Ahri540 : ṁ(M1–M2,P,ρ,V,N) · Ẇ(M3–M10) | SstSdt : ṁ,Ẇ = a00+a10·SST+a01·SDT+a11·SST·SDT";
|
||||
case "ScrewEconomizerCompressor":
|
||||
case "ScrewCompressor":
|
||||
return "Carte bilinéaire SST/SDT : ṁ,Ẇ = a00+a10·SST+a01·SDT+a11·SST·SDT";
|
||||
case "BphxEvaporator":
|
||||
case "BphxCondenser":
|
||||
return "Corrélation Longo/Shah → h → UA · résidus ε-NTU";
|
||||
case "Condenser":
|
||||
case "CondenserCoil":
|
||||
return "ε-NTU biphasique : Q = ε·C_sec·(T_cond−T_sec) · UA global";
|
||||
case "Evaporator":
|
||||
case "EvaporatorCoil":
|
||||
return "ε-NTU DX : Q = ε·C_sec·(T_sec−T_evap) · clôture superheat";
|
||||
case "FloodedEvaporator":
|
||||
return "ε-NTU noyé : Q = ε·C_sec·(T_sec−T_evap) · h_out≈h_g(P)";
|
||||
case "IsenthalpicExpansionValve":
|
||||
case "EXV":
|
||||
return "Isenthalpe h_out=h_in · option orifice ṁ=Kv·op·√(2ρΔP)";
|
||||
case "Pump":
|
||||
return "Courbes 1D H(Q), η(Q) + affinité vitesse";
|
||||
case "Fan":
|
||||
return "Courbes 1D ΔP(Q), puissance · affinité vitesse";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function docSlugForType(type: string): string | undefined {
|
||||
return COMPONENT_DOC_SLUG[type];
|
||||
}
|
||||
|
||||
export function docUrlForType(type: string): string | undefined {
|
||||
const slug = docSlugForType(type);
|
||||
return slug ? `/docs/components/${slug}.md` : undefined;
|
||||
}
|
||||
60
apps/web/src/lib/componentInspector.test.ts
Normal file
60
apps/web/src/lib/componentInspector.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { buildComponentInspector } from "./componentInspector";
|
||||
import type { SimulationResult } from "./api";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, params: {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildComponentInspector", () => {
|
||||
it("computes ΔP, ṁ and port table from enriched state", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp"),
|
||||
node("d", "Condenser", "cond"),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: "e0",
|
||||
source: "c",
|
||||
target: "d",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
},
|
||||
];
|
||||
const result: SimulationResult = {
|
||||
input: "t",
|
||||
status: "converged",
|
||||
state: [
|
||||
{
|
||||
edge: 0,
|
||||
pressure_bar: 12.5,
|
||||
enthalpy_kj_kg: 430,
|
||||
mass_flow_kg_s: 0.05,
|
||||
source: "comp",
|
||||
target: "cond",
|
||||
source_port: "outlet",
|
||||
target_port: "inlet",
|
||||
temperature_c: 55,
|
||||
saturation_temperature_c: 48,
|
||||
},
|
||||
],
|
||||
};
|
||||
const insp = buildComponentInspector(nodes[0], nodes, edges, result);
|
||||
expect(insp.title).toBe("comp");
|
||||
expect(insp.ports).toHaveLength(1);
|
||||
expect(insp.ports[0].port).toBe("outlet");
|
||||
expect(insp.ports[0].pressure_bar).toBe(12.5);
|
||||
expect(insp.summaryLines.some((l) => l.includes("ṁ"))).toBe(true);
|
||||
});
|
||||
});
|
||||
224
apps/web/src/lib/componentInspector.ts
Normal file
224
apps/web/src/lib/componentInspector.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Modelica / Dymola-style post-solve variables for a selected component.
|
||||
* Built from diagram wiring + enriched `SimulationResult.state` edges.
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { COMPONENT_BY_TYPE, isSecondaryPort } from "./componentMeta";
|
||||
import type { SimulationResult } from "./api";
|
||||
|
||||
export interface PortVariable {
|
||||
port: string;
|
||||
role: "in" | "out";
|
||||
stream: "primary" | "secondary" | "other";
|
||||
pressure_bar: number | null;
|
||||
temperature_c: number | null;
|
||||
tsat_c: number | null;
|
||||
enthalpy_kj_kg: number | null;
|
||||
mass_flow_kg_s: number | null;
|
||||
edgeIndex: number;
|
||||
}
|
||||
|
||||
export interface ComponentInspector {
|
||||
title: string;
|
||||
typeLabel: string;
|
||||
type: string;
|
||||
status: string | null;
|
||||
ports: PortVariable[];
|
||||
/** Primary-side pressure drop [bar] (in − out). */
|
||||
delta_p_bar: number | null;
|
||||
/** Secondary-side pressure drop [bar]. */
|
||||
delta_p_sec_bar: number | null;
|
||||
/** m·Δh on primary [kW]. */
|
||||
q_primary_kw: number | null;
|
||||
/** m·Δh on secondary [kW]. */
|
||||
q_secondary_kw: number | null;
|
||||
/** |W| shaft estimate for compressors/fans/pumps [kW]. */
|
||||
work_kw: number | null;
|
||||
superheat_k: number | null;
|
||||
subcooling_k: number | null;
|
||||
summaryLines: string[];
|
||||
}
|
||||
|
||||
type StateEntry = NonNullable<SimulationResult["state"]>[number];
|
||||
|
||||
function streamOf(port: string): PortVariable["stream"] {
|
||||
if (isSecondaryPort(port)) return "secondary";
|
||||
if (/inlet|outlet|suction|discharge/i.test(port)) return "primary";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function roleOf(port: string, isSource: boolean): PortVariable["role"] {
|
||||
if (/outlet|discharge/i.test(port)) return "out";
|
||||
if (/inlet|suction/i.test(port)) return "in";
|
||||
return isSource ? "out" : "in";
|
||||
}
|
||||
|
||||
function findStateForEdge(
|
||||
edgeIndex: number,
|
||||
edge: Edge,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
state: StateEntry[] | undefined,
|
||||
): StateEntry | undefined {
|
||||
if (!state?.length) return undefined;
|
||||
const byIndex = state[edgeIndex];
|
||||
if (byIndex) return byIndex;
|
||||
|
||||
const src = nodes.find((n) => n.id === edge.source)?.data.name;
|
||||
const tgt = nodes.find((n) => n.id === edge.target)?.data.name;
|
||||
if (!src || !tgt) return undefined;
|
||||
return state.find((s) => s.source === src && s.target === tgt);
|
||||
}
|
||||
|
||||
/** Build Modelica-style variables for the selected component after a solve. */
|
||||
export function buildComponentInspector(
|
||||
node: Node<EntropykNodeData>,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
result: SimulationResult | null,
|
||||
): ComponentInspector {
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
const title = node.data.name;
|
||||
const typeLabel = meta?.label ?? node.data.type;
|
||||
const status = result ? String(result.status) : null;
|
||||
const state = result?.state;
|
||||
|
||||
const ports: PortVariable[] = [];
|
||||
edges.forEach((edge, edgeIndex) => {
|
||||
const touches =
|
||||
edge.source === node.id || edge.target === node.id;
|
||||
if (!touches) return;
|
||||
const isSource = edge.source === node.id;
|
||||
const handle = isSource
|
||||
? (edge.sourceHandle ?? "outlet")
|
||||
: (edge.targetHandle ?? "inlet");
|
||||
const entry = findStateForEdge(edgeIndex, edge, nodes, state);
|
||||
const stream = streamOf(handle);
|
||||
// Infer secondary from partner name when handle is plain outlet (BrineSource).
|
||||
const partnerName = isSource
|
||||
? nodes.find((n) => n.id === edge.target)?.data.name ?? ""
|
||||
: nodes.find((n) => n.id === edge.source)?.data.name ?? "";
|
||||
const selfName = node.data.name;
|
||||
const looksWater =
|
||||
stream === "secondary" ||
|
||||
/water|brine|glycol/i.test(`${selfName} ${partnerName}`) ||
|
||||
/Brine|WaterPipe/i.test(node.data.type);
|
||||
const looksAir =
|
||||
/air|duct/i.test(`${selfName} ${partnerName}`) || /AirSource|AirSink|AirDuct/i.test(node.data.type);
|
||||
|
||||
ports.push({
|
||||
port: handle,
|
||||
role: roleOf(handle, isSource),
|
||||
stream: looksAir ? "other" : looksWater ? "secondary" : stream,
|
||||
pressure_bar: entry?.pressure_bar ?? null,
|
||||
temperature_c: entry?.temperature_c ?? null,
|
||||
// Tsat only meaningful on refrigerant — hide for water/air loops.
|
||||
tsat_c:
|
||||
looksWater || looksAir ? null : (entry?.saturation_temperature_c ?? null),
|
||||
enthalpy_kj_kg: entry?.enthalpy_kj_kg ?? null,
|
||||
mass_flow_kg_s: entry?.mass_flow_kg_s ?? null,
|
||||
edgeIndex,
|
||||
});
|
||||
});
|
||||
|
||||
// Prefer meta port order
|
||||
const order = meta?.ports ?? [];
|
||||
ports.sort((a, b) => {
|
||||
const ia = order.indexOf(a.port);
|
||||
const ib = order.indexOf(b.port);
|
||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
||||
if (ia >= 0) return -1;
|
||||
if (ib >= 0) return 1;
|
||||
return a.port.localeCompare(b.port);
|
||||
});
|
||||
|
||||
const primaryIn = ports.find((p) => p.stream === "primary" && p.role === "in");
|
||||
const primaryOut = ports.find((p) => p.stream === "primary" && p.role === "out");
|
||||
const secIn = ports.find((p) => p.stream === "secondary" && p.role === "in");
|
||||
const secOut = ports.find((p) => p.stream === "secondary" && p.role === "out");
|
||||
|
||||
const delta_p_bar =
|
||||
primaryIn?.pressure_bar != null && primaryOut?.pressure_bar != null
|
||||
? primaryIn.pressure_bar - primaryOut.pressure_bar
|
||||
: null;
|
||||
const delta_p_sec_bar =
|
||||
secIn?.pressure_bar != null && secOut?.pressure_bar != null
|
||||
? secIn.pressure_bar - secOut.pressure_bar
|
||||
: null;
|
||||
|
||||
const q_primary_kw = streamDutyKw(primaryIn, primaryOut);
|
||||
const q_secondary_kw = streamDutyKw(secIn, secOut);
|
||||
|
||||
const isMachine = /Compressor|Pump|Fan|Centrifugal/i.test(node.data.type);
|
||||
const work_kw = isMachine && q_primary_kw != null ? Math.abs(q_primary_kw) : null;
|
||||
|
||||
let superheat_k: number | null = null;
|
||||
let subcooling_k: number | null = null;
|
||||
if (
|
||||
primaryOut?.temperature_c != null &&
|
||||
primaryOut.tsat_c != null &&
|
||||
/Evaporator|Flooded|BphxEvap/i.test(node.data.type)
|
||||
) {
|
||||
superheat_k = primaryOut.temperature_c - primaryOut.tsat_c;
|
||||
}
|
||||
if (
|
||||
primaryOut?.temperature_c != null &&
|
||||
primaryOut.tsat_c != null &&
|
||||
/Condenser|BphxCond|GasCooler/i.test(node.data.type)
|
||||
) {
|
||||
subcooling_k = primaryOut.tsat_c - primaryOut.temperature_c;
|
||||
}
|
||||
// Suction superheat at compressor inlet
|
||||
if (
|
||||
primaryIn?.temperature_c != null &&
|
||||
primaryIn.tsat_c != null &&
|
||||
/Compressor/i.test(node.data.type)
|
||||
) {
|
||||
superheat_k = primaryIn.temperature_c - primaryIn.tsat_c;
|
||||
}
|
||||
|
||||
const summaryLines: string[] = [];
|
||||
if (delta_p_bar != null) summaryLines.push(`ΔP = ${fmt(delta_p_bar, 3)} bar`);
|
||||
if (delta_p_sec_bar != null) summaryLines.push(`ΔP sec = ${fmt(delta_p_sec_bar, 3)} bar`);
|
||||
if (q_primary_kw != null) summaryLines.push(`Q̇ = ${fmt(Math.abs(q_primary_kw), 2)} kW`);
|
||||
if (q_secondary_kw != null) summaryLines.push(`Q̇ sec = ${fmt(Math.abs(q_secondary_kw), 2)} kW`);
|
||||
if (work_kw != null) summaryLines.push(`Ẇ = ${fmt(work_kw, 2)} kW`);
|
||||
if (superheat_k != null) summaryLines.push(`SH = ${fmt(superheat_k, 2)} K`);
|
||||
if (subcooling_k != null) summaryLines.push(`SC = ${fmt(subcooling_k, 2)} K`);
|
||||
const m = primaryIn?.mass_flow_kg_s ?? primaryOut?.mass_flow_kg_s;
|
||||
if (m != null) summaryLines.push(`ṁ = ${fmt(m, 4)} kg/s`);
|
||||
|
||||
return {
|
||||
title,
|
||||
typeLabel,
|
||||
type: node.data.type,
|
||||
status,
|
||||
ports,
|
||||
delta_p_bar,
|
||||
delta_p_sec_bar,
|
||||
q_primary_kw,
|
||||
q_secondary_kw,
|
||||
work_kw,
|
||||
superheat_k,
|
||||
subcooling_k,
|
||||
summaryLines,
|
||||
};
|
||||
}
|
||||
|
||||
function streamDutyKw(
|
||||
inn: PortVariable | undefined,
|
||||
out: PortVariable | undefined,
|
||||
): number | null {
|
||||
if (!inn || !out) return null;
|
||||
const m = inn.mass_flow_kg_s ?? out.mass_flow_kg_s;
|
||||
const hIn = inn.enthalpy_kj_kg;
|
||||
const hOut = out.enthalpy_kj_kg;
|
||||
if (m == null || hIn == null || hOut == null) return null;
|
||||
// kW = kg/s · kJ/kg
|
||||
return m * (hOut - hIn);
|
||||
}
|
||||
|
||||
function fmt(v: number, digits: number): string {
|
||||
return Number.isFinite(v) ? v.toFixed(digits) : "—";
|
||||
}
|
||||
213
apps/web/src/lib/componentMeta.test.ts
Normal file
213
apps/web/src/lib/componentMeta.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
COMPONENTS,
|
||||
COMPONENT_BY_TYPE,
|
||||
COMPONENT_CATEGORIES,
|
||||
defaultParams,
|
||||
isSecondaryPort,
|
||||
nodeSize,
|
||||
portLabel,
|
||||
portRole,
|
||||
portSide,
|
||||
} from "./componentMeta";
|
||||
|
||||
describe("portSide", () => {
|
||||
it("places inlet/suction on the left", () => {
|
||||
expect(portSide("inlet")).toBe("left");
|
||||
expect(portSide("inlet_a")).toBe("left");
|
||||
expect(portSide("suction")).toBe("left");
|
||||
});
|
||||
|
||||
it("places outlet/discharge on the right", () => {
|
||||
expect(portSide("outlet")).toBe("right");
|
||||
expect(portSide("outlet_b")).toBe("right");
|
||||
expect(portSide("discharge")).toBe("right");
|
||||
});
|
||||
|
||||
it("places liquid/vapor outlets on the bottom and economizer on top", () => {
|
||||
expect(portSide("liquid_outlet")).toBe("bottom");
|
||||
expect(portSide("vapor_outlet")).toBe("bottom");
|
||||
expect(portSide("economizer")).toBe("top");
|
||||
});
|
||||
|
||||
it("places the caloporteur inlet at the bottom and outlet at the top", () => {
|
||||
expect(portSide("secondary_inlet")).toBe("bottom");
|
||||
expect(portSide("secondary_outlet")).toBe("top");
|
||||
});
|
||||
|
||||
it("defaults unknown ports to the left", () => {
|
||||
expect(portSide("mystery")).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secondary (caloporteur) ports", () => {
|
||||
it("identifies the secondary ports", () => {
|
||||
expect(isSecondaryPort("secondary_inlet")).toBe(true);
|
||||
expect(isSecondaryPort("secondary_outlet")).toBe(true);
|
||||
expect(isSecondaryPort("inlet")).toBe(false);
|
||||
expect(isSecondaryPort("outlet")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats the secondary inlet as a target and outlet as a source", () => {
|
||||
expect(portRole("secondary_inlet")).toBe("target");
|
||||
expect(portRole("secondary_outlet")).toBe("source");
|
||||
});
|
||||
|
||||
it("labels the secondary ports as htf", () => {
|
||||
expect(portLabel("secondary_inlet")).toBe("htf·in");
|
||||
expect(portLabel("secondary_outlet")).toBe("htf·out");
|
||||
});
|
||||
|
||||
it("exposes caloporteur ports on the main heat exchangers", () => {
|
||||
for (const type of [
|
||||
"Condenser",
|
||||
"Evaporator",
|
||||
"FloodedEvaporator",
|
||||
"FinCoilCondenser",
|
||||
"BphxEvaporator",
|
||||
"BphxCondenser",
|
||||
]) {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
expect(meta.ports, type).toContain("secondary_inlet");
|
||||
expect(meta.ports, type).toContain("secondary_outlet");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("portRole", () => {
|
||||
it("treats outlets/discharge/*_outlet as sources", () => {
|
||||
expect(portRole("outlet")).toBe("source");
|
||||
expect(portRole("discharge")).toBe("source");
|
||||
expect(portRole("liquid_outlet")).toBe("source");
|
||||
expect(portRole("hot_outlet")).toBe("source");
|
||||
});
|
||||
|
||||
it("treats inlets/suction as targets", () => {
|
||||
expect(portRole("inlet")).toBe("target");
|
||||
expect(portRole("suction")).toBe("target");
|
||||
expect(portRole("cold_inlet")).toBe("target");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeSize", () => {
|
||||
it("gives compressors a square footprint", () => {
|
||||
const s = nodeSize("IsentropicCompressor");
|
||||
expect(s.w).toBe(s.h);
|
||||
});
|
||||
|
||||
it("makes heat exchangers wider than tall", () => {
|
||||
const s = nodeSize("Condenser");
|
||||
expect(s.w).toBeGreaterThan(s.h);
|
||||
});
|
||||
|
||||
it("makes the separator (drum) taller than wide", () => {
|
||||
const s = nodeSize("Drum");
|
||||
expect(s.h).toBeGreaterThan(s.w);
|
||||
});
|
||||
|
||||
it("returns positive dimensions for unknown types", () => {
|
||||
const s = nodeSize("Whatever");
|
||||
expect(s.w).toBeGreaterThan(0);
|
||||
expect(s.h).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portLabel", () => {
|
||||
it("abbreviates known ports", () => {
|
||||
expect(portLabel("inlet")).toBe("in");
|
||||
expect(portLabel("discharge")).toBe("dis");
|
||||
expect(portLabel("economizer")).toBe("eco");
|
||||
});
|
||||
|
||||
it("falls back to the raw port name", () => {
|
||||
expect(portLabel("custom_port")).toBe("custom_port");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultParams", () => {
|
||||
it("returns the declared default for each param", () => {
|
||||
const params = defaultParams("IsentropicCompressor");
|
||||
expect(params.isentropic_efficiency).toBe(0.75);
|
||||
expect(params.t_cond_k).toBe(323.15);
|
||||
});
|
||||
|
||||
it("returns an empty object for an unknown type", () => {
|
||||
expect(defaultParams("DoesNotExist")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("COMPONENTS catalogue integrity", () => {
|
||||
it("has a unique type for every component", () => {
|
||||
const types = COMPONENTS.map((c) => c.type);
|
||||
expect(new Set(types).size).toBe(types.length);
|
||||
});
|
||||
|
||||
it("indexes every component in COMPONENT_BY_TYPE", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
expect(COMPONENT_BY_TYPE[c.type]).toBe(c);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares at least one port per component", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
if (c.category === "Controls" || c.category === "Advanced") continue;
|
||||
if (c.type === "SaturatedController") continue;
|
||||
expect(c.ports.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses only declared categories", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
expect(COMPONENT_CATEGORIES).toContain(c.category);
|
||||
}
|
||||
});
|
||||
|
||||
it("has unique param keys within each component", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
const keys = c.params.map((p) => p.key);
|
||||
expect(new Set(keys).size, `duplicate param in ${c.type}`).toBe(keys.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses hex colours", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
expect(c.color, c.type).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes detailed fin-coil engineering geometry", () => {
|
||||
const keys = COMPONENT_BY_TYPE.FinCoilCondenser.params.map((param) => param.key);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"manufacturer",
|
||||
"model",
|
||||
"face_width_m",
|
||||
"face_height_m",
|
||||
"n_rows",
|
||||
"fin_type",
|
||||
"fin_pitch_fpi",
|
||||
"tube_od_mm",
|
||||
"tube_pitch_mm",
|
||||
"air_face_velocity_m_s",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes manufacturer and plate-pack data for both BPHX models", () => {
|
||||
for (const type of ["BphxEvaporator", "BphxCondenser"]) {
|
||||
const keys = COMPONENT_BY_TYPE[type].params.map((param) => param.key);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"manufacturer",
|
||||
"model",
|
||||
"n_plates",
|
||||
"plate_length_m",
|
||||
"plate_width_m",
|
||||
"channel_spacing_mm",
|
||||
"chevron_angle_deg",
|
||||
"correlation",
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
1825
apps/web/src/lib/componentMeta.ts
Normal file
1825
apps/web/src/lib/componentMeta.ts
Normal file
File diff suppressed because it is too large
Load Diff
476
apps/web/src/lib/configBuilder.test.ts
Normal file
476
apps/web/src/lib/configBuilder.test.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
applyBoundaryFixSemantics,
|
||||
buildScenarioConfig,
|
||||
buildFixedFreeCalibrationControls,
|
||||
resolveSecondaryStreams,
|
||||
stripUiOnlyParams,
|
||||
validateConfig,
|
||||
SCHEMA_VERSION,
|
||||
type EntropykNodeData,
|
||||
} from "./configBuilder";
|
||||
import { fixedFlagKey } from "./componentMeta";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
circuit: number,
|
||||
params: Record<string, number | string | boolean> = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit, rotation: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildScenarioConfig", () => {
|
||||
it("groups components by circuit, sorted by id", () => {
|
||||
const nodes = [
|
||||
node("a", "Condenser", "cond", 1, { ua: 5000 }),
|
||||
node("b", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
expect(cfg.circuits.map((c) => c.id)).toEqual([0, 1]);
|
||||
expect(cfg.circuits[0].components[0].name).toBe("evap");
|
||||
expect(cfg.circuits[1].components[0].name).toBe("cond");
|
||||
});
|
||||
|
||||
it("flattens params to the top level of each component", () => {
|
||||
const nodes = [node("a", "Condenser", "cond", 0, { ua: 5000, t_sat_k: 320 })];
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
const comp = cfg.circuits[0].components[0];
|
||||
expect(comp).toMatchObject({ type: "Condenser", name: "cond", ua: 5000, t_sat_k: 320 });
|
||||
});
|
||||
|
||||
it("translates edge handles into name:port references", () => {
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0),
|
||||
node("b", "Condenser", "cond", 0),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "a", target: "b", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.circuits[0].edges).toEqual([{ from: "comp:outlet", to: "cond:inlet" }]);
|
||||
});
|
||||
|
||||
it("only keeps edges whose endpoints are in the same circuit", () => {
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0),
|
||||
node("b", "Condenser", "cond", 1),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "a", target: "b", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.circuits[0].edges).toHaveLength(0);
|
||||
expect(cfg.circuits[1].edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to outlet→inlet when handles are missing", () => {
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0),
|
||||
node("b", "Condenser", "cond", 0),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "a", target: "b", sourceHandle: null, targetHandle: null },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
// Source role → outlet-like; target role → inlet-like
|
||||
expect(cfg.circuits[0].edges[0]).toEqual({ from: "comp:outlet", to: "cond:inlet" });
|
||||
});
|
||||
|
||||
it("applies fluid / backend / solver options and defaults", () => {
|
||||
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], [], {
|
||||
fluid: "R290",
|
||||
fluidBackend: "Test",
|
||||
maxIterations: 50,
|
||||
});
|
||||
expect(cfg.fluid).toBe("R290");
|
||||
expect(cfg.fluid_backend).toBe("Test");
|
||||
expect(cfg.solver.max_iterations).toBe(50);
|
||||
expect(cfg.solver.tolerance).toBe(1e-6);
|
||||
|
||||
const defaults = buildScenarioConfig([], []);
|
||||
expect(defaults.fluid).toBe("R410A");
|
||||
expect(defaults.fluid_backend).toBe("CoolProp");
|
||||
expect(defaults.solver.strategy).toBe("newton");
|
||||
});
|
||||
|
||||
it("emits the unified IR schema_version", () => {
|
||||
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], []);
|
||||
expect(cfg.schema_version).toBe(SCHEMA_VERSION);
|
||||
expect(cfg.schema_version).toBe("2");
|
||||
});
|
||||
|
||||
it("passes control loops through into the IR", () => {
|
||||
const controls = [
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "cap",
|
||||
measure: { component: "cond", output: "capacity" },
|
||||
actuator: { component: "comp", factor: "f_m", initial: 1.0, min: 0.5, max: 1.5 },
|
||||
target: 7000,
|
||||
gain: 0.01,
|
||||
band: 1.0,
|
||||
},
|
||||
];
|
||||
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], [], { controls });
|
||||
expect(cfg.controls).toEqual(controls);
|
||||
|
||||
// No controls -> the field is omitted (stays a clean v1-compatible document).
|
||||
const bare = buildScenarioConfig([node("a", "Condenser", "cond", 0)], []);
|
||||
expect(bare.controls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exports visible controller nodes as controls, not physical components", () => {
|
||||
const nodes = [
|
||||
node("comp", "IsentropicCompressor", "comp", 0, { liquid_injection: true }),
|
||||
node("ctrl", "SaturatedController", "dgt_limiter", 0, {
|
||||
measure_component: "comp",
|
||||
measure_output: "temperature",
|
||||
actuator_component: "comp",
|
||||
actuator_factor: "injection",
|
||||
initial: 0.15,
|
||||
min: 0,
|
||||
max: 0.3,
|
||||
target: 330,
|
||||
gain: -0.5,
|
||||
band: 5,
|
||||
alpha: 0.002,
|
||||
objectives_json: JSON.stringify([
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
];
|
||||
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
expect(cfg.circuits[0].components.map((c) => c.type)).toEqual(["IsentropicCompressor"]);
|
||||
expect(cfg.controls).toEqual([
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "dgt_limiter",
|
||||
measure: { component: "comp", output: "temperature" },
|
||||
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
||||
target: 330,
|
||||
gain: -0.5,
|
||||
band: 5,
|
||||
objectives: [
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
],
|
||||
alpha: 0.002,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("flags an empty diagram", () => {
|
||||
expect(validateConfig([], [])).toContain("Add at least one component.");
|
||||
});
|
||||
|
||||
it("flags duplicate names within a circuit", () => {
|
||||
const nodes = [
|
||||
node("a", "Condenser", "dup", 0),
|
||||
node("b", "Evaporator", "dup", 0),
|
||||
];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("Duplicate component name"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows identical names in different circuits", () => {
|
||||
const nodes = [
|
||||
node("a", "Condenser", "hx", 0),
|
||||
node("b", "Evaporator", "hx", 1),
|
||||
];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("Duplicate"))).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a missing required parameter", () => {
|
||||
// Condenser requires `ua`; omit it.
|
||||
const nodes = [node("a", "Condenser", "cond", 0, { t_sat_k: 320 })];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("required parameter"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags an unknown component type", () => {
|
||||
const nodes = [node("a", "Nonexistent", "x", 0)];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("Unknown component type"))).toBe(true);
|
||||
});
|
||||
|
||||
it("passes a valid single-component diagram without secondary ports", () => {
|
||||
// Compressors have no secondary ports — pure required-param check.
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0, {
|
||||
isentropic_efficiency: 0.7,
|
||||
t_cond_k: 320,
|
||||
t_evap_k: 280,
|
||||
superheat_k: 5,
|
||||
}),
|
||||
];
|
||||
expect(validateConfig(nodes, [])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("requires live secondary ports OR rating scalars for heat exchangers", () => {
|
||||
const bare = [node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 })];
|
||||
expect(validateConfig(bare, []).some((i) => i.includes("secondary incomplete"))).toBe(true);
|
||||
|
||||
// Rating mode: scalars only, no edges — must be allowed.
|
||||
const rating = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, {
|
||||
ua: 8000,
|
||||
secondary_inlet_temp_c: 12,
|
||||
secondary_mass_flow_kg_s: 0.5,
|
||||
secondary_cp_j_per_kgk: 4180,
|
||||
}),
|
||||
];
|
||||
expect(validateConfig(rating, []).some((i) => i.includes("secondary incomplete"))).toBe(false);
|
||||
|
||||
// System mode: live secondary ports.
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, { p_set_bar: 2, temperature_c: 10, mass_flow_kg_s: 1.2 }),
|
||||
node("k", "BrineSink", "sink", 0, { p_back_bar: 2 }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
expect(validateConfig(nodes, edges).some((i) => i.includes("secondary incomplete"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("flags quality_control as a DoF risk", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000, quality_control: true }),
|
||||
node("b", "BrineSource", "brine", 0, { p_set_bar: 2, t_set_c: 10, m_flow_kg_s: 1.2 }),
|
||||
node("k", "BrineSink", "sink", 0, { p_back_bar: 2 }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
expect(validateConfig(nodes, edges).some((i) => i.includes("quality_control"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary Fixed/Free (Modelica-style)", () => {
|
||||
it("emits fix_mass_flow=false when ṁ is Free", () => {
|
||||
const raw = {
|
||||
p_set_bar: 2,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.5,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
[fixedFlagKey("m_flow_kg_s")]: false,
|
||||
};
|
||||
const out = stripUiOnlyParams("BrineSource", raw);
|
||||
expect(out.fix_mass_flow).toBe(false);
|
||||
expect(out.fix_pressure).toBe(true);
|
||||
expect(out.fix_temperature).toBe(true);
|
||||
expect(out.m_flow_kg_s).toBe(0.5);
|
||||
});
|
||||
|
||||
it("omits Free sink T_out so CLI does not impose enthalpy", () => {
|
||||
const raw = {
|
||||
p_back_bar: 2,
|
||||
t_set_c: 7,
|
||||
[fixedFlagKey("p_back_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: false,
|
||||
};
|
||||
const out = stripUiOnlyParams("BrineSink", raw);
|
||||
expect(out.t_set_c).toBeUndefined();
|
||||
expect(out.fix_temperature).toBe(false);
|
||||
});
|
||||
|
||||
it("forces Modelica MassFlowSource emit: Free P when Fixed ṁ", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
}),
|
||||
node("k", "BrineSink", "sink", 0, {
|
||||
p_back_bar: 3,
|
||||
[fixedFlagKey("p_back_bar")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
const src = cfg.circuits[0].components.find((c) => c.name === "brine")!;
|
||||
expect(src.fix_mass_flow).toBe(true);
|
||||
expect(src.fix_pressure).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Free ṁ on source without inventing sink T from a source ΔT", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.5,
|
||||
[fixedFlagKey("m_flow_kg_s")]: false,
|
||||
}),
|
||||
node("k", "BrineSink", "sink", 0, {
|
||||
p_back_bar: 3,
|
||||
t_set_c: 7,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
const sink = cfg.circuits[0].components.find((c) => c.name === "sink")!;
|
||||
const src = cfg.circuits[0].components.find((c) => c.name === "brine")!;
|
||||
expect(src.fix_mass_flow).toBe(false);
|
||||
expect(sink.t_set_c).toBe(7);
|
||||
expect(sink.fix_temperature).toBe(true);
|
||||
expect(src).not.toHaveProperty("delta_t_k");
|
||||
});
|
||||
|
||||
it("applyBoundaryFixSemantics leaves non-boundaries unchanged", () => {
|
||||
const out = applyBoundaryFixSemantics("Condenser", { ua: 1000 }, { ua: 1000 });
|
||||
expect(out).toEqual({ ua: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("caloporteur (secondary stream) resolution", () => {
|
||||
it("does not fold or absorb explicit secondary stream nodes", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, { temperature_c: 9, mass_flow_kg_s: 1.4 }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
];
|
||||
const { overrides, absorbed } = resolveSecondaryStreams(nodes, edges);
|
||||
expect(absorbed.size).toBe(0);
|
||||
expect(overrides.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves a secondary sink connected to the exchanger outlet", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
node("k", "BrineSink", "sink", 0, {}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const { absorbed } = resolveSecondaryStreams(nodes, edges);
|
||||
expect(absorbed.has("k")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps caloporteur connections in the solver graph as explicit 4-port branches", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", 0),
|
||||
node("e", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
node("b", "BrineSource", "brine", 0, { temperature_c: 11, mass_flow_kg_s: 2 }),
|
||||
node("k", "BrineSink", "sink", 0, {}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "r1", source: "c", target: "e", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
const circuit = cfg.circuits[0];
|
||||
|
||||
const names = circuit.components.map((c) => c.name);
|
||||
expect(names).toContain("comp");
|
||||
expect(names).toContain("evap");
|
||||
expect(names).toContain("brine");
|
||||
expect(names).toContain("sink");
|
||||
|
||||
expect(circuit.edges).toEqual([
|
||||
{ from: "comp:outlet", to: "evap:inlet" },
|
||||
{ from: "brine:outlet", to: "evap:secondary_inlet" },
|
||||
{ from: "evap:secondary_outlet", to: "sink:inlet" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not absorb non-boundary nodes wired to a secondary port", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
node("p", "Pump", "pump", 0, {}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "p", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
];
|
||||
const { absorbed, overrides } = resolveSecondaryStreams(nodes, edges);
|
||||
expect(absorbed.has("p")).toBe(false);
|
||||
expect(overrides.has("e")).toBe(false);
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.circuits[0].components.map((c) => c.name)).toContain("pump");
|
||||
expect(cfg.circuits[0].edges).toEqual([{ from: "pump:outlet", to: "evap:secondary_inlet" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fixed / Free calibration (Dymola-style checkboxes)", () => {
|
||||
it("emits a control when SST is Fixed and Z_UA is Free", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, {
|
||||
ua: 9000,
|
||||
calib_sst_c: 5,
|
||||
z_ua: 1.0,
|
||||
// Fixed ON for SST, Fixed OFF for Z_UA
|
||||
[fixedFlagKey("calib_sst_c")]: true,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
];
|
||||
const controls = buildFixedFreeCalibrationControls(nodes);
|
||||
expect(controls).toHaveLength(1);
|
||||
expect(controls[0]).toMatchObject({
|
||||
measure: { component: "evap", output: "saturationTemperature" },
|
||||
actuator: { component: "evap", factor: "z_ua" },
|
||||
target: 5 + 273.15,
|
||||
});
|
||||
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
expect(cfg.controls?.length).toBe(1);
|
||||
// UI-only keys stripped from component JSON
|
||||
const comp = cfg.circuits[0].components[0];
|
||||
expect(comp.calib_sst_c).toBeUndefined();
|
||||
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
||||
expect(comp.z_ua).toBe(1.0);
|
||||
});
|
||||
|
||||
it("emits no control when Z_UA stays Fixed", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, {
|
||||
ua: 6000,
|
||||
calib_sst_c: 5,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("calib_sst_c")]: true,
|
||||
[fixedFlagKey("z_ua")]: true,
|
||||
}),
|
||||
];
|
||||
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
687
apps/web/src/lib/configBuilder.ts
Normal file
687
apps/web/src/lib/configBuilder.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* Convert a React Flow graph (nodes + edges) into the ScenarioConfig JSON
|
||||
* expected by the Entropyk CLI / API (crates/cli/src/config.rs).
|
||||
*
|
||||
* ScenarioConfig schema:
|
||||
* {
|
||||
* "fluid": "R410A",
|
||||
* "fluid_backend": "CoolProp",
|
||||
* "circuits": [
|
||||
* {
|
||||
* "id": 0,
|
||||
* "components": [ { "type": "...", "name": "...", ...params } ],
|
||||
* "edges": [ { "from": "comp:outlet", "to": "cond:inlet" } ]
|
||||
* }
|
||||
* ],
|
||||
* "thermal_couplings": [ { "hot_circuit": 0, "cold_circuit": 1, "ua": 6000, "efficiency": 0.95 } ],
|
||||
* "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
|
||||
* }
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
enforceModelicaBoundaryEmit,
|
||||
findConnectedSecondaryBoundary,
|
||||
isBoundaryParamFixed,
|
||||
} from "./boundaryFix";
|
||||
import {
|
||||
COMPONENT_BY_TYPE,
|
||||
FIXED_FLAG_PREFIX,
|
||||
isParamFixed,
|
||||
isSecondaryPort,
|
||||
type ParamMeta,
|
||||
} from "./componentMeta";
|
||||
|
||||
// Re-export for existing imports (PropertiesPanel, dofLedger, tests).
|
||||
export { findConnectedSecondaryBoundary } from "./boundaryFix";
|
||||
|
||||
export interface EntropykNodeData {
|
||||
type: string; // Entropyk component type ("Condenser", ...)
|
||||
name: string; // unique name within circuit
|
||||
circuit: number;
|
||||
params: Record<string, number | string | boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ControlConfig {
|
||||
type?: string;
|
||||
id: string;
|
||||
measure: { component: string; output: string };
|
||||
actuator: { component: string; factor: string; initial?: number; min: number; max: number };
|
||||
target: number;
|
||||
gain?: number;
|
||||
band?: number;
|
||||
smooth_eps?: number;
|
||||
objectives?: ControlObjectiveConfig[];
|
||||
alpha?: number;
|
||||
}
|
||||
|
||||
export interface ControlObjectiveConfig {
|
||||
component: string;
|
||||
output: string;
|
||||
setpoint: number;
|
||||
gain: number;
|
||||
combine: "min" | "max";
|
||||
}
|
||||
|
||||
export interface SubsystemTemplate {
|
||||
params?: Record<string, number | string | boolean>;
|
||||
components: Array<Record<string, unknown>>;
|
||||
edges?: Array<{ from: string; to: string }>;
|
||||
ports?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface InstanceConfig {
|
||||
of: string;
|
||||
name: string;
|
||||
circuit?: number;
|
||||
params?: Record<string, number | string | boolean>;
|
||||
}
|
||||
|
||||
export interface ScenarioConfig {
|
||||
/**
|
||||
* Model IR schema version. "1" is the legacy flat circuits/components/edges
|
||||
* graph; "2" adds controls/subsystems/instances/connections. The web UI emits
|
||||
* the current version so the CLI and every consumer read one unified IR.
|
||||
*/
|
||||
schema_version?: string;
|
||||
name?: string;
|
||||
fluid: string;
|
||||
fluid_backend?: string;
|
||||
circuits: Array<{
|
||||
id: number;
|
||||
name?: string;
|
||||
components: Array<Record<string, unknown>>;
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
}>;
|
||||
thermal_couplings?: Array<{
|
||||
hot_circuit: number;
|
||||
cold_circuit: number;
|
||||
ua: number;
|
||||
efficiency: number;
|
||||
}>;
|
||||
/** Steady-state control loops (co-solved). Mirrors crates/cli config `controls`. */
|
||||
controls?: ControlConfig[];
|
||||
/** Reusable subsystem templates (flattened by the CLI at load time). */
|
||||
subsystems?: Record<string, SubsystemTemplate>;
|
||||
/** Template instantiations. */
|
||||
instances?: InstanceConfig[];
|
||||
/** External connections between instance ports. */
|
||||
connections?: Array<{ from: string; to: string }>;
|
||||
solver: {
|
||||
strategy: string;
|
||||
max_iterations: number;
|
||||
tolerance: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** The Model IR schema version emitted by this UI build (kept in sync with the CLI). */
|
||||
export const SCHEMA_VERSION = "2";
|
||||
export const CONTROL_NODE_TYPE = "SaturatedController";
|
||||
|
||||
export interface BuildOptions {
|
||||
fluid?: string;
|
||||
fluidBackend?: string;
|
||||
solverStrategy?: string;
|
||||
maxIterations?: number;
|
||||
tolerance?: number;
|
||||
thermalCouplings?: Array<{
|
||||
hot_circuit: number;
|
||||
cold_circuit: number;
|
||||
ua: number;
|
||||
efficiency: number;
|
||||
}>;
|
||||
/** Steady-state control loops to co-solve (emitted verbatim into the IR). */
|
||||
controls?: ControlConfig[];
|
||||
}
|
||||
|
||||
const PARAM_ALIASES: Record<string, string[]> = {
|
||||
t_set_c: ["temperature_c", "T"],
|
||||
m_flow_kg_s: ["mass_flow_kg_s", "mass_flow"],
|
||||
rh: ["relative_humidity"],
|
||||
p_set_bar: ["pressure_bar"],
|
||||
p_back_bar: ["pressure_bar"],
|
||||
};
|
||||
|
||||
function getParam(
|
||||
params: Record<string, number | string | boolean>,
|
||||
key: string,
|
||||
): number | string | boolean | undefined {
|
||||
if (params[key] !== undefined) return params[key];
|
||||
for (const alias of PARAM_ALIASES[key] ?? []) {
|
||||
if (params[alias] !== undefined) return params[alias];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function canonicalizeParams(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const next = { ...params };
|
||||
if (type === "BrineSource") {
|
||||
const t = getParam(next, "t_set_c");
|
||||
const m = getParam(next, "m_flow_kg_s");
|
||||
const p = getParam(next, "p_set_bar");
|
||||
if (t !== undefined) next.t_set_c = t;
|
||||
if (m !== undefined) next.m_flow_kg_s = m;
|
||||
if (p !== undefined) next.p_set_bar = p;
|
||||
}
|
||||
if (type === "AirSource") {
|
||||
const t = getParam(next, "t_dry_c") ?? getParam(next, "t_set_c");
|
||||
const m = getParam(next, "m_flow_kg_s");
|
||||
const p = getParam(next, "p_set_bar");
|
||||
const rh = getParam(next, "rh");
|
||||
if (t !== undefined) next.t_dry_c = t;
|
||||
if (m !== undefined) next.m_flow_kg_s = m;
|
||||
if (p !== undefined) next.p_set_bar = p;
|
||||
if (rh !== undefined) next.rh = rh;
|
||||
}
|
||||
if (type === "BrineSink" || type === "AirSink" || type === "RefrigerantSink") {
|
||||
const p = getParam(next, "p_back_bar");
|
||||
if (p !== undefined) next.p_back_bar = p;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each React Flow edge carries the source/target port id on its handle.
|
||||
* The CLI expects "componentName:port" strings, so we translate handle ids
|
||||
* (which are port names like "outlet") into "name:port".
|
||||
*
|
||||
* When the handle is missing, fall back by role: sources use an outlet-like
|
||||
* port, targets an inlet-like port — never both ends as `ports[0]` (inlet),
|
||||
* which breaks pipe splice / manual wires.
|
||||
*/
|
||||
function edgeRef(
|
||||
node: Node<EntropykNodeData> | undefined,
|
||||
handleId: string | null | undefined,
|
||||
role: "source" | "target" = "target",
|
||||
): string {
|
||||
if (!node) return "";
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
const ports = meta?.ports ?? [];
|
||||
if (handleId && ports.includes(handleId)) {
|
||||
return `${node.data.name}:${handleId}`;
|
||||
}
|
||||
const port =
|
||||
role === "source"
|
||||
? ports.find((p) => /outlet|discharge|out$/i.test(p)) ??
|
||||
ports[ports.length - 1] ??
|
||||
"outlet"
|
||||
: ports.find((p) => /inlet|suction|^in$/i.test(p)) ?? ports[0] ?? "inlet";
|
||||
return `${node.data.name}:${port}`;
|
||||
}
|
||||
|
||||
/** A boundary node (Source/Sink) supplies/absorbs a secondary stream. */
|
||||
function isBoundaryNode(node: Node<EntropykNodeData> | undefined): boolean {
|
||||
return !!node && /(?:Source|Sink)$/.test(node.data.type);
|
||||
}
|
||||
|
||||
export interface SecondaryResolution {
|
||||
/** Legacy compatibility: secondary streams are no longer reduced into hidden params. */
|
||||
overrides: Map<string, Record<string, number>>;
|
||||
/** Legacy compatibility: boundary nodes are preserved as explicit solver components. */
|
||||
absorbed: Set<string>;
|
||||
}
|
||||
|
||||
export function resolveSecondaryStreams(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): SecondaryResolution {
|
||||
void nodes;
|
||||
void edges;
|
||||
return { overrides: new Map(), absorbed: new Set() };
|
||||
}
|
||||
|
||||
export function buildScenarioConfig(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
options: BuildOptions = {},
|
||||
): ScenarioConfig {
|
||||
// Group nodes by circuit id.
|
||||
const circuitsMap = new Map<number, Node<EntropykNodeData>[]>();
|
||||
for (const n of nodes) {
|
||||
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);
|
||||
|
||||
const circuits = Array.from(circuitsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([circuitId, cNodes]) => {
|
||||
const components = cNodes
|
||||
.filter((n) => n.data.type !== CONTROL_NODE_TYPE)
|
||||
.map((n) => {
|
||||
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.
|
||||
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;
|
||||
return true;
|
||||
});
|
||||
|
||||
const edgeConfigs = circuitEdges.map((e) => ({
|
||||
from: edgeRef(nodeById.get(e.source), e.sourceHandle, "source"),
|
||||
to: edgeRef(nodeById.get(e.target), e.targetHandle, "target"),
|
||||
}));
|
||||
|
||||
enforceModelicaBoundaryEmit(components, cNodes, circuitEdges);
|
||||
|
||||
return { id: circuitId, name: `Circuit ${circuitId}`, components, edges: edgeConfigs };
|
||||
});
|
||||
|
||||
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),
|
||||
fixedFreeControls,
|
||||
);
|
||||
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
fluid: options.fluid || "R410A",
|
||||
fluid_backend: options.fluidBackend || "CoolProp",
|
||||
circuits,
|
||||
thermal_couplings: options.thermalCouplings || [],
|
||||
...(controls.length > 0 ? { controls } : {}),
|
||||
solver: {
|
||||
strategy: options.solverStrategy || "newton",
|
||||
max_iterations: options.maxIterations ?? 300,
|
||||
tolerance: options.tolerance ?? 1e-6,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove UI-only keys before sending to the CLI:
|
||||
* - `__fixed_*` Fixed checkbox flags
|
||||
* - measure-only calib targets (calib_sst_c, …) — they become control setpoints
|
||||
* - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
|
||||
*/
|
||||
export function stripUiOnlyParams(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const measureOnly = new Set(
|
||||
(meta?.params ?? [])
|
||||
.filter((p) => p.measureOutput && !p.actuatorFactor)
|
||||
.map((p) => p.key),
|
||||
);
|
||||
const out: Record<string, number | string | boolean> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (k.startsWith(FIXED_FLAG_PREFIX)) continue;
|
||||
if (measureOnly.has(k)) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return applyBoundaryFixSemantics(type, out, params);
|
||||
}
|
||||
|
||||
const BOUNDARY_FIX_TYPES = new Set([
|
||||
"BrineSource",
|
||||
"BrineSink",
|
||||
"AirSource",
|
||||
"AirSink",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Translate UI Fixed checkboxes into CLI `fix_*` flags for boundary nodes.
|
||||
* Free sink temperatures omit the Dirichlet key so legacy configs stay valid.
|
||||
*/
|
||||
export function applyBoundaryFixSemantics(
|
||||
type: string,
|
||||
cleaned: Record<string, number | string | boolean>,
|
||||
rawParams: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
if (!BOUNDARY_FIX_TYPES.has(type)) return cleaned;
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
if (!meta) return cleaned;
|
||||
const out = { ...cleaned };
|
||||
|
||||
const pMeta = meta.params.find((p) => p.key === "p_set_bar" || p.key === "p_back_bar");
|
||||
const tMeta = meta.params.find(
|
||||
(p) => p.key === "t_set_c" || p.key === "t_dry_c" || p.key === "t_back_c",
|
||||
);
|
||||
const mMeta = meta.params.find((p) => p.key === "m_flow_kg_s");
|
||||
|
||||
if (pMeta?.fixable) {
|
||||
out.fix_pressure = isBoundaryParamFixed(rawParams, pMeta);
|
||||
}
|
||||
if (tMeta?.fixable) {
|
||||
const fixedT = isBoundaryParamFixed(rawParams, tMeta);
|
||||
out.fix_temperature = fixedT;
|
||||
// Free T on sinks: omit the setpoint so CLI does not impose h (legacy presence rule).
|
||||
if (!fixedT && (type === "BrineSink" || type === "AirSink")) {
|
||||
delete out.t_set_c;
|
||||
delete out.t_back_c;
|
||||
}
|
||||
}
|
||||
if (mMeta?.fixable) {
|
||||
out.fix_mass_flow = isBoundaryParamFixed(rawParams, mMeta);
|
||||
}
|
||||
|
||||
delete out.delta_t_k;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inverse-calibration controls from Fixed checkboxes (EES/Dymola style).
|
||||
*
|
||||
* - Param with `measureOutput` + Fixed ON → impose that measure (setpoint = value)
|
||||
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
|
||||
* Pairs on the same component: each free factor with the first fixed measure.
|
||||
*/
|
||||
export function buildFixedFreeCalibrationControls(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
): ControlConfig[] {
|
||||
const controls: ControlConfig[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.data.type === CONTROL_NODE_TYPE) continue;
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
if (!meta) continue;
|
||||
const params = node.data.params ?? {};
|
||||
|
||||
type Measure = { output: string; target: number; key: string };
|
||||
type FreeAct = { factor: string; initial: number; min: number; max: number; key: string };
|
||||
|
||||
const measures: Measure[] = [];
|
||||
const freeActs: FreeAct[] = [];
|
||||
|
||||
for (const p of meta.params) {
|
||||
if (!p.fixable) continue;
|
||||
const fixed = isParamFixed(params, p);
|
||||
const raw = params[p.key];
|
||||
|
||||
if (p.measureOutput && fixed) {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
measures.push({
|
||||
output: p.measureOutput,
|
||||
target: measureSetpointSi(p, n),
|
||||
key: p.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (p.actuatorFactor && !fixed) {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
const initial = Number.isFinite(n) ? n : 1.0;
|
||||
freeActs.push({
|
||||
factor: p.actuatorFactor,
|
||||
initial,
|
||||
min: p.freeMin ?? 0.1,
|
||||
max: p.freeMax ?? 3.0,
|
||||
key: p.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (freeActs.length === 0 || measures.length === 0) continue;
|
||||
|
||||
for (let i = 0; i < freeActs.length; i++) {
|
||||
const act = freeActs[i];
|
||||
const meas = measures[Math.min(i, measures.length - 1)];
|
||||
controls.push({
|
||||
type: "SaturatedController",
|
||||
id: `calib_${node.data.name}_${act.factor}`,
|
||||
measure: {
|
||||
component: node.data.name,
|
||||
output: meas.output,
|
||||
},
|
||||
actuator: {
|
||||
component: node.data.name,
|
||||
factor: act.factor,
|
||||
initial: act.initial,
|
||||
min: act.min,
|
||||
max: act.max,
|
||||
},
|
||||
target: meas.target,
|
||||
// Negative gain: higher Z_UA → higher capacity / often higher T_sat for flooded
|
||||
// — user can refine; default chosen for UA-style calibration.
|
||||
gain: -0.5,
|
||||
band: 2.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
/** Convert UI measure value to SI expected by the solver (temps → K). */
|
||||
function measureSetpointSi(meta: ParamMeta, value: number): number {
|
||||
const unit = (meta.unit ?? "").toLowerCase();
|
||||
if (unit === "°c" || unit === "c" || meta.key.endsWith("_c")) {
|
||||
return value + 273.15;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function controlNodeToConfig(node: Node<EntropykNodeData>): ControlConfig {
|
||||
const p = node.data.params;
|
||||
const cfg: ControlConfig = {
|
||||
type: "SaturatedController",
|
||||
id: node.data.name,
|
||||
measure: {
|
||||
component: stringParam(p.measure_component, "comp"),
|
||||
output: stringParam(p.measure_output, "temperature"),
|
||||
},
|
||||
actuator: {
|
||||
component: stringParam(p.actuator_component, "comp"),
|
||||
factor: stringParam(p.actuator_factor, "injection"),
|
||||
initial: numberParam(p.initial, 0.15),
|
||||
min: numberParam(p.min, 0.0),
|
||||
max: numberParam(p.max, 0.3),
|
||||
},
|
||||
target: numberParam(p.target, 330.0),
|
||||
gain: numberParam(p.gain, -0.5),
|
||||
band: numberParam(p.band, 5.0),
|
||||
};
|
||||
if (typeof p.smooth_eps === "number" && Number.isFinite(p.smooth_eps)) {
|
||||
cfg.smooth_eps = p.smooth_eps;
|
||||
}
|
||||
const objectives = parseControlObjectives(p.objectives_json);
|
||||
if (objectives.length > 0) cfg.objectives = objectives;
|
||||
if (typeof p.alpha === "number" && Number.isFinite(p.alpha) && p.alpha > 0) {
|
||||
cfg.alpha = p.alpha;
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
export function parseControlObjectives(value: unknown): ControlObjectiveConfig[] {
|
||||
if (typeof value !== "string" || value.trim() === "") return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.flatMap((objective): ControlObjectiveConfig[] => {
|
||||
if (!objective || typeof objective !== "object") return [];
|
||||
const candidate = objective as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.component !== "string" ||
|
||||
typeof candidate.output !== "string" ||
|
||||
typeof candidate.setpoint !== "number" ||
|
||||
!Number.isFinite(candidate.setpoint) ||
|
||||
typeof candidate.gain !== "number" ||
|
||||
!Number.isFinite(candidate.gain) ||
|
||||
(candidate.combine !== "min" && candidate.combine !== "max")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
component: candidate.component,
|
||||
output: candidate.output,
|
||||
setpoint: candidate.setpoint,
|
||||
gain: candidate.gain,
|
||||
combine: candidate.combine,
|
||||
}];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeControls(base: ControlConfig[], fromNodes: ControlConfig[]): ControlConfig[] {
|
||||
const merged = new Map<string, ControlConfig>();
|
||||
for (const control of base) merged.set(control.id, control);
|
||||
for (const control of fromNodes) merged.set(control.id, control);
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function stringParam(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.trim() ? value : fallback;
|
||||
}
|
||||
|
||||
function numberParam(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/** Validate the built config — returns a list of human-readable issues. */
|
||||
export function validateConfig(nodes: Node<EntropykNodeData>[], edges: Edge[]): string[] {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
issues.push("Add at least one component.");
|
||||
}
|
||||
|
||||
// Each circuit must have at least one component.
|
||||
const circuits = new Set(nodes.map((n) => n.data?.circuit ?? 0));
|
||||
for (const c of circuits) {
|
||||
const cNodes = nodes.filter((n) => (n.data?.circuit ?? 0) === c);
|
||||
if (cNodes.length === 0) issues.push(`Circuit ${c} is empty.`);
|
||||
}
|
||||
|
||||
// Duplicate names within a circuit.
|
||||
for (const c of circuits) {
|
||||
const names = nodes
|
||||
.filter((n) => (n.data?.circuit ?? 0) === c)
|
||||
.map((n) => n.data.name);
|
||||
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
||||
if (dupes.length > 0) issues.push(`Duplicate component name(s) in circuit ${c}: ${[...new Set(dupes)].join(", ")}`);
|
||||
}
|
||||
|
||||
// Required params present.
|
||||
for (const n of nodes) {
|
||||
const meta = COMPONENT_BY_TYPE[n.data.type];
|
||||
if (!meta) {
|
||||
issues.push(`Unknown component type "${n.data.type}".`);
|
||||
continue;
|
||||
}
|
||||
for (const p of meta.params) {
|
||||
const params = canonicalizeParams(n.data.type, n.data.params);
|
||||
const supplied = params[p.key] !== undefined && params[p.key] !== "";
|
||||
const fromSecondary = secondaryParamSuppliedByConnection(n, p.key, nodes, edges);
|
||||
// Free fixable params are not required (value is only an initial hint).
|
||||
const needFixed = BOUNDARY_FIX_TYPES.has(n.data.type)
|
||||
? isBoundaryParamFixed(n.data.params, p)
|
||||
: !p.fixable || isParamFixed(n.data.params, p);
|
||||
if (p.required && needFixed && !supplied && !fromSecondary) {
|
||||
issues.push(`${n.data.name}: required parameter "${p.label}" is missing.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Dual-mode HX secondary:
|
||||
// system → both secondary_inlet + secondary_outlet wired (live edges)
|
||||
// rating → scalar T_sec + C_sec (or ṁ·cp) without live edges
|
||||
if (meta.ports.some(isSecondaryPort)) {
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.source === n.id && e.sourceHandle) connected.add(e.sourceHandle);
|
||||
if (e.target === n.id && e.targetHandle) connected.add(e.targetHandle);
|
||||
}
|
||||
const hasIn = connected.has("secondary_inlet");
|
||||
const hasOut = connected.has("secondary_outlet");
|
||||
const liveOk = hasIn && hasOut;
|
||||
const params = canonicalizeParams(n.data.type, n.data.params);
|
||||
const ratingOk = hasRatingSecondaryScalars(params);
|
||||
if (!liveOk && !ratingOk) {
|
||||
issues.push(
|
||||
`${n.data.name}: secondary incomplete — wire secondary_inlet + secondary_outlet ` +
|
||||
`(system mode) OR set rating scalars (secondary_inlet_temp_c + mass flow/cp).`,
|
||||
);
|
||||
} else if ((hasIn || hasOut) && !liveOk) {
|
||||
issues.push(
|
||||
`${n.data.name}: secondary ports partial (need both secondary_inlet and secondary_outlet).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
n.data.type === "FloodedEvaporator" &&
|
||||
(n.data.params?.quality_control === true || n.data.params?.quality_control === "true")
|
||||
) {
|
||||
issues.push(
|
||||
`${n.data.name}: quality_control=true adds +1 FIX residual — free an actuator (EXV/level) ` +
|
||||
`or leave it off for compressor suction models.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Modelica boundary conflicts are reported in the DoF ledger; emit-time
|
||||
// `enforceModelicaBoundaryEmit` auto-corrects legalizable cases (Free P on
|
||||
// MassFlowSource, Free ṁ when Fixed T_out). Hard-block only if emit cannot help.
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function secondaryParamSuppliedByConnection(
|
||||
node: Node<EntropykNodeData>,
|
||||
key: string,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): boolean {
|
||||
if (key !== "secondary_inlet_temp_c" && key !== "secondary_mass_flow_kg_s") return false;
|
||||
const sourceEdge = edges.find((edge) => edge.target === node.id && edge.targetHandle === "secondary_inlet");
|
||||
if (!sourceEdge) return false;
|
||||
const source = nodes.find((candidate) => candidate.id === sourceEdge.source);
|
||||
if (!source || !isBoundaryNode(source)) return false;
|
||||
const params = canonicalizeParams(source.data.type, source.data.params);
|
||||
if (key === "secondary_inlet_temp_c") return params.t_set_c !== undefined || params.t_dry_c !== undefined;
|
||||
return params.m_flow_kg_s !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rating-mode secondary stream is complete when T_sec,in and a positive capacity
|
||||
* rate are available: either C_sec directly, or ṁ·cp (with default cp assumed if
|
||||
* only mass flow is set — matches CLI `parse_secondary_stream` defaults).
|
||||
*/
|
||||
function hasRatingSecondaryScalars(
|
||||
params: Record<string, number | string | boolean | undefined>,
|
||||
): boolean {
|
||||
const t =
|
||||
numParam(params.secondary_inlet_temp_c) ?? numParam(params.secondary_inlet_temp_k);
|
||||
if (t === undefined) return false;
|
||||
|
||||
const cDirect = numParam(params.secondary_capacity_rate_w_per_k);
|
||||
if (cDirect !== undefined && cDirect > 0) return true;
|
||||
|
||||
const m = numParam(params.secondary_mass_flow_kg_s);
|
||||
if (m === undefined || m <= 0) return false;
|
||||
const cp = numParam(params.secondary_cp_j_per_kgk);
|
||||
// CLI supplies a fluid-dependent default cp when only mass flow is given.
|
||||
return cp === undefined || cp > 0;
|
||||
}
|
||||
|
||||
function numParam(v: number | string | boolean | undefined): number | undefined {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "") {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
65
apps/web/src/lib/dofCoach.test.ts
Normal file
65
apps/web/src/lib/dofCoach.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { buildDofCoach } from "./dofCoach";
|
||||
import { hxFamily, hxGlyphKey } from "./hxFamily";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
params: EntropykNodeData["params"] = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropyk",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name: id, circuit: 1, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("hxFamily", () => {
|
||||
it("distinguishes DX / flooded / plate / coil / MCHX", () => {
|
||||
expect(hxFamily("Evaporator")?.badge).toBe("DX");
|
||||
expect(hxFamily("FloodedEvaporator")?.badge).toBe("Noyé");
|
||||
expect(hxFamily("BphxCondenser")?.badge).toBe("Plaques");
|
||||
expect(hxFamily("AirCooledCondenser")?.badge).toBe("Coil");
|
||||
expect(hxFamily("MchxCondenser")?.badge).toBe("MCHX");
|
||||
expect(hxFamily("Condenser")?.badge).toBe("Tubes");
|
||||
expect(hxGlyphKey("FloodedEvaporator")).toBe("hx_flooded");
|
||||
expect(hxGlyphKey("BphxEvaporator")).toBe("hx_plate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDofCoach", () => {
|
||||
it("guides empty canvas", () => {
|
||||
const coach = buildDofCoach([], []);
|
||||
expect(coach.balance).toBe("empty");
|
||||
expect(coach.tips[0]?.id).toBe("empty");
|
||||
});
|
||||
|
||||
it("flags Fixed P + Fixed ṁ on MassFlowSource-style brine source", () => {
|
||||
const nodes = [
|
||||
node("src", "BrineSource", {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 1.2,
|
||||
fix_pressure: true,
|
||||
fix_mass_flow: true,
|
||||
}),
|
||||
];
|
||||
const coach = buildDofCoach(nodes, []);
|
||||
expect(coach.tips.some((t) => t.id.startsWith("mflow-p"))).toBe(true);
|
||||
});
|
||||
|
||||
it("suggests wiring secondary when HX has no secondary edges", () => {
|
||||
const nodes = [
|
||||
node("comp", "ScrollCompressor", {}),
|
||||
node("cond", "Condenser", { ua: 1000 }),
|
||||
node("exv", "ExpansionValve", {}),
|
||||
node("evap", "Evaporator", { ua: 1000 }),
|
||||
];
|
||||
const edges: Edge[] = [];
|
||||
const coach = buildDofCoach(nodes, edges);
|
||||
expect(coach.tips.some((t) => t.id.startsWith("sec-"))).toBe(true);
|
||||
});
|
||||
});
|
||||
211
apps/web/src/lib/dofCoach.ts
Normal file
211
apps/web/src/lib/dofCoach.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Actionable DoF coaching — turns ledger diagnostics into ordered next steps
|
||||
* (guide, not just a red/green light).
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import {
|
||||
computeDofLedger,
|
||||
type DofBalance,
|
||||
type DofLedger,
|
||||
} from "./dofLedger";
|
||||
import { COMPONENT_BY_TYPE } from "./componentMeta";
|
||||
import { isBoundaryParamFixed } from "./boundaryFix";
|
||||
import { hxFamily } from "./hxFamily";
|
||||
|
||||
export type CoachSeverity = "ok" | "tip" | "warn" | "block";
|
||||
|
||||
export interface CoachTip {
|
||||
id: string;
|
||||
severity: CoachSeverity;
|
||||
/** Short headline */
|
||||
title: string;
|
||||
/** What to do next */
|
||||
action: string;
|
||||
/** Optional component name to highlight */
|
||||
focus?: string;
|
||||
}
|
||||
|
||||
export interface DofCoach {
|
||||
balance: DofBalance;
|
||||
headline: string;
|
||||
tips: CoachTip[];
|
||||
ledger: DofLedger;
|
||||
}
|
||||
|
||||
export function buildDofCoach(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): DofCoach {
|
||||
const ledger = computeDofLedger(nodes, edges);
|
||||
const tips: CoachTip[] = [];
|
||||
|
||||
if (ledger.balance === "empty") {
|
||||
return {
|
||||
balance: "empty",
|
||||
headline: "Glisse des composants depuis la bibliothèque pour commencer.",
|
||||
tips: [
|
||||
{
|
||||
id: "empty",
|
||||
severity: "tip",
|
||||
title: "Feuille vide",
|
||||
action:
|
||||
"Exemple : Compresseur → Condenseur → Détendeur → Évaporateur, puis boucles eau/air Source→HX→Sink.",
|
||||
},
|
||||
],
|
||||
ledger,
|
||||
};
|
||||
}
|
||||
|
||||
// Topology coaching
|
||||
const types = new Set(nodes.map((n) => n.data.type));
|
||||
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"));
|
||||
const hasEvap = [...types].some((t) => t.includes("Evaporator"));
|
||||
|
||||
if (hasComp && hasCond && hasEvap && hasExv) {
|
||||
const openHx = nodes.filter((n) => {
|
||||
const fam = hxFamily(n.data.type);
|
||||
if (!fam) return false;
|
||||
const meta = COMPONENT_BY_TYPE[n.data.type];
|
||||
if (!meta) return false;
|
||||
const secPorts = meta.ports.filter((p) => p.includes("secondary"));
|
||||
if (secPorts.length < 2) return false;
|
||||
const wired = edges.some(
|
||||
(e) =>
|
||||
(e.source === n.id || e.target === n.id) &&
|
||||
(String(e.sourceHandle).includes("secondary") ||
|
||||
String(e.targetHandle).includes("secondary")),
|
||||
);
|
||||
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);
|
||||
tips.push({
|
||||
id: `sec-${n.id}`,
|
||||
severity: "tip",
|
||||
title: `${n.data.name} — secondaire non câblé`,
|
||||
action: fam
|
||||
? `Relie BrineSource/AirSource → secondary_in → secondary_out → Sink (${fam.label}).`
|
||||
: "Relie une boucle Source → secondary → Sink.",
|
||||
focus: n.data.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
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");
|
||||
if (!pMeta || !mMeta) continue;
|
||||
const fixP = isBoundaryParamFixed(p, pMeta);
|
||||
const fixM =
|
||||
isBoundaryParamFixed(p, mMeta) &&
|
||||
p.m_flow_kg_s !== undefined &&
|
||||
p.m_flow_kg_s !== "" &&
|
||||
Number(p.m_flow_kg_s) > 0;
|
||||
if (fixP && fixM) {
|
||||
tips.push({
|
||||
id: `mflow-p-${n.id}`,
|
||||
severity: "warn",
|
||||
title: `${n.data.name} — Fixed P + Fixed ṁ`,
|
||||
action:
|
||||
"Modelica MassFlowSource_T : décoche Fixed sur P (garde ṁ + T). Le Sink ancre la pression.",
|
||||
focus: n.data.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (w === undefined || w === "" || Number(w) <= 0) {
|
||||
tips.push({
|
||||
id: `w-${n.id}`,
|
||||
severity: "warn",
|
||||
title: `${n.data.name} — W air manquant`,
|
||||
action:
|
||||
"Renseigne secondary_humidity_ratio (= W de l’AirSource, ex. 0.014 à 35 °C / 40 % RH) sinon le solveur peut diverger.",
|
||||
focus: n.data.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ledger diagnostics → tips
|
||||
for (const [i, d] of ledger.diagnostics.slice(0, 8).entries()) {
|
||||
const sev: CoachSeverity =
|
||||
ledger.balance === "balanced" && !d.startsWith("Over") && !d.startsWith("Under")
|
||||
? "tip"
|
||||
: "warn";
|
||||
tips.push({
|
||||
id: `diag-${i}`,
|
||||
severity: sev,
|
||||
title: shortenDiagTitle(d),
|
||||
action: d,
|
||||
});
|
||||
}
|
||||
|
||||
if (ledger.balance === "over-constrained") {
|
||||
tips.unshift({
|
||||
id: "over",
|
||||
severity: "block",
|
||||
title: `Trop d’équations (+${ledger.delta})`,
|
||||
action:
|
||||
"Décoche Fixed sur une frontière, active emergent_pressure, ou libère un actionneur (Z_UA, ouverture EXV).",
|
||||
});
|
||||
} else if (ledger.balance === "under-constrained") {
|
||||
tips.unshift({
|
||||
id: "under",
|
||||
severity: "block",
|
||||
title: `Pas assez d’équations (${ledger.delta})`,
|
||||
action:
|
||||
"Fixe ṁ ou T sur une Source, ancre P sur le Sink, ou coche emergent_pressure + sous-refroidissement / surchauffe.",
|
||||
});
|
||||
} else if (tips.length === 0) {
|
||||
tips.push({
|
||||
id: "ok",
|
||||
severity: "ok",
|
||||
title: "Système carré",
|
||||
action: "Tu peux lancer Solve. Les warnings soft (OutletClosure émergent) sont normaux.",
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate by title+action
|
||||
const seen = new Set<string>();
|
||||
const unique = tips.filter((t) => {
|
||||
const k = `${t.title}|${t.action}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
|
||||
const headline =
|
||||
ledger.balance === "balanced"
|
||||
? "Balance OK — prêt à simuler"
|
||||
: ledger.balance === "over-constrained"
|
||||
? "Trop de Fixed — suis les étapes ci-dessous"
|
||||
: "Il manque des contraintes — suis le guide";
|
||||
|
||||
return { balance: ledger.balance, headline, tips: unique.slice(0, 10), ledger };
|
||||
}
|
||||
|
||||
function truthy(v: unknown): boolean {
|
||||
return v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
|
||||
function shortenDiagTitle(d: string): string {
|
||||
if (d.includes("MassFlowSource") || d.includes("Fixed P + Fixed")) return "Conflit P / ṁ";
|
||||
if (d.includes("secondary")) return "Boucle secondaire";
|
||||
if (d.includes("quality_control")) return "quality_control";
|
||||
if (d.includes("Over-constrained")) return "Surcontraint";
|
||||
if (d.includes("Under-constrained")) return "Sous-contraint";
|
||||
return d.length > 48 ? `${d.slice(0, 46)}…` : d;
|
||||
}
|
||||
199
apps/web/src/lib/dofLedger.test.ts
Normal file
199
apps/web/src/lib/dofLedger.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
classifyParamDof,
|
||||
computeDofLedger,
|
||||
estimateComponentEquations,
|
||||
} from "./dofLedger";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean> = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropyk",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
function edge(
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
sourceHandle: string,
|
||||
targetHandle: string,
|
||||
): Edge {
|
||||
return { id, source, target, sourceHandle, targetHandle };
|
||||
}
|
||||
|
||||
describe("computeDofLedger", () => {
|
||||
it("returns empty for no nodes", () => {
|
||||
const L = computeDofLedger([], []);
|
||||
expect(L.balance).toBe("empty");
|
||||
expect(L.nEquations).toBe(0);
|
||||
expect(L.nUnknowns).toBe(0);
|
||||
});
|
||||
|
||||
it("counts a water-cooled 4-port chiller close to 19=19", () => {
|
||||
// Rough topology of chiller_watercooled_r410a.json
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", { emergent_pressure: true }),
|
||||
node("cd", "Condenser", "cond", { emergent_pressure: true }),
|
||||
node("x", "IsenthalpicExpansionValve", "exv", { emergent_pressure: true }),
|
||||
node("e", "Evaporator", "evap", { emergent_pressure: true }),
|
||||
node("cwi", "BrineSource", "cond_water_in", { m_flow_kg_s: 0.4 }),
|
||||
node("cwo", "BrineSink", "cond_water_out"),
|
||||
node("ewi", "BrineSource", "evap_water_in", { m_flow_kg_s: 0.5 }),
|
||||
node("ewo", "BrineSink", "evap_water_out"),
|
||||
];
|
||||
const edges = [
|
||||
edge("1", "c", "cd", "outlet", "inlet"),
|
||||
edge("2", "cd", "x", "outlet", "inlet"),
|
||||
edge("3", "x", "e", "outlet", "inlet"),
|
||||
edge("4", "e", "c", "outlet", "inlet"),
|
||||
edge("5", "cwi", "cd", "outlet", "secondary_inlet"),
|
||||
edge("6", "cd", "cwo", "secondary_outlet", "inlet"),
|
||||
edge("7", "ewi", "e", "outlet", "secondary_inlet"),
|
||||
edge("8", "e", "ewo", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const L = computeDofLedger(nodes, edges);
|
||||
// Expect square-ish: honest budget is 19. Client estimate should match.
|
||||
expect(L.nEdges).toBe(8);
|
||||
expect(L.nBranches).toBe(3); // ref + cw + chw
|
||||
expect(L.nUnknowns).toBe(3 + 2 * 8); // 19
|
||||
expect(L.nEquations).toBe(19);
|
||||
expect(L.balance).toBe("balanced");
|
||||
});
|
||||
|
||||
it("flags quality_control without free actuator as over-constrained risk", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", { quality_control: true }),
|
||||
node("s", "BrineSource", "src", { m_flow_kg_s: 1 }),
|
||||
node("k", "BrineSink", "sink"),
|
||||
];
|
||||
const edges = [
|
||||
edge("a", "s", "e", "outlet", "secondary_inlet"),
|
||||
edge("b", "e", "k", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const est = estimateComponentEquations(nodes[0], edges, nodes);
|
||||
expect(est.nEquations).toBe(5); // ΔP + energy + outlet_closure + P_sec + energy_sec
|
||||
expect(est.roles.some((r) => r.includes("quality"))).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores orphan pipes in the equation count (no hard-block)", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", { emergent_pressure: true }),
|
||||
node("cd", "Condenser", "cond", { emergent_pressure: true }),
|
||||
node("x", "IsenthalpicExpansionValve", "exv", { emergent_pressure: true }),
|
||||
node("e", "Evaporator", "evap", { emergent_pressure: true }),
|
||||
node("cwi", "BrineSource", "cond_water_in", { m_flow_kg_s: 0.4 }),
|
||||
node("cwo", "BrineSink", "cond_water_out"),
|
||||
node("ewi", "BrineSource", "evap_water_in", { m_flow_kg_s: 0.5 }),
|
||||
node("ewo", "BrineSink", "evap_water_out"),
|
||||
node("p", "RefrigerantPipe", "line1", { length_m: 3, diameter_m: 0.012 }),
|
||||
];
|
||||
const edges = [
|
||||
edge("1", "c", "cd", "outlet", "inlet"),
|
||||
edge("2", "cd", "x", "outlet", "inlet"),
|
||||
edge("3", "x", "e", "outlet", "inlet"),
|
||||
edge("4", "e", "c", "outlet", "inlet"),
|
||||
edge("5", "cwi", "cd", "outlet", "secondary_inlet"),
|
||||
edge("6", "cd", "cwo", "secondary_outlet", "inlet"),
|
||||
edge("7", "ewi", "e", "outlet", "secondary_inlet"),
|
||||
edge("8", "e", "ewo", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const L = computeDofLedger(nodes, edges);
|
||||
expect(L.balance).toBe("balanced");
|
||||
expect(L.components.find((c) => c.name === "line1")?.nEquations).toBe(0);
|
||||
});
|
||||
|
||||
it("counts a spliced refrigerant pipe as +2 eqs / +1 edge (neutral DoF)", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", { emergent_pressure: true }),
|
||||
node("cd", "Condenser", "cond", { emergent_pressure: true }),
|
||||
node("x", "IsenthalpicExpansionValve", "exv", { emergent_pressure: true }),
|
||||
node("e", "Evaporator", "evap", { emergent_pressure: true }),
|
||||
node("cwi", "BrineSource", "cond_water_in", { m_flow_kg_s: 0.4 }),
|
||||
node("cwo", "BrineSink", "cond_water_out"),
|
||||
node("ewi", "BrineSource", "evap_water_in", { m_flow_kg_s: 0.5 }),
|
||||
node("ewo", "BrineSink", "evap_water_out"),
|
||||
node("p", "RefrigerantPipe", "line1", { length_m: 3, diameter_m: 0.012 }),
|
||||
];
|
||||
const edges = [
|
||||
edge("1a", "c", "p", "outlet", "inlet"),
|
||||
edge("1b", "p", "cd", "outlet", "inlet"),
|
||||
edge("2", "cd", "x", "outlet", "inlet"),
|
||||
edge("3", "x", "e", "outlet", "inlet"),
|
||||
edge("4", "e", "c", "outlet", "inlet"),
|
||||
edge("5", "cwi", "cd", "outlet", "secondary_inlet"),
|
||||
edge("6", "cd", "cwo", "secondary_outlet", "inlet"),
|
||||
edge("7", "ewi", "e", "outlet", "secondary_inlet"),
|
||||
edge("8", "e", "ewo", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const L = computeDofLedger(nodes, edges);
|
||||
expect(L.nEdges).toBe(9);
|
||||
expect(L.nEquations).toBe(21);
|
||||
expect(L.nUnknowns).toBe(L.nBranches + 2 * 9);
|
||||
expect(L.balance).toBe("balanced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyParamDof", () => {
|
||||
it("marks emergent t_sat as seed not fix", () => {
|
||||
const tag = classifyParamDof("Condenser", "t_sat_k", { emergent_pressure: true });
|
||||
expect(tag?.kind).toBe("solved");
|
||||
});
|
||||
|
||||
it("marks boundary T as FIX", () => {
|
||||
const tag = classifyParamDof("BrineSource", "t_set_c", { t_set_c: 12 });
|
||||
expect(tag?.kind).toBe("fixed");
|
||||
});
|
||||
|
||||
it("marks Free ṁ on BrineSource as FREE", () => {
|
||||
const tag = classifyParamDof("BrineSource", "m_flow_kg_s", {
|
||||
m_flow_kg_s: 0.5,
|
||||
__fixed_m_flow_kg_s: false,
|
||||
});
|
||||
expect(tag?.kind).toBe("free");
|
||||
});
|
||||
|
||||
it("flags Fixed T_out + Fixed ṁ as ΔT rating conflict", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", { ua: 8000 }),
|
||||
node("s", "BrineSource", "src", {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
__fixed_m_flow_kg_s: true,
|
||||
}),
|
||||
node("k", "BrineSink", "sink", {
|
||||
p_back_bar: 3,
|
||||
t_set_c: 7,
|
||||
__fixed_t_set_c: true,
|
||||
}),
|
||||
];
|
||||
const edges = [
|
||||
edge("1", "s", "e", "outlet", "secondary_inlet"),
|
||||
edge("2", "e", "k", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const ledger = computeDofLedger(nodes, edges);
|
||||
expect(ledger.diagnostics.some((d) => d.includes("Fixed T_out") && d.includes("Fixed ṁ"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks scalar secondary as risk without live ports", () => {
|
||||
const tag = classifyParamDof(
|
||||
"FloodedEvaporator",
|
||||
"secondary_inlet_temp_c",
|
||||
{ secondary_inlet_temp_c: 12 },
|
||||
{ hasLiveSecondary: false },
|
||||
);
|
||||
expect(tag?.kind).toBe("risk");
|
||||
});
|
||||
});
|
||||
702
apps/web/src/lib/dofLedger.ts
Normal file
702
apps/web/src/lib/dofLedger.ts
Normal file
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* Client-side degrees-of-freedom ledger for the diagram UI.
|
||||
*
|
||||
* Mirrors the solver rule `n_equations == n_unknowns` with a topology-based
|
||||
* estimate. This is intentionally a **live design aid**, not a bit-exact copy
|
||||
* of `System::dof_report()` (which requires a finalized Rust graph + CoolProp).
|
||||
*
|
||||
* Counting model (CM1.4 style):
|
||||
* unknowns ≈ n_mass_branches + 2 × n_edges (+ free actuators / controls)
|
||||
* equations ≈ Σ component residual estimates (+ control tracking residuals)
|
||||
*
|
||||
* Fix / Free discipline surfaced to the UI:
|
||||
* - Boundary sources FIX P, h, (ṁ)
|
||||
* - Outlet closures (SH / SC / quality) FIX a thermo state — need a FREE actuator
|
||||
* - Emergent pressure FREES the design-point pressure pin
|
||||
* - Controls: measure FIX paired with actuator FREE
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
findSecondaryLoopDofConflicts,
|
||||
isBoundaryParamFixed,
|
||||
} from "./boundaryFix";
|
||||
import { COMPONENT_BY_TYPE, isSecondaryPort } from "./componentMeta";
|
||||
import { CONTROL_NODE_TYPE, type EntropykNodeData } from "./configBuilder";
|
||||
|
||||
export type DofBalance = "balanced" | "over-constrained" | "under-constrained" | "empty";
|
||||
|
||||
export type FixFreeKind = "fixed" | "free" | "solved" | "parameter" | "risk";
|
||||
|
||||
export interface ParamDofTag {
|
||||
key: string;
|
||||
kind: FixFreeKind;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface ComponentDofEstimate {
|
||||
name: string;
|
||||
type: string;
|
||||
nEquations: number;
|
||||
roles: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DofLedger {
|
||||
nEquations: number;
|
||||
nUnknowns: number;
|
||||
balance: DofBalance;
|
||||
delta: number;
|
||||
nEdges: number;
|
||||
nBranches: number;
|
||||
nControls: number;
|
||||
components: ComponentDofEstimate[];
|
||||
diagnostics: string[];
|
||||
/** Human one-liner for the status bar. */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
function truthy(v: unknown): boolean {
|
||||
return v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
|
||||
function num(v: unknown, fallback = 0): number {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Edges incident to a node (by React Flow id). */
|
||||
function incidentEdges(nodeId: string, edges: Edge[]): Edge[] {
|
||||
return edges.filter((e) => e.source === nodeId || e.target === nodeId);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
return connected.has("secondary_inlet") && connected.has("secondary_outlet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate component residual count from type + params + wiring.
|
||||
* Conservative and documented; prefer over-counting diagnostics over silent under-count.
|
||||
*/
|
||||
export function estimateComponentEquations(
|
||||
node: Node<EntropykNodeData>,
|
||||
edges: Edge[],
|
||||
allNodes: Node<EntropykNodeData>[],
|
||||
): ComponentDofEstimate {
|
||||
const type = node.data.type;
|
||||
const name = node.data.name;
|
||||
const p = node.data.params ?? {};
|
||||
const roles: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let n = 0;
|
||||
|
||||
const liveSec = hasLiveSecondary(node, edges);
|
||||
const emergent = truthy(p.emergent_pressure);
|
||||
const skipP = truthy(p.skip_pressure_eq);
|
||||
|
||||
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]");
|
||||
break;
|
||||
case "ScrewEconomizerCompressor":
|
||||
n = 3;
|
||||
roles.push("mass", "energy[discharge]", "economizer");
|
||||
break;
|
||||
case "CentrifugalCompressor":
|
||||
n = 2;
|
||||
roles.push("mass", "energy[discharge]");
|
||||
break;
|
||||
case "CapillaryTube":
|
||||
n = 2;
|
||||
roles.push("mass", "energy[isenthalpic]");
|
||||
break;
|
||||
case "IsenthalpicExpansionValve":
|
||||
case "EXV":
|
||||
case "ExpansionValve":
|
||||
// emergent: isenthalpic only (1); fixed-P: +P_evap pin (2); orifice: +flow residual
|
||||
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) {
|
||||
n += 1;
|
||||
roles.push("actuator[orifice]");
|
||||
}
|
||||
break;
|
||||
case "Condenser":
|
||||
case "CondenserCoil":
|
||||
case "MchxCondenserCoil":
|
||||
case "FinCoilCondenser":
|
||||
case "FloodedCondenser":
|
||||
case "AirCooledCondenser": {
|
||||
n = skipP ? 1 : 2; // ΔP + energy
|
||||
roles.push(skipP ? "energy[refrigerant]" : "momentum[refrigerant]", "energy[refrigerant]");
|
||||
if (emergent) {
|
||||
n += 1;
|
||||
roles.push("outlet_closure[subcooling]");
|
||||
}
|
||||
if (liveSec) {
|
||||
// Isobaric secondary P + energy (same-branch mass dropped) — Modelica
|
||||
// MassFlowSource_T Free P needs HX to propagate sink P to source edge.
|
||||
n += 2;
|
||||
roles.push("momentum[secondary]", "energy[secondary]");
|
||||
} else {
|
||||
warnings.push(
|
||||
"No live secondary ports — system-mode coupling needs BrineSource→secondary_in→secondary_out→BrineSink",
|
||||
);
|
||||
}
|
||||
if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
|
||||
n += 1;
|
||||
roles.push("actuator[fan_head_pressure]");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Evaporator":
|
||||
case "EvaporatorCoil": {
|
||||
n = skipP ? 1 : 2;
|
||||
roles.push(skipP ? "energy[refrigerant]" : "momentum[refrigerant]", "energy[refrigerant]");
|
||||
const superheatReg = truthy(p.superheat_regulated);
|
||||
if (emergent && !superheatReg) {
|
||||
n += 1;
|
||||
roles.push("outlet_closure[superheat]");
|
||||
}
|
||||
if (superheatReg) {
|
||||
roles.push("superheat_regulated(drop SH residual)");
|
||||
const hasCtrl = allNodes.some(
|
||||
(n) =>
|
||||
n.data.type === CONTROL_NODE_TYPE &&
|
||||
String(n.data.params?.measure_component ?? "") === name,
|
||||
);
|
||||
if (!hasCtrl) {
|
||||
warnings.push(
|
||||
"superheat_regulated drops the SH residual — pair with a SaturatedController (EXV opening)",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (liveSec) {
|
||||
n += 2;
|
||||
roles.push("momentum[secondary]", "energy[secondary]");
|
||||
} else {
|
||||
warnings.push(
|
||||
"No live secondary ports — connect a water/brine loop for real-machine energy balance",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "FloodedEvaporator": {
|
||||
// ΔP + energy + outlet closure (saturated vapor default, or quality if enabled)
|
||||
n = 3;
|
||||
roles.push("momentum[refrigerant]", "energy[refrigerant]");
|
||||
if (truthy(p.quality_control)) {
|
||||
roles.push("outlet_closure[quality]");
|
||||
warnings.push(
|
||||
"quality_control uses q_target instead of saturated-vapor suction closure — pair with free actuator if this over-constrains controls",
|
||||
);
|
||||
} else {
|
||||
roles.push("outlet_closure[saturated_vapor]");
|
||||
}
|
||||
if (liveSec) {
|
||||
n += 2;
|
||||
roles.push("momentum[secondary]", "energy[secondary]");
|
||||
} else {
|
||||
warnings.push(
|
||||
"Flooded system mode requires live secondary edges; scalar secondary_* fields are rating-only",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "BrineSource":
|
||||
case "AirSource":
|
||||
case "RefrigerantSource": {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const pMeta = meta?.params.find((x) => x.key === "p_set_bar");
|
||||
const tMeta = meta?.params.find((x) => x.key === "t_set_c" || x.key === "t_dry_c");
|
||||
const mMeta = meta?.params.find((x) => x.key === "m_flow_kg_s");
|
||||
const fixP = !pMeta?.fixable || isBoundaryParamFixed(p, pMeta);
|
||||
const fixT = !tMeta?.fixable || isBoundaryParamFixed(p, tMeta);
|
||||
const fixM = !mMeta?.fixable || isBoundaryParamFixed(p, mMeta);
|
||||
n = 0;
|
||||
if (fixP) {
|
||||
n += 1;
|
||||
roles.push("boundary[P]");
|
||||
}
|
||||
if (fixT) {
|
||||
n += 1;
|
||||
roles.push("boundary[h]");
|
||||
}
|
||||
if (
|
||||
fixM &&
|
||||
p.m_flow_kg_s !== undefined &&
|
||||
p.m_flow_kg_s !== "" &&
|
||||
num(p.m_flow_kg_s) > 0
|
||||
) {
|
||||
n += 1;
|
||||
roles.push("boundary[m]");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "BrineSink":
|
||||
case "AirSink":
|
||||
case "RefrigerantSink": {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const pMeta = meta?.params.find((x) => x.key === "p_back_bar");
|
||||
const tMeta = meta?.params.find((x) => x.key === "t_set_c" || x.key === "t_back_c");
|
||||
const fixP = !pMeta?.fixable || isBoundaryParamFixed(p, pMeta);
|
||||
n = 0;
|
||||
if (fixP) {
|
||||
n += 1;
|
||||
roles.push("boundary[P]");
|
||||
}
|
||||
const tKey = type === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
const tFixed = tMeta
|
||||
? isBoundaryParamFixed(p, tMeta)
|
||||
: p[tKey] !== undefined && p[tKey] !== "";
|
||||
if (tFixed && p[tKey] !== undefined && p[tKey] !== "") {
|
||||
n += 1;
|
||||
roles.push("boundary[h]");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Anchor":
|
||||
n = 2;
|
||||
roles.push("continuity[P]", "continuity[h]");
|
||||
if (p.constraint || p.superheat_k || p.quality || p.t_set_k || p.p_set_pa) {
|
||||
n += 1;
|
||||
roles.push("outlet_closure[spec]");
|
||||
warnings.push("Anchor constraint FIX consumes one DoF — free something elsewhere");
|
||||
}
|
||||
break;
|
||||
case "HeatExchanger":
|
||||
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
|
||||
}
|
||||
break;
|
||||
case "Pump":
|
||||
case "Fan":
|
||||
n = 2;
|
||||
roles.push("momentum", "energy");
|
||||
break;
|
||||
case "Pipe":
|
||||
case "RefrigerantPipe":
|
||||
case "WaterPipe":
|
||||
case "AirDuct":
|
||||
case "PipeWater":
|
||||
case "PipeAir":
|
||||
case "ReversingValve":
|
||||
case "BypassValve": {
|
||||
// Unwired 2-port parts must not inflate the ledger — an orphan pipe
|
||||
// (+2 eqs, +0 edges) hard-blocks Simulate as "over-constrained".
|
||||
const degree = incidentEdges(node.id, edges).length;
|
||||
if (degree < 2) {
|
||||
n = 0;
|
||||
roles.push("unconnected");
|
||||
warnings.push(
|
||||
degree === 0
|
||||
? "not connected — drop on a matching wire or connect inlet+outlet"
|
||||
: "partially connected (need inlet + outlet)",
|
||||
);
|
||||
} else {
|
||||
n = 2;
|
||||
roles.push("momentum", "energy");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Drum":
|
||||
n = 4;
|
||||
roles.push("mass", "energy", "level_or_volume", "outlet");
|
||||
break;
|
||||
case "ThermalLoad":
|
||||
case "HeatSource":
|
||||
n = 2;
|
||||
roles.push("mass", "energy");
|
||||
break;
|
||||
case "FlowSplitter":
|
||||
case "FlowMerger":
|
||||
n = 2;
|
||||
roles.push("mass", "pressure");
|
||||
break;
|
||||
case "Placeholder":
|
||||
n = Math.max(0, Math.floor(num(p.n_equations, 2)));
|
||||
roles.push(`placeholder×${n}`);
|
||||
break;
|
||||
case CONTROL_NODE_TYPE:
|
||||
// Controller residuals are counted at system level (2 per loop).
|
||||
n = 0;
|
||||
roles.push("control(system-level)");
|
||||
break;
|
||||
default:
|
||||
n = 2;
|
||||
roles.push("unspecified×2");
|
||||
warnings.push(`No DoF template for type '${type}' — assumed 2 equations`);
|
||||
}
|
||||
|
||||
// Series refrigerant/secondary: same-branch drops mass residual — already assumed
|
||||
// for 2-port chain components above.
|
||||
|
||||
return { name, type, nEquations: n, roles, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate mass-flow branches: connected components of edges that share a series
|
||||
* path (1-in/1-out through non-junction nodes). Approximate with undirected edge
|
||||
* connectivity partitioned by fluid "kind" (secondary vs refrigerant) via handles.
|
||||
*/
|
||||
function estimateBranches(nodes: Node<EntropykNodeData>[], edges: Edge[]): number {
|
||||
if (edges.length === 0) return 0;
|
||||
|
||||
// Union-Find over edges: two edges share a branch if they meet at a 1-in/1-out
|
||||
// component on matching stream (both secondary or both primary).
|
||||
const parent = new Map<string, string>();
|
||||
const find = (x: string): string => {
|
||||
let p = parent.get(x) ?? x;
|
||||
while (parent.get(p) && parent.get(p) !== p) p = parent.get(p)!;
|
||||
parent.set(x, p);
|
||||
return p;
|
||||
};
|
||||
const unite = (a: string, b: string) => {
|
||||
const ra = find(a);
|
||||
const rb = find(b);
|
||||
if (ra !== rb) parent.set(ra, rb);
|
||||
};
|
||||
for (const e of edges) parent.set(e.id, e.id);
|
||||
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
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[] = [];
|
||||
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);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
const roots = new Set<string>();
|
||||
for (const e of edges) roots.add(find(e.id));
|
||||
return roots.size;
|
||||
}
|
||||
|
||||
/** Count free actuators implied by the diagram (orifice, fan head-pressure, controls). */
|
||||
function estimateExtraUnknowns(nodes: Node<EntropykNodeData>[]): {
|
||||
freeActuators: number;
|
||||
saturatedPairs: number;
|
||||
controlDiagnostics: string[];
|
||||
} {
|
||||
let freeActuators = 0;
|
||||
let saturatedPairs = 0;
|
||||
const controlDiagnostics: string[] = [];
|
||||
|
||||
for (const n of nodes) {
|
||||
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
|
||||
) {
|
||||
freeActuators += 1; // opening unknown
|
||||
}
|
||||
if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
|
||||
freeActuators += 1;
|
||||
}
|
||||
if (n.data.type === CONTROL_NODE_TYPE) {
|
||||
saturatedPairs += 1; // (u, x) + 2 residuals → neutral if paired
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan quality_control without free actuator
|
||||
for (const n of nodes) {
|
||||
if (n.data.type === "FloodedEvaporator" && truthy(n.data.params?.quality_control)) {
|
||||
if (freeActuators + saturatedPairs === 0) {
|
||||
controlDiagnostics.push(
|
||||
`${n.data.name}: quality_control FIX without free actuator → over-constrained risk`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { freeActuators, saturatedPairs, controlDiagnostics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full client-side DoF ledger for the current diagram.
|
||||
*/
|
||||
export function computeDofLedger(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): DofLedger {
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
nEquations: 0,
|
||||
nUnknowns: 0,
|
||||
balance: "empty",
|
||||
delta: 0,
|
||||
nEdges: 0,
|
||||
nBranches: 0,
|
||||
nControls: 0,
|
||||
components: [],
|
||||
diagnostics: [],
|
||||
summary: "No components — empty system",
|
||||
};
|
||||
}
|
||||
|
||||
const physNodes = nodes.filter((n) => n.data.type !== CONTROL_NODE_TYPE);
|
||||
const components = physNodes.map((n) => estimateComponentEquations(n, edges, nodes));
|
||||
let nEquations = components.reduce((s, c) => s + c.nEquations, 0);
|
||||
|
||||
const nBranches = estimateBranches(nodes, edges);
|
||||
const nEdges = edges.length;
|
||||
// unknowns: ṁ per branch + (P,h) per edge
|
||||
let nUnknowns = nBranches + 2 * nEdges;
|
||||
|
||||
const { freeActuators, saturatedPairs, controlDiagnostics } = estimateExtraUnknowns(nodes);
|
||||
nUnknowns += freeActuators + 2 * saturatedPairs;
|
||||
nEquations += 2 * saturatedPairs; // saturated PI: +2 residuals per loop
|
||||
|
||||
const nControls = nodes.filter((n) => n.data.type === CONTROL_NODE_TYPE).length;
|
||||
|
||||
const diagnostics: string[] = [...controlDiagnostics];
|
||||
for (const c of components) {
|
||||
for (const w of c.warnings) diagnostics.push(`${c.name}: ${w}`);
|
||||
}
|
||||
|
||||
const delta = nEquations - nUnknowns;
|
||||
let balance: DofBalance;
|
||||
if (nEquations === nUnknowns) balance = "balanced";
|
||||
else if (nEquations > nUnknowns) balance = "over-constrained";
|
||||
else balance = "under-constrained";
|
||||
|
||||
for (const c of findSecondaryLoopDofConflicts(physNodes, edges)) {
|
||||
diagnostics.push(c.message);
|
||||
}
|
||||
|
||||
if (balance === "over-constrained") {
|
||||
diagnostics.unshift(
|
||||
`Over-constrained by ${delta}: remove a FIX (outlet closure / quality / boundary) or FREE an actuator`,
|
||||
);
|
||||
} else if (balance === "under-constrained") {
|
||||
diagnostics.unshift(
|
||||
`Under-constrained by ${-delta}: add a residual (emergent closure, boundary) or remove a free unknown`,
|
||||
);
|
||||
}
|
||||
|
||||
const summary =
|
||||
balance === "balanced"
|
||||
? `DoF balanced · ${nEquations} eqs = ${nUnknowns} unk`
|
||||
: balance === "over-constrained"
|
||||
? `DoF OVER · ${nEquations} eqs > ${nUnknowns} unk (+${delta})`
|
||||
: `DoF UNDER · ${nEquations} eqs < ${nUnknowns} unk (${delta})`;
|
||||
|
||||
return {
|
||||
nEquations,
|
||||
nUnknowns,
|
||||
balance,
|
||||
delta,
|
||||
nEdges,
|
||||
nBranches,
|
||||
nControls,
|
||||
components,
|
||||
diagnostics,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a parameter as fixed / free / solved / plain for the properties panel.
|
||||
*/
|
||||
export function classifyParamDof(
|
||||
type: string,
|
||||
key: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
opts?: { hasLiveSecondary?: boolean; hasControl?: boolean },
|
||||
): ParamDofTag | null {
|
||||
const emergent = truthy(params.emergent_pressure);
|
||||
const hasLive = opts?.hasLiveSecondary ?? false;
|
||||
|
||||
// Shared patterns
|
||||
if (key === "ua" || key === "z_ua" || key === "z_dp") {
|
||||
return {
|
||||
key,
|
||||
kind: "parameter",
|
||||
label: "param",
|
||||
hint: "Model parameter (not a Newton unknown unless inverse-calibrated)",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
key === "t_sat_k" ||
|
||||
key === "t_cond_k" ||
|
||||
key === "t_evap_k"
|
||||
) {
|
||||
if (emergent) {
|
||||
return {
|
||||
key,
|
||||
kind: "solved",
|
||||
label: "seed",
|
||||
hint: "Initialization / design guess only — pressure is SOLVED (emergent)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
kind: "fixed",
|
||||
label: "FIX",
|
||||
hint: "Imposes a design-point pressure (fixed-P mode). Prefer emergent_pressure for real machines.",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "emergent_pressure") {
|
||||
return {
|
||||
key,
|
||||
kind: "free",
|
||||
label: "FREE P",
|
||||
hint: "When ON, condensing/evaporating pressure is free (solved from secondary balance)",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "superheat_k" || key === "subcooling_k") {
|
||||
if (key === "superheat_k" && truthy(params.superheat_regulated)) {
|
||||
return {
|
||||
key,
|
||||
kind: "solved",
|
||||
label: "target",
|
||||
hint: "Superheat residual dropped — closed by EXV controller (FREE opening)",
|
||||
};
|
||||
}
|
||||
if (emergent) {
|
||||
return {
|
||||
key,
|
||||
kind: "fixed",
|
||||
label: "FIX",
|
||||
hint: "Outlet closure residual (+1 eq). Consumes one DoF; OK when pressure is free.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
kind: "parameter",
|
||||
label: "param",
|
||||
hint: "Used with fixed-pressure mode",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "superheat_regulated") {
|
||||
return {
|
||||
key,
|
||||
kind: opts?.hasControl ? "free" : "risk",
|
||||
label: opts?.hasControl ? "pair OK" : "needs FREE",
|
||||
hint: "Drops SH residual (−1 eq). Must pair with SaturatedController → EXV opening.",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "quality_control") {
|
||||
return {
|
||||
key,
|
||||
kind: truthy(params.quality_control) ? "risk" : "parameter",
|
||||
label: truthy(params.quality_control) ? "FIX +1" : "off",
|
||||
hint: "When ON, adds quality residual. Pair with free actuator or leave OFF for suction models.",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "target_quality") {
|
||||
return {
|
||||
key,
|
||||
kind: truthy(params.quality_control) ? "fixed" : "parameter",
|
||||
label: truthy(params.quality_control) ? "FIX set" : "unused",
|
||||
hint: "Only active if quality_control is ON (costs +1 DoF)",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
key.startsWith("secondary_inlet_temp") ||
|
||||
key.startsWith("secondary_mass_flow") ||
|
||||
key.startsWith("secondary_capacity") ||
|
||||
key === "secondary_cp_j_per_kgk"
|
||||
) {
|
||||
if (hasLive) {
|
||||
return {
|
||||
key,
|
||||
kind: "parameter",
|
||||
label: "rating",
|
||||
hint: "Live secondary edges present — scalars are rating-only (ignored in system residual path)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
kind: "risk",
|
||||
label: "scalar",
|
||||
hint: "Scalar secondary is NOT a water-loop unknown. Wire BrineSource/Sink to secondary ports for real machines.",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type.endsWith("Source") &&
|
||||
(key === "t_set_c" || key === "p_set_bar" || key === "m_flow_kg_s" || key === "t_dry_c")
|
||||
) {
|
||||
const meta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === key);
|
||||
const fixed = !meta?.fixable || isBoundaryParamFixed(params, meta);
|
||||
return {
|
||||
key,
|
||||
kind: fixed ? "fixed" : "free",
|
||||
label: fixed ? "FIX" : "FREE",
|
||||
hint: fixed
|
||||
? "Boundary Dirichlet — machine input (correct place to fix T/ṁ/P)"
|
||||
: "Free at source — Modelica Boundary_pT; pair with Fixed T_out on Sink",
|
||||
};
|
||||
}
|
||||
|
||||
if (type.endsWith("Sink") && (key === "p_back_bar" || key === "t_set_c" || key === "t_back_c")) {
|
||||
const meta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === key);
|
||||
const fixed = meta ? isBoundaryParamFixed(params, meta) : params[key] !== undefined;
|
||||
return {
|
||||
key,
|
||||
kind: fixed ? "fixed" : "free",
|
||||
label: fixed ? "FIX" : "FREE",
|
||||
hint:
|
||||
key === "p_back_bar"
|
||||
? fixed
|
||||
? "Back-pressure fixed"
|
||||
: "Free back-pressure — unusual; ensure another P anchor exists"
|
||||
: fixed
|
||||
? "Fixed T_out — consumes DoF (pair with Free ṁ on Source)"
|
||||
: "T_out free (solved from energy)",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "opening" || key === "fan_speed" || key === "speed_hz") {
|
||||
return {
|
||||
key,
|
||||
kind: "free",
|
||||
label: "FREE?",
|
||||
hint: "Actuator candidate — free when co-solved by a control loop / free actuator residual",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
118
apps/web/src/lib/edgeInsert.test.ts
Normal file
118
apps/web/src/lib/edgeInsert.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import {
|
||||
findNearestEdge,
|
||||
planEdgeInsert,
|
||||
pipeMediaKind,
|
||||
validatePipeOnEdge,
|
||||
} from "./edgeInsert";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x, y },
|
||||
width: 100,
|
||||
height: 60,
|
||||
data: { type, name, circuit: 0, rotation: 0, params: {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe("edgeInsert", () => {
|
||||
it("classifies pipe media", () => {
|
||||
expect(pipeMediaKind("WaterPipe")).toBe("water");
|
||||
expect(pipeMediaKind("AirDuct")).toBe("air");
|
||||
expect(pipeMediaKind("RefrigerantPipe")).toBe("refrigerant");
|
||||
});
|
||||
|
||||
it("finds nearest edge and plans A→pipe→B splice", () => {
|
||||
const nodes = [
|
||||
node("a", "BrineSource", "evap_water_in", 0, 0),
|
||||
node("b", "FloodedEvaporator", "evap", 200, 0),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: "e1",
|
||||
source: "a",
|
||||
target: "b",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "secondary_inlet",
|
||||
},
|
||||
];
|
||||
const hit = findNearestEdge({ x: 100, y: 30 }, nodes, edges, {
|
||||
preferMedia: "water",
|
||||
});
|
||||
expect(hit?.edge.id).toBe("e1");
|
||||
expect(hit?.media).toBe("water");
|
||||
|
||||
const ok = validatePipeOnEdge("WaterPipe", { fluid: "Water" }, hit!.edge, nodes);
|
||||
expect(ok.ok).toBe(true);
|
||||
|
||||
const plan = planEdgeInsert({
|
||||
type: "WaterPipe",
|
||||
id: "pipe1",
|
||||
name: "water_pipe_1",
|
||||
position: hit!.midpoint,
|
||||
params: { fluid: "Water", length_m: 5 },
|
||||
edge: hit!.edge,
|
||||
sourceCenter: hit!.sourceCenter,
|
||||
targetCenter: hit!.targetCenter,
|
||||
});
|
||||
expect(plan.edgeIdToRemove).toBe("e1");
|
||||
expect(plan.node.data.rotation).toBe(0); // left → right
|
||||
expect(plan.edgesToAdd).toHaveLength(2);
|
||||
expect(plan.edgesToAdd[0]).toMatchObject({
|
||||
source: "a",
|
||||
target: "pipe1",
|
||||
targetHandle: "inlet",
|
||||
});
|
||||
expect(plan.edgesToAdd[1]).toMatchObject({
|
||||
source: "pipe1",
|
||||
sourceHandle: "outlet",
|
||||
target: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("orients pipe 180° when flow is right-to-left", () => {
|
||||
const plan = planEdgeInsert({
|
||||
type: "RefrigerantPipe",
|
||||
id: "p2",
|
||||
name: "ref_1",
|
||||
position: { x: 0, y: 0 },
|
||||
params: {},
|
||||
edge: {
|
||||
id: "e",
|
||||
source: "right",
|
||||
target: "left",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
},
|
||||
sourceCenter: { x: 300, y: 100 },
|
||||
targetCenter: { x: 100, y: 100 },
|
||||
});
|
||||
expect(plan.node.data.rotation).toBe(180);
|
||||
});
|
||||
|
||||
it("rejects water pipe on refrigerant edge", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", 0, 0),
|
||||
node("d", "Condenser", "cond", 200, 0),
|
||||
];
|
||||
const edge: Edge = {
|
||||
id: "e2",
|
||||
source: "c",
|
||||
target: "d",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
};
|
||||
const check = validatePipeOnEdge("WaterPipe", { fluid: "Water" }, edge, nodes);
|
||||
expect(check.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
238
apps/web/src/lib/edgeInsert.ts
Normal file
238
apps/web/src/lib/edgeInsert.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Drop a 2-port insertable (pipe / duct) onto an existing wire:
|
||||
* A ──→ B becomes A ──→ pipe ──→ B
|
||||
*
|
||||
* The pipe is oriented along the edge (Modelica-style) so inlet faces the
|
||||
* upstream component — avoids React Flow arrow loops from wrong handle sides.
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { nodeSize } from "./componentMeta";
|
||||
import { mediaForEdge, mediaForPort, type MediaKind } from "./mediaStyle";
|
||||
import {
|
||||
centerNodePosition,
|
||||
orientationAlongEdge,
|
||||
type RotationDeg,
|
||||
} from "./orientation";
|
||||
|
||||
export const PIPE_TYPES = new Set([
|
||||
"Pipe",
|
||||
"RefrigerantPipe",
|
||||
"WaterPipe",
|
||||
"AirDuct",
|
||||
"PipeWater",
|
||||
"PipeAir",
|
||||
]);
|
||||
|
||||
export function isPipeType(type: string): boolean {
|
||||
return PIPE_TYPES.has(type) || type.includes("Pipe") || type === "AirDuct";
|
||||
}
|
||||
|
||||
export function pipeMediaKind(type: string, params?: Record<string, unknown>): MediaKind {
|
||||
if (type === "WaterPipe" || type === "PipeWater") return "water";
|
||||
if (type === "AirDuct" || type === "PipeAir") return "air";
|
||||
if (type === "RefrigerantPipe") return "refrigerant";
|
||||
const fluid = params?.fluid;
|
||||
if (typeof fluid === "string") {
|
||||
const f = fluid.toLowerCase();
|
||||
if (f === "air" || f.startsWith("air")) return "air";
|
||||
if (f === "water" || f.includes("glycol") || f === "meg" || f === "brine") return "water";
|
||||
}
|
||||
return "refrigerant";
|
||||
}
|
||||
|
||||
function distToSegment(
|
||||
p: { x: number; y: number },
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): number {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y);
|
||||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const proj = { x: a.x + t * dx, y: a.y + t * dy };
|
||||
return Math.hypot(p.x - proj.x, p.y - proj.y);
|
||||
}
|
||||
|
||||
function nodeCenter(node: Node<EntropykNodeData>): { x: number; y: number } {
|
||||
const size = nodeSize(node.data.type);
|
||||
const w = (node.measured?.width ?? node.width ?? size.w) as number;
|
||||
const h = (node.measured?.height ?? node.height ?? size.h) as number;
|
||||
return { x: node.position.x + w / 2, y: node.position.y + h / 2 };
|
||||
}
|
||||
|
||||
export interface NearestEdgeHit {
|
||||
edge: Edge;
|
||||
distance: number;
|
||||
media: MediaKind;
|
||||
midpoint: { x: number; y: number };
|
||||
sourceCenter: { x: number; y: number };
|
||||
targetCenter: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** Find the closest edge to a flow-space point (optionally prefer matching media). */
|
||||
export function findNearestEdge(
|
||||
point: { x: number; y: number },
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
options?: { maxDistance?: number; preferMedia?: MediaKind },
|
||||
): NearestEdgeHit | null {
|
||||
const maxDistance = options?.maxDistance ?? 56;
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
let best: NearestEdgeHit | null = null;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const edge of edges) {
|
||||
const s = byId.get(edge.source);
|
||||
const t = byId.get(edge.target);
|
||||
if (!s || !t) continue;
|
||||
const a = nodeCenter(s);
|
||||
const b = nodeCenter(t);
|
||||
const distance = distToSegment(point, a, b);
|
||||
if (distance > maxDistance) continue;
|
||||
const media = mediaForEdge(s, edge.sourceHandle, t, edge.targetHandle);
|
||||
const mediaPenalty =
|
||||
options?.preferMedia && media !== options.preferMedia && media !== "unknown"
|
||||
? 40
|
||||
: 0;
|
||||
const score = distance + mediaPenalty;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = {
|
||||
edge,
|
||||
distance,
|
||||
media,
|
||||
midpoint: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
||||
sourceCenter: a,
|
||||
targetCenter: b,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface InsertPlan {
|
||||
node: Node<EntropykNodeData>;
|
||||
edgesToAdd: Edge[];
|
||||
edgeIdToRemove: string;
|
||||
}
|
||||
|
||||
/** Build nodes/edges mutation to splice a 2-port component into an edge. */
|
||||
export function planEdgeInsert(args: {
|
||||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
position: { x: number; y: number };
|
||||
params: Record<string, number | string | boolean>;
|
||||
edge: Edge;
|
||||
circuit?: number;
|
||||
sourceCenter: { x: number; y: number };
|
||||
targetCenter: { x: number; y: number };
|
||||
}): InsertPlan {
|
||||
const { type, id, name, params, edge } = args;
|
||||
const rotation: RotationDeg = orientationAlongEdge(args.sourceCenter, args.targetCenter);
|
||||
const size = nodeSize(type);
|
||||
// Center pipe on the edge midpoint (ignore caller position if it's off-center).
|
||||
const mid = {
|
||||
x: (args.sourceCenter.x + args.targetCenter.x) / 2,
|
||||
y: (args.sourceCenter.y + args.targetCenter.y) / 2,
|
||||
};
|
||||
const position = centerNodePosition(mid, size);
|
||||
|
||||
const node: Node<EntropykNodeData> = {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position,
|
||||
data: {
|
||||
type,
|
||||
name,
|
||||
circuit: args.circuit ?? 0,
|
||||
rotation,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params,
|
||||
},
|
||||
};
|
||||
|
||||
// Orthogonal routing — smoother arrows than default when handles face correctly.
|
||||
const edgeType = "smoothstep";
|
||||
|
||||
const edgesToAdd: Edge[] = [
|
||||
{
|
||||
id: `${edge.id}-a-${id.slice(0, 8)}`,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle ?? undefined,
|
||||
target: id,
|
||||
targetHandle: "inlet",
|
||||
animated: false,
|
||||
type: edgeType,
|
||||
},
|
||||
{
|
||||
id: `${edge.id}-b-${id.slice(0, 8)}`,
|
||||
source: id,
|
||||
sourceHandle: "outlet",
|
||||
target: edge.target,
|
||||
targetHandle: edge.targetHandle ?? undefined,
|
||||
animated: false,
|
||||
type: edgeType,
|
||||
},
|
||||
];
|
||||
|
||||
return { node, edgesToAdd, edgeIdToRemove: edge.id };
|
||||
}
|
||||
|
||||
export function mediaCompatible(pipe: MediaKind, edge: MediaKind): boolean {
|
||||
if (edge === "unknown" || pipe === "unknown") return true;
|
||||
return pipe === edge;
|
||||
}
|
||||
|
||||
export function circuitFromEdge(
|
||||
edge: Edge,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
): number {
|
||||
const s = nodes.find((n) => n.id === edge.source);
|
||||
return s?.data.circuit ?? 0;
|
||||
}
|
||||
|
||||
export function validatePipeOnEdge(
|
||||
pipeType: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
edge: Edge,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
): { ok: boolean; reason?: string } {
|
||||
const pipeKind = pipeMediaKind(pipeType, params);
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const edgeKind = mediaForEdge(
|
||||
byId.get(edge.source),
|
||||
edge.sourceHandle,
|
||||
byId.get(edge.target),
|
||||
edge.targetHandle,
|
||||
);
|
||||
if (!mediaCompatible(pipeKind, edgeKind)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Ce conduit (${pipeKind}) ne correspond pas à la ligne (${edgeKind}).`,
|
||||
};
|
||||
}
|
||||
const probe = mediaForPort(
|
||||
{
|
||||
data: {
|
||||
type: pipeType,
|
||||
name: "p",
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params,
|
||||
},
|
||||
},
|
||||
"inlet",
|
||||
);
|
||||
if (!mediaCompatible(probe, edgeKind)) {
|
||||
return { ok: false, reason: `Milieu incompatible (${probe} vs ${edgeKind}).` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
109
apps/web/src/lib/hxFamily.ts
Normal file
109
apps/web/src/lib/hxFamily.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Visual family for heat exchangers — drives glyph + node badge so DX /
|
||||
* flooded / plate / coil / MCHX are distinguishable at a glance.
|
||||
*/
|
||||
|
||||
export type HxFamilyId =
|
||||
| "dx"
|
||||
| "flooded"
|
||||
| "plate"
|
||||
| "coil"
|
||||
| "mchx"
|
||||
| "shell"
|
||||
| "generic";
|
||||
|
||||
export interface HxFamily {
|
||||
id: HxFamilyId;
|
||||
/** Short French badge on the canvas */
|
||||
badge: string;
|
||||
/** Palette / tooltip line */
|
||||
label: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const FAMILIES: Record<HxFamilyId, HxFamily> = {
|
||||
dx: {
|
||||
id: "dx",
|
||||
badge: "DX",
|
||||
label: "Détente directe",
|
||||
accent: "#1565c0",
|
||||
},
|
||||
flooded: {
|
||||
id: "flooded",
|
||||
badge: "Noyé",
|
||||
label: "Calandre noyée",
|
||||
accent: "#0d7377",
|
||||
},
|
||||
plate: {
|
||||
id: "plate",
|
||||
badge: "Plaques",
|
||||
label: "Échangeur à plaques",
|
||||
accent: "#1a56a8",
|
||||
},
|
||||
coil: {
|
||||
id: "coil",
|
||||
badge: "Coil",
|
||||
label: "Batterie à ailettes",
|
||||
accent: "#c27803",
|
||||
},
|
||||
mchx: {
|
||||
id: "mchx",
|
||||
badge: "MCHX",
|
||||
label: "Microcanaux",
|
||||
accent: "#d97706",
|
||||
},
|
||||
shell: {
|
||||
id: "shell",
|
||||
badge: "Tubes",
|
||||
label: "Calandre / tubes",
|
||||
accent: "#c0392b",
|
||||
},
|
||||
generic: {
|
||||
id: "generic",
|
||||
badge: "HX",
|
||||
label: "Échangeur",
|
||||
accent: "#475569",
|
||||
},
|
||||
};
|
||||
|
||||
/** 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;
|
||||
if (
|
||||
type.includes("FinCoil") ||
|
||||
type.includes("AirCooled") ||
|
||||
type === "CondenserCoil" ||
|
||||
type === "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;
|
||||
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);
|
||||
if (!f) return null;
|
||||
switch (f.id) {
|
||||
case "plate":
|
||||
return "hx_plate";
|
||||
case "flooded":
|
||||
return "hx_flooded";
|
||||
case "coil":
|
||||
return "hx_coil";
|
||||
case "mchx":
|
||||
return "hx_mchx";
|
||||
case "dx":
|
||||
return "hx_dx";
|
||||
case "shell":
|
||||
return type.includes("Evaporator") ? "hx_shell_evap" : "hx_shell_cond";
|
||||
default:
|
||||
return "hx";
|
||||
}
|
||||
}
|
||||
48
apps/web/src/lib/mediaStyle.test.ts
Normal file
48
apps/web/src/lib/mediaStyle.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { classifyFluidName, mediaForEdge, mediaForPort } from "./mediaStyle";
|
||||
|
||||
function node(
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean> = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id: name,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, rotation: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("mediaStyle", () => {
|
||||
it("classifies fluid names", () => {
|
||||
expect(classifyFluidName("R134a")).toBe("refrigerant");
|
||||
expect(classifyFluidName("Water")).toBe("water");
|
||||
expect(classifyFluidName("MEG")).toBe("water");
|
||||
expect(classifyFluidName("Air")).toBe("air");
|
||||
});
|
||||
|
||||
it("marks brine source ports as water and compressor as refrigerant", () => {
|
||||
expect(mediaForPort(node("BrineSource", "evap_water_in"), "outlet")).toBe("water");
|
||||
expect(mediaForPort(node("IsentropicCompressor", "comp"), "outlet")).toBe("refrigerant");
|
||||
expect(mediaForPort(node("AirSource", "oa"), "outlet")).toBe("air");
|
||||
});
|
||||
|
||||
it("marks flooded secondary ports as water and fin-coil secondary as air", () => {
|
||||
expect(
|
||||
mediaForPort(node("FloodedEvaporator", "evap"), "secondary_inlet"),
|
||||
).toBe("water");
|
||||
expect(
|
||||
mediaForPort(node("FinCoilCondenser", "cond"), "secondary_inlet"),
|
||||
).toBe("air");
|
||||
expect(mediaForPort(node("FinCoilCondenser", "cond"), "inlet")).toBe("refrigerant");
|
||||
});
|
||||
|
||||
it("colours an edge from brine source to flooded secondary as water", () => {
|
||||
const src = node("BrineSource", "evap_water_in");
|
||||
const tgt = node("FloodedEvaporator", "evap");
|
||||
expect(mediaForEdge(src, "outlet", tgt, "secondary_inlet")).toBe("water");
|
||||
});
|
||||
});
|
||||
204
apps/web/src/lib/mediaStyle.ts
Normal file
204
apps/web/src/lib/mediaStyle.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Modelica / TIL-style medium colours for diagram wires and connectors.
|
||||
*
|
||||
* Convention (TIL Media + common refrigeration Modelica libraries):
|
||||
* - Refrigerant (VLE) … green
|
||||
* - Water / brine / glycol … blue
|
||||
* - Air / gas …………… yellow / amber
|
||||
*
|
||||
* Buildings/MSL do not hard-code medium colours on connectors; TIL and most
|
||||
* HVAC libraries do — we follow that engineering diagram practice.
|
||||
*/
|
||||
|
||||
import { isSecondaryPort } from "./componentMeta";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type MediaKind = "refrigerant" | "water" | "air" | "unknown";
|
||||
|
||||
/** Stroke / connector fill colours (TIL-like). */
|
||||
export const MEDIA_COLOR: Record<MediaKind, string> = {
|
||||
refrigerant: "#2e7d32", // green — two-phase / VLE
|
||||
water: "#1565c0", // blue — liquid HTF (Modelica connector blue)
|
||||
air: "#f9a825", // amber/yellow — gas / moist air
|
||||
unknown: "#36475a",
|
||||
};
|
||||
|
||||
export const MEDIA_LABEL: Record<MediaKind, string> = {
|
||||
refrigerant: "Frigorigène",
|
||||
water: "Eau / brine",
|
||||
air: "Air",
|
||||
unknown: "Autre",
|
||||
};
|
||||
|
||||
const AIR_TYPES = new Set([
|
||||
"AirSource",
|
||||
"AirSink",
|
||||
"Fan",
|
||||
"FinCoilCondenser",
|
||||
"AirCooledCondenser",
|
||||
"MchxCondenserCoil",
|
||||
]);
|
||||
|
||||
const WATER_TYPES = new Set([
|
||||
"BrineSource",
|
||||
"BrineSink",
|
||||
"Pump",
|
||||
"FreeCoolingExchanger",
|
||||
"WaterPipe",
|
||||
"PipeWater",
|
||||
]);
|
||||
|
||||
const AIR_PIPE_TYPES = new Set(["AirDuct", "PipeAir"]);
|
||||
|
||||
const REFRIGERANT_TYPES = new Set([
|
||||
"RefrigerantSource",
|
||||
"RefrigerantSink",
|
||||
"IsentropicCompressor",
|
||||
"Compressor",
|
||||
"ScrewEconomizerCompressor",
|
||||
"CentrifugalCompressor",
|
||||
"IsenthalpicExpansionValve",
|
||||
"ExpansionValve",
|
||||
"CapillaryTube",
|
||||
"ReversingValve",
|
||||
"BypassValve",
|
||||
"FloodedEvaporator",
|
||||
"BphxEvaporator",
|
||||
"BphxCondenser",
|
||||
"Condenser",
|
||||
"Evaporator",
|
||||
"CondenserCoil",
|
||||
"EvaporatorCoil",
|
||||
"Drum",
|
||||
"Pipe",
|
||||
"RefrigerantPipe",
|
||||
"FlowSplitter",
|
||||
"FlowMerger",
|
||||
]);
|
||||
|
||||
/** Map a fluid name string to a medium kind. */
|
||||
export function classifyFluidName(raw: string | undefined | null): MediaKind | null {
|
||||
if (!raw) return null;
|
||||
const f = String(raw).trim().toLowerCase();
|
||||
if (!f) return null;
|
||||
if (
|
||||
f === "air" ||
|
||||
f === "moistair" ||
|
||||
f === "moist_air" ||
|
||||
f.startsWith("air")
|
||||
) {
|
||||
return "air";
|
||||
}
|
||||
if (
|
||||
f === "water" ||
|
||||
f === "meg" ||
|
||||
f === "mpg" ||
|
||||
f.includes("glycol") ||
|
||||
f.includes("brine") ||
|
||||
f === "incompressiblewater"
|
||||
) {
|
||||
return "water";
|
||||
}
|
||||
// R134a, R410A, CO2, ammonia, …
|
||||
if (
|
||||
f.startsWith("r") ||
|
||||
f === "co2" ||
|
||||
f === "r744" ||
|
||||
f.includes("ammonia") ||
|
||||
f === "nh3" ||
|
||||
f === "propane" ||
|
||||
f === "r290"
|
||||
) {
|
||||
return "refrigerant";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type PortHost =
|
||||
| Node<EntropykNodeData>
|
||||
| { data: EntropykNodeData }
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Infer the medium on a specific port of a component (Modelica Medium package).
|
||||
*/
|
||||
export function mediaForPort(
|
||||
node: PortHost,
|
||||
port: string | null | undefined,
|
||||
): MediaKind {
|
||||
if (!node) return "unknown";
|
||||
const type = node.data.type;
|
||||
const params = node.data.params ?? {};
|
||||
const p = port ?? "";
|
||||
|
||||
if (type === "SaturatedController" || type === "Placeholder") return "unknown";
|
||||
|
||||
// Explicit 4-port HX fluid ids
|
||||
if (p === "hot_inlet" || p === "hot_outlet") {
|
||||
return classifyFluidName(String(params.hot_fluid_id ?? params.hot_fluid ?? "")) ?? "water";
|
||||
}
|
||||
if (p === "cold_inlet" || p === "cold_outlet") {
|
||||
return classifyFluidName(String(params.cold_fluid_id ?? params.cold_fluid ?? "")) ?? "air";
|
||||
}
|
||||
|
||||
// Secondary / HTF side of refrigerant HX
|
||||
if (isSecondaryPort(p)) {
|
||||
if (AIR_TYPES.has(type)) return "air";
|
||||
const sec = classifyFluidName(
|
||||
String(params.secondary_fluid ?? params.fluid ?? ""),
|
||||
);
|
||||
if (sec) return sec;
|
||||
// Flooded / BPHX / generic condenser-evaporator → water loop by default
|
||||
return "water";
|
||||
}
|
||||
|
||||
// Dedicated pipe / duct components (palette types)
|
||||
if (AIR_PIPE_TYPES.has(type)) return "air";
|
||||
if (type === "WaterPipe" || type === "PipeWater") return "water";
|
||||
if (type === "RefrigerantPipe") return "refrigerant";
|
||||
if (type === "Pipe") {
|
||||
return classifyFluidName(String(params.fluid ?? "")) ?? "refrigerant";
|
||||
}
|
||||
|
||||
// Whole-component media (sources, fans, pumps)
|
||||
if (AIR_TYPES.has(type) && !isSecondaryPort(p) && type !== "FinCoilCondenser" && type !== "AirCooledCondenser" && type !== "MchxCondenserCoil") {
|
||||
return "air";
|
||||
}
|
||||
if (type === "Fan") return "air";
|
||||
if (WATER_TYPES.has(type)) {
|
||||
return classifyFluidName(String(params.fluid ?? "")) ?? "water";
|
||||
}
|
||||
if (type === "AirSource" || type === "AirSink") return "air";
|
||||
if (type === "BrineSource" || type === "BrineSink") return "water";
|
||||
|
||||
// Air-cooled coils: primary ports are refrigerant, secondary already handled
|
||||
if (AIR_TYPES.has(type)) return "refrigerant";
|
||||
|
||||
if (REFRIGERANT_TYPES.has(type)) {
|
||||
return classifyFluidName(String(params.fluid ?? params.refrigerant ?? "")) ?? "refrigerant";
|
||||
}
|
||||
|
||||
// HeatExchanger primary-style ports without hot_/cold_ prefix
|
||||
if (type === "HeatExchanger") {
|
||||
return classifyFluidName(String(params.hot_fluid_id ?? "")) ?? "water";
|
||||
}
|
||||
|
||||
return "refrigerant";
|
||||
}
|
||||
|
||||
/** Edge medium: prefer the upstream (source) port, fall back to target. */
|
||||
export function mediaForEdge(
|
||||
source: Node<EntropykNodeData> | undefined,
|
||||
sourceHandle: string | null | undefined,
|
||||
target: Node<EntropykNodeData> | undefined,
|
||||
targetHandle: string | null | undefined,
|
||||
): MediaKind {
|
||||
const a = mediaForPort(source, sourceHandle);
|
||||
if (a !== "unknown") return a;
|
||||
return mediaForPort(target, targetHandle);
|
||||
}
|
||||
|
||||
export function mediaColor(kind: MediaKind): string {
|
||||
return MEDIA_COLOR[kind];
|
||||
}
|
||||
113
apps/web/src/lib/multiRun.test.ts
Normal file
113
apps/web/src/lib/multiRun.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import {
|
||||
applyOverride,
|
||||
buildSweepCases,
|
||||
defaultSweepsFromDiagram,
|
||||
discoverSweepTargets,
|
||||
parseValueList,
|
||||
suggestValues,
|
||||
} from "./multiRun";
|
||||
|
||||
function node(
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id: name,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, rotation: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("multiRun", () => {
|
||||
it("parses value lists", () => {
|
||||
expect(parseValueList("1, 2; 3\n4")).toEqual(["1", "2", "3", "4"]);
|
||||
});
|
||||
|
||||
it("applies brine water temperature overrides by component name", () => {
|
||||
const base = {
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "BrineSource", name: "evap_water_in", t_set_c: 12 },
|
||||
{ type: "BrineSource", name: "cond_water_in", t_set_c: 30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = applyOverride(base, "evap_water_in.t_set_c", "10", "scalar") as typeof base;
|
||||
expect(next.circuits[0].components[0].t_set_c).toBe(10);
|
||||
expect(next.circuits[0].components[1].t_set_c).toBe(30);
|
||||
});
|
||||
|
||||
it("discovers water temperatures from BrineSource nodes first", () => {
|
||||
const nodes = [
|
||||
node("FloodedEvaporator", "evap", { ua: 8000 }),
|
||||
node("BrineSource", "evap_water_in", { t_set_c: 12, m_flow_kg_s: 0.5, p_set_bar: 2 }),
|
||||
node("BrineSource", "cond_water_in", { t_set_c: 30, m_flow_kg_s: 0.6, p_set_bar: 2 }),
|
||||
];
|
||||
const targets = discoverSweepTargets(nodes, "R134a");
|
||||
const paths = targets.map((t) => t.path);
|
||||
expect(paths).toContain("evap_water_in.t_set_c");
|
||||
expect(paths).toContain("cond_water_in.t_set_c");
|
||||
expect(paths.indexOf("evap_water_in.t_set_c")).toBeLessThan(paths.indexOf("evap.ua"));
|
||||
expect(targets.find((t) => t.path === "evap_water_in.t_set_c")?.label).toMatch(/évaporateur/i);
|
||||
});
|
||||
|
||||
it("defaults multi-run axes to both water temperatures", () => {
|
||||
const nodes = [
|
||||
node("BrineSource", "evap_water_in", { t_set_c: 12, m_flow_kg_s: 0.5 }),
|
||||
node("BrineSource", "cond_water_in", { t_set_c: 30, m_flow_kg_s: 0.6 }),
|
||||
];
|
||||
const sweeps = defaultSweepsFromDiagram(nodes, "R134a");
|
||||
expect(sweeps).toHaveLength(2);
|
||||
expect(sweeps.map((s) => s.path).sort()).toEqual([
|
||||
"cond_water_in.t_set_c",
|
||||
"evap_water_in.t_set_c",
|
||||
].sort());
|
||||
expect(sweeps[0].valuesText).toContain(",");
|
||||
});
|
||||
|
||||
it("suggests temperature steps around current", () => {
|
||||
expect(suggestValues(12, "t_set_c")).toBe("10, 12, 14");
|
||||
});
|
||||
|
||||
it("builds cartesian product of water temp sweeps", () => {
|
||||
const base = {
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
components: [
|
||||
{ name: "evap_water_in", t_set_c: 12 },
|
||||
{ name: "cond_water_in", t_set_c: 30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const cases = buildSweepCases(base, [
|
||||
{
|
||||
path: "evap_water_in.t_set_c",
|
||||
label: "T évap",
|
||||
kind: "scalar",
|
||||
valuesText: "10, 12",
|
||||
},
|
||||
{
|
||||
path: "cond_water_in.t_set_c",
|
||||
label: "T cond",
|
||||
kind: "scalar",
|
||||
valuesText: "30, 35",
|
||||
},
|
||||
]);
|
||||
expect(cases).toHaveLength(4);
|
||||
const cfg = cases.find((c) => c.label.includes("10") && c.label.includes("35"))
|
||||
?.config as typeof base;
|
||||
expect(cfg.circuits[0].components[0].t_set_c).toBe(10);
|
||||
expect(cfg.circuits[0].components[1].t_set_c).toBe(35);
|
||||
});
|
||||
});
|
||||
411
apps/web/src/lib/multiRun.ts
Normal file
411
apps/web/src/lib/multiRun.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Parallel multi-run helpers: discover sweepable params from the diagram,
|
||||
* clone ScenarioConfig, and solve cases in parallel.
|
||||
*/
|
||||
|
||||
import { simulate, type SimulationResult } from "./api";
|
||||
import { COMPONENT_BY_TYPE } from "./componentMeta";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type SweepKind = "scalar" | "fluid";
|
||||
|
||||
export interface SweepSpec {
|
||||
/** `fluid` or `componentName.param` (CLI-flattened JSON). */
|
||||
path: string;
|
||||
label: string;
|
||||
kind: SweepKind;
|
||||
/** Comma / newline separated values. */
|
||||
valuesText: string;
|
||||
}
|
||||
|
||||
export interface SweepTarget {
|
||||
path: string;
|
||||
label: string;
|
||||
kind: SweepKind;
|
||||
group: "boundaries" | "thermal" | "machine" | "global";
|
||||
/** Current value on the diagram (for suggested ranges). */
|
||||
current?: number | string | boolean;
|
||||
unit?: string;
|
||||
/** Component display name (empty for global). */
|
||||
componentName?: string;
|
||||
componentType?: string;
|
||||
paramKey?: string;
|
||||
}
|
||||
|
||||
export interface MultiRunCase {
|
||||
id: string;
|
||||
label: string;
|
||||
config: unknown;
|
||||
overrides: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface MultiRunResult {
|
||||
case: MultiRunCase;
|
||||
ok: boolean;
|
||||
result?: SimulationResult;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Params engineers typically sweep on a cycle. */
|
||||
const SWEEP_PARAM_PRIORITY: Record<string, { group: SweepTarget["group"]; rank: number }> = {
|
||||
t_set_c: { group: "boundaries", rank: 10 },
|
||||
t_dry_c: { group: "boundaries", rank: 11 },
|
||||
oat_k: { group: "boundaries", rank: 12 },
|
||||
m_flow_kg_s: { group: "boundaries", rank: 20 },
|
||||
air_face_velocity_m_s: { group: "boundaries", rank: 21 },
|
||||
ua: { group: "thermal", rank: 30 },
|
||||
opening: { group: "machine", rank: 40 },
|
||||
speed_ratio: { group: "machine", rank: 41 },
|
||||
frequency_hz: { group: "machine", rank: 42 },
|
||||
speed_rpm: { group: "machine", rank: 43 },
|
||||
slide_valve_position: { group: "machine", rank: 44 },
|
||||
volume_index: { group: "machine", rank: 45 },
|
||||
isentropic_efficiency: { group: "machine", rank: 46 },
|
||||
};
|
||||
|
||||
const BOUNDARY_TYPES = new Set([
|
||||
"BrineSource",
|
||||
"AirSource",
|
||||
"RefrigerantSource",
|
||||
]);
|
||||
|
||||
/** Parse "1, 2, 3" or multiline into string tokens. */
|
||||
export function parseValueList(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function humanParamLabel(type: string, paramKey: string, unit?: string): string {
|
||||
const meta = COMPONENT_BY_TYPE[type]?.params.find((p) => p.key === paramKey);
|
||||
const base = meta?.label ?? paramKey;
|
||||
const u = unit ?? meta?.unit;
|
||||
return u ? `${base} (${u})` : base;
|
||||
}
|
||||
|
||||
function friendlyComponentRole(type: string, name: string): string {
|
||||
if (type === "BrineSource") {
|
||||
if (/evap/i.test(name)) return "Eau évaporateur";
|
||||
if (/cond/i.test(name)) return "Eau condenseur";
|
||||
return `Source eau « ${name} »`;
|
||||
}
|
||||
if (type === "AirSource") {
|
||||
if (/cond|oat|outdoor/i.test(name)) return "Air extérieur";
|
||||
if (/evap|indoor/i.test(name)) return "Air intérieur";
|
||||
return `Source air « ${name} »`;
|
||||
}
|
||||
const label = COMPONENT_BY_TYPE[type]?.label ?? type;
|
||||
return `${label} « ${name} »`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover sweepable parameters from the live diagram.
|
||||
* Boundaries (water/air T) come first — that's what engineers sweep for ratings.
|
||||
*/
|
||||
export function discoverSweepTargets(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
fluid?: string,
|
||||
): SweepTarget[] {
|
||||
const targets: SweepTarget[] = [
|
||||
{
|
||||
path: "fluid",
|
||||
label: "Fluide frigorigène",
|
||||
kind: "fluid",
|
||||
group: "global",
|
||||
current: fluid,
|
||||
},
|
||||
];
|
||||
|
||||
for (const node of nodes) {
|
||||
const type = node.data.type;
|
||||
const name = node.data.name;
|
||||
if (!type || !name || type === "SaturatedController") continue;
|
||||
const params = node.data.params ?? {};
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (key.startsWith("__")) continue;
|
||||
const prio = SWEEP_PARAM_PRIORITY[key];
|
||||
if (!prio) continue;
|
||||
// Prefer live boundary setpoints over rating scalars on HX when both exist.
|
||||
if (
|
||||
(key === "secondary_inlet_temp_c" || key === "secondary_mass_flow_kg_s") &&
|
||||
!BOUNDARY_TYPES.has(type)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const paramMeta = meta?.params.find((p) => p.key === key);
|
||||
const role = friendlyComponentRole(type, name);
|
||||
const paramLabel = humanParamLabel(type, key, paramMeta?.unit);
|
||||
targets.push({
|
||||
path: `${name}.${key}`,
|
||||
label: `${role} — ${paramLabel}`,
|
||||
kind: typeof val === "string" && key === "fluid" ? "fluid" : "scalar",
|
||||
group: BOUNDARY_TYPES.has(type) ? "boundaries" : prio.group,
|
||||
current: val,
|
||||
unit: paramMeta?.unit,
|
||||
componentName: name,
|
||||
componentType: type,
|
||||
paramKey: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
targets.sort((a, b) => {
|
||||
const order = { boundaries: 0, thermal: 1, machine: 2, global: 3 };
|
||||
const ga = order[a.group];
|
||||
const gb = order[b.group];
|
||||
if (ga !== gb) return ga - gb;
|
||||
const ra = a.paramKey ? (SWEEP_PARAM_PRIORITY[a.paramKey]?.rank ?? 99) : 0;
|
||||
const rb = b.paramKey ? (SWEEP_PARAM_PRIORITY[b.paramKey]?.rank ?? 99) : 0;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Suggest a small sweep around the current numeric value. */
|
||||
export function suggestValues(current: number | string | boolean | undefined, paramKey?: string): string {
|
||||
if (typeof current !== "number" || !Number.isFinite(current)) {
|
||||
if (paramKey === "fluid" || current === undefined) return "R134a, R410A";
|
||||
return String(current ?? "");
|
||||
}
|
||||
if (paramKey === "t_set_c" || paramKey === "t_dry_c") {
|
||||
const step = 2;
|
||||
return [current - step, current, current + step].map((v) => String(v)).join(", ");
|
||||
}
|
||||
if (paramKey === "oat_k") {
|
||||
return [current - 5, current, current + 5].map((v) => String(v)).join(", ");
|
||||
}
|
||||
if (paramKey === "ua") {
|
||||
return [current * 0.8, current, current * 1.2]
|
||||
.map((v) => String(Math.round(v)))
|
||||
.join(", ");
|
||||
}
|
||||
if (paramKey === "m_flow_kg_s" || paramKey === "opening" || paramKey === "speed_ratio") {
|
||||
const a = Math.max(current * 0.8, 0);
|
||||
const b = current;
|
||||
const c = current * 1.2;
|
||||
return [a, b, c].map((v) => (Number.isInteger(v) ? String(v) : v.toFixed(3))).join(", ");
|
||||
}
|
||||
return String(current);
|
||||
}
|
||||
|
||||
/** Default axes when opening Multi-run: both water temperatures if present. */
|
||||
export function defaultSweepsFromDiagram(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
fluid?: string,
|
||||
): SweepSpec[] {
|
||||
const targets = discoverSweepTargets(nodes, fluid);
|
||||
const waterTemps = targets.filter(
|
||||
(t) =>
|
||||
t.componentType === "BrineSource" &&
|
||||
t.paramKey === "t_set_c",
|
||||
);
|
||||
if (waterTemps.length >= 1) {
|
||||
return waterTemps.slice(0, 2).map((t) => ({
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
}));
|
||||
}
|
||||
const airTemps = targets.filter(
|
||||
(t) => t.componentType === "AirSource" && t.paramKey === "t_dry_c",
|
||||
);
|
||||
if (airTemps.length >= 1) {
|
||||
return airTemps.slice(0, 2).map((t) => ({
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
}));
|
||||
}
|
||||
const first = targets.find((t) => t.path !== "fluid") ?? targets[0];
|
||||
return [
|
||||
{
|
||||
path: first.path,
|
||||
label: first.label,
|
||||
kind: first.kind,
|
||||
valuesText: suggestValues(first.current, first.paramKey),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a parameter on a named component: `evap_water_in.t_set_c`.
|
||||
* Component names may contain underscores.
|
||||
*/
|
||||
export function applyOverride(
|
||||
config: unknown,
|
||||
path: string,
|
||||
raw: string,
|
||||
kind: SweepKind,
|
||||
): unknown {
|
||||
const value: string | number =
|
||||
kind === "fluid" ? raw : Number.isFinite(Number(raw)) ? Number(raw) : raw;
|
||||
|
||||
if (path === "fluid") {
|
||||
const c = structuredClone(config) as Record<string, unknown>;
|
||||
c.fluid = value;
|
||||
return c;
|
||||
}
|
||||
|
||||
const dot = path.indexOf(".");
|
||||
if (dot > 0) {
|
||||
const name = path.slice(0, dot);
|
||||
const param = path.slice(dot + 1);
|
||||
if (name && param && !param.includes(".")) {
|
||||
const c = structuredClone(config) as {
|
||||
circuits?: Array<{
|
||||
components?: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
let hit = false;
|
||||
for (const circuit of c.circuits ?? []) {
|
||||
for (const comp of circuit.components ?? []) {
|
||||
if (comp.name === name) {
|
||||
comp[param] = value;
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hit) {
|
||||
// Keep config but caller may surface a warning via empty hit.
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
return structuredClone(config);
|
||||
}
|
||||
|
||||
/** Cartesian product of sweep axes → run cases. */
|
||||
export function buildSweepCases(
|
||||
baseConfig: unknown,
|
||||
sweeps: SweepSpec[],
|
||||
): MultiRunCase[] {
|
||||
const axes = sweeps
|
||||
.map((s) => ({
|
||||
...s,
|
||||
values: parseValueList(s.valuesText),
|
||||
}))
|
||||
.filter((s) => s.values.length > 0);
|
||||
|
||||
if (axes.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: "base",
|
||||
label: "base",
|
||||
config: structuredClone(baseConfig),
|
||||
overrides: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let combos: Array<Record<string, string>> = [{}];
|
||||
for (const axis of axes) {
|
||||
const next: Array<Record<string, string>> = [];
|
||||
for (const prev of combos) {
|
||||
for (const v of axis.values) {
|
||||
next.push({ ...prev, [axis.path]: v });
|
||||
}
|
||||
}
|
||||
combos = next;
|
||||
}
|
||||
|
||||
return combos.map((overrides, i) => {
|
||||
let cfg = structuredClone(baseConfig);
|
||||
const labels: string[] = [];
|
||||
for (const axis of axes) {
|
||||
const raw = overrides[axis.path];
|
||||
cfg = applyOverride(cfg, axis.path, raw, axis.kind);
|
||||
labels.push(`${axis.label || axis.path}=${raw}`);
|
||||
}
|
||||
return {
|
||||
id: `case-${i + 1}`,
|
||||
label: labels.join(" · "),
|
||||
config: cfg,
|
||||
overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Run cases with a concurrency limit (default 4). */
|
||||
export async function runParallel(
|
||||
cases: MultiRunCase[],
|
||||
options?: { concurrency?: number; onProgress?: (done: number, total: number) => void },
|
||||
): Promise<MultiRunResult[]> {
|
||||
const concurrency = Math.max(1, options?.concurrency ?? 4);
|
||||
const total = cases.length;
|
||||
const results: MultiRunResult[] = new Array(total);
|
||||
let next = 0;
|
||||
let done = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < total) {
|
||||
const idx = next++;
|
||||
const c = cases[idx];
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const resp = await simulate(c.config);
|
||||
results[idx] = {
|
||||
case: c,
|
||||
ok: !!resp.ok && !!resp.result,
|
||||
result: resp.result,
|
||||
error: resp.error,
|
||||
durationMs: performance.now() - t0,
|
||||
};
|
||||
} catch (e) {
|
||||
results[idx] = {
|
||||
case: c,
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
durationMs: performance.now() - t0,
|
||||
};
|
||||
}
|
||||
done++;
|
||||
options?.onProgress?.(done, total);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Compact KPIs for comparison tables. */
|
||||
export function extractKpis(result?: SimulationResult): {
|
||||
status: string;
|
||||
cop: number | null;
|
||||
qCoolKw: number | null;
|
||||
powerKw: number | null;
|
||||
iterations: number | null;
|
||||
} {
|
||||
if (!result) {
|
||||
return { status: "error", cop: null, qCoolKw: null, powerKw: null, iterations: null };
|
||||
}
|
||||
const p = result.performance;
|
||||
const cop =
|
||||
p?.cop ??
|
||||
p?.cop_cooling ??
|
||||
(p?.cooling_capacity_w != null && p?.compressor_power_w
|
||||
? p.cooling_capacity_w / p.compressor_power_w
|
||||
: null);
|
||||
const qCoolKw =
|
||||
p?.q_cooling_kw ??
|
||||
(p?.cooling_capacity_w != null ? p.cooling_capacity_w / 1000 : null);
|
||||
const powerKw =
|
||||
p?.compressor_power_kw ??
|
||||
(p?.compressor_power_w != null ? p.compressor_power_w / 1000 : null);
|
||||
return {
|
||||
status: result.status,
|
||||
cop: cop != null && Number.isFinite(cop) ? cop : null,
|
||||
qCoolKw: qCoolKw != null && Number.isFinite(qCoolKw) ? qCoolKw : null,
|
||||
powerKw: powerKw != null && Number.isFinite(powerKw) ? powerKw : null,
|
||||
iterations: result.iterations ?? result.convergence?.iterations ?? null,
|
||||
};
|
||||
}
|
||||
28
apps/web/src/lib/orientation.test.ts
Normal file
28
apps/web/src/lib/orientation.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
effectiveSide,
|
||||
orientationAlongEdge,
|
||||
rotateSide,
|
||||
} from "./orientation";
|
||||
|
||||
describe("orientation", () => {
|
||||
it("rotates sides clockwise", () => {
|
||||
expect(rotateSide("left", 90)).toBe("top");
|
||||
expect(rotateSide("left", 180)).toBe("right");
|
||||
expect(rotateSide("left", 270)).toBe("bottom");
|
||||
});
|
||||
|
||||
it("applies Modelica rotate then flip", () => {
|
||||
expect(effectiveSide("left", 0, false, false)).toBe("left");
|
||||
expect(effectiveSide("left", 180, false, false)).toBe("right");
|
||||
expect(effectiveSide("left", 0, true, false)).toBe("right");
|
||||
expect(effectiveSide("top", 0, false, true)).toBe("bottom");
|
||||
});
|
||||
|
||||
it("picks rotation along an edge", () => {
|
||||
expect(orientationAlongEdge({ x: 0, y: 0 }, { x: 100, y: 0 })).toBe(0);
|
||||
expect(orientationAlongEdge({ x: 100, y: 0 }, { x: 0, y: 0 })).toBe(180);
|
||||
expect(orientationAlongEdge({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90);
|
||||
expect(orientationAlongEdge({ x: 0, y: 100 }, { x: 0, y: 0 })).toBe(270);
|
||||
});
|
||||
});
|
||||
78
apps/web/src/lib/orientation.ts
Normal file
78
apps/web/src/lib/orientation.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Modelica / Dymola-style component orientation.
|
||||
*
|
||||
* Handles must NOT be CSS-rotated — React Flow routes edges from Handle
|
||||
* `position` (Left/Right/Top/Bottom). We remap logical port sides through
|
||||
* rotation + flips so connectors stay on the correct box edge.
|
||||
*/
|
||||
|
||||
export type Side = "left" | "right" | "top" | "bottom";
|
||||
|
||||
export type RotationDeg = 0 | 90 | 180 | 270;
|
||||
|
||||
export function normaliseRotation(deg: number): RotationDeg {
|
||||
const n = ((Math.round(deg / 90) * 90) % 360 + 360) % 360;
|
||||
return n as RotationDeg;
|
||||
}
|
||||
|
||||
/** Rotate a side clockwise by 90° × steps. */
|
||||
export function rotateSide(side: Side, rotation: number): Side {
|
||||
const order: Side[] = ["left", "top", "right", "bottom"];
|
||||
const steps = ((Math.round(rotation / 90) % 4) + 4) % 4;
|
||||
const i = order.indexOf(side);
|
||||
return order[(i + steps) % 4];
|
||||
}
|
||||
|
||||
export function flipSideH(side: Side): Side {
|
||||
if (side === "left") return "right";
|
||||
if (side === "right") return "left";
|
||||
return side;
|
||||
}
|
||||
|
||||
export function flipSideV(side: Side): Side {
|
||||
if (side === "top") return "bottom";
|
||||
if (side === "bottom") return "top";
|
||||
return side;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective diagram side for a logical port side after Modelica transform:
|
||||
* rotate clockwise, then flip horizontal, then flip vertical.
|
||||
*/
|
||||
export function effectiveSide(
|
||||
logical: Side,
|
||||
rotation: number,
|
||||
flipH = false,
|
||||
flipV = false,
|
||||
): Side {
|
||||
let s = rotateSide(logical, rotation);
|
||||
if (flipH) s = flipSideH(s);
|
||||
if (flipV) s = flipSideV(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose rotation so a 2-port part (inlet=left, outlet=right at 0°)
|
||||
* faces along the edge from source → target.
|
||||
*/
|
||||
export function orientationAlongEdge(
|
||||
sourceCenter: { x: number; y: number },
|
||||
targetCenter: { x: number; y: number },
|
||||
): RotationDeg {
|
||||
const dx = targetCenter.x - sourceCenter.x;
|
||||
const dy = targetCenter.y - sourceCenter.y;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
// Horizontal: 0° = flow left→right, 180° = right→left
|
||||
return dx >= 0 ? 0 : 180;
|
||||
}
|
||||
// Vertical: 90° CW puts inlet on top, outlet on bottom (flow down)
|
||||
return dy >= 0 ? 90 : 270;
|
||||
}
|
||||
|
||||
/** Top-left position so the node box is centered on `mid`. */
|
||||
export function centerNodePosition(
|
||||
mid: { x: number; y: number },
|
||||
size: { w: number; h: number },
|
||||
): { x: number; y: number } {
|
||||
return { x: mid.x - size.w / 2, y: mid.y - size.h / 2 };
|
||||
}
|
||||
504
apps/web/src/store/diagramStore.test.ts
Normal file
504
apps/web/src/store/diagramStore.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
useDiagramStore,
|
||||
snapToGridValue,
|
||||
snapPosition,
|
||||
normaliseRotation,
|
||||
GRID_SIZE,
|
||||
} from "./diagramStore";
|
||||
|
||||
function reset() {
|
||||
useDiagramStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
fluid: "R410A",
|
||||
fluidBackend: "CoolProp",
|
||||
controls: [],
|
||||
snapToGrid: true,
|
||||
result: null,
|
||||
simulating: false,
|
||||
simError: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("controls import", () => {
|
||||
it("preserves imported co-solved controls for the next simulation run", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [{ type: "IsentropicCompressor", name: "comp", liquid_injection: true }],
|
||||
edges: [],
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "dgt_limiter",
|
||||
measure: { component: "comp", output: "temperature" },
|
||||
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
||||
target: 330,
|
||||
gain: -0.5,
|
||||
band: 5,
|
||||
alpha: 0.002,
|
||||
objectives: [
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
solver: { strategy: "newton", max_iterations: 300, tolerance: 1e-6 },
|
||||
});
|
||||
|
||||
expect(useDiagramStore.getState().controls).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "dgt_limiter",
|
||||
actuator: expect.objectContaining({ factor: "injection", initial: 0.15 }),
|
||||
target: 330,
|
||||
}),
|
||||
]);
|
||||
expect(useDiagramStore.getState().nodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: "SaturatedController",
|
||||
name: "dgt_limiter",
|
||||
params: expect.objectContaining({
|
||||
measure_component: "comp",
|
||||
actuator_factor: "injection",
|
||||
alpha: 0.002,
|
||||
objectives_json: JSON.stringify([
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grid helpers", () => {
|
||||
it("snaps a value to the nearest grid line", () => {
|
||||
expect(snapToGridValue(5, 12)).toBe(0);
|
||||
expect(snapToGridValue(7, 12)).toBe(12);
|
||||
expect(snapToGridValue(18, 12)).toBe(24);
|
||||
});
|
||||
|
||||
it("snaps an {x, y} position", () => {
|
||||
expect(snapPosition({ x: 7, y: 5 }, 12)).toEqual({ x: 12, y: 0 });
|
||||
});
|
||||
|
||||
it("normalises rotation to 0/90/180/270", () => {
|
||||
expect(normaliseRotation(0)).toBe(0);
|
||||
expect(normaliseRotation(90)).toBe(90);
|
||||
expect(normaliseRotation(360)).toBe(0);
|
||||
expect(normaliseRotation(-90)).toBe(270);
|
||||
expect(normaliseRotation(450)).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addComponent", () => {
|
||||
it("creates a node with defaults, rotation 0, and selects it", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 100, y: 100 });
|
||||
const { nodes, selectedNodeId } = useDiagramStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].data.type).toBe("Condenser");
|
||||
expect(nodes[0].data.rotation).toBe(0);
|
||||
expect(nodes[0].data.params.ua).toBe(5000);
|
||||
expect(selectedNodeId).toBe(nodes[0].id);
|
||||
});
|
||||
|
||||
it("snaps the drop position when snap is enabled", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 103, y: 97 });
|
||||
const n = useDiagramStore.getState().nodes[0];
|
||||
expect(n.position.x % GRID_SIZE).toBe(0);
|
||||
expect(n.position.y % GRID_SIZE).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the raw position when snap is disabled", () => {
|
||||
useDiagramStore.setState({ snapToGrid: false });
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 103, y: 97 });
|
||||
const n = useDiagramStore.getState().nodes[0];
|
||||
expect(n.position).toEqual({ x: 103, y: 97 });
|
||||
});
|
||||
|
||||
it("generates unique names per type", () => {
|
||||
const s = useDiagramStore.getState();
|
||||
s.addComponent("Condenser", { x: 0, y: 0 });
|
||||
s.addComponent("Condenser", { x: 0, y: 0 });
|
||||
const names = useDiagramStore.getState().nodes.map((n) => n.data.name);
|
||||
expect(new Set(names).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("node mutations", () => {
|
||||
it("updates params, name, and circuit", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
const s = useDiagramStore.getState();
|
||||
s.updateNodeParams(id, { ua: 9999 });
|
||||
s.updateNodeName(id, "my_cond");
|
||||
s.updateNodeCircuit(id, 2);
|
||||
const n = useDiagramStore.getState().nodes[0];
|
||||
expect(n.data.params.ua).toBe(9999);
|
||||
expect(n.data.name).toBe("my_cond");
|
||||
expect(n.data.circuit).toBe(2);
|
||||
});
|
||||
|
||||
it("rotates clockwise and counter-clockwise with wrap-around", () => {
|
||||
useDiagramStore.getState().addComponent("Compressor", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
const { rotateNode } = useDiagramStore.getState();
|
||||
rotateNode(id, 1);
|
||||
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(90);
|
||||
rotateNode(id, 1);
|
||||
rotateNode(id, 1);
|
||||
rotateNode(id, 1);
|
||||
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(0);
|
||||
rotateNode(id, -1);
|
||||
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(270);
|
||||
});
|
||||
|
||||
it("flips horizontal and vertical like Modelica", () => {
|
||||
useDiagramStore.getState().addComponent("RefrigerantPipe", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
expect(useDiagramStore.getState().nodes[0].data.flipH).toBe(false);
|
||||
useDiagramStore.getState().flipNodeH(id);
|
||||
expect(useDiagramStore.getState().nodes[0].data.flipH).toBe(true);
|
||||
useDiagramStore.getState().flipNodeV(id);
|
||||
expect(useDiagramStore.getState().nodes[0].data.flipV).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connections and removal", () => {
|
||||
it("adds an edge on connect", () => {
|
||||
const s = useDiagramStore.getState();
|
||||
s.addComponent("IsentropicCompressor", { x: 0, y: 0 });
|
||||
s.addComponent("Condenser", { x: 200, y: 0 });
|
||||
const [a, b] = useDiagramStore.getState().nodes;
|
||||
useDiagramStore.getState().onConnect({
|
||||
source: a.id,
|
||||
target: b.id,
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
});
|
||||
const edges = useDiagramStore.getState().edges;
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].source).toBe(a.id);
|
||||
expect(edges[0].target).toBe(b.id);
|
||||
});
|
||||
|
||||
it("removes a node along with its connected edges and clears selection", () => {
|
||||
const s = useDiagramStore.getState();
|
||||
s.addComponent("IsentropicCompressor", { x: 0, y: 0 });
|
||||
s.addComponent("Condenser", { x: 200, y: 0 });
|
||||
const [a, b] = useDiagramStore.getState().nodes;
|
||||
useDiagramStore.getState().onConnect({
|
||||
source: a.id,
|
||||
target: b.id,
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
});
|
||||
useDiagramStore.getState().setSelected(a.id);
|
||||
useDiagramStore.getState().removeNode(a.id);
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(1);
|
||||
expect(st.edges).toHaveLength(0);
|
||||
expect(st.selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("clears selection when the selected node is removed via onNodesChange", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
useDiagramStore.getState().setSelected(id);
|
||||
useDiagramStore.getState().onNodesChange([{ type: "remove", id }]);
|
||||
expect(useDiagramStore.getState().selectedNodeId).toBeNull();
|
||||
expect(useDiagramStore.getState().nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleSnapToGrid", () => {
|
||||
it("flips the snap flag", () => {
|
||||
expect(useDiagramStore.getState().snapToGrid).toBe(true);
|
||||
useDiagramStore.getState().toggleSnapToGrid();
|
||||
expect(useDiagramStore.getState().snapToGrid).toBe(false);
|
||||
useDiagramStore.getState().toggleSnapToGrid();
|
||||
expect(useDiagramStore.getState().snapToGrid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadFromConfig and clear", () => {
|
||||
const config = {
|
||||
fluid: "R290",
|
||||
fluid_backend: "Test",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
||||
{ type: "Condenser", name: "cond", ua: 5000 },
|
||||
],
|
||||
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("rebuilds nodes, edges, rotation and fluid from a config", () => {
|
||||
useDiagramStore.getState().loadFromConfig(config);
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(2);
|
||||
expect(st.edges).toHaveLength(1);
|
||||
expect(st.fluid).toBe("R290");
|
||||
expect(st.fluidBackend).toBe("Test");
|
||||
for (const n of st.nodes) expect(n.data.rotation).toBe(0);
|
||||
// the params should be carried over as primitives
|
||||
const comp = st.nodes.find((n) => n.data.name === "comp");
|
||||
expect(comp?.data.params.isentropic_efficiency).toBe(0.7);
|
||||
});
|
||||
|
||||
it("ignores configs without circuits", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
||||
useDiagramStore.getState().loadFromConfig({ foo: "bar" });
|
||||
expect(useDiagramStore.getState().nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flattens subsystem instances from system model JSON", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
fluid_backend: "CoolProp",
|
||||
subsystems: {
|
||||
Circuit: {
|
||||
params: { ua_cond: 700 },
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
||||
{ type: "Condenser", name: "cond", ua: "$ua_cond" },
|
||||
],
|
||||
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
||||
ports: { discharge: "comp:outlet" },
|
||||
},
|
||||
},
|
||||
instances: [
|
||||
{ of: "Circuit", name: "A", circuit: 0, params: { ua_cond: 800 } },
|
||||
{ of: "Circuit", name: "B", circuit: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(4);
|
||||
expect(st.edges).toHaveLength(2);
|
||||
expect(st.nodes.map((n) => n.data.name)).toContain("A.comp");
|
||||
expect(st.nodes.map((n) => n.data.name)).toContain("B.cond");
|
||||
expect(st.nodes.find((n) => n.data.name === "A.cond")?.data.params.ua).toBe(800);
|
||||
expect(st.nodes.find((n) => n.data.name === "B.cond")?.data.params.ua).toBe(700);
|
||||
});
|
||||
|
||||
it("keeps explicit water circuits while flattening refrigerant subsystem instances", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 2,
|
||||
components: [
|
||||
{ type: "BrineSource", name: "water_in", t_set_c: 30 },
|
||||
{ type: "ThermalLoad", name: "water_load", mass_flow_kg_s: 1.0 },
|
||||
{ type: "BrineSink", name: "water_out" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "water_in:outlet", to: "water_load:inlet" },
|
||||
{ from: "water_load:outlet", to: "water_out:inlet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
subsystems: {
|
||||
Circuit: {
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
||||
{ type: "Condenser", name: "cond", ua: 700 },
|
||||
],
|
||||
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
||||
},
|
||||
},
|
||||
instances: [{ of: "Circuit", name: "A", circuit: 0 }],
|
||||
});
|
||||
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes.map((n) => n.data.name)).toEqual(
|
||||
expect.arrayContaining(["A.comp", "A.cond", "water_in", "water_load", "water_out"]),
|
||||
);
|
||||
expect(st.edges).toHaveLength(3);
|
||||
expect(st.nodes.find((n) => n.data.name === "water_load")?.data.circuit).toBe(2);
|
||||
});
|
||||
|
||||
it("lays out imported chiller cycles as a horizontal HVAC schematic", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", liquid_injection: true },
|
||||
{ type: "Condenser", name: "cond", ua: 5000 },
|
||||
{ type: "IsenthalpicExpansionValve", name: "exv", opening: 0.8 },
|
||||
{ type: "Evaporator", name: "evap", ua: 6000 },
|
||||
{ type: "BrineSource", name: "cond_water_in", t_set_c: 30 },
|
||||
{ type: "BrineSink", name: "cond_water_out" },
|
||||
{ type: "BrineSource", name: "evap_water_in", t_set_c: 12 },
|
||||
{ type: "BrineSink", name: "evap_water_out" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "comp:outlet", to: "cond:inlet" },
|
||||
{ from: "cond:outlet", to: "exv:inlet" },
|
||||
{ from: "exv:outlet", to: "evap:inlet" },
|
||||
{ from: "evap:outlet", to: "comp:inlet" },
|
||||
{ from: "cond_water_in:outlet", to: "cond:secondary_inlet" },
|
||||
{ from: "cond:secondary_outlet", to: "cond_water_out:inlet" },
|
||||
{ from: "evap_water_in:outlet", to: "evap:secondary_inlet" },
|
||||
{ from: "evap:secondary_outlet", to: "evap_water_out:inlet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
id: "dgt_limiter",
|
||||
measure: { component: "comp", output: "temperature" },
|
||||
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
||||
target: 330,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nodes = useDiagramStore.getState().nodes;
|
||||
const byName = Object.fromEntries(nodes.map((node) => [node.data.name, node]));
|
||||
|
||||
expect(byName.evap.position.x).toBeLessThan(byName.comp.position.x);
|
||||
expect(byName.comp.position.x).toBeLessThan(byName.cond.position.x);
|
||||
expect(byName.exv.position.x).toBeGreaterThan(byName.cond.position.x);
|
||||
expect(byName.exv.position.y).toBeGreaterThan(byName.cond.position.y);
|
||||
expect(byName.dgt_limiter.position.y).toBeLessThan(byName.comp.position.y);
|
||||
|
||||
expect(byName.evap_water_out.position.y).toBeLessThan(byName.evap.position.y);
|
||||
expect(byName.evap_water_in.position.y).toBeGreaterThan(byName.evap.position.y);
|
||||
expect(byName.cond_water_out.position.y).toBeLessThan(byName.cond.position.y);
|
||||
expect(byName.cond_water_in.position.y).toBeGreaterThan(byName.cond.position.y);
|
||||
expect(useDiagramStore.getState().edges).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("uses deterministic fallback positions for partial imported configs", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "Pipe", name: "pipe_b", length_m: 2 },
|
||||
{ type: "Placeholder", name: "custom_a", n_equations: 1 },
|
||||
],
|
||||
edges: [{ from: "custom_a:outlet", to: "pipe_b:inlet" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = useDiagramStore.getState().nodes.map((node) => [node.data.name, node.position]);
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "Pipe", name: "pipe_b", length_m: 2 },
|
||||
{ type: "Placeholder", name: "custom_a", n_equations: 1 },
|
||||
],
|
||||
edges: [{ from: "custom_a:outlet", to: "pipe_b:inlet" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(useDiagramStore.getState().nodes.map((node) => [node.data.name, node.position])).toEqual(first);
|
||||
expect(useDiagramStore.getState().edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("places secondary boundaries near generic four-port heat exchangers", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "HeatExchanger", name: "hx", ua: 3000 },
|
||||
{ type: "BrineSource", name: "water_in", t_set_c: 20 },
|
||||
{ type: "BrineSink", name: "water_out" },
|
||||
{ type: "AirSource", name: "air_in", t_dry_c: 15 },
|
||||
{ type: "AirSink", name: "air_out" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "water_in:outlet", to: "hx:hot_inlet" },
|
||||
{ from: "hx:hot_outlet", to: "water_out:inlet" },
|
||||
{ from: "air_in:outlet", to: "hx:cold_inlet" },
|
||||
{ from: "hx:cold_outlet", to: "air_out:inlet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const byName = Object.fromEntries(useDiagramStore.getState().nodes.map((node) => [node.data.name, node]));
|
||||
expect(Math.abs(byName.water_in.position.x - byName.hx.position.x)).toBeLessThan(220);
|
||||
expect(Math.abs(byName.air_in.position.x - byName.hx.position.x)).toBeLessThan(220);
|
||||
expect(byName.water_out.position.y).toBeLessThan(byName.hx.position.y);
|
||||
expect(byName.air_out.position.y).toBeLessThan(byName.hx.position.y);
|
||||
expect(byName.water_in.position.y).toBeGreaterThan(byName.hx.position.y);
|
||||
expect(byName.air_in.position.y).toBeGreaterThan(byName.hx.position.y);
|
||||
});
|
||||
|
||||
it("separates multiple imported refrigeration circuits horizontally", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp_a" },
|
||||
{ type: "Condenser", name: "cond_a" },
|
||||
],
|
||||
edges: [{ from: "comp_a:outlet", to: "cond_a:inlet" }],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp_b" },
|
||||
{ type: "Condenser", name: "cond_b" },
|
||||
],
|
||||
edges: [{ from: "comp_b:outlet", to: "cond_b:inlet" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const byName = Object.fromEntries(useDiagramStore.getState().nodes.map((node) => [node.data.name, node]));
|
||||
expect(byName.comp_b.position.x - byName.comp_a.position.x).toBeGreaterThan(900);
|
||||
expect(byName.cond_b.position.x - byName.cond_a.position.x).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
it("clear() empties the sheet", () => {
|
||||
useDiagramStore.getState().loadFromConfig(config);
|
||||
useDiagramStore.getState().clear();
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(0);
|
||||
expect(st.edges).toHaveLength(0);
|
||||
expect(st.selectedNodeId).toBeNull();
|
||||
});
|
||||
});
|
||||
712
apps/web/src/store/diagramStore.ts
Normal file
712
apps/web/src/store/diagramStore.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Edge, Node, OnNodesChange, OnEdgesChange, OnConnect } from "@xyflow/react";
|
||||
import { applyNodeChanges, applyEdgeChanges, addEdge } from "@xyflow/react";
|
||||
import { hydrateBoundaryFixFlags } from "@/lib/boundaryFix";
|
||||
import { defaultParams } from "@/lib/componentMeta";
|
||||
import { CONTROL_NODE_TYPE, canonicalizeParams } from "@/lib/configBuilder";
|
||||
import type { ControlConfig } from "@/lib/configBuilder";
|
||||
import type { SimulationResult } from "@/lib/api";
|
||||
import {
|
||||
circuitFromEdge,
|
||||
planEdgeInsert,
|
||||
validatePipeOnEdge,
|
||||
} from "@/lib/edgeInsert";
|
||||
import { nodeSize } from "@/lib/componentMeta";
|
||||
import { normaliseRotation as normaliseRotationDeg } from "@/lib/orientation";
|
||||
|
||||
export interface EntropykNodeData {
|
||||
type: string;
|
||||
name: string;
|
||||
circuit: number;
|
||||
/** Icon rotation in degrees (0/90/180/270), Dymola-style. */
|
||||
rotation: number;
|
||||
/** Mirror about vertical axis (Modelica flip horizontal). */
|
||||
flipH: boolean;
|
||||
/** Mirror about horizontal axis (Modelica flip vertical). */
|
||||
flipV: boolean;
|
||||
params: Record<string, number | string | boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EntropykNode = Node<EntropykNodeData>;
|
||||
type PrimitiveParam = number | string | boolean;
|
||||
type ImportedComponent = Record<string, unknown> & { type: string; name: string };
|
||||
type ImportedCircuit = {
|
||||
id: number;
|
||||
components: ImportedComponent[];
|
||||
edges?: Array<{ from: string; to: string }>;
|
||||
};
|
||||
type ImportedSubsystem = {
|
||||
params?: Record<string, PrimitiveParam>;
|
||||
components: ImportedComponent[];
|
||||
edges?: Array<{ from: string; to: string }>;
|
||||
ports?: Record<string, string>;
|
||||
};
|
||||
type ImportedInstance = {
|
||||
of: string;
|
||||
name: string;
|
||||
circuit?: number;
|
||||
params?: Record<string, PrimitiveParam>;
|
||||
};
|
||||
type ImportedScenario = {
|
||||
fluid?: string;
|
||||
fluid_backend?: string;
|
||||
controls?: ControlConfig[];
|
||||
solver?: {
|
||||
strategy?: string;
|
||||
max_iterations?: number;
|
||||
tolerance?: number;
|
||||
};
|
||||
circuits?: ImportedCircuit[];
|
||||
subsystems?: Record<string, ImportedSubsystem>;
|
||||
instances?: ImportedInstance[];
|
||||
connections?: Array<{ from: string; to: string }>;
|
||||
};
|
||||
|
||||
/** Diagram grid pitch in px (Dymola snaps icons to a fixed grid). */
|
||||
export const GRID_SIZE = 12;
|
||||
|
||||
/** Snap a single coordinate to the diagram grid. */
|
||||
export function snapToGridValue(value: number, grid: number = GRID_SIZE): number {
|
||||
return Math.round(value / grid) * grid;
|
||||
}
|
||||
|
||||
/** Snap an {x, y} position to the diagram grid. */
|
||||
export function snapPosition(
|
||||
position: { x: number; y: number },
|
||||
grid: number = GRID_SIZE,
|
||||
): { x: number; y: number } {
|
||||
return { x: snapToGridValue(position.x, grid), y: snapToGridValue(position.y, grid) };
|
||||
}
|
||||
|
||||
/** Normalise any angle to one of 0/90/180/270. */
|
||||
export function normaliseRotation(deg: number): number {
|
||||
return normaliseRotationDeg(deg);
|
||||
}
|
||||
|
||||
interface DiagramState {
|
||||
nodes: EntropykNode[];
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
fluid: string;
|
||||
fluidBackend: string;
|
||||
solverStrategy: string;
|
||||
maxIterations: number;
|
||||
tolerance: number;
|
||||
controls: ControlConfig[];
|
||||
snapToGrid: boolean;
|
||||
result: SimulationResult | null;
|
||||
lastConfig: unknown | null;
|
||||
simulating: boolean;
|
||||
simError: string | null;
|
||||
|
||||
onNodesChange: OnNodesChange;
|
||||
onEdgesChange: OnEdgesChange;
|
||||
onConnect: OnConnect;
|
||||
addComponent: (type: string, position: { x: number; y: number }) => void;
|
||||
/** Insert a 2-port component into an existing edge (A→B → A→comp→B). */
|
||||
insertOnEdge: (
|
||||
type: string,
|
||||
position: { x: number; y: number },
|
||||
edgeId: string,
|
||||
) => { ok: boolean; reason?: string };
|
||||
updateNodeParams: (id: string, params: Record<string, number | string | boolean>) => void;
|
||||
/** Apply param patches to several nodes in one atomic set() (Modelica Fixed pairing). */
|
||||
updateNodesParams: (
|
||||
patches: Map<string, Record<string, number | string | boolean>>,
|
||||
) => void;
|
||||
updateNodeName: (id: string, name: string) => void;
|
||||
updateNodeCircuit: (id: string, circuit: number) => void;
|
||||
rotateNode: (id: string, dir?: 1 | -1) => void;
|
||||
/** Modelica flip about vertical axis. */
|
||||
flipNodeH: (id: string) => void;
|
||||
/** Modelica flip about horizontal axis. */
|
||||
flipNodeV: (id: string) => void;
|
||||
setSelected: (id: string | null) => void;
|
||||
removeNode: (id: string) => void;
|
||||
setFluid: (fluid: string) => void;
|
||||
setFluidBackend: (backend: string) => void;
|
||||
setSolverStrategy: (strategy: string) => void;
|
||||
setMaxIterations: (maxIterations: number) => void;
|
||||
setTolerance: (tolerance: number) => void;
|
||||
setControls: (controls: ControlConfig[]) => void;
|
||||
toggleSnapToGrid: () => void;
|
||||
setLastConfig: (config: unknown | null) => void;
|
||||
setResult: (result: SimulationResult | null, error?: string | null) => void;
|
||||
setSimulating: (v: boolean) => void;
|
||||
loadFromConfig: (config: unknown) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
let nameCounter: Record<string, number> = {};
|
||||
|
||||
function uniqueName(type: string): string {
|
||||
nameCounter[type] = (nameCounter[type] ?? 0) + 1;
|
||||
const base = type.toLowerCase().replace(/[^a-z0-9]/g, "_").slice(0, 8);
|
||||
return `${base}_${nameCounter[type]}`;
|
||||
}
|
||||
|
||||
function substituteTemplateValue(value: unknown, params: Record<string, PrimitiveParam>): unknown {
|
||||
if (typeof value === "string" && value.startsWith("$")) {
|
||||
return params[value.slice(1)] ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function substituteComponent(
|
||||
component: ImportedComponent,
|
||||
params: Record<string, PrimitiveParam>,
|
||||
prefix?: string,
|
||||
): ImportedComponent {
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(component)) {
|
||||
next[key] = substituteTemplateValue(value, params);
|
||||
}
|
||||
next.name = prefix ? `${prefix}.${component.name}` : component.name;
|
||||
return next as ImportedComponent;
|
||||
}
|
||||
|
||||
function prefixEdgeRef(ref: string, prefix: string): string {
|
||||
const [component, port] = ref.split(":");
|
||||
return port ? `${prefix}.${component}:${port}` : `${prefix}.${component}`;
|
||||
}
|
||||
|
||||
function resolveInstancePortRef(
|
||||
ref: string,
|
||||
instances: Map<string, ImportedInstance>,
|
||||
subsystems: Record<string, ImportedSubsystem>,
|
||||
): { circuit: number; ref: string } | null {
|
||||
const [instanceName, portName] = ref.split(".");
|
||||
if (!instanceName || !portName) return null;
|
||||
|
||||
const instance = instances.get(instanceName);
|
||||
if (!instance) return null;
|
||||
|
||||
const subsystem = subsystems[instance.of];
|
||||
const mapped = subsystem?.ports?.[portName];
|
||||
if (!mapped) return null;
|
||||
|
||||
return {
|
||||
circuit: instance.circuit ?? 0,
|
||||
ref: prefixEdgeRef(mapped, instance.name),
|
||||
};
|
||||
}
|
||||
|
||||
function flattenImportedScenario(cfg: ImportedScenario): ImportedScenario {
|
||||
if (!cfg.subsystems || !cfg.instances?.length) return cfg;
|
||||
|
||||
const circuits = new Map<number, ImportedCircuit>(
|
||||
(cfg.circuits ?? []).map((circuit) => [
|
||||
circuit.id,
|
||||
{
|
||||
...circuit,
|
||||
components: [...circuit.components],
|
||||
edges: [...(circuit.edges ?? [])],
|
||||
},
|
||||
]),
|
||||
);
|
||||
const instancesByName = new Map(cfg.instances.map((instance) => [instance.name, instance]));
|
||||
|
||||
for (const instance of cfg.instances) {
|
||||
const subsystem = cfg.subsystems[instance.of];
|
||||
if (!subsystem) continue;
|
||||
|
||||
const circuitId = instance.circuit ?? 0;
|
||||
const circuit = circuits.get(circuitId) ?? { id: circuitId, components: [], edges: [] };
|
||||
const params = { ...(subsystem.params ?? {}), ...(instance.params ?? {}) };
|
||||
|
||||
circuit.components.push(
|
||||
...subsystem.components.map((component) => substituteComponent(component, params, instance.name)),
|
||||
);
|
||||
circuit.edges?.push(
|
||||
...(subsystem.edges ?? []).map((edge) => ({
|
||||
from: prefixEdgeRef(edge.from, instance.name),
|
||||
to: prefixEdgeRef(edge.to, instance.name),
|
||||
})),
|
||||
);
|
||||
|
||||
circuits.set(circuitId, circuit);
|
||||
}
|
||||
|
||||
for (const connection of cfg.connections ?? []) {
|
||||
const from = resolveInstancePortRef(connection.from, instancesByName, cfg.subsystems);
|
||||
const to = resolveInstancePortRef(connection.to, instancesByName, cfg.subsystems);
|
||||
if (!from || !to || from.circuit !== to.circuit) continue;
|
||||
|
||||
const circuit = circuits.get(from.circuit) ?? { id: from.circuit, components: [], edges: [] };
|
||||
circuit.edges?.push({ from: from.ref, to: to.ref });
|
||||
circuits.set(from.circuit, circuit);
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
circuits: Array.from(circuits.values()).sort((a, b) => a.id - b.id),
|
||||
};
|
||||
}
|
||||
|
||||
function controlParams(control: ControlConfig): Record<string, PrimitiveParam> {
|
||||
return {
|
||||
measure_component: control.measure.component,
|
||||
measure_output: control.measure.output,
|
||||
actuator_component: control.actuator.component,
|
||||
actuator_factor: control.actuator.factor,
|
||||
initial: control.actuator.initial ?? 0.15,
|
||||
min: control.actuator.min,
|
||||
max: control.actuator.max,
|
||||
target: control.target,
|
||||
gain: control.gain ?? -0.5,
|
||||
band: control.band ?? 5.0,
|
||||
...(control.smooth_eps !== undefined ? { smooth_eps: control.smooth_eps } : {}),
|
||||
...(control.alpha !== undefined ? { alpha: control.alpha } : {}),
|
||||
objectives_json: JSON.stringify(control.objectives ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function isCompressorNode(node: EntropykNode): boolean {
|
||||
return node.data.type.includes("Compressor");
|
||||
}
|
||||
|
||||
function isCondenserNode(node: EntropykNode): boolean {
|
||||
const text = `${node.data.type} ${node.data.name}`.toLowerCase();
|
||||
return text.includes("condenser") || /\bcond\b/.test(text);
|
||||
}
|
||||
|
||||
function isEvaporatorNode(node: EntropykNode): boolean {
|
||||
const text = `${node.data.type} ${node.data.name}`.toLowerCase();
|
||||
return text.includes("evaporator") || /\bevap\b/.test(text);
|
||||
}
|
||||
|
||||
function isExpansionNode(node: EntropykNode): boolean {
|
||||
const text = `${node.data.type} ${node.data.name}`.toLowerCase();
|
||||
return node.data.type.includes("Valve") || text.includes("exv") || text.includes("expansion");
|
||||
}
|
||||
|
||||
function isGenericHeatExchangerNode(node: EntropykNode): boolean {
|
||||
return node.data.type === "HeatExchanger";
|
||||
}
|
||||
|
||||
function isControllerNode(node: EntropykNode): boolean {
|
||||
return node.data.type === CONTROL_NODE_TYPE;
|
||||
}
|
||||
|
||||
function isSecondaryBoundaryNode(node: EntropykNode): boolean {
|
||||
return node.data.type.startsWith("Brine") || node.data.type.startsWith("Air");
|
||||
}
|
||||
|
||||
function connectedPortOn(nodeId: string, edge: Edge): string {
|
||||
if (edge.source === nodeId) return String(edge.sourceHandle ?? "");
|
||||
if (edge.target === nodeId) return String(edge.targetHandle ?? "");
|
||||
return "";
|
||||
}
|
||||
|
||||
function oppositeNodeId(nodeId: string, edge: Edge): string | null {
|
||||
if (edge.source === nodeId) return edge.target;
|
||||
if (edge.target === nodeId) return edge.source;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sortedByName(nodes: EntropykNode[]): EntropykNode[] {
|
||||
return [...nodes].sort((a, b) => a.data.name.localeCompare(b.data.name));
|
||||
}
|
||||
|
||||
function firstByFamily(nodes: EntropykNode[], predicate: (node: EntropykNode) => boolean): EntropykNode | undefined {
|
||||
return sortedByName(nodes.filter(predicate))[0];
|
||||
}
|
||||
|
||||
function layoutImportedNodes(nodes: EntropykNode[], edges: Edge[]): EntropykNode[] {
|
||||
if (nodes.length === 0) return nodes;
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const byCircuit = new Map<number, EntropykNode[]>();
|
||||
for (const node of nodes) {
|
||||
const circuit = node.data.circuit ?? 0;
|
||||
byCircuit.set(circuit, [...(byCircuit.get(circuit) ?? []), node]);
|
||||
}
|
||||
|
||||
const secondaryCounts = new Map<string, number>();
|
||||
const fallbackCounts = new Map<number, number>();
|
||||
|
||||
for (const [layoutIndex, [circuitId, circuitNodes]] of [...byCircuit.entries()].sort(([a], [b]) => a - b).entries()) {
|
||||
const xOffset = layoutIndex * 1100;
|
||||
const yOffset = layoutIndex * 40;
|
||||
const evaporator = firstByFamily(circuitNodes, isEvaporatorNode);
|
||||
const compressor = firstByFamily(circuitNodes, isCompressorNode);
|
||||
const condenser = firstByFamily(circuitNodes, isCondenserNode);
|
||||
const expansion = firstByFamily(circuitNodes, isExpansionNode);
|
||||
const genericHeatExchangers = sortedByName(circuitNodes.filter(isGenericHeatExchangerNode));
|
||||
|
||||
if (evaporator) positions.set(evaporator.id, { x: 420 + xOffset, y: 300 + yOffset });
|
||||
if (compressor) positions.set(compressor.id, { x: 720 + xOffset, y: 270 + yOffset });
|
||||
if (condenser) positions.set(condenser.id, { x: 1040 + xOffset, y: 300 + yOffset });
|
||||
if (expansion) positions.set(expansion.id, { x: 1260 + xOffset, y: 450 + yOffset });
|
||||
genericHeatExchangers.forEach((node, index) => {
|
||||
if (!positions.has(node.id)) {
|
||||
positions.set(node.id, { x: 420 + xOffset + index * 260, y: 300 + yOffset });
|
||||
}
|
||||
});
|
||||
|
||||
for (const controller of sortedByName(circuitNodes.filter(isControllerNode))) {
|
||||
const measuredName = String(controller.data.params.measure_component ?? controller.data.params.actuator_component ?? "");
|
||||
const anchor =
|
||||
circuitNodes.find((node) => node.data.name === measuredName) ??
|
||||
compressor ??
|
||||
evaporator ??
|
||||
condenser;
|
||||
const anchorPosition = anchor ? positions.get(anchor.id) ?? anchor.position : { x: 600 + xOffset, y: 220 + yOffset };
|
||||
positions.set(controller.id, { x: anchorPosition.x - 120, y: anchorPosition.y - 150 });
|
||||
}
|
||||
|
||||
for (const boundary of sortedByName(circuitNodes.filter(isSecondaryBoundaryNode))) {
|
||||
const link = edges
|
||||
.filter((edge) => edge.source === boundary.id || edge.target === boundary.id)
|
||||
.map((edge) => {
|
||||
const otherId = oppositeNodeId(boundary.id, edge);
|
||||
const other = otherId ? nodeById.get(otherId) : undefined;
|
||||
return other ? { edge, other } : null;
|
||||
})
|
||||
.find((entry): entry is { edge: Edge; other: EntropykNode } => !!entry);
|
||||
if (!link) continue;
|
||||
|
||||
const anchorPosition = positions.get(link.other.id);
|
||||
if (!anchorPosition) continue;
|
||||
|
||||
const exchangerPort = connectedPortOn(link.other.id, link.edge);
|
||||
const key = `${link.other.id}:${exchangerPort || "secondary"}`;
|
||||
const count = secondaryCounts.get(key) ?? 0;
|
||||
secondaryCounts.set(key, count + 1);
|
||||
|
||||
const isOutlet = exchangerPort.includes("outlet");
|
||||
const vertical = isOutlet ? -150 : 150;
|
||||
const horizontal =
|
||||
link.other === condenser
|
||||
? isOutlet ? -40 : -70
|
||||
: isOutlet ? 120 : -40;
|
||||
positions.set(boundary.id, {
|
||||
x: anchorPosition.x + horizontal + count * 84,
|
||||
y: anchorPosition.y + vertical,
|
||||
});
|
||||
}
|
||||
|
||||
for (const node of sortedByName(circuitNodes)) {
|
||||
if (positions.has(node.id)) continue;
|
||||
const count = fallbackCounts.get(circuitId) ?? 0;
|
||||
fallbackCounts.set(circuitId, count + 1);
|
||||
positions.set(node.id, {
|
||||
x: 120 + count * 170 + xOffset,
|
||||
y: 120 + yOffset + (count % 2) * 140,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
position: snapPosition(positions.get(node.id) ?? node.position),
|
||||
}));
|
||||
}
|
||||
|
||||
export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
fluid: "R410A",
|
||||
fluidBackend: "CoolProp",
|
||||
solverStrategy: "newton",
|
||||
maxIterations: 300,
|
||||
tolerance: 1e-6,
|
||||
controls: [],
|
||||
snapToGrid: true,
|
||||
result: null,
|
||||
lastConfig: null,
|
||||
simulating: false,
|
||||
simError: null,
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
const removed = changes.filter((c) => c.type === "remove").map((c) => c.id);
|
||||
const sel = get().selectedNodeId;
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes) as EntropykNode[],
|
||||
selectedNodeId: sel && removed.includes(sel) ? null : sel,
|
||||
});
|
||||
},
|
||||
|
||||
onEdgesChange: (changes) =>
|
||||
set({ edges: applyEdgeChanges(changes, get().edges) }),
|
||||
|
||||
onConnect: (connection) =>
|
||||
set({
|
||||
edges: addEdge(
|
||||
{ ...connection, animated: false, type: "smoothstep" },
|
||||
get().edges,
|
||||
),
|
||||
}),
|
||||
|
||||
addComponent: (type, position) => {
|
||||
const id = crypto.randomUUID();
|
||||
const pos = get().snapToGrid ? snapPosition(position) : position;
|
||||
const node: EntropykNode = {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: pos,
|
||||
data: {
|
||||
type,
|
||||
name: uniqueName(type),
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: defaultParams(type),
|
||||
},
|
||||
};
|
||||
set({ nodes: [...get().nodes, node], selectedNodeId: id });
|
||||
},
|
||||
|
||||
insertOnEdge: (type, position, edgeId) => {
|
||||
const { nodes, edges, snapToGrid } = get();
|
||||
const edge = edges.find((e) => e.id === edgeId);
|
||||
if (!edge) return { ok: false, reason: "Ligne introuvable." };
|
||||
|
||||
const params = defaultParams(type);
|
||||
const check = validatePipeOnEdge(type, params, edge, nodes as EntropykNode[]);
|
||||
if (!check.ok) return check;
|
||||
|
||||
const src = nodes.find((n) => n.id === edge.source);
|
||||
const tgt = nodes.find((n) => n.id === edge.target);
|
||||
if (!src || !tgt) return { ok: false, reason: "Extrémités de ligne manquantes." };
|
||||
|
||||
const srcSize = nodeSize(src.data.type);
|
||||
const tgtSize = nodeSize(tgt.data.type);
|
||||
const sourceCenter = {
|
||||
x: src.position.x + srcSize.w / 2,
|
||||
y: src.position.y + srcSize.h / 2,
|
||||
};
|
||||
const targetCenter = {
|
||||
x: tgt.position.x + tgtSize.w / 2,
|
||||
y: tgt.position.y + tgtSize.h / 2,
|
||||
};
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const name = uniqueName(type);
|
||||
const pos = snapToGrid ? snapPosition(position) : position;
|
||||
const plan = planEdgeInsert({
|
||||
type,
|
||||
id,
|
||||
name,
|
||||
position: pos,
|
||||
params,
|
||||
edge,
|
||||
circuit: circuitFromEdge(edge, nodes as EntropykNode[]),
|
||||
sourceCenter,
|
||||
targetCenter,
|
||||
});
|
||||
|
||||
// Snap final position after orientation centering
|
||||
if (snapToGrid) {
|
||||
plan.node.position = snapPosition(plan.node.position);
|
||||
}
|
||||
|
||||
set({
|
||||
nodes: [...nodes, plan.node as EntropykNode],
|
||||
edges: [
|
||||
...edges.filter((e) => e.id !== plan.edgeIdToRemove),
|
||||
...plan.edgesToAdd,
|
||||
],
|
||||
selectedNodeId: id,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
updateNodeParams: (id, params) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, params: { ...n.data.params, ...params } } } : n,
|
||||
),
|
||||
}),
|
||||
|
||||
updateNodesParams: (patches) =>
|
||||
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) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, name } } : n)),
|
||||
}),
|
||||
|
||||
updateNodeCircuit: (id, circuit) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, circuit } } : n)),
|
||||
}),
|
||||
|
||||
rotateNode: (id, dir = 1) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
rotation: normaliseRotation((n.data.rotation ?? 0) + dir * 90),
|
||||
},
|
||||
}
|
||||
: n,
|
||||
),
|
||||
}),
|
||||
|
||||
flipNodeH: (id) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, flipH: !n.data.flipH } } : n,
|
||||
),
|
||||
}),
|
||||
|
||||
flipNodeV: (id) =>
|
||||
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) =>
|
||||
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 }),
|
||||
setSolverStrategy: (strategy) => set({ solverStrategy: strategy }),
|
||||
setMaxIterations: (maxIterations) => set({ maxIterations }),
|
||||
setTolerance: (tolerance) => set({ tolerance }),
|
||||
setControls: (controls) => set({ controls }),
|
||||
toggleSnapToGrid: () => set({ snapToGrid: !get().snapToGrid }),
|
||||
|
||||
setLastConfig: (config) => set({ lastConfig: config }),
|
||||
setResult: (result, error = null) => set({ result, simError: error }),
|
||||
setSimulating: (v) => set({ simulating: v }),
|
||||
|
||||
loadFromConfig: (config) => {
|
||||
const cfg = flattenImportedScenario(config as ImportedScenario);
|
||||
if (!cfg?.circuits) return;
|
||||
|
||||
const nodes: EntropykNode[] = [];
|
||||
let x = 100;
|
||||
const nameToId = new Map<string, string>();
|
||||
|
||||
for (const circuit of cfg.circuits) {
|
||||
let y = 100;
|
||||
for (const comp of circuit.components) {
|
||||
const id = crypto.randomUUID();
|
||||
nameToId.set(`${circuit.id}:${comp.name}`, id);
|
||||
const { type, name, ...rest } = comp;
|
||||
// Keep only primitive params.
|
||||
let params: Record<string, PrimitiveParam> = {};
|
||||
for (const [k, v] of Object.entries(rest)) {
|
||||
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") {
|
||||
params[k] = v;
|
||||
}
|
||||
}
|
||||
params = canonicalizeParams(type as string, params);
|
||||
params = hydrateBoundaryFixFlags(type as string, params);
|
||||
// Merge meta defaults without clobbering imported Fixed flags / setpoints.
|
||||
const defaults = defaultParams(type as string);
|
||||
params = { ...defaults, ...params };
|
||||
nodes.push({
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x, y },
|
||||
data: {
|
||||
type: type as string,
|
||||
name: name as string,
|
||||
circuit: circuit.id,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params,
|
||||
},
|
||||
});
|
||||
y += 120;
|
||||
}
|
||||
x += 280;
|
||||
}
|
||||
|
||||
for (const [index, control] of (cfg.controls ?? []).entries()) {
|
||||
const measuredNode = nodes.find((node) => node.data.name === control.measure.component);
|
||||
const id = crypto.randomUUID();
|
||||
nodes.push({
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: measuredNode
|
||||
? { x: measuredNode.position.x + 140, y: Math.max(40, measuredNode.position.y - 72) }
|
||||
: { x: 100 + index * 150, y: 40 },
|
||||
data: {
|
||||
type: CONTROL_NODE_TYPE,
|
||||
name: control.id,
|
||||
circuit: measuredNode?.data.circuit ?? 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: controlParams(control),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const edges: Edge[] = [];
|
||||
for (const circuit of cfg.circuits) {
|
||||
circuit.edges?.forEach((e, i) => {
|
||||
const [fromName, fromPort] = e.from.split(":");
|
||||
const [toName, toPort] = e.to.split(":");
|
||||
const sId = nameToId.get(`${circuit.id}:${fromName}`);
|
||||
const tId = nameToId.get(`${circuit.id}:${toName}`);
|
||||
if (sId && tId) {
|
||||
edges.push({
|
||||
id: `e${circuit.id}-${i}`,
|
||||
source: sId,
|
||||
target: tId,
|
||||
sourceHandle: fromPort,
|
||||
targetHandle: toPort,
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set({
|
||||
nodes: layoutImportedNodes(nodes, edges),
|
||||
edges,
|
||||
fluid: cfg.fluid || "R410A",
|
||||
fluidBackend: cfg.fluid_backend || "CoolProp",
|
||||
solverStrategy: cfg.solver?.strategy || "newton",
|
||||
maxIterations: cfg.solver?.max_iterations ?? 300,
|
||||
tolerance: cfg.solver?.tolerance ?? 1e-6,
|
||||
controls: cfg.controls ?? [],
|
||||
result: null,
|
||||
lastConfig: cfg,
|
||||
simError: null,
|
||||
selectedNodeId: null,
|
||||
});
|
||||
},
|
||||
|
||||
clear: () =>
|
||||
set({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
controls: [],
|
||||
result: null,
|
||||
lastConfig: null,
|
||||
simError: null,
|
||||
selectedNodeId: null,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user