Skip to content

API Reference

opens_suite

analysis_widget

AnalysisWidget

Bases: QDockWidget

Source code in src/opens_suite/analysis_widget.py
class AnalysisWidget(QDockWidget):
    analysesChanged = pyqtSignal()

    def __init__(self, parent=None):
        super().__init__("Analysis", parent)
        self.setAllowedAreas(
            Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea
        )

        self.tree_view = QTreeView()
        self.tree_view.setHeaderHidden(True)
        self.tree_view.doubleClicked.connect(self.on_double_click)
        self.tree_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tree_view.customContextMenuRequested.connect(self.show_context_menu)

        self.model = QStandardItemModel(self)
        self.tree_view.setModel(self.model)
        self.model.itemChanged.connect(self.on_item_changed)

        # Add Root/Placeholder
        self.add_placeholder()

        self.setWidget(self.tree_view)

        self.analysis_data = {}  # type -> config_dict

    def add_placeholder(self):
        # Only add if not already present
        for i in range(self.model.rowCount()):
            if (
                self.model.item(i)
                and self.model.item(i).text() == "Click here to add analysis"
            ):
                return

        item = QStandardItem("Click here to add analysis")
        item.setEditable(False)
        self.model.appendRow(item)

    def on_item_changed(self, item):
        if item.isCheckable():
            self.analysesChanged.emit()

    def on_double_click(self, index):
        item = self.model.itemFromIndex(index)
        if item.text() == "Click here to add analysis":
            # New Analysis
            dialog = AnalysisDialog(self)
            if dialog.exec():
                config = dialog.get_config()
                self.add_analysis(config)
        else:
            # Edit Analysis (parse type from text or data)
            # Store config in user role?
            config = item.data(Qt.ItemDataRole.UserRole)
            if config:
                dialog = AnalysisDialog(self, config)
                if dialog.exec():
                    new_config = dialog.get_config()
                    self.add_analysis(new_config, item)

    def show_context_menu(self, position):
        index = self.tree_view.indexAt(position)
        if not index.isValid():
            return

        item = self.model.itemFromIndex(index)

        # Don't show context menu for the placeholder or child items (parameters)
        if item.text() == "Click here to add analysis" or item.parent() is not None:
            return

        from PyQt6.QtWidgets import QMenu

        menu = QMenu()
        remove_action = menu.addAction("Remove Analysis")

        action = menu.exec(self.tree_view.viewport().mapToGlobal(position))
        if action == remove_action:
            if item.checkState() == Qt.CheckState.Checked:
                # Need to emit signal if removing an active analysis
                emit = True
            else:
                emit = False
            self.model.removeRow(item.row())
            if emit:
                self.analysesChanged.emit()

    def add_analysis(self, config, existing_item=None):
        an_type = config.get("type")
        text = f"{an_type} Analysis"

        # Determine Check State
        enabled = config.get("enabled", True)
        check_state = Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked

        if existing_item:
            parent_item = existing_item
            parent_item.setText(text)
            parent_item.setData(config, Qt.ItemDataRole.UserRole)
            parent_item.setCheckState(check_state)

            # Clear children to rebuild
            if parent_item.hasChildren():
                parent_item.removeRows(0, parent_item.rowCount())
        else:
            # Check if we have "Click here..."
            root = self.model.invisibleRootItem()
            count = root.rowCount()

            # Find insertion point (before placeholder)
            placeholder_row = -1
            for i in range(count):
                if root.child(i).text() == "Click here to add analysis":
                    placeholder_row = i
                    break

            parent_item = QStandardItem(text)
            parent_item.setEditable(False)
            parent_item.setData(config, Qt.ItemDataRole.UserRole)
            parent_item.setCheckable(True)
            parent_item.setCheckState(check_state)

            if placeholder_row != -1:
                self.model.insertRow(placeholder_row, parent_item)
            else:
                self.model.appendRow(parent_item)

        # Add Parameters as Children
        # Config has keys like 'start', 'stop', 'source', etc.
        # We can format them nicely.
        for key, value in config.items():
            if key in ("type", "enabled"):
                continue
            if not value:
                continue

            child_text = f"{key}: {value}"
            child = QStandardItem(child_text)
            child.setEditable(False)
            parent_item.appendRow(child)

        # Expand
        self.tree_view.expand(self.model.indexFromItem(parent_item))

        if not self.signalsBlocked():
            self.analysesChanged.emit()

    def get_all_analyses(self):
        analyses = []
        root = self.model.invisibleRootItem()
        for i in range(root.rowCount()):
            item = root.child(i)
            if item.text() != "Click here to add analysis":
                config = item.data(Qt.ItemDataRole.UserRole) or {}
                # Update enabled state
                config["enabled"] = item.checkState() == Qt.CheckState.Checked
                analyses.append(config)
        return analyses

    def restore_analyses(self, analyses_list):
        # Using beginResetModel/endResetModel is the safest way to clear/reset
        self.model.beginResetModel()
        try:
            # Use removeRows to avoid QStandardItemModel.clear()'s internal reset signals
            self.model.removeRows(0, self.model.rowCount())

            # Add analyses
            self.blockSignals(True)
            for config in analyses_list:
                # Convert old boolean or string to boolean if needed
                if "enabled" in config and isinstance(config["enabled"], str):
                    config["enabled"] = config["enabled"].lower() == "true"

                self.add_analysis(config)

            # Add placeholder at end
            self.add_placeholder()
        finally:
            self.blockSignals(False)
            self.model.endResetModel()

    def get_current_analysis_type(self):
        """Returns the type of the currently selected analysis, or the first active one."""
        # 1. Check selection
        indexes = self.tree_view.selectedIndexes()
        if indexes:
            item = self.model.itemFromIndex(indexes[0])
            config = item.data(Qt.ItemDataRole.UserRole)
            if config and "type" in config:
                return config["type"]

        # 2. Fallback to first enabled analysis
        root = self.model.invisibleRootItem()
        for i in range(root.rowCount()):
            item = root.child(i)
            if item.checkState() == Qt.CheckState.Checked:
                config = item.data(Qt.ItemDataRole.UserRole)
                if config and "type" in config:
                    return config["type"]

        return "Tran"  # Default
get_current_analysis_type()

Returns the type of the currently selected analysis, or the first active one.

Source code in src/opens_suite/analysis_widget.py
def get_current_analysis_type(self):
    """Returns the type of the currently selected analysis, or the first active one."""
    # 1. Check selection
    indexes = self.tree_view.selectedIndexes()
    if indexes:
        item = self.model.itemFromIndex(indexes[0])
        config = item.data(Qt.ItemDataRole.UserRole)
        if config and "type" in config:
            return config["type"]

    # 2. Fallback to first enabled analysis
    root = self.model.invisibleRootItem()
    for i in range(root.rowCount()):
        item = root.child(i)
        if item.checkState() == Qt.CheckState.Checked:
            config = item.data(Qt.ItemDataRole.UserRole)
            if config and "type" in config:
                return config["type"]

    return "Tran"  # Default

calculator_widget

CalculatorDialog

Bases: QMainWindow

Source code in src/opens_suite/calculator_widget.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
class CalculatorDialog(QMainWindow):
    sendToOutputsRequested = pyqtSignal(str)
    probeRequested = pyqtSignal()

    def __init__(self, raw_path, parent=None):
        super().__init__(parent)
        self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
        self.raw_path = raw_path
        self.all_plots = {}
        self._load_data()
        self.viewer = None  # Waveform viewer window

        self.probe_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "probe.svg")
        )
        self._setup_ui()
        self._populate_signals()

    def _load_data(self):
        if not self.raw_path or not os.path.exists(self.raw_path):
            return

        parser = SpiceRawParser(self.raw_path)
        self.all_plots = parser.parse() or {}

    def refresh(self):
        self._load_data()
        self._populate_signals()

    def _bring_to_front(self):
        self.show()
        self.raise_()
        self.activateWindow()

    def _refresh_and_replot(self):
        self.refresh()
        self.evaluate()

    def _setup_ui(self):
        self.setWindowTitle("Simulation Calculator")
        self.resize(900, 600)

        # Toolbar
        toolbar = QToolBar()
        toolbar.setMovable(False)

        self.send_to_outputs_action = QAction("📤 Send to Outputs", self)
        self.send_to_outputs_action.setToolTip(
            "Add this expression to the Output Expressions dock"
        )
        self.send_to_outputs_action.triggered.connect(self._send_to_outputs)
        toolbar.addAction(self.send_to_outputs_action)

        self.eval_action = QAction("▶️ Evaluate", self)
        self.eval_action.setToolTip("Execute the Python script")
        self.eval_action.triggered.connect(self.evaluate)
        toolbar.addAction(self.eval_action)

        self.probe_action = QAction(self.probe_icon, "Probe Schematic", self)
        self.probe_action.setToolTip("Click a net in the schematic to insert it here")
        self.probe_action.triggered.connect(self.probeRequested.emit)
        toolbar.addAction(self.probe_action)

        self.clear_action = QAction("🧹 Clear", self)
        self.clear_action.setToolTip("Clear the python script")
        toolbar.addAction(self.clear_action)

        # Help action to show available functions/variables
        self.help_action = QAction("❓ Help", self)
        self.help_action.setToolTip("Show available functions and variables")
        self.help_action.triggered.connect(self._show_help_dialog)
        toolbar.addAction(self.help_action)

        self.addToolBar(toolbar)

        # Central widget: script editor
        central = QWidget()
        central_layout = QVBoxLayout(central)
        central_layout.setContentsMargins(8, 8, 8, 8)

        title_label = QLabel("Python Script:")
        title_label.setStyleSheet("font-size: 12pt; font-weight: bold;")
        central_layout.addWidget(title_label)

        self.script_edit = QTextEdit()
        self.script_edit.setAcceptRichText(False)
        self.script_edit.setPlaceholderText(
            "# Example:\nplot(t, dB(vf('vout')))\n\n# Or just:\nvt('v1')"
        )
        from opens_suite.syntax_highlighter import apply_dark_plus_theme

        apply_dark_plus_theme(self.script_edit)
        central_layout.addWidget(self.script_edit)

        self.setCentralWidget(central)
        self._setup_result_dock()  # Add result dock
        self._setup_signal_browser()

    def _setup_result_dock(self):
        self.result_edit = QTextEdit()
        self.result_edit.setReadOnly(True)
        self.result_edit.setPlaceholderText("Scalar results will appear here...")

        dock = QDockWidget("Results", self)
        dock.setWidget(self.result_edit)
        dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
        self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, dock)

    def insert_expression(self, expr):
        """Insert a string into the script editor at the current cursor position."""
        self.script_edit.insertPlainText(expr + "\n")
        self.script_edit.ensureCursorVisible()
        self.activateWindow()
        self.raise_()

    def _setup_signal_browser(self):
        # Signal Browser as a dock widget on the right
        browser_container = QWidget()
        browser_layout = QVBoxLayout(browser_container)
        browser_layout.setContentsMargins(4, 4, 4, 4)
        browser_layout.addWidget(QLabel("Signal Browser:"))
        self.signal_tree = QTreeView()
        self.signal_model = QStandardItemModel()
        self.signal_model.setHorizontalHeaderLabels(["Analysis / Signal"])
        self.signal_tree.setModel(self.signal_model)
        self.signal_tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers)
        self.signal_tree.setHeaderHidden(False)
        self.signal_tree.doubleClicked.connect(self._on_signal_double_clicked)
        browser_layout.addWidget(self.signal_tree)

        dock = QDockWidget("Signal Browser", self)
        dock.setWidget(browser_container)
        dock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea)
        self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)

    def _send_to_outputs(self):
        script = self.script_edit.toPlainText().strip()
        if script:
            self.sendToOutputsRequested.emit(script)

    def _show_help_dialog(self):
        """Show a dialog listing available functions and variables from the current data scope."""
        scope = self._create_scope()
        keys = sorted(scope.keys())

        help_lines = [
            "Available functions and variables:",
            "",
        ]
        for k in keys:
            help_lines.append(k)

        help_text = "\n".join(help_lines)

        dlg = QDialog(self)
        dlg.setWindowTitle("Calculator Help")
        dlg.resize(500, 400)
        layout = QVBoxLayout(dlg)
        te = QTextEdit()
        te.setReadOnly(True)
        te.setPlainText(help_text)
        layout.addWidget(te)
        dlg.exec()

    def _populate_signals(self):
        self.signal_model.clear()
        self.signal_model.setHorizontalHeaderLabels(["Analysis / Signal"])

        for plot_name, signals in self.all_plots.items():
            plot_item = QStandardItem(plot_name)
            plot_item.setData(plot_name, Qt.ItemDataRole.UserRole)

            for sig_name in sorted(signals.keys()):
                sig_item = QStandardItem(sig_name)
                sig_item.setData(sig_name, Qt.ItemDataRole.UserRole)
                plot_item.appendRow(sig_item)

            self.signal_model.appendRow(plot_item)

        self.signal_tree.expandAll()

    def _on_signal_double_clicked(self, index: QModelIndex):
        item = self.signal_model.itemFromIndex(index)
        if not item or item.parent() is None:
            return  # It's a plot name, not a signal

        plot_name = item.parent().data(Qt.ItemDataRole.UserRole)
        sig_name = item.data(Qt.ItemDataRole.UserRole)

        # Use new generic signal types
        is_ac = "AC" in plot_name
        is_op = "Operating Point" in plot_name
        is_dc = "DC" in plot_name

        if is_op:
            text = f"sop('{sig_name}')"
        elif is_ac:
            text = f"sf('{sig_name}')"
        elif is_dc:
            text = f"sdc('{sig_name}')"
        else:
            text = f"st('{sig_name}')"

        if is_op:
            self.insert_expression(text)
            return

        full_expr = f'plot({text}, label="{text}")'
        self.insert_expression(full_expr)

    def evaluate(self):
        script = self.script_edit.toPlainText().strip()
        if not script:
            return

        # Ensure viewer is ready
        if not self.viewer or self.viewer.isHidden():
            self.viewer = WaveformViewer(self)
            self.viewer.openCalculatorRequested.connect(self._bring_to_front)
            self.viewer.refreshRequested.connect(self._refresh_and_replot)
            self.viewer.show()

        self.viewer.clear()
        scope = self._create_scope()

        try:
            # Use scope as both globals and locals to ensure refs are found
            # and __builtins__ is handled by eval itself
            result = eval(script, scope)
            if isinstance(result, np.ndarray):
                # Auto-plot if result is an array
                x_axis = scope["t"] if "vt" in script or "it" in script else scope["f"]

                # If this is an AC complex result plotted against frequency, show Bode (dB + phase)
                if (
                    np.array_equal(x_axis, scope.get("f", np.array([])))
                    and np.iscomplexobj(result)
                    and len(x_axis) == len(result)
                ):
                    self.viewer.bode(result, label=script)
                else:
                    self.viewer.plot(x_axis, result, label=script)

            self.viewer.show()
            self.viewer.raise_()

            # Handle scalar result for the result dock
            if result is not None:
                self._display_result(script, result)

        except Exception as e:
            # If eval fails, try exec for multi-line scripts
            try:
                # Helper to execute multi-line and get last value
                import ast

                def exec_get_last(code, scope):
                    tree = ast.parse(code)
                    if not tree.body:
                        return None
                    last_node = tree.body[-1]
                    if isinstance(last_node, ast.Expr):
                        if len(tree.body) > 1:
                            exec_body = ast.Module(body=tree.body[:-1], type_ignores=[])
                            exec(
                                compile(exec_body, "<string>", "exec"),
                                scope,
                            )
                        eval_expr = ast.Expression(body=last_node.value)
                        return eval(
                            compile(eval_expr, "<string>", "eval"),
                            scope,
                        )
                    else:
                        exec(code, scope)
                        return None

                result = exec_get_last(script, scope)

                self.viewer.show()
                self.viewer.raise_()

                if result is not None:
                    self._display_result(script, result)

            except Exception as e2:
                QMessageBox.critical(
                    self, "Execution Error", f"Error: {e2}\n{traceback.format_exc()}"
                )

    def _display_result(self, script, result):
        # We only want to show scalars (or small objects) in the dock
        from opens_suite.design_points import DesignPoints

        if isinstance(result, (int, float, np.number, complex)):
            val_str = ""
            if isinstance(result, complex):
                val_str = f"{DesignPoints._format_si(result.real)} + j{DesignPoints._format_si(result.imag)}"
            else:
                val_str = DesignPoints._format_si(float(result))

            # Simple one-liner script display
            short_script = script.split("\n")[-1]
            if len(short_script) > 30:
                short_script = short_script[:27] + "..."

            self.result_edit.append(f"<b>{short_script}</b> = {val_str}")
        elif isinstance(result, np.ndarray) and result.size == 1:
            val_float = float(result.item())
            self.result_edit.append(
                f"<b>{script}</b> = {DesignPoints._format_si(val_float)}"
            )

    def _create_scope(self):
        # Default plots
        tran_plot = None
        ac_plot = None
        op_plot = None
        dc_plot = None

        for name, data in self.all_plots.items():
            if "Transient" in name:
                tran_plot = data
            elif "AC Analysis" in name:
                ac_plot = data
            elif "Operating Point" in name:
                op_plot = data
            elif "DC transfer characteristic" in name:
                dc_plot = data

        # Fallbacks
        if not tran_plot and self.all_plots:
            candidates = [
                p for n, p in self.all_plots.items() if "Operating Point" not in n
            ]
            if candidates:
                tran_plot = sorted(
                    candidates, key=lambda x: len(next(iter(x.values()))), reverse=True
                )[0]

        t = np.array([])
        f_vec = np.array([])
        sw_vec = np.array([])

        if tran_plot:
            for k in tran_plot.keys():
                if k.lower() == "time":
                    t = np.array(tran_plot[k])
                    break
        if ac_plot:
            for k in ac_plot.keys():
                if k.lower() == "frequency":
                    f_vec = np.array(ac_plot[k]).real
                    break
        if dc_plot:
            # First variable in DC plot is the swept one
            first_key = list(dc_plot.keys())[0] if dc_plot else None
            if first_key:
                sw_vec = np.array(dc_plot[first_key])

        from opens_suite.spice_parser import SpiceRawParser

        def vt(name, plot=None):
            ds = self.all_plots.get(plot, tran_plot) if plot else tran_plot
            val = SpiceRawParser.find_signal(ds, name, type_hint="v")
            if val is None:
                raise ValueError(f"Transient signal '{name}' not found.")
            return np.array(val)

        def it(name, plot=None):
            ds = self.all_plots.get(plot, tran_plot) if plot else tran_plot
            val = SpiceRawParser.find_signal(ds, name, type_hint="i")
            if val is None:
                raise ValueError(f"Transient current '{name}' not found.")
            return np.array(val)

        def vf(name, plot=None):
            ds = self.all_plots.get(plot, ac_plot) if plot else ac_plot
            val = SpiceRawParser.find_signal(ds, name, type_hint="v")
            if val is None:
                raise ValueError(f"AC signal '{name}' not found.")
            return np.array(val)

        def ifc(name, plot=None):
            ds = self.all_plots.get(plot, ac_plot) if plot else ac_plot
            val = SpiceRawParser.find_signal(ds, name, type_hint="i")
            if val is None:
                raise ValueError(f"AC current '{name}' not found.")
            return np.array(val)

        def op(name, plot=None):
            ds = self.all_plots.get(plot, op_plot) if plot else op_plot
            val = SpiceRawParser.find_signal(ds, name, type_hint="v")
            if val is None:
                val = SpiceRawParser.find_signal(ds, name, type_hint="i")
            if val is None:
                raise ValueError(f"OP signal '{name}' not found.")
            return val[0] if len(val) > 0 else None

        def plot_func(x, y=None, label=None, title=None):
            x_label, x_unit = None, None
            if y is None:
                y = x
                # smart default x-axis
                x = np.arange(len(y))  # default fallback
                # Order matters here: sw_vec, then t, then f_vec
                for cand, (lbl, unt) in [
                    (sw_vec, ("Sweep", "")),
                    (t, ("Time", "s")),
                    (f_vec, ("Frequency", "Hz")),
                ]:
                    if cand is not None and len(cand) == len(y) and len(cand) > 0:
                        x = cand
                        x_label, x_unit = lbl, unt
                        break
            self.viewer.plot(x, y, label=label)
            if x_label and self.viewer:
                for p in self.viewer.plots:
                    p.setLabel("bottom", x_label, x_unit)

        def bode(target, plot=None, unwrap_phase=False, wrap_to_180=False):
            # Resolve target to complex array
            if isinstance(target, str):
                y = vf(target, plot)
            else:
                y = target

            self.viewer.bode(y, label=str(target))

        def vdc(name, plot=None):
            ds = self.all_plots.get(plot, dc_plot) if plot else dc_plot
            val = SpiceRawParser.find_signal(ds, name, type_hint="v")
            if val is None:
                raise ValueError(f"DC signal '{name}' not found.")
            return np.array(val)

        def v(name, plot=None):
            """Generic voltage fetcher that tries Tran, then DC, then AC, then OP."""
            for plot_key in (
                [plot]
                if plot
                else [
                    "Transient Analysis",
                    "DC transfer characteristic",
                    "AC Analysis",
                    "Operating Point",
                ]
            ):
                ds = self.all_plots.get(plot_key)
                if not ds:
                    continue
                val = SpiceRawParser.find_signal(ds, name, type_hint="v")
                if val is not None:
                    return val[0] if plot_key == "Operating Point" else np.array(val)
            raise ValueError(f"Signal '{name}' not found in any plot.")

        def mean(x, y, t_start=None, t_stop=None):
            if t_start is None:
                t_start = x[0]
            if t_stop is None:
                t_stop = x[-1]
            mask = (x >= t_start) & (x <= t_stop)
            return np.mean(y[mask])

        def rms(x, y, t_start=None, t_stop=None):
            if t_start is None:
                t_start = x[0]
            if t_stop is None:
                t_stop = x[-1]
            mask = (x >= t_start) & (x <= t_stop)
            return np.sqrt(np.mean(np.array(y[mask], dtype=complex) ** 2))

        def p2p(x, y, t_start=None, t_stop=None):
            if t_start is None:
                t_start = x[0]
            if t_stop is None:
                t_stop = x[-1]
            mask = (x >= t_start) & (x <= t_stop)
            return np.max(y[mask]) - np.min(y[mask])

        def value(x, y, at):
            return np.interp(at, x, y)

        def subaxis(nrows, idx):
            if self.viewer:
                return self.viewer.subaxis(nrows, idx)
            return None

        def st(name, plot=None):
            """Generic signal fetcher for Transient results."""
            ds = self.all_plots.get(plot, tran_plot) if plot else tran_plot
            val = SpiceRawParser.find_signal(ds, name)
            if val is None:
                raise ValueError(f"Transient signal '{name}' not found.")
            return np.array(val)

        def sf(name, plot=None):
            """Generic signal fetcher for Frequency (AC) results."""
            ds = self.all_plots.get(plot, ac_plot) if plot else ac_plot
            val = SpiceRawParser.find_signal(ds, name)
            if val is None:
                raise ValueError(f"AC signal '{name}' not found.")
            return np.array(val)

        def sop(name, plot=None):
            """Generic signal fetcher for Operating Point results."""
            ds = self.all_plots.get(plot, op_plot) if plot else op_plot
            val = SpiceRawParser.find_signal(ds, name)
            if val is None:
                raise ValueError(f"OP signal '{name}' not found.")
            return val[0] if len(val) > 0 else None

        def sdc(name, plot=None):
            """Generic signal fetcher for DC sweep results."""
            ds = self.all_plots.get(plot, dc_plot) if plot else dc_plot
            val = SpiceRawParser.find_signal(ds, name)
            if val is None:
                raise ValueError(f"DC signal '{name}' not found.")
            return np.array(val)

        def f3db(result_array, plot_name=None):
            """Calculate 3dB frequency.
            result_array: complex array (e.g. from sf('VOUT'))
            plot_name: optional ac plot name
            """
            if f_vec is None or len(f_vec) == 0:
                raise ValueError("No frequency data available.")

            y = np.array(result_array)
            mag_db = 20 * np.log10(np.abs(y))

            # Lowest frequency as DC gain
            dc_gain_db = mag_db[0]
            target_db = dc_gain_db - 3.0

            # Find crossing.
            idx = np.where(mag_db <= target_db)[0]
            if len(idx) == 0:
                return np.nan

            idx = idx[0]
            if idx == 0:
                return f_vec[0]

            # Interpolate between idx-1 and idx
            f1, f2 = f_vec[idx - 1], f_vec[idx]
            db1, db2 = mag_db[idx - 1], mag_db[idx]

            if abs(db2 - db1) < 1e-12:
                return f1

            logf1, logf2 = np.log10(f1), np.log10(f2)
            logf = logf1 + (logf2 - logf1) * (target_db - db1) / (db2 - db1)
            return 10**logf

        scope = {
            "v": v,
            "vt": st,  # Aliased for backward compatibility
            "it": st,
            "vf": sf,
            "ifc": sf,
            "vdc": sdc,
            "op": sop,
            "st": st,
            "sf": sf,
            "sop": sop,
            "sdc": sdc,
            "mean": mean,
            "rms": rms,
            "p2p": p2p,
            "value": value,
            "plot": plot_func,
            "t": t,
            "f": f_vec,
            "sw": sw_vec,
            "mag": np.abs,
            "db": lambda x: 20 * np.log10(np.abs(x)),
            "dB": lambda x: 20 * np.log10(np.abs(x)),
            "ph": lambda x: np.angle(x, deg=True),
            "np": np,
            "plots": self.all_plots,
            "bode": bode,
            "f3db": f3db,
            "subfigure": self.viewer.subaxis if self.viewer else lambda *args: None,
            "subaxis": subaxis,
        }

        # Add outputs from the outputs_dock if available
        try:
            # Look for outputs_dock in the main window
            main_window = self.parent()
            # If parent is not MainWindow, try to find it
            while main_window and not hasattr(main_window, "outputs_dock"):
                main_window = main_window.parent()

            if main_window and hasattr(main_window, "outputs_dock"):
                outputs_scope = main_window.outputs_dock.get_results_scope()
                # Prioritize calculator functions over outputs by merging them last
                scope = {**outputs_scope, **scope}
        except Exception as e:
            print(f"Note: Could not load output expressions into calculator scope: {e}")

        return scope
insert_expression(expr)

Insert a string into the script editor at the current cursor position.

Source code in src/opens_suite/calculator_widget.py
def insert_expression(self, expr):
    """Insert a string into the script editor at the current cursor position."""
    self.script_edit.insertPlainText(expr + "\n")
    self.script_edit.ensureCursorVisible()
    self.activateWindow()
    self.raise_()

commands

MoveItemsCommand

Bases: QUndoCommand

Source code in src/opens_suite/commands.py
class MoveItemsCommand(QUndoCommand):
    def __init__(self, moving_items, delta, rubber_band_wires=None, parent=None):
        """
        moving_items: list of items that were moved by delta
        delta: QPointF, total translation
        rubber_band_wires: list of (wire, old_line, new_line) tuples for wires that stretched
        """
        super().__init__("Move Items", parent)
        self.moving_items = moving_items
        self.delta = delta
        self.rubber_band_wires = rubber_band_wires or []

    def redo(self):
        # Apply move
        for item in self.moving_items:
            item.moveBy(self.delta.x(), self.delta.y())

        # Apply rubber band updates
        for wire, _, new_line in self.rubber_band_wires:
            wire.setLine(new_line)

    def undo(self):
        # Revert move
        for item in self.moving_items:
            item.moveBy(-self.delta.x(), -self.delta.y())

        # Revert rubber band updates
        for wire, old_line, _ in self.rubber_band_wires:
            wire.setLine(old_line)
__init__(moving_items, delta, rubber_band_wires=None, parent=None)

moving_items: list of items that were moved by delta delta: QPointF, total translation rubber_band_wires: list of (wire, old_line, new_line) tuples for wires that stretched

Source code in src/opens_suite/commands.py
def __init__(self, moving_items, delta, rubber_band_wires=None, parent=None):
    """
    moving_items: list of items that were moved by delta
    delta: QPointF, total translation
    rubber_band_wires: list of (wire, old_line, new_line) tuples for wires that stretched
    """
    super().__init__("Move Items", parent)
    self.moving_items = moving_items
    self.delta = delta
    self.rubber_band_wires = rubber_band_wires or []

TransformItemsCommand

Bases: QUndoCommand

Source code in src/opens_suite/commands.py
class TransformItemsCommand(QUndoCommand):
    def __init__(self, items, old_state, new_state, parent=None):
        """Apply arbitrary transform/position changes to items.

        old_state/new_state: dict mapping item -> (pos, transform matrix tuple)
        transform matrix tuple: (m11, m12, m21, m22, dx, dy)
        """
        super().__init__("Transform Items", parent)
        self.items = items
        self.old_state = old_state
        self.new_state = new_state

    def _apply_state(self, state):
        from PyQt6.QtGui import QTransform
        from opens_suite.wire import Wire

        for item in self.items:
            if item not in state:
                continue
            data = state[item]
            # data can be (pos, transform_vals) OR (pos, transform_vals, line)
            pos = data[0]
            tvals = data[1]
            line = data[2] if len(data) > 2 else None

            item.setPos(pos)
            if tvals is not None:
                m11, m12, m21, m22, dx, dy = tvals
                t = QTransform(m11, m12, m21, m22, dx, dy)
                item.setTransform(t)

            if line is not None and hasattr(item, "setLine"):
                item.setLine(line)

    def redo(self):
        self._apply_state(self.new_state)

    def undo(self):
        self._apply_state(self.old_state)
__init__(items, old_state, new_state, parent=None)

Apply arbitrary transform/position changes to items.

old_state/new_state: dict mapping item -> (pos, transform matrix tuple) transform matrix tuple: (m11, m12, m21, m22, dx, dy)

Source code in src/opens_suite/commands.py
def __init__(self, items, old_state, new_state, parent=None):
    """Apply arbitrary transform/position changes to items.

    old_state/new_state: dict mapping item -> (pos, transform matrix tuple)
    transform matrix tuple: (m11, m12, m21, m22, dx, dy)
    """
    super().__init__("Transform Items", parent)
    self.items = items
    self.old_state = old_state
    self.new_state = new_state

design_points

DesignPoints

Source code in src/opens_suite/design_points.py
class DesignPoints:
    def __init__(self):
        self._data = {}
        self._units = {}
        self._length = 0
        self.crossproduct = CrossProductAccessor(self)

    def _parse_key(self, key):
        # Allow spaces around name and unit
        m = re.match(r"^(.*?)(?:\[(.*?)\])?$", key.strip())
        if m:
            name = m.group(1).strip()
            unit = m.group(2).strip() if m.group(2) else ""
            return name, unit
        return key.strip(), ""

    @staticmethod
    def _parse_val(val):
        if not isinstance(val, str):
            return val

        val = val.strip()
        suffixes = {
            "T": 1e12,
            "G": 1e9,
            "Meg": 1e6,
            "meg": 1e6,
            "k": 1e3,
            "K": 1e3,
            "m": 1e-3,
            "M": 1e-3,  # In Spice, M is milli
            "u": 1e-6,
            "n": 1e-9,
            "p": 1e-12,
            "f": 1e-15,
        }

        # Match number and optional suffix
        m = re.match(
            r"^([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)([TGMkKmunpf]|Meg|meg)?$", val
        )
        if m:
            number = float(m.group(1))
            suffix = m.group(2)
            if suffix:
                return number * suffixes[suffix]
            return number
        return val

    def __setitem__(self, key, value):
        name, unit = self._parse_key(key)

        # Handle list of strings or single string with SI suffix
        if isinstance(value, str):
            value = self._parse_val(value)
        elif isinstance(value, (list, tuple, np.ndarray)):
            value = [self._parse_val(v) for v in value]

        val_array = np.atleast_1d(value)

        if self._length == 0:
            self._length = len(val_array)
        elif self._length == 1 and len(val_array) > 1:
            # Broadcast existing single-row data to the new length
            new_len = len(val_array)
            for k in self._data:
                self._data[k] = np.full(new_len, self._data[k][0])
            self._length = new_len
        elif len(val_array) == 1 and self._length > 1:
            # Broadcast the assigned single value to the existing length
            val_array = np.full(self._length, val_array[0])

        if len(val_array) != self._length:
            raise ValueError(
                f"Length mismatch: assigning array of length {len(val_array)} to DesignPoints of length {self._length}"
            )

        self._data[name] = val_array
        if unit or name not in self._units:
            self._units[name] = unit

    def __getitem__(self, key):
        name, _ = self._parse_key(key)
        if name in self._data:
            return self._data[name]
        raise KeyError(name)

    def _add_crossproduct(self, key, values):
        name, unit = self._parse_key(key)
        values = list(values)

        if self._length == 0:
            self.__setitem__(key, values)
            return

        n_existing = self._length
        n_new = len(values)

        # Duplicate existing columns n_new times
        for k in self._data.keys():
            self._data[k] = np.tile(self._data[k], n_new)

        # Add new column repeated n_existing times for each new value
        new_col = np.repeat(values, n_existing)

        self._length = n_existing * n_new
        self._data[name] = new_col
        if unit or name not in self._units:
            self._units[name] = unit

    @staticmethod
    def _format_si(val):
        if not isinstance(val, (int, float, np.number)):
            return str(val)
        if val == 0:
            return "0"

        abs_val = abs(val)
        prefixes = [
            (1e12, "T"),
            (1e9, "G"),
            (1e6, "Meg"),
            (1e3, "k"),
            (1, ""),
            (1e-3, "m"),
            (1e-6, "u"),
            (1e-9, "n"),
            (1e-12, "p"),
            (1e-15, "f"),
        ]

        for factor, prefix in prefixes:
            if abs_val >= factor * 0.999:  # Small threshold for float rounding
                formatted = f"{val/factor:.4g}"
                # Remove trailing .0 but keep other precision
                if formatted.endswith(".0"):
                    formatted = formatted[:-2]
                return f"{formatted}{prefix}"

        # Fallback for very small or very large numbers
        return f"{val:.3g}"

    def to_ascii(self, n=None):
        if self._length == 0:
            return "Empty DesignPoints"

        keys = list(self._data.keys())
        headers = []
        for k in keys:
            u = self._units.get(k, "")
            headers.append(f"{k} [{u}]" if u else k)

        display_len = self._length
        if n is not None:
            display_len = min(n, self._length)

        # Find column widths
        col_widths = [len(h) for h in headers]
        formatted_data = []

        for i in range(display_len):
            row = []
            for j, k in enumerate(keys):
                val_str = self._format_si(self._data[k][i])
                row.append(val_str)
                col_widths[j] = max(col_widths[j], len(val_str))
            formatted_data.append(row)

        # Build string
        res = []
        header_str = " | ".join(h.ljust(w) for h, w in zip(headers, col_widths))
        res.append(header_str)
        res.append("-" * len(header_str))

        for row in formatted_data:
            res.append(" | ".join(v.ljust(w) for v, w in zip(row, col_widths)))

        if display_len < self._length:
            res.append(f"... and {self._length - display_len} more rows.")

        return "\n".join(res)

    def __repr__(self):
        return self.to_ascii()

    def to_html(self, n=None):
        if self._length == 0:
            return "<i>Empty DesignPoints</i>"

        keys = list(self._data.keys())
        headers = []
        for k in keys:
            u = self._units.get(k, "")
            headers.append(f"{k} [{u}]" if u else k)

        display_len = self._length
        if n is not None:
            display_len = min(n, self._length)

        html = [
            "<table style='border-collapse: collapse; border: 1px solid #ccc; text-align: right;'>"
        ]
        html.append(
            "  <thead style='border-bottom: 2px solid #ccc; background-color: #f8f9fa;'><tr>"
        )
        # Headers
        html.append(
            "    <th style='padding: 8px 12px; border: 1px solid #ccc; font-family: monospace; background-color: #eee;'>#</th>"
        )
        for h in headers:
            html.append(
                f"    <th style='padding: 8px 12px; border: 1px solid #ccc; font-family: monospace;'>{h}</th>"
            )
        html.append("  </tr></thead>")
        html.append("  <tbody>")
        for i in range(display_len):
            bg_color = "#ffffff" if i % 2 == 0 else "#f9f9f9"
            html.append(f"    <tr style='background-color: {bg_color};'>")
            # Index cell
            html.append(
                f"      <td style='padding: 6px 12px; border: 1px solid #ccc; font-family: monospace; background-color: #eee; font-weight: bold;'>{i}</td>"
            )
            for k in keys:
                val_str = self._format_si(self._data[k][i])
                html.append(
                    f"      <td style='padding: 6px 12px; border: 1px solid #ccc; font-family: monospace;'>{val_str}</td>"
                )
            html.append("    </tr>")
        html.append("  </tbody>")
        html.append("</table>")

        if display_len < self._length:
            html.append(
                f"<p>Showing first {display_len} rows of {self._length} rows.</p>"
            )
        else:
            html.append(f"<p>All {self._length} rows shown.</p>")

        return "\n".join(html)

    def _repr_html_(self):
        return self.to_html(n=30)

    @property
    def E24(self):
        """Standard E24 series base values."""
        return np.array(
            [
                1.0,
                1.1,
                1.2,
                1.3,
                1.5,
                1.6,
                1.8,
                2.0,
                2.2,
                2.4,
                2.7,
                3.0,
                3.3,
                3.6,
                3.9,
                4.3,
                4.7,
                5.1,
                5.6,
                6.2,
                6.8,
                7.5,
                8.2,
                9.1,
            ]
        )

    def _generate_series(self, decades):
        """Helper to generate E24 values across multiple decades."""
        base = self.E24
        series = []
        for d in decades:
            series.extend(base * (10**d))
        return np.array(series)

    @property
    def R(self):
        """E24 series Resistors: 1 Ohm to 9.1 MOhm."""
        return self._generate_series(range(0, 7))

    @property
    def L(self):
        """E24 series Inductors: 1 nH to 9.1 H."""
        return self._generate_series(range(-9, 1))

    @property
    def C(self):
        """E24 series Capacitors: 1 pF to 9.1 mF."""
        return self._generate_series(range(-12, -2))

    def _filter_series(self, vals, min_val=None, max_val=None, num=None):
        """Helper to filter and optionally decimate a value series."""
        if min_val is not None:
            vals = vals[vals >= min_val]
        if max_val is not None:
            vals = vals[vals <= max_val]

        if num is not None and len(vals) > num:
            # Use linspace to get 'num' indices uniformly distributed across the available range
            indices = np.linspace(0, len(vals) - 1, num, dtype=int)
            vals = vals[indices]
        return vals

    def get_R(self, min_r=None, max_r=None, num=None):
        """Get E24 resistor values within [min_r, max_r], optionally decimated to 'num' elements."""
        return self._filter_series(self.R, min_r, max_r, num)

    def get_L(self, min_l=None, max_l=None, num=None):
        """Get E24 inductor values within [min_l, max_l], optionally decimated to 'num' elements."""
        return self._filter_series(self.L, min_l, max_l, num)

    def get_C(self, min_c=None, max_c=None, num=None):
        """Get E24 capacitor values within [min_c, max_c], optionally decimated to 'num' elements."""
        return self._filter_series(self.C, min_c, max_c, num)

    def filter(self, mask):
        """Returns a new DesignPoints object containing only the rows where mask is True."""
        mask = np.atleast_1d(mask)
        if mask.dtype != bool:
            mask = mask.astype(bool)

        if len(mask) != self._length:
            raise ValueError(
                f"Mask length {len(mask)} does not match DesignPoints length {self._length}"
            )

        new_dp = DesignPoints()
        new_dp._length = int(np.sum(mask))
        new_dp._units = self._units.copy()
        for k, v in self._data.items():
            new_dp._data[k] = v[mask]
        return new_dp

    def to_dict(self, row_index=0):
        """Returns a dictionary of a specific row in 'Component.Param' format if possible, or just 'Name' keys."""
        if self._length == 0:
            return {}
        if row_index >= self._length:
            raise IndexError("Row index out of range")

        res = {}
        for k in self._data.keys():
            res[k] = self._data[k][row_index]
        return res

    def to_json(self, filepath, row_index=0):
        """Writes a specific row to a JSON file."""
        import json

        data = self.to_dict(row_index)

        # Convert numpy types to native python types for JSON serialization
        serializable = {}
        for k, v in data.items():
            if isinstance(v, (np.generic, np.ndarray)):
                serializable[k] = v.item() if hasattr(v, "item") else v.tolist()
            else:
                serializable[k] = v

        with open(filepath, "w") as f:
            json.dump(serializable, f, indent=4)

    def save(self, filepath, id=0):
        """Alias for to_json to store a specific row (id)."""
        self.to_json(filepath, row_index=id)
