Skip to content

Routing

Routing turns the laid-out MetroGraph, meaning stations, ports and junctions with coordinates, into a list of RoutedPath polylines, one per edge. The entry point is route_edges in src/nf_metro/layout/routing/core.py.

Lines are drawn as horizontal runs joined by 45-degree diagonal transitions. Inter-section edges use L-shaped routing, meaning a horizontal leg and a vertical one.

The routing pipeline builds the semantic scaffold, complete exit-turn and fan decisions, immutable member templates and complete convergence decisions, then one atomic emission decision per route system, as described in route planning and observation. Inter-section edges are classified exactly once during planning. Production either copies a frozen member template or consumes a complete convergence plan, and it never reruns a fallback dispatcher during emission.

observe_route_edges returns the same decisions together with final emission bindings, corridor reservations and settled layout provenance. Whole-graph rail mode takes the short circuit below.

Before the normal dispatch, route_edges checks the graph’s line_spread, a LineSpread of BUNDLE, CENTERED or RAILS:

  • When line_spread is LineSpread.RAILS, the whole graph is routed by route_rail_edges in routing/rail.py and route_edges returns early.
  • When only some sections opt into rails (has_rail_sections), the edges internal to those rail sections are routed by route_rail_edges up front, and the rest fall through to the normal handler chain below.

Rail routing does not bundle. Each line runs along a single fixed horizontal rail Y, assigned in layout/rail_mode.py, so each edge is a straight horizontal run at its line’s rail Y, and shared stations render as interchange pills bridging the rails.

Mermaid source
examples/rail_mode.mmd
%%metro title: Rail mode: variant-calling sample routes
%%metro style: nfcore
%%metro line_spread: rails
%%metro label_angle: 45
%%metro line: germline | Germline | #2db572
%%metro line: tumor_only | Tumour-only | #f4a300
%%metro line: pair_n | Pair (normal) | #0570b0
%%metro line: pair_t | Pair (tumour) | #e63946
%%metro legend_combo: pair_n, pair_t | Tumour-normal pair
%%metro legend: bl
%%metro file: cram_in | CRAM
%%metro file: samples_csv | CSV | Samples
%%metro file: vcf_out | VCF
%%metro off_track: samples_csv
graph LR
subgraph calling [Variant calling]
cram_in[ ]
align[Alignment]
markdup[Mark duplicates]
bqsr[BQSR]
samples_csv[ ]
callvar[Call variants]
somatic[Somatic filter]
concord[Concordance]
cram_in -->|germline,tumor_only,pair_n,pair_t| align
align -->|germline,tumor_only,pair_n,pair_t| markdup
markdup -->|germline,tumor_only,pair_n,pair_t| bqsr
samples_csv -->|pair_n,pair_t| bqsr
bqsr -->|germline,tumor_only,pair_n,pair_t| callvar
callvar -->|tumor_only,pair_n,pair_t| somatic
callvar -->|germline| concord
somatic -->|pair_n,pair_t| concord
end
subgraph annotate [Annotation and output]
norm[Normalize]
vep[Annotate]
vcf_out[ ]
norm -->|germline,tumor_only,pair_n,pair_t| vep
vep -->|germline,tumor_only,pair_n,pair_t| vcf_out
end
CLI command
Terminal window
nf-metro render examples/rail_mode.mmd -o rail_mode.svg
Rendered map
Rail mode: variant-calling sample routes 1 2 CRAM CSV Samples VCF Normalize Alignment Annotate Mark duplicates BQSR Call variants Somatic filter Concordance Germline Tumour-only Tumour-normal pair created with nf-metro v2.0.0+dev

route_edges first builds a _RoutingCtx holding merge classification, fold X, bundle information, station offsets, fork stations and other shared facts.

Before emission, the exit-turn planner classifies every member in each complete exit group, and every inter-section member is classified once against the stable family table. Exit turns that share a row or column gap with other routes enter the member allocator as movable claims. The allocator settles all resident channels together, then the exit-turn planner publishes the resulting axis, lane assignment and corner offsets.

The convergence planner then builds complete target-side candidates without routing non-convergence members. A preliminary system decision assigns a complete geometry owner, after which preliminary convergence settlement exposes fixed channel claims to the mutable member allocator. In canonical order, the member-geometry planner calls each remaining member’s family, materializes its gap slots around those claims and its trunk slot, then freezes its production seed and owned gap channels. Global convergence settlement allocates the target-side trunk, landings, continuation and endpoint owners against those immutable member channels and prior convergence claims.

The final system decision freezes canonical order, a PLANNED disposition, plan and reservation owners, and exactly one geometry owner for every member. An unsupported planning result fails closed rather than selecting an alternate emitter.

