Initial Ti375 GNSS IMU board design and documentation

This commit is contained in:
2026-08-15 18:09:17 +03:00
commit b706197667
69 changed files with 132859 additions and 0 deletions
+599
View File
@@ -0,0 +1,599 @@
#!/usr/bin/env python3
"""Create the placed eight-layer KiCad PCB from the audited design database.
The script is deterministic so placement, net assignments, stackup, and rules
can be regenerated and independently audited. Routing uses the staged
BGA/plane/Freerouting flow in scripts/README.md; finalize_routing.py replaces
autorouted RF/USB copper with audited deterministic geometry.
"""
from __future__ import annotations
import math
import re
import sys
from collections import defaultdict
from pathlib import Path
import pcbnew
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from design_data import components # noqa: E402
HW = ROOT / "hardware"
BOARD_FILE = HW / "board.kicad_pcb"
STOCK = Path("/usr/share/kicad/footprints")
# The distributed SMT test pads and removal of the former bottom test-point
# field allow a compact 155 x 90 mm outline.
X0, Y0, X1, Y1 = 20.0, 20.0, 175.0, 110.0
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_path(identifier: str) -> tuple[Path, str]:
library, name = identifier.split(":", 1)
if library == "board":
return HW / "board.pretty", name
return STOCK / f"{library}.pretty", name
MAJOR = {
# FPGA/configuration. All four PMODs are on the north edge, close to the
# BGA side carrying their I/O banks. Rotation makes the connector field
# shallow and keeps all modules clear of the RF and IMU regions.
"U1": (108, 72, 0), "U26": (97, 54, 0),
"JPMOD1": (78, 24, 0), "JPMOD2": (96, 24, 0),
"JPMOD3": (114, 24, 0), "JPMOD4": (132, 24, 0),
# USB-C and debug block.
"J1": (23.1, 48, 270), "U5": (30.5, 44.5, 0), "U6": (30.5, 51.5, 0),
"U2": (43, 55, 0), "U25": (35, 62, 0), "U3": (51, 63, 0),
"U29": (60, 54.5, 0), "U30": (67, 58.5, 0), "U31": (62, 63.5, 0),
# Mechanically quiet IMU region.
"U22": (51, 84, 0),
# Protected input and switching-power block.
"J3": (24, 100, 90), "U7": (34, 96, 0), "U8": (42, 104, 0), "U9": (46, 96, 0),
"U10": (54, 104, 0), "U11": (59, 104, 0), "U12": (64, 94, 0),
# High-current FPGA rails sit directly south of the BGA. This keeps the
# 15-A core path and both 6-A I/O-rail paths short enough to enter their
# local plane patches without long high-current traces.
"U13": (100, 93, 0), "U14": (115, 97, 0), "U15": (130, 97, 0), "U16": (90, 98, 0),
"U17": (143, 63, 0), "U18": (136, 67, 0), "U19": (152, 67, 0), "U20": (163, 63, 0),
"U28": (141, 72.5, 0), "U32": (136, 74.5, 0), "U34": (88, 88, 0),
"Q1": (70, 62, 0),
# User reset controls remain edge-accessible; only the tiny protection and
# open-drain circuitry sit inward from the connector/button.
"J4": (73, 106.5, 180), "SW1": (82, 106, 0), "D2": (78, 100.5, 0),
"U33": (87, 103, 0),
# RF front end. J2's launch reference is exactly on the right PCB edge.
"J2": (175, 42, 0), "D1": (170.45, 40.75, 0), "FL1": (164.0, 42.1, 180),
"J5": (171, 56, 0), "U27": (165, 52, 0), "U21": (155.5, 45, 0),
"U23": (149, 53, 0), "U24": (137, 55, 0),
}
# Parts whose exact physical location materially affects signal integrity,
# power-loop area, or mechanical performance.
SPECIAL = {
# RF series parts are collinear. L4 pad 2 lands directly on the SMA feed
# rather than creating a bias-tee stub; the optional shunt-tuning pad 1s
# 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),
# 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),
"R46": (154.6, 50.2, 90), "R47": (155.8, 50.2, 90),
"R48": (157.0, 50.2, 90), "R49": (158.2, 50.2, 90),
"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),
# 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),
"R57": (102.0, 58.8, 0), "R58": (104.0, 58.8, 0),
"R59": (106.0, 58.8, 0), "R60": (60.0, 83.6, 0),
"R61": (41.0, 83.6, 0),
# Buffered FTDI interface-1 hard JTAG. Output-series parts sit directly
# after the power-domain buffer; the TDO part is retained at the FPGA side.
"R70": (65.0, 52.0, 0), "R71": (65.0, 53.2, 0),
"R72": (65.0, 54.4, 0), "R73": (96.0, 70.0, 0),
"R74": (96.0, 71.2, 0),
}
# Small SMT bring-up pads live beside the circuit they measure; there is no
# connector-like test-point bank along the board edge.
SPECIAL.update({
"TP1": (37.5, 94.0, 0), "TP2": (45.5, 104.0, 0),
"TP3": (166.5, 54.0, 0), "TP4": (29.0, 39.0, 0),
"TP5": (22.0, 89.0, 0), "TP6": (49.5, 98.5, 0),
"TP7": (66.5, 90.0, 0), "TP8": (105.0, 98.0, 0),
"TP9": (118.0, 102.0, 0), "TP10": (133.0, 102.0, 0),
"TP11": (146.0, 61.0, 0), "TP12": (137.0, 70.0, 0),
"TP13": (97.0, 58.0, 0), "TP14": (99.0, 58.0, 0),
"TP15": (91.5, 103.0, 0),
# Added configuration/JTAG pulls and reset-domain support.
"R89": (96.0, 65.0, 0),
"R90": (96.0, 66.2, 0), "R91": (96.0, 67.4, 0), "R92": (96.0, 68.6, 0),
"R93": (96.0, 72.4, 0), "R94": (96.0, 73.6, 0),
"R95": (96.0, 74.8, 0), "R96": (96.0, 76.0, 0),
"R97": (59.0, 65.5, 0), "R98": (145.0, 74.5, 0),
"R99": (73.0, 60.0, 0), "R100": (73.0, 61.2, 0), "R101": (73.0, 62.4, 0),
"R102": (84.0, 100.5, 0), "R103": (80.0, 102.0, 0),
"C120": (145.0, 72.5, 0), "C121": (82.0, 102.0, 0),
"C122": (142.0, 76.5, 0), "C123": (64.0, 48.5, 0),
"C124": (69.5, 56.0, 0), "C125": (62.0, 66.5, 0),
"C126": (136.0, 78.0, 0), "C127": (90.0, 100.5, 0),
"R104": (83.0, 86.8, 0), "R105": (83.0, 88.0, 0),
"R106": (83.0, 89.2, 0), "C128": (93.0, 88.0, 0),
})
# Critical placement references must never silently drift when parts are added
# earlier in design_data.py. A mismatch here would corrupt RF geometry or put
# a source-series resistor at the receiver end of a long digital trace.
CRITICAL_REF_NETS = {
"C83": {"RF_SMA", "RF_PRE_SAW"},
"C84": {"RF_POST_SAW", "GND"},
"R39": {"RF_POST_SAW", "MAX_MIXIN_HI"},
"C85": {"MAX_MIXIN_HI", "GND"},
"R56": {"OSC40_OUT", "CLK_40M_FPGA"},
"R57": {"IMU_SCK", "IMU_SCK_DEV"},
"R58": {"IMU_MOSI", "IMU_MOSI_DEV"},
"R59": {"IMU_CS", "IMU_CS_DEV"},
"R60": {"IMU_MISO", "IMU_MISO_DEV"},
}
_component_by_ref = {component.ref: component for component in components}
for _ref, _expected_nets in CRITICAL_REF_NETS.items():
_actual_nets = {pin.net for pin in _component_by_ref[_ref].pins if pin.net}
assert _actual_nets == _expected_nets, (_ref, _actual_nets, _expected_nets)
def local_offsets(count: int, clearance_radius: float) -> list[tuple[float, float]]:
"""Return dense candidate rings for collision-aware group placement."""
result: list[tuple[float, float]] = []
ring = 0
while len(result) < count:
r = clearance_radius + ring * 1.20
slots = max(12, int(2 * math.pi * r / 0.80))
for idx in range(slots):
angle = -math.pi / 2 + idx * 2 * math.pi / slots
result.append((r * math.cos(angle), r * math.sin(angle)))
if len(result) == count:
return result
ring += 1
return result
def courtyard_box(fp: pcbnew.FOOTPRINT, margin: float = 0.12) -> tuple[float, float, float, float]:
layers = pcbnew.LSET()
layers.AddLayer(pcbnew.F_CrtYd)
layers.AddLayer(pcbnew.B_CrtYd)
box = fp.GetLayerBoundingBox(layers)
return (pcbnew.ToMM(box.GetX()) - margin, pcbnew.ToMM(box.GetY()) - margin,
pcbnew.ToMM(box.GetRight()) + margin, pcbnew.ToMM(box.GetBottom()) + margin)
def boxes_overlap(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]
def add_text(board: pcbnew.BOARD, value: str, x: float, y: float, size: float = 1.0,
layer: int = pcbnew.F_SilkS, angle: float = 0) -> None:
text = pcbnew.PCB_TEXT(board)
text.SetText(value)
text.SetPosition(point(x, y))
text.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size)))
text.SetTextThickness(mm(max(0.15, size * 0.15)))
text.SetLayer(layer)
text.SetTextAngle(pcbnew.EDA_ANGLE(angle, pcbnew.DEGREES_T))
board.Add(text)
def add_outline(board: pcbnew.BOARD) -> None:
for start, end in (((X0, Y0), (X1, Y0)), ((X1, Y0), (X1, Y1)),
((X1, Y1), (X0, Y1)), ((X0, Y1), (X0, Y0))):
seg = pcbnew.PCB_SHAPE(board)
seg.SetShape(pcbnew.SHAPE_T_SEGMENT)
seg.SetStart(point(*start))
seg.SetEnd(point(*end))
seg.SetWidth(mm(0.05))
seg.SetLayer(pcbnew.Edge_Cuts)
board.Add(seg)
def configure_rules(board: pcbnew.BOARD) -> None:
ds = board.GetDesignSettings()
ds.SetBoardThickness(mm(1.6))
ds.m_TrackMinWidth = mm(0.10)
ds.m_MinClearance = mm(0.10)
ds.m_ViasMinSize = mm(0.45)
ds.m_MinThroughDrill = mm(0.225)
ds.m_ViasMinAnnularWidth = mm(0.10)
ds.m_CopperEdgeClearance = mm(0.25)
ds.m_HoleClearance = mm(0.19)
settings = ds.m_NetSettings
default = settings.GetDefaultNetclass()
default.SetClearance(mm(0.10))
default.SetTrackWidth(mm(0.12))
default.SetViaDiameter(mm(0.45))
default.SetViaDrill(mm(0.225))
default.SetDiffPairWidth(mm(0.135))
default.SetDiffPairGap(mm(0.15))
def netclass(name: str, clearance: float, width: float, via: float = 0.45, drill: float = 0.225,
diff_width: float | None = None, diff_gap: float | None = None) -> None:
nc = pcbnew.NETCLASS(name)
nc.SetClearance(mm(clearance))
nc.SetTrackWidth(mm(width))
nc.SetViaDiameter(mm(via))
nc.SetViaDrill(mm(drill))
if diff_width is not None:
nc.SetDiffPairWidth(mm(diff_width))
if diff_gap is not None:
nc.SetDiffPairGap(mm(diff_gap))
settings.SetNetclass(name, nc)
netclass("BGA_ESCAPE", 0.10, 0.10)
netclass("RF50_CPWG", 0.20, 0.164)
netclass("USB90_DIFF", 0.15, 0.135, diff_width=0.135, diff_gap=0.15)
netclass("CLOCK", 0.15, 0.15)
# A 0.45/0.225-mm dogbone via with 0.10-mm clearance fits the Ti375's
# 0.8-mm BGA pitch. The 0.40-mm trunk width is retained; Freerouting's
# automatic neck-down is allowed only inside the escape field.
netclass("POWER", 0.10, 0.40, 0.45, 0.225)
for pattern in ("RF_SMA", "RF_PRE_SAW", "RF_POST_SAW", "MAX_MIXIN_HI"):
settings.SetNetclassPatternAssignment(pattern, "RF50_CPWG")
for pattern in ("USB_D_P", "USB_D_N", "FTDI_D_P", "FTDI_D_N"):
settings.SetNetclassPatternAssignment(pattern, "USB90_DIFF")
# Use exact clock families so VCC3V0_CLK does not acquire an accidental
# aggregate CLOCK,POWER effective class.
for pattern in ("MAX_CLKOUT*", "MAX_SCLK*", "MAX_ADC_CLKIN", "QSPI_SCLK",
"CLK_40M_FPGA", "OSC40_OUT", "TCXO16_OUT", "FTDI_OSC12",
"FTDI_OSCI", "IMU_SCK*", "*TCK*"):
settings.SetNetclassPatternAssignment(pattern, "CLOCK")
for pattern in ("VCC*", "SYS5", "VBUS_RAW", "USB5_PROT", "EXT5_*", "GND", "CORE_SW",
"BUCK1V8_SW", "BUCK3V3_SW", "ANT_BIAS_SEL", "ANT_BIAS_SW",
"FTDI_1V8", "FTDI_VPHY", "FTDI_VPLL", "IMU_3V3",
"IMU_VREGA2", "IMU_VREGD", "USB_SHIELD"):
settings.SetNetclassPatternAssignment(pattern, "POWER")
settings.SetNetclassPatternAssignment("EXP_*", "BGA_ESCAPE")
settings.SetNetclassPatternAssignment("PMOD*", "BGA_ESCAPE")
def add_stackup(board: pcbnew.BOARD) -> None:
board.SetCopperLayerCount(8)
board.SetLayerName(pcbnew.In1_Cu, "L2_GND")
board.SetLayerName(pcbnew.In2_Cu, "L3_SIG")
board.SetLayerName(pcbnew.In3_Cu, "L4_PWR")
board.SetLayerName(pcbnew.In4_Cu, "L5_SIG")
board.SetLayerName(pcbnew.In5_Cu, "L6_SIG")
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.In6_Cu, pcbnew.LT_POWER)
def place_components(board: pcbnew.BOARD) -> dict[str, pcbnew.FOOTPRINT]:
net_items: dict[str, pcbnew.NETINFO_ITEM] = {}
for comp in components:
for pin in comp.pins:
if pin.net and pin.net not in net_items:
item = pcbnew.NETINFO_ITEM(board, pin.net)
board.Add(item)
net_items[pin.net] = item
loaded: dict[str, pcbnew.FOOTPRINT] = {}
current_group = "U1"
groups: dict[str, list] = defaultdict(list)
for comp in components:
if comp.ref in MAJOR:
current_group = comp.ref
if comp.ref not in MAJOR and comp.ref not in SPECIAL:
if comp.description.startswith("Ti375 ") or "Efinix mandatory" in comp.description:
groups["U1"].append(comp)
else:
groups[current_group].append(comp)
group_positions: dict[str, list[tuple[float, float]]] = {}
for group, members in groups.items():
radius = 4.0
if group == "U1":
radius = 11.5
elif group in {"U2", "U13", "U21", "U22"}:
radius = 5.5
# Extra candidates let the actual courtyard collision pass skip places
# occupied by adjacent functional blocks and larger bulk capacitors.
group_positions[group] = local_offsets(max(1000, len(members) * 40), radius)
automatic: list[tuple[object, str]] = []
current_group = "U1"
for comp in components:
path, name = footprint_path(comp.footprint)
fp = pcbnew.FootprintLoad(str(path), name)
if fp is None:
raise RuntimeError(f"Cannot load {comp.footprint} for {comp.ref}")
fp.SetReference(comp.ref)
fp.SetValue(comp.value)
fp.Reference().SetVisible(False)
fp.Value().SetVisible(False)
fp.Reference().SetLayer(pcbnew.F_Fab)
fp.Value().SetLayer(pcbnew.F_Fab)
# Dense components retain complete assembly/Fab graphics, while the
# production silkscreen is limited to functional labels and the IMU
# orientation mark so it cannot print on fine-pitch copper.
for graphic in fp.GraphicalItems():
if graphic.GetLayer() == pcbnew.F_SilkS:
graphic.SetLayer(pcbnew.F_Fab)
if comp.ref in MAJOR:
current_group = comp.ref
x, y, rotation = MAJOR[comp.ref]
elif comp.ref in SPECIAL:
x, y, rotation = SPECIAL[comp.ref]
else:
group = "U1" if (comp.description.startswith("Ti375 ") or "Efinix mandatory" in comp.description) else current_group
ax, ay, _ = MAJOR[group]
x, y, rotation = ax, ay, 0
automatic.append((comp, group))
fp.SetPosition(point(x, y))
fp.SetOrientationDegrees(rotation)
if comp.dnp:
fp.SetDNP(True)
fp.SetExcludedFromPosFiles(True)
fp.SetField("MPN", comp.mpn)
fp.SetField("Manufacturer", comp.manufacturer)
for field_name in ("MPN", "Manufacturer"):
field = fp.GetField(field_name)
field.SetVisible(False)
field.SetLayer(pcbnew.F_Fab)
board.Add(fp)
loaded[comp.ref] = fp
pad_map: dict[str, list[pcbnew.PAD]] = defaultdict(list)
for physical_pad in fp.Pads():
if physical_pad.GetNumber():
pad_map[physical_pad.GetNumber()].append(physical_pad)
for pin in comp.pins:
for physical_pad in pad_map[str(pin.number)]:
if pin.net:
physical_pad.SetNet(net_items[pin.net])
# Place all automatic local parts after the fixed functional floorplan is
# known, using their actual KiCad courtyards rather than nominal body size.
occupied = [courtyard_box(loaded[ref]) for ref in (*MAJOR.keys(), *SPECIAL.keys()) if ref in loaded]
search_start: dict[str, int] = defaultdict(int)
for comp, group in automatic:
fp = loaded[comp.ref]
ax, ay, _ = MAJOR[group]
candidates = group_positions[group]
placed = False
for idx in range(search_start[group], len(candidates)):
dx, dy = candidates[idx]
x, y = ax + dx, ay + dy
fp.SetPosition(point(x, y))
candidate = courtyard_box(fp)
# Keep assembly courtyards on-board; edge connectors are all fixed
# members and are intentionally permitted to protrude.
if candidate[0] < X0 + 0.5 or candidate[2] > X1 - 0.5 or candidate[1] < Y0 + 0.5 or candidate[3] > Y1 - 0.5:
continue
if any(boxes_overlap(candidate, used) for used in occupied):
continue
occupied.append(candidate)
search_start[group] = idx + 1
placed = True
break
if not placed:
raise RuntimeError(f"Could not place {comp.ref} near {group} without courtyard overlap")
board.BuildListOfNets()
return loaded
def add_mounting_holes(board: pcbnew.BOARD) -> None:
path = STOCK / "MountingHole.pretty"
# The lower-left power connector owns the corner; offset H3 so its screw
# head and courtyard cannot collide with J3.
for idx, (x, y) in enumerate(((24, 24), (171, 24), (31, 106), (171, 106)), 1):
fp = pcbnew.FootprintLoad(str(path), "MountingHole_3.2mm_M3")
if fp is None:
raise RuntimeError("Missing KiCad M3 mounting-hole footprint")
fp.SetReference(f"H{idx}")
fp.Reference().SetVisible(False)
fp.SetPosition(point(x, y))
board.Add(fp)
def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
bounds: tuple[float, float, float, float], priority: int = 0) -> 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.
"""
net = board.FindNet(net_name)
if net is None:
raise RuntimeError(f"Cannot create plane for missing net {net_name}")
x0, y0, x1, y1 = bounds
zone = pcbnew.ZONE(board)
zone.SetLayer(layer)
zone.SetNet(net)
zone.SetLocalClearance(mm(0.20))
zone.SetMinThickness(mm(0.10))
zone.SetThermalReliefGap(mm(0.20))
zone.SetThermalReliefSpokeWidth(mm(0.25))
zone.SetPadConnection(pcbnew.ZONE_CONNECTION_FULL)
zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_NEVER)
zone.SetAssignedPriority(priority)
outline = zone.Outline()
outline.NewOutline()
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
outline.Append(point(x, y))
board.Add(zone)
def add_power_planes(board: pcbnew.BOARD) -> None:
# Continuous reference planes immediately below the two outer signal
# layers. A 0.50-mm inset exceeds JLCPCB's copper-to-edge requirement.
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))
# 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)),
# 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)),
)
for priority, (net_name, bounds) in enumerate(islands, 1):
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority)
# 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)
def add_temporary_critical_keepouts(board: pcbnew.BOARD) -> None:
"""Reserve the layers below RF and USB while Freerouting is running.
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.
"""
layers = 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)
def add_documentation_graphics(board: pcbnew.BOARD) -> None:
add_text(board, "Ti375 GNSS / SCH16T", 105, 33.0, 1.1)
add_text(board, "REV A1 JLC08161H-3313 8L", 105, 35.2, 0.8)
add_text(board, "USB-C 5V", 31, 35.5, 0.9)
add_text(board, "EXT_5V", 28, 108.0, 0.9)
add_text(board, "GPS L1/L1C ACTIVE", 162, 34, 0.9)
add_text(board, "BIAS J5: 3V3-OFF-5V", 163, 72, 0.8)
add_text(board, "RF - NO DIGITAL ROUTING BELOW", 151, 69, 0.8)
add_text(board, "IMU PIN1", 42, 78, 0.8)
add_text(board, "X+ -> Y+ ^", 51, 84, 0.8)
add_text(board, "USB JTAG / UART", 55, 38.5, 0.8)
add_text(board, "EXT_RST", 73, 101.5, 0.8)
add_text(board, "FPGA RESET", 82, 109.0, 0.8)
add_text(board, "PMOD1 3V3", 78, 30, 0.8)
add_text(board, "PMOD2 3V3", 96, 30, 0.8)
add_text(board, "PMOD3 1V8", 114, 30, 0.8)
add_text(board, "PMOD4 1V8", 132, 30, 0.8)
STACKUP = '''\t\t(stackup
\t\t\t(layer "F.SilkS" (type "Top Silk Screen"))
\t\t\t(layer "F.Paste" (type "Top Solder Paste"))
\t\t\t(layer "F.Mask" (type "Top Solder Mask") (thickness 0.01))
\t\t\t(layer "F.Cu" (type "copper") (thickness 0.035))
\t\t\t(layer "dielectric 1" (type "prepreg") (thickness 0.0994) (material "JLC 3313") (epsilon_r 4.1) (loss_tangent 0.02))
\t\t\t(layer "L2_GND" (type "copper") (thickness 0.0152))
\t\t\t(layer "dielectric 2" (type "core") (thickness 0.1) (material "JLC FR-4 core") (epsilon_r 4.36) (loss_tangent 0.02))
\t\t\t(layer "L3_SIG" (type "copper") (thickness 0.0152))
\t\t\t(layer "dielectric 3" (type "prepreg") (thickness 0.3568) (material "JLC 3x2116") (epsilon_r 4.16) (loss_tangent 0.02))
\t\t\t(layer "L4_PWR" (type "copper") (thickness 0.0152))
\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 "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))
\t\t\t(layer "B.Cu" (type "copper") (thickness 0.035))
\t\t\t(layer "B.Mask" (type "Bottom Solder Mask") (thickness 0.01))
\t\t\t(layer "B.Paste" (type "Bottom Solder Paste"))
\t\t\t(layer "B.SilkS" (type "Bottom Silk Screen"))
\t\t\t(copper_finish "ENIG")
\t\t\t(dielectric_constraints yes)
\t\t)'''
def inject_stackup(path: Path) -> None:
data = path.read_text()
if "\n\t\t(stackup\n" in data:
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)
def main() -> None:
board = pcbnew.BOARD()
add_stackup(board)
configure_rules(board)
add_outline(board)
loaded = place_components(board)
add_mounting_holes(board)
add_power_planes(board)
add_temporary_critical_keepouts(board)
add_documentation_graphics(board)
title = board.GetTitleBlock()
title.SetTitle("Ti375 C529 GPS L1/L1C + SCH16T acquisition board")
title.SetRevision("A0")
title.SetCompany("JLCPCB manufacturing design")
title.SetComment(0, "USB-C 5 V primary; protected EXT_5V; no back-feed")
title.SetComment(1, "JLC08161H-3313 exact eight-layer construction")
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Initial inner-plane fill failed")
pcbnew.SaveBoard(str(BOARD_FILE), board)
inject_stackup(BOARD_FILE)
print(f"Placed {len(loaded)} schematic components plus four NPTH mounting holes")
print(f"Saved {BOARD_FILE}")
if __name__ == "__main__":
main()