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

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

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

View File

@@ -0,0 +1,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) : "—";
}