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) <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:13:31 +02:00
parent e28a0ef074
commit 22ad8b3e0c
2 changed files with 314 additions and 0 deletions

133
src/calc/flow.test.ts Normal file
View File

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

181
src/calc/flow.ts Normal file
View File

@ -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<string, number>;
/** Total flow entering each node (supply + incoming). */
nodeInflow: Record<string, number>;
/** supply + inflow demand outflow; ~0 when conserved. */
nodeImbalance: Record<string, number>;
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<string, number>(ids.map((id) => [id, 0]));
const out = new Map<string, string[]>(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<string>();
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<string, number>();
const demand = new Map<string, number>();
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<string, FlowEdge[]>(nodes.map((n) => [n.id, []]));
const inEdges = new Map<string, FlowEdge[]>(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<string, number>(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<string, number> = {};
for (const e of edges) edgeFlow[e.id] = 0;
const nodeInflow: Record<string, number> = {};
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<string, number> = {};
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,
};
}