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 = {}, ): Node { 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"); }); });