Ilya Ashikhmin dd547f7ea1 fix(sim): exclude power lines from fluid routing
Power and signal edges no longer act as fluid paths in the mock solver
(a water demand was previously routed through the pump's power cable).

Also: bundled demo diagram, visual e2e script with dev-mode editor hook,
plan checklist completed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:34:44 +02:00

138 lines
4.9 KiB
TypeScript

/**
* Mock flow-distribution solver.
*
* Not a hydraulic simulation — a plausible mass-balance mock:
* - sources offer `supply`, consumers request `demand` (per relation network);
* - each consumer's demand is split across reachable sources proportionally
* to their supply;
* - each (source, consumer) portion is pushed along the shortest path
* (BFS, edge count), accumulating a signed flow per edge;
* - positive flow runs source→target of the edge as drawn, negative runs
* against it. Demands are scaled down if total supply is insufficient.
*/
import type { EdgeData, PipelineGraph } from '../model/graph';
import { getNodeType } from '../model/nodeTypes';
export interface EdgeFlow {
edgeId: string;
/** Signed flow, positive along the drawn source→target direction, m³/h. */
flow: number;
}
export interface FlowResult {
/** edgeId → signed flow. Every edge of the graph is present. */
flows: Map<string, number>;
maxAbsFlow: number;
totalSupplied: number;
totalDemand: number;
/** Consumer node ids that no source can reach. */
unreachedConsumers: string[];
}
function numProp(props: Record<string, unknown>, key: string): number {
const v = props[key];
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
}
interface Adj {
edge: EdgeData;
other: string;
}
const FLUID_RELATIONS = new Set(['water', 'heat', 'gas', 'sewage']);
/** Undirected adjacency restricted to fluid-carrying relations. */
function buildAdjacency(graph: PipelineGraph): Map<string, Adj[]> {
const adj = new Map<string, Adj[]>();
for (const edge of graph.edges.values()) {
if (!FLUID_RELATIONS.has(edge.relation)) continue; // power/signal lines carry no fluid
const a = adj.get(edge.sourceNodeId) ?? [];
a.push({ edge, other: edge.targetNodeId });
adj.set(edge.sourceNodeId, a);
const b = adj.get(edge.targetNodeId) ?? [];
b.push({ edge, other: edge.sourceNodeId });
adj.set(edge.targetNodeId, b);
}
return adj;
}
/** BFS shortest path from `from` to `to`; returns traversed adjacency steps. */
function shortestPath(adj: Map<string, Adj[]>, from: string, to: string): { edge: EdgeData; forward: boolean }[] | null {
if (from === to) return [];
const prev = new Map<string, { node: string; edge: EdgeData }>();
const queue = [from];
const seen = new Set([from]);
while (queue.length > 0) {
const node = queue.shift()!;
for (const { edge, other } of adj.get(node) ?? []) {
if (seen.has(other)) continue;
seen.add(other);
prev.set(other, { node, edge });
if (other === to) {
const path: { edge: EdgeData; forward: boolean }[] = [];
let cur = to;
while (cur !== from) {
const step = prev.get(cur)!;
path.unshift({ edge: step.edge, forward: step.edge.sourceNodeId === step.node });
cur = step.node;
}
return path;
}
queue.push(other);
}
}
return null;
}
export function solveFlow(graph: PipelineGraph): FlowResult {
const adj = buildAdjacency(graph);
const flows = new Map<string, number>();
for (const edge of graph.edges.values()) flows.set(edge.id, 0);
const sources: { id: string; supply: number }[] = [];
const consumers: { id: string; demand: number }[] = [];
for (const node of graph.nodes.values()) {
const role = getNodeType(node.typeId)?.simRole;
if (role === 'source') {
const supply = numProp(node.props, 'supply');
if (supply > 0) sources.push({ id: node.id, supply });
} else if (role === 'consumer') {
const demand = numProp(node.props, 'demand');
if (demand > 0) consumers.push({ id: node.id, demand });
}
}
const totalDemand = consumers.reduce((s, c) => s + c.demand, 0);
let totalSupplied = 0;
const unreachedConsumers: string[] = [];
for (const consumer of consumers) {
// Which sources can reach this consumer, and by what path?
const routes: { path: { edge: EdgeData; forward: boolean }[]; supply: number }[] = [];
for (const source of sources) {
const path = shortestPath(adj, source.id, consumer.id);
if (path) routes.push({ path, supply: source.supply });
}
if (routes.length === 0) {
unreachedConsumers.push(consumer.id);
continue;
}
const reachableSupply = routes.reduce((s, r) => s + r.supply, 0);
// Scale demand down when reachable supply cannot cover it.
const served = Math.min(consumer.demand, reachableSupply);
totalSupplied += served;
for (const route of routes) {
const portion = served * (route.supply / reachableSupply);
for (const step of route.path) {
flows.set(step.edge.id, (flows.get(step.edge.id) ?? 0) + (step.forward ? portion : -portion));
}
}
}
let maxAbsFlow = 0;
for (const f of flows.values()) maxAbsFlow = Math.max(maxAbsFlow, Math.abs(f));
return { flows, maxAbsFlow, totalSupplied, totalDemand, unreachedConsumers };
}