Skip to content

Routing

Routing turns the laid-out MetroGraph (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 (horizontal + vertical) routing.

Before the normal dispatch, route_edges checks the graph’s line_spread (a LineSpread of BUNDLE / CENTERED / 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; 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: dark
%%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 v1.1.0+dev

route_edges first builds a _RoutingCtx (a dataclass of shared pre-computed state: merge-junction classification, fold X, bundle info, per-station offsets, fork stations, etc.), then routes each edge by trying a fixed sequence of handler functions in priority order. The first handler that returns a RoutedPath wins; handlers that do not apply return None.

The order in route_edges is:

  1. _route_inter_section - edges crossing a section boundary (port/junction to port/junction). Dispatches internally to a large family of sub-handlers (L-shape, top-entry L-shape, left/right-entry wraps, TB bottom-exit, merge trunk/branch, bypass, stepped descent, inter-row corridors, around-section-below).
  2. _route_tb_section - edges touching a TB section. Dispatches over the ordered _TB_SECTION_SHAPES tuple (first match wins): internal vertical drops (_route_tb_internal), internal station to a LEFT/RIGHT exit port (_route_tb_lr_exit), LEFT/RIGHT entry port to an internal station (_route_tb_lr_entry), and TOP/BOTTOM port to an internal station (_route_perp_entry). Each shape describes a centreline and fans it through the bundle builder (build_tapered_bundle / 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 - flow-side entry port to a deep internal station: compresses the diagonal into the entry region and runs a horizontal runway past the bypassed early-layer stations.
  4. _route_intra_section - the general intra-section case: diagonals, cross-row fold routing, and straight lines. This is also the fallback for port/junction-to-port/junction edges that the inter- section family did not claim.

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: dark
%%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 v1.1.0+dev

After all edges are routed, route_edges runs a series of post-passes that adjust the assembled polylines as a set (for example _spread_diagonal_bundles, _materialize_gap_slots, _materialize_trunk_slots, _coincide_same_line_tracks).

When several lines travel between the same pair of endpoints they form a bundle. Per-line offsets (computed by compute_station_offsets, applied through _RoutingCtx.station_offsets) fan the bundle out into parallel tracks so individual lines stay visually distinct. Bundle ordering is preserved across multi-corner paths by handedness-aware offset propagation at each corner (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.

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 v1.1.0+dev

assert_render_curve_invariants (in routing/invariants.py) runs a set of correctness checks on the final route_edges output every render — the exact geometry the renderer is about to draw — so a defective route 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 — a merge feeder must land on its trunk’s channel (merge junctions only).
  • check_no_hanging_routes — the general backstop: every route’s two endpoints must each lie within 2 * CURVE_RADIUS of a station/port/junction marker or of another route it joins (a bundle mate, a branch onto a trunk, a peel-off). Rail-mode endpoints are skipped (a rail stub terminates on its rail). This generalises the merge-only check to any route family; the family-specific checks remain as sharper diagnostics.

routing/inter_section.py defines WRAP_TABLE, a declarative mirror of the inter-section if-cascade. It is a documentation reference only: nothing in routing consumes it at runtime. Each entry is keyed by

(exit_side, entry_side, drow_sign, dcol_sign)

where exit_side is None when the source is a junction without a port side, and drow_sign / dcol_sign are sign() values in {-1, 0, +1} describing the section-grid step. Each value is a WrapDescriptor recording the RouteKind (e.g. L_SHAPE, TOP_ENTRY_L_SHAPE, LEFT_ENTRY_WRAP, RIGHT_ENTRY_WRAP, TB_BOTTOM_EXIT), a ChannelKind, and a TurnSequence - the ordered list of Corners (each an (incoming, outgoing) Direction pair).

TurnSequence.parity counts how many times the corner handedness (CW / CCW) flips along the path; it is the contract pinned by tests/test_inter_section_descriptor.py, which also sanity-checks that every key is well-formed and that the table stays non-empty. Same-row L-shapes and the straight TB (BOTTOM, TOP, 1, 0) drop are intentionally absent because the dispatcher handles those degenerate cases directly.

ModuleResponsibility
core.pyroute_edges dispatcher; re-exports handlers from sibling modules for backward-compatible imports
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, …)
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
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
inter_section.pyWRAP_TABLE descriptor catalogue (documentation reference; not used at runtime)
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