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:
parent
e28a0ef074
commit
22ad8b3e0c
133
src/calc/flow.test.ts
Normal file
133
src/calc/flow.test.ts
Normal 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
181
src/calc/flow.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user