PnR (FUG-138): place-and-route engine — ingestion, constraints, placement + orientation - #126
Open
issuefleet[bot] wants to merge 37 commits into
Open
issuefleet[bot] wants to merge 37 commits into
issuefleet[bot] wants to merge 37 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hardware/splanc_dev — complete board as code, builds hermetically via the rules_atopile toolchain (ato build -> resolved .kicad_pcb, 79 footprints, no network at build): ESP32-C6-WROOM-1 + USB-C native-USB, TLV62569 3V3 buck, TPS61088 5V boost, 2x monitored/switched LED channels (INA226 + P-FET load switch + 74LVC1T45 level shift + connector), MPU6050 + BMP280 sensors on I2C, MCP73831 charger + MAX17048 fuel gauge, buttons, and the 2x10 EoL connector. Every part is a pre-picked atomic LCSC component (elec/src/parts + passives.ato). firmware — //firmware/player_app:splanc_dev (-DLM_BOARD_SPLANC_DEV pin map), verified to build a 2.28MB C6 image. hardware/tools — local picker catalog + part-generation and footprint-sanitize scripts (the sanitizer works around an atopile 0.15.8 layout crash on footprints whose silkscreen is only circles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the atopile PCB build as a runnable target. rules_atopile's Nix toolchain
uses root-only rules_nixpkgs tags that don't compose as a bazel_dep, so instead
of a fragile MODULE.bazel integration, ato_build.sh fetches rules_atopile at a
pinned commit, re-pins the aarch64-linux venvHash, sanitizes the footprints, and
drives ato + kicad-cli through its nix develop shell. Verified end-to-end:
bazel run //hardware/splanc_dev:build{, -- pdf, -- gerber} produces the resolved
.kicad_pcb, a layout PDF, and Gerbers + drill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hardware/splanc_eol_tester — the End-of-Line test fixture as code, builds hermetically (bazel run //hardware/splanc_eol_tester:build): ESP32-C6 sequencer, mates the dev module J_EOL (2x10), VBUS-inject switch, isolated button drivers, per-channel SHORT+NOMINAL simulated loads, rail/CSA dividers into the MCU ADC, and level-shifted digital taps to a header for the external HITL-style FX2/ fx2lafw 24MHz analyzer. Reuses the dev module's atomic parts; FX2 (external dongle) + opto isolation + power shunts use documented stand-ins where a footprint wouldn't generate. hardware/README.md — atopile setup, build commands, authoring flow, the 0.15.8 workarounds, and known first-spin simplifications. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-splanc-dev-module # Conflicts: # firmware/player_app/led_config.h
Replace the per-board sh_binary(build) wrapper around ato_build.sh with real
atopile_project targets (//hardware/<board>:<board> + .pdf/.gerber/.view/...),
driven by a Nix-provided toolchain — no PATH lookups.
- MODULE.bazel: pull rules_atopile as a bazel_dep (git_override) and build
ato/kicad-cli/freerouting + a py3.12 (pcbnew) from this repo's rules_nixpkgs,
pinned to rules_atopile's nixpkgs (ac62194c; has python3.14 + matching
venvHash). Register the toolchains.
- hardware/atopile: atopile_toolchain binding those Nix tools (per exec platform).
- Three patches to rules_atopile (applied via git_override):
* composable-dep: minimal MODULE.bazel so it composes as a dependency
(its rules_nixpkgs tags are root-only and error otherwise).
* venvhash: re-pin the drifted aarch64-linux uv-venv FOD hash.
* autoroute-kicad-python: a kicad_python toolchain field + derive KiCad's
pcbnew site-packages for the autoroute step (strict_action_env blocks env
injection); adds route_max_passes to bound FreeRouting.
- Boards: outline_margin_mm=4 + autoroute=True (route_max_passes=3) — a framed,
headless-routed PREVIEW (auto-placed row -> long routing; ~3 min/build), not a
fab layout. README documents the flow + the planned algorithmic PnR system.
Verified in-container: both boards build .kicad_pcb; splanc_dev .pdf (outline +
routed) and .gerber build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs/hardware/pnr-system.md — problem space, SOTA survey (traditional + neural), and an implementation sketch for a single build target that goes from a netlist + spatial constraints to a DRC-clean, fabricatable PCB + manufacturing files. Core plan: constraint model (fixed/edge/orientation/side/keep-out) -> a DREAMPlace/Cypress-style differentiable graph-relaxation placement (LSE wirelength + per-side density + net-crossing routability, Gumbel-Softmax orientation) -> FastRoute/FLUTE lookahead global routing -> a place<->route feedback loop made convergent with a PathFinder-style history term (region inflation a la RePlAce) -> FreeRouting detailed route -> DRC -> fab export via kicad-cli/pcbnew. Sober ML take: borrow autodiff placement + a cheap routability signal; route classically; treat RL/learned-router work as inspiration, not dependencies. Grounded in cited primary sources (Cypress ISPD'25, DREAMPlace, ePlace/RePlAce, PathFinder, FLUTE/FastRoute, AlphaChip + its critique). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the design doc pick-up-able by a fresh worker agent: - docs/hardware/pnr-system.md: new "Handoff / bootstrapping" section (repo mechanics, proposed hardware/pnr/ layout, language+PyTorch-under-Bazel decision, Task 0 blockers, first vertical slice); a concrete "Ingestion contract" (pcbnew field->internal-graph mapping, verified against splanc_dev.kicad_pcb) and a "Concrete constraint file (v0)" YAML with a worked splanc_dev example (usbc/buttons/ESP32-C6 antenna keep-out); per-phase acceptance tests + a fixture-extraction recipe. Fix a prettier-mangled bullet in the build-integration paragraph. - hardware/pnr/WORKLOG.md: seed the code location with a newest-first handoff log (current state = design only, no code; Task 0 blockers = Cypress license + torch-under-Bazel; Phase 1 first slice). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The atopile uv-venv fixed-output hash for aarch64-linux drifts as its transitive deps release (documented in hardware/README.md). Re-pin it to the current value so //hardware/splanc_dev builds again — needed to regenerate the PnR ingestion fixture. The venv FOD is reproducible, so the pin holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The algorithmic place-and-route engine's differentiable placement core is a PyTorch autograd program (docs/hardware/pnr-system.md §4/§8); pyyaml parses the sidecar constraints.yaml. CPU-first by design. torch is capped <2.4 on purpose: from 2.4 PyTorch ships CUDA-enabled aarch64 (sbsa) wheels, so resolving on this aarch64 host would pull ~20 linux-only nvidia-*/triton wheels into the single, markerless requirements.lock — which then fails to resolve on macOS-arm64. Under 2.4 the aarch64 wheel is CPU-only and the x86_64 nvidia deps are platform-marked, so the lock stays clean and cross-platform. Resolves to torch==2.3.1; lock change is purely additive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First code for the algorithmic place-and-route engine (design doc docs/hardware/pnr-system.md). Lands the ingestion bridge and constraint front end so a later phase can wire the differentiable placer over them. - pnr/graph.py: the neutral BoardGraph (design §2) — components, pads, nets, outline. stdlib-only on purpose: it is the JSON seam between the KiCad pcbnew interpreter (ingest) and the hermetic rules_python interpreter (torch), which never share a process. Coordinates normalized to float so JSON round-trips byte-stably. - pnr/ingest.py: pcbnew .kicad_pcb -> BoardGraph (nm/y-down -> mm/y-up, bottom-left origin), plus a pure dump_svg ratsnest renderer. pcbnew is imported lazily (only under @kicad_python), so the module imports fine without KiCad and the graph/SVG logic stays unit-testable. - pnr/constraints.py: constraints.yaml (design §3) -> hard barriers / soft penalty terms; globs expand against the netlist, unknown refs warn. Front end only (no torch math yet). - testdata/splanc_dev/: frozen fixture — the built splanc_dev.kicad_pcb, its ingested graph.json (79 components / 71 nets / 338 pads), and a hand-written constraints.yaml. Tests never rebuild atopile. - tests + BUILD: torch_smoke_test (Task 0 gate — torch imports + autograd on CPU), graph_test, constraints_test, ingest_test (frozen counts + a KiCad-gated live re-extraction). All green: 4/4 under bazelisk --output_base=... test //hardware/pnr/... Task 0 decisions recorded in hardware/pnr/WORKLOG.md: torch under Bazel done; Cypress NOT vendored (repo is AGPLv3, NVlabs/Cypress is likely NVIDIA-source non-commercial and unverifiable offline) — reimplement its math clean-room, the design's stated fallback. Next: Phase 2 placement MVP (design §9.2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The atomic EasyEDA footprints have no F/B.CrtYd courtyard layer, so ingest was falling back to the graphical bounding box *including silkscreen + reference text* — inflating every part ~5x (a 0402 read as 4.1x5.1 mm; total 3580 mm^2). Use pcbnew's text-excluded GetBoundingBox(False) instead (0402 -> 1.9x1.2 mm; total 1490 mm^2), which is the real placement extent. Regenerate the frozen splanc_dev graph.json (component/net/pad counts unchanged: 79/71/338). Grow the fixture outline to 60x50 mm and fix U5 (the ESP32-C6 RF module) at the north edge so its antenna keep-out is a well-defined region — both needed so Phase 2 placement is feasible and its keep-out is meaningful. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflows the atopile row placement into a legal, compact layout (design §9.2). - pnr/place/model.py — differentiable global placement (torch, CPU, seeded): loss = log-sum-exp wirelength + pairwise spreading + outline containment + soft edge-align + soft grouping + keep-out penalty; Adam. Fixed parts are held as constants but still anchor the wirelength. The DREAMPlace 'placement is training a network' reframing in plain PyTorch. No orientation search yet. - pnr/place/legalize.py — grid nearest-free-fit legalizer (numpy): rasterize the outline, block fixed courtyards + keep-outs, place movable parts biggest-first into the free slot nearest their continuous target. Disjoint blocks sized ceil((courtyard+clearance)/g) give 0 overlaps + in-outline by construction. - pnr/place/geometry.py (Rect, courtyard, pins, edge-pose/keep-out resolution), metrics.py (HPWL, overlaps, hard-violation checks), placer.py (place() + PlacementReport), __main__.py (CLI: python -m pnr.place / //hardware/pnr:place). - test_placement.py acceptance (green): on splanc_dev the result is legal (0 overlaps / 0 outside / 0 fixed-off / 0 keep-out), HPWL 15832 -> 2023 mm (87% shorter than the ato row), and deterministic under a fixed seed. BUILD: split :pnr (core, torch-free) from :pnr_place (torch+numpy) so the schema tests stay light; added the placement_test and a runnable :place binary. MVP simplifications tracked in WORKLOG for Phase 3+: no orientation search, single-sided legalization (side_pref is a soft term but parts stay top), keep-out supports rel-to-fixed + absolute polygon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The placer now co-optimizes a 90-degree rotation per movable part alongside
position (design §9.3).
- pnr/place/model.py — each movable part carries a categorical over
{0,90,180,270}, relaxed to a temperature-annealed softmax (a deterministic
Concrete/Gumbel-Softmax relaxation, Cypress-style). Pin offsets and courtyard
extents become the *expected* offset/extent under that distribution, so
orientation is differentiable and co-optimized with position; at the end we
snap to the arg-max angle. Fixed parts keep their constrained angle. Runs torch
single-threaded (set at import) so the placement is reproducible.
- placer.place() gains orient= (default True); writes the chosen rotations into
the graph before legalization (which already honors rotated courtyards).
PlacementReport gains a rotated count.
- tests/test_orientation.py (Phase 3 acceptance, green): orientations settle to
legal discrete angles, the result stays fully legal, orientation is exercised,
HPWL improves vs. the position-only Phase 2 placement, and it's deterministic.
The Phase 2 placement_test now pins orient=False for position-only semantics.
Result on splanc_dev: legal, HPWL 2023 -> 1836 mm (orientation on vs off; 88%
below the ato row), 61 parts rotated. Full suite 6/6 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # MODULE.bazel.lock # firmware/player_app/led_config.h
…-138)
Integrates the design's remaining phases so a single build target takes the
splanc module from netlist to a placed, routed, fab-ready board:
bazel build //hardware/splanc_dev:splanc_dev.fab
Phase 4 (pnr/route/): RMST net decomposition + a coarse gcell global router with
PathFinder present/history negotiated congestion, and the place<->route feedback
loop — global-route overflow drives RePlAce-style per-part inflation (weighted by
an accumulated history term) until the board is routable. Threads an `inflation`
hook through place()/global_place()/legalize(). Tests: route_test (Steiner,
capacity/overflow, PathFinder history, inflation) + route_feedback_test
(overflow->0, terminates, non-increasing, placement stays legal, deterministic).
Phase 5: pnr/writeback.py applies the optimized placement back onto the board via
pcbnew (poses/orientation/side, clears stale preview tracks) and strips Edge.Cuts
as text so board_outline.py reframes cleanly (works around a broken
BOARD.GetDrawings() SWIG iterator in this KiCad 9 python). The atopile_pnr rule
(hardware/pnr/pnr.bzl) orchestrates ingest -> place+route -> writeback -> framing
-> FreeRouting -> DRC across the two interpreters (@kicad_python + the hermetic
torch pnr_fab binary) and re-provides AtopileLayoutInfo to the existing kicad-cli
exporters, which emit the Gerber/drill/BOM/pick-place bundle.
Verified: splanc_dev.fab builds clean — place<->route converges (HPWL
15832->1849 mm, 61 parts rotated, 0 overflow), FreeRouting lays 1004 tracks +
80 vias over 79 footprints, and the full fab bundle is produced. Engine suite
9/9 green.
Docs: docs/hardware/pnr-inputs.md (the constraints.yaml guidance-input
reference), hardware/README.md (the two layout paths), design-doc §9 marks
Phases 4-5 done.
Also re-pins the atopile aarch64 venvHash (known FOD drift) so the .fab build
realizes the toolchain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y pass (FUG-138) Adds the design's quality passes (§9.6) to the .fab flow. Schema (constraints.py): net_class (per-class trace width/clearance over net-name globs), diff_pair (p/n + skew tolerance), length_match (net group + tolerance). compile_routing_rules() expands the net globs against the real netlist into a stdlib rules.json — the seam the pcbnew steps consume without pyyaml/torch. Widths reach the router (writeback.apply_net_classes): net classes are applied to the board's KiCad net settings (default class + named classes via SetNetclassPatternAssignment) BEFORE apply_placement, so BuildConnectivity() keeps them and FreeRouting's DSN carries per-class widths. Verified: power rails route at 0.4 mm, signal nets at 0.2 mm. Quality pass (pnr/quality.py): reads the routed board via pcbnew (per-net track length + via count) and a pure analyze() scores routed length, vias, diff-pair length skew, length-match spread vs tolerance, and per-net-class length roll-up -> quality.txt in the fab bundle. Advisory by default; quality_gate=True fails the build on a diff-pair/length-match miss. Wiring (pnr.bzl): pnr_fab --dump-rules, writeback --rules, a quality step, and a PnrReportsInfo provider so the bundle collects drc.rpt + quality.txt. splanc_dev constraints.yaml gains a `power` net class (rails -> 0.4 mm). The board has no genuine diff-pair/length-match need (USB is inside the WROOM module), so those are covered in the docs, tests, and fixture rather than forced onto it. Verified end-to-end: splanc_dev.fab builds, quality PASS, power nets widened, quality.txt shipped. Engine suite 10/10 green. The optional learned routability predictor is intentionally deferred (the analytical loop already converges). Docs: docs/hardware/pnr-inputs.md (routing-rule reference), design-doc §9.6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erhang, routing gate (FUG-138) An EE review of the first routed splanc_dev.fab caught real defects. Fixes: 1. Outline clipped parts (ESP32 pads outside Edge.Cuts). The outline was framed from footprint origins (atopile board_outline.py), discarding the placement's containment. Now writeback.frame_region stamps Edge.Cuts at the placement region [0,W]x[0,H] — which the placer keeps every courtyard inside — as text, so all pads are inside by construction. Dropped board_outline.py from the flow. 2. 2-vs-4 layer mismatch. constraints.layers now actually sets the board copper layer count (SetCopperLayerCount) via rules.json; gerbers export In1/In2 and FreeRouting routes on all four layers. 3. Connector edge overhang. New overhang_mm on fixed/edge_align (geometry. _edge_pose) so an edge connector protrudes a set distance past the board edge for cable mating; USB1 = 1.5 mm. Fixed parts excluded from the outside-outline check (metrics.outside_outline(exclude=…)) since the overhang is intentional. 4. Routing-completeness gate (the key behavior change): pnr.quality counts unrouted ratsnest (GetUnconnectedCount) and the build FAILS if any net is unrouted (require_routed, default True); route_max_passes=0 routes to completion. No more green build on a partial route. Tests: geometry_test (overhang + outline exclude) + quality unrouted gate; suite 11/11 green. KNOWN-OPEN: the board does not yet fully route — on 4 layers FreeRouting leaves ~81 connections unrouted, so splanc_dev.fab CORRECTLY FAILS. The place<->route loop's coarse global-route lookahead reads overflow 0 while the detailed router can't route the dense placement; making the board routable (realistic lookahead so the loop spreads, bigger/less-dense board, channel-aware legalization, or detailed-route-in-loop) is the next work. See hardware/pnr/WORKLOG.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routability investigation from the EE review, and the kickoff of our own detailed
router (Kevin's call: FreeRouting + naive planes can't get a dense fine-pitch
board DRC-clean).
Diagnosis (union-find over tracks+pads reproduces pcbnew's ratsnest exactly at
81): 55/81 unrouted (68%) are high-fanout ground/power — `lv` ground is 75 pads /
32 unrouted — that must be planes, not traces.
Planes (validated, connect the high-fanout nets):
- net_class.plane_layer schema (+ rules.json, board layers).
- writeback.apply_planes: split-plane pour (several rails share an inner layer,
each = bbox of its own pads, priority-carved) + via-stitch pads to the plane;
plane layers typed LT_POWER so the router keeps signals on F/B.
- splanc_dev: In1 = solid `lv` ground, In2 = split {p3v3,p5v,vsys,vbat,hv}.
KNOWN-OPEN (-> phase R1): via-in-pad stitching shorts on 0.5mm-pitch parts (612
DRC violations). Fix is dog-bone fanout (offset via + short trace), the standard
for >=0.5mm pitch; that's the first router phase.
Design: docs/hardware/pnr-system.md gains the detailed-router phased plan R1-R5
(dog-bone plane fanout -> geometry/pin-access model -> escape routing ->
PathFinder negotiated signal router -> close the loop with real congestion),
SOTA-grounded. Until it lands, splanc_dev.fab correctly FAILS the require_routed
gate.
Also: overhang_mm connector edge protrusion + plane_layer docs. Suite 11/11 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…irst) (FUG-138) Starts the detailed router at R1 (DRC-clean plane fanout). writeback. _dogbone_fanout_net places an offset via + short trace per plane-net pad (the >=0.5mm-pitch standard) instead of via-in-pad, clearance-checked against other-net pads/tracks (_collect_obstacles / _clear_of_obstacles), via-in-pad only for big pads. _collect_obstacles guards GetTracks() (flaky SWIG iterator in this KiCad-9 python — usually works, sometimes not; falls back to pads-only). FINDING (recorded in WORKLOG): a naive outward offset is still NOT DRC-clean (~28 shorts on the dense fixture) because the fanout *trace* isn't collision- routed and via geometry needs the proper model. So R1 depends on R2 — clean fanout needs the geometry/obstacle model + a real trace router. Build order is R2 (geometry + pin access) -> R1/R3 (fanout/escape) -> R4 (signal router) -> R5 (close the loop). The scaffold + obstacle framework here is the foundation. splanc_dev.fab still correctly fails the require_routed gate. Engine suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r) (FUG-138) The own detailed router now has its engine — pure Python/numpy on the placed graph (like the placer), fast + unit-tested, no pcbnew in the loop. R2 — pnr/route/detail/grid.py: RouteGrid, a per-signal-layer occupancy grid built from the placed graph. Grid pitch >= track+clearance => DRC-by-construction. Pads (now carry `size` — added to graph.Pad + ingest; fixture graph.json regenerated with sizes, counts unchanged) become own-net access cells; other-net pads / keep- outs are obstacles; the outline is the grid bound. detail_grid_test green (incl. building on the real 338-pad fixture). R4 — pnr/route/detail/maze.py: a multi-layer A* + PathFinder negotiated router. In-plane + via moves (via surcharge), multi-pin nets grow a tree (Prim on the grid), cost (1+history)*present; rip-up-&-reroute until no grid cell is shared by two nets => DRC-clean at the pitch. detail_maze_test green: 2-pin, via-when- needed, obstacle avoidance, TWO CROSSING NETS NEGOTIATE DRC-clean (no shared cells), deterministic, other-net-pad blocks. This engine subsumes R1/R3 fanout (a dog-bone is a short route + via). Also adds pad `size` to the ingest contract (graph.Pad.size, ingest reads GetSize) and geometry.pad_rects (rotation-aware pad rectangles). Suite 13/13 green. Design doc marks R2 + R4-core done; WORKLOG has the R5 integration plan. splanc_dev.fab still correctly fails the gate until R5 lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…FUG-138) route_board (pnr/route/detail/router.py) ties R2 (grid) + R4 (maze) into one call over a placed graph: build the grid, exclude the plane nets, negotiated-route the signals, and emit mm-space tracks/vias ready for write-back. route() now GREEDY-FINALIZES to a DRC-clean result always: contested nets are dropped to unrouted rather than emitted, so the routing handed back is never a short — it is honest ground truth (routed + unrouted) for the place<->route loop. A* sources are iterated in sorted order for determinism. detail_route_test: places the real splanc_dev fixture and routes its signals (power/ground on planes, excluded) — asserts DRC-clean by construction (no grid cell shared by two nets), deterministic (two fresh routes match), emits geometry, routes a meaningful fraction on the dense 2-signal-layer board. Fixture constraints.yaml gains plane_layer net classes. Suite 14/14 green. Design doc + WORKLOG: R5 in progress with the finish plan (emit via writeback + plane fanout via the engine, then close the loop with real congestion + tuning to reach 100%). splanc_dev.fab still correctly fails the gate until that lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path wired (FUG-138) The own detailed router now fully routes the splanc_dev fixture: pnr_fab reports `detail route: 48/48 nets, 0 unrouted, 8 passes` at 0.3mm pitch (6 power/ground nets are planes). This is the board FreeRouting could not finish. FreeRouting is removed from the pipeline; the own engine (grid model + negotiated multi-layer maze + PathFinder) is the router. Emit path wired end-to-end: - pnr.route --dump-routes runs route_board on the placed board and dumps mm-space tracks/vias + pad->cell access stubs (routes.json) - pnr.writeback --routes emits them (emit_routes); net codes read up front via _net_code_map since FindNet/GetFootprints flake mid-session; _set_design_rules stamps the board default clearance/width (0.13/0.15) to match the grid - pnr.bzl drops the FreeRouting step + route_max_passes/freerouting attrs KNOWN-OPEN (R3): the emitted pcbnew board is not DRC-clean yet (~1584 DRC) — via geometry, plane fanout shorts, pin-access stub alignment, and track clearance margin. The routing decisions are DRC-clean in the grid model; turning them into DRC-clean copper is the remaining R3 work. splanc_dev.fab correctly still fails the routing-completeness + DRC gates until then. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validated against kicad-cli pcb drc: placement-only (zero routing) already reports 66 violations, ALL pad-vs-pad inside the source footprints (U2 exposed thermal pad vs its thermal PTH; USB1 overlapping connector pads). Adding the full signal route adds only ~13 more — 4 of them an EN-1/en-1 net-name case collision (atopile naming, not a real short) — i.e. ~9 genuine routing violations, each a tight via-near-pad edge case. The router no longer emits shorts/clearance failures. (Started the session at 1097 full-board violations.) Six fixes got there: 1. Design rules must be stamped into the .kicad_pro, not the board. DRC reads board constraints + net-class clearances from the PROJECT file, and apply_placement (SetCopperLayerCount/BuildConnectivity) detaches the board's live settings from the project SaveBoard writes — so GetDesignSettings() never reached DRC. writeback.patch_project_rules() patches the .kicad_pro JSON as the last pipeline step (pnr.planes), mirroring how frame_region stamps Edge.Cuts as text. Rule set (_RULE_SET_MM): clr 0.13, hole/hole-to-hole/edge 0.20, via Ø0.45, drill 0.20, annular 0.0 — manufacturable and grid-satisfiable; drill/annular loosened only to tolerate source footprints (U2 0.2 drills, USB1 ~0 annulus). 2. Pad y-flip frame bug: pcbnew footprint-local coords are y-DOWN, the engine y-UP; ingest stored the pad offset unflipped, so every pad's router geometry was mirrored about the part centre (halo/access on the wrong pad → shorts). Fix: offset=(x, -y) in ingest. Pad-position delta vs emitted pcbnew is now 0.0000 mm across all 79 parts. 3. Pad clearance halos (grid.add_pad): reserve own-net cells out to clearance + via_radius so other-net tracks AND vias keep clear. 4. Via keep-out (maze._footprint): Ø0.45/0.25 vias + a radius-1 keep-out halo on both layers around every via ⇒ via-via ≥0.6 mm centre-to-centre and via-track both DRC-clean at 0.3 mm pitch; folded into PathFinder occupancy and the greedy finalize (a net whose footprint overlaps an accepted net drops to unrouted). 5. Through-hole pads on ALL signal layers; no-net copper is a hard obstacle; custom-pad true extent (U2 pad 21: Ø0.01 anchor but 4.97x2.04 real copper — use GetBoundingBox); edge inset (grid.block_edge_inset). 6. ingest board name strips .kicad_pcb. detail_route_test now asserts the footprint-level DRC-clean invariant (cells + via keep-out halos, no cross-net overlap) and a realistic routed fraction. Suite 14/14. Fixture graph.json regenerated (through_hole + custom-pad sizes + y-flip). OPEN (R5): DRC-clean routed fraction is ~14-22/48 on 2 signal layers — feed the detailed router's congestion back into placement inflation to close it; until then splanc_dev.fab correctly fails the routing-completeness gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… routing (FUG-138) Loop closure: route_and_place(detail_rules=...) (+ pnr.route --detail-loop, wired into pnr.bzl) now uses the DRC-clean detailed router as the loop's ground truth — it re-routes every round and #unrouted signals is the objective, with detail_congestion() stamping each failed net's pad bbox into the placement inflation map. This replaces the global lookahead, which reported overflow 0 (perfectly routable) on placements the DRC-clean router cannot finish. This is the user's directive: a failed route must steer the next placement cycle. 4-layer signal routing: router._signal_layers/_mark_plane_regions build a 4-layer grid and block the split-plane regions on the inners; maze via moves treat a through-via as an antipad pass-through (only the target layer must be clear — a track can't sit under plane copper, a via may pass through it); emit_routes maps In1/In2. Signals now route the inner-layer gaps between the split planes. Validated: In2 signal tracks are 0/0 DRC against the plane. Routability ceiling, honestly characterized: ~40% of signals (16-22/48), firm across pitch (0.25->17, 0.3->20, 0.4->14) and negotiation depth (12 vs 40 passes -> ~18); the detail-loop oscillates (32->29->36->32) and does not converge. Root cause is routing RESOURCE, not the router: the ground net's 75 pads span the whole board so its plane consumes ~all of In1, and the power split-planes cover much of In2 -> a 4-layer/2-plane stack gives ~2 effective signal layers here. A fully routed board needs 6-layer, a bigger outline, or a ground-plane strategy that frees an inner layer. Until then splanc_dev.fab correctly FAILS the routing-completeness gate (loop-until-success-or-fail, as requested). detail_route_test exercises 4-layer (allows In1/In2 track layers). Suite 14/14. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…outline (FUG-138) Two levers toward a fully-routed board (user chose "invest in a stronger router" and OK'd growing the outline): 1. Rip-up-&-reroute finalize (maze.route): the finalize was greedy — a net that contended for even one cell during PathFinder negotiation was dropped whole. Now it's two passes: (1) commit the negotiated routes that don't collide (preserves the spread paths); (2) for every dropped net, re-route it from scratch AROUND all committed copper (hard-blocked A*, threaded via a `blocked` set through _astar/_route_one), shortest-span-first. A net stays unrouted only if it genuinely can't path around the committed set — the difference between a greedy grid router and a real one. Still DRC-clean by construction. 2. Rubber-band board outline (route_and_place auto_outline, pnr.route --auto-outline / --outline-max-scale): the constraints' board.outline is an approximate target, not a hard size — a too-small board is simply unroutable. When the loop doesn't fully route at the target, the outline scales up (aspect preserved) and the whole loop retries, up to --outline-max-scale; the smallest outline that fully routes wins. place() already stamps placed.outline from the (now rubber-banded) constraint size, so write-back frames to it. Findings: RRR alone barely moves splanc_dev (~19-20/48) — the leftovers genuinely can't fit, so it's congestion not greedy-drop. A bigger board helps globally (pitch 0.4: 14/48 at 1.0x -> 21/48 at 1.6x / 2.56x area) but the harder wall is LOCAL pin-escape at fine-pitch parts (needs finer pitch there, and the inflation loop needs >1 round to spread dense clusters). Both levers are correct and composable; tuning the combination (bigger board + pitch 0.3 + multi-round inflation) is the next step. Unit suite green (maze/grid/feedback). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t (FUG-138) Diagnosis of the routability wall: the 29 unrouted nets concentrate on the dense parts (U5/ESP32 11, H1/20-pin 7, U2/QFN 5, U11 5) — fine-pitch PIN ESCAPE plus long nets that can't cross the dense channel. Key constraint recorded: the DRC-clean grid pitch has a hard floor at track+clearance ~0.28mm, and a 0.15mm track cannot fit between 0.5mm-pitch pads, so inner-row pins can't escape at ANY board size. The next lever is signal escape to the inner layers (via in/adjacent to a stuck pad down to an inner-layer gap — the QFN/BGA dogbone, for signals). detail_route_test: the RRR pass-2 re-routes + 4-layer grid made it time out at size=medium; run at pitch 0.5/max_iters 10 (still exercises 4-layer + RRR + DRC-clean-by-construction) and bump to size=large. Green in ~115s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `fab:` block (FabProfile) to the constraints: track width, clearance, via
diameter/drill, hole/edge clearance, min through-drill, via annular. Defaults are
the conservative JLC-class 4-layer set (behavior-preserving); a fab house that
supports finer geometry can tighten them for escape headroom — the user's "DRC may
be less strict; fab houses may support better than we chose."
One source of truth: the profile flows through rules.json ("fab") into
- the grid pitch (auto = track+clearance DRC-clean floor; a tighter fab routes
finer) and the grid track/clearance/via_radius,
- the via keep-out radius, now DERIVED from the geometry (ceil((via_d+clr)/pitch)
- 1) instead of hardcoded 1 — so via-via stays DRC-clean at any profile (a
tighter fab needs a wider halo; caught that a naive radius-1 shorts at 0.35 via
/ 0.20 pitch),
- the emitted via geometry (emit_routes) and the authoritative .kicad_pro rule
set (patch_project_rules now reads the fab profile).
--route-pitch default is now 0 = auto-from-fab (explicit value still overrides).
Measured (splanc_dev): default fab -> pitch 0.30, 23/48; tight 0.10/0.10 fab ->
pitch 0.20, 21/48 (finer pitch helps tracks but the wider via keep-out offsets it).
E1 is the knob; the via-in-pad / dogbone escape (E2/E3) is the real fine-pitch fix.
NOTE: DRC fast-loop re-validation of the emitted board is pending kicad-python
re-realization (the nix store paths were GC'd this session — relaunch-gated). The
writeback/DRC path is the same code as the extensively-validated default-fab run,
now reading the profile; routing side verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onstruction (FUG-138)
New pnr/route/detail/escape.py: per-pad escape planning fed into route_board.
For each routable pad, in increasing cost:
- on-layer: keep the pad-centre access cell when the net can leave laterally
(the maze then vias where it needs to — subsumes a local dog-bone);
- via-in-pad (E2): if the pad is boxed on its layer, drop a via IN the pad down
to another layer that has room — GATED by _via_clean so the via's keep-out
touches no other net's copper (a Ø0.45 via in a 0.5 mm-pitch pad field would
short its neighbours; correctly rejected there). Reserves its keep-out.
- dog-bone (E3): stub outward to an offset cell + via, with the stub path
_line_clear-checked and _reserve_line'd so it's DRC-clean like a routed seg.
Escape vias/stubs reserve their footprint in the grid up front, so the maze and
later escapes route around them — the emitted escape geometry is DRC-clean by
construction (validated: escapes ON vs OFF give identical pcbnew DRC).
Honest scope: on the congestion-limited splanc_dev the planner is ~a no-op — its
dense parts are boxed by OTHER-NET pads, where a full-size via-in-pad legitimately
can't go (needs micro-vias: set a tighter via in the fab profile, E1). It fires for
pads boxed by obstacles (plane regions / keep-outs / edges) → via down to a gap. A
dog-bone as a distinct routing CHOICE (not just a local fallback) needs
group-terminal maze routing — the documented next step. --no-escape A/B flag added.
detail_escape_test covers on-layer / via-in-pad(obstacle-boxed) / disabled. Suite
15/15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… board Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…und loop (FUG-138) Visual diagnosis (rendered the routed board) showed F.Cu nearly empty — almost all routing on B.Cu — because the pad clearance halo was VIA-sized (clearance + via_radius ~0.355mm) everywhere, walling off the pad-dense top layer for tracks and forcing routing to the back (paying via cost). A track only needs clearance + ½track (~0.205mm). Fix: two-tier halo in the grid. add_pad now reserves a narrow TRACK halo (own-net, in pad_net — a foreign track shorts the pad) and a separate wider VIA halo (in a new via_halo map — a foreign via, being fatter, must stay outside it, but a track may enter). New grid.via_passable() = passable AND clear of other nets' via_halo; the maze via move and the escape via drops use it, track moves use passable. Result: F.Cu segments 782 -> 1171 (now balanced with B.Cu 1250), routed 23 -> 25/48, and router-copper DRC 20 -> 18 (still DRC-clean by construction; validated vs kicad DRC). Also: the place<->route loop now tracks and returns the BEST round (fewest unrouted), not the last — the inflation feedback can overshoot/oscillate (19->24 seen), and returning the last round threw away the better placement. At the fixed 60x50 the board is still congested (25/48 at 12 or 40 iters); the freed top layer pays off with the rubber-band (bigger board) — testing next. Suite 15/15 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent spread (FUG-138) Rendered the routed board and diagnosed with an isolation test (PNR_DIAG_UNROUTED): 21 of 23 unrouted nets route FINE in isolation — so the board is routable; the ROUTER was giving up. Two real bugs found and fixed: 1. Negotiation was mis-designed. It rebuilt congestion incrementally in a fixed net order (so the first net never negotiated) and grew the present-sharing penalty MULTIPLICATIVELY (pres_fac *= 1.8) — which explodes, so MORE iterations made it WORSE (30 iters gave 24, fewer than 12's 26). Rewrote maze.route() as proper McMurchie-Ebeling: rip up ONE net at a time and reroute it against the OTHERS' current congestion (symmetric negotiation), with ADDITIVE penalty growth. Now more iterations help/plateau instead of diverging. 2. Greedy finalize dropped routable nets. Added iterative rip-up-&-reroute (pass 3): a still-unrouted net may cross committed copper at a rising penalty; it rips the crossed nets ONLY IF every one can reroute cleanly around the new commit (so a rip never loses a net — committed count only grows). soft-cost overlay threaded through _astar/_route_one. Result: 25 -> 30/48 at the default 60x50 board, DRC-clean by construction (detail_route_test green). The remaining 16/18 unrouted still route in isolation — more router work (deeper RRR / negotiation tuning) will close it further. Also: two-tier pad halo already freed the top layer (F.Cu balanced with B.Cu); placement `spread` knob (global overlap-term inflation, legalize capped) added but counterproductive on a fixed board (longer traces) — kept as an off-by-default knob for use with the rubber-band. --no-escape / --place-spread / PNR_DIAG_UNROUTED diagnostics added. Suite 15/15. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-limit mapped (FUG-138) Replaced the single-level "reroute-or-undo" RRR with a proper negotiated queue rip-up-&-reroute: a still-unrouted net crosses committed copper at a penalty that RISES with how often that net has itself been ripped, rips the crossed nets, and re-queues them; we snapshot the best (most-routed) DRC-clean state and return it, so churn never corrupts the result. apply_planes now sets an explicit zone clearance so a signal routed on a plane layer stays carved-clear. Extensive experiments pin the wall precisely (all measured, not guessed): - Isolation diagnostic (PNR_DIAG_UNROUTED): 16 of 18 unrouted nets route FINE alone. So the board is routable; the router can't fit them SIMULTANEOUSLY. - 4 full signal layers (plane-signals experiment) plateaus at the SAME ~30 — so it's the negotiation converging to a local optimum, not layer/space resource. - Placement spread, via-cost, more iters, bigger board, bigger rip-ups: none break past ~30 at the fixed board. The negotiation fix (prior commit) was the real lever (25->30). Net: the detailed router is genuinely improved and correct (DRC-clean by construction, suite 15/15) but tops out at 30/48 on splanc_dev. Closing to 100% needs escaping the negotiation local optimum (stronger search / routability-driven placement) and/or fixing the plane-fanout DRC to expose clean 4-layer capacity — both are substantial, well-scoped next steps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ego_order seam Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…amperage) (FUG-138) Two of the three review asks: 1. Power-domain spatial grouping (global_place). Added a plane-compactness term (minimise each plane net's pad-bbox AREA) + an inter-domain separation term (penalise overlap between different planes' bboxes), so the split planes come out compact and disjoint instead of sprawling overlapping rectangles. NOTE on splanc_dev: separation is inherently limited because H1 (a 20-pin header) exposes 4 power rails, anchoring those 4 domains to one spot — measured 42/50 power-touching parts are single-domain, but the 8 bridge parts (header + power ICs) keep the domains overlapping near H1. The compactness still shrinks each plane, and priority-carving already prevents copper shorts. Routing-neutral at the tuned weights (~29-30/48). 2. Track-width classes by type/amperage. NetClass gains current_a; a new width_for_current() sizes the trace per IPC-2221 (A=(I/(k·ΔT^0.44))^(1/0.725)), used when width_mm isn't given. compile_routing_rules resolves each class width; the router emits every net's tracks at its class width, and — crucially — a wider-than-signal net reserves a proportional TRACK HALO (maze _footprint track_halo) instead of coarsening the whole grid (a naive "pitch = widest track" collapsed routing 30->6). Verified: a 0.25 mm class emits 205 wide segs alongside 0.15 mm signals on a fine grid, DRC-clean (14 router-copper over the 66-footprint baseline); 3 A resolves to 1.37 mm correctly. Suite 15/15. (Third ask — 45° routing — is next; needs a design choice, octile A* vs corner-chamfer post-pass, flagged separately.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The maze router now takes 45° diagonal steps, not just Manhattan. A diagonal move (cost ×√2, its true length) is allowed only when BOTH orthogonal corner cells are free (no corner-cutting), and the net's footprint RESERVES those two corners — so the diagonal, and any opposite diagonal that would cross it (an X), stay DRC-clean. The A* heuristic is now octile (admissible with diagonals). Routes are tracked as edges (new _Route: cells + edges) so _to_geometry emits the exact 45° segments the router chose and the finalize/RRR reserve diagonal corners; the writeback needs no change (a diagonal track is just a track with diagonal endpoints). Measured on splanc_dev: 690 of 1750 track segments are 45° diagonals (~39%), validated DRC-clean vs kicad-cli (10 router-copper over the 66-footprint baseline — no diagonal shorts). The board now reads like hand-routed 45° work instead of staircases. Routed fraction dips 29->24/48 (the corner reservations consume grid room) — the routability-driven spreading step (next) gives that room back by moving loosely-coupled parts into the open board area. detail_maze_test's connectivity walk updated to follow diagonal adjacency. Suite 15/15. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Algorithmic place-and-route for the atopile PCBs (FUG-138)
Builds out the PnR system on the FUG-131 atopile boards per
docs/hardware/pnr-system.md. Carries the design doc plus Task 0 → Phase 3 ofthe phased plan.
Task 0 — foundations
torch(CPU) +pyyamlin the root lockfile, gated by//hardware/pnr:torch_smoke_test. Pinned<2.4so the single markerlessrequirements.lockstays CPU-only and cross-platform (2.4+ ships CUDA aarch64wheels that would break macOS resolution). Resolves to
torch==2.3.1.(repo is AGPLv3; Cypress is likely NVIDIA-source non-commercial, unverifiable
offline) — reimplement its math clean-room, the design's fallback.
Phase 1 — ingestion + constraints (
hardware/pnr/pnr/)graph.py— neutralBoardGraph(§2), stdlib-only: the JSON seam between theKiCad
pcbnewinterpreter and the torch interpreter.ingest.py—pcbnew.kicad_pcb→BoardGraph+ puredump_svg. Uses thetext-excluded footprint bbox as the courtyard (these atomic parts carry no
courtyard layer; the default bbox included silk/text and inflated parts ~5×).
constraints.py—constraints.yaml(§3) → hard barriers / soft penalties.testdata/splanc_dev/— frozen fixture: built.kicad_pcb,graph.json(79 comp / 71 net / 338 pad), hand-written
constraints.yaml.Phase 2 — placement MVP (
hardware/pnr/pnr/place/)model.py— differentiable global placement (torch, CPU, seeded, single-threaded for reproducibility): log-sum-exp wirelength + spreading + outline
containment + soft edge-align + soft grouping + keep-out penalty; Adam.
legalize.py— grid nearest-free-fit legalizer (numpy): disjoint blocks ⇒0 overlaps + in-outline by construction.
metrics.py/geometry.py/placer.py/__main__.py(runnable//hardware/pnr:place).Phase 3 — orientation search
softmax over {0,90,180,270} (deterministic Concrete/Gumbel-Softmax); pin
offsets + courtyard extents become the expected value under the distribution,
snapped to the arg-max angle at the end.
place(orient=True)by default.splanc_dev: legal (0 overlaps / 0 outside / 0 fixed-off / 0keep-out); HPWL 15832 → 2023 (position-only) → 1836 mm with orientation
(88% below the ato row); 61 parts rotated; deterministic.
Verification
bazelisk --output_base=$HOME/.cache/bazel-atopile test //hardware/pnr/...→6/6 pass (
torch_smoke_test,graph_test,constraints_test,ingest_test,placement_test,orientation_test). All prek hooks pass.Next (tracked in
hardware/pnr/WORKLOG.md)Phase 4 — FLUTE+FastRoute lookahead global route + PathFinder-history place↔route
feedback loop. Then Phase 5 detailed route + DRC + fab export. Documented MVP
simplifications: single-sided legalization (
side_prefis a soft term but partsstay top), keep-out rel-to-fixed / absolute polygon, no hMETIS partition seed yet.
Closes-Linear: FUG-138 (https://linear.app/fughilli/issue/FUG-138/hardware-build-out-pnr-system)