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.
Files changed (74) hide show
  1. wobbl/__init__.py +16 -0
  2. wobbl/__main__.py +4 -0
  3. wobbl/ask.py +177 -0
  4. wobbl/circuits/__init__.py +1 -0
  5. wobbl/circuits/run.py +270 -0
  6. wobbl/circuits/templates.py +184 -0
  7. wobbl/circuits/tools.py +93 -0
  8. wobbl/cli/__init__.py +1 -0
  9. wobbl/cli/admin.py +177 -0
  10. wobbl/cli/circuits.py +73 -0
  11. wobbl/cli/main.py +438 -0
  12. wobbl/cli/thermal.py +90 -0
  13. wobbl/cli/ui.py +93 -0
  14. wobbl/fe.py +141 -0
  15. wobbl/fix/__init__.py +1 -0
  16. wobbl/fix/apply.py +245 -0
  17. wobbl/fix/cad_edit.py +115 -0
  18. wobbl/fix/suggest.py +178 -0
  19. wobbl/geometry/__init__.py +1 -0
  20. wobbl/geometry/cad.py +350 -0
  21. wobbl/geometry/faces.py +426 -0
  22. wobbl/geometry/parametric.py +92 -0
  23. wobbl/geometry/remap.py +67 -0
  24. wobbl/geometry/select.py +270 -0
  25. wobbl/materials.py +251 -0
  26. wobbl/mcp/__init__.py +1 -0
  27. wobbl/mcp/server.py +136 -0
  28. wobbl/mcp/tools.py +284 -0
  29. wobbl/mesh/__init__.py +1 -0
  30. wobbl/mesh/isolate.py +90 -0
  31. wobbl/mesh/mesher.py +696 -0
  32. wobbl/outcome_generic.py +94 -0
  33. wobbl/report/__init__.py +1 -0
  34. wobbl/report/html.py +129 -0
  35. wobbl/report/render.py +348 -0
  36. wobbl/setup/__init__.py +1 -0
  37. wobbl/setup/agents.py +240 -0
  38. wobbl/setup/installer.py +247 -0
  39. wobbl/skill/SKILL.md +64 -0
  40. wobbl/solvers/__init__.py +1 -0
  41. wobbl/solvers/base.py +115 -0
  42. wobbl/solvers/calculix.py +334 -0
  43. wobbl/solvers/locate.py +73 -0
  44. wobbl/solvers/ngspice.py +74 -0
  45. wobbl/structural/__init__.py +1 -0
  46. wobbl/structural/budget.py +56 -0
  47. wobbl/structural/build.py +286 -0
  48. wobbl/structural/hotspots.py +183 -0
  49. wobbl/structural/outcome.py +493 -0
  50. wobbl/structural/pipeline.py +58 -0
  51. wobbl/structural/report.py +101 -0
  52. wobbl/structural/run.py +719 -0
  53. wobbl/structural/spec.py +248 -0
  54. wobbl/thermal/__init__.py +1 -0
  55. wobbl/thermal/study.py +502 -0
  56. wobbl/thermal/tools.py +85 -0
  57. wobbl/units.py +212 -0
  58. wobbl/validation/__init__.py +1 -0
  59. wobbl/validation/cases.py +342 -0
  60. wobbl/validation/geometry.py +93 -0
  61. wobbl/validation/parts/README.md +16 -0
  62. wobbl/validation/parts/cast_motor_mount.step +7204 -0
  63. wobbl/validation/parts/drone_arm_truss.step +7849 -0
  64. wobbl/validation/parts/enclosure_shell.step +5548 -0
  65. wobbl/validation/parts/shelf_bracket_fillet.step +812 -0
  66. wobbl/validation/parts/shelf_bracket_sharp.step +812 -0
  67. wobbl/verify/__init__.py +1 -0
  68. wobbl/verify/beam.py +118 -0
  69. wobbl/verify/checks.py +184 -0
  70. wobbl-0.1.0.dist-info/METADATA +343 -0
  71. wobbl-0.1.0.dist-info/RECORD +74 -0
  72. wobbl-0.1.0.dist-info/WHEEL +4 -0
  73. wobbl-0.1.0.dist-info/entry_points.txt +2 -0
  74. 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
@@ -0,0 +1,4 @@
1
+ from .cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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}