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.
Files changed (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. 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()