Files
Entropyk/apps/web/src/lib/dofLedger.test.ts
sepehr 3358b74342 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>
2026-07-17 22:46:46 +02:00

200 lines
7.3 KiB
TypeScript

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");
});
});