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
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
|