ecadlib 0.3.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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/eval/runner.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Run the real pipeline on an accuracy test set and score every part (`ecadlib eval run` / `compare`).
|
|
2
|
+
|
|
3
|
+
ecadlib eval run [--set kicad-v1] [--limit N] [--family qfn,sot] [--model M] [--record DIR | --replay DIR] -o out/eval/x
|
|
4
|
+
ecadlib eval compare out/eval/a out/eval/b
|
|
5
|
+
|
|
6
|
+
run: the set's cases become one BOM (MPN, manufacturer, the package a BOM would name, datasheet link) and go through
|
|
7
|
+
librarian.run with the sources you choose (default: the rules' [sources] order without `kicad`, because KiCad's library
|
|
8
|
+
is the ground truth here and would answer its own questions). Whatever source resolves a part, its .kicad_mod and
|
|
9
|
+
.SchLib are scored against the reference (ecadlib/eval/score.py). Reported: pass rate overall, per family and per
|
|
10
|
+
source; tokens and USD per part (the API usage block of the message that answered: live, cached or replayed; else
|
|
11
|
+
datasheet's own estimate, which is rough without a key); wall time. Claude modes:
|
|
12
|
+
live ANTHROPIC_API_KEY set: one synchronous call per part that reaches the datasheet source (cached in <out>)
|
|
13
|
+
--record D live, and every answer is also saved to D/<MPN>.json (message + what was sent)
|
|
14
|
+
--replay D no API at all: answers come from D; a part without a recording is reported unresolved
|
|
15
|
+
estimate no key and no --replay: nothing is sent; those parts are unresolved, with estimated tokens / USD
|
|
16
|
+
Outputs: <out>/report.json, <out>/report.md, <out>/lib (the librarian's output: plan.json, review.md, models).
|
|
17
|
+
compare: pass rate and cost per family side by side, and the cases that flipped (pass -> fail, fail -> pass)."""
|
|
18
|
+
import csv, datetime, hashlib, json, re, time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .. import altium_lib as AL, datasheet as D, footprint_bench as FB, librarian as L, rules as RU
|
|
22
|
+
from . import score as S
|
|
23
|
+
|
|
24
|
+
SETS = Path(__file__).with_name("sets")
|
|
25
|
+
BOM_HEAD = ["Designator", "Qty", "Value", "Footprint", "Manufacturer", "Mfr. Part #", "Description", "Datasheet"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_path(name):
|
|
29
|
+
p = Path(name)
|
|
30
|
+
if p.suffix == ".json" and p.exists():
|
|
31
|
+
return p
|
|
32
|
+
f = SETS / f"{name}.json"
|
|
33
|
+
if not f.exists():
|
|
34
|
+
raise FileNotFoundError(f"no test set {name!r}; available: {', '.join(available()) or 'none'}")
|
|
35
|
+
return f
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def available():
|
|
39
|
+
return sorted(f.stem for f in SETS.glob("*.json"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_set(name):
|
|
43
|
+
return json.loads(set_path(name).read_text(encoding="utf-8"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def select(cases, families=None, ids=None, limit=None):
|
|
47
|
+
"""Filter, then interleave families round-robin so --limit N still covers every family."""
|
|
48
|
+
cs = [c for c in cases if (not families or c["family"] in families) and (not ids or c["id"] in ids or c["mpn"] in ids)]
|
|
49
|
+
by = {}
|
|
50
|
+
for c in cs:
|
|
51
|
+
by.setdefault(c["family"], []).append(c)
|
|
52
|
+
out, i = [], 0
|
|
53
|
+
while any(i < len(v) for v in by.values()):
|
|
54
|
+
out += [v[i] for v in by.values() if i < len(v)]
|
|
55
|
+
i += 1
|
|
56
|
+
return out[:limit] if limit else out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Designator class by the reference footprint's library, as a real BOM would have it: triage sorts parts by it, and
|
|
60
|
+
# a chip capacitor on a U line is not a capacitor to any source. Everything else stays U.
|
|
61
|
+
DESIGNATOR = {"Resistor_SMD": "R", "Capacitor_SMD": "C", "Inductor_SMD": "L", "LED_SMD": "D", "Diode_SMD": "D"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_bom(cases, path):
|
|
65
|
+
path = Path(path)
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
68
|
+
w = csv.writer(f)
|
|
69
|
+
w.writerow(BOM_HEAD)
|
|
70
|
+
for i, c in enumerate(cases, 1):
|
|
71
|
+
w.writerow([f"{DESIGNATOR.get(c['footprint'].split(':')[0], 'U')}{i}", 1, c["mpn"], c["package"], c["manufacturer"], c["mpn"], c["description"], c["datasheet"]])
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def usd(model, usage):
|
|
76
|
+
"""USD of one message from its usage block (synchronous rates): ds_route.usage_usd, which also knows the cache-read
|
|
77
|
+
prices and Claude Haiku 5.5's dearer tier above 100K prompt tokens."""
|
|
78
|
+
if not usage:
|
|
79
|
+
return None
|
|
80
|
+
from .. import ds_route
|
|
81
|
+
return ds_route.usage_usd(model, usage)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _safe(s):
|
|
85
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", str(s))[:100] or "part"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EvalReader(D.Reader):
|
|
89
|
+
"""datasheet.Reader that remembers what each part cost, and can record answers or replay them."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, *a, record=None, replay=None, **kw):
|
|
92
|
+
super().__init__(*a, **kw)
|
|
93
|
+
self.record, self.replay = (Path(record) if record else None), (Path(replay) if replay else None)
|
|
94
|
+
self.results = D.ResultCache(self.out / "cache") # per eval run: no answer leaks in from elsewhere
|
|
95
|
+
self.used = {}
|
|
96
|
+
if self.replay:
|
|
97
|
+
self.key, self.estimate_only = "replay (never sent)", False
|
|
98
|
+
elif not self.key:
|
|
99
|
+
self.estimate_only = True
|
|
100
|
+
|
|
101
|
+
def _estimate(self, job):
|
|
102
|
+
k, self.key = self.key, None # datasheet's own estimate, without count_tokens
|
|
103
|
+
try:
|
|
104
|
+
r = self.estimate([job], batch=False)["rows"][0]
|
|
105
|
+
finally:
|
|
106
|
+
self.key = k
|
|
107
|
+
return {"input_tokens": r["input_tokens"], "output_tokens": r["output_tokens"], "usd": r["usd"], "how": r["how"]}
|
|
108
|
+
|
|
109
|
+
def job(self, part, urls, package=None, pages=None, name=None, fp_needed=None):
|
|
110
|
+
if self.replay:
|
|
111
|
+
f = self.replay / f"{_safe(part)}.json"
|
|
112
|
+
if not f.exists():
|
|
113
|
+
raise D.DatasheetError(f"no recorded response for {part} in {self.replay}")
|
|
114
|
+
rec = json.loads(f.read_text(encoding="utf-8"))
|
|
115
|
+
j = rec["job"]
|
|
116
|
+
job = {"key": f"replay-{_safe(part)}", "name": name or part, "part": part, "package": package, "url": j["url"],
|
|
117
|
+
"where": j["where"], "offset": j["offset"], "headers": {}, "body": {"max_tokens": D.MAX_TOKENS},
|
|
118
|
+
"prov": j["prov"], "recording": rec, "fp_needed": fp_needed}
|
|
119
|
+
else:
|
|
120
|
+
job = super().job(part, urls, package, pages, name, fp_needed)
|
|
121
|
+
job["estimate"] = (job.get("recording") or {}).get("estimate") or self._estimate(job)
|
|
122
|
+
self.used.setdefault(part, {"estimate": job["estimate"]})
|
|
123
|
+
return job
|
|
124
|
+
|
|
125
|
+
def cached(self, job):
|
|
126
|
+
rec = job.get("recording")
|
|
127
|
+
if rec:
|
|
128
|
+
return self._result(job, {"message": rec["message"], "model": rec.get("model"), "via": "replay",
|
|
129
|
+
"page_offset": job["offset"]})
|
|
130
|
+
res = super().cached(job)
|
|
131
|
+
if res is not None:
|
|
132
|
+
self.used[job["part"]]["via"] = "cached"
|
|
133
|
+
return res
|
|
134
|
+
|
|
135
|
+
def check_message(self, job, model, headers, body, key, pages_sent=None):
|
|
136
|
+
"""The cross-check's second read: replayed from the recording (never sent), or live and added to it."""
|
|
137
|
+
from .. import crosscheck as CC
|
|
138
|
+
rec = job.get("recording")
|
|
139
|
+
if self.replay:
|
|
140
|
+
got = (rec or {}).get("check")
|
|
141
|
+
if not got:
|
|
142
|
+
raise CC.Unavailable("no recorded second read")
|
|
143
|
+
msg, via = got["message"], "replay"
|
|
144
|
+
else:
|
|
145
|
+
msg, via = super().check_message(job, model, headers, body, key, pages_sent)
|
|
146
|
+
from .. import ds_route as DR
|
|
147
|
+
self.used.setdefault(job["part"], {})["check"] = {"model": DR.billed_model(model, msg), "usage": msg.get("usage")}
|
|
148
|
+
if self.replay:
|
|
149
|
+
return msg, via
|
|
150
|
+
if self.record and not rec:
|
|
151
|
+
f = self.record / f"{_safe(job['part'])}.json"
|
|
152
|
+
if f.exists():
|
|
153
|
+
r = json.loads(f.read_text(encoding="utf-8"))
|
|
154
|
+
r["check"] = {"model": model, "message": msg}
|
|
155
|
+
f.write_text(json.dumps(r, indent=1, default=str), encoding="utf-8")
|
|
156
|
+
return msg, via
|
|
157
|
+
|
|
158
|
+
def read_now(self, job, _guarded=False):
|
|
159
|
+
if self.replay:
|
|
160
|
+
raise D.DatasheetError(f"no recorded response for {job['part']}")
|
|
161
|
+
t0 = time.time()
|
|
162
|
+
try:
|
|
163
|
+
return super().read_now(job, _guarded)
|
|
164
|
+
finally:
|
|
165
|
+
self.used[job["part"]]["api_s"] = round(time.time() - t0, 2)
|
|
166
|
+
|
|
167
|
+
def _result(self, job, rec):
|
|
168
|
+
msg = rec["message"]
|
|
169
|
+
u = self.used.setdefault(job["part"], {"estimate": job.get("estimate")})
|
|
170
|
+
u.setdefault("via", rec.get("via") or "live")
|
|
171
|
+
from .. import ds_route as DR # priced as the served model when its price is known
|
|
172
|
+
u.update(usage=msg.get("usage"), model=DR.billed_model(rec.get("model") or job.get("model") or self.model, msg))
|
|
173
|
+
# every answer the part's route paid for (cheaper / fewer-page tries first, this one last): all count in the cost
|
|
174
|
+
atts = (job["recording"].get("attempts") if job.get("recording") else
|
|
175
|
+
[{"model": a.get("served") or a.get("model"), "usage": a.get("usage")} for a in (job.get("route") or {}).get("attempts") or []])
|
|
176
|
+
u["attempts"] = atts or []
|
|
177
|
+
if self.record and not job.get("recording"):
|
|
178
|
+
self.record.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
j = {k: job[k] for k in ("url", "where", "offset", "prov")}
|
|
180
|
+
(self.record / f"{_safe(job['part'])}.json").write_text(json.dumps(
|
|
181
|
+
{"part": job["part"], "model": rec.get("model") or self.model, "recorded": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
182
|
+
"job": j, "estimate": job.get("estimate"), "message": msg, "attempts": u["attempts"]}, indent=1, default=str), encoding="utf-8")
|
|
183
|
+
return super()._result(job, rec)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _symbol_pins(path):
|
|
187
|
+
try:
|
|
188
|
+
_, syms = AL.read_schlib(path)
|
|
189
|
+
except (OSError, ValueError):
|
|
190
|
+
return None
|
|
191
|
+
pins = {}
|
|
192
|
+
for s in syms:
|
|
193
|
+
for r in s["records"]:
|
|
194
|
+
if r.get("RECORD") == "2" and "num" in r:
|
|
195
|
+
pins.setdefault(r["num"], r["name"])
|
|
196
|
+
return list(pins.items())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _cost(case_used, model):
|
|
200
|
+
"""-> tokens_in, tokens_out, usd, how."""
|
|
201
|
+
if not case_used:
|
|
202
|
+
return 0, 0, 0.0, ""
|
|
203
|
+
u, est = case_used.get("usage"), case_used.get("estimate") or {}
|
|
204
|
+
if u:
|
|
205
|
+
tin = u.get("input_tokens", 0) + u.get("cache_creation_input_tokens", 0) + u.get("cache_read_input_tokens", 0)
|
|
206
|
+
tout, cost = u.get("output_tokens", 0), usd(case_used.get("model") or model, u)
|
|
207
|
+
how = {"replay": "recorded usage (replay)", "cached": "recorded usage (cached)"}.get(case_used.get("via"), "API usage")
|
|
208
|
+
earlier = (case_used.get("attempts") or [])[:-1] # answers before the kept one (route escalations)
|
|
209
|
+
for a in earlier:
|
|
210
|
+
au = a.get("usage") or {}
|
|
211
|
+
tin += au.get("input_tokens", 0) + au.get("cache_creation_input_tokens", 0) + au.get("cache_read_input_tokens", 0)
|
|
212
|
+
tout += au.get("output_tokens", 0)
|
|
213
|
+
c = usd(a.get("model") or model, au) if au else None
|
|
214
|
+
cost = None if cost is None or c is None else round(cost + c, 6)
|
|
215
|
+
if earlier:
|
|
216
|
+
how += f" + {len(earlier)} earlier answer{'s' if len(earlier) > 1 else ''}"
|
|
217
|
+
ck = case_used.get("check") or {}
|
|
218
|
+
if ck.get("usage"): # the cross-check's second read (ecadlib.crosscheck)
|
|
219
|
+
cu = ck["usage"]
|
|
220
|
+
tin += cu.get("input_tokens", 0) + cu.get("cache_creation_input_tokens", 0) + cu.get("cache_read_input_tokens", 0)
|
|
221
|
+
tout += cu.get("output_tokens", 0)
|
|
222
|
+
c = usd(ck.get("model") or model, cu)
|
|
223
|
+
cost = None if cost is None or c is None else round(cost + c, 6)
|
|
224
|
+
how += " + cross-check read"
|
|
225
|
+
return tin, tout, cost, how
|
|
226
|
+
return est.get("input_tokens", 0), est.get("output_tokens", 0), est.get("usd"), f"estimate ({est.get('how', '')})"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def summarize(rows):
|
|
230
|
+
def agg(rs):
|
|
231
|
+
n = len(rs)
|
|
232
|
+
p = sum(r["pass"] for r in rs)
|
|
233
|
+
cost = [r["usd"] for r in rs if r["usd"]]
|
|
234
|
+
return {"cases": n, "pass": p, "rate": round(p / n, 4) if n else None,
|
|
235
|
+
"tokens_in": sum(r["tokens_in"] for r in rs), "tokens_out": sum(r["tokens_out"] for r in rs),
|
|
236
|
+
"usd": round(sum(cost), 4), "usd_per_part": round(sum(cost) / n, 5) if n else None,
|
|
237
|
+
"sources": dict(sorted({s: sum(r["source"] == s for r in rs) for s in {r["source"] for r in rs}}.items()))}
|
|
238
|
+
fams = {}
|
|
239
|
+
for r in rows:
|
|
240
|
+
fams.setdefault(r["family"], []).append(r)
|
|
241
|
+
srcs = {}
|
|
242
|
+
for r in rows:
|
|
243
|
+
srcs.setdefault(r["source"], []).append(r)
|
|
244
|
+
fields = {}
|
|
245
|
+
for r in rows:
|
|
246
|
+
for k, f in r["fields"].items():
|
|
247
|
+
fields.setdefault(k, [0, 0])
|
|
248
|
+
fields[k][0] += f["ok"]
|
|
249
|
+
fields[k][1] += 1
|
|
250
|
+
shared = [r for r in rows if r.get("footprint_shared_with")]
|
|
251
|
+
cc = {}
|
|
252
|
+
for r in rows:
|
|
253
|
+
if r.get("cross_check"):
|
|
254
|
+
cc[r["cross_check"]] = cc.get(r["cross_check"], 0) + 1
|
|
255
|
+
return {"cross_check": dict(sorted(cc.items())),"overall": agg(rows), "shared_footprints": {"cases": len(shared), "failing": sum(not r["pass"] for r in shared)}, "families": {k: agg(v) for k, v in sorted(fams.items())},
|
|
256
|
+
"by_source": {k: agg(v) for k, v in sorted(srcs.items())},
|
|
257
|
+
"fields": {k: {"ok": a, "of": b} for k, (a, b) in sorted(fields.items())}}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run(set_name="kicad-v1", out="out/eval/run", limit=None, model=None, record=None, replay=None, sources=None,
|
|
261
|
+
families=None, ids=None, offline=False, cache="out/http_cache", http=None, api_key=None, rules=None, providers=None,
|
|
262
|
+
tol=None, log=print, provider=None, base_url=None, assist=None, api_key_env=None, repeat=1):
|
|
263
|
+
"""repeat: run the same cases again (into <out>/repeat-2 ...) and report how many parts gave the same files
|
|
264
|
+
(summary["consistency"]): from the recorded answers when there are any (--record / --replay: the code must be
|
|
265
|
+
deterministic), else live again (the model's own consistency; this pays again).
|
|
266
|
+
provider / base_url / assist / api_key_env: the [ai] settings for this run (ecadlib.llm), so models from any
|
|
267
|
+
provider can be compared on the same set: e.g. provider="ollama", model="qwen2.5:7b" against the Claude default."""
|
|
268
|
+
data = load_set(set_name)
|
|
269
|
+
cases = select(data["cases"], families, ids, limit)
|
|
270
|
+
if not cases:
|
|
271
|
+
raise ValueError("no cases selected")
|
|
272
|
+
out = Path(out)
|
|
273
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
lib = out / "lib"
|
|
275
|
+
rules = RU.resolve(rules, out)
|
|
276
|
+
over = {k: v for k, v in (("provider", provider), ("base_url", base_url), ("assist", assist),
|
|
277
|
+
("api_key_env", api_key_env)) if v}
|
|
278
|
+
if over or (model and provider):
|
|
279
|
+
rules = dict(rules, ai={**rules.get("ai", {}), **over, **({"model": model} if model else {})},
|
|
280
|
+
datasheet={**rules.get("datasheet", {}), **({k: "" for k in ("provider", "base_url", "assist")} if over else {}),
|
|
281
|
+
**({"api_key_env": ""} if provider and provider != "anthropic" else {})})
|
|
282
|
+
RU.validate({k: v for k, v in rules.items() if k in ("ai",)}, "eval --provider/--model")
|
|
283
|
+
sources = list(sources) if sources else [s for s in rules["sources"]["order"] if s != "kicad"]
|
|
284
|
+
if "kicad" in sources:
|
|
285
|
+
log(" note: `kicad` is the ground truth of this set; parts it resolves are scored against themselves")
|
|
286
|
+
bom = write_bom(cases, out / "bom.csv")
|
|
287
|
+
if http is None:
|
|
288
|
+
from ..httpcache import CachedHTTP
|
|
289
|
+
http = CachedHTTP(cache, min_interval=0.5, offline=offline, timeout=60)
|
|
290
|
+
reader = EvalReader(lib, cache, api_key, rules, http, model=model, record=record, replay=replay, log=lambda *a: None)
|
|
291
|
+
mode = "replay" if replay else "estimate (no API key: nothing sent)" if reader.estimate_only else \
|
|
292
|
+
"live, recording" if record else "live"
|
|
293
|
+
inf = reader.info(reader.model)
|
|
294
|
+
log(f" eval {data['meta']['name']}: {len(cases)} cases, sources {','.join(sources)}, model {reader.model} "
|
|
295
|
+
f"({inf['ep']['name']}, {inf['plan']['tier']} tier, assist {inf['plan']['assist']}), {mode}")
|
|
296
|
+
t0 = time.time()
|
|
297
|
+
items = L.run(bom, [], lib, sources=sources, refresh=[], offline=offline, cache=cache, http=http, api_key=api_key,
|
|
298
|
+
log=lambda *a: None, gen_step=False, rules=rules, reader=reader, providers=providers)
|
|
299
|
+
wall = time.time() - t0
|
|
300
|
+
by_mpn = {}
|
|
301
|
+
for it in items:
|
|
302
|
+
mpn = next((p["value"] for p in it.get("parameters", []) if p["name"] == "Manufacturer Part Number"), it["name"])
|
|
303
|
+
by_mpn[mpn] = it
|
|
304
|
+
rows = []
|
|
305
|
+
for c in cases:
|
|
306
|
+
it = by_mpn.get(c["mpn"]) or {}
|
|
307
|
+
fp = (it.get("footprint") or {}).get("kicad_mod")
|
|
308
|
+
mine = FB.numbers((lib / fp).read_text(encoding="utf-8")) if fp and (lib / fp).exists() else None
|
|
309
|
+
sf = (it.get("symbol") or {}).get("file")
|
|
310
|
+
pins = _symbol_pins(lib / sf) if sf and (lib / sf).exists() else None
|
|
311
|
+
sc = S.score(mine, data["references"][c["footprint"]], pins, c.get("pins"), tol)
|
|
312
|
+
tin, tout, cost, how = _cost(reader.used.get(c["mpn"]), reader.model)
|
|
313
|
+
src = ((it.get("source") or {}).get("footprint") or "none").split(":")[0]
|
|
314
|
+
shared = next((n[len("shared with "):] for n in (it.get("librarian") or {}).get("notes", []) if n.startswith("shared with ")), "")
|
|
315
|
+
files = {} # what this run wrote for the part (consistency between runs)
|
|
316
|
+
for kind, rel in (("kicad_mod", fp), ("pcblib", (it.get("footprint") or {}).get("file")), ("schlib", sf)):
|
|
317
|
+
if rel and (lib / rel).exists():
|
|
318
|
+
files[kind] = file_fingerprint(lib / rel)
|
|
319
|
+
rows.append({"id": c["id"], "family": c["family"], "mpn": c["mpn"], "footprint": c["footprint"], "pass": sc["pass"],
|
|
320
|
+
"files": files, "cross_check": (it.get("cross_check") or {}).get("status", ""),
|
|
321
|
+
"source": src, "symbol_source": ((it.get("source") or {}).get("symbol") or "none").split(":")[0],
|
|
322
|
+
"errors": sc["errors"], "fields": sc["fields"], "tokens_in": tin, "tokens_out": tout, "usd": cost,
|
|
323
|
+
"cost_how": how, "footprint_shared_with": shared, "api_s": (reader.used.get(c["mpn"]) or {}).get("api_s"),
|
|
324
|
+
"tried": ((it.get("librarian") or {}).get("tried") or [])})
|
|
325
|
+
rep = {"meta": {"set": data["meta"]["name"], "set_version": data["meta"].get("version"), "set_built": data["meta"].get("built"),
|
|
326
|
+
"model": reader.model, "provider": inf["ep"]["name"], "tier": inf["plan"]["tier"],
|
|
327
|
+
"rails": inf["plan"]["rails"], "sources": sources, "mode": mode, "record": str(record or ""), "replay": str(replay or ""),
|
|
328
|
+
"tolerances": dict(S.TOL, **(tol or {})), "cases": len(rows),
|
|
329
|
+
"started": datetime.datetime.fromtimestamp(t0).isoformat(timespec="seconds"),
|
|
330
|
+
"wall_s": round(wall, 1), "wall_s_per_part": round(wall / len(rows), 3)},
|
|
331
|
+
"summary": summarize(rows), "cases": rows}
|
|
332
|
+
for i in range(2, max(1, int(repeat or 1)) + 1):
|
|
333
|
+
again = run(set_name, out / f"repeat-{i}", limit, model, None, replay or record, sources, families, ids, offline, cache,
|
|
334
|
+
http, api_key, rules, providers, tol, log, provider, base_url, assist, api_key_env)
|
|
335
|
+
c = consistency(rep, again)
|
|
336
|
+
prev = rep["summary"].get("consistency")
|
|
337
|
+
if prev is None or c["same"] < prev["same"]:
|
|
338
|
+
rep["summary"]["consistency"] = dict(c, runs=i)
|
|
339
|
+
(out / "report.json").write_text(json.dumps(rep, indent=1, default=str), encoding="utf-8")
|
|
340
|
+
(out / "report.md").write_text(report_md(rep), encoding="utf-8")
|
|
341
|
+
return rep
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def file_fingerprint(path):
|
|
345
|
+
"""sha256 (16 hex) of what a library file holds: a .kicad_mod's bytes; for .PcbLib / .SchLib the records read back
|
|
346
|
+
(altium_lib), so the write time in the Altium file header does not count as a different answer."""
|
|
347
|
+
p = Path(path)
|
|
348
|
+
try:
|
|
349
|
+
if p.suffix.lower() == ".pcblib":
|
|
350
|
+
data = json.dumps(AL.read_pcblib(p), sort_keys=True, default=str).encode()
|
|
351
|
+
elif p.suffix.lower() == ".schlib":
|
|
352
|
+
data = json.dumps(AL.read_schlib(p), sort_keys=True, default=str).encode()
|
|
353
|
+
else:
|
|
354
|
+
data = p.read_bytes()
|
|
355
|
+
except (OSError, ValueError):
|
|
356
|
+
data = p.read_bytes() if p.exists() else b""
|
|
357
|
+
return hashlib.sha256(data).hexdigest()[:16]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def consistency(a, b):
|
|
361
|
+
"""Two runs of the same cases (reports, dirs or report.json files) -> how many parts gave the same files:
|
|
362
|
+
{cases, same, rate, from, differ: [{id, files}]}. A part with no files in both runs counts as the same answer."""
|
|
363
|
+
A, B = (x if isinstance(x, dict) else _load_report(x) for x in (a, b))
|
|
364
|
+
cb = {r["id"]: r for r in B["cases"]}
|
|
365
|
+
differ, n = [], 0
|
|
366
|
+
for r in A["cases"]:
|
|
367
|
+
o = cb.get(r["id"])
|
|
368
|
+
if o is None:
|
|
369
|
+
continue
|
|
370
|
+
n += 1
|
|
371
|
+
fa, fb = r.get("files") or {}, o.get("files") or {}
|
|
372
|
+
bad = sorted(k for k in set(fa) | set(fb) if fa.get(k) != fb.get(k))
|
|
373
|
+
if bad or r.get("cross_check", "") != o.get("cross_check", ""):
|
|
374
|
+
differ.append({"id": r["id"], "files": bad, "cross_check": [r.get("cross_check", ""), o.get("cross_check", "")]})
|
|
375
|
+
same = n - len(differ)
|
|
376
|
+
return {"cases": n, "same": same, "rate": round(same / n, 4) if n else None, "differ": differ,
|
|
377
|
+
"from": "recorded answers" if B["meta"].get("mode") == "replay" else B["meta"].get("mode")}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _pct(x):
|
|
381
|
+
return "-" if x is None else f"{100 * x:.1f} %"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _usd(x):
|
|
385
|
+
return "-" if x is None else f"${x:.4f}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def report_md(rep):
|
|
389
|
+
"""Outcome first (lines 2-5 are what `ecadlib eval run` prints), then the wrong parts, then the breakdowns."""
|
|
390
|
+
m, s = rep["meta"], rep["summary"]
|
|
391
|
+
o = s["overall"]
|
|
392
|
+
bad = [r for r in rep["cases"] if not r["pass"]]
|
|
393
|
+
md = [f"# Accuracy: {m['set']} (v{m['set_version']})", "",
|
|
394
|
+
f"**Pass: {o['pass']}/{o['cases']} ({_pct(o['rate'])})**. "
|
|
395
|
+
+ (f"{len(bad)} {'part was' if len(bad) == 1 else 'parts were'} wrong: see \"Wrong parts\" below." if bad else "Every part was right."), "",
|
|
396
|
+
f"Model {m['model']}, sources {', '.join(m['sources'])}, mode {m['mode']}. {m['cases']} parts in {m['wall_s']} s "
|
|
397
|
+
f"({m['wall_s_per_part']} s per part); {o['tokens_in']} tokens in / {o['tokens_out']} out; "
|
|
398
|
+
f"{_usd(o['usd'])} in all, {_usd(o['usd_per_part'])} per part.", ""]
|
|
399
|
+
if bad:
|
|
400
|
+
md += [f"## Wrong parts ({len(bad)})", ""]
|
|
401
|
+
for r in bad:
|
|
402
|
+
why = "; ".join(r["errors"][:3]) if r["source"] != "none" else "no source had it: " + "; ".join(r["tried"][-2:])
|
|
403
|
+
sh = f" [footprint shared with {r['footprint_shared_with']}]" if r.get("footprint_shared_with") else ""
|
|
404
|
+
md.append(f"- **{r['id']}** (from {r['source']}){sh}: {why}")
|
|
405
|
+
md.append("")
|
|
406
|
+
md += ["## By package family", "", "| Family | Cases | Pass | Rate | Answered by | USD / part |", "|---|---|---|---|---|---|"]
|
|
407
|
+
for k, f in s["families"].items():
|
|
408
|
+
md.append(f"| {k} | {f['cases']} | {f['pass']} | {_pct(f['rate'])} | "
|
|
409
|
+
+ ", ".join(f"{a} {b}" for a, b in f["sources"].items()) + f" | {_usd(f['usd_per_part'])} |")
|
|
410
|
+
co = s.get("consistency")
|
|
411
|
+
if co:
|
|
412
|
+
md += ["", f"**Consistency:** {co['same']}/{co['cases']} parts gave the same files when run again ({co['from']}, "
|
|
413
|
+
f"{co['runs']} runs)" + (": differ " + ", ".join(d["id"] for d in co["differ"][:20]) if co["differ"] else ".")]
|
|
414
|
+
if s.get("cross_check"):
|
|
415
|
+
md += ["", "Cross-checks of datasheet reads: " + ", ".join(f"{k} {v}" for k, v in s["cross_check"].items()) + "."]
|
|
416
|
+
sh = s.get("shared_footprints") or {}
|
|
417
|
+
if sh.get("cases"):
|
|
418
|
+
md += ["", f"{sh['cases']} parts got the footprint file of another part with the same BOM footprint name "
|
|
419
|
+
f"(shared only when the pads are the same); {sh['failing']} of them fail."]
|
|
420
|
+
md += ["", "## By source", "", "| Source | Cases | Pass | Rate |", "|---|---|---|---|"]
|
|
421
|
+
md += [f"| {k} | {f['cases']} | {f['pass']} | {_pct(f['rate'])} |" for k, f in s["by_source"].items()]
|
|
422
|
+
md += ["", "## By field", "", "| Field | OK |", "|---|---|"] + [f"| {k} | {v['ok']}/{v['of']} |" for k, v in s["fields"].items()]
|
|
423
|
+
return "\n".join(md) + "\n"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _load_report(p):
|
|
427
|
+
p = Path(p)
|
|
428
|
+
return json.loads((p / "report.json" if p.is_dir() else p).read_text(encoding="utf-8"))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def compare(a, b):
|
|
432
|
+
"""Two reports (dirs or report.json) -> {families: {fam: (A, B)}, flipped: {...}, text}."""
|
|
433
|
+
A, B = _load_report(a), _load_report(b)
|
|
434
|
+
fa, fb = A["summary"]["families"], B["summary"]["families"]
|
|
435
|
+
fams = {}
|
|
436
|
+
lines = [f"A: {a} ({A['meta']['model']}{' via ' + A['meta']['provider'] if A['meta'].get('provider') else ''}, {A['meta']['mode']}, {A['meta']['cases']} cases)",
|
|
437
|
+
f"B: {b} ({B['meta']['model']}{' via ' + B['meta']['provider'] if B['meta'].get('provider') else ''}, {B['meta']['mode']}, {B['meta']['cases']} cases)", "",
|
|
438
|
+
f"{'family':<12} {'pass A':>9} {'pass B':>9} {'diff':>8} {'$/part A':>10} {'$/part B':>10}"]
|
|
439
|
+
for k in sorted(set(fa) | set(fb)) + ["overall"]:
|
|
440
|
+
x = A["summary"]["overall"] if k == "overall" else fa.get(k)
|
|
441
|
+
y = B["summary"]["overall"] if k == "overall" else fb.get(k)
|
|
442
|
+
fams[k] = (x, y)
|
|
443
|
+
ra, rb = (x or {}).get("rate"), (y or {}).get("rate")
|
|
444
|
+
d = f"{100 * (rb - ra):+.1f}" if ra is not None and rb is not None else "-"
|
|
445
|
+
lines.append(f"{k:<12} {_pct(ra):>9} {_pct(rb):>9} {d:>8} {_usd((x or {}).get('usd_per_part')):>10} "
|
|
446
|
+
f"{_usd((y or {}).get('usd_per_part')):>10}")
|
|
447
|
+
ca, cb = {r["id"]: r for r in A["cases"]}, {r["id"]: r for r in B["cases"]}
|
|
448
|
+
both = [i for i in ca if i in cb]
|
|
449
|
+
flipped = {"fixed": [i for i in both if not ca[i]["pass"] and cb[i]["pass"]],
|
|
450
|
+
"broken": [i for i in both if ca[i]["pass"] and not cb[i]["pass"]],
|
|
451
|
+
"only_in_a": [i for i in ca if i not in cb], "only_in_b": [i for i in cb if i not in ca]}
|
|
452
|
+
lines += ["", f"fail -> pass ({len(flipped['fixed'])}): " + ", ".join(flipped["fixed"]),
|
|
453
|
+
f"pass -> fail ({len(flipped['broken'])}): " + "; ".join(f"{i} [{'; '.join(cb[i]['errors'][:2])}]" for i in flipped["broken"])]
|
|
454
|
+
if flipped["only_in_a"] or flipped["only_in_b"]:
|
|
455
|
+
lines.append(f"cases only in A: {len(flipped['only_in_a'])}, only in B: {len(flipped['only_in_b'])}")
|
|
456
|
+
return {"families": fams, "flipped": flipped, "text": "\n".join(lines)}
|
ecadlib/eval/score.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Score one generated part against its reference: footprint (pads, pin 1, pitch, courtyard) and symbol pins.
|
|
2
|
+
|
|
3
|
+
Both footprints are footprint_bench.numbers() dicts (pads [num, x, y, w, h, drill], courtyard box). Thermal vias (a
|
|
4
|
+
drilled pad sitting inside a larger pad of the same number) are dropped on both sides first: they are a layout
|
|
5
|
+
choice, not part of the land pattern. Ours is then moved so the centre of its pad box lands on the reference's
|
|
6
|
+
(a different origin is reported in `origin` but does not fail the case: KiCad puts THT origins on pin 1, others
|
|
7
|
+
use the body centre). Fields, each {ok, error, ...}:
|
|
8
|
+
pad_count same number of pads and the same pad numbers (lists the missing / extra ones)
|
|
9
|
+
pin1 pad 1 (A1 for ball grids) within TOL["pin1"] of the reference's: a mirrored or rotated part fails
|
|
10
|
+
pitch the most common nearest-neighbour spacing within TOL["pitch"]
|
|
11
|
+
pads every pad matched by number: centre within TOL["pos"], size within TOL["size"], drill within TOL["drill"]
|
|
12
|
+
courtyard every edge of the courtyard box within TOL["courtyard"]
|
|
13
|
+
symbol_pins (when the reference knows the pins) the same pin numbers
|
|
14
|
+
symbol_names (when the reference names its pins) at least TOL["names"] of the names agree after normalising
|
|
15
|
+
(case, overbars ~{X} / X\\, spaces, '/', '#', leading 'n')
|
|
16
|
+
A case passes when every field present passes (origin is information only)."""
|
|
17
|
+
import math, re
|
|
18
|
+
from collections import Counter
|
|
19
|
+
|
|
20
|
+
TOL = {"pos": 0.05, "size": 0.10, "drill": 0.05, "pitch": 0.01, "courtyard": 0.25, "pin1": 0.25, "names": 0.8}
|
|
21
|
+
INFO_ONLY = ("origin",)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def drop_vias(num):
|
|
25
|
+
"""numbers() dict without thermal vias (drilled pads inside a bigger pad of the same number)."""
|
|
26
|
+
pads = num["pads"]
|
|
27
|
+
keep = []
|
|
28
|
+
for p in pads:
|
|
29
|
+
if p[5] and any(q is not p and q[0] == p[0] and q[3] * q[4] > p[3] * p[4] * 2
|
|
30
|
+
and abs(p[1] - q[1]) <= q[3] / 2 + 1e-6 and abs(p[2] - q[2]) <= q[4] / 2 + 1e-6 for q in pads):
|
|
31
|
+
continue
|
|
32
|
+
keep.append(p)
|
|
33
|
+
return {"pads": keep, "courtyard": list(num.get("courtyard") or [])}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _centre(pads):
|
|
37
|
+
xs, ys = [p[1] for p in pads], [p[2] for p in pads]
|
|
38
|
+
return ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2) if pads else (0.0, 0.0)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _shift(num, dx, dy):
|
|
42
|
+
c = num["courtyard"]
|
|
43
|
+
return {"pads": [[p[0], p[1] + dx, p[2] + dy, p[3], p[4], p[5]] for p in num["pads"]],
|
|
44
|
+
"courtyard": [c[0] + dx, c[1] + dy, c[2] + dx, c[3] + dy] if c else []}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _natural(s):
|
|
48
|
+
return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", str(s))]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pin1(pads):
|
|
52
|
+
named = [p for p in pads if p[0] in ("1", "A1")]
|
|
53
|
+
if named:
|
|
54
|
+
return min(named, key=lambda p: (p[0] != "1", p[3] * p[4])) # '1' before 'A1'; the land, not a big EP
|
|
55
|
+
rest = sorted((p for p in pads if p[0]), key=lambda p: _natural(p[0]))
|
|
56
|
+
return rest[0] if rest else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pitch(pads):
|
|
60
|
+
"""Most common nearest-neighbour centre spacing (0.005 mm bins) among numbered pads."""
|
|
61
|
+
pts = [(p[1], p[2]) for p in pads if p[0]]
|
|
62
|
+
if len(pts) < 2:
|
|
63
|
+
return None
|
|
64
|
+
nn = []
|
|
65
|
+
for i, (x, y) in enumerate(pts):
|
|
66
|
+
d = min(math.hypot(x - u, y - v) for j, (u, v) in enumerate(pts) if j != i)
|
|
67
|
+
if d > 1e-6:
|
|
68
|
+
nn.append(round(d / 0.005) * 0.005)
|
|
69
|
+
if not nn:
|
|
70
|
+
return None
|
|
71
|
+
best = Counter(nn).most_common()
|
|
72
|
+
top = max(n for _, n in best)
|
|
73
|
+
return round(min(v for v, n in best if n == top), 4) # ties: the smaller spacing
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def norm_name(s):
|
|
77
|
+
s = str(s or "").upper().replace("~{", "").replace("}", "").replace("\\", "").replace(" ", "")
|
|
78
|
+
s = s.replace("~", "")
|
|
79
|
+
s = re.sub(r"^(N(?=[A-Z]{2,})|/|#)", "", s)
|
|
80
|
+
return re.sub(r"[/#*_-]", "", s)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _f(ok, error="", **kw):
|
|
84
|
+
return dict(ok=bool(ok), error="" if ok else error, **kw)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def score_footprint(mine, ref, tol=None):
|
|
88
|
+
"""mine/ref: numbers() dicts -> {field: {...}}."""
|
|
89
|
+
t = dict(TOL, **(tol or {}))
|
|
90
|
+
mine, ref = drop_vias(mine), drop_vias(ref)
|
|
91
|
+
out = {}
|
|
92
|
+
nums = lambda n: Counter(p[0] or "(hole)" for p in n["pads"])
|
|
93
|
+
a, b = nums(mine), nums(ref)
|
|
94
|
+
missing, extra = sorted((b - a).elements(), key=_natural), sorted((a - b).elements(), key=_natural)
|
|
95
|
+
out["pad_count"] = _f(not missing and not extra, f"ours {len(mine['pads'])} pads, KiCad {len(ref['pads'])}"
|
|
96
|
+
+ (f"; missing {missing[:8]}" if missing else "") + (f"; extra {extra[:8]}" if extra else ""),
|
|
97
|
+
ours=len(mine["pads"]), ref=len(ref["pads"]))
|
|
98
|
+
(mx, my), (rx, ry) = _centre(mine["pads"]), _centre(ref["pads"])
|
|
99
|
+
dx, dy = rx - mx, ry - my
|
|
100
|
+
out["origin"] = _f(max(abs(dx), abs(dy)) <= t["pos"], f"origin differs by ({-dx:+.3f}, {-dy:+.3f}) mm", dx=round(-dx, 4), dy=round(-dy, 4))
|
|
101
|
+
m = _shift(mine, dx, dy)
|
|
102
|
+
p, q = pin1(m["pads"]), pin1(ref["pads"])
|
|
103
|
+
if p is None or q is None:
|
|
104
|
+
out["pin1"] = _f(False, "no pad 1 in " + ("ours" if p is None else "KiCad's"))
|
|
105
|
+
else:
|
|
106
|
+
d = max(abs(p[1] - q[1]), abs(p[2] - q[2]))
|
|
107
|
+
out["pin1"] = _f(d <= t["pin1"], f"pad {p[0]} at ({p[1]:.3f}, {p[2]:.3f}), KiCad's at ({q[1]:.3f}, {q[2]:.3f}) "
|
|
108
|
+
"(pad-box centres aligned): mirrored or rotated?", off=round(d, 4))
|
|
109
|
+
pa, pb = pitch(m["pads"]), pitch(ref["pads"])
|
|
110
|
+
if pb is None:
|
|
111
|
+
out["pitch"] = _f(True)
|
|
112
|
+
else:
|
|
113
|
+
out["pitch"] = _f(pa is not None and abs(pa - pb) <= t["pitch"] + 1e-9, f"pitch {pa} mm, KiCad {pb} mm", ours=pa, ref=pb)
|
|
114
|
+
left, worst, bad = list(ref["pads"]), {"pos": 0.0, "size": 0.0, "drill": 0.0}, []
|
|
115
|
+
for p in m["pads"]:
|
|
116
|
+
cands = [r for r in left if r[0] == p[0]]
|
|
117
|
+
if not cands:
|
|
118
|
+
continue
|
|
119
|
+
r = min(cands, key=lambda r: math.hypot(r[1] - p[1], r[2] - p[2]))
|
|
120
|
+
left.remove(r)
|
|
121
|
+
e = {"pos": max(abs(p[1] - r[1]), abs(p[2] - r[2])), "size": max(abs(p[3] - r[3]), abs(p[4] - r[4])),
|
|
122
|
+
"drill": abs(p[5] - r[5])}
|
|
123
|
+
for k, v in e.items():
|
|
124
|
+
worst[k] = max(worst[k], v)
|
|
125
|
+
why = [f"{k} off {v:.3f}" for k, v in e.items() if v > t[k] + 1e-9]
|
|
126
|
+
if why:
|
|
127
|
+
bad.append(f"pad {p[0] or '(hole)'}: " + ", ".join(why))
|
|
128
|
+
out["pads"] = _f(not bad, f"{len(bad)} pads out of tolerance: " + "; ".join(bad[:4]) + (" ..." if len(bad) > 4 else ""),
|
|
129
|
+
worst={k: round(v, 4) for k, v in worst.items()}, bad=len(bad))
|
|
130
|
+
ca, cb = m["courtyard"], ref["courtyard"]
|
|
131
|
+
if not cb:
|
|
132
|
+
out["courtyard"] = _f(True)
|
|
133
|
+
elif not ca:
|
|
134
|
+
out["courtyard"] = _f(False, "no courtyard in ours")
|
|
135
|
+
else:
|
|
136
|
+
d = max(abs(x - y) for x, y in zip(ca, cb))
|
|
137
|
+
out["courtyard"] = _f(d <= t["courtyard"], f"courtyard edge off by {d:.3f} mm (ours {[round(v, 3) for v in ca]}, "
|
|
138
|
+
f"KiCad {[round(v, 3) for v in cb]})", off=round(d, 4))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def score_symbol(mine_pins, ref_pins, tol=None):
|
|
143
|
+
"""Pin lists [(num, name)] -> {symbol_pins, symbol_names?}. mine_pins None = no symbol made."""
|
|
144
|
+
t = dict(TOL, **(tol or {}))
|
|
145
|
+
if not ref_pins:
|
|
146
|
+
return {}
|
|
147
|
+
if mine_pins is None:
|
|
148
|
+
return {"symbol_pins": _f(False, "no symbol")}
|
|
149
|
+
a, b = dict((str(n), s) for n, s in mine_pins), dict((str(n), s) for n, s in ref_pins)
|
|
150
|
+
missing, extra = sorted(set(b) - set(a), key=_natural), sorted(set(a) - set(b), key=_natural)
|
|
151
|
+
out = {"symbol_pins": _f(not missing and not extra, f"ours {len(a)} pins, KiCad {len(b)}"
|
|
152
|
+
+ (f"; missing {missing[:8]}" if missing else "") + (f"; extra {extra[:8]}" if extra else ""),
|
|
153
|
+
ours=len(a), ref=len(b))}
|
|
154
|
+
named = {n: norm_name(s) for n, s in b.items() if norm_name(s) and not re.fullmatch(r"PIN\d+", norm_name(s))}
|
|
155
|
+
if named:
|
|
156
|
+
same = [n for n, s in named.items() if norm_name(a.get(n)) == s]
|
|
157
|
+
diff = [f"{n}: {a.get(n)!r} vs {b[n]!r}" for n in sorted(set(named) - set(same), key=_natural)]
|
|
158
|
+
once = Counter(named.values())
|
|
159
|
+
moved = [n for n in set(named) - set(same) if once.get(norm_name(a.get(n))) == 1] # a name KiCad has on another pin:
|
|
160
|
+
out["symbol_names"] = _f(len(same) >= t["names"] * len(named) and not moved, # swapped pins, not naming noise
|
|
161
|
+
f"{len(same)}/{len(named)} pin names agree"
|
|
162
|
+
+ (f" ({len(moved)} on the wrong pin)" if moved else "") + "; " + "; ".join(diff[:4]),
|
|
163
|
+
matched=len(same), named=len(named), moved=len(moved))
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def verdict(fields):
|
|
168
|
+
return all(f["ok"] for k, f in fields.items() if k not in INFO_ONLY)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def score(mine_fp, ref_fp, mine_pins=None, ref_pins=None, tol=None):
|
|
172
|
+
"""One case -> {pass, fields, errors}. mine_fp None = no footprint made."""
|
|
173
|
+
if mine_fp is None:
|
|
174
|
+
fields = {"footprint": _f(False, "no footprint")}
|
|
175
|
+
else:
|
|
176
|
+
fields = score_footprint(mine_fp, ref_fp, tol)
|
|
177
|
+
fields.update(score_symbol(mine_pins, ref_pins, tol))
|
|
178
|
+
ok = verdict(fields)
|
|
179
|
+
return {"pass": ok, "fields": fields, "errors": [f"{k}: {f['error']}" for k, f in fields.items() if not f["ok"] and k not in INFO_ONLY]}
|