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>
138 lines
4.9 KiB
TypeScript
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 };
|
|
}
|