traceforge-pcb 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Circuit-as-code: a YAML/JSON description of parts and their connections.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
name: led_indicator
|
|
6
|
+
board: {width: 30, height: 20}
|
|
7
|
+
parts:
|
|
8
|
+
- ref: J1
|
|
9
|
+
symbol: Connector_Generic:Conn_01x02
|
|
10
|
+
footprint: Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical
|
|
11
|
+
value: PWR
|
|
12
|
+
pins: {1: VIN, 2: GND}
|
|
13
|
+
- ref: R1
|
|
14
|
+
symbol: Device:R
|
|
15
|
+
value: 1k
|
|
16
|
+
footprint: Resistor_SMD:R_0603_1608Metric
|
|
17
|
+
pins: {1: VIN, 2: LED_A}
|
|
18
|
+
- ref: D1
|
|
19
|
+
symbol: Device:LED
|
|
20
|
+
footprint: LED_SMD:LED_0603_1608Metric
|
|
21
|
+
pins: {A: LED_A, K: GND}
|
|
22
|
+
power_nets: [VIN, GND]
|
|
23
|
+
|
|
24
|
+
Pins are matched by number first, then by pin name. Connections may also be
|
|
25
|
+
listed per net under ``nets: {NET: [R1.1, D1.A]}``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Literal
|
|
33
|
+
|
|
34
|
+
import yaml
|
|
35
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
36
|
+
|
|
37
|
+
from traceforge.design.constraints import ConstraintError, RuleAreaSpec
|
|
38
|
+
from traceforge.design.constraints import validate as _validate_constraints
|
|
39
|
+
from traceforge.design.outline import (
|
|
40
|
+
CastellatedSpec,
|
|
41
|
+
FiducialSpec,
|
|
42
|
+
MountingSpec,
|
|
43
|
+
OutlineSpec,
|
|
44
|
+
PanelSpec,
|
|
45
|
+
ToolingSpec,
|
|
46
|
+
bbox_size,
|
|
47
|
+
check_outline,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_REF_RE = re.compile(r"^[A-Za-z]+[A-Za-z_]*\d+$")
|
|
51
|
+
_NET_RE = re.compile(r"^[^\s\"(){}]+$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BoardSpec(BaseModel):
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
width: float | None = Field(default=None, gt=0, description="Board width in mm")
|
|
58
|
+
height: float | None = Field(default=None, gt=0, description="Board height in mm")
|
|
59
|
+
layers: int = Field(default=2, description="Copper layers (2, 4, 6 or 8)")
|
|
60
|
+
thickness: float = Field(default=1.6, gt=0)
|
|
61
|
+
origin: tuple[float, float] = (100.0, 100.0)
|
|
62
|
+
margin: float = Field(default=3.0, ge=0, description="Clearance from parts to board edge")
|
|
63
|
+
corner_radius: float = Field(default=0.0, ge=0)
|
|
64
|
+
ground_plane: str | None = Field(
|
|
65
|
+
default="GND",
|
|
66
|
+
description="net joined by a bottom-layer pour when routing (null: route as tracks)",
|
|
67
|
+
)
|
|
68
|
+
mounting_holes: float | None = Field(
|
|
69
|
+
default=None, description="Drill diameter for 4 corner holes"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
outline: OutlineSpec | None = Field(
|
|
73
|
+
default=None, description="non-rectangular outline, cut-outs and slots (design/outline.py)"
|
|
74
|
+
)
|
|
75
|
+
mounting: MountingSpec | None = Field(
|
|
76
|
+
default=None, description="mounting-hole pattern (board-only, not in the schematic)"
|
|
77
|
+
)
|
|
78
|
+
fiducials: FiducialSpec | None = None
|
|
79
|
+
tooling_holes: ToolingSpec | None = None
|
|
80
|
+
castellated: list[CastellatedSpec] = Field(
|
|
81
|
+
default_factory=list, description="half-cut plated holes along an edge"
|
|
82
|
+
)
|
|
83
|
+
panel: PanelSpec | None = Field(
|
|
84
|
+
default=None, description="panel of copies: tabs or V-score, rails (written as *_panel)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@field_validator("layers")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _layers(cls, value: int) -> int:
|
|
90
|
+
if value not in (2, 4, 6, 8):
|
|
91
|
+
raise ValueError("layers must be 2, 4, 6 or 8")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def _outline_size(self) -> BoardSpec:
|
|
96
|
+
if self.outline is not None:
|
|
97
|
+
w, h = bbox_size(self.outline, self.width, self.height)
|
|
98
|
+
if self.outline.shape in ("round", "polygon"):
|
|
99
|
+
self.width, self.height = w, h
|
|
100
|
+
check_outline(w, h, self.outline, self.corner_radius)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PartSpec(BaseModel):
|
|
105
|
+
model_config = ConfigDict(extra="forbid")
|
|
106
|
+
|
|
107
|
+
ref: str
|
|
108
|
+
symbol: str = Field(description="KiCad symbol id, e.g. Device:R")
|
|
109
|
+
footprint: str | None = Field(default=None, description="KiCad footprint id")
|
|
110
|
+
value: str | None = None
|
|
111
|
+
pins: dict[str, str] = Field(default_factory=dict, description="pin number or name -> net")
|
|
112
|
+
nc: list[str] = Field(default_factory=list, description="pins intentionally left unconnected")
|
|
113
|
+
fields: dict[str, str] = Field(default_factory=dict, description="extra fields, e.g. LCSC, MPN")
|
|
114
|
+
dnp: bool = False
|
|
115
|
+
at: tuple[float, float] | None = Field(
|
|
116
|
+
default=None, description="PCB position (mm, board origin)"
|
|
117
|
+
)
|
|
118
|
+
rotation: float = Field(
|
|
119
|
+
default=0.0,
|
|
120
|
+
description="orientation as KiCad's footprint properties show it; for a bottom part it "
|
|
121
|
+
"is the orientation of the turned-over footprint (the placement file converts it)",
|
|
122
|
+
)
|
|
123
|
+
side: Literal["top", "bottom"] = Field(
|
|
124
|
+
default="top", description="board side the footprint is placed on"
|
|
125
|
+
)
|
|
126
|
+
decoupling: Literal["same", "opposite"] = Field(
|
|
127
|
+
default="same",
|
|
128
|
+
description="where this part's decoupling capacitors go: next to it, or on the "
|
|
129
|
+
"other side of the board (a capacitor's own side overrides this)",
|
|
130
|
+
)
|
|
131
|
+
group: str | None = Field(default=None, description="placement cluster name")
|
|
132
|
+
description: str | None = None
|
|
133
|
+
sheet: str | None = Field(default=None, description="schematic sheet this part is drawn on")
|
|
134
|
+
|
|
135
|
+
@field_validator("ref")
|
|
136
|
+
@classmethod
|
|
137
|
+
def _ref(cls, value: str) -> str:
|
|
138
|
+
if not _REF_RE.match(value):
|
|
139
|
+
raise ValueError(f"reference {value!r} must be letters followed by a number, e.g. R1")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
@field_validator("value", mode="before")
|
|
143
|
+
@classmethod
|
|
144
|
+
def _value(cls, value: Any) -> Any:
|
|
145
|
+
return None if value is None else str(value)
|
|
146
|
+
|
|
147
|
+
@field_validator("fields", mode="before")
|
|
148
|
+
@classmethod
|
|
149
|
+
def _fields(cls, value: Any) -> Any:
|
|
150
|
+
if isinstance(value, dict):
|
|
151
|
+
return {str(k): str(v) for k, v in value.items()}
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
@field_validator("pins", mode="before")
|
|
155
|
+
@classmethod
|
|
156
|
+
def _pins(cls, value: Any) -> Any:
|
|
157
|
+
if isinstance(value, dict):
|
|
158
|
+
return {str(k): str(v) for k, v in value.items()}
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
@field_validator("nc", mode="before")
|
|
162
|
+
@classmethod
|
|
163
|
+
def _nc(cls, value: Any) -> Any:
|
|
164
|
+
if isinstance(value, list):
|
|
165
|
+
return [str(v) for v in value]
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RulesSpec(BaseModel):
|
|
170
|
+
"""Board-wide design rules finer (or coarser) than the fab profile's defaults."""
|
|
171
|
+
|
|
172
|
+
model_config = ConfigDict(extra="forbid")
|
|
173
|
+
|
|
174
|
+
clearance: float | None = Field(
|
|
175
|
+
default=None,
|
|
176
|
+
gt=0,
|
|
177
|
+
description="default copper clearance, mm; never below the fab's minimum "
|
|
178
|
+
"(fine-pitch QFN and BGA fan-out needs about 0.15)",
|
|
179
|
+
)
|
|
180
|
+
track_width: float | None = Field(default=None, gt=0, description="default track width, mm")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class LengthGroupSpec(BaseModel):
|
|
184
|
+
"""Nets whose routed lengths must agree (byte lanes, address buses, pairs of a lane)."""
|
|
185
|
+
|
|
186
|
+
model_config = ConfigDict(extra="forbid")
|
|
187
|
+
|
|
188
|
+
name: str = Field(description="group name, e.g. DQ_LANE0")
|
|
189
|
+
nets: list[str] = Field(
|
|
190
|
+
min_length=2, description="nets of the group; a pair's two nets count as one member"
|
|
191
|
+
)
|
|
192
|
+
tolerance: float = Field(default=0.5, ge=0, description="allowed length spread, mm")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DiffPairSpec(BaseModel):
|
|
196
|
+
"""Two nets routed as a coupled pair (constant gap, matched length)."""
|
|
197
|
+
|
|
198
|
+
model_config = ConfigDict(extra="forbid")
|
|
199
|
+
|
|
200
|
+
nets: tuple[str, str] = Field(description="positive and negative net, e.g. [USB_DP, USB_DM]")
|
|
201
|
+
impedance: float | None = Field(
|
|
202
|
+
default=None, gt=0, description="differential impedance target in ohms (USB: 90)"
|
|
203
|
+
)
|
|
204
|
+
width: float | None = Field(default=None, gt=0, description="track width, mm")
|
|
205
|
+
gap: float | None = Field(default=None, gt=0, description="edge-to-edge gap, mm")
|
|
206
|
+
tolerance: float = Field(default=0.1, ge=0, description="allowed length skew, mm")
|
|
207
|
+
drc_skew: bool = Field(
|
|
208
|
+
default=False,
|
|
209
|
+
description="also write a KiCad skew rule (needs net names KiCad pairs: X+/X-, XP/XN)",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class DielectricSpec(BaseModel):
|
|
214
|
+
model_config = ConfigDict(extra="forbid")
|
|
215
|
+
|
|
216
|
+
thickness: float = Field(gt=0, description="mm")
|
|
217
|
+
er: float = Field(default=4.4, ge=1, description="relative permittivity")
|
|
218
|
+
kind: str = Field(default="prepreg", pattern="^(core|prepreg)$")
|
|
219
|
+
material: str = "FR4"
|
|
220
|
+
loss_tangent: float = Field(default=0.02, ge=0)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class PlaneNetSpec(BaseModel):
|
|
224
|
+
"""One net of a split plane: the region it fills, or None to derive it from its pads."""
|
|
225
|
+
|
|
226
|
+
model_config = ConfigDict(extra="forbid")
|
|
227
|
+
|
|
228
|
+
net: str
|
|
229
|
+
region: list[float] | None = Field(
|
|
230
|
+
default=None,
|
|
231
|
+
min_length=4,
|
|
232
|
+
max_length=4,
|
|
233
|
+
description="[x0, y0, x1, y1] in mm from the board's top-left corner",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class StackupSpec(BaseModel):
|
|
238
|
+
"""Layer roles, copper weights and dielectrics of a multi-layer board.
|
|
239
|
+
|
|
240
|
+
``preset`` (jlc-2, jlc-4, jlc-6, jlc-8) gives typical JLCPCB values; anything else given here
|
|
241
|
+
overrides it. The fab's own stackup is authoritative, copy its numbers in before ordering.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
model_config = ConfigDict(extra="forbid")
|
|
245
|
+
|
|
246
|
+
preset: str | None = Field(default=None, description="jlc-2 | jlc-4 | jlc-6 | jlc-8")
|
|
247
|
+
roles: list[str] | None = Field(
|
|
248
|
+
default=None, description="signal or plane per copper layer, top to bottom"
|
|
249
|
+
)
|
|
250
|
+
planes: dict[str, str | list[str | PlaneNetSpec]] = Field(
|
|
251
|
+
default_factory=dict,
|
|
252
|
+
description=(
|
|
253
|
+
"plane layer -> net poured on it, e.g. {In1.Cu: GND}; a list of nets splits the layer "
|
|
254
|
+
"(the first fills the board, the others take regions around their pads, or the given "
|
|
255
|
+
"region): {In2.Cu: [+3V3, +1V1]}"
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
copper_oz: list[float] | None = Field(
|
|
259
|
+
default=None, description="copper weight per layer in ounces (1 oz = 35 um)"
|
|
260
|
+
)
|
|
261
|
+
dielectrics: list[DielectricSpec] | None = Field(
|
|
262
|
+
default=None, description="gap between each neighbouring copper pair, top to bottom"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
@field_validator("roles")
|
|
266
|
+
@classmethod
|
|
267
|
+
def _roles(cls, value: list[str] | None) -> list[str] | None:
|
|
268
|
+
if value is not None and any(r not in ("signal", "plane") for r in value):
|
|
269
|
+
raise ValueError("roles must be signal or plane")
|
|
270
|
+
return value
|
|
271
|
+
|
|
272
|
+
def resolve(self, layers: int):
|
|
273
|
+
"""The :class:`~traceforge.kicad.stackup.Stackup` for a board of ``layers`` layers."""
|
|
274
|
+
from dataclasses import replace
|
|
275
|
+
|
|
276
|
+
from traceforge.kicad import stackup as st
|
|
277
|
+
|
|
278
|
+
base = st.preset(self.preset or f"jlc-{layers}")
|
|
279
|
+
if base.count != layers:
|
|
280
|
+
raise ValueError(f"stackup preset {base.name} has {base.count} layers, board {layers}")
|
|
281
|
+
copper = list(base.copper)
|
|
282
|
+
if self.roles is not None:
|
|
283
|
+
if len(self.roles) != layers:
|
|
284
|
+
raise ValueError(f"stackup roles lists {len(self.roles)} layers, board {layers}")
|
|
285
|
+
copper = [replace(c, role=r) for c, r in zip(copper, self.roles)]
|
|
286
|
+
if self.copper_oz is not None:
|
|
287
|
+
if len(self.copper_oz) != layers:
|
|
288
|
+
raise ValueError(f"copper_oz lists {len(self.copper_oz)} layers, board {layers}")
|
|
289
|
+
copper = [
|
|
290
|
+
replace(c, thickness=round(oz * 0.035, 4)) for c, oz in zip(copper, self.copper_oz)
|
|
291
|
+
]
|
|
292
|
+
gaps = list(base.dielectrics)
|
|
293
|
+
if self.dielectrics is not None:
|
|
294
|
+
if len(self.dielectrics) != layers - 1:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"stackup needs {layers - 1} dielectrics, got {len(self.dielectrics)}"
|
|
297
|
+
)
|
|
298
|
+
gaps = [
|
|
299
|
+
st.Dielectric(d.thickness, d.er, d.kind, d.material, d.loss_tangent)
|
|
300
|
+
for d in self.dielectrics
|
|
301
|
+
]
|
|
302
|
+
stack = replace(base, copper=tuple(copper), dielectrics=tuple(gaps))
|
|
303
|
+
return stack.with_planes(self.planes) if self.planes else stack
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class DesignSpec(BaseModel):
|
|
307
|
+
model_config = ConfigDict(extra="forbid")
|
|
308
|
+
|
|
309
|
+
name: str
|
|
310
|
+
description: str | None = None
|
|
311
|
+
board: BoardSpec = Field(default_factory=BoardSpec)
|
|
312
|
+
parts: list[PartSpec]
|
|
313
|
+
nets: dict[str, list[str]] = Field(default_factory=dict, description="NET: [REF.PIN, ...]")
|
|
314
|
+
power_nets: list[str] = Field(default_factory=list, description="nets that get a PWR_FLAG")
|
|
315
|
+
net_classes: dict[str, dict[str, Any]] = Field(
|
|
316
|
+
default_factory=dict,
|
|
317
|
+
description="class name -> {nets, track_width, clearance, via_diameter, via_drill, "
|
|
318
|
+
"min_track_width, min_via_diameter, min_via_drill, edge_clearance, layers}",
|
|
319
|
+
)
|
|
320
|
+
rule_areas: list[RuleAreaSpec] = Field(
|
|
321
|
+
default_factory=list,
|
|
322
|
+
description="keep-outs and rule areas: {name, rect | polygon, layers, keepout, clearance, "
|
|
323
|
+
"max_track_width}",
|
|
324
|
+
)
|
|
325
|
+
diff_pairs: list[DiffPairSpec] = Field(
|
|
326
|
+
default_factory=list,
|
|
327
|
+
description="differential pairs; nets named X+/X-, X_P/X_N or USB_DP/USB_DM are "
|
|
328
|
+
"paired automatically at 90 ohms",
|
|
329
|
+
)
|
|
330
|
+
rules: RulesSpec | None = Field(
|
|
331
|
+
default=None, description="board-wide clearance and track width (default: the fab's)"
|
|
332
|
+
)
|
|
333
|
+
length_groups: list[LengthGroupSpec] = Field(
|
|
334
|
+
default_factory=list,
|
|
335
|
+
description="length-matching groups: {name, nets, tolerance}; the router serpentines "
|
|
336
|
+
"the shorter members and reports the spread",
|
|
337
|
+
)
|
|
338
|
+
sheets: list[str] = Field(
|
|
339
|
+
default_factory=list,
|
|
340
|
+
description="schematic sheet names; splits the design (see part.sheet)",
|
|
341
|
+
)
|
|
342
|
+
rev: str | None = Field(default=None, description="revision in the schematic title block")
|
|
343
|
+
date: str | None = Field(default=None, description="date in the title block (default: today)")
|
|
344
|
+
stackup: StackupSpec | None = Field(
|
|
345
|
+
default=None,
|
|
346
|
+
description="layer roles, copper weights and dielectrics (4+ layers get the JLCPCB "
|
|
347
|
+
"preset for their layer count when omitted)",
|
|
348
|
+
)
|
|
349
|
+
fab: str = "jlcpcb"
|
|
350
|
+
kicad_version: int | str | None = Field(
|
|
351
|
+
default=None,
|
|
352
|
+
description="KiCad file format to write: 7, 8, 9, 10, or auto (the installed KiCad)",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@field_validator("name")
|
|
356
|
+
@classmethod
|
|
357
|
+
def _name(cls, value: str) -> str:
|
|
358
|
+
if not re.match(r"^[A-Za-z0-9_.-]+$", value):
|
|
359
|
+
raise ValueError("name may only contain letters, digits, '_', '-' and '.'")
|
|
360
|
+
return value
|
|
361
|
+
|
|
362
|
+
@model_validator(mode="after")
|
|
363
|
+
def _merge(self) -> DesignSpec:
|
|
364
|
+
refs = [p.ref for p in self.parts]
|
|
365
|
+
duplicates = sorted({r for r in refs if refs.count(r) > 1})
|
|
366
|
+
if duplicates:
|
|
367
|
+
raise ValueError(f"duplicate references: {duplicates}")
|
|
368
|
+
by_ref = {p.ref: p for p in self.parts}
|
|
369
|
+
for net, members in self.nets.items():
|
|
370
|
+
for member in members:
|
|
371
|
+
ref, dot, pin = str(member).partition(".")
|
|
372
|
+
if not dot or not pin:
|
|
373
|
+
raise ValueError(f"net {net}: {member!r} must look like REF.PIN")
|
|
374
|
+
if ref not in by_ref:
|
|
375
|
+
raise ValueError(f"net {net}: unknown part {ref!r}")
|
|
376
|
+
existing = by_ref[ref].pins.get(pin)
|
|
377
|
+
if existing and existing != net:
|
|
378
|
+
raise ValueError(f"{ref}.{pin} is on both {existing!r} and {net!r}")
|
|
379
|
+
by_ref[ref].pins[pin] = net
|
|
380
|
+
for part in self.parts:
|
|
381
|
+
for pin, net in part.pins.items():
|
|
382
|
+
if not _NET_RE.match(net):
|
|
383
|
+
raise ValueError(f"{part.ref}.{pin}: invalid net name {net!r}")
|
|
384
|
+
return self
|
|
385
|
+
|
|
386
|
+
@model_validator(mode="after")
|
|
387
|
+
def _stackup(self) -> DesignSpec:
|
|
388
|
+
if self.stackup is not None:
|
|
389
|
+
self.stackup.resolve(self.board.layers) # raises on a mismatch
|
|
390
|
+
return self
|
|
391
|
+
|
|
392
|
+
@model_validator(mode="after")
|
|
393
|
+
def _constraints(self) -> DesignSpec:
|
|
394
|
+
try:
|
|
395
|
+
_validate_constraints(self)
|
|
396
|
+
except ConstraintError as exc:
|
|
397
|
+
raise ValueError(str(exc)) from exc
|
|
398
|
+
return self
|
|
399
|
+
|
|
400
|
+
def stackup_model(self):
|
|
401
|
+
"""The resolved stackup; None for a plain 2-layer board without a ``stackup`` block."""
|
|
402
|
+
if self.stackup is None and self.board.layers == 2:
|
|
403
|
+
return None
|
|
404
|
+
return (self.stackup or StackupSpec()).resolve(self.board.layers)
|
|
405
|
+
|
|
406
|
+
def all_nets(self) -> dict[str, list[tuple[str, str]]]:
|
|
407
|
+
result: dict[str, list[tuple[str, str]]] = {}
|
|
408
|
+
for part in self.parts:
|
|
409
|
+
for pin, net in part.pins.items():
|
|
410
|
+
result.setdefault(net, []).append((part.ref, pin))
|
|
411
|
+
return dict(sorted(result.items()))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class SpecError(ValueError):
|
|
415
|
+
"""A design spec that cannot be read or does not validate (the message says where and why)."""
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _validation_message(path: Path, exc: ValidationError) -> str:
|
|
419
|
+
lines = []
|
|
420
|
+
for item in exc.errors():
|
|
421
|
+
where = ".".join(str(part) for part in item.get("loc", ()))
|
|
422
|
+
msg = str(item.get("msg", "")).removeprefix("Value error, ")
|
|
423
|
+
lines.append(f" {where}: {msg}" if where else f" {msg}")
|
|
424
|
+
return (
|
|
425
|
+
f"{path}: invalid design spec ({len(lines)} problem{'s' if len(lines) != 1 else ''}):\n"
|
|
426
|
+
+ "\n".join(lines)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def load_spec(path: str | Path) -> DesignSpec:
|
|
431
|
+
"""Read and validate a spec (.yaml/.yml/.json); raises SpecError with a readable message."""
|
|
432
|
+
import json
|
|
433
|
+
|
|
434
|
+
path = Path(path)
|
|
435
|
+
try:
|
|
436
|
+
text = path.read_text(encoding="utf-8")
|
|
437
|
+
except FileNotFoundError:
|
|
438
|
+
raise SpecError(f"design spec not found: {path}") from None
|
|
439
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
440
|
+
raise SpecError(f"cannot read {path}: {exc}") from exc
|
|
441
|
+
try:
|
|
442
|
+
if path.suffix.lower() in {".yaml", ".yml"}:
|
|
443
|
+
data = yaml.safe_load(text)
|
|
444
|
+
else:
|
|
445
|
+
data = json.loads(text)
|
|
446
|
+
except yaml.YAMLError as exc:
|
|
447
|
+
mark = getattr(exc, "problem_mark", None)
|
|
448
|
+
where = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
|
|
449
|
+
problem = getattr(exc, "problem", None) or str(exc).splitlines()[0]
|
|
450
|
+
raise SpecError(f"{path}: not valid YAML{where}: {problem}") from None
|
|
451
|
+
except json.JSONDecodeError as exc:
|
|
452
|
+
raise SpecError(f"{path}: not valid JSON at line {exc.lineno}: {exc.msg}") from None
|
|
453
|
+
if not isinstance(data, dict):
|
|
454
|
+
raise SpecError(f"{path}: a design spec is a mapping with name, board and parts")
|
|
455
|
+
try:
|
|
456
|
+
return DesignSpec.model_validate(data)
|
|
457
|
+
except ValidationError as exc:
|
|
458
|
+
raise SpecError(_validation_message(path, exc)) from None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def spec_schema() -> dict[str, Any]:
|
|
462
|
+
return DesignSpec.model_json_schema()
|