C property

E24 series Capacitors: 1 pF to 9.1 mF.

E24 property

Standard E24 series base values.

L property

E24 series Inductors: 1 nH to 9.1 H.

R property

E24 series Resistors: 1 Ohm to 9.1 MOhm.

filter(mask)

Returns a new DesignPoints object containing only the rows where mask is True.

Source code in src/opens_suite/design_points.py
def filter(self, mask):
    """Returns a new DesignPoints object containing only the rows where mask is True."""
    mask = np.atleast_1d(mask)
    if mask.dtype != bool:
        mask = mask.astype(bool)

    if len(mask) != self._length:
        raise ValueError(
            f"Mask length {len(mask)} does not match DesignPoints length {self._length}"
        )

    new_dp = DesignPoints()
    new_dp._length = int(np.sum(mask))
    new_dp._units = self._units.copy()
    for k, v in self._data.items():
        new_dp._data[k] = v[mask]
    return new_dp
get_C(min_c=None, max_c=None, num=None)

Get E24 capacitor values within [min_c, max_c], optionally decimated to 'num' elements.

Source code in src/opens_suite/design_points.py
def get_C(self, min_c=None, max_c=None, num=None):
    """Get E24 capacitor values within [min_c, max_c], optionally decimated to 'num' elements."""
    return self._filter_series(self.C, min_c, max_c, num)
get_L(min_l=None, max_l=None, num=None)

Get E24 inductor values within [min_l, max_l], optionally decimated to 'num' elements.

Source code in src/opens_suite/design_points.py
def get_L(self, min_l=None, max_l=None, num=None):
    """Get E24 inductor values within [min_l, max_l], optionally decimated to 'num' elements."""
    return self._filter_series(self.L, min_l, max_l, num)
get_R(min_r=None, max_r=None, num=None)

Get E24 resistor values within [min_r, max_r], optionally decimated to 'num' elements.

Source code in src/opens_suite/design_points.py
def get_R(self, min_r=None, max_r=None, num=None):
    """Get E24 resistor values within [min_r, max_r], optionally decimated to 'num' elements."""
    return self._filter_series(self.R, min_r, max_r, num)
save(filepath, id=0)

Alias for to_json to store a specific row (id).

Source code in src/opens_suite/design_points.py
def save(self, filepath, id=0):
    """Alias for to_json to store a specific row (id)."""
    self.to_json(filepath, row_index=id)
to_dict(row_index=0)

Returns a dictionary of a specific row in 'Component.Param' format if possible, or just 'Name' keys.

Source code in src/opens_suite/design_points.py
def to_dict(self, row_index=0):
    """Returns a dictionary of a specific row in 'Component.Param' format if possible, or just 'Name' keys."""
    if self._length == 0:
        return {}
    if row_index >= self._length:
        raise IndexError("Row index out of range")

    res = {}
    for k in self._data.keys():
        res[k] = self._data[k][row_index]
    return res
to_json(filepath, row_index=0)

Writes a specific row to a JSON file.

Source code in src/opens_suite/design_points.py
def to_json(self, filepath, row_index=0):
    """Writes a specific row to a JSON file."""
    import json

    data = self.to_dict(row_index)

    # Convert numpy types to native python types for JSON serialization
    serializable = {}
    for k, v in data.items():
        if isinstance(v, (np.generic, np.ndarray)):
            serializable[k] = v.item() if hasattr(v, "item") else v.tolist()
        else:
            serializable[k] = v

    with open(filepath, "w") as f:
        json.dump(serializable, f, indent=4)

main_window

MainWindow

Bases: QMainWindow

