diff --git a/src/nf_metro/layout/phases/off_track.py b/src/nf_metro/layout/phases/off_track.py index 152e26f4e..a051a07d3 100644 --- a/src/nf_metro/layout/phases/off_track.py +++ b/src/nf_metro/layout/phases/off_track.py @@ -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, @@ -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) @@ -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. diff --git a/tests/test_compensation_passes_idempotent.py b/tests/test_compensation_passes_idempotent.py index 1473f7ea8..6845d5f9a 100644 --- a/tests/test_compensation_passes_idempotent.py +++ b/tests/test_compensation_passes_idempotent.py @@ -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 @@ -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"}), diff --git a/tests/test_layout_invariants.py b/tests/test_layout_invariants.py index c8fb7a67f..046086db2 100644 --- a/tests/test_layout_invariants.py +++ b/tests/test_layout_invariants.py @@ -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, @@ -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, @@ -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.