/** * 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[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[], 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, nodes: Node[], edges: Edge[], result: SimulationResult | null, ): ComponentInspector { const meta = COMPONENT_BY_TYPE[node.data.type]; const title = node.data.name; const typeLabel = node.data.type === "ModuleInstance" ? `Sous-système Module (${String(node.data.params.module_name ?? "ekmod")})` : (meta?.label ?? node.data.type); const status = result ? String(result.status) : null; const state = result?.state; 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) : "—"; }