| name | plan-pcb-routing |
| description | Analyzes a KiCad PCB file and creates a comprehensive routing plan. Examines components for fanout needs (BGA/QFN/QFP/PGA), identifies differential pairs, categorizes power/ground nets, and presents a step-by-step routing workflow with explanations. |
Plan PCB Routing
When this skill is invoked with a KiCad PCB file, perform a comprehensive analysis and present a routing plan to the user.
Step 1: Load and Analyze PCB Structure
from kicad_parser import parse_kicad_pcb
pcb = parse_kicad_pcb('path/to/file.kicad_pcb')
# Basic stats
print(f'Total nets: {len(pcb.nets)}')
print(f'Total footprints: {len(pcb.footprints)}')
print(f'Existing segments: {len(pcb.segments)}')
print(f'Existing vias: {len(pcb.vias)}')
Report to user:
- Number of nets, components, existing routing
- Whether this is a fresh board or partially routed
Step 2: Identify Copper Layers
Check the KiCad file directly for layer definitions:
grep -E "^\s+\([0-9]+ \".*\.Cu\"" path/to/file.kicad_pcb
Report to user:
- Available copper layers (F.Cu, B.Cu, In1.Cu, In2.Cu, etc.)
- Whether it's a 2-layer, 4-layer, or multi-layer board
Stackup Check (always run this early)
Inspect the stackup now, before planning, and report the verdict at the top of the
plan report so problems surface before any routing work:
from kicad_parser import parse_kicad_pcb
pcb = parse_kicad_pcb('path/to/file.kicad_pcb')
for layer in pcb.board_info.stackup: # List[StackupLayer], ordered top to bottom
print(layer.name, layer.layer_type, layer.thickness, layer.epsilon_r)
- No stackup section, or all dielectrics with identical thickness and ε_r ≈ 4.5, means
KiCad's untouched default. If the board also has impedance-relevant signals (see the
speed detection in Step 4), lead the report with a clear warning: impedance and
time-matching calculations will not match the user's fab, and
/recommend-stackup
should be run before impedance-controlled routing. Take plane-layer assignments from
its output when available.
- A 2-layer board with multiple differential pairs or planes-worth of power nets is
itself worth flagging (no inner layers for reference planes).
- If the stackup looks deliberate, say so in one line and move on.
Report problems prominently but still produce the full plan - the user decides whether
to fix the stackup first.
Step 3: Check for Components Needing Fanout
Identify BGA, QFN, QFP, PGA, LGA, and other array packages that benefit from escape routing:
for ref, fp in pcb.footprints.items():
name_upper = fp.footprint_name.upper()
pad_count = len(fp.pads)
# Check for array / fine-pitch land/no-lead packages by name. Note 'QFP'
# already matches LQFP/TQFP/VQFP, 'QFN' matches VQFN/WQFN/HVQFN, and 'BGA'
# matches FBGA/UFBGA/TFBGA, so only distinct families need listing.
needs_fanout = any(k in name_upper for k in (
'BGA', # ball grid array
'PGA', # pin grid array (through-hole)
'LGA', # land grid array (interior lands, e.g. LGA-12) - issue #144
'CSP', 'WLCSP', 'WLP', # wafer-level / chip-scale = micro-BGA, sub-0.5mm
'CGA', # column grid array
'QFN', 'DFN', # quad / dual no-lead (exposed-pad)
'QFP', # quad flat pack
))
# SMD vs through-hole FIRST -- it gates everything below (#513 item 16).
smd_count = sum(1 for p in fp.pads if p.drill == 0)
th_count = sum(1 for p in fp.pads if p.drill > 0)
mostly_tht = th_count > smd_count
# A THT part's pins are reachable on EVERY copper layer -- there is no
# "escape" problem to solve, so fanout buys nothing regardless of pad
# count. PLCC/DIP/ZIF SOCKETS are the trap: a PLCC-44 THT socket's
# staggered double-ring reads as a sparse uniform grid and used to be
# misdetected as a BGA (rc2014_82c55_ide U1 -- nets near it burned >1M
# A* iterations each behind a phantom exclusion zone, #513 item 16).
# Wide-pitch (>=2mm) PGAs route fine without fanout too; only reach for
# bga_fanout on a PGA when its channels are genuinely contested.
if mostly_tht and 'PGA' not in name_upper:
needs_fanout = False
# Fine-pitch arrays strand even at low pad count: trigger by PITCH + interior
# pads, not just pad_count > 40 (issue #144: LGA-12 at 0.5mm has only 12 pads
# but its center lands box in). Compute the min pad-to-pad spacing and whether
# any pad is interior (not on the bounding-box edge).
if not needs_fanout and not mostly_tht and pad_count >= 6:
xs = sorted({round(p.local_x, 3) for p in fp.pads})
ys = sorted({round(p.local_y, 3) for p in fp.pads})
def _min_step(v):
return min((b - a for a, b in zip(v, v[1:])), default=999)
pitch = min(_min_step(xs), _min_step(ys))
minx, maxx, miny, maxy = xs[0], xs[-1], ys[0], ys[-1]
has_interior = any(minx < round(p.local_x, 3) < maxx and
miny < round(p.local_y, 3) < maxy for p in fp.pads)
# Fine pitch (<=0.6mm) with interior pads, OR a large multi-row part
# AT FINE PITCH. Raw pad_count > 40 alone is NOT a fanout signal: a
# 44-pin THT socket, a 2x20 header, or a 1.27mm connector trips it
# while gaining nothing from escape routing.
if (pitch <= 0.6 and has_interior) or (pad_count > 40 and pitch <= 0.8):
needs_fanout = True
if needs_fanout:
# Analyze pad arrangement
xs = sorted(set(round(p.local_x, 2) for p in fp.pads))
ys = sorted(set(round(p.local_y, 2) for p in fp.pads))
grid_cols, grid_rows = len(xs), len(ys)
Does this part actually BENEFIT from fanout? (check before planning it)
A name/pad-count match is a candidate, not a decision. Fanout (escape routing)
exists to solve ONE problem: pads that cannot be reached by ordinary routing
because neighboring pads at fine pitch box them in. Before adding a fanout
step, confirm the geometry actually has that problem:
- Through-hole part (most pads drilled)? → No fanout. Every pin is
reachable on every layer; there is nothing to escape. This includes
PLCC/DIP/ZIF sockets (a PLCC-44 THT socket's staggered pin field looks
like a sparse grid but is just a socket, #513 item 16), headers, and DIN /
backplane connectors. Wide-pitch (>=2mm) PGAs also normally route fine
without fanout.
- Wide-pitch SMD (>=1.27mm) perimeter part? → No fanout; plain routing
handles it.
- Interior pads at fine pitch (<=0.6mm), or a perimeter at <=0.65mm with
many pads? → Yes, fanout genuinely helps (this is the boxed-in case).
Dense 2-row mezzanine/card-edge connectors at 0.4mm (CM4/CM5, 200+ pads)
DO benefit -- use
qfn_fanout.py --escape-method underpad --allow-via-in-pad.
- Unsure? The fanout tools now refuse or warn on wrong shapes
(staggered arrays, non-arrays). Trust a refusal: if the tool says the part
is not an array and the geometry checks above say the pins are reachable,
plan ordinary routing instead of forcing a workaround.
Fanout Tool Selection
| Package Type | Tool | Notes |
|---|
| BGA (SMD grid) | bga_fanout.py | Escape routing for ball grid arrays |
| PGA (through-hole grid) | bga_fanout.py | Same tool works for PGA |
| LGA / WLCSP / CGA (land/chip-scale grid) | bga_fanout.py | Grid escape; interior lands strand without it (issue #144) |
| QFN/QFP/DFN (perimeter SMD) | qfn_fanout.py | Stub routing for quad/dual no-lead and flat packages |
| AQFN / staggered multi-row no-lead | qfn_fanout.py --escape-method underpad --allow-via-in-pad | Inner rows the surface fan cannot reach - see below. Never bga_fanout.py |
| DIP/SOIC (through-hole/SMD rows) | None needed | Standard routing handles these |
| PLCC (SMD J-lead or THT socket) | None needed | Perimeter part; the THT socket's pins reach every layer. Never a BGA (#513 item 16) |
| Sockets / headers / backplane connectors (THT) | None needed | All-layer reachable; pad count alone is not a fanout signal |
When to Use Fanout for BGA/PGA/LGA
Rule: Use fanout for any grid array (BGA/PGA/LGA/WLCSP/CGA) with more than 2 pins
depth from outside to center, OR any fine-pitch (<=0.5mm) array with interior pads
regardless of pin count — a small LGA-12/WLCSP at 0.5mm pitch boxes its center
lands in even though it has well under 40 pads (issue #144).
Important: Calculate ACTUAL depth by counting pads from the edge toward center, not grid size.
Many PGA/BGA packages (especially FPGAs/CPLDs) have hollow centers with only perimeter pins populated.
To calculate actual depth:
# Check middle column from top edge toward center
mid_col = xs[len(xs)//2]
depth = 0
for y in ys: # ys sorted from edge
if (mid_col, y) in pad_positions:
depth += 1
else:
break # Stop at first empty position
Examples:
- 13×13 grid, fully populated → depth = 7 → USE FANOUT
- 13×13 grid, hollow center (3 rows populated) → depth = 3 → USE FANOUT
- 10×10 grid, hollow center (2 rows populated) → depth = 2 → fanout optional
- 4×4 grid, fully populated → depth = 2 → fanout optional
Inner pins beyond depth 2 cannot escape without fanout routing through channels between outer pins.
Escape layers (multi-layer boards): bga_fanout.py defaults to --layers F.Cu B.Cu
only. On a 4+ layer board, pass ALL the board's copper layers, e.g.
--layers F.Cu In1.Cu In2.Cu B.Cu — otherwise deep balls have nowhere to escape to
and those nets are dropped from the fanout. qfn_fanout.py is perimeter-only and
doesn't take escape layers.
Staggered multi-row no-lead packages (AQFN) - use via-in-pad (#500). An
AQFN (e.g. Nordic_AQFN-73-1EP_7x7mm_P0.5mm, on osprey_kb / hex_gateway /
mikoto_nrf52840) puts its pads in TWO OR MORE staggered rows per side. The
surface 45-degree stub fan reaches only the outermost row, so the default
silently drops the rest. Measured on osprey_kb U1 (78 pads, 39 nets):
| command | escaped | time |
|---|
qfn_fanout.py (default stub) | 26/40 | 2.4s |
qfn_fanout.py --escape-method underpad | 35/40 | 2.6s |
qfn_fanout.py --escape-method underpad --allow-via-in-pad | 39/39, DRC-clean | 2.4s |
bga_fanout.py | 39/39 | 2967s |
So: for any AQFN or staggered multi-row no-lead part, plan
qfn_fanout.py --escape-method underpad --allow-via-in-pad. Via-in-pad is
what reaches the innermost row; without it 5 pads drop.
Do NOT send these to bga_fanout.py. It models a ball grid, and a staggered
package's two offset rows project onto each axis at HALF the real pad spacing -
so its detected pitch is half the truth, its escape budget evaluates to a
NEGATIVE via size, and it grinds for ~50 minutes to reach the same answer.
bga_fanout.py now refuses these outright with the qfn_fanout command to use
(override: KICAD_ALLOW_STAGGERED_BGA=1).
Spotting one: the footprint name contains AQFN, or the part has far more pads
than a single peripheral ring of its size would hold (73-90 pads on a 7x7mm
body), or bga_fanout.py reports a pitch that is half the name's P<pitch>mm.
Crowded fine-pitch QFN edge (surface fan has no room): if a qfn_fanout
stub (especially a diff pair) is boxed in by a neighbour pair and a foreign
track and the surface 45° fan drops it, use qfn_fanout.py --escape-method underpad --via-size 0.45 --via-drill 0.25 (#164). It drops a through-via just
past each pad and escapes on an inner/back layer — straight out past the lateral
congestion instead of fanning into it (adjacent vias are staggered to clear).
Match --via-size/--via-drill to the board's fine-pitch via rule. If the
underpad run still drops a leg ("N dropped") because the via has no clear
room outward (a neighbour pad/track exactly one pitch away), add
--allow-via-in-pad (#161): the escape via may then sit on its own pad and
stagger inward toward the chip, away from the neighbour, instead of being
dropped. It still clears every other-net pad/via/track — it only gains
permission to overlap its own pad — so reach for it specifically when underpad
reports drops on a boxed-in fine-pitch pair.
Size the escape via/track to the pitch BEFORE running fanout (issue #158).
bga_fanout.py escapes one track down the channel between adjacent via columns —
at the half-pitch. So the via, track, and clearance must fit that half-pitch
or every escape grazes the neighbouring column's via by a few µm, and the fanout
still reports failed: 0 (its success metric ignores sub-clearance grazes). The
budget, per array (measure each component's own pitch — they differ):
via_size + track_width + 2·clearance + margin ≤ pitch (one escape track per channel)
via_size ≥ via_drill + 2·min_annular_ring, track_width ≥ fab min track (fab floors)
Don't just shrink the via against a fixed track — solve for via AND track
together, taking each down toward the fab floor as the pitch demands, and leave
a little margin so the result clears DRC instead of merely touching it. Read each
array's own ball pitch P (the min ball spacing — arrays on one board differ) and
the requested clearance C (Default net-class clearance from
list_nets.py --design-rules), plus the board's fab floors (min_track_width,
min_via_diameter/min_via_drill, annular ring), then:
margin = 0.05 # slack: clear DRC, don't graze it
budget = P - 2*C - margin # room for one via + one track
track = max(min(nominal_track, 0.15), min_track_width) # keep a routable track
via = min(nominal_via, budget - track) # largest via that still fits
if via < via_floor: # via fell below the floor -> thin the track to free room
via = via_floor
track = max(min_track_width, budget - via)
infeasible = track < min_track_width or via < via_floor # even fab floors won't fit
via_drill = max(min_via_drill, via - 2*min_annular_ring) # hold the annular ring at floor
# via_floor = max(min_via_diameter, min_via_drill + 2*min_annular_ring)
Pass the computed --via-size via --via-drill via_drill --track-width track --clearance C to the fanout step. If infeasible, the pitch can't take a channel
escape even at the fab floor → for a POPULATED array prefer --escape-method dogbone (it never escapes fewer balls than underpad and matches the human idiom; this supersedes older underpad advice), else --escape-method underpad, and/or add
escape layers; don't ship the graze.
Plan params can set ANY GUI option: in the GUI's RESULT schema, each
step's params may include any option shown on that step's tab or the shared
options panel, keyed by its snake_case field name (max_iterations,
max_ripup, grid_step, board_edge_clearance, hole_to_hole_clearance,
via_cost, heuristic_weight, turn_cost, ordering_strategy, ...).
Unknown names are ignored with a note in the plan log. Use this to carry the
same values the equivalent CLI chain would pass (e.g. --max-ripup 5 --grid-step 0.05), so a GUI plan run matches a stress run step for step.
(Leave max_iterations at its default — the engine self-budgets, #529.)
Why this heuristic matters for the GUI: the plugin runs /plan-pcb-routing in
plan-only mode — it never executes the fanout and never runs the DRC↔smaller-via
retry loop, so it cannot discover a too-big via after the fact and shrink it. The
plan must therefore carry via/track that are already DRC-safe for the pitch.
Computing them here — both dimensions, with margin, clamped to the fab floor — is
what lets the single fanout the GUI runs come out clean the first time.
Worked example (a 256-ball 0.8 mm-pitch BGA, clearance 0.1, fab floor track 0.1 / via 0.45):
budget = 0.8 − 0.2 − 0.05 = 0.55; track 0.127 → via = min(working, 0.55−0.127) =
0.42 (≥ floor) → DRC-clean, vs the Ø0.5 the net-class default would have used
(163 grazes). At 0.4 mm pitch the budget forces both to the floor (track 0.10, via
~0.30/0.20 advanced); if even those don't fit, go --escape-method dogbone
(populated array; underpad only when no inter-ball gap site exists at all).
bga_fanout.py also warns WARNING: escape via ... busts the half-pitch budget
when handed infeasible params, but choose feasible ones here so it never fires.
Always check the fanout escaped all requested balls. bga_fanout.py ends with
JSON_SUMMARY: {"component", "requested", "escaped", "failed", "unescaped_nets", ...}.
A dropped ball is removed from the output and later fails signal routing as "no
rippable blockers", so it must be caught here. If failed > 0, retry the fanout with
more layers and/or a smaller --clearance (see "Escape clearance" below) before
moving on — do not start signal routing while balls are still dropped.
If balls still drop on a dense, fully-populated array, switch to the dog-bone
escape: add --escape-method dogbone with a small via/track for the pitch
(e.g. --via-size 0.35 --track-width 0.12 --clearance 0.1 at 0.8 mm pitch). The
channel engine confines every layer to the gaps between ball rows, so a few
channels over-subscribe and the deepest balls can't escape; dogbone stubs each
ball to a via in the diagonal inter-ball gap (falling back per-ball to
via-in-pad), so it never escapes fewer balls than underpad at roughly half the
via-in-pad / IPC-4761 fab burden (#669: orangecrab U3 108/108 vs underpad's
104/108, with the 3 stranded balls unrecoverable by ANY later routing).
underpad (every via in its pad) is for arrays with no legal inter-ball gap
at all — WLCSP-class pitches where even a floor via busts the half-pitch lane
budget. Caveats (both grid escapes): diff pairs route single-ended, and
power/plane nets are skipped as escapes — but every skipped plane ball still
gets a plane-drop via (below), so nothing is left stranded. auto (the
default) retries channel's drops with underpad only — a dogbone-first retry
was measured and REJECTED as the default (#669 sets1-5 corpus A/B: +10
incomplete nets, +59 kicad DRC — dogbone gap vias claim inter-ball streets
that chains not authored for dogbone then collide with). So on a populated
array, dogbone must be passed EXPLICITLY, with via/track/clearance chosen
for it — which is exactly what this plan does.
How humans escape big BGAs — and which of OUR tool options that maps to
(survey of 54 human corpus boards with a real ≥100-ball array; the fanout
places vias itself, so this is about choosing its options, not via positions):
- Dog-bone is the dominant human method at every pitch (median 30–43% of
balls; via-in-pad is ~0% on most boards, appearing only on a handful of very
dense 6/8-layer designs). Roughly HALF of all balls get no via at all — the
outer rings escape on the surface, rails connect into pours. Mapping: for a
populated array prefer
--escape-method dogbone — each ball vias in a
free inter-ball gap and falls back per-ball to via-in-pad, so it never
escapes fewer balls than underpad while keeping the inner-layer streets
open. channel (auto's first pass) already leaves the outer rings
via-free; keep it for sparse/perimeter-heavy arrays and diff pairs. Note
auto's retry for channel's drops is underpad, NOT dogbone (#669 measured
a dogbone-first retry worse as a default) — so a populated array gets
dogbone only by passing --escape-method dogbone explicitly, with params
chosen for it.
- Rail balls under a pour need NO via when the pour is on their own layer
— the plane-drop pass (#424) detects this automatically when the pours
already exist (the Step 1 pour runs before fanout): it prints
N pour-covered (no via needed) and skips those vias (measured: 104 of 127 GND balls on a 285-ball
BGA, ~100 via barrels kept out of the escape field;
KICAD_FANOUT_POUR_DIRECT=0 reverts). This is why Step 1 pours before fanout;
put rail pours on the layers that carry the rail balls (the outer layer for
a surface flood). This generalizes beyond BGAs: choose each outer-layer
flood net by same-layer SMD pad count — every SMD pad of the flood net on
that layer connects by fill contact with no via at all. list_nets.py --power prints per-net (F.Cu n SMD, B.Cu n SMD, TH n) for this choice;
ignore the TH counts (barrels connect on every layer regardless).
- Escape via, by pitch: at 0.8–1.0 mm the median minimum via in the
courtyard is 0.45/0.20; at ≤0.5 mm humans go to 0.28/0.15 and even
0.25/0.10. Escape-track minimum: median 0.125 mm at coarse pitch, 0.089–0.10
(the fab floor) at fine pitch. The computed budget-per-pitch above lands in
the same range — trust it, and treat 0.25/0.15 as the floor for ≤0.5 mm.
- Deep balls leave through the inners, not the surface. Inner-layer share
of courtyard copper: ~0–15% on 4-layer boards, 30–67% on 6/8-layer. Mapping:
give the fanout the FULL
--layers list, and keep the escape-depth inner
layers ROUTABLE — on a 6-layer board that means at most ONE solid inner
plane next to each outer (fine-pitch-BGA humans keep a median of ONE solid
plane total; pouring 2–3 solid inner planes on a 6-layer BGA board is the
classic self-inflicted failure — it leaves signals a 2-layer board).
Rails beyond GND go as SPLIT region pours or late route+pour, not extra
solid planes and not wide tracks.
Plane-net balls are dropped to vias automatically (#424). With any escape
method, after the signal escape each SMD ball on a plane net — a net excluded
from the fanout with ≥ 6 balls on the part, or an excluded net that already
owns a copper zone — gets a via immediately: a dog-bone via in a free
inter-ball gap, else a via-in-pad tap. The Step 1 pour — run BEFORE fanout and
before any routing — picks these vias up at fill while the pour is
still intact, which kills the tap-behind-the-ball-wall failure class (#360)
and, with the default-on plane-fragility field, keeps the plane whole through
signal routing (measured: pour-first + fragility served 63/70 balls by fill
alone; pour-last served 0/70 — every ball needed repair welds). Consequences
for the plan:
- Keep excluding plane nets from the FANOUT's
--nets — the exclusion is
exactly what marks them for drops. (The ROUTE step later includes them,
#562 — its pour-launch anchors and in-run finalize complete them.)
- Pour the planes in Step 1, BEFORE fanout (see the Routing Order Rationale),
so the drop pass sees real fill.
- The route step's plane finalize rarely needs to do more than verify under
a dropped BGA (its oracle exits at round 0 on a healthy board).
--plane-drop off disables the pass; KICAD_FANOUT_PLANE_DROP=0/1
overrides either way (the recorded-manifest A/B switch). The per-net drop
counts are in JSON_SUMMARY.plane_drop.
After every BGA/PGA fanout, run the decoupling-cap placement optimizer
(#130). A fanout drops vias near the ball field; where a foreign-net via
lands under a decoupling cap placed at a ball, the via copper overlaps the
cap pad → a real PAD-VIA DRC violation at the clearance floor. The fix is
placement, so run place_fanout_clearance.py on the fanned board to
nudge those caps clear (and pull each pad toward its nearest same-net ball so
a power/GND via dropped there later shares the via). See "Step 1b" below for
the command. It's cheap, only touches caps near a BGA, and is a no-op when
nothing collides — so run it ONCE after ALL fanouts are done, before signal
routing (see Step 1c for why once, not per-BGA).
Report to user:
- List of components that may need fanout
- Package type, pad count, and grid depth for each
- Recommended fanout tool
Step 4: Check for Differential Pairs and Power Nets
Use list_nets.py to detect differential pairs and power/ground nets:
python3 py_router/list_nets.py path/to/file.kicad_pcb --diff-pairs --power
Read the board's design rules and pass them to the CLI
The router does NOT read the board's design rules — it falls back to a generic
--clearance 0.25 / --track-width default, which is often WIDER than the
board's own rule and can box pads in so nets fail with "no rippable blockers".
Read the board's real rules and pass them explicitly:
python3 py_router/list_nets.py path/to/file.kicad_pcb --design-rules
KiCad has TWO tiers of rules, and DRC only enforces one of them — this matters
for fine-pitch boards (#111/#115):
- Net-class values (
clearance, track_width, via_diameter, via_drill):
these are the size new objects are drawn at. Of these, only clearance is
a DRC-enforced minimum. track_width and via_diameter/drill are not DRC
floors — they are just defaults, so a board can (and the human originals do) use
a smaller via/track than the net-class nominal and still pass DRC.
- Board Constraints (
min_clearance, min_track_width, min_via_diameter,
min_hole_to_hole, min_through_hole_diameter): these are the actual DRC
floors. --design-rules reads them from design_settings.rules and combines
them with the JLCPCB fab minimum (backstop when a Constraint is 0/unset — e.g.
min_clearance is frequently 0) into a single manufacturing floor.
Use the printed flags as-is:
-
Routing (route.py, qfn_fanout.py, bga_fanout.py, route_planes.py):
--clearance from the Default class, but --via-size/--via-drill
from the working floor, NOT the net-class via_diameter. Emitting the net-class
via everywhere is #115 — it's a max-like default, far too big for fine-pitch
escape (e.g. a 0.4 mm QFN/BGA needs the small working via the original used).
For --track-width, the net-class value is only a starting point and is not a
hard minimum: on dense/congested boards route ordinary signals at the fab
physical floor instead (thinner is both more complete and faster — see "Route
signals at the FAB floor by default" in Diagnose and Retry). Keep the net-class
width only for current-carrying nets (--power-nets).
Do NOT keep the net-class gap/width for impedance-controlled (diff-pair) nets —
the stock net class is usually wide (diff_pair_gap 0.25 / width 0.2 mm), and a
fat pair is a wider bundle that gets dropped on congested boards (measured:
a 4-layer FPGA corpus board routes all 13 of its pairs at --diff-pair-gap 0.1 but loses 2 at
0.25). Per /find-high-speed-nets, route those at the fab floor for gap and
clearance (~0.1 mm) while keeping --impedance for the width (the router
computes it from the stackup and clamps it to the floor). route_diff.py then
auto-updates the Default net class to those tight values (only-loosen, via
fix_kicad_drc_settings.py), so the .kicad_pro stops advertising the wide gap.
-
Diff-pair sizing default + shrink-to-succeed. Default route_diff.py to
--track-width 0.1 and --diff-pair-gap 0.1 (the fab floor) — a thin, tight
bundle routes on congested boards where a fat pair is dropped. If the interface
is impedance-controlled, ALSO pass --impedance <ohms>: the router derives the
width from the stackup and clamps it to the floor, so the target impedance is
maintained while the geometry stays as small as it can. When a pair fails
or falls back — route_diff.py's JSON_SUMMARY lists it in failed_diff_pairs
or , or DRC shows an intra-pair / via-via graze — re-run
the failing pairs with
(/ toward the fab via floor). A tighter track+gap fits a
narrower channel, and smaller vias fit a tight pad pitch (measured: lumenpnp
USB_D's two 0.5 mm vias collide by 0.1 mm at the connector pitch — a smaller via
clears it). Step , the fab floors, and keep
so the ohms target is held as the geometry shrinks.
Verification (DRC/connectivity) grades at the manufacturing floor, not the
inflated net-class clearance — that is the same rule the human original passes, so
it's the honest delta. The routing/plane/fanout steps now record the smallest
clearance any step actually used (route_planes, route.py's plane finalize, and the
single-ended multipoint taps auto-step the fine-pitch tap clearance DOWN toward the
fab floor as the geometry demands) into the output .kicad_pro DRC floor and into
JSON_SUMMARY (min_clearance_used). check_drc.py auto-grades at that
.kicad_pro clearance when -c is omitted, so a bare check_drc.py board.kicad_pcb
already grades at the true routed floor. Passing --clearance <floor> still works
to TIGHTEN the grade — it is a FLOOR, max(-c, classA, classB), not an
override, so a value at or below the board's netclasses changes nothing.
See Step 6.
check_drc.py -c is NOT route.py --clearance. On route.py the flag is a
ceiling over every class (--clearance caps each net at min(its class, --clearance)). On check_drc it is only the global fallback, and a netclass
override still wins — the tool prints Required clearance: 0.1600mm (local/netclass override; global 0.1500mm) and grades at 0.16 no matter what
-c says. Measured on one board: 7 violations at -c 0.16, the same 7 at
-c 0.15, the same 7 at -c 0.149. If you expected a looser -c to clear
class-driven violations, it will not; change the class, or use
--clearance-margin (default 0.05) to filter grid-quantisation noise — and when
you use it, quote the unfiltered count beside the filtered one.
Only fall back to tool defaults when neither net classes nor Constraints are found
(--design-rules then prints the JLCPCB fab floor for the board's layer count).
This will output:
- Differential pairs detected (P/N naming conventions)
- Ground nets with pad counts
- Power nets with pad counts
If differential pairs are found:
- List each P/N pair
- Note that
route_diff.py should be used for these
- Explain that diff pairs maintain consistent spacing and length matching
- If a pair's pads are on a BGA/PGA being fanned out, escape it with
bga_fanout.py too — pass --diff-pairs "<patterns>" --diff-pair-gap <gap>
so P and N escape the array together on one layer. Don't just exclude the
pair from fanout and hand it to route_diff.py: it can't launch from the
deep balls ("no valid position at any setback"). route_diff.py then
connects the escaped stubs — but on a 4+ layer board you must pass those
inner layers to route_diff.py via --layers too (it defaults to F.Cu
B.Cu, so an inner-layer escaped stub is otherwise unreachable and the pair is
silently dropped — issue #116). Pairs not on an array package don't need fanout.
Tip: Name-based detection misses pairs with unconventional names. For boards with
high-speed ICs (PHYs, SerDes, USB, FPGA transceivers), or when detection finds suspiciously
few pairs, run /identify-diff-pairs for datasheet-based detection by pin function and
per-interface gap/impedance recommendations.
Polarity-swap policy (#279). route_diff.py can resolve a P/N polarity mismatch by
swapping the target pads' net assignments — but a swap physically cross-connects one
device's P pin to the other's N pin, and is only harmless when an endpoint can compensate.
Swaps are denied by default; grant them per pair with --polarity-swap-nets <patterns>.
Before emitting the route_diff command, classify each pair's electrical endpoints (walk
through series AC caps/resistors to the real device):
- Allow pairs with an FPGA/CPLD generic-I/O endpoint (pin functions are reassigned in
gateware — look for paired
IO_LxxP/N-style pinfunctions on Xilinx/Lattice/Altera/Gowin
parts), and protocol-tolerant links (PCIe lanes, SerDes with polarity-invert, 1000BASE-T).
- Deny USB
D+/D-, MIPI, TMDS/HDMI/DP, CAN, RS-485/422, DDR CK/DQS, clock/analog
inputs to fixed-function parts, anything reaching a connector or unknown part, and any
pair whose nets carry an asymmetric attachment (e.g. a single-sided pull-up) — it stays
on its net and would land on the wrong physical wire. MCUs/SoCs do NOT count as
programmable (their diff functions are fixed silicon). When in doubt, deny — a
skipped pair beats a dead interface. /identify-diff-pairs reports a per-pair
polarity_swappable verdict from datasheet pin functions for the ambiguous cases.
Pass the resulting allowlist, e.g. --polarity-swap-nets '/fpga/IO_*' (use '*' only when
every pair classifies swappable). Applied swaps are listed in polarity_swapped_pairs —
when they happen, the schematic sync step below applies (see "Schematic Synchronization
After Swaps"). Pairs that wanted a swap but were denied are listed in
polarity_swap_denied_pairs — surface these to the user (they either routed via the
opposite-side flip or failed honestly and may need a manual pin swap in the schematic).
Far-apart terminal pads → single-ended follow-up (issue #121). A "diff pair"
sometimes has pads that aren't a coupled connection — e.g. a P and an N test point
several mm apart, or a logical pair daisy-chained through spread-out parts. If the
coupled chain can't be routed, route_diff.py peels those far-apart pads off the
chain (routing the genuinely-coupled terminals as a pair) and lists the affected
nets under single_ended_followup_nets in its JSON_SUMMARY (and a "route them
single-ended next" block on stdout). Those pads are not dropped — the Signal
Routing step (route.py --nets "*") connects them P→P / N→N along with
every other unrouted net, since they remain unrouted after the diff-pair step. So:
do not exclude the diff-pair nets from the signal-routing step's net selection —
that step is what finishes the peeled pads. If you scope the signal step to specific
nets instead of "*", add any single_ended_followup_nets to it explicitly.
Check for DDR/High-Speed Memory Signals
Look for DDR signal patterns in the net list that may need length matching:
- Data signals: DQ0-DQ63
- Strobes: DQS, DQM, DM
- Clocks: CLK, CK
If DDR signals detected:
- Note that
--length-match-group auto should be used
- DQ0-7 + DQS0 form byte lane 0, DQ8-15 + DQS1 form byte lane 1, etc.
Report to user:
- List of detected differential pairs (or "none found")
- Whether
route_diff.py is needed
- Whether DDR/length-matching is needed
High-Speed Signal Check (delegate to /find-high-speed-nets)
Whether the plan includes GND return vias - and the --gnd-via-distance to use -
is the /find-high-speed-nets skill's job: it classifies nets into speed tiers
(datasheet lookup, rise-time estimates) and maps tiers to recommended distances.
Follow that skill's methodology here (its quick net-name/footprint scan decides
whether the deeper datasheet pass is worth it) and put the recommended distance
into the plan's GND-via step. Remember its physical floor: never set
--gnd-via-distance below 3 x (via_size + clearance), ~2.5 mm for standard vias.
Report to user when presenting the plan:
- If high-speed nets found: "GND Return Vias: This board has [tier] signals ([examples]).
GND return vias are included in Step N with
--gnd-via-distance [X]mm. Let me know if
you'd like to skip this step."
- If no high-speed nets found: "GND Return Vias: The high-speed scan found
no nets that need them (only low-frequency I2C/UART/GPIO). The step is
included; it is cheap and harmless here. Want me to remove it?"
Say what the SCAN found, not that the vias are "optional" -- optional invites
dropping them on a board where the scan simply was not run, and a missing
return path is not visible in any DRC.
/find-high-speed-nets ALSO reports controlled-impedance nets (its Step 4.5):
RF/antenna feeds (radio/PA/LNA -> SMA/U.FL/chip-antenna = 50 ohm single-ended,
or 100 ohm if balanced), DDR SSTL, and the impedance-controlled diff interfaces.
Thread these into the plan:
- Differential impedance nets stay in the diff-pair step (Step 2) — just add
route_diff.py --impedance <ohms>.
- Single-ended impedance nets (RF 50, DDR SSTL 40) get a dedicated
route.py --impedance pass placed AFTER diff pairs and BEFORE the general
signal route (Step 2b below). They must then be excluded from the general
signal route ("*" "!RF" — the plane nets stay IN that route, #562) and
counted in the Step 5b ledger as
claimed by the impedance step — otherwise a later rip-up re-routes them at the
wrong width.
- Impedance width is computed from the stackup: if the board has only KiCad's
default stackup, lead the report with that warning and run
/recommend-stackup
first (an RF feed routed at a wrong width is electrically useless).
- For an RF/antenna feed also recommend (in words) a
User.2 keepout around the
antenna region and --keepout, and route it short/direct on an outer layer.
If no controlled-impedance nets are found, omit Step 2b.
Step 2b-i: Coplanar (CPW-over-ground) — decide this WITH the plane step (#486)
An impedance trace on an outer layer that will also carry a GND pour is not a
microstrip: the side ground pulls Z0 down hard, so hitting the target needs a
narrower trace (e.g. 0.277 mm instead of 0.376 mm for 50 Ω on 0.2 mm FR4).
Routing the microstrip width through a pour lands the trace well below target.
The router cannot detect this — the trace width comes from your declaration,
not from sensing copper. So this is your decision to make in the plan, and
it must be coordinated across two steps. (With the pour-first order, an
outer-layer GND pour normally comes from Step 1c — give THAT call the matching
--zone-clearance G; a pour-first pour makes the declaration safer, since the
copper the trace is sized against actually exists when it routes.)
Declare coplanar when ALL of these hold:
- The impedance net routes on an outer layer (
F.Cu / B.Cu). Inner layers
are stripline; the flag is ignored there.
- A
route_planes step in this plan pours GND on that same layer — or the
board already has an outer-layer GND pour that will survive.
- You can name the gap: it is the pour's zone clearance.
If you are not pouring on the signal's own layer, do NOT pass --coplanar-gap.
A coplanar declaration whose pour never arrives leaves the trace too narrow, i.e.
impedance too HIGH — the opposite error, equally wrong.
Coordination — one number, three places:
# choose ONE gap G (the pour's clearance; near the fab floor, e.g. 0.2)
# 1. route the impedance nets, declaring G
python3 py_router/route.py in.kicad_pcb s2b.kicad_pcb --nets "RF*" \
--impedance 50 --coplanar-gap 0.2 --clearance 0.2
# 2. pour GND on the SAME layer with a MATCHING zone clearance
python3 py_router/route_planes.py s2b.kicad_pcb s5.kicad_pcb \
--nets GND GND --plane-layers F.Cu B.Cu --zone-clearance 0.2
# 3. verify the declaration actually held
python3 py_tools/check_impedance.py s5.kicad_pcb --coplanar-gap 0.2 --nets "RF*"
--coplanar-nets "<patterns>" narrows the declaration to some nets in a call;
omit it and every net in that call is treated as coplanar. Since Step 2b is
already a dedicated impedance pass over exactly those nets, omitting it is
usually right.
route_diff.py takes --coplanar-gap but has no --coplanar-nets (the
diff engine bakes one width per layer). Split interfaces into separate calls.
- The gap must be achievable: it is a pour clearance, so it cannot be below
the fab floor, and near via antipads / pads the real gap will be wider. The
Step-3 audit reports how much of each net actually achieved it.
Report to the user which nets you declared coplanar, the gap, and the plane
step it is tied to — this is a coupled choice they may want to override. If the
board has no outer-layer pour planned, say so explicitly and note that the
impedance nets are being routed as plain microstrip.
Step 5: Review Power and Ground Net Strategy (delegate to /recommend-plane-mappings)
Which nets deserve planes and on which copper layers is the
/recommend-plane-mappings skill's job: it weighs pad counts and datasheet
current estimates, and assigns layers with SI rationale (GND adjacent to signal
layers for return paths, power planes paired against GND, split layers for
multiple rails). Follow its methodology here, seeded by the list_nets.py --power
output, and put the resulting net -> layer assignments into the plan's
route_planes steps. Nets it leaves to wide traces become --power-nets /
--power-nets-widths on the route step instead.
Report to user:
- Identified GND nets and pad counts
- Identified power nets and pad counts
- Recommended strategy (plane vs wide traces) with layer assignments
Step 5a-tuned: Plane-map derivation rules (measured-optimal; refine the delegate's output with these)
THE DENSITY GATE COMES FIRST — plane-map aggression must scale with the
board (15-board wave + controlled A/B, 2026-08-17). The aggressive map
below (outer floods, rail co-pours, many-rail splits) is what wins on dense
BGA boards (orangecrab 18 KiCad-unconnected, the best from-scratch result of
five arms; daisho 8-layer: 1 open, 0 new DRC). The SAME map applied to
small boards was the wave's dominant failure source AND its dominant time
sink: outer floods got carved into pad-anchored islands and hairline
(<60 µm) gaps, and every route pass re-oracled the big outer fills.
Controlled A/B on the four regressed boards, changing ONLY the plane map
(floods+fragility=0 → inner-only) with every other step identical:
| board | aggressive map | inner-only map |
|---|
| a 4-layer board | 7 open, 60s | 0 open, 41s |
| upduino | 5 open + 2 DRC, 286s | 0 open, 1 DRC, 61s |
| eis | 3 open, 504s | 0 open, 2 DRC, 115s |
| a small 2-layer board | 1 open, 428s | 0 open, 0 DRC, 88s |
And the reverse control on the dense board cuts the other way just as
hard — the same skill chain with the conservative (recorded-style) map
on orangecrab: 26 open at 7687 s vs 18 open at 2028 s with the
aggressive map (plus ~2000 self-crossing weld-debris warnings on the
conservative arm). The aggressive map on a dense board is BOTH more
complete and ~4× faster; the conservative map on a small board is both
more complete and 3–5× faster. Neither map is "the safe one" — the GATE
is the safety.
Compute the tier, don't vibe it. DENSE = the board has 6+ copper
layers AND (a populated fine-pitch grid array of ≥100 balls at ≤0.8 mm
pitch, or >150 nets). Everything else is STANDARD. Layer count is the
load-bearing half of the gate: on ≤4 layers the outer layers ARE the
routing surface, so floods there lose even next to a big BGA (measured:
eis, 4-layer with a fully-populated BGA-121 @0.8 mm, went 3 opens/504 s
with the aggressive map → 0 opens/115 s inner-only; orangecrab and daisho,
6/8-layer, are where the aggressive map wins).
STANDARD boards (the measured-optimal default — most boards):
- Inner-only pours: GND solid on the first inner layer (price 6.0);
the ONE dominant rail (most pads) solid or split on the second inner
(price 2.5). NO outer-layer floods — on a small board the outer
layers ARE the routing surface, and a flood there becomes island debt,
sub-60 µm gap debt, and board-edge DRC (all three measured).
- Every other rail rides
--power-nets as a wide trace. Do not
Voronoi many rails onto one layer: never more than 2–3 rails share a
split layer (measured: six rails Voronoi'd onto one 4-layer board's In2
fragmented +3V3 into 8 pad-anchored islands → 7 opens; the 2-rail map
→ 0).
- 2-layer boards: GND flood(s) per Step 8's 2-layer flow (pour LAST on
dense 2-layer); rails as traces.
- No per-zone fragility overrides — the default fragility field protects
inner pours correctly.
DENSE boards (the aggressive map — every rule keys on a MEASURED board
property; derive, don't copy):
- Outer-layer GND floods (pour-direct service). Count GND SMD pads
per outer layer. An outer layer with a substantial GND SMD population
(≳20 pads, or a fine-pitch BGA's GND balls on it) gets a GND flood:
pads and balls are then served by FILL CONTACT with zero vias
(measured: 124 balls pour-served, the first 100% fanout escapes).
Floods must be carve-free — per-zone fragility
GND@<layer>=0 — or
later routing fragments them into weld debt. (This knob is DENSE-only:
it is exactly what turned small-board floods into island debt.)
- One solid inner GND plane, adjacent to the highway. The unsplit
reference layer. Price it 6.0 in
--layer-costs.
- Bus-highway layer: derive it, keep it EMPTY and FREE. Find the
board's widest bus (largest same-endpoint-footprint-pair net group, or
the dominant netclass family — DDR data/address, ≥8 nets). The highway
is the inner layer adjacent to the GND reference spanning the bus's
endpoints. NO pours on it, cost 1.0 — pricing or pouring the highway
cost completions every time it was measured.
- A rail whose pads live overwhelmingly on one outer layer shares
that layer's flood. ≥~80% of the rail's pads SMD on outer layer L
(termination arrays, e.g. VTT on B.Cu) → co-pour on L by Voronoi/
grammar partition; the pads connect by fill contact and the rail needs
no inner-layer real estate at all.
- Remaining rails: split across the remaining inner layers, grouped
by pad geography — capped at 2–3 rails per layer. Cluster rails
geographically (the grammar-pour clustering) and assign clusters per
layer so each partition stays compact (#662 shape targets: sheet
compactness ≥0.6, islands ≥0.5). Price rail layers 2.5. Rails that
don't fit under the cap (or whose region would be a sliver) ride
--power-nets as wide traces instead — a fragmented pour is worse
than no pour.
- Carry the SAME
--layer-costs vector into route_diff — the
diff step is otherwise plane-blind and its pairs squat the priced
layers.
Step 5a-tuned-ii: Escape completeness sweep (all interior-pad parts)
COMPLETE FANOUT IS THE INVARIANT — never trade an escaped ball for a
routing score. Beyond the big BGAs, enumerate EVERY component with
interior pads the surface router cannot reach (WLCSP/CSP at ≤0.5mm pitch,
staggered no-lead arrays — the issue-#144 class) and give each its own
fanout step (underpad + via-in-pad at the pitch-derived fab-floor via)
BEFORE the route step. On the benchmark, three 0.4mm WLCSP regulators
nobody had ever fanned were part of the winning plan. A board-wide sweep:
for every footprint, compute min pad pitch and whether any pad is
enclosed by other pads on all four sides; plan an escape for every hit.
Step 5a-tuned-iii: Length matching from the board's own classes
If netclass names carry length-match hints (*_LM<tol>, *length*,
DDR-class groupings), wire them into the route step:
--length-match-group auto --length-match-tolerance <tol> and
--time-matching when the bus spans layers — but --time-matching ONLY
on a board with a real stackup (it converts length to delay through the
dielectrics; on KiCad's default stackup it computes garbage — the Step 10
rule-1 no-stackup precedence applies to it exactly as to --impedance).
The board's classes are the author's spec — honor them even when the
recorded chains never did.
Step 5b: Net-Coverage Reconciliation (mandatory — do not skip)
The stages partition every routable net by glob pattern, and the patterns are
not reconciled automatically. The failure mode this step prevents: a net is
excluded from one stage (!X) but never claimed by a later one, so it
silently gets zero copper and the run "completes" with it fully unrouted. This
is exactly how GNDA (an analog ground tied to GND through a single 0Ω/
ferrite) was dropped — excluded from the signal route as a "power net", yet never
added to the plane step's --nets, ending with 0/23 pads connected while the run
reported success.
The invariant: every routable net (≥2 pads, not no-connect) must be claimed by
exactly one stage. A net excluded from any stage MUST be claimed by a later one.
Before running any command, write the net-handling ledger and reconcile it
mechanically — do not eyeball it:
-
Assign every routable net to one handler:
route step — ordinary signals AND the plane nets (#562: the route step
takes "*"; plane pads weld into their pour via pour-launch and the
in-run finalize taps whatever fill can't reach)
diff-pair route — detected pairs
impedance SE route (Step 2b) — single-ended controlled-impedance nets (RF/antenna
50 ohm, DDR SSTL 40 ohm); the ONLY nets excluded from the route step
pour — nets the plane step pours; they are ALSO in the route step (see above)
wide trace — power carried via --power-nets widths (never excluded)
-
Diff the pattern lists (#562 rules). Two checks, and note that the old
"every poured net must be excluded" rule is now exactly backwards — a
poured net that is missing from the route step is the bug, because nothing
then welds its pads to the pour:
- the route step's exclusions MUST equal the Step-2b impedance set;
- every poured net MUST appear in the route step's
--power-nets (that is
where the finalize's taps and welds get their width).
route_exclusions = {"RF"} # the !X you will pass route.py
plane_nets = {"GND", "+3V3"} # the --nets you pass route_planes.py
impedance_se = {"RF"} # nets routed in Step 2b (route.py --impedance)
power_nets = {"GND", "+3V3"} # the --power-nets on the route step
orphans = route_exclusions ^ impedance_se
assert not orphans, f"Net-coverage gap: {sorted(orphans)} handled by no stage"
unsized = plane_nets - power_nets
assert not unsized, f"Poured but no route-step width: {sorted(unsized)}"
Do not proceed until both are empty.
-
Secondary grounds / split rails (AGND, GNDA, DGND, VREF, or any rail
tied to its parent through a single 0Ω resistor or ferrite bead — find the tie
with list_nets.py: the part with one pad on each net). These are real,
separate nets. Pour each as its own local region (Voronoi-sharing an inner
layer with the main ground is fine) and let the single tie component join it to
the parent. Never merge it into the parent plane (that shorts the split and
defeats its purpose — a green connectivity check then hides an electrical error)
and never leave it out (that leaves it unrouted). Give each its own
entry in the plane step, so it appears in BOTH lists in step 2 above.
Step 6: Generate Routing Plan
Based on the analysis, generate a step-by-step plan. The general order is:
Routing Order Rationale
- Pour the planes FIRST — before fanout, before any routing. A bare
route_planes
call: nets + layers only. NO --add-gnd-vias, NO --stitch-vias — those
adapt to signals that don't exist yet (the old #56 hazard) and belong in
Step 3. (The pour cannot rip at all any more: --rip-blocker-nets and the
other tap knobs were REMOVED from route_planes with the tap machinery.) Why the pour comes first (#424, measured):
the fanout's plane-drop vias connect to a still-intact pour immediately, and
the plane-fragility field (default on: KICAD_PLANE_FRAGILITY_COST,
2.0 mm-equiv, =0 reverts) then makes every later routing step pay to cut
the real fill where it is narrow — so signals cross planes mid-pour, not at
necks. Measured on a 4-layer corpus board, this order + the field: power nets fully connected,
+3V3 pour ONE intact island, GND weld copper cut to a third, connectivity
net-better, DRC clean. With planes poured signals-first style instead, the
pour under a BGA arrives pre-shredded and every drop via needs repair welds.
1b. Fanout (if needed) - Escape routing on the poured board. Exclude the
plane nets ("*" "!GND" "!VCC") — that exclusion marks them for automatic
plane-drop vias (#424), and because the pour already exists the drop
pass can skip a via entirely where the fill already covers the ball
(pour-direct) and land the rest on intact copper.
1c. After ALL fanouts are done — once, not per-BGA — run
place_fanout_clearance.py to clear decoupling-cap / fanout-via
collisions (#130) before routing. The pass is board-global (it reads every
via and every BGA), so one late run sees everything; running it per-BGA
compounds cap displacement and changes what later fanouts route around.
See Step 1c.
- Differential Pairs - The most constrained routes claim their channels before
anything else can block them (if present). Add
--impedance <ohms> for the
controlled ones (USB/Ethernet/LVDS/balanced-RF; from /find-high-speed-nets).
May peel far-apart "terminal" pads (e.g. spread-out test points) off the coupled
chain and leave them for the signal-routing step (reported as
single_ended_followup_nets, issue #121). (The pour is not an obstacle to
them — pours never block the router; the fragility field only prices
plane-severing paths.)
2b. Impedance-controlled single-ended nets (only if /find-high-speed-nets
found any - RF/antenna feeds = 50 ohm, DDR SSTL = 40 ohm). A dedicated
route.py --impedance <ohms> pass, routed here - after diff pairs, before the
bulk signal route - because they need a stackup-derived width and a short,
direct path over a clean ground reference, so (like diff pairs) they must claim
their channel before the bulk signals fill the area. Route an RF feed on an
outer layer (); requires a real stackup (see Step 2 stackup
check). These nets are then EXCLUDED from step 3.
Example Plan Output Format
Present the plan to the user as a numbered list with explanations:
## Routing Plan for board.kicad_pcb
### Board Summary
- 2-layer board (F.Cu, B.Cu)
- 174 nets, 25 components
- Unrouted (0 existing traces)
### Components Requiring Special Handling
- **U9 (PGA120)**: 120-pin grid array - use bga_fanout.py for signals only
### Differential Pairs
- None detected
### Power/Ground Nets
- **GND**: 42 pads - use plane on B.Cu
- **VCC**: 23 pads - use plane on F.Cu (or wide traces if planes not desired)
---
## Step-by-Step Routing Commands
### Step 1: Pour the Power Planes (FIRST — before fanout and routing, #424/#562)
A bare pour: nets and layers ONLY. No `--add-gnd-vias`, no `--stitch-*` —
those adapt to signals that don't exist yet and run in Step 3 instead. **The pour runs FIRST, before fanout**: the fanout's
plane-drop pass then sees real fill, so a ball the pour already covers needs
no via at all (pour-direct) and the ones that do get a via land on intact
copper. The pour step itself does no routing at all (#562: it places no taps
— the route step's pour-launch and in-run finalize own every plane pad).
The default-on plane-fragility field
(`KICAD_PLANE_FRAGILITY_COST`, 2.0 mm-equiv; `=0` reverts) then charges every
later routing step for cutting the fill where it is narrow — signals cross the
planes mid-pour instead of severing them at necks.
python3 -X utf8 py_router/route_planes.py board.kicad_pcb board_step1.kicad_pcb \
--nets GND VCC \
--plane-layers B.Cu F.Cu \
2>&1 | tee /tmp/step1_pour.txt
**Zone clearance is a MINIMUM-ALLOWED, not a target — never pass a
`--zone-clearance` larger than the routed clearance.** The default already
follows `--clearance` and auto-steps down to the fab floor when the pour
can't thread the densest BGA via lattice; a larger value only stops pours
from penetrating between balls/vias (human boards pour at ~0.1 for exactly
this reason). Watch the pour output for
`pour cannot thread the densest BGA lattice even at the fab floor`: when it
fires, no clearance setting can get an INNER-layer pour through that field —
the fix is a pour on the balls' OWN (outer) layer, which connects the pads by
direct contact (the plane-drop pass then skips those vias: `N pour-covered`).
`--min-thickness` (default 0.1) matches human under-BGA pours (0.089–0.1);
leave it unless a fab demands wider minimum copper.
Expect `check_connected` to show the plane nets fully connected from here on
(the drops + pour serve every BGA plane ball with no tap search).
### Step 1b: Fanout U9 (PGA120) - All Non-Plane Nets
Generates escape routing for ALL nets on the component EXCEPT those that the
planes step will handle. This ensures every signal net gets fanned out,
avoiding `--no-bga-zone` workarounds during routing.
**Important:** Use `"*" "!GND" "!VCC"` to fan out all nets except the power
plane nets. Do NOT use `"/*"` alone, as it misses nets with non-hierarchical
names like `Net-(U9-Pad1)` which would then require `--no-bga-zone` to route.
On a 4+ layer board also pass every copper layer with `--layers` (default is
F.Cu B.Cu only) so inner balls can escape — drop `--layers` only for true
2-layer boards.
python3 -X utf8 py_router/bga_fanout.py board_step1.kicad_pcb \
--component U9 \
--nets "*" "!GND" "!VCC" \
--layers F.Cu In1.Cu In2.Cu B.Cu \
--output board_step1b.kicad_pcb \
2>&1 | tee /tmp/step1_fanout.txt
**Then check the `JSON_SUMMARY` line: if `failed > 0`, balls were dropped — retry
before continuing.** First confirm all copper layers are passed; then re-run with
`--clearance` at the manufacturing floor (e.g. `--clearance 0.1`), which fixes the
common case (an 0.8 mm-pitch BGA can't fit a track between balls at 0.2 mm). If still
short, add the fine-pitch escape via and/or a smaller `--track-width`. Only proceed
to Step 2 once `failed == 0` (or the remaining `unescaped_nets` are understood and
accepted).
### Step 1c: Optimize Decoupling-Cap Placement (run ONCE after ALL fanouts — issue #130)
Nudges decoupling caps near the BGA off the foreign-net fanout vias (the
`PAD-VIA` violations #130) and pulls each pad toward its nearest same-net
ball. Run it on the fully-fanned board — after the LAST fanout, **before**
signal routing. Use the
**same `--clearance`** you gave the fanout / your DRC floor — that's the only
setting that matters (it reads each via's real size from the board).
python3 py_placer/place_fanout_clearance.py board_step1b.kicad_pcb board_step1c.kicad_pcb \
--clearance 0.1
It prints `Moved N cap(s); resolved R/V initial violations; K unresolved`, plus
`(F freed by via-nudge)` when the #313 last resort moved a via to free a boxed
cap. **resolved** means "was grazing at the seed and is clean now", counted at
the END of the pass, so it credits both the cap move and the via-nudge (#746).
Any **unresolved** caps are still grazing foreign copper — a via, a track, or a
component pad — and are not auto-fixed; note them for a manual nudge. A
`Re-grazed by this pass's own connector copper:` line names the subset that
was **clean before the via-nudge and is grazing after it** — copper this pass
drew, not copper the board arrived with. Those caps are in the unresolved list
too, so treat them as you would any other; the extra line says where the
copper came from. Grade with `check_drc.py` before acting: the repair pass
deliberately over-blocks a track on a layer the board never declared, so some
of these grade clean and some are real. By default (`--cap-prefix C,R`) it moves 2-pad
**caps and resistors** near a BGA (RN-style arrays auto-excluded since only
2-copper-pad parts move); it never overlaps parts, and is a no-op when nothing
collides. Feed `board_step1c.kicad_pcb`
into the next step. **With multiple BGAs, run it ONCE after the LAST fanout,
not after each.** The pass is board-global — it reads every via on the board
(`for v in pcb_data.vias`) and every BGA footprint for the same-net ball
attraction — so a single late run already sees every constraint at once. Per-BGA
runs are not equivalent, in two ways:
- **Displacement compounds.** Each cap's seed is wherever it sits on the board
it is handed (`seed_x, seed_y = fp.x, fp.y`), and the budget
(`--max-displacement` 2.0, growing ×1.5 to `--max-displacement-cap` 3.0) is
measured from THAT seed. A second run re-seeds at the already-moved position,
so a cap can drift ~2× the cap budget from where it started, and "move as
little as possible" becomes minimal-from-the-moved-spot rather than from its
real seed.
- **Moving caps changes later fanouts.** Cap pads are in the escape router's
obstacle map (foreign pads + existing copper + vias), so tidying after BGA1
hands BGA2's fanout a different obstacle field — different escapes, different
vias, and then different cap decisions.
The two orders usually converge anyway (decoupling caps cluster around their own
BGA, so BGA1's caps are rarely in BGA2's escape field), which is why per-BGA was
long treated as interchangeable. Once-after-all is the default because it cannot
compound and costs one step instead of N.
Verify with `check_drc.py board_step1c.kicad_pcb -c 0.1` (PAD-VIA count drops).
### Step 2a: Differential Pairs (only if any were detected)
The most constrained routes claim their channels first. Add `--impedance <ohms>`
for controlled interfaces (USB/Ethernet/LVDS/balanced RF, from
`/find-high-speed-nets` or `/identify-diff-pairs`). The pours from Step 1 do not
block these — pours are never obstacles; the fragility field only prices paths
that would sever them. Pairs may peel far-apart terminal pads off the coupled
chain and report them in `single_ended_followup_nets`; the Step 2 route finishes
those, so do NOT exclude the pair nets there.
python3 -X utf8 py_router/route_diff.py board_step1c.kicad_pcb board_diff.kicad_pcb \
--nets <pair globs, e.g. '/usb/*'> \
--track-width 0.1 --diff-pair-gap 0.1 --clearance <floor> \
[--impedance 90] \
2>&1 | tee /tmp/step2a_diffpairs.txt
(No diff pairs on the board? Skip this step and feed `board_step1c.kicad_pcb`
straight into Step 2b / Step 2.)
### Step 2b: Impedance-Controlled Single-Ended Nets (only if any were found; runs before the Step 2 signal route)
ONLY when `/find-high-speed-nets` reported single-ended controlled-impedance nets
(RF/antenna feed = 50 ohm, DDR SSTL = 40 ohm). Route them in their own
`--impedance` pass, after diff pairs and BEFORE the general signal route, so they
claim a clean, short, direct channel at the stackup-derived width. Requires a real
stackup (run `/recommend-stackup` first if the board has KiCad's default). Route an
RF feed on an outer layer over the GND plane; recommend a `User.2` keepout +
`--keepout` around any antenna region (user draws it).
python3 -X utf8 py_router/route.py board_diff.kicad_pcb board_step2b.kicad_pcb \
--nets RF --impedance 50 --layers F.Cu \
--clearance <floor> --no-bga-zones \
2>&1 | tee /tmp/step2b_impedance.txt
### Step 2: Route ALL Nets — plane nets included (#562)
Routes every unrouted net, **including the plane nets poured in Step 1** —
`--nets "*"` with no plane exclusions. Plane-net pads connect by welding
into the pour (pour-launch anchors, on by default), not by re-routing the
net as a track web, and the run **finishes with the plane finalize**: the
plane-repair engine (pad taps + region joins), the plane-copper cleanup,
and the KiCad-oracle exact-fill verify/reconnect all run IN this step, with
any stubborn oracle links joining the run's own final reconciliation. There
is **no separate plane-repair step anymore** — `repair_planes.py`
remains only for repairing a board outside this chain. Exclude only the
single-ended impedance nets already routed in Step 2b (`"!RF"`), so the
bulk pass cannot re-route them off their controlled width. The pours don't
block the router, and the fragility field makes plane-severing paths
expensive, which is what keeps them intact through this step.
**Pass the plane nets in `--power-nets` with widths** (e.g. `GND 0.3`): the
finalize's taps and welds size their copper from the power-width channel.
For boards with BGA/PGA components, use `--no-bga-zone` to allow the router
to find alternative paths through the dense pin area (even when fanout was
done, some paths may require this). Use `--max-ripup 5` for difficult
2-layer boards.
**If the finalize reports `Pads still unconnected` on fine-pitch (BGA/QFN
≤0.5 mm-pitch) pads, re-run this step in this order — cheapest first:**
1. **Smaller via** — drop `--via-size`/`--via-drill` toward the fab's
fine-pitch escape via (e.g. `0.30/0.15`), never below the fab via floor.
A boxed ball usually fails because the tap via can't fit beside it.
2. **Then finer grid** — drop `--grid-step` (e.g. `0.05 → 0.025`), not
below the board's minimum feature: a 0.65 mm-pitch escape can be a
grid-resolution limit, not a width one.
(BGA plane balls under a dropped part should already carry fanout-time
plane-drop vias (#424), so this retry is rare.)
> **Do NOT pass `--max-iterations` (#529 dynamic iterations, default on).**
> The router self-budgets: full searches automatically earn +1×base
> extensions while the search's heuristic keeps approaching the target, up
> to a 1e7-iteration ceiling — a genuinely hard net gets far MORE than the
> old `--max-iterations 1000000` advice ever gave it, while hopeless
> searches stop early. A net that still fails after an
> `"dynamic iterations (#529): search extended to N"` log line is a
> capacity problem (rip-up, clearance, layers), not a budget problem.
> (`KICAD_DYNAMIC_ITERATIONS=0` restores the legacy static caps for A/B.)
python3 -X utf8 py_router/route.py board_step1c.kicad_pcb board_step2.kicad_pcb \
--nets "*" \
--no-bga-zone \
--max-ripup 5 \
--power-nets GND VCC <other PWR...> --power-nets-widths 0.3 0.4 <W...> \
--layers <ALL copper layers> --layer-costs <1.0 signals / 3.0 solid planes / 1.5 split-or-highway> \
2>&1 | tee /tmp/step2_routing.txt
The `--layer-costs` line is NOT optional when Step 1 poured any solid plane:
without it signals cross the pours at cost 1.0 and shred them (measured: split
power pours at 0–2% connected under a BGA on a chain that omitted it). Order
matches `--layers`; 3.0 on solid-plane layers, 1.0–1.5 on split/route+pour and
highway layers, 1.0 on F/B. On dense boards use the measured-optimal pricing
from Step 2c instead (GND plane 6.0, rail pours 2.5, bus highway FREE).
(When Step 2b ran, exclude its impedance nets, e.g. `--nets "*" "!RF"`, and
route from `board_step2b.kicad_pcb`.)
This produces the **canonical final board** — the finalize's `JSON_ORACLE`
line reports the KiCad-verified plane-completion verdict for the run.
### Step 2c: Tuned route parameters (the measured-optimal set)
A 15-board screen (2026-08-17) measured the following parameter set as
STRICTLY DOMINANT over each board's naive parameters — total KiCad
post-refill unconnected 62 → 23 across the corpus at **equal total wall
time** (better first-pass arrangement repays the extra search in saved
rip/retry churn). Apply it whenever the board is dense enough that any
fanout or escape is contested; on trivially-open boards the defaults are
fine.
1. **Strict small features** (the single biggest lever on packed boards —
lane pitch is quantized by track+clearance, and a 0.1 grid cannot
express a 0.28 pitch at fat features):
`--track-width 0.0762 --clearance 0.0889 --via-size 0.25
--via-drill 0.15`
The fab-floor clamps pin track/clearance/via UP automatically on
boards whose layer count or fab tier can't take them — passing those
four is always safe. Grading stays honest via the `.kicad_pro` floor
writeback.
**EXCEPTION — `--hole-to-hole-clearance` does NOT clamp**: route.py
board-derives h2h only when the flag is OMITTED and honors an explicit
value verbatim, while `check_drc` pins its grade UP to the board's
`min_hole_to_hole` — so an explicit 0.2 on a 0.25-constraint board
routes real, graded drill-pair violations (verified in code by two
independent plan audits). Pass the BOARD's own `min_hole_to_hole`
(from `--design-rules`), or omit the flag and let route.py derive it.
2. **Direction preference**: this is now the DEFAULT (5), so passing
`--direction-preference-cost 5` is optional — keep it if you want the
manifest self-documenting. #663's corpus screen took the old 250 default
to 5 on the strength of sets 1-5, 75 boards per arm at one commit: −22
incomplete nets (−19.6%), W15/L6, real DRC flat. A weak nudge organizes
layers; 250 priced every off-axis move above 3 vias and forced detours,
while 0 loses the organization entirely.
3. **Layer pricing** (order matches `--layers`): GND solid-plane layer
**6.0**; rail/split pour layers **2.5**; F/B and free routing layers
**1.0**; and leave the board's **bus-highway layer at 1.0 even if it
carries pours** — the inner layer adjacent to the largest BGA that its
widest bus needs (orangecrab: In2, the RAM highway; pricing it cost
completions every time it was tried).
4. **The plan/attraction environment** (route step only, as env-var
prefixes on the command line so the manifest replays them):
`KICAD_GLOBAL_PLAN=1 KICAD_GLOBAL_PLAN_SEQ=1
KICAD_GLOBAL_PLAN_SEQ_COST=1.5 KICAD_GLOBAL_PLAN_VIA_COST=20
KICAD_GLOBAL_PLAN_ITERS=50000 KICAD_GLOBAL_PLAN_ATTRACT=1
KICAD_ATTRACT_POTENTIAL=65 KICAD_GLOBAL_PLAN_RIVER=1
KICAD_FINALIZE_REAUDIT=1 KICAD_PACK_INLINE=1`
(SEQ-negotiated global plan + potential attraction + river packing +
finalize re-audit. These are env knobs today, so they ride the
redo-manifest form of the plan but NOT the GUI plan JSON — see the
promotion note in Step 9.)
**On DENSE-tier boards (Step 5a-tuned gate), ALSO prepend**
`KICAD_GLOBAL_PLAN_LAYER_MODE=clique KICAD_GLOBAL_PLAN_LAYER=pref` —
clique-negotiated layer assignment with preference-directed layers.
These were the knobs that unlocked the orangecrab hand-ladder's 22→15
descent; on the skill's own plan they measured 17 vs 18 unconnected at
equal wall time (within single-board wobble by itself, but
directionally consistent and free). Leave them OFF for STANDARD
boards — unmeasured there, and standard boards already hit 0.
### Step 2d: Guided iteration + endgame (dense boards)
When Step 2 leaves failures on a dense board, do NOT hand-tune — iterate:
```bash
# each pass re-attempts only the failed/open tail (connected nets
# gate-skip), with rip authority against the settled board; the plan
# guidance persists through rips, which is what makes iteration
# CONVERGE instead of plateauing
python3 -X utf8 py_router/route.py board_step2.kicad_pcb board_iter1.kicad_pcb --nets "*" <same flags+env>
python3 -X utf8 py_router/route.py board_iter1.kicad_pcb board_iter2.kicad_pcb --nets "*" <same flags+env>
Two iterations are near-free (measured: the tuned corpus run with 2
iterations baked in cost +1.5% total time) and historically descend
frontier boards 25→15 over 3–5 passes — so bake two passes into every
plan, dense or not. The near-free property DEPENDS on the Step 5a-tuned
density gate: connected nets gate-skip, but each pass still re-oracles
every pour, so big carve-free outer floods on a small board turn "free"
iterations into 100 s+ passes (one measured 428 s chain; 88 s with
the same iterations after the flood was removed). Then two endgame
signatures:
- A net walled by its own protected diff partner (
no rippable blockers naming the partner): the #521 override — name BOTH members
EXACTLY with --nets P N --force-reroute, then one more all-nets
iteration (override passes may pay off one pass late).
- Bare-ball / island signatures are now handled automatically