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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. 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]}