traceforge-pcb 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,350 @@
1
+ """IPC-2221 conductor width and spacing checks, plus an IEC 60664-1 style creepage/clearance guide.
2
+
3
+ All tables are reference guides reproduced from the public standards' commonly quoted values; they are not
4
+ a substitute for the standard and not a certification. Currents and voltages come from the design's
5
+ declaration in ``.traceforge/electrical.yaml`` (see :func:`load_electrical`) or from net class names such
6
+ as ``Power_10A`` / ``HV_230V``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import math
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ from traceforge.checks import Finding
20
+ from traceforge.kicad.pcb import Board, Track
21
+ from traceforge.validate import circuits
22
+
23
+ GUIDE_NOTE = (
24
+ "Guide values from IPC-2221B (conductor width, Table 6-1 spacing) and IEC 60664-1 (creepage, pollution "
25
+ "degree 2): a design aid, not a compliance statement. Check the standard that applies to your product."
26
+ )
27
+
28
+ # IPC-2221B Table 6-1 electrical conductor spacing (mm). Columns:
29
+ # B1 internal, B2 external uncoated <3050 m, B3 external uncoated >3050 m, B4 external with permanent
30
+ # polymer coating, A5 external with conformal coating, A6 component lead/termination (conformal).
31
+ SPACING_COLUMNS = ("B1", "B2", "B3", "B4", "A5", "A6")
32
+ SPACING_TABLE: tuple[tuple[float, tuple[float, ...]], ...] = (
33
+ (15, (0.05, 0.1, 0.1, 0.05, 0.13, 0.13)),
34
+ (30, (0.05, 0.1, 0.1, 0.05, 0.13, 0.25)),
35
+ (50, (0.1, 0.6, 0.6, 0.13, 0.13, 0.4)),
36
+ (100, (0.1, 0.6, 1.5, 0.13, 0.13, 0.5)),
37
+ (150, (0.2, 0.6, 3.2, 0.4, 0.4, 0.8)),
38
+ (170, (0.2, 1.25, 3.2, 0.4, 0.4, 0.8)),
39
+ (250, (0.2, 1.25, 6.4, 0.4, 0.4, 0.8)),
40
+ (300, (0.2, 1.25, 12.5, 0.4, 0.4, 0.8)),
41
+ (500, (0.25, 2.5, 12.5, 0.8, 0.8, 1.5)),
42
+ )
43
+ _ABOVE_500_PER_V = (0.0025, 0.005, 0.025, 0.00305, 0.00305, 0.00305)
44
+
45
+ # IEC 60664-1 Table F.4 style creepage (mm), pollution degree 2, by working voltage (rms) and material
46
+ # group I / II / III (a+b). FR-4 is typically CTI 175 to 250 = material group IIIa.
47
+ CREEPAGE_TABLE: tuple[tuple[float, tuple[float, float, float]], ...] = (
48
+ (50, (0.6, 0.9, 1.2)),
49
+ (100, (0.7, 1.0, 1.4)),
50
+ (125, (0.8, 1.1, 1.6)),
51
+ (160, (0.9, 1.3, 1.8)),
52
+ (200, (1.0, 1.4, 2.0)),
53
+ (250, (1.25, 1.8, 2.5)),
54
+ (320, (1.6, 2.2, 3.2)),
55
+ (400, (2.0, 2.8, 4.0)),
56
+ (500, (2.5, 3.6, 5.0)),
57
+ (630, (3.2, 4.5, 6.3)),
58
+ (800, (4.0, 5.6, 8.0)),
59
+ (1000, (5.0, 7.1, 10.0)),
60
+ )
61
+ # Basic-insulation clearance (mm), overvoltage category II, pollution degree 2, inhomogeneous field.
62
+ CLEARANCE_TABLE: tuple[tuple[float, float], ...] = (
63
+ (50, 0.04),
64
+ (100, 0.1),
65
+ (150, 0.5),
66
+ (300, 1.5),
67
+ (600, 3.0),
68
+ (1000, 5.5),
69
+ )
70
+
71
+
72
+ def spacing_mm(voltage: float, column: str = "B2") -> float:
73
+ """IPC-2221B minimum spacing between conductors at ``voltage`` (peak DC or AC) for a column."""
74
+ index = SPACING_COLUMNS.index(column)
75
+ v = abs(voltage)
76
+ for limit, values in SPACING_TABLE:
77
+ if v <= limit:
78
+ return values[index]
79
+ last = SPACING_TABLE[-1][1][index]
80
+ return round(last + (v - 500) * _ABOVE_500_PER_V[index], 3)
81
+
82
+
83
+ def creepage_mm(voltage_rms: float, material_group: int = 3) -> float:
84
+ for limit, values in CREEPAGE_TABLE:
85
+ if abs(voltage_rms) <= limit:
86
+ return values[material_group - 1]
87
+ limit, values = CREEPAGE_TABLE[-1]
88
+ return round(values[material_group - 1] * abs(voltage_rms) / limit, 2)
89
+
90
+
91
+ def clearance_mm(voltage_rms: float) -> float:
92
+ for limit, value in CLEARANCE_TABLE:
93
+ if abs(voltage_rms) <= limit:
94
+ return value
95
+ limit, value = CLEARANCE_TABLE[-1]
96
+ return round(value * abs(voltage_rms) / limit, 2)
97
+
98
+
99
+ def min_width_mm(
100
+ current_a: float, *, delta_t: float = 10.0, copper_oz: float = 1.0, internal: bool = False
101
+ ) -> float:
102
+ """IPC-2221 conductor width for ``current_a``: I = k dT^0.44 A^0.725 (A in mil2)."""
103
+ if current_a <= 0:
104
+ return 0.0
105
+ k = 0.024 if internal else 0.048
106
+ area_mil2 = (current_a / (k * delta_t**0.44)) ** (1 / 0.725)
107
+ width_mil = area_mil2 / (copper_oz * 1.378)
108
+ return width_mil * 0.0254
109
+
110
+
111
+ def max_current_a(
112
+ width_mm: float, *, delta_t: float = 10.0, copper_oz: float = 1.0, internal: bool = False
113
+ ) -> float:
114
+ k = 0.024 if internal else 0.048
115
+ area_mil2 = width_mm / 0.0254 * copper_oz * 1.378
116
+ return k * delta_t**0.44 * area_mil2**0.725
117
+
118
+
119
+ # ------------------------------------------------------------------ declarations
120
+
121
+ _CLASS_HINT_A = re.compile(r"(?<![\w.])(\d+(?:\.\d+)?)\s*A(?![A-Za-z])")
122
+ _CLASS_HINT_V = re.compile(r"(?<![\w.])(\d+(?:\.\d+)?)\s*V(?![A-Za-z])")
123
+
124
+
125
+ def load_electrical(workspace: Path) -> dict[str, Any]:
126
+ """``.traceforge/electrical.yaml``::
127
+
128
+ delta_t: 10 # allowed temperature rise, K
129
+ copper_oz: {outer: 1, inner: 0.5}
130
+ coating: uncoated # uncoated | coated | conformal (selects the spacing column)
131
+ nets:
132
+ VBUS: {current_a: 10, voltage_v: 5}
133
+ "HV_*": {voltage_v: 230}
134
+ """
135
+ path = workspace / ".traceforge" / "electrical.yaml"
136
+ if not path.is_file():
137
+ return {}
138
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
139
+ return data if isinstance(data, dict) else {}
140
+
141
+
142
+ def declared(
143
+ board: Board, spec: dict[str, Any], netclasses: dict[str, str] | None = None
144
+ ) -> tuple[dict[str, float], dict[str, float]]:
145
+ """(net -> declared current in A, net -> voltage in V) for the nets on the board."""
146
+ nets = {n for n in board.nets.values() if n}
147
+ currents: dict[str, float] = {}
148
+ volts: dict[str, float] = {}
149
+ for net in nets: # net class names such as Power_10A, HV_230V
150
+ cls = (netclasses or {}).get(net, "")
151
+ if cls:
152
+ a, v = _CLASS_HINT_A.search(cls), _CLASS_HINT_V.search(cls)
153
+ if a:
154
+ currents[net] = float(a.group(1))
155
+ if v:
156
+ volts[net] = float(v.group(1))
157
+ for pattern, item in (spec.get("nets") or {}).items():
158
+ if not isinstance(item, dict):
159
+ continue
160
+ for net in nets:
161
+ if (
162
+ net == pattern
163
+ or net.lstrip("/") == pattern.lstrip("/")
164
+ or fnmatch.fnmatchcase(net, pattern)
165
+ ):
166
+ if item.get("current_a") is not None:
167
+ currents[net] = float(item["current_a"])
168
+ if item.get("voltage_v") is not None:
169
+ volts[net] = float(item["voltage_v"])
170
+ for net in nets: # rails implied by the name, only where nothing was declared
171
+ if net not in volts:
172
+ inferred = circuits.rail_voltage(net)
173
+ if inferred is not None:
174
+ volts[net] = inferred
175
+ return currents, volts
176
+
177
+
178
+ def netclasses_of(project: Path | None) -> dict[str, str]:
179
+ """net -> net class name from a ``.kicad_pro`` (patterns and explicit assignments)."""
180
+ if project is None or not project.is_file():
181
+ return {}
182
+ import json
183
+
184
+ data = json.loads(project.read_text(encoding="utf-8"))
185
+ settings = data.get("net_settings") or {}
186
+ out: dict[str, str] = {}
187
+ for item in settings.get("netclass_patterns") or []:
188
+ out[item.get("pattern", "")] = item.get("netclass", "")
189
+ for net, name in (settings.get("netclass_assignments") or {}).items():
190
+ out[net] = name if isinstance(name, str) else (name[0] if name else "")
191
+ return out
192
+
193
+
194
+ def _expand_classes(board: Board, patterns: dict[str, str]) -> dict[str, str]:
195
+ out = {}
196
+ for net in {n for n in board.nets.values() if n}:
197
+ for pattern, name in patterns.items():
198
+ if net == pattern or fnmatch.fnmatchcase(net, pattern):
199
+ out[net] = name
200
+ return out
201
+
202
+
203
+ # ---------------------------------------------------------------------- checks
204
+
205
+
206
+ def _is_internal(layer: str) -> bool:
207
+ return layer.endswith(".Cu") and layer not in {"F.Cu", "B.Cu"}
208
+
209
+
210
+ def _seg_distance(a: Track, b: Track) -> float:
211
+ """Distance between two straight segments (centre lines)."""
212
+
213
+ def point_seg(px, py, x1, y1, x2, y2):
214
+ dx, dy = x2 - x1, y2 - y1
215
+ length = dx * dx + dy * dy
216
+ t = 0.0 if length == 0 else max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / length))
217
+ return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
218
+
219
+ def cross(o, p, q):
220
+ return (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0])
221
+
222
+ p1, p2, q1, q2 = (a.x1, a.y1), (a.x2, a.y2), (b.x1, b.y1), (b.x2, b.y2)
223
+ if cross(p1, p2, q1) * cross(p1, p2, q2) < 0 and cross(q1, q2, p1) * cross(q1, q2, p2) < 0:
224
+ return 0.0
225
+ return min(
226
+ point_seg(*p1, *q1, *q2),
227
+ point_seg(*p2, *q1, *q2),
228
+ point_seg(*q1, *p1, *p2),
229
+ point_seg(*q2, *p1, *p2),
230
+ )
231
+
232
+
233
+ def check_board(
234
+ board: Board,
235
+ spec: dict[str, Any] | None = None,
236
+ netclasses: dict[str, str] | None = None,
237
+ *,
238
+ currents: dict[str, float] | None = None,
239
+ voltages: dict[str, float] | None = None,
240
+ ) -> list[Finding]:
241
+ spec = spec or {}
242
+ declared_i, declared_v = declared(board, spec, _expand_classes(board, netclasses or {}))
243
+ declared_i.update(currents or {})
244
+ declared_v.update(voltages or {})
245
+ delta_t = float(spec.get("delta_t", 10.0))
246
+ oz = spec.get("copper_oz", 1.0)
247
+ outer_oz = float(oz.get("outer", 1.0)) if isinstance(oz, dict) else float(oz)
248
+ inner_oz = float(oz.get("inner", 0.5)) if isinstance(oz, dict) else float(oz)
249
+ column = {"uncoated": "B2", "coated": "B4", "conformal": "A5"}.get(
250
+ str(spec.get("coating", "uncoated")), "B2"
251
+ )
252
+ findings: list[Finding] = []
253
+
254
+ by_net: dict[str, list[Track]] = {}
255
+ for track in board.tracks:
256
+ by_net.setdefault(track.net, []).append(track)
257
+ for net, current in sorted(declared_i.items()):
258
+ narrow: dict[str, list[Track]] = {}
259
+ for track in by_net.get(net, []):
260
+ internal = _is_internal(track.layer)
261
+ need = min_width_mm(
262
+ current,
263
+ delta_t=delta_t,
264
+ copper_oz=inner_oz if internal else outer_oz,
265
+ internal=internal,
266
+ )
267
+ if track.width < need * 0.98:
268
+ narrow.setdefault(f"{track.layer}|{need:.3f}", []).append(track)
269
+ for key, tracks in narrow.items():
270
+ layer, need = key.split("|")
271
+ worst = min(tracks, key=lambda t: t.width)
272
+ allowed = max_current_a(
273
+ worst.width,
274
+ delta_t=delta_t,
275
+ copper_oz=inner_oz if _is_internal(layer) else outer_oz,
276
+ internal=_is_internal(layer),
277
+ )
278
+ findings.append(
279
+ Finding(
280
+ "P0" if current / allowed > 1.5 else "P1",
281
+ "pcb.ipc2221",
282
+ f"net {net} carries {current:g} A but {len(tracks)} track segment(s) on {layer} are only "
283
+ f"{worst.width:g} mm wide; IPC-2221 needs {float(need):.2f} mm for a {delta_t:g} K rise "
284
+ f"({max_current_a(worst.width, delta_t=delta_t, copper_oz=inner_oz if _is_internal(layer) else outer_oz, internal=_is_internal(layer)):.1f} A at {worst.width:g} mm)",
285
+ nets=[net],
286
+ evidence=f"first segment at ({worst.x1:g}, {worst.y1:g})-({worst.x2:g}, {worst.y2:g}) mm on {layer}; "
287
+ f"I = k*dT^0.44*A^0.725, k=0.048 external / 0.024 internal, {outer_oz:g} oz outer",
288
+ rule="IPC-2221B section 6.2 conductor sizing (guide value)",
289
+ action=f"Widen the track to at least {float(need):.2f} mm, pour copper, or add parallel layers; allowed "
290
+ f"current at {allowed:.1f} A for {worst.width:g} mm.",
291
+ )
292
+ )
293
+ findings += _clearance_findings(board, declared_v, column, spec)
294
+ return findings
295
+
296
+
297
+ def _clearance_findings(
298
+ board: Board, volts: dict[str, float], column: str, spec: dict
299
+ ) -> list[Finding]:
300
+ """Track-to-track clearance on the same layer against the voltage between the two nets."""
301
+ live = {n for n, v in volts.items() if abs(v) > 15}
302
+ if not live:
303
+ return []
304
+ tracks = [t for t in board.tracks if t.mid is None and t.net]
305
+ cell = 5.0
306
+ grid: dict[tuple[str, int, int], list[int]] = {}
307
+ for index, t in enumerate(tracks):
308
+ for gx in range(int(min(t.x1, t.x2) // cell) - 1, int(max(t.x1, t.x2) // cell) + 2):
309
+ for gy in range(int(min(t.y1, t.y2) // cell) - 1, int(max(t.y1, t.y2) // cell) + 2):
310
+ grid.setdefault((t.layer, gx, gy), []).append(index)
311
+ seen: set[tuple[int, int]] = set()
312
+ worst: dict[tuple[str, str, str], tuple[float, float, Track, Track]] = {}
313
+ for members in grid.values():
314
+ for i in members:
315
+ for j in members:
316
+ if j <= i or (i, j) in seen:
317
+ continue
318
+ seen.add((i, j))
319
+ a, b = tracks[i], tracks[j]
320
+ if a.net == b.net or not (a.net in live or b.net in live):
321
+ continue
322
+ va, vb = volts.get(a.net), volts.get(b.net)
323
+ if va is not None and vb is not None:
324
+ diff = abs(va - vb)
325
+ else:
326
+ diff = max(abs(v) for v in (va, vb) if v is not None)
327
+ internal = _is_internal(a.layer)
328
+ need = spacing_mm(diff, "B1" if internal else column)
329
+ gap = _seg_distance(a, b) - a.width / 2 - b.width / 2
330
+ if gap < need - 1e-6:
331
+ key = (a.layer, *sorted((a.net, b.net)))
332
+ if key not in worst or gap < worst[key][0]:
333
+ worst[key] = (gap, need, a, b)
334
+ findings = []
335
+ for (layer, n1, n2), (gap, need, a, b) in sorted(worst.items()):
336
+ diff = abs((volts.get(n1) or 0) - (volts.get(n2) or 0)) or max(
337
+ abs(volts.get(n1, 0)), abs(volts.get(n2, 0))
338
+ )
339
+ findings.append(
340
+ Finding(
341
+ "P0" if gap < need / 2 else "P1",
342
+ "pcb.ipc2221",
343
+ f"{n1} and {n2} are {max(gap, 0):.2f} mm apart on {layer}; IPC-2221 needs {need:g} mm at {diff:g} V",
344
+ nets=[n1, n2],
345
+ evidence=f"closest segments near ({a.x1:g}, {a.y1:g}) and ({b.x1:g}, {b.y1:g}) mm; table column {column}",
346
+ rule="IPC-2221B Table 6-1 (guide value); for mains-class voltages also see creepage_mm()",
347
+ action=f"Increase spacing to at least {need:g} mm or slot the board.",
348
+ )
349
+ )
350
+ return findings