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,410 @@
1
+ """Machine-checkable requirements, evaluated on a :class:`Netlist` and a :class:`Board`.
2
+
3
+ A task names the parts it cares about as *roles* (``mcu``, ``usbc``, ``ldo``)
4
+ with selectors, then lists checks over those roles. Selectors and checks only
5
+ look at what KiCad writes (values, library ids, footprints, pin names, nets), so
6
+ they are blind to which tool produced the project.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import deque
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from traceforge.bench.design import Board, Component, Netlist, clean_net, edge_distance, is_ground
17
+
18
+ _PREFIX = {
19
+ "p": 1e-12,
20
+ "n": 1e-9,
21
+ "u": 1e-6,
22
+ "µ": 1e-6,
23
+ "μ": 1e-6,
24
+ "m": 1e-3,
25
+ "k": 1e3,
26
+ "K": 1e3,
27
+ "M": 1e6,
28
+ "G": 1e9,
29
+ "R": 1.0,
30
+ "r": 1.0,
31
+ }
32
+ _VALUE_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*([pnuµμmkKMGRr]?)(\d*)")
33
+ PASSIVE_KINDS = "R|C|L|D|LED|F|FB|FUSE|RN|VD|TVS|Z"
34
+
35
+
36
+ def parse_value(text: str | float | int | None) -> float | None:
37
+ """'5.1k' / '5k1' / '4R7' / '100nF' / '0.1 uF 16V' / 5100 -> number (SI)."""
38
+ if text is None:
39
+ return None
40
+ if isinstance(text, (int, float)):
41
+ return float(text)
42
+ match = _VALUE_RE.search(str(text).replace("Ω", "").replace("ohm", ""))
43
+ if not match:
44
+ return None
45
+ whole, prefix, frac = match.groups()
46
+ number = float(whole.replace(",", "."))
47
+ if frac and prefix:
48
+ number = float(f"{whole}.{frac}")
49
+ return number * _PREFIX.get(prefix, 1.0)
50
+
51
+
52
+ def value_matches(text: str, want: Any, tolerance: float = 0.03) -> bool:
53
+ got, target = parse_value(text), parse_value(want)
54
+ if got is None or target is None:
55
+ return False
56
+ return abs(got - target) <= tolerance * abs(target) + 1e-18
57
+
58
+
59
+ @dataclass
60
+ class Selector:
61
+ """Which components play a role. All given fields must match."""
62
+
63
+ match: str = "" # regex searched in value / lib id / MPN / description
64
+ kind: str = "" # regex fully matching the reference prefix (R, C, U, J, ...)
65
+ footprint: str = "" # regex searched in the footprint id
66
+ value: str | float | None = None # numeric value (5.1k, 100n) within tolerance
67
+ tolerance: float = 0.03
68
+ exclude: str = "" # regex that must NOT be found in the part's text (e.g. LED)
69
+
70
+ @classmethod
71
+ def build(cls, raw: Any) -> Selector:
72
+ if isinstance(raw, str):
73
+ return cls(match=raw)
74
+ return cls(**{k: v for k, v in dict(raw).items() if k in cls.__dataclass_fields__})
75
+
76
+ def accepts(self, comp: Component) -> bool:
77
+ if self.kind and not re.fullmatch(self.kind, comp.kind, re.I):
78
+ return False
79
+ if self.match and not re.search(self.match, comp.text(), re.I):
80
+ return False
81
+ if self.exclude and re.search(self.exclude, comp.text(), re.I):
82
+ return False
83
+ if self.footprint and not re.search(self.footprint, comp.footprint, re.I):
84
+ return False
85
+ return not (
86
+ self.value is not None and not value_matches(comp.value, self.value, self.tolerance)
87
+ )
88
+
89
+
90
+ @dataclass
91
+ class Result:
92
+ id: str
93
+ label: str
94
+ score: float # 0..1
95
+ detail: str = ""
96
+ weight: float = 1.0
97
+
98
+ @property
99
+ def passed(self) -> bool:
100
+ return self.score >= 0.999
101
+
102
+ def to_dict(self) -> dict:
103
+ return {
104
+ "id": self.id,
105
+ "label": self.label,
106
+ "passed": self.passed,
107
+ "score": round(self.score, 4),
108
+ "weight": self.weight,
109
+ "detail": self.detail,
110
+ }
111
+
112
+
113
+ @dataclass
114
+ class Context:
115
+ netlist: Netlist
116
+ board: Board | None
117
+ roles: dict[str, Selector] = field(default_factory=dict)
118
+ base: Netlist | None = None # edit tasks: the project before the edit
119
+
120
+ def selector(self, role_or_selector: Any) -> Selector:
121
+ if isinstance(role_or_selector, Selector):
122
+ return role_or_selector
123
+ if isinstance(role_or_selector, str) and role_or_selector in self.roles:
124
+ return self.roles[role_or_selector]
125
+ return Selector.build(role_or_selector)
126
+
127
+ def candidates(self, role_or_selector: Any) -> list[Component]:
128
+ selector = self.selector(role_or_selector)
129
+ comps = dict(self.netlist.components)
130
+ if self.board: # PCB-only items (mounting holes, logos) count too
131
+ for ref, comp in self.board.netlist().components.items():
132
+ comps.setdefault(ref, comp)
133
+ return [
134
+ c for ref, c in sorted(comps.items()) if not ref.startswith("#") and selector.accepts(c)
135
+ ]
136
+
137
+ def endpoint_nets(self, raw: Any) -> set[str]:
138
+ """``{role, pin}`` -> nets on that pin; ``{net: regex}`` or ``GND`` -> named nets."""
139
+ if isinstance(raw, list):
140
+ return set().union(*(self.endpoint_nets(r) for r in raw)) if raw else set()
141
+ if isinstance(raw, str):
142
+ raw = {"net": raw}
143
+ nets = set(self.netlist.nets)
144
+ if "net" in raw:
145
+ pattern = str(raw["net"])
146
+ if pattern.upper() == "GND":
147
+ return {n for n in nets if is_ground(n)}
148
+ return {n for n in nets if re.fullmatch(pattern, clean_net(n).lstrip("/"), re.I)}
149
+ pin_re = str(raw.get("pin", ".*"))
150
+ out = set()
151
+ selector = (
152
+ raw.get("role") or raw.get("part") or {k: v for k, v in raw.items() if k != "pin"}
153
+ )
154
+ for comp in self.candidates(selector):
155
+ for pin, net in self.netlist.pins_of(comp.ref):
156
+ if pin_matches(pin.pin, pin.function, pin_re):
157
+ out.add(net)
158
+ return out
159
+
160
+
161
+ def pin_matches(number: str, function: str, pattern: str) -> bool:
162
+ if re.fullmatch(pattern, number, re.I):
163
+ return True
164
+ names = [function, *re.split(r"[/,\s]+", function)] if function else []
165
+ return any(n and re.fullmatch(pattern, n, re.I) for n in names)
166
+
167
+
168
+ def _describe(raw: Any) -> str:
169
+ if isinstance(raw, list):
170
+ return " or ".join(_describe(r) for r in raw)
171
+ if isinstance(raw, str):
172
+ return raw
173
+ if "net" in raw:
174
+ return f"net {raw['net']}"
175
+ who = raw.get("role") or raw.get("part") or raw.get("match") or raw.get("kind") or "part"
176
+ return f"{who}.{raw['pin']}" if "pin" in raw else str(who)
177
+
178
+
179
+ # ---------------------------------------------------------------------- checks
180
+
181
+
182
+ def check_part(ctx: Context, spec: dict) -> tuple[float, str]:
183
+ found = ctx.candidates(spec.get("role") or spec.get("part"))
184
+ need = int(spec.get("min", 1))
185
+ refs = ", ".join(c.ref for c in found[:8]) or "none"
186
+ if "max" in spec and len(found) > int(spec["max"]):
187
+ return 0.0, f"{len(found)} found (max {spec['max']}): {refs}"
188
+ return min(1.0, len(found) / need) if need else 1.0, f"{len(found)}/{need}: {refs}"
189
+
190
+
191
+ def check_connected(ctx: Context, spec: dict) -> tuple[float, str]:
192
+ """All endpoints share at least one net."""
193
+ sets = [ctx.endpoint_nets(e) for e in spec["pins"]]
194
+ if not all(sets):
195
+ missing = [_describe(e) for e, s in zip(spec["pins"], sets, strict=True) if not s]
196
+ return 0.0, "not found: " + ", ".join(missing)
197
+ common = set.intersection(*sets)
198
+ if common:
199
+ return 1.0, "on " + ", ".join(sorted(common))
200
+ return 0.0, " / ".join(",".join(sorted(s)) for s in sets)
201
+
202
+
203
+ def check_separate(ctx: Context, spec: dict) -> tuple[float, str]:
204
+ """Endpoints exist and are NOT on a common net (e.g. something sits in series)."""
205
+ sets = [ctx.endpoint_nets(e) for e in spec["pins"]]
206
+ if not all(sets):
207
+ return 0.0, "endpoint not found"
208
+ common = set.intersection(*sets)
209
+ return (0.0, "shorted on " + ", ".join(sorted(common))) if common else (1.0, "separate nets")
210
+
211
+
212
+ def _two_sided(ctx: Context, sel: Selector, a: set[str], b: set[str]) -> list[str]:
213
+ hits = []
214
+ for comp in ctx.candidates(sel):
215
+ nets = {net for _, net in ctx.netlist.pins_of(comp.ref)}
216
+ if any(x != y for x in nets & a for y in nets & b):
217
+ hits.append(comp.ref)
218
+ return hits
219
+
220
+
221
+ def check_through(ctx: Context, spec: dict) -> tuple[float, str]:
222
+ """A component matching ``via`` bridges ``from`` and ``to`` (e.g. CC1 -> 5.1k -> GND)."""
223
+ a, b = ctx.endpoint_nets(spec["from"]), ctx.endpoint_nets(spec["to"])
224
+ if not a or not b:
225
+ return 0.0, f"endpoint not found ({_describe(spec['from'])} / {_describe(spec['to'])})"
226
+ via = ctx.selector(spec["via"])
227
+ hits = _two_sided(ctx, via, a, b)
228
+ need = int(spec.get("min", 1))
229
+ return min(1.0, len(hits) / need), (", ".join(hits) if hits else "no bridging part")
230
+
231
+
232
+ def check_path(ctx: Context, spec: dict) -> tuple[float, str]:
233
+ """A chain of at most ``max_parts`` components links ``from`` to ``to``.
234
+
235
+ ``allow`` restricts which components may be traversed (default: passives
236
+ and diodes); every selector in ``through`` must appear on the chain.
237
+ """
238
+ starts, goals = ctx.endpoint_nets(spec["from"]), ctx.endpoint_nets(spec["to"])
239
+ if not starts or not goals:
240
+ return 0.0, "endpoint not found"
241
+ allow = ctx.selector(spec.get("allow", {"kind": PASSIVE_KINDS}))
242
+ through = [ctx.selector(t) for t in spec.get("through", [])]
243
+ max_parts = int(spec.get("max_parts", 3))
244
+ if not through and starts & goals:
245
+ return 1.0, "same net: " + ", ".join(sorted(starts & goals))
246
+ comps = ctx.candidates(allow)
247
+ comp_nets = {c.ref: {n for _, n in ctx.netlist.pins_of(c.ref)} for c in comps}
248
+ marks = {c.ref: frozenset(i for i, t in enumerate(through) if t.accepts(c)) for c in comps}
249
+ full = frozenset(range(len(through)))
250
+ queue = deque((n, frozenset(), (), frozenset()) for n in starts)
251
+ while queue:
252
+ net, done, chain, used = queue.popleft()
253
+ if len(chain) >= max_parts:
254
+ continue
255
+ for ref, nets in comp_nets.items():
256
+ if ref in used or net not in nets:
257
+ continue
258
+ got = done | marks[ref]
259
+ for nxt in nets - {net}:
260
+ if nxt in goals and got == full:
261
+ return 1.0, " -> ".join([*chain, ref])
262
+ queue.append((nxt, got, (*chain, ref), used | {ref}))
263
+ return 0.0, "no path"
264
+
265
+
266
+ def check_preserve(ctx: Context, spec: dict) -> tuple[float, str]:
267
+ """Edit tasks: the original connectivity survives (node sets, net names may change)."""
268
+ if ctx.base is None:
269
+ return 0.0, "no base project"
270
+ ignore_refs = set(spec.get("ignore_refs", []))
271
+ ignore_nets = re.compile(spec["ignore_nets"], re.I) if spec.get("ignore_nets") else None
272
+ base = {
273
+ n: s
274
+ for n, s in ctx.base.node_sets(ignore_refs).items()
275
+ if not (ignore_nets and ignore_nets.fullmatch(n))
276
+ }
277
+ now = set(ctx.netlist.node_sets(ignore_refs).values())
278
+ kept = [n for n, s in base.items() if s in now]
279
+ lost = sorted(set(base) - set(kept))
280
+ score = len(kept) / len(base) if base else 1.0
281
+ return score, f"{len(kept)}/{len(base)} nets kept" + (
282
+ f"; changed: {', '.join(lost[:8])}" if lost else ""
283
+ )
284
+
285
+
286
+ def check_matched_length(ctx: Context, spec: dict) -> tuple[float, str]:
287
+ """A routed differential pair: both nets have copper and their length skew is small."""
288
+ if ctx.board is None:
289
+ return 0.0, "no board"
290
+ nets = []
291
+ for end in spec["nets"]:
292
+ found = sorted(ctx.endpoint_nets(end))
293
+ if len(found) != 1:
294
+ return 0.0, f"{_describe(end)} -> {found or 'no net'}"
295
+ nets.append(found[0])
296
+ lengths = [ctx.board.track_length.get(n, 0.0) for n in nets]
297
+ if min(lengths) <= 0:
298
+ return 0.0, f"unrouted: {dict(zip(nets, lengths, strict=True))}"
299
+ skew = max(lengths) - min(lengths)
300
+ limit = float(spec.get("max_skew_mm", 1.0))
301
+ detail = f"{nets[0]} {lengths[0]:.2f} mm / {nets[1]} {lengths[1]:.2f} mm, skew {skew:.2f} mm"
302
+ return (1.0 if skew <= limit else 0.5), detail
303
+
304
+
305
+ def check_count(ctx: Context, spec: dict) -> tuple[float, str]:
306
+ """Component count of a kind, e.g. at least 4 mounting holes."""
307
+ return check_part(ctx, spec)
308
+
309
+
310
+ CHECKS = {
311
+ "part": check_part,
312
+ "count": check_count,
313
+ "connected": check_connected,
314
+ "separate": check_separate,
315
+ "through": check_through,
316
+ "path": check_path,
317
+ "preserve": check_preserve,
318
+ "matched_length": check_matched_length,
319
+ }
320
+
321
+
322
+ def evaluate(ctx: Context, checks: list[dict]) -> list[Result]:
323
+ results = []
324
+ for i, spec in enumerate(checks):
325
+ kind = spec.get("type", "part")
326
+ label = spec.get("label") or f"{kind} {i + 1}"
327
+ try:
328
+ score, detail = CHECKS[kind](ctx, spec)
329
+ except Exception as exc: # a broken project must not crash the scorer
330
+ score, detail = 0.0, f"error: {type(exc).__name__}: {exc}"
331
+ results.append(
332
+ Result(
333
+ spec.get("id", f"r{i + 1}"), label, score, detail, float(spec.get("weight", 1.0))
334
+ )
335
+ )
336
+ return results
337
+
338
+
339
+ # --------------------------------------------------------------- board checks
340
+
341
+
342
+ def board_checks(board: Board | None, spec: dict) -> list[Result]:
343
+ out: list[Result] = []
344
+ if not spec:
345
+ return out
346
+ if board is None:
347
+ return [Result("board", "board exists", 0.0, "no .kicad_pcb")]
348
+ size = board.size
349
+ if "max_width" in spec or "max_height" in spec:
350
+ limit = sorted([float(spec.get("max_width", 1e9)), float(spec.get("max_height", 1e9))])
351
+ if size is None:
352
+ out.append(Result("size", "board size", 0.0, "no Edge.Cuts outline"))
353
+ else:
354
+ ok = all(s <= lim + 0.1 for s, lim in zip(sorted(size), limit, strict=True))
355
+ out.append(
356
+ Result(
357
+ "size",
358
+ f"board within {spec.get('max_width')} x {spec.get('max_height')} mm",
359
+ float(ok),
360
+ f"{size[0]} x {size[1]} mm",
361
+ )
362
+ )
363
+ if "layers" in spec:
364
+ got = len(board.copper_layers)
365
+ out.append(
366
+ Result(
367
+ "layers",
368
+ f"{spec['layers']} copper layers",
369
+ float(got == int(spec["layers"])),
370
+ f"{got} layers",
371
+ )
372
+ )
373
+ return out
374
+
375
+
376
+ def edge_checks(ctx: Context, roles: list[Any], max_inset_mm: float = 3.0) -> list[Result]:
377
+ out = []
378
+ for role in roles:
379
+ label = f"{role if isinstance(role, str) else _describe(role)} on board edge"
380
+ if ctx.board is None:
381
+ out.append(Result("edge", label, 0.0, "no board"))
382
+ continue
383
+ refs = {c.ref for c in ctx.candidates(role)}
384
+ fps = [fp for fp in ctx.board.footprints if fp.ref in refs]
385
+ if not fps:
386
+ out.append(Result("edge", label, 0.0, "connector not on the board"))
387
+ continue
388
+ dist = {fp.ref: edge_distance(ctx.board, fp) for fp in fps}
389
+ on_edge = [r for r, d in dist.items() if d is not None and d <= max_inset_mm]
390
+ out.append(
391
+ Result(
392
+ "edge",
393
+ label,
394
+ len(on_edge) / len(fps),
395
+ ", ".join(f"{r}: {d} mm" for r, d in dist.items()),
396
+ )
397
+ )
398
+ return out
399
+
400
+
401
+ def refdes_check(board: Board | None) -> Result:
402
+ if board is None:
403
+ return Result("refdes", "reference designators visible", 0.0, "no board")
404
+ fps = [fp for fp in board.footprints if fp.ref and not fp.ref.startswith("#") and fp.pads]
405
+ hidden = [fp.ref for fp in fps if not fp.ref_visible]
406
+ score = 1 - len(hidden) / len(fps) if fps else 0.0
407
+ detail = f"{len(fps) - len(hidden)}/{len(fps)} visible on silkscreen"
408
+ if hidden:
409
+ detail += "; hidden: " + ", ".join(sorted(hidden)[:10])
410
+ return Result("refdes", "reference designators visible", score, detail)
@@ -0,0 +1,121 @@
1
+ """``traceforge bench``: list, prepare, run and score benchmark tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from traceforge.bench.tasks import default_root, load_tasks
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(
15
+ prog="traceforge bench", description="Open benchmark for PCB design agents"
16
+ )
17
+ p.add_argument("--root", type=Path, help="benchmark folder (default: benchmarks/)")
18
+ sub = p.add_subparsers(dest="action", required=True)
19
+
20
+ ls = sub.add_parser("list", help="list tasks")
21
+ ls.add_argument("select", nargs="*", help="task ids, id prefixes, tiers or kinds")
22
+
23
+ prep = sub.add_parser(
24
+ "prepare", help="(re)generate planted review/edit projects from their specs"
25
+ )
26
+ prep.add_argument("select", nargs="*")
27
+
28
+ run = sub.add_parser("run", help="run tasks against an agent and score them")
29
+ run.add_argument("select", nargs="*")
30
+ run.add_argument("--agent", default="traceforge", help="traceforge | peer | command")
31
+ run.add_argument(
32
+ "--cmd", help="for --agent command: CLI with {workspace} and {prompt} placeholders"
33
+ )
34
+ run.add_argument("--model", help="model for the traceforge adapter")
35
+ run.add_argument("--exe", help="executable of the peer agent CLI")
36
+ run.add_argument(
37
+ "--agent-arg", action="append", default=[], help="extra argument for the agent CLI"
38
+ )
39
+ run.add_argument(
40
+ "--out", type=Path, help="results folder (default: bench-results/<agent>-<time>)"
41
+ )
42
+ run.add_argument("--timeout", type=float, default=3600, help="seconds per task")
43
+ run.add_argument(
44
+ "--prices", type=Path, help="price table YAML (default: benchmarks/prices.yaml)"
45
+ )
46
+ run.add_argument("--offline", action="store_true", help="skip LCSC verification")
47
+
48
+ sc = sub.add_parser("score", help="score an existing project folder for one task")
49
+ sc.add_argument("task")
50
+ sc.add_argument("workspace", type=Path)
51
+ sc.add_argument("--answer", type=Path, help="file with the agent's final answer (review tasks)")
52
+ sc.add_argument("--out", type=Path, help="write the result JSON here")
53
+ sc.add_argument("--offline", action="store_true")
54
+ return p
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ args = build_parser().parse_args(argv)
59
+ root = args.root or default_root()
60
+ tasks = load_tasks(root, getattr(args, "select", None) or None)
61
+ if args.action == "list":
62
+ for t in tasks:
63
+ print(f"{t.id:<34} {t.tier:<7} {t.kind:<7} {t.title}")
64
+ print(f"{len(tasks)} tasks")
65
+ return 0
66
+ if args.action == "prepare":
67
+ from traceforge.bench.review import build_planted
68
+
69
+ for t in tasks:
70
+ if t.planted and t.setup_project:
71
+ result = build_planted(root, t.planted, t.setup_project)
72
+ print(f"{t.id}: {'ok' if result['ok'] else 'issues'} -> {t.setup_project}")
73
+ return 0
74
+ if args.action == "score":
75
+ from traceforge.bench.score import score_project
76
+
77
+ match = [t for t in tasks if t.id == args.task] or [
78
+ t for t in tasks if t.id.startswith(args.task)
79
+ ]
80
+ if len(match) != 1:
81
+ print(f"task {args.task!r} matches {len(match)} tasks", file=sys.stderr)
82
+ return 2
83
+ answer = args.answer.read_text(encoding="utf-8") if args.answer else ""
84
+ if args.out:
85
+ work = args.out.parent / f"{args.out.stem}.files"
86
+ else:
87
+ import tempfile
88
+
89
+ work = Path(tempfile.mkdtemp(prefix=f"bench-{match[0].id}-"))
90
+ result = score_project(
91
+ match[0], args.workspace.resolve(), answer=answer, work_dir=work, offline=args.offline
92
+ )
93
+ text = json.dumps(result, indent=1, default=str)
94
+ if args.out:
95
+ args.out.write_text(text, encoding="utf-8")
96
+ print(text if not args.out else f"{match[0].id}: {result['score']} -> {args.out}")
97
+ return 0
98
+ from datetime import datetime
99
+
100
+ from traceforge.bench.adapters import make_adapter
101
+ from traceforge.bench.runner import run_suite
102
+
103
+ adapter = make_adapter(
104
+ args.agent,
105
+ command=args.cmd,
106
+ model=args.model,
107
+ executable=args.exe,
108
+ extra_args=args.agent_arg,
109
+ )
110
+ out = args.out or Path("bench-results") / f"{adapter.name}-{datetime.now():%Y%m%d-%H%M%S}"
111
+ suite = run_suite(
112
+ tasks,
113
+ adapter,
114
+ out.resolve(),
115
+ timeout=args.timeout,
116
+ prices_path=args.prices or root / "prices.yaml",
117
+ offline=args.offline,
118
+ progress=lambda msg: print(msg, file=sys.stderr),
119
+ )
120
+ print((out / "results.md").read_text(encoding="utf-8"))
121
+ return 0 if suite["results"] else 1