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/autoplace.py ADDED
@@ -0,0 +1,522 @@
1
+ """Automatic placement and routing for `Netlist` -> `Schematic`.
2
+
3
+ Strategy (works for the usual analog teaching circuits - filters, dividers, clippers, transistor and op-amp stages):
4
+
5
+ 1. Classify nodes: ground, supply rails (DC sources to ground), the input (signal source) and the output node.
6
+ 2. The *backbone* is the shortest signal path from input to output through series parts; transistors and op-amps are
7
+ traversed base->collector / input->output. It is laid out left to right with spacing wide enough for MC9's voltage boxes.
8
+ 3. Everything else hangs off backbone nodes as *branches*: parts to ground go down, parts to a rail go up (chains of series
9
+ parts are stacked). A part joining two backbone nodes (feedback) is a *bridge*.
10
+ 4. Ground and rails are drawn as buses; sources sit at the ends. If a drawing would cross wires, the offending net falls
11
+ back to *named labels* (MC9 treats equally named nodes as one net), so the result is always electrically correct.
12
+ 5. The result is checked with `Schematic.check()/audit()` and re-spaced if boxes or labels would collide.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from collections import deque
17
+ from dataclasses import dataclass, field
18
+
19
+ from . import catalog
20
+ from .netlist import GROUND, Netlist, NPart
21
+ from .schematic import Part, Schematic
22
+
23
+ COL = 8 # horizontal room for one hanging branch (grid units)
24
+ STUB = 3 # wire between a node and the first part of a branch
25
+ SOURCES = ("Voltage Source", "Battery")
26
+
27
+
28
+ def _signal_value(v: str | None) -> bool:
29
+ t = (v or "").lower().split()
30
+ return "sin" in t or "pulse" in (v or "").lower() or "ac" in t
31
+
32
+
33
+ @dataclass
34
+ class Options:
35
+ mode: str = "dc" # "dc" | "ac": how much room MC9's voltage boxes need
36
+ gap: int | None = None # minimum backbone wire length (grid); default by mode
37
+ input: str | None = None # input node (default: node of the first signal source)
38
+ output: str | None = None # output node (default: the node farthest from the input)
39
+ label_nets: set = field(default_factory=set)
40
+ ground_labels: bool = False # a Ground symbol on every ground pin instead of a bus
41
+ rails_as_labels: bool = False
42
+
43
+
44
+ class _Run:
45
+ def __init__(self, nl: Netlist, o: Options, gap: int):
46
+ self.nl, self.o, self.gap = nl, o, gap
47
+ self.sch = Schematic()
48
+ self.by_net: dict[str, list[tuple[NPart, str]]] = {}
49
+ for p in nl.parts:
50
+ for pin, net in p.nets.items():
51
+ self.by_net.setdefault(net, []).append((p, pin))
52
+ self.placed: dict[str, Part] = {}
53
+ self.wires: list[tuple[tuple, tuple, str]] = []
54
+ self.net_pt: dict[str, tuple[int, int]] = {}
55
+ self.ground_drops: list[tuple[int, int]] = []
56
+ self.ground_up: list[tuple[int, int]] = [] # ground symbols that must point up (end of an upward stub)
57
+ self.rail_drops: dict[str, list[tuple[int, int]]] = {}
58
+ self.stubs: list[tuple[str, tuple[int, int]]] = [] # (net, point) pairs connected by name only
59
+ self.auto = 0
60
+ self.notes: list[str] = []
61
+ self.rail_src: dict[str, NPart] = {}
62
+ self.bridges_labeled: set[str] = set()
63
+ self.bb_nets: set[str] = set()
64
+ self.anchor_nets: set[str] = set()
65
+
66
+ # ---- small helpers ------------------------------------------------------------------------
67
+ def gnd(self, n: str) -> bool:
68
+ return n in GROUND
69
+
70
+ def rail(self, n: str) -> bool:
71
+ return n in self.rail_src
72
+
73
+ def W(self, a, b, net: str) -> None:
74
+ a, b = (int(a[0]), int(a[1])), (int(b[0]), int(b[1]))
75
+ if a == b:
76
+ return
77
+ if a[0] != b[0] and a[1] != b[1]: # make every wire axis aligned
78
+ self.W(a, (b[0], a[1]), net); self.W((b[0], a[1]), b, net)
79
+ return
80
+ self.sch.wire(a, b)
81
+ self.wires.append((a, b, net))
82
+
83
+ def mark(self, net: str, pt) -> None:
84
+ self.net_pt.setdefault(net, (int(pt[0]), int(pt[1])))
85
+
86
+ def rot_for(self, kind: catalog.Kind, first: str, second: str, axis: str) -> int:
87
+ """Rotation putting pin `first` before `second` along x (left->right) or y (top->bottom)."""
88
+ for rot in ((0, 2) if axis == "x" else (1, 3)):
89
+ a = catalog.transform(*kind.pins[first], rot)
90
+ b = catalog.transform(*kind.pins[second], rot)
91
+ if (a[0] < b[0]) if axis == "x" else (a[1] < b[1]):
92
+ return rot
93
+ return 0
94
+
95
+ def add(self, p: NPart, at, rot: int) -> Part:
96
+ sp = self.sch.add(p.kind.name, p.name, p.value, at=at, rot=rot, **p.extra)
97
+ self.placed[p.name] = sp
98
+ return sp
99
+
100
+ def pin_of(self, p: NPart, net: str, skip: str | None = None) -> str:
101
+ return next(pin for pin, n in p.nets.items() if n == net and pin != skip)
102
+
103
+ def label_name(self, net: str) -> str:
104
+ if net.startswith("_") or net in GROUND:
105
+ self.auto += 1
106
+ return f"n{self.auto}"
107
+ return net
108
+
109
+ def label_pt(self, net: str, pt) -> None:
110
+ self.stubs.append((net, (int(pt[0]), int(pt[1]))))
111
+
112
+ # ---- classification -----------------------------------------------------------------------
113
+ def classify(self) -> None:
114
+ parts = self.nl.parts
115
+ grounded = [p for p in parts if p.kind.name in SOURCES and any(self.gnd(n) for n in p.nets.values())]
116
+ sig = [p for p in grounded if _signal_value(p.value)]
117
+ active = any(p.kind.name in ("NPN", "PNP", "Opamp", "NMOS") for p in parts)
118
+ self.src = None
119
+ if self.o.input:
120
+ self.src = next((p for p in grounded if self.o.input in p.nets.values()), None)
121
+ elif sig:
122
+ self.src = sig[0]
123
+ elif grounded and not active:
124
+ self.src = grounded[0]
125
+ self.supplies = [p for p in grounded if p is not self.src]
126
+ for p in self.supplies:
127
+ net = next(n for n in p.nets.values() if not self.gnd(n))
128
+ self.rail_src[net] = p
129
+ self.input_net = self.o.input or (next((n for n in self.src.nets.values() if not self.gnd(n)), None) if self.src else None)
130
+ if self.input_net is None:
131
+ raise ValueError("no input: add a signal source (sin/pulse/AC) between a node and 0, or pass input='node'")
132
+
133
+ def edges(self):
134
+ """Traversable (part, enter_pin, exit_pin) per node, excluding ground/rail nodes and source/supply parts."""
135
+ out: dict[str, list[tuple[NPart, str, str]]] = {}
136
+ for p in self.nl.parts:
137
+ if p is self.src or p in self.supplies:
138
+ continue
139
+ k = p.kind.name
140
+ pairs: list[tuple[str, str]] = []
141
+ if p.two_terminal:
142
+ a, b = list(p.nets)
143
+ pairs = [(a, b), (b, a)]
144
+ elif k in ("NPN", "PNP"):
145
+ pairs = [("Base", "Collector")]
146
+ elif k == "NMOS":
147
+ pairs = [("Gate", "Drain")]
148
+ elif k == "Opamp":
149
+ pairs = [("Plus input", "Output"), ("Minus input", "Output")]
150
+ for ein, eout in pairs:
151
+ na, nb = p.nets[ein], p.nets[eout]
152
+ if self.gnd(na) or self.gnd(nb) or self.rail(na) or self.rail(nb) or na == nb:
153
+ continue
154
+ out.setdefault(na, []).append((p, ein, eout))
155
+ for lst in out.values(): # active devices first: they, not feedback parts, form the path
156
+ lst.sort(key=lambda st: st[0].two_terminal)
157
+ return out
158
+
159
+ def find_backbone(self) -> list[tuple[NPart, str, str]]:
160
+ e = self.edges()
161
+ prev: dict[str, tuple[str, tuple]] = {}
162
+ order = [self.input_net]
163
+ q = deque([self.input_net])
164
+ seen = {self.input_net}
165
+ while q:
166
+ n = q.popleft()
167
+ for step in e.get(n, []):
168
+ m = step[0].nets[step[2]]
169
+ if m not in seen:
170
+ seen.add(m); prev[m] = (n, step); order.append(m); q.append(m)
171
+ controls = {n for p in self.nl.parts for pin, n in p.nets.items()
172
+ if pin in ("Base", "Gate", "Plus input", "Minus input")} # feedback nodes are never "the output"
173
+ cands = [n for n in order[1:] if n not in controls]
174
+ target = self.o.output or (cands[-1] if cands else (order[-1] if len(order) > 1 else None))
175
+ if target is None or target not in prev:
176
+ return []
177
+ path, n = [], target
178
+ while n != self.input_net:
179
+ n, step = prev[n]
180
+ path.append(step)
181
+ path.reverse()
182
+ self.output_net = target
183
+ return path
184
+
185
+ @staticmethod
186
+ def colw(items) -> int:
187
+ """Horizontal room for hanging branches: wide enough for the labels each part carries."""
188
+ w = COL
189
+ for p, _pin in items:
190
+ chars = max(len(p.name or ""), len(p.value or ""))
191
+ w = max(w, int(chars * 7 / 8 + 0.999) + 5)
192
+ return w
193
+
194
+ # ---- branches -----------------------------------------------------------------------------
195
+ def branch_items(self, net: str) -> list[tuple[NPart, str]]:
196
+ return [(p, pin) for p, pin in self.by_net.get(net, [])
197
+ if p.name not in self.placed and p.two_terminal and p is not self.src and p not in self.supplies
198
+ and not any(n in (self.bb_nets | self.anchor_nets) and n != net for n in p.nets.values())] # those are bridges
199
+
200
+ def direction_of(self, p: NPart, net: str) -> str:
201
+ o = p.nets[next(pin for pin in p.nets if p.nets[pin] != net)] if len({*p.nets.values()}) > 1 else net
202
+ return "up" if self.rail(o) else "down"
203
+
204
+ def hang(self, net: str, anchors: list[tuple[int, int]], direction: str | None = None) -> None:
205
+ """Hang the unplaced parts of `net` below/above anchor points that lie on a wire of that net."""
206
+ items = self.branch_items(net)
207
+ if not items:
208
+ return
209
+ groups = {"up": [], "down": []}
210
+ for p, pin in items:
211
+ groups[direction or self.direction_of(p, net)].append((p, pin))
212
+ for d, lst in groups.items():
213
+ for i, (p, _pin) in enumerate(lst):
214
+ ax, ay = anchors[min(i, len(anchors) - 1)] if len(anchors) >= len(lst) else anchors[0]
215
+ if len(anchors) < len(lst):
216
+ ax += round((i - (len(lst) - 1) / 2) * self.colw(lst))
217
+ if p.name in self.placed:
218
+ continue
219
+ self.chain(p, net, (ax, ay), d)
220
+
221
+ def chain(self, p: NPart, net: str, node: tuple[int, int], d: str) -> None:
222
+ x, y = node
223
+ near = self.pin_of(p, net)
224
+ far = next(pin for pin in p.nets if pin != near)
225
+ sgn = 1 if d == "down" else -1
226
+ y_near = y + sgn * STUB
227
+ rot = self.rot_for(p.kind, near, far, "y") if d == "down" else self.rot_for(p.kind, far, near, "y")
228
+ off = catalog.transform(*p.kind.pins[near], rot)
229
+ part = self.add(p, (x - off[0], y_near - off[1]), rot)
230
+ self.W((x, y), (x, y_near), net)
231
+ far_pt = part.pin(far)
232
+ self.mark(net, (x, y + sgn))
233
+ fnet = p.nets[far]
234
+ if self.gnd(fnet):
235
+ self.ground_drops.append(far_pt)
236
+ elif self.rail(fnet):
237
+ self.rail_drops.setdefault(fnet, []).append(far_pt)
238
+ else:
239
+ self.mark(fnet, far_pt)
240
+ self.hang(fnet, [far_pt], d)
241
+ # net continues nowhere else: remember a point for labels
242
+ if self.branch_items(fnet) == [] and len(self.by_net.get(fnet, [])) <= 1:
243
+ self.notes.append(f"node {fnet} is connected to a single pin")
244
+
245
+ # ---- main layout --------------------------------------------------------------------------
246
+ def layout(self) -> None:
247
+ self.classify()
248
+ path = self.find_backbone()
249
+ if not path:
250
+ raise ValueError("no signal path between the input and the output node; set input=/output= or check the netlist")
251
+ self.bb_nets = {self.input_net} | {p.nets[eout] for p, _, eout in path}
252
+ for p, ein, eout in path: # nets of side pins (emitter, 2nd op-amp input) get their own anchor
253
+ if not p.two_terminal:
254
+ self.anchor_nets |= {n for pin, n in p.nets.items() if pin not in (ein, eout, "VCC", "VEE")
255
+ and not self.gnd(n) and not self.rail(n)}
256
+ y = 0
257
+ xs = 0
258
+ srcp = self.add(self.src, (xs, y + 6), 3) # vertical source: Plus up
259
+ plus = srcp.pin("Plus")
260
+ self.ground_drops.append(srcp.pin("Minus"))
261
+ prev, row_y = plus, y
262
+ self.mark(self.input_net, (prev[0] + 2, prev[1]))
263
+ for _i, (p, ein, eout) in enumerate(path):
264
+ net_in, net_out = p.nets[ein], p.nets[eout]
265
+ ups = [it for it in self.branch_items(net_in) if self.direction_of(it[0], net_in) == "up"]
266
+ downs = [it for it in self.branch_items(net_in) if self.direction_of(it[0], net_in) == "down"]
267
+ cw = self.colw(ups + downs)
268
+ gap = max(self.gap, cw * max(len(ups), len(downs), 1) + 2)
269
+ if p.kind.name in ("NPN", "PNP", "NMOS", "Opamp"):
270
+ rot = 0
271
+ else:
272
+ rot = self.rot_for(p.kind, ein, eout, "x")
273
+ off = catalog.transform(*p.kind.pins[ein], rot)
274
+ enter = (prev[0] + gap, row_y)
275
+ part = self.add(p, (enter[0] - off[0], enter[1] - off[1]), rot)
276
+ self.W(prev, enter, net_in)
277
+ ax = prev[0] + gap // 2
278
+ anchors = [(ax, row_y)]
279
+ if len(ups) > 1 or len(downs) > 1:
280
+ n = max(len(ups), len(downs))
281
+ anchors = [(round(ax + (k - (n - 1) / 2) * cw), row_y) for k in range(n)]
282
+ self.mark(net_in, (prev[0] + 2, row_y)) if net_in not in self.net_pt else None
283
+ self.hang(net_in, anchors)
284
+ exit_pt = part.pin(eout)
285
+ row_y = exit_pt[1]
286
+ self.side_pins(p, part, ein, eout)
287
+ prev = exit_pt
288
+ self.mark(net_out, (prev[0] + 2, row_y))
289
+ # output tail
290
+ ups = [it for it in self.branch_items(self.output_net) if self.direction_of(it[0], self.output_net) == "up"]
291
+ downs = [it for it in self.branch_items(self.output_net) if self.direction_of(it[0], self.output_net) == "down"]
292
+ cw = self.colw(ups + downs)
293
+ gap = max(self.gap, cw * max(len(ups), len(downs), 1) + 2)
294
+ n = max(len(ups), len(downs), 1)
295
+ anchors = [(round(prev[0] + gap // 2 + (k - (n - 1) / 2) * cw), row_y) for k in range(n)]
296
+ has_branches = bool(ups or downs)
297
+ end = (max(a[0] for a in anchors) if has_branches else prev[0] + 6, row_y) # no dangling wire past the last branch
298
+ self.W(prev, end, self.output_net)
299
+ self.hang(self.output_net, anchors)
300
+ self.bridges(path, row_y)
301
+ self.rail_buses()
302
+ self.leftovers()
303
+ self.ground_bus()
304
+
305
+ def side_pins(self, p: NPart, part: Part, ein: str, eout: str) -> None:
306
+ """Pins of multi-pin parts that are not on the backbone: emitter/source, op-amp second input and supplies."""
307
+ k = p.kind.name
308
+ if k in ("NPN", "PNP", "NMOS"):
309
+ pin = "Emitter" if k != "NMOS" else "Source"
310
+ pt = part.pin(pin)
311
+ net = p.nets[pin]
312
+ d = "down" if (pt[1] > part.pin(ein)[1]) else "up"
313
+ self.route_pin(net, pt, d)
314
+ if k == "NMOS":
315
+ bpt = part.pin("Body"); bnet = p.nets["Body"]
316
+ if bnet != net:
317
+ self.route_pin(bnet, (bpt[0] + 0, bpt[1]), "down", stub_x=2)
318
+ elif k == "Opamp":
319
+ other = "Plus input" if ein == "Minus input" else "Minus input"
320
+ pt = part.pin(other)
321
+ net = p.nets[other]
322
+ if self.gnd(net):
323
+ end = (pt[0] - 3, pt[1])
324
+ self.W(pt, end, net)
325
+ self.ground_drops.append(end)
326
+ else:
327
+ end = (pt[0] - 4, pt[1])
328
+ self.W(pt, end, net)
329
+ self.mark(net, (end[0], end[1]))
330
+ self.hang(net, [end], "down")
331
+ for pin, d in (("VCC", "up"), ("VEE", "down")):
332
+ net = p.nets[pin]
333
+ pt = part.pin(pin)
334
+ end = (pt[0], pt[1] + (-STUB if d == "up" else STUB))
335
+ self.W(pt, end, net)
336
+ if self.rail(net) and d == "up" and not self.o.rails_as_labels:
337
+ self.rail_drops.setdefault(net, []).append(end)
338
+ elif self.gnd(net):
339
+ self.ground_drops.append(end)
340
+ else:
341
+ self.label_pt(net, end)
342
+
343
+ def route_pin(self, net: str, pt, d: str, stub_x: int = 0) -> None:
344
+ if self.gnd(net):
345
+ self.ground_drops.append(pt)
346
+ elif self.rail(net):
347
+ self.rail_drops.setdefault(net, []).append(pt)
348
+ else:
349
+ self.mark(net, pt)
350
+ self.hang(net, [pt], d)
351
+
352
+ def bridges(self, path, row_y: int) -> None:
353
+ """Parts joining two nets that already have points (feedback): placed above the backbone with labelled stubs."""
354
+ todo = [p for p in self.nl.parts if p.name not in self.placed and p.two_terminal and p is not self.src
355
+ and p not in self.supplies and all(n in self.net_pt for n in p.nets.values())]
356
+ top = min([w[0][1] for w in self.wires] + [w[1][1] for w in self.wires] + [row_y]) - 8
357
+ for i, p in enumerate(todo):
358
+ a, b = list(p.nets.values())
359
+ xa, xb = self.net_pt[a][0], self.net_pt[b][0]
360
+ cx = (xa + xb) // 2
361
+ ytop = top - 8 * i
362
+ pa, pb = list(p.nets)[0], list(p.nets)[1]
363
+ na, nb = p.nets[pa], p.nets[pb]
364
+ left_first = self.net_pt[na][0] <= self.net_pt[nb][0]
365
+ first, second = (pa, pb) if left_first else (pb, pa)
366
+ rot = self.rot_for(p.kind, first, second, "x")
367
+ off = catalog.transform(*p.kind.pins[first], rot)
368
+ x1 = cx - 3
369
+ part = self.add(p, (x1 - off[0], ytop - off[1]), rot)
370
+ direct = p.nets[first] not in self.o.label_nets and p.nets[second] not in self.o.label_nets
371
+ for pin in (first, second):
372
+ net = p.nets[pin]; pt = part.pin(pin); anchor = self.net_pt[net]
373
+ if direct:
374
+ self.W(pt, (anchor[0], pt[1]), net)
375
+ self.W((anchor[0], pt[1]), anchor, net)
376
+ else:
377
+ end = (pt[0] + (-3 if pin == first else 3), pt[1])
378
+ self.W(pt, end, net)
379
+ self.label_pt(net, end)
380
+ self.label_pt(net, anchor)
381
+
382
+ def leftovers(self) -> None:
383
+ """Anything still unplaced (supplies for label rails, odd parts) goes into a free row, connected by labels."""
384
+ free = [p for p in self.nl.parts if p.name not in self.placed]
385
+ xs = [w[0][0] for w in self.wires] + [w[1][0] for w in self.wires]
386
+ x = (max(xs) if xs else 0) + 16
387
+ for p in free:
388
+ if p.kind.name in SOURCES:
389
+ plus = next(pin for pin in p.nets if pin in ("Plus",))
390
+ minus = "Minus"
391
+ part = self.add(p, (x, 6), 3)
392
+ pp = part.pin("Plus"); mp = part.pin("Minus")
393
+ end = (pp[0], pp[1] - STUB)
394
+ self.W(pp, end, p.nets[plus])
395
+ net = p.nets[plus]
396
+ if self.gnd(net):
397
+ self.ground_up.append(end)
398
+ else:
399
+ self.label_pt(net, end)
400
+ mnet = p.nets[minus]
401
+ if self.gnd(mnet):
402
+ self.ground_drops.append(mp)
403
+ else:
404
+ end2 = (mp[0], mp[1] + STUB)
405
+ self.W(mp, end2, mnet)
406
+ self.label_pt(mnet, end2)
407
+ x += 12
408
+ else:
409
+ self.notes.append(f"{p.name}: could not be placed automatically; connect it by node name")
410
+
411
+ def rail_buses(self) -> None:
412
+ """First up-rail as a bus with its source at the right end; further rails by labels."""
413
+ used = []
414
+ for net, drops in list(self.rail_drops.items()):
415
+ src = self.rail_src.get(net)
416
+ if self.o.rails_as_labels or net in self.o.label_nets or (used and True):
417
+ for pt in drops:
418
+ end = (pt[0], pt[1] - STUB)
419
+ self.W(pt, end, net)
420
+ self.label_pt(net, end)
421
+ if src and src.name not in self.placed:
422
+ pass
423
+ continue
424
+ used.append(net)
425
+ ytop = min(pt[1] for pt in drops) - 3
426
+ xmin, xmax = min(pt[0] for pt in drops), max(pt[0] for pt in drops)
427
+ for pt in drops:
428
+ self.W(pt, (pt[0], ytop), net)
429
+ xs = xmax
430
+ right = max([w[0][0] for w in self.wires] + [w[1][0] for w in self.wires] + [xmax])
431
+ if src is not None and src.name not in self.placed:
432
+ xs = max(xmax + 10, right + 8) # beyond the whole circuit: its drop to ground must not cut through parts
433
+ part = self.add(src, (xs, ytop + 6), 3)
434
+ plus_pt = part.pin("Plus")
435
+ if self.gnd(src.nets["Plus"]): # negative rail: source upside down
436
+ self.sch.parts.remove(part); self.placed.pop(src.name)
437
+ part = self.add(src, (xs, ytop), 1)
438
+ plus_pt, minus_pt = part.pin("Plus"), part.pin("Minus")
439
+ self.W((xmax, ytop), (xs, ytop), net)
440
+ else:
441
+ self.W((xmax, ytop), plus_pt, net)
442
+ minus_pt = part.pin("Minus")
443
+ self.ground_drops.append(minus_pt)
444
+ self.W((xmin, ytop), (xs, ytop), net)
445
+ self.mark(net, (xmin, ytop))
446
+
447
+ def ground_bus(self) -> None:
448
+ for pt in self.ground_up:
449
+ self.sch.add("GND", None, at=pt, rot=3)
450
+ if not self.ground_drops:
451
+ return
452
+ if self.o.ground_labels or "0" in self.o.label_nets:
453
+ for pt in self.ground_drops:
454
+ self.sch.add("GND", None, at=pt)
455
+ return
456
+ ybot = max(pt[1] for pt in self.ground_drops) + 3
457
+ xs = [pt[0] for pt in self.ground_drops]
458
+ for pt in self.ground_drops:
459
+ self.W(pt, (pt[0], ybot), "0")
460
+ self.W((min(xs), ybot), (max(xs), ybot), "0")
461
+ self.sch.add("GND", None, at=(max(xs), ybot))
462
+
463
+ # ---- finishing ----------------------------------------------------------------------------
464
+ def finish(self) -> Schematic:
465
+ for net in self.nl.nodes():
466
+ if self.gnd(net) or net.startswith("_") or net not in self.net_pt:
467
+ continue
468
+ self.sch.node(net, self.net_pt[net])
469
+ done: set[str] = set()
470
+ for net, pt in self.stubs: # name-connected stubs: every group needs the same label
471
+ nm = self.label_name(net)
472
+ self.sch.node(nm, pt)
473
+ done.add(net)
474
+ self.sch.notes += self.notes
475
+ return self.sch
476
+
477
+ def crossings(self) -> list[tuple[str, str]]:
478
+ """Pairs of nets whose recorded wires cross without sharing an endpoint."""
479
+ out = []
480
+ for i, (a1, a2, n1) in enumerate(self.wires):
481
+ for b1, b2, n2 in self.wires[i + 1:]:
482
+ if n1 == n2 or (a1[0] == a2[0]) == (b1[0] == b2[0]):
483
+ continue
484
+ v, h = ((a1, a2), (b1, b2)) if a1[0] == a2[0] else ((b1, b2), (a1, a2))
485
+ x, y = v[0][0], h[0][1]
486
+ if min(v[0][1], v[1][1]) < y < max(v[0][1], v[1][1]) and min(h[0][0], h[1][0]) < x < max(h[0][0], h[1][0]):
487
+ out.append((n1, n2))
488
+ return out
489
+
490
+
491
+ def place(nl: Netlist, mode: str = "dc", gap: int | None = None, input: str | None = None, output: str | None = None,
492
+ rails_as_labels: bool = False, ground_labels: bool = False, max_tries: int = 8) -> Schematic:
493
+ """Place and route a netlist. Returns a `Schematic` (call `.build(name)`); `.notes` lists anything unusual."""
494
+ o = Options(mode=mode, gap=gap, input=input, output=output, rails_as_labels=rails_as_labels, ground_labels=ground_labels)
495
+ g = gap or (8 if mode == "dc" else 14)
496
+ last = None
497
+ for _ in range(max_tries):
498
+ r = _Run(nl, o, g)
499
+ r.layout()
500
+ crossed = r.crossings()
501
+ if crossed: # cheapest fix first: ground per pin, then rails, then bridges
502
+ nets = {n for pair in crossed for n in pair}
503
+ if "0" in nets and not o.ground_labels:
504
+ o.ground_labels = True
505
+ elif any(r.rail(n) for n in nets) and not o.rails_as_labels:
506
+ o.rails_as_labels = True
507
+ else:
508
+ o.label_nets |= {n for n in nets if n != "0"}
509
+ last = r
510
+ continue
511
+ sch = r.finish()
512
+ sch.auto_labels(mode)
513
+ issues = sch.audit(mode)
514
+ boxes = [m for m in issues if "voltage box" in m or "overlap" in m]
515
+ if boxes and g < 26:
516
+ g += 4
517
+ last = r
518
+ continue
519
+ return sch
520
+ sch = (last or r).finish()
521
+ sch.notes.append("auto-placement did not converge to a collision-free drawing; check the audit messages")
522
+ return sch
@@ -0,0 +1,20 @@
1
+ """MC9 drivers. `create(name)` picks the backend: Windows native, or Linux + Wine sandbox."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from ..config import config
7
+ from .base import Driver
8
+
9
+
10
+ def create(name: str) -> Driver:
11
+ kind = config.backend
12
+ if kind == "auto":
13
+ kind = "windows" if sys.platform == "win32" else "wine"
14
+ if kind == "windows":
15
+ from .windows import WindowsDriver
16
+ return WindowsDriver(name)
17
+ if kind == "wine":
18
+ from .linux_wine import LinuxWineDriver
19
+ return LinuxWineDriver(name)
20
+ raise ValueError(f"unknown backend {kind!r} (use 'auto', 'wine' or 'windows')")
mc9/backends/base.py ADDED
@@ -0,0 +1,62 @@
1
+ """Driver interface: the few things MC9 automation needs, independent of the operating system."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+
9
+ class Driver(ABC):
10
+ """Controls one running MC9 instance that has the circuit open."""
11
+
12
+ name: str
13
+
14
+ @abstractmethod
15
+ def open_analysis(self, key: str) -> None:
16
+ """Open an analysis (key: transient|ac|dc|distortion|dynamic_dc|dynamic_ac); its limits dialog appears."""
17
+
18
+ @abstractmethod
19
+ def press_run(self) -> None:
20
+ """Start the analysis whose limits dialog is open."""
21
+
22
+ @abstractmethod
23
+ def confirm_dynamic(self, key: str) -> None:
24
+ """Press OK in the Dynamic DC / Dynamic AC dialog."""
25
+
26
+ @abstractmethod
27
+ def set_cursors(self, left: str, right: str | None) -> None:
28
+ """Open MC9's 'go to X' dialog, put the left (and right) cursor there, close it."""
29
+
30
+ @abstractmethod
31
+ def exit_analysis(self) -> None:
32
+ """Close the analysis window, back to the schematic."""
33
+
34
+ @abstractmethod
35
+ def prepare_capture(self, schematic: bool) -> None:
36
+ """Remove hover artefacts before a screenshot (leave placement mode, park the mouse)."""
37
+
38
+ @abstractmethod
39
+ def grab(self):
40
+ """PIL image of the MC9 window (origin = window top-left, size 1900x980)."""
41
+
42
+ @abstractmethod
43
+ def close(self) -> None:
44
+ """Terminate MC9."""
45
+
46
+ # ---- shared helpers ------------------------------------------------------------------------
47
+ @staticmethod
48
+ def sleep(seconds: float) -> None:
49
+ time.sleep(seconds)
50
+
51
+ @staticmethod
52
+ def wait_output(path: Path, timeout: float, stable_s: int = 4) -> bool:
53
+ """Wait until `path` exists, is non-empty and its size stopped changing for `stable_s` seconds."""
54
+ last, same, t0 = -1, 0, time.time()
55
+ while time.time() - t0 < timeout:
56
+ time.sleep(1)
57
+ sz = path.stat().st_size if path.exists() else -1
58
+ same = same + 1 if (sz > 0 and sz == last) else 0
59
+ last = sz
60
+ if same >= stable_s:
61
+ return True
62
+ return False