Restack to four signal layers and route the board

The Ti375 fanout could not be resolved with three routing layers, so L5
stops being a dedicated 1.8-V plane and becomes a signal layer.  No rail
owns a whole layer any more: the supplies are split into zone islands on
L4 and L6, L2 and L7 stay solid and completely unrouted, and F.Cu, L3, L5
and B.Cu are all available for signals.

Routing is staged rather than handed to the autorouter wholesale.  The
BGA escape, the buck high-current loops, plane stitching and the local
power completion all run first and are locked; Freerouting then sees only
the ordinary digital classes, with everything else present as fixed
copper.  finalize_routing.py installs the audited RF and USB geometry and
closes whatever the autorouter left.

Four bugs behind the routing failures, all of them the same shape - a
layer list written down instead of read from the board, which was correct
while L5 was a plane and silently wrong afterwards:

- export_routing_stage.py emitted via keepouts for three layers only, so
  every existing via was invisible to the router on L5.  It drove traces
  straight through them: 102 shorting_items in DRC.
- route_local_power.py never used L5 at all, and picked up escape vias on
  two layers instead of all four.
- add_rf_ground() judged fence positions against vias and pads but not
  tracks.  It runs after the session import, when that region already has
  routed copper, and dropped a ground via on top of a signal trace.

route_local_power.py also grew a via cloud on every rail: reaching a
plane counted as progress even when it merged nothing, so the same pad
collected a via per pass, 0.4 mm further out each time.  Progress is now
measured by the group count actually falling, and groups already sitting
on their plane are skipped.  That removed 404 redundant vias.

The MAX2771 PLL loop filter was spread across three corners of the
package, which forced the charge-pump node onto an inner layer under the
GPS front end.  All three parts now sit in the pocket at U21's north-west
corner and the net stays on F.Cu.  A permanent RF_NO_DIGITAL area plus a
scoped rule keeps that region free of non-power copper on L3/L5/L6/B.Cu,
and the local router enforces the same restriction, since it is the one
stage that runs after the temporary keepout is deleted.

Also enables the solder-mask bridge check that was switched off
board-wide; the board passes it with no violations.