Source code in src/opens_suite/main_window.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
class MainWindow(QMainWindow):
    def __init__(self, project_dir=None):
        super().__init__()
        self.setWindowTitle("OpenS - Schematic Entry")
        self.setWindowIcon(
            QIcon(os.path.join(os.path.dirname(__file__), "assets", "launcher.png"))
        )
        self.setGeometry(100, 100, 1920, 1080)
        self.project_dir = project_dir or os.getcwd()

        self.output_console = None  # Placeholder for future
        self.simulation_process = None
        self.current_simulation_view = None
        self.current_raw_path = None
        self.waveform_viewer = None

        # Load Icons
        self.play_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "play.svg")
        )
        self.stop_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "stop.svg")
        )
        self.calc_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "calculator.svg")
        )
        self.probe_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "probe.svg")
        )
        self.undo_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "undo.svg")
        )
        self.redo_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "redo.svg")
        )
        self.report_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "report.svg")
        )
        self.symbol_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "symbol.svg")
        )
        self.labels_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "labels.svg")
        )
        self.active_calculators = []
        self._probi_calc = None  # Track which calculator is probing

        self._setup_ui()
        self._create_actions()
        self._create_menus()
        self._create_toolbars()

        self.plugin_manager = PluginManager(self)
        self.plugin_manager.load_plugins()
        self._tabify_right_docks()

        # Check for Xyce updates in the background
        self._check_for_xyce_updates()

    def _check_for_xyce_updates(self, force=False):
        self._xyce_updater = XyceUpdater(self)
        self._xyce_updater.updateAvailable.connect(self._on_xyce_update_available)
        if force:
            self._xyce_updater.noUpdateAvailable.connect(
                lambda: QMessageBox.information(
                    self, "Up to date", "Xyce is already up to date."
                )
            )
            self._xyce_updater.errorOccurred.connect(
                lambda msg: QMessageBox.warning(self, "Update Error", msg)
            )
        self._xyce_updater.check_for_updates(force=force)

    def _on_xyce_update_available(self, info):
        res = QMessageBox.question(
            self,
            "Xyce Update Available",
            f"A new Xyce release ({info['version']}) is available. Do you want to download and install it now?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if res == QMessageBox.StandardButton.Yes:
            self._install_xyce_update(info)

    def _install_xyce_update(self, info):
        self.update_status(f"Downloading Xyce {info['version']}...")
        from PyQt6.QtWidgets import QProgressDialog

        self.progress_dialog = QProgressDialog(
            "Downloading Xyce...", "Cancel", 0, 100, self
        )
        self.progress_dialog.setWindowTitle("Xyce Update")
        self.progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
        self.progress_dialog.setMinimumDuration(0)

        self._xyce_update_worker = XyceUpdateWorker(
            info["download_url"], self._xyce_updater.base_dir
        )
        self._xyce_update_worker.progressChanged.connect(self._on_update_progress)
        self._xyce_update_worker.finished.connect(
            lambda s, m: self._on_update_finished(s, m, info)
        )

        self.progress_dialog.canceled.connect(self._xyce_update_worker.terminate)
        self._xyce_update_worker.start()

    def _on_update_progress(self, percent, text):
        if hasattr(self, "progress_dialog"):
            self.progress_dialog.setLabelText(text)
            self.progress_dialog.setValue(percent)

    def _on_update_finished(self, success, message, info):
        if hasattr(self, "progress_dialog"):
            self.progress_dialog.close()

        if success:
            self._xyce_updater.save_local_info(info)
            self.update_status("Xyce updated successfully.")
            QMessageBox.information(
                self, "Update Complete", "Xyce update installed successfully."
            )
        else:
            self.update_status("Xyce update failed.")
            QMessageBox.critical(
                self, "Update Failed", f"Failed to install Xyce update:\n{message}"
            )

    def closeEvent(self, event):
        """Auto-save all tabs and close all child windows on exit."""
        # 1. Save all open schematic/symbol tabs
        for i in range(self.tabs.count()):
            widget = self.tabs.widget(i)
            file_name = getattr(widget, "filename", None)
            if file_name:
                try:
                    if isinstance(widget, SymbolView):
                        widget.save_symbol(file_name)
                    elif isinstance(widget, SchematicView):
                        analyses = (
                            self.analysis_dock.get_all_analyses()
                            if hasattr(self, "analysis_dock")
                            else getattr(widget, "analyses", [])
                        )
                        outputs = (
                            self.outputs_dock.get_expressions_data()
                            if hasattr(self, "outputs_dock")
                            else getattr(widget, "outputs", [])
                        )
                        variables = (
                            self.variables_dock.get_variables()
                            if hasattr(self, "variables_dock")
                            else getattr(widget, "variables", [])
                        )
                        widget.save_schematic(
                            file_name,
                            analyses=analyses,
                            outputs=outputs,
                            variables=variables,
                        )
                    print(f"Auto-saved: {file_name}")
                except Exception as e:
                    print(f"Warning: Could not auto-save {file_name}: {e}")

        # 2. Close all active calculator windows
        for calc in list(self.active_calculators):
            try:
                calc.close()
            except (RuntimeError, AttributeError):
                pass
        self.active_calculators.clear()

        # 3. Terminate any running simulation process
        if (
            self.simulation_process
            and self.simulation_process.state() != QProcess.ProcessState.NotRunning
        ):
            self.simulation_process.kill()
            self.simulation_process.waitForFinished(3000)

        # 4. Close all matplotlib figures
        import matplotlib.pyplot as plt

        plt.close("all")

        event.accept()

    def _setup_ui(self):
        # Tab Widget
        self.tabs = QTabWidget()
        self.tabs.setTabsClosable(True)
        self.tabs.tabCloseRequested.connect(self.close_tab)
        self.tabs.currentChanged.connect(self._on_tab_changed)
        self.setCentralWidget(self.tabs)

        # Enable dock nesting and tabify support
        self.setDockNestingEnabled(True)
        self.setTabPosition(
            Qt.DockWidgetArea.AllDockWidgetAreas, QTabWidget.TabPosition.South
        )

        # Status Bar
        self.status_bar = self.statusBar()
        self.status_bar.showMessage("Ready")

    def _create_actions(self):
        # New Action
        self.new_action = QAction(
            self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon),
            "&New",
            self,
        )
        self.new_action.setShortcut("Ctrl+N")
        self.new_action.setStatusTip("Create a new schematic")
        self.new_action.triggered.connect(self.new_file)

        # Save Action
        self.save_action = QAction(
            self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton),
            "&Save",
            self,
        )
        self.save_action.setShortcut("Ctrl+S")
        self.save_action.setStatusTip("Save current schematic")
        self.save_action.triggered.connect(self.save_file)

        # Create Symbol Action
        self.create_symbol_action = QAction(
            self.symbol_icon,
            "Create/Update Symbol",
            self,
        )
        self.create_symbol_action.setStatusTip(
            "Generate a symbol from the current schematic"
        )
        self.create_symbol_action.triggered.connect(self.create_symbol)

        # Generate Report Action
        self.generate_report_action = QAction(
            self.report_icon,
            "Generate Report",
            self,
        )
        self.generate_report_action.setStatusTip(
            "Generate a headless HTML simulation report for this schematic"
        )
        self.generate_report_action.triggered.connect(self.generate_report)

        # Exit Action
        self.exit_action = QAction(
            self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton),
            "E&xit",
            self,
        )
        self.exit_action.setShortcut("Ctrl+Q")
        self.exit_action.setStatusTip("Exit application")
        self.exit_action.triggered.connect(self.close)

        # Undo Action
        self.undo_action = QAction(self.undo_icon, "&Undo", self)
        self.undo_action.setShortcut("Ctrl+Z")
        self.undo_action.setStatusTip("Undo last action")
        self.undo_action.triggered.connect(self.undo)

        # Redo Action
        self.redo_action = QAction(self.redo_icon, "&Redo", self)
        self.redo_action.setShortcut("Ctrl+Shift+Z")
        self.redo_action.setStatusTip("Redo last undone action")
        self.redo_action.triggered.connect(self.redo)

        self.settings_action = QAction(
            self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon),
            "&Settings...",
            self,
        )
        self.settings_action.setStatusTip("Configure application settings")
        self.settings_action.triggered.connect(self.show_settings)

    def _create_menus(self):
        menubar = self.menuBar()
        file_menu = menubar.addMenu("&File")
        file_menu.addAction(self.new_action)
        file_menu.addAction(self.save_action)
        file_menu.addSeparator()
        file_menu.addAction(self.exit_action)

        edit_menu = menubar.addMenu("&Edit")
        edit_menu.addAction(self.undo_action)
        edit_menu.addAction(self.redo_action)

        view_menu = menubar.addMenu("&View")

        tools_menu = menubar.addMenu("&Tools")
        tools_menu.addAction(self.create_symbol_action)
        tools_menu.addAction(self.generate_report_action)
        tools_menu.addSeparator()
        tools_menu.addAction(self.settings_action)

    def _create_toolbars(self):
        toolbar = QToolBar("File Toolbar")
        toolbar.setIconSize(QSize(16, 16))
        toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
        self.addToolBar(toolbar)
        toolbar.addAction(self.save_action)

        edit_toolbar = QToolBar("Edit Toolbar")
        edit_toolbar.setIconSize(QSize(16, 16))
        edit_toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
        self.addToolBar(edit_toolbar)
        edit_toolbar.addAction(self.undo_action)
        edit_toolbar.addAction(self.redo_action)

        sim_toolbar = QToolBar("Simulation Toolbar")
        sim_toolbar.setIconSize(QSize(16, 16))
        sim_toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
        self.addToolBar(sim_toolbar)
        sim_toolbar.addAction(self.create_symbol_action)
        sim_toolbar.addAction(self.generate_report_action)
        sim_toolbar.addSeparator()
        self.show_labels_action = QAction(self.labels_icon, "Show Wire Labels", self)
        self.show_labels_action.setCheckable(True)
        self.show_labels_action.setChecked(True)
        self.show_labels_action.toggled.connect(self._on_show_labels_changed)
        sim_toolbar.addAction(self.show_labels_action)

    def _on_show_labels_changed(self, checked):
        show = checked
        for i in range(self.tabs.count()):
            widget = self.tabs.widget(i)
            if hasattr(widget, "scene"):
                scene = widget.scene()
                if scene:
                    for item in scene.items():
                        if hasattr(item, "show_label"):
                            item.show_label = show
                            item.update()

    def update_status(self, message):
        self.status_bar.showMessage(message)

    def undo(self):
        current_widget = self.tabs.currentWidget()
        if isinstance(current_widget, SchematicView):
            current_widget.undo_stack.undo()

    def redo(self):
        current_widget = self.tabs.currentWidget()
        if isinstance(current_widget, SchematicView):
            current_widget.undo_stack.redo()

    def _on_selection_changed(self):
        view = self.tabs.currentWidget()
        if isinstance(view, (SchematicView, SymbolView)):
            try:
                scene = view.scene()
                if scene:
                    selection = scene.selectedItems()
                    if hasattr(self, "properties_dock"):
                        self.properties_dock.update_selection(selection)
                    if selection:
                        if hasattr(self, "properties_dock"):
                            self.properties_dock.show()
                            self.properties_dock.raise_()
                    else:
                        if hasattr(self, "library_dock"):
                            self.library_dock.show()
                            self.library_dock.raise_()
            except (RuntimeError, AttributeError):
                pass

    def _tabify_right_docks(self):
        # Find all dock widgets that have been placed in the right area
        right_docks = []
        # Specifically gather the known docks in a preferred order if possible,
        # or just gather all from RightDockWidgetArea
        for dock in self.findChildren(QDockWidget):
            if self.dockWidgetArea(dock) == Qt.DockWidgetArea.RightDockWidgetArea:
                right_docks.append(dock)

        if len(right_docks) > 1:
            # Tabify them all together
            for i in range(len(right_docks) - 1):
                self.tabifyDockWidget(right_docks[i], right_docks[i + 1])

            # Start with Library visible if it exists
            if hasattr(self, "library_dock"):
                self.library_dock.show()
                self.library_dock.raise_()

    def _on_tab_changed(self, index):
        if index < 0:
            return
        view = self.tabs.widget(index)
        if isinstance(view, SchematicView):
            # Reload all symbols in case they were modified in the symbol editor
            view.reload_symbols()

            # Sync Properties (if plugin loaded)
            self._on_selection_changed()
            self._update_action_states()
            if hasattr(self, "results_selection_dock"):
                self.results_selection_dock.set_scene(view.scene())

        pass
        view = self.tabs.currentWidget()
        has_results = False
        if isinstance(view, SchematicView):
            filename = getattr(view, "filename", None)
            if filename:
                sim_dir = os.path.join(os.path.dirname(filename), "simulation")
                base = os.path.splitext(os.path.basename(filename))[0]
                raw_path = os.path.join(sim_dir, f"{base}.raw")
                if os.path.exists(raw_path):
                    has_results = True

    def _update_action_states(self):
        pass

    def new_file(self):
        view = SchematicView()
        view.modeChanged.connect(self.update_status_mode)
        view.statusMessage.connect(self.update_status)
        view.openSubcircuitRequested.connect(self.open_file)

        # Connect Selection signals
        view.scene().selectionChanged.connect(self._on_selection_changed)

        if hasattr(self, "properties_dock"):
            self.properties_dock.propertyChanged.connect(view.recalculate_connectivity)

        index = self.tabs.addTab(view, "Untitled")
        self.tabs.setCurrentIndex(index)
        self.update_status(f"Mode: {view.current_mode}")

    def _get_tab_title(self, file_path):
        if not file_path:
            return "Untitled"
        import os

        basename = os.path.basename(file_path)
        cell = os.path.basename(os.path.dirname(os.path.abspath(file_path)))
        if cell and cell not in [".", ""]:
            return f"{cell}/{basename}"
        return basename

    def update_status_mode(self, mode):
        # Compatibility slot if needed, or just rely on statusMessage
        pass

    def open_file(self, file_name=None):
        if not file_name:
            file_name, _ = QFileDialog.getOpenFileName(
                self, "Open Schematic", "", "SVG Files (*.svg);;All Files (*)"
            )
        if file_name:
            import os

            file_name = os.path.abspath(file_name)

            # Check if file is already open
            for i in range(self.tabs.count()):
                widget = self.tabs.widget(i)
                if (
                    hasattr(widget, "filename")
                    and widget.filename
                    and os.path.abspath(widget.filename) == file_name
                ):
                    self.tabs.setCurrentIndex(i)
                    return

            try:
                if (
                    file_name.endswith(".sym.svg")
                    or os.path.basename(file_name) == "symbol.svg"
                ):
                    view = SymbolView()
                    view.filename = file_name
                    view.load_symbol(file_name)
                    view.statusMessage.connect(self.update_status)
                    view.symbol_scene.selectionChanged.connect(
                        self._on_selection_changed
                    )
                    self.tabs.addTab(view, self._get_tab_title(file_name))
                    self.tabs.setCurrentWidget(view)
                    self.update_status(f"Loaded symbol {file_name}")
                    return

                view = SchematicView()
                view.filename = file_name  # Track filename
                view.modeChanged.connect(self.update_status_mode)
                view.statusMessage.connect(self.update_status)
                view.openSubcircuitRequested.connect(self.open_file)

                # Connect Selection signals
                view.scene().selectionChanged.connect(self._on_selection_changed)

                if hasattr(self, "properties_dock"):
                    self.properties_dock.propertyChanged.connect(
                        view.recalculate_connectivity
                    )

                # Use unified loading logic
                view.load_schematic(file_name)

                # Load extra data (handled by plugins or view if needed, but for now we can read them)
                try:
                    tree = ET.parse(file_name)
                    root = tree.getroot()
                    analyses = []
                    for elem in root.iter("{http://opens-schematic.org}analysis"):
                        analyses.append(dict(elem.attrib))
                    view.analyses = analyses

                    if hasattr(self, "analysis_dock"):
                        self.analysis_dock.blockSignals(True)
                        self.analysis_dock.restore_analyses(analyses)
                        self.analysis_dock.blockSignals(False)

                    outputs = []
                    for elem in root.iter("{http://opens-schematic.org}output"):
                        if elem.text:
                            outputs.append(
                                {
                                    "expression": elem.text,
                                    "name": elem.attrib.get("name", ""),
                                    "unit": elem.attrib.get("unit", ""),
                                    "min": elem.attrib.get("min", ""),
                                    "max": elem.attrib.get("max", ""),
                                }
                            )
                    view.outputs = outputs

                    if hasattr(self, "outputs_dock"):
                        self.outputs_dock.blockSignals(True)
                        self.outputs_dock.restore_expressions(outputs)
                        self.outputs_dock.blockSignals(False)

                    variables = []
                    for elem in root.iter("{http://opens-schematic.org}variable"):
                        variables.append(dict(elem.attrib))
                    view.variables = variables

                    if hasattr(self, "variables_dock"):
                        self.variables_dock.blockSignals(True)
                        self.variables_dock.set_variables(variables)
                        self.variables_dock.blockSignals(False)
                except Exception:
                    pass

                self.tabs.addTab(view, self._get_tab_title(file_name))
                self.tabs.setCurrentWidget(view)
                self.update_status(f"Loaded {file_name}")

            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to open file: {e}")
                import traceback

                traceback.print_exc()

    def save_file(self):
        current_widget = self.tabs.currentWidget()
        if not current_widget:
            return

        # Check for duplicate names
        if isinstance(current_widget, SchematicView):
            names = set()
            for item in current_widget.scene().items():
                if isinstance(item, SchematicItem) and getattr(item, "name", None):
                    if item.name in names:
                        QMessageBox.warning(
                            self,
                            "Validation Error",
                            f"Cannot save. Duplicate component name found: {item.name}",
                        )
                        return
                    names.add(item.name)

        file_name = getattr(current_widget, "filename", None)
        if not file_name:
            file_name, _ = QFileDialog.getSaveFileName(
                self, "Save Schematic", "", "SVG Files (*.svg);;All Files (*)"
            )

        if file_name:
            if not file_name.endswith(".svg"):
                file_name += ".svg"

            try:
                if isinstance(current_widget, SymbolView):
                    current_widget.save_symbol(file_name)
                else:
                    analyses = (
                        self.analysis_dock.get_all_analyses()
                        if hasattr(self, "analysis_dock")
                        else getattr(current_widget, "analyses", [])
                    )
                    outputs = (
                        self.outputs_dock.get_expressions_data()
                        if hasattr(self, "outputs_dock")
                        else getattr(current_widget, "outputs", [])
                    )
                    variables = (
                        self.variables_dock.get_variables()
                        if hasattr(self, "variables_dock")
                        else getattr(current_widget, "variables", [])
                    )
                    current_widget.save_schematic(
                        file_name,
                        analyses=analyses,
                        outputs=outputs,
                        variables=variables,
                    )

                index = self.tabs.currentIndex()
                self.tabs.setTabText(index, self._get_tab_title(file_name))
                self.update_status(f"Saved to {file_name}")

            except Exception as e:
                QMessageBox.critical(self, "Error", f"Could not save file: {e}")
                import traceback

                traceback.print_exc()

    def close_tab(self, index):
        widget = self.tabs.widget(index)
        if widget:
            widget.deleteLater()
            self.tabs.removeTab(index)

    def create_symbol(self):
        view = self.tabs.currentWidget()
        if not isinstance(view, SchematicView):
            return

        # 1. Ensure File is Saved
        filename = getattr(view, "filename", None)

        if not filename:
            res = QMessageBox.question(
                self,
                "Save Schematic",
                "The schematic must be saved before creating a symbol. Save now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            )
            if res == QMessageBox.StandardButton.Yes:
                self.save_file()
                filename = getattr(view, "filename", None)
                if not filename:
                    return
            else:
                return

        # 2. Save current state
        view.save_schematic(filename)

        # Compute expected symbol path
        if filename.endswith(".sch.svg"):
            base_path = filename[:-8]
        elif filename.endswith(".svg"):
            base_path = filename[:-4]
        else:
            base_path = filename
        symbol_path = base_path + ".sym.svg"

        if os.path.exists(symbol_path):
            res = QMessageBox.question(
                self,
                "Overwrite Symbol",
                f"A symbol already exists at {symbol_path}.\nDo you want to overwrite it?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            )
            if res != QMessageBox.StandardButton.Yes:
                return

        # 3. Generate Symbol
        try:
            symbol_path = SymbolGenerator.generate_symbol(filename, symbol_path)
            if symbol_path:
                QMessageBox.information(
                    self, "Success", f"Symbol saved to {symbol_path}"
                )

                # 4. Refresh Library
                self.library_dock._populate_library()  # Re-scan

                # 5. Open in Editor
                self.open_file(symbol_path)

        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to generate symbol: {e}")
            import traceback

            traceback.print_exc()

    def show_settings(self):
        dialog = SettingsDialog(self)
        dialog.exec()

    def generate_report(self):
        current_widget = self.tabs.currentWidget()
        if not isinstance(current_widget, SchematicView) or not getattr(
            current_widget, "filename", None
        ):
            QMessageBox.warning(
                self,
                "No file",
                "Please save your schematic before generating a report.",
            )
            return

        filename = current_widget.filename
        default_report_dir = os.path.join(os.path.dirname(filename), "report")

        reply = QMessageBox.question(
            self,
            "Generate Report",
            f"Generate HTML report into:\n{default_report_dir}?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.Yes,
        )

        if reply == QMessageBox.StandardButton.Yes:
            from opens_suite.reporting.report_generator import ReportGenerator

            self.update_status("Generating report... please wait.")

            try:
                # Force local save so it hits disk for snapshot
                current_widget.save_schematic(
                    filename,
                    self.analysis_dock.get_all_analyses(),
                    self.outputs_dock.get_expressions_data(),
                    self.variables_dock.get_variables(),
                )

                gen = ReportGenerator(filename, default_report_dir)
                gen.generate()

                report_file = os.path.join(default_report_dir, "index.html")
                self.update_status(f"Report generated at {report_file}")

                # Auto-open in browser
                from PyQt6.QtGui import QDesktopServices
                from PyQt6.QtCore import QUrl

                QDesktopServices.openUrl(QUrl.fromLocalFile(report_file))

                # Auto-refresh library if library browser exists
                if hasattr(self, "library_dock"):
                    self.library_dock._populate_library()

            except Exception as e:
                import traceback

                QMessageBox.critical(
                    self,
                    "Report Failed",
                    f"Failed to generate report:\n{e}\n\n{traceback.format_exc()}",
                )
                self.update_status("Report generation failed.")
closeEvent(event)

Auto-save all tabs and close all child windows on exit.

Source code in src/opens_suite/main_window.py
def closeEvent(self, event):
    """Auto-save all tabs and close all child windows on exit."""
    # 1. Save all open schematic/symbol tabs
    for i in range(self.tabs.count()):
        widget = self.tabs.widget(i)
        file_name = getattr(widget, "filename", None)
        if file_name:
            try:
                if isinstance(widget, SymbolView):
                    widget.save_symbol(file_name)
                elif isinstance(widget, SchematicView):
                    analyses = (
                        self.analysis_dock.get_all_analyses()
                        if hasattr(self, "analysis_dock")
                        else getattr(widget, "analyses", [])
                    )
                    outputs = (
                        self.outputs_dock.get_expressions_data()
                        if hasattr(self, "outputs_dock")
                        else getattr(widget, "outputs", [])
                    )
                    variables = (
                        self.variables_dock.get_variables()
                        if hasattr(self, "variables_dock")
                        else getattr(widget, "variables", [])
                    )
                    widget.save_schematic(
                        file_name,
                        analyses=analyses,
                        outputs=outputs,
                        variables=variables,
                    )
                print(f"Auto-saved: {file_name}")
            except Exception as e:
                print(f"Warning: Could not auto-save {file_name}: {e}")

    # 2. Close all active calculator windows
    for calc in list(self.active_calculators):
        try:
            calc.close()
        except (RuntimeError, AttributeError):
            pass
    self.active_calculators.clear()

    # 3. Terminate any running simulation process
    if (
        self.simulation_process
        and self.simulation_process.state() != QProcess.ProcessState.NotRunning
    ):
        self.simulation_process.kill()
        self.simulation_process.waitForFinished(3000)

    # 4. Close all matplotlib figures
    import matplotlib.pyplot as plt

    plt.close("all")

    event.accept()

model_editor

ModelEditorDialog

Bases: QDialog

Dialog to edit .model symbol parameters.

The dialog contains a shared ModelName field and three tabs: DIODE, NMOS, PMOS. When accepted, it exposes .modelname, .type and .args properties.

Source code in src/opens_suite/model_editor.py
class ModelEditorDialog(QDialog):
    """Dialog to edit `.model` symbol parameters.

    The dialog contains a shared ModelName field and three tabs: DIODE, NMOS, PMOS.
    When accepted, it exposes .modelname, .type and .args properties.
    """

    def __init__(self, parent=None, initial=None):
        super().__init__(parent)
        self.setWindowTitle("Edit Model")
        self.resize(420, 320)

        self.modelname_edit = QLineEdit()

        # Tabs
        self.tabs = QTabWidget()
        self.diode_tab = QWidget()
        self.nmos_tab = QWidget()
        self.pmos_tab = QWidget()
        self.python_tab = QWidget()

        # Build tab contents
        self._build_diode_tab()
        self._build_nmos_tab()
        self._build_pmos_tab()
        self._build_python_tab()

        # Add tabs
        self.tabs.addTab(self.diode_tab, "D (DIODE)")
        self.tabs.addTab(self.nmos_tab, "NMOS")
        self.tabs.addTab(self.pmos_tab, "PMOS")
        self.tabs.addTab(self.python_tab, "PYTHON")

        # Buttons
        self.buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        self.buttons.accepted.connect(self.accept)
        self.buttons.rejected.connect(self.reject)

        layout = QVBoxLayout()
        layout.addWidget(QLabel("Model Name:"))
        layout.addWidget(self.modelname_edit)
        layout.addWidget(self.tabs)
        layout.addWidget(self.buttons)

        self.setLayout(layout)

        # Fill initial values if provided
        if initial:
            # Case-insensitive access for incoming parameter dict
            def _get(key, default=""):
                for k, v in initial.items():
                    if k.lower() == key.lower():
                        return v
                return default

            self.modelname_edit.setText(_get("MODELNAME", ""))

            # Parse ARGS into dict and populate fields depending on type
            args_raw = _get("ARGS", "")
            args_map = self._parse_args_to_dict(args_raw)

            tval = _get("TYPE", "NMOS").strip().upper()
            if tval in ("DIODE", "D"):
                self.tabs.setCurrentWidget(self.diode_tab)
                # populate diode fields
                self.d_is.setText(args_map.get("IS", self.d_is.text()))
                self.d_n.setText(args_map.get("N", self.d_n.text()))
                self.d_rs.setText(args_map.get("RS", self.d_rs.text()))
                self.d_cjo.setText(args_map.get("CJO", self.d_cjo.text()))
                self.d_m.setText(args_map.get("M", self.d_m.text()))
                self.d_tt.setText(args_map.get("TT", self.d_tt.text()))
            elif tval == "PMOS":
                self.tabs.setCurrentWidget(self.pmos_tab)
                self.p_level.setText(args_map.get("LEVEL", self.p_level.text()))
                self.p_vto.setText(args_map.get("VTO", self.p_vto.text()))
                self.p_kp.setText(args_map.get("KP", self.p_kp.text()))
                self.p_lambda.setText(args_map.get("LAMBDA", self.p_lambda.text()))
                self.p_cgso.setText(args_map.get("CGSO", self.p_cgso.text()))
                self.p_cgdo.setText(args_map.get("CGDO", self.p_cgdo.text()))
                self.p_cbd.setText(args_map.get("CBD", self.p_cbd.text()))
            elif tval in ("PYTHON", "PY"):
                self.tabs.setCurrentWidget(self.python_tab)
                # Populate python-specific fields
                self.py_module.setText(
                    args_map.get("PYTHON_MODULE", self.py_module.text())
                )
                self.py_class.setText(
                    args_map.get("PYTHON_CLASS", self.py_class.text())
                )
                self.py_path.setText(args_map.get("PYTHON_PATH", self.py_path.text()))
            else:
                self.tabs.setCurrentWidget(self.nmos_tab)
                self.n_level.setText(args_map.get("LEVEL", self.n_level.text()))
                self.n_vto.setText(args_map.get("VTO", self.n_vto.text()))
                self.n_kp.setText(args_map.get("KP", self.n_kp.text()))
                self.n_lambda.setText(args_map.get("LAMBDA", self.n_lambda.text()))
                self.n_cgso.setText(args_map.get("CGSO", self.n_cgso.text()))
                self.n_cgdo.setText(args_map.get("CGDO", self.n_cgdo.text()))
                self.n_cbd.setText(args_map.get("CBD", self.n_cbd.text()))

    def _build_diode_tab(self):
        form = QFormLayout()
        # Typical diode parameters
        self.d_is = QLineEdit("1e-14")
        self.d_n = QLineEdit("1")
        self.d_rs = QLineEdit("0")
        self.d_cjo = QLineEdit("1p")
        self.d_m = QLineEdit("0.5")
        self.d_tt = QLineEdit("1n")

        form.addRow("IS:", self.d_is)
        form.addRow("N:", self.d_n)
        form.addRow("RS:", self.d_rs)
        form.addRow("CJO:", self.d_cjo)
        form.addRow("M:", self.d_m)
        form.addRow("TT:", self.d_tt)

        self.diode_tab.setLayout(form)

    def _build_nmos_tab(self):
        form = QFormLayout()
        # Use defaults similar to previous ARGS
        self.n_level = QLineEdit("1")
        self.n_vto = QLineEdit("2.5")
        self.n_kp = QLineEdit("0.5")
        self.n_lambda = QLineEdit("0.02")
        self.n_cgso = QLineEdit("100p")
        self.n_cgdo = QLineEdit("10p")
        self.n_cbd = QLineEdit("50p")

        form.addRow("LEVEL:", self.n_level)
        form.addRow("VTO:", self.n_vto)
        form.addRow("KP:", self.n_kp)
        form.addRow("LAMBDA:", self.n_lambda)
        form.addRow("CGSO:", self.n_cgso)
        form.addRow("CGDO:", self.n_cgdo)
        form.addRow("CBD:", self.n_cbd)

        self.nmos_tab.setLayout(form)

    def _build_pmos_tab(self):
        form = QFormLayout()
        # Start with same defaults but inverted sign where typical
        self.p_level = QLineEdit("1")
        self.p_vto = QLineEdit("-2.5")
        self.p_kp = QLineEdit("0.5")
        self.p_lambda = QLineEdit("0.02")
        self.p_cgso = QLineEdit("100p")
        self.p_cgdo = QLineEdit("10p")
        self.p_cbd = QLineEdit("50p")

        form.addRow("LEVEL:", self.p_level)
        form.addRow("VTO:", self.p_vto)
        form.addRow("KP:", self.p_kp)
        form.addRow("LAMBDA:", self.p_lambda)
        form.addRow("CGSO:", self.p_cgso)
        form.addRow("CGDO:", self.p_cgdo)
        form.addRow("CBD:", self.p_cbd)

        self.pmos_tab.setLayout(form)

    def _build_python_tab(self):
        form = QFormLayout()

        self.py_module = QLineEdit("")
        self.py_class = QLineEdit("")
        self.py_path = QLineEdit(".")

        browse = QPushButton("Browse")

        def _on_browse():
            d = QFileDialog.getExistingDirectory(self, "Select Python Path", ".")
            if d:
                self.py_path.setText(d)

        browse.clicked.connect(_on_browse)

        edit_button = QPushButton("Edit in Editor")

        def _on_edit():
            path = self.py_path.text().strip()
            if not path:
                return

            # Resolve relative path if possible?
            # For now just use as is or absolute
            abs_path = os.path.abspath(path)

            settings = QSettings("OpenS", "OpenS")
            cmd_template = settings.value("editor_command", "code '%s'")

            if "%s" in cmd_template:
                cmd = cmd_template.replace("%s", abs_path)
            else:
                cmd = f"{cmd_template} {abs_path}"

            try:
                # Use shell=True to support commands like "code '%s'" or aliases
                subprocess.Popen(cmd, shell=True)
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to open editor: {e}")

        edit_button.clicked.connect(_on_edit)

        # Path row: use a horizontal layout
        h = QHBoxLayout()
        h.addWidget(self.py_path)
        h.addWidget(browse)
        h.addWidget(edit_button)

        form.addRow("Python Module:", self.py_module)
        form.addRow("Python Class:", self.py_class)
        form.addRow("Python Path:", h)

        self.python_tab.setLayout(form)

    def get_result(self):
        modelname = self.modelname_edit.text().strip() or "MODEL1"
        current = self.tabs.currentWidget()

        if current is self.diode_tab:
            typ = "D"  # Use standard spice 'D' for diode
            args = (
                f"(IS={self.d_is.text()} N={self.d_n.text()} RS={self.d_rs.text()} "
                f"CJO={self.d_cjo.text()} M={self.d_m.text()} TT={self.d_tt.text()})"
            )
        elif current is self.pmos_tab:
            typ = "PMOS"
            args = (
                f"(LEVEL={self.p_level.text()} VTO={self.p_vto.text()} KP={self.p_kp.text()} "
                f"LAMBDA={self.p_lambda.text()} CGSO={self.p_cgso.text()} CGDO={self.p_cgdo.text()} "
                f"CBD={self.p_cbd.text()})"
            )
        elif current is self.python_tab:
            typ = "python"
            # Quote strings to produce: (python_module = "mod" python_class = "cls" python_path=".")
            mod = self.py_module.text().strip()
            cls = self.py_class.text().strip()
            path = self.py_path.text().strip()
            args = (
                f'(python_module = "{mod}" '
                f'python_class = "{cls}" '
                f'python_path="{path}")'
            )
        else:
            typ = "NMOS"
            args = (
                f"(LEVEL={self.n_level.text()} VTO={self.n_vto.text()} KP={self.n_kp.text()} "
                f"LAMBDA={self.n_lambda.text()} CGSO={self.n_cgso.text()} CGDO={self.n_cgdo.text()} "
                f"CBD={self.n_cbd.text()})"
            )

        return {"MODELNAME": modelname, "TYPE": typ, "ARGS": args}

    def _parse_args_to_dict(self, args_raw: str) -> dict:
        """Parse an ARGS string like '(LEVEL=1 VTO=2.5 ...)' into a dict.

        Accepts versions with or without surrounding parentheses. Keys are
        returned upper-cased.
        """
        out = {}
        if not args_raw:
            return out

        s = args_raw.strip()
        if s.startswith("(") and s.endswith(")"):
            s = s[1:-1]

        # Use regex to find key=value pairs. Value may be quoted and may contain
        # characters (but not closing quote). Accept both 'KEY=VALUE' and
        # 'KEY = "value with spaces"' styles.
        import re

        pattern = re.compile(r"(\w+)\s*=\s*(\".*?\"|\S+)")
        for m in pattern.finditer(s):
            k = m.group(1).upper()
            v = m.group(2)
            # Strip surrounding quotes if present
            if v.startswith('"') and v.endswith('"') and len(v) >= 2:
                v = v[1:-1]
            out[k] = v

        return out

outputs_widget

OutputsWidget

Bases: QDockWidget

Source code in src/opens_suite/outputs_widget.py
class OutputsWidget(QDockWidget):
    expressionPlotTriggered = pyqtSignal(str)
    expressionCalculatorTriggered = pyqtSignal(str)
    expressionsChanged = pyqtSignal()
    bulkPlotTriggered = pyqtSignal(list)

    COL_NAME = 0
    COL_EXPR = 1
    COL_VALUE = 2
    COL_UNIT = 3
    COL_MIN = 4
    COL_MAX = 5
    COL_DESC = 6

    def __init__(self, parent=None):
        super().__init__("Output Expressions", parent)
        self.setAllowedAreas(
            Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea
        )

        container = QWidget()
        layout = QVBoxLayout(container)

        self.table_view = QTableView()
        self.model = QStandardItemModel(0, 7)
        self.model.setHorizontalHeaderLabels(
            [
                "Name",
                "Expression",
                "Value",
                "Unit",
                "Min Spec",
                "Max Spec",
                "Description",
            ]
        )

        self.table_view.setModel(self.model)
        self.table_view.setSelectionBehavior(
            QAbstractItemView.SelectionBehavior.SelectRows
        )
        self.table_view.setSelectionMode(
            QAbstractItemView.SelectionMode.ExtendedSelection
        )
        self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.table_view.customContextMenuRequested.connect(self._show_context_menu)
        self.table_view.doubleClicked.connect(self._on_item_double_clicked)

        # Apply delegate to Value column
        self.value_delegate = ValueColumnDelegate()
        self.table_view.setItemDelegateForColumn(self.COL_VALUE, self.value_delegate)

        # Adjust headers
        header = self.table_view.horizontalHeader()
        header.setSectionResizeMode(self.COL_NAME, QHeaderView.ResizeMode.Interactive)
        header.setSectionResizeMode(self.COL_EXPR, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(self.COL_VALUE, QHeaderView.ResizeMode.Interactive)
        header.setSectionResizeMode(self.COL_UNIT, QHeaderView.ResizeMode.Interactive)
        header.setSectionResizeMode(self.COL_MIN, QHeaderView.ResizeMode.Interactive)
        header.setSectionResizeMode(self.COL_MAX, QHeaderView.ResizeMode.Interactive)
        header.setSectionResizeMode(self.COL_DESC, QHeaderView.ResizeMode.Stretch)

        layout.addWidget(self.table_view)
        layout.setContentsMargins(2, 2, 2, 2)
        self.setWidget(container)

        self.model.itemChanged.connect(self._on_item_changed)
        self._last_raw_path = None
        self._results_cache = {}  # name -> result_object

    def add_expression(
        self, expression, min_spec="", max_spec="", name="", unit="", description=""
    ):
        if not expression and not name:
            return

        row = self.model.rowCount()
        item_name = QStandardItem(str(name))
        item_expr = QStandardItem(str(expression))
        item_value = QStandardItem("")
        item_value.setEditable(False)
        # Make value cell non-selectable so selection highlight doesn't override its background
        item_value.setFlags(item_value.flags() & ~Qt.ItemFlag.ItemIsSelectable)

        item_unit = QStandardItem(str(unit))

        item_min = QStandardItem(str(min_spec))
        item_max = QStandardItem(str(max_spec))
        item_desc = QStandardItem(str(description))

        self.model.appendRow(
            [item_name, item_expr, item_value, item_unit, item_min, item_max, item_desc]
        )

        if self._last_raw_path:
            self.evaluate_row(row, self._last_raw_path)

        self.expressionsChanged.emit()

    def get_expressions_data(self):
        """Returns complex data including specs."""
        data = []
        for i in range(self.model.rowCount()):
            data.append(
                {
                    "name": self.model.item(i, self.COL_NAME).text(),
                    "expression": self.model.item(i, self.COL_EXPR).text(),
                    "unit": self.model.item(i, self.COL_UNIT).text(),
                    "min": self.model.item(i, self.COL_MIN).text(),
                    "max": self.model.item(i, self.COL_MAX).text(),
                    "description": self.model.item(i, self.COL_DESC).text(),
                }
            )
        return data

    def get_expressions(self):
        """Legacy support for simple string list."""
        return [
            self.model.item(i, self.COL_EXPR).text()
            for i in range(self.model.rowCount())
        ]

    def clear(self):
        self.model.removeRows(0, self.model.rowCount())

    def restore_expressions(self, expressions):
        self.clear()
        for expr in expressions:
            if isinstance(expr, dict):
                self.add_expression(
                    expr.get("expression", ""),
                    expr.get("min", ""),
                    expr.get("max", ""),
                    expr.get("name", ""),
                    expr.get("unit", ""),
                    expr.get("description", ""),
                )
            else:
                self.add_expression(expr)

    def evaluate_all(self, raw_path):
        self._last_raw_path = raw_path
        if not raw_path or not os.path.exists(raw_path):
            return

        from opens_suite.calculator_widget import CalculatorDialog

        try:
            temp_calc = CalculatorDialog(raw_path)
            scope = temp_calc._create_scope()
            scope["plot"] = lambda *args, **kwargs: None
            scope["bode"] = lambda *args, **kwargs: None
            scope["subaxis"] = lambda *args, **kwargs: None
            self._results_cache.clear()

            rows_to_eval = list(range(self.model.rowCount()))

            # Iterative evaluation to resolve inter-expression dependencies
            for pass_num in range(10):
                if not rows_to_eval:
                    break

                # Add current cache to scope at the start of each pass
                scope.update(self._results_cache)

                newly_evaluated = []
                for row in rows_to_eval:
                    success, result, val_str, val_float = self._evaluate_row_internal(
                        row, raw_path, scope
                    )
                    if success:
                        newly_evaluated.append(row)
                        # Update UI
                        item_value = self.model.item(row, self.COL_VALUE)
                        item_value.setText(val_str)
                        self._apply_spec_coloring(row, val_float)

                        # Inject into scope and cache if name is valid identifier
                        name = self.model.item(row, self.COL_NAME).text().strip()
                        if name and name.isidentifier():
                            self._results_cache[name] = result
                            scope[name] = result

                if not newly_evaluated:
                    # No progress - circular dependency or actual errors
                    break

                for row in newly_evaluated:
                    rows_to_eval.remove(row)

            # Mark remaining failed rows
            for row in rows_to_eval:
                self.model.item(row, self.COL_VALUE).setText("Eval Error")
                self.model.item(row, self.COL_VALUE).setBackground(QBrush())

        except Exception as e:
            print(f"Error evaluating outputs: {e}")

    def evaluate_row(self, row, raw_path, scope=None):
        """Wrapper for single-row evaluation (e.g. on item change)."""
        if scope is None:
            from opens_suite.calculator_widget import CalculatorDialog

            try:
                temp_calc = CalculatorDialog(raw_path)
                scope = temp_calc._create_scope()
                scope["plot"] = lambda *args, **kwargs: None
                scope["bode"] = lambda *args, **kwargs: None
                scope["subaxis"] = lambda *args, **kwargs: None
                # Include existing results from other rows
                scope.update(self._results_cache)
            except Exception:
                return

        success, result, val_str, val_float = self._evaluate_row_internal(
            row, raw_path, scope
        )
        item_value = self.model.item(row, self.COL_VALUE)
        if success:
            item_value.setText(val_str)
            self._apply_spec_coloring(row, val_float)

            # Update cache
            name = self.model.item(row, self.COL_NAME).text().strip()
            if name and name.isidentifier():
                self._results_cache[name] = result
        else:
            item_value.setText(f"Error: {result}")
            item_value.setBackground(QBrush())

    def _evaluate_row_internal(self, row, raw_path, scope):
        """Returns (success, result_obj, val_str, val_float)"""
        import ast

        expression = self.model.item(row, self.COL_EXPR).text()
        if not expression:
            return False, None, "", None

        try:
            # Helper to execute multi-line and get last value
            def exec_get_last(code, l_scope):
                tree = ast.parse(code)
                if not tree.body:
                    return None

                last_node = tree.body[-1]
                if isinstance(last_node, ast.Expr):
                    if len(tree.body) > 1:
                        exec_body = ast.Module(body=tree.body[:-1], type_ignores=[])
                        exec(compile(exec_body, "<string>", "exec"), l_scope)

                    eval_expr = ast.Expression(body=last_node.value)
                    return eval(compile(eval_expr, "<string>", "eval"), l_scope)
                else:
                    exec(code, l_scope)
                    return None

            result = exec_get_last(expression, scope)

            # Format result
            val_str = ""
            val_float = None

            if isinstance(result, (int, float, np.number)):
                val_float = float(result)
                val_str = DesignPoints._format_si(val_float)
            elif isinstance(result, np.ndarray) and result.size == 1:
                val_float = float(result.item())
                val_str = DesignPoints._format_si(val_float)
            else:
                val_str = str(result)

            return True, result, val_str, val_float

        except Exception as e:
            return False, e, str(e), None

    def get_results_scope(self):
        """Returns a copy of the current results cache for use in calculator."""
        return dict(self._results_cache)

    def _apply_spec_coloring(self, row, val_float):
        item_value = self.model.item(row, self.COL_VALUE)
        if val_float is None:
            item_value.setBackground(QBrush())  # No color if not numeric
            return

        min_str = self.model.item(row, self.COL_MIN).text()
        max_str = self.model.item(row, self.COL_MAX).text()

        try:
            low_str = min_str.strip()
            high_str = max_str.strip()

            if not low_str and not high_str:
                item_value.setBackground(QBrush())
                return

            low = float(low_str) if low_str else -float("inf")
            high = float(high_str) if high_str else float("inf")

            if low <= val_float <= high:
                item_value.setBackground(QColor("#ccffcc"))  # Light green
            else:
                item_value.setBackground(QColor("#ffcccc"))  # Light red
        except Exception:
            item_value.setBackground(QBrush())

    def _on_item_changed(self, item):
        column = item.column()
        row = item.row()

        if column in [self.COL_MIN, self.COL_MAX]:
            # Re-check current value against new specs
            val_item = self.model.item(row, self.COL_VALUE)
            if val_item and self._last_raw_path:
                # Re-evaluate all because specs might depend on other rows too in future?
                # For now just re-eval all to be safe and consistent.
                self.evaluate_all(self._last_raw_path)

        if (
            column == self.COL_EXPR
            or column == self.COL_NAME
            or column == self.COL_UNIT
        ):
            if self._last_raw_path:
                self.evaluate_all(self._last_raw_path)
            self.expressionsChanged.emit()

    def _show_context_menu(self, position):
        selection = self.table_view.selectionModel().selectedRows()
        if not selection:
            return

        menu = QMenu()
        if len(selection) == 1:
            row = selection[0].row()
            expr = self.model.item(row, self.COL_EXPR).text()

            plot_action = menu.addAction("Plot")
            send_action = menu.addAction("Send to Calculator")
            menu.addSeparator()
            remove_action = menu.addAction("Remove")
        else:
            plot_all_action = menu.addAction(f"Plot Selected ({len(selection)})")
            menu.addSeparator()
            remove_all_action = menu.addAction(f"Remove Selected ({len(selection)})")

        action = menu.exec(self.table_view.viewport().mapToGlobal(position))
        if not action:
            return

        if len(selection) == 1:
            row = selection[0].row()
            expr = self.model.item(row, self.COL_EXPR).text()
            if action.text() == "Plot":
                self.expressionPlotTriggered.emit(expr)
            elif action.text() == "Send to Calculator":
                self.expressionCalculatorTriggered.emit(expr)
            elif action.text() == "Remove":
                self.model.removeRow(row)
                self.expressionsChanged.emit()
        else:
            if action.text().startswith("Plot Selected"):
                expressions = [
                    self.model.item(idx.row(), self.COL_EXPR).text()
                    for idx in selection
                ]
                self.bulkPlotTriggered.emit(expressions)
            elif action.text().startswith("Remove Selected"):
                rows = sorted([idx.row() for idx in selection], reverse=True)
                for row in rows:
                    self.model.removeRow(row)
                self.expressionsChanged.emit()

    def _on_item_double_clicked(self, index: QModelIndex):
        if index.isValid() and index.column() == self.COL_EXPR:
            self.expressionPlotTriggered.emit(index.data())
evaluate_row(row, raw_path, scope=None)

Wrapper for single-row evaluation (e.g. on item change).

Source code in src/opens_suite/outputs_widget.py
def evaluate_row(self, row, raw_path, scope=None):
    """Wrapper for single-row evaluation (e.g. on item change)."""
    if scope is None:
        from opens_suite.calculator_widget import CalculatorDialog

        try:
            temp_calc = CalculatorDialog(raw_path)
            scope = temp_calc._create_scope()
            scope["plot"] = lambda *args, **kwargs: None
            scope["bode"] = lambda *args, **kwargs: None
            scope["subaxis"] = lambda *args, **kwargs: None
            # Include existing results from other rows
            scope.update(self._results_cache)
        except Exception:
            return

    success, result, val_str, val_float = self._evaluate_row_internal(
        row, raw_path, scope
    )
    item_value = self.model.item(row, self.COL_VALUE)
    if success:
        item_value.setText(val_str)
        self._apply_spec_coloring(row, val_float)

        # Update cache
        name = self.model.item(row, self.COL_NAME).text().strip()
        if name and name.isidentifier():
            self._results_cache[name] = result
    else:
        item_value.setText(f"Error: {result}")
        item_value.setBackground(QBrush())
get_expressions()

Legacy support for simple string list.

Source code in src/opens_suite/outputs_widget.py
def get_expressions(self):
    """Legacy support for simple string list."""
    return [
        self.model.item(i, self.COL_EXPR).text()
        for i in range(self.model.rowCount())
    ]
get_expressions_data()

Returns complex data including specs.

Source code in src/opens_suite/outputs_widget.py
def get_expressions_data(self):
    """Returns complex data including specs."""
    data = []
    for i in range(self.model.rowCount()):
        data.append(
            {
                "name": self.model.item(i, self.COL_NAME).text(),
                "expression": self.model.item(i, self.COL_EXPR).text(),
                "unit": self.model.item(i, self.COL_UNIT).text(),
                "min": self.model.item(i, self.COL_MIN).text(),
                "max": self.model.item(i, self.COL_MAX).text(),
                "description": self.model.item(i, self.COL_DESC).text(),
            }
        )
    return data
get_results_scope()

Returns a copy of the current results cache for use in calculator.

Source code in src/opens_suite/outputs_widget.py
def get_results_scope(self):
    """Returns a copy of the current results cache for use in calculator."""
    return dict(self._results_cache)

ValueColumnDelegate

Bases: QStyledItemDelegate

Delegate to prevent selection highlight from obscuring the background color.

Source code in src/opens_suite/outputs_widget.py
class ValueColumnDelegate(QStyledItemDelegate):
    """Delegate to prevent selection highlight from obscuring the background color."""

    def paint(self, painter, option, index):
        # Clear selected state so background color always shows
        option.state &= ~QStyle.StateFlag.State_Selected
        super().paint(painter, option, index)

pcell

PCell support: base symbol class and a programmable cell (pcell) implementation.

This module provides: - SymbolBase: a minimal base class (non-GUI) describing a symbol's parameters. - PCellSymbol: a QGraphicsObject that renders a rectangle with a column of pins on the right side. Pins can be defined via a parameter (e.g. 'PINS' = 'PB0 PB1 PB2'). - PCELL_REGISTRY and register_pcell() to allow manual registration of pcell classes.

This is additive and should not change existing SVG-based symbols.

PCellSymbol

Bases: QGraphicsObject, SymbolBase

A programmable cell drawn as a rectangle with pins on the right column.

Parameters are read from a parameter named 'PINS' (space-separated) by default.

Source code in src/opens_suite/pcell.py
class PCellSymbol(QGraphicsObject, SymbolBase):
    """A programmable cell drawn as a rectangle with pins on the right column.

    Parameters are read from a parameter named 'PINS' (space-separated) by default.
    """

    def __init__(self, parameters: Dict[str, str] = None, parent=None):
        QGraphicsObject.__init__(self, parent)
        SymbolBase.__init__(self, parameters=parameters)

        # Use the QGraphicsItem flag enum values to set item behavior
        self.setFlags(
            QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
            | QGraphicsItem.GraphicsItemFlag.ItemIsMovable
            | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
        )

        # Visual and layout
        self.width = 180
        self.pin_spacing = 18
        self.left_margin = 8
        self.top_margin = 12
        self.pin_size = 8

        self.pins: List[str] = []
        self.pin_items: Dict[str, QGraphicsRectItem] = {}
        self.connected_pins: List[str] = []

        # Naming/prefix used by the schematic for automatic instance names
        # Provide sensible defaults so _assign_name in SchematicView can operate
        # on programmatic pcells just like SVG-based SchematicItem instances.
        self.prefix = self.get_parameter("PREFIX", "A")
        # self.name is managed by SymbolBase (initialized from MODELNAME)
        if not getattr(self, "name", None):
            self.name = ""

        # parse initial pins
        # Ensure the PINS parameter exists (space-separated string)
        if "PINS" not in self.parameters and "pins" not in self.parameters:
            self.parameters["PINS"] = ""

        pins_raw = self.get_parameter("PINS", self.get_parameter("pins", ""))
        if not pins_raw:
            # fallback: maybe parameters specify explicit numbered pins
            pins_raw = self.get_parameter("PB", "")

        self._set_pins_from_string(pins_raw)

    def boundingRect(self) -> QRectF:
        height = max(40, self.top_margin * 2 + len(self.pins) * self.pin_spacing)
        return QRectF(0, 0, self.width, height)

    def paint(self, painter: QPainter, option, widget) -> None:
        rect = self.boundingRect()
        painter.setPen(QPen(QColor("black"), 1))
        painter.setBrush(QBrush(QColor("#f0f0f0")))
        painter.drawRect(rect)

        # Draw instance name (assigned by the scene) at top-left. Draw the
        # MODELNAME (model identifier) as a smaller secondary label so the
        # two are visually distinct.
        painter.setPen(QPen(QColor("black")))
        font = QFont("Arial", 10)
        painter.setFont(font)
        inst_name = self.name or "PCELL"
        painter.drawText(QPointF(self.left_margin, 12), inst_name)

        modelname = self.get_parameter("MODELNAME", "")
        if modelname:
            small_font = QFont("Arial", 8)
            painter.setFont(small_font)
            painter.setPen(QPen(QColor("#333333")))
            painter.drawText(QPointF(self.left_margin, 26), modelname)

        # Draw pin labels on the right side; the actual pin rectangles are
        # represented by QGraphicsRectItem children (self.pin_items). We hide
        # labels for pins that are connected (self.connected_pins) so the UI
        # matches the behavior of SVG-based items.
        width = rect.width()
        for idx, pin in enumerate(self.pins):
            # Skip labels for connected pins
            if hasattr(self, "connected_pins") and pin in getattr(
                self, "connected_pins", []
            ):
                continue
            pin_item = self.pin_items.get(pin)
            if pin_item is not None:
                local_y = pin_item.pos().y()
            else:
                local_y = self.top_margin + idx * self.pin_spacing
            px = width - self.pin_size
            # label left of the pin square
            text_x = px - 6 - painter.fontMetrics().horizontalAdvance(pin)
            painter.drawText(QPointF(text_x, local_y + self.pin_size), pin)

        # Also render a compact list of pin names on the left inside the box
        if self.pins:
            painter.setPen(QPen(QColor("black")))
            small_font = QFont("Arial", 8)
            painter.setFont(small_font)
            for idx, pin in enumerate(self.pins):
                ty = self.top_margin + idx * (self.pin_spacing - 2) + 4
                painter.drawText(QPointF(self.left_margin, ty + 12), pin)
        else:
            # Hint for editing pins
            hint_font = QFont("Arial", 8)
            hint_font.setItalic(True)
            painter.setFont(hint_font)
            painter.setPen(QPen(QColor("#666666")))
            painter.drawText(
                QPointF(self.left_margin, rect.height() - 8),
                "Double-click to edit PINS",
            )

    def _set_pins_from_string(self, s: str) -> None:
        """Set pins from a whitespace-separated string and create pin graphics.

        Existing pin items are removed and recreated.
        """
        s = (s or "").strip()
        if not s:
            self.pins = []
        else:
            self.pins = [tok for tok in s.split() if tok]

        # Remove existing pin items
        for item in list(self.pin_items.values()):
            try:
                item.setParentItem(None)
                item.scene().removeItem(item)
            except Exception:
                pass
        self.pin_items.clear()

        # Create new pin QGraphicsRectItem children to support connectivity searches
        for idx, pin in enumerate(self.pins):
            rect = QGraphicsRectItem(self)
            rect.setRect(0, 0, self.pin_size, self.pin_size)
            rect.setBrush(QBrush(QColor("red")))
            rect.setPen(QPen(Qt.PenStyle.NoPen))
            # position will be updated in update_pin_positions
            rect.setData(Qt.ItemDataRole.UserRole, pin)
            self.pin_items[pin] = rect

        self.update_pin_positions()
        self.update()

    def update_pin_positions(self) -> None:
        rect = self.boundingRect()
        width = rect.width()
        scene = self.scene()
        # Determine grid size (fallback to 10 if scene doesn't provide it)
        grid = getattr(scene, "grid_size", 20) if scene is not None else 20
        for idx, pin in enumerate(self.pins):
            # desired local coordinates for the pin's top-left
            local_y = self.top_margin + idx * self.pin_spacing
            local_x = width - self.pin_size

            # Compute the pin center in local coordinates
            local_center = QPointF(
                local_x + self.pin_size / 2.0, local_y + self.pin_size / 2.0
            )

            # Map to scene, snap the scene Y coordinate to grid, then map back
            try:
                scene_center = self.mapToScene(local_center)
                snapped_scene_center = QPointF(
                    scene_center.x(), round(scene_center.y() / grid) * grid
                )
                final_local_center = self.mapFromScene(snapped_scene_center)
                # Compute top-left local from center
                final_local_topleft = QPointF(
                    final_local_center.x() - self.pin_size / 2.0,
                    final_local_center.y() - self.pin_size / 2.0,
                )
            except Exception:
                # Fallback: fall back to previous heuristic based on item pos
                scene_pin_center_y = self.pos().y() + local_y + self.pin_size / 2
                snapped_center_y = round(scene_pin_center_y / grid) * grid
                final_local_topleft = QPointF(
                    local_x, snapped_center_y - self.pos().y() - self.pin_size / 2
                )

            item = self.pin_items.get(pin)
            if item:
                # Keep the rect local to the pin item and use setPos for placement
                item.setRect(0, 0, self.pin_size, self.pin_size)
                item.setParentItem(self)
                # Shift pins slightly to the right so the visual square overlaps
                # the symbol border and aligns with the scene grid. Some scenes
                # use different coordinate origins; adding half the symbol width
                # compensates and places the pin marker exactly on the border.
                try:
                    shift_x = self.pin_size / 2.0
                    item.setPos(final_local_topleft + QPointF(shift_x, 0))
                except Exception:
                    item.setPos(final_local_topleft)

    def set_parameter(self, name: str, value: str) -> None:
        SymbolBase.set_parameter(self, name, value)
        # respond to pins parameter changes
        if name.lower() in ("pins", "pids", "pins_list", "p") or name.upper() == "PINS":
            self._set_pins_from_string(value)
        # Do not map MODELNAME to the instance name. MODELNAME is the model
        # identifier (e.g. transistor model) and must remain separate from the
        # instance name (which is assigned by the scene via set_name()).
        self.update()

    def itemChange(self, change, value):
        """Snap item position so symbol borders and pin locations fall on the scene grid.

        Additionally, after the position has been changed, update pin positions so
        the pin QGraphicsRectItem children stay correctly aligned.
        """
        if (
            change == QGraphicsItem.GraphicsItemChange.ItemPositionChange
            and self.scene()
        ):
            grid = getattr(self.scene(), "grid_size", 20)
            new_pos = value
            # Snap Y to grid
            y = round(new_pos.y() / grid) * grid
            # Snap X such that the right border (x + width) lies on grid
            width = self.boundingRect().width()
            right = new_pos.x() + width
            snapped_right = round(right / grid) * grid
            x = snapped_right - width
            return QPointF(x, y)

        if change == QGraphicsItem.GraphicsItemChange.ItemSceneHasChanged:
            scene = self.scene()
            if scene:
                grid = getattr(scene, "grid_size", 20)
                pos = self.pos()
                y = round(pos.y() / grid) * grid
                width = self.boundingRect().width()
                right = pos.x() + width
                x = round(right / grid) * grid - width
                if QPointF(x, y) != pos:
                    self.setPos(x, y)

            try:
                self.update_pin_positions()
            except Exception:
                pass

        if change in (
            QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged,
            QGraphicsItem.GraphicsItemChange.ItemRotationHasChanged,
            QGraphicsItem.GraphicsItemChange.ItemTransformHasChanged,
        ):
            try:
                self.update_pin_positions()
            except Exception:
                pass

        return super().itemChange(change, value)

    def set_name(self, new_name: str) -> None:
        """Set the instance name for this pcell (compatible with SchematicView._assign_name)."""
        # Only set the instance name. Do NOT overwrite MODELNAME: the model
        # identifier is independent from the instance name (e.g. model M1 vs A1).
        self.name = new_name

    def set_parameters(self, params: Dict[str, str]) -> None:
        SymbolBase.set_parameters(self, params)
        # If PINS present, update
        if "PINS" in params or "pins" in params:
            pins_val = params.get("PINS") or params.get("pins")
            self._set_pins_from_string(pins_val)

    def set_connected_pins(self, pin_ids):
        """Hide pin graphics for pins that are connected (like SchematicItem)."""
        for pid, item in self.pin_items.items():
            try:
                item.setVisible(pid not in pin_ids)
            except Exception:
                pass
        # remember connected pins for paint-time decisions
        try:
            self.connected_pins = list(pin_ids)
        except Exception:
            self.connected_pins = []
        self.update()

    def format_netlist(self, item_node_map: dict):
        """Return a netlist line (or list of lines) for this pcell.

        The default implementation returns a simple line using the instance
        name, a bracketed list of pin identifiers, and the MODELNAME parameter
        (falling back to 'python_block'). Advanced pcells may override this
        method to emit arbitrary lines. The mapping `item_node_map` is
        provided as a helper: keys are (item, pin_id) -> node name.
        """
        try:
            # Prefer actual net names for each pin using the provided mapping
            pin_nodes = []
            for pid in self.pins:
                node = item_node_map.get((self, pid))
                if not node:
                    node = f"N_float_{self.name}_{pid}"
                pin_nodes.append(node)

            pins_str = " ".join(pin_nodes)
            modelname = self.get_parameter("MODELNAME", None)
            if not modelname:
                modelname = "python_block"
            return f"A{self.name} [{pins_str}] {modelname}"
        except Exception:
            return f"* Error formatting pcell {getattr(self, 'name', '<unnamed>')}"

    def mouseDoubleClickEvent(self, event):
        # Quick editor for PINS parameter
        current = self.get_parameter("PINS", "")
        text, ok = QInputDialog.getText(
            None, "Edit PINS", "PINS (space-separated):", text=current
        )
        if ok:
            self.set_parameter("PINS", text)
        else:
            super().mouseDoubleClickEvent(event)
format_netlist(item_node_map)

Return a netlist line (or list of lines) for this pcell.

The default implementation returns a simple line using the instance name, a bracketed list of pin identifiers, and the MODELNAME parameter (falling back to 'python_block'). Advanced pcells may override this method to emit arbitrary lines. The mapping item_node_map is provided as a helper: keys are (item, pin_id) -> node name.

Source code in src/opens_suite/pcell.py
def format_netlist(self, item_node_map: dict):
    """Return a netlist line (or list of lines) for this pcell.

    The default implementation returns a simple line using the instance
    name, a bracketed list of pin identifiers, and the MODELNAME parameter
    (falling back to 'python_block'). Advanced pcells may override this
    method to emit arbitrary lines. The mapping `item_node_map` is
    provided as a helper: keys are (item, pin_id) -> node name.
    """
    try:
        # Prefer actual net names for each pin using the provided mapping
        pin_nodes = []
        for pid in self.pins:
            node = item_node_map.get((self, pid))
            if not node:
                node = f"N_float_{self.name}_{pid}"
            pin_nodes.append(node)

        pins_str = " ".join(pin_nodes)
        modelname = self.get_parameter("MODELNAME", None)
        if not modelname:
            modelname = "python_block"
        return f"A{self.name} [{pins_str}] {modelname}"
    except Exception:
        return f"* Error formatting pcell {getattr(self, 'name', '<unnamed>')}"
itemChange(change, value)

Snap item position so symbol borders and pin locations fall on the scene grid.

Additionally, after the position has been changed, update pin positions so the pin QGraphicsRectItem children stay correctly aligned.

Source code in src/opens_suite/pcell.py
def itemChange(self, change, value):
    """Snap item position so symbol borders and pin locations fall on the scene grid.

    Additionally, after the position has been changed, update pin positions so
    the pin QGraphicsRectItem children stay correctly aligned.
    """
    if (
        change == QGraphicsItem.GraphicsItemChange.ItemPositionChange
        and self.scene()
    ):
        grid = getattr(self.scene(), "grid_size", 20)
        new_pos = value
        # Snap Y to grid
        y = round(new_pos.y() / grid) * grid
        # Snap X such that the right border (x + width) lies on grid
        width = self.boundingRect().width()
        right = new_pos.x() + width
        snapped_right = round(right / grid) * grid
        x = snapped_right - width
        return QPointF(x, y)

    if change == QGraphicsItem.GraphicsItemChange.ItemSceneHasChanged:
        scene = self.scene()
        if scene:
            grid = getattr(scene, "grid_size", 20)
            pos = self.pos()
            y = round(pos.y() / grid) * grid
            width = self.boundingRect().width()
            right = pos.x() + width
            x = round(right / grid) * grid - width
            if QPointF(x, y) != pos:
                self.setPos(x, y)

        try:
            self.update_pin_positions()
        except Exception:
            pass

    if change in (
        QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged,
        QGraphicsItem.GraphicsItemChange.ItemRotationHasChanged,
        QGraphicsItem.GraphicsItemChange.ItemTransformHasChanged,
    ):
        try:
            self.update_pin_positions()
        except Exception:
            pass

    return super().itemChange(change, value)
set_connected_pins(pin_ids)

Hide pin graphics for pins that are connected (like SchematicItem).

Source code in src/opens_suite/pcell.py
def set_connected_pins(self, pin_ids):
    """Hide pin graphics for pins that are connected (like SchematicItem)."""
    for pid, item in self.pin_items.items():
        try:
            item.setVisible(pid not in pin_ids)
        except Exception:
            pass
    # remember connected pins for paint-time decisions
    try:
        self.connected_pins = list(pin_ids)
    except Exception:
        self.connected_pins = []
    self.update()
set_name(new_name)

Set the instance name for this pcell (compatible with SchematicView._assign_name).

Source code in src/opens_suite/pcell.py
def set_name(self, new_name: str) -> None:
    """Set the instance name for this pcell (compatible with SchematicView._assign_name)."""
    # Only set the instance name. Do NOT overwrite MODELNAME: the model
    # identifier is independent from the instance name (e.g. model M1 vs A1).
    self.name = new_name

SymbolBase

Minimal non-GUI base describing parameters and name.

Subclasses may implement GUI by composing or inheriting this class.

Source code in src/opens_suite/pcell.py
class SymbolBase:
    """Minimal non-GUI base describing parameters and name.

    Subclasses may implement GUI by composing or inheriting this class.
    """

    def __init__(self, parameters: Dict[str, str] = None):
        self.parameters = parameters.copy() if parameters else {}
        # Ensure MODELNAME key exists so UIs and serializers can always find it
        self.parameters.setdefault("MODELNAME", "")
        # instance name is managed by the scene (via set_name);
        # do not initialize the instance name from MODELNAME to avoid
        # conflating the instance name with the model identifier.
        self.name = ""

    def set_parameter(self, name: str, value: str) -> None:
        self.parameters[name] = value

    def get_parameter(self, name: str, default: str = "") -> str:
        return self.parameters.get(name, default)

    def set_parameters(self, params: Dict[str, str]) -> None:
        self.parameters.update(params)

register_pcell(name, cls)

Register a pcell class under a name.

Example: register_pcell("python_block", PCellSymbol)

Source code in src/opens_suite/pcell.py
def register_pcell(name: str, cls: type) -> None:
    """Register a pcell class under a name.

    Example: register_pcell("python_block", PCellSymbol)
    """
    PCELL_REGISTRY[name] = cls

plugins

base

OpenSPlugin
Source code in src/opens_suite/plugins/base.py
class OpenSPlugin:
    def __init__(self, main_window):
        self.main_window = main_window

    def setup(self):
        """Called to initialize the plugin and integrate it with the main window."""
        pass

    def get_menu(self, title):
        """Helper to get or create a menu by title."""
        for action in self.main_window.menuBar().actions():
            if action.text().replace("&", "") == title.replace("&", ""):
                return action.menu()
        return self.main_window.menuBar().addMenu(title)

    def get_toolbar(self, title):
        """Helper to get or create a toolbar by title."""
        for tb in self.main_window.findChildren(QToolBar):
            if tb.windowTitle() == title:
                return tb
        tb = QToolBar(title)
        self.main_window.addToolBar(tb)
        return tb
get_menu(title)

Helper to get or create a menu by title.

Source code in src/opens_suite/plugins/base.py
def get_menu(self, title):
    """Helper to get or create a menu by title."""
    for action in self.main_window.menuBar().actions():
        if action.text().replace("&", "") == title.replace("&", ""):
            return action.menu()
    return self.main_window.menuBar().addMenu(title)
get_toolbar(title)

Helper to get or create a toolbar by title.

Source code in src/opens_suite/plugins/base.py
def get_toolbar(self, title):
    """Helper to get or create a toolbar by title."""
    for tb in self.main_window.findChildren(QToolBar):
        if tb.windowTitle() == title:
            return tb
    tb = QToolBar(title)
    self.main_window.addToolBar(tb)
    return tb
setup()

Called to initialize the plugin and integrate it with the main window.

Source code in src/opens_suite/plugins/base.py
def setup(self):
    """Called to initialize the plugin and integrate it with the main window."""
    pass

reporting

report_generator

ReportGenerator
Source code in src/opens_suite/reporting/report_generator.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
class ReportGenerator:
    def __init__(self, schematic_path, report_dir):
        self.schematic_path = os.path.abspath(schematic_path)
        self.report_dir = os.path.abspath(report_dir)
        self.view = None
        self.netlist = ""
        self.outputs = []
        self._raw_path = ""
        self._netlist_path = ""
        self._log_path = ""
        self.hierarchy_images = []  # [(label, filename), ...]

    def generate(self):
        """Main execution sequence to drive reporting."""
        print(f"Generating report in {self.report_dir}...")
        self._prepare_directory()
        self._load_and_snapshot()
        self._export_hierarchy()
        self._find_simulation_results()
        self._evaluate_and_plot()
        self._build_html()
        print(
            f"Report generated successfully: {os.path.join(self.report_dir, 'index.html')}"
        )

    def _prepare_directory(self):
        os.makedirs(self.report_dir, exist_ok=True)
        # Copy the OpenS logo for the footer
        logo_src = os.path.join(
            os.path.dirname(__file__), "..", "assets", "launcher.png"
        )
        if os.path.exists(logo_src):
            shutil.copy2(logo_src, os.path.join(self.report_dir, "launcher.png"))

    def _render_scene_to_image(self, view, output_path):
        """Render a SchematicView's scene to a PNG file."""
        scene = view.scene()
        rect = scene.itemsBoundingRect()
        margin = 20
        rect.adjust(-margin, -margin, margin, margin)

        # High-res: scale up by 2x for retina-quality
        scale = 2
        img = QImage(
            int(rect.width() * scale),
            int(rect.height() * scale),
            QImage.Format.Format_ARGB32_Premultiplied,
        )
        img.fill(Qt.GlobalColor.white)

        painter = QPainter(img)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        from PyQt6.QtCore import QRectF

        scene.render(painter, target=QRectF(img.rect()), source=rect)
        painter.end()

        img.save(output_path)

    def _load_and_snapshot(self):
        print("Loading schematic and rendering snapshot...")
        self.view = SchematicView()
        self.view.filename = self.schematic_path
        self.view.load_schematic(self.schematic_path)
        self.view.recalculate_connectivity()

        self._render_scene_to_image(
            self.view, os.path.join(self.report_dir, "circuit.png")
        )

        # Extract analyses, variables, and outputs
        tree = ET.parse(self.schematic_path)
        root = tree.getroot()

        self.analyses = [
            dict(elem.attrib)
            for elem in root.iter("{http://opens-schematic.org}analysis")
        ]
        self.variables = [
            dict(elem.attrib)
            for elem in root.iter("{http://opens-schematic.org}variable")
        ]
        self.outputs = []
        for elem in root.iter("{http://opens-schematic.org}output"):
            out_data = dict(elem.attrib)
            out_data["expression"] = elem.text.strip() if elem.text else ""
            self.outputs.append(out_data)

    def _export_hierarchy(self):
        """Walk scene items to find subcircuit references and export their schematics."""
        print("Exporting hierarchy schematics...")
        from opens_suite.schematic_item import SchematicItem

        scene = self.view.scene()
        visited = set()

        for item in scene.items():
            if not isinstance(item, SchematicItem):
                continue
            if not getattr(item, "prefix", "") == "X":
                continue

            model_param = item.parameters.get("MODEL", "")
            if not model_param:
                continue

            # Resolve the schematic path for this subcircuit
            sch_path = self._resolve_subcircuit_path(item)
            if not sch_path or sch_path in visited:
                continue

            visited.add(sch_path)
            label = os.path.splitext(os.path.basename(sch_path))[0]
            filename = f"subcircuit_{label}.png"

            try:
                sub_view = SchematicView()
                sub_view.filename = sch_path
                sub_view.load_schematic(sch_path)
                sub_view.recalculate_connectivity()
                self._render_scene_to_image(
                    sub_view, os.path.join(self.report_dir, filename)
                )
                self.hierarchy_images.append((label, filename))
                print(f"  Exported subcircuit: {label}")

                # Recurse: check subcircuits within this subcircuit
                self._export_sub_hierarchy(sub_view, visited)
            except Exception as e:
                print(f"  Warning: Could not export subcircuit {label}: {e}")

    def _export_sub_hierarchy(self, parent_view, visited):
        """Recursively export subcircuit schematics from a parent view."""
        from opens_suite.schematic_item import SchematicItem

        scene = parent_view.scene()
        for item in scene.items():
            if not isinstance(item, SchematicItem):
                continue
            if not getattr(item, "prefix", "") == "X":
                continue

            sch_path = self._resolve_subcircuit_path(item)
            if not sch_path or sch_path in visited:
                continue

            visited.add(sch_path)
            label = os.path.splitext(os.path.basename(sch_path))[0]
            filename = f"subcircuit_{label}.png"

            try:
                sub_view = SchematicView()
                sub_view.filename = sch_path
                sub_view.load_schematic(sch_path)
                sub_view.recalculate_connectivity()
                self._render_scene_to_image(
                    sub_view, os.path.join(self.report_dir, filename)
                )
                self.hierarchy_images.append((label, filename))
                print(f"  Exported subcircuit: {label}")
                self._export_sub_hierarchy(sub_view, visited)
            except Exception as e:
                print(f"  Warning: Could not export subcircuit {label}: {e}")

    def _resolve_subcircuit_path(self, item):
        """Resolve the schematic .svg path for a subcircuit item."""
        model_param = item.parameters.get("MODEL", "")
        if not model_param:
            return None

        sym_dir = os.path.dirname(item.svg_path) if item.svg_path else ""
        base_sch = model_param.replace(".sch", "").replace(".svg", "")

        candidates = [
            os.path.join(sym_dir, f"{base_sch}.svg"),
            os.path.join(sym_dir, f"{base_sch}.sch.svg"),
            os.path.join(sym_dir, "schematic.svg"),
            os.path.join(sym_dir, "schematic.sch.svg"),
        ]

        if item.svg_path and item.svg_path.endswith(".sym.svg"):
            candidates.append(item.svg_path.replace(".sym.svg", ".sch.svg"))
            candidates.append(item.svg_path.replace(".sym.svg", ".svg"))

        for path in candidates:
            if os.path.exists(path):
                return os.path.abspath(path)

        return None

    def _find_simulation_results(self):
        print("Checking for existing simulation results...")
        sim_dir = os.path.join(os.path.dirname(self.schematic_path), "simulation")
        base = os.path.splitext(os.path.basename(self.schematic_path))[0]

        raw_target = os.path.join(sim_dir, f"{base}.raw")
        log_target = os.path.join(sim_dir, f"{base}.log")

        if os.path.exists(raw_target):
            self._raw_path = raw_target
        else:
            print("No existing simulation raw file found.")
            self._raw_path = None

        if os.path.exists(log_target):
            self._log_path = log_target
        else:
            self._log_path = None

    def _evaluate_and_plot(self):
        print("Evaluating outputs...")
        from opens_suite.waveform_viewer import WaveformViewer
        import ast
        import numpy as np
        from opens_suite.design_points import DesignPoints

        calc = None
        scope = {}
        viewer = None

        if self._raw_path and os.path.exists(self._raw_path):
            calc = CalculatorDialog(self._raw_path)
            viewer = WaveformViewer()
            viewer.resize(800, 400)
            calc.viewer = viewer
            scope = calc._create_scope()

        for i, out in enumerate(self.outputs):
            name = out.get("name", f"expr_{i}")
            expr = out.get("expression", "")
            unit = out.get("unit", "")

            if not expr:
                continue

            if not self._raw_path:
                out["_eval_success"] = False
                out["_eval_error"] = "No simulation results available"
                continue

            try:
                if viewer:
                    viewer.clear()

                tree = ast.parse(expr)
                if not tree.body:
                    continue
                last_node = tree.body[-1]

                result = None
                if isinstance(last_node, ast.Expr):
                    if len(tree.body) > 1:
                        exec_body = ast.Module(body=tree.body[:-1], type_ignores=[])
                        exec(compile(exec_body, "<string>", "exec"), scope)
                    eval_expr = ast.Expression(body=last_node.value)
                    result = eval(compile(eval_expr, "<string>", "eval"), scope)
                else:
                    exec(expr, scope)

                out["_eval_success"] = True

                if isinstance(result, (int, float, np.number, complex)):
                    if isinstance(result, complex):
                        val_str = f"{DesignPoints._format_si(result.real)} + j{DesignPoints._format_si(result.imag)}"
                    else:
                        val_str = DesignPoints._format_si(float(result))
                    out["_eval_scalar"] = val_str

                elif isinstance(result, np.ndarray) and result.size == 1:
                    val_str = DesignPoints._format_si(float(result.item()))
                    out["_eval_scalar"] = val_str

                elif isinstance(result, np.ndarray):
                    # Smart x-axis selection
                    x_axis = np.array([])
                    # Use more specific hints to avoid matching 't' in 'plot' or 'out'
                    if any(h in expr for h in ["vt(", "it(", "st(", ".t"]):
                        x_axis = scope.get("t", x_axis)
                    elif any(h in expr for h in ["vf(", "ifc(", "sf(", ".f"]):
                        x_axis = scope.get("f", x_axis)
                    elif any(h in expr for h in ["vdc(", "sdc(", "sw"]):
                        x_axis = scope.get("sw", x_axis)

                    if len(x_axis) != len(result):
                        # Fallback: find any default vector with matching length
                        # Prioritize sw if it matches, then t, then f
                        for cand in ["sw", "t", "f"]:
                            vec = scope.get(cand, [])
                            if len(vec) == len(result) and len(vec) > 0:
                                x_axis = vec
                                break

                    x_label, x_unit = None, None
                    if np.array_equal(x_axis, scope.get("sw", np.array([1]))):
                        x_label, x_unit = "Sweep", ""
                    elif np.array_equal(x_axis, scope.get("t", np.array([1]))):
                        x_label, x_unit = "Time", "s"
                    elif np.array_equal(x_axis, scope.get("f", np.array([1]))):
                        x_label, x_unit = "Frequency", "Hz"

                    if (
                        np.array_equal(x_axis, scope.get("f", np.array([])))
                        and np.iscomplexobj(result)
                        and len(x_axis) == len(result)
                    ):
                        viewer.bode(result, label=name)
                    else:
                        viewer.plot(x_axis, result, label=name)
                        if x_label:
                            for p in viewer.plots:
                                p.setLabel("bottom", x_label, x_unit)

                if viewer and len(viewer.signals) > 0:
                    from PyQt6.QtWidgets import QApplication
                    import pyqtgraph.exporters as exporters

                    QApplication.processEvents()

                    plot_filename = f"plot_{i}.png"
                    plot_path = os.path.join(self.report_dir, plot_filename)

                    exporter = exporters.ImageExporter(viewer.glw.scene())
                    exporter.parameters()["width"] = 1600
                    exporter.export(plot_path)

                    out["_eval_plot"] = plot_filename

            except Exception as e:
                import traceback

                print(f"Error evaluating {name}: {e}\n{traceback.format_exc()}")
                out["_eval_success"] = False
                out["_eval_error"] = str(e)

        if viewer:
            viewer.close()

    def _build_html(self):
        print("Building HTML file...")

        # Section numbering
        sect = 1

        html = [
            "<!DOCTYPE html>",
            "<html lang='en'>",
            "<head>",
            "    <meta charset='UTF-8'>",
            "    <meta name='viewport' content='width=device-width, initial-scale=1.0'>",
            "    <title>OpenS Simulation Report</title>",
            "    <style>",
            "        * { box-sizing: border-box; }",
            "        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; background-color: #f4f4f9; color: #333; }",
            "        .container { max-width: 1000px; margin: auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }",
            "        h1 { border-bottom: 2px solid #005A9C; padding-bottom: 10px; color: #005A9C; }",
            "        h2 { margin-top: 30px; color: #004080; }",
            "        .schematic { text-align: center; margin: 20px 0; }",
            "        .schematic img { max-width: 100%; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); cursor: pointer; }",
            "        table { width: 100%; border-collapse: collapse; margin-top: 20px; }",
            "        th, td { padding: 12px; border: 1px solid #ddd; text-align: left; vertical-align: top; }",
            "        th { background-color: #005A9C; color: white; }",
            "        tr:nth-child(even) { background-color: #f9f9f9; }",
            "        pre { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 5px; overflow-x: auto; font-family: 'Courier New', Courier, monospace; }",
            "        .plot-container { text-align: center; margin: 15px 0; border: 1px solid #eee; padding: 10px; background: #fafafa; }",
            "        .plot-container img { max-width: 100%; cursor: pointer; }",
            "        .error { color: #d9534f; font-weight: bold; }",
            # TOC Styles
            "        .toc { background: #f0f4f8; border: 1px solid #d0d8e0; border-radius: 6px; padding: 20px; margin: 20px 0; }",
            "        .toc h3 { margin-top: 0; color: #004080; }",
            "        .toc ul { list-style: none; padding-left: 0; margin: 0; }",
            "        .toc li { padding: 4px 0; }",
            "        .toc a { text-decoration: none; color: #005A9C; }",
            "        .toc a:hover { text-decoration: underline; }",
            # Lightbox Styles
            "        .lightbox-overlay { display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.92); z-index: 9999; justify-content: center; align-items: center; cursor: zoom-out; }",
            "        .lightbox-overlay.active { display: flex; }",
            "        .lightbox-overlay img { max-width: 95vw; max-height: 95vh; object-fit: contain; touch-action: none; transform-origin: center center; transition: transform 0.1s ease; }",
            "        .lightbox-close { position: fixed; top: 15px; right: 25px; color: white; font-size: 35px; cursor: pointer; z-index: 10000; font-weight: bold; line-height: 1; }",
            "    </style>",
            "</head>",
            "<body>",
            "    <div class='container'>",
            f"        <h1>Simulation Report: {os.path.basename(self.schematic_path)}</h1>",
            "",
        ]

        # --- Table of Contents ---
        toc_items = []

        toc_items.append((f"sect-{sect}", f"{sect}. Top-Level Schematic"))
        schematic_sect = sect
        sect += 1

        if self.hierarchy_images:
            toc_items.append((f"sect-{sect}", f"{sect}. Subcircuit Schematics"))
            hierarchy_sect = sect
            sect += 1
        else:
            hierarchy_sect = None

        toc_items.append(("sect-outputs", f"{sect}. Output Expressions"))
        outputs_sect = sect
        sect += 1

        # Check if any plots exist
        has_plots = any(out.get("_eval_plot") for out in self.outputs)
        if has_plots:
            toc_items.append(("sect-plots", f"{sect}. Waveform Plots"))
            plots_sect = sect
            sect += 1
        else:
            plots_sect = None

        toc_items.append(("sect-logs", f"{sect}. Simulation Logs"))
        logs_sect = sect
        sect += 1

        html.append("        <div class='toc'>")
        html.append("            <h3>Table of Contents</h3>")
        html.append("            <ul>")
        for anchor, label in toc_items:
            html.append(f"                <li><a href='#{anchor}'>{label}</a></li>")
        html.append("            </ul>")
        html.append("        </div>")
        html.append("")

        # --- Section: Top-Level Schematic ---
        html.append(
            f"        <h2 id='sect-{schematic_sect}'>{schematic_sect}. Top-Level Schematic</h2>"
        )
        html.append("        <div class='schematic'>")
        html.append(
            "            <img src='circuit.png' alt='Circuit Schematic' onclick='openLightbox(this)'>"
        )
        html.append("        </div>")
        html.append("")

        # --- Section: Subcircuit Schematics ---
        if hierarchy_sect is not None:
            html.append(
                f"        <h2 id='sect-{hierarchy_sect}'>{hierarchy_sect}. Subcircuit Schematics</h2>"
            )
            for label, filename in self.hierarchy_images:
                html.append(f"        <h3>{label}</h3>")
                html.append("        <div class='schematic'>")
                html.append(
                    f"            <img src='{filename}' alt='{label}' onclick='openLightbox(this)'>"
                )
                html.append("        </div>")
            html.append("")

        # --- Section: Output Expressions ---
        html.append(
            f"        <h2 id='sect-outputs'>{outputs_sect}. Output Expressions</h2>"
        )

        if not self.outputs:
            html.append("        <p>No outputs configured for this schematic.</p>")
        else:
            html.append("        <table>")
            html.append(
                "            <tr><th>Name</th><th>Expression</th><th>Unit</th><th>Min</th><th>Value</th><th>Max</th><th>Description</th></tr>"
            )
            for out in self.outputs:
                if "_eval_scalar" not in out and not out.get("_eval_error"):
                    continue

                name = out.get("name", "Unnamed")
                expr = out.get("expression", "")
                unit = out.get("unit", "")
                spec_min = out.get("min", "")
                spec_max = out.get("max", "")
                description = out.get("description", "")

                html.append("            <tr>")
                html.append(f"                <td>{name}</td>")
                html.append(
                    f"                <td><details><summary>show</summary><code>{expr}</code></details></td>"
                )
                html.append(f"                <td>{unit}</td>")
                html.append(f"                <td>{spec_min}</td>")

                # Value column with spec coloring
                if out.get("_eval_success") and "_eval_scalar" in out:
                    scalar_val = out["_eval_scalar"]
                    cell_color = self._spec_color(scalar_val, spec_min, spec_max)
                    html.append(
                        f"                <td style='background-color: {cell_color}; font-weight: bold; white-space: nowrap;'>{scalar_val}</td>"
                    )
                elif out.get("_eval_success"):
                    html.append("                <td>\u2014</td>")
                else:
                    err = out.get("_eval_error", "Unknown Error")
                    html.append(f"                <td class='error'>{err}</td>")

                html.append(f"                <td>{spec_max}</td>")
                html.append(f"                <td>{description}</td>")

                html.append("            </tr>")
            html.append("        </table>")

        # --- Section: Waveform Plots ---
        if plots_sect is not None:
            html.append("")
            html.append(
                f"        <h2 id='sect-plots'>{plots_sect}. Waveform Plots</h2>"
            )
            for out in self.outputs:
                if not out.get("_eval_plot"):
                    continue
                name = out.get("name", "Unnamed")
                expr = out.get("expression", "")
                description = out.get("description", "")
                html.append(f"        <h3>{name}</h3>")
                html.append(
                    f"        <p style='color: #666; font-size: 0.9em;'><code>{expr}</code></p>"
                )
                html.append("        <div class='schematic'>")
                html.append(
                    f"            <img src='{out['_eval_plot']}' alt='{name}' onclick='openLightbox(this)'>"
                )
                html.append("        </div>")
                if description:
                    html.append(
                        f"        <p style='margin-bottom: 30px;'>{description}</p>"
                    )

        # --- Section: Simulation Logs ---
        html.append("")
        html.append(f"        <h2 id='sect-logs'>{logs_sect}. Simulation Logs</h2>")

        if self._log_path and os.path.exists(self._log_path):
            with open(self._log_path, "r") as lf:
                log_content = lf.read()

            if len(log_content) > 100000:
                log_content = (
                    log_content[:50000]
                    + "\n\n... [LOG TRUNCATED] ...\n\n"
                    + log_content[-50000:]
                )

            html.append("        <details>")
            html.append("            <summary>Click to view simulation log</summary>")
            html.append(f"            <pre>{log_content}</pre>")
            html.append("        </details>")
        else:
            html.append("        <p><em>No simulation log available.</em></p>")

        html.append("    </div>")
        html.append("")

        # --- Footer Banner ---
        html.append(
            "    <div style='text-align: center; margin: 30px auto 10px; padding: 15px; opacity: 0.7;'>"
        )
        html.append(
            "        <a href='https://seimsoft.github.io/OpenS/' target='_blank' style='text-decoration: none; color: #666; display: inline-flex; align-items: center; gap: 8px;'>"
        )
        html.append(
            "            <img src='launcher.png' alt='OpenS' style='height: 24px; width: 24px;'>"
        )
        html.append(
            "            <span style='font-size: 12px;'>Created by OpenS</span>"
        )
        html.append("        </a>")
        html.append("    </div>")
        html.append("")

        # --- Lightbox overlay ---
        html.append(
            "    <div class='lightbox-overlay' id='lightbox' onclick='closeLightbox(event)'>"
        )
        html.append(
            "        <span class='lightbox-close' onclick='closeLightbox(event)'>&times;</span>"
        )
        html.append("        <img id='lightbox-img' src='' alt='Fullscreen'>")
        html.append("    </div>")
        html.append("")

        # --- Lightbox JavaScript with pinch-zoom ---
        html.append("    <script>")
        html.append(
            """
        const overlay = document.getElementById('lightbox');
        const lbImg = document.getElementById('lightbox-img');

        let scale = 1;
        let translateX = 0, translateY = 0;
        let isDragging = false;
        let startX, startY;
        let initialPinchDist = null;
        let initialScale = 1;

        function openLightbox(img) {
            lbImg.src = img.src;
            scale = 1; translateX = 0; translateY = 0;
            applyTransform();
            overlay.classList.add('active');
            document.body.style.overflow = 'hidden';
        }

        function closeLightbox(e) {
            if (e.target === overlay || e.target.classList.contains('lightbox-close')) {
                overlay.classList.remove('active');
                document.body.style.overflow = '';
            }
        }

        function applyTransform() {
            lbImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
        }

        // Mouse wheel zoom
        overlay.addEventListener('wheel', function(e) {
            e.preventDefault();
            const delta = e.deltaY > 0 ? 0.9 : 1.1;
            scale = Math.min(Math.max(0.2, scale * delta), 20);
            applyTransform();
        }, { passive: false });

        // Mouse drag
        lbImg.addEventListener('mousedown', function(e) {
            e.preventDefault();
            isDragging = true;
            startX = e.clientX - translateX;
            startY = e.clientY - translateY;
            lbImg.style.cursor = 'grabbing';
        });

        window.addEventListener('mousemove', function(e) {
            if (!isDragging) return;
            translateX = e.clientX - startX;
            translateY = e.clientY - startY;
            applyTransform();
        });

        window.addEventListener('mouseup', function() {
            isDragging = false;
            lbImg.style.cursor = 'grab';
        });

        // Touch: pinch zoom + drag
        lbImg.addEventListener('touchstart', function(e) {
            if (e.touches.length === 2) {
                initialPinchDist = Math.hypot(
                    e.touches[0].clientX - e.touches[1].clientX,
                    e.touches[0].clientY - e.touches[1].clientY
                );
                initialScale = scale;
            } else if (e.touches.length === 1) {
                isDragging = true;
                startX = e.touches[0].clientX - translateX;
                startY = e.touches[0].clientY - translateY;
            }
        }, { passive: true });

        lbImg.addEventListener('touchmove', function(e) {
            if (e.touches.length === 2 && initialPinchDist) {
                e.preventDefault();
                const dist = Math.hypot(
                    e.touches[0].clientX - e.touches[1].clientX,
                    e.touches[0].clientY - e.touches[1].clientY
                );
                scale = Math.min(Math.max(0.2, initialScale * (dist / initialPinchDist)), 20);
                applyTransform();
            } else if (e.touches.length === 1 && isDragging) {
                translateX = e.touches[0].clientX - startX;
                translateY = e.touches[0].clientY - startY;
                applyTransform();
            }
        }, { passive: false });

        lbImg.addEventListener('touchend', function(e) {
            if (e.touches.length < 2) initialPinchDist = null;
            if (e.touches.length === 0) isDragging = false;
        });

        // ESC to close
        document.addEventListener('keydown', function(e) {
            if (e.key === 'Escape') {
                overlay.classList.remove('active');
                document.body.style.overflow = '';
            }
        });
        """
        )
        html.append("    </script>")
        html.append("</body>")
        html.append("</html>")

        with open(os.path.join(self.report_dir, "index.html"), "w") as f:
            f.write("\n".join(html))

    @staticmethod
    def _spec_color(scalar_str, spec_min, spec_max):
        """Return a CSS background color based on spec compliance."""
        try:
            # Parse numeric value from SI-formatted string
            val_str = scalar_str.strip()
            # Remove any trailing unit text
            parts = val_str.split()
            num_str = parts[0] if parts else val_str

            si_suffixes = {
                "y": 1e-24,
                "z": 1e-21,
                "a": 1e-18,
                "f": 1e-15,
                "p": 1e-12,
                "n": 1e-9,
                "u": 1e-6,
                "µ": 1e-6,
                "m": 1e-3,
                "k": 1e3,
                "K": 1e3,
                "M": 1e6,
                "G": 1e9,
                "T": 1e12,
            }

            multiplier = 1.0
            clean = num_str
            if clean and clean[-1] in si_suffixes:
                multiplier = si_suffixes[clean[-1]]
                clean = clean[:-1]

            val = float(clean) * multiplier

            has_min = spec_min and spec_min.strip()
            has_max = spec_max and spec_max.strip()

            if not has_min and not has_max:
                return "transparent"

            in_spec = True
            if has_min:
                if val < float(spec_min):
                    in_spec = False
            if has_max:
                if val > float(spec_max):
                    in_spec = False

            return "#d4edda" if in_spec else "#f8d7da"  # green / red

        except (ValueError, TypeError, IndexError):
            return "transparent"
generate()

Main execution sequence to drive reporting.

Source code in src/opens_suite/reporting/report_generator.py
def generate(self):
    """Main execution sequence to drive reporting."""
    print(f"Generating report in {self.report_dir}...")
    self._prepare_directory()
    self._load_and_snapshot()
    self._export_hierarchy()
    self._find_simulation_results()
    self._evaluate_and_plot()
    self._build_html()
    print(
        f"Report generated successfully: {os.path.join(self.report_dir, 'index.html')}"
    )

schematic_item

SchematicItem

Bases: QGraphicsObject

Source code in src/opens_suite/schematic_item.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
class SchematicItem(QGraphicsObject):
    openSubcircuitRequested = pyqtSignal(str)

    def __init__(self, svg_path, parent=None):
        super().__init__(parent)
        self.svg_path = svg_path
        self.setFlags(
            QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
            | QGraphicsItem.GraphicsItemFlag.ItemIsMovable
            | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
        )
        self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache)

        self.pins = {}  # id -> QPointF (relative to item)
        self.parameters = {}  # name -> value_str
        self.name = ""
        self.prefix = "X"
        self.connected_pins = []
        self.buttons = {}  # action -> QRectF

        # Simulation export settings
        self.save_voltage = True
        self.save_current = False

        # Template-based Text
        with open(svg_path, "r") as f:
            self.svg_template = f.read()

        # Instance-specific renderer
        self._renderer = QSvgRenderer()

        self.text_anchors = {}  # 'name': QPointF, 'value': QPointF
        self.label_items = {}  # template_str -> QGraphicsSimpleTextItem
        self.simulation_results = {}  # key -> float

        self._parse_pins()
        self._parse_labels()
        self._parse_parameters()
        self._parse_buttons()
        self._update_svg()

        theme_manager.themeChanged.connect(self.apply_theme)

    def reload_symbol(self):
        """Re-reads the SVG and updates pins/labels/visuals."""
        # 1. Cleanup existing generated children
        # Pins
        for pin_info in self.pins.values():
            if "item" in pin_info and pin_info["item"] in self.childItems():
                pin_info["item"].setParentItem(None)
                if self.scene():
                    self.scene().removeItem(pin_info["item"])
        self.pins.clear()

        # Labels
        for label_item in self.label_items.values():
            if label_item in self.childItems():
                label_item.setParentItem(None)
                if self.scene():
                    self.scene().removeItem(label_item)
        self.label_items.clear()

        # 2. Re-read and Re-parse
        try:
            with open(self.svg_path, "r") as f:
                self.svg_template = f.read()
            self._parse_pins()
            self._parse_labels()
            self._parse_parameters(overwrite=False)
            self._parse_buttons()
            self._update_svg()
            self._update_labels()
        except Exception as e:
            print(f"Error reloading symbol {self.svg_path}: {e}")

        self.update()

    def apply_theme(self):
        self._update_label_styles()
        self._update_labels()
        self._update_svg()
        self.update()

    def _update_label_styles(self):
        for item in self.label_items.values():
            cls = item.data(0)
            if cls == "label":
                item.setBrush(QBrush(theme_manager.get_color("font_label")))
            elif cls in ["value", "voltage"]:
                item.setBrush(QBrush(theme_manager.get_color("font_voltage")))
            else:
                item.setBrush(QBrush(theme_manager.get_color("font_default")))

    def boundingRect(self):
        if self._renderer.isValid():
            viewbox = self._renderer.viewBoxF()
            if not viewbox.isNull():
                return viewbox
            return QRectF(self._renderer.defaultSize())
        return QRectF(0, 0, 50, 50)

    def paint(self, painter, option, widget):
        # Draw Sanitized SVG
        if self._renderer.isValid():
            viewbox = self._renderer.viewBoxF()
            if viewbox.isNull():
                viewbox = QRectF(self._renderer.defaultSize())
            # Render SVG onto its native bounds (not the enlarged bounding box)
            self._renderer.render(painter, viewbox)

        # Draw Bounding Box if selected
        if self.isSelected():
            painter.setPen(
                QPen(theme_manager.get_color("selection"), 1, Qt.PenStyle.DashLine)
            )
            painter.setBrush(Qt.BrushStyle.NoBrush)
            painter.drawRect(self.boundingRect())

    def set_name(self, name):
        self.name = name
        self._update_labels()

    def set_parameter(self, name, value):
        # Accept case-insensitive parameter names. If a matching key exists
        # (any case), update that entry. Otherwise add the parameter using
        # the provided name so newly-created parameters are preserved.
        if name in self.parameters:
            self.parameters[name] = value
            self._update_labels()
            return

        # Try case-insensitive match
        lname = name.lower()
        for k in list(self.parameters.keys()):
            if k.lower() == lname:
                self.parameters[k] = value
                self._update_labels()
                return

        # No existing parameter found: add it
        self.parameters[name] = value
        self._update_labels()

        # If it was a NET_NAME change, we might need to refresh pins
        if name.upper() == "NET_NAME":
            self._update_svg()
            self._parse_pins()

    def _update_labels(self):
        # Update independent text items
        # Compute "index" which is name without prefix (e.g. "R1" -> "1")
        idx = self.name or ""
        if idx and self.prefix and idx.startswith(self.prefix):
            idx = idx[len(self.prefix) :]

        full_name = self.name or ""
        for template, item in self.label_items.items():
            text = template
            # Provide {name} (full name, e.g. R1) and {index} (e.g. 1)
            text = text.replace("{name}", full_name)
            text = text.replace("{index}", idx)
            text = text.replace("{fullName}", full_name)
            text = text.replace("{Name}", full_name)
            # Replace parameter placeholders case-insensitively. Parameters
            # are stored normalized (upper-case) by _parse_parameters, but
            # templates may use any case like {modelname} or {MODELNAME}.
            import re

            for k, v in self.parameters.items():
                try:
                    pattern = re.compile(
                        r"\{" + re.escape(k) + r"\}", flags=re.IGNORECASE
                    )
                    text = pattern.sub(str(v), text)
                except re.error:
                    # Fallback to simple replace if regex fails for some reason
                    text = text.replace(f"{{{k}}}", str(v))
            # Normalized placeholder
            # Replace generic {value} placeholder (case-insensitive)
            if self.parameters:
                try:
                    val_pattern = re.compile(r"\{value\}", flags=re.IGNORECASE)
                    text = val_pattern.sub(str(list(self.parameters.values())[0]), text)
                except re.error:
                    if "{value}" in text:
                        text = text.replace(
                            "{value}", str(list(self.parameters.values())[0])
                        )

            # Back-annotation placeholders (e.g., {i(v1)}, {@r1[i]})
            from opens_suite.spice_parser import SpiceRawParser

            placeholders = re.findall(r"\{(.*?)\}", text)
            for p in placeholders:
                # Resolve using smart helper
                hint = (
                    "i"
                    if p.lower().endswith(":i") or p.lower().startswith("i(")
                    else "v"
                )
                val = SpiceRawParser.find_signal(
                    self.simulation_results, p, type_hint=hint
                )

                if val is not None:
                    # Format with unit
                    formatted = self._format_value(val)
                    text = text.replace(f"{{{p}}}", formatted)
                elif "(" in p or "@" in p or ":" in p:
                    # If it looks like a simulation variable but we don't have it yet,
                    # just keep the placeholder or clear it?
                    # Keep it for now.
                    pass
                    # we could hide it or show empty. Let's show empty or "?"
                    text = text.replace(f"{{{p}}}", "--")

            item.setText(text)

            # Apply alignment based on stored metadata
            orig_x = item.data(1)
            orig_y = item.data(2)
            anchor = item.data(3)

            if orig_x is not None and orig_y is not None:
                # SVG y is baseline, Qt SimpleTextItem y is top.
                rect = item.boundingRect()

                new_x = orig_x
                if anchor == "end":
                    new_x = orig_x - rect.width()
                elif anchor == "middle":
                    new_x = orig_x - rect.width() / 2

                # Baseline correction: Shift up by roughly 75% of the height
                # to make the text appear on the baseline.
                item.setPos(new_x, orig_y - rect.height() * 0.75)

    def _format_value(self, val):
        unit = "A"  # Default for currents
        abs_val = abs(val)
        if abs_val == 0:
            return "0"

        if abs_val >= 1e6:
            return f"{val/1e6:.2f}Meg{unit}"
        elif abs_val >= 1e3:
            return f"{val/1e3:.2f}k{unit}"
        elif abs_val >= 1:
            return f"{val:.2f}{unit}"
        elif abs_val >= 1e-3:
            return f"{val*1e3:.2f}m{unit}"
        elif abs_val >= 1e-6:
            return f"{val*1e6:.2f}u{unit}"
        elif abs_val >= 1e-9:
            return f"{val*1e9:.2f}n{unit}"
        elif abs_val >= 1e-12:
            return f"{val*1e12:.2f}p{unit}"
        else:
            return f"{val:.2e}{unit}"

    def _update_svg(self):
        # Remove <text> elements from template to avoid doubling/clipping.
        # Use ET to be robust vs. structure and namespaces.
        try:
            root = ET.fromstring(self.svg_template)
            line_color = theme_manager.get_color("line_default").name()

            import re

            def replace_black(s):
                if not s:
                    return s
                # Replace 'black' as a whole word
                s = re.sub(r"\bblack\b", line_color, s, flags=re.IGNORECASE)
                # Replace #000000 and #000 precisely (not followed by another hex digit)
                s = re.sub(
                    r"#000000(?![0-9a-fA-F])", line_color, s, flags=re.IGNORECASE
                )
                s = re.sub(r"#000(?![0-9a-fA-F])", line_color, s, flags=re.IGNORECASE)
                return s

            for elem in root.iter():
                # 1. Handle <style> tags
                if elem.tag.split("}")[-1] == "style":
                    if elem.text:
                        elem.text = replace_black(elem.text)

                # 2. Handle inline attributes
                for attr in ["stroke", "fill", "style"]:
                    if attr in elem.attrib:
                        elem.attrib[attr] = replace_black(elem.attrib[attr])

                # 3. Remove <text> elements
                to_remove = []
                for child in elem:
                    if child.tag.split("}")[-1] == "text":
                        to_remove.append(child)
                for child in to_remove:
                    elem.remove(child)

            content = ET.tostring(root, encoding="unicode")

            self.prepareGeometryChange()
            success = self._renderer.load(QByteArray(content.encode("utf-8")))
            if not success:
                # Fallback
                self._renderer.load(QByteArray(self.svg_template.encode("utf-8")))
        except Exception as e:
            print(f"DEBUG: SVG Update Error: {e}")
            self._renderer.load(QByteArray(self.svg_template.encode("utf-8")))

        self._update_labels()
        self.update()

    def rotate_item(self):
        self.setRotation(self.rotation() + 90)

    def _parse_parameters(self, overwrite=True):
        try:
            tree = ET.parse(self.svg_path)
            root = tree.getroot()

            for elem in root.iter():
                # Params
                if "param" in elem.tag:
                    name = elem.get("name")
                    value = elem.get("value")
                    if name:
                        # Normalize parameter keys to upper-case to have a
                        # consistent internal representation.
                        name_up = name.upper()
                        if overwrite or name_up not in self.parameters:
                            self.parameters[name_up] = value or ""

                elif "symbol" in elem.tag:
                    prefix = elem.get("prefix")
                    if prefix:
                        self.prefix = prefix

                elif "spice" in elem.tag or "xyce" in elem.tag:
                    template = elem.get("template")
                    if template:
                        self.spice_template = template

        except Exception as e:
            print(f"Error parsing SVG parameters for {self.svg_path}: {e}")

    def set_connected_pins(self, pin_ids):
        self.connected_pins = pin_ids
        for pid, info in self.pins.items():
            if "item" in info:
                info["item"].setVisible(pid not in pin_ids)
        self.update()

    def _parse_labels(self):
        try:
            tree = ET.parse(self.svg_path)
            root = tree.getroot()

            for elem in root.iter():
                # Check for <text> tags
                if elem.tag.endswith("text"):
                    template = elem.text or ""
                    if not template:
                        continue

                    x = float(elem.get("x", 0))
                    y = float(elem.get("y", 0))
                    cls = elem.get("class", "")

                    # Create independent text item
                    item = QGraphicsSimpleTextItem(self)
                    item.setPos(x, y)

                    # Style parsing
                    font_size = 8
                    fill_color = None

                    # 1. Check direct attributes
                    if elem.get("font-size"):
                        try:
                            font_size = int(float(elem.get("font-size")))
                        except:
                            pass
                    if elem.get("fill"):
                        fill_color = QColor(elem.get("fill"))

                    # 2. Check style attribute (e.g., style="font-size: 12px; fill: red;")
                    style = elem.get("style", "")
                    if style:
                        import re

                        fs_match = re.search(r"font-size:\s*(\d+)px", style)
                        if fs_match:
                            try:
                                font_size = int(fs_match.group(1))
                            except:
                                pass
                        fill_match = re.search(r"fill:\s*([^;]+)", style)
                        if fill_match:
                            try:
                                fill_color = QColor(fill_match.group(1).strip())
                            except:
                                pass

                    # Apply font
                    item.setFont(QFont("Arial", font_size))

                    # 3. Store metadata for alignment in _update_labels
                    anchor = elem.get("text-anchor", "start")
                    item.setData(1, x)  # orig_x
                    item.setData(2, y)  # orig_y
                    item.setData(3, anchor)

                    # Apply color: prefer explicit SVG fill, then theme-by-class
                    if fill_color and fill_color.isValid():
                        item.setBrush(QBrush(fill_color))
                    else:
                        if cls == "label":
                            item.setBrush(QBrush(theme_manager.get_color("font_label")))
                        elif cls == "value" or cls == "voltage":
                            item.setBrush(
                                QBrush(theme_manager.get_color("font_voltage"))
                            )
                        else:
                            item.setBrush(
                                QBrush(theme_manager.get_color("font_default"))
                            )

                    item.setData(0, cls)  # Store class for theme updates
                    self.label_items[template] = item

        except Exception as e:
            print(f"Error parsing SVG labels for {self.svg_path}: {e}")

    def _parse_pins(self):
        try:
            # Parse from the TEMPLATE which has params already substituted
            root = ET.fromstring(self.svg_template)

            # Namespace map for finding elements
            ns = {
                "svg": "http://www.w3.org/2000/svg",
                "opens": "http://opens-schematic.org",
            }

            # Cleanup existing visual pins if any
            for pin_info in self.pins.values():
                if "item" in pin_info and pin_info["item"] in self.childItems():
                    pin_info["item"].setParentItem(None)
                    if self.scene():
                        self.scene().removeItem(pin_info["item"])
            self.pins.clear()

            # Find all circles with class='pin'
            for elem in root.iter():
                # Check for class="pin"
                if elem.get("class") == "pin":
                    pin_id = elem.get("id")
                    cx = float(elem.get("cx", 0))
                    cy = float(elem.get("cy", 0))
                    net_override = elem.get("net")

                    # Create visual pin (Red Rectangle)
                    pin_size = 6
                    rect = QGraphicsRectItem(
                        cx - pin_size / 2, cy - pin_size / 2, pin_size, pin_size, self
                    )
                    rect.setBrush(QBrush(QColor("red")))
                    rect.setPen(QPen(Qt.PenStyle.NoPen))
                    rect.setData(Qt.ItemDataRole.UserRole, pin_id)  # Identify the pin

                    self.pins[pin_id] = {"pos": QPointF(cx, cy), "item": rect}
                    if net_override is not None:
                        self.pins[pin_id]["net_override"] = net_override

            # Legacy/Namespace check for opens:pin overrides
            for elem in root.iter():
                if elem.tag.split("}")[-1] == "pin":
                    pin_id = elem.get("id")
                    net_override = elem.get("net")
                    if pin_id in self.pins and net_override is not None:
                        self.pins[pin_id]["net_override"] = net_override

        except Exception as e:
            print(f"Error parsing SVG pins for {self.svg_path}: {e}")

    def _parse_buttons(self):
        try:
            tree = ET.parse(self.svg_path)
            root = tree.getroot()
            for elem in root.iter():
                action = elem.get("{http://opens-schematic.org}action")
                if action:
                    x = float(elem.get("x", 0))
                    y = float(elem.get("y", 0))
                    w = float(elem.get("width", 0))
                    h = float(elem.get("height", 0))
                    self.buttons[action] = QRectF(x, y, w, h)
        except Exception as e:
            print(f"Error parsing SVG buttons for {self.svg_path}: {e}")

    def mousePressEvent(self, event):
        pos = event.pos()
        for action, rect in self.buttons.items():
            if rect.contains(pos):
                self._handle_button_click(action)
                event.accept()
                return
        super().mousePressEvent(event)

    def _handle_button_click(self, action):
        if action == "run":
            from opens_suite.design_script_dialog import DesignScriptDialog

            DesignScriptDialog.execute_and_apply(self)

    def itemChange(self, change, value):
        if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
            # Snap to grid
            grid_size = 10
            new_pos = value
            x = round(new_pos.x() / grid_size) * grid_size
            y = round(new_pos.y() / grid_size) * grid_size
            return QPointF(x, y)

        return super().itemChange(change, value)

    def mouseDoubleClickEvent(self, event):
        """Open a dedicated model editor when this is a model symbol.

        Detect model symbols by filename or presence of ARGS parameter.
        """
        try:
            # Special case for Python Model: Resolve PYTHONPATH/MODULE and open script
            if self.svg_path and "python_model" in self.svg_path.lower():
                import os

                ppath = self.parameters.get("PYTHONPATH", ".")
                module = self.parameters.get("MODULE", "controller")
                cls_name = self.parameters.get("CLASS", "Controller")

                # Resolve $SVG to the directory of the current schematic
                sch_dir = ""
                try:
                    view = self.scene().views()[0]
                    if hasattr(view, "filename") and view.filename:
                        sch_dir = os.path.dirname(view.filename)
                except Exception:
                    pass

                ppath = ppath.replace("$SVG", sch_dir)
                ppath = os.path.expandvars(ppath)
                abs_ppath = os.path.abspath(ppath)
                script_path = os.path.join(abs_ppath, f"{module}.py")

                if not os.path.exists(script_path):
                    # Ensure directory exists
                    os.makedirs(abs_ppath, exist_ok=True)
                    # Create template
                    template = f"""#
# Python Model (16 Pins available)
#

class {cls_name}:
    def __init__(self):
        \"\"\"Setup input/outputs\"\"\"
        self.VDD = Input(0)  # 3.3 volt
        self.VSS = Input(15)

        self.VOUT = ResistorOutput(10, 10.0, self.VDD, self.VSS)
        self.VOUT.set_pwm(0.5, 1 / 100e3)

    def update(self, time):
        # Update each time point
        pass
"""
                    with open(script_path, "w") as f:
                        f.write(template)

                # Open with configured editor
                from PyQt6.QtCore import QSettings

                settings = QSettings("OpenS", "OpenS")
                editor_cmd = settings.value("editor_command", "code '%s'")

                try:
                    import shlex
                    import subprocess

                    if "%s" in editor_cmd:
                        cmd_str = editor_cmd.replace("%s", script_path)
                    else:
                        cmd_str = f"{editor_cmd} '{script_path}'"
                    args = shlex.split(cmd_str)
                    subprocess.Popen(args)
                except Exception as e:
                    from PyQt6.QtWidgets import QMessageBox

                    QMessageBox.critical(None, "Error", f"Failed to open script: {e}")
                return

            is_model = False
            if self.svg_path and self.svg_path.lower().endswith("model.svg"):
                is_model = True
            if not is_model and "ARGS" in self.parameters:
                is_model = True

            if is_model:
                # Lazy import to avoid cycles / startup cost
                from opens_suite.model_editor import ModelEditorDialog

                # Build case-insensitive initial parameter map
                param_map = {k.lower(): v for k, v in self.parameters.items()}
                initial = {
                    "MODELNAME": param_map.get("modelname", ""),
                    "TYPE": param_map.get("type", "NMOS"),
                    "ARGS": param_map.get("args", ""),
                }
                dlg = ModelEditorDialog(None, initial=initial)
                if dlg.exec() == QDialog.DialogCode.Accepted:
                    res = dlg.get_result()
                    # Apply results to parameters
                    # Use set_parameter to update labels/UI
                    self.set_parameter("MODELNAME", res.get("MODELNAME", ""))
                    self.set_parameter("TYPE", res.get("TYPE", ""))
                    self.set_parameter("ARGS", res.get("ARGS", ""))

            is_script = False
            is_stimuli = False

            if self.svg_path:
                lower_path = self.svg_path.lower()
                if (
                    "design_script.svg" in lower_path
                    or "design_script/symbol.svg" in lower_path
                ):
                    is_script = True
                elif (
                    "stimuli_generator.svg" in lower_path
                    or "stimuli_generator/symbol.svg" in lower_path
                ):
                    is_stimuli = True

            if not is_script and not is_stimuli and "SCRIPT" in self.parameters:
                is_script = True  # fallback if they use an arbitrary script symbol

            if is_script or is_stimuli:
                from opens_suite.design_script_dialog import DesignScriptDialog

                DesignScriptDialog.open_notebook(self)

            # drill down into subcircuits
            model_param = self.parameters.get("MODEL")
            if model_param and (
                model_param.endswith(".sch") or model_param.endswith(".sch.svg")
            ):
                import os

                dir_name = os.path.dirname(self.svg_path)
                base_sch = model_param.replace(".sch", "")
                sch_paths_to_try = [
                    os.path.join(dir_name, f"{base_sch}.svg"),
                    os.path.join(dir_name, f"{base_sch}.sch.svg"),
                    os.path.join(dir_name, "schematic.svg"),
                    os.path.join(dir_name, "schematic.sch.svg"),
                ]
                if self.svg_path.endswith(".sym.svg"):
                    sch_paths_to_try.append(
                        self.svg_path.replace(".sym.svg", ".sch.svg")
                    )
                    sch_paths_to_try.append(self.svg_path.replace(".sym.svg", ".svg"))

                for sch_path in sch_paths_to_try:
                    if os.path.exists(sch_path):
                        self.openSubcircuitRequested.emit(sch_path)
                        return

        except Exception as e:
            print(f"Item editor failed: {e}")
mouseDoubleClickEvent(event)

Open a dedicated model editor when this is a model symbol.

Detect model symbols by filename or presence of ARGS parameter.

Source code in src/opens_suite/schematic_item.py
    def mouseDoubleClickEvent(self, event):
        """Open a dedicated model editor when this is a model symbol.

        Detect model symbols by filename or presence of ARGS parameter.
        """
        try:
            # Special case for Python Model: Resolve PYTHONPATH/MODULE and open script
            if self.svg_path and "python_model" in self.svg_path.lower():
                import os

                ppath = self.parameters.get("PYTHONPATH", ".")
                module = self.parameters.get("MODULE", "controller")
                cls_name = self.parameters.get("CLASS", "Controller")

                # Resolve $SVG to the directory of the current schematic
                sch_dir = ""
                try:
                    view = self.scene().views()[0]
                    if hasattr(view, "filename") and view.filename:
                        sch_dir = os.path.dirname(view.filename)
                except Exception:
                    pass

                ppath = ppath.replace("$SVG", sch_dir)
                ppath = os.path.expandvars(ppath)
                abs_ppath = os.path.abspath(ppath)
                script_path = os.path.join(abs_ppath, f"{module}.py")

                if not os.path.exists(script_path):
                    # Ensure directory exists
                    os.makedirs(abs_ppath, exist_ok=True)
                    # Create template
                    template = f"""#
# Python Model (16 Pins available)
#

class {cls_name}:
    def __init__(self):
        \"\"\"Setup input/outputs\"\"\"
        self.VDD = Input(0)  # 3.3 volt
        self.VSS = Input(15)

        self.VOUT = ResistorOutput(10, 10.0, self.VDD, self.VSS)
        self.VOUT.set_pwm(0.5, 1 / 100e3)

    def update(self, time):
        # Update each time point
        pass
"""
                    with open(script_path, "w") as f:
                        f.write(template)

                # Open with configured editor
                from PyQt6.QtCore import QSettings

                settings = QSettings("OpenS", "OpenS")
                editor_cmd = settings.value("editor_command", "code '%s'")

                try:
                    import shlex
                    import subprocess

                    if "%s" in editor_cmd:
                        cmd_str = editor_cmd.replace("%s", script_path)
                    else:
                        cmd_str = f"{editor_cmd} '{script_path}'"
                    args = shlex.split(cmd_str)
                    subprocess.Popen(args)
                except Exception as e:
                    from PyQt6.QtWidgets import QMessageBox

                    QMessageBox.critical(None, "Error", f"Failed to open script: {e}")
                return

            is_model = False
            if self.svg_path and self.svg_path.lower().endswith("model.svg"):
                is_model = True
            if not is_model and "ARGS" in self.parameters:
                is_model = True

            if is_model:
                # Lazy import to avoid cycles / startup cost
                from opens_suite.model_editor import ModelEditorDialog

                # Build case-insensitive initial parameter map
                param_map = {k.lower(): v for k, v in self.parameters.items()}
                initial = {
                    "MODELNAME": param_map.get("modelname", ""),
                    "TYPE": param_map.get("type", "NMOS"),
                    "ARGS": param_map.get("args", ""),
                }
                dlg = ModelEditorDialog(None, initial=initial)
                if dlg.exec() == QDialog.DialogCode.Accepted:
                    res = dlg.get_result()
                    # Apply results to parameters
                    # Use set_parameter to update labels/UI
                    self.set_parameter("MODELNAME", res.get("MODELNAME", ""))
                    self.set_parameter("TYPE", res.get("TYPE", ""))
                    self.set_parameter("ARGS", res.get("ARGS", ""))

            is_script = False
            is_stimuli = False

            if self.svg_path:
                lower_path = self.svg_path.lower()
                if (
                    "design_script.svg" in lower_path
                    or "design_script/symbol.svg" in lower_path
                ):
                    is_script = True
                elif (
                    "stimuli_generator.svg" in lower_path
                    or "stimuli_generator/symbol.svg" in lower_path
                ):
                    is_stimuli = True

            if not is_script and not is_stimuli and "SCRIPT" in self.parameters:
                is_script = True  # fallback if they use an arbitrary script symbol

            if is_script or is_stimuli:
                from opens_suite.design_script_dialog import DesignScriptDialog

                DesignScriptDialog.open_notebook(self)

            # drill down into subcircuits
            model_param = self.parameters.get("MODEL")
            if model_param and (
                model_param.endswith(".sch") or model_param.endswith(".sch.svg")
            ):
                import os

                dir_name = os.path.dirname(self.svg_path)
                base_sch = model_param.replace(".sch", "")
                sch_paths_to_try = [
                    os.path.join(dir_name, f"{base_sch}.svg"),
                    os.path.join(dir_name, f"{base_sch}.sch.svg"),
                    os.path.join(dir_name, "schematic.svg"),
                    os.path.join(dir_name, "schematic.sch.svg"),
                ]
                if self.svg_path.endswith(".sym.svg"):
                    sch_paths_to_try.append(
                        self.svg_path.replace(".sym.svg", ".sch.svg")
                    )
                    sch_paths_to_try.append(self.svg_path.replace(".sym.svg", ".svg"))

                for sch_path in sch_paths_to_try:
                    if os.path.exists(sch_path):
                        self.openSubcircuitRequested.emit(sch_path)
                        return

        except Exception as e:
            print(f"Item editor failed: {e}")
reload_symbol()

Re-reads the SVG and updates pins/labels/visuals.

Source code in src/opens_suite/schematic_item.py
def reload_symbol(self):
    """Re-reads the SVG and updates pins/labels/visuals."""
    # 1. Cleanup existing generated children
    # Pins
    for pin_info in self.pins.values():
        if "item" in pin_info and pin_info["item"] in self.childItems():
            pin_info["item"].setParentItem(None)
            if self.scene():
                self.scene().removeItem(pin_info["item"])
    self.pins.clear()

    # Labels
    for label_item in self.label_items.values():
        if label_item in self.childItems():
            label_item.setParentItem(None)
            if self.scene():
                self.scene().removeItem(label_item)
    self.label_items.clear()

    # 2. Re-read and Re-parse
    try:
        with open(self.svg_path, "r") as f:
            self.svg_template = f.read()
        self._parse_pins()
        self._parse_labels()
        self._parse_parameters(overwrite=False)
        self._parse_buttons()
        self._update_svg()
        self._update_labels()
    except Exception as e:
        print(f"Error reloading symbol {self.svg_path}: {e}")

    self.update()

spice_parser

SpiceRawParser

Source code in src/opens_suite/spice_parser.py
class SpiceRawParser:
    def __init__(self, filepath):
        self.filepath = filepath
        self.variables = []  # List of (index, name, type)
        self.plots = {}  # plotname -> data dict {var_name: [values]}
        self.no_variables = 0
        self.no_points = 0

    def parse(self):
        if not os.path.exists(self.filepath):
            return None

        self.plots = {}
        with open(self.filepath, "rb") as f:
            while True:
                header = {}
                self.variables = []
                line = ""
                header_finished = False

                # Check for EOF
                first_char = f.read(1)
                if not first_char:
                    break
                f.seek(-1, os.SEEK_CUR)

                while True:
                    char = f.read(1)
                    if not char:
                        header_finished = True
                        break
                    if char == b"\n":
                        decoded_line = line.strip()
                        if decoded_line.startswith("Binary:"):
                            break
                        if decoded_line.startswith("Values:"):
                            break

                        if ":" in decoded_line:
                            parts = decoded_line.split(":", 1)
                            if len(parts) == 2:
                                key, val = parts
                                header[key.strip()] = val.strip()

                        if decoded_line.startswith("Variables:"):
                            no_vars = int(header.get("No. Variables", 0))
                            for i in range(no_vars):
                                v_line = ""
                                while True:
                                    v_char = f.read(1)
                                    if not v_char or v_char == b"\n":
                                        break
                                    v_line += v_char.decode("ascii", errors="ignore")
                                parts = v_line.strip().split()
                                if len(parts) >= 3:
                                    self.variables.append(
                                        (int(parts[0]), parts[1], parts[2])
                                    )
                                elif len(parts) == 2:
                                    self.variables.append(
                                        (int(parts[0]), parts[1], "voltage")
                                    )
                        line = ""
                    else:
                        line += char.decode("ascii", errors="ignore")

                if header_finished and not header:
                    break

                plotname = header.get("Plotname", f"Plot_{len(self.plots)}")
                no_points = int(header.get("No. Points", 0) or 0)
                no_vars = int(header.get("No. Variables", 0) or 0)

                if no_vars == 0:
                    break

                # Read Data
                results = {}
                for _, name, _ in self.variables:
                    results[name] = []

                if not self.variables:
                    break

                is_complex = "complex" in header.get("Flags", "").lower()
                field_size = 16 if is_complex else 8
                fmt = "dd" if is_complex else "d"

                for p in range(no_points):
                    for i in range(no_vars):
                        chunk = f.read(field_size)
                        if not chunk:
                            break
                        val = struct.unpack(fmt, chunk)
                        v_name = self.variables[i][1]
                        if is_complex:
                            results[v_name].append(complex(val[0], val[1]))
                        else:
                            results[v_name].append(val[0])

                self.plots[plotname] = results

                # Skip any trailing newlines
                while True:
                    curr = f.tell()
                    c = f.read(1)
                    if not c:
                        break
                    if c not in (b"\n", b"\r"):
                        f.seek(curr)
                        break

        return self.plots

    @staticmethod
    def find_signal(data, name, type_hint=None):
        """Helper to find a signal in a data dictionary using various naming conventions.
        name: the base name (e.g. 'vin' or 'r1')
        type_hint: 'v' for voltage, 'i' for current
        """
        if not data:
            return None

        # Try exact match first
        if name in data:
            return data[name]

        nl = name.lower()
        # Try case-insensitive exact map
        for k in data.keys():
            if k.lower() == nl:
                return data[k]

        # Try common SPICE/Xyce prefixes
        prefixes = []
        if type_hint == "v":
            prefixes = ["v("]
        elif type_hint == "i":
            prefixes = ["i(", "@"]
        else:
            prefixes = ["v(", "i(", "@"]

        for p in prefixes:
            target = f"{p}{nl})" if p.endswith("(") else f"{p}{nl}"
            for k in data.keys():
                kl = k.lower()
                if (
                    kl == target
                    or kl.startswith(target + "[")
                    or kl.replace("#branch", "") == nl
                ):
                    return data[k]
                if p == "i(" and (kl == f"{nl}:i" or kl == f"i({nl})"):
                    return data[k]

        # Last resort: if name contains :i or resembles a current
        if nl.endswith(":i"):
            base = nl[:-2]
            return SpiceRawParser.find_signal(data, base, "i")

        return None

    def get_op_results(self):
        for name, data in self.plots.items():
            if "Operating Point" in name:
                return {k: v[0] for k, v in data.items() if len(v) > 0}
        if len(self.plots) == 1:
            data = list(self.plots.values())[0]
            return {k: v[0] for k, v in data.items() if len(v) > 0}
        return None
find_signal(data, name, type_hint=None) staticmethod

Helper to find a signal in a data dictionary using various naming conventions. name: the base name (e.g. 'vin' or 'r1') type_hint: 'v' for voltage, 'i' for current

Source code in src/opens_suite/spice_parser.py
@staticmethod
def find_signal(data, name, type_hint=None):
    """Helper to find a signal in a data dictionary using various naming conventions.
    name: the base name (e.g. 'vin' or 'r1')
    type_hint: 'v' for voltage, 'i' for current
    """
    if not data:
        return None

    # Try exact match first
    if name in data:
        return data[name]

    nl = name.lower()
    # Try case-insensitive exact map
    for k in data.keys():
        if k.lower() == nl:
            return data[k]

    # Try common SPICE/Xyce prefixes
    prefixes = []
    if type_hint == "v":
        prefixes = ["v("]
    elif type_hint == "i":
        prefixes = ["i(", "@"]
    else:
        prefixes = ["v(", "i(", "@"]

    for p in prefixes:
        target = f"{p}{nl})" if p.endswith("(") else f"{p}{nl}"
        for k in data.keys():
            kl = k.lower()
            if (
                kl == target
                or kl.startswith(target + "[")
                or kl.replace("#branch", "") == nl
            ):
                return data[k]
            if p == "i(" and (kl == f"{nl}:i" or kl == f"i({nl})"):
                return data[k]

    # Last resort: if name contains :i or resembles a current
    if nl.endswith(":i"):
        base = nl[:-2]
        return SpiceRawParser.find_signal(data, base, "i")

    return None

stimuli

stimuli

Stimuli

Stimuli helper class for generating PWL sources and

component networks for Xyce.

Source code in src/opens_suite/stimuli/stimuli.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
class Stimuli:
    """Stimuli helper class for generating PWL sources and

    component networks for Xyce.

    """

    def __init__(self):

        self._data = {}

        self._t = None

        self._components = {}  # (Node1, Node2) -> Component network

        self._currents = {}  # (n_plus, n_minus) -> expression/scalar

    def __setitem__(self, key, value):

        if key == "t":

            self._t = np.array(value)

            return

        # Bus support: stimuli["name<msb:lsb>"] = [v_lsb, ..., v_msb]

        if isinstance(key, str):

            match = re.fullmatch(
                r"(?P<base>[^<>]+)<(?P<msb>-?\d+):(?P<lsb>-?\d+)>", key
            )

            if match is not None:
                base = match.group("base")
                msb = int(match.group("msb"))
                lsb = int(match.group("lsb"))
                width = abs(msb - lsb) + 1

                if not isinstance(value, (list, tuple, np.ndarray)):
                    raise TypeError(
                        f"Bus assignment for '{key}' must be a list/tuple/array of length {width}."
                    )

                values = list(value)
                if len(values) != width:
                    raise ValueError(
                        f"Bus assignment for '{key}' must have length {width}, got {len(values)}."
                    )
                step = 1 if msb >= lsb else -1

                for offset, v in enumerate(values):

                    idx = lsb + offset * step

                    bit_node = f"{base}<{idx}>"

                    self[bit_node] = v

                return

        # Ensure key is a tuple: (n_in, n_out)

        if isinstance(key, tuple):

            if len(key) != 2:

                raise ValueError(
                    "Key tuple must have exactly two nodes, e.g. ('VOUT', '0')."
                )

            nodes = key

        else:

            nodes = (key, "0")

        # Distinguish component networks from raw stimulus data

        if isinstance(value, BaseElement):

            self._components[nodes] = value

        else:

            self._data[nodes] = value

    def __getitem__(self, key):

        if key == "t":

            return self._t

        if isinstance(key, tuple):

            return self._data.get(key)

        # For node lookups, return a proxy so users can do:

        #   stimuli["node"] << 1e-6

        #   stimuli["node"] >> 1e-6

        return _NodeRef(self, str(key))

    @staticmethod
    def vdc(dc, ac=None):

        return VdcStimulus(dc, ac=ac)

    @staticmethod
    def vsin(f, amp=1.0, offset=0.0, phase=0.0, ac=None):

        return SinStimulus(f, amp, offset, phase, ac=ac)

    @staticmethod
    def vpulse(v1, v2, td=0, tr=0, tf=0, pw=1, per=2, ac=None):

        return PulseStimulus(v1, v2, td, tr, tf, pw, per, ac=ac)

    @staticmethod
    def res(value):

        return Resistor(value)

    @staticmethod
    def cap(value):

        return Capacitor(value)

    @staticmethod
    def ind(value):

        return Inductor(value)

    def save_json(self, filename, format="spice"):

        if format == "spice":

            runset = self.generate_spice()

        else:

            runset = self.generate_spectre()

        output_data = {"runset": runset}

        with open(filename, "w") as f:

            json.dump(output_data, f, indent=2)

        print(f"Stimuli saved to {filename}")

    def save(self, filename):

        # Backwards-compatible helper: write JSON with a single "runset" key.

        self.save_json(filename, format="spice")

    def save_ascii(self, filename, format="spice"):

        if format == "spice":

            runset = self.generate_spice()

        else:

            runset = self.generate_spectre()

        with open(filename, "w") as f:

            f.write(runset)

        print(f"Stimuli saved to {filename}")

    def _iter_named_sources(self):

        source_counter = 0

        for nodes, value in self._data.items():

            source_counter += 1

            n_in, n_out = nodes

            source_name = (
                f"V_STIM_{source_counter}_{n_in}"
                if n_out == "0"
                else f"V_STIM_{source_counter}_{n_in}_{n_out}"
            )

            yield source_name, n_in, n_out, value

    def _iter_named_currents(self):

        current_counter = 0

        for (n_plus, n_minus), value in self._currents.items():

            current_counter += 1

            # Include node names in the instance name for easier debugging

            name = f"I_STIM_{current_counter}_{n_plus}_TO_{n_minus}"

            yield name, n_plus, n_minus, value

    def generate_spice(self) -> str:
        """Generate a SPICE/Xyce-style netlist string."""

        netlist_lines = []

        # 1) Sources

        for source_name, n_in, n_out, value in self._iter_named_sources():

            dc_str = ""

            ac_str = ""

            if isinstance(value, StimulusExpression):

                if value.dc is not None:

                    dc_str = f" DC {value.dc}"

                if value.ac is not None:

                    ac_str = f" AC {value.ac}"

                native_spice = value.to_spice()

                if native_spice:

                    netlist_lines.append(
                        f"{source_name} {n_in} {n_out}{dc_str}{ac_str} {native_spice}"
                    )

                    continue

                if self._t is None:

                    netlist_lines.append(
                        f"{source_name} {n_in} {n_out}{dc_str}{ac_str}"
                    )

                    continue

                expr_val = value.evaluate(self._t)

            else:

                expr_val = value

            if np.isscalar(expr_val) or (
                hasattr(expr_val, "size") and np.array(expr_val).size == 1
            ):

                val = float(expr_val)

                netlist_lines.append(
                    f"{source_name} {n_in} {n_out}{dc_str}{ac_str} {val}"
                )

                continue

            if self._t is None:

                netlist_lines.append(f"{source_name} {n_in} {n_out}{dc_str}{ac_str}")

                continue

            eval_array = np.array(expr_val)

            if eval_array.size != self._t.size:

                continue

            pairs = [f"{t} {v}" for t, v in zip(self._t, eval_array)]

            pwl_str = " ".join(pairs)

            netlist_lines.append(
                f"{source_name} {n_in} {n_out}{dc_str}{ac_str} PWL({pwl_str})"
            )

        # 2) Passive networks

        for nodes, network in self._components.items():

            n_in, n_out = nodes

            netlist_lines.append(f"* --- Network between {n_in} and {n_out} ---")

            netlist_lines.extend(network.generate_netlist(n_in, n_out))

        # 3) Current sources

        for name, n_plus, n_minus, value in self._iter_named_currents():

            if isinstance(value, StimulusExpression):

                dc_str = ""

                ac_str = ""

                if value.dc is not None:

                    dc_str = f" DC {value.dc}"

                if value.ac is not None:

                    ac_str = f" AC {value.ac}"

                native = value.to_spice()

                if native:

                    netlist_lines.append(
                        f"{name} {n_plus} {n_minus}{dc_str}{ac_str} {native}"
                    )

                    continue

                if self._t is None:

                    # no time vector: just DC/AC if any

                    netlist_lines.append(f"{name} {n_plus} {n_minus}{dc_str}{ac_str}")

                    continue

                expr_val = value.evaluate(self._t)

            else:

                expr_val = value

            if np.isscalar(expr_val) or (
                hasattr(expr_val, "size") and np.array(expr_val).size == 1
            ):

                netlist_lines.append(f"{name} {n_plus} {n_minus} {float(expr_val)}")

                continue

            if self._t is None:

                netlist_lines.append(f"{name} {n_plus} {n_minus}")

                continue

            eval_array = np.array(expr_val)

            if eval_array.size != self._t.size:

                continue

            pairs = [f"{t} {v}" for t, v in zip(self._t, eval_array)]

            pwl_str = " ".join(pairs)

            netlist_lines.append(f"{name} {n_plus} {n_minus} PWL({pwl_str})")

        return "\n".join(netlist_lines)

    def generate_spectre(self) -> str:
        """Generate a Spectre-format netlist string.



        Notes:

        - Sources are emitted using `vsource`.

        - Passives are emitted using `resistor`, `capacitor`, `inductor`.

        """

        netlist_lines = ["simulator lang=spectre"]

        # Sources

        for source_name, n_in, n_out, value in self._iter_named_sources():

            inst_name = _spectre_identifier(source_name)

            n_in_s = _spectre_escape_node(n_in)

            n_out_s = _spectre_escape_node(n_out)

            if isinstance(value, StimulusExpression):

                spectre = value.to_spectre(time_vector=self._t)

                if spectre:

                    # spectre string should already contain dc/ac/pwl/sine/pulse specifics

                    netlist_lines.append(
                        f"{inst_name} ({n_in_s} {n_out_s}) vsource {spectre}"
                    )

                    continue

                # Fallback: if only DC is known, emit dc

                if value.dc is not None:

                    netlist_lines.append(
                        f"{inst_name} ({n_in_s} {n_out_s}) vsource type=dc dc={value.dc}"
                    )

                    continue

                netlist_lines.append(f"{inst_name} ({n_in_s} {n_out_s}) vsource")

                continue

            # Numeric (scalar or vector)

            if np.isscalar(value) or (
                hasattr(value, "size") and np.array(value).size == 1
            ):

                netlist_lines.append(
                    f"{inst_name} ({n_in_s} {n_out_s}) vsource type=dc dc={float(value)}"
                )

                continue

            if self._t is None:

                netlist_lines.append(f"{inst_name} ({n_in_s} {n_out_s}) vsource")

                continue

            eval_array = np.array(value)

            if eval_array.size != self._t.size:

                netlist_lines.append(f"{inst_name} ({n_in_s} {n_out_s}) vsource")

                continue

            wave_pairs = []

            for t, v in zip(self._t, eval_array):

                wave_pairs.append(f"{t} {v}")

            wave = " ".join(wave_pairs)

            netlist_lines.append(
                f"{inst_name} ({n_in_s} {n_out_s}) vsource type=pwl wave=[{wave}]"
            )

        # Passive networks

        for nodes, network in self._components.items():

            n_in, n_out = nodes

            netlist_lines.append(
                f"// --- Network between {_spectre_escape_node(n_in)} and {_spectre_escape_node(n_out)} ---"
            )

            netlist_lines.extend(network.generate_spectre_netlist(n_in, n_out))

        # Current sources

        for name, n_plus, n_minus, value in self._iter_named_currents():

            inst_name = _spectre_identifier(name)

            n_plus_s = _spectre_escape_node(n_plus)

            n_minus_s = _spectre_escape_node(n_minus)

            if isinstance(value, StimulusExpression):

                spectre_rhs = value.to_spectre(time_vector=self._t)

                if spectre_rhs:

                    netlist_lines.append(
                        f"{inst_name} ({n_plus_s} {n_minus_s}) isource {spectre_rhs}"
                    )

                elif value.dc is not None:

                    netlist_lines.append(
                        f"{inst_name} ({n_plus_s} {n_minus_s}) isource type=dc dc={value.dc}"
                    )

                else:

                    netlist_lines.append(
                        f"{inst_name} ({n_plus_s} {n_minus_s}) isource"
                    )

                continue

            if np.isscalar(value) or (
                hasattr(value, "size") and np.array(value).size == 1
            ):

                netlist_lines.append(
                    f"{inst_name} ({n_plus_s} {n_minus_s}) isource type=dc dc={float(value)}"
                )

                continue

            if self._t is None:

                netlist_lines.append(f"{inst_name} ({n_plus_s} {n_minus_s}) isource")

                continue

            eval_array = np.array(value)

            if eval_array.size != self._t.size:

                netlist_lines.append(f"{inst_name} ({n_plus_s} {n_minus_s}) isource")

                continue

            wave_pairs = [f"{t} {v}" for t, v in zip(self._t, eval_array)]

            wave = " ".join(wave_pairs)

            netlist_lines.append(
                f"{inst_name} ({n_plus_s} {n_minus_s}) isource type=pwl wave=[{wave}]"
            )

        return "\n".join(netlist_lines)
generate_spectre()

Generate a Spectre-format netlist string.

Notes:

  • Sources are emitted using vsource.

  • Passives are emitted using resistor, capacitor, inductor.

Source code in src/opens_suite/stimuli/stimuli.py
def generate_spectre(self) -> str:
    """Generate a Spectre-format netlist string.



    Notes:

    - Sources are emitted using `vsource`.

    - Passives are emitted using `resistor`, `capacitor`, `inductor`.

    """

    netlist_lines = ["simulator lang=spectre"]

    # Sources

    for source_name, n_in, n_out, value in self._iter_named_sources():

        inst_name = _spectre_identifier(source_name)

        n_in_s = _spectre_escape_node(n_in)

        n_out_s = _spectre_escape_node(n_out)

        if isinstance(value, StimulusExpression):

            spectre = value.to_spectre(time_vector=self._t)

            if spectre:

                # spectre string should already contain dc/ac/pwl/sine/pulse specifics

                netlist_lines.append(
                    f"{inst_name} ({n_in_s} {n_out_s}) vsource {spectre}"
                )

                continue

            # Fallback: if only DC is known, emit dc

            if value.dc is not None:

                netlist_lines.append(
                    f"{inst_name} ({n_in_s} {n_out_s}) vsource type=dc dc={value.dc}"
                )

                continue

            netlist_lines.append(f"{inst_name} ({n_in_s} {n_out_s}) vsource")

            continue

        # Numeric (scalar or vector)

        if np.isscalar(value) or (
            hasattr(value, "size") and np.array(value).size == 1
        ):

            netlist_lines.append(
                f"{inst_name} ({n_in_s} {n_out_s}) vsource type=dc dc={float(value)}"
            )

            continue

        if self._t is None:

            netlist_lines.append(f"{inst_name} ({n_in_s} {n_out_s}) vsource")

            continue

        eval_array = np.array(value)

        if eval_array.size != self._t.size:

            netlist_lines.append(f"{inst_name} ({n_in_s} {n_out_s}) vsource")

            continue

        wave_pairs = []

        for t, v in zip(self._t, eval_array):

            wave_pairs.append(f"{t} {v}")

        wave = " ".join(wave_pairs)

        netlist_lines.append(
            f"{inst_name} ({n_in_s} {n_out_s}) vsource type=pwl wave=[{wave}]"
        )

    # Passive networks

    for nodes, network in self._components.items():

        n_in, n_out = nodes

        netlist_lines.append(
            f"// --- Network between {_spectre_escape_node(n_in)} and {_spectre_escape_node(n_out)} ---"
        )

        netlist_lines.extend(network.generate_spectre_netlist(n_in, n_out))

    # Current sources

    for name, n_plus, n_minus, value in self._iter_named_currents():

        inst_name = _spectre_identifier(name)

        n_plus_s = _spectre_escape_node(n_plus)

        n_minus_s = _spectre_escape_node(n_minus)

        if isinstance(value, StimulusExpression):

            spectre_rhs = value.to_spectre(time_vector=self._t)

            if spectre_rhs:

                netlist_lines.append(
                    f"{inst_name} ({n_plus_s} {n_minus_s}) isource {spectre_rhs}"
                )

            elif value.dc is not None:

                netlist_lines.append(
                    f"{inst_name} ({n_plus_s} {n_minus_s}) isource type=dc dc={value.dc}"
                )

            else:

                netlist_lines.append(
                    f"{inst_name} ({n_plus_s} {n_minus_s}) isource"
                )

            continue

        if np.isscalar(value) or (
            hasattr(value, "size") and np.array(value).size == 1
        ):

            netlist_lines.append(
                f"{inst_name} ({n_plus_s} {n_minus_s}) isource type=dc dc={float(value)}"
            )

            continue

        if self._t is None:

            netlist_lines.append(f"{inst_name} ({n_plus_s} {n_minus_s}) isource")

            continue

        eval_array = np.array(value)

        if eval_array.size != self._t.size:

            netlist_lines.append(f"{inst_name} ({n_plus_s} {n_minus_s}) isource")

            continue

        wave_pairs = [f"{t} {v}" for t, v in zip(self._t, eval_array)]

        wave = " ".join(wave_pairs)

        netlist_lines.append(
            f"{inst_name} ({n_plus_s} {n_minus_s}) isource type=pwl wave=[{wave}]"
        )

    return "\n".join(netlist_lines)
generate_spice()

Generate a SPICE/Xyce-style netlist string.

Source code in src/opens_suite/stimuli/stimuli.py
def generate_spice(self) -> str:
    """Generate a SPICE/Xyce-style netlist string."""

    netlist_lines = []

    # 1) Sources

    for source_name, n_in, n_out, value in self._iter_named_sources():

        dc_str = ""

        ac_str = ""

        if isinstance(value, StimulusExpression):

            if value.dc is not None:

                dc_str = f" DC {value.dc}"

            if value.ac is not None:

                ac_str = f" AC {value.ac}"

            native_spice = value.to_spice()

            if native_spice:

                netlist_lines.append(
                    f"{source_name} {n_in} {n_out}{dc_str}{ac_str} {native_spice}"
                )

                continue

            if self._t is None:

                netlist_lines.append(
                    f"{source_name} {n_in} {n_out}{dc_str}{ac_str}"
                )

                continue

            expr_val = value.evaluate(self._t)

        else:

            expr_val = value

        if np.isscalar(expr_val) or (
            hasattr(expr_val, "size") and np.array(expr_val).size == 1
        ):

            val = float(expr_val)

            netlist_lines.append(
                f"{source_name} {n_in} {n_out}{dc_str}{ac_str} {val}"
            )

            continue

        if self._t is None:

            netlist_lines.append(f"{source_name} {n_in} {n_out}{dc_str}{ac_str}")

            continue

        eval_array = np.array(expr_val)

        if eval_array.size != self._t.size:

            continue

        pairs = [f"{t} {v}" for t, v in zip(self._t, eval_array)]

        pwl_str = " ".join(pairs)

        netlist_lines.append(
            f"{source_name} {n_in} {n_out}{dc_str}{ac_str} PWL({pwl_str})"
        )

    # 2) Passive networks

    for nodes, network in self._components.items():

        n_in, n_out = nodes

        netlist_lines.append(f"* --- Network between {n_in} and {n_out} ---")

        netlist_lines.extend(network.generate_netlist(n_in, n_out))

    # 3) Current sources

    for name, n_plus, n_minus, value in self._iter_named_currents():

        if isinstance(value, StimulusExpression):

            dc_str = ""

            ac_str = ""

            if value.dc is not None:

                dc_str = f" DC {value.dc}"

            if value.ac is not None:

                ac_str = f" AC {value.ac}"

            native = value.to_spice()

            if native:

                netlist_lines.append(
                    f"{name} {n_plus} {n_minus}{dc_str}{ac_str} {native}"
                )

                continue

            if self._t is None:

                # no time vector: just DC/AC if any

                netlist_lines.append(f"{name} {n_plus} {n_minus}{dc_str}{ac_str}")

                continue

            expr_val = value.evaluate(self._t)

        else:

            expr_val = value

        if np.isscalar(expr_val) or (
            hasattr(expr_val, "size") and np.array(expr_val).size == 1
        ):

            netlist_lines.append(f"{name} {n_plus} {n_minus} {float(expr_val)}")

            continue

        if self._t is None:

            netlist_lines.append(f"{name} {n_plus} {n_minus}")

            continue

        eval_array = np.array(expr_val)

        if eval_array.size != self._t.size:

            continue

        pairs = [f"{t} {v}" for t, v in zip(self._t, eval_array)]

        pwl_str = " ".join(pairs)

        netlist_lines.append(f"{name} {n_plus} {n_minus} PWL({pwl_str})")

    return "\n".join(netlist_lines)
StimulusExpression
Source code in src/opens_suite/stimuli/stimuli.py
class StimulusExpression:

    def __init__(self, dc=None, ac=None):

        self.dc = dc

        self.ac = ac

    def evaluate(self, t):

        raise NotImplementedError

    def to_spice(self):

        return None

    def to_spectre(self, time_vector=None):
        """Return the RHS of a Spectre `vsource` element.



        Example: `type=sine ampl=1 freq=1k offset=0`

        """

        return None
to_spectre(time_vector=None)

Return the RHS of a Spectre vsource element.

Example: type=sine ampl=1 freq=1k offset=0

Source code in src/opens_suite/stimuli/stimuli.py
def to_spectre(self, time_vector=None):
    """Return the RHS of a Spectre `vsource` element.



    Example: `type=sine ampl=1 freq=1k offset=0`

    """

    return None

symbol_editor

ResizeHandle

Bases: QGraphicsRectItem

Handle for resizing items.

Source code in src/opens_suite/symbol_editor.py
class ResizeHandle(QGraphicsRectItem):
    """Handle for resizing items."""

    def __init__(self, parent, nx, ny):
        # nx, ny are in [0, 0.5, 1]
        super().__init__(-2, -2, 4, 4, parent)
        self.nx = nx
        self.ny = ny
        self.setBrush(QBrush(QColor("white")))
        self.setPen(QPen(QColor("blue"), 0.5))
        # Important: Don't set ItemIsMovable on handles, otherwise the parent moves.
        # We handle movement manually.
        self.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable, False)
        self.setZValue(1000)
        self.setAcceptHoverEvents(True)
        self._update_cursor()
        self.hide()
        self.is_dragging = False

    def _update_cursor(self):
        if (self.nx == 0 and self.ny == 0) or (self.nx == 1 and self.ny == 1):
            self.setCursor(Qt.CursorShape.SizeFDiagCursor)
        elif (self.nx == 1 and self.ny == 0) or (self.nx == 0 and self.ny == 1):
            self.setCursor(Qt.CursorShape.SizeBDiagCursor)
        elif self.nx == 0.5:
            self.setCursor(Qt.CursorShape.SizeVerCursor)
        else:
            self.setCursor(Qt.CursorShape.SizeHorCursor)

    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self.is_dragging = True
            # Store initial positions for manual drag
            self.drag_start_pos = event.scenePos()
            self.initial_rect = self.parentItem().rect()
            self.initial_pos = self.parentItem().pos()
            event.accept()
        else:
            super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.is_dragging:
            delta = event.scenePos() - self.drag_start_pos
            # Snap delta to grid for the handle movement
            dx = round(delta.x() / 10) * 10
            dy = round(delta.y() / 10) * 10

            self.parentItem()._on_handle_dragged(
                self, dx, dy, self.initial_rect, self.initial_pos
            )
            event.accept()
        else:
            super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if self.is_dragging:
            self.is_dragging = False
            event.accept()
        else:
            super().mouseReleaseEvent(event)

    # Remove itemChange since we don't use ItemIsMovable anymore
    def itemChange(self, change, value):
        return super().itemChange(change, value)

symbol_generator

SymbolGenerator

Source code in src/opens_suite/symbol_generator.py
class SymbolGenerator:
    @staticmethod
    def generate_symbol(schematic_path, symbol_path):
        """
        Generates a fresh .sym.svg file for the given schematic .svg.
        All input pins go to the left, output pins to the right.
        Bidirectional pins go to the left as well or wherever they fit.
        """
        if not schematic_path.endswith(".svg"):
            print("Error: Input must be a .svg file")
            return None

        if schematic_path.endswith(".sch.svg"):
            schematic_name = os.path.basename(schematic_path[:-8])
        else:
            schematic_name = os.path.basename(schematic_path[:-4])

        # If the file is just called 'schematic', use the parent directory name
        if schematic_name == "schematic":
            parent_dir = os.path.basename(
                os.path.dirname(os.path.abspath(schematic_path))
            )
            if parent_dir and parent_dir not in [".", ""]:
                schematic_name = parent_dir

        dir_name = os.path.dirname(schematic_path)
        symbol_path = os.path.join(dir_name, "symbol.svg")

        pins = SymbolGenerator._extract_pins_from_schematic(schematic_path)
        print(f"Found pins in schematic: {pins}")

        in_pins = [p for p in pins if p["type"] == "in"]
        out_pins = [p for p in pins if p["type"] == "out"]
        bi_pins = [p for p in pins if p["type"] == "bi"]

        # Put bi pins on the left below in_pins
        left_pins = in_pins + bi_pins
        right_pins = out_pins

        num_left = len(left_pins)
        num_right = len(right_pins)
        max_pins = max(num_left, num_right, 1)

        # Layout metrics
        grid = 10
        pin_spacing = 20
        box_x = 20
        box_width = 80
        box_right = box_x + box_width

        box_height = max_pins * pin_spacing + 20
        total_width = 120
        total_height = box_height

        ET.register_namespace("", "http://www.w3.org/2000/svg")
        ET.register_namespace("opens", "http://opens-schematic.org")

        root = ET.Element(
            "svg",
            dict(
                xmlns="http://www.w3.org/2000/svg",
                width=str(total_width),
                height=str(total_height),
                viewBox=f"0 0 {total_width} {total_height}",
            ),
        )

        # Definitions and style
        defs = ET.SubElement(root, "defs")
        style = ET.SubElement(defs, "style")
        style.text = """
            .pin { fill: none; stroke: none; }
            .symbol { fill: none; stroke: black; stroke-width: 2; stroke-linecap: round; }
            .label { font-family: Arial; font-size: 8px; fill: blue; }
            .value { font-family: Arial; font-size: 8px; fill: black; }
            .pin-label { font-family: Arial; font-size: 8px; fill: black; }
        """

        # Parameters and metadata
        ET.SubElement(
            defs,
            "{http://opens-schematic.org}param",
            {"name": "Model", "value": f"{schematic_name}.sch"},
        )
        ET.SubElement(
            defs,
            "{http://opens-schematic.org}symbol",
            {"prefix": "X", "category": "Subcircuits"},
        )

        # We also need a template for the netlister to resolve the subcircuit call automatically
        pin_order_str = " ".join([f"{{pin_{p['name']}}}" for p in pins])
        template_str = f"X_{{name}} {pin_order_str} {{Model}}"
        ET.SubElement(
            defs, "{http://opens-schematic.org}xyce", {"template": template_str}
        )

        # Background Rect
        ET.SubElement(
            root,
            "rect",
            {
                "x": str(box_x),
                "y": "10",
                "width": str(box_width),
                "height": str(box_height - 20),
                "rx": "2",
                "class": "symbol",
                "fill": "#fcfcfc",
                "stroke": "black",
                "stroke-width": "2",
            },
        )

        # Place Left Pins
        y_cursor = 20
        for p in left_pins:
            name = p["name"]
            ET.SubElement(
                root,
                "line",
                {
                    "x1": "0",
                    "y1": str(y_cursor),
                    "x2": str(box_x),
                    "y2": str(y_cursor),
                    "class": "symbol",
                    "stroke": "black",
                    "stroke-width": "2",
                },
            )
            ET.SubElement(
                root,
                "circle",
                {
                    "id": name,
                    "cx": "0",
                    "cy": str(y_cursor),
                    "r": "2",
                    "class": "pin",
                    "fill": "red",
                    "stroke": "none",
                },
            )
            ET.SubElement(
                root,
                "text",
                {
                    "x": str(box_x + 3),
                    "y": str(y_cursor),
                    "class": "pin-label",
                    "dominant-baseline": "central",
                    "fill": "black",
                },
            ).text = name
            y_cursor += pin_spacing

        # Place Right Pins
        y_cursor = 20
        for p in right_pins:
            name = p["name"]
            ET.SubElement(
                root,
                "line",
                {
                    "x1": str(box_right),
                    "y1": str(y_cursor),
                    "x2": str(total_width),
                    "y2": str(y_cursor),
                    "class": "symbol",
                    "stroke": "black",
                    "stroke-width": "2",
                },
            )
            ET.SubElement(
                root,
                "circle",
                {
                    "id": name,
                    "cx": str(total_width),
                    "cy": str(y_cursor),
                    "r": "2",
                    "class": "pin",
                    "fill": "red",
                    "stroke": "none",
                },
            )
            ET.SubElement(
                root,
                "text",
                {
                    "x": str(box_right - 3),
                    "y": str(y_cursor),
                    "class": "pin-label",
                    "text-anchor": "end",
                    "dominant-baseline": "central",
                    "fill": "black",
                },
            ).text = name
            y_cursor += pin_spacing

        # Labels
        # Name Placeholder
        ET.SubElement(
            root,
            "text",
            {
                "x": str(box_x + box_width / 2),
                "y": "8",
                "class": "label",
                "style": "text-anchor: middle;",
                "fill": "blue",
            },
        ).text = "{name}"

        # Model Placeholder
        ET.SubElement(
            root,
            "text",
            {
                "x": str(box_x + box_width / 2),
                "y": str(box_height - 2),
                "class": "value",
                "style": "text-anchor: middle;",
                "fill": "black",
            },
        ).text = "{Model}"

        xml_bytes = ET.tostring(root, encoding="utf-8")
        xmlstr = minidom.parseString(xml_bytes).toprettyxml(indent="  ")
        xmlstr = "\n".join([line for line in xmlstr.split("\n") if line.strip()])

        with open(symbol_path, "w") as f:
            f.write(xmlstr)

        print(f"Saved symbol to {symbol_path}")
        return symbol_path

    @staticmethod
    def _extract_pins_from_schematic(path):
        """
        Parses schematic SVG looking for library items that are pins.
        Returns list of dicts: {'name': 'P1', 'type': 'in'|'out'|'bi'}
        """
        pins = []
        try:
            tree = ET.parse(path)
            root = tree.getroot()

            for elem in root.iter():
                sym = elem.get("symbol_name", "")
                lib_path = elem.get("library_path", "")

                # Handle old way or programmatic pins
                if sym.startswith("pin_"):
                    name = elem.get("name") or elem.get("param_Name", "Unknown")
                    type_ = sym.replace("pin_", "")
                    pins.append({"name": name, "type": type_})
                # Handle new library paths
                elif "pin_in" in lib_path or "/pin/" in lib_path:
                    name = elem.get("name") or elem.get("param_Name", "Unknown")
                    pins.append({"name": name, "type": "in"})
                elif "pin_out" in lib_path:
                    name = elem.get("name") or elem.get("param_Name", "Unknown")
                    pins.append({"name": name, "type": "out"})
                elif "pin_bi" in lib_path:
                    name = elem.get("name") or elem.get("param_Name", "Unknown")
                    pins.append({"name": name, "type": "bi"})

        except Exception as e:
            print(f"Error parse schematic: {e}")

        # ensure stable order
        pins.sort(key=lambda x: x["name"])
        return pins
generate_symbol(schematic_path, symbol_path) staticmethod

Generates a fresh .sym.svg file for the given schematic .svg. All input pins go to the left, output pins to the right. Bidirectional pins go to the left as well or wherever they fit.

Source code in src/opens_suite/symbol_generator.py
@staticmethod
def generate_symbol(schematic_path, symbol_path):
    """
    Generates a fresh .sym.svg file for the given schematic .svg.
    All input pins go to the left, output pins to the right.
    Bidirectional pins go to the left as well or wherever they fit.
    """
    if not schematic_path.endswith(".svg"):
        print("Error: Input must be a .svg file")
        return None

    if schematic_path.endswith(".sch.svg"):
        schematic_name = os.path.basename(schematic_path[:-8])
    else:
        schematic_name = os.path.basename(schematic_path[:-4])

    # If the file is just called 'schematic', use the parent directory name
    if schematic_name == "schematic":
        parent_dir = os.path.basename(
            os.path.dirname(os.path.abspath(schematic_path))
        )
        if parent_dir and parent_dir not in [".", ""]:
            schematic_name = parent_dir

    dir_name = os.path.dirname(schematic_path)
    symbol_path = os.path.join(dir_name, "symbol.svg")

    pins = SymbolGenerator._extract_pins_from_schematic(schematic_path)
    print(f"Found pins in schematic: {pins}")

    in_pins = [p for p in pins if p["type"] == "in"]
    out_pins = [p for p in pins if p["type"] == "out"]
    bi_pins = [p for p in pins if p["type"] == "bi"]

    # Put bi pins on the left below in_pins
    left_pins = in_pins + bi_pins
    right_pins = out_pins

    num_left = len(left_pins)
    num_right = len(right_pins)
    max_pins = max(num_left, num_right, 1)

    # Layout metrics
    grid = 10
    pin_spacing = 20
    box_x = 20
    box_width = 80
    box_right = box_x + box_width

    box_height = max_pins * pin_spacing + 20
    total_width = 120
    total_height = box_height

    ET.register_namespace("", "http://www.w3.org/2000/svg")
    ET.register_namespace("opens", "http://opens-schematic.org")

    root = ET.Element(
        "svg",
        dict(
            xmlns="http://www.w3.org/2000/svg",
            width=str(total_width),
            height=str(total_height),
            viewBox=f"0 0 {total_width} {total_height}",
        ),
    )

    # Definitions and style
    defs = ET.SubElement(root, "defs")
    style = ET.SubElement(defs, "style")
    style.text = """
        .pin { fill: none; stroke: none; }
        .symbol { fill: none; stroke: black; stroke-width: 2; stroke-linecap: round; }
        .label { font-family: Arial; font-size: 8px; fill: blue; }
        .value { font-family: Arial; font-size: 8px; fill: black; }
        .pin-label { font-family: Arial; font-size: 8px; fill: black; }
    """

    # Parameters and metadata
    ET.SubElement(
        defs,
        "{http://opens-schematic.org}param",
        {"name": "Model", "value": f"{schematic_name}.sch"},
    )
    ET.SubElement(
        defs,
        "{http://opens-schematic.org}symbol",
        {"prefix": "X", "category": "Subcircuits"},
    )

    # We also need a template for the netlister to resolve the subcircuit call automatically
    pin_order_str = " ".join([f"{{pin_{p['name']}}}" for p in pins])
    template_str = f"X_{{name}} {pin_order_str} {{Model}}"
    ET.SubElement(
        defs, "{http://opens-schematic.org}xyce", {"template": template_str}
    )

    # Background Rect
    ET.SubElement(
        root,
        "rect",
        {
            "x": str(box_x),
            "y": "10",
            "width": str(box_width),
            "height": str(box_height - 20),
            "rx": "2",
            "class": "symbol",
            "fill": "#fcfcfc",
            "stroke": "black",
            "stroke-width": "2",
        },
    )

    # Place Left Pins
    y_cursor = 20
    for p in left_pins:
        name = p["name"]
        ET.SubElement(
            root,
            "line",
            {
                "x1": "0",
                "y1": str(y_cursor),
                "x2": str(box_x),
                "y2": str(y_cursor),
                "class": "symbol",
                "stroke": "black",
                "stroke-width": "2",
            },
        )
        ET.SubElement(
            root,
            "circle",
            {
                "id": name,
                "cx": "0",
                "cy": str(y_cursor),
                "r": "2",
                "class": "pin",
                "fill": "red",
                "stroke": "none",
            },
        )
        ET.SubElement(
            root,
            "text",
            {
                "x": str(box_x + 3),
                "y": str(y_cursor),
                "class": "pin-label",
                "dominant-baseline": "central",
                "fill": "black",
            },
        ).text = name
        y_cursor += pin_spacing

    # Place Right Pins
    y_cursor = 20
    for p in right_pins:
        name = p["name"]
        ET.SubElement(
            root,
            "line",
            {
                "x1": str(box_right),
                "y1": str(y_cursor),
                "x2": str(total_width),
                "y2": str(y_cursor),
                "class": "symbol",
                "stroke": "black",
                "stroke-width": "2",
            },
        )
        ET.SubElement(
            root,
            "circle",
            {
                "id": name,
                "cx": str(total_width),
                "cy": str(y_cursor),
                "r": "2",
                "class": "pin",
                "fill": "red",
                "stroke": "none",
            },
        )
        ET.SubElement(
            root,
            "text",
            {
                "x": str(box_right - 3),
                "y": str(y_cursor),
                "class": "pin-label",
                "text-anchor": "end",
                "dominant-baseline": "central",
                "fill": "black",
            },
        ).text = name
        y_cursor += pin_spacing

    # Labels
    # Name Placeholder
    ET.SubElement(
        root,
        "text",
        {
            "x": str(box_x + box_width / 2),
            "y": "8",
            "class": "label",
            "style": "text-anchor: middle;",
            "fill": "blue",
        },
    ).text = "{name}"

    # Model Placeholder
    ET.SubElement(
        root,
        "text",
        {
            "x": str(box_x + box_width / 2),
            "y": str(box_height - 2),
            "class": "value",
            "style": "text-anchor: middle;",
            "fill": "black",
        },
    ).text = "{Model}"

    xml_bytes = ET.tostring(root, encoding="utf-8")
    xmlstr = minidom.parseString(xml_bytes).toprettyxml(indent="  ")
    xmlstr = "\n".join([line for line in xmlstr.split("\n") if line.strip()])

    with open(symbol_path, "w") as f:
        f.write(xmlstr)

    print(f"Saved symbol to {symbol_path}")
    return symbol_path

syntax_highlighter

Python syntax highlighter with a VS Code Dark+ inspired colour scheme.

PythonHighlighter

Bases: QSyntaxHighlighter

Regex-based Python highlighter (single-line patterns + multi-line strings).

Source code in src/opens_suite/syntax_highlighter.py
class PythonHighlighter(QSyntaxHighlighter):
    """Regex-based Python highlighter (single-line patterns + multi-line strings)."""

    # Python keywords
    KEYWORDS = [
        "False",
        "None",
        "True",
        "and",
        "as",
        "assert",
        "async",
        "await",
        "break",
        "class",
        "continue",
        "def",
        "del",
        "elif",
        "else",
        "except",
        "finally",
        "for",
        "from",
        "global",
        "if",
        "import",
        "in",
        "is",
        "lambda",
        "nonlocal",
        "not",
        "or",
        "pass",
        "raise",
        "return",
        "try",
        "while",
        "with",
        "yield",
    ]

    BUILTINS = [
        "abs",
        "all",
        "any",
        "bin",
        "bool",
        "bytes",
        "callable",
        "chr",
        "classmethod",
        "complex",
        "dict",
        "dir",
        "divmod",
        "enumerate",
        "eval",
        "exec",
        "filter",
        "float",
        "format",
        "frozenset",
        "getattr",
        "globals",
        "hasattr",
        "hash",
        "help",
        "hex",
        "id",
        "input",
        "int",
        "isinstance",
        "issubclass",
        "iter",
        "len",
        "list",
        "locals",
        "map",
        "max",
        "memoryview",
        "min",
        "next",
        "object",
        "oct",
        "open",
        "ord",
        "pow",
        "print",
        "property",
        "range",
        "repr",
        "reversed",
        "round",
        "set",
        "setattr",
        "slice",
        "sorted",
        "staticmethod",
        "str",
        "sum",
        "super",
        "tuple",
        "type",
        "vars",
        "zip",
    ]

    def __init__(self, parent=None):
        super().__init__(parent)

        self._rules: list[tuple[re.Pattern, QTextCharFormat, int]] = []

        # ── Build rules (order matters – first match wins for overlapping) ──

        # Decorator
        self._rules.append(
            (
                re.compile(r"@\w+"),
                _fmt(_COLORS["decorator"]),
                0,
            )
        )

        # class / def  followed by name
        self._rules.append(
            (
                re.compile(r"\bclass\s+(\w+)"),
                _fmt(_COLORS["class_def"], bold=True),
                1,
            )
        )
        self._rules.append(
            (
                re.compile(r"\bdef\s+(\w+)"),
                _fmt(_COLORS["func_def"]),
                1,
            )
        )

        # Keywords
        kw_pattern = r"\b(?:" + "|".join(self.KEYWORDS) + r")\b"
        self._rules.append(
            (
                re.compile(kw_pattern),
                _fmt(_COLORS["keyword"]),
                0,
            )
        )

        # self / cls
        self._rules.append(
            (
                re.compile(r"\bself\b|\bcls\b"),
                _fmt(_COLORS["self"], italic=True),
                0,
            )
        )

        # Builtins
        bi_pattern = r"\b(?:" + "|".join(self.BUILTINS) + r")\b"
        self._rules.append(
            (
                re.compile(bi_pattern),
                _fmt(_COLORS["builtin"]),
                0,
            )
        )

        # Numbers (int, float, hex, oct, bin, complex)
        self._rules.append(
            (
                re.compile(
                    r"\b0[xXoObB][\da-fA-F_]+\b|\b\d[\d_]*\.?[\d_]*(?:[eE][+-]?\d+)?j?\b"
                ),
                _fmt(_COLORS["number"]),
                0,
            )
        )

        # Comment – applied BEFORE strings so that string formatting
        # overwrites any false '#' matches inside quoted text.
        self._rules.append(
            (
                re.compile(r"#[^\n]*"),
                _fmt(_COLORS["comment"], italic=True),
                0,
            )
        )

        # Strings – single-line  (single/double, raw/f-strings)
        # Applied AFTER comments so they take priority over '#' inside strings.
        self._rules.append(
            (
                re.compile(r'''[brufBRUF]{0,2}"[^"\\]*(?:\\.[^"\\]*)*"'''),
                _fmt(_COLORS["string"]),
                0,
            )
        )
        self._rules.append(
            (
                re.compile(r"""[brufBRUF]{0,2}'[^'\\]*(?:\\.[^'\\]*)*'"""),
                _fmt(_COLORS["string"]),
                0,
            )
        )

        # Multi-line string delimiters (used in highlightBlock state machine)
        self._tri_double = re.compile(r'"""')
        self._tri_single = re.compile(r"'''")
        self._string_fmt = _fmt(_COLORS["string"])

    # ─────────────────────────────────────────────────────────────────
    def highlightBlock(self, text: str):
        # Pass 1: left-to-right scan to find string and comment spans.
        # This ensures '#' inside strings is never treated as a comment.
        protected: list[tuple[int, int]] = []  # (start, end) of strings/comments
        i = 0
        n = len(text)
        while i < n:
            ch = text[i]
            # Check for string start (single or double quote, with optional prefix)
            if ch in ('"', "'") or (
                ch in "brufBRUF"
                and i + 1 < n
                and text[i + 1] in ("'", '"', "b", "r", "u", "f", "B", "R", "U", "F")
            ):
                # Consume optional prefix characters
                start = i
                while i < n and text[i] in "brufBRUF":
                    i += 1
                if i >= n or text[i] not in ("'", '"'):
                    i = start + 1
                    continue
                quote = text[i]
                i += 1
                # Consume until matching unescaped close quote
                while i < n:
                    if text[i] == "\\":
                        i += 2  # skip escaped char
                        continue
                    if text[i] == quote:
                        i += 1
                        break
                    i += 1
                end = i
                self.setFormat(start, end - start, self._string_fmt)
                protected.append((start, end))
            elif ch == "#":
                # Real comment – everything to end of line
                self.setFormat(i, n - i, _fmt(_COLORS["comment"], italic=True))
                protected.append((i, n))
                break
            else:
                i += 1

        # Pass 2: apply keyword / builtin / number / decorator rules
        # only outside protected (string/comment) spans.
        def is_protected(start, end):
            for ps, pe in protected:
                if start < pe and end > ps:  # overlap
                    return True
            return False

        for pattern, fmt, group in self._rules:
            for m in pattern.finditer(text):
                s = m.start(group)
                e = m.end(group)
                if not is_protected(s, e):
                    self.setFormat(s, e - s, fmt)

        # Pass 3: Multi-line strings (triple quotes)
        self._match_multiline(text, self._tri_double, 1)
        self._match_multiline(text, self._tri_single, 2)

    def _match_multiline(self, text: str, delimiter: re.Pattern, state_id: int):
        """Handle triple-quoted multi-line strings."""
        if self.previousBlockState() == state_id:
            # We are inside a multi-line string from a previous block
            start = 0
            add = 0
        else:
            # Look for the start of a triple-quote
            m = delimiter.search(text)
            if m is None:
                return
            start = m.start()
            add = 3  # length of the opening delimiter

        # From 'start + add', look for the closing delimiter
        while start >= 0:
            end_match = delimiter.search(text, start + add)
            if end_match:
                # Found the end in this block
                length = end_match.end() - start
                self.setFormat(start, length, self._string_fmt)
                self.setCurrentBlockState(0)
                # Continue searching for another triple-quote in this line
                start = end_match.end()
                add = 0
                m2 = delimiter.search(text, start)
                if m2:
                    start = m2.start()
                    add = 3
                else:
                    break
            else:
                # End not found – the rest of the block is inside the string
                self.setCurrentBlockState(state_id)
                self.setFormat(start, len(text) - start, self._string_fmt)
                break

apply_dark_plus_theme(text_edit)

Apply Dark+ colours to a QTextEdit and attach the highlighter.

Source code in src/opens_suite/syntax_highlighter.py
def apply_dark_plus_theme(text_edit):
    """Apply Dark+ colours to a QTextEdit and attach the highlighter."""
    text_edit.setStyleSheet(
        """
        QTextEdit {
            background-color: #1E1E1E;
            color: #D4D4D4;
            selection-background-color: #264F78;
            selection-color: #D4D4D4;
            border: 1px solid #3C3C3C;
        }
    """
    )
    font = text_edit.font()
    font.setFamily("Menlo")  # macOS mono; falls back gracefully
    font.setPointSize(12)
    font.setFixedPitch(True)
    text_edit.setFont(font)

    text_edit.setTabStopDistance(text_edit.fontMetrics().horizontalAdvance("    "))

    highlighter = PythonHighlighter(text_edit.document())
    # Store a reference so it doesn't get garbage-collected
    text_edit._syntax_highlighter = highlighter
    return highlighter

view

core

SchematicView

Bases: IOMixin, SimulationMixin, ConnectivityMixin, EventsMixin, QGraphicsView

Source code in src/opens_suite/view/core.py
class SchematicView(
    IOMixin, SimulationMixin, ConnectivityMixin, EventsMixin, QGraphicsView
):
    MODE_SELECT = "Select"
    MODE_WIRE = "Wire"
    MODE_MOVE = "Move"
    MODE_LINE = "Line"
    MODE_COPY = "Copy"
    MODE_PROBE = "Probe"
    MODE_ZOOM_RECT = "ZoomRect"
    WIRE_MODE_FREE = "Free"
    WIRE_MODE_HV = "HV"  # Horizontal then Vertical
    WIRE_MODE_VH = "VH"  # Vertical then Horizontal

    modeChanged = pyqtSignal(str)
    statusMessage = pyqtSignal(str)
    netSignalsPlotRequested = pyqtSignal(str)
    netProbed = pyqtSignal(str)
    simulationFinished = pyqtSignal()
    openSubcircuitRequested = pyqtSignal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setScene(SchematicScene(self))

        # Performance Optimizations
        self.setRenderHints(
            QPainter.RenderHint.Antialiasing
            | QPainter.RenderHint.TextAntialiasing
            | QPainter.RenderHint.SmoothPixmapTransform
        )
        self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate)
        self.setOptimizationFlags(
            QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing
        )

        # Undo Stack
        self.undo_stack = QUndoStack(self)
        self.undo_stack.indexChanged.connect(self.recalculate_connectivity)

        # Navigation
        self.setDragMode(
            QGraphicsView.DragMode.RubberBandDrag
        )  # Default to rubber band
        self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
        self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)

        # Zooming
        self.zoom_factor = 1.3

        # Drag and Drop
        self.setAcceptDrops(True)

        # State
        self.current_mode = self.MODE_SELECT
        self.wire_submode = self.WIRE_MODE_FREE
        self.wire_hv_mode = True  # Default Horizontal First
        self.wire_mode_locked = False

        self.wire_start = None
        self.current_wire = None

        # Line Mode
        self.line_start = None
        self.current_line_item = None

        self.wire_preview_path = QGraphicsPathItem()
        self.scene().addItem(self.wire_preview_path)
        self.wire_preview_path.setVisible(False)

        # Zoom Rect Preview
        self.zoom_rect_item = QGraphicsRectItem()
        self.zoom_rect_item.setPen(QPen(QColor(0, 0, 255), 1, Qt.PenStyle.DashLine))
        self.zoom_rect_item.setBrush(QColor(0, 0, 255, 30))
        self.scene().addItem(self.zoom_rect_item)
        self.zoom_rect_item.setVisible(False)
        self.zoom_start_pos = None
        self.move_ref_pos = None
        self.moving_items = []
        self.rubber_band_data = []  # List of (wire, moving_endpoint_index)

        # Copy Mode State
        self.copy_ref_pos = None
        self.copy_source_items = []
        self.copy_preview_items = []

        self.analyses = []
        self.outputs = []
        self.filename = None
        self.last_item_to_node = {}  # item -> node_name from last simulation

        from opens_suite.theme import theme_manager

        self.apply_theme()
        theme_manager.themeChanged.connect(self.apply_theme)

    def _connect_item(self, item):
        if isinstance(item, SchematicItem):
            item.openSubcircuitRequested.connect(self.openSubcircuitRequested.emit)

    def apply_theme(self):
        from opens_suite.theme import theme_manager

        self.wire_preview_path.setPen(
            QPen(theme_manager.get_color("font_label"), 2, Qt.PenStyle.DashLine)
        )
        self.update()

    def reload_symbols(self):
        """Reloads all symbols in the scene from disk."""
        for item in self.scene().items():
            if isinstance(item, SchematicItem):
                item.reload_symbol()
        self.recalculate_connectivity()

    def set_mode(self, mode):
        self.current_mode = mode
        self.modeChanged.emit(mode)

        self.recalculate_connectivity()

        if mode == self.MODE_SELECT:
            self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
            self.setCursor(Qt.CursorShape.ArrowCursor)
            # Cancel operations
            self.wire_preview_path.setVisible(False)
            self.wire_start = None
            if self.current_wire:
                self.scene().removeItem(self.current_wire)
                self.current_wire = None

            self.move_ref_pos = None
            self.moving_items = []
            self.rubber_band_data = []

            # Cleanup copy mode
            for item in self.copy_preview_items:
                self.scene().removeItem(item)
            self.copy_preview_items = []
            self.copy_ref_pos = None
            self.copy_source_items = []

            self.statusMessage.emit("Mode: Select")

        elif mode == self.MODE_WIRE:
            self.setDragMode(QGraphicsView.DragMode.NoDrag)
            self.setCursor(Qt.CursorShape.CrossCursor)
            self.statusMessage.emit(f"Mode: Wire")
            self.wire_mode_locked = False

        elif mode == self.MODE_MOVE:
            self.setDragMode(QGraphicsView.DragMode.NoDrag)
            self.setCursor(Qt.CursorShape.SizeAllCursor)
            self.move_ref_pos = None  # Old move ref
            self.move_start = None  # New move start
            # Prepare moving items
            self.moving_items = self.scene().selectedItems()
            self.statusMessage.emit("Mode: Move (Click to pick up)")

        elif mode == self.MODE_COPY:
            self.setDragMode(QGraphicsView.DragMode.NoDrag)
            self.setCursor(Qt.CursorShape.SizeAllCursor)
            self.copy_ref_pos = None
            self.copy_source_items = [
                it for it in self.scene().selectedItems() if it.parentItem() is None
            ]
            # Cleanup previous previews if any
            for item in self.copy_preview_items:
                self.scene().removeItem(item)
            self.copy_preview_items = []
            self.statusMessage.emit("Mode: Copy (Click reference point)")

        elif mode == self.MODE_LINE:
            self.setDragMode(QGraphicsView.DragMode.NoDrag)
            self.setCursor(Qt.CursorShape.CrossCursor)
            self.statusMessage.emit("Mode: Line")
            self.line_start = None
