Ilya 58f3c891ee feat(calc,routing): config, orthogonal routing, and mock flow solver
Add observable AppConfig (grid/snap/arc-on-crossing/animation/theme),
pure-geometry orthogonal router with grid-snapped bends and perpendicular
crossing detection for arc hops, and a mock flow-distribution solver that
pushes supply to demand along shortest paths and records signed per-edge
flow and direction. Covered by routing and flow-solver tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:15:35 +02:00

161 lines
5.6 KiB
Python

"""Mock flow-distribution 'calculation'.
This is a deliberately simplified stand-in for a real hydraulic/electrical
solver. It distributes flow from source nodes to sink nodes across the network
so that each edge receives a plausible signed flow value and direction, which
the animation layer then visualizes.
Approach (mock, not physically rigorous):
1. Identify sources (supply/feed) and sinks (demand) from node properties.
2. Build an undirected graph of nodes connected by edges.
3. For each (source, sink) pair with a path, push the sink's demand (scaled by
available supply) along the shortest path, accumulating signed flow per edge.
4. Edge direction is the sign of the accumulated flow along source->target.
The result is stored on each :class:`EdgeModel` as ``flow`` (magnitude) and
``flow_direction`` (+1/-1/0).
"""
from __future__ import annotations
from collections import deque
from typing import Optional
class FlowResult:
"""Per-edge flow outcome plus network totals."""
def __init__(self) -> None:
self.edge_flow: dict[str, float] = {} # signed, along source->target
self.total_supply: float = 0.0
self.total_demand: float = 0.0
self.unbalanced: float = 0.0 # supply - served demand
def direction(self, edge_id: str) -> int:
f = self.edge_flow.get(edge_id, 0.0)
if f > 1e-9:
return 1
if f < -1e-9:
return -1
return 0
def _node_supply(node) -> float:
return float(node.properties.value("supply", 0.0) or 0.0)
def _node_demand(node) -> float:
return float(node.properties.value("demand", 0.0) or 0.0)
def solve(document) -> FlowResult:
"""Distribute flow across the document's edges. Returns a :class:`FlowResult`."""
result = FlowResult()
nodes = list(document.nodes())
edges = list(document.edges())
if not edges:
return result
# adjacency: node_id -> list of (neighbor_id, edge_id, sign_for_source_to_target)
adj: dict[str, list[tuple[str, str, int]]] = {n.node_id: [] for n in nodes}
for e in edges:
if e.source_node in adj and e.target_node in adj:
adj[e.source_node].append((e.target_node, e.edge_id, +1))
adj[e.target_node].append((e.source_node, e.edge_id, -1))
result.edge_flow[e.edge_id] = 0.0
sources = [(n.node_id, _node_supply(n)) for n in nodes if _node_supply(n) > 0]
sinks = [(n.node_id, _node_demand(n)) for n in nodes if _node_demand(n) > 0]
result.total_supply = sum(s for _, s in sources)
result.total_demand = sum(d for _, d in sinks)
if not sources or not sinks:
# No defined supply/demand: assign a nominal unit flow so the diagram
# still animates. Direction follows edge source->target.
for e in edges:
result.edge_flow[e.edge_id] = 1.0
return result
# Scale served demand so we never exceed available supply.
scale = 1.0
if result.total_demand > result.total_supply and result.total_demand > 0:
scale = result.total_supply / result.total_demand
result.unbalanced = result.total_supply - result.total_demand * scale
remaining_supply = {sid: s for sid, s in sources}
for sink_id, demand in sinks:
served = demand * scale
# distribute this sink's served demand across reachable sources
while served > 1e-9:
src = _nearest_source_with_supply(sink_id, remaining_supply, adj)
if src is None:
break
path = _shortest_path(src, sink_id, adj)
if not path:
remaining_supply[src] = 0.0
continue
push = min(served, remaining_supply[src])
_accumulate_along_path(path, adj, push, result)
remaining_supply[src] -= push
served -= push
return result
def _nearest_source_with_supply(sink_id, remaining_supply, adj) -> Optional[str]:
"""BFS from sink to the closest source that still has supply."""
seen = {sink_id}
q = deque([sink_id])
while q:
cur = q.popleft()
if cur in remaining_supply and remaining_supply[cur] > 1e-9:
return cur
for nb, _eid, _sign in adj.get(cur, []):
if nb not in seen:
seen.add(nb)
q.append(nb)
return None
def _shortest_path(start, goal, adj) -> list[tuple[str, str, int]]:
"""BFS path as a list of (from_node, edge_id, sign) hops from start to goal."""
if start == goal:
return []
prev: dict[str, tuple[str, str, int]] = {}
seen = {start}
q = deque([start])
while q:
cur = q.popleft()
for nb, eid, sign in adj.get(cur, []):
if nb not in seen:
seen.add(nb)
prev[nb] = (cur, eid, sign)
if nb == goal:
q.clear()
break
q.append(nb)
if goal not in prev:
return []
hops: list[tuple[str, str, int]] = []
node = goal
while node != start:
frm, eid, sign = prev[node]
hops.append((frm, eid, sign))
node = frm
hops.reverse()
return hops
def _accumulate_along_path(path, adj, amount, result) -> None:
for _frm, eid, sign in path:
result.edge_flow[eid] += sign * amount
def apply_to_document(document) -> FlowResult:
"""Solve and write ``flow`` / ``flow_direction`` back onto each edge."""
result = solve(document)
for e in document.edges():
f = result.edge_flow.get(e.edge_id, 0.0)
e.flow = abs(f)
e.flow_direction = result.direction(e.edge_id)
return result