247 lines
9.1 KiB
Python
247 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Import a Specctra session and install audited RF/USB copper.
|
|
|
|
Freerouting is used only for the dense non-critical interconnect. The GPS
|
|
front end and USB2 pair are removed from that result and rebuilt here with
|
|
deterministic geometry derived from the selected JLC08161H-3313 stackup.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pcbnew
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PLACED = ROOT / "hardware" / "board.kicad_pcb"
|
|
ROUTED = ROOT / "hardware" / "board_routed.kicad_pcb"
|
|
|
|
RF_NETS = {"RF_SMA", "RF_PRE_SAW", "RF_POST_SAW", "MAX_MIXIN_HI"}
|
|
USB_NETS = {"USB_D_P", "USB_D_N", "FTDI_D_P", "FTDI_D_N"}
|
|
CRITICAL_NETS = RF_NETS | USB_NETS
|
|
|
|
|
|
def mm(value: float) -> int:
|
|
return pcbnew.FromMM(value)
|
|
|
|
|
|
def point(x: float, y: float) -> pcbnew.VECTOR2I:
|
|
return pcbnew.VECTOR2I_MM(x, y)
|
|
|
|
|
|
def footprint(board: pcbnew.BOARD, ref: str) -> pcbnew.FOOTPRINT:
|
|
item = board.FindFootprintByReference(ref)
|
|
if item is None:
|
|
raise RuntimeError(f"Missing footprint {ref}")
|
|
return item
|
|
|
|
|
|
def pads(board: pcbnew.BOARD, ref: str, number: str) -> list[pcbnew.PAD]:
|
|
result = [pad for pad in footprint(board, ref).Pads() if pad.GetNumber() == str(number)]
|
|
if not result:
|
|
raise RuntimeError(f"Missing pad {ref}.{number}")
|
|
return result
|
|
|
|
|
|
def pad_xy(board: pcbnew.BOARD, ref: str, number: str, index: int = 0) -> tuple[float, float]:
|
|
position = pads(board, ref, number)[index].GetPosition()
|
|
return pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
|
|
|
|
|
|
def assert_ref_nets(board: pcbnew.BOARD, ref: str, expected: set[str]) -> None:
|
|
actual = {pad.GetNetname() for pad in footprint(board, ref).Pads() if pad.GetNetname()}
|
|
if actual != expected:
|
|
raise RuntimeError(f"{ref} net mismatch: expected {sorted(expected)}, got {sorted(actual)}")
|
|
|
|
|
|
def add_segment(board: pcbnew.BOARD, net_name: str,
|
|
start: tuple[float, float], end: tuple[float, float],
|
|
width: float, layer: int = pcbnew.F_Cu, locked: bool = True) -> 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(locked)
|
|
board.Add(track)
|
|
|
|
|
|
def add_polyline(board: pcbnew.BOARD, net_name: str,
|
|
vertices: list[tuple[float, float]], width: float,
|
|
layer: int = pcbnew.F_Cu) -> None:
|
|
for start, end in zip(vertices, vertices[1:]):
|
|
add_segment(board, net_name, start, end, width, layer)
|
|
|
|
|
|
def add_via(board: pcbnew.BOARD, net_name: str, position: tuple[float, float],
|
|
diameter: float = 0.45, drill: float = 0.225) -> None:
|
|
via = pcbnew.PCB_VIA(board)
|
|
via.SetPosition(point(*position))
|
|
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 remove_critical_routes(board: pcbnew.BOARD) -> int:
|
|
doomed = [item for item in board.GetTracks() if item.GetNetname() in CRITICAL_NETS]
|
|
for item in doomed:
|
|
board.Remove(item)
|
|
return len(doomed)
|
|
|
|
|
|
def remove_temporary_keepouts(board: pcbnew.BOARD) -> int:
|
|
doomed = [zone for zone in board.Zones()
|
|
if zone.GetIsRuleArea() and zone.GetZoneName().startswith("TEMP_")]
|
|
for zone in doomed:
|
|
board.Remove(zone)
|
|
return len(doomed)
|
|
|
|
|
|
def route_rf(board: pcbnew.BOARD) -> None:
|
|
# 0.164-mm L1 CPWG with 0.200-mm ground gap: 49.65 ohms on the exact
|
|
# JLC08161H-3313 L1/L2 dielectric construction.
|
|
for ref, expected in {
|
|
"C83": {"RF_SMA", "RF_PRE_SAW"},
|
|
"C84": {"RF_POST_SAW", "GND"},
|
|
"R39": {"RF_POST_SAW", "MAX_MIXIN_HI"},
|
|
"C85": {"MAX_MIXIN_HI", "GND"},
|
|
}.items():
|
|
assert_ref_nets(board, ref, expected)
|
|
|
|
w = 0.164
|
|
j2 = pad_xy(board, "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)
|
|
d1 = pad_xy(board, "D1", "1")
|
|
add_polyline(board, "RF_SMA", [d1, (d1[0], 45.07)], w)
|
|
|
|
add_polyline(board, "RF_PRE_SAW",
|
|
[pad_xy(board, "C83", "2"), pad_xy(board, "FL1", "1")], w)
|
|
|
|
fl_out = pad_xy(board, "FL1", "4")
|
|
tune1 = pad_xy(board, "C84", "1")
|
|
series_1 = pad_xy(board, "R39", "1")
|
|
add_polyline(board, "RF_POST_SAW", [fl_out, tune1, series_1], w)
|
|
|
|
series_2 = pad_xy(board, "R39", "2")
|
|
mixin = pad_xy(board, "U21", "25")
|
|
add_polyline(board, "MAX_MIXIN_HI",
|
|
[series_2, (159.69, 41.80), (mixin[0], 41.80), mixin], w)
|
|
tune2 = pad_xy(board, "C85", "1")
|
|
add_polyline(board, "MAX_MIXIN_HI", [tune2, (tune2[0], 41.80)], w)
|
|
|
|
|
|
def route_usb(board: pcbnew.BOARD) -> None:
|
|
# Main device-side pair uses the upper receptacle contact pair. The two
|
|
# 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)
|
|
|
|
add_polyline(board, "USB_D_N", [dm_main, dm_join], w)
|
|
add_polyline(board, "USB_D_P", [dp_main, dp_join], w)
|
|
add_polyline(board, "USB_D_N", [dm_mirror, dm_aux], w)
|
|
add_polyline(board, "USB_D_P", [dp_mirror, dp_aux], w)
|
|
for net, location in (("USB_D_N", dm_join), ("USB_D_P", dp_join),
|
|
("USB_D_N", dm_aux), ("USB_D_P", dp_aux)):
|
|
add_via(board, net, location)
|
|
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)
|
|
|
|
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)
|
|
add_polyline(board, "USB_D_P",
|
|
[dp_esd, (30.715, 51.685), (33.715, 54.685), (35.80, 54.685), dp_r], 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)
|
|
|
|
|
|
def add_rf_ground(board: pcbnew.BOARD) -> None:
|
|
zone = pcbnew.ZONE(board)
|
|
zone.SetLayer(pcbnew.F_Cu)
|
|
zone.SetNet(board.FindNet("GND"))
|
|
zone.SetLocalClearance(mm(0.20))
|
|
zone.SetMinThickness(mm(0.10))
|
|
zone.SetPadConnection(pcbnew.ZONE_CONNECTION_FULL)
|
|
zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_NEVER)
|
|
zone.SetAssignedPriority(100)
|
|
outline = zone.Outline()
|
|
outline.NewOutline()
|
|
for x, y in ((152.0, 36.0), (174.5, 36.0), (174.5, 49.5), (152.0, 49.5)):
|
|
outline.Append(point(x, y))
|
|
board.Add(zone)
|
|
|
|
# Lambda/20 is about 9.5 mm at GPS L1 in FR-4. This 2-mm fence is much
|
|
# 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)]
|
|
for location in fence:
|
|
add_via(board, "GND", location, 0.45, 0.225)
|
|
|
|
|
|
def routed_lengths(board: pcbnew.BOARD) -> dict[str, float]:
|
|
totals = {net: 0.0 for net in CRITICAL_NETS}
|
|
for item in board.GetTracks():
|
|
net = item.GetNetname()
|
|
if net not in totals or isinstance(item, pcbnew.PCB_VIA):
|
|
continue
|
|
start, end = item.GetStart(), item.GetEnd()
|
|
totals[net] += math.hypot(pcbnew.ToMM(end.x - start.x), pcbnew.ToMM(end.y - start.y))
|
|
return totals
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("session", type=Path, help="Freerouting .ses file")
|
|
parser.add_argument("--output", type=Path, default=ROUTED)
|
|
args = parser.parse_args()
|
|
|
|
board = pcbnew.LoadBoard(str(PLACED))
|
|
if not pcbnew.ImportSpecctraSES(board, str(args.session)):
|
|
raise RuntimeError(f"Could not import {args.session}")
|
|
removed = remove_critical_routes(board)
|
|
removed_keepouts = remove_temporary_keepouts(board)
|
|
route_rf(board)
|
|
route_usb(board)
|
|
add_rf_ground(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)
|
|
if source.exists():
|
|
shutil.copyfile(source, args.output.with_suffix(suffix))
|
|
print(f"Removed {removed} autorouted critical-net items")
|
|
print(f"Removed {removed_keepouts} temporary critical-route keepouts")
|
|
print(f"Saved {args.output}")
|
|
for net, length in sorted(routed_lengths(board).items()):
|
|
print(f"{net:16s} {length:8.3f} mm")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|