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 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
@@ -0,0 +1,6 @@
1
+ """``python -m mc9 ...`` is the same as the ``mc9`` command."""
2
+
3
+ from mc9.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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)