Files
Entropyk/apps/web/src/lib/componentInspector.ts
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

228 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 =
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) : "—";
}