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>
161 lines
5.6 KiB
Python
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
|