traceforge-pcb 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|
traceforge/bench/cli.py
ADDED
|
@@ -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
|