Files
pcb/scripts/generate_kicad.py
T

461 lines
21 KiB
Python

#!/usr/bin/env python3
"""Generate the KiCad schematic hierarchy and BOM tables from design_data."""
from __future__ import annotations
import csv
import copy
import math
import re
import sys
import uuid
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from design_data import Component, Pin, components # noqa: E402
HW = ROOT / "hardware"
DOCS = ROOT / "docs"
OUT = ROOT / "outputs"
HW.mkdir(exist_ok=True)
DOCS.mkdir(exist_ok=True)
OUT.mkdir(exist_ok=True)
def q(text: str) -> str:
return text.replace("\\", "\\\\").replace('"', '\\"')
def safe(text: str) -> str:
value = re.sub(r"[^A-Za-z0-9_]+", "_", text)
return value.strip("_") or "PIN"
def category(comp: Component) -> str:
nets = {pin.net or "" for pin in comp.pins}
if comp.ref == "U1" or any(n.startswith("REF_RES") for n in nets):
return "fpga"
if comp.ref in {"J2", "J5", "D1", "FL1", "U21", "U23", "U24", "U27"} or any(
n.startswith(("RF_", "MAX_", "ANT_", "TCXO", "OSC40")) for n in nets):
return "rf_clock"
if comp.ref == "U22" or any(n.startswith("IMU_") for n in nets):
return "imu"
if comp.ref in {"U2", "U3", "U25"} or any(
n.startswith(("FTDI_", "HARD_", "RISCV_", "UART")) for n in nets):
return "usb_debug"
if comp.ref == "U26" or comp.ref.startswith(("JPMOD", "JH", "JHV")) or any(
n.startswith(("QSPI_", "EXP_", "PMOD")) for n in nets):
return "configuration_expansion"
return "power_usb"
SHEETS = ["power_usb", "rf_clock", "imu", "usb_debug", "configuration_expansion", "fpga"]
def chunks(comp: Component) -> list[list[Pin]]:
if comp.ref == "U1":
# Fourteen units keeps every unit below 40 balls and the complete 529-ball
# package readable without losing the common U1 reference.
size = math.ceil(len(comp.pins) / 14)
return [comp.pins[i:i + size] for i in range(0, len(comp.pins), size)]
return [comp.pins]
def geometry(pins: list[Pin]) -> tuple[int, int, dict[str, tuple[int, int, str]]]:
left = pins[::2]
right = pins[1::2]
rows = max(len(left), len(right), 2)
half_h = max(150, (rows - 1) * 50 + 100)
endpoints: dict[str, tuple[int, int, str]] = {}
start_y = (len(left) - 1) * 50
for idx, pin in enumerate(left):
endpoints[pin.number] = (-700, start_y - idx * 100, "R")
start_y = (len(right) - 1) * 50
for idx, pin in enumerate(right):
endpoints[pin.number] = (700, start_y - idx * 100, "L")
return 500, half_h, endpoints
def symbol_library() -> str:
lines = ["EESchema-LIBRARY Version 2.4", "#encoding utf-8"]
etypes = {
"input": "I", "output": "O", "bidirectional": "B", "power_in": "W",
"power_out": "w", "open_collector": "C", "passive": "P",
}
for comp in components:
name = f"BOARD_{safe(comp.ref)}"
units = chunks(comp)
lines += ["#", f"# {name}", "#",
f"DEF {name} {comp.ref[0]} 0 30 Y Y {len(units)} L N",
f'F0 "{q(comp.ref)}" 0 200 50 H V C CNN',
f'F1 "{q(comp.value)}" 0 100 50 H V C CNN',
f'F2 "{q(comp.footprint)}" 0 -100 50 H I C CNN',
f'F3 "" 0 0 50 H I C CNN', "DRAW"]
for unit, pins in enumerate(units, 1):
half_w, half_h, endpoints = geometry(pins)
lines.append(f"S {-half_w} {half_h} {half_w} {-half_h} {unit} 1 10 f")
for pin in pins:
x, y, orient = endpoints[pin.number]
lines.append(
f"X {safe(pin.name)} {pin.number} {x} {y} 200 {orient} 30 30 {unit} 1 "
f"{etypes.get(pin.etype, 'P')}"
)
lines += ["ENDDRAW", "ENDDEF"]
lines += ["#", "#End Library"]
return "\n".join(lines) + "\n"
def component_block(comp: Component, unit: int, x: int, y: int, stamp: int) -> list[str]:
name = f"BOARD_{safe(comp.ref)}"
f0y = y - 100
return [
"$Comp", f"L {name} {comp.ref}", f"U {unit} 1 {stamp:08X}", f"P {x} {y}",
f'F 0 "{q(comp.ref)}" H {x} {f0y} 50 0000 C CNN',
f'F 1 "{q(comp.value)}" H {x} {y + 100} 50 0000 C CNN',
f'F 2 "{q(comp.footprint)}" H {x} {y - 200} 50 0001 C CNN',
f'F 3 "" H {x} {y} 50 0001 C CNN',
f'F 4 "{q(comp.mpn)}" H {x} {y} 50 0001 C CNN "MPN"',
f'F 5 "{q(comp.manufacturer)}" H {x} {y} 50 0001 C CNN "Manufacturer"',
f"\t{unit} {x} {y}", "\t1 0 0 -1", "$EndComp",
]
def add_pin_connections(lines: list[str], pins: list[Pin], x: int, y: int) -> None:
_, _, endpoints = geometry(pins)
for pin in pins:
px, py, side = endpoints[pin.number]
px += x
py += y
if pin.net is None:
lines.append(f"NoConn ~ {px} {py}")
continue
wire_end = px - 150 if side == "R" else px + 150
lines.append(f"Wire Wire Line\n\t{px} {py} {wire_end} {py}")
orient = 2 if side == "R" else 0
lines.append(f"Text GLabel {wire_end} {py} {orient} 30 BiDi ~ 0\n{pin.net}")
def sheet_file(sheet: str, comps: list[Component], sheet_index: int) -> str:
lines = [
"EESchema Schematic File Version 4", "LIBS:board-cache", "EELAYER 29 0",
"EELAYER END", "$Descr A0 46811 33110", "encoding utf-8", "Sheet 1 1",
f'Title "Ti375 GNSS/IMU board - {sheet.replace("_", " ").title()}"',
'Date "2026-08-15"', 'Rev "A0"', 'Comp "Open hardware design for JLCPCB"',
'Comment1 "Generated from scripts/design_data.py; do not hand-edit"',
'Comment2 "No LEDs, no push buttons"', 'Comment3 "Ti375C529C3 / MAX2771 / SCH16T-K01-1"',
'Comment4 "JLC08161H-3313 eight-layer stack"', "$EndDescr",
]
x, y = 1800, 1800
column_width = 3000
bottom = 31600
stamp = 0x65000000 + sheet_index * 0x10000
for comp in comps:
for unit, pins in enumerate(chunks(comp), 1):
_, half_h, _ = geometry(pins)
height = 2 * half_h + 500
if y + half_h > bottom:
x += column_width
y = 1800
if x > 45000:
raise RuntimeError(f"{sheet} overflow at {comp.ref}")
stamp += 1
lines += component_block(comp, unit, x, y, stamp)
add_pin_connections(lines, pins, x, y)
y += height
lines += ["$EndSCHEMATC"]
return "\n".join(lines) + "\n"
def root_file() -> str:
lines = [
"EESchema Schematic File Version 4", "LIBS:board-cache", "EELAYER 29 0", "EELAYER END",
"$Descr A4 11693 8268", "encoding utf-8", "Sheet 1 7",
'Title "Ti375 C529 GNSS/IMU acquisition board"', 'Date "2026-08-15"', 'Rev "A0"',
'Comp "JLCPCB manufacture/assembly package"',
'Comment1 "USB-C 5 V primary; protected external 5 V; no back-feed"',
'Comment2 "GPS L1/L1C direct MAX2771 MIXIN_HI; selectable active-antenna bias"',
'Comment3 "Every Ti375 programmable GPIO has a documented disposition"',
'Comment4 "Generated source - see docs/design_audit.md"', "$EndDescr",
]
for idx, sheet in enumerate(SHEETS, 1):
x = 1400 + ((idx - 1) % 2) * 4800
y = 1000 + ((idx - 1) // 2) * 1900
lines += [
"$Sheet", f"S {x} {y} 3600 1200", f"U {0x66000000 + idx:08X}",
f'F0 "{sheet.replace("_", " ").upper()}" 60', f'F1 "{sheet}.sch" 60', "$EndSheet",
]
lines += ["$EndSCHEMATC"]
return "\n".join(lines) + "\n"
def write_boms() -> None:
fields = ["Reference", "Qty", "Value", "Manufacturer", "MPN", "Footprint",
"Assembly", "DNP", "Description"]
grouped: dict[tuple[str, ...], list[str]] = defaultdict(list)
for comp in components:
key = (comp.value, comp.manufacturer, comp.mpn, comp.footprint,
comp.assembly, "yes" if comp.dnp else "no", comp.description)
grouped[key].append(comp.ref)
with (OUT / "bom.csv").open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
for key, refs in sorted(grouped.items(), key=lambda item: item[1][0]):
value, manufacturer, mpn, footprint, assembly, dnp, desc = key
w.writerow(dict(zip(fields, [",".join(refs), len(refs), value, manufacturer,
mpn, footprint, assembly, dnp, desc])))
jlc_fields = ["Comment", "Designator", "Footprint", "LCSC Part #", "Manufacturer",
"Manufacturer Part Number", "Quantity", "Assembly Type", "DNP"]
with (OUT / "jlcpcb_bom.csv").open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=jlc_fields)
w.writeheader()
for key, refs in sorted(grouped.items(), key=lambda item: item[1][0]):
value, manufacturer, mpn, footprint, assembly, dnp, _ = key
w.writerow({"Comment": value, "Designator": ",".join(refs), "Footprint": footprint,
"LCSC Part #": "", "Manufacturer": manufacturer,
"Manufacturer Part Number": mpn, "Quantity": len(refs),
"Assembly Type": assembly if not dnp == "yes" else "DNP", "DNP": dnp})
def duid(text: str) -> str:
"""Stable UUIDs keep generated KiCad diffs reproducible."""
return str(uuid.uuid5(uuid.UUID("830005da-d201-4458-b73e-8946620d7012"), text))
def write_native_schematic() -> None:
"""Write a flat, native KiCad schematic using kiutils' format model.
Flat global-label connectivity is deliberate: it keeps 529 FPGA balls and
273 physical components machine-auditable without hidden hierarchical pins.
"""
try:
from kiutils.schematic import Schematic
from kiutils.symbol import Symbol, SymbolLib, SymbolPin
from kiutils.items.common import (Effects, Fill, Font, PageSettings,
Position, Property, Stroke, TitleBlock)
from kiutils.items.schitems import (GlobalLabel, HierarchicalSheetInstance,
NoConnect, SchematicSymbol, SymbolInstance)
from kiutils.items.syitems import SyRect
except ImportError as exc:
raise SystemExit("Install the pinned generator dependency: python3 -m pip install -r requirements.txt") from exc
sch = Schematic.create_new()
sch.version = "20231120"
sch.generator = "board_generator"
sch.uuid = duid("schematic-root")
sch.paper = PageSettings(paperSize="A0")
sch.titleBlock = TitleBlock(
title="Ti375 C529 GPS L1/L1C + SCH16T acquisition board",
date="2026-08-15", revision="A0", company="JLCPCB manufacturing design",
comments={1: "Generated from official-package connectivity in scripts/design_data.py",
2: "USB-C 5 V primary / protected EXT_5V / no source back-feed",
3: "MAX2771 RF connects only to MIXIN_HI; internal LNAs unused",
4: "No LEDs and no push buttons"})
# create_new supplies the root instance; make the path explicit and stable.
sch.sheetInstances = [HierarchicalSheetInstance(instancePath="/", page="1")]
font = Font(width=0.8, height=0.8)
hidden = Effects(font=font, hide=True)
shown = Effects(font=font)
etypes = {
"input": "input", "output": "output", "bidirectional": "bidirectional",
"power_in": "power_in", "power_out": "power_out",
"open_collector": "open_collector", "passive": "passive",
}
def ngeom(pins: list[Pin]):
left, right = pins[::2], pins[1::2]
rows = max(len(left), len(right), 2)
half_h = max(3.81, (rows - 1) * 1.27 + 2.54)
endpoints = {}
for side_pins, x, angle in ((left, -17.78, 0), (right, 17.78, 180)):
start_y = -(len(side_pins) - 1) * 1.27
for idx, pin in enumerate(side_pins):
endpoints[pin.number] = (x, start_y + idx * 2.54, angle)
return 12.7, half_h, endpoints
lib_by_ref = {}
for comp in components:
symbol_name = f"BOARD_{safe(comp.ref)}"
parent = Symbol.create_new(f"board:{symbol_name}", comp.ref[0], comp.value, comp.footprint)
parent.pinNames = True
parent.pinNamesOffset = 0.508
parent.properties.extend([
Property(key="MPN", value=comp.mpn, id=4, effects=hidden),
Property(key="Manufacturer", value=comp.manufacturer, id=5, effects=hidden),
Property(key="Description", value=comp.description, id=6, effects=hidden),
])
seen_power_out_nets: set[str] = set()
for unit_idx, pins in enumerate(chunks(comp), 1):
unit = Symbol()
unit.libId = f"{symbol_name}_{unit_idx}_1"
half_w, half_h, endpoints = ngeom(pins)
unit.graphicItems.append(SyRect(start=Position(-half_w, -half_h),
end=Position(half_w, half_h),
stroke=Stroke(width=0.254, type="default"),
fill=Fill(type="background")))
for pin in pins:
x, y, angle = endpoints[pin.number]
electrical_type = etypes.get(pin.etype, "passive")
if pin.net == "GND" and electrical_type == "bidirectional":
# Deliberately strapped bidirectional configuration pins
# (for example unused TPS6287B I2C) are passive straps in
# this design, not active bus drivers.
electrical_type = "passive"
if electrical_type == "power_out" and pin.net in seen_power_out_nets:
electrical_type = "passive"
elif electrical_type == "power_out" and pin.net is not None:
seen_power_out_nets.add(pin.net)
unit.pins.append(SymbolPin(
electricalType=electrical_type, graphicalStyle="line",
position=Position(x, y, angle), length=5.08,
name=pin.name, number=pin.number,
nameEffects=Effects(font=Font(width=0.65, height=0.65)),
numberEffects=Effects(font=Font(width=0.65, height=0.65))))
parent.units.append(unit)
sch.libSymbols.append(parent)
lib_by_ref[comp.ref] = parent
# Pack tallest symbols first within functional groups, using generous label clearance.
ordered = sorted(components, key=lambda comp: (SHEETS.index(category(comp)),
-max(len(x) for x in chunks(comp)), comp.ref))
# All origins and margins are exact multiples of KiCad's 1.27-mm
# connection grid. Symbol-library Y coordinates are Cartesian (positive
# up), whereas schematic sheet Y coordinates grow downward.
x, y = 50.8, 38.1
column_width, bottom = 127.0, 800.1
for comp in ordered:
for unit_idx, pins in enumerate(chunks(comp), 1):
_, half_h, endpoints = ngeom(pins)
height = 2 * half_h + 10.16
if y + half_h > bottom:
x += column_width
y = 38.1
if x > 1150.0:
raise RuntimeError(f"native A0 sheet overflow at {comp.ref}")
origin_y = y + half_h
inst_uuid = duid(f"symbol:{comp.ref}:{unit_idx}")
inst = SchematicSymbol()
inst.libId = f"board:BOARD_{safe(comp.ref)}"
inst.position = Position(x, origin_y, 0)
inst.unit = unit_idx
inst.inBom = not comp.dnp
inst.onBoard = True
inst.dnp = comp.dnp
inst.uuid = inst_uuid
inst.properties = [
Property(key="Reference", value=comp.ref, id=0,
position=Position(x, origin_y - half_h - 2.0, 0), effects=shown),
Property(key="Value", value=comp.value, id=1,
position=Position(x, origin_y + half_h + 2.0, 0), effects=shown),
Property(key="Footprint", value=comp.footprint, id=2,
position=Position(x, origin_y, 0), effects=hidden),
Property(key="Datasheet", value="", id=3,
position=Position(x, origin_y, 0), effects=hidden),
Property(key="MPN", value=comp.mpn, id=4,
position=Position(x, origin_y, 0), effects=hidden),
Property(key="Manufacturer", value=comp.manufacturer, id=5,
position=Position(x, origin_y, 0), effects=hidden),
]
for pin in pins:
inst.pins[pin.number] = duid(f"pin:{comp.ref}:{unit_idx}:{pin.number}")
px, py, angle = endpoints[pin.number]
pos = Position(x + px, origin_y - py, angle)
if pin.net is None:
sch.noConnects.append(NoConnect(position=pos,
uuid=duid(f"nc:{comp.ref}:{unit_idx}:{pin.number}")))
else:
sch.globalLabels.append(GlobalLabel(
text=pin.net, shape="bidirectional", position=pos, effects=shown,
uuid=duid(f"label:{comp.ref}:{unit_idx}:{pin.number}")))
sch.schematicSymbols.append(inst)
sch.symbolInstances.append(SymbolInstance(
path=f"/{inst_uuid}", reference=comp.ref, unit=unit_idx,
value=comp.value, footprint=comp.footprint))
y += height
# ERC power flags are schematic-only assertions. They are generated only
# for nets containing power-input pins but no direct power-output pin (for
# example GND, external connector feeds, and ferrite-isolated subrails).
power_inputs = {pin.net for comp in components for pin in comp.pins
if pin.etype == "power_in" and pin.net is not None}
power_outputs = {pin.net for comp in components for pin in comp.pins
if pin.etype == "power_out" and pin.net is not None}
flag_nets = sorted(power_inputs - power_outputs)
flag_lib = Symbol.create_new("board:PWR_FLAG", "#FLG", "PWR_FLAG")
flag_lib.inBom = False
flag_lib.onBoard = False
flag_lib.isPower = True
flag_unit = Symbol()
flag_unit.libId = "PWR_FLAG_1_1"
flag_unit.pins.append(SymbolPin(
electricalType="power_out", graphicalStyle="line",
position=Position(0, 0, 0), length=0, name="~", number="1",
nameEffects=shown, numberEffects=shown))
flag_lib.units.append(flag_unit)
sch.libSymbols.append(flag_lib)
for idx, net in enumerate(flag_nets, 1):
fx = 1117.6 if idx <= 24 else 1168.4
fy = 304.8 + ((idx - 1) % 24) * 12.7
flag_uuid = duid(f"power-flag:{net}")
flag = SchematicSymbol()
flag.libId = "board:PWR_FLAG"
flag.position = Position(fx, fy, 0)
flag.unit = 1
flag.inBom = False
flag.onBoard = False
flag.uuid = flag_uuid
ref = f"#FLG{idx:03d}"
flag.properties = [
Property(key="Reference", value=ref, id=0,
position=Position(fx, fy, 0), effects=hidden),
Property(key="Value", value="PWR_FLAG", id=1,
position=Position(fx, fy, 0), effects=hidden),
Property(key="Footprint", value="", id=2,
position=Position(fx, fy, 0), effects=hidden),
Property(key="Datasheet", value="", id=3,
position=Position(fx, fy, 0), effects=hidden),
]
flag.pins["1"] = duid(f"power-flag-pin:{net}")
sch.schematicSymbols.append(flag)
sch.globalLabels.append(GlobalLabel(
text=net, shape="bidirectional", position=Position(fx, fy, 0), effects=shown,
uuid=duid(f"power-flag-label:{net}")))
sch.symbolInstances.append(SymbolInstance(
path=f"/{flag_uuid}", reference=ref, unit=1, value="PWR_FLAG", footprint=""))
sch.to_file(str(HW / "board.kicad_sch"))
external_symbols = copy.deepcopy(sch.libSymbols)
for symbol in external_symbols:
symbol.libraryNickname = None
SymbolLib(version="20231120", generator="board_generator",
symbols=external_symbols).to_file(str(HW / "board.kicad_sym"))
(HW / "sym-lib-table").write_text(
'(sym_lib_table\n (version 7)\n'
' (lib (name "board")(type "KiCad")(uri "${KIPRJMOD}/board.kicad_sym")(options "")(descr "Generated board symbols"))\n)\n')
(HW / "fp-lib-table").write_text(
'(fp_lib_table\n (version 7)\n'
' (lib (name "board")(type "KiCad")(uri "${KIPRJMOD}/board.pretty")(options "")(descr "Official-land-pattern custom footprints"))\n)\n')
def main() -> None:
(HW / "board-cache.lib").write_text(symbol_library())
assignments = {sheet: [] for sheet in SHEETS}
for comp in components:
assignments[category(comp)].append(comp)
for idx, sheet in enumerate(SHEETS, 1):
(HW / f"{sheet}.sch").write_text(sheet_file(sheet, assignments[sheet], idx))
(HW / "board.sch").write_text(root_file())
write_boms()
write_native_schematic()
(HW / "board.kicad_pro").write_text(
'{\n "meta": { "filename": "board.kicad_pro", "version": 1 },\n'
' "erc": {}, "net_settings": {}, "text_variables": {}\n}\n')
print(f"generated {len(components)} components on {len(SHEETS)} hierarchical sheets")
if __name__ == "__main__":
main()