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,400 @@
|
|
|
1
|
+
"""Altium ``.SchDoc`` parser and connectivity extraction (nets from wires, pins and labels)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from traceforge.altium.cfb import AltiumError, length_prefixed, open_cfb, parse_properties
|
|
10
|
+
|
|
11
|
+
SCALE = 100000 # coordinates are 10 mil units with a 1e-5 fraction
|
|
12
|
+
MM_PER_UNIT = 0.254
|
|
13
|
+
ELECTRICAL = {
|
|
14
|
+
0: "input",
|
|
15
|
+
1: "bidirectional",
|
|
16
|
+
2: "output",
|
|
17
|
+
3: "open_collector",
|
|
18
|
+
4: "passive",
|
|
19
|
+
5: "tri_state",
|
|
20
|
+
6: "open_emitter",
|
|
21
|
+
7: "power",
|
|
22
|
+
}
|
|
23
|
+
_DIRS = {0: (1, 0), 1: (0, 1), 2: (-1, 0), 3: (0, -1)}
|
|
24
|
+
|
|
25
|
+
Point = tuple[int, int]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _int(props: dict[str, str], key: str, default: int = 0) -> int:
|
|
29
|
+
try:
|
|
30
|
+
return int(float(props.get(key, default)))
|
|
31
|
+
except ValueError:
|
|
32
|
+
return default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _pt(props: dict[str, str], prefix: str = "LOCATION.") -> Point:
|
|
36
|
+
def axis(a: str) -> int:
|
|
37
|
+
base = _int(props, f"{prefix}{a}")
|
|
38
|
+
return base * SCALE + _int(props, f"{prefix}{a}_FRAC")
|
|
39
|
+
|
|
40
|
+
return (axis("X"), axis("Y"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _flag(props: dict[str, str], key: str) -> bool:
|
|
44
|
+
return props.get(key, "").upper() == "T"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def natural_key(text: str):
|
|
48
|
+
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", text)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AltPin:
|
|
53
|
+
number: str
|
|
54
|
+
name: str
|
|
55
|
+
electrical: str
|
|
56
|
+
x: float # connection (hot) end, mm
|
|
57
|
+
y: float
|
|
58
|
+
hidden: bool = False
|
|
59
|
+
part: int = -1
|
|
60
|
+
point: Point = (0, 0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class AltComponent:
|
|
65
|
+
designator: str
|
|
66
|
+
comment: str = ""
|
|
67
|
+
description: str = ""
|
|
68
|
+
lib_ref: str = ""
|
|
69
|
+
footprint: str = ""
|
|
70
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
71
|
+
pins: list[AltPin] = field(default_factory=list)
|
|
72
|
+
sheet: str = ""
|
|
73
|
+
x: float = 0.0
|
|
74
|
+
y: float = 0.0
|
|
75
|
+
part_count: int = 1
|
|
76
|
+
|
|
77
|
+
def pin(self, number: str) -> AltPin | None:
|
|
78
|
+
for p in self.pins:
|
|
79
|
+
if p.number == str(number):
|
|
80
|
+
return p
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_power(self) -> bool:
|
|
85
|
+
return self.designator.startswith("#")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class SheetSymbol:
|
|
90
|
+
name: str
|
|
91
|
+
file: str
|
|
92
|
+
entries: list[tuple[str, Point]] = field(default_factory=list)
|
|
93
|
+
point: Point = (0, 0)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class AltSheet:
|
|
98
|
+
path: Path
|
|
99
|
+
title: str = ""
|
|
100
|
+
components: list[AltComponent] = field(default_factory=list)
|
|
101
|
+
wires: list[list[Point]] = field(default_factory=list)
|
|
102
|
+
junctions: list[Point] = field(default_factory=list)
|
|
103
|
+
net_labels: list[tuple[str, Point]] = field(default_factory=list)
|
|
104
|
+
power_ports: list[tuple[str, Point]] = field(default_factory=list)
|
|
105
|
+
ports: list[tuple[str, Point]] = field(default_factory=list)
|
|
106
|
+
sheet_symbols: list[SheetSymbol] = field(default_factory=list)
|
|
107
|
+
no_erc: int = 0
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def name(self) -> str:
|
|
111
|
+
return self.path.name
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_sheet(path: str | Path) -> AltSheet:
|
|
115
|
+
path = Path(path)
|
|
116
|
+
ole = open_cfb(path)
|
|
117
|
+
try:
|
|
118
|
+
if not ole.exists(["FileHeader"]):
|
|
119
|
+
raise AltiumError(f"{path.name}: no FileHeader stream (not a schematic document)")
|
|
120
|
+
data = ole.openstream(["FileHeader"]).read()
|
|
121
|
+
finally:
|
|
122
|
+
ole.close()
|
|
123
|
+
records = [parse_properties(p) for flag, p in length_prefixed(data) if flag == 0]
|
|
124
|
+
if not records or "HEADER" not in records[0]:
|
|
125
|
+
raise AltiumError(f"{path.name}: unexpected schematic header")
|
|
126
|
+
body = records[1:] # OwnerIndex counts from the first record after the header
|
|
127
|
+
sheet = AltSheet(path=path)
|
|
128
|
+
|
|
129
|
+
owned: dict[int, list[tuple[int, dict[str, str]]]] = {}
|
|
130
|
+
for i, r in enumerate(body):
|
|
131
|
+
if "OWNERINDEX" in r:
|
|
132
|
+
owned.setdefault(_int(r, "OWNERINDEX"), []).append((i, r))
|
|
133
|
+
|
|
134
|
+
comps: dict[int, AltComponent] = {}
|
|
135
|
+
for i, r in enumerate(body):
|
|
136
|
+
if r.get("RECORD") != "1":
|
|
137
|
+
continue
|
|
138
|
+
x, y = _pt(r)
|
|
139
|
+
comp = AltComponent(
|
|
140
|
+
designator="",
|
|
141
|
+
lib_ref=r.get("DESIGNITEMID") or r.get("LIBREFERENCE", ""),
|
|
142
|
+
description=r.get("COMPONENTDESCRIPTION", ""),
|
|
143
|
+
sheet=path.name,
|
|
144
|
+
x=x / SCALE * MM_PER_UNIT,
|
|
145
|
+
y=y / SCALE * MM_PER_UNIT,
|
|
146
|
+
part_count=_int(r, "PARTCOUNT", 1),
|
|
147
|
+
)
|
|
148
|
+
comp.params["_part"] = r.get("CURRENTPARTID", "1")
|
|
149
|
+
comps[i] = comp
|
|
150
|
+
for i, comp in comps.items():
|
|
151
|
+
current = _int(body[i], "CURRENTPARTID", 1)
|
|
152
|
+
mode = _int(body[i], "CURRENTPARTDISPLAYMODE", 0)
|
|
153
|
+
for j, r in owned.get(i, []):
|
|
154
|
+
kind = r.get("RECORD")
|
|
155
|
+
if kind == "34":
|
|
156
|
+
comp.designator = r.get("TEXT", "")
|
|
157
|
+
elif kind == "41":
|
|
158
|
+
name = r.get("NAME", "")
|
|
159
|
+
if name and name != "CurrentTime":
|
|
160
|
+
comp.params[name] = r.get("TEXT", "")
|
|
161
|
+
elif kind == "2":
|
|
162
|
+
part = _int(r, "OWNERPARTID", -1)
|
|
163
|
+
if part not in (-1, current) or _int(r, "OWNERPARTDISPLAYMODE", 0) != mode:
|
|
164
|
+
continue
|
|
165
|
+
conglomerate = _int(r, "PINCONGLOMERATE")
|
|
166
|
+
dx, dy = _DIRS[conglomerate & 3]
|
|
167
|
+
length = _int(r, "PINLENGTH") * SCALE + _int(r, "PINLENGTH_FRAC")
|
|
168
|
+
px, py = _pt(r)
|
|
169
|
+
hot = (px + dx * length, py + dy * length)
|
|
170
|
+
comp.pins.append(
|
|
171
|
+
AltPin(
|
|
172
|
+
number=r.get("DESIGNATOR", ""),
|
|
173
|
+
name=r.get("NAME", ""),
|
|
174
|
+
electrical=ELECTRICAL.get(_int(r, "ELECTRICAL"), "passive"),
|
|
175
|
+
x=hot[0] / SCALE * MM_PER_UNIT,
|
|
176
|
+
y=hot[1] / SCALE * MM_PER_UNIT,
|
|
177
|
+
hidden=_flag(r, "HIDDEN"),
|
|
178
|
+
part=part,
|
|
179
|
+
point=hot,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
elif kind == "44": # implementation list: footprints hang below it
|
|
183
|
+
for _, impl in owned.get(j, []):
|
|
184
|
+
if impl.get("RECORD") == "45" and impl.get("MODELTYPE") == "PCBLIB":
|
|
185
|
+
if not comp.footprint or _flag(impl, "ISCURRENT"):
|
|
186
|
+
comp.footprint = impl.get("MODELNAME", "")
|
|
187
|
+
comp.comment = _resolve(comp.params.get("Comment", ""), comp.params) or comp.lib_ref
|
|
188
|
+
comp.description = comp.description or comp.params.get("Description", "")
|
|
189
|
+
sheet.components.append(comp)
|
|
190
|
+
|
|
191
|
+
symbols: dict[int, SheetSymbol] = {}
|
|
192
|
+
for i, r in enumerate(body):
|
|
193
|
+
kind = r.get("RECORD")
|
|
194
|
+
if kind == "27":
|
|
195
|
+
n = _int(r, "LOCATIONCOUNT")
|
|
196
|
+
sheet.wires.append(
|
|
197
|
+
[
|
|
198
|
+
(_int(r, f"X{k}") * SCALE + _int(r, f"X{k}_FRAC"),)
|
|
199
|
+
+ (_int(r, f"Y{k}") * SCALE + _int(r, f"Y{k}_FRAC"),)
|
|
200
|
+
for k in range(1, n + 1)
|
|
201
|
+
] # noqa: E501
|
|
202
|
+
)
|
|
203
|
+
elif kind == "29":
|
|
204
|
+
sheet.junctions.append(_pt(r))
|
|
205
|
+
elif kind == "25":
|
|
206
|
+
sheet.net_labels.append((r.get("TEXT", ""), _pt(r)))
|
|
207
|
+
elif kind == "17":
|
|
208
|
+
sheet.power_ports.append((r.get("TEXT", ""), _pt(r)))
|
|
209
|
+
elif kind == "18":
|
|
210
|
+
sheet.ports.append((r.get("NAME", ""), _pt(r)))
|
|
211
|
+
elif kind == "22":
|
|
212
|
+
sheet.no_erc += 1
|
|
213
|
+
elif kind == "15":
|
|
214
|
+
symbols[i] = SheetSymbol(name="", file="", point=_pt(r))
|
|
215
|
+
for r in body:
|
|
216
|
+
kind = r.get("RECORD")
|
|
217
|
+
owner = _int(r, "OWNERINDEX", -1)
|
|
218
|
+
if owner in symbols:
|
|
219
|
+
if kind == "32":
|
|
220
|
+
symbols[owner].name = r.get("TEXT", "")
|
|
221
|
+
elif kind == "33":
|
|
222
|
+
symbols[owner].file = r.get("TEXT", "")
|
|
223
|
+
elif kind == "16":
|
|
224
|
+
sym = symbols[owner]
|
|
225
|
+
sx, sy = sym.point
|
|
226
|
+
w = _int(r, "DISTANCEFROMTOP") * SCALE + _int(r, "DISTANCEFROMTOP_FRAC")
|
|
227
|
+
side = _int(r, "SIDE")
|
|
228
|
+
# Sheet symbol origin is its top-left corner; entries sit on an edge.
|
|
229
|
+
size_x = _int(body[owner], "XSIZE") * SCALE
|
|
230
|
+
if side == 0:
|
|
231
|
+
pos = (sx, sy - w)
|
|
232
|
+
elif side == 1:
|
|
233
|
+
pos = (sx + size_x, sy - w)
|
|
234
|
+
else:
|
|
235
|
+
pos = (sx + w, sy)
|
|
236
|
+
sym.entries.append((r.get("NAME", ""), pos))
|
|
237
|
+
sheet.sheet_symbols = list(symbols.values())
|
|
238
|
+
return sheet
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _resolve(text: str, params: dict[str, str]) -> str:
|
|
242
|
+
"""Comment values may be ``=ParameterName`` references."""
|
|
243
|
+
if text.startswith("="):
|
|
244
|
+
return params.get(text[1:], "")
|
|
245
|
+
return text
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --- connectivity -----------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _UF:
|
|
252
|
+
def __init__(self) -> None:
|
|
253
|
+
self.p: dict = {}
|
|
254
|
+
|
|
255
|
+
def find(self, a):
|
|
256
|
+
self.p.setdefault(a, a)
|
|
257
|
+
while self.p[a] != a:
|
|
258
|
+
self.p[a] = self.p[self.p[a]]
|
|
259
|
+
a = self.p[a]
|
|
260
|
+
return a
|
|
261
|
+
|
|
262
|
+
def union(self, a, b) -> None:
|
|
263
|
+
ra, rb = self.find(a), self.find(b)
|
|
264
|
+
if ra != rb:
|
|
265
|
+
self.p[rb] = ra
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _on_segment(p: Point, a: Point, b: Point) -> bool:
|
|
269
|
+
(px, py), (ax, ay), (bx, by) = p, a, b
|
|
270
|
+
if (bx - ax) * (py - ay) - (by - ay) * (px - ax) != 0:
|
|
271
|
+
return False
|
|
272
|
+
return min(ax, bx) <= px <= max(ax, bx) and min(ay, by) <= py <= max(ay, by)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class Netlist:
|
|
277
|
+
nets: dict[str, list[tuple[str, str]]] # name -> [(designator, pin number)]
|
|
278
|
+
warnings: list[str] = field(default_factory=list)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def extract_nets(sheets: list[AltSheet], hierarchy: bool | None = None) -> Netlist:
|
|
282
|
+
"""Union-find over wire geometry, labels, ports and power ports.
|
|
283
|
+
|
|
284
|
+
Power ports are global. Without sheet symbols (flat project) net labels and ports connect
|
|
285
|
+
across sheets by name; with sheet symbols, net labels are local to their sheet and ports
|
|
286
|
+
connect through sheet entries (the same sheet file used twice is read once) (a sheet file used twice is read once).
|
|
287
|
+
"""
|
|
288
|
+
uf = _UF()
|
|
289
|
+
pin_node: dict[tuple[str, str], tuple] = {}
|
|
290
|
+
warnings: list[str] = []
|
|
291
|
+
by_file = {s.path.name.lower(): s for s in sheets}
|
|
292
|
+
if hierarchy is None:
|
|
293
|
+
hierarchy = any(s.sheet_symbols for s in sheets)
|
|
294
|
+
soft: list[
|
|
295
|
+
tuple[tuple, str]
|
|
296
|
+
] = [] # port / sheet entry names: used when nothing else names a net
|
|
297
|
+
for sheet in sheets:
|
|
298
|
+
sid = sheet.path.name.lower()
|
|
299
|
+
points: dict[Point, list] = {}
|
|
300
|
+
wire_nodes: list[tuple[tuple, list[Point]]] = []
|
|
301
|
+
|
|
302
|
+
def add(point: Point, node, points=points) -> None:
|
|
303
|
+
points.setdefault(point, []).append(node)
|
|
304
|
+
|
|
305
|
+
for wi, wire in enumerate(sheet.wires):
|
|
306
|
+
node = ("wire", sid, wi)
|
|
307
|
+
uf.find(node)
|
|
308
|
+
wire_nodes.append((node, wire))
|
|
309
|
+
for pt in wire:
|
|
310
|
+
add(pt, node)
|
|
311
|
+
for comp in sheet.components:
|
|
312
|
+
if comp.is_power:
|
|
313
|
+
continue
|
|
314
|
+
for pin in comp.pins:
|
|
315
|
+
node = ("pin", comp.designator, pin.number)
|
|
316
|
+
uf.find(node)
|
|
317
|
+
pin_node[(comp.designator, pin.number)] = node
|
|
318
|
+
add(pin.point, node)
|
|
319
|
+
if pin.hidden and pin.name:
|
|
320
|
+
uf.union(node, ("name", "g", pin.name))
|
|
321
|
+
for text, pt in sheet.net_labels:
|
|
322
|
+
node = ("label", sid, text, pt)
|
|
323
|
+
add(pt, node)
|
|
324
|
+
uf.union(node, ("name", sid if hierarchy else "g", text))
|
|
325
|
+
for text, pt in sheet.power_ports:
|
|
326
|
+
node = ("power", sid, text, pt)
|
|
327
|
+
add(pt, node)
|
|
328
|
+
uf.union(node, ("name", "g", text))
|
|
329
|
+
uf.union(("name", sid, text), ("name", "g", text)) # same-named labels on this sheet
|
|
330
|
+
for text, pt in sheet.ports:
|
|
331
|
+
node = ("port", sid, text, pt)
|
|
332
|
+
add(pt, node)
|
|
333
|
+
soft.append((node, text))
|
|
334
|
+
if not hierarchy:
|
|
335
|
+
uf.union(node, ("name", "g", text))
|
|
336
|
+
uf.union(("name", sid, text), ("name", "g", text))
|
|
337
|
+
else:
|
|
338
|
+
uf.union(node, ("port", sid, text))
|
|
339
|
+
for si, sym in enumerate(sheet.sheet_symbols):
|
|
340
|
+
for ename, pt in sym.entries:
|
|
341
|
+
node = ("entry", sid, si, ename)
|
|
342
|
+
add(pt, node)
|
|
343
|
+
soft.append((node, ename))
|
|
344
|
+
target = by_file.get(Path(sym.file.replace("\\", "/")).name.lower())
|
|
345
|
+
if target is None:
|
|
346
|
+
warnings.append(f"sheet symbol {sym.name!r}: file {sym.file!r} not loaded")
|
|
347
|
+
else:
|
|
348
|
+
uf.union(node, ("port", target.path.name.lower(), ename))
|
|
349
|
+
for nodes in points.values():
|
|
350
|
+
for other in nodes[1:]:
|
|
351
|
+
uf.union(nodes[0], other)
|
|
352
|
+
junctions = set(sheet.junctions)
|
|
353
|
+
for jpt in junctions: # a junction joins every wire passing through or ending on it
|
|
354
|
+
first = None
|
|
355
|
+
for wnode, wire in wire_nodes:
|
|
356
|
+
if any(_on_segment(jpt, a, b) for a, b in zip(wire, wire[1:])):
|
|
357
|
+
if first is None:
|
|
358
|
+
first = wnode
|
|
359
|
+
else:
|
|
360
|
+
uf.union(first, wnode)
|
|
361
|
+
for wnode, wire in wire_nodes:
|
|
362
|
+
for a, b in zip(wire, wire[1:]):
|
|
363
|
+
for pt, nodes in points.items():
|
|
364
|
+
if pt in (a, b) or not _on_segment(pt, a, b):
|
|
365
|
+
continue
|
|
366
|
+
for other in nodes:
|
|
367
|
+
# A wire ending on another wire's side needs a junction; anything
|
|
368
|
+
# else (pin, label, port) on a wire is connected to it.
|
|
369
|
+
if other[0] != "wire" or pt in junctions:
|
|
370
|
+
uf.union(wnode, other)
|
|
371
|
+
groups: dict = {}
|
|
372
|
+
for key in pin_node:
|
|
373
|
+
groups.setdefault(uf.find(pin_node[key]), []).append(key)
|
|
374
|
+
named: dict = {}
|
|
375
|
+
for node in list(uf.p):
|
|
376
|
+
if node[0] == "name":
|
|
377
|
+
named.setdefault(uf.find(node), []).append(node)
|
|
378
|
+
soft_names: dict = {}
|
|
379
|
+
for node, text in sorted(soft, key=lambda item: item[1]):
|
|
380
|
+
soft_names.setdefault(uf.find(node), text)
|
|
381
|
+
nets: dict[str, list[tuple[str, str]]] = {}
|
|
382
|
+
for root, pins in groups.items():
|
|
383
|
+
label = sorted(named.get(root, []), key=lambda n: (n[1] != "g", n[2] if len(n) > 2 else ""))
|
|
384
|
+
if label:
|
|
385
|
+
name = label[0][2]
|
|
386
|
+
if label[0][1] != "g" and len({sh for sh in (n[1] for n in label)}) > 1:
|
|
387
|
+
name = label[0][2]
|
|
388
|
+
elif root in soft_names:
|
|
389
|
+
name = soft_names[root]
|
|
390
|
+
elif len(pins) < 2:
|
|
391
|
+
continue # an unconnected pin is not a net
|
|
392
|
+
else:
|
|
393
|
+
first = sorted(pins, key=lambda k: (natural_key(k[0]), natural_key(k[1])))[0]
|
|
394
|
+
name = f"Net{first[0]}_{first[1]}"
|
|
395
|
+
base, n = name, 2
|
|
396
|
+
while name in nets:
|
|
397
|
+
name = f"{base}_{n}"
|
|
398
|
+
n += 1
|
|
399
|
+
nets[name] = sorted(pins, key=lambda k: (natural_key(k[0]), natural_key(k[1])))
|
|
400
|
+
return Netlist(nets=dict(sorted(nets.items())), warnings=warnings)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Agent tools for Altium projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from traceforge.tools.base import Tool, ToolContext, ToolError, ToolKind
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_for_tool(args: dict[str, Any], ctx: ToolContext):
|
|
11
|
+
from traceforge.altium import AltiumError, load_project
|
|
12
|
+
|
|
13
|
+
raw = args.get("project")
|
|
14
|
+
path = ctx.resolve(raw, must_exist=True) if raw else ctx.workspace
|
|
15
|
+
try:
|
|
16
|
+
return load_project(path)
|
|
17
|
+
except AltiumError as exc:
|
|
18
|
+
raise ToolError(str(exc)) from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def altium_project(args: dict[str, Any], ctx: ToolContext) -> dict:
|
|
22
|
+
return load_for_tool(args, ctx).summary()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def altium_connections(args: dict[str, Any], ctx: ToolContext) -> dict:
|
|
26
|
+
from traceforge.altium.project import clean_pin_name
|
|
27
|
+
|
|
28
|
+
project = load_for_tool(args, ctx)
|
|
29
|
+
comps = project.components()
|
|
30
|
+
out: dict[str, Any] = {}
|
|
31
|
+
|
|
32
|
+
def pin_info(ref: str, number: str) -> dict:
|
|
33
|
+
found = project.find_pin(ref, number)
|
|
34
|
+
if found is None:
|
|
35
|
+
raise ToolError(f"{ref} has no pin {number!r}")
|
|
36
|
+
comp, pin = found
|
|
37
|
+
net = project.net_of(ref, pin.number)
|
|
38
|
+
info = {
|
|
39
|
+
"pin": f"{ref}.{pin.number}",
|
|
40
|
+
"name": clean_pin_name(pin.name),
|
|
41
|
+
"type": pin.electrical,
|
|
42
|
+
"part": f"{comp.comment} ({comp.footprint})",
|
|
43
|
+
"net": net or "(unconnected)",
|
|
44
|
+
}
|
|
45
|
+
if net:
|
|
46
|
+
info["connected_to"] = [
|
|
47
|
+
f"{r}.{n}" for r, n in project.netlist.nets[net] if (r, n) != (ref, pin.number)
|
|
48
|
+
]
|
|
49
|
+
return info
|
|
50
|
+
|
|
51
|
+
if args.get("pin"):
|
|
52
|
+
ref, _, number = str(args["pin"]).partition(".")
|
|
53
|
+
out.update(pin_info(ref, number))
|
|
54
|
+
if args.get("part"):
|
|
55
|
+
ref = str(args["part"])
|
|
56
|
+
comp = comps.get(ref)
|
|
57
|
+
if comp is None:
|
|
58
|
+
raise ToolError(f"no part {ref!r}")
|
|
59
|
+
out["part"] = {
|
|
60
|
+
"ref": ref,
|
|
61
|
+
"value": comp.comment,
|
|
62
|
+
"footprint": comp.footprint,
|
|
63
|
+
"pins": [
|
|
64
|
+
{
|
|
65
|
+
"pin": p.number,
|
|
66
|
+
"name": clean_pin_name(p.name),
|
|
67
|
+
"type": p.electrical,
|
|
68
|
+
"net": project.net_of(ref, p.number) or "(unconnected)",
|
|
69
|
+
}
|
|
70
|
+
for p in sorted(comp.pins, key=lambda p: (len(p.number), p.number))
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
if args.get("net"):
|
|
74
|
+
name = str(args["net"])
|
|
75
|
+
pins = project.netlist.nets.get(name)
|
|
76
|
+
if pins is None:
|
|
77
|
+
raise ToolError(f"no net {name!r}; nets: {', '.join(list(project.netlist.nets)[:40])}")
|
|
78
|
+
out["net"] = {"name": name, "pins": [f"{r}.{n}" for r, n in pins]}
|
|
79
|
+
if not out:
|
|
80
|
+
raise ToolError("give pin='U1.14', part='U1' or net='GND'")
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def altium_bom(args: dict[str, Any], ctx: ToolContext) -> dict:
|
|
85
|
+
from traceforge.altium.project import bom_rows
|
|
86
|
+
|
|
87
|
+
return {"rows": bom_rows(load_for_tool(args, ctx))}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def altium_review(args: dict[str, Any], ctx: ToolContext) -> dict:
|
|
91
|
+
"""Run the built-in schematic and schematic-vs-board checks on the converted models."""
|
|
92
|
+
from traceforge import checks
|
|
93
|
+
from traceforge.altium.model import to_board, to_schematic
|
|
94
|
+
|
|
95
|
+
project = load_for_tool(args, ctx)
|
|
96
|
+
schematic = to_schematic(project)
|
|
97
|
+
findings = checks.schematic_checks(schematic)
|
|
98
|
+
board = to_board(project)
|
|
99
|
+
if board is not None:
|
|
100
|
+
findings += checks.parity_checks(schematic, board)
|
|
101
|
+
return {"project": project.name, **checks.summarize(findings)}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def altium_convert(args: dict[str, Any], ctx: ToolContext) -> dict:
|
|
105
|
+
from traceforge.altium.outputs import convert
|
|
106
|
+
|
|
107
|
+
project = load_for_tool(args, ctx)
|
|
108
|
+
out = ctx.resolve(args.get("out_dir") or "outputs/altium", for_write=True)
|
|
109
|
+
return convert(project, out)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_PROJECT = {"project": {"type": "string", "description": "a .PrjPcb, .SchDoc or folder"}}
|
|
113
|
+
|
|
114
|
+
ALTIUM_TOOLS = [
|
|
115
|
+
Tool(
|
|
116
|
+
"altium_project",
|
|
117
|
+
"Overview of an Altium Designer / CircuitMaker project (.PrjPcb, .SchDoc, .PcbDoc): "
|
|
118
|
+
"sheets, parts, pins, nets, board layers, tracks, vias, outline. Use it when the "
|
|
119
|
+
"workspace has Altium files instead of KiCad.",
|
|
120
|
+
{"type": "object", "properties": _PROJECT},
|
|
121
|
+
altium_project,
|
|
122
|
+
ToolKind.READ,
|
|
123
|
+
),
|
|
124
|
+
Tool(
|
|
125
|
+
"altium_connections",
|
|
126
|
+
"Connectivity of an Altium project: pin='U1.14' -> name, type, net and the other pins "
|
|
127
|
+
"on it; part='U1' -> every pin with its net; net='GND' -> its pins.",
|
|
128
|
+
{
|
|
129
|
+
"type": "object",
|
|
130
|
+
"properties": {
|
|
131
|
+
**_PROJECT,
|
|
132
|
+
"pin": {"type": "string"},
|
|
133
|
+
"part": {"type": "string"},
|
|
134
|
+
"net": {"type": "string"},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
altium_connections,
|
|
138
|
+
ToolKind.READ,
|
|
139
|
+
),
|
|
140
|
+
Tool(
|
|
141
|
+
"altium_bom",
|
|
142
|
+
"Bill of materials of an Altium project, identical parts grouped, with MPN and LCSC "
|
|
143
|
+
"numbers from the component parameters.",
|
|
144
|
+
{"type": "object", "properties": _PROJECT},
|
|
145
|
+
altium_bom,
|
|
146
|
+
ToolKind.READ,
|
|
147
|
+
),
|
|
148
|
+
Tool(
|
|
149
|
+
"altium_review",
|
|
150
|
+
"Built-in checks (annotation, power, labels, schematic-vs-board parity) on an Altium "
|
|
151
|
+
"project.",
|
|
152
|
+
{"type": "object", "properties": _PROJECT},
|
|
153
|
+
altium_review,
|
|
154
|
+
ToolKind.READ,
|
|
155
|
+
),
|
|
156
|
+
Tool(
|
|
157
|
+
"altium_convert",
|
|
158
|
+
"Write a KiCad netlist (.net) and BOM (.csv) from an Altium project.",
|
|
159
|
+
{
|
|
160
|
+
"type": "object",
|
|
161
|
+
"properties": {**_PROJECT, "out_dir": {"type": "string"}},
|
|
162
|
+
},
|
|
163
|
+
altium_convert,
|
|
164
|
+
ToolKind.WRITE,
|
|
165
|
+
),
|
|
166
|
+
]
|