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