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
traceforge/skills.py ADDED
@@ -0,0 +1,131 @@
1
+ """Skills: reusable, on-demand instructions for recurring kinds of work.
2
+
3
+ A skill is a folder with a ``SKILL.md`` file that starts with YAML front matter
4
+ (``name`` and ``description``) followed by Markdown instructions, plus any
5
+ supporting files (checklists, scripts, templates). Skills are found, in order of
6
+ precedence (later wins on a name clash):
7
+
8
+ 1. built in, shipped with Traceforge (``traceforge/skill_library/``),
9
+ 2. the user's, in ``~/.config/traceforge/skills/<name>/SKILL.md``,
10
+ 3. the project's, in ``.traceforge/skills/<name>/SKILL.md``.
11
+
12
+ Only names and descriptions go into the system prompt. The agent loads the full
13
+ instructions with the ``load_skill`` tool when a task matches (progressive
14
+ disclosure), so unused skills cost almost nothing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass
21
+ from importlib import resources
22
+ from pathlib import Path
23
+
24
+ import yaml
25
+
26
+ from traceforge.config import config_home
27
+
28
+ MAX_SKILLS = 64
29
+ MAX_SKILL_BYTES = 64 * 1024
30
+ MAX_DESCRIPTION = 500
31
+ NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
32
+ FRONT_MATTER = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Skill:
37
+ name: str
38
+ description: str
39
+ path: Path # the SKILL.md file
40
+ source: str # built-in | user | project
41
+
42
+ @property
43
+ def folder(self) -> Path:
44
+ return self.path.parent
45
+
46
+ def body(self) -> str:
47
+ text = self.path.read_text(encoding="utf-8")
48
+ match = FRONT_MATTER.match(text)
49
+ return text[match.end() :].strip() if match else text.strip()
50
+
51
+ def files(self) -> list[str]:
52
+ return sorted(
53
+ p.relative_to(self.folder).as_posix()
54
+ for p in self.folder.rglob("*")
55
+ if p.is_file() and p.name != "SKILL.md" and "__pycache__" not in p.parts
56
+ )[:100]
57
+
58
+
59
+ def parse_skill(path: Path, source: str) -> tuple[Skill | None, str | None]:
60
+ """Read one SKILL.md; returns (skill, None) or (None, problem)."""
61
+ folder = path.parent.name
62
+ try:
63
+ if path.stat().st_size > MAX_SKILL_BYTES:
64
+ return None, f"{folder}: SKILL.md is larger than {MAX_SKILL_BYTES} bytes"
65
+ text = path.read_text(encoding="utf-8")
66
+ except OSError as exc:
67
+ return None, f"{folder}: {exc}"
68
+ match = FRONT_MATTER.match(text)
69
+ if not match:
70
+ return None, f"{folder}: SKILL.md has no YAML front matter"
71
+ try:
72
+ meta = yaml.safe_load(match.group(1)) or {}
73
+ except yaml.YAMLError:
74
+ return None, f"{folder}: invalid YAML front matter"
75
+ if not isinstance(meta, dict):
76
+ return None, f"{folder}: invalid front matter"
77
+ name = str(meta.get("name") or folder).strip().lower()
78
+ description = " ".join(str(meta.get("description") or "").split())[:MAX_DESCRIPTION]
79
+ if not NAME.match(name):
80
+ return None, f"{folder}: invalid skill name {name!r}"
81
+ if not description:
82
+ return None, f"{folder}: missing description"
83
+ return Skill(name, description, path, source), None
84
+
85
+
86
+ def _roots(workspace: Path | None) -> list[tuple[Path, str]]:
87
+ roots: list[tuple[Path, str]] = []
88
+ try:
89
+ builtin = Path(str(resources.files("traceforge").joinpath("skill_library")))
90
+ roots.append((builtin, "built-in"))
91
+ except (ModuleNotFoundError, TypeError): # pragma: no cover
92
+ pass
93
+ roots.append((config_home() / "skills", "user"))
94
+ if workspace is not None:
95
+ roots.append((Path(workspace) / ".traceforge" / "skills", "project"))
96
+ return roots
97
+
98
+
99
+ def discover(workspace: Path | None) -> tuple[dict[str, Skill], list[str]]:
100
+ skills: dict[str, Skill] = {}
101
+ problems: list[str] = []
102
+ for root, source in _roots(workspace):
103
+ if not root.is_dir():
104
+ continue
105
+ for folder in sorted(root.iterdir()):
106
+ file = folder / "SKILL.md"
107
+ if not folder.is_dir() or not file.is_file():
108
+ continue
109
+ skill, problem = parse_skill(file, source)
110
+ if problem:
111
+ problems.append(f"{source} skill {problem}")
112
+ elif skill is not None:
113
+ skills[skill.name] = skill
114
+ if len(skills) >= MAX_SKILLS:
115
+ break
116
+ return skills, problems
117
+
118
+
119
+ def prompt_section(skills: dict[str, Skill]) -> str:
120
+ if not skills:
121
+ return ""
122
+ lines = [
123
+ "# Skills",
124
+ "",
125
+ "These skills hold tested instructions for recurring tasks. When a request matches one, "
126
+ "call `load_skill` with its name before you start, then follow it.",
127
+ "",
128
+ ]
129
+ for skill in sorted(skills.values(), key=lambda s: s.name):
130
+ lines.append(f"- {skill.name} ({skill.source}): {skill.description}")
131
+ return "\n".join(lines)
traceforge/thermal.py ADDED
@@ -0,0 +1,557 @@
1
+ """First-order thermal analysis of a KiCad design.
2
+
3
+ Per part: estimated dissipation, copper area around it (pours on every layer, tracks of the heat-sinking
4
+ nets, thermal vias), a junction temperature estimate Tj = Ta + P * theta_JA and mitigation advice.
5
+
6
+ This is a screening model, not a simulation. See ``LIMITS``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from traceforge import sexpr
17
+ from traceforge.kicad.pcb import Board, Footprint
18
+ from traceforge.sexpr import find, find_all, num, value
19
+ from traceforge.validate import circuits
20
+ from traceforge.validate.netmodel import DesignModel, Part
21
+
22
+ LIMITS = (
23
+ "Screening estimate only: steady-state, single part, still air, no neighbour heating or enclosure. "
24
+ "theta_JA comes from the datasheet when given, otherwise from a typical per-package table (JEDEC "
25
+ "minimum-pad value) scaled by copper area with an empirical power law that is only credible within "
26
+ "about +/-40 percent. Copper area counts pours, tracks and pads of the heat-sinking nets inside a square "
27
+ "window around the part, summed over layers; it ignores pour shape, thermal reliefs and via count beyond "
28
+ "a simple tally. Load currents of ICs are not guessed: give them with 'loads' or 'rail_current_ma', "
29
+ "otherwise only the loads that can be derived from the schematic (resistor and LED chains) are counted. "
30
+ "Linear-regulator dissipation excludes quiescent current unless 'iq_ma' is given. Verify with the datasheet "
31
+ "thermal curves and a measurement before relying on it."
32
+ )
33
+
34
+
35
+ # Typical junction-to-ambient thermal resistance (K/W) on a minimum-copper JEDEC board, the copper
36
+ # area (mm2) that value assumes, and whether the package spreads heat into copper through a tab or pad.
37
+ @dataclass(frozen=True)
38
+ class PackageThermal:
39
+ pattern: str
40
+ theta_ja: float
41
+ ref_area_mm2: float
42
+ spreads: bool
43
+
44
+
45
+ PACKAGE_TABLE: tuple[PackageThermal, ...] = (
46
+ PackageThermal(r"SOT-?223", 90.0, 100.0, True),
47
+ PackageThermal(r"TO-?263|D2PAK", 60.0, 150.0, True),
48
+ PackageThermal(r"TO-?252|DPAK", 80.0, 100.0, True),
49
+ PackageThermal(r"TO-?220", 50.0, 0.0, False),
50
+ PackageThermal(r"SOT-?89", 150.0, 60.0, True),
51
+ PackageThermal(r"SOT-?23", 220.0, 20.0, False),
52
+ PackageThermal(r"SC-?70|SOT-?323|SOT-?363", 300.0, 10.0, False),
53
+ PackageThermal(r"HSOP|SOIC.*EP|SO.*PowerPAD|ESOP", 50.0, 150.0, True),
54
+ PackageThermal(r"SOIC-?16|SO-?16", 100.0, 0.0, False),
55
+ PackageThermal(r"SOIC|SOP-?8|SO-?8", 130.0, 0.0, False),
56
+ PackageThermal(r"TSSOP|MSOP|SSOP", 110.0, 0.0, False),
57
+ PackageThermal(r"QFN-?(?:16|20|24)|DFN|WSON|VSON|LGA", 55.0, 100.0, True),
58
+ PackageThermal(r"QFN|VQFN|WQFN", 35.0, 150.0, True),
59
+ PackageThermal(r"LQFP|TQFP|QFP", 55.0, 0.0, False),
60
+ PackageThermal(r"BGA|CSP", 45.0, 0.0, False),
61
+ PackageThermal(r"DIP|PDIP", 100.0, 0.0, False),
62
+ PackageThermal(r"SMA|SMB|SMC|SOD-?123|SOD-?323", 100.0, 40.0, True),
63
+ PackageThermal(r"2512", 70.0, 60.0, True),
64
+ PackageThermal(r"1206", 100.0, 20.0, True),
65
+ PackageThermal(r"0805", 180.0, 10.0, True),
66
+ PackageThermal(r"0603", 250.0, 6.0, True),
67
+ PackageThermal(r"0402", 350.0, 3.0, True),
68
+ )
69
+ DEFAULT_THETA = PackageThermal("", 120.0, 0.0, False)
70
+
71
+ _THETA_RE = re.compile(
72
+ r"(?:R?\s*(?:θ|Θ|theta|Theta)\s*_?\s*\(?\s*(?:JA|J-A)\)?|junction[- ]to[- ]ambient)[^0-9\n]{0,60}"
73
+ r"(\d{2,3}(?:\.\d+)?)\s*(?:°\s*C\s*/\s*W|K\s*/\s*W|C\s*/\s*W)",
74
+ re.I,
75
+ )
76
+ _SWITCHER = re.compile(
77
+ r"TPS5|TPS6|LM2596|LM2576|MP1584|MP2307|MP2315|AP63|AP62|XL4015|XL1509|MT3608|LMR\d|RT6\d|SY8\d|"
78
+ r"TLV62|TLV61|TPS54|BUCK|BOOST|SWITCHER",
79
+ re.I,
80
+ )
81
+ _MOSFET = re.compile(r"MOSFET|^Q$|AO3400|AO3401|IRLML|SI23|DMN|DMP|IRF|2N7002|BSS138", re.I)
82
+
83
+
84
+ def extract_theta_ja(pages: list[str]) -> tuple[float, int] | None:
85
+ """(theta_JA in K/W, page number) from datasheet page texts, or None."""
86
+ for number, text in enumerate(pages, start=1):
87
+ match = _THETA_RE.search(text)
88
+ if match:
89
+ return float(match.group(1)), number
90
+ return None
91
+
92
+
93
+ def package_thermal(footprint: str) -> PackageThermal:
94
+ for entry in PACKAGE_TABLE:
95
+ if re.search(entry.pattern, footprint, re.I):
96
+ return entry
97
+ return DEFAULT_THETA
98
+
99
+
100
+ # ----------------------------------------------------------------------------- copper
101
+
102
+
103
+ def _clip_area(points: list[tuple[float, float]], box: tuple[float, float, float, float]) -> float:
104
+ """Area of a polygon clipped to an axis-aligned box (Sutherland-Hodgman)."""
105
+ x0, y0, x1, y1 = box
106
+
107
+ def clip(poly, inside, cross):
108
+ out = []
109
+ for i, cur in enumerate(poly):
110
+ prev = poly[i - 1]
111
+ if inside(cur):
112
+ if not inside(prev):
113
+ out.append(cross(prev, cur))
114
+ out.append(cur)
115
+ elif inside(prev):
116
+ out.append(cross(prev, cur))
117
+ return out
118
+
119
+ def at_x(x):
120
+ return lambda a, b: (x, a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0]))
121
+
122
+ def at_y(y):
123
+ return lambda a, b: (a[0] + (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]), y)
124
+
125
+ poly = points
126
+ for inside, cross in (
127
+ (lambda p: p[0] >= x0, at_x(x0)),
128
+ (lambda p: p[0] <= x1, at_x(x1)),
129
+ (lambda p: p[1] >= y0, at_y(y0)),
130
+ (lambda p: p[1] <= y1, at_y(y1)),
131
+ ):
132
+ if not poly:
133
+ return 0.0
134
+ poly = clip(poly, inside, cross)
135
+ if len(poly) < 3:
136
+ return 0.0
137
+ return (
138
+ abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(poly, poly[1:] + poly[:1], strict=True)))
139
+ / 2
140
+ )
141
+
142
+
143
+ def filled_polygons(path: Path) -> list[tuple[str, str, list[tuple[float, float]]]]:
144
+ """(net, layer, outline points) of every filled zone polygon in a board file."""
145
+ root = sexpr.parse(path.read_text(encoding="utf-8"))
146
+ found = []
147
+ for zone in find_all(root, "zone"):
148
+ net = str(value(zone, "net_name", ""))
149
+ for poly in find_all(zone, "filled_polygon"):
150
+ layer = str(value(poly, "layer", ""))
151
+ pts = find(poly, "pts")
152
+ if pts is None:
153
+ continue
154
+ points = [
155
+ (num(p[1]), num(p[2])) for p in pts[1:] if isinstance(p, list) and len(p) >= 3
156
+ ]
157
+ if len(points) >= 3:
158
+ found.append((net, layer, points))
159
+ return found
160
+
161
+
162
+ @dataclass
163
+ class Copper:
164
+ nets: list[str] = field(default_factory=list)
165
+ by_layer_mm2: dict[str, float] = field(default_factory=dict)
166
+ total_mm2: float = 0.0
167
+ thermal_vias: int = 0
168
+ window_mm: float = 0.0
169
+
170
+
171
+ def copper_around(
172
+ board: Board,
173
+ footprint: Footprint,
174
+ polygons: list[tuple[str, str, list[tuple[float, float]]]],
175
+ *,
176
+ window_mm: float = 10.0,
177
+ ) -> Copper:
178
+ """Copper of the part's heat-sinking nets inside a window centred on the part.
179
+
180
+ Heat-sinking nets are those of pads at least half as large as the largest pad (tab or exposed pad)."""
181
+ area = lambda p: p.width * p.height # noqa: E731
182
+ biggest = max((area(p) for p in footprint.pads), default=0.0)
183
+ nets = sorted({p.net for p in footprint.pads if p.net and area(p) >= 0.5 * biggest})
184
+ box = (
185
+ footprint.x - window_mm,
186
+ footprint.y - window_mm,
187
+ footprint.x + window_mm,
188
+ footprint.y + window_mm,
189
+ )
190
+ by_layer: dict[str, float] = {}
191
+ for net, layer, points in polygons:
192
+ if net in nets:
193
+ by_layer[layer] = by_layer.get(layer, 0.0) + _clip_area(points, box)
194
+ for track in board.tracks:
195
+ if track.net in nets and (box[0] <= track.x1 <= box[2] and box[1] <= track.y1 <= box[3]):
196
+ by_layer[track.layer] = by_layer.get(track.layer, 0.0) + track.length * track.width
197
+ for pad in footprint.pads:
198
+ if pad.net in nets:
199
+ for layer in pad.layers:
200
+ if layer.endswith(".Cu"):
201
+ by_layer[layer] = by_layer.get(layer, 0.0) + area(pad)
202
+ vias = sum(
203
+ 1
204
+ for v in board.vias
205
+ if v.net in nets
206
+ and abs(v.x - footprint.x) <= window_mm / 2
207
+ and abs(v.y - footprint.y) <= window_mm / 2
208
+ )
209
+ return Copper(
210
+ nets,
211
+ {k: round(v, 1) for k, v in by_layer.items()},
212
+ round(sum(by_layer.values()), 1),
213
+ vias,
214
+ window_mm,
215
+ )
216
+
217
+
218
+ def effective_theta(entry: PackageThermal, copper_mm2: float | None) -> tuple[float, str]:
219
+ """theta_JA after scaling the table value to the actual copper area (heat-spreading packages)."""
220
+ if not entry.spreads or entry.ref_area_mm2 <= 0 or not copper_mm2:
221
+ return entry.theta_ja, "package table (no copper scaling)"
222
+ ratio = max(0.25, min(8.0, copper_mm2 / entry.ref_area_mm2))
223
+ factor = max(0.45, min(1.6, ratio**-0.3))
224
+ return round(entry.theta_ja * factor, 1), f"package table scaled by copper area (x{factor:.2f})"
225
+
226
+
227
+ # ------------------------------------------------------------------------- dissipation
228
+
229
+
230
+ @dataclass
231
+ class PartThermal:
232
+ ref: str
233
+ kind: str
234
+ power_mw: float
235
+ basis: str
236
+ footprint: str = ""
237
+ theta_ja: float | None = None
238
+ theta_source: str = ""
239
+ copper: Copper | None = None
240
+ tj_c: float | None = None
241
+ tj_max_c: float = 125.0
242
+ margin_c: float | None = None
243
+ severity: str = ""
244
+ advice: list[str] = field(default_factory=list)
245
+
246
+ def to_dict(self) -> dict:
247
+ data = {
248
+ "ref": self.ref,
249
+ "kind": self.kind,
250
+ "power_mw": round(self.power_mw, 2),
251
+ "basis": self.basis,
252
+ "footprint": self.footprint,
253
+ "theta_ja_k_per_w": self.theta_ja,
254
+ "theta_source": self.theta_source,
255
+ "junction_c": None if self.tj_c is None else round(self.tj_c, 1),
256
+ "tj_max_c": self.tj_max_c,
257
+ "margin_c": None if self.margin_c is None else round(self.margin_c, 1),
258
+ "severity": self.severity,
259
+ "advice": self.advice,
260
+ }
261
+ if self.copper is not None:
262
+ data["copper"] = {
263
+ "nets": self.copper.nets,
264
+ "total_mm2": self.copper.total_mm2,
265
+ "by_layer_mm2": self.copper.by_layer_mm2,
266
+ "thermal_vias": self.copper.thermal_vias,
267
+ "window_mm": self.copper.window_mm,
268
+ }
269
+ return data
270
+
271
+
272
+ def linear_regulator_mw(vin: float, vout: float, iout_a: float, iq_a: float = 0.0) -> float:
273
+ return ((vin - vout) * iout_a + vin * iq_a) * 1000.0
274
+
275
+
276
+ def switching_regulator_mw(vout: float, iout_a: float, efficiency: float) -> float:
277
+ return vout * iout_a * (1.0 / efficiency - 1.0) * 1000.0
278
+
279
+
280
+ def resistor_mw(ohms: float, amps: float | None = None, volts: float | None = None) -> float:
281
+ if amps is not None:
282
+ return amps * amps * ohms * 1000.0
283
+ return (volts or 0.0) ** 2 / ohms * 1000.0
284
+
285
+
286
+ def mosfet_mw(rds_on_ohm: float, amps: float) -> float:
287
+ return amps * amps * rds_on_ohm * 1000.0
288
+
289
+
290
+ def _pin_nets(model: DesignModel, part: Part) -> list[str]:
291
+ return sorted(set(model.part_nets(part.ref).values()))
292
+
293
+
294
+ def rail_currents(
295
+ model: DesignModel,
296
+ volts: dict[str, float],
297
+ overrides_ma: dict[str, float],
298
+ loads_ma: dict[str, float],
299
+ ) -> tuple[dict[str, float], list[str]]:
300
+ """Current (A) drawn from each rail by the loads we can derive, plus a list of unaccounted consumers."""
301
+ totals: dict[str, float] = {}
302
+ notes: list[str] = []
303
+ for net, ma in overrides_ma.items():
304
+ totals[net] = ma / 1000.0
305
+ for part in model.parts.values():
306
+ if part.dnp:
307
+ continue
308
+ nets = _pin_nets(model, part)
309
+ if part.ref in loads_ma:
310
+ for net in nets:
311
+ if volts.get(net, 0) > 0.5 and net not in overrides_ma:
312
+ totals[net] = totals.get(net, 0.0) + loads_ma[part.ref] / 1000.0
313
+ continue
314
+ if part.prefix == "R" and len(nets) == 2:
315
+ high, low = sorted(nets, key=lambda n: -volts.get(n, -99))
316
+ if volts.get(low) == 0.0 and volts.get(high, 0) > 0 and high not in overrides_ma:
317
+ ohms = circuits.parse_resistance(part.value)
318
+ if ohms:
319
+ totals[high] = totals.get(high, 0.0) + volts[high] / ohms
320
+ for part in model.parts.values():
321
+ if part.dnp or part.prefix != "R" or part.ref in loads_ma:
322
+ continue
323
+ # LED chain: rail -> R -> LED -> ground (or the reverse order)
324
+ nets = _pin_nets(model, part)
325
+ ohms = circuits.parse_resistance(part.value)
326
+ if not ohms or len(nets) != 2:
327
+ continue
328
+ for rail, mid in (nets, nets[::-1]):
329
+ if volts.get(rail, 0) <= 0 or rail in overrides_ma:
330
+ continue
331
+ leds = [
332
+ model.parts[r]
333
+ for r in model.refs_on(mid)
334
+ if r in model.parts and circuits.is_led(model.parts[r])
335
+ ]
336
+ if len(leds) == 1:
337
+ led = leds[0]
338
+ vf = circuits.led_color(led).vf_typ
339
+ current = max(0.0, (volts[rail] - vf) / ohms)
340
+ loads_ma.setdefault(f"__led_{part.ref}", current * 1000.0)
341
+ totals[rail] = totals.get(rail, 0.0) + current
342
+ break
343
+ for part in model.parts.values():
344
+ if circuits.is_ic(part) and not circuits.is_regulator(part) and part.ref not in loads_ma:
345
+ notes.append(part.ref)
346
+ return totals, notes
347
+
348
+
349
+ def analyse(
350
+ model: DesignModel,
351
+ board: Board,
352
+ *,
353
+ ambient_c: float = 25.0,
354
+ loads_ma: dict[str, float] | None = None,
355
+ rail_current_ma: dict[str, float] | None = None,
356
+ power_mw: dict[str, float] | None = None,
357
+ theta_ja: dict[str, float] | None = None,
358
+ iq_ma: dict[str, float] | None = None,
359
+ rds_on_mohm: dict[str, float] | None = None,
360
+ efficiency: float = 0.85,
361
+ window_mm: float = 10.0,
362
+ tj_max_c: float = 125.0,
363
+ min_report_mw: float = 20.0,
364
+ datasheet_theta: dict[str, tuple[float, str]] | None = None,
365
+ ) -> dict[str, Any]:
366
+ loads_ma = dict(loads_ma or {})
367
+ volts = circuits.infer_voltages(model)
368
+ currents, unaccounted = rail_currents(model, volts, dict(rail_current_ma or {}), loads_ma)
369
+ polygons = filled_polygons(board.path)
370
+ results: list[PartThermal] = []
371
+ assumptions: list[str] = []
372
+
373
+ def add(part: Part, kind: str, mw: float, basis: str) -> None:
374
+ fp = model.footprint(part.ref)
375
+ entry = package_thermal(part.footprint or (fp.lib_id if fp else ""))
376
+ item = PartThermal(part.ref, kind, mw, basis, part.footprint, tj_max_c=tj_max_c)
377
+ copper = copper_around(board, fp, polygons, window_mm=window_mm) if fp else None
378
+ item.copper = copper
379
+ if theta_ja and part.ref in theta_ja:
380
+ item.theta_ja, item.theta_source = theta_ja[part.ref], "user value"
381
+ elif datasheet_theta and part.ref in datasheet_theta:
382
+ value_, page = datasheet_theta[part.ref]
383
+ item.theta_ja, item.theta_source = value_, f"datasheet page {page}"
384
+ else:
385
+ item.theta_ja, item.theta_source = effective_theta(
386
+ entry, copper.total_mm2 if copper else None
387
+ )
388
+ item.tj_c = ambient_c + mw / 1000.0 * item.theta_ja
389
+ item.margin_c = tj_max_c - item.tj_c
390
+ item.severity = "P1" if item.margin_c < 0 else "P2" if item.margin_c < 25 else "ok"
391
+ item.advice = mitigation(item, entry)
392
+ results.append(item)
393
+
394
+ for part in model.parts.values():
395
+ if part.dnp:
396
+ continue
397
+ ref = part.ref
398
+ if power_mw and ref in power_mw:
399
+ add(part, "user", power_mw[ref], "power given by user")
400
+ elif circuits.is_regulator(part):
401
+ vout = circuits.regulator_output(part)
402
+ ins, outs = circuits.regulator_pins(part)
403
+ in_net = next((model.net_of(ref, p) for p in ins if model.net_of(ref, p)), None)
404
+ out_net = next((model.net_of(ref, p) for p in outs if model.net_of(ref, p)), None)
405
+ vin = volts.get(in_net or "")
406
+ vout = vout if vout is not None else volts.get(out_net or "")
407
+ iout = currents.get(out_net or "")
408
+ if ref in loads_ma and out_net is None:
409
+ iout = loads_ma[ref] / 1000.0
410
+ if vin is None or vout is None or iout is None:
411
+ assumptions.append(
412
+ f"{ref}: regulator vin/vout/load unknown; give rail_current_ma or power_mw"
413
+ )
414
+ continue
415
+ iq = (iq_ma or {}).get(ref, 0.0) / 1000.0
416
+ if _SWITCHER.search(part.text):
417
+ eff = efficiency
418
+ add(
419
+ part,
420
+ "switching regulator",
421
+ switching_regulator_mw(vout, iout, eff),
422
+ f"Pout*(1/eta-1): {vout:g} V x {iout * 1000:.1f} mA, eta {eff:g} (assumed)",
423
+ )
424
+ else:
425
+ add(
426
+ part,
427
+ "linear regulator",
428
+ linear_regulator_mw(vin, vout, iout, iq),
429
+ f"(Vin-Vout)*Iout: ({vin:g}-{vout:g}) V x {iout * 1000:.1f} mA"
430
+ + (f" + Vin*Iq {iq * 1000:g} mA" if iq else ""),
431
+ )
432
+ elif circuits.is_led(part):
433
+ res = [
434
+ r
435
+ for r in model.parts.values()
436
+ if r.prefix == "R" and set(_pin_nets(model, r)) & set(_pin_nets(model, part))
437
+ ]
438
+ current = loads_ma.get(ref)
439
+ vf = circuits.led_color(part).vf_typ
440
+ if current is None:
441
+ for r in res:
442
+ ohms = circuits.parse_resistance(r.value)
443
+ others = [n for n in _pin_nets(model, r) if n not in _pin_nets(model, part)]
444
+ if ohms and others and volts.get(others[0], 0) > vf:
445
+ current = (volts[others[0]] - vf) / ohms * 1000.0
446
+ break
447
+ if current:
448
+ add(
449
+ part,
450
+ "LED",
451
+ vf * current,
452
+ f"Vf*If: {vf:g} V x {current:.1f} mA (Vf typical for colour)",
453
+ )
454
+ elif part.prefix == "R":
455
+ ohms = circuits.parse_resistance(part.value)
456
+ nets = _pin_nets(model, part)
457
+ if not ohms or len(nets) != 2:
458
+ continue
459
+ amps = (loads_ma.get(ref) or 0.0) / 1000.0 or None
460
+ va, vb = volts.get(nets[0]), volts.get(nets[1])
461
+ if amps is not None:
462
+ add(
463
+ part,
464
+ "resistor",
465
+ resistor_mw(ohms, amps=amps),
466
+ f"I^2*R: {amps * 1000:g} mA, {ohms:g} ohm",
467
+ )
468
+ elif va is not None and vb is not None and va != vb:
469
+ add(
470
+ part,
471
+ "resistor",
472
+ resistor_mw(ohms, volts=abs(va - vb)),
473
+ f"V^2/R: {abs(va - vb):g} V across {ohms:g} ohm",
474
+ )
475
+ else:
476
+ led_current = _led_chain_current(model, volts, part, ohms)
477
+ if led_current:
478
+ add(
479
+ part,
480
+ "resistor",
481
+ resistor_mw(ohms, amps=led_current),
482
+ f"I^2*R: {led_current * 1000:.1f} mA LED chain, {ohms:g} ohm",
483
+ )
484
+ elif _MOSFET.search(part.text) or (part.prefix == "Q" and "MOS" in part.text):
485
+ rds = (rds_on_mohm or {}).get(ref)
486
+ amps = (loads_ma.get(ref) or 0.0) / 1000.0
487
+ if rds and amps:
488
+ add(
489
+ part,
490
+ "MOSFET",
491
+ mosfet_mw(rds / 1000.0, amps),
492
+ f"I^2*Rds(on): {amps * 1000:g} mA, {rds:g} mohm",
493
+ )
494
+ else:
495
+ assumptions.append(
496
+ f"{ref}: MOSFET needs rds_on_mohm and loads (mA) to estimate dissipation"
497
+ )
498
+ results = [r for r in results if r.power_mw >= min_report_mw or r.severity != "ok"]
499
+ results.sort(key=lambda r: -(r.tj_c or 0))
500
+ hotspots = [r.ref for r in results if r.severity != "ok"]
501
+ if unaccounted:
502
+ assumptions.append(
503
+ "IC supply currents not counted unless given in 'loads': " + ", ".join(unaccounted[:12])
504
+ )
505
+ return {
506
+ "ambient_c": ambient_c,
507
+ "parts": [r.to_dict() for r in results],
508
+ "hotspots": hotspots,
509
+ "rail_current_ma": {n: round(a * 1000, 2) for n, a in sorted(currents.items())},
510
+ "assumptions": assumptions,
511
+ "limits": LIMITS,
512
+ }
513
+
514
+
515
+ def _led_chain_current(
516
+ model: DesignModel, volts: dict[str, float], res: Part, ohms: float
517
+ ) -> float | None:
518
+ nets = _pin_nets(model, res)
519
+ for rail, mid in (nets, nets[::-1]):
520
+ if volts.get(rail, 0) <= 0:
521
+ continue
522
+ leds = [
523
+ model.parts[r]
524
+ for r in model.refs_on(mid)
525
+ if r in model.parts and circuits.is_led(model.parts[r])
526
+ ]
527
+ if len(leds) == 1:
528
+ return max(0.0, (volts[rail] - circuits.led_color(leds[0]).vf_typ) / ohms)
529
+ return None
530
+
531
+
532
+ def mitigation(item: PartThermal, entry: PackageThermal) -> list[str]:
533
+ if item.severity == "ok":
534
+ return []
535
+ advice = []
536
+ copper = item.copper.total_mm2 if item.copper else 0.0
537
+ if entry.spreads:
538
+ target = max(entry.ref_area_mm2 * 4, copper * 2, 400)
539
+ advice.append(
540
+ f"Increase copper on the heat-sinking pad/tab net ({', '.join(item.copper.nets) if item.copper else '?'}): "
541
+ f"now about {copper:.0f} mm2 within {item.copper.window_mm:g} mm, aim for 400+ mm2 (>= {target:.0f})."
542
+ )
543
+ if item.copper is None or item.copper.thermal_vias < 4:
544
+ advice.append(
545
+ "Add 4 to 9 thermal vias (0.3 mm drill) under or beside the pad to an inner/bottom pour."
546
+ )
547
+ else:
548
+ advice.append(
549
+ "This package has little copper coupling: move the part away from other heat sources and pour copper on its power pins."
550
+ )
551
+ advice.append(
552
+ f"Reduce dissipation ({item.power_mw:.0f} mW): lower the input-output drop (pre-regulator or buck), "
553
+ "spread the load over parts, or use a switching stage."
554
+ if item.kind == "linear regulator"
555
+ else f"Reduce dissipation ({item.power_mw:.0f} mW) or choose a package with a lower theta_JA."
556
+ )
557
+ return advice