Final board: DRC clean - no shorts, clearance, disallowed-item or
dangling-copper violations - with 26 unconnected endpoints left.  Routing
is spread 37% L3, 27% F.Cu, 26% L5, 10% B.Cu over 6548 mm and 971 vias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 01:41:43 +03:00
parent b706197667
commit af7c43b2a8
19 changed files with 62060 additions and 58295 deletions
+5
View File
@@ -18,8 +18,12 @@ references/official/
hardware/board_prerouted.*
hardware/board_plane_stitched.*
hardware/board_power_routed.*
hardware/board_power_local.*
hardware/board_routed.*
# KiCad writes these when it rescues a symbol; the originals are tracked.
hardware/rescue-backup/
# Routing/build workspace. Keep only the current review deliverables.
outputs/*
!outputs/README.md
@@ -27,4 +31,5 @@ outputs/*
!outputs/jlcpcb_bom.csv
!outputs/erc_final_reset.rpt
!outputs/drc_final_placement.rpt
!outputs/drc_final_routed.rpt
!outputs/board_final_reset_top.png
+13
View File
@@ -83,6 +83,19 @@ python3 scripts/generate_design_tables.py
/usr/bin/python3 scripts/generate_pcb.py
```
Routing, aynı kaynaklardan tekrar üretilebilen aşamalı bir akıştır. Sırayla
BGA escape, plane bağlantıları, buck güç katı, yerel güç tamamlama, sinyal
autorouting ve denetlenmiş RF/USB geometrisi uygulanır:
```bash
/usr/bin/python3 scripts/preroute_bga.py
/usr/bin/python3 scripts/stitch_plane_pads.py
/usr/bin/python3 scripts/route_power_stage.py
/usr/bin/python3 scripts/route_local_power.py
```
Devamı ve autorouter komutları için [routing akışı](scripts/README.md).
Kontrol:
```bash
+42 -8
View File
@@ -8,9 +8,10 @@
- `board.kicad_dru`: üreticiye ve kontrollü empedansa özgü DRC kuralları.
- `board.kicad_sym` ve `board.pretty/`: projeye özel sembol ve footprintler.
`board_prerouted`, `board_plane_stitched` ve eski `board_power_routed` dosyaları
ara çalışma ürünleridir. Güncel reset/netlist revizyonundan önce oluşturulmuş
routing dosyaları yeni tasarım için kaynak kabul edilmez.
`board_prerouted`, `board_plane_stitched`, `board_power_routed`,
`board_power_local` ve `board_routed` dosyaları scriptlerle yeniden üretilen
ara çalışma ürünleridir; kalıcı kararların kaynağı ilgili scriptler ve
`board.kicad_pcb` dosyasıdır.
## Güç mimarisi
@@ -87,11 +88,44 @@ yoktur.
route veya via kullanılmaz.
- USB2 diferansiyel çift 0.135 mm hat ve 0.150 mm coupled gap kuralındadır.
- RF ve USB geometrisi `scripts/finalize_routing.py` tarafından deterministik
olarak kurulur; Freerouter çıktısındaki kritik hatlar kullanılmaz.
- L2 ve L7 kesintisiz GND referans katmanlarıdır. L4 bölünmüş DC güç katmanıdır.
olarak kurulur; bu netler autorouter'a hiç verilmez. Yerlerini routing
boyunca `TEMP_RF_LINE` ve `TEMP_USB_*` koridor rule area'ları tutar; bunlar
F.Cu dahil bütün bakır katmanlarını kapsar ve finalde silinir.
- GPS ön ucunun altında kalıcı bir `RF_NO_DIGITAL` rule area'sı vardır (L3, L5,
L6, B.Cu). Kendisi bakır yasaklamaz; `board.kicad_dru` içindeki kural POWER
dışı net sınıflarına track ve via yasağı koyar. Böylece ground stitching ve
MAX2771'in kendi besleme inişleri geçebilirken, RF hattının altına veya hemen
yanına sonradan elle bir digital hat çekilmesi DRC hatası olur.
- L2 ve L7 kesintisiz GND referans katmanlarıdır ve hiçbir şey route edilmez;
dört routing katmanının her birinin komşusunda bir tanesi bulunur.
- Hiçbir ray tek başına bir katman harcamaz. DC güç L4 ve L6'ya bölünmüş zone
adaları olarak dağıtılır: L4 çekirdek, 5 V giriş, always-on yardımcı ray ve
dört adet 3.0 V ön uç rayını; L6 iki büyük FPGA I/O rayını (`VCC3V3` zemin,
`VCC1V8` kendi yüklerinin üstünde oyulmuş) taşır.
- Bu sayede F.Cu, L3, L5 ve B.Cu dört sinyal katmanı olarak kalır. Ti375'in
fanout'u üç iç/alt katmana (L3, L5, B.Cu) dağıtılır, F.Cu ise BGA escape'i ve
RF ön ucunu taşır.
- Plane'i olmayan küçük raylar ve plane adasının dışında kalan güç pad'leri
`scripts/route_local_power.py` tarafından kilitli, 45 derecelik kısa yollarla
tamamlanır. Bu iş autorouter'a bırakılmaz.
## Güç dağıtımı kuralı
Güç raylarının hiçbiri autorouter'a verilmez. Sırasıyla plane, plane stitching
ve `route_local_power.py` kullanılır; ancak plane'i olmayan küçük raylar
(`FTDI_1V8`, `FTDI_VPHY`, `FTDI_VPLL`, `VBUS_RAW`, `USB5_PROT`, `EXT5_PROT`,
`ANT_BIAS_SW`) `--extra-nets` ile sinyal katına dahil edilir. Bunun nedeni
Specctra DSN'inin plane içermemesidir: `GND` veya `VCC3V3` autorouter'a
verilseydi, plane'in zaten birleştirdiği yüzlerce pad'i trace ile bağlamaya
çalışırdı.
`board.kicad_pcb` üzerindeki `TEMP_` rule area'ları yalnızca routing süresince
vardır ve `finalize_routing.py` tarafından silinir. RF bölgesindeki GND pad'leri
ile USB koridorundaki güç pad'leri de bu nedenle ancak final aşamada bağlanır.
## Üretime çıkış şartı
Bu revizyonda placement kontrolleri geçmiştir fakat `board.kicad_pcb` üzerinde
track yoktur. Üretim öncesinde routing akışı tamamlanmalı; zone'lar doldurulmalı;
ERC, DRC, schematic parity, Gerber ve drill incelemesi tekrar yapılmalıdır.
Routing akışı tamamlandıktan sonra zone'lar doldurulmalı; ERC, DRC, schematic
parity, Gerber ve drill incelemesi tekrar yapılmalıdır. Final kartta açık
bağlantı, short, clearance ve isolated copper kabul edilmez; RF uzunlukları,
USB eşleşmesi, güç loopları ve return path görsel olarak da incelenmelidir.
+25 -8
View File
@@ -17,13 +17,17 @@
(condition "A.Reference == 'U13' && B.Reference == 'U13'")
(constraint clearance (min 0.10mm)))
# A 0.45-mm dogbone via at the centre of four 0.8-mm-pitch BGA lands has
# 0.1407-mm geometric clearance. This local rule is still well above the
# selected fabricator's 0.10-mm copper minimum; clocks retain 0.15 mm outside
# the U1 escape field.
(rule "Ti375 0.8-mm BGA dogbone clearance"
# Inside the Ti375 escape field the 0.8-mm ball pitch fixes the geometry: a
# 0.45-mm dogbone via between four lands leaves 0.1407 mm, and a 0.12-mm trace
# threading between two lands leaves 0.14 mm only if it stays exactly centred.
# This field therefore runs at the fabricator's 0.10-mm copper minimum, which
# is what the Default class uses across the rest of the board anyway. The rule
# only fires when one of the two objects belongs to U1, so it relaxes copper
# against the balls and nothing else: two tracks crossing the field still owe
# each other their own net classes' clearance, CLOCK's 0.15 mm included.
(rule "Ti375 0.8-mm BGA escape clearance"
(condition "A.Reference == 'U1' || B.Reference == 'U1'")
(constraint clearance (min 0.14mm)))
(constraint clearance (min 0.10mm)))
# JLC08161H-3313 L1/L2 geometry. Net-class widths are routing defaults only;
# these rules make the controlled-impedance geometries actual DRC limits.
@@ -39,9 +43,22 @@
(condition "A.NetClass == 'USB90_DIFF'")
(constraint track_width (min 0.135mm) (opt 0.135mm) (max 0.135mm)))
# The TPD2EUSB30A clamp is a SOT-23 whose ground pad sits between its two
# channel pads, so the pair has to open up for about 1.5 mm to pass it. The
# 0.90-mm coupled limit still applies over the whole rest of the run.
(rule "USB2 coupled gap"
(condition "A.NetClass == 'USB90_DIFF' && AB.isCoupledDiffPair()")
(constraint diff_pair_gap (min 0.15mm) (opt 0.15mm) (max 0.15mm)))
(condition "A.NetClass == 'USB90_DIFF' && AB.isCoupledDiffPair() && !A.intersectsArea('USB_ESD_BREAKOUT')")
(constraint diff_pair_gap (min 0.15mm) (opt 0.15mm) (max 0.90mm)))
# The GPS front end owns its region on every layer under F.Cu, so no digital
# route can run beneath or immediately beside the 50-ohm line and no return
# path there is broken. Ground stitching and the MAX2771's own supply drops
# still have to reach it, so the disallow is scoped to the signal classes
# rather than made a plain keepout. The RF line itself is on F.Cu, which the
# area does not cover, and L2/L7 stay solid because nothing routes on them.
(rule "No digital copper under the GNSS front end"
(condition "A.intersectsArea('RF_NO_DIGITAL') && A.NetClass != 'POWER'")
(constraint disallow track via))
(rule "Clock trunk width outside Ti375 escape"
(condition "A.NetClass == 'CLOCK' && A.Type == 'Track' && !A.insideCourtyard('U1')")
+58498 -56948
View File
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -7,12 +7,25 @@ anlamlı teslim çıktıları tutulur:
- `jlcpcb_bom.csv`: montaj servisi için sadeleştirilmiş BOM.
- `erc_final_reset.rpt`: güncel şema ERC raporu; 0 ihlal.
- `drc_final_placement.rpt`: unrouted placement DRC raporu.
- `drc_power_local.rpt`: sinyal routing'inden önceki güç katı DRC raporu.
- `drc_final_routed.rpt`: routed kart DRC raporu.
- `board_final_reset_top.png`: güncel üst görünüm.
Eski `.dsn`, `.ses`, preview PCB ve ara DRC raporları yeni netlist için
authoritative değildir. Routing her netlist/placement değişiminden sonra güncel
`hardware/board.kicad_pcb` üzerinden yeniden başlatılmalıdır.
Routing çalışma dosyaları:
Güncel placement raporundaki 12 `isolated_copper` ve 499 unconnected kayıt,
kartın henüz route edilmemiş olduğunu gösterir. Bunlar üretime hazır olunduğu
anlamına gelmez; final routed raporda sıfır açık bağlantı hedeflenir.
- `signal_stage.dsn` / `signal_stage.ses`: autorouter'a verilen ve ondan alınan
sinyal katı. Sadece `board_power_local.kicad_pcb` ile birlikte anlamlıdır.
- `signal_stage_preview.kicad_pcb`: DSN'e giren bakırın KiCad'de incelenebilir
hâli.
- `freerouting_signal.log`: pass başına unrouted/violation sayısı.
Eski `.dsn`, `.ses`, preview PCB ve ara DRC raporları yeni netlist için
authoritative değildir. `.dsn` her zaman onu üreten karta bağlıdır: placement,
güç bakırı veya keepout değiştiğinde export ve routing baştan yapılmalıdır,
yoksa import edilen sonuç mevcut bakırla çakışır.
Final routed raporda sıfır açık bağlantı, sıfır clearance/short ve sıfır
schematic-parity hatası hedeflenir. Unrouted aşamadaki `unconnected_items`,
`via_dangling` ve `isolated_copper` kayıtları normaldir: sırasıyla henüz
çekilmemiş bağlantıları, henüz kullanılmayan BGA escape via'larını ve final
fill'de silinecek plane parçalarını gösterir.
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
** Drc report for board_routed.kicad_pcb **
** Created on 2026-08-16T01:33:28 **
** Report includes: Errors, Warnings **
** Found 0 DRC violations **
** Found 26 unconnected pads **
[unconnected_items]: Missing connection between items
Local override; error
@(106.4000 mm, 65.6000 mm): Pad D10 [MAX_I0] of U1 on F.Cu
@(154.6000 mm, 49.6900 mm): Pad 2 [MAX_I0] of R46 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(28.1950 mm, 51.2000 mm): Track [GND] on F.Cu, length 5.8400 mm
@(30.8500 mm, 51.9250 mm): Pad 2 [GND] of U6 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(175.0000 mm, 46.2500 mm): Pad 2 [GND] of J2 on B.Cu
@(175.0000 mm, 46.2500 mm): Pad 2 [GND] of J2 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(106.8000 mm, 64.4000 mm): Track [VCC3V0_DIG] on L3_SIG, length 1.4764 mm
@(112.0000 mm, 68.8000 mm): Pad H17 [VCC3V0_DIG] of U1 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(112.0000 mm, 68.8000 mm): Pad H17 [VCC3V0_DIG] of U1 on F.Cu
@(137.0000 mm, 70.0000 mm): Track [VCC3V0_DIG] on F.Cu, length 0.9050 mm
[unconnected_items]: Missing connection between items
Local override; error
@(153.0459 mm, 42.6000 mm): Track [VCC3V0_RF] on F.Cu, length 2.3988 mm
@(153.1000 mm, 44.0000 mm): Pad 2 [VCC3V0_RF] of U21 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(150.4900 mm, 44.5000 mm): Pad 1 [MAX_CLKOUT_SRC] of R45 on F.Cu
@(153.1000 mm, 45.0000 mm): Pad 4 [MAX_CLKOUT_SRC] of U21 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(122.0100 mm, 65.5000 mm): Pad 2 [MAX_SCLK_DEV] of R51 on F.Cu
@(154.0000 mm, 47.4000 mm): Pad 8 [MAX_SCLK_DEV] of U21 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(155.0000 mm, 47.4000 mm): Pad 10 [MAX_Q1_DEV] of U21 on F.Cu
@(158.2000 mm, 50.7100 mm): Pad 1 [MAX_Q1_DEV] of R49 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(37.5251 mm, 104.2026 mm): Track [EXT5_PROT] on F.Cu, length 1.3496 mm
@(42.2500 mm, 104.0000 mm): Pad 6 [EXT5_PROT] of U8 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(44.2500 mm, 59.0000 mm): Pad 24 [FTDI_B_TDO_SRC] of U2 on F.Cu
@(96.5100 mm, 70.0000 mm): Pad 2 [FTDI_B_TDO_SRC] of R73 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(47.0000 mm, 56.7500 mm): Pad 32 [FTDI_C_TXD_SRC] of U2 on F.Cu
@(62.8625 mm, 54.8250 mm): Pad 15 [FTDI_C_TXD_SRC] of U29 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(47.0000 mm, 51.7500 mm): Pad 42 [FTDI_D_TXD_SRC] of U2 on F.Cu
@(62.8625 mm, 53.5250 mm): Pad 17 [FTDI_D_TXD_SRC] of U29 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(105.6000 mm, 65.6000 mm): Track [VCC3V3] on F.Cu, length 0.5657 mm
@(108.8000 mm, 64.0000 mm): Track [VCC3V3] on F.Cu, length 0.5657 mm
[unconnected_items]: Missing connection between items
Local override; error
@(110.4000 mm, 67.2000 mm): Pad F15 [VCC3V3] of U1 on F.Cu
@(112.0000 mm, 75.2000 mm): Pad T17 [VCC3V3] of U1 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(110.4000 mm, 67.2000 mm): Track [VCC3V3] on F.Cu, length 0.5657 mm
@(108.8000 mm, 64.0000 mm): Track [VCC3V3] on F.Cu, length 0.5657 mm
[unconnected_items]: Missing connection between items
Local override; error
@(112.8000 mm, 76.8000 mm): Track [VCC3V3] on F.Cu, length 0.5657 mm
@(112.0000 mm, 75.2000 mm): Track [VCC3V3] on F.Cu, length 0.5657 mm
[unconnected_items]: Missing connection between items
Local override; error
@(113.2000 mm, 77.2000 mm): Via [VCC3V3] on F.Cu - B.Cu
@(119.3969 mm, 80.9333 mm): Track [VCC3V3] on F.Cu, length 0.3913 mm
[unconnected_items]: Missing connection between items
Local override; error
@(111.2000 mm, 67.2000 mm): Pad F16 [MAX_SCLK] of U1 on F.Cu
@(120.9900 mm, 65.5000 mm): Pad 1 [MAX_SCLK] of R51 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(32.5100 mm, 58.0000 mm): Pad 2 [FTDI_OSCI] of R67 on F.Cu
@(39.0000 mm, 52.7500 mm): Pad 3 [FTDI_OSCI] of U2 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(39.0000 mm, 55.7500 mm): Pad 9 [FTDI_VPLL] of U2 on F.Cu
@(47.7170 mm, 58.2000 mm): Track [FTDI_VPLL] on L5_SIG, length 10.0820 mm
[unconnected_items]: Missing connection between items
Local override; error
@(106.4000 mm, 68.0000 mm): Pad G10 [HARD_TCK] of U1 on F.Cu
@(96.9700 mm, 65.7400 mm): Track [HARD_TCK] on F.Cu, length 0.6505 mm
[unconnected_items]: Missing connection between items
Local override; error
@(107.2000 mm, 67.2000 mm): Pad F11 [MAX_LD] of U1 on F.Cu
@(160.5000 mm, 46.9900 mm): Pad 2 [MAX_LD] of R50 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(83.0800 mm, 24.0000 mm): PTH pad 3 [PMOD1_3] of JPMOD1
@(108.8000 mm, 63.2000 mm): Pad A13 [PMOD1_3] of U1 on F.Cu
[unconnected_items]: Missing connection between items
Local override; error
@(112.0000 mm, 63.2000 mm): Pad A17 [PMOD3_8] of U1 on F.Cu
@(116.5400 mm, 26.5400 mm): PTH pad 8 [PMOD3_8] of JPMOD3
[unconnected_items]: Missing connection between items
Local override; error
@(111.2000 mm, 68.0000 mm): Pad G16 [MAX_SDATA] of U1 on F.Cu
@(120.9900 mm, 67.9000 mm): Pad 1 [MAX_SDATA] of R53 on F.Cu
** Found 0 Footprint errors **
** Ignored checks **
- Footprint has no courtyard defined
- Track endpoint not centered on via
- Tuning profile track geometries
- Footprint doesn't match symbol's footprint filters
- Footprint component type doesn't match footprint pads
** End of Report **
+2 -2
View File
@@ -1,5 +1,5 @@
ERC report (2026-08-15T18:09:04, Encoding UTF8)
Report includes: Errors, Warnings, Exclusions
ERC report (2026-08-16T01:41:08, Encoding UTF8)
Report includes: Errors, Warnings
***** Sheet /
+109 -29
View File
@@ -60,70 +60,147 @@ beklenmez.
Ti375'in bağlı ball'larına kilitli 0.8 mm pitch dogbone/via escape ekler.
### 2. Yerel plane bağlantıları
### 2. Güç katı
```bash
/usr/bin/python3 scripts/route_power_stage.py \
hardware/board_prerouted.kicad_pcb \
hardware/board_power_routed.kicad_pcb
```
Autorouter'dan önce aşağıdaki yüksek akım hatlarını deterministik ve kilitli
olarak route eder:
- TPS6287B15 giriş kapasitörü, SW, indüktör, çıkış kapasitörü ve remote-sense.
- TPS62867 1.8 V / 3.3 V giriş-SW-çıkış döngüleri ve plane via dizileri.
- Converter bölgesindeki SYS5 dağıtımı ve ana güç geçişleri.
SW node bakırı küçük tutulmalı; feedback/sense hatları SW ve indüktörden uzak,
Kelvin bağlantılı götürülmelidir. Autorouter bu kararları vermez.
Bu aşama stitching'den **önce** gelir. Tersi sırada plane stitching, buck
çıkışının geçmesi gereken koridora via bırakıp yüksek akım yolunu kesiyordu.
### 3. Yerel plane bağlantıları
```bash
/usr/bin/python3 scripts/stitch_plane_pads.py \
hardware/board_prerouted.kicad_pcb \
hardware/board_power_routed.kicad_pcb \
hardware/board_plane_stitched.kicad_pcb
```
Yalnızca altında gerçekten aynı nete ait dolu plane bulunan SMD güç/GND
padlerini kısa dogbone ile bağlar. Bu işlem yüksek akım buck loop routinginin
yerini tutmaz.
yerini tutmaz. RF/USB geçici keepoutlarına through-via koymaz; edge-launch J2
ground bağlantıları final RF ground zone/via-fence aşamasına bırakılır.
### 3. Güç katı
### 4. Yerel güç tamamlama
KiCad içinde önce aşağıdaki hatlar elle route edilmelidir:
```bash
/usr/bin/python3 scripts/route_local_power.py \
hardware/board_plane_stitched.kicad_pcb \
hardware/board_power_local.kicad_pcb --clocks
```
- TPS6287B15 giriş kapasitörü, SW, indüktör, çıkış kapasitörü ve remote-sense.
- TPS62867 1.8 V ve 3.3 V converter loopları.
- SYS5 dağıtımı ve ana güç geçişleri.
- LDO giriş/çıkış bypass bağlantıları.
`--clocks` clock sınıfını da burada, autorouter başlamadan kapatır; böylece
saat hatları sabit bakır olarak DSN'e girer ve router onları bozamaz.
SW node bakırı küçük tutulmalı; feedback/sense hatları SW ve indüktörden uzak,
Kelvin bağlantılı götürülmelidir. Autorouter bu kararları vermez.
Plane ve plane stitching'in ulaşamadığı güç bağlantılarını tamamlar: adasının
hemen dışında kalan pad'ler, plane'i olmayan küçük raylar (`FTDI_1V8`,
`IMU_VREGD`, anten bias hatları) ve her LDO'nun etrafındaki yerel giriş/çıkış
döngüleri. Gerçek connectivity grafiği üzerinde çalışır: ayrık kalan her grup
için ya kendi plane'ine via indirir ya da en yakın gruba 45 derecelik yol
çeker. Bütün sonuçlar kilitlidir.
### 4. Freerouting için sınıf bazlı DSN
İki fazlıdır. Önce bütün raylar için yalnızca plane inişi ve kısa doğrudan
yollar denenir; ikinci fazda kalanlar ara nokta üzerinden dolaşabilir. Bu ayrım
olmadan listedeki ilk ray, sonraki bir rayın kısa bağlantı için ihtiyaç duyduğu
boşluğu uzun yollarla harcar.
Bir grubu kendi plane'ine indirmek, o adada başka grup yoksa grup sayısını
düşürmez. Bu yüzden ilerleme, `reach_plane`'in döndürdüğü değere değil grup
sayısının gerçekten azalmasına bakılarak ölçülür ve zaten plane'e bağlı gruplar
atlanır. Aksi halde aynı pad çevresinde her turda 0.4 mm daha dışarıda yeni bir
via açılır ve ray tek bir pad'in etrafında onlarca gereksiz via'dan oluşan bir
"via cloud" ile biter.
Bu aşama güç raylarını autorouter'a bırakmamanın karşılığıdır. Freerouting
DSN'de plane göremez; `GND` ve `VCC3V3` gibi rayları ona verseydik yüzlerce
pad'i plane yerine trace ile birbirine bağlamaya çalışırdı.
### 5. Freerouting için sınıf bazlı DSN
```bash
/usr/bin/python3 scripts/export_routing_stage.py \
hardware/board_plane_stitched.kicad_pcb outputs/signal_stage.dsn \
hardware/board_power_local.kicad_pcb outputs/signal_stage.dsn \
--classes "Default;BGA_ESCAPE;CLOCK" \
--preview outputs/signal_stage_preview.kicad_pcb
python3 scripts/prepare_freerouting_dsn.py \
outputs/signal_stage.dsn outputs/signal_stage_freerouting.dsn
--extra-nets "ANT_BIAS_SW;EXT5_PROT;FTDI_1V8;FTDI_VPHY;FTDI_VPLL;USB5_PROT;VBUS_RAW" \
--fixed-copper keepout
```
İkinci script Freerouting 2.3.0/KiCad 10 DSN clearance ölçek farkını düzeltir;
koordinatlara, track genişliklerine, via veya keepoutlara dokunmaz. Freerouting
arayüzünde düzeltilmiş DSN açılır, en fazla 8 thread kullanılır ve sonuç `.ses`
olarak kaydedilir.
`--extra-nets`, POWER sınıfında olduğu için sınıf filtresine takılan ama
plane'i de bulunmayan rayları listeler; yerel tamamlama bunlardan artakalan
bağlantıları autorouter'a devreder.
RF, USB, güç converter loopları ve hassas clock kararları otomatik sonuca
bırakılmamalıdır. `to route` sayısının uzun süre düşmemesi halinde aynı işlemi
saatlerce sürdürmek yerine placement/escape/keepout çatışması incelenmelidir.
Route edilmeyen netlerin bakırı `--fixed-copper keepout` ile her parça için bir
rule area olarak aktarılır: router o bakırı görmez, yalnızca etrafından dolaşır.
`--fixed-copper wire` alternatifi bakırı kendi netinde `(type fix)` wire olarak
yazar ve netleri network bölümünde pinsiz tanımlar; daha az alan üretir ama bu
kartta pass başına yaklaşık dört kat yavaş çalışır.
### 5. Kritik hatları kur ve session'ı sonlandır
Buradaki alan sayısı doğrudan yerel güç aşamasının ne kadar bakır bıraktığına
bağlıdır: via cloud'lar temizlendikten sonra 4146 keepout 2541'e düştü.
Bundled Freerouting 2.3.0 build'i KiCad 10 DSN clearance değerlerini doğrudan
doğru ölçekte okur; clearance değerleri ölçeklenmez. Bu sürüm çok-thread route
optimizasyonunun clearance ihlali üretebildiğini kendi logunda bildirir, bu
nedenle `-mt 1` kullanılır ve sonuç `.ses` olarak kaydedilir.
```bash
.tools/freerouting-2.3.0/app/freerouting-2.3.0-linux-x64/bin/freerouting \
-de outputs/signal_stage.dsn -do outputs/signal_stage.ses \
-mp 60 -mt 1 -us Greedy -is prioritized
```
RF, USB ve güç converter loopları otomatik sonuca bırakılmaz. Bu netler DSN'de
hiç yoktur; yerlerini `generate_pcb.py` içindeki `TEMP_` rule area'ları tutar.
`TEMP_*_BELOW` alanları RF ve USB altındaki referans/routing katmanlarını,
`TEMP_RF_LINE` ve `TEMP_USB_*` koridorları ise `finalize_routing.py`'nin
kuracağı geometrinin F.Cu izini korur. CLOCK sınıfı kendi genişlik/clearance
kurallarıyla route edilir ve finalde ayrıca incelenir.
### 6. Kritik hatları kur ve session'ı sonlandır
```bash
/usr/bin/python3 scripts/finalize_routing.py \
outputs/signal_stage.ses \
--base hardware/board_power_local.kicad_pcb \
--output hardware/board_routed.kicad_pcb
```
Bu işlem Freerouter'ın RF ve USB üzerindeki copperını kaldırır, denetlenmiş
RF/USB geometrisini ekler, RF GND via fence'i kurar ve zone'ları doldurur.
Bu işlem Freerouter'ın RF ve USB üzerindeki copperını kaldırır, geçici rule
area'ları siler, denetlenmiş RF/USB geometrisini ekler ve RF GND via fence'i
kurar. Ardından koridorlar boşaldığı için `stitch_plane_pads` ve
`route_local_power` bir kez daha çalıştırılır; bu, RF/USB bölgesindeki güç
pad'lerinin plane'e ulaşabildiği tek andır. Son olarak plane'lerde island
temizliği açılır ve zone'lar doldurulur.
### 6. Final kontroller
### 7. Final kontroller
```bash
kicad-cli pcb drc --severity-all --schematic-parity --refill-zones \
--save-board -o outputs/drc_final_routed.rpt \
hardware/board_routed.kicad_pcb
/usr/bin/python3 scripts/report_routing.py hardware/board_routed.kicad_pcb
```
DRC "yanlış olan var mı" sorusunu, `report_routing.py` ise "eksik olan var mı
ve bakır kurallarına uyuyor mu" sorusunu yanıtlar: hâlâ birden fazla parçaya
bölünmüş netleri, net-class başına gerçekten kullanılan hat genişliğini
(Specctra gidiş-dönüşünde sessizce değişebilen değer) ve katman dağılımını
listeler.
Final kartta unconnected item, short, clearance, isolated copper veya
schematic-parity hatası kabul edilmez. RF uzunlukları, USB eşleşmesi, güç
loopları, return path ve test-point erişimi görsel olarak da incelenmelidir.
@@ -139,6 +216,9 @@ loopları, return path ve test-point erişimi görsel olarak da incelenmelidir.
| `generate_pcb.py` | Stackup, rule, zone ve deterministic placement üretimi |
| `preroute_bga.py` | Kilitli Ti375 dogbone escape |
| `stitch_plane_pads.py` | Yerel pad-to-plane bağlantıları |
| `route_power_stage.py` | Buck yüksek-akım döngüleri ve plane girişleri |
| `route_local_power.py` | Plane'e ulaşamayan güç bağlantılarının deterministik tamamlanması |
| `export_routing_stage.py` | Seçili net-class için DSN üretimi |
| `prepare_freerouting_dsn.py` | Freerouting 2.3 clearance düzeltmesi |
| `prepare_freerouting_dsn.py` | Yalnız doğrulanmış farklı router build'leri için opsiyonel DSN clearance ölçekleme |
| `finalize_routing.py` | SES importu ve denetlenmiş RF/USB geometrisi |
| `report_routing.py` | Routed kartta eksik bağlantı, hat genişliği ve katman dağılımı raporu |
+176 -3
View File
@@ -4,11 +4,103 @@
from __future__ import annotations
import argparse
import re
from pathlib import Path
import pcbnew
NET_TOKEN = re.compile(r'\(net\s+("[^"]+"|[^\s()]+)')
RULE_WIDTH = re.compile(r'(\(width\s+)(\d+(?:\.\d+)?)')
# Keepouts describe the copper outline itself and nothing more. Freerouting
# already applies the routing clearance to a keepout boundary, so any envelope
# added here is counted twice: measured against a 0.10-mm envelope it kept
# 0.20 mm, against plain wiring 0.102 mm. In the Ti375 escape field that is
# the difference between a usable 0.385-mm channel and a 0.485-mm one that
# does not exist, and 184 of the 261 escape vias are power nets exported this
# way, so the doubled figure sealed the array shut.
OBSTACLE_CLEARANCE_MM = 0.0
def restore_fixed_net_declarations(dsn: Path) -> list[str]:
"""Declare fixed-copper-only nets without adding their pads to routing."""
source = dsn.read_text()
network_start = source.index(" (network\n")
wiring_start = source.index(" (wiring\n")
network = source[network_start:wiring_start]
wiring = source[wiring_start:]
declared = set(NET_TOKEN.findall(network))
used_by_copper = set(NET_TOKEN.findall(wiring))
missing = sorted(used_by_copper - declared)
if not missing:
return []
declarations = "".join(f" (net {name})\n" for name in missing)
insertion = network_start + len(" (network\n")
dsn.write_text(source[:insertion] + declarations + source[insertion:])
return missing
def inject_obstacles(dsn: Path, obstacles: list[str]) -> None:
"""Insert physical keepouts for copper omitted from the routing nets."""
if not obstacles:
return
source = dsn.read_text()
structure = source.index(" (structure\n")
insertion = source.index(" (via ", structure)
rendered = "".join(f" {item}\n" for item in obstacles)
dsn.write_text(source[:insertion] + rendered + source[insertion:])
def scale_route_rule_widths(dsn: Path, factor: float = 1.0) -> int:
"""Optionally rescale the routed widths the DSN asks Freerouting for.
The default is 1.0 because Freerouting 2.3.0 honours the DSN rule width
exactly: a `(width 120)` rule produces 0.12-mm copper. An earlier 4/3
factor here, meant to compensate a supposed 0.75x output, instead made
every autorouted trace a third too wide, and that is fatal in the Ti375
escape field. Its via grid is on a 0.8-mm pitch, so a trace threading
between two 0.45-mm vias has 0.400 mm to work with: a 0.12-mm trace at
0.10-mm clearance needs 0.385 mm and fits, a 0.16-mm one needs 0.405 mm
and cannot be placed legally at all. Verify with report_routing.py after
every import: the width per net class must equal the net class setting.
"""
source = dsn.read_text()
def scale(match: re.Match[str]) -> str:
value = float(match.group(2)) * factor
rendered = str(int(round(value)))
return match.group(1) + rendered
converted, count = RULE_WIDTH.subn(scale, source)
dsn.write_text(converted)
return count
def enforce_router_clearance_floor(dsn: Path, minimum_um: float = 100) -> int:
"""Hold every routed class to at least the board's minimum copper clearance.
100 um is that minimum, and it is also what the Default and BGA_ESCAPE
classes already ask for, so this normally changes nothing. It must not be
raised to the 0.14 mm of the U1 dogbone rule: that rule governs copper
against Ti375 *lands*, and applying it to via-to-trace spacing as well
closes every channel through the escape field.
"""
source = dsn.read_text()
pattern = re.compile(r'(\(clearance\s+)(\d+(?:\.\d+)?)(\))')
changed = 0
def raise_value(match: re.Match[str]) -> str:
nonlocal changed
value = float(match.group(2))
if value >= minimum_um:
return match.group(0)
changed += 1
return f"{match.group(1)}{minimum_um:g}{match.group(3)}"
dsn.write_text(pattern.sub(raise_value, source))
return changed
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("board", type=Path)
@@ -17,6 +109,20 @@ def main() -> None:
help="Exact KiCad net-class names separated by ';' (or ',' for legacy single names)")
parser.add_argument("--preview", type=Path,
help="Optional filtered KiCad board for inspection")
parser.add_argument("--fixed-copper", choices=("wire", "keepout"),
default="wire",
help="How copper on non-routed nets is presented to the "
"router (see comment in the source)")
parser.add_argument("--only-nets", default="",
help="Route just these nets, separated by ';'. Every "
"other net becomes fixed copper. Used for a "
"focused second round over the connections the "
"first one left open.")
parser.add_argument("--extra-nets", default="",
help="Additional net names to route, separated by ';'. "
"Used for the few POWER-class rails that have no "
"plane and that route_local_power.py could not "
"finish with a short local path.")
args = parser.parse_args()
# KiCad can report a compound effective class such as "CLOCK,POWER" when
@@ -27,24 +133,91 @@ def main() -> None:
board = pcbnew.LoadBoard(str(args.board))
kept_pads = 0
omitted_pads = 0
kept_nets: set[str] = set()
extra = {name.strip() for name in args.extra_nets.split(";") if name.strip()}
only = {name.strip() for name in args.only_nets.split(";") if name.strip()}
kept_nets = {
pad.GetNetname()
for fp in board.GetFootprints()
for pad in fp.Pads()
if pad.GetNetname() and (pad.GetNetClassName() in keep
or pad.GetNetname() in extra)
}
if only:
kept_nets &= only
kept_nets |= only
for fp in board.GetFootprints():
for pad in fp.Pads():
if not pad.GetNetname():
continue
if pad.GetNetClassName() in keep:
if pad.GetNetname() in kept_nets:
kept_pads += 1
kept_nets.add(pad.GetNetname())
else:
pad.SetNetCode(0)
omitted_pads += 1
# Copper on omitted nets must stay a physical obstacle without becoming
# something the router tries to connect. Two encodings are available.
#
# "wire" is the default and much the faster of the two: the copper keeps
# its net, so KiCad exports it as ordinary `(type fix)` wiring, and
# restore_fixed_net_declarations() then declares those nets in the network
# section with no pins. Freerouting therefore has no connection to route
# on them and simply routes around the fixed copper.
#
# "keepout" strips the net and emits an expanded envelope per item instead.
# It is unambiguous but produces thousands of small rule areas, which slows
# every routing pass down considerably. Kept for comparison runs.
obstacles: list[str] = []
# A through via blocks every routing layer, so the layer list has to come
# from the board rather than be written out here: hard-coding it meant that
# promoting L5 from a power plane to a signal layer left every existing via
# invisible on L5, and the router happily drove traces straight through
# them. Freerouting only routes layers it was told are signal layers, so
# that set is exactly the one that needs a keepout.
routing_layers = [layer for layer in board.GetEnabledLayers().CuStack()
if board.GetLayerType(layer) == pcbnew.LT_SIGNAL]
for item in board.GetTracks():
if not item.GetNetname() or item.GetNetname() in kept_nets:
continue
if args.fixed_copper == "wire":
continue
if isinstance(item, pcbnew.PCB_VIA):
diameter = pcbnew.ToMM(item.GetWidth(pcbnew.F_Cu)) + 2 * OBSTACLE_CLEARANCE_MM
position = item.GetPosition()
x, y = pcbnew.ToMM(position.x), -pcbnew.ToMM(position.y)
for layer in routing_layers:
obstacles.append(
f'(keepout "" (circle {board.GetLayerName(layer)} '
f'{diameter * 1000:g} {x * 1000:g} {y * 1000:g}))'
)
else:
start, end = item.GetStart(), item.GetEnd()
width = pcbnew.ToMM(item.GetWidth()) + 2 * OBSTACLE_CLEARANCE_MM
obstacles.append(
f'(keepout "" (path {board.GetLayerName(item.GetLayer())} '
f'{width * 1000:g} {pcbnew.ToMM(start.x) * 1000:g} '
f'{-pcbnew.ToMM(start.y) * 1000:g} {pcbnew.ToMM(end.x) * 1000:g} '
f'{-pcbnew.ToMM(end.y) * 1000:g}))'
)
item.SetNetCode(0)
for zone in list(board.Zones()):
if not zone.GetIsRuleArea():
board.Remove(zone)
board.BuildListOfNets()
if args.preview:
pcbnew.SaveBoard(str(args.preview), board)
if not pcbnew.ExportSpecctraDSN(board, str(args.dsn)):
raise RuntimeError(f"Failed to export {args.dsn}")
inject_obstacles(args.dsn, obstacles)
scaled_widths = scale_route_rule_widths(args.dsn)
raised_clearances = enforce_router_clearance_floor(args.dsn)
restored = restore_fixed_net_declarations(args.dsn)
print(f"Classes: {', '.join(sorted(keep))}")
print(f"Kept {kept_pads} pads on {len(kept_nets)} nets; omitted {omitted_pads} pads")
print(f"Fixed copper as {args.fixed_copper}: {len(obstacles)} keepouts inserted")
print(f"Rewrote {scaled_widths} route-rule widths")
print(f"Raised {raised_clearances} route clearances to the board minimum")
print(f"Declared {len(restored)} fixed-copper-only nets")
print(f"Saved {args.dsn}")
+241 -22
View File
@@ -15,8 +15,11 @@ from pathlib import Path
import pcbnew
import route_local_power
import stitch_plane_pads
ROOT = Path(__file__).resolve().parents[1]
PLACED = ROOT / "hardware" / "board.kicad_pcb"
POWER_LOCAL = ROOT / "hardware" / "board_power_local.kicad_pcb"
ROUTED = ROOT / "hardware" / "board_routed.kicad_pcb"
RF_NETS = {"RF_SMA", "RF_PRE_SAW", "RF_POST_SAW", "MAX_MIXIN_HI"}
@@ -119,11 +122,12 @@ def route_rf(board: pcbnew.BOARD) -> None:
w = 0.164
j2 = pad_xy(board, "J2", "1")
j2_inner = (j2[0] - 0.50, j2[1])
dc_block_1 = pad_xy(board, "C83", "1")
l4_2 = pad_xy(board, "L4", "2")
add_polyline(board, "RF_SMA", [j2, l4_2, dc_block_1], w)
add_polyline(board, "RF_SMA", [j2_inner, (170.10, 42.09), l4_2, dc_block_1], w)
d1 = pad_xy(board, "D1", "1")
add_polyline(board, "RF_SMA", [d1, (d1[0], 45.07)], w)
add_polyline(board, "RF_SMA", [d1, (d1[0], 42.09)], w)
add_polyline(board, "RF_PRE_SAW",
[pad_xy(board, "C83", "2"), pad_xy(board, "FL1", "1")], w)
@@ -146,12 +150,16 @@ def route_usb(board: pcbnew.BOARD) -> None:
# mirrored USB-C contacts join through a sub-1.5-mm L3 crossover so neither
# polarity creates a same-layer crossing at the receptacle.
w = 0.135
dm_main = pad_xy(board, "J1", "B7")
dp_main = pad_xy(board, "J1", "A6")
dm_mirror = pad_xy(board, "J1", "A7")
dp_mirror = pad_xy(board, "J1", "B6")
dm_join, dp_join = (28.30, 47.25), (28.80, 47.75)
dm_aux, dp_aux = (27.30, 48.25), (27.80, 48.75)
for ref, expected in {
"R62": {"USB_D_P", "FTDI_D_P"},
"R63": {"USB_D_N", "FTDI_D_N"},
}.items():
assert_ref_nets(board, ref, expected)
dm_main, dp_main = pad_xy(board, "J1", "B7"), pad_xy(board, "J1", "A6")
dm_mirror, dp_mirror = pad_xy(board, "J1", "A7"), pad_xy(board, "J1", "B6")
dm_join, dp_join = (28.60, 47.25), (29.20, 47.75)
dm_aux, dp_aux = (27.60, 48.25), (28.20, 48.75)
add_polyline(board, "USB_D_N", [dm_main, dm_join], w)
add_polyline(board, "USB_D_P", [dp_main, dp_join], w)
@@ -163,21 +171,33 @@ def route_usb(board: pcbnew.BOARD) -> None:
add_polyline(board, "USB_D_N", [dm_aux, dm_join], w, pcbnew.In2_Cu)
add_polyline(board, "USB_D_P", [dp_aux, dp_join], w, pcbnew.In2_Cu)
# Down the free channel east of J1 into the TPD2EUSB30A clamp pads. D_P
# drops at x=30.00 rather than on its own pad column so it keeps 0.2325 mm
# to the D_N clamp pad, then jogs 45 degrees onto U6.1.
dm_esd = pad_xy(board, "U6", "3")
dp_esd = pad_xy(board, "U6", "1")
add_polyline(board, "USB_D_N", [dm_join, (30.00, 49.00), (30.00, 50.70), dm_esd], w)
add_polyline(board, "USB_D_P", [dp_join, (30.285, 49.285), (30.285, 51.64), dp_esd], w)
dm_r = pad_xy(board, "R64", "1")
dp_r = pad_xy(board, "R63", "1")
add_polyline(board, "USB_D_N",
[dm_esd, (31.00, 51.40), (34.00, 54.40), (35.80, 54.40), dm_r], w)
[dm_join, (29.60, 47.25), (30.50, 48.15), dm_esd], w)
add_polyline(board, "USB_D_P",
[dp_esd, (30.715, 51.685), (33.715, 54.685), (35.80, 54.685), dp_r], w)
[dp_join, (29.45, 47.75), (30.00, 48.30),
(30.00, 51.775), dp_esd], w)
# Equal 1.511-mm post-termination legs into the FT4232H USB PHY.
add_polyline(board, "FTDI_D_N", [pad_xy(board, "R64", "2"), pad_xy(board, "U2", "7")], w)
add_polyline(board, "FTDI_D_P", [pad_xy(board, "R63", "2"), pad_xy(board, "U2", "8")], w)
# Both clamp taps run as a 0.5-mm-pitch 45-degree pair to the series
# terminators. D_N leaves U6.3 straight east; D_P has to step south first,
# because U6.2 (ground) sits directly east of U6.1 on the same pad row.
dm_r = pad_xy(board, "R63", "1")
dp_r = pad_xy(board, "R62", "1")
add_polyline(board, "USB_D_N",
[dm_esd, (31.000, 51.075), (34.425, 54.50), dm_r], w)
add_polyline(board, "USB_D_P",
[dp_esd, (30.150, 52.500), (31.718, 52.500),
(34.718, 55.50), dp_r], w)
# Equal short post-termination legs into the FT4232H USB PHY.
add_polyline(board, "FTDI_D_N",
[pad_xy(board, "R63", "2"), (37.76, 54.75), pad_xy(board, "U2", "7")], w)
add_polyline(board, "FTDI_D_P",
[pad_xy(board, "R62", "2"), (37.76, 55.25), pad_xy(board, "U2", "8")], w)
def add_rf_ground(board: pcbnew.BOARD) -> None:
@@ -199,8 +219,183 @@ def add_rf_ground(board: pcbnew.BOARD) -> None:
# tighter and also gives every local shunt/EP a short plane connection.
fence = [(x, 38.7) for x in range(153, 174, 2)]
fence += [(x, 45.0) for x in range(161, 174, 2)]
existing_vias = [item for item in board.GetTracks()
if isinstance(item, pcbnew.PCB_VIA)]
# The fence goes in after the session has been imported, so by now there is
# routed copper on F.Cu and B.Cu inside this region as well. Checking only
# vias and pads let a fence via land straight on top of a signal trace.
other_tracks = [item for item in board.GetTracks()
if not isinstance(item, pcbnew.PCB_VIA)
and item.GetNetname() != "GND"]
all_pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()]
via_radius = 0.225 + 0.10
for location in fence:
add_via(board, "GND", location, 0.45, 0.225)
candidate = point(*location)
blocked = any(
item.GetNetname() != "GND" and
math.hypot(pcbnew.ToMM(item.GetPosition().x - candidate.x),
pcbnew.ToMM(item.GetPosition().y - candidate.y)) < 0.75
for item in existing_vias
)
if not blocked:
x, y = location
for pad in all_pads:
if pad.GetNetname() == "GND":
continue
box = pad.GetBoundingBox()
if (pcbnew.ToMM(box.GetX()) - 0.35 <= x <= pcbnew.ToMM(box.GetRight()) + 0.35 and
pcbnew.ToMM(box.GetY()) - 0.35 <= y <= pcbnew.ToMM(box.GetBottom()) + 0.35):
blocked = True
break
if not blocked:
for item in other_tracks:
start, end = item.GetStart(), item.GetEnd()
reach = via_radius + pcbnew.ToMM(item.GetWidth()) / 2
if route_local_power.segment_distance(location,
(pcbnew.ToMM(start.x), pcbnew.ToMM(start.y)),
(pcbnew.ToMM(end.x), pcbnew.ToMM(end.y))) < reach:
blocked = True
break
if not blocked:
add_via(board, "GND", location, 0.45, 0.225)
NETCLASS_WIDTH = {"Default": 0.12, "BGA_ESCAPE": 0.10, "CLOCK": 0.15,
"POWER": 0.40}
MIN_TRACK_WIDTH = 0.10
MIN_CLEARANCE = 0.10
def normalize_imported_widths(board: pcbnew.BOARD) -> tuple[int, int]:
"""Restore the net class width on copper the session came back too thin.
Freerouting's auto-routing stage honours the DSN rule width exactly, but
its fanout stage emits three quarters of it, which lands below this board's
0.10-mm minimum track width. The affected pieces are the short stubs at
the pads, and widening one can walk it into a neighbouring pad, so each is
grown only as far as it still clears everything else - the class width
where there is room, the board minimum where there is not.
"""
obstacles = route_local_power.Obstacles(board)
doomed = []
widened = 0
for item in board.GetTracks():
if isinstance(item, pcbnew.PCB_VIA) or item.IsLocked():
continue
target = NETCLASS_WIDTH.get(item.GetNetClassName())
if target is None or pcbnew.ToMM(item.GetWidth()) >= target - 1e-6:
continue
start = (pcbnew.ToMM(item.GetStart().x), pcbnew.ToMM(item.GetStart().y))
end = (pcbnew.ToMM(item.GetEnd().x), pcbnew.ToMM(item.GetEnd().y))
for width in (target, MIN_TRACK_WIDTH):
# Judge existing copper against the rule itself, not the extra
# margin the local router leaves itself when placing new copper.
if obstacles.track_clear(item.GetNetname(), start, end,
width, item.GetLayer(),
clearance_floor=MIN_CLEARANCE):
item.SetWidth(mm(width))
widened += 1
break
else:
# Nothing legal fits here, so this piece cannot ship. Dropping it
# unravels its net back to the pads, and the completion pass
# further down rebuilds the connection at the proper width.
doomed.append(item)
for item in doomed:
board.Remove(item)
return widened, len(doomed)
def remove_dangling_copper(board: pcbnew.BOARD) -> tuple[int, int]:
"""Delete copper the finished routing left hanging.
A ball whose net ended up routed entirely on F.Cu leaves its pre-routed
dogbone via connected on one layer only, and removing that via in turn
strands the stub that fed it. Both are drilled holes and copper with no
purpose, so this repeats until nothing new comes loose.
"""
vias = tracks = 0
for _round in range(8):
board.BuildConnectivity()
connectivity = board.GetConnectivity()
doomed = [item for item in board.GetTracks()
if item.GetNetname() not in CRITICAL_NETS
and connectivity.TestTrackEndpointDangling(item, False)]
if not doomed:
break
for item in doomed:
if isinstance(item, pcbnew.PCB_VIA):
vias += 1
else:
tracks += 1
board.Remove(item)
board.BuildConnectivity()
return vias, tracks
def drop_plane_islands(board: pcbnew.BOARD) -> int:
"""Discard unconnected plane fragments left by the escape via anti-pads.
The generated planes keep every fragment while the board is unrouted so the
Specctra export still shows the router a plane target under each via field.
A finished board must not ship floating copper, so the final fill removes
any island the completed routing did not connect.
"""
changed = 0
for zone in board.Zones():
if zone.GetIsRuleArea() or zone.GetNetname() == "":
continue
zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS)
changed += 1
return changed
def add_named_rule_areas(board: pcbnew.BOARD) -> None:
"""Permanent named areas that only exist so custom DRC rules can name them.
The TPD2EUSB30A is a SOT-23 clamp whose ground pad sits between its two
channel pads, so USB_D_P and USB_D_N must separate for about 1.5 mm to pass
it. That is normal for this package, but it does break the 0.90-mm coupled
gap the pair holds everywhere else, so the rule is scoped to skip this one
breakout instead of being relaxed board-wide.
RF_NO_DIGITAL names the region under the GPS front end. The TEMP_ version
of it exists only while the autorouter runs, so once that is deleted
nothing would stop a later hand edit from dropping a digital trace onto L3,
L5, L6 or B.Cu directly under the RF line. A plain keepout cannot be used
here because it would also reject the ground stitching vias and the
MAX2771's own supply drops, so the area carries no constraint itself and
board.kicad_dru scopes the disallow to non-power net classes. It
deliberately stops short of L2 and L7: those must stay solid and unbroken
under the RF path, which is exactly what an unrouted plane layer gives.
"""
below = pcbnew.LSET()
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.B_Cu):
below.AddLayer(layer)
top = pcbnew.LSET()
top.AddLayer(pcbnew.F_Cu)
areas = (
("USB_ESD_BREAKOUT", (29.90, 50.70, 32.10, 53.10), top),
("RF_NO_DIGITAL", (152.00, 36.00, 174.50, 49.50), below),
)
for name, bounds, layers in areas:
x0, y0, x1, y1 = bounds
area = pcbnew.ZONE(board)
area.SetZoneName(name)
area.SetIsRuleArea(True)
area.SetLayerSet(layers)
area.SetDoNotAllowTracks(False)
area.SetDoNotAllowVias(False)
area.SetDoNotAllowPads(False)
area.SetDoNotAllowFootprints(False)
area.SetDoNotAllowZoneFills(False)
outline = area.Outline()
outline.NewOutline()
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
outline.Append(point(x, y))
board.Add(area)
def routed_lengths(board: pcbnew.BOARD) -> dict[str, float]:
@@ -217,26 +412,50 @@ def routed_lengths(board: pcbnew.BOARD) -> dict[str, float]:
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("session", type=Path, help="Freerouting .ses file")
parser.add_argument("--base", type=Path, default=POWER_LOCAL,
help="Board used to create the routed DSN")
parser.add_argument("--output", type=Path, default=ROUTED)
args = parser.parse_args()
board = pcbnew.LoadBoard(str(PLACED))
board = pcbnew.LoadBoard(str(args.base))
if not pcbnew.ImportSpecctraSES(board, str(args.session)):
raise RuntimeError(f"Could not import {args.session}")
removed = remove_critical_routes(board)
widened, dropped = normalize_imported_widths(board)
stranded_vias, stranded_tracks = remove_dangling_copper(board)
removed_keepouts = remove_temporary_keepouts(board)
route_rf(board)
route_usb(board)
add_rf_ground(board)
add_named_rule_areas(board)
# The reserved RF and USB corridors are gone now, so the power pads that
# sit inside them can finally reach their planes. Both passes are the same
# deterministic code used earlier in the pipeline.
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Zone fill before final power completion failed")
stitched, _unreachable = stitch_plane_pads.stitch(board)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Zone fill after final plane stitching failed")
route_local_power.complete(board, signals=True)
late_vias, late_tracks = remove_dangling_copper(board)
cleaned = drop_plane_islands(board)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Final zone fill failed")
pcbnew.SaveBoard(str(args.output), board)
for suffix in (".kicad_pro", ".kicad_dru"):
source = PLACED.with_suffix(suffix)
source = args.base.with_suffix(suffix)
if source.exists():
shutil.copyfile(source, args.output.with_suffix(suffix))
print(f"Removed {removed} autorouted critical-net items")
print(f"Widened {widened} imported tracks; dropped {dropped} that had no room")
print(f"Removed {stranded_vias + late_vias} unused vias and "
f"{stranded_tracks + late_tracks} stranded stubs")
print(f"Removed {removed_keepouts} temporary critical-route keepouts")
print("Late plane stitching:",
", ".join(f"{n}={k}" for n, k in sorted(stitched.items())) or "none")
print(f"Enabled island removal on {cleaned} plane zones")
print(f"Saved {args.output}")
for net, length in sorted(routed_lengths(board).items()):
print(f"{net:16s} {length:8.3f} mm")
+183 -60
View File
@@ -29,6 +29,13 @@ STOCK = Path("/usr/share/kicad/footprints")
# field allow a compact 155 x 90 mm outline.
X0, Y0, X1, Y1 = 20.0, 20.0, 175.0, 110.0
# Anti-pad clearance for planes that have to stay continuous under the Ti375's
# 0.8-mm escape via grid. This is the board's declared minimum copper
# clearance; anything larger merges neighbouring anti-pads and erases the
# plane across the whole BGA footprint. Split L4 islands keep the wider
# default so the gaps between different DC rails stay generous.
PLANE_CLEARANCE = 0.10
def mm(value: float) -> int:
return pcbnew.FromMM(value)
@@ -91,9 +98,40 @@ SPECIAL = {
# likewise land on the two controlled-impedance sections.
"C83": (167.5, 42.1, 180), "C84": (162.15, 41.45, 90), "R39": (160.5, 42.1, 180),
"C85": (159.0, 40.70, 90), "L4": (169.5, 42.585, 90),
"L1": (105.0, 91.0, 0), "L2": (115.0, 91.0, 0), "L3": (130.0, 91.0, 0),
"FB1": (60.0, 88.5, 0), "FB2": (36.0, 49.0, 0), "FB3": (39.0, 46.5, 0),
"R63": (37.0, 55.50, 0), "R64": (37.0, 54.50, 0),
# Converter magnetics and capacitors form compact current loops. U13's
# SW bank faces south, so L1 is vertical; U14/U15 SW pins face east and
# their inductors/output capacitors continue directly to the right.
"L1": (100.0, 98.5, 270), "L2": (118.0, 97.0, 0), "L3": (133.0, 97.0, 0),
"C18": (95.7, 91.8, 180), "C19": (95.7, 94.4, 180),
"C20": (104.2, 91.8, 0), "C21": (104.2, 94.4, 0),
"C22": (95.5, 103.5, 270), "C23": (99.0, 103.5, 270),
"C24": (102.5, 103.5, 270), "C25": (106.0, 103.5, 270),
"C26": (112.5, 96.2, 180), "C27": (112.5, 97.8, 180),
"C28": (121.3, 96.0, 0), "C29": (121.3, 98.3, 0),
"C30": (127.5, 96.2, 180), "C31": (127.5, 97.8, 180),
"C32": (136.3, 96.0, 0), "C33": (136.3, 98.3, 0),
# Core remote-sense and compensation stay on the quiet north side of U13.
"C16": (96.2, 89.0, 180), "R29": (98.1, 89.0, 180),
"C17": (100.0, 89.0, 0), "R27": (104.0, 87.8, 180),
"R28": (104.0, 89.2, 180),
"R31": (115.0, 94.3, 0), "R32": (130.0, 94.3, 0),
# FB3 feeds the FT4232H PLL supply on pin 9, which sits south of the USB
# pair corridor. Keeping the bead on that side avoids a supply trace that
# would otherwise have to cross the reserved differential run.
"FB1": (60.0, 88.5, 0), "FB2": (36.0, 49.0, 0), "FB3": (36.5, 58.2, 0),
# The 12-MHz series damping resistor moves west with its oscillator to keep
# the space south of the FT4232H free for FB3.
"R67": (32.0, 58.0, 0),
# USB series pair and FT4232 REF resistor follow the device pin order.
"R62": (37.0, 55.50, 0), "R63": (37.0, 54.50, 0),
# R64 is the FT4232H reference resistor, not part of the pair; it moves off
# the terminator column so the reserved pair corridor stays empty.
"R64": (36.0, 51.50, 0),
# The shield network belongs at the receptacle shell, not east of the
# contacts: automatic placement filled the only corridor that carries
# USB_D_P/USB_D_N away from J1. Pin all three parts south of the shell,
# clear of the pair's diagonal run towards the series terminators.
"C2": (24.0, 55.5, 0), "R9": (26.5, 57.5, 0), "R10": (29.5, 57.5, 0),
# MAX2771 output damping sits at the receiver; FPGA-driven 3-wire control
# damping sits at the BGA source. R55 holds SHDN low at the receiver.
"R45": (151.0, 44.5, 0),
@@ -102,7 +140,16 @@ SPECIAL = {
"R50": (160.5, 47.5, 90),
"R51": (121.5, 65.5, 0), "R52": (121.5, 66.7, 0),
"R53": (121.5, 67.9, 0), "R54": (121.5, 69.1, 0),
"R55": (161.7, 44.5, 90), "C89": (146.5, 53.0, 0),
"R55": (161.7, 44.5, 90),
# PLL loop filter. CPOUT is a high-impedance charge-pump node, so the
# whole branch sits in the free pocket at U21's north-west corner rather
# than being spread around the package: pin 1 is 2 mm from the 15-pF shunt
# and the entire net stays on F.Cu. Scattering these parts is what forced
# CPOUT onto an inner layer underneath the GPS front end. The column stops
# short of x = 154.4 so it never enters the reserved RF corridor.
"C88": (153.60, 40.80, 90),
"R43": (153.60, 38.85, 90),
"C89": (154.80, 38.85, 90),
# 40-MHz termination is at U24. SafeSPI controller outputs are terminated
# beside the FPGA; MISO and EXTRESN bias remain beside the IMU.
"R56": (140.2, 54.35, 0),
@@ -283,11 +330,17 @@ def add_stackup(board: pcbnew.BOARD) -> None:
board.SetLayerName(pcbnew.In1_Cu, "L2_GND")
board.SetLayerName(pcbnew.In2_Cu, "L3_SIG")
board.SetLayerName(pcbnew.In3_Cu, "L4_PWR")
# L5 is a routing layer, not a rail. Declaring it signal is what puts
# it into the Specctra export as routable: a layer left as "power" is
# offered to the router as "not used for routing", which is exactly the
# congestion this stackup exists to avoid.
board.SetLayerName(pcbnew.In4_Cu, "L5_SIG")
board.SetLayerName(pcbnew.In5_Cu, "L6_SIG")
board.SetLayerName(pcbnew.In5_Cu, "L6_PWR")
board.SetLayerName(pcbnew.In6_Cu, "L7_GND")
board.SetLayerType(pcbnew.In1_Cu, pcbnew.LT_POWER)
board.SetLayerType(pcbnew.In3_Cu, pcbnew.LT_POWER)
board.SetLayerType(pcbnew.In4_Cu, pcbnew.LT_SIGNAL)
board.SetLayerType(pcbnew.In5_Cu, pcbnew.LT_POWER)
board.SetLayerType(pcbnew.In6_Cu, pcbnew.LT_POWER)
@@ -420,12 +473,20 @@ def add_mounting_holes(board: pcbnew.BOARD) -> None:
def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
bounds: tuple[float, float, float, float], priority: int = 0) -> None:
bounds: tuple[float, float, float, float], priority: int = 0,
clearance: float = 0.20) -> None:
"""Add a retained solid inner-layer plane region.
These regions are retained even before fanout vias exist so the Specctra
export contains real plane targets for the router. The final refill then
clears anti-pads around every unrelated via and plated hole.
`clearance` decides whether a plane survives under the Ti375. Its escape
vias sit on a 0.8-mm grid, so at the default 0.20 mm every anti-pad
(0.225 + 0.20 = 0.425 mm radius) overlaps its neighbour and the plane is
erased across the whole BGA footprint. At the board minimum of 0.10 mm the
anti-pads are 0.325 mm and a 0.15-mm web survives between them, which is
what lets an isolated bank-supply ball reach its rail at all.
"""
net = board.FindNet(net_name)
if net is None:
@@ -434,7 +495,7 @@ def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
zone = pcbnew.ZONE(board)
zone.SetLayer(layer)
zone.SetNet(net)
zone.SetLocalClearance(mm(0.20))
zone.SetLocalClearance(mm(clearance))
zone.SetMinThickness(mm(0.10))
zone.SetThermalReliefGap(mm(0.20))
zone.SetThermalReliefSpokeWidth(mm(0.25))
@@ -449,69 +510,133 @@ def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
def add_power_planes(board: pcbnew.BOARD) -> None:
"""Ground on L2/L7, and the supply rails split across L4 and L6.
No rail owns a whole layer. L4 carries the core, the 5-V input rail, the
always-on auxiliary rail and the four 3.0-V front-end rails; L6 carries the
two large FPGA I/O rails. That leaves L3, L5 and B.Cu free for routing,
which is where the Ti375's fanout has to go.
"""
# Continuous reference planes immediately below the two outer signal
# layers. A 0.50-mm inset exceeds JLCPCB's copper-to-edge requirement.
# These are the return path for every routed layer, so they use the tight
# anti-pad clearance and stay continuous through the Ti375 via field. L2
# in particular is the reference under the whole GPS front end and is never
# split between "RF ground" and "digital ground".
for layer in (pcbnew.In1_Cu, pcbnew.In6_Cu):
add_zone(board, "GND", layer, (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50))
add_zone(board, "GND", layer, (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50),
clearance=PLANE_CLEARANCE)
# L4 is a deliberately split DC plane. Islands follow the functional
# floorplan; no high-speed route uses L4 and no signal crosses a split on
# either adjacent routing layer without a nearby return transition.
islands = (
("VCC3V3_AUX", (20.50, 38.00, 67.00, 72.00)),
("VCC3V3", (39.00, 73.00, 68.00, 85.10)),
("SYS5", (20.50, 85.50, 90.00, 109.50)),
("SYS5", (90.40, 106.40, 140.00, 109.50)),
# L4: everything except the two big I/O rails. Islands follow the
# functional floorplan; no high-speed route uses L4 and no signal crosses a
# split on either adjacent routing layer without a nearby return via.
l4 = (
("VCC3V3_AUX", (20.50, 36.00, 74.00, 72.00)),
("VCC3V3_AUX", (53.00, 88.00, 70.00, 105.00)),
("VCC3V3_AUX", (77.50, 84.00, 94.20, 100.50)),
("VCC3V3_AUX", (133.00, 74.50, 152.00, 82.00)),
("SYS5", (20.50, 85.50, 52.50, 109.50)),
("SYS5", (89.50, 103.60, 145.00, 109.50)),
# A corridor up the east edge carries 5 V to the antenna-bias block
# instead of leaving those three pads stranded 55 mm from the rail.
("SYS5", (140.00, 103.00, 174.50, 109.50)),
("SYS5", (144.00, 86.50, 174.50, 104.00)),
("SYS5", (157.00, 72.00, 174.50, 87.00)),
("SYS5", (157.00, 63.50, 174.50, 72.50)),
("VCC3V0_CLK", (126.00, 48.50, 156.00, 67.00)),
("VCC3V0_RF", (140.00, 58.50, 150.50, 65.50)),
("VCC3V0_DIG", (134.00, 62.50, 147.00, 74.00)),
# Core balls are interspersed with GND/auxiliary balls, so a complete
# local patch (with automatic anti-pads) replaces the invalid left/right
# voltage split that previously crossed the BGA field.
("VCC0V95", (95.00, 60.50, 119.00, 94.00)),
("VCC3V3", (119.50, 87.00, 140.00, 106.00)),
("VCC3V0_CLK", (126.00, 50.00, 148.50, 72.00)),
("VCC3V0_DIG", (119.50, 60.50, 125.50, 72.00)),
("VCC3V0_DIG", (148.90, 50.40, 164.00, 72.00)),
("VCC3V3_ANT", (164.40, 50.40, 174.50, 70.00)),
("VCC3V0_RF", (145.00, 36.00, 174.50, 50.00)),
# local patch (with automatic anti-pads) replaces any voltage split
# crossing the BGA field.
("VCC0V95", (94.50, 56.00, 121.00, 103.00)),
("VCC3V0_RF", (146.00, 39.80, 174.50, 50.50)),
("VCC3V0_DIG", (149.00, 35.20, 161.00, 39.60)),
("VCC3V0_DIG", (151.50, 44.80, 159.00, 48.60)),
("VCC3V3_ANT", (162.00, 53.50, 174.50, 63.20)),
)
for priority, (net_name, bounds) in enumerate(islands, 1):
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority)
for priority, (net_name, bounds) in enumerate(l4, 1):
# Only the core island sits under the BGA and needs the tight anti-pad.
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority,
clearance=PLANE_CLEARANCE if net_name == "VCC0V95" else 0.20)
# Local BGA supply patches. L2/L7 remain uninterrupted ground planes;
# only the area below and immediately south of U1 is reserved on these
# otherwise routable signal layers.
add_zone(board, "VCC1V8", pcbnew.In4_Cu, (97.50, 60.50, 122.00, 101.00), 50)
add_zone(board, "VCC3V3", pcbnew.In5_Cu, (97.50, 60.50, 140.00, 101.00), 50)
# L6: the two FPGA I/O rails. VCC3V3 is the background because its loads
# are spread over the whole west and the RF corner; VCC1V8 is carved out
# where its own loads sit - under the BGA, along the 1.8-V PMOD headers,
# over the configuration flash and at its converter output. The carve-outs
# overlap each other so the rail stays one connected pour.
l6 = (
# Overlapping zones need distinct priorities even when they carry the
# same net, so the carve-outs are numbered rather than sharing one.
("VCC3V3", (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50), 1),
("VCC1V8", (99.00, 39.50, 122.00, 92.00), 10),
("VCC1V8", (115.00, 20.50, 174.50, 40.50), 11),
("VCC1V8", (94.00, 45.00, 106.00, 56.00), 12),
("VCC1V8", (108.50, 91.50, 127.00, 106.00), 13),
("VCC3V3", (128.00, 91.50, 141.00, 106.00), 20),
)
for net_name, bounds, priority in l6:
add_zone(board, net_name, pcbnew.In5_Cu, bounds, priority,
clearance=PLANE_CLEARANCE)
# Areas the autorouter and the power stages must leave alone until
# finalize_routing.py installs the audited RF and USB copper. The "below"
# regions keep unrelated copper off the reference and routing layers under the
# GPS front end and the USB pair; the "corridor" regions additionally reserve
# F.Cu along the exact paths that finalize_routing.py later builds, since the
# critical nets themselves are not part of the Specctra export.
CRITICAL_KEEPOUTS_BELOW = (
("TEMP_RF_NO_DIGITAL_BELOW", (152.00, 36.00, 174.50, 49.50)),
("TEMP_USB_NO_CROSSING_BELOW", (26.00, 46.00, 39.60, 56.60)),
)
CRITICAL_KEEPOUTS_CORRIDOR = (
# GPS chain: SMA launch, bias tee, DC block, SAW, series R and MIXIN_HI.
("TEMP_RF_LINE", (154.40, 40.00, 174.60, 43.70)),
# USB pair: receptacle breakout and the mirrored-contact crossover.
("TEMP_USB_BREAKOUT", (27.30, 46.90, 30.65, 49.60)),
# USB pair: vertical channel into the TPD2EUSB30A clamp, and the short
# southward step D_P needs to clear the clamp's ground pad.
("TEMP_USB_CHANNEL", (29.60, 49.60, 30.68, 52.60)),
# USB pair: 45-degree run from the clamp to the series terminators.
("TEMP_USB_DIAGONAL", (30.68, 52.20, 35.30, 56.00)),
# USB pair: terminators and the matched legs into the FT4232H PHY.
("TEMP_USB_TERMINATORS", (34.80, 54.35, 39.45, 55.60)),
)
def add_temporary_critical_keepouts(board: pcbnew.BOARD) -> None:
"""Reserve the layers below RF and USB while Freerouting is running.
"""Reserve the RF and USB geometry while the rest of the board is routed.
F.Cu deliberately remains available so the autorouted critical nets reserve
their own top-layer corridors. The rule areas prevent unrelated signal
tracks and through vias from crossing underneath. finalize_routing.py
removes these temporary areas before installing the audited copper.
finalize_routing.py removes every TEMP_ area before installing the audited
copper, so nothing here reaches the manufactured board.
"""
layers = pcbnew.LSET()
below = pcbnew.LSET()
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.B_Cu):
layers.AddLayer(layer)
for name, bounds in (
("TEMP_RF_NO_DIGITAL_BELOW", (152.0, 36.0, 174.5, 49.5)),
("TEMP_USB_NO_CROSSING_BELOW", (22.0, 41.5, 44.5, 57.5))):
x0, y0, x1, y1 = bounds
area = pcbnew.ZONE(board)
area.SetZoneName(name)
area.SetIsRuleArea(True)
area.SetLayerSet(layers)
area.SetDoNotAllowTracks(True)
area.SetDoNotAllowVias(True)
area.SetDoNotAllowPads(False)
area.SetDoNotAllowFootprints(False)
area.SetDoNotAllowZoneFills(False)
outline = area.Outline()
outline.NewOutline()
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
outline.Append(point(x, y))
board.Add(area)
below.AddLayer(layer)
corridor = pcbnew.LSET()
for layer in (pcbnew.F_Cu, pcbnew.In2_Cu, pcbnew.In4_Cu,
pcbnew.In5_Cu, pcbnew.B_Cu):
corridor.AddLayer(layer)
for layers, group in ((below, CRITICAL_KEEPOUTS_BELOW),
(corridor, CRITICAL_KEEPOUTS_CORRIDOR)):
for name, bounds in group:
x0, y0, x1, y1 = bounds
area = pcbnew.ZONE(board)
area.SetZoneName(name)
area.SetIsRuleArea(True)
area.SetLayerSet(layers)
area.SetDoNotAllowTracks(True)
area.SetDoNotAllowVias(True)
area.SetDoNotAllowPads(False)
area.SetDoNotAllowFootprints(False)
area.SetDoNotAllowZoneFills(False)
outline = area.Outline()
outline.NewOutline()
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
outline.Append(point(x, y))
board.Add(area)
def add_documentation_graphics(board: pcbnew.BOARD) -> None:
@@ -547,7 +672,7 @@ STACKUP = '''\t\t(stackup
\t\t\t(layer "dielectric 4" (type "core") (thickness 0.3) (material "JLC FR-4 core") (epsilon_r 4.41) (loss_tangent 0.02))
\t\t\t(layer "L5_SIG" (type "copper") (thickness 0.0152))
\t\t\t(layer "dielectric 5" (type "prepreg") (thickness 0.3568) (material "JLC 3x2116") (epsilon_r 4.16) (loss_tangent 0.02))
\t\t\t(layer "L6_SIG" (type "copper") (thickness 0.0152))
\t\t\t(layer "L6_PWR" (type "copper") (thickness 0.0152))
\t\t\t(layer "dielectric 6" (type "core") (thickness 0.1) (material "JLC FR-4 core") (epsilon_r 4.36) (loss_tangent 0.02))
\t\t\t(layer "L7_GND" (type "copper") (thickness 0.0152))
\t\t\t(layer "dielectric 7" (type "prepreg") (thickness 0.0994) (material "JLC 3313") (epsilon_r 4.1) (loss_tangent 0.02))
@@ -566,8 +691,6 @@ def inject_stackup(path: Path) -> None:
data = re.sub(r"\n\t\t\(stackup\n.*?\n\t\t\)\n", "\n" + STACKUP + "\n", data, count=1, flags=re.S)
else:
data = data.replace("\n\t(setup\n", "\n\t(setup\n" + STACKUP + "\n", 1)
data = data.replace("(allow_soldermask_bridges_in_footprints no)",
"(allow_soldermask_bridges_in_footprints yes)")
path.write_text(data)
+8 -7
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env python3
"""Compensate Freerouting 2.3.0's KiCad-10 DSN clearance scale mismatch.
"""Optionally scale only DSN clearance entries by an explicit factor.
KiCad exports widths and clearances in micrometres. Freerouting 2.3.0 keeps
the widths but internally imports DSN clearance values at one quarter of the
intended physical value. Scaling only ``(clearance N)`` entries by four makes
the router's physical rules match the source KiCad board. Coordinates, track
widths, vias, drills, padstacks, and keepouts are deliberately untouched.
The bundled Freerouting 2.3.0 build reads KiCad 10 micrometre clearance values
directly, so the safe default is 1.0 (no scaling). This utility is retained for
reproducible experiments with other router builds; never apply a non-unity
factor without comparing the imported physical rule values first.
"""
from __future__ import annotations
@@ -22,8 +21,10 @@ def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--factor", type=float, default=4.0)
parser.add_argument("--factor", type=float, default=1.0)
args = parser.parse_args()
if args.factor <= 0:
raise ValueError("--factor must be positive")
source = args.input.read_text()
original = [float(match.group(2)) for match in CLEARANCE.finditer(source)]
+47 -3
View File
@@ -22,6 +22,10 @@ def mm(value: float) -> int:
return pcbnew.FromMM(value)
def centre_mm(position: pcbnew.VECTOR2I) -> tuple[float, float]:
return pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
def add_segment(board: pcbnew.BOARD, pad: pcbnew.PAD,
end: pcbnew.VECTOR2I, width_mm: float) -> None:
track = pcbnew.PCB_TRACK(board)
@@ -46,10 +50,25 @@ def add_via(board: pcbnew.BOARD, pad: pcbnew.PAD,
board.Add(via)
def ball_grid(footprint: pcbnew.FOOTPRINT) -> tuple[dict, float, float, float, float]:
"""Every ball position, connected or not, plus the array extent."""
positions = {}
for pad in footprint.Pads():
p = pad.GetPosition()
positions[(round(pcbnew.ToMM(p.x), 2), round(pcbnew.ToMM(p.y), 2))] = pad
xs = [x for x, _ in positions]
ys = [y for _, y in positions]
return positions, min(xs), min(ys), max(xs), max(ys)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--surface-rings", type=int, default=1,
help="How many outer ball rings escape on F.Cu without "
"a via. Each via removed here frees an inner-layer "
"channel and one anti-pad per plane.")
args = parser.parse_args()
board = pcbnew.LoadBoard(str(args.input))
@@ -58,12 +77,37 @@ def main() -> None:
raise RuntimeError("U1 Ti375 footprint is missing")
centre = fpga.GetPosition()
occupied: set[tuple[int, int]] = set()
counts = {"power": 0, "signal": 0}
counts = {"power": 0, "signal": 0, "surface": 0}
_grid, gx0, gy0, gx1, gy1 = ball_grid(fpga)
pitch = 0.8
for pad in fpga.Pads():
if not pad.GetNetname():
continue
position = pad.GetPosition()
x, y = pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
ring = min(round((x - gx0) / pitch), round((gx1 - x) / pitch),
round((y - gy0) / pitch), round((gy1 - y) / pitch))
is_power = pad.GetNetname() in POWER_NETS
# Signal balls on the outer rings leave straight out on F.Cu. They
# need no via at all, and the interstitial site they would have taken
# stays open as a channel for the balls further in. Power and ground
# balls always drop, because their whole purpose is to reach a plane.
if not is_power and ring < args.surface_rings:
dx = 0.0 if abs(x - centre_mm(centre)[0]) < 0.4 else (
1.0 if x > centre_mm(centre)[0] else -1.0)
dy = 0.0 if abs(y - centre_mm(centre)[1]) < 0.4 else (
1.0 if y > centre_mm(centre)[1] else -1.0)
if abs(x - centre_mm(centre)[0]) > abs(y - centre_mm(centre)[1]):
dy = 0.0
else:
dx = 0.0
reach = 1.6
end = pcbnew.VECTOR2I(position.x + mm(dx * reach),
position.y + mm(dy * reach))
add_segment(board, pad, end, 0.10)
counts["surface"] += 1
continue
# Move each ball toward its package quadrant. Centre-row/column balls
# use the positive direction, leaving a full 0.8-mm via grid without
# duplicated interstitial locations.
@@ -74,7 +118,6 @@ def main() -> None:
if key in occupied:
raise RuntimeError(f"Duplicate dogbone via at {key} for U1.{pad.GetNumber()}")
occupied.add(key)
is_power = pad.GetNetname() in POWER_NETS
add_segment(board, pad, via_position, 0.15 if is_power else 0.10)
add_via(board, pad, via_position)
counts["power" if is_power else "signal"] += 1
@@ -86,7 +129,8 @@ def main() -> None:
source = args.input.with_suffix(suffix)
if source.exists():
shutil.copyfile(source, args.output.with_suffix(suffix))
print(f"Installed {counts['power']} power/GND and {counts['signal']} signal dogbones")
print(f"Installed {counts['power']} power/GND and {counts['signal']} signal "
f"dogbones, {counts['surface']} via-less surface escapes")
print(f"Saved {args.output}")
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Summarise what a routed board actually contains.
DRC answers "is anything wrong"; this answers "is anything missing, and does
the copper match the rules it was routed under". It reports the still-open
connections per net, the track width actually used per net class (the Specctra
round trip is where widths silently drift), the layer split, and the via count.
"""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
from pathlib import Path
import pcbnew
def net_groups(board: pcbnew.BOARD, net: str) -> int:
"""Count the separate copper islands a net is currently split into."""
connectivity = board.GetConnectivity()
pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()
if pad.GetNetname() == net]
seen: set[str] = set()
groups = 0
for pad in pads:
key = pad.m_Uuid.AsString()
if key in seen:
continue
members = [item for item in connectivity.GetConnectedItems(pad, 0)
if item.GetClass() == "PAD"]
for item in members:
seen.add(item.m_Uuid.AsString())
seen.add(key)
groups += 1
return groups
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("board", type=Path)
args = parser.parse_args()
board = pcbnew.LoadBoard(str(args.board))
board.BuildConnectivity()
net_class: dict[str, str] = {}
pad_count: Counter[str] = Counter()
for footprint in board.GetFootprints():
for pad in footprint.Pads():
name = pad.GetNetname()
if name:
net_class[name] = pad.GetNetClassName()
pad_count[name] += 1
widths: dict[str, Counter[float]] = defaultdict(Counter)
layers: Counter[str] = Counter()
vias = 0
for item in board.GetTracks():
if isinstance(item, pcbnew.PCB_VIA):
vias += 1
continue
name = item.GetNetname()
widths[net_class.get(name, "?")][round(pcbnew.ToMM(item.GetWidth()), 3)] += 1
layers[board.GetLayerName(item.GetLayer())] += 1
print(f"Board: {args.board}")
print(f"Unconnected endpoints reported by connectivity: "
f"{board.GetConnectivity().GetUnconnectedCount(True)}")
print(f"Tracks: {sum(layers.values())} Vias: {vias}")
print("\nTrack segments per layer:")
for layer, count in layers.most_common():
print(f" {layer:10s} {count:6d}")
print("\nTrack width used per net class (mm: segments):")
for klass in sorted(widths):
used = ", ".join(f"{width}: {count}"
for width, count in sorted(widths[klass].items()))
print(f" {klass:12s} {used}")
print("\nNets still split into more than one copper group:")
incomplete = []
for name in sorted(net_class):
if pad_count[name] < 2:
continue
groups = net_groups(board, name)
if groups > 1:
incomplete.append((groups, name))
for groups, name in sorted(incomplete, reverse=True):
print(f" {name:22s} {net_class[name]:12s} "
f"{pad_count[name]:3d} pads in {groups} groups")
print(f" ({len(incomplete)} nets incomplete)" if incomplete
else " none - every net is one connected group")
if __name__ == "__main__":
main()
+858
View File
@@ -0,0 +1,858 @@
#!/usr/bin/env python3
"""Finish the power distribution that planes and plane stitching cannot reach.
Three kinds of connection are left after `stitch_plane_pads.py`:
* pads whose rail has a plane, but not directly underneath them (a regulator
output capacitor just outside its island, a PMOD header on the board edge);
* rails that are too small to deserve a plane at all (`FTDI_1V8`,
`IMU_VREGD`, the antenna-bias nets), which are nevertheless excluded from
the Specctra export because their netclass is POWER;
* the local input/output loops around each LDO, where two different rails are
interleaved inside a couple of millimetres and no split plane can serve both.
None of these belong in the autorouter: it cannot see planes at all, so handing
it `GND` or `VCC3V3` would make it wire hundreds of pads together that the
plane already joins. This stage works from the real connectivity graph
instead. For every group of a rail that is still separate it either drops a
via onto the rail's own plane, or routes to the nearest other group with
45-degree geometry.
It runs in two phases. The first allows only plane drops and short direct
paths, for every rail; the second lets whatever is left take a detour through
an intermediate waypoint. Without that split the first rail in the list
spends the open space on long routes that a later rail needed for a short one.
Everything installed here is locked, and the same code runs again from
finalize_routing.py once the RF and USB corridors are released.
"""
from __future__ import annotations
import argparse
import math
import shutil
from collections import defaultdict
from pathlib import Path
import pcbnew
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT = ROOT / "hardware" / "board_plane_stitched.kicad_pcb"
DEFAULT_OUTPUT = ROOT / "hardware" / "board_power_local.kicad_pcb"
# Rails handled here. Signal nets, the deterministic converter switch nodes
# and the critical RF/USB nets are deliberately absent.
PLANE_NETS = (
"GND", "SYS5", "VCC0V95", "VCC1V8", "VCC3V3", "VCC3V3_AUX",
"VCC3V0_CLK", "VCC3V0_DIG", "VCC3V0_RF", "VCC3V3_ANT",
)
TRACE_NETS = (
"VBUS_RAW", "USB5_PROT", "EXT5_RAW", "EXT5_PROT", "USB_SHIELD",
"FTDI_1V8", "FTDI_VPHY", "FTDI_VPLL",
"IMU_3V3", "IMU_VREGA2", "IMU_VREGD",
"ANT_BIAS_SEL", "ANT_BIAS_SW",
)
TARGET_NETS = PLANE_NETS + TRACE_NETS
WIDE_NETS = {"GND", "SYS5", "VCC0V95", "VBUS_RAW", "USB5_PROT",
"EXT5_RAW", "EXT5_PROT"}
VIA_DIAMETER = 0.45
VIA_DRILL = 0.225
CLEARANCE = 0.13
# The rule value plus a rounding margin, used when completing signal nets
# through the Ti375 escape field where nothing wider will fit.
SIGNAL_CLEARANCE = 0.105
HOLE_TO_HOLE = 0.30
EDGE_CLEARANCE = 0.30
EDGE = (20.6, 20.6, 174.4, 109.4)
# Nets whose netclass demands more than the plain copper clearance. The local
# power router must respect the controlled-impedance corridors even though it
# never routes them itself.
STRICT_CLEARANCE = {
"RF_SMA": 0.20, "RF_PRE_SAW": 0.20, "RF_POST_SAW": 0.20, "MAX_MIXIN_HI": 0.20,
"USB_D_P": 0.15, "USB_D_N": 0.15, "FTDI_D_P": 0.15, "FTDI_D_N": 0.15,
}
# The edge-launch SMA lands sit on the board outline by construction; their
# ground return is built by finalize_routing.py's RF ground zone and via fence.
SKIP_REFS = {"J2"}
# Every ground land under the GPS front end is connected by that same RF ground
# zone and its via fence. Ordinary dogbones here would only add long stubs
# alongside the controlled-impedance line.
RF_GROUND_REGION = (152.0, 36.0, 174.5, 49.5)
# Nothing but ground and the MAX2771's own supplies may run under the GPS front
# end on a layer other than F.Cu. This router is the one stage that runs after
# finalize_routing.py has deleted the temporary keepout, so it has to carry the
# restriction itself; without it the deterministic completion is free to drop a
# charge-pump or control net onto L3 directly beneath the 50-ohm line.
RF_REGION_LAYERS = (pcbnew.In2_Cu, pcbnew.In3_Cu, pcbnew.In4_Cu,
pcbnew.In5_Cu, pcbnew.B_Cu)
def rf_region_forbids(net: str, layer: int | None) -> bool:
"""True when this net may not occupy the RF region on this layer.
`layer` of None means a through via, which is present on every layer and so
is judged as if it were on the inner ones.
"""
if net in TARGET_NETS:
return False
if layer is not None and layer not in RF_REGION_LAYERS:
return False
return True
def in_rf_region(position: tuple[float, float], margin: float = 0.0) -> bool:
return (RF_GROUND_REGION[0] - margin <= position[0] <= RF_GROUND_REGION[2] + margin and
RF_GROUND_REGION[1] - margin <= position[1] <= RF_GROUND_REGION[3] + margin)
MAX_PLANE_REACH = 7.0
# A dogbone via never sits further than this from the pad it escapes.
ESCAPE_VIA_REACH = 0.8
MAX_JOIN_LENGTH = 80.0
def mm(value: float) -> int:
return pcbnew.FromMM(value)
def to_mm(value: int) -> float:
return pcbnew.ToMM(value)
def point(xy: tuple[float, float]) -> pcbnew.VECTOR2I:
return pcbnew.VECTOR2I(mm(xy[0]), mm(xy[1]))
# Widths used when this stage finishes leftover signal nets. They match the
# KiCad net classes so the copper is indistinguishable from the autorouted
# result; the controlled-impedance classes are absent because finalize_routing
# builds those by hand.
NETCLASS_WIDTH = {"Default": 0.12, "BGA_ESCAPE": 0.10, "CLOCK": 0.15}
# Per-class routing clearance. CLOCK keeps its wider figure; the others drop
# to the rule value so they still fit the Ti375 escape channels.
NETCLASS_CLEARANCE = {"Default": 0.105, "BGA_ESCAPE": 0.105, "CLOCK": 0.155}
SKIP_CLASSES = frozenset({"RF50_CPWG", "USB90_DIFF"})
def net_width(net: str) -> float:
return 0.40 if net in WIDE_NETS else 0.25
def segment_distance(p: tuple[float, float], a: tuple[float, float],
b: tuple[float, float]) -> float:
dx, dy = b[0] - a[0], b[1] - a[1]
length = dx * dx + dy * dy
if length == 0.0:
return math.hypot(p[0] - a[0], p[1] - a[1])
t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length))
return math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy))
def segments_distance(a0: tuple[float, float], a1: tuple[float, float],
b0: tuple[float, float], b1: tuple[float, float]) -> float:
"""Minimum distance between two segments, including the crossing case."""
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
d1, d2 = cross(a0, a1, b0), cross(a0, a1, b1)
d3, d4 = cross(b0, b1, a0), cross(b0, b1, a1)
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
return 0.0
return min(segment_distance(b0, a0, a1), segment_distance(b1, a0, a1),
segment_distance(a0, b0, b1), segment_distance(a1, b0, b1))
class Obstacles:
"""Other-net copper, indexed on a coarse grid for quick local lookups."""
CELL = 5.0
def __init__(self, board: pcbnew.BOARD) -> None:
self.board = board
self.cells: dict[tuple[int, int], list] = defaultdict(list)
self.holes: list[tuple[tuple[float, float], float]] = []
for footprint in board.GetFootprints():
for pad in footprint.Pads():
box = pad.GetBoundingBox()
bounds = (to_mm(box.GetX()), to_mm(box.GetY()),
to_mm(box.GetRight()), to_mm(box.GetBottom()))
self._insert(("pad", pad.GetNetname(), bounds,
pad.GetLayerSet()), bounds)
drill = max(to_mm(pad.GetDrillSizeX()), to_mm(pad.GetDrillSizeY()))
if drill > 0:
position = pad.GetPosition()
self.holes.append(((to_mm(position.x), to_mm(position.y)),
drill / 2))
for item in board.GetTracks():
if isinstance(item, pcbnew.PCB_VIA):
position = (to_mm(item.GetPosition().x), to_mm(item.GetPosition().y))
radius = to_mm(item.GetWidth(pcbnew.F_Cu)) / 2
self.add_via(item.GetNetname(), position, radius,
to_mm(item.GetDrill()) / 2)
else:
start = (to_mm(item.GetStart().x), to_mm(item.GetStart().y))
end = (to_mm(item.GetEnd().x), to_mm(item.GetEnd().y))
self.add_track(item.GetNetname(), start, end,
to_mm(item.GetWidth()), item.GetLayer())
# Only areas that actually forbid copper block routing. Rule areas
# that exist purely so a custom DRC rule can name a region (see
# finalize_routing.add_named_rule_areas) must not stop anything.
self.track_areas = [zone for zone in board.Zones()
if zone.GetIsRuleArea() and zone.GetDoNotAllowTracks()]
self.via_areas = [zone for zone in board.Zones()
if zone.GetIsRuleArea() and zone.GetDoNotAllowVias()]
def _keys(self, bounds: tuple[float, float, float, float]):
x0 = int(math.floor(bounds[0] / self.CELL))
y0 = int(math.floor(bounds[1] / self.CELL))
x1 = int(math.floor(bounds[2] / self.CELL))
y1 = int(math.floor(bounds[3] / self.CELL))
for x in range(x0 - 1, x1 + 2):
for y in range(y0 - 1, y1 + 2):
yield x, y
def _insert(self, entry, bounds) -> None:
for key in self._keys(bounds):
self.cells[key].append(entry)
def add_via(self, net: str, position: tuple[float, float], radius: float,
drill_radius: float = VIA_DRILL / 2) -> None:
bounds = (position[0] - radius, position[1] - radius,
position[0] + radius, position[1] + radius)
self._insert(("via", net, position, radius), bounds)
self.holes.append((position, drill_radius))
def add_track(self, net: str, start: tuple[float, float],
end: tuple[float, float], width: float, layer: int) -> None:
bounds = (min(start[0], end[0]) - width, min(start[1], end[1]) - width,
max(start[0], end[0]) + width, max(start[1], end[1]) + width)
self._insert(("track", net, start, end, width / 2, layer), bounds)
def _nearby(self, bounds):
seen = set()
for key in self._keys(bounds):
for entry in self.cells.get(key, ()):
if id(entry) in seen:
continue
seen.add(id(entry))
yield entry
def rule_area_blocks(self, position: tuple[float, float], layer: int | None,
radius: float = 0.0) -> bool:
"""Test a disc, not a point: copper close to a rule area still violates it.
A layer of None means a through via, which is checked against the areas
that forbid vias on any layer.
"""
probes = [position]
for step in range(8):
angle = math.radians(45 * step)
probes.append((position[0] + radius * math.cos(angle),
position[1] + radius * math.sin(angle)))
areas = self.via_areas if layer is None else self.track_areas
for zone in areas:
if layer is not None and not zone.GetLayerSet().Contains(layer):
continue
outline = zone.Outline()
if any(outline.Contains(point(probe)) for probe in probes):
return True
return False
def hole_clear(self, position: tuple[float, float],
drill_radius: float = VIA_DRILL / 2) -> bool:
for other, other_radius in self.holes:
gap = math.hypot(position[0] - other[0], position[1] - other[1])
if gap < drill_radius + other_radius + HOLE_TO_HOLE:
return False
return True
def track_clear(self, net: str, start: tuple[float, float],
end: tuple[float, float], width: float, layer: int,
clearance_floor: float | None = None) -> bool:
"""`clearance_floor` overrides the routing margin with the bare rule
value, for callers that are checking existing copper rather than
placing new copper with room to spare."""
floor = CLEARANCE if clearance_floor is None else clearance_floor
half = width / 2
margin_to_edge = half + EDGE_CLEARANCE
for probe in (start, end):
if not (EDGE[0] + margin_to_edge <= probe[0] <= EDGE[2] - margin_to_edge and
EDGE[1] + margin_to_edge <= probe[1] <= EDGE[3] - margin_to_edge):
return False
forbidden = rf_region_forbids(net, layer)
for fraction in range(0, 11):
sample = (start[0] + (end[0] - start[0]) * fraction / 10,
start[1] + (end[1] - start[1]) * fraction / 10)
if self.rule_area_blocks(sample, layer, half + CLEARANCE):
return False
if forbidden and in_rf_region(sample, half + CLEARANCE):
return False
bounds = (min(start[0], end[0]) - 2, min(start[1], end[1]) - 2,
max(start[0], end[0]) + 2, max(start[1], end[1]) + 2)
for entry in self._nearby(bounds):
if entry[1] == net:
continue
clearance = max(floor, STRICT_CLEARANCE.get(entry[1], 0.0))
if entry[0] == "pad":
_, _, box, layers = entry
if not layers.Contains(layer):
continue
margin = half + clearance
corners = ((box[0], box[1]), (box[2], box[1]),
(box[2], box[3]), (box[0], box[3]))
inflated = (box[0] - margin, box[1] - margin,
box[2] + margin, box[3] + margin)
for probe in (start, end):
if (inflated[0] <= probe[0] <= inflated[2] and
inflated[1] <= probe[1] <= inflated[3]):
return False
for index in range(4):
if segments_distance(start, end, corners[index],
corners[(index + 1) % 4]) < margin:
return False
elif entry[0] == "via":
_, _, position, radius = entry
if segment_distance(position, start, end) < half + radius + clearance:
return False
else:
_, _, other_start, other_end, other_half, other_layer = entry
if other_layer != layer:
continue
if segments_distance(start, end, other_start,
other_end) < half + other_half + clearance:
return False
return True
def via_clear(self, net: str, position: tuple[float, float],
clearance_floor: float | None = None) -> bool:
floor = CLEARANCE if clearance_floor is None else clearance_floor
radius = VIA_DIAMETER / 2
if not (EDGE[0] + radius <= position[0] <= EDGE[2] - radius and
EDGE[1] + radius <= position[1] <= EDGE[3] - radius):
return False
# A through via is present on every layer, so any rule area blocks it.
if self.rule_area_blocks(position, None, radius + CLEARANCE):
return False
if rf_region_forbids(net, None) and in_rf_region(position, radius + CLEARANCE):
return False
if not self.hole_clear(position):
return False
bounds = (position[0] - 2, position[1] - 2, position[0] + 2, position[1] + 2)
for entry in self._nearby(bounds):
if entry[1] == net:
continue
clearance = max(floor, STRICT_CLEARANCE.get(entry[1], 0.0))
if entry[0] == "pad":
_, _, box, _layers = entry
margin = radius + clearance
if (box[0] - margin <= position[0] <= box[2] + margin and
box[1] - margin <= position[1] <= box[3] + margin):
return False
elif entry[0] == "via":
_, _, other, other_radius = entry
if math.hypot(position[0] - other[0],
position[1] - other[1]) < radius + other_radius + clearance:
return False
else:
_, _, start, end, half, _layer = entry
if segment_distance(position, start, end) < radius + half + clearance:
return False
return True
def diagonal_paths(a: tuple[float, float],
b: tuple[float, float]) -> list[list[tuple[float, float]]]:
"""Two-segment 45-degree routes between two points, shortest first."""
dx, dy = b[0] - a[0], b[1] - a[1]
if abs(dx) < 1e-6 or abs(dy) < 1e-6 or abs(abs(dx) - abs(dy)) < 1e-6:
return [[a, b]]
sx = math.copysign(1.0, dx)
sy = math.copysign(1.0, dy)
run = abs(abs(dx) - abs(dy))
if abs(dx) > abs(dy):
diagonal_first = [a, (a[0] + sx * abs(dy), a[1] + dy), b]
straight_first = [a, (a[0] + sx * run, a[1]), b]
else:
diagonal_first = [a, (a[0] + dx, a[1] + sy * abs(dx)), b]
straight_first = [a, (a[0], a[1] + sy * run), b]
return [diagonal_first, straight_first,
[a, (a[0], b[1]), b], [a, (b[0], a[1]), b]]
def detour_paths(a: tuple[float, float], b: tuple[float, float],
step: float = 2.5, margin: float = 12.0,
limit: int = 96) -> list[list[tuple[float, float]]]:
"""Routes that reach b through one intermediate point, shortest first.
Two segments only ever get around an obstacle by luck. Sampling a waypoint
grid over the span between the two ends, and joining a-to-waypoint and
waypoint-to-b with the same 45-degree geometry, is enough to get a supply
trace past the components between an LDO and the far end of its rail.
"""
x0, x1 = sorted((a[0], b[0]))
y0, y1 = sorted((a[1], b[1]))
candidates = []
steps_x = int((x1 - x0 + 2 * margin) / step) + 1
steps_y = int((y1 - y0 + 2 * margin) / step) + 1
for ix in range(steps_x):
for iy in range(steps_y):
way = (round(x0 - margin + ix * step, 3),
round(y0 - margin + iy * step, 3))
if not (EDGE[0] <= way[0] <= EDGE[2] and EDGE[1] <= way[1] <= EDGE[3]):
continue
length = (math.hypot(way[0] - a[0], way[1] - a[1]) +
math.hypot(b[0] - way[0], b[1] - way[1]))
candidates.append((length, way))
candidates.sort()
routes = []
for _length, way in candidates[:limit]:
first = diagonal_paths(a, way)[0]
second = diagonal_paths(way, b)[0]
routes.append(first + second[1:])
return routes
class LocalRouter:
def __init__(self, board: pcbnew.BOARD) -> None:
self.board = board
self.obstacles = Obstacles(board)
self.installed = 0
self.vias = 0
self.widths: dict[str, float] = {}
# Power rails are routed with a margin over the rule. Signal
# completion has to drop to the rule itself: a 0.12-mm trace needs
# 0.385 mm of the 0.400-mm channel between two Ti375 escape vias, and
# at 0.13 mm it would need 0.415 mm and never fit.
self.clearance = CLEARANCE
self.clearances: dict[str, float] = {}
# Taken from the board, not written down here: the stackup gained a
# fourth signal layer when L5 stopped being a power plane, and a fixed
# list would have gone on routing everything through the old three.
self.routing_layers = [layer for layer in board.GetEnabledLayers().CuStack()
if board.GetLayerType(layer) == pcbnew.LT_SIGNAL]
def width_for(self, net: str) -> float:
return self.widths.get(net, net_width(net))
def clearance_for(self, net: str) -> float:
return self.clearances.get(net, self.clearance)
def path_clear(self, net: str, vertices: list[tuple[float, float]],
width: float, layer: int) -> bool:
for start, end in zip(vertices, vertices[1:]):
if start == end:
continue
if not self.obstacles.track_clear(net, start, end, width, layer,
clearance_floor=self.clearance_for(net)):
return False
return True
def first_clear(self, net: str, a: tuple[float, float], b: tuple[float, float],
width: float, layer: int,
detour: bool = True) -> list[tuple[float, float]] | None:
"""The shortest clear route from a to b on one layer, or None."""
for vertices in diagonal_paths(a, b):
if self.path_clear(net, vertices, width, layer):
return vertices
if not detour:
return None
for vertices in detour_paths(a, b):
if self.path_clear(net, vertices, width, layer):
return vertices
return None
def commit(self, net: str, vertices: list[tuple[float, float]],
width: float, layer: int) -> None:
for start, end in zip(vertices, vertices[1:]):
if start == end:
continue
track = pcbnew.PCB_TRACK(self.board)
track.SetStart(point(start))
track.SetEnd(point(end))
track.SetWidth(mm(width))
track.SetLayer(layer)
track.SetNet(self.board.FindNet(net))
track.SetLocked(True)
self.board.Add(track)
self.obstacles.add_track(net, start, end, width, layer)
self.installed += 1
def commit_via(self, net: str, position: tuple[float, float]) -> None:
via = pcbnew.PCB_VIA(self.board)
via.SetPosition(point(position))
via.SetWidth(mm(VIA_DIAMETER))
via.SetDrill(mm(VIA_DRILL))
via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
via.SetNet(self.board.FindNet(net))
via.SetLocked(True)
self.board.Add(via)
self.obstacles.add_via(net, position, VIA_DIAMETER / 2)
self.vias += 1
def connect(self, net: str, a: tuple[float, float], a_layer: int,
b: tuple[float, float], b_layer: int,
detour: bool = True) -> bool:
"""Route a to b, changing layer through a via when that is what fits."""
width = self.width_for(net)
if a_layer == b_layer:
vertices = self.first_clear(net, a, b, width, a_layer, detour)
if vertices is not None:
self.commit(net, vertices, width, a_layer)
return True
for layer in self.routing_layers:
if layer == a_layer and layer == b_layer:
continue
for via_a, via_b in self.via_options(net, a, a_layer, b, b_layer, layer):
plan = []
if via_a is not None:
if not self.path_clear(net, [a, via_a], width, a_layer):
continue
plan.append(("track", [a, via_a], a_layer))
plan.append(("via", via_a))
start = via_a
else:
start = a
end = via_b if via_b is not None else b
middle = self.first_clear(net, start, end, width, layer, detour)
if middle is None:
continue
plan.append(("track", middle, layer))
if via_b is not None:
if not self.path_clear(net, [via_b, b], width, b_layer):
continue
plan.append(("via", via_b))
plan.append(("track", [via_b, b], b_layer))
for entry in plan:
if entry[0] == "via":
self.commit_via(net, entry[1])
else:
self.commit(net, entry[1], width, entry[2])
return True
return False
def via_options(self, net: str, a: tuple[float, float], a_layer: int,
b: tuple[float, float], b_layer: int, layer: int):
"""Offer via drop points just outside each endpoint's own pad."""
escapes_a = [None] if a_layer == layer else self.escape_points(net, a, b)
escapes_b = [None] if b_layer == layer else self.escape_points(net, b, a)
for via_a in escapes_a[:4]:
for via_b in escapes_b[:4]:
yield via_a, via_b
def escape_points(self, net: str, origin: tuple[float, float],
towards: tuple[float, float]) -> list[tuple[float, float]]:
heading = math.atan2(towards[1] - origin[1], towards[0] - origin[0])
options = []
for radius in (0.65, 0.95, 1.35):
for step in range(8):
angle = math.radians(45 * step)
candidate = (round(origin[0] + radius * math.cos(angle), 3),
round(origin[1] + radius * math.sin(angle), 3))
if not self.obstacles.via_clear(net, candidate, self.clearance_for(net)):
continue
score = -math.cos(angle - heading) * 10 + radius
options.append((score, candidate))
options.sort()
return [candidate for _score, candidate in options]
def reach_plane(self, net: str, zones: list[pcbnew.ZONE],
terminals: list[tuple[tuple[float, float], int]]) -> bool:
"""Drop a via from one of this group's pads onto the rail's own plane."""
width = self.width_for(net)
best = None
for origin, layer in terminals:
for radius in [x * 0.4 for x in range(2, int(MAX_PLANE_REACH / 0.4) + 1)]:
for step in range(8):
angle = math.radians(45 * step)
candidate = (round(origin[0] + radius * math.cos(angle), 3),
round(origin[1] + radius * math.sin(angle), 3))
probe = point(candidate)
if not any(zone.HitTestFilledArea(zone.GetLayer(), probe)
for zone in zones):
continue
if not self.obstacles.via_clear(net, candidate, self.clearance_for(net)):
continue
if not self.path_clear(net, [origin, candidate], width, layer):
continue
if best is None or radius < best[0]:
best = (radius, origin, candidate, layer)
break
if best is not None and best[0] <= radius:
break
if best is None:
return False
_radius, origin, candidate, layer = best
self.commit(net, [origin, candidate], width, layer)
self.commit_via(net, candidate)
return True
def pad_layer(pad: pcbnew.PAD) -> int:
layers = pad.GetLayerSet().Seq()
return layers[0] if layers else pcbnew.F_Cu
def groups_for_net(board: pcbnew.BOARD, net: str) -> list[list[pcbnew.PAD]]:
connectivity = board.GetConnectivity()
pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()
if pad.GetNetname() == net]
seen: set[str] = set()
groups: list[list[pcbnew.PAD]] = []
for pad in pads:
key = pad.m_Uuid.AsString()
if key in seen:
continue
members = [item for item in connectivity.GetConnectedItems(pad, 0)
if item.GetClass() == "PAD"] or [pad]
for item in members:
seen.add(item.m_Uuid.AsString())
seen.add(key)
groups.append([item.Cast() if hasattr(item, "Cast") else item
for item in members])
return groups
def escape_via_map(board: pcbnew.BOARD) -> dict[str, list[tuple[float, float]]]:
result: dict[str, list[tuple[float, float]]] = defaultdict(list)
for item in board.GetTracks():
if isinstance(item, pcbnew.PCB_VIA):
position = item.GetPosition()
result[item.GetNetname()].append(
(round(to_mm(position.x), 4), round(to_mm(position.y), 4)))
return result
def terminals_of(group: list[pcbnew.PAD], net: str,
vias: dict[str, list[tuple[float, float]]] | None = None,
inner_layers: tuple[int, ...] = (pcbnew.B_Cu,),
) -> list[tuple[tuple[float, float], int]]:
"""Points this group can be routed from, with the layer each one sits on.
A pad that already has a dogbone via contributes that via as well. Under
the Ti375 the escape vias are the only usable terminals: an isolated bank
supply ball has no plane left around its via (every neighbouring ball's
anti-pad eats it), and F.Cu there is solid escape fan-out, so the one route
that exists is a short hop to the neighbouring same-net via on B.Cu.
`inner_layers` is the set of routing layers such a via can be picked up on;
it comes from the board so a stackup change cannot silently strand one.
"""
result = []
for pad in group:
parent = pad.GetParentFootprint()
if parent is not None and parent.GetReference() in SKIP_REFS:
continue
position = pad.GetPosition()
xy = (round(to_mm(position.x), 4), round(to_mm(position.y), 4))
if net == "GND" and (RF_GROUND_REGION[0] <= xy[0] <= RF_GROUND_REGION[2] and
RF_GROUND_REGION[1] <= xy[1] <= RF_GROUND_REGION[3]):
continue
result.append((xy, pad_layer(pad)))
for via in (vias or {}).get(net, ()):
if math.hypot(via[0] - xy[0], via[1] - xy[1]) <= ESCAPE_VIA_REACH:
for layer in inner_layers:
result.append((via, layer))
return result
def on_plane(board: pcbnew.BOARD, group: list[pcbnew.PAD],
zones: list[pcbnew.ZONE]) -> bool:
"""True when this group already sits on one of the rail's own zones.
Without this test a group that reached its plane but did not thereby merge
with any other group - because no other group is on that island - reports
progress on every pass. route_net then keeps calling reach_plane, which
keeps finding the next free spot one step further out, and the rail ends up
with a cloud of dozens of redundant vias around a single pad.
"""
if not zones:
return False
wanted = {zone.m_Uuid.AsString() for zone in zones}
connectivity = board.GetConnectivity()
for pad in group:
for item in connectivity.GetConnectedItems(pad, 0):
if item.GetClass() == "ZONE" and item.m_Uuid.AsString() in wanted:
return True
return False
def route_net(router: LocalRouter, board: pcbnew.BOARD, net: str,
zones: list[pcbnew.ZONE], detour: bool = True) -> tuple[int, int]:
"""Return the number of groups joined and the number left unresolved."""
joined = 0
stalled = 0
previous = None
for _pass in range(60):
board.BuildConnectivity()
groups = groups_for_net(board, net)
if len(groups) <= 1:
break
# A pass that claims progress without reducing the group count has not
# actually connected anything new. Allow a couple of those - a plane
# drop can legitimately need one - then stop rather than pile on copper.
if previous is not None and len(groups) >= previous:
stalled += 1
if stalled >= 2:
break
else:
stalled = 0
previous = len(groups)
vias = escape_via_map(board)
inner = tuple(layer for layer in router.routing_layers
if layer != pcbnew.F_Cu)
terminals = [terminals_of(group, net, vias, inner) for group in groups]
sizes = [len(group) for group in groups]
main = sizes.index(max(sizes))
progress = False
for index, group_terminals in enumerate(terminals):
if index == main:
continue
if (zones and not on_plane(board, groups[index], zones)
and router.reach_plane(net, zones, group_terminals)):
joined += 1
progress = True
break
candidates = []
for other, other_terminals in enumerate(terminals):
if other == index:
continue
for a, a_layer in group_terminals:
for b, b_layer in other_terminals:
distance = math.hypot(a[0] - b[0], a[1] - b[1])
if distance <= MAX_JOIN_LENGTH:
candidates.append((distance, a, a_layer, b, b_layer))
candidates.sort(key=lambda entry: entry[0])
for _distance, a, a_layer, b, b_layer in candidates[:24]:
if router.connect(net, a, a_layer, b, b_layer, detour):
joined += 1
progress = True
break
if progress:
break
if not progress:
break
board.BuildConnectivity()
return joined, len(groups_for_net(board, net)) - 1
def signal_nets(board: pcbnew.BOARD,
classes: frozenset[str] | None = None) -> dict[str, float]:
"""Routable signal nets and the track width their net class asks for."""
result: dict[str, float] = {}
for footprint in board.GetFootprints():
for pad in footprint.Pads():
name = pad.GetNetname()
if not name or name in TARGET_NETS or name in result:
continue
klass = pad.GetNetClassName()
if klass in SKIP_CLASSES or (classes and klass not in classes):
continue
width = NETCLASS_WIDTH.get(klass)
if width is not None:
result[name] = width
return result
def signal_clearances(board: pcbnew.BOARD,
classes: frozenset[str] | None = None) -> dict[str, float]:
result: dict[str, float] = {}
for footprint in board.GetFootprints():
for pad in footprint.Pads():
name = pad.GetNetname()
if not name or name in result:
continue
klass = pad.GetNetClassName()
if classes and klass not in classes:
continue
value = NETCLASS_CLEARANCE.get(klass)
if value is not None:
result[name] = value
return result
def complete(board: pcbnew.BOARD, verbose: bool = True,
signals: bool = False,
signal_classes: frozenset[str] | None = None) -> dict[str, int]:
"""Run the local power completion on an already-filled board.
finalize_routing.py calls this a second time once the temporary RF and USB
rule areas are gone, which is the only moment the pads inside those
corridors can be reached. With `signals` it then makes the same two passes
over ordinary signal nets, to close the connections the autorouter gave up
on; those use their net class width and are locked like everything else.
"""
router = LocalRouter(board)
zones_by_net: dict[str, list[pcbnew.ZONE]] = defaultdict(list)
for zone in board.Zones():
if not zone.GetIsRuleArea():
zones_by_net[zone.GetNetname()].append(zone)
# Two phases. Everything that can be closed with a plane drop or a short
# direct path is done first, for every rail; only then are the leftovers
# allowed to take a detour. Otherwise the first rail in the list spends
# the free space on long routes that a later rail needed for a short one.
remaining: dict[str, int] = {}
for detour in (False, True):
remaining = {}
for net in TARGET_NETS:
if board.FindNet(net) is None:
continue
joined, left = route_net(router, board, net,
zones_by_net.get(net, []), detour)
if verbose and detour and (joined or left):
print(f"{net:14s} {left} groups still separate")
if left:
remaining[net] = left
if signals:
router.widths = signal_nets(board, signal_classes)
router.clearance = SIGNAL_CLEARANCE
router.clearances = signal_clearances(board, signal_classes)
closed = 0
for detour in (False, True):
for net in sorted(router.widths):
joined, left = route_net(router, board, net, [], detour)
closed += joined
if left:
remaining[net] = left
else:
remaining.pop(net, None)
if verbose:
print(f"Signal completion joined {closed} groups")
if verbose:
print(f"Installed {router.installed} tracks and {router.vias} vias")
print("Unresolved:",
", ".join(f"{n}={k}" for n, k in sorted(remaining.items())) or "none")
return remaining
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--clocks", action="store_true",
help="Also route the CLOCK class here, before the "
"autorouter runs, so those nets are fixed copper "
"by the time it starts.")
args = parser.parse_args()
board = pcbnew.LoadBoard(str(args.input))
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Could not fill planes before local power routing")
complete(board, signals=args.clocks,
signal_classes=frozenset({"CLOCK"}) if args.clocks else None)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Could not refill planes after local power routing")
pcbnew.SaveBoard(str(args.output), board)
for suffix in (".kicad_pro", ".kicad_dru"):
source = args.input.with_suffix(suffix)
if source.exists():
shutil.copyfile(source, args.output.with_suffix(suffix))
print(f"Saved {args.output}")
if __name__ == "__main__":
main()
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Route the three buck converters before general signal autorouting.
The switching nodes and high-current input/output paths are deterministic and
locked. Freerouting must never be asked to invent these loops. Internal-plane
entry points use explicit via arrays; ordinary low-current power stitching is
left to stitch_plane_pads.py.
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
import pcbnew
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT = ROOT / "hardware" / "board_prerouted.kicad_pcb"
DEFAULT_OUTPUT = ROOT / "hardware" / "board_power_routed.kicad_pcb"
def mm(value: float) -> int:
return pcbnew.FromMM(value)
def point(x: float, y: float) -> pcbnew.VECTOR2I:
return pcbnew.VECTOR2I(mm(x), mm(y))
def pad(board: pcbnew.BOARD, reference: str, number: str) -> pcbnew.PAD:
footprint = board.FindFootprintByReference(reference)
if footprint is None:
raise RuntimeError(f"Missing footprint {reference}")
matches = [item for item in footprint.Pads() if item.GetNumber() == number]
if not matches:
raise RuntimeError(f"Missing pad {reference}.{number}")
return matches[0]
def xy(item: pcbnew.BOARD_ITEM) -> tuple[float, float]:
position = item.GetPosition()
return pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
def assert_pad_net(board: pcbnew.BOARD, reference: str, number: str,
expected: str) -> None:
actual = pad(board, reference, number).GetNetname()
if actual != expected:
raise RuntimeError(
f"{reference}.{number}: expected {expected}, found {actual or '<none>'}"
)
def add_track(board: pcbnew.BOARD, net_name: str,
start: tuple[float, float], end: tuple[float, float],
width: float, layer: int = pcbnew.F_Cu) -> None:
if start == end:
return
track = pcbnew.PCB_TRACK(board)
track.SetStart(point(*start))
track.SetEnd(point(*end))
track.SetWidth(mm(width))
track.SetLayer(layer)
track.SetNet(board.FindNet(net_name))
track.SetLocked(True)
board.Add(track)
def add_path(board: pcbnew.BOARD, net_name: str,
points: list[tuple[float, float]], width: float,
layer: int = pcbnew.F_Cu) -> None:
for start, end in zip(points, points[1:]):
add_track(board, net_name, start, end, width, layer)
def add_via(board: pcbnew.BOARD, net_name: str, x: float, y: float,
diameter: float = 0.70, drill: float = 0.35) -> None:
via = pcbnew.PCB_VIA(board)
via.SetPosition(point(x, y))
via.SetWidth(mm(diameter))
via.SetDrill(mm(drill))
via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
via.SetNet(board.FindNet(net_name))
via.SetLocked(True)
board.Add(via)
def route_core_buck(board: pcbnew.BOARD) -> None:
for reference, number, expected in (
("U13", "4", "SYS5"), ("U13", "9", "CORE_SW"),
("U13", "23", "CORE_COMP"), ("U13", "24", "CORE_SENSE_N"),
("L1", "1", "CORE_SW"), ("L1", "2", "VCC0V95"),
("C18", "1", "SYS5"), ("C22", "1", "VCC0V95"),
("R27", "2", "CORE_SENSE_P"), ("R28", "2", "CORE_SENSE_N"),
):
assert_pad_net(board, reference, number, expected)
# The exposed pad carries the converter return/thermal current. Four
# via-in-pad connections plus explicit perimeter-pin escapes replace the
# generic stitching that is intentionally disabled for U13.
for x in (99.70, 100.30):
for y in (92.40, 93.60):
add_via(board, "GND", x, y, 0.45, 0.225)
add_track(board, "GND", xy(pad(board, "U13", "3")), (98.10, 92.25), 0.22)
add_via(board, "GND", 98.10, 92.25, 0.45, 0.225)
add_track(board, "GND", xy(pad(board, "U13", "7")), (98.10, 94.25), 0.22)
add_via(board, "GND", 98.10, 94.25, 0.45, 0.225)
add_path(board, "GND", [xy(pad(board, "U13", "8")),
(98.675, 95.10), (98.10, 95.10)], 0.22)
add_via(board, "GND", 98.10, 95.10, 0.45, 0.225)
add_track(board, "GND", xy(pad(board, "U13", "19")), (101.65, 91.75), 0.22)
add_via(board, "GND", 101.65, 91.75, 0.45, 0.225)
add_path(board, "GND", [xy(pad(board, "U13", "20")),
(101.25, 91.10), (101.65, 91.10)], 0.22)
add_via(board, "GND", 101.65, 91.10, 0.45, 0.225)
add_path(board, "GND", [xy(pad(board, "U13", "22")),
(100.25, 92.40), (100.30, 92.40)], 0.22)
add_track(board, "GND", xy(pad(board, "U13", "14")), (101.65, 94.25), 0.22)
add_via(board, "GND", 101.65, 94.25, 0.45, 0.225)
add_path(board, "GND", [xy(pad(board, "U13", "13")),
(101.25, 95.10), (101.65, 95.10)], 0.22)
add_via(board, "GND", 101.65, 95.10, 0.45, 0.225)
# TPS6287B switching pins fan into a short common neck, then immediately
# enter L1. No other net is permitted through this high-dV/dt region.
for number in ("9", "10", "11", "12"):
x, y = xy(pad(board, "U13", number))
add_track(board, "CORE_SW", (x, y), (x, 95.25), 0.20)
add_track(board, "CORE_SW", (99.25, 95.25), (100.75, 95.25), 0.35)
add_track(board, "CORE_SW", (100.0, 95.25), xy(pad(board, "L1", "1")), 0.80)
# Expand immediately after L1 into the four 100-uF output capacitors.
l1_out = xy(pad(board, "L1", "2"))
add_track(board, "VCC0V95", l1_out, (100.0, 102.025), 1.20)
add_track(board, "VCC0V95", (95.5, 102.025), (106.0, 102.025), 1.00)
# A separate broad trunk and four-via entry feed the 0.95-V internal plane.
add_path(board, "VCC0V95",
[(106.0, 102.025), (108.2, 102.025), (108.2, 93.0)], 1.50)
for x in (107.8, 108.6):
for y in (92.6, 93.4):
add_via(board, "VCC0V95", x, y, 0.75, 0.38)
# Both VIN sides of U13 are locally decoupled. The right bank joins the
# left bank above the IC, away from the switching/output trunk.
add_track(board, "SYS5", xy(pad(board, "C18", "1")),
xy(pad(board, "C19", "1")), 0.60)
for number in ("4", "5", "6"):
source = xy(pad(board, "U13", number))
add_track(board, "SYS5", source, (97.175, source[1]), 0.25)
add_track(board, "SYS5", xy(pad(board, "C20", "1")),
xy(pad(board, "C21", "1")), 0.60)
for number in ("15", "16", "18"):
source = xy(pad(board, "U13", number))
add_track(board, "SYS5", source, (102.725, source[1]), 0.25)
# Feed the right input-capacitor bank from the existing B.Cu VIN trunk so
# the quiet compensation/sense region above U13 remains unobstructed.
add_track(board, "SYS5", xy(pad(board, "C20", "1")),
(102.725, 90.70), 0.60)
add_via(board, "SYS5", 102.725, 90.70, 0.70, 0.35)
add_path(board, "SYS5", [(102.725, 90.70), (110.00, 90.70),
(110.00, 97.20), (109.00, 97.20)],
0.80, pcbnew.B_Cu)
# Drop to B.Cu just below C19, bypassing the power-sequencer components
# that occupy the left side of the converter on F.Cu.
add_track(board, "SYS5", xy(pad(board, "C19", "1")),
(97.175, 95.60), 0.60)
add_via(board, "SYS5", 97.175, 95.60, 0.75, 0.38)
add_track(board, "SYS5", (97.175, 95.60), (97.175, 97.20),
0.80, pcbnew.B_Cu)
add_path(board, "SYS5", [(97.175, 97.20), (109.00, 97.20),
(109.00, 108.50), (92.60, 108.50)],
1.20, pcbnew.B_Cu)
for x in (92.2, 93.0):
for y in (108.1, 108.9):
add_via(board, "SYS5", x, y, 0.75, 0.38)
# Compensation components form a literal series chain. Keep it on F.Cu
# and out of the switch-node field.
add_track(board, "CORE_COMP_RC", xy(pad(board, "C16", "1")),
xy(pad(board, "R29", "2")), 0.15)
add_track(board, "CORE_COMP", xy(pad(board, "R29", "1")),
xy(pad(board, "C17", "1")), 0.15)
add_path(board, "CORE_COMP",
[xy(pad(board, "C17", "1")), (99.52, 90.20),
(99.75, 90.20),
xy(pad(board, "U13", "23"))], 0.15)
# Route the positive/negative remote-sense pair together on L3_SIG. The
# pair stays north of the converter and never approaches CORE_SW.
sense = (
("CORE_SENSE_P", "1", "R27", "2", 98.40, 90.40, 87.60),
("CORE_SENSE_N", "24", "R28", "2", 99.10, 90.70, 89.70),
)
for (net_name, u13_pin, resistor, resistor_pin,
start_x, start_y, lane_y) in sense:
start = xy(pad(board, "U13", u13_pin))
start_via = (start_x, start_y)
end = xy(pad(board, resistor, resistor_pin))
end_via = (102.90, end[1])
add_path(board, net_name, [start, (start[0], start_y), start_via], 0.12)
add_via(board, net_name, *start_via, 0.45, 0.225)
add_path(board, net_name,
[start_via, (start_x, lane_y), (102.90, lane_y), end_via],
0.12, pcbnew.In2_Cu)
add_via(board, net_name, *end_via, 0.45, 0.225)
add_track(board, net_name, end_via, end, 0.12)
def route_small_buck(board: pcbnew.BOARD, *, regulator: str, inductor: str,
input_caps: tuple[str, str], output_caps: tuple[str, str],
sw_net: str, out_net: str, input_x: float,
plane_via_x: float) -> None:
for reference, number, expected in (
(regulator, "3", "SYS5"), (regulator, "7", sw_net),
(inductor, "1", sw_net), (inductor, "2", out_net),
(input_caps[0], "1", "SYS5"), (input_caps[1], "1", "SYS5"),
(output_caps[0], "1", out_net), (output_caps[1], "1", out_net),
):
assert_pad_net(board, reference, number, expected)
# Input capacitors, VIN pin, and the SYS5 plane entry form one compact path.
upper_in = xy(pad(board, input_caps[0], "1"))
lower_in = xy(pad(board, input_caps[1], "1"))
add_track(board, "SYS5", upper_in, lower_in, 0.45)
regulator_in = xy(pad(board, regulator, "3"))
diagonal_start_x = lower_in[0] + abs(lower_in[1] - regulator_in[1])
add_path(board, "SYS5", [regulator_in,
(diagonal_start_x, regulator_in[1]), lower_in], 0.30)
add_track(board, "SYS5", lower_in, (input_x, 107.60), 0.70)
for x in (input_x - 0.35, input_x + 0.35):
for y in (107.25, 107.95):
add_via(board, "SYS5", x, y, 0.65, 0.33)
# Keep the switching node to the literal pin-to-inductor span.
add_track(board, sw_net, xy(pad(board, regulator, "7")),
xy(pad(board, inductor, "1")), 0.50)
# Join the inductor, output capacitors, regulator output pins and a
# plane entry installed by stitch_plane_pads.py. These spans stay on F.Cu.
coil_out = xy(pad(board, inductor, "2"))
cap_a = xy(pad(board, output_caps[0], "1"))
cap_b = xy(pad(board, output_caps[1], "1"))
add_path(board, out_net, [coil_out, (plane_via_x, 97.0),
(plane_via_x, cap_a[1]), cap_a], 0.55)
add_path(board, out_net, [(plane_via_x, 97.0),
(plane_via_x, cap_b[1]), cap_b], 0.55)
reg_out_a = xy(pad(board, regulator, "8"))
reg_out_b = xy(pad(board, regulator, "9"))
add_track(board, out_net, reg_out_a, reg_out_b, 0.25)
add_path(board, out_net,
[reg_out_b, (plane_via_x, reg_out_b[1]), (plane_via_x, 97.0)], 0.30)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
board = pcbnew.LoadBoard(str(args.input))
route_core_buck(board)
route_small_buck(
board, regulator="U14", inductor="L2", input_caps=("C26", "C27"),
output_caps=("C28", "C29"), sw_net="BUCK1V8_SW", out_net="VCC1V8",
input_x=112.98, plane_via_x=120.105,
)
route_small_buck(
board, regulator="U15", inductor="L3", input_caps=("C30", "C31"),
output_caps=("C32", "C33"), sw_net="BUCK3V3_SW", out_net="VCC3V3",
input_x=127.98, plane_via_x=135.105,
)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Zone fill failed after power-stage routing")
pcbnew.SaveBoard(str(args.output), board)
for suffix in (".kicad_pro", ".kicad_dru"):
source = args.input.with_suffix(suffix)
if source.exists():
shutil.copyfile(source, args.output.with_suffix(suffix))
print(f"Saved {args.output}")
if __name__ == "__main__":
main()
+164 -47
View File
@@ -5,6 +5,10 @@ This deliberately does not replace power-stage routing. It only installs
short pad-to-via connections where a filled, same-net plane is physically
present below the candidate via. High-current converter loops and plane
bridges are handled separately.
It runs *after* route_power_stage.py: a candidate via is only checked against
copper that already exists, so any stage that lays down wide trunks has to go
first or its copper is invisible here.
"""
from __future__ import annotations
@@ -18,7 +22,7 @@ import pcbnew
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT = ROOT / "hardware" / "board_prerouted.kicad_pcb"
DEFAULT_INPUT = ROOT / "hardware" / "board_power_routed.kicad_pcb"
DEFAULT_OUTPUT = ROOT / "hardware" / "board_plane_stitched.kicad_pcb"
PLANE_NETS = {
"GND", "SYS5", "VCC0V95", "VCC1V8", "VCC3V3", "VCC3V3_AUX",
@@ -38,7 +42,7 @@ def distance_mm(a: pcbnew.VECTOR2I, b: pcbnew.VECTOR2I) -> float:
def add_track(board: pcbnew.BOARD, pad: pcbnew.PAD,
end: pcbnew.VECTOR2I, width: float) -> None:
end: pcbnew.VECTOR2I, width: float) -> pcbnew.PCB_TRACK:
track = pcbnew.PCB_TRACK(board)
track.SetStart(pad.GetPosition())
track.SetEnd(end)
@@ -47,6 +51,23 @@ def add_track(board: pcbnew.BOARD, pad: pcbnew.PAD,
track.SetNet(pad.GetNet())
track.SetLocked(True)
board.Add(track)
return track
def already_connected(pad: pcbnew.PAD, tracks: list[pcbnew.PCB_TRACK],
vias: list[pcbnew.PCB_VIA]) -> bool:
"""Report whether an earlier stage already attached copper to this pad."""
net = pad.GetNetname()
box = pad.GetBoundingBox()
def inside(position: pcbnew.VECTOR2I) -> bool:
return (box.GetX() <= position.x <= box.GetRight() and
box.GetY() <= position.y <= box.GetBottom())
for track in tracks:
if track.GetNetname() == net and (inside(track.GetStart()) or inside(track.GetEnd())):
return True
return any(via.GetNetname() == net and inside(via.GetPosition()) for via in vias)
def add_via(board: pcbnew.BOARD, pad: pcbnew.PAD,
@@ -68,6 +89,66 @@ def plane_at(zones_by_net: dict[str, list[pcbnew.ZONE]], net: str,
for zone in zones_by_net.get(net, ()))
def inside_temporary_keepout(position: pcbnew.VECTOR2I,
keepouts: list[pcbnew.ZONE]) -> bool:
"""Reject a through-via whose copper can touch an RF/USB rule area."""
radius = VIA_DIAMETER / 2 + CLEARANCE
probes = [(0.0, 0.0)]
for index in range(8):
angle = index * math.pi / 4
probes.append((radius * math.cos(angle), radius * math.sin(angle)))
for dx, dy in probes:
probe = pcbnew.VECTOR2I(position.x + mm(dx), position.y + mm(dy))
if any(zone.Outline().Contains(probe) for zone in keepouts):
return True
return False
def segment_distance_mm(point: pcbnew.VECTOR2I, start: pcbnew.VECTOR2I,
end: pcbnew.VECTOR2I) -> float:
px, py = pcbnew.ToMM(point.x), pcbnew.ToMM(point.y)
ax, ay = pcbnew.ToMM(start.x), pcbnew.ToMM(start.y)
bx, by = pcbnew.ToMM(end.x), pcbnew.ToMM(end.y)
dx, dy = bx - ax, by - ay
length = dx * dx + dy * dy
if length == 0.0:
return math.hypot(px - ax, py - ay)
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
def clear_of_tracks(position: pcbnew.VECTOR2I, net: str,
tracks: list[pcbnew.PCB_TRACK], extra: float = 0.0) -> bool:
"""Keep a via barrel clear of copper installed by the routing stages."""
for track in tracks:
if track.GetNetname() == net or isinstance(track, pcbnew.PCB_VIA):
continue
limit = (VIA_DIAMETER / 2 + extra + pcbnew.ToMM(track.GetWidth()) / 2
+ CLEARANCE + 0.03)
if segment_distance_mm(position, track.GetStart(), track.GetEnd()) < limit:
return False
return True
def clear_track_of_tracks(start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, net: str,
tracks: list[pcbnew.PCB_TRACK], layer: int,
width: float) -> bool:
for track in tracks:
if track.GetNetname() == net or isinstance(track, pcbnew.PCB_VIA):
continue
if track.GetLayer() != layer:
continue
limit = width / 2 + pcbnew.ToMM(track.GetWidth()) / 2 + CLEARANCE + 0.03
for step in range(0, 13):
sample = pcbnew.VECTOR2I(
round(start.x + (end.x - start.x) * step / 12),
round(start.y + (end.y - start.y) * step / 12),
)
if segment_distance_mm(sample, track.GetStart(), track.GetEnd()) < limit:
return False
return True
def clear_of_other_copper(position: pcbnew.VECTOR2I, net: str,
pads: list[pcbnew.PAD], vias: list[pcbnew.PCB_VIA]) -> bool:
x, y = pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
@@ -116,19 +197,93 @@ def candidates(pad: pcbnew.PAD, footprint: pcbnew.FOOTPRINT) -> list[pcbnew.VECT
away = 0.0
radius = max(pcbnew.ToMM(pad.GetSizeX()), pcbnew.ToMM(pad.GetSizeY())) / 2
radius += VIA_DIAMETER / 2 + CLEARANCE + 0.08
angles = [away]
for delta_deg in (30, -30, 60, -60, 90, -90, 120, -120, 150, -150, 180):
angles.append(away + math.radians(delta_deg))
# Freerouting treats fixed non-45-degree traces as invalid geometry. Use
# only global orthogonal/45-degree directions, ordered by how closely they
# point away from the footprint centre.
directions = [(math.cos(math.radians(angle)), math.sin(math.radians(angle)))
for angle in range(0, 360, 45)]
directions.sort(key=lambda direction: -(
direction[0] * math.cos(away) + direction[1] * math.sin(away)
))
result = []
for scale in (1.0, 1.35, 1.75):
for angle in angles:
for dx, dy in directions:
result.append(pcbnew.VECTOR2I(
pad_pos.x + mm(radius * scale * math.cos(angle)),
pad_pos.y + mm(radius * scale * math.sin(angle)),
pad_pos.x + mm(radius * scale * dx),
pad_pos.y + mm(radius * scale * dy),
))
return result
def stitch(board: pcbnew.BOARD) -> tuple[dict[str, int], dict[str, int]]:
"""Connect every reachable SMD power pad to its plane on the given board.
Callable twice: once before signal routing, so the planes already have
real connection points, and again from finalize_routing.py once the RF and
USB corridors are no longer reserved by temporary rule areas. Pads that
are already stitched are skipped because the candidate location is then
occupied by the earlier via.
"""
zones_by_net: dict[str, list[pcbnew.ZONE]] = {}
for zone in board.Zones():
if not zone.GetIsRuleArea() and zone.GetNetname() in PLANE_NETS:
zones_by_net.setdefault(zone.GetNetname(), []).append(zone)
temporary_keepouts = [zone for zone in board.Zones()
if zone.GetIsRuleArea() and zone.GetZoneName().startswith("TEMP_")]
all_pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()]
all_vias = [item for item in board.GetTracks() if isinstance(item, pcbnew.PCB_VIA)]
all_tracks = [item for item in board.GetTracks() if not isinstance(item, pcbnew.PCB_VIA)]
connected: dict[str, int] = {}
skipped: dict[str, int] = {}
for fp in board.GetFootprints():
# U1 already has deterministic dogbones. J2 is an edge-launch part;
# its ground lands are completed by finalize_routing.py's RF ground
# zone/fence, not by ordinary tracks that would run along Edge.Cuts.
# U13 ground/thermal vias are part of the deterministic high-current
# converter geometry in route_power_stage.py. Generic radial dogbones
# here can intrude into its SW and VIN paths.
if fp.GetReference() in {"U1", "U13", "J2"}:
continue
for pad in fp.Pads():
net = pad.GetNetname()
if net not in PLANE_NETS or pad.GetAttribute() != pcbnew.PAD_ATTRIB_SMD:
continue
if already_connected(pad, all_tracks, all_vias):
continue
chosen = None
pad_min = min(pcbnew.ToMM(pad.GetSizeX()), pcbnew.ToMM(pad.GetSizeY()))
width = max(0.15, min(0.30, pad_min * 0.65))
for candidate in candidates(pad, fp):
x, y = pcbnew.ToMM(candidate.x), pcbnew.ToMM(candidate.y)
if not (20.5 < x < 174.5 and 20.5 < y < 109.5):
continue
if not plane_at(zones_by_net, net, candidate):
continue
if inside_temporary_keepout(candidate, temporary_keepouts):
continue
if not clear_of_other_copper(candidate, net, all_pads, all_vias):
continue
if not clear_track_path(pad.GetPosition(), candidate, net, pad, all_pads, width):
continue
if not clear_of_tracks(candidate, net, all_tracks):
continue
if not clear_track_of_tracks(pad.GetPosition(), candidate, net,
all_tracks, pad.GetLayer(), width):
continue
chosen = candidate
break
if chosen is None:
skipped[net] = skipped.get(net, 0) + 1
continue
all_tracks.append(add_track(board, pad, chosen, width))
all_vias.append(add_via(board, pad, chosen))
connected[net] = connected.get(net, 0) + 1
return connected, skipped
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
@@ -138,45 +293,7 @@ def main() -> None:
board = pcbnew.LoadBoard(str(args.input))
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Could not fill planes before stitching")
zones_by_net: dict[str, list[pcbnew.ZONE]] = {}
for zone in board.Zones():
if not zone.GetIsRuleArea() and zone.GetNetname() in PLANE_NETS:
zones_by_net.setdefault(zone.GetNetname(), []).append(zone)
all_pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()]
all_vias = [item for item in board.GetTracks() if isinstance(item, pcbnew.PCB_VIA)]
connected: dict[str, int] = {}
skipped: dict[str, int] = {}
for fp in board.GetFootprints():
if fp.GetReference() == "U1":
continue
for pad in fp.Pads():
net = pad.GetNetname()
if net not in PLANE_NETS or pad.GetAttribute() != pcbnew.PAD_ATTRIB_SMD:
continue
chosen = None
pad_min = min(pcbnew.ToMM(pad.GetSizeX()), pcbnew.ToMM(pad.GetSizeY()))
width = max(0.15, min(0.30, pad_min * 0.65))
for candidate in candidates(pad, fp):
x, y = pcbnew.ToMM(candidate.x), pcbnew.ToMM(candidate.y)
if not (20.5 < x < 174.5 and 20.5 < y < 109.5):
continue
if not plane_at(zones_by_net, net, candidate):
continue
if not clear_of_other_copper(candidate, net, all_pads, all_vias):
continue
if not clear_track_path(pad.GetPosition(), candidate, net, pad, all_pads, width):
continue
chosen = candidate
break
if chosen is None:
skipped[net] = skipped.get(net, 0) + 1
continue
add_track(board, pad, chosen, width)
all_vias.append(add_via(board, pad, chosen))
connected[net] = connected.get(net, 0) + 1
connected, skipped = stitch(board)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Could not refill planes after stitching")