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>
This commit is contained in:
Ilya 2026-07-02 23:15:35 +02:00
parent 62780c66be
commit 58f3c891ee
5 changed files with 483 additions and 0 deletions

View File

@ -0,0 +1,160 @@
"""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

63
pipeline_editor/config.py Normal file
View File

@ -0,0 +1,63 @@
"""Application configuration (grid, snapping, routing, animation, theming)."""
from __future__ import annotations
from PyQt6.QtCore import QObject, pyqtSignal
class AppConfig(QObject):
"""Live, observable editor configuration.
Emitting :attr:`changed` lets the canvas and items refresh when the user
tweaks a setting in the configuration dialog.
"""
changed = pyqtSignal()
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self.grid_size = 20
self.show_grid = True
self.snap_to_grid = True
# Draw a small arc "hop" where an edge crosses another edge.
self.arc_on_crossing = True
self.arc_radius = 5.0
# Routing stub length from a port before turning.
self.route_stub = 20
# Flow animation.
self.animation_speed = 1.0 # multiplier
self.color_scheme = "default"
# Canvas theme.
self.background = "#fafafa"
self.grid_color = "#e0e0e0"
def snap(self, value: float) -> float:
if not self.snap_to_grid or self.grid_size <= 0:
return value
g = self.grid_size
return round(value / g) * g
def snap_point(self, x: float, y: float) -> tuple[float, float]:
return self.snap(x), self.snap(y)
def emit_changed(self) -> None:
self.changed.emit()
def to_dict(self) -> dict:
return {
"grid_size": self.grid_size,
"show_grid": self.show_grid,
"snap_to_grid": self.snap_to_grid,
"arc_on_crossing": self.arc_on_crossing,
"arc_radius": self.arc_radius,
"route_stub": self.route_stub,
"animation_speed": self.animation_speed,
"color_scheme": self.color_scheme,
"background": self.background,
"grid_color": self.grid_color,
}
def update_from(self, data: dict) -> None:
for k, v in data.items():
if hasattr(self, k):
setattr(self, k, v)
self.changed.emit()

View File

@ -0,0 +1,125 @@
"""Pure-geometry orthogonal routing and crossing detection.
Kept free of Qt imports so it can be unit-tested independently. Points are
``(x, y)`` float tuples; segments are ``(p0, p1)`` axis-aligned pairs.
"""
from __future__ import annotations
from typing import Iterable
Point = tuple[float, float]
Segment = tuple[Point, Point]
def _snap(v: float, grid: int) -> float:
if grid <= 0:
return v
return round(v / grid) * grid
def orthogonal_route(
start: Point,
start_normal: Point,
end: Point,
end_normal: Point,
*,
grid: int = 20,
stub: int | None = None,
) -> list[Point]:
"""Compute an axis-aligned poly-line from ``start`` to ``end``.
The path leaves ``start`` along ``start_normal`` and arrives at ``end``
against ``end_normal``, turning through a mid-line. Endpoints are exact;
intermediate bends are snapped to ``grid``.
"""
if stub is None:
stub = grid
sx, sy = start
ex, ey = end
snx, sny = start_normal
enx, eny = end_normal
s1 = (sx + snx * stub, sy + sny * stub)
e1 = (ex + enx * stub, ey + eny * stub)
pts: list[Point] = [(sx, sy), s1]
if snx != 0: # start exits horizontally -> pivot on a vertical mid-line
midx = _snap((s1[0] + e1[0]) / 2.0, grid)
pts += [(midx, s1[1]), (midx, e1[1])]
else: # start exits vertically -> pivot on a horizontal mid-line
midy = _snap((s1[1] + e1[1]) / 2.0, grid)
pts += [(s1[0], midy), (e1[0], midy)]
pts += [e1, (ex, ey)]
return _cleanup(pts)
def _cleanup(pts: list[Point], eps: float = 1e-6) -> list[Point]:
"""Drop duplicate and collinear intermediate points."""
out: list[Point] = []
for p in pts:
if out and abs(p[0] - out[-1][0]) < eps and abs(p[1] - out[-1][1]) < eps:
continue
out.append(p)
# remove collinear middles
cleaned: list[Point] = []
for i, p in enumerate(out):
if 0 < i < len(out) - 1:
a, b = out[i - 1], out[i + 1]
# collinear if all three share an x or all share a y
if (abs(a[0] - p[0]) < eps and abs(p[0] - b[0]) < eps) or \
(abs(a[1] - p[1]) < eps and abs(p[1] - b[1]) < eps):
continue
cleaned.append(p)
return cleaned
def polyline_segments(points: Iterable[Point]) -> list[Segment]:
pts = list(points)
return [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
def _is_horizontal(seg: Segment, eps: float = 1e-6) -> bool:
return abs(seg[0][1] - seg[1][1]) < eps
def _is_vertical(seg: Segment, eps: float = 1e-6) -> bool:
return abs(seg[0][0] - seg[1][0]) < eps
def segment_crossings(
path: list[Segment],
others: list[Segment],
*,
eps: float = 1e-6,
) -> list[Point]:
"""Return true perpendicular crossing points between ``path`` and ``others``.
Only counts a horizontal segment crossing a vertical one (or vice versa)
strictly in the interior of both shared endpoints and overlaps are ignored.
Used to render arc "hops" where pipes cross.
"""
crossings: list[Point] = []
for a in path:
for b in others:
pt = _perpendicular_crossing(a, b, eps)
if pt is not None:
crossings.append(pt)
return crossings
def _perpendicular_crossing(a: Segment, b: Segment, eps: float) -> Point | None:
if _is_horizontal(a) and _is_vertical(b):
h, v = a, b
elif _is_vertical(a) and _is_horizontal(b):
v, h = a, b
else:
return None
hy = h[0][1]
vx = v[0][0]
hx0, hx1 = sorted((h[0][0], h[1][0]))
vy0, vy1 = sorted((v[0][1], v[1][1]))
# strictly interior on both segments (avoid endpoints / T-joins)
if hx0 + eps < vx < hx1 - eps and vy0 + eps < hy < vy1 - eps:
return (vx, hy)
return None

74
tests/test_flow_solver.py Normal file
View File

@ -0,0 +1,74 @@
"""Tests for the mock flow solver."""
import pytest
from pipeline_editor.calc import flow_solver
from pipeline_editor.model.document import DiagramDocument
from pipeline_editor.model.edge import EdgeModel
from pipeline_editor.model.node_library import default_library
@pytest.fixture
def lib():
return default_library()
def _add(doc, lib, key, x=0, y=0):
return doc.add_node(lib.get(key).instantiate(doc.next_id("n"), x, y))
def test_linear_network_flow_direction(qapp, lib):
doc = DiagramDocument()
src = _add(doc, lib, "source") # supply 120
pump = _add(doc, lib, "pump", 200)
cons = _add(doc, lib, "consumer", 400) # demand 40
e1 = doc.add_edge(EdgeModel(doc.next_id("e"), src.node_id, "out", pump.node_id, "in"))
e2 = doc.add_edge(EdgeModel(doc.next_id("e"), pump.node_id, "out", cons.node_id, "in"))
result = flow_solver.apply_to_document(doc)
# 40 units flow from source to consumer along both edges, in source->target dir
assert result.edge_flow[e1.edge_id] == pytest.approx(40)
assert result.edge_flow[e2.edge_id] == pytest.approx(40)
assert e1.flow_direction == 1 and e2.flow_direction == 1
assert e1.flow == pytest.approx(40)
def test_reversed_edge_gives_negative_direction(qapp, lib):
doc = DiagramDocument()
src = _add(doc, lib, "source")
cons = _add(doc, lib, "consumer", 400)
# edge authored consumer(source-of-edge) -> source(target-of-edge)
e = doc.add_edge(EdgeModel(doc.next_id("e"), cons.node_id, "in", src.node_id, "out"))
flow_solver.apply_to_document(doc)
# physical flow is source->consumer, i.e. against edge authoring direction
assert e.flow_direction == -1
assert e.flow == pytest.approx(40)
def test_demand_capped_by_supply(qapp, lib):
doc = DiagramDocument()
src = _add(doc, lib, "source") # supply 120
c1 = _add(doc, lib, "consumer", 200)
c2 = _add(doc, lib, "consumer", 400)
c1.properties.set_value("demand", 100)
c2.properties.set_value("demand", 100) # total demand 200 > supply 120
doc.add_edge(EdgeModel(doc.next_id("e"), src.node_id, "out", c1.node_id, "in"))
doc.add_edge(EdgeModel(doc.next_id("e"), src.node_id, "out", c2.node_id, "in"))
result = flow_solver.apply_to_document(doc)
served = sum(abs(f) for f in result.edge_flow.values())
assert served == pytest.approx(120) # cannot exceed supply
def test_no_sources_uses_nominal_flow(qapp, lib):
doc = DiagramDocument()
a = _add(doc, lib, "junction")
b = _add(doc, lib, "junction", 200)
e = doc.add_edge(EdgeModel(doc.next_id("e"), a.node_id, "e", b.node_id, "w"))
flow_solver.apply_to_document(doc)
assert e.flow == pytest.approx(1.0)
assert e.flow_direction == 1
def test_empty_document(qapp):
doc = DiagramDocument()
result = flow_solver.solve(doc)
assert result.edge_flow == {}

61
tests/test_routing.py Normal file
View File

@ -0,0 +1,61 @@
"""Tests for orthogonal routing and crossing detection (pure geometry)."""
import math
from pipeline_editor.view.routing import (
orthogonal_route, polyline_segments, segment_crossings,
)
def _is_orthogonal(points):
for (x0, y0), (x1, y1) in zip(points, points[1:]):
if not (math.isclose(x0, x1) or math.isclose(y0, y1)):
return False
return True
def test_route_is_orthogonal_horizontal_ports():
# source outlet facing right -> pump inlet facing left
pts = orthogonal_route((0, 0), (1, 0), (200, 80), (-1, 0), grid=20)
assert pts[0] == (0, 0)
assert pts[-1] == (200, 80)
assert _is_orthogonal(pts)
assert len(pts) >= 2
def test_route_is_orthogonal_vertical_ports():
pts = orthogonal_route((50, 0), (0, -1), (130, 200), (0, 1), grid=20)
assert pts[0] == (50, 0) and pts[-1] == (130, 200)
assert _is_orthogonal(pts)
def test_route_straight_line_collapses():
# aligned ports facing each other -> essentially a straight run
pts = orthogonal_route((0, 40), (1, 0), (200, 40), (-1, 0), grid=20)
assert _is_orthogonal(pts)
assert all(math.isclose(p[1], 40) for p in pts)
def test_bends_snapped_to_grid():
pts = orthogonal_route((0, 0), (1, 0), (207, 83), (-1, 0), grid=20)
for x, y in pts[1:-1]:
assert math.isclose(x % 20, 0) or math.isclose(x % 20, 20)
def test_perpendicular_crossing_detected():
horiz = polyline_segments([(0, 50), (100, 50)])
vert = polyline_segments([(50, 0), (50, 100)])
cx = segment_crossings(horiz, vert)
assert cx == [(50, 50)]
def test_shared_endpoint_is_not_a_crossing():
# a T-junction where the vertical touches the horizontal endpoint
horiz = polyline_segments([(0, 50), (50, 50)])
vert = polyline_segments([(50, 50), (50, 100)])
assert segment_crossings(horiz, vert) == []
def test_parallel_segments_no_crossing():
a = polyline_segments([(0, 50), (100, 50)])
b = polyline_segments([(0, 70), (100, 70)])
assert segment_crossings(a, b) == []