reload_symbols()

Reloads all symbols in the scene from disk.

Source code in src/opens_suite/view/core.py
def reload_symbols(self):
    """Reloads all symbols in the scene from disk."""
    for item in self.scene().items():
        if isinstance(item, SchematicItem):
            item.reload_symbol()
    self.recalculate_connectivity()

events

EventsMixin
Source code in src/opens_suite/view/events.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
class EventsMixin:
    def wheelEvent(self, event):
        modifiers = event.modifiers()
        if modifiers & (
            Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier
        ):
            if event.angleDelta().y() > 0:
                factor = self.zoom_factor
            else:
                factor = 1 / self.zoom_factor

            # Use Qt built-in anchor for perfect alignment under mouse
            old_anchor = self.transformationAnchor()
            self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
            self.scale(factor, factor)
            self.setTransformationAnchor(old_anchor)
            event.accept()
        else:
            super().wheelEvent(event)

    def _delete_selected(self):
        items = self.scene().selectedItems()
        if items:
            cmd = RemoveItemsCommand(self.scene(), items)
            self.undo_stack.push(cmd)
            self.recalculate_connectivity()

    def keyPressEvent(self, event: QKeyEvent):
        if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
            self._delete_selected()
        elif event.key() == Qt.Key.Key_Escape:
            if self.current_mode == self.MODE_WIRE and self.current_wire:
                self.scene().removeItem(self.current_wire)
                self.current_wire = None
                self.wire_start = None

            self.set_mode(self.MODE_SELECT)

        elif (
            event.key() == Qt.Key.Key_W
            and event.modifiers() == Qt.KeyboardModifier.NoModifier
        ):
            self.set_mode(self.MODE_WIRE)
        elif (
            event.key() == Qt.Key.Key_M
            and event.modifiers() == Qt.KeyboardModifier.NoModifier
        ):
            self.set_mode(self.MODE_MOVE)
        elif (
            event.key() == Qt.Key.Key_C
            and event.modifiers() == Qt.KeyboardModifier.NoModifier
        ):
            self.set_mode(self.MODE_COPY)
        elif (
            event.key() == Qt.Key.Key_L
            and event.modifiers() == Qt.KeyboardModifier.NoModifier
        ):
            self.set_mode(self.MODE_LINE)
        elif (
            event.key() == Qt.Key.Key_R
            and event.modifiers() == Qt.KeyboardModifier.NoModifier
        ):
            # Rotation around selection center
            items = [
                it for it in self.scene().selectedItems() if it.parentItem() is None
            ]
            if items:
                self._transform_selection(mode="rotate")

        elif event.key() == Qt.Key.Key_Z and (
            event.modifiers() & Qt.KeyboardModifier.ControlModifier
        ):
            if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
                self.undo_stack.redo()
            else:
                self.undo_stack.undo()

        elif event.key() == Qt.Key.Key_F:
            self.fitInView(
                self.scene().itemsBoundingRect(), Qt.AspectRatioMode.KeepAspectRatio
            )
        elif (
            event.key() == Qt.Key.Key_E
            and event.modifiers() == Qt.KeyboardModifier.NoModifier
        ):
            # Mirror selection horizontally across selection center
            items = [
                it for it in self.scene().selectedItems() if it.parentItem() is None
            ]
            if items:
                self._transform_selection(mode="mirror")
        else:
            # Check for symbol bindkeys: require Shift + <letter> to avoid collisions
            if (
                event.modifiers() == Qt.KeyboardModifier.ShiftModifier
                and len(event.text()) == 1
            ):
                # event.text() will be uppercase when Shift is pressed; normalize to lower
                key = event.text().lower()
                # Get library from main window
                from PyQt6.QtWidgets import QApplication

                main_window = QApplication.instance().activeWindow()
                if hasattr(main_window, "library_dock"):
                    symbol_path = main_window.library_dock.get_symbol_by_bindkey(key)
                    if symbol_path:
                        # Place symbol at current cursor position (snapped to grid)
                        vp_pos = self.mapFromGlobal(QCursor.pos())
                        cursor_pos = self.mapToScene(vp_pos)
                        cursor_pos = self.snap_to_grid(cursor_pos)
                        item = SchematicItem(symbol_path)
                        item.setPos(cursor_pos)

                        self._assign_name(item)

                        from opens_suite.commands import InsertItemsCommand

                        cmd = InsertItemsCommand(self.scene(), [item])
                        self.undo_stack.push(cmd)
                        self.recalculate_connectivity()

                        import os

                        self.statusMessage.emit(
                            f"Added {os.path.basename(symbol_path).replace('.svg', '')} with key 'Shift+{key.upper()}'"
                        )
                        event.accept()
                        return

            super().keyPressEvent(event)

    def snap_to_grid(self, pos: QPointF):
        grid_size = 10  # Half of visual grid? Or full? Let's say 10
        x = round(pos.x() / grid_size) * grid_size
        y = round(pos.y() / grid_size) * grid_size
        return QPointF(x, y)

    def mousePressEvent(self, event: QMouseEvent):
        from PyQt6.QtCore import Qt

        pos = self.mapToScene(event.position().toPoint())
        # Snap
        pos = QPointF(round(pos.x() / 10) * 10, round(pos.y() / 10) * 10)

        # Right click drag for Zoom-to-Rect
        if event.button() == Qt.MouseButton.RightButton:
            # Ensure zoom_rect_item is alive and in the scene
            try:
                if not self.zoom_rect_item.scene():
                    self.scene().addItem(self.zoom_rect_item)
            except (RuntimeError, AttributeError):
                from PyQt6.QtWidgets import QGraphicsRectItem
                from PyQt6.QtGui import QPen, QColor
                from PyQt6.QtCore import Qt

                self.zoom_rect_item = QGraphicsRectItem()
                self.zoom_rect_item.setPen(
                    QPen(QColor(0, 0, 255), 1, Qt.PenStyle.DashLine)
                )
                self.zoom_rect_item.setBrush(QColor(0, 0, 255, 30))
                self.scene().addItem(self.zoom_rect_item)

            self._old_mode = self.current_mode
            self.set_mode(self.MODE_ZOOM_RECT)
            self.zoom_start_pos = self.mapToScene(event.position().toPoint())
            self.zoom_rect_item.setRect(
                QRectF(self.zoom_start_pos, self.zoom_start_pos)
            )
            self.zoom_rect_item.setVisible(True)
            return
        if self.current_mode == self.MODE_PROBE:
            # Find net under cursor
            items = self.scene().items(pos)
            net_name = None

            # 1. Check for Wire
            for item in items:
                if isinstance(item, Wire):
                    if (
                        hasattr(self, "last_item_to_node")
                        and self.last_item_to_node
                        and item in self.last_item_to_node
                    ):
                        net_name = self.last_item_to_node[item]
                    else:
                        net_name = item.name or "N_?"
                    break

            # 2. Check for Pin
            if not net_name:
                for item in items:
                    if isinstance(item, SchematicItem):
                        for pin_id, info in item.pins.items():
                            pin_pos = item.mapToScene(info["pos"])
                            if (pin_pos - pos).manhattanLength() < 7:
                                if (
                                    hasattr(self, "last_item_to_node")
                                    and self.last_item_to_node
                                    and (item, pin_id) in self.last_item_to_node
                                ):
                                    net_name = self.last_item_to_node[(item, pin_id)]
                                else:
                                    net_name = f"V({item.name}:{pin_id})"
                                break
                    if net_name:
                        break

            if net_name:
                self.netProbed.emit(net_name)

            self.set_mode(self.MODE_SELECT)
            return

        if self.current_mode == self.MODE_WIRE:
            if event.button() == Qt.MouseButton.LeftButton:
                if not self.wire_start:
                    self.wire_start = pos
                    self.current_wire = Wire(pos, pos)
                    self.scene().addItem(self.current_wire)
                    self.wire_mode_locked = False
                else:
                    # Finish segment
                    # The current_wire is a temporary visual. We create a new one for the command.

                    start_p = self.wire_start
                    end_p = pos

                    # Logic based on locked mode
                    if self.wire_mode_locked:
                        if self.wire_hv_mode:  # HV
                            corner_p = QPointF(end_p.x(), start_p.y())
                            if corner_p != start_p:
                                cmd = CreateWireCommand(self.scene(), start_p, corner_p)
                                self.undo_stack.push(cmd)
                                start_p = corner_p
                            if corner_p != end_p:
                                cmd = CreateWireCommand(self.scene(), start_p, end_p)
                                self.undo_stack.push(cmd)
                        else:  # VH
                            corner_p = QPointF(start_p.x(), end_p.y())
                            if corner_p != start_p:
                                cmd = CreateWireCommand(self.scene(), start_p, corner_p)
                                self.undo_stack.push(cmd)
                                start_p = corner_p
                            if corner_p != end_p:
                                cmd = CreateWireCommand(self.scene(), start_p, end_p)
                                self.undo_stack.push(cmd)
                    else:
                        # Direct connection (straight line)
                        # This happens if user clicks BEFORE 50px threshold.
                        # We just create a straight wire.
                        if start_p != end_p:
                            cmd = CreateWireCommand(self.scene(), start_p, end_p)
                            self.undo_stack.push(cmd)

                    # Clean up temp wire
                    if self.current_wire:
                        self.scene().removeItem(self.current_wire)
                        self.current_wire = None

                    # Check if we clicked on a pin or another wire to end chain
                    clicked_items = self.scene().items(pos)
                    hit_pin = False
                    for item in clicked_items:
                        if isinstance(item, SchematicItem):
                            # Check distance to pins
                            for pin_id, pin_info in item.pins.items():
                                pin_pos = item.mapToScene(pin_info["pos"])
                                if (
                                    pin_pos - pos
                                ).manhattanLength() < 5:  # Small tolerance
                                    hit_pin = True
                                    break
                            if hit_pin:
                                break  # Found a pin on a schematic item

                    if hit_pin:
                        self.wire_start = None
                        self.set_mode(self.MODE_SELECT)  # Exit wire mode on pin
                    else:
                        # Chain
                        self.wire_start = pos
                        self.current_wire = Wire(pos, pos)
                        self.scene().addItem(self.current_wire)
                        self.wire_mode_locked = False  # Reset for next segment

                    self.recalculate_connectivity()

            elif event.button() == Qt.MouseButton.MiddleButton:
                # Force switch manually if needed, though user relies on auto
                self.wire_hv_mode = not self.wire_hv_mode
                self.wire_mode_locked = True  # Lock it if manually switched
                self.statusMessage.emit(
                    f"Mode: Wire ({'HV' if self.wire_hv_mode else 'VH'})"
                )

            elif event.button() == Qt.MouseButton.RightButton:
                if self.current_wire:
                    self.scene().removeItem(self.current_wire)
                    self.current_wire = None
                self.wire_start = None
                self.set_mode(self.MODE_SELECT)

        elif self.current_mode == self.MODE_LINE:
            if event.button() == Qt.MouseButton.LeftButton:
                if not self.line_start:
                    # Start Line
                    self.line_start = pos
                    self.current_line_item = QGraphicsLineItem(QLineF(pos, pos))
                    # Styling for graphical lines
                    pen = QPen(theme_manager.get_color("line_mode"))
                    pen.setWidth(2)
                    self.current_line_item.setPen(pen)
                    self.scene().addItem(self.current_line_item)
                else:
                    # Finish Line
                    start_p = self.line_start
                    end_p = pos
                    if start_p != end_p:
                        # Create persistent line item
                        line_item = QGraphicsLineItem(QLineF(start_p, end_p))
                        line_item.setFlags(
                            QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
                            | QGraphicsItem.GraphicsItemFlag.ItemIsMovable
                        )
                        pen = QPen(theme_manager.get_color("line_mode"))
                        pen.setWidth(2)
                        line_item.setPen(pen)
                        # Add to scene via command
                        cmd = InsertItemsCommand(self.scene(), [line_item])
                        self.undo_stack.push(cmd)

                    # Clean up temp
                    if self.current_line_item:
                        self.scene().removeItem(self.current_line_item)
                        self.current_line_item = None
                    self.line_start = None

        elif self.current_mode == self.MODE_MOVE:
            super().mousePressEvent(event)  # Handle selection
            if event.button() == Qt.MouseButton.LeftButton:
                if not self.move_ref_pos:
                    # Step 1: Click Reference Point
                    self.move_ref_pos = pos

                    # Identify Rubber Band Wires
                    self.rubber_band_data = []
                    # Find pins of selected schematic items
                    for item in self.moving_items:
                        if isinstance(item, SchematicItem):
                            for pin_info in item.pins.values():
                                pin_scene_pos = item.mapToScene(pin_info["pos"])
                                # Find wires connected to this pin (that are NOT moving)
                                # TODO: Optimize spatial search
                                for other_item in self.scene().items(pin_scene_pos):
                                    if (
                                        isinstance(other_item, Wire)
                                        and other_item not in self.moving_items
                                    ):
                                        # Check which end matches
                                        line = other_item.line()
                                        if (
                                            line.p1() - pin_scene_pos
                                        ).manhattanLength() < 2:
                                            self.rubber_band_data.append(
                                                (other_item, 0, QLineF(line))
                                            )
                                        elif (
                                            line.p2() - pin_scene_pos
                                        ).manhattanLength() < 2:
                                            self.rubber_band_data.append(
                                                (other_item, 1, QLineF(line))
                                            )

                else:
                    # Step 2: Click Target Point (Commit)
                    delta = pos - self.move_ref_pos

                    # We moved them visually during mouseMoveEvent.
                    # To use UndoCommand, we should revert the visual move, and let the command do it.
                    # Move back
                    for item in self.moving_items:
                        item.moveBy(-delta.x(), -delta.y())

                    # Revert wires
                    # ... Actually, the wires were modified in mouseMove potentially if we implemented it there.
                    # If we didn't calculate rubberband in mouseMove (visual only), we are fine.
                    # But we want visual feedback.
                    # Let's revert visual rubberband:
                    for wire, idx, original_line in self.rubber_band_data:
                        wire.setLine(original_line)

                    # Prepare Command Data
                    # Rubber band: list of (wire, old_line, new_line)
                    rb_cmd_data = []
                    for wire, idx, original_line in self.rubber_band_data:
                        new_line = QLineF(original_line)
                        if idx == 0:
                            new_line.setP1(new_line.p1() + delta)
                        else:
                            new_line.setP2(new_line.p2() + delta)
                        rb_cmd_data.append((wire, original_line, new_line))

                    cmd = MoveItemsCommand(self.moving_items, delta, rb_cmd_data)
                    self.undo_stack.push(cmd)

                    self.set_mode(self.MODE_SELECT)

        elif self.current_mode == self.MODE_COPY:
            if event.button() == Qt.MouseButton.LeftButton:
                if not self.copy_ref_pos:
                    # Step 1: Pick up origin
                    self.copy_ref_pos = pos
                    if not self.copy_source_items:
                        # If nothing was selected, try to pick up item at cursor
                        # We use a small rect around pos to find items
                        items = self.scene().items(
                            QRectF(pos.x() - 5, pos.y() - 5, 10, 10)
                        )
                        # Filter for parent items (top level)
                        top_items = [
                            it
                            for it in items
                            if it.parentItem() is None
                            and isinstance(it, (SchematicItem, Wire, Junction))
                        ]
                        if top_items:
                            self.copy_source_items = [top_items[0]]

                    if self.copy_source_items:
                        # Create preview clones
                        self.copy_preview_items = self._clone_items(
                            self.copy_source_items, assign_name=False
                        )
                        for item in self.copy_preview_items:
                            self.scene().addItem(item)
                            item.setZValue(1000)  # Ensure they are on top
                            item.setOpacity(0.5)  # Ghostly preview
                        self.statusMessage.emit("Mode: Copy (Click target point)")
                    else:
                        self.copy_ref_pos = None  # Reset if nothing to copy
                else:
                    # Step 2: Place copies
                    delta = pos - self.copy_ref_pos
                    clones = self._clone_items(self.copy_source_items)
                    for clone in clones:
                        clone.moveBy(delta.x(), delta.y())

                    cmd = InsertItemsCommand(self.scene(), clones)
                    self.undo_stack.push(cmd)

                    self.statusMessage.emit(
                        "Mode: Copy (Click target for more, Esc to exit)"
                    )

            elif event.button() == Qt.MouseButton.RightButton:
                self.set_mode(self.MODE_SELECT)

        else:
            # Store initial positions for undo
            self._move_start_positions = {
                item: item.pos()
                for item in self.scene().selectedItems()
                if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsMovable
            }
            super().mousePressEvent(event)
            # If nothing was selected before, check if something is selected now
            if not self._move_start_positions:
                self._move_start_positions = {
                    item: item.pos()
                    for item in self.scene().selectedItems()
                    if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsMovable
                }

    def mouseDoubleClickEvent(self, event: QMouseEvent):
        # Allow base class (and thus items) to handle it first
        super().mouseDoubleClickEvent(event)
        if event.isAccepted():
            return

        # Not handled by an item? Check for net plotting.
        pos = self.mapToScene(event.position().toPoint())
        items = self.scene().items(pos)

        # 1. Check for Wire
        for item in items:
            if isinstance(item, Wire):
                # Try last simulation mapping first, then item.name
                name = self.last_item_to_node.get(item) or item.name
                if name:
                    self.netSignalsPlotRequested.emit(name)
                    event.accept()
                    return

        # 2. Check for Pin of SchematicItem
        for item in items:
            if hasattr(item, "pins") and isinstance(item.pins, dict):
                # Check each pin's distance
                for pid, info in item.pins.items():
                    try:
                        pin_scene = item.mapToScene(info["pos"])
                        if (pin_scene - pos).manhattanLength() < 10:
                            # Found a pin. Now find a wire connected to it.
                            pin_ref = (item, pid)
                            name = self.last_item_to_node.get(pin_ref)
                            if name:
                                self.netSignalsPlotRequested.emit(name)
                                event.accept()
                                return

                            for neighbor in self.scene().items(pin_scene):
                                if isinstance(neighbor, Wire):
                                    name = (
                                        self.last_item_to_node.get(neighbor)
                                        or neighbor.name
                                    )
                                    if name:
                                        self.netSignalsPlotRequested.emit(name)
                                        event.accept()
                                        return
                    except Exception:
                        continue
            elif hasattr(item, "pin_items") and isinstance(item.pin_items, dict):
                for pid, pin_obj in item.pin_items.items():
                    try:
                        r = pin_obj.rect()
                        pin_scene = pin_obj.mapToScene(r.center())
                        if (pin_scene - pos).manhattanLength() < 10:
                            pin_ref = (item, pid)
                            name = self.last_item_to_node.get(pin_ref)
                            if name:
                                self.netSignalsPlotRequested.emit(name)
                                event.accept()
                                return

                            for neighbor in self.scene().items(pin_scene):
                                if isinstance(neighbor, Wire):
                                    name = (
                                        self.last_item_to_node.get(neighbor)
                                        or neighbor.name
                                    )
                                    if name:
                                        self.netSignalsPlotRequested.emit(name)
                                        event.accept()
                                        return
                    except Exception:
                        continue

    def mouseReleaseEvent(self, event: QMouseEvent):
        if self.current_mode == self.MODE_ZOOM_RECT:
            rect = self.zoom_rect_item.rect()
            self.zoom_rect_item.setVisible(False)
            self.set_mode(getattr(self, "_old_mode", self.MODE_SELECT))
            if rect.width() > 5 and rect.height() > 5:
                self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
            return

        if self.current_mode == self.MODE_SELECT:
            # Check if any items moved and commit to undo stack
            moved_data = []
            for item, start_pos in getattr(self, "_move_start_positions", {}).items():
                if item.pos() != start_pos:
                    delta = item.pos() - start_pos
                    moved_data.append((item, start_pos, delta))

            if moved_data:
                # Group by delta (usually they all move by the same amount if dragged together)
                # For simplicity, we assume they move together if they were selected together
                # We'll use the delta from the first item
                items = [d[0] for d in moved_data]
                delta = moved_data[0][2]

                # Snap the final positions to grid and adjust delta
                # Actually delta should already be snapped if items were snapped.

                # Revert visually for the command to take over
                for item, start_pos, _ in moved_data:
                    item.setPos(start_pos)

                # Push MoveItemsCommand
                from opens_suite.commands import MoveItemsCommand

                cmd = MoveItemsCommand(items, delta, [])
                self.undo_stack.push(cmd)

            self._move_start_positions = {}

        super().mouseReleaseEvent(event)
        # Always recalculate after any release to be sure
        self.recalculate_connectivity()

    def mouseMoveEvent(self, event: QMouseEvent):
        scene_pos = self.mapToScene(event.position().toPoint())

        if self.current_mode == self.MODE_ZOOM_RECT:
            rect = QRectF(self.zoom_start_pos, scene_pos).normalized()
            self.zoom_rect_item.setRect(rect)
            return

        snapped_pos = self.snap_to_grid(scene_pos)

        if self.current_mode == self.MODE_WIRE:
            if self.wire_start:
                path = QPainterPath(self.wire_start)

                # Check for lock threshold
                if not self.wire_mode_locked:
                    diff = snapped_pos - self.wire_start
                    dist = (diff.x() ** 2 + diff.y() ** 2) ** 0.5
                    if dist > 10:
                        self.wire_mode_locked = True
                        # Determine HV vs VH
                        if abs(diff.x()) > abs(diff.y()):
                            self.wire_hv_mode = True  # Horizontal first
                        else:
                            self.wire_hv_mode = False  # Vertical first

                if not self.wire_mode_locked:
                    # Straight Line Preview
                    path.lineTo(snapped_pos)
                else:
                    # L-Shape Preview
                    if self.wire_hv_mode:
                        path.lineTo(snapped_pos.x(), self.wire_start.y())
                        path.lineTo(snapped_pos)
                    else:
                        path.lineTo(self.wire_start.x(), snapped_pos.y())
                        path.lineTo(snapped_pos)

                self.wire_preview_path.setPath(path)
                if not self.wire_preview_path.isVisible():
                    self.wire_preview_path.setVisible(True)

        elif self.current_mode == self.MODE_LINE:
            # Defensive: only update preview line if we have a valid start point
            if self.current_line_item and self.line_start is not None:
                self.current_line_item.setLine(QLineF(self.line_start, snapped_pos))

        elif self.current_mode == self.MODE_MOVE:
            if self.move_ref_pos:
                delta = snapped_pos - self.move_ref_pos

                # Visual Feedback: Move items
                # Warning: We are accumulating delta if we use moveBy repeatedly relative to self.move_ref_pos
                # Logic: Reset to ref, then move to new?
                # Better: track 'last_pos' and moveBy(diff)

                # ... implementing incremental move
                # Need to update ref_pos or track delta from start.
                # Let's track delta from start.
                # But mouseMove provides absolute pos.

                # Simplify: Just don't do real-time feedback for rubber bands perfectly OR
                # restore state every frame? Expensive.
                # Incremental approach:
                # self.current_pos (last frame)

                # OK, simplest for now:
                # We won't do full rubber band visual feedback in this iteration to insure stability,
                # we just move the items.
                last_pos = getattr(self, "last_move_pos", self.move_ref_pos)
                frame_delta = snapped_pos - last_pos

                for item in self.moving_items:
                    item.moveBy(frame_delta.x(), frame_delta.y())

                # Rubber band visual update?
                for wire, idx, original_line in self.rubber_band_data:
                    # Update wire endpoints to follow pins
                    # For now just call recalculate_pin_connectivity for red marker updates
                    pass

                self._update_pin_connectivity()
                self.last_move_pos = snapped_pos

        elif self.current_mode == self.MODE_COPY:
            if self.copy_ref_pos and self.copy_preview_items:
                delta = snapped_pos - self.copy_ref_pos
                # Reset to source positions before applying current delta
                for preview, source in zip(
                    self.copy_preview_items, self.copy_source_items
                ):
                    preview.setPos(source.pos() + delta)

        elif self.current_mode == self.MODE_SELECT:
            if self.scene().mouseGrabberItem():
                self._update_pin_connectivity()

        super().mouseMoveEvent(event)

    def dragEnterEvent(self, event):
        if event.mimeData().hasText():
            event.acceptProposedAction()

    def dragMoveEvent(self, event):
        event.acceptProposedAction()

    def dropEvent(self, event):
        path = event.mimeData().text()
        # Support two types of drops: file-based SVG symbols and programmatic PCELLs
        if path.startswith("PCELL:"):
            # Programmatic pcell instantiation
            try:
                from opens import pcell as _pcell

                name = path.split(":", 1)[1]
                cls = _pcell.PCELL_REGISTRY.get(name)
                if cls:
                    item = cls(parameters={})
                    scene_pos = self.mapToScene(event.position().toPoint())
                    item.setPos(self.snap_to_grid(scene_pos))

                    # Assign a generated name only if the item provides the
                    # expected naming interface (prefix and set_name).
                    try:
                        if hasattr(item, "prefix") and callable(
                            getattr(item, "set_name", None)
                        ):
                            self._assign_name(item)
                    except Exception:
                        # Be defensive: don't block insertion if naming fails.
                        pass

                    from opens_suite.commands import InsertItemsCommand

                    cmd = InsertItemsCommand(self.scene(), item)
                    self.undo_stack.push(cmd)

                    event.acceptProposedAction()
                    self.recalculate_connectivity()
                    return
            except Exception:
                pass

        # Fallback: treat text as a filesystem path for SVG symbols
        if os.path.exists(path):
            item = SchematicItem(path)
            scene_pos = self.mapToScene(event.position().toPoint())
            item.setPos(self.snap_to_grid(scene_pos))

            self._assign_name(item)
            self._connect_item(item)

            from opens_suite.commands import InsertItemsCommand

            cmd = InsertItemsCommand(self.scene(), item)
            self.undo_stack.push(cmd)

            event.acceptProposedAction()
            self.recalculate_connectivity()

    def _assign_name(self, item):
        prefix = item.prefix
        indices = []
        for i in self.scene().items():
            # Include programmatic items that expose a prefix attribute
            if hasattr(i, "prefix") and getattr(i, "prefix") == prefix and i != item:
                name = i.name
                if name.startswith(prefix):
                    try:
                        idx = int(name[len(prefix) :])
                        indices.append(idx)
                    except ValueError:
                        pass

        max_idx = max(indices) if indices else 0
        new_name = f"{prefix}{max_idx + 1}"
        item.set_name(new_name)

    def _clone_item(self, item, assign_name=True):
        """Creates a deep-ish clone of a schematic item or wire."""
        if isinstance(item, SchematicItem):
            clone = SchematicItem(item.svg_path)
            clone.setPos(item.pos())
            clone.setTransform(item.transform())
            # Important: custom attributes like rotation_angle if they exist
            if hasattr(item, "rotation_angle"):
                clone.rotation_angle = item.rotation_angle

            if hasattr(item, "parameters"):
                clone.parameters = item.parameters.copy()

            # Refresh visuals
            clone._update_labels()
            clone._update_svg()

            if assign_name:
                # Assign a NEW name to avoid collisions
                self._assign_name(clone)
            else:
                clone.name = getattr(item, "name", "")
                clone._update_labels()

            self._connect_item(clone)
            return clone

        elif isinstance(item, Wire):
            l = item.line()
            p1 = item.mapToScene(l.p1())
            p2 = item.mapToScene(l.p2())
            clone = Wire(p1, p2)
            if item.name:
                clone.name = item.name
            return clone

        elif isinstance(item, Junction):
            center = item.mapToScene(item.rect().center())
            clone = Junction(center)
            return clone

        return None

    def _clone_items(self, items, assign_name=True):
        """Clones a list of items and returns the clones."""
        return [
            cl
            for cl in [self._clone_item(it, assign_name=assign_name) for it in items]
            if cl is not None
        ]

    def _transform_selection(self, mode="rotate"):
        """Rotates or mirrors the current selection around its center."""
        items = [it for it in self.scene().selectedItems() if it.parentItem() is None]
        if not items:
            return

        # 1. Calculate bounding rect in scene coords
        rect = items[0].sceneBoundingRect()
        for it in items[1:]:
            rect = rect.united(it.sceneBoundingRect())

        # 2. Get center and snap it
        center = rect.center()
        center = self.snap_to_grid(center)

        from PyQt6.QtGui import QTransform
        from opens_suite.commands import TransformItemsCommand

        old_state = {}
        new_state = {}

        for it in items:
            # Current state
            t = it.transform()
            old_pos = it.scenePos()
            old_line = it.line() if hasattr(it, "line") else None

            old_data = [old_pos, (t.m11(), t.m12(), t.m21(), t.m22(), t.dx(), t.dy())]
            if old_line:
                old_data.append(QLineF(old_line))
            old_state[it] = tuple(old_data)

            # Calculate new
            rel_pos = old_pos - center

            if mode == "rotate":
                # Position rotation
                new_rel_pos = QPointF(-rel_pos.y(), rel_pos.x())
                # Local transform rotation
                rot_t = QTransform().rotate(90)
                new_t = rot_t * t
            else:  # mirror
                # Position mirroring
                new_rel_pos = QPointF(-rel_pos.x(), rel_pos.y())
                # Local transform mirroring
                mirror_t = QTransform(-1, 0, 0, 1, 0, 0)
                new_t = mirror_t * t

            # Base new position (un-snapped yet, will snap below)
            raw_new_pos = center + new_rel_pos

            if isinstance(it, Wire):
                # For wires, we want to convert the transform into line endpoints
                # and snap them to ensure they are on grid.
                temp_w = Wire(old_line.p1(), old_line.p2())
                temp_w.setPos(raw_new_pos)
                temp_w.setTransform(new_t)

                p1_s = self.snap_to_grid(temp_w.mapToScene(old_line.p1()))
                p2_s = self.snap_to_grid(temp_w.mapToScene(old_line.p2()))

                # New state for wire: pos (0,0), identity transform, updated line
                new_state[it] = (
                    QPointF(0, 0),
                    (1, 0, 0, 1, 0, 0),  # Identity
                    QLineF(p1_s, p2_s),
                )
            else:
                # For components, just snap the position and keep the transform
                new_state[it] = (
                    self.snap_to_grid(raw_new_pos),
                    (
                        new_t.m11(),
                        new_t.m12(),
                        new_t.m21(),
                        new_t.m22(),
                        new_t.dx(),
                        new_t.dy(),
                    ),
                )

        cmd = TransformItemsCommand(items, old_state, new_state)
        self.undo_stack.push(cmd)
        self.recalculate_connectivity()

waveform_viewer

WaveformViewer

Bases: QMainWindow

Source code in src/opens_suite/waveform_viewer.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
class WaveformViewer(QMainWindow):
    openCalculatorRequested = pyqtSignal()
    refreshRequested = pyqtSignal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Waveform Viewer")
        self.resize(1200, 800)

        # Internal state
        self.plots = []  # List of pg.PlotItem
        self.signals = {}  # {name: SignalItem}
        self.cursors = {}  # {'A': line, 'B': line, 'Probe': line}

        self.last_mouse_scene_pos = None
        self.custom_markers = []
        self.markers_data = {}
        self.selected_signal = None

        self.hover_text = pg.TextItem(
            "", color="black", fill=pg.mkBrush(255, 255, 255, 200)
        )
        self.hover_text.setZValue(100)
        self.hover_text_added_to = None

        self._setup_ui()
        self._setup_toolbar()

    def _setup_ui(self):
        self.central_widget = QSplitter(Qt.Orientation.Horizontal)
        self.setCentralWidget(self.central_widget)

        # Graphics Layout
        self.glw = pg.GraphicsLayoutWidget()
        self.glw.ci.setSpacing(0)
        self.central_widget.addWidget(self.glw)

        # Signal Browser Dock
        self.browser_dock = QDockWidget("Signals", self)
        self.signal_tree = QTreeWidget()
        self.signal_tree.setHeaderLabels(["Plot / Signal", "Value"])
        self.signal_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.signal_tree.customContextMenuRequested.connect(self._show_browser_menu)
        self.signal_tree.itemSelectionChanged.connect(self._on_tree_selection_changed)
        self.browser_dock.setWidget(self.signal_tree)
        self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.browser_dock)

        # Status Bar for measurements
        self.status = QStatusBar()
        self.setStatusBar(self.status)
        self.status.showMessage("Ready")

        # Measurements Dock
        self.measurements_dock = QDockWidget("Measurements", self)
        self.measurements_text = QTextEdit()
        self.measurements_text.setReadOnly(True)
        # Use Monospace font for alignment
        font = self.measurements_text.font()
        font.setFamily("Courier New")
        self.measurements_text.setFont(font)
        self.measurements_dock.setWidget(self.measurements_text)
        self.addDockWidget(
            Qt.DockWidgetArea.BottomDockWidgetArea, self.measurements_dock
        )

        self.glw.scene().sigMouseMoved.connect(self.on_mouse_moved)

    def on_mouse_moved(self, pos):
        self.last_mouse_scene_pos = pos

        view_box = None
        plot_item = None
        for p in self.plots:
            vb = p.getViewBox()
            if vb.sceneBoundingRect().contains(pos):
                view_box = vb
                plot_item = p
                break

        if not view_box:
            if self.hover_text_added_to:
                try:
                    self.hover_text_added_to.removeItem(self.hover_text)
                except Exception:
                    pass
                self.hover_text_added_to = None
            return

        mouse_point = view_box.mapSceneToView(pos)
        mx, my = mouse_point.x(), mouse_point.y()

        min_dist = float("inf")
        best_pt = None
        best_name = None
        rect = view_box.viewRect()
        rx, ry = rect.width(), rect.height()
        if rx == 0 or ry == 0:
            return

        for sig in self.signals.values():
            if len(self.plots) > sig.axis_idx and self.plots[sig.axis_idx] == plot_item:
                dx = (sig.x - mx) / rx
                dy = (sig.y - my) / ry
                dist = dx**2 + dy**2
                idx = np.argmin(dist)
                d = dist[idx]
                if d < 0.005 and d < min_dist:
                    min_dist = d
                    best_pt = (sig.x[idx], sig.y[idx])
                    best_name = sig.name

        if best_pt:
            from opens_suite.design_points import DesignPoints

            x_str = DesignPoints._format_si(best_pt[0])
            y_str = DesignPoints._format_si(best_pt[1])
            self.hover_text.setText(f"{best_name}\nx={x_str}\ny={y_str}")
            self.hover_text.setPos(best_pt[0], best_pt[1])
            if self.hover_text_added_to != plot_item:
                if self.hover_text_added_to:
                    try:
                        self.hover_text_added_to.removeItem(self.hover_text)
                    except Exception:
                        pass
                plot_item.addItem(self.hover_text)
                self.hover_text_added_to = plot_item
        else:
            if self.hover_text_added_to:
                try:
                    self.hover_text_added_to.removeItem(self.hover_text)
                except Exception:
                    pass
                self.hover_text_added_to = None

    def keyPressEvent(self, event):
        key = event.text().upper()
        if key == "F":
            for p in self.plots:
                p.autoRange()
        elif key == "R":
            self.refreshRequested.emit()
        elif key in ["A", "B", "V", "H", "E"]:
            self.handle_cursor_key(key)
        else:
            super().keyPressEvent(event)

    def handle_cursor_key(self, key):
        if key == "E":
            for item in self.custom_markers:
                for p in self.plots:
                    try:
                        p.removeItem(item)
                    except:
                        pass
            self.custom_markers.clear()
            self.markers_data.clear()
            self._update_measurements()
            return

        if not self.last_mouse_scene_pos:
            return
        pos = self.last_mouse_scene_pos

        view_box = None
        plot_item = None
        for p in self.plots:
            vb = p.getViewBox()
            if vb.sceneBoundingRect().contains(pos):
                view_box = vb
                plot_item = p
                break

        if not view_box:
            return

        mouse_point = view_box.mapSceneToView(pos)
        mx, my = mouse_point.x(), mouse_point.y()

        if key in ["A", "B"]:
            min_dist = float("inf")
            best_pt = None
            rect = view_box.viewRect()
            rx, ry = rect.width(), rect.height()
            if rx == 0 or ry == 0:
                return

            for sig in self.signals.values():
                if self.plots[sig.axis_idx] == plot_item:
                    dx = (sig.x - mx) / rx
                    dy = (sig.y - my) / ry
                    dist = dx**2 + dy**2
                    idx = np.argmin(dist)
                    d = dist[idx]
                    if d < min_dist:
                        min_dist = d
                        best_pt = (sig.x[idx], sig.y[idx])

            if best_pt:
                old_item = self.markers_data.get(f"{key}_item")
                if old_item:
                    for p in self.plots:
                        try:
                            p.removeItem(old_item)
                        except:
                            pass
                    if old_item in self.custom_markers:
                        self.custom_markers.remove(old_item)
                old_text = self.markers_data.get(f"{key}_text")
                if old_text:
                    for p in self.plots:
                        try:
                            p.removeItem(old_text)
                        except:
                            pass
                    if old_text in self.custom_markers:
                        self.custom_markers.remove(old_text)

                color = "cyan" if key == "A" else "yellow"
                scatter = pg.ScatterPlotItem(
                    size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
                )
                scatter.addPoints([{"pos": best_pt}])
                plot_item.addItem(scatter)
                self.custom_markers.append(scatter)

                text = pg.TextItem(f"{key}", color=color, anchor=(0, 1))
                text.setPos(*best_pt)
                plot_item.addItem(text)
                self.custom_markers.append(text)

                self.markers_data[f"{key}_item"] = scatter
                self.markers_data[f"{key}_text"] = text
                self.markers_data[key] = best_pt

                self._update_measurements()

        elif key == "V":
            line = pg.InfiniteLine(
                angle=90,
                movable=False,
                pos=mx,
                pen=pg.mkPen("red", width=1, style=Qt.PenStyle.DashLine),
            )
            plot_item.addItem(line)
            self.custom_markers.append(line)

            vals = []
            for sig in self.signals.values():
                try:
                    sort_idx = np.argsort(sig.x)
                    sx = sig.x[sort_idx]
                    sy = sig.y[sort_idx]
                    if sx[0] <= mx <= sx[-1]:
                        y_val = np.interp(mx, sx, sy)
                        vals.append(f"{sig.name}: y={y_val:.4g}")
                except:
                    pass

            text = "V-Line @ x={:.4g}\n".format(mx) + "\n".join(vals)
            self.markers_data[f"V_{mx}_{len(self.custom_markers)}"] = text
            self._update_measurements()

        elif key == "H":
            line = pg.InfiniteLine(
                angle=0,
                movable=False,
                pos=my,
                pen=pg.mkPen("green", width=1, style=Qt.PenStyle.DashLine),
            )
            plot_item.addItem(line)
            self.custom_markers.append(line)

            vals = []
            for sig in self.signals.values():
                if self.plots[sig.axis_idx] == plot_item:
                    try:
                        sy = sig.y - my
                        crossings = np.where(np.diff(np.sign(sy)))[0]
                        if len(crossings) > 0:
                            xs = []
                            for c in crossings[:5]:
                                if sy[c] == sy[c + 1]:
                                    x_val = sig.x[c]
                                else:
                                    x_val = sig.x[c] - sy[c] * (
                                        sig.x[c + 1] - sig.x[c]
                                    ) / (sy[c + 1] - sy[c])
                                xs.append(f"{x_val:.4g}")
                            res = f"{sig.name}: x=" + ", ".join(xs)
                            if len(crossings) > 5:
                                res += " ..."
                            vals.append(res)
                    except:
                        pass

            text = "H-Line @ y={:.4g}\n".format(my) + "\n".join(vals)
            self.markers_data[f"H_{my}_{len(self.custom_markers)}"] = text
            self._update_measurements()

    def _update_measurements(self):
        lines = []
        if "A" in self.markers_data:
            ax, ay = self.markers_data["A"]
            lines.append(f"A: x={ax:.4g}, y={ay:.4g}")
        if "B" in self.markers_data:
            bx, by = self.markers_data["B"]
            lines.append(f"B: x={bx:.4g}, y={by:.4g}")
        if "A" in self.markers_data and "B" in self.markers_data:
            ax, ay = self.markers_data["A"]
            bx, by = self.markers_data["B"]
            dx = bx - ax
            dy = by - ay
            lines.append(f"Delta (B-A): dx={dx:.4g}, dy={dy:.4g}")

        for k, v in self.markers_data.items():
            if k.startswith("V_") or k.startswith("H_"):
                lines.append("-" * 20)
                lines.append(v)

        self.measurements_text.setPlainText("\n".join(lines))

    def _setup_toolbar(self):
        toolbar = self.addToolBar("Cursor Tools")

        # Calculator
        calc_icon = QIcon(
            os.path.join(os.path.dirname(__file__), "assets", "icons", "calculator.svg")
        )
        self.calc_action = QAction(calc_icon, "Calculator", self)
        self.calc_action.triggered.connect(self.openCalculatorRequested.emit)
        toolbar.addAction(self.calc_action)

        toolbar.addSeparator()

        # Rect Zoom Mode (standard)
        self.rect_zoom_action = QAction("Rect Zoom", self)
        self.rect_zoom_action.setCheckable(True)
        self.rect_zoom_action.setChecked(
            False
        )  # Default to False -> PanMode (right click scale/zoom, left click pan/click)
        self.rect_zoom_action.triggered.connect(self._toggle_rect_zoom)
        toolbar.addAction(self.rect_zoom_action)

        toolbar.addSeparator()

        self.cursor_a_action = QAction("Cursor A", self)
        self.cursor_a_action.setCheckable(True)
        self.cursor_a_action.triggered.connect(lambda: self.toggle_cursor("A"))
        toolbar.addAction(self.cursor_a_action)

        self.cursor_b_action = QAction("Cursor B", self)
        self.cursor_b_action.setCheckable(True)
        self.cursor_b_action.triggered.connect(lambda: self.toggle_cursor("B"))
        toolbar.addAction(self.cursor_b_action)

        toolbar.addSeparator()

        self.probe_cursor_action = QAction("Probe Cursor", self)
        self.probe_cursor_action.setCheckable(True)
        self.probe_cursor_action.triggered.connect(lambda: self.toggle_cursor("Probe"))
        toolbar.addAction(self.probe_cursor_action)

    def _toggle_rect_zoom(self, checked):
        mode = pg.ViewBox.RectMode if checked else pg.ViewBox.PanMode
        for p in self.plots:
            p.getViewBox().setMouseMode(mode)

    def _get_or_create_axis(self, idx):
        while len(self.plots) <= idx:
            # Create new plot
            p = self.glw.addPlot(row=len(self.plots), col=0)
            p.showGrid(x=True, y=True, alpha=0.3)
            p.getViewBox().setMouseMode(
                pg.ViewBox.RectMode
                if self.rect_zoom_action.isChecked()
                else pg.ViewBox.PanMode
            )

            # Sync X axis
            if len(self.plots) > 0:
                p.setXLink(self.plots[0])

            self.plots.append(p)
            self._update_tree()
        return self.plots[idx]

    def add_signal(self, name, x, y, axis_idx=0, color=None):
        if color is None:
            # Simple color rotation
            colors = ["y", "g", "c", "m", "r", "w"]
            color = colors[len(self.signals) % len(colors)]

        plot = self._get_or_create_axis(axis_idx)

        # High performance PlotDataItem
        item = pg.PlotDataItem(
            x,
            y,
            pen=pg.mkPen(color, width=1.5),
            name=name,
        )

        # Make the curve selectable via left-click!
        item.curve.setClickable(True)
        # Mouse event is emitted from PlotCurveItem (item.curve)
        item.curve.sigClicked.connect(
            lambda c, evt, n=name: self._on_curve_clicked(n, c, evt)
        )

        plot.addItem(item)

        self.signals[name] = SignalItem(name, x, y, item, axis_idx)
        self._update_tree()

    def remove_signal(self, name):
        if name in self.signals:
            sig = self.signals.pop(name)
            self.plots[sig.axis_idx].removeItem(sig.plot_data_item)
            self._update_tree()

    def move_signal(self, name, target_idx):
        if name in self.signals:
            sig = self.signals[name]
            if sig.axis_idx == target_idx:
                return

            # Remove from old
            self.plots[sig.axis_idx].removeItem(sig.plot_data_item)

            # Add to new
            target_plot = self._get_or_create_axis(target_idx)
            target_plot.addItem(sig.plot_data_item)
            sig.axis_idx = target_idx
            self._update_tree()

    def _highlight_signal(self, name):
        """Highlight a signal by making it bold and bringing it to top."""
        self.selected_signal = name
        for sig_name, sig in self.signals.items():
            # In pyqtgraph, pen is stored in opts['pen']
            old_pen = sig.plot_data_item.opts.get("pen")

            if sig_name == name:
                width = 4
                sig.plot_data_item.setZValue(10)
            else:
                width = 1.5
                sig.plot_data_item.setZValue(0)

            # Create a new pen with the same color but different width
            new_pen = pg.mkPen(old_pen)
            new_pen.setWidthF(width)
            sig.plot_data_item.setPen(new_pen)

    def _on_tree_selection_changed(self):
        selected_items = self.signal_tree.selectedItems()
        if not selected_items:
            return

        item = selected_items[0]
        # Check if it's a signal item (has a parent)
        if item.parent():
            sig_name = item.text(0)
            self._highlight_signal(sig_name)

    def _on_curve_clicked(self, name, curve_item, event):
        if event.button() != Qt.MouseButton.LeftButton:
            return

        # Select in tree
        match = self.signal_tree.findItems(
            name, Qt.MatchFlag.MatchExactly | Qt.MatchFlag.MatchRecursive
        )
        if match:
            # Block signals to avoid recursion if we want, but here it's fine
            self.signal_tree.setCurrentItem(match[0])

        self._highlight_signal(name)
        event.accept()

    def _show_browser_menu(self, pos):
        item = self.signal_tree.itemAt(pos)
        if not item:
            return

        menu = QMenu()
        # Check if it's a signal item (has a parent)
        if item.parent():
            sig_name = item.text(0)

            del_action = menu.addAction(f"Delete '{sig_name}'")
            move_menu = menu.addMenu("Move to Axis")
            for i in range(len(self.plots)):
                move_menu.addAction(f"Plot {i+1}")
            move_menu.addAction("New Plot")

            action = menu.exec(self.signal_tree.viewport().mapToGlobal(pos))
            if action == del_action:
                self.remove_signal(sig_name)
            elif action and action.parent() == move_menu:
                if action.text() == "New Plot":
                    self.move_signal(sig_name, len(self.plots))
                else:
                    target = int(action.text().split()[-1]) - 1
                    self.move_signal(sig_name, target)
        else:
            # Plot item? Maybe allow deleting the whole plot?
            plot_idx = self.signal_tree.indexOfTopLevelItem(item)
            del_plot_action = menu.addAction(f"Remove Plot {plot_idx+1}")
            action = menu.exec(self.signal_tree.viewport().mapToGlobal(pos))
            if action == del_plot_action:
                self.remove_plot(plot_idx)

    def remove_plot(self, idx):
        if 0 <= idx < len(self.plots):
            p = self.plots.pop(idx)
            # Remove all signals in this plot from self.signals
            to_remove = [
                name for name, sig in self.signals.items() if sig.axis_idx == idx
            ]
            for r in to_remove:
                del self.signals[r]

            # Reparent or shift indices of other signals?
            # For simplicity, just remove it from layout
            self.glw.removeItem(p)

            # Shift indices
            for sig in self.signals.values():
                if sig.axis_idx > idx:
                    sig.axis_idx -= 1

            self._update_tree()

    def _update_tree(self):
        self.signal_tree.clear()
        for i, plot in enumerate(self.plots):
            plot_item = QTreeWidgetItem([f"Plot {i+1}"])
            self.signal_tree.addTopLevelItem(plot_item)
            for sig_name, sig in self.signals.items():
                if sig.axis_idx == i:
                    sig_node = QTreeWidgetItem([sig_name, ""])
                    plot_item.addChild(sig_node)
        self.signal_tree.expandAll()

    def toggle_cursor(self, label):
        if label in self.cursors:
            # Remove
            line = self.cursors.pop(label)
            for p in self.plots:
                p.removeItem(line)
            self._update_cursor_readouts()
            return

        # Add
        line = pg.InfiniteLine(
            angle=90,
            movable=True,
            pen=pg.mkPen(
                (
                    QColor("cyan")
                    if label == "A"
                    else QColor("yellow") if label == "B" else QColor("white")
                ),
                width=1,
                style=Qt.PenStyle.DashLine,
            ),
        )
        self.cursors[label] = line

        # Add to all plots (they will be synced)
        for p in self.plots:
            p.addItem(line)

        line.sigPositionChanged.connect(self._update_cursor_readouts)
        self._update_cursor_readouts()

    def _update_cursor_readouts(self):
        msg = []
        if "A" in self.cursors:
            x_a = self.cursors["A"].value()
            msg.append(f"A: {x_a:.4g}")
        if "B" in self.cursors:
            x_b = self.cursors["B"].value()
            msg.append(f"B: {x_b:.4g}")
        if "A" in self.cursors and "B" in self.cursors:
            dx = abs(self.cursors["B"].value() - self.cursors["A"].value())
            msg.append(f"dX: {dx:.4g}")

        self.status.showMessage(" | ".join(msg) if msg else "Ready")

        # Update values in the tree for Probe or active cursors
        # We can also implement a more efficient readout here
        pass

    def clear(self):
        self.glw.clear()
        self.plots = []
        self.signals = {}
        self.cursors = {}
        self.signal_tree.clear()

    # API for Calculator Dialog compatibility
    def subaxis(self, nrows, idx):
        # idx is 1-based usually in matplotlib
        return self._get_or_create_axis(idx - 1)

    def plot(self, x, y=None, label=None, **kwargs):
        if y is None:
            y = x
            x = np.arange(len(y))

        if np.iscomplexobj(y):
            y = np.abs(y)

        # Use latest axis by default or first
        axis_idx = len(self.plots) - 1 if self.plots else 0
        self.add_signal(label or f"Signal {len(self.signals)}", x, y, axis_idx=axis_idx)

    def bode(self, complex_y, label=None):
        """Magnitude/Phase plots for AC results."""
        # Try to find frequency vector from existing signals
        f = None
        for sig in self.signals.values():
            if sig.name.lower() in ["f", "frequency"]:
                f = sig.x
                break

        if f is None:
            # Fallback logspace
            f = np.logspace(0, 9, len(complex_y))

        mag_db = 20 * np.log10(np.abs(complex_y))
        ph_deg = np.angle(complex_y, deg=True)

        idx_mag = len(self.plots)
        self.subaxis(2, idx_mag + 1)
        self.add_signal(f"{label} (Mag)", f, mag_db, axis_idx=idx_mag)
        self.plots[idx_mag].setLogMode(x=True, y=False)
        self.plots[idx_mag].setLabel("left", "Magnitude", "dB")

        idx_ph = len(self.plots)
        self.subaxis(2, idx_ph + 1)
        self.add_signal(f"{label} (Phase)", f, ph_deg, axis_idx=idx_ph)
        self.plots[idx_ph].setLogMode(x=True, y=False)
        self.plots[idx_ph].setLabel("left", "Phase", "deg")
        self.plots[idx_ph].setLabel("bottom", "Frequency", "Hz")
        self.plots[idx_ph].setXLink(self.plots[idx_mag])
bode(complex_y, label=None)

Magnitude/Phase plots for AC results.

Source code in src/opens_suite/waveform_viewer.py
def bode(self, complex_y, label=None):
    """Magnitude/Phase plots for AC results."""
    # Try to find frequency vector from existing signals
    f = None
    for sig in self.signals.values():
        if sig.name.lower() in ["f", "frequency"]:
            f = sig.x
            break

    if f is None:
        # Fallback logspace
        f = np.logspace(0, 9, len(complex_y))

    mag_db = 20 * np.log10(np.abs(complex_y))
    ph_deg = np.angle(complex_y, deg=True)

    idx_mag = len(self.plots)
    self.subaxis(2, idx_mag + 1)
    self.add_signal(f"{label} (Mag)", f, mag_db, axis_idx=idx_mag)
    self.plots[idx_mag].setLogMode(x=True, y=False)
    self.plots[idx_mag].setLabel("left", "Magnitude", "dB")

    idx_ph = len(self.plots)
    self.subaxis(2, idx_ph + 1)
    self.add_signal(f"{label} (Phase)", f, ph_deg, axis_idx=idx_ph)
    self.plots[idx_ph].setLogMode(x=True, y=False)
    self.plots[idx_ph].setLabel("left", "Phase", "deg")
    self.plots[idx_ph].setLabel("bottom", "Frequency", "Hz")
    self.plots[idx_ph].setXLink(self.plots[idx_mag])

xyce_runner

XyceRunner

Bases: QObject

Source code in src/opens_suite/xyce_runner.py
class XyceRunner(QObject):
    simulationFinished = pyqtSignal(int, int)  # exit_code, exit_status
    readyReadStandardOutput = pyqtSignal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.process = None

    @classmethod
    def get_executable_path(cls):
        system = platform.system().lower()
        if system == "windows":
            plat = "win64"
            exe = "Xyce.exe"
        elif system == "darwin":
            # Check for intel_mac vs macos if necessary, default to intel_mac based on user info
            base_xyce = os.path.join(os.path.dirname(os.path.abspath(__file__)), "xyce")
            # Prioritize intel_mac if it exists
            if os.path.exists(os.path.join(base_xyce, "intel_mac", "bin", "Xyce")):
                plat = "intel_mac"
            elif os.path.exists(os.path.join(base_xyce, "darwin", "bin", "Xyce")):
                plat = "darwin"
            else:
                plat = "macos"
            exe = "Xyce"
        else:
            plat = "linux"
            exe = "Xyce"

        xyce_path = os.path.join(
            os.path.dirname(os.path.abspath(__file__)),
            "xyce",
            plat,
            "bin",
            exe,
        )

        generic_path = os.path.join(
            os.path.dirname(os.path.abspath(__file__)),
            "xyce",
            "bin",
            exe,
        )

        if not os.path.exists(xyce_path) and os.path.exists(generic_path):
            xyce_path = generic_path

        # Fallback to system Xyce if bundled one is missing
        if not os.path.exists(xyce_path):
            import shutil

            system_xyce = shutil.which("Xyce")
            if system_xyce:
                return system_xyce

        return xyce_path

    def run_cli(self, netlist_path, raw_path):
        """Run Xyce synchronously in CLI mode."""
        xyce_path = self.get_executable_path()
        if not os.path.exists(xyce_path):
            raise FileNotFoundError(f"Xyce executable not found at {xyce_path}")

        # Use netlist directory as CWD
        cwd = os.path.dirname(os.path.abspath(netlist_path))

        proc = subprocess.Popen(
            [xyce_path, "-r", raw_path, netlist_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            cwd=cwd,
        )

        for line in proc.stdout:
            print(line, end="")
        proc.wait()
        return proc.returncode

    def run_async(self, netlist_path, raw_path):
        """Run Xyce asynchronously for GUI, returning the QProcess."""
        xyce_path = self.get_executable_path()
        if not os.path.exists(xyce_path):
            raise FileNotFoundError(f"Xyce executable not found at {xyce_path}")

        if self.process is not None:
            self.process.kill()
            self.process.waitForFinished()

        self.process = QProcess(self)
        self.process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)

        # Use netlist directory as CWD
        cwd = os.path.dirname(os.path.abspath(netlist_path))
        self.process.setWorkingDirectory(cwd)

        # Connect signals
        self.process.readyReadStandardOutput.connect(self._on_ready_read)
        self.process.finished.connect(self._on_finished)

        self.process.start(xyce_path, ["-r", raw_path, netlist_path])
        return self.process

    def _on_ready_read(self):
        if self.process:
            data = (
                self.process.readAllStandardOutput()
                .data()
                .decode("utf-8", errors="replace")
            )
            self.readyReadStandardOutput.emit(data)

    def _on_finished(self, exit_code, exit_status):
        self.simulationFinished.emit(exit_code, exit_status)
        self.process = None

    def kill(self):
        """Kill the running asynchronous process."""
        if self.process and self.process.state() == QProcess.ProcessState.Running:
            self.process.kill()
            self.process.waitForFinished(3000)
            self.process = None
kill()

Kill the running asynchronous process.

Source code in src/opens_suite/xyce_runner.py
def kill(self):
    """Kill the running asynchronous process."""
    if self.process and self.process.state() == QProcess.ProcessState.Running:
        self.process.kill()
        self.process.waitForFinished(3000)
        self.process = None
run_async(netlist_path, raw_path)

Run Xyce asynchronously for GUI, returning the QProcess.

Source code in src/opens_suite/xyce_runner.py
def run_async(self, netlist_path, raw_path):
    """Run Xyce asynchronously for GUI, returning the QProcess."""
    xyce_path = self.get_executable_path()
    if not os.path.exists(xyce_path):
        raise FileNotFoundError(f"Xyce executable not found at {xyce_path}")

    if self.process is not None:
        self.process.kill()
        self.process.waitForFinished()

    self.process = QProcess(self)
    self.process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)

    # Use netlist directory as CWD
    cwd = os.path.dirname(os.path.abspath(netlist_path))
    self.process.setWorkingDirectory(cwd)

    # Connect signals
    self.process.readyReadStandardOutput.connect(self._on_ready_read)
    self.process.finished.connect(self._on_finished)

    self.process.start(xyce_path, ["-r", raw_path, netlist_path])
    return self.process
run_cli(netlist_path, raw_path)

Run Xyce synchronously in CLI mode.

Source code in src/opens_suite/xyce_runner.py
def run_cli(self, netlist_path, raw_path):
    """Run Xyce synchronously in CLI mode."""
    xyce_path = self.get_executable_path()
    if not os.path.exists(xyce_path):
        raise FileNotFoundError(f"Xyce executable not found at {xyce_path}")

    # Use netlist directory as CWD
    cwd = os.path.dirname(os.path.abspath(netlist_path))

    proc = subprocess.Popen(
        [xyce_path, "-r", raw_path, netlist_path],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        cwd=cwd,
    )

    for line in proc.stdout:
        print(line, end="")
    proc.wait()
    return proc.returncode

xyce_updater

XyceUpdater

Bases: QObject

Source code in src/opens_suite/xyce_updater.py
class XyceUpdater(QObject):
    updateAvailable = pyqtSignal(dict)  # Emits release info if update is available
    noUpdateAvailable = pyqtSignal()
    errorOccurred = pyqtSignal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "xyce")
        self.version_file = os.path.join(self.base_dir, "version_info.json")
        self.API_URL = (
            "https://api.github.com/repos/SeimSoft/xyce-python/releases/latest"
        )

        # Determine platform keyword used in github release assets
        sys_plat = platform.system().lower()
        if sys_plat == "windows":
            self.asset_keyword = "windows"
        elif sys_plat == "darwin":
            # For now, we prefer intel build if architecture is x86_64, else latest
            if platform.machine() == "x86_64":
                self.asset_keyword = "macos-26-intel"
            else:
                self.asset_keyword = "macos"
        else:
            self.asset_keyword = "ubuntu"

    def get_local_info(self):
        """Returns the local version info dict or None if not found."""
        if os.path.exists(self.version_file):
            try:
                with open(self.version_file, "r") as f:
                    return json.load(f)
            except Exception:
                return None
        return None

    def save_local_info(self, info):
        """Saves version info locally."""
        os.makedirs(self.base_dir, exist_ok=True)
        try:
            with open(self.version_file, "w") as f:
                json.dump(info, f, indent=4)
        except Exception as e:
            print(f"Failed to save version info: {e}")

    def check_for_updates(self, force=False):
        """Asynchronously (or quickly) checks for updates via GitHub API."""
        import threading

        def _check():
            try:
                import urllib.request
                import urllib.error

                req = urllib.request.Request(
                    self.API_URL, headers={"User-Agent": "OpenS-Updater"}
                )
                with urllib.request.urlopen(req, timeout=5) as response:
                    data = json.loads(response.read().decode("utf-8"))

                latest_version = data.get("tag_name", "")

                # Find the right asset
                download_url = None
                asset_size = 0
                for asset in data.get("assets", []):
                    # Pick the first asset matching our platform
                    name = asset.get("name", "").lower()
                    if self.asset_keyword in name and (
                        name.endswith(".zip") or name.endswith(".tar.gz")
                    ):
                        # If there are multiple macos, prefer intel specifically if we asked for it
                        if self.asset_keyword == "macos":
                            # General macos - if it's the intel one, skip it unless we are intel
                            if "intel" in name and platform.machine() != "x86_64":
                                continue
                        download_url = asset.get("browser_download_url")
                        asset_size = asset.get("size", 0)
                        break

                if not download_url:
                    self.errorOccurred.emit(
                        "No compatible Xyce release found for your platform."
                    )
                    return

                # Calculate a pseudo-hash using the tag and asset size
                # (since GitHub releases API doesn't provide commit hashes directly for assets without extra queries)
                latest_hash = f"{latest_version}_{asset_size}"

                local_info = self.get_local_info()

                needs_update = False
                if force or not local_info:
                    needs_update = True
                else:
                    if (
                        local_info.get("version") != latest_version
                        or local_info.get("hash") != latest_hash
                    ):
                        needs_update = True

                if needs_update:
                    self.updateAvailable.emit(
                        {
                            "version": latest_version,
                            "hash": latest_hash,
                            "download_url": download_url,
                        }
                    )
                else:
                    self.noUpdateAvailable.emit()

            except Exception as e:
                self.errorOccurred.emit(f"Failed to check for updates: {e}")

        # Run check in background thread so it doesn't freeze the GUI
        t = threading.Thread(target=_check)
        t.daemon = True
        t.start()
check_for_updates(force=False)

Asynchronously (or quickly) checks for updates via GitHub API.

Source code in src/opens_suite/xyce_updater.py
def check_for_updates(self, force=False):
    """Asynchronously (or quickly) checks for updates via GitHub API."""
    import threading

    def _check():
        try:
            import urllib.request
            import urllib.error

            req = urllib.request.Request(
                self.API_URL, headers={"User-Agent": "OpenS-Updater"}
            )
            with urllib.request.urlopen(req, timeout=5) as response:
                data = json.loads(response.read().decode("utf-8"))

            latest_version = data.get("tag_name", "")

            # Find the right asset
            download_url = None
            asset_size = 0
            for asset in data.get("assets", []):
                # Pick the first asset matching our platform
                name = asset.get("name", "").lower()
                if self.asset_keyword in name and (
                    name.endswith(".zip") or name.endswith(".tar.gz")
                ):
                    # If there are multiple macos, prefer intel specifically if we asked for it
                    if self.asset_keyword == "macos":
                        # General macos - if it's the intel one, skip it unless we are intel
                        if "intel" in name and platform.machine() != "x86_64":
                            continue
                    download_url = asset.get("browser_download_url")
                    asset_size = asset.get("size", 0)
                    break

            if not download_url:
                self.errorOccurred.emit(
                    "No compatible Xyce release found for your platform."
                )
                return

            # Calculate a pseudo-hash using the tag and asset size
            # (since GitHub releases API doesn't provide commit hashes directly for assets without extra queries)
            latest_hash = f"{latest_version}_{asset_size}"

            local_info = self.get_local_info()

            needs_update = False
            if force or not local_info:
                needs_update = True
            else:
                if (
                    local_info.get("version") != latest_version
                    or local_info.get("hash") != latest_hash
                ):
                    needs_update = True

            if needs_update:
                self.updateAvailable.emit(
                    {
                        "version": latest_version,
                        "hash": latest_hash,
                        "download_url": download_url,
                    }
                )
            else:
                self.noUpdateAvailable.emit()

        except Exception as e:
            self.errorOccurred.emit(f"Failed to check for updates: {e}")

    # Run check in background thread so it doesn't freeze the GUI
    t = threading.Thread(target=_check)
    t.daemon = True
    t.start()
get_local_info()

Returns the local version info dict or None if not found.

Source code in src/opens_suite/xyce_updater.py
def get_local_info(self):
    """Returns the local version info dict or None if not found."""
    if os.path.exists(self.version_file):
        try:
            with open(self.version_file, "r") as f:
                return json.load(f)
        except Exception:
            return None
    return None
save_local_info(info)

Saves version info locally.

Source code in src/opens_suite/xyce_updater.py
def save_local_info(self, info):
    """Saves version info locally."""
    os.makedirs(self.base_dir, exist_ok=True)
    try:
        with open(self.version_file, "w") as f:
            json.dump(info, f, indent=4)
    except Exception as e:
        print(f"Failed to save version info: {e}")