The edge loop encounters systems in graph order but emits each one exactly once in scaffold order. Each non-convergence path is a fresh mutable copy of its RouteMemberGeometryPlan, and its family is not called a second time. Convergence members consume their complete convergence plan. TB, entry-runway and intra-section handlers remain for local edges outside the inter-section route-system pipeline.

The order in route_edges is:

  1. Inter-section planning. Edges crossing a section boundary, running port or junction to port or junction, are classified into a family such as L-shape, top-entry L-shape, left- or right-entry wrap, TB bottom-exit, merge trunk or branch, bypass, stepped descent, inter-row corridor, or around-section-below.
  2. _route_tb_section handles edges touching a TB section. It dispatches over the ordered _TB_SECTION_SHAPES tuple, first match winning, across internal vertical drops (_route_tb_internal), internal station to a LEFT or RIGHT exit port (_route_tb_lr_exit), LEFT or RIGHT entry port to an internal station (_route_tb_lr_entry), and TOP or BOTTOM port to an internal station (_route_perp_entry). Each shape describes a centreline and fans it through the bundle builder, build_tapered_bundle or build_offset_bundle, so no handler hand-assembles per-line points or curve radii. The perpendicular-entry corridor variant, _route_perp_entry_from_corridor, routes the same way.
  3. _route_entry_runway handles a flow-side entry port reaching a deep internal station. It compresses the diagonal into the entry region and runs a horizontal runway past the bypassed early-layer stations.
  4. _route_intra_section handles the general local case: diagonals, cross-row fold routing and straight lines.

The fan-out below illustrates all four handler families at once: inter-section L-shapes connect the sections, TB-section routing applies inside the vertical connector, entry runways compress the diagonal at each section’s input port, and intra-section diagonals handle the station-to-station edges within each horizontal section.

Mermaid source
examples/guide/04_directions.mmd
%%metro title: Section Directions
%%metro style: nfcore
%%metro line: rna | RNA-seq | #2db572
%%metro line: dna | DNA-seq | #e63946
%%metro legend: bl
graph LR
subgraph preprocessing [Pre-processing]
fastqc[FastQC]
trim[Trimming]
fastqc -->|rna,dna| trim
end
subgraph rna_analysis [RNA Analysis]
star[STAR]
salmon[Salmon]
star -->|rna| salmon
end
subgraph dna_analysis [DNA Analysis]
bwa[BWA-MEM]
gatk[GATK]
bwa -->|dna| gatk
end
subgraph postprocessing [Post-processing]
%%metro direction: TB
samtools[SAMtools]
picard[Picard]
bedtools[BEDTools]
samtools -->|rna,dna| picard
picard -->|rna,dna| bedtools
end
subgraph reporting [Reporting]
multiqc[MultiQC]
report[Report]
multiqc -->|rna,dna| report
end
trim -->|rna| star
trim -->|dna| bwa
salmon -->|rna| samtools
gatk -->|dna| samtools
bedtools -->|rna,dna| multiqc
CLI command
Terminal window
nf-metro render examples/guide/04_directions.mmd -o 04_directions.svg
Rendered map
Section Directions 1 2 3 4 5 FastQC STAR SAMtools MultiQC BWA-MEM Trimming Salmon Picard Report GATK BEDTools RNA-seq DNA-seq created with nf-metro v2.0.0+dev

After all edges are routed, route_edges runs a series of post-passes that adjust the assembled polylines as a set, covering diagonal spreading, gap and trunk slot materialization, and same-line coincidence. Source-turn segments, member-template gap channels, and the convergence trunks and joins the planners own are immutable during these passes. Member-template gap slots were already materialized once before final convergence settlement, so the general gap pass only allocates channels that remain unowned. For a gap-allocated planned turn, that pass validates the planned column or row and recomputes its expected corner radius rather than choosing a new seat. A snapshot ratchet checks source turns after every relevant pass.

The final invariants check every source assignment, lane offset, route family, turn direction, turn axis, convergence landing and endpoint owner. The ordered route emission inventory names every production emitter and classifies system execution, settlement, compatibility records, validation and observation. It describes the post-emission pass chain as a whole rather than naming each pass.

Routing answers two different kinds of question. Keeping them separate avoids trying to recover authored intent from helper nodes that the resolver inserted.

QuestionSource
Which authored connector, endpoint group, fan, or merge is this?RouteTopology and RouteResolutionTrace
Which port or junction represents that group?RouteResolutionTrace
Which corridor is clear, or where did a route actually turn?The laid-out graph and routed polylines
Which exact non-convergence template and gap channel will production emit?RouteMemberGeometryPlan
Which feeder owns a convergence trunk and where do its siblings join it?The immutable convergence plan

RouteTopologyQuery is the read-only bridge between the first two rows, built once for each routing or offset context. Its results follow authored topology order, and its reverse edge lookup returns every owning connector. That reverse lookup is deliberately one-to-many, because exact duplicate connectors and shared resolver legs can occupy the same final edge.

