wobbl 0.1.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.
- wobbl/__init__.py +16 -0
- wobbl/__main__.py +4 -0
- wobbl/ask.py +177 -0
- wobbl/circuits/__init__.py +1 -0
- wobbl/circuits/run.py +270 -0
- wobbl/circuits/templates.py +184 -0
- wobbl/circuits/tools.py +93 -0
- wobbl/cli/__init__.py +1 -0
- wobbl/cli/admin.py +177 -0
- wobbl/cli/circuits.py +73 -0
- wobbl/cli/main.py +438 -0
- wobbl/cli/thermal.py +90 -0
- wobbl/cli/ui.py +93 -0
- wobbl/fe.py +141 -0
- wobbl/fix/__init__.py +1 -0
- wobbl/fix/apply.py +245 -0
- wobbl/fix/cad_edit.py +115 -0
- wobbl/fix/suggest.py +178 -0
- wobbl/geometry/__init__.py +1 -0
- wobbl/geometry/cad.py +350 -0
- wobbl/geometry/faces.py +426 -0
- wobbl/geometry/parametric.py +92 -0
- wobbl/geometry/remap.py +67 -0
- wobbl/geometry/select.py +270 -0
- wobbl/materials.py +251 -0
- wobbl/mcp/__init__.py +1 -0
- wobbl/mcp/server.py +136 -0
- wobbl/mcp/tools.py +284 -0
- wobbl/mesh/__init__.py +1 -0
- wobbl/mesh/isolate.py +90 -0
- wobbl/mesh/mesher.py +696 -0
- wobbl/outcome_generic.py +94 -0
- wobbl/report/__init__.py +1 -0
- wobbl/report/html.py +129 -0
- wobbl/report/render.py +348 -0
- wobbl/setup/__init__.py +1 -0
- wobbl/setup/agents.py +240 -0
- wobbl/setup/installer.py +247 -0
- wobbl/skill/SKILL.md +64 -0
- wobbl/solvers/__init__.py +1 -0
- wobbl/solvers/base.py +115 -0
- wobbl/solvers/calculix.py +334 -0
- wobbl/solvers/locate.py +73 -0
- wobbl/solvers/ngspice.py +74 -0
- wobbl/structural/__init__.py +1 -0
- wobbl/structural/budget.py +56 -0
- wobbl/structural/build.py +286 -0
- wobbl/structural/hotspots.py +183 -0
- wobbl/structural/outcome.py +493 -0
- wobbl/structural/pipeline.py +58 -0
- wobbl/structural/report.py +101 -0
- wobbl/structural/run.py +719 -0
- wobbl/structural/spec.py +248 -0
- wobbl/thermal/__init__.py +1 -0
- wobbl/thermal/study.py +502 -0
- wobbl/thermal/tools.py +85 -0
- wobbl/units.py +212 -0
- wobbl/validation/__init__.py +1 -0
- wobbl/validation/cases.py +342 -0
- wobbl/validation/geometry.py +93 -0
- wobbl/validation/parts/README.md +16 -0
- wobbl/validation/parts/cast_motor_mount.step +7204 -0
- wobbl/validation/parts/drone_arm_truss.step +7849 -0
- wobbl/validation/parts/enclosure_shell.step +5548 -0
- wobbl/validation/parts/shelf_bracket_fillet.step +812 -0
- wobbl/validation/parts/shelf_bracket_sharp.step +812 -0
- wobbl/verify/__init__.py +1 -0
- wobbl/verify/beam.py +118 -0
- wobbl/verify/checks.py +184 -0
- wobbl-0.1.0.dist-info/METADATA +343 -0
- wobbl-0.1.0.dist-info/RECORD +74 -0
- wobbl-0.1.0.dist-info/WHEEL +4 -0
- wobbl-0.1.0.dist-info/entry_points.txt +2 -0
- wobbl-0.1.0.dist-info/licenses/LICENSE +21 -0
wobbl/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Wobbl: simulate anything before you build it.
|
|
2
|
+
|
|
3
|
+
Wobbl turns a CAD file and a plain question ("will this motor mount crack under
|
|
4
|
+
200 N?") into a verified engineering answer: it meshes the part, applies loads
|
|
5
|
+
and materials, runs a real solver (CalculiX, ngspice), checks its own work and
|
|
6
|
+
reports the number, a confidence level, the assumptions and a suggested fix.
|
|
7
|
+
|
|
8
|
+
The package is layered the same way the work flows:
|
|
9
|
+
|
|
10
|
+
domain (what you ask) -> solver adapter (who computes it)
|
|
11
|
+
-> verification (can we trust it) -> report (what you see)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
wobbl/__main__.py
ADDED
wobbl/ask.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Read a plain English question into a study, and say exactly what was assumed.
|
|
2
|
+
|
|
3
|
+
"will this motor mount crack under 200 N?" is not enough to simulate; the
|
|
4
|
+
loaded face, the direction and the supports are missing. Wobbl fills the gaps
|
|
5
|
+
with the most common reading (bolted through the holes, load on the face
|
|
6
|
+
farthest from them, pushing down) and prints every guess together with the
|
|
7
|
+
exact command that would override it. Agents should pass structured flags.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .geometry.faces import PartFeatures
|
|
17
|
+
from .materials import LIBRARY, get_material
|
|
18
|
+
from .structural.spec import Fixture, Load, StructuralStudy, parse_fixture, parse_load
|
|
19
|
+
from .units import parse_quantity
|
|
20
|
+
|
|
21
|
+
_FORCE = re.compile(r"(\d+(?:\.\d+)?)\s*(kn|n|newtons?|lbf|lbs?|pounds?|kgf|kg|kips?)\b", re.I)
|
|
22
|
+
_PRESS = re.compile(r"(\d+(?:\.\d+)?)\s*(mpa|kpa|psi|bar|pa)\b", re.I)
|
|
23
|
+
_GRAV = re.compile(r"(\d+(?:\.\d+)?)\s*g'?s?\b(?!\w)", re.I)
|
|
24
|
+
_SF = re.compile(r"safety factor (?:of |above |at least |>=? ?)?(\d+(?:\.\d+)?)", re.I)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Reading:
|
|
29
|
+
domain: str = "strength"
|
|
30
|
+
guesses: list[str] = field(default_factory=list)
|
|
31
|
+
load: str | None = None
|
|
32
|
+
fixture: str | None = None
|
|
33
|
+
material: str | None = None
|
|
34
|
+
target_sf: float | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_domain(question: str, path: str = "") -> str:
|
|
38
|
+
q = question.lower()
|
|
39
|
+
if path.lower().endswith((".cir", ".sp", ".spice", ".net")) or re.search(
|
|
40
|
+
r"\b(led|resistor|ripple|mosfet|regulator|buck|capacitor|circuit|volts?|amps?)\b", q):
|
|
41
|
+
return "circuit"
|
|
42
|
+
if re.search(r"\b(overheat|heat|hot|temperature|thermal|cool|watts?|\d+\s*w\b|celsius|°c)\b", q):
|
|
43
|
+
return "thermal"
|
|
44
|
+
return "strength"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_material(question: str) -> str | None:
|
|
48
|
+
q = question.lower()
|
|
49
|
+
names = []
|
|
50
|
+
for m in LIBRARY.values():
|
|
51
|
+
for n in (m.key, *m.aliases, m.name.lower()):
|
|
52
|
+
if len(n) >= 3 and re.search(r"(?<![\w-])" + re.escape(n) + r"(?![\w-])", q):
|
|
53
|
+
names.append((len(n), m.key))
|
|
54
|
+
if names:
|
|
55
|
+
return max(names)[1]
|
|
56
|
+
if re.search(r"\b(3d[- ]?print|printed|fdm)\b", q):
|
|
57
|
+
return "pla"
|
|
58
|
+
if re.search(r"\bsteel\b", q):
|
|
59
|
+
return "mild-steel"
|
|
60
|
+
if re.search(r"\balumin", q):
|
|
61
|
+
return "6061-t6"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _direction(q: str) -> str | None:
|
|
66
|
+
for words, d in ((r"\b(down|downward|downwards|pushing down|presses down|press down|weight|sits on|on top)\b",
|
|
67
|
+
"down"), (r"\b(up|upward|upwards|lift|lifting|pull up)\b", "up"),
|
|
68
|
+
(r"\b(sideways|side load|lateral)\b", "+x")):
|
|
69
|
+
if re.search(words, q):
|
|
70
|
+
return d
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_question(question: str, features: PartFeatures, study: StructuralStudy) -> Reading:
|
|
75
|
+
"""Fill the parts of ``study`` the user did not give explicitly, from the question and the geometry."""
|
|
76
|
+
r = Reading()
|
|
77
|
+
q = question.lower()
|
|
78
|
+
if study.material in (None, "", "auto"):
|
|
79
|
+
mat = find_material(q)
|
|
80
|
+
if mat is None:
|
|
81
|
+
mat = "6061-t6"
|
|
82
|
+
r.guesses.append("material not mentioned: assumed aluminum 6061-T6 (override with --material)")
|
|
83
|
+
r.material = mat
|
|
84
|
+
study.material = mat
|
|
85
|
+
m = _SF.search(q)
|
|
86
|
+
if m and study.target_sf is None:
|
|
87
|
+
study.target_sf = float(m.group(1))
|
|
88
|
+
if not study.fixtures:
|
|
89
|
+
fx = _guess_fixture(q, features)
|
|
90
|
+
r.guesses.append(f"supports not given: {fx[1]} (override with --fix)")
|
|
91
|
+
study.fixtures.append(parse_fixture(fx[0]))
|
|
92
|
+
if not study.loads:
|
|
93
|
+
ld = _guess_load(q, features, study)
|
|
94
|
+
if ld is None:
|
|
95
|
+
raise ValueError("the question has no load I can read. Say it like '200 N', '45 lbf', '3 g' or "
|
|
96
|
+
"'0.2 MPa', or pass --load '200 N down @ top'")
|
|
97
|
+
r.guesses.append(f"load placement: {ld[1]} (override with --load)")
|
|
98
|
+
study.loads.append(parse_load(ld[0]))
|
|
99
|
+
return r
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _guess_fixture(q: str, f: PartFeatures) -> tuple[str, str]:
|
|
103
|
+
holes = f.holes()
|
|
104
|
+
if re.search(r"\b(bolt|bolted|screw|screwed|mounted through|mounting holes?)\b", q) and holes:
|
|
105
|
+
d = _common_hole(holes)
|
|
106
|
+
return f"bolt holes d={d:.4g}", f"bolted through the {d:.3g} mm holes"
|
|
107
|
+
m = re.search(r"\b(fixed|clamped|glued|welded|bolted|mounted|held)\s+(?:at|on|to)\s+(?:the\s+)?"
|
|
108
|
+
r"(top|bottom|left|right|front|back|base)\b", q)
|
|
109
|
+
if m:
|
|
110
|
+
side = {"base": "bottom"}.get(m.group(2), m.group(2))
|
|
111
|
+
return f"fixed {side}", f"the {side} face is held still"
|
|
112
|
+
if holes:
|
|
113
|
+
d = _common_hole(holes)
|
|
114
|
+
return f"bolt holes d={d:.4g}", f"bolted through the {d:.3g} mm holes (the most common hole size)"
|
|
115
|
+
return "fixed bottom", "the bottom face is held still"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _common_hole(holes) -> float:
|
|
119
|
+
"""The most common hole size; ties go to the size whose holes sit lowest, since parts usually bolt down."""
|
|
120
|
+
ds = np.round(np.array([2 * h.radius for h in holes]), 2)
|
|
121
|
+
vals, counts = np.unique(ds, return_counts=True)
|
|
122
|
+
best = vals[counts == counts.max()]
|
|
123
|
+
if len(best) == 1:
|
|
124
|
+
return float(best[0])
|
|
125
|
+
low = {v: min(h.axis_point[2] for h, d in zip(holes, ds) if d == v) for v in best}
|
|
126
|
+
return float(min(best, key=lambda v: low[v]))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _guess_load(q: str, f: PartFeatures, study: StructuralStudy) -> tuple[str, str] | None:
|
|
130
|
+
direction = _direction(q) or "down"
|
|
131
|
+
m = _FORCE.search(q)
|
|
132
|
+
gm = _GRAV.search(q)
|
|
133
|
+
pm = _PRESS.search(q)
|
|
134
|
+
if pm:
|
|
135
|
+
return f"{pm.group(1)} {pm.group(2)} @ top", f"pressure on the top face"
|
|
136
|
+
if gm and not m:
|
|
137
|
+
return f"{gm.group(1)} g {direction}", f"{gm.group(1)} g of acceleration, {direction}"
|
|
138
|
+
if not m:
|
|
139
|
+
return None
|
|
140
|
+
unit = m.group(2).lower()
|
|
141
|
+
nice = {"n": "N", "newton": "N", "newtons": "N", "kn": "kN", "kg": "kgf", "kgs": "kgf", "kgf": "kgf",
|
|
142
|
+
"lb": "lbf", "lbs": "lbf", "lbf": "lbf", "pound": "lbf", "pounds": "lbf", "kip": "kip", "kips": "kip"}
|
|
143
|
+
mag = f"{m.group(1)} {nice.get(unit, unit)}"
|
|
144
|
+
sel, why = _far_face(f, study)
|
|
145
|
+
if re.search(r"\b(on|at) (the )?top\b", q):
|
|
146
|
+
sel, why = "top", "the top face"
|
|
147
|
+
elif re.search(r"\b(tip|end|free end)\b", q):
|
|
148
|
+
sel, why = _far_face(f, study)
|
|
149
|
+
return f"{mag} {direction} @ {sel}", f"{mag} pushing {direction} on {why}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _far_face(f: PartFeatures, study: StructuralStudy) -> tuple[str, str]:
|
|
153
|
+
"""The flat face farthest from the supports: the free end of a cantilevered part."""
|
|
154
|
+
flats = [x for x in f.faces.values() if x.kind == "plane" and x.area > 0.01 * sum(y.area for y in f.faces.values())]
|
|
155
|
+
if not flats:
|
|
156
|
+
return "top", "the top face"
|
|
157
|
+
sup = []
|
|
158
|
+
for fx in study.fixtures:
|
|
159
|
+
if "hole" in fx.faces:
|
|
160
|
+
sup += [np.asarray(h.axis_point) for h in f.holes()]
|
|
161
|
+
if not sup:
|
|
162
|
+
lo = np.asarray(f.bbox[0])
|
|
163
|
+
sup = [lo]
|
|
164
|
+
c = np.mean(sup, axis=0)
|
|
165
|
+
best = max(flats, key=lambda x: float(np.linalg.norm(np.asarray(x.centroid) - c)))
|
|
166
|
+
return f"face {best.id}", f"face {best.id}, the flat face farthest from the supports"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def gravity_in_question(q: str) -> bool:
|
|
170
|
+
return bool(re.search(r"\b(own weight|self weight|gravity|drop)\b", q.lower()))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def force_text(value: str) -> float:
|
|
174
|
+
return parse_quantity(value, "force").value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = ["read_question", "detect_domain", "find_material", "Reading", "get_material", "Fixture", "Load"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Circuit checks with ngspice."""
|
wobbl/circuits/run.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Run a circuit check with ngspice and verify it: textbook comparison, time step convergence, power balance."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..outcome_generic import GenericOutcome
|
|
12
|
+
from ..solvers import base as solvers
|
|
13
|
+
from ..units import fmt
|
|
14
|
+
from ..verify.checks import Check, confidence
|
|
15
|
+
from .templates import TEMPLATES, Template, e24_above
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ngspice():
|
|
19
|
+
a = solvers.for_domain("circuit")
|
|
20
|
+
a.require()
|
|
21
|
+
return a
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def simulate(t: Template, work: Path, step_factor: float = 1.0) -> dict[str, np.ndarray]:
|
|
25
|
+
net = t.netlist.rstrip()
|
|
26
|
+
if net.lower().endswith(".end"):
|
|
27
|
+
net = net[:-4].rstrip()
|
|
28
|
+
if t.analysis == "tran":
|
|
29
|
+
step, stop = t.tran
|
|
30
|
+
s = step * step_factor
|
|
31
|
+
net += f"\n.tran {s:.6e} {stop:.6e} 0 {s:.6e} uic\n"
|
|
32
|
+
data, log, secs = _ngspice().run(net + "\n.end\n", work, t.vectors, f"{t.name}_{step_factor:g}")
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _interp_at(x, y, x0):
|
|
37
|
+
return float(np.interp(x0, x, y))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_circuit(template: str, params: dict, out_dir: str | Path, question: str = "") -> GenericOutcome:
|
|
41
|
+
from ..report.render import line_plot
|
|
42
|
+
|
|
43
|
+
t0 = time.time()
|
|
44
|
+
out = Path(out_dir)
|
|
45
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
if template not in TEMPLATES:
|
|
47
|
+
raise ValueError(f"unknown circuit template '{template}' (use {', '.join(TEMPLATES)}) or pass a netlist")
|
|
48
|
+
tpl = TEMPLATES[template](params)
|
|
49
|
+
work = out / "work"
|
|
50
|
+
data = simulate(tpl, work)
|
|
51
|
+
checks: list[Check] = []
|
|
52
|
+
extra = []
|
|
53
|
+
fixes = []
|
|
54
|
+
imgs = {}
|
|
55
|
+
p = tpl.params
|
|
56
|
+
sp = _ngspice()
|
|
57
|
+
solver = f"ngspice {sp.version()}"
|
|
58
|
+
if template == "led":
|
|
59
|
+
i = -float(data["i(v1)"][-1])
|
|
60
|
+
va = float(data["v(a)"][-1])
|
|
61
|
+
vs, r = p["supply"], p["resistor"]
|
|
62
|
+
exact = tpl.expect["i_led"]
|
|
63
|
+
err = abs(i - exact) / max(exact, 1e-12)
|
|
64
|
+
checks.append(Check("hand calculation", "pass" if err < 0.01 else "warn",
|
|
65
|
+
f"diode equation solved by hand gives {fmt(exact * 1000)} mA, ngspice {fmt(i * 1000)} mA "
|
|
66
|
+
f"({100 * err:.2g}% apart); the quick estimate (Vs - Vf)/R is "
|
|
67
|
+
f"{fmt(tpl.expect['i_simple'] * 1000)} mA", {"exact_A": exact}))
|
|
68
|
+
p_src, p_r, p_led = vs * i, i * i * r, va * i
|
|
69
|
+
bal = abs(p_src - p_r - p_led) / max(p_src, 1e-12)
|
|
70
|
+
checks.append(Check("power balance", "pass" if bal < 0.005 else "warn",
|
|
71
|
+
f"supply gives {fmt(p_src * 1000)} mW = resistor {fmt(p_r * 1000)} mW + LED "
|
|
72
|
+
f"{fmt(p_led * 1000)} mW ({100 * bal:.2g}% apart)"))
|
|
73
|
+
imax, rating = p["imax"], p["rating"]
|
|
74
|
+
if vs <= p["vf"] * 0.9:
|
|
75
|
+
verdict, head = "fails", f"The {fmt(vs)} V supply is below the LED's {fmt(p['vf'])} V forward voltage: " \
|
|
76
|
+
"it will barely glow."
|
|
77
|
+
elif i > imax:
|
|
78
|
+
verdict = "fails"
|
|
79
|
+
head = (f"The LED fries: {fmt(i * 1000)} mA through it, above its {fmt(imax * 1000)} mA rating "
|
|
80
|
+
f"({i / imax:.2g}x).")
|
|
81
|
+
elif i > 0.8 * imax or p_r > 0.6 * rating:
|
|
82
|
+
verdict = "warm"
|
|
83
|
+
head = (f"{fmt(i * 1000)} mA through the LED ({100 * i / imax:.0f}% of its rating); resistor dissipates "
|
|
84
|
+
f"{fmt(p_r * 1000)} mW of its {fmt(rating * 1000)} mW rating. Works, little margin.")
|
|
85
|
+
else:
|
|
86
|
+
verdict = "ok"
|
|
87
|
+
head = (f"Safe: {fmt(i * 1000)} mA through the LED ({100 * i / imax:.0f}% of its {fmt(imax * 1000)} mA "
|
|
88
|
+
f"rating); the resistor runs at {fmt(p_r * 1000)} mW.")
|
|
89
|
+
checks.append(Check("operating limits", "info",
|
|
90
|
+
f"LED {fmt(i * 1000)} mA of {fmt(imax * 1000)} mA; resistor {fmt(p_r * 1000)} mW of "
|
|
91
|
+
f"{fmt(rating * 1000)} mW"))
|
|
92
|
+
target = 0.020 if imax <= 0.05 else 0.7 * imax
|
|
93
|
+
if verdict != "ok" or i < 0.3 * imax:
|
|
94
|
+
rn = e24_above((vs - p["vf"]) / target) if vs > p["vf"] else None
|
|
95
|
+
if rn:
|
|
96
|
+
pr = (vs - p["vf"]) ** 2 / rn
|
|
97
|
+
fixes.append({"kind": "resistor", "title": f"Use a {fmt(rn)} ohm resistor",
|
|
98
|
+
"summary": f"Gives about {fmt((vs - p['vf']) / rn * 1000)} mA; it dissipates "
|
|
99
|
+
f"{fmt(pr * 1000)} mW, so a {'0.25' if pr < 0.12 else '0.5' if pr < 0.25 else '1'}"
|
|
100
|
+
f" W part is enough.", "auto": False})
|
|
101
|
+
metrics = [("LED current", f"{fmt(i * 1000)} mA"), ("rating", f"{fmt(imax * 1000)} mA"),
|
|
102
|
+
("resistor power", f"{fmt(p_r * 1000)} mW"), ("LED power", f"{fmt(p_led * 1000)} mW")]
|
|
103
|
+
values = {"i_led_A": i, "v_led_V": va, "p_resistor_W": p_r}
|
|
104
|
+
assumptions = [f"{p['led']} LED modeled with the diode equation: {fmt(p['vf'])} V at "
|
|
105
|
+
f"{'20 mA' if imax <= 0.05 else '350 mA'}, ideality {p['n']:.2g}, series resistance 1.5 ohm.",
|
|
106
|
+
f"Resistor {fmt(r)} ohm exact, rated {fmt(rating)} W.", "Room temperature, steady DC."]
|
|
107
|
+
elif template == "rc":
|
|
108
|
+
tt, v = data["time"], data["v(out)"]
|
|
109
|
+
vt = _interp_at(tt, v, p["tau"])
|
|
110
|
+
exp_v = tpl.expect["v_at_tau"]
|
|
111
|
+
err = abs(vt - exp_v) / exp_v
|
|
112
|
+
checks.append(Check("hand calculation", "pass" if err < 0.005 else "warn",
|
|
113
|
+
f"V(t = RC) is {fmt(vt)} V; theory says 63.2% of {fmt(p['v'])} V = {fmt(exp_v)} V "
|
|
114
|
+
f"({100 * err:.2g}% apart)"))
|
|
115
|
+
d2 = simulate(tpl, work, 0.5)
|
|
116
|
+
v2 = _interp_at(d2["time"], d2["v(out)"], p["tau"])
|
|
117
|
+
checks.append(_step_check(vt, v2, "V(t = RC)"))
|
|
118
|
+
reach = tt[np.argmax(v >= 0.99 * p["v"])] if (v >= 0.99 * p["v"]).any() else float("nan")
|
|
119
|
+
verdict, head = "ok", (f"Time constant {fmt(p['tau'] * 1000)} ms: the output reaches 63% in "
|
|
120
|
+
f"{fmt(p['tau'] * 1000)} ms and 99% in {fmt(reach * 1000)} ms.")
|
|
121
|
+
metrics = [("time constant", f"{fmt(p['tau'] * 1000)} ms"), ("V at RC", f"{fmt(vt)} V"),
|
|
122
|
+
("99% after", f"{fmt(reach * 1000)} ms")]
|
|
123
|
+
values = {"v_at_tau": vt, "t99_s": float(reach)}
|
|
124
|
+
assumptions = ["Ideal resistor and capacitor, step input at t = 0."]
|
|
125
|
+
imgs["waveform"] = line_plot(out / "images" / "waveform.png", tt * 1000, {"v(out)": v}, "ms", "V")
|
|
126
|
+
elif template == "buck":
|
|
127
|
+
tt, vo, il = data["time"], data["v(out)"], data["i(l1)"]
|
|
128
|
+
period = 1 / p["frequency"]
|
|
129
|
+
tw = tt[-1] - 20 * period
|
|
130
|
+
m = tt >= tw
|
|
131
|
+
rip_v = float(vo[m].max() - vo[m].min())
|
|
132
|
+
rip_i = float(il[m].max() - il[m].min())
|
|
133
|
+
vavg = float(np.mean(vo[m]))
|
|
134
|
+
m2 = (tt >= tw - 20 * period) & (tt < tw)
|
|
135
|
+
drift = abs(vavg - float(np.mean(vo[m2]))) / max(vavg, 1e-12)
|
|
136
|
+
ev, ei = tpl.expect["ripple_v"], tpl.expect["ripple_i"]
|
|
137
|
+
errv = abs(rip_v - ev) / ev
|
|
138
|
+
checks.append(Check("hand calculation", "pass" if errv < 0.15 else "warn",
|
|
139
|
+
f"ideal waveform math (triangular inductor ripple dI = {fmt(ei)} A through C and ESR) gives "
|
|
140
|
+
f"{fmt(ev * 1000)} mV; ngspice {fmt(rip_v * 1000)} mV and {fmt(rip_i)} A "
|
|
141
|
+
f"({100 * errv:.0f}% apart; the math ignores switch and diode drops)"))
|
|
142
|
+
checks.append(Check("steady state", "pass" if drift < 0.005 else "warn",
|
|
143
|
+
f"average output moved {100 * drift:.2g}% between the last two 20 cycle windows"))
|
|
144
|
+
d2 = simulate(tpl, work, 0.5)
|
|
145
|
+
m_2 = d2["time"] >= d2["time"][-1] - 20 * period
|
|
146
|
+
rip2 = float(d2["v(out)"][m_2].max() - d2["v(out)"][m_2].min())
|
|
147
|
+
checks.append(_step_check(rip_v, rip2, "output ripple"))
|
|
148
|
+
if not tpl.expect["ccm"]:
|
|
149
|
+
checks.append(Check("operating mode", "warn", "inductor current reaches zero each cycle "
|
|
150
|
+
"(discontinuous mode); ripple formulas do not apply"))
|
|
151
|
+
spec = float(p.get("max_ripple", 0.01 * p["vout"]))
|
|
152
|
+
spec = spec if spec > 0 else 0.01 * p["vout"]
|
|
153
|
+
verdict = "ok" if rip_v <= spec else "fails"
|
|
154
|
+
head = (f"Output ripple {fmt(rip_v * 1000)} mV peak to peak on {fmt(vavg)} V "
|
|
155
|
+
f"({'within' if verdict == 'ok' else 'over'} the {fmt(spec * 1000)} mV limit); inductor ripple "
|
|
156
|
+
f"{fmt(rip_i)} A, peak {fmt(float(il[m].max()))} A.")
|
|
157
|
+
if verdict != "ok":
|
|
158
|
+
cnew = rip_i / (8 * p["frequency"] * max(spec * 0.8 - rip_i * p["esr"], 1e-6))
|
|
159
|
+
fixes.append({"kind": "capacitor", "title": f"Raise output capacitance to about {fmt(cnew * 1e6)} uF",
|
|
160
|
+
"summary": "Ripple falls with 1/(8 f C) until ESR dominates; use low ESR ceramics.",
|
|
161
|
+
"auto": False})
|
|
162
|
+
metrics = [("ripple", f"{fmt(rip_v * 1000)} mV"), ("output", f"{fmt(vavg)} V"),
|
|
163
|
+
("inductor ripple", f"{fmt(rip_i)} A"), ("duty", f"{100 * p['duty']:.1f}%")]
|
|
164
|
+
values = {"ripple_V": rip_v, "ripple_A": rip_i, "vout_avg": vavg, "il_peak": float(il[m].max())}
|
|
165
|
+
assumptions = [f"Open loop at the steady state duty cycle {100 * p['duty']:.1f}%; real controllers add "
|
|
166
|
+
"their own ripple and transients.", f"Switch on resistance {fmt(p.get('rds', 0.05))} ohm, "
|
|
167
|
+
"Schottky catch diode.", f"Capacitor ESR {fmt(p['esr'] * 1000)} mohm, no ESL."]
|
|
168
|
+
zoom = tt >= tt[-1] - 3 * period
|
|
169
|
+
imgs["waveform"] = line_plot(out / "images" / "waveform.png", (tt[zoom] - tt[zoom][0]) * 1e6,
|
|
170
|
+
{"v(out)": vo[zoom]}, "us", "V")
|
|
171
|
+
elif template == "mosfet":
|
|
172
|
+
tt, vd, idr = data["time"], data["v(d)"], data["i(vs)"]
|
|
173
|
+
f = p["frequency"]
|
|
174
|
+
tw = tt[-1] - (10 / f if f > 0 else tt[-1] / 2)
|
|
175
|
+
m = tt >= tw
|
|
176
|
+
pc_sim = float(np.mean(vd[m] * idr[m]))
|
|
177
|
+
i_rms = float(np.sqrt(np.mean(idr[m] ** 2)))
|
|
178
|
+
rds25 = p["rds_on"]
|
|
179
|
+
pc_hand = i_rms ** 2 * rds25
|
|
180
|
+
err = abs(pc_sim - pc_hand) / max(pc_hand, 1e-12)
|
|
181
|
+
checks.append(Check("hand calculation", "pass" if err < 0.02 else "warn",
|
|
182
|
+
f"conduction loss I_rms^2 R = {fmt(pc_hand)} W; ngspice average of V x I = {fmt(pc_sim)} W "
|
|
183
|
+
f"({100 * err:.2g}% apart)"))
|
|
184
|
+
trf = float(p.get("switching_time", 40e-9))
|
|
185
|
+
psw = 0.5 * p["vbus"] * p["current"] * trf * f * 2 if f > 0 else 0.0
|
|
186
|
+
ta = float(p.get("ambient", 25))
|
|
187
|
+
theta = float(p.get("theta_ja", 62))
|
|
188
|
+
tjmax = float(p.get("tj_max", 150))
|
|
189
|
+
tc = float(p.get("rds_tempco", 0.006))
|
|
190
|
+
tj = ta
|
|
191
|
+
for _ in range(50):
|
|
192
|
+
ptot = pc_sim * (1 + tc * (tj - 25)) + psw
|
|
193
|
+
tj_new = ta + ptot * theta
|
|
194
|
+
if abs(tj_new - tj) < 1e-3:
|
|
195
|
+
break
|
|
196
|
+
tj = tj_new if tj_new < 1000 else 1000
|
|
197
|
+
runaway = tj >= 999
|
|
198
|
+
checks.append(Check("thermal loop", "fail" if runaway else "pass",
|
|
199
|
+
"on resistance rises with temperature faster than the heat can leave: thermal runaway"
|
|
200
|
+
if runaway else f"junction settles at {fmt(tj)} C after the on resistance warms up "
|
|
201
|
+
f"({fmt(rds25 * (1 + tc * (tj - 25)) * 1000)} mohm hot)"))
|
|
202
|
+
verdict = "ok" if tj <= tjmax - 25 else ("warm" if tj <= tjmax else "hot")
|
|
203
|
+
head = (f"Junction reaches about {fmt(tj)} C ({fmt(ptot)} W: {fmt(ptot - psw)} W conduction, {fmt(psw)} W "
|
|
204
|
+
f"switching) with {fmt(theta)} C/W to {fmt(ta)} C air; limit {fmt(tjmax)} C.")
|
|
205
|
+
if verdict != "ok":
|
|
206
|
+
th_need = (tjmax - 25 - ta) / max(ptot, 1e-9)
|
|
207
|
+
fixes.append({"kind": "heatsink", "title": f"Get the junction to air resistance under {fmt(th_need)} C/W",
|
|
208
|
+
"summary": "Add copper area or a heatsink; for TO-220, a clip on sink of about "
|
|
209
|
+
f"{fmt(max(th_need - 2, 1))} C/W plus pad is typical.", "auto": False})
|
|
210
|
+
fixes.append({"kind": "part", "title": f"Pick a part with under {fmt(rds25 * (tjmax - 25 - ta) / max(tj - ta, 1e-9) * 1000)} mohm",
|
|
211
|
+
"summary": "Conduction loss scales with on resistance.", "auto": False})
|
|
212
|
+
metrics = [("junction", f"{fmt(tj)} C"), ("limit", f"{fmt(tjmax)} C"), ("loss", f"{fmt(ptot)} W"),
|
|
213
|
+
("theta JA", f"{fmt(theta)} C/W")]
|
|
214
|
+
values = {"tj_C": tj, "p_total_W": ptot, "p_cond_W": ptot - psw, "p_sw_W": psw}
|
|
215
|
+
assumptions = [f"On resistance {fmt(rds25 * 1000)} mohm at 25 C rising {tc * 100:g}% per C.",
|
|
216
|
+
f"Switching loss from {fmt(trf * 1e9)} ns edges: 0.5 V I (tr + tf) f.",
|
|
217
|
+
f"Junction to air {fmt(theta)} C/W (datasheet, no heatsink unless included)."]
|
|
218
|
+
out_o = GenericOutcome("circuit", template, question, verdict, head, metrics, "high", [], checks, assumptions,
|
|
219
|
+
{**values, "params": {k: (v if isinstance(v, (int, float, str, bool)) else str(v))
|
|
220
|
+
for k, v in p.items()},
|
|
221
|
+
"_figures": [["waveform", "Simulated waveform"]]}, imgs, fixes, solver=solver,
|
|
222
|
+
t_total=time.time() - t0)
|
|
223
|
+
out_o.confidence, out_o.confidence_reasons = confidence(checks, extra)
|
|
224
|
+
(out / "netlist.cir").write_text(tpl.netlist)
|
|
225
|
+
return out_o.write(out)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _step_check(a: float, b: float, label: str) -> Check:
|
|
229
|
+
d = abs(a - b) / max(abs(b), 1e-15)
|
|
230
|
+
st = "pass" if d < 0.01 else ("warn" if d < 0.05 else "fail")
|
|
231
|
+
return Check("time step convergence", st, f"{label} {fmt(a)} with the normal step, {fmt(b)} with half the step "
|
|
232
|
+
f"({100 * d:.2g}% apart)")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_netlist(path: str, probes: list[str], out_dir: str | Path, question: str = "") -> GenericOutcome:
|
|
236
|
+
"""Any user netlist: run it, report the probed vectors, and recheck with half the time step."""
|
|
237
|
+
from ..report.render import line_plot
|
|
238
|
+
|
|
239
|
+
t0 = time.time()
|
|
240
|
+
out = Path(out_dir)
|
|
241
|
+
text = Path(path).read_text()
|
|
242
|
+
if not probes:
|
|
243
|
+
raise ValueError("say which signals to read, for example --probe 'v(out)' --probe 'i(V1)'")
|
|
244
|
+
body = re.sub(r"(?ims)^\.control.*?^\.endc\s*$", "", text)
|
|
245
|
+
tran = re.search(r"(?im)^\.tran\s+(\S+)\s+(\S+)(.*)$", body)
|
|
246
|
+
tpl = Template(Path(path).stem, body, probes, {}, "dc")
|
|
247
|
+
data = _ngspice().run(body, out / "work", probes, "run")[0]
|
|
248
|
+
checks = []
|
|
249
|
+
if tran:
|
|
250
|
+
from ..units import parse_spice_value
|
|
251
|
+
step = parse_spice_value(tran.group(1))
|
|
252
|
+
half = body[:tran.start()] + f".tran {step / 2:.6e} {tran.group(2)}{tran.group(3)}" + body[tran.end():]
|
|
253
|
+
d2 = _ngspice().run(half, out / "work", probes, "half")[0]
|
|
254
|
+
for pr in probes:
|
|
255
|
+
a, b = float(np.max(np.abs(data[pr]))), float(np.max(np.abs(d2[pr])))
|
|
256
|
+
checks.append(_step_check(a, b, f"peak |{pr}|"))
|
|
257
|
+
vals = {pr: {"min": float(np.min(data[pr])), "max": float(np.max(data[pr])), "mean": float(np.mean(data[pr])),
|
|
258
|
+
"final": float(data[pr][-1])} for pr in probes}
|
|
259
|
+
xkey = "time" if "time" in data else next(iter(data))
|
|
260
|
+
imgs = {"waveform": line_plot(out / "images" / "waveform.png", data[xkey], {pr: data[pr] for pr in probes},
|
|
261
|
+
xkey, "value")}
|
|
262
|
+
lvl, reasons = confidence(checks)
|
|
263
|
+
head = "; ".join(f"{pr}: min {fmt(v['min'])}, max {fmt(v['max'])}, final {fmt(v['final'])}" for pr, v in
|
|
264
|
+
vals.items())
|
|
265
|
+
o = GenericOutcome("circuit", path, question, "ok", head, [(pr, fmt(v["final"])) for pr, v in vals.items()][:4],
|
|
266
|
+
lvl, reasons, checks, ["Simulated as written; models come from the netlist."],
|
|
267
|
+
{"probes": vals, "_figures": [["waveform", "Simulated signals"]]}, imgs,
|
|
268
|
+
solver=f"ngspice {_ngspice().version()}", t_total=time.time() - t0)
|
|
269
|
+
_ = tpl, math
|
|
270
|
+
return o.write(out)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Ready made circuit checks: LED current, RC timing, buck regulator ripple, MOSFET heating.
|
|
2
|
+
|
|
3
|
+
Each template writes a SPICE netlist, runs ngspice, and also computes the
|
|
4
|
+
textbook answer so the two can be compared. Values use plain SI units.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..units import parse_spice_value, parse_quantity
|
|
14
|
+
|
|
15
|
+
VT = 8.617333e-5 * 300.15 # thermal voltage at 27 C as ngspice computes it, V
|
|
16
|
+
|
|
17
|
+
# 5 mm through hole LEDs: forward voltage at 20 mA, continuous current rating
|
|
18
|
+
LEDS = {
|
|
19
|
+
"red": (2.0, 0.030), "orange": (2.05, 0.030), "yellow": (2.1, 0.030), "green": (3.1, 0.030),
|
|
20
|
+
"blue": (3.2, 0.030), "white": (3.2, 0.030), "uv": (3.4, 0.030), "ir": (1.3, 0.050),
|
|
21
|
+
"power-white": (3.0, 0.350), "power-red": (2.2, 0.350),
|
|
22
|
+
}
|
|
23
|
+
E24 = [1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0, 3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5,
|
|
24
|
+
8.2, 9.1]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def e24_above(x: float) -> float:
|
|
28
|
+
if x <= 0:
|
|
29
|
+
return 1.0
|
|
30
|
+
dec = 10 ** math.floor(math.log10(x))
|
|
31
|
+
for m in E24 + [10.0]:
|
|
32
|
+
if m * dec >= x * 0.999:
|
|
33
|
+
return m * dec
|
|
34
|
+
return 10 * dec
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def val(params: dict, key: str, default=None, kind: str | None = None) -> float:
|
|
38
|
+
v = params.get(key, default)
|
|
39
|
+
if v is None:
|
|
40
|
+
raise ValueError(f"missing parameter '{key}'")
|
|
41
|
+
if isinstance(v, (int, float)):
|
|
42
|
+
return float(v)
|
|
43
|
+
s = str(v).strip()
|
|
44
|
+
if kind:
|
|
45
|
+
try:
|
|
46
|
+
return parse_quantity(s, kind).value
|
|
47
|
+
except Exception: # noqa: BLE001
|
|
48
|
+
pass
|
|
49
|
+
return parse_spice_value(s)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Template:
|
|
54
|
+
name: str
|
|
55
|
+
netlist: str
|
|
56
|
+
vectors: list[str]
|
|
57
|
+
params: dict
|
|
58
|
+
analysis: str = "dc" # dc or tran
|
|
59
|
+
tran: tuple[float, float] | None = None # (step, stop)
|
|
60
|
+
expect: dict = field(default_factory=dict) # closed form numbers
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def led(p: dict) -> Template:
|
|
64
|
+
vs = val(p, "supply", "5", "voltage")
|
|
65
|
+
r = val(p, "resistor", "150", "resistance")
|
|
66
|
+
color = str(p.get("led", "red")).lower()
|
|
67
|
+
vf, imax = LEDS.get(color, LEDS["red"])
|
|
68
|
+
vf = val(p, "vf", vf, "voltage")
|
|
69
|
+
imax = val(p, "imax", imax, "current")
|
|
70
|
+
n, rs = max(2.0, vf / 1.2), 1.5 # keeps IS above the ~1e-28 floor ngspice applies
|
|
71
|
+
i_ref = 0.020 if imax < 0.2 else 0.350
|
|
72
|
+
isat = i_ref / math.exp((vf - i_ref * rs) / (n * VT))
|
|
73
|
+
net = f"""* LED with series resistor
|
|
74
|
+
V1 vin 0 DC {vs}
|
|
75
|
+
R1 vin a {r}
|
|
76
|
+
D1 a 0 LEDX
|
|
77
|
+
.model LEDX D(IS={isat:.6e} N={n} RS={rs})
|
|
78
|
+
.dc V1 {vs} {vs} 1
|
|
79
|
+
.end
|
|
80
|
+
"""
|
|
81
|
+
# textbook: solve Vs = I R + I Rs + n Vt ln(I/Is + 1) by Newton
|
|
82
|
+
i = max((vs - vf) / r, 1e-9)
|
|
83
|
+
for _ in range(100):
|
|
84
|
+
f = i * (r + rs) + n * VT * math.log(i / isat + 1) - vs
|
|
85
|
+
df = (r + rs) + n * VT / (i + isat)
|
|
86
|
+
i_new = max(i - f / df, 1e-15)
|
|
87
|
+
if abs(i_new - i) < 1e-15:
|
|
88
|
+
break
|
|
89
|
+
i = i_new
|
|
90
|
+
p_out = dict(p, supply=vs, resistor=r, vf=vf, imax=imax, led=color, n=n, rating=val(p, "rating", "0.25", "si_power"))
|
|
91
|
+
return Template("led", net, ["i(v1)", "v(a)"], p_out, "dc",
|
|
92
|
+
expect={"i_led": i, "i_simple": max((vs - vf) / r, 0.0)})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def rc(p: dict) -> Template:
|
|
96
|
+
r = val(p, "r", "10k", "resistance")
|
|
97
|
+
c = val(p, "c", "10u", "capacitance")
|
|
98
|
+
v = val(p, "v", "5", "voltage")
|
|
99
|
+
tau = r * c
|
|
100
|
+
net = f"""* RC charging
|
|
101
|
+
V1 in 0 PULSE(0 {v} 0 1n 1n 1 2)
|
|
102
|
+
R1 in out {r}
|
|
103
|
+
C1 out 0 {c} IC=0
|
|
104
|
+
.end
|
|
105
|
+
"""
|
|
106
|
+
return Template("rc", net, ["v(out)"], dict(p, r=r, c=c, v=v, tau=tau), "tran", (tau / 400, 5 * tau),
|
|
107
|
+
expect={"v_at_tau": v * (1 - math.exp(-1)), "tau": tau, "t99": tau * math.log(100)})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def buck(p: dict) -> Template:
|
|
111
|
+
vin = val(p, "vin", "12", "voltage")
|
|
112
|
+
vout = val(p, "vout", "5", "voltage")
|
|
113
|
+
iout = val(p, "current", "1", "current")
|
|
114
|
+
f = val(p, "frequency", "500k", "frequency")
|
|
115
|
+
L = val(p, "inductor", "10u", "inductance")
|
|
116
|
+
C = val(p, "capacitor", "22u", "capacitance")
|
|
117
|
+
esr = val(p, "esr", "0.01", "resistance")
|
|
118
|
+
rds = val(p, "rds", "0.05", "resistance")
|
|
119
|
+
vd = 0.35 # Schottky catch diode
|
|
120
|
+
duty = (vout + vd + iout * 0.0) / (vin - iout * rds + vd)
|
|
121
|
+
period = 1.0 / f
|
|
122
|
+
ton = duty * period
|
|
123
|
+
rload = vout / iout
|
|
124
|
+
di = (vin - vout) * (vout / vin) / (L * f)
|
|
125
|
+
dv = _ripple(di, vout / vin, f, C, esr)
|
|
126
|
+
cycles = int(p.get("cycles", 300))
|
|
127
|
+
net = f"""* Buck converter power stage, open loop at the steady state duty cycle
|
|
128
|
+
V1 vin 0 DC {vin}
|
|
129
|
+
VG g 0 PULSE(0 5 0 {period / 200:.4e} {period / 200:.4e} {ton:.6e} {period:.6e})
|
|
130
|
+
S1 vin sw g 0 SWMOD
|
|
131
|
+
.model SWMOD SW(VT=2.5 VH=0.1 RON={rds} ROFF=1e6)
|
|
132
|
+
D1 0 sw SCHOTTKY
|
|
133
|
+
.model SCHOTTKY D(IS=5e-5 N=1.05 RS=0.02)
|
|
134
|
+
L1 sw out {L} IC={iout}
|
|
135
|
+
C1 out cmid {C} IC={vout}
|
|
136
|
+
R2 cmid 0 {esr}
|
|
137
|
+
R1 out 0 {rload}
|
|
138
|
+
.end
|
|
139
|
+
"""
|
|
140
|
+
return Template("buck", net, ["v(out)", "i(l1)"], dict(p, vin=vin, vout=vout, current=iout, frequency=f,
|
|
141
|
+
inductor=L, capacitor=C, esr=esr, duty=duty, cycles=cycles),
|
|
142
|
+
"tran", (period / 400, cycles * period),
|
|
143
|
+
expect={"ripple_v": dv, "ripple_i": di, "ccm": iout > di / 2})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ripple(di: float, duty: float, f: float, C: float, esr: float) -> float:
|
|
147
|
+
"""Exact peak to peak output ripple for a triangular capacitor current: ESR and charge terms out of phase."""
|
|
148
|
+
T = 1.0 / f
|
|
149
|
+
t = np.linspace(0, T, 4001)
|
|
150
|
+
rise = duty * T
|
|
151
|
+
ic = np.where(t < rise, -di / 2 + di * t / rise, di / 2 - di * (t - rise) / (T - rise))
|
|
152
|
+
q = np.concatenate([[0.0], np.cumsum(0.5 * (ic[1:] + ic[:-1]) * np.diff(t))])
|
|
153
|
+
v = esr * ic + q / C
|
|
154
|
+
return float(v.max() - v.min())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def mosfet(p: dict) -> Template:
|
|
158
|
+
i = val(p, "current", "10", "current")
|
|
159
|
+
rds = val(p, "rds_on", "0.01", "resistance")
|
|
160
|
+
vbus = val(p, "vbus", "12", "voltage")
|
|
161
|
+
f = val(p, "frequency", "0", "frequency")
|
|
162
|
+
duty = float(p.get("duty", 1.0 if f == 0 else 0.5))
|
|
163
|
+
rload = max(vbus / i - rds, 1e-3)
|
|
164
|
+
if f > 0:
|
|
165
|
+
period = 1 / f
|
|
166
|
+
drive = f"PULSE(0 10 0 {period / 500:.4e} {period / 500:.4e} {duty * period:.6e} {period:.6e})"
|
|
167
|
+
tran = (period / 500, 40 * period)
|
|
168
|
+
else:
|
|
169
|
+
drive = "DC 10"
|
|
170
|
+
tran = (1e-6, 1e-4)
|
|
171
|
+
net = f"""* MOSFET conduction with a resistive load
|
|
172
|
+
V1 vbus 0 DC {vbus}
|
|
173
|
+
RL vbus d {rload}
|
|
174
|
+
VG g 0 {drive}
|
|
175
|
+
S1 d s g 0 SWMOD
|
|
176
|
+
.model SWMOD SW(VT=5 VH=0.1 RON={rds} ROFF=1e7)
|
|
177
|
+
VS s 0 DC 0
|
|
178
|
+
.end
|
|
179
|
+
"""
|
|
180
|
+
return Template("mosfet", net, ["v(d)", "i(vs)"], dict(p, current=i, rds_on=rds, vbus=vbus, frequency=f,
|
|
181
|
+
duty=duty), "tran", tran)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
TEMPLATES = {"led": led, "rc": rc, "buck": buck, "mosfet": mosfet}
|