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,439 @@
1
+ """A neutral view of a finished KiCad project: components, nets and board geometry.
2
+
3
+ Everything here comes from KiCad's own files: the netlist that ``kicad-cli sch
4
+ export netlist`` writes, and the ``.kicad_pcb`` board. Nothing depends on how the
5
+ project was made, so any agent's output is read the same way.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from traceforge import sexpr
16
+ from traceforge.kicad.pcb import pin_function
17
+
18
+ GROUND_RE = re.compile(r"^(GND|GNDD|GNDA|DGND|AGND|PGND|GNDPWR|VSS|0V|GROUND)$", re.I)
19
+ _LCSC_FIELD = re.compile(r"^(lcsc|jlc|jlcpcb)([ _#-]*(part|pn|code|no\.?|number|#))*$", re.I)
20
+ _MPN_FIELD = re.compile(
21
+ r"^(mpn|mfr[ _.]*(part|pn|no\.?|number)?|manufacturer[ _]*part.*|part[ _]?number)$", re.I
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class Pin:
27
+ ref: str
28
+ pin: str
29
+ function: str = ""
30
+
31
+
32
+ @dataclass
33
+ class Component:
34
+ ref: str
35
+ value: str = ""
36
+ footprint: str = ""
37
+ lib: str = "" # lib:part
38
+ description: str = ""
39
+ fields: dict[str, str] = field(default_factory=dict)
40
+ exclude_from_bom: bool = False
41
+
42
+ @property
43
+ def kind(self) -> str:
44
+ """Reference prefix: R, C, U, J, ... (``#PWR`` style refs give '#')."""
45
+ match = re.match(r"[A-Za-z#]+", self.ref)
46
+ return match.group(0).upper() if match else ""
47
+
48
+ def field_like(self, pattern: re.Pattern[str]) -> str:
49
+ for key, val in self.fields.items():
50
+ if pattern.match(key.strip()) and val.strip():
51
+ return val.strip()
52
+ return ""
53
+
54
+ @property
55
+ def lcsc(self) -> str:
56
+ return self.field_like(_LCSC_FIELD)
57
+
58
+ @property
59
+ def mpn(self) -> str:
60
+ return self.field_like(_MPN_FIELD)
61
+
62
+ def text(self) -> str:
63
+ """Everything that names the part, for selector matching."""
64
+ bits = [self.value, self.lib, self.lib.split(":")[-1], self.mpn, self.description]
65
+ return " | ".join(b for b in bits if b)
66
+
67
+
68
+ @dataclass
69
+ class Netlist:
70
+ components: dict[str, Component] = field(default_factory=dict)
71
+ nets: dict[str, list[Pin]] = field(default_factory=dict)
72
+ source: str = "" # schematic | pcb
73
+
74
+ def net_of(self, ref: str, pin: str) -> str | None:
75
+ for name, pins in self.nets.items():
76
+ if any(p.ref == ref and p.pin == pin for p in pins):
77
+ return name
78
+ return None
79
+
80
+ def pins_of(self, ref: str) -> list[tuple[Pin, str]]:
81
+ return [(p, name) for name, pins in self.nets.items() for p in pins if p.ref == ref]
82
+
83
+ def node_sets(
84
+ self, ignore_refs: set[str] = frozenset()
85
+ ) -> dict[str, frozenset[tuple[str, str]]]:
86
+ out = {}
87
+ for name, pins in self.nets.items():
88
+ nodes = frozenset((p.ref, p.pin) for p in pins if p.ref not in ignore_refs)
89
+ if len(nodes) >= 2:
90
+ out[name] = nodes
91
+ return out
92
+
93
+
94
+ def clean_net(name: str) -> str:
95
+ return name[1:] if name.startswith("/") and name.count("/") == 1 else name
96
+
97
+
98
+ def is_ground(name: str) -> bool:
99
+ return bool(GROUND_RE.match(clean_net(name).lstrip("+")))
100
+
101
+
102
+ def parse_kicad_netlist(text: str) -> Netlist:
103
+ """Parse a ``kicadsexpr`` netlist (``kicad-cli sch export netlist``)."""
104
+ root = sexpr.parse(text)
105
+ out = Netlist(source="schematic")
106
+ comps = sexpr.find(root, "components") or []
107
+ for comp in sexpr.find_all(comps, "comp"):
108
+ ref = str(sexpr.value(comp, "ref", ""))
109
+ lib = sexpr.find(comp, "libsource")
110
+ fields: dict[str, str] = {}
111
+ for fnode in sexpr.find_all(sexpr.find(comp, "fields") or [], "field"):
112
+ key = str(sexpr.value(fnode, "name", ""))
113
+ vals = [c for c in fnode[1:] if isinstance(c, str)]
114
+ fields[key] = str(vals[-1]) if vals else ""
115
+ props = {
116
+ str(sexpr.value(p, "name", "")): str(sexpr.value(p, "value", ""))
117
+ for p in sexpr.find_all(comp, "property")
118
+ }
119
+ for key, val in props.items():
120
+ if not key.startswith("ki_") and key not in {"Sheetname", "Sheetfile"}:
121
+ fields.setdefault(key, val)
122
+ out.components[ref] = Component(
123
+ ref=ref,
124
+ value=str(sexpr.value(comp, "value", "")),
125
+ footprint=str(sexpr.value(comp, "footprint", "")),
126
+ lib=f"{sexpr.value(lib, 'lib', '')}:{sexpr.value(lib, 'part', '')}" if lib else "",
127
+ description=str(
128
+ sexpr.value(comp, "description", "") or props.get("ki_description", "")
129
+ ),
130
+ fields=fields,
131
+ exclude_from_bom="exclude_from_bom" in props or "dnp" in props,
132
+ )
133
+ # Pin names per library part, for nodes that carry no "pinfunction" (newer KiCad
134
+ # versions do not always write it): (lib, part) -> {pin number: name}.
135
+ lib_pins: dict[tuple[str, str], dict[str, str]] = {}
136
+ for libpart in sexpr.find_all(sexpr.find(root, "libparts") or [], "libpart"):
137
+ key = (str(sexpr.value(libpart, "lib", "")), str(sexpr.value(libpart, "part", "")))
138
+ lib_pins[key] = {
139
+ str(sexpr.value(pin, "num", "")): str(sexpr.value(pin, "name", ""))
140
+ for pin in sexpr.find_all(sexpr.find(libpart, "pins") or [], "pin")
141
+ }
142
+ comp_lib = {}
143
+ for comp in sexpr.find_all(comps, "comp"):
144
+ lib = sexpr.find(comp, "libsource")
145
+ if lib:
146
+ comp_lib[str(sexpr.value(comp, "ref", ""))] = (
147
+ str(sexpr.value(lib, "lib", "")),
148
+ str(sexpr.value(lib, "part", "")),
149
+ )
150
+
151
+ def function(node) -> str:
152
+ ref, pin = str(sexpr.value(node, "ref", "")), str(sexpr.value(node, "pin", ""))
153
+ name = pin_function(sexpr.value(node, "pinfunction", ""), pin)
154
+ if name:
155
+ return name
156
+ found = lib_pins.get(comp_lib.get(ref, ("", "")), {}).get(pin, "")
157
+ return "" if found in {"~", pin} else found
158
+
159
+ nets = sexpr.find(root, "nets") or []
160
+ for net in sexpr.find_all(nets, "net"):
161
+ name = clean_net(str(sexpr.value(net, "name", "")))
162
+ out.nets[name] = [
163
+ Pin(str(sexpr.value(n, "ref", "")), str(sexpr.value(n, "pin", "")), function(n))
164
+ for n in sexpr.find_all(net, "node")
165
+ ]
166
+ return out
167
+
168
+
169
+ # ----------------------------------------------------------------------- board
170
+
171
+
172
+ @dataclass
173
+ class Pad:
174
+ number: str
175
+ x: float
176
+ y: float
177
+ w: float
178
+ h: float
179
+ net: str = ""
180
+ function: str = ""
181
+ kind: str = ""
182
+
183
+
184
+ @dataclass
185
+ class Footprint:
186
+ ref: str
187
+ lib: str
188
+ value: str
189
+ x: float
190
+ y: float
191
+ rot: float
192
+ layer: str
193
+ pads: list[Pad] = field(default_factory=list)
194
+ points: list[tuple[float, float]] = field(default_factory=list) # graphics, absolute
195
+ ref_visible: bool = True
196
+ fields: dict[str, str] = field(default_factory=dict)
197
+
198
+
199
+ @dataclass
200
+ class Board:
201
+ path: Path
202
+ copper_layers: list[str] = field(default_factory=list)
203
+ outline: tuple[float, float, float, float] | None = None # x0, y0, x1, y1
204
+ footprints: list[Footprint] = field(default_factory=list)
205
+ track_length: dict[str, float] = field(default_factory=dict)
206
+ vias: int = 0
207
+ zones: list[str] = field(default_factory=list)
208
+
209
+ @property
210
+ def size(self) -> tuple[float, float] | None:
211
+ if not self.outline:
212
+ return None
213
+ x0, y0, x1, y1 = self.outline
214
+ return round(x1 - x0, 3), round(y1 - y0, 3)
215
+
216
+ def netlist(self) -> Netlist:
217
+ """The board's own netlist (pads grouped by net), for parity and fallback."""
218
+ out = Netlist(source="pcb")
219
+ for fp in self.footprints:
220
+ if fp.ref and not fp.ref.startswith("#"):
221
+ out.components.setdefault(
222
+ fp.ref,
223
+ Component(fp.ref, fp.value, fp.lib, fields=dict(fp.fields)),
224
+ )
225
+ for pad in fp.pads:
226
+ if pad.net and not pad.net.startswith("unconnected-"):
227
+ pins = out.nets.setdefault(clean_net(pad.net), [])
228
+ if not any(p.ref == fp.ref and p.pin == pad.number for p in pins):
229
+ pins.append(Pin(fp.ref, pad.number, pad.function))
230
+ return out
231
+
232
+ def total_connections(self) -> int:
233
+ return sum(max(0, len(p) - 1) for p in self.netlist().nets.values())
234
+
235
+
236
+ def _rotate(x: float, y: float, deg: float) -> tuple[float, float]:
237
+ # KiCad: positive angles are counter-clockwise on screen, with y pointing down.
238
+ a = math.radians(deg)
239
+ return x * math.cos(a) + y * math.sin(a), -x * math.sin(a) + y * math.cos(a)
240
+
241
+
242
+ def _xy(node, key: str = "at") -> tuple[float, float, float]:
243
+ at = sexpr.find(node, key)
244
+ if not at:
245
+ return 0.0, 0.0, 0.0
246
+ vals = [sexpr.num(v) for v in at[1:4] if not isinstance(v, list)]
247
+ vals += [0.0] * (3 - len(vals))
248
+ return vals[0], vals[1], vals[2]
249
+
250
+
251
+ def _layer(node) -> str:
252
+ return str(sexpr.value(node, "layer", ""))
253
+
254
+
255
+ def _hidden(node) -> bool:
256
+ if any(isinstance(c, str) and c == "hide" for c in node):
257
+ return True
258
+ hide = sexpr.find(node, "hide")
259
+ if hide is not None and (len(hide) < 2 or str(hide[1]) == "yes"):
260
+ return True
261
+ effects = sexpr.find(node, "effects")
262
+ return bool(effects and (_hidden(effects) if effects is not node else False))
263
+
264
+
265
+ def _graphic_points(node) -> list[tuple[float, float]]:
266
+ pts = []
267
+ for key in ("start", "end", "mid", "center"):
268
+ sub = sexpr.find(node, key)
269
+ if sub:
270
+ pts.append((sexpr.num(sub[1]), sexpr.num(sub[2])))
271
+ poly = sexpr.find(node, "pts")
272
+ if poly:
273
+ pts += [(sexpr.num(p[1]), sexpr.num(p[2])) for p in sexpr.find_all(poly, "xy")]
274
+ if sexpr.name(node) in {"gr_circle", "fp_circle"} and len(pts) >= 2:
275
+ (cx, cy), (ex, ey) = pts[0], pts[1]
276
+ r = math.hypot(ex - cx, ey - cy)
277
+ pts = [(cx - r, cy - r), (cx + r, cy + r)]
278
+ return pts
279
+
280
+
281
+ def load_board(path: Path) -> Board:
282
+ root = sexpr.parse(path.read_text(encoding="utf-8"))
283
+ board = Board(path=path)
284
+ layers = sexpr.find(root, "layers") or []
285
+ for layer in layers[1:]:
286
+ if isinstance(layer, list) and len(layer) >= 3 and str(layer[1]).endswith(".Cu"):
287
+ board.copper_layers.append(str(layer[1]))
288
+ edge: list[tuple[float, float]] = []
289
+ for item in root[1:]:
290
+ head = sexpr.name(item)
291
+ if head and head.startswith("gr_") and _layer(item) == "Edge.Cuts":
292
+ edge += _graphic_points(item)
293
+ nets_by_id = {str(n[1]): str(n[2]) for n in sexpr.find_all(root, "net") if len(n) >= 3}
294
+ for fp_node in sexpr.find_all(root, "footprint") + sexpr.find_all(root, "module"):
295
+ fx, fy, frot = _xy(fp_node)
296
+ ref, value, ref_visible = "", "", False
297
+ fields: dict[str, str] = {}
298
+ for text in sexpr.find_all(fp_node, "fp_text"):
299
+ if len(text) >= 3 and text[1] in {"reference", "value"}:
300
+ if text[1] == "reference":
301
+ ref = str(text[2])
302
+ ref_visible = "Silk" in _layer(text) and not _hidden(text)
303
+ else:
304
+ value = str(text[2])
305
+ for prop in sexpr.find_all(fp_node, "property"):
306
+ if len(prop) < 3:
307
+ continue
308
+ key, val = str(prop[1]), str(prop[2])
309
+ if key == "Reference":
310
+ ref = val
311
+ ref_visible = "Silk" in _layer(prop) and not _hidden(prop)
312
+ elif key == "Value":
313
+ value = val
314
+ else:
315
+ fields[key] = val
316
+ fp = Footprint(
317
+ ref=ref,
318
+ lib=str(fp_node[1]) if len(fp_node) > 1 else "",
319
+ value=value,
320
+ x=fx,
321
+ y=fy,
322
+ rot=frot,
323
+ layer=_layer(fp_node),
324
+ ref_visible=ref_visible,
325
+ fields=fields,
326
+ )
327
+ for pad in sexpr.find_all(fp_node, "pad"):
328
+ px, py, _ = _xy(pad)
329
+ dx, dy = _rotate(px, py, frot)
330
+ size = sexpr.find(pad, "size") or [None, 0, 0]
331
+ net = sexpr.find(pad, "net")
332
+ net_name = ""
333
+ if net and len(net) >= 3:
334
+ net_name = str(net[2])
335
+ elif net and len(net) == 2:
336
+ net_name = nets_by_id.get(str(net[1]), str(net[1]))
337
+ fp.pads.append(
338
+ Pad(
339
+ number=str(pad[1]) if len(pad) > 1 else "",
340
+ x=fx + dx,
341
+ y=fy + dy,
342
+ w=sexpr.num(size[1]),
343
+ h=sexpr.num(size[2]),
344
+ net=net_name,
345
+ function=pin_function(sexpr.value(pad, "pinfunction", ""), str(pad[1])),
346
+ kind=str(pad[2]) if len(pad) > 2 else "",
347
+ )
348
+ )
349
+ for item in fp_node:
350
+ head = sexpr.name(item)
351
+ if head and head.startswith("fp_") and head != "fp_text":
352
+ for x, y in _graphic_points(item):
353
+ dx, dy = _rotate(x, y, frot)
354
+ fp.points.append((fx + dx, fy + dy))
355
+ if _layer(item) == "Edge.Cuts":
356
+ edge += [
357
+ (fx + _rotate(x, y, frot)[0], fy + _rotate(x, y, frot)[1])
358
+ for x, y in _graphic_points(item)
359
+ ]
360
+ board.footprints.append(fp)
361
+ if edge:
362
+ xs, ys = [p[0] for p in edge], [p[1] for p in edge]
363
+ board.outline = (min(xs), min(ys), max(xs), max(ys))
364
+ for seg in sexpr.find_all(root, "segment") + sexpr.find_all(root, "arc"):
365
+ start, end = sexpr.find(seg, "start"), sexpr.find(seg, "end")
366
+ net = sexpr.find(seg, "net")
367
+ if not (start and end and net and len(net) >= 2):
368
+ continue
369
+ name = clean_net(nets_by_id.get(str(net[1]), str(net[1])))
370
+ length = math.hypot(
371
+ sexpr.num(end[1]) - sexpr.num(start[1]), sexpr.num(end[2]) - sexpr.num(start[2])
372
+ )
373
+ board.track_length[name] = board.track_length.get(name, 0.0) + length
374
+ board.vias = len(sexpr.find_all(root, "via"))
375
+ for zone in sexpr.find_all(root, "zone"):
376
+ board.zones.append(clean_net(str(sexpr.value(zone, "net_name", ""))))
377
+ return board
378
+
379
+
380
+ def footprint_extent(fp: Footprint) -> tuple[float, float, float, float]:
381
+ xs = [p.x - p.w / 2 for p in fp.pads] + [p.x + p.w / 2 for p in fp.pads]
382
+ ys = [p.y - p.h / 2 for p in fp.pads] + [p.y + p.h / 2 for p in fp.pads]
383
+ xs += [p[0] for p in fp.points]
384
+ ys += [p[1] for p in fp.points]
385
+ if not xs:
386
+ return fp.x, fp.y, fp.x, fp.y
387
+ return min(xs), min(ys), max(xs), max(ys)
388
+
389
+
390
+ def edge_distance(board: Board, fp: Footprint) -> float | None:
391
+ """How far the footprint's nearest extent is from the board outline (<= 0: overhangs)."""
392
+ if not board.outline:
393
+ return None
394
+ x0, y0, x1, y1 = board.outline
395
+ fx0, fy0, fx1, fy1 = footprint_extent(fp)
396
+ return round(min(fx0 - x0, fy0 - y0, x1 - fx1, y1 - fy1), 3)
397
+
398
+
399
+ # ------------------------------------------------------------------ discovery
400
+
401
+ _SKIP_DIRS = {"backups", "outputs", "preview", ".git", ".traceforge", "gerbers", "libraries"}
402
+
403
+
404
+ def find_project(root: Path) -> tuple[Path | None, Path | None]:
405
+ """The main (schematic, board) pair in a workspace.
406
+
407
+ Prefers a board with a schematic of the same name, then the largest board,
408
+ then the shallowest path; ignores backup and output folders.
409
+ """
410
+ boards = [
411
+ p
412
+ for p in root.rglob("*.kicad_pcb")
413
+ if not any(
414
+ part in _SKIP_DIRS or part.endswith("-backups")
415
+ for part in p.relative_to(root).parts[:-1]
416
+ )
417
+ ]
418
+ schematics = [
419
+ p
420
+ for p in root.rglob("*.kicad_sch")
421
+ if not any(
422
+ part in _SKIP_DIRS or part.endswith("-backups")
423
+ for part in p.relative_to(root).parts[:-1]
424
+ )
425
+ ]
426
+ if not boards:
427
+ roots = [s for s in schematics if s.with_suffix(".kicad_pro").exists()] or schematics
428
+ roots.sort(key=lambda p: (len(p.parts), -p.stat().st_size))
429
+ return (roots[0] if roots else None), None
430
+ boards.sort(
431
+ key=lambda p: (
432
+ not p.with_suffix(".kicad_sch").exists(),
433
+ -p.stat().st_size,
434
+ len(p.parts),
435
+ )
436
+ )
437
+ board = boards[0]
438
+ sch = board.with_suffix(".kicad_sch")
439
+ return (sch if sch.exists() else None), board
@@ -0,0 +1,99 @@
1
+ """KiCad runs for scoring: netlist export and DRC with refilled zones.
2
+
3
+ DRC always runs on a scratch copy, so scoring never modifies an agent's output.
4
+ Zones are refilled first: kicad-cli 10+ does it with ``--refill-zones``; older
5
+ versions refill through KiCad's own Python module (``pcbnew``) when it is
6
+ installed, which is also how KiCad 7 runs DRC at all.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ from traceforge.bench.design import Netlist, parse_kicad_netlist
17
+ from traceforge.kicad.cli import (
18
+ CheckReport,
19
+ KicadCli,
20
+ KicadCliError,
21
+ find_kicad_python,
22
+ parse_text_drc,
23
+ )
24
+
25
+ _REFILL_DRC = """
26
+ import sys, pcbnew
27
+ board = pcbnew.LoadBoard(sys.argv[1])
28
+ zones = board.Zones()
29
+ if len(zones):
30
+ pcbnew.ZONE_FILLER(board).Fill(zones)
31
+ board.Save(sys.argv[1])
32
+ if len(sys.argv) > 2:
33
+ ok = pcbnew.WriteDRCReport(board, sys.argv[2], pcbnew.EDA_UNITS_MILLIMETRES, True)
34
+ sys.exit(0 if ok else 3)
35
+ """
36
+
37
+
38
+ def export_netlist(cli: KicadCli, schematic: Path, out_dir: Path) -> Netlist:
39
+ out_dir.mkdir(parents=True, exist_ok=True)
40
+ output = out_dir / f"{schematic.stem}.net"
41
+ output.unlink(missing_ok=True)
42
+ completed = cli.run(
43
+ ["sch", "export", "netlist", "--format", "kicadsexpr", "-o", str(output), str(schematic)]
44
+ )
45
+ if not output.exists():
46
+ raise KicadCliError(
47
+ f"netlist export failed: {(completed.stderr or completed.stdout)[-800:]}"
48
+ )
49
+ return parse_kicad_netlist(output.read_text(encoding="utf-8"))
50
+
51
+
52
+ def _copy_board(board: Path, out_dir: Path) -> Path:
53
+ dest = out_dir / "drc_copy"
54
+ shutil.rmtree(dest, ignore_errors=True)
55
+ dest.mkdir(parents=True)
56
+ for suffix in (".kicad_pcb", ".kicad_pro", ".kicad_dru", ".kicad_prl"):
57
+ src = board.with_suffix(suffix)
58
+ if src.exists():
59
+ shutil.copy2(src, dest / src.name)
60
+ return dest / board.name
61
+
62
+
63
+ def _pcbnew(python: str, *args: str) -> subprocess.CompletedProcess[str]:
64
+ env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"}
65
+ return subprocess.run( # noqa: S603
66
+ [python, "-c", _REFILL_DRC, *args],
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=900,
70
+ env=env,
71
+ check=False,
72
+ )
73
+
74
+
75
+ def run_drc(cli: KicadCli, board: Path, out_dir: Path) -> tuple[CheckReport, dict]:
76
+ """DRC on a copy of ``board`` with zones refilled. Returns (report, how it ran)."""
77
+ copy = _copy_board(board, out_dir)
78
+ version = cli.version()
79
+ info = {"kicad": ".".join(map(str, version)), "zones_refilled": False, "engine": "kicad-cli"}
80
+ python = find_kicad_python() if version < (10, 0, 0) else None
81
+ if version < (8, 0, 0):
82
+ if python is None:
83
+ raise KicadCliError(
84
+ "DRC on KiCad 7 needs KiCad's Python module (set TRACEFORGE_KICAD_PYTHON)"
85
+ )
86
+ report_path = out_dir / f"{board.stem}-drc.txt"
87
+ report_path.unlink(missing_ok=True)
88
+ done = _pcbnew(python, str(copy.resolve()), str(report_path.resolve()))
89
+ if not report_path.exists():
90
+ raise KicadCliError(f"pcbnew DRC failed: {(done.stderr or done.stdout)[-1500:]}")
91
+ info.update(engine="pcbnew", zones_refilled=True)
92
+ return parse_text_drc(report_path), info
93
+ if python is not None:
94
+ done = _pcbnew(python, str(copy.resolve()))
95
+ info["zones_refilled"] = done.returncode == 0
96
+ else:
97
+ info["zones_refilled"] = version >= (10, 0, 0)
98
+ report = cli.drc(copy, out_dir, schematic_parity=False)
99
+ return report, info
@@ -0,0 +1,159 @@
1
+ """Check the BOM's LCSC part numbers against the live catalogue.
2
+
3
+ Every fact is tagged with where it came from: ``design`` (the project's own
4
+ fields) or ``catalogue`` (what JLCPCB/LCSC answered, with the provider names).
5
+ Only stable facts are scored (the code exists, its package fits the footprint,
6
+ its value matches); stock and price are recorded but never scored, so a re-run
7
+ next month gives the same number.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any
14
+
15
+ from traceforge.bench.checks import parse_value
16
+ from traceforge.bench.design import Component, Netlist
17
+
18
+ _NOT_ORDERABLE = re.compile(r"^(H|MH|TP|FID|LOGO|G|NT|JP\d*SJ|REF)$", re.I)
19
+ _UNIT = {"R": "Ω", "C": "F", "L": "H"}
20
+ _CAT_VALUE = re.compile(r"(\d+(?:\.\d+)?)\s*([pnuµμmkKM]?)\s*(Ω|ohm|F|H)\b", re.I)
21
+
22
+
23
+ def bom_components(netlist: Netlist) -> list[Component]:
24
+ out = []
25
+ for ref, comp in sorted(netlist.components.items()):
26
+ if ref.startswith("#") or comp.exclude_from_bom or not comp.footprint:
27
+ continue
28
+ if _NOT_ORDERABLE.match(comp.kind) or re.search(
29
+ r"MountingHole|Fiducial|TestPoint", comp.footprint, re.I
30
+ ):
31
+ continue
32
+ out.append(comp)
33
+ return out
34
+
35
+
36
+ def _catalogue_values(part, unit: str) -> list[float]:
37
+ text = " ".join([part.description, *part.attributes.values()])
38
+ out = []
39
+ for num, prefix, u in _CAT_VALUE.findall(text):
40
+ u = "Ω" if u.lower() in {"ohm", "ω"} else u.upper()
41
+ if u == unit:
42
+ value = parse_value(f"{num}{prefix}")
43
+ if value is not None:
44
+ out.append(value)
45
+ return out
46
+
47
+
48
+ def _package_problems(comp: Component, part) -> list[str]:
49
+ try:
50
+ from traceforge.parts import packages
51
+
52
+ cat = packages.parse_catalog_package(
53
+ part.package, description=part.description, attributes=part.attributes
54
+ )
55
+ fp = packages.parse_footprint(comp.footprint)
56
+ mismatches = packages.compare(cat, fp, compare_pins=False)
57
+ return [f"{m.aspect}: {m.detail}" for m in mismatches if m.severity == "P0"]
58
+ except Exception: # package parsing is best effort; never fail scoring on it
59
+ return []
60
+
61
+
62
+ def check_component(comp: Component, report) -> dict[str, Any]:
63
+ entry: dict[str, Any] = {
64
+ "ref": comp.ref,
65
+ "design": {
66
+ "lcsc": comp.lcsc,
67
+ "value": comp.value,
68
+ "footprint": comp.footprint,
69
+ "mpn": comp.mpn,
70
+ },
71
+ "catalogue": None,
72
+ "checks": [],
73
+ "status": "no_code",
74
+ "score": 0.0,
75
+ }
76
+ if not comp.lcsc:
77
+ return entry
78
+ if report is None or (not report.answered):
79
+ entry["status"] = "unverified"
80
+ entry["score"] = None
81
+ entry["lookup"] = report.describe() if report is not None else "not looked up"
82
+ return entry
83
+ part = report.part
84
+ entry["lookup"] = report.describe()
85
+ if part is None:
86
+ entry["status"] = "not_found"
87
+ return entry
88
+ entry["catalogue"] = {
89
+ "lcsc": part.lcsc,
90
+ "mpn": part.mpn,
91
+ "manufacturer": part.manufacturer,
92
+ "package": part.package,
93
+ "description": part.description,
94
+ "library_type": part.library_type,
95
+ "stock": part.stock,
96
+ "lifecycle": part.lifecycle,
97
+ "sources": list(part.sources),
98
+ "stale": part.stale,
99
+ }
100
+ problems = _package_problems(comp, part)
101
+ for problem in problems:
102
+ entry["checks"].append(
103
+ {"check": "package", "ok": False, "source": "catalogue", "detail": problem}
104
+ )
105
+ unit = _UNIT.get(comp.kind)
106
+ want = parse_value(comp.value) if unit else None
107
+ if unit and want:
108
+ values = _catalogue_values(part, unit)
109
+ if values:
110
+ ok = any(abs(v - want) <= 0.02 * want for v in values)
111
+ entry["checks"].append(
112
+ {
113
+ "check": "value",
114
+ "ok": ok,
115
+ "source": "catalogue",
116
+ "detail": f"design {comp.value}, catalogue {', '.join(f'{v:g}' for v in values[:3])}",
117
+ }
118
+ )
119
+ if comp.mpn and part.mpn:
120
+ from traceforge.parts.providers.base import same_device
121
+
122
+ ok = same_device(comp.mpn, part.mpn)
123
+ entry["checks"].append(
124
+ {
125
+ "check": "mpn",
126
+ "ok": ok,
127
+ "source": "catalogue",
128
+ "detail": f"design {comp.mpn}, catalogue {part.mpn}",
129
+ }
130
+ )
131
+ bad = [c for c in entry["checks"] if not c["ok"]]
132
+ entry["status"] = "mismatch" if bad else "verified"
133
+ entry["score"] = 0.5 if bad else 1.0
134
+ return entry
135
+
136
+
137
+ def verify_parts(netlist: Netlist, catalog=None, offline: bool = False) -> dict[str, Any]:
138
+ comps = bom_components(netlist)
139
+ codes = sorted({c.lcsc.strip().upper() for c in comps if c.lcsc})
140
+ reports: dict[str, Any] = {}
141
+ if codes and not offline:
142
+ if catalog is None:
143
+ from traceforge.parts import default_catalog
144
+
145
+ catalog = default_catalog()
146
+ reports = {code: catalog.lookup_report(code) for code in codes}
147
+ entries = [
148
+ check_component(c, reports.get(c.lcsc.strip().upper()) if c.lcsc else None) for c in comps
149
+ ]
150
+ scored = [] if offline else [e["score"] for e in entries if e["score"] is not None]
151
+ counts: dict[str, int] = {}
152
+ for e in entries:
153
+ counts[e["status"]] = counts.get(e["status"], 0) + 1
154
+ return {
155
+ "score": (sum(scored) / len(scored)) if scored else None,
156
+ "lines": len(entries),
157
+ "counts": counts,
158
+ "parts": entries,
159
+ }