The query selects the semantic candidates for fan and merge handling. The convergence planner first constructs its own canonical candidates. The member-geometry planner then combines semantic identity with settled coordinates and calls the canonical family once. Final convergence settlement consumes the frozen member channels as external obstacles. _classify_merge_edges supplies geometric measurements and the structural longest-bypass candidate, and the plan freezes the selected trunk, axis, feeder order, join points, continuation and endpoint ownership. Templates and post-passes consume that record rather than selecting another trunk or landing.

Several broad edge scans therefore remain by design:

  • compute_bundle_info groups final edges that share a routing corridor. An authored BundleRun only means that connectors share exact authored endpoints.
  • Bypass-gap and fan-corridor passes inspect section positions and obstructions.
  • Merge trunk and branch selection compares final spans and bypass channels.
  • Exit-port alignment follows immediate resolved successors, because a branch feeding a merge can have a different geometric anchor from its authored destination.
  • Normalization groups settled unowned vertical channels, trunks and route endpoints, while plan-owned convergence geometry is validation-only.
  • Runtime invariants inspect the geometry that will actually be drawn.

Graphs parsed from Mermaid always carry both topology records. Hand-built MetroGraph objects may carry neither, and classify their explicit junctions from final graph structure. Supplying only one record is invalid, because it would mix authored identity with an incomplete resolver mapping.

A pass asking whether it may move a coordinate reads one of two predicates in common.py, and neither contains the other:

  • planner_owns_segment(route, rank) asks whether a plan states that segment. A member plan’s arms and an exit turn’s arms match the rank exactly. Only the convergence arm reaches a rank either side, because a trunk axis states a run whose two corners it fixes as well.
  • route_system_owns_segment_boundary(route, rank) asks whether a convergence or member plan owns a corner at either end of the segment, which is what a pass translating the segment re-forms.

A pass that moves a whole segment reads both, and the guards that close on the result read them the same way. check_no_fused_cotravelling_lines attributes a lane to exactly the plan kinds planner_owns_segment names. A caller that wants the widening on one side only says which side, as _corridor_run_band does for the leg whose length a planned turn’s runway fixes.

When several lines travel between the same pair of endpoints they form a bundle. Per-line offsets, computed by compute_station_offsets and applied through _RoutingCtx.station_offsets, fan the bundle out into parallel tracks so individual lines stay visually distinct. Handedness-aware offset propagation at each corner preserves bundle ordering across multi-corner paths, and the corner-radius helpers live in routing/corners.py. The runtime guard check_bundle_order_preserved, in routing/invariants.py, catches any regression where a line crosses over its bundle-mates.

Immediately before emission, the exit-turn planner removes slots for lines that do not leave a supported exit group. It owns the exit port, any divergence and compatible continuation stations across the source seam. A mismatched incoming feeder keeps its original lane and receives an explicit transition into the compacted seam. Every transition must preserve pairwise lane order at both ends. If one cannot, the child plan declines and another complete geometry owner has to cover the route system.

The debug overlay below makes the offset geometry visible: each line’s parallel track and the station markers it must pass through.

Rendered map
Fan-out Pipeline 1 2 3 4 5 FastQC BWA-MEM VEP BWA-MEM Minimap2 Trimming GATK HaplotypeCaller Report GATK Mutect2 FreeBayes preprocessing__exit_right_0 (right) wgs_analysis__exit_right_1 (right) wes_analysis__exit_right_2 (right) panel_analysis__exit_right_3 (right) wgs_analysis__entry_left_4 (left) wes_analysis__entry_left_5 (left) panel_analysis__entry_left_6 (left) annotation__entry_left_7 (left) __junction_8 (?) col 0|1 col 1|2 row 0|1 row 1|2 row 0 grid row 0 grid row 1 grid row 2 grid row 0 grid Whole Genome Whole Exome Targeted Panel created with nf-metro v2.0.0+dev

The gap between two stacked grid rows holds horizontal channels. A channel placed there normally derives its band from the two rows’ bbox edges, keeping INTER_ROW_EDGE_CLEARANCE below the box above and INTER_ROW_HEADER_CLEARANCE above the next row’s header badge, in _center_inter_row_channel. Those edges are only a proxy for the real obstruction: a section spanning the boundary, or one whose box sits outside the run’s own reach, moves an edge without bounding the corridor.

Where the corridor carries a RouteReservation, that proxy is not used. _RoutingCtx.reserved_bands maps a grid-row boundary to the clear span the reservation realises, measured against the blockers over the corridor’s own declared span, and the channel is placed inside that. The lookup exists only on a re-route driven by an existing ledger, namely envelope settlement’s, which has just widened these boundaries, so the pass that publishes the ledger is unaffected. See the router contract in route_plan.

A band says how much room a corridor is left, not which lane inside it the corridor takes. Every claim crossing one boundary realises the same band, so several independently placed corridors can each be put in it without any of them seeing the others. Two can then settle less than one OFFSET_STEP apart, close enough that two distinct lines paint a single two-tone stripe and one of them cannot be read at all. _separate_fused_cotravelling_runs is the closing pass that sees every corridor at once and restores the step. The unit it moves is a track, meaning every run of one line drawn on one lane through one corridor, so re-seating cannot split a fused fan-out into two parallel same-colour runs. check_no_fused_cotravelling_lines is its postcondition on the render chokepoint. Plans seat distinct co-travelling trunks against frozen member corridors before ownership begins. The final check includes immutable tracks and attributes a violation to its route system and exact owning plans.

validate_exit_turn_plans runs before the generic render guards. It checks every planned lane, transition, family, turn axis and exactly-once assignment against the emitted routes, and attributes a failure to the route system and authored connectors.

validate_route_system_emission runs after normalization and before the planner-specific validators. It checks the attribution carried by each final path against the canonical execution record. A failure names the route system, authored connectors, emission member, plan IDs and reservation IDs, so a post-pass cannot silently detach geometry from its owner.

assert_render_curve_invariants, in routing/invariants.py, runs a set of correctness checks on the final route_edges output on every render, against the exact geometry the renderer is about to draw. A defective route therefore aborts the render with a message naming the offending edge rather than being shipped. It is always on, independent of compute_layout’s validate flag.

Among these are the endpoint guards, which assert that a routed segment terminates at a real anchor rather than hanging in open space:

  • check_merge_branches_meet_trunk requires a merge feeder to land on its trunk’s channel. It applies to merge junctions only.
  • check_no_hanging_routes is the general backstop. Every route’s two endpoints must each lie within 2 * CURVE_RADIUS of a station, port or junction marker, or of another route it joins, such as a bundle mate, a branch onto a trunk or a peel-off. Rail-mode endpoints are skipped, because a rail stub terminates on its rail. This generalises the merge-only check to any route family, and the family-specific checks remain as sharper diagnostics.

Both checks allow 2 * CURVE_RADIUS of slack, because they look for paths that end in open space. A smaller gap can still draw a visible stub. check_merge_feeders_land_on_trunk therefore applies the tighter COORD_TOLERANCE limit in Tier C. Planned feeders consume their exact join during emission, and the corpus oracle holds every emitted path to the same final endpoint constraint.

ModuleResponsibility
core.pycanonical route-system loop; local-edge handlers; ordered post-pass and validation pipeline; public re-exports of sibling handlers
context.py_RoutingCtx dataclass and _build_routing_context; per-station offset helpers; shared section-geometry helpers (_resolve_section_col, _has_intervening_sections, compute_junction_fan_info, …)
system_emission.pyatomic system disposition, canonical members, frozen planned families, plan/reservation attribution, and the final attribution validator
member_geometry.pycanonical-order member template construction, one-time gap-slot materialization, immutable gap-channel publication, exact planned production copies, and template/emission validation
inter_section_handlers.pyhandler 1 family: bypass, left/right entry wraps, around-section, inter-row corridors, stepped descent, L-shape
tb_handlers.pyTB section shapes dispatched by _route_tb_section over _TB_SECTION_SHAPES (_route_tb_internal, _route_tb_lr_exit, _route_tb_lr_entry, _route_perp_entry, _route_perp_entry_from_corridor) and _compute_diagonal_placement
intra_handlers.py_route_entry_runway and _route_intra_section (the general intra-section fallback)
bundle.pyconstructive bundle-curve builders (build_concentric_bundle, build_tapered_bundle, build_offset_bundle); fans a centreline into per-line offset paths with concentric corners
centrelines.pycentreline templates and bundle-gathering helpers (gather_member_edges, route_along, route_tapered, …) layered over bundle.py
exit_turns.pycomplete pre-routing exit-group plans, active-lane compaction, handler consumption, and planner-owned geometry invariants
postprocess.pypost-routing passes: diagonal bundle spread and bubble-station centring
normalize.pychannel and trunk normalization passes (_materialize_gap_slots, htrunk restacking, riser/port-approach alignment, …)
common.pyRoutedPath, Direction, bundle/channel helpers
corners.pycorner radii and curve smoothing
offsets.pyper-station Y offsets for parallel lines
reversal.pyfold/reversal (serpentine row) routing
invariants.pyruntime routing guards (check_bundle_order_preserved)
rail.pyroute_rail_edges straight-rail router for rail mode
reserved_bands.pyrealised row- and column-gap corridor bands read off a RouteReservation ledger (build_reserved_corridors, ReservedCorridors)