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