Skip to content

Commit 81e43cb

Browse files
authored
Merge pull request #1565 from seqeralabs/fix/symmetric-diamond-join-centre
fix(layout): close a port-fed symmetric diamond on its entry centreline
2 parents d928901 + e393e76 commit 81e43cb

4 files changed

Lines changed: 106 additions & 33 deletions

File tree

src/nf_metro/layout/engine.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,9 +1802,9 @@ def _place_pass_c_content(
18021802
y_spacing,
18031803
)
18041804
# Stage 6.7b: carry each symmetric fork's dead-end continuation onto its
1805-
# branch track. Stage 6.7c/6.7d then pin the section and its entry port
1806-
# to the incoming bundle -- both must follow the recenter that fixes the
1807-
# branch Ys they align to.
1805+
# branch track. Stage 6.7c/6.7d then pin the section, its entry port and
1806+
# the reconvergence spine that port feeds to the incoming bundle -- all
1807+
# must follow the recenter that fixes the branch Ys they align to.
18081808
_carry_symmetric_branch_continuations(graph, section_y_padding)
18091809
_align_symfan_section_to_row_feeder(graph)
18101810
_center_lr_entry_ports_on_fork(graph, y_spacing)

src/nf_metro/layout/phases/fan_bundles.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,24 +1139,56 @@ def _align_symfan_section_to_row_feeder(graph: MetroGraph) -> None:
11391139
shift_section(graph, section, dy=delta)
11401140

11411141

1142+
def _entry_fork_join(
1143+
graph: MetroGraph, section: Section, branches: list[str]
1144+
) -> Station | None:
1145+
"""The station a port-fed two-way fork cleanly reconverges at, or None.
1146+
1147+
The port-fork counterpart of :func:`_iter_fork_join_diamonds`, which admits
1148+
only station forks. Requiring the two branches to be the join's *only*
1149+
feeders keeps it the point where the whole bundle reunites, so no third
1150+
feeder has a competing claim on its Y.
1151+
"""
1152+
if len(branches) != 2:
1153+
return None
1154+
succs = [_in_section_ontrack_successors(graph, section, b) for b in branches]
1155+
if succs[0] != succs[1] or len(succs[0]) != 1:
1156+
return None
1157+
join_id = succs[0][0]
1158+
feeders = {
1159+
e.source
1160+
for e in graph.edges_to(join_id)
1161+
if _is_in_section_on_track(graph.station_for_edge_source(e), section.id)
1162+
}
1163+
if feeders != set(branches):
1164+
return None
1165+
return graph.stations[join_id]
1166+
1167+
11421168
def _center_lr_entry_ports_on_fork(graph: MetroGraph, y_spacing: float) -> None:
1143-
"""Centre an LR entry port on the two-way fork it fans into.
1169+
"""Centre an LR entry port, and the join it reconverges at, on its two-way fork.
11441170
11451171
Under ``diamond_style: symmetric`` a section's LR entry port that fans into
11461172
branches at exactly two distinct Ys should sit at their midpoint, so the
11471173
fork reads symmetric about the incoming bundle and the run from the feeding
11481174
section arrives straight. Otherwise the port stays pinned to whichever
11491175
branch the section layout seated it on (e.g. the top branch of a
1150-
reconverging diamond), leaving the inter-section run kinked. Only the port
1151-
moves; the branch stations keep their places.
1176+
reconverging diamond), leaving the inter-section run kinked.
1177+
1178+
When the fork reconverges at a clean in-section join, that join and its
1179+
linear trunk continuation ride the same midpoint centreline, so the diamond
1180+
closes symmetrically and the trunk leaves it straight. A station fork gets
1181+
this for free -- the join inherits the fork's own trunk track -- but a port
1182+
fork has no station on the centreline to inherit from, so the join would
1183+
otherwise stay on the track of whichever branch fed it first, turning the
1184+
diamond into a lopsided fan. The branch stations themselves keep their
1185+
places either way.
11521186
11531187
An off-grid midpoint (branches an odd number of slots apart) is only a
1154-
valid seat when the fork reconverges at an in-section join: that join
1155-
already sits at the midpoint, so the port matches it and the exit fork it
1156-
mirrors. A non-reconverging dead-end fan instead keeps its port on the
1157-
feeder trunk with the branches straddling half a slot either side, so
1158-
seating the port on the off-grid midpoint there would drag the row's trunk
1159-
off the inter-section run.
1188+
valid seat when the fork reconverges: a non-reconverging dead-end fan
1189+
instead keeps its port on the feeder trunk with the branches straddling
1190+
half a slot either side, so seating the port on the off-grid midpoint there
1191+
would drag the row's trunk off the inter-section run.
11601192
"""
11611193
if graph.diamond_style != "symmetric":
11621194
return
@@ -1181,10 +1213,17 @@ def _center_lr_entry_ports_on_fork(graph: MetroGraph, y_spacing: float) -> None:
11811213
midpoint = (branch_ys[0] + branch_ys[1]) / 2.0
11821214
if abs(graph.stations[pid].y - midpoint) >= 1.0:
11831215
_set_port_y(graph, pid, midpoint)
1216+
join = _entry_fork_join(graph, section, branches)
1217+
if join is not None and abs(join.y - midpoint) >= 1.0:
1218+
join.y = midpoint
1219+
_pull_continuation_onto(graph, section, join)
11841220
if off_grid:
1185-
# The port (and the diamond spine it feeds) rides the off-grid
1186-
# midpoint centreline, like the join it reconverges at, so the
1187-
# grid snap must treat it as half-grid, not re-seat it on a row.
1221+
# The port rides the off-grid midpoint centreline, so the grid
1222+
# snap must treat it as half-grid, not re-seat it on a row. The
1223+
# spine it feeds is deliberately left out: the snap runs earlier
1224+
# in the pipeline, so entries here would reach it only on a
1225+
# subsequent layout pass, where a whole spine of them would
1226+
# outvote the branch rows for the group's grid origin.
11881227
graph.half_grid_station_ids.add(pid)
11891228

11901229

tests/test_layout_invariants.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9806,6 +9806,23 @@ def _direct_fork_children(
98069806
return triples
98079807

98089808

9809+
def _shared_successors(graph: MetroGraph, a: Station, b: Station) -> list[Station]:
9810+
"""The real, on-track stations both *a* and *b* feed directly."""
9811+
targets = {
9812+
sid: [
9813+
graph.station_for_edge_target(e).id
9814+
for e in graph.edges_from(sid)
9815+
if not (t := graph.station_for_edge_target(e)).is_port
9816+
and not t.is_hidden
9817+
and not t.off_track
9818+
]
9819+
for sid in (a.id, b.id)
9820+
}
9821+
return [
9822+
graph.stations[sid] for sid in sorted(set(targets[a.id]) & set(targets[b.id]))
9823+
]
9824+
9825+
98099826
_SYMMETRIC_DEADEND_FANOUT_FIXTURES = [
98109827
"topologies/symmetric_deadend_fanout.mmd",
98119828
"topologies/symmetric_deadend_fanout_relay.mmd",
@@ -9949,21 +9966,27 @@ def test_symmetric_fork_entry_port_stays_on_feeder_trunk(fixture):
99499966

99509967
_SYMMETRIC_RECONVERGING_DIAMOND_FIXTURES = [
99519968
"topologies/symmetric_diamond_odd_slot_entry.mmd",
9969+
"topologies/paired_input_fan_branch_tree.mmd",
9970+
"topologies/rowmate_tb_side_entry_top_align.mmd",
99529971
]
99539972

99549973

99559974
@pytest.mark.parametrize("fixture", _SYMMETRIC_RECONVERGING_DIAMOND_FIXTURES)
99569975
def test_symmetric_reconverging_diamond_entry_port_centres_on_fork(fixture):
9957-
"""A reconverging symmetric diamond centres its LR entry port on the fork.
9976+
"""A reconverging symmetric diamond closes on its LR entry port's centreline.
99589977
99599978
When a ``diamond_style: symmetric`` fork reconverges at an in-section join,
9960-
the join sits at the branch midpoint, so the entry port fanning into that
9961-
fork must sit there too -- even when the branches are an odd number of grid
9962-
slots apart and the midpoint lands off-grid. Otherwise the entry fork reads
9963-
lopsided (the run lands level with one branch, the other kinks off it) while
9964-
the exit fork about the join is symmetric. The off-grid-midpoint skip
9965-
applies only to a non-reconverging dead-end fan, whose port stays on its
9966-
feeder trunk.
9979+
the branches straddle the entry port's Y and the join returns to it, so the
9980+
diamond reads as a bubble on the incoming bundle -- even when the branches
9981+
are an odd number of grid slots apart and the centreline lands off-grid.
9982+
Otherwise the diamond reads lopsided: the run lands level with one branch
9983+
and the other kinks off it, the two legs differing in length. The
9984+
off-grid-midpoint skip applies only to a non-reconverging dead-end fan,
9985+
whose port stays on its feeder trunk.
9986+
9987+
A station fork closes on its centreline for free, the join inheriting the
9988+
fork's own trunk track. A port fork has no station on the centreline to
9989+
inherit from, so its join has to be seated there explicitly.
99679990
"""
99689991
graph = _layout_diamond(fixture, "symmetric")
99699992
checked = 0
@@ -9983,6 +10006,12 @@ def test_symmetric_reconverging_diamond_entry_port_centres_on_fork(fixture):
998310006
f"{fixture}: entry port {fork.id!r} station y={fork.y:.1f} but port "
998410007
f"record y={None if port is None else round(port.y, 1)} -- desynced"
998510008
)
10009+
for join in _shared_successors(graph, lo, hi):
10010+
assert abs(join.y - fork.y) < 1.0, (
10011+
f"{fixture}: diamond fed by entry port {fork.id!r} rejoins at "
10012+
f"{join.id!r} (y={join.y:.1f}), off the port's centreline "
10013+
f"y={fork.y:.1f} -- the diamond closes lopsided"
10014+
)
998610015
checked += 1
998710016
assert checked, f"{fixture}: no entry-port fork found to check"
998810017

tests/test_symmetric_merge_median.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1-
"""A symmetric-mode multi-track merge anchors on the median of its feeders.
1+
"""A symmetric-mode multi-track merge anchors between its innermost feeders.
22
33
When 3+ metro lines on distinct tracks converge on a single shared station and
44
no single predecessor already carries the full bundle, the station is a genuine
5-
multi-track merge. In ``diamond_style: symmetric`` the merge must sit on the
6-
median predecessor track so each feeder bends toward it by the least amount --
5+
multi-track merge. In ``diamond_style: symmetric`` the merge must sit in the
6+
middle of its feeder spread so each feeder bends toward it by the least amount --
77
not snap to the first-declared line's (extreme) track, which forces every other
88
feeder into a longer detour (#1277).
9+
10+
The least-detour seat is the closed band between the two innermost feeders: an
11+
odd count collapses it to the single median track, and across an even count's
12+
band the total feeder bend is constant, so anywhere in it is equally good.
913
"""
1014

1115
from __future__ import annotations
1216

1317
from pathlib import Path
14-
from statistics import median_low
18+
from statistics import median_high, median_low
1519

1620
import networkx as nx
1721
import pytest
@@ -70,15 +74,16 @@ def _convergence_merges(graph) -> list[tuple[str, list[float]]]:
7074

7175

7276
@pytest.mark.parametrize("name", _SYMMETRIC_FIXTURES)
73-
def test_symmetric_merge_sits_on_median_feeder_track(name: str) -> None:
77+
def test_symmetric_merge_sits_between_its_innermost_feeders(name: str) -> None:
7478
graph = _layout(name)
7579
for sid, pred_ys in _convergence_merges(graph):
76-
expected = median_low(pred_ys)
80+
lo, hi = median_low(pred_ys), median_high(pred_ys)
7781
actual = graph.stations[sid].y
78-
assert abs(actual - expected) <= SAME_COORD_TOLERANCE, (
79-
f"{name}: merge {sid!r} sits at y={actual:.1f}, not on the median "
80-
f"feeder track y={expected:.1f} (feeders {pred_ys}) -- snapped to an "
81-
"extreme track, forcing other feeders into longer detours"
82+
assert lo - SAME_COORD_TOLERANCE <= actual <= hi + SAME_COORD_TOLERANCE, (
83+
f"{name}: merge {sid!r} sits at y={actual:.1f}, outside the "
84+
f"[{lo:.1f}, {hi:.1f}] band between its innermost feeders "
85+
f"(feeders {pred_ys}) -- it snapped toward an extreme track, "
86+
"forcing other feeders into longer detours"
8287
)
8388

8489

0 commit comments

Comments
 (0)