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,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
|
+
}
|