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/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
|
mc9/backends/__init__.py
ADDED
|
@@ -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
|