Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/nf_metro/layout/phases/off_track.py
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,38 @@ def _place(st: Station, candidate: float, bump_dir: float) -> None:
return lift_extreme, opp_extreme


def _feeding_run_spans_flow_coord(
graph: MetroGraph,
trunk_st: Station,
off_flow: float,
flow_axis: str,
in_section: set[str],
) -> bool:
"""Whether some run feeding *trunk_st* actually spans ``off_flow``.

The run drawn into ``trunk_st`` starts at its own in-section
predecessor(s) (``in_section`` -- station and port ids of the section
*trunk_st* belongs to), not at the icon. A predecessor whose flow
coordinate also lies downstream of ``off_flow`` means that run never
reaches back far enough to cross the icon's column, even though
``trunk_st`` itself is downstream. With no in-section predecessor, be
conservative and treat the trunk station as contesting.
"""
preds = [e.source for e in graph.edges_to(trunk_st.id) if e.source in in_section]
if not preds:
return True
trunk_flow = getattr(trunk_st, flow_axis)
for pid in preds:
pred_st = graph.stations.get(pid)
if pred_st is None:
continue
pred_flow = getattr(pred_st, flow_axis)
lo, hi = min(pred_flow, trunk_flow), max(pred_flow, trunk_flow)
if lo - SAME_COORD_TOLERANCE <= off_flow <= hi + SAME_COORD_TOLERANCE:
return True
return False


def _bump_off_track_clear_of_trunks(
graph: MetroGraph,
off_st: Station,
Expand Down Expand Up @@ -1542,10 +1574,12 @@ def _bump_off_track_clear_of_trunks(
MAX_STEPS = 6

offset_step = resolve_offset_step(graph.track_gap)
in_section = set(section.station_ids) | section.port_ids

# Find trunk stations in the same section whose line bundle crosses the
# icon's column (they lie downstream of the icon in flow, so the run
# feeding them passes the icon's flow coordinate).
# icon's column: downstream of the icon in flow is necessary but not
# sufficient, since the run feeding a downstream station may itself start
# further downstream than the icon and so never reach the icon's column.
trunk_bands: list[float] = []
for sid in section.station_ids:
st2 = graph.stations.get(sid)
Expand All @@ -1557,6 +1591,10 @@ def _bump_off_track_clear_of_trunks(
continue
if flow_sign * (getattr(st2, flow_axis) - off_flow) <= SAME_COORD_TOLERANCE:
continue
if not _feeding_run_spans_flow_coord(
graph, st2, off_flow, flow_axis, in_section
):
continue
# The line-track band spread across the cross axis at this trunk
# station: each line sits at ``st2_cross + offset(line)``, bounded by
# ``(n_lines - 1) * OFFSET_STEP`` centred on the station's cross coord.
Expand Down
13 changes: 0 additions & 13 deletions tests/test_compensation_passes_idempotent.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,6 @@
# the X positions of two off-track sibling stations instead of reproducing
# them, an order-sensitivity bug that has not been investigated further.
#
# ``topologies/paired_input_fan_branch_tree``'s "6.6"/"6.8" entries record a
# real divergence that call order alone keeps benign. Stage 6.18 seats the
# branch-tree spur on the row an off-track output icon already occupies two
# columns to its left. ``_bump_off_track_clear_of_trunks`` then admits any
# non-terminus trunk station merely downstream of the icon in flow and bumps
# when that station's line band overlaps the icon's cross extent -- but the
# seated branch is fed from a column downstream of the icon, so its inbound run
# never reaches the icon's column.
# The gate needs the feeding run's actual flow span, not just "downstream of the
# icon" (#1566). Stage 6.18 runs after the last reanchor, so the shipped
# geometry keeps the tighter placement.
#
# Entries are removed only when the underlying stage genuinely becomes an
# end-of-layout no-op; the assertions below fail loudly both on any new,
# unregistered gap and on any registered gap that stops reproducing, so this
Expand All @@ -123,7 +111,6 @@
"topologies/off_track_input_above_consumer": frozenset({"4.7"}),
"topologies/out_of_section_retag_fan": frozenset({"4.7"}),
"topologies/packed_multiline_serpentine_grid": frozenset({"4.7"}),
"topologies/paired_input_fan_branch_tree": frozenset({"6.6", "6.8"}),
"topologies/rl_entry_right_exit_left": frozenset({"4.7"}),
"topologies/rowmate_tb_side_entry_top_align_grow": frozenset({"4.7"}),
"topologies/side_branch_ascent_label_strike": frozenset({"4.7"}),
Expand Down
48 changes: 48 additions & 0 deletions tests/test_layout_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
_tb_top_entry_drop_overshoot,
)
from nf_metro.layout.phases.off_track import (
_bump_off_track_clear_of_trunks,
_cross_bbox_edges,
_is_single_trunk_section,
_off_track_anchor_of,
Expand Down Expand Up @@ -117,6 +118,7 @@
from nf_metro.parser.mermaid import parse_metro_mermaid
from nf_metro.parser.model import (
BYPASS_V_PREFIX,
Edge,
MetroGraph,
PortSide,
Section,
Expand Down Expand Up @@ -1270,6 +1272,52 @@ def test_single_line_dual_source_exit_shares_feeder_row():
)


def _off_track_bump_fixture(pred_x: float) -> tuple[MetroGraph, Station, Section]:
"""A minimal section: an off-track icon, a trunk station downstream of
it in flow, and that trunk station's own in-section predecessor at
``pred_x``. The predecessor sits far off the icon's row so it never
contests on its own; only its flow coordinate matters here."""
section = Section(id="sec", name="Sec", station_ids=["icon", "pred", "trunk"])
graph = MetroGraph()
graph.sections["sec"] = section
icon = Station(
id="icon", label="", x=100.0, y=100.0, section_id="sec", off_track=True
)
pred = Station(id="pred", label="P", x=pred_x, y=500.0, section_id="sec")
trunk = Station(id="trunk", label="T", x=250.0, y=100.0, section_id="sec")
graph.stations["icon"] = icon
graph.stations["pred"] = pred
graph.stations["trunk"] = trunk
graph.edges = [Edge(source="pred", target="trunk", line_id="L1")]
return graph, icon, section


def test_off_track_bump_gate_ignores_run_that_never_reaches_icon_column():
"""A trunk station downstream of an off-track icon in flow only
contests the icon's row if its own feeding run's predecessor sits on
the near side of the icon. A predecessor already downstream of the
icon means that run never reaches back across its column, so it must
not bump the icon out to a further row.
"""
graph, icon, section = _off_track_bump_fixture(pred_x=150.0) # downstream of icon
result = _bump_off_track_clear_of_trunks(
graph, icon, candidate=100.0, step=20.0, section=section, junction_ids=set()
)
assert result == pytest.approx(100.0)


def test_off_track_bump_gate_bumps_when_feeding_run_spans_icon_column():
"""A trunk station fed from a predecessor on the near side of the icon
genuinely has its run crossing the icon's column, so the icon must be
bumped clear of it.
"""
graph, icon, section = _off_track_bump_fixture(pred_x=50.0) # upstream of icon
result = _bump_off_track_clear_of_trunks(
graph, icon, candidate=100.0, step=20.0, section=section, junction_ids=set()
)
assert result != pytest.approx(100.0)


@pytest.mark.parametrize("fixture", ALL_FIXTURES)
def test_port_station_and_port_records_share_coords(fixture):
"""A port's Station record and Port record must hold the same coordinates.
Expand Down
Loading