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/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) : "—";
|
||||
}
|
||||
Reference in New Issue
Block a user