mc9py 0.2.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.
- mc9/__init__.py +15 -0
- mc9/__main__.py +6 -0
- mc9/api.py +504 -0
- mc9/autoplace.py +522 -0
- mc9/backends/__init__.py +20 -0
- mc9/backends/base.py +62 -0
- mc9/backends/linux_wine.py +188 -0
- mc9/backends/windows.py +292 -0
- mc9/catalog.py +101 -0
- mc9/cir.py +156 -0
- mc9/cli.py +126 -0
- mc9/config.py +19 -0
- mc9/layout.py +41 -0
- mc9/library.py +142 -0
- mc9/measure.py +153 -0
- mc9/netlist.py +102 -0
- mc9/results.py +101 -0
- mc9/schematic.py +505 -0
- mc9/server.py +262 -0
- mc9/sources.py +26 -0
- mc9/templates/base.cir +1609 -0
- mc9/ui.py +86 -0
- mc9/workspace.py +57 -0
- mc9py-0.2.0.dist-info/METADATA +276 -0
- mc9py-0.2.0.dist-info/RECORD +28 -0
- mc9py-0.2.0.dist-info/WHEEL +4 -0
- mc9py-0.2.0.dist-info/entry_points.txt +3 -0
- mc9py-0.2.0.dist-info/licenses/LICENSE +21 -0
mc9/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""mc9 - scripting layer for Micro-Cap 9.
|
|
2
|
+
|
|
3
|
+
import mc9
|
|
4
|
+
c = mc9.open("lab/lr2.cir")
|
|
5
|
+
r = c.ac(1, 2e7)
|
|
6
|
+
c.shot("fig.png")
|
|
7
|
+
"""
|
|
8
|
+
from . import library, measure, sources # noqa: F401
|
|
9
|
+
from .api import OP, Circuit, Result, Run, attach, close_all, open # noqa: F401
|
|
10
|
+
from .config import config # noqa: F401
|
|
11
|
+
from .netlist import Netlist # noqa: F401
|
|
12
|
+
from .schematic import Schematic # noqa: F401
|
|
13
|
+
from .workspace import exchange as exchange_dir # noqa: F401
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
mc9/__main__.py
ADDED
mc9/api.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Public scripting API.
|
|
2
|
+
|
|
3
|
+
import mc9
|
|
4
|
+
with mc9.open("lab/lr2.cir") as c: # copied into the sandbox; the original is never touched
|
|
5
|
+
c.set("R1", "RESISTANCE", "300")
|
|
6
|
+
r = c.ac(1, 2e7, points=401, curves=["db(v(Ua)/v(Ue))", "ph(v(Ua)/v(Ue))"])
|
|
7
|
+
f_lo, f_hi = r.cross("db(v(Ua)/v(Ue))", r.peak("db(v(Ua)/v(Ue))")[1] - 3)
|
|
8
|
+
c.cursors(f_lo, f_hi) # MC9's own cursors + cursor table
|
|
9
|
+
c.shot("ac.png") # native MC9 pixels, tight crop
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import atexit
|
|
14
|
+
import bisect
|
|
15
|
+
import hashlib
|
|
16
|
+
import math
|
|
17
|
+
import shutil
|
|
18
|
+
import warnings
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from . import cir as cirmod
|
|
23
|
+
from . import results as resmod
|
|
24
|
+
from . import ui, workspace
|
|
25
|
+
from .config import config
|
|
26
|
+
|
|
27
|
+
_ANALYSES = ("transient", "ac", "dc", "distortion")
|
|
28
|
+
_LIMIT_NAME = {"transient": "Transient", "ac": "AC", "dc": "DC", "distortion": "Distortion",
|
|
29
|
+
"dynamic_ac": "DynamicAC", "dynamic_dc": "DynamicDC"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _num(v) -> str:
|
|
33
|
+
"""Python number -> MC9 text (strings pass through, so '4.7k' works)."""
|
|
34
|
+
return v if isinstance(v, str) else repr(float(v)) if not float(v).is_integer() else str(int(v))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ----------------------------------------------------------------------------------------------
|
|
38
|
+
class Run:
|
|
39
|
+
"""One run's data: x = first column. Helper methods interpolate (log-x for wide AC sweeps)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, analysis: str, columns: list[str], units: list[str], rows: list[list[float]], label: str = ""):
|
|
42
|
+
self.analysis, self.columns, self.units, self.label = analysis, list(columns), units, label
|
|
43
|
+
self._cols = {c: [r[i] for r in rows] for i, c in enumerate(columns)}
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def x(self) -> list[float]:
|
|
47
|
+
return self._cols[self.columns[0]]
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, col: str) -> list[float]:
|
|
50
|
+
try:
|
|
51
|
+
return self._cols[col]
|
|
52
|
+
except KeyError:
|
|
53
|
+
lower = {c.lower(): c for c in self.columns}
|
|
54
|
+
if col.lower() in lower:
|
|
55
|
+
return self._cols[lower[col.lower()]]
|
|
56
|
+
raise KeyError(f"no column {col!r}; have {self.columns}") from None
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return len(self.x)
|
|
60
|
+
|
|
61
|
+
def _log_x(self) -> bool:
|
|
62
|
+
x = self.x
|
|
63
|
+
return self.analysis == "ac" and min(x) > 0 and max(x) / min(x) > 50
|
|
64
|
+
|
|
65
|
+
def value_at(self, col: str, x: float) -> float:
|
|
66
|
+
"""Interpolated y at x."""
|
|
67
|
+
xs, ys = self.x, self[col]
|
|
68
|
+
i = bisect.bisect_left(xs, x)
|
|
69
|
+
if i == 0:
|
|
70
|
+
return ys[0]
|
|
71
|
+
if i >= len(xs):
|
|
72
|
+
return ys[-1]
|
|
73
|
+
t = (math.log(x / xs[i - 1]) / math.log(xs[i] / xs[i - 1])) if self._log_x() \
|
|
74
|
+
else (x - xs[i - 1]) / (xs[i] - xs[i - 1])
|
|
75
|
+
return ys[i - 1] + t * (ys[i] - ys[i - 1])
|
|
76
|
+
|
|
77
|
+
def cross(self, col: str, level: float, edge: str | None = None) -> list[float]:
|
|
78
|
+
"""x values where the curve crosses `level` (interpolated). edge: 'rising' | 'falling' | None."""
|
|
79
|
+
xs, ys, out = self.x, self[col], []
|
|
80
|
+
for i in range(1, len(xs)):
|
|
81
|
+
a, b = ys[i - 1] - level, ys[i] - level
|
|
82
|
+
if a != a or b != b or a == b or a * b > 0:
|
|
83
|
+
continue
|
|
84
|
+
if (edge == "rising" and not b > a) or (edge == "falling" and not b < a):
|
|
85
|
+
continue
|
|
86
|
+
t = -a / (b - a)
|
|
87
|
+
out.append(xs[i - 1] * (xs[i] / xs[i - 1]) ** t if self._log_x() else xs[i - 1] + t * (xs[i] - xs[i - 1]))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
def _window(self, col, xmin, xmax):
|
|
91
|
+
return [(x, y) for x, y in zip(self.x, self[col]) if y == y
|
|
92
|
+
and (xmin is None or x >= xmin) and (xmax is None or x <= xmax)]
|
|
93
|
+
|
|
94
|
+
def peak(self, col: str, xmin: float | None = None, xmax: float | None = None) -> tuple[float, float]:
|
|
95
|
+
"""(x, y) of the maximum within [xmin, xmax]."""
|
|
96
|
+
return max(self._window(col, xmin, xmax), key=lambda p: p[1])
|
|
97
|
+
|
|
98
|
+
def trough(self, col: str, xmin: float | None = None, xmax: float | None = None) -> tuple[float, float]:
|
|
99
|
+
return min(self._window(col, xmin, xmax), key=lambda p: p[1])
|
|
100
|
+
|
|
101
|
+
def amplitude(self, col: str, xmin: float | None = None, xmax: float | None = None) -> float:
|
|
102
|
+
"""(max - min) / 2 inside the window (sine amplitude in steady state)."""
|
|
103
|
+
ys = [y for _, y in self._window(col, xmin, xmax)]
|
|
104
|
+
return (max(ys) - min(ys)) / 2
|
|
105
|
+
|
|
106
|
+
def mean(self, col: str, xmin: float | None = None, xmax: float | None = None) -> float:
|
|
107
|
+
ys = [y for _, y in self._window(col, xmin, xmax)]
|
|
108
|
+
return sum(ys) / len(ys)
|
|
109
|
+
|
|
110
|
+
def to_csv(self, path: str) -> None:
|
|
111
|
+
with open(path, "w") as f:
|
|
112
|
+
f.write(",".join(self.columns) + "\n")
|
|
113
|
+
for i in range(len(self)):
|
|
114
|
+
f.write(",".join(repr(self._cols[c][i]) for c in self.columns) + "\n")
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
return f"Run({self.analysis}, {len(self)} rows, columns={self.columns}{', ' + self.label if self.label else ''})"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Result:
|
|
121
|
+
"""Numeric output of one analysis. Behaves like its first run; stepped runs are in `.runs`."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, analysis: str, path: str):
|
|
124
|
+
self.analysis, self.path = analysis, path
|
|
125
|
+
tables = resmod.parse_waveforms(resmod.read_text(path))
|
|
126
|
+
if not tables:
|
|
127
|
+
raise RuntimeError(f"no 'Waveform Values' in {path}: numeric output disabled, no curves "
|
|
128
|
+
"defined, or the run failed (see c.shot('debug.png', 'window'))")
|
|
129
|
+
self.runs: list[Run] = []
|
|
130
|
+
cur: dict | None = None
|
|
131
|
+
for t in tables:
|
|
132
|
+
if cur is not None and t["columns"] == cur["columns"] and len(self.runs) >= 0 and t["columns"][1:] \
|
|
133
|
+
and cur.get("closed", True):
|
|
134
|
+
self.runs.append(self._mk(cur)); cur = None
|
|
135
|
+
if cur is not None and len(t["rows"]) == len(cur["rows"]) and t["columns"][0] == cur["columns"][0] \
|
|
136
|
+
and [r[0] for r in t["rows"]] == [r[0] for r in cur["rows"]]:
|
|
137
|
+
for j, c in enumerate(t["columns"][1:], 1): # extra page of the same run: add columns
|
|
138
|
+
cur["columns"].append(c)
|
|
139
|
+
for r0, r1 in zip(cur["rows"], t["rows"]):
|
|
140
|
+
r0.append(r1[j])
|
|
141
|
+
continue
|
|
142
|
+
if cur is not None:
|
|
143
|
+
self.runs.append(self._mk(cur))
|
|
144
|
+
cur = {"columns": list(t["columns"]), "units": t["units"], "rows": [list(r) for r in t["rows"]],
|
|
145
|
+
"label": t.get("label", "")}
|
|
146
|
+
if cur is not None:
|
|
147
|
+
self.runs.append(self._mk(cur))
|
|
148
|
+
|
|
149
|
+
def _mk(self, d: dict) -> Run:
|
|
150
|
+
return Run(self.analysis, d["columns"], d["units"], d["rows"], d["label"])
|
|
151
|
+
|
|
152
|
+
# first run delegates
|
|
153
|
+
@property
|
|
154
|
+
def columns(self) -> list[str]:
|
|
155
|
+
return self.runs[0].columns
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def x(self) -> list[float]:
|
|
159
|
+
return self.runs[0].x
|
|
160
|
+
|
|
161
|
+
def __getitem__(self, col: str) -> list[float]:
|
|
162
|
+
return self.runs[0][col]
|
|
163
|
+
|
|
164
|
+
def __len__(self) -> int:
|
|
165
|
+
return len(self.runs[0])
|
|
166
|
+
|
|
167
|
+
def __getattr__(self, name: str):
|
|
168
|
+
if name in ("value_at", "cross", "peak", "trough", "amplitude", "mean", "to_csv", "units"):
|
|
169
|
+
return getattr(self.runs[0], name)
|
|
170
|
+
raise AttributeError(name)
|
|
171
|
+
|
|
172
|
+
def __repr__(self) -> str:
|
|
173
|
+
return f"Result({self.analysis}, {len(self.runs)} run(s), {len(self)} rows, columns={self.columns})"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class OP:
|
|
177
|
+
"""DC operating point: node voltages and per-device values (gm, IC, power, ...)."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, path: str):
|
|
180
|
+
d = resmod.parse_op(resmod.read_text(path))
|
|
181
|
+
self.nodes: dict[str, float] = d["nodes"]
|
|
182
|
+
self.devices: dict[str, dict[str, float]] = d["devices"]
|
|
183
|
+
|
|
184
|
+
def v(self, node: str) -> float:
|
|
185
|
+
"""Node voltage by name or number (case-insensitive)."""
|
|
186
|
+
for k, val in self.nodes.items():
|
|
187
|
+
if k.lower() == str(node).lower():
|
|
188
|
+
return val
|
|
189
|
+
raise KeyError(f"no node {node!r}; have {list(self.nodes)}")
|
|
190
|
+
|
|
191
|
+
def __getitem__(self, device: str) -> dict[str, float]:
|
|
192
|
+
for k, val in self.devices.items():
|
|
193
|
+
if k.lower() == device.lower():
|
|
194
|
+
return val
|
|
195
|
+
raise KeyError(f"no device {device!r}; have {list(self.devices)}")
|
|
196
|
+
|
|
197
|
+
def __repr__(self) -> str:
|
|
198
|
+
return f"OP(nodes={self.nodes}, devices={list(self.devices)})"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ----------------------------------------------------------------------------------------------
|
|
202
|
+
class Circuit:
|
|
203
|
+
def __init__(self, name: str):
|
|
204
|
+
self.name = name
|
|
205
|
+
self.path = workspace.path(name)
|
|
206
|
+
self._cir = cirmod.Cir.load(str(self.path))
|
|
207
|
+
self._dirty = True # the first run must write the numeric-output flags
|
|
208
|
+
self._ui: ui.MC9 | None = None
|
|
209
|
+
self._hash: str | None = None
|
|
210
|
+
self.last: Result | None = None
|
|
211
|
+
|
|
212
|
+
# ---- inspect / edit parts ----------------------------------------------------------------
|
|
213
|
+
def parts(self) -> dict[str, dict]:
|
|
214
|
+
"""{'R1': {'type': 'Resistor', 'RESISTANCE': '200'}, ...}"""
|
|
215
|
+
return {c["attrs"]["PART"]: {"type": c["type"], **{k: v for k, v in c["attrs"].items() if k != "PART"}}
|
|
216
|
+
for c in self._cir.components() if "PART" in c["attrs"]}
|
|
217
|
+
|
|
218
|
+
def get(self, part: str, attr: str) -> str:
|
|
219
|
+
c = self._cir.find_part(part)
|
|
220
|
+
key = next((k for k in c["attrs"] if k.lower() == attr.lower()), None)
|
|
221
|
+
if key is None:
|
|
222
|
+
raise KeyError(f"{part} has no {attr!r}; has {list(c['attrs'])}")
|
|
223
|
+
return c["attrs"][key]
|
|
224
|
+
|
|
225
|
+
def set(self, part: str, attr: str, value) -> str:
|
|
226
|
+
"""c.set('R2','RESISTANCE','233.2k'); c.set('V2','VALUE',21.6). Returns the old value."""
|
|
227
|
+
old = self._cir.set_attr(part, attr, _num(value))
|
|
228
|
+
self._dirty = True
|
|
229
|
+
return old
|
|
230
|
+
|
|
231
|
+
# ---- raw analysis settings ----------------------------------------------------------------
|
|
232
|
+
def limits(self, analysis: str, **kv) -> dict:
|
|
233
|
+
"""Read/set raw [Limits] keys of an analysis ('transient','ac','dc','distortion','dynamic_ac','dynamic_dc')."""
|
|
234
|
+
lim = self._cir.limits(_LIMIT_NAME.get(analysis.lower(), analysis))
|
|
235
|
+
for k, v in kv.items():
|
|
236
|
+
lim.set(k, _num(v))
|
|
237
|
+
if kv:
|
|
238
|
+
self._dirty = True
|
|
239
|
+
return dict(ln.split("=", 1) for ln in lim.lines if "=" in ln)
|
|
240
|
+
|
|
241
|
+
def curves(self, analysis: str, *exprs) -> list[dict]:
|
|
242
|
+
"""Replace an analysis' curves. Items: 'v(Ua)', ('F','db(v(Ua))') or dict(y=,x=,plot=,xrng=,yrng=)."""
|
|
243
|
+
name = _LIMIT_NAME.get(analysis.lower(), analysis)
|
|
244
|
+
spec = []
|
|
245
|
+
for e in exprs:
|
|
246
|
+
d = {"y": e} if isinstance(e, str) else {"x": e[0], "y": e[1]} if isinstance(e, tuple) else dict(e)
|
|
247
|
+
spec.append(d)
|
|
248
|
+
self._cir.set_waveforms(name, spec)
|
|
249
|
+
self._dirty = True
|
|
250
|
+
return self._cir.waveforms(name)
|
|
251
|
+
|
|
252
|
+
def show(self, **flags) -> None:
|
|
253
|
+
"""Schematic display: c.show(voltages=True, currents=False, power=False, condition=False, nodes=False)."""
|
|
254
|
+
keys = {"voltages": "Show Voltages", "currents": "Show Current", "power": "Show Power",
|
|
255
|
+
"condition": "Show Condition", "nodes": "Show Nodes", "pins": "Show Pin Connections",
|
|
256
|
+
"attributes": "Show Attribute Text", "grid_text": "Show Grid Text"}
|
|
257
|
+
sec = next(s for s in self._cir.sections if s.name == "Circuit")
|
|
258
|
+
for k, v in flags.items():
|
|
259
|
+
sec.set(keys[k], "True" if v else "False")
|
|
260
|
+
self._dirty = True
|
|
261
|
+
|
|
262
|
+
_MAXIMIZED = "0,3, -1,-1, -4,-30, 0,0,1630,589" # WINDOWPLACEMENT with showCmd=3 (maximized)
|
|
263
|
+
|
|
264
|
+
def _known_names(self) -> set[str]:
|
|
265
|
+
names = {p.lower() for p in self.parts()}
|
|
266
|
+
for sec in self._cir.sections:
|
|
267
|
+
if sec.name == "Grid Text":
|
|
268
|
+
t = (sec.get("Text") or "").strip('"')
|
|
269
|
+
if t and "\u044e" not in t: # skip banner texts
|
|
270
|
+
names.add(t.lower())
|
|
271
|
+
return names
|
|
272
|
+
|
|
273
|
+
def _check_expressions(self, analysis: str) -> None:
|
|
274
|
+
"""Warn about v(label)/i(part) references MC9 would reject ('label not found')."""
|
|
275
|
+
import re
|
|
276
|
+
known = self._known_names()
|
|
277
|
+
for w in self._cir.waveforms(_LIMIT_NAME[analysis]):
|
|
278
|
+
for expr in (w.get("y"), w.get("x")):
|
|
279
|
+
for ref in re.findall(r"\b[vVi]\(\s*([A-Za-z_][\w.]*)\s*[,)]", expr or ""):
|
|
280
|
+
if ref.lower() not in known and not ref.isdigit():
|
|
281
|
+
warnings.warn(f"{analysis}: curve '{expr}' uses '{ref}', which is neither a part nor a named node "
|
|
282
|
+
f"(name it with Schematic.node or use a node number); MC9 will report 'label not found'.",
|
|
283
|
+
stacklevel=4)
|
|
284
|
+
|
|
285
|
+
def _flush(self) -> None:
|
|
286
|
+
for sec in self._cir.sections:
|
|
287
|
+
if sec.name in ("Transient", "AC", "DC", "Distortion") and sec.get("Window Placement") is not None:
|
|
288
|
+
sec.set("Window Placement", self._MAXIMIZED if config.maximize_plots else sec.get("Window Placement"))
|
|
289
|
+
for a in _ANALYSES:
|
|
290
|
+
name = _LIMIT_NAME[a]
|
|
291
|
+
if self._cir.waveform_indices(name):
|
|
292
|
+
self._cir.enable_output(name) # numeric output is what makes results readable
|
|
293
|
+
self._cir.save(str(self.path))
|
|
294
|
+
self._dirty = False
|
|
295
|
+
|
|
296
|
+
# ---- live MC9 -----------------------------------------------------------------------------
|
|
297
|
+
def _session(self) -> ui.MC9:
|
|
298
|
+
if self._dirty:
|
|
299
|
+
self._flush()
|
|
300
|
+
h = hashlib.sha1(self.path.read_bytes()).hexdigest()
|
|
301
|
+
if self._ui is None or h != self._hash:
|
|
302
|
+
if self._ui is not None:
|
|
303
|
+
self._ui.close()
|
|
304
|
+
self._ui, self._hash = ui.MC9(self.name), h
|
|
305
|
+
return self._ui
|
|
306
|
+
|
|
307
|
+
def _to_schematic(self, m: ui.MC9) -> None:
|
|
308
|
+
if m.mode != "schematic":
|
|
309
|
+
m.exit_analysis()
|
|
310
|
+
|
|
311
|
+
def run(self, analysis: str, timeout: int | None = None) -> Result:
|
|
312
|
+
"""Run an analysis with the settings currently in the file; its plot stays open for cursors()/shot()."""
|
|
313
|
+
analysis = analysis.lower()
|
|
314
|
+
if analysis not in _ANALYSES:
|
|
315
|
+
raise ValueError(f"analysis must be one of {_ANALYSES}")
|
|
316
|
+
if analysis != "distortion":
|
|
317
|
+
self._check_expressions(analysis)
|
|
318
|
+
lim = self.limits(analysis)
|
|
319
|
+
npts = int(float(lim.get("NPts", "0") or 0))
|
|
320
|
+
if analysis == "ac" and lim.get("Sweep type", "Auto") != "Log":
|
|
321
|
+
warnings.warn("AC 'Sweep type' is not Log: MC9 writes a coarse, linearly spaced table and cursors "
|
|
322
|
+
"interpolate between those points (band edges can be off by >10%). "
|
|
323
|
+
"Use c.ac(fmin, fmax, points=401).", stacklevel=3)
|
|
324
|
+
elif analysis in ("transient", "ac") and npts and npts < 200:
|
|
325
|
+
warnings.warn(f"{analysis}: NPts={npts} gives only {npts} numeric rows; raise points "
|
|
326
|
+
"(the table, not the simulation step, limits measurement accuracy).", stacklevel=3)
|
|
327
|
+
m = self._session()
|
|
328
|
+
self._to_schematic(m)
|
|
329
|
+
path = m.run(analysis, timeout)
|
|
330
|
+
self.last = Result(analysis, path)
|
|
331
|
+
return self.last
|
|
332
|
+
|
|
333
|
+
# ---- friendly analyses --------------------------------------------------------------------
|
|
334
|
+
def ac(self, fmin: float = 1, fmax: float = 2e7, points: int = 401, curves: Sequence | None = None,
|
|
335
|
+
temp=None, run: bool = True, **limits) -> Result | None:
|
|
336
|
+
"""AC sweep (always logarithmic). curves default: keep the file's, e.g. ['db(v(Ua)/v(Ue))','ph(v(Ua)/v(Ue))']."""
|
|
337
|
+
kv = {"FRange": f"{_num(fmax)},{_num(fmin)}", "NPts": int(points), "Sweep type": "Log"}
|
|
338
|
+
if temp is not None:
|
|
339
|
+
kv.update(self._temps(temp))
|
|
340
|
+
self.limits("ac", **kv, **limits)
|
|
341
|
+
if curves:
|
|
342
|
+
self.curves("ac", *[("F", c) if isinstance(c, str) else c for c in curves])
|
|
343
|
+
return self.run("ac") if run else None
|
|
344
|
+
|
|
345
|
+
def transient(self, t_stop, t_start=0, points: int = 1001, curves: Sequence | None = None, max_step=None,
|
|
346
|
+
temp=None, state: str = "Zero", run: bool = True, **limits) -> Result | None:
|
|
347
|
+
"""Transient. Numeric table = `points` rows between t_start and t_stop; steady-state: t_start > 0."""
|
|
348
|
+
ts = float(t_stop) if not isinstance(t_stop, str) else resmod.parse_value(t_stop)
|
|
349
|
+
t0 = float(t_start) if not isinstance(t_start, str) else resmod.parse_value(t_start)
|
|
350
|
+
step = max_step if max_step is not None else (ts - t0) / points
|
|
351
|
+
kv = {"TRange": f"{_num(t_stop)},{_num(t_start)}", "TStep": _num(step), "NPts": int(points), "StateVar": state}
|
|
352
|
+
if temp is not None:
|
|
353
|
+
kv.update(self._temps(temp))
|
|
354
|
+
self.limits("transient", **kv, **limits)
|
|
355
|
+
if curves:
|
|
356
|
+
self.curves("transient", *[("T", c) if isinstance(c, str) else c for c in curves])
|
|
357
|
+
return self.run("transient") if run else None
|
|
358
|
+
|
|
359
|
+
def dc(self, source: str, start, stop, step, curves: Sequence | None = None, temp=None, run: bool = True,
|
|
360
|
+
**limits) -> Result | None:
|
|
361
|
+
"""DC sweep of a source/part: c.dc('V2', 0, 20, 0.5, ['v(5)']). temp may be a list -> one run per temperature."""
|
|
362
|
+
a, b, s = (resmod.parse_value(v) if isinstance(v, str) else float(v) for v in (start, stop, step))
|
|
363
|
+
kv = {"I1": source, "I1Range": f"{_num(stop)},{_num(start)},{_num(step)}", "Sweep1 type": "Linear",
|
|
364
|
+
"NPts": max(int(round((b - a) / s)) + 1, 2)}
|
|
365
|
+
if temp is not None:
|
|
366
|
+
kv.update(self._temps(temp))
|
|
367
|
+
self.limits("dc", **kv, **limits)
|
|
368
|
+
if curves:
|
|
369
|
+
self.curves("dc", *[("DCINPUT1", c) if isinstance(c, str) else c for c in curves])
|
|
370
|
+
return self.run("dc") if run else None
|
|
371
|
+
|
|
372
|
+
def distortion(self, f0, source: str, amplitude, out: str, cycles: int = 20, max_step=None, temp=25,
|
|
373
|
+
run: bool = True, **limits) -> Result | None:
|
|
374
|
+
"""Harmonic distortion analysis (HARM/IHD/THD curves of `out` at fundamental f0)."""
|
|
375
|
+
f = resmod.parse_value(f0) if isinstance(f0, str) else float(f0)
|
|
376
|
+
amps = amplitude if isinstance(amplitude, str) else ",".join(_num(a) for a in (
|
|
377
|
+
amplitude if isinstance(amplitude, Iterable) and not isinstance(amplitude, (int, float)) else [amplitude]))
|
|
378
|
+
kv = {"F0": _num(f0), "ISource": source, "ISourceAmp": amps, "OutExpr": out, "Cycles": int(cycles),
|
|
379
|
+
"Temp": _num(temp), "AmpMethod": "List"}
|
|
380
|
+
if max_step is not None:
|
|
381
|
+
kv["TStep"] = _num(max_step)
|
|
382
|
+
self.limits("distortion", **kv, **limits)
|
|
383
|
+
fn = f"{f:g}"
|
|
384
|
+
self.curves("distortion",
|
|
385
|
+
dict(x="F", y=f"HARM({out})", plot=1), dict(x="F", y=f"IHD(HARM({out}),{fn})", plot=2),
|
|
386
|
+
dict(x="F", y=f"THD(HARM({out}),{fn})"), dict(x="T", y=out, plot=4))
|
|
387
|
+
return self.run("distortion") if run else None
|
|
388
|
+
|
|
389
|
+
def op(self) -> OP:
|
|
390
|
+
"""DC operating point: node voltages and device tables (IC, gm, power, ...). Fast: no waveform run."""
|
|
391
|
+
sec = next(x for x in self._cir.sections if x.name == "Transient")
|
|
392
|
+
old = sec.get("OperPointOnly")
|
|
393
|
+
sec.set("OperPointOnly", "True")
|
|
394
|
+
self._dirty = True
|
|
395
|
+
try:
|
|
396
|
+
m = self._session()
|
|
397
|
+
self._to_schematic(m)
|
|
398
|
+
path = m.run("transient")
|
|
399
|
+
finally:
|
|
400
|
+
sec.set("OperPointOnly", old or "False")
|
|
401
|
+
self._dirty = True # next run restarts MC9 with the normal setting
|
|
402
|
+
return OP(path)
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _temps(temp) -> dict:
|
|
406
|
+
if isinstance(temp, (list, tuple)):
|
|
407
|
+
return {"Temp": ",".join(_num(t) for t in temp), "Temperature Type": "List"}
|
|
408
|
+
return {"Temp": _num(temp), "Temperature Type": "Linear"}
|
|
409
|
+
|
|
410
|
+
# ---- dynamic (schematic overlay) analyses --------------------------------------------------
|
|
411
|
+
def dynamic_dc(self, temperature: float = 25, slider_percent: float = 1, text: bool = False) -> None:
|
|
412
|
+
"""Node voltages/currents drawn on the schematic (like lab-report 'режим спокою' figures)."""
|
|
413
|
+
self.limits("dynamic_dc", Temp=temperature, SliderPer=slider_percent, PlaceText="True" if text else "False")
|
|
414
|
+
m = self._session()
|
|
415
|
+
self._to_schematic(m)
|
|
416
|
+
m.dynamic_dc()
|
|
417
|
+
|
|
418
|
+
def dynamic_ac(self, freq, temperature: float = 25, first: str = "Mag", second: str = "PhaseDeg",
|
|
419
|
+
slider_percent: float = 10, text: bool = False) -> None:
|
|
420
|
+
"""Phasors on the schematic. first: Mag|MagDB|Real|None, second: PhaseDeg|PhaseRad|Imag|None."""
|
|
421
|
+
self.limits("dynamic_ac", Temp=temperature, FreqList=_num(freq), SliderPer=slider_percent,
|
|
422
|
+
FirstValueDisplay=first, SecondValueDisplay=second, PlaceText="True" if text else "False")
|
|
423
|
+
m = self._session()
|
|
424
|
+
self._to_schematic(m)
|
|
425
|
+
m.dynamic_ac()
|
|
426
|
+
|
|
427
|
+
# ---- plot tools / output -------------------------------------------------------------------
|
|
428
|
+
def cursors(self, left, right=None) -> None:
|
|
429
|
+
"""Put MC9's cursors at X values in the open plot (MC9 then shows its own cursor table)."""
|
|
430
|
+
self._session().cursors(_num(left), None if right is None else _num(right))
|
|
431
|
+
|
|
432
|
+
def back(self) -> None:
|
|
433
|
+
"""Leave the analysis window (return to the schematic)."""
|
|
434
|
+
if self._ui is not None and self._ui.mode != "schematic":
|
|
435
|
+
self._ui.exit_analysis()
|
|
436
|
+
|
|
437
|
+
def shot(self, file_name: str, region: str = "plot", trim: bool = True) -> Path:
|
|
438
|
+
"""Native MC9 screenshot -> sandbox/exchange/<file_name>. region: plot | schematic | window."""
|
|
439
|
+
if "/" in file_name or not file_name.endswith(".png"):
|
|
440
|
+
raise ValueError("file_name must be a plain *.png name")
|
|
441
|
+
crop = None
|
|
442
|
+
side = workspace.exchange() / f"{self.name}.layout.json"
|
|
443
|
+
if region == "schematic" and trim and side.exists(): # tight crop from the builder's own geometry
|
|
444
|
+
import json
|
|
445
|
+
c = json.loads(side.read_text()).get("crop")
|
|
446
|
+
crop = tuple(c) if c else None
|
|
447
|
+
return Path(self._session().screenshot(file_name, region, trim, crop=crop))
|
|
448
|
+
|
|
449
|
+
def export(self, dst: str) -> Path:
|
|
450
|
+
"""Copy the (edited) circuit to a NEW host path; refuses to overwrite."""
|
|
451
|
+
if self._dirty:
|
|
452
|
+
self._flush()
|
|
453
|
+
d = Path(dst).expanduser()
|
|
454
|
+
if d.exists():
|
|
455
|
+
raise FileExistsError(f"{d} exists; refusing to overwrite")
|
|
456
|
+
shutil.copyfile(self.path, d)
|
|
457
|
+
return d
|
|
458
|
+
|
|
459
|
+
def close(self) -> None:
|
|
460
|
+
if self._ui is not None:
|
|
461
|
+
self._ui.close()
|
|
462
|
+
self._ui = None
|
|
463
|
+
|
|
464
|
+
def __enter__(self) -> "Circuit":
|
|
465
|
+
return self
|
|
466
|
+
|
|
467
|
+
def __exit__(self, *exc) -> None:
|
|
468
|
+
self.close()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
_OPEN: dict[str, Circuit] = {}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def safe_name(s: str) -> str:
|
|
475
|
+
return "".join(ch if ch.isascii() and (ch.isalnum() or ch in "_.-") else "_" for ch in s)[:64]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def open(src: str, name: str | None = None) -> Circuit: # noqa: A001
|
|
479
|
+
"""Import a host .cir into the sandbox (read-only on the original) and return a Circuit."""
|
|
480
|
+
p = Path(src).expanduser()
|
|
481
|
+
if p.suffix.lower() != ".cir" or not p.is_file():
|
|
482
|
+
raise ValueError("src must be an existing .cir file")
|
|
483
|
+
name = safe_name(name or p.stem)
|
|
484
|
+
if name in _OPEN:
|
|
485
|
+
_OPEN.pop(name).close()
|
|
486
|
+
shutil.copyfile(p, workspace.path(name))
|
|
487
|
+
_OPEN[name] = Circuit(name)
|
|
488
|
+
return _OPEN[name]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def attach(name: str) -> Circuit:
|
|
492
|
+
"""Circuit already in the sandbox (e.g. built with Schematic or imported earlier)."""
|
|
493
|
+
if name not in _OPEN:
|
|
494
|
+
_OPEN[name] = Circuit(name)
|
|
495
|
+
return _OPEN[name]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def close_all() -> None:
|
|
499
|
+
for c in list(_OPEN.values()):
|
|
500
|
+
c.close()
|
|
501
|
+
_OPEN.clear()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
atexit.register(close_all)
|