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>
200 lines
7.3 KiB
TypeScript
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");
|
|
});
|
|
});
|