From 22ad8b3e0c9a5238f916a510264867014ad13b5f Mon Sep 17 00:00:00 2001 From: Ilya Ashikhmin Date: Thu, 2 Jul 2026 23:13:31 +0200 Subject: [PATCH] feat(calc): add mock flow-distribution solver with tests - Deterministic heuristic solver: topo sweep with downstream-demand-weighted splits at junctions, supply/demand conservation, cycle tolerance - Reports per-edge signed flow, node inflow/imbalance, balance + warnings, maxFlow - 8 unit tests: line, split, merge, equal-split, unbalanced, cycle, empty Co-Authored-By: Claude Opus 4.8 (1M context) --- src/calc/flow.test.ts | 133 +++++++++++++++++++++++++++++++ src/calc/flow.ts | 181 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 src/calc/flow.test.ts create mode 100644 src/calc/flow.ts diff --git a/src/calc/flow.test.ts b/src/calc/flow.test.ts new file mode 100644 index 0000000..2896a5c --- /dev/null +++ b/src/calc/flow.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "vitest"; +import { solveFlow, type FlowNetwork } from "./flow"; + +describe("solveFlow", () => { + it("pushes all supply along a single line to the sink", () => { + const net: FlowNetwork = { + nodes: [ + { id: "s", supply: 10 }, + { id: "m" }, + { id: "d", demand: 10 }, + ], + edges: [ + { id: "e1", source: "s", target: "m" }, + { id: "e2", source: "m", target: "d" }, + ], + }; + const r = solveFlow(net); + expect(r.edgeFlow.e1).toBeCloseTo(10); + expect(r.edgeFlow.e2).toBeCloseTo(10); + expect(r.balanced).toBe(true); + expect(Object.values(r.nodeImbalance).every((v) => Math.abs(v) < 1e-6)).toBe(true); + }); + + it("splits flow at a junction proportional to downstream demand", () => { + const net: FlowNetwork = { + nodes: [ + { id: "s", supply: 12 }, + { id: "j" }, + { id: "a", demand: 3 }, + { id: "b", demand: 9 }, + ], + edges: [ + { id: "sj", source: "s", target: "j" }, + { id: "ja", source: "j", target: "a" }, + { id: "jb", source: "j", target: "b" }, + ], + }; + const r = solveFlow(net); + expect(r.edgeFlow.sj).toBeCloseTo(12); + // 3:9 split of 12 → 3 and 9 + expect(r.edgeFlow.ja).toBeCloseTo(3); + expect(r.edgeFlow.jb).toBeCloseTo(9); + expect(r.balanced).toBe(true); + }); + + it("merges two supplies into one sink", () => { + const net: FlowNetwork = { + nodes: [ + { id: "s1", supply: 4 }, + { id: "s2", supply: 6 }, + { id: "d", demand: 10 }, + ], + edges: [ + { id: "e1", source: "s1", target: "d" }, + { id: "e2", source: "s2", target: "d" }, + ], + }; + const r = solveFlow(net); + expect(r.edgeFlow.e1).toBeCloseTo(4); + expect(r.edgeFlow.e2).toBeCloseTo(6); + expect(r.nodeInflow.d).toBeCloseTo(10); + expect(r.balanced).toBe(true); + }); + + it("splits equally when downstream demands are all zero", () => { + const net: FlowNetwork = { + nodes: [ + { id: "s", supply: 8 }, + { id: "a" }, + { id: "b" }, + ], + edges: [ + { id: "sa", source: "s", target: "a" }, + { id: "sb", source: "s", target: "b" }, + ], + }; + const r = solveFlow(net); + expect(r.edgeFlow.sa).toBeCloseTo(4); + expect(r.edgeFlow.sb).toBeCloseTo(4); + }); + + it("flags unbalanced networks and under-supplied nodes", () => { + const net: FlowNetwork = { + nodes: [ + { id: "s", supply: 3 }, + { id: "d", demand: 10 }, + ], + edges: [{ id: "e", source: "s", target: "d" }], + }; + const r = solveFlow(net); + expect(r.balanced).toBe(false); + expect(r.warnings.some((w) => /unbalanced/i.test(w))).toBe(true); + expect(r.warnings.some((w) => /under-supplied/i.test(w))).toBe(true); + expect(r.edgeFlow.e).toBeCloseTo(3); + }); + + it("terminates and stays finite on a cycle", () => { + const net: FlowNetwork = { + nodes: [ + { id: "s", supply: 5 }, + { id: "a" }, + { id: "b" }, + { id: "d", demand: 5 }, + ], + edges: [ + { id: "sa", source: "s", target: "a" }, + { id: "ab", source: "a", target: "b" }, + { id: "ba", source: "b", target: "a" }, // cycle a<->b + { id: "bd", source: "b", target: "d" }, + ], + }; + const r = solveFlow(net); + for (const v of Object.values(r.edgeFlow)) expect(Number.isFinite(v)).toBe(true); + expect(r.maxFlow).toBeGreaterThan(0); + }); + + it("reports maxFlow for normalisation", () => { + const net: FlowNetwork = { + nodes: [ + { id: "s", supply: 20 }, + { id: "d", demand: 20 }, + ], + edges: [{ id: "e", source: "s", target: "d" }], + }; + expect(solveFlow(net).maxFlow).toBeCloseTo(20); + }); + + it("handles an empty network", () => { + const r = solveFlow({ nodes: [], edges: [] }); + expect(r.maxFlow).toBe(0); + expect(r.balanced).toBe(true); + }); +}); diff --git a/src/calc/flow.ts b/src/calc/flow.ts new file mode 100644 index 0000000..3d4fd60 --- /dev/null +++ b/src/calc/flow.ts @@ -0,0 +1,181 @@ +/** + * Mock flow-distribution solver. + * + * Given a directed network (edges point source-port → target-port) with node + * supplies and demands, it estimates a plausible, conservation-respecting flow + * on each edge. It is deliberately a lightweight heuristic — not a hydraulic + * solver — but it is deterministic and unit-tested so the animation layer has + * stable, physically-sensible numbers to drive. + * + * Method: + * 1. Topologically order nodes (Kahn); ties broken by id for determinism. + * 2. Pre-compute each edge's downstream "demand weight" (reverse pass) so + * splits at a junction favour the branch that needs more water. + * 3. Sweep in topo order: inflow = supply + Σ incoming flow; consume the + * node's demand; distribute the remainder across out-edges proportional to + * demand weight (equal split when weights are all zero). + * 4. Report per-node imbalance and global supply/demand balance. + * + * Cycles are tolerated: nodes left over after Kahn are appended in id order, so + * loops get an approximate (still finite, deterministic) distribution. + */ + +export interface FlowNode { + id: string; + supply?: number; + demand?: number; +} + +export interface FlowEdge { + id: string; + source: string; + target: string; +} + +export interface FlowNetwork { + nodes: FlowNode[]; + edges: FlowEdge[]; +} + +export interface FlowResult { + /** Signed flow per edge id; positive means source → target. */ + edgeFlow: Record; + /** Total flow entering each node (supply + incoming). */ + nodeInflow: Record; + /** supply + inflow − demand − outflow; ~0 when conserved. */ + nodeImbalance: Record; + totalSupply: number; + totalDemand: number; + /** Largest absolute edge flow, for animation normalisation. */ + maxFlow: number; + balanced: boolean; + warnings: string[]; +} + +const EPS = 1e-6; + +function topoOrder(nodes: FlowNode[], edges: FlowEdge[]): string[] { + const ids = nodes.map((n) => n.id); + const indeg = new Map(ids.map((id) => [id, 0])); + const out = new Map(ids.map((id) => [id, []])); + for (const e of edges) { + if (!indeg.has(e.source) || !indeg.has(e.target)) continue; + if (e.source === e.target) continue; // ignore self-loops for ordering + indeg.set(e.target, (indeg.get(e.target) ?? 0) + 1); + out.get(e.source)!.push(e.target); + } + // Stable: always take the smallest available id. + const ready = ids.filter((id) => (indeg.get(id) ?? 0) === 0).sort(); + const order: string[] = []; + const seen = new Set(); + while (ready.length) { + const id = ready.shift()!; + if (seen.has(id)) continue; + seen.add(id); + order.push(id); + for (const t of out.get(id) ?? []) { + indeg.set(t, (indeg.get(t) ?? 0) - 1); + if ((indeg.get(t) ?? 0) === 0 && !seen.has(t)) { + // insert keeping sorted order + const idx = ready.findIndex((r) => r > t); + if (idx === -1) ready.push(t); + else ready.splice(idx, 0, t); + } + } + } + // Append any nodes stuck in cycles, in id order. + for (const id of ids) if (!seen.has(id)) order.push(id); + return order; +} + +export function solveFlow(network: FlowNetwork): FlowResult { + const { nodes, edges } = network; + const supply = new Map(); + const demand = new Map(); + for (const n of nodes) { + supply.set(n.id, Math.max(0, n.supply ?? 0)); + demand.set(n.id, Math.max(0, n.demand ?? 0)); + } + + const outEdges = new Map(nodes.map((n) => [n.id, []])); + const inEdges = new Map(nodes.map((n) => [n.id, []])); + for (const e of edges) { + outEdges.get(e.source)?.push(e); + inEdges.get(e.target)?.push(e); + } + + const order = topoOrder(nodes, edges); + const rank = new Map(order.map((id, i) => [id, i])); + + // Reverse pass: downstream demand weight per node. + const demandWeight = new Map(nodes.map((n) => [n.id, demand.get(n.id) ?? 0])); + for (let i = order.length - 1; i >= 0; i--) { + const id = order[i]; + let w = demand.get(id) ?? 0; + for (const e of outEdges.get(id) ?? []) { + // Only trust forward edges (target later in order) to avoid cycle loops. + if ((rank.get(e.target) ?? 0) > (rank.get(id) ?? 0)) { + w += demandWeight.get(e.target) ?? 0; + } + } + demandWeight.set(id, w); + } + + const edgeFlow: Record = {}; + for (const e of edges) edgeFlow[e.id] = 0; + const nodeInflow: Record = {}; + + for (const id of order) { + let inflow = supply.get(id) ?? 0; + for (const e of inEdges.get(id) ?? []) inflow += edgeFlow[e.id] ?? 0; + nodeInflow[id] = inflow; + + const available = Math.max(0, inflow - (demand.get(id) ?? 0)); + const outs = outEdges.get(id) ?? []; + if (outs.length === 0 || available <= EPS) continue; + + const weights = outs.map((e) => Math.max(0, demandWeight.get(e.target) ?? 0)); + const total = weights.reduce((a, b) => a + b, 0); + outs.forEach((e, i) => { + const share = total > EPS ? weights[i] / total : 1 / outs.length; + edgeFlow[e.id] = (edgeFlow[e.id] ?? 0) + available * share; + }); + } + + // Imbalance per node. + const nodeImbalance: Record = {}; + const warnings: string[] = []; + for (const n of nodes) { + const inflow = nodeInflow[n.id] ?? 0; + let outflow = 0; + for (const e of outEdges.get(n.id) ?? []) outflow += edgeFlow[e.id] ?? 0; + // Conservation: what enters (supply already folded into inflow) must equal + // what is consumed plus what leaves. + const imb = inflow - (demand.get(n.id) ?? 0) - outflow; + nodeImbalance[n.id] = Math.abs(imb) < EPS ? 0 : imb; + if ((demand.get(n.id) ?? 0) > 0 && inflow + EPS < (demand.get(n.id) ?? 0)) { + warnings.push(`Node ${n.id} is under-supplied (${inflow.toFixed(2)} < ${demand.get(n.id)!.toFixed(2)}).`); + } + } + + const totalSupply = [...supply.values()].reduce((a, b) => a + b, 0); + const totalDemand = [...demand.values()].reduce((a, b) => a + b, 0); + const maxFlow = Object.values(edgeFlow).reduce((m, v) => Math.max(m, Math.abs(v)), 0); + const balanced = Math.abs(totalSupply - totalDemand) < 1e-3; + if (!balanced) { + warnings.push( + `Network is unbalanced: supply ${totalSupply.toFixed(2)} vs demand ${totalDemand.toFixed(2)}.`, + ); + } + + return { + edgeFlow, + nodeInflow, + nodeImbalance, + totalSupply, + totalDemand, + maxFlow, + balanced, + warnings, + }; +}