Skip to content

Controllers

This page provides the documentation for the controllers used by the Application.

TiBi.controllers.AppController

Bases: QObject

Main application controller.

This controller serves as a high-level coordinator in the application, handling communication between different subsystems. It connects signals from various controllers and routes requests to appropriate handlers, ensuring that components remain decoupled from each other.

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

bz_plot_controller BrillouinZonePlotController

Controller of the Broullouin zone graphical component

computation_controller ComputationController

Controller orchestrating computations

main_ui_controller MainUIController

Controller in charge of menus and toolbars

plot_controller PlotController

Controller of the results graphical component

uc_controller UnitCellController

Controller in charge of UnitCell creation/editing

uc_plot_controller UnitCellPlotController

Controller of the UnitCell graphical component

Source code in TiBi/controllers/app_controller.py
 13
 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
class AppController(QObject):
    """
    Main application controller.

    This controller serves as a high-level coordinator in the application,
    handling communication between different subsystems.
    It connects signals from various controllers and routes requests to
    appropriate handlers, ensuring that components remain decoupled from
    each other.

    Attributes
    ----------
    unit_cells : dict[uuid.UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    bz_plot_controller : BrillouinZonePlotController
        Controller of the Broullouin zone graphical component
    computation_controller : ComputationController
        Controller orchestrating computations
    main_ui_controller : MainUIController
        Controller in charge of menus and toolbars
    plot_controller : PlotController
        Controller of the results graphical component
    uc_controller : UnitCellController
        Controller in charge of `UnitCell` creation/editing
    uc_plot_controller : UnitCellPlotController
        Controller of the `UnitCell` graphical component
    """

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        bz_plot_controller: BrillouinZonePlotController,
        computation_controller: ComputationController,
        main_ui_controller: MainUIController,
        plot_controller: PlotController,
        uc_controller: UnitCellController,
        uc_plot_controller: UnitCellPlotController,
    ):
        super().__init__()
        self.unit_cells = unit_cells
        self.selection = selection
        self.bz_plot_controller = bz_plot_controller
        self.computation_controller = computation_controller
        self.main_ui_controller = main_ui_controller
        self.plot_controller = plot_controller
        self.uc_controller = uc_controller
        self.uc_plot_controller = uc_plot_controller

        # Connect signals
        # Redraw the panels only when the unit cell selection changes.
        # Selecting inside the unit cell should not cause redraws.
        self.selection.unit_cell_updated.connect(self._update_panels)
        # When a new site is selected, reddraw only the unit cell plot
        self.selection.site_updated.connect(self._update_unit_cell_plot)

        # bz_plot_controller
        # When the path is updated, the bandstructure is cleared.
        # We pass an empty band structure to the plotting function
        # resulting in a cleared plot.
        self.bz_plot_controller.bz_path_updated.connect(self._plot_bands)

        # computation_controller
        self.computation_controller.status_updated.connect(self._relay_status)
        self.computation_controller.bands_plot_requested.connect(
            self._plot_bands
        )
        self.computation_controller.dos_plot_requested.connect(self._plot_dos)
        # Handle the programmatic selection of an item in the tree
        # due to undo/redo in the hopping controller
        self.computation_controller.selection_requested.connect(
            self.uc_controller.select_item
        )

        # Handle the request to draw hopping segments after a pair of states
        # is selected from the hopping button matrix
        self.computation_controller.hopping_segments_requested.connect(
            self._handle_hopping_segments_requested
        )

        # main_ui_controller
        self.main_ui_controller.project_refresh_requested.connect(
            self._handle_project_refresh_requested
        )
        self.main_ui_controller.unit_cell_update_requested.connect(
            self._update_unit_cell_plot
        )
        # uc_controller
        self.uc_controller.hopping_projection_update_requested.connect(
            self._handle_hopping_projection_update
        )
        # If site parameter changes, the change is purely cosmetic,
        # so the only the unit cell plot is redrawn
        self.uc_controller.site_parameter_changed.connect(
            self._update_unit_cell_plot
        )
        # Unit cell parameter changes typically invalidate
        # derived quantities, requiring a full redraw.
        self.uc_controller.unit_cell_parameter_changed.connect(
            self._update_panels
        )

    def _relay_status(self, msg):
        """
        Send a message to the status bar.

        Parameters
        ----------
        msg : str
            Message to be show in the status bar
        """
        self.main_ui_controller.update_status(msg)

    def _update_panels(self):
        """
        Perform a full redraw of plots and panels.

        This major update is called when the `UnitCell` selection changes,
        `UnitCell` parameter changes or `Site` parameter changes.
        """
        uc_id = self.selection.unit_cell
        if uc_id is not None:
            unit_cell = self.unit_cells[uc_id]

            # Check which vectors of the unit cell are periodic and activate
            # the UC spinners if they are
            self.main_ui_controller.set_spinbox_status(
                unit_cell.v1.is_periodic,
                unit_cell.v2.is_periodic,
                unit_cell.v3.is_periodic,
            )

        # Deactivate the spinners
        else:
            self.main_ui_controller.set_spinbox_status(False, False, False)

        # Update the 3D plots for BZ and UC
        self._update_unit_cell_plot()
        self.bz_plot_controller.update_brillouin_zone()

        # Update the computation panels
        self.computation_controller.update_bands_panel()
        self.computation_controller.update_hopping_panel()

    def _handle_hopping_segments_requested(self):
        """
        Draw hopping segments connecting the selected state pair.

        After a pair of states is selected from the hopping button matrix,
        this function passes them to the update_hopping_segments function
        to draw the lines connecting the source state with the destination
        ones. This approach avoids redrawing the rest of the plot.
        """
        pair_selection = self.computation_controller.get_pair_selection()
        self.uc_plot_controller.update_hopping_segments(pair_selection)

    def _handle_hopping_projection_update(self):
        """
        Redraw the hopping panels and the projection dropbox.

        This function is necessary to make sure that the label names in the
        hopping matrix, hopping table, and projection drop box
        accurately reflect the item names. Additionally, if states are added
        or removed, the hopping matrix needs to be updated.
        """
        self.computation_controller.update_hopping_panel()
        self.computation_controller.update_projection_combo()

    def _handle_project_refresh_requested(self):
        """
        Reset the selection and the tree, and do a full redraw.

        The unit cell dictionary and
        the project path have already been updated.
        Here, only the selection and views are refreshed.
        """
        # Current selection state (tracks which items are selected in the UI)
        self.selection.set_selection(uc_id=None, site_id=None, state_id=None)
        self.uc_controller.refresh_tree()
        self._update_panels()

    def _update_unit_cell_plot(self):
        """
        Redraw the unit cell plot.

        Called during the full panels update or when site parameters change.
        """
        n1, n2, n3, wireframe_shown = (
            self.main_ui_controller.get_uc_plot_properties()
        )
        self.uc_plot_controller.update_unit_cell(wireframe_shown, n1, n2, n3)
        # If a pair of states is selected, also plot the hopping segments
        pair_selection = self.computation_controller.get_pair_selection()
        if pair_selection[0] is not None and pair_selection[1] is not None:
            self.uc_plot_controller.update_hopping_segments(pair_selection)

    def _plot_bands(self):
        """
        Plot the bands for the selected `UnitCell`.
        """
        idx = self.computation_controller.get_projection_indices()
        self.plot_controller.plot_band_structure(idx)

    def _plot_dos(self):
        """
        Plot the DOS for the selected `UnitCell`.
        """
        idx = self.computation_controller.get_projection_indices()
        num_bins, plot_type, broadening = (
            self.computation_controller.get_dos_properties()
        )
        self.plot_controller.plot_dos(num_bins, idx, plot_type, broadening)

TiBi.controllers.bands_controller.BandsController

Bases: QObject

Controller for the hopping parameter interface.

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

bands_panel BandsPanel

Main panel for bands and BZ grid calculations

bands_plot_requested Signal

Request band plots update.

dos_plot_requested Signal

Request DOS plots update.

status_updated Signal(str)

Update the status bar information.

Methods:

Name Description
get_dos_properties

Get the DOS properties for the plots.

get_projection_indices

Get the states selected for projection from the dropdown menu.

update_bands_panel

Update the BandsPanel.

update_combo

Update the states in the combo box.

Source code in TiBi/controllers/bands_controller.py
 12
 13
 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
class BandsController(QObject):
    """
    Controller for the hopping parameter interface.

    Attributes
    ----------
    unit_cells : dict[UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    bands_panel : BandsPanel
        Main panel for bands and BZ grid calculations
    bands_plot_requested : Signal
        Request band plots update.
    dos_plot_requested : Signal
        Request DOS plots update.
    status_updated : Signal(str)
        Update the status bar information.

    Methods
    -------
    get_dos_properties()
        Get the DOS properties for the plots.
    get_projection_indices()
        Get the states selected for projection from the dropdown menu.
    update_bands_panel()
        Update the `BandsPanel`.
    update_combo()
        Update the states in the combo box.
    """

    bands_plot_requested = Signal()
    dos_plot_requested = Signal()
    status_updated = Signal(str)

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        bands_panel: BandsPanel,
    ):
        super().__init__()
        self.unit_cells = unit_cells
        self.selection = selection
        self.bands_panel = bands_panel
        # Conenct the signals
        self.bands_panel.compute_bands_btn.clicked.connect(self._compute_bands)
        self.bands_panel.compute_grid_btn.clicked.connect(self._compute_grid)
        self.bands_panel.proj_combo.selection_changed.connect(
            lambda _: (
                self.bands_plot_requested.emit()
                if (self.bands_panel.radio_group.checkedId() == 0)
                else self.dos_plot_requested.emit()
            )
        )
        self.bands_panel.select_all_btn.clicked.connect(
            self.bands_panel.proj_combo.select_all
        )
        self.bands_panel.clear_all_btn.clicked.connect(
            self.bands_panel.proj_combo.clear_selection
        )
        # Toggle whether to show bands or DOS:
        self.bands_panel.radio_group.buttonToggled.connect(
            lambda _, checked: (
                (
                    self.bands_plot_requested.emit()
                    if (self.bands_panel.radio_group.checkedId() == 0)
                    else self.dos_plot_requested.emit()
                )
                if checked
                else None
            )
        )
        # Toggle between Histogram and Lorentzian.
        # Only trigger if DOS is selected
        self.bands_panel.presentation_choice_group.buttonToggled.connect(
            lambda _, checked: (
                self.dos_plot_requested.emit()
                if (
                    checked and (self.bands_panel.radio_group.checkedId() == 1)
                )
                else None
            )
        )
        # Trigger plots when changing broadening or bin number
        self.bands_panel.broadening_spinbox.editingConfirmed.connect(
            lambda: (
                None
                if (self.bands_panel.radio_group.checkedId() == 0)
                else self.dos_plot_requested.emit()
            )
        )
        self.bands_panel.num_bins_spinbox.editingConfirmed.connect(
            lambda: (
                None
                if (self.bands_panel.radio_group.checkedId() == 0)
                else self.dos_plot_requested.emit()
            )
        )
        # Update the approximate output sizes
        self.bands_panel.n_points_spinbox.valueChanged.connect(
            self._set_approximate_band_output
        )
        self.bands_panel.v1_points_spinbox.valueChanged.connect(
            self._set_approximate_BZ_output
        )
        self.bands_panel.v2_points_spinbox.valueChanged.connect(
            self._set_approximate_BZ_output
        )
        self.bands_panel.v3_points_spinbox.valueChanged.connect(
            self._set_approximate_BZ_output
        )
        self.selection.selection_changed.connect(
            self._set_approximate_band_output
        )

        self.selection.selection_changed.connect(
            self._set_approximate_BZ_output
        )

    def _set_approximate_band_output(self, _=None):
        """
        Update the approximate output size label.
        """
        n_pts = self.bands_panel.n_points_spinbox.value()
        if self.selection.unit_cell:
            n_states = len(
                self.unit_cells[self.selection.unit_cell].get_states()[0]
            )
            # The multiplication by 10 is due to JSON overhead
            # (not being binary)
            res = n_pts * (16 * n_states**2 + 8 * n_states) * 10
        else:
            res = 0
        self.bands_panel.approximate_band_size.setText(
            f"Approximate output size: {res // 1000} kB"
        )

    def _set_approximate_BZ_output(self, _=None):
        """
        Update the approximate output size label.
        """

        n_pts = [
            y
            for y in [
                x.value() * x.isEnabled()
                for x in [
                    self.bands_panel.v1_points_spinbox,
                    self.bands_panel.v2_points_spinbox,
                    self.bands_panel.v3_points_spinbox,
                ]
            ]
            if y > 0
        ]
        if len(n_pts) == 0:
            n_pts = 0
        else:
            n_pts = np.prod(n_pts)
        if self.selection.unit_cell:
            n_states = len(
                self.unit_cells[self.selection.unit_cell].get_states()[0]
            )
            # The multiplication by 10 is due to JSON overhead
            # (not being binary)
            res = n_pts * (16 * n_states**2 + 8 * n_states) * 10
        else:
            res = 0
        self.bands_panel.approximate_BZ_grid_size.setText(
            f"Approximate output size: {res // 1000} kB"
        )

    def _compute_bands(self):
        """
        Calculate the electronic band structure along a specified k-path.

        The path is defined by the special points in the Brillouin zone.
        """
        # Set the radio toggle to the correct option
        for b in self.bands_panel.radio_group.buttons():
            b.blockSignals(True)

        self.bands_panel.bands_radio.setChecked(True)

        for b in self.bands_panel.radio_group.buttons():
            b.blockSignals(False)

        # Get the selected unit cell
        uc_id = self.selection.unit_cell
        unit_cell = self.unit_cells[uc_id]

        # Check if the coupling is Hermitian and only then calculate
        if not unit_cell.is_hermitian():
            self.status_updated.emit(
                "Computation halted: the system is non-Hermitian"
            )
            return
        self.status_updated.emit("Band structure calculation started...")
        num_points = self.bands_panel.n_points_spinbox.value()

        # Get Hamiltonian function
        hamiltonian_func = unit_cell.get_hamiltonian_function()

        # Perform calculation
        k_path = interpolate_k_path(
            unit_cell.bandstructure.special_points, num_points
        )

        # Perform calculation on a separate thread
        self.worker = DiagonalizationWorker(hamiltonian_func, k_path)
        self.thread = QThread()

        self.worker.moveToThread(self.thread)
        self.thread.started.connect(self.worker.do_work)
        self.worker.task_finished.connect(self._handle_band_results)

        self.dialog = ProgressDialog()
        self.worker.progress_updated.connect(self.dialog.update_progress)
        self.worker.task_finished.connect(self.dialog.accept)
        self.worker.task_aborted.connect(self.dialog.reject)
        self.dialog.cancel_requested.connect(
            self.worker.request_abort, Qt.DirectConnection
        )

        # Cleanup
        self.worker.task_finished.connect(self.thread.quit)
        self.worker.task_aborted.connect(self.thread.quit)
        self.thread.finished.connect(self.thread.deleteLater)

        self.thread.start()
        # Only show the dialog after a delay if it's still running
        self._show_timer = QTimer(self)
        self._show_timer.setSingleShot(True)

        def maybe_show_dialog():
            if self.thread.isRunning():
                self.dialog.show()

        self._show_timer.timeout.connect(maybe_show_dialog)
        self._show_timer.start(150)  # Delay in ms

        # Wait for the thread to finish and kill the timer if needed
        self.thread.finished.connect(self._show_timer.stop)

    def _handle_band_results(self, res):
        """
        Handle the results of the band structure calculation.

        Parameters
        ----------
        res : tuple[list[NDArray[np.float64]], \
            list[NDArray[np.float64]], list[NDArray[np.float64]]]
            Contains the eigenvalues, eigenvectors, and k-points
        """
        self.status_updated.emit("Band structure calculation completed.")

        uc_id = self.selection.unit_cell
        unit_cell = self.unit_cells[uc_id]

        eigenvalues, eigenvectors, k_path = res
        unit_cell.bandstructure.eigenvalues = eigenvalues
        unit_cell.bandstructure.eigenvectors = eigenvectors
        unit_cell.bandstructure.path = k_path
        # Update combo to make sure all sites are selected
        self.update_combo()

    def _compute_grid(self):
        """
        Calculate the BZ grid using the settings from the panel.
        """
        # Set the radio toggle to the correct option
        for b in self.bands_panel.radio_group.buttons():
            b.blockSignals(True)

        self.bands_panel.dos_radio.setChecked(True)

        for b in self.bands_panel.radio_group.buttons():
            b.blockSignals(False)

        # Get the selected unit cell
        uc_id = self.selection.unit_cell
        unit_cell = self.unit_cells[uc_id]

        # Check if the coupling is Hermitian and only then calculate
        if not unit_cell.is_hermitian():
            self.status_updated.emit(
                "Computation halted: the system is non-Hermitian"
            )
            return
        self.status_updated.emit("BZ grid calculation started...")
        k_points = get_BZ_grid(
            unit_cell=unit_cell,
            n1=self.bands_panel.v1_points_spinbox.value(),
            n2=self.bands_panel.v2_points_spinbox.value(),
            n3=self.bands_panel.v3_points_spinbox.value(),
            typ=self.bands_panel.grid_choice_group.checkedId(),
        )

        # Get Hamiltonian function
        hamiltonian_func = unit_cell.get_hamiltonian_function()

        self.status_updated.emit("Computing the grid")
        # Perform calculation on a separate thread
        self.worker = DiagonalizationWorker(hamiltonian_func, k_points)
        self.thread = QThread()

        self.worker.moveToThread(self.thread)
        self.thread.started.connect(self.worker.do_work)
        self.worker.task_finished.connect(self._handle_grid_results)

        self.dialog = ProgressDialog()
        self.worker.progress_updated.connect(self.dialog.update_progress)
        self.worker.task_finished.connect(self.dialog.accept)
        self.worker.task_aborted.connect(self.dialog.reject)
        self.dialog.cancel_requested.connect(
            self.worker.request_abort, Qt.DirectConnection
        )

        # Cleanup
        self.worker.task_finished.connect(self.thread.quit)
        self.worker.task_aborted.connect(self.thread.quit)
        self.thread.finished.connect(self.thread.deleteLater)

        self.thread.start()
        # Only show the dialog after a delay if it's still running
        self._show_timer = QTimer(self)
        self._show_timer.setSingleShot(True)

        def maybe_show_dialog():
            if self.thread.isRunning():
                self.dialog.show()

        self._show_timer.timeout.connect(maybe_show_dialog)
        self._show_timer.start(150)  # Delay in ms

        # Wait for the thread to finish and kill the timer if needed
        self.thread.finished.connect(self._show_timer.stop)

    def _handle_grid_results(self, res):
        """
        Handle the results of the BZ grid calculation.

        Parameters
        ----------
        res : tuple[list[NDArray[np.float64]], \
            list[NDArray[np.float64]], list[NDArray[np.float64]]]
            Contains the eigenvalues, eigenvectors, and k-points
        """
        self.status_updated.emit("BZ grid calculation completed.")
        uc_id = self.selection.unit_cell
        unit_cell = self.unit_cells[uc_id]

        eigenvalues, eigenvectors, k_points = res

        unit_cell.bz_grid.grid_divs = (
            self.bands_panel.v1_points_spinbox.value(),
            self.bands_panel.v2_points_spinbox.value(),
            self.bands_panel.v3_points_spinbox.value(),
        )
        unit_cell.bz_grid.eigenvalues = eigenvalues
        unit_cell.bz_grid.eigenvectors = eigenvectors
        unit_cell.bz_grid.k_points = k_points
        unit_cell.bz_grid.is_gamma_centered = bool(
            self.bands_panel.grid_choice_group.checkedId()
        )
        # Update combo to make sure all sites are selected
        self.update_combo()

    def update_bands_panel(self):
        """
        Update the `BandsPanel`.

        The UI components are activated/deactivated based on
        the system parameters.
        Projection menu is also updated programmatically.
        Selection is set to "bands"
        """
        # Set the radio toggle to bands
        for b in self.bands_panel.radio_group.buttons():
            b.blockSignals(True)

        self.bands_panel.bands_radio.setChecked(True)

        for b in self.bands_panel.radio_group.buttons():
            b.blockSignals(False)
        uc_id = self.selection.unit_cell
        if uc_id is None:
            dim = 0
        else:
            unit_cell = self.unit_cells[uc_id]
            # Get the system dimensionality
            dim = (
                unit_cell.v1.is_periodic
                + unit_cell.v2.is_periodic
                + unit_cell.v3.is_periodic
            )
            # Fill out the fields
            bandstructure = unit_cell.bandstructure
            bz_grid = unit_cell.bz_grid

            if bandstructure.path:
                self.bands_panel.n_points_spinbox.setValue(
                    len(bandstructure.path)
                )
            else:
                self.bands_panel.n_points_spinbox.setValue(100)
            # Set the type of the grid
            self.bands_panel.grid_choice_group.button(
                bz_grid.is_gamma_centered
            ).setChecked(True)
            self.bands_panel.v1_points_spinbox.setValue(bz_grid.grid_divs[0])
            self.bands_panel.v2_points_spinbox.setValue(bz_grid.grid_divs[1])
            self.bands_panel.v3_points_spinbox.setValue(bz_grid.grid_divs[2])

        # BZ path selection buttons
        # Activate/deactivate buttons based on dimensionality
        self.bands_panel.add_gamma_btn.setEnabled(dim > 0)
        for btn in self.bands_panel.vertex_btns:
            btn.setEnabled(dim > 0)
        for btn in self.bands_panel.edge_btns:
            btn.setEnabled(dim > 1)
        for btn in self.bands_panel.face_btns:
            btn.setEnabled(dim > 2)

        # Computation and BZ path buttons
        if uc_id is None:
            self.bands_panel.remove_last_btn.setEnabled(False)
            self.bands_panel.clear_path_btn.setEnabled(False)
            self.bands_panel.compute_bands_btn.setEnabled(False)

        else:
            self.bands_panel.remove_last_btn.setEnabled(
                len(unit_cell.bandstructure.special_points) > 0
            )
            self.bands_panel.clear_path_btn.setEnabled(
                len(unit_cell.bandstructure.special_points) > 0
            )
            self.bands_panel.compute_bands_btn.setEnabled(
                len(unit_cell.bandstructure.special_points) > 1
            )

        # BZ grid spinboxes
        self.bands_panel.v1_points_spinbox.setEnabled(dim > 0)
        self.bands_panel.v2_points_spinbox.setEnabled(dim > 1)
        self.bands_panel.v3_points_spinbox.setEnabled(dim > 2)

        # BZ grid spinboxes and button
        self.bands_panel.v1_points_spinbox.setEnabled(dim > 0)
        self.bands_panel.v2_points_spinbox.setEnabled(dim > 1)
        self.bands_panel.v3_points_spinbox.setEnabled(dim > 2)
        self.bands_panel.compute_grid_btn.setEnabled(dim > 0)

        # Update the projection combo
        self.update_combo()

        # Update the approximate output size labels
        self._set_approximate_band_output()
        self._set_approximate_BZ_output()

    def update_combo(self):
        """
        Update the states in the combo box.

        Once the items are updated, the selection buttons are activated
        if the number of items is not zero. Additionally, all the items
        are selected programatically.
        """
        uc_id = self.selection.unit_cell
        if uc_id is None:
            items = []
        else:
            unit_cell = self.unit_cells[uc_id]
            _, state_info = unit_cell.get_states()
            items = [f"{s[0]}.{s[2]}" for s in state_info]
        self.bands_panel.proj_combo.refresh_combo(items)
        self.bands_panel.select_all_btn.setEnabled(len(items) > 0)
        self.bands_panel.clear_all_btn.setEnabled(len(items) > 0)
        self.bands_panel.proj_combo.select_all()

    def get_projection_indices(self):
        """
        Get the indices of the selected states from the projection menu.

        Returns
        -------
        list[int]
            Indices of the selected states
        """
        return self.bands_panel.proj_combo.checked_items()

    def get_dos_properties(self):
        """
        Get the DOS properties for the plots.

        Returns
        -------
        tuple[int, int, np.float64]
            Number of bins/points to be used in the plot, the plot type
            (0 for a histogram, 1 for Lorentzian), and Lorentzian broadening
        """
        num_bins = self.bands_panel.num_bins_spinbox.value()
        plot_type = self.bands_panel.presentation_choice_group.checkedId()
        broadening = self.bands_panel.broadening_spinbox.value()
        return (num_bins, plot_type, broadening)

get_dos_properties()

Get the DOS properties for the plots.

Returns:

Type Description
tuple[int, int, float64]

Number of bins/points to be used in the plot, the plot type (0 for a histogram, 1 for Lorentzian), and Lorentzian broadening

Source code in TiBi/controllers/bands_controller.py
502
503
504
505
506
507
508
509
510
511
512
513
514
515
def get_dos_properties(self):
    """
    Get the DOS properties for the plots.

    Returns
    -------
    tuple[int, int, np.float64]
        Number of bins/points to be used in the plot, the plot type
        (0 for a histogram, 1 for Lorentzian), and Lorentzian broadening
    """
    num_bins = self.bands_panel.num_bins_spinbox.value()
    plot_type = self.bands_panel.presentation_choice_group.checkedId()
    broadening = self.bands_panel.broadening_spinbox.value()
    return (num_bins, plot_type, broadening)

get_projection_indices()

Get the indices of the selected states from the projection menu.

Returns:

Type Description
list[int]

Indices of the selected states

Source code in TiBi/controllers/bands_controller.py
491
492
493
494
495
496
497
498
499
500
def get_projection_indices(self):
    """
    Get the indices of the selected states from the projection menu.

    Returns
    -------
    list[int]
        Indices of the selected states
    """
    return self.bands_panel.proj_combo.checked_items()

update_bands_panel()

Update the BandsPanel.

The UI components are activated/deactivated based on the system parameters. Projection menu is also updated programmatically. Selection is set to "bands"

Source code in TiBi/controllers/bands_controller.py
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
def update_bands_panel(self):
    """
    Update the `BandsPanel`.

    The UI components are activated/deactivated based on
    the system parameters.
    Projection menu is also updated programmatically.
    Selection is set to "bands"
    """
    # Set the radio toggle to bands
    for b in self.bands_panel.radio_group.buttons():
        b.blockSignals(True)

    self.bands_panel.bands_radio.setChecked(True)

    for b in self.bands_panel.radio_group.buttons():
        b.blockSignals(False)
    uc_id = self.selection.unit_cell
    if uc_id is None:
        dim = 0
    else:
        unit_cell = self.unit_cells[uc_id]
        # Get the system dimensionality
        dim = (
            unit_cell.v1.is_periodic
            + unit_cell.v2.is_periodic
            + unit_cell.v3.is_periodic
        )
        # Fill out the fields
        bandstructure = unit_cell.bandstructure
        bz_grid = unit_cell.bz_grid

        if bandstructure.path:
            self.bands_panel.n_points_spinbox.setValue(
                len(bandstructure.path)
            )
        else:
            self.bands_panel.n_points_spinbox.setValue(100)
        # Set the type of the grid
        self.bands_panel.grid_choice_group.button(
            bz_grid.is_gamma_centered
        ).setChecked(True)
        self.bands_panel.v1_points_spinbox.setValue(bz_grid.grid_divs[0])
        self.bands_panel.v2_points_spinbox.setValue(bz_grid.grid_divs[1])
        self.bands_panel.v3_points_spinbox.setValue(bz_grid.grid_divs[2])

    # BZ path selection buttons
    # Activate/deactivate buttons based on dimensionality
    self.bands_panel.add_gamma_btn.setEnabled(dim > 0)
    for btn in self.bands_panel.vertex_btns:
        btn.setEnabled(dim > 0)
    for btn in self.bands_panel.edge_btns:
        btn.setEnabled(dim > 1)
    for btn in self.bands_panel.face_btns:
        btn.setEnabled(dim > 2)

    # Computation and BZ path buttons
    if uc_id is None:
        self.bands_panel.remove_last_btn.setEnabled(False)
        self.bands_panel.clear_path_btn.setEnabled(False)
        self.bands_panel.compute_bands_btn.setEnabled(False)

    else:
        self.bands_panel.remove_last_btn.setEnabled(
            len(unit_cell.bandstructure.special_points) > 0
        )
        self.bands_panel.clear_path_btn.setEnabled(
            len(unit_cell.bandstructure.special_points) > 0
        )
        self.bands_panel.compute_bands_btn.setEnabled(
            len(unit_cell.bandstructure.special_points) > 1
        )

    # BZ grid spinboxes
    self.bands_panel.v1_points_spinbox.setEnabled(dim > 0)
    self.bands_panel.v2_points_spinbox.setEnabled(dim > 1)
    self.bands_panel.v3_points_spinbox.setEnabled(dim > 2)

    # BZ grid spinboxes and button
    self.bands_panel.v1_points_spinbox.setEnabled(dim > 0)
    self.bands_panel.v2_points_spinbox.setEnabled(dim > 1)
    self.bands_panel.v3_points_spinbox.setEnabled(dim > 2)
    self.bands_panel.compute_grid_btn.setEnabled(dim > 0)

    # Update the projection combo
    self.update_combo()

    # Update the approximate output size labels
    self._set_approximate_band_output()
    self._set_approximate_BZ_output()

update_combo()

Update the states in the combo box.

Once the items are updated, the selection buttons are activated if the number of items is not zero. Additionally, all the items are selected programatically.

Source code in TiBi/controllers/bands_controller.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def update_combo(self):
    """
    Update the states in the combo box.

    Once the items are updated, the selection buttons are activated
    if the number of items is not zero. Additionally, all the items
    are selected programatically.
    """
    uc_id = self.selection.unit_cell
    if uc_id is None:
        items = []
    else:
        unit_cell = self.unit_cells[uc_id]
        _, state_info = unit_cell.get_states()
        items = [f"{s[0]}.{s[2]}" for s in state_info]
    self.bands_panel.proj_combo.refresh_combo(items)
    self.bands_panel.select_all_btn.setEnabled(len(items) > 0)
    self.bands_panel.clear_all_btn.setEnabled(len(items) > 0)
    self.bands_panel.proj_combo.select_all()

TiBi.controllers.BrillouinZonePlotController

Bases: QObject

Controller for the Brillouin zone plot view.

This controller manages the 3D visualization of the Brillouin zone. It handles the visualization of selected high-symmetry points within the BZ and paths for band structure calculations.

The controller observes two views: BZ plot itself and a panel in the computation view which allows one to select BZ points and create path.

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

bz_plot_view BrillouinZonePlotView

The view component for displaying the Brillouin zone

computation_view ComputationView

The view component that contains controls for creating a path in the BZ

undo_stack QUndoStack

QUndoStack to hold "undo-able" commands

unit_cell UnitCell

The currently selected unit cell

bz_plot_items dict

Dictionary to store plot items

dim int

Dimensionality of the Brillouin zone

bz_point_selection dict

Indices of the selected high-symmetry points in the BZ

bz_point_lists dict

Lists of high-symmetry points, grouped by type

bz_path_updated Signal

Emitted when the BZ special points path is updated by adding or removing points. Triggers a redraw of the path in the plot.

Methods:

Name Description
update_brillouin_zone

Draw the Brillouin zone of the selected UnitCell.

Source code in TiBi/controllers/bz_plot_controller.py
 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
class BrillouinZonePlotController(QObject):
    """
    Controller for the Brillouin zone plot view.

    This controller manages the 3D visualization of the Brillouin zone.
    It handles the visualization of selected high-symmetry points within
    the BZ and paths for band structure calculations.

    The controller observes two views: BZ plot itself and a panel in the
    computation view which allows one to select BZ points and create
    path.

    Attributes
    ----------
    unit_cells : dict[uuid.UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    bz_plot_view : BrillouinZonePlotView
        The view component for displaying the Brillouin zone
    computation_view : ComputationView
        The view component that contains controls for creating
        a path in the BZ
    undo_stack : QUndoStack
        `QUndoStack` to hold "undo-able" commands
    unit_cell : UnitCell
        The currently selected unit cell
    bz_plot_items : dict
        Dictionary to store plot items
    dim : int
        Dimensionality of the Brillouin zone
    bz_point_selection : dict
        Indices of the selected high-symmetry points in the BZ
    bz_point_lists : dict
        Lists of high-symmetry points, grouped by type
    bz_path_updated : Signal
        Emitted when the BZ special points path is updated
        by adding or removing points. Triggers a redraw of
        the path in the plot.

    Methods
    -------
    update_brillouin_zone()
        Draw the Brillouin zone of the selected `UnitCell`.
    """

    bz_path_updated = Signal()

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        bz_plot_view: BrillouinZonePlotView,
        computation_view: ComputationView,
        undo_stack: QUndoStack,
    ):
        super().__init__()

        self.unit_cells = unit_cells
        self.selection = selection
        self.bz_plot_view = bz_plot_view
        self.computation_view = computation_view
        self.undo_stack = undo_stack

        # Internal controller state
        self.unit_cell = None
        self.bz_plot_items = {}  # Dictionary to store plot items
        self.dim = 0  # Dimensionality
        # Indices of the selected high-symmetry points in the BZ
        self.bz_point_selection = bz_point_selection_init()
        # Lists of high-symmetry points, grouped by type
        self.bz_point_lists = bz_point_lists_init()

        self.bz_path_updated.connect(self._update_path_visualization)

        # Signals from the ComputationView used for selecting/picking
        # high-symmetry points in the BZ
        self.computation_view.bands_panel.add_gamma_btn.clicked.connect(
            lambda: self._add_point("gamma")
        )
        self.computation_view.bands_panel.prev_vertex_btn.clicked.connect(
            lambda: self._select_point(-1, "vertex")
        )
        self.computation_view.bands_panel.next_vertex_btn.clicked.connect(
            lambda: self._select_point(+1, "vertex")
        )
        self.computation_view.bands_panel.add_vertex_btn.clicked.connect(
            lambda: self._add_point("vertex")
        )
        self.computation_view.bands_panel.prev_edge_btn.clicked.connect(
            lambda: self._select_point(-1, "edge")
        )
        self.computation_view.bands_panel.next_edge_btn.clicked.connect(
            lambda: self._select_point(+1, "edge")
        )
        self.computation_view.bands_panel.add_edge_btn.clicked.connect(
            lambda: self._add_point("edge")
        )

        self.computation_view.bands_panel.prev_face_btn.clicked.connect(
            lambda: self._select_point(-1, "face")
        )
        self.computation_view.bands_panel.next_face_btn.clicked.connect(
            lambda: self._select_point(+1, "face")
        )

        self.computation_view.bands_panel.add_face_btn.clicked.connect(
            lambda: self._add_point("face")
        )

        self.computation_view.bands_panel.remove_last_btn.clicked.connect(
            self._remove_last_point
        )
        self.computation_view.bands_panel.clear_path_btn.clicked.connect(
            self._clear_path
        )

    def update_brillouin_zone(self):
        """
        Draw the Brillouin zone of the selected `UnitCell`.

        This method is the core rendering function that:
        1. Clears any existing visualization
        2. Calculates the Brillouin zone vertices and faces
        3. Renders the BZ wireframe and key points (Gamma, vertices,
        edge midpoints, face centers)
        4. Updates UI controls based on the dimensionality of the BZ

        The method is triggered whenever the `UnitCell` changes or
        a new unit cell is selected.
        """
        uc_id = self.selection.unit_cell
        # Clear previous plot items except axes
        for key, item in list(self.bz_plot_items.items()):
            self.bz_plot_view.view.removeItem(item)
            del self.bz_plot_items[key]

        # Indices of the selected high-symmetry points in the BZ
        # The key is the type of the high symmetry point
        # ("face", "edge", "vertex")
        # The values are the cardinal indices of each type
        self.bz_point_selection = bz_point_selection_init()
        # Lists of high-symmetry points, grouped by type.
        # The key is the type of the high symmetry point
        # ("face", "edge", "vertex")
        # The values are arrays of length-3 arrays of coordinates
        self.bz_point_lists = bz_point_lists_init()

        if uc_id is None:
            return
        else:
            self.unit_cell = self.unit_cells[uc_id]

        # Guard against 0-volume Brillouin zone: can occur in the process
        # of creation of the unit cell or due to a mistake
        if self.unit_cell.volume() == 0:
            return

        self.bz_vertices, self.bz_faces = self.unit_cell.get_BZ()

        # Determine system dimensionality
        self.dim = (
            0 if len(self.bz_vertices) == 0 else len(self.bz_vertices[0])
        )

        # Extract vertices and faces from the BZ data
        # Note: In 2D, the faces are equivalent to edges.
        # In 3D, the faces are polygons.
        self.bz_point_lists["vertex"] = np.array(self.bz_vertices)

        if self.dim == 2:
            # Get the edge points
            for edge in self.bz_faces:
                # Midpoint of the edge
                mid_point = np.mean(edge, axis=0)
                self.bz_point_lists["edge"].append(mid_point)
            self.bz_point_lists["edge"] = np.array(self.bz_point_lists["edge"])

        elif self.dim == 3:
            # Use the set of unique edges to avoid duplication
            # due to faces sharing edges
            unique_edges = set()
            edge_midpoints = []

            for face in self.bz_faces:
                for ii in range(len(face)):
                    next_ii = (ii + 1) % len(face)
                    v1 = tuple(face[ii])
                    v2 = tuple(face[next_ii])
                    edge = tuple(sorted((v1, v2)))
                    if edge not in unique_edges:
                        unique_edges.add(edge)
                        midpoint = 0.5 * (np.array(v1) + np.array(v2))
                        edge_midpoints.append(midpoint)

                # Face midpoint (no duplication issue here)
                face_mid = np.mean(face, axis=0)
                self.bz_point_lists["face"].append(face_mid)

            self.bz_point_lists["edge"] = np.array(edge_midpoints)
            self.bz_point_lists["face"] = np.array(self.bz_point_lists["face"])

        # Draw the path
        self._update_path_visualization()
        # Create the BZ wireframe by making edges
        # (connect the vertices based on face data)
        self._create_bz_wireframe()

        # Plot the BZ points as spheres
        # Add Gamma point at origin
        sphere = self._make_point()
        sphere.setColor((1, 1, 1, 1))

        self.bz_plot_view.view.addItem(sphere)
        self.bz_plot_items["Gamma"] = sphere

        # Plot points for vertices, edges, and faces
        for typ, pt in self.bz_point_lists.items():
            if len(pt) > 0:
                # Pad all the points of the same type
                pt_3d = self._pad_to_3d(pt)
                # Select the 1st point of the type
                self.bz_point_selection[typ] = 0
                # Loop over all the padded points
                for ii, p in enumerate(pt_3d):
                    # Make a sphere and position it
                    # at the appropriate location
                    sphere = self._make_point()
                    sphere.translate(p[0], p[1], p[2])
                    self.bz_plot_view.view.addItem(sphere)
                    self.bz_plot_items[f"bz_{typ}_{ii}"] = sphere
                    # Highlight the first point
                    if ii == 0:
                        sphere.setColor(self.bz_plot_view.selected_point_color)

    def _create_bz_wireframe(self):
        """
        Create a wireframe visualization of the Brillouin zone.

        This method extracts edges from the Brillouin zone faces and creates a
        GLLinePlotItem to visualize them.

        For 2D BZ, the wireframe is a polygon outline.
        For 3D BZ, the wireframe is the edges of the polyhedron.
        """
        if len(self.bz_faces) > 0:
            unique_edges = set()

            for face in self.bz_faces:
                for ii in range(len(face)):
                    next_ii = (ii + 1) % len(face)
                    v1 = tuple(face[ii])
                    v2 = tuple(face[next_ii])
                    edge = tuple(sorted((v1, v2)))
                    unique_edges.add(edge)

            # Convert edges to line vertices
            line_vertices = []
            for v1, v2 in unique_edges:
                line_vertices.extend([v1, v2])

            # Make sure all the line vertices are 3D
            line_vertices = self._pad_to_3d(line_vertices)

            # Create a GLLinePlotItem for all BZ edges
            bz_wireframe = gl.GLLinePlotItem(
                pos=np.array(line_vertices),
                color=(1, 1, 1, 0.8),
                width=1,
                mode="lines",
            )
            self.bz_plot_view.view.addItem(bz_wireframe)
            self.bz_plot_items["bz_wireframe"] = bz_wireframe

    def _make_point(self, vertex_size=0.20):
        """Create a sphere mesh item for a point in the BZ."""
        # vertex_size = 1 / (self.unit_cell.volume()) ** (1 / 3) / 4
        return gl.GLMeshItem(
            meshdata=gl.MeshData.sphere(rows=10, cols=10, radius=vertex_size),
            smooth=True,
            color=self.bz_plot_view.point_color,
            shader="shaded",
        )

    def _pad_to_3d(self, points) -> NDArray[np.float64]:
        """
        Ensure points have 3D coordinates by padding with zeros if needed.

        Parameters
        ----------
        points
            Array of point coordinates

        Returns
        -------
        NDArray
            Array of points with 3D coordinates
        """
        pad_width = 3 - self.dim
        if pad_width > 0:
            return np.pad(points, ((0, 0), (0, pad_width)), mode="constant")
        return np.array(points)

    def _select_point(self, step, typ):
        """
        Select a point in the Brillouin zone based on type and
        step direction.

        This method implements the navigation through different types
        of points in the BZ (vertices, edge midpoints, or face centers).
        It updates the visual highlighting to show which point is currently
        selected, and maintains the selection state.

        Parameters
        ----------
        step : int
            Direction to move in the selection
            (+1 for next, -1 for previous)
        typ : str
            Type of point to select ('vertex', 'edge', or 'face')
        """
        # Guard against empty vertex list
        if len(self.bz_point_lists[typ]) == 0:
            return

        prev_point = self.bz_point_selection[typ]
        # Update the selection index
        if prev_point is None:
            self.bz_point_selection[typ] = 0
        else:
            self.bz_point_selection[typ] = (prev_point + step) % len(
                self.bz_point_lists[typ]
            )
            prev_key = f"bz_{typ}_{prev_point}"
            self.bz_plot_items[prev_key].setColor(
                self.bz_plot_view.point_color
            )

        new_key = f"bz_{typ}_{self.bz_point_selection[typ]}"
        self.bz_plot_items[new_key].setColor(
            self.bz_plot_view.selected_point_color
        )

    def _add_point(self, point: str):
        """
        Add a selected point to the Brillouin zone path.

        This method adds the currently selected point (of the specified type)
        to the Brillouin zone path. The path is a sequence of k-points
        that will be used for band structure calculations.
        Points can be the origin (Gamma), vertices, edge midpoints, or
        face centers depending on the dimensionality of the BZ.

        Parameters
        ----------
        point : str
            The type of point to add ("gamma", "vertex", "edge", or "face")
        """

        if point == "gamma":
            point_coord: NDArray[np.float64] = np.array([0.0] * self.dim)
        else:
            if (
                self.bz_point_selection[point] is not None
                and self.bz_point_lists[point] is not None
            ):
                point_coord: NDArray[np.float64] = np.array(
                    self.bz_point_lists[point][self.bz_point_selection[point]]
                )
            else:
                print("No point selected")
                return
        self.undo_stack.push(
            AddBZPointCommand(
                unit_cell=self.unit_cell,
                point=point_coord,
                computation_view=self.computation_view,
                signal=self.bz_path_updated,
            )
        )

    def _remove_last_point(self):
        """Remove the last point added to the path."""
        self.undo_stack.push(
            RemoveBZPointCommand(
                unit_cell=self.unit_cell,
                computation_view=self.computation_view,
                signal=self.bz_path_updated,
            )
        )

    def _clear_path(self):
        """Remove all points from the path."""
        self.undo_stack.push(
            ClearBZPathCommand(
                unit_cell=self.unit_cell,
                computation_view=self.computation_view,
                signal=self.bz_path_updated,
            )
        )

    def _update_path_visualization(self):
        """
        Update the visualization of the BZ path based on current path points.

        This method creates or updates the visual representation of the k-path
        in the Brillouin zone. The path is shown as a series of connected
        line segments between the selected k-points.
        The visualization helps users understand the path along which
        the band structure will be calculated.

        If the path has fewer than 2 points, no visualization is created.
        """
        # Remove existing path visualization if it exists
        if "bz_path" in self.bz_plot_items:
            self.bz_plot_view.view.removeItem(self.bz_plot_items["bz_path"])
            del self.bz_plot_items["bz_path"]
        # Only create visualization if we have at least 2 points
        if len(self.unit_cell.bandstructure.special_points) < 2:
            return

        # Convert path points to 3D if needed
        path_3d = self._pad_to_3d(self.unit_cell.bandstructure.special_points)
        # Create line segments for the path
        path_pos = []
        for ii in range(len(path_3d) - 1):
            # Add both points of each segment
            path_pos.extend([path_3d[ii], path_3d[ii + 1]])
        # Create the path visualization
        path_object = gl.GLLinePlotItem(
            pos=np.array(path_pos), color=CF_RED, width=5, mode="lines"
        )
        self.bz_plot_view.view.addItem(path_object)
        self.bz_plot_items["bz_path"] = path_object

update_brillouin_zone()

Draw the Brillouin zone of the selected UnitCell.

This method is the core rendering function that: 1. Clears any existing visualization 2. Calculates the Brillouin zone vertices and faces 3. Renders the BZ wireframe and key points (Gamma, vertices, edge midpoints, face centers) 4. Updates UI controls based on the dimensionality of the BZ

The method is triggered whenever the UnitCell changes or a new unit cell is selected.

Source code in TiBi/controllers/bz_plot_controller.py
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
def update_brillouin_zone(self):
    """
    Draw the Brillouin zone of the selected `UnitCell`.

    This method is the core rendering function that:
    1. Clears any existing visualization
    2. Calculates the Brillouin zone vertices and faces
    3. Renders the BZ wireframe and key points (Gamma, vertices,
    edge midpoints, face centers)
    4. Updates UI controls based on the dimensionality of the BZ

    The method is triggered whenever the `UnitCell` changes or
    a new unit cell is selected.
    """
    uc_id = self.selection.unit_cell
    # Clear previous plot items except axes
    for key, item in list(self.bz_plot_items.items()):
        self.bz_plot_view.view.removeItem(item)
        del self.bz_plot_items[key]

    # Indices of the selected high-symmetry points in the BZ
    # The key is the type of the high symmetry point
    # ("face", "edge", "vertex")
    # The values are the cardinal indices of each type
    self.bz_point_selection = bz_point_selection_init()
    # Lists of high-symmetry points, grouped by type.
    # The key is the type of the high symmetry point
    # ("face", "edge", "vertex")
    # The values are arrays of length-3 arrays of coordinates
    self.bz_point_lists = bz_point_lists_init()

    if uc_id is None:
        return
    else:
        self.unit_cell = self.unit_cells[uc_id]

    # Guard against 0-volume Brillouin zone: can occur in the process
    # of creation of the unit cell or due to a mistake
    if self.unit_cell.volume() == 0:
        return

    self.bz_vertices, self.bz_faces = self.unit_cell.get_BZ()

    # Determine system dimensionality
    self.dim = (
        0 if len(self.bz_vertices) == 0 else len(self.bz_vertices[0])
    )

    # Extract vertices and faces from the BZ data
    # Note: In 2D, the faces are equivalent to edges.
    # In 3D, the faces are polygons.
    self.bz_point_lists["vertex"] = np.array(self.bz_vertices)

    if self.dim == 2:
        # Get the edge points
        for edge in self.bz_faces:
            # Midpoint of the edge
            mid_point = np.mean(edge, axis=0)
            self.bz_point_lists["edge"].append(mid_point)
        self.bz_point_lists["edge"] = np.array(self.bz_point_lists["edge"])

    elif self.dim == 3:
        # Use the set of unique edges to avoid duplication
        # due to faces sharing edges
        unique_edges = set()
        edge_midpoints = []

        for face in self.bz_faces:
            for ii in range(len(face)):
                next_ii = (ii + 1) % len(face)
                v1 = tuple(face[ii])
                v2 = tuple(face[next_ii])
                edge = tuple(sorted((v1, v2)))
                if edge not in unique_edges:
                    unique_edges.add(edge)
                    midpoint = 0.5 * (np.array(v1) + np.array(v2))
                    edge_midpoints.append(midpoint)

            # Face midpoint (no duplication issue here)
            face_mid = np.mean(face, axis=0)
            self.bz_point_lists["face"].append(face_mid)

        self.bz_point_lists["edge"] = np.array(edge_midpoints)
        self.bz_point_lists["face"] = np.array(self.bz_point_lists["face"])

    # Draw the path
    self._update_path_visualization()
    # Create the BZ wireframe by making edges
    # (connect the vertices based on face data)
    self._create_bz_wireframe()

    # Plot the BZ points as spheres
    # Add Gamma point at origin
    sphere = self._make_point()
    sphere.setColor((1, 1, 1, 1))

    self.bz_plot_view.view.addItem(sphere)
    self.bz_plot_items["Gamma"] = sphere

    # Plot points for vertices, edges, and faces
    for typ, pt in self.bz_point_lists.items():
        if len(pt) > 0:
            # Pad all the points of the same type
            pt_3d = self._pad_to_3d(pt)
            # Select the 1st point of the type
            self.bz_point_selection[typ] = 0
            # Loop over all the padded points
            for ii, p in enumerate(pt_3d):
                # Make a sphere and position it
                # at the appropriate location
                sphere = self._make_point()
                sphere.translate(p[0], p[1], p[2])
                self.bz_plot_view.view.addItem(sphere)
                self.bz_plot_items[f"bz_{typ}_{ii}"] = sphere
                # Highlight the first point
                if ii == 0:
                    sphere.setColor(self.bz_plot_view.selected_point_color)

TiBi.controllers.ComputationController

Bases: QObject

Controller responsible for physics calculations within the application.

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

computation_view ComputationView

UI object containing the computation view

hopping_controller HoppingController

Child controller in charge of the hopping panel of the computation UI

bands_controller BandsController

Child controller in charge of the bands panel of the computation UI

status_updated Signal(str)

Signal emitted to update the status of the computation

bands_plot_requested Signal

Request bands plot. Re-emitting signal from BandsController

dos_plot_requested Signal

Request DOS plot. Re-emitting signal from BandsController

hopping_segments_requested Signal

Signal requesting the plotting of hopping segments in the unit cell plot. Re-emitting signal for the HoppingController when the user selects a pair of sites from the hopping matrix.

selection_requested Signal(object, object, object)

Signal requesting a programmatic selection. Re-emitting signal for the HoppingController.

Methods:

Name Description
get_dos_properties

Get the number of bins/points, plot type, and broadening.

get_pair_selection

Get the selected state pair from the hopping matrix, if any.

get_projection_indices

Get the projection indices from the projection combo.

update_bands_panel

Update the bands UI panel.

update_hopping_panel

Redraw the hoppings UI panel.

update_projection_combo

Update the projection combo in the bands panel.

Source code in TiBi/controllers/computation_controller.py
 11
 12
 13
 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
class ComputationController(QObject):
    """
    Controller responsible for physics calculations within the application.

    Attributes
    ----------
    unit_cells : dict[uuid.UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    computation_view : ComputationView
        UI object containing the computation view
    hopping_controller : HoppingController
        Child controller in charge of the hopping panel of the computation UI
    bands_controller : BandsController
        Child controller in charge of the bands panel of the computation UI
    status_updated : Signal(str)
        Signal emitted to update the status of the computation
    bands_plot_requested : Signal
        Request bands plot.
        Re-emitting signal from `BandsController`
    dos_plot_requested : Signal
        Request DOS plot.
        Re-emitting signal from `BandsController`
    hopping_segments_requested : Signal
        Signal requesting the plotting of hopping segments in the
        unit cell plot. Re-emitting signal for the `HoppingController`
        when the user selects a pair of sites from the hopping matrix.
    selection_requested : Signal(object, object, object)
        Signal requesting a programmatic selection. Re-emitting signal for
        the `HoppingController`.

    Methods
    -------
    get_dos_properties()
        Get the number of bins/points, plot type, and broadening.
    get_pair_selection()
        Get the selected state pair from the hopping matrix, if any.
    get_projection_indices()
        Get the projection indices from the projection combo.
    update_bands_panel()
        Update the bands UI panel.
    update_hopping_panel()
        Redraw the hoppings UI panel.
    update_projection_combo()
        Update the projection combo in the bands panel.
    """

    status_updated = Signal(str)
    # Hopping controller signals to relay
    hopping_segments_requested = Signal()
    selection_requested = Signal(object, object, object)
    # Band controller signals to relay
    bands_plot_requested = Signal()
    dos_plot_requested = Signal()

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        computation_view: ComputationView,
        undo_stack: QUndoStack,
    ):
        super().__init__()
        self.computation_view = computation_view
        self.undo_stack = undo_stack

        self.unit_cells = unit_cells
        self.selection = selection
        # Component controllers
        self.hopping_controller = HoppingController(
            self.unit_cells,
            self.selection,
            self.computation_view.hopping_panel,
            self.undo_stack,
        )
        self.bands_controller = BandsController(
            self.unit_cells, self.selection, self.computation_view.bands_panel
        )
        # Connect the signals
        # Hoppings Panel
        self.hopping_controller.hopping_segments_requested.connect(
            self.hopping_segments_requested.emit
        )
        self.hopping_controller.selection_requested.connect(
            self.selection_requested.emit
        )
        # Bands Panel
        self.bands_controller.bands_plot_requested.connect(
            self.bands_plot_requested.emit
        )
        self.bands_controller.dos_plot_requested.connect(
            self.dos_plot_requested.emit
        )
        self.bands_controller.status_updated.connect(self.status_updated.emit)

    def get_pair_selection(self):
        """
        Get the selected state pair from the hopping matrix, if any.

        Returns
        -------
        list[tuple]  | list[None]
            List of selected states, if available, where the elements of
            the list are (site_name, site_id, state_name, state_id).
        """
        return self.hopping_controller.pair_selection

    def update_hopping_panel(self):
        """
        Redraw the hoppings UI panel.

        This method is called when the user renames a tree item to make sure
        that the matrix table contains the correct item names.
        """
        self.hopping_controller.update_unit_cell()

    def update_bands_panel(self):
        """
        Update the bands UI panel.
        """
        self.bands_controller.update_bands_panel()

    def update_projection_combo(self):
        """
        Update the projection combo in the bands panel.
        """
        self.bands_controller.update_combo()

    def get_projection_indices(self):
        """
        Get the projection indices from the projection combo.
        """
        return self.bands_controller.get_projection_indices()

    def get_dos_properties(self):
        """
        Get the number of bins/points, plot type, and broadening.

        Returns
        -------
        tuple[int, int]
            Number of bins/points to be used in the plot and the plot type
            (0 for a histogram, 1 for Lorentzian)
        """
        return self.bands_controller.get_dos_properties()

get_dos_properties()

Get the number of bins/points, plot type, and broadening.

Returns:

Type Description
tuple[int, int]

Number of bins/points to be used in the plot and the plot type (0 for a histogram, 1 for Lorentzian)

Source code in TiBi/controllers/computation_controller.py
146
147
148
149
150
151
152
153
154
155
156
def get_dos_properties(self):
    """
    Get the number of bins/points, plot type, and broadening.

    Returns
    -------
    tuple[int, int]
        Number of bins/points to be used in the plot and the plot type
        (0 for a histogram, 1 for Lorentzian)
    """
    return self.bands_controller.get_dos_properties()

get_pair_selection()

Get the selected state pair from the hopping matrix, if any.

Returns:

Type Description
list[tuple] | list[None]

List of selected states, if available, where the elements of the list are (site_name, site_id, state_name, state_id).

Source code in TiBi/controllers/computation_controller.py
107
108
109
110
111
112
113
114
115
116
117
def get_pair_selection(self):
    """
    Get the selected state pair from the hopping matrix, if any.

    Returns
    -------
    list[tuple]  | list[None]
        List of selected states, if available, where the elements of
        the list are (site_name, site_id, state_name, state_id).
    """
    return self.hopping_controller.pair_selection

get_projection_indices()

Get the projection indices from the projection combo.

Source code in TiBi/controllers/computation_controller.py
140
141
142
143
144
def get_projection_indices(self):
    """
    Get the projection indices from the projection combo.
    """
    return self.bands_controller.get_projection_indices()

update_bands_panel()

Update the bands UI panel.

Source code in TiBi/controllers/computation_controller.py
128
129
130
131
132
def update_bands_panel(self):
    """
    Update the bands UI panel.
    """
    self.bands_controller.update_bands_panel()

update_hopping_panel()

Redraw the hoppings UI panel.

This method is called when the user renames a tree item to make sure that the matrix table contains the correct item names.

Source code in TiBi/controllers/computation_controller.py
119
120
121
122
123
124
125
126
def update_hopping_panel(self):
    """
    Redraw the hoppings UI panel.

    This method is called when the user renames a tree item to make sure
    that the matrix table contains the correct item names.
    """
    self.hopping_controller.update_unit_cell()

update_projection_combo()

Update the projection combo in the bands panel.

Source code in TiBi/controllers/computation_controller.py
134
135
136
137
138
def update_projection_combo(self):
    """
    Update the projection combo in the bands panel.
    """
    self.bands_controller.update_combo()

TiBi.controllers.hopping_controller.HoppingController

Bases: QObject

Controller for the hopping parameter interface.

This controller manages the creation and editing of hopping parameters (tight-binding matrix elements) between quantum states. It handles:

  1. The interactive matrix grid where each button represents a possible hopping between two states
  2. The detailed parameter table for editing specific hopping values
  3. The right-click context menu for performing operations like creating Hermitian partners

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

hopping_view HoppingPanel

The main view component

undo_stack QUndoStack

QUndoStack to hold "undo-able" commands

state_info list[tuple]

List of tuples with state information (site_name, site_id, state_name, state_id) for each State in the UnitCell

pair_selection list[tuple]

2-element list of tuples containing the selected state pair

hoppings dict[tuple[uuid, uuid], list[tuple[tuple[int, int, int], complex128]]]

Dictionary containing the hopping parameters for the UnitCell. The keys are State UUID tuples. The values are lists of hoppings. Each hopping is a tuple of a displacement tuple, given in terms of lattice vectors, and a complex amplitude.

btn_clicked Signal(object, object)

Emitted when a hopping button is clicked. The Signal carries the source and destination state info following the (site_name, site_id, state_name, state_id) format.

hoppings_changed Signal(object, object, object, object, object)

Emitted by the command when couplings are modified. The signal carries the information about the current item selection, as well as the selection of the state pair. It triggers a table and matrix update.

hopping_segments_requested Signal

Emitted when the coupling table is updated, triggering an update of hopping segments.

selection_requested Signal(object, object, object)

Emitted when the selection change in the tree is required, carrying the unit cell, site, and state IDs.

Methods:

Name Description
update_unit_cell

Update the hopping data model with the UnitCell's hoppings

Source code in TiBi/controllers/hopping_controller.py
 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
class HoppingController(QObject):
    """
    Controller for the hopping parameter interface.

    This controller manages the creation and editing of hopping parameters
    (tight-binding matrix elements) between quantum states. It handles:

    1. The interactive matrix grid where each button represents a possible
       hopping between two states
    2. The detailed parameter table for editing specific hopping values
    3. The right-click context menu for performing operations like creating
       Hermitian partners

    Attributes
    ----------
    unit_cells : dict[UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    hopping_view : HoppingPanel
        The main view component
    undo_stack : QUndoStack
        `QUndoStack` to hold "undo-able" commands
    state_info : list[tuple]
        List of tuples with state information \
        (site_name, site_id, state_name, state_id)\
        for each `State` in the `UnitCell`
    pair_selection : list[tuple]
        2-element list of tuples containing the selected state pair
    hoppings : dict[tuple[uuid, uuid],\
            list[tuple[tuple[int, int, int], np.complex128]]]
        Dictionary containing the hopping parameters for the `UnitCell`.
        The keys are `State` UUID tuples. The values are lists of hoppings.
        Each hopping is a tuple of a displacement tuple, given in
        terms of lattice vectors, and a complex amplitude.
    btn_clicked : Signal(object, object)
        Emitted when a hopping button is clicked. The Signal carries the source
        and destination state info following the
        (site_name, site_id, state_name, state_id) format.
    hoppings_changed : Signal(object, object, object, object, object)
        Emitted by the command when couplings are modified.
        The signal carries the information about the current item selection,
        as well as the selection of the state pair. It triggers
        a table and matrix update.
    hopping_segments_requested : Signal
        Emitted when the coupling table is updated, triggering an
        update of hopping segments.
    selection_requested : Signal(object, object, object)
        Emitted when the selection change in the tree is required,
        carrying the unit cell, site, and state IDs.

    Methods
    -------
    update_unit_cell()
        Update the hopping data model with the `UnitCell`'s hoppings
    """

    btn_clicked = Signal(object, object)
    hoppings_changed = Signal(object, object, object, object, object)
    hopping_segments_requested = Signal()
    selection_requested = Signal(object, object, object)

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        hopping_view: HoppingPanel,
        undo_stack: QUndoStack,
    ):
        super().__init__()
        self.unit_cells = unit_cells
        self.selection = selection
        self.hopping_view = hopping_view
        self.undo_stack = undo_stack

        # Internal controller state
        self.state_info = []
        self.pair_selection = [
            None,
            None,
        ]
        self.hoppings = {}

        # Connect Signals
        self.btn_clicked.connect(self._update_pair_selection)
        self.hopping_view.table_panel.add_row_btn.clicked.connect(
            self._add_empty_row
        )
        self.hopping_view.table_panel.remove_row_btn.clicked.connect(
            self._remove_selected_coupling
        )
        self.hopping_view.table_panel.save_btn.clicked.connect(
            self._save_couplings
        )
        self.hoppings_changed.connect(self._handle_hoppings_changed)

    def update_unit_cell(self):
        """
        Update the hopping data model with the selected `UnitCell`'s hoppings.

        This method is called when the selection changes in the tree view.
        It retrieves the currently selected unit cell and its hoppings,
        refreshing the matrix.
        """
        uc_id = self.selection.unit_cell
        # Deselect the previous states
        self._update_pair_selection(None, None)

        # If no unit cell selected, hide the panels and exit early
        if uc_id is None:
            self.hopping_view.panel_stack.setCurrentWidget(
                self.hopping_view.info_label
            )
            return

        else:
            uc = self.unit_cells[uc_id]

            # Get the states and their "info" from inside the unit cell
            _, new_info = uc.get_states()

            # Use the states and the info to construct the hopping matrix grid
            self.hoppings = uc.hoppings
            self.state_info = new_info
            # If there are no states in the unit cell, hide the panels
            if new_info == []:
                self.hopping_view.panel_stack.setCurrentWidget(
                    self.hopping_view.info_label
                )
            else:
                self.hopping_view.panel_stack.setCurrentWidget(
                    self.hopping_view.panel
                )
                self._refresh_matrix()

    def _refresh_matrix(self):
        """
        Redraw the hopping matrix.

        Button colors are updated based on whether hoppings
        exist between states.
        """
        # Clear existing widgets in grid layout
        while self.hopping_view.matrix_panel.grid_layout.count():
            item = self.hopping_view.matrix_panel.grid_layout.takeAt(0)
            if item.widget():
                item.widget().deleteLater()

        # Configure grid layout to center the content
        self.hopping_view.matrix_panel.grid_layout.setAlignment(Qt.AlignCenter)

        # Create the button grid
        self.buttons = {}
        for ii in range(len(self.state_info)):
            for jj in range(len(self.state_info)):
                btn = QPushButton("")
                btn.setFixedSize(20, 20)
                btn.setContextMenuPolicy(Qt.CustomContextMenu)
                btn.customContextMenuRequested.connect(
                    lambda _, row=ii, col=jj, b=btn: self._add_context_menu(
                        b, row, col
                    )
                )
                # Get the states
                state1 = self.state_info[ii]
                state2 = self.state_info[jj]
                # Apply button styles

                hop = set(self.hoppings.get((state1[3], state2[3]), []))
                hop_herm = set(self.hoppings.get((state2[3], state1[3]), []))
                has_hopping = bool(hop)
                hop_neg_conj = set(
                    ((-d1, -d2, -d3), np.conj(x)) for ((d1, d2, d3), x) in hop
                )
                is_hermitian = hop_neg_conj == hop_herm
                self._apply_button_style(btn, has_hopping, is_hermitian)

                # Set tips to show both states when hovering.
                # Show the state and site names.
                # From second quantization, the hopping goes FROM column INTO
                # row (columns multiply annihilation operators,
                # rows multiply creation)
                btn.setToolTip(
                    f"{state2[0]}.{state2[2]}{state1[0]}.{state1[2]}"
                )
                btn.setStatusTip(
                    f"{state2[0]}.{state2[2]}{state1[0]}.{state1[2]}"
                )

                # Button click handler implementation:
                btn.clicked.connect(
                    lambda checked=False, r=ii, c=jj: self.btn_clicked.emit(
                        self.state_info[r],
                        self.state_info[c],  # Format (TO_state, FROM_state)
                    )
                )
                # Add the button to the grid
                self.hopping_view.matrix_panel.grid_layout.addWidget(
                    btn, ii, jj
                )
                # Save to the button dictionary
                self.buttons[(ii, jj)] = btn

    def _apply_button_style(
        self, button: QPushButton, has_hopping, hermitian=False
    ):
        """
        Apply the appropriate style to a button based on its hoppings.

        Parameters
        ----------
        button : QPushButton
            The button to style
        has_hopping : bool
            Indicator whether the button marks a connection with hoppings
        hermitian : bool
            Boolean indicating whether the coupling is Hermitian
        """
        if not has_hopping:
            style = BUTTON_STYLE_DEFAULT
        else:
            if hermitian:
                style = BUTTON_STYLE_HAS_HOPPING
            else:
                style = BUTTON_STYLE_NONHERMITIAN

        button.setStyleSheet(style)

    def _update_pair_selection(self, s1, s2):
        """
        Update the pair selection and the table to display hopping terms.

        Parameters
        ----------
        s1 : tuple[str, uuid.UUID, str, uuid.UUID] | None
            Information tuple for the destination `State` (row)
        s2 : tuple[str, uuid.UUID, str, uuid.UUID] | None
            Information tuple for the source `State` (column)
        """

        # Store the UUIDs of the selected states
        self.pair_selection = [s1, s2]
        if (s1 is None) or (s2 is None):
            self.hopping_view.table_stack.setCurrentWidget(
                self.hopping_view.table_info_label
            )
        else:
            self.hopping_view.table_stack.setCurrentWidget(
                self.hopping_view.table_panel
            )

            # Update the table title to show the selected states
            # (source → destination)
            self.hopping_view.table_panel.table_title.setText(
                f"{s2[0]}.{s2[2]}{s1[0]}.{s1[2]}"
            )
            self._refresh_table()

    def _refresh_table(self):
        """Clear the table and repopulate it with the latest hopping terms"""
        self.hopping_view.table_panel.hopping_table.setRowCount(
            0
        )  # Clear existing data
        for (d1, d2, d3), amplitude in self.hoppings.get(
            (self.pair_selection[0][3], self.pair_selection[1][3]), []
        ):
            row_index = self.hopping_view.table_panel.hopping_table.rowCount()
            self.hopping_view.table_panel.hopping_table.insertRow(row_index)

            # Use cell widgets instead of QTableWidgetItem
            spinbox_d1 = self._make_spinbox(value=d1)
            spinbox_d2 = self._make_spinbox(value=d2)
            spinbox_d3 = self._make_spinbox(value=d3)
            re_box = self._make_doublespinbox(value=np.real(amplitude))
            im_box = self._make_doublespinbox(value=np.imag(amplitude))

            self.hopping_view.table_panel.hopping_table.setCellWidget(
                row_index, 0, spinbox_d1
            )
            self.hopping_view.table_panel.hopping_table.setCellWidget(
                row_index, 1, spinbox_d2
            )
            self.hopping_view.table_panel.hopping_table.setCellWidget(
                row_index, 2, spinbox_d3
            )
            self.hopping_view.table_panel.hopping_table.setCellWidget(
                row_index, 3, re_box
            )
            self.hopping_view.table_panel.hopping_table.setCellWidget(
                row_index, 4, im_box
            )
        self.hopping_segments_requested.emit()

    def _make_spinbox(self, value=0, minimum=-99, maximum=99):
        """Auxiliary function to create a spinbox for hopping displacement"""
        box = QSpinBox()
        box.setRange(minimum, maximum)
        box.setValue(value)
        box.setButtonSymbols(QSpinBox.NoButtons)
        return box

    def _make_doublespinbox(
        self, value=0.0, minimum=-1e6, maximum=1e6, decimals=3
    ):
        """
        Auxiliary function to create a double spinbox for hopping amplitude
        """
        box = QDoubleSpinBox()
        box.setRange(minimum, maximum)
        box.setDecimals(decimals)
        box.setValue(value)
        box.setButtonSymbols(QDoubleSpinBox.NoButtons)

        return box

    def _add_empty_row(self):
        """Add a new empty row to the table"""
        row_index = self.hopping_view.table_panel.hopping_table.rowCount()
        self.hopping_view.table_panel.hopping_table.insertRow(row_index)

        # Pre-fill with default values
        self.hopping_view.table_panel.hopping_table.setCellWidget(
            row_index, 0, self._make_spinbox()
        )
        self.hopping_view.table_panel.hopping_table.setCellWidget(
            row_index, 1, self._make_spinbox()
        )
        self.hopping_view.table_panel.hopping_table.setCellWidget(
            row_index, 2, self._make_spinbox()
        )
        self.hopping_view.table_panel.hopping_table.setCellWidget(
            row_index, 3, self._make_doublespinbox()
        )
        self.hopping_view.table_panel.hopping_table.setCellWidget(
            row_index, 4, self._make_doublespinbox()
        )

    def _remove_selected_coupling(self):
        """Remove selected row(s) from the table"""
        selected_rows = set()

        # Get the selection model from the table
        selection_model = (
            self.hopping_view.table_panel.hopping_table.selectionModel()
        )

        # Get the selected rows
        selected_indexes = selection_model.selectedRows()

        # Extract the row numbers from the selected indexes
        for index in selected_indexes:
            selected_rows.add(index.row())

        # Remove the rows from the table in reverse order to avoid
        # shifting issues
        for row in sorted(selected_rows, reverse=True):
            self.hopping_view.table_panel.hopping_table.removeRow(row)

    def _save_couplings(self):
        """
        Extract data from the hopping table and save it to the `UnitCell`.

        Read all rows from the table, converting cell values to the
        appropriate types:
        - First 3 columns (d₁,d₂,d₃) to integers (displacement vector)
        - Last 2 columns (Re(t), Im(t)) to floats (complex amplitude)

        If the same triplet (d₁,d₂,d₃) appears more than once,
        the amplitudes are summed. The data is then passed to the
        `SaveHoppingsCommand` to update the unit cell model.
        """
        new_couplings: dict[tuple[int, int, int], np.complex128] = {}
        # Extract values from each row in the table
        for row in range(
            self.hopping_view.table_panel.hopping_table.rowCount()
        ):
            d1, d2, d3, re, im = [
                self.hopping_view.table_panel.hopping_table.cellWidget(
                    row, n
                ).value()
                for n in range(5)
            ]

            # Create the complex amplitude
            amplitude = np.complex128(re + im * 1j)

            # Create a tuple for the displacement vector (d₁, d₂, d₃)
            triplet = (d1, d2, d3)
            # If the triplet already exists, merge amplitudes by adding
            # the new amplitude
            if triplet in new_couplings:
                new_couplings[triplet] += amplitude
            else:
                new_couplings[triplet] = amplitude

        # Convert the dictionary to the expected format of the list of tuples.
        # Remove any entries with non-finite or zero amplitudes
        merged_couplings = [
            ((d1, d2, d3), amplitude)
            for (d1, d2, d3), amplitude in new_couplings.items()
            if np.isfinite(amplitude.real)
            and np.isfinite(amplitude.imag)
            and not np.isclose(amplitude, 0)
        ]
        # Only update the model if the hoppings have changed
        if set(merged_couplings) == set(
            self.unit_cells[self.selection.unit_cell].hoppings.get(
                (self.pair_selection[0][3], self.pair_selection[1][3]), []
            )
        ):
            # No changes detected, just refresh the table in case the user
            # rearranged the rows without actually changing the data or
            # added rows with zero amplitude
            self._refresh_table()
            return

        # Update the data model with the new couplings and emit the signal
        self.undo_stack.push(
            SaveHoppingsCommand(
                unit_cells=self.unit_cells,
                selection=self.selection,
                pair_selection=self.pair_selection,
                new_hoppings=merged_couplings,
                signal=self.hoppings_changed,
            )
        )

    def _add_context_menu(self, button, ii, jj):
        """
        Create a context menu for the button to manage hoppings.

        Parameters
        ----------
        button : QPushButton
            The button that was right-clicked
        ii : int
            Row index of the button in the matrix
        jj : int
            Column index of the button in the matrix
        """
        menu = QMenu()
        # Send hopping data to the transpose element
        action_send_hoppings = QAction("Set transpose element", self)
        action_send_hoppings.triggered.connect(
            lambda: self._create_hermitian_partner(ii, jj)
        )
        menu.addAction(action_send_hoppings)

        # Get hopping data from the transpose element
        action_get_hoppings = QAction("Get transpose element", self)
        action_get_hoppings.triggered.connect(
            lambda: self._create_hermitian_partner(jj, ii)
        )
        menu.addAction(action_get_hoppings)

        # Clear hoppings
        action_clear_hoppings = QAction("Clear hoppings", self)
        action_clear_hoppings.triggered.connect(
            lambda: self._delete_coupling(ii, jj)
        )
        menu.addAction(action_clear_hoppings)

        menu.exec_(button.mapToGlobal(QPoint(0, button.height())))

    def _create_hermitian_partner(self, ii, jj):
        """
        Create a Hermitian partner for the selected hopping.

        The Hermitian partner is created by negating the displacement vector
        and taking the complex conjugate of the amplitude.

        Parameters
        ----------
        ii : int
            Index of the destination state in the matrix
        jj : int
            Index of the source state in the matrix
        """
        s1 = self.state_info[ii]  # Destination
        s2 = self.state_info[jj]  # Source
        hop = self.hoppings.get((s1[3], s2[3]), [])
        hop_herm = [((-d1, -d2, -d3), np.conj(x)) for ((d1, d2, d3), x) in hop]
        self.pair_selection = [s2, s1]

        self.undo_stack.push(
            SaveHoppingsCommand(
                unit_cells=self.unit_cells,
                selection=self.selection,
                pair_selection=self.pair_selection,
                new_hoppings=hop_herm,
                signal=self.hoppings_changed,
            )
        )

    def _delete_coupling(self, ii, jj):
        """
        Delete the coupling between two states.

        Parameters
        ----------
        ii : int
            Index of the destination state in the matrix (row index)
        jj : int
            Index of the source state in the matrix (column index)
        """
        s1 = self.state_info[ii]  # Destination
        s2 = self.state_info[jj]  # Source

        self._update_pair_selection(s1, s2)

        self.undo_stack.push(
            SaveHoppingsCommand(
                unit_cells=self.unit_cells,
                selection=self.selection,
                pair_selection=self.pair_selection,
                new_hoppings=[],
                signal=self.hoppings_changed,
            )
        )

    def _handle_hoppings_changed(self, uc_id, site_id, state_id, s1, s2):
        """
        Redraw the matrix and table when hoppings are modified.

        If the selection at the point of the hopping change is different
        from the current one, emit a signal to update the selection in
        the tree. Otherwise, refresh the matrix. Additionally, select
        the pair of states and update the table.

        Parameters
        ----------
        uc_id : uuid.UUID
            UUID of the unit cell
        site_id : uuid.UUID
            UUID of the site
        state_id : uuid.UUID
            UUID of the state
        s1 : tuple[str, uuid.UUID, str, uuid.UUID]
            Information tuple for the destination `State` (row)
        s2 : tuple[str, uuid.UUID, str, uuid.UUID]
            Information tuple for the source `State` (column)
        """
        # If the unit cell selection needs to change, matrix redrawing
        # will be handled by the app controller as all panels are updated
        # Otherwise, we need to refresh the matrix manually.

        if self.selection.unit_cell == uc_id:
            self._refresh_matrix()

        if (
            self.selection.unit_cell != uc_id
            or self.selection.site != site_id
            or self.selection.state != state_id
        ):
            self.selection_requested.emit(uc_id, site_id, state_id)

        # Update Pair Selection
        self._update_pair_selection(s1, s2)

update_unit_cell()

Update the hopping data model with the selected UnitCell's hoppings.

This method is called when the selection changes in the tree view. It retrieves the currently selected unit cell and its hoppings, refreshing the matrix.

Source code in TiBi/controllers/hopping_controller.py
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
def update_unit_cell(self):
    """
    Update the hopping data model with the selected `UnitCell`'s hoppings.

    This method is called when the selection changes in the tree view.
    It retrieves the currently selected unit cell and its hoppings,
    refreshing the matrix.
    """
    uc_id = self.selection.unit_cell
    # Deselect the previous states
    self._update_pair_selection(None, None)

    # If no unit cell selected, hide the panels and exit early
    if uc_id is None:
        self.hopping_view.panel_stack.setCurrentWidget(
            self.hopping_view.info_label
        )
        return

    else:
        uc = self.unit_cells[uc_id]

        # Get the states and their "info" from inside the unit cell
        _, new_info = uc.get_states()

        # Use the states and the info to construct the hopping matrix grid
        self.hoppings = uc.hoppings
        self.state_info = new_info
        # If there are no states in the unit cell, hide the panels
        if new_info == []:
            self.hopping_view.panel_stack.setCurrentWidget(
                self.hopping_view.info_label
            )
        else:
            self.hopping_view.panel_stack.setCurrentWidget(
                self.hopping_view.panel
            )
            self._refresh_matrix()

TiBi.controllers.MainUIController

Bases: QObject

Controller for the main UI components (menu bar, toolbar, status bar).

This controller connects the UI elements to the application logic and manages the action manager that provides shared actions to the menu bar and toolbar.

Attributes:

Name Type Description
project_path str | None

The file to which the dictionary containing the unit cell objects is saved

unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

main_window MainWindow

Subclass of QMainWindow containing the application's main view

menu_bar_view MenuBarView

Standard menu bar

toolbar_view MainToolbarView

Toolbar at the top of the application window

status_bar_view StatusBarView

Status bar at the bottom of the application window

undo_stack QUndoStack

Stack for 'undo-able' actions

project_refresh_requested Signal

Request to refresh the project view after loading or creating a new one

unit_cell_update_requested Signal

Request to update the unit cell plot with new parameters

Methods:

Name Description
get_uc_plot_properties

Get the unit cell visualization properties.

set_spinbox_status

Activate/deactivate unit cell spinboxes.

update_status

Display a message in the status bar.

Source code in TiBi/controllers/main_ui_controller.py
 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
class MainUIController(QObject):
    """
    Controller for the main UI components (menu bar, toolbar, status bar).

    This controller connects the UI elements to the application logic and
    manages the action manager that provides shared actions to the menu
    bar and toolbar.

    Attributes
    ----------
    project_path : str | None
        The file to which the dictionary containing the unit
        cell objects is saved
    unit_cells : dict[uuid.UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    main_window : MainWindow
        Subclass of QMainWindow containing the application's main view
    menu_bar_view : MenuBarView
        Standard menu bar
    toolbar_view : MainToolbarView
        Toolbar at the top of the application window
    status_bar_view : StatusBarView
        Status bar at the bottom of the application window
    undo_stack : QUndoStack
        Stack for 'undo-able' actions
    project_refresh_requested : Signal
        Request to refresh the project view after loading or creating a new one
    unit_cell_update_requested : Signal
        Request to update the unit cell plot with new parameters

    Methods
    -------
    get_uc_plot_properties()
        Get the unit cell visualization properties.
    set_spinbox_status()
        Activate/deactivate unit cell spinboxes.
    update_status(message : str)
        Display a message in the status bar.
    """

    unit_cell_update_requested = Signal()
    # Requst an updated unit cell plot
    # Request a UI refresh after creating a new project
    # or loading an existing one
    project_refresh_requested = Signal()

    def __init__(
        self,
        project_path: str | None,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        main_window: MainWindow,
        menu_bar_view: MenuBarView,
        toolbar_view: MainToolbarView,
        status_bar_view: StatusBarView,
        undo_stack: QUndoStack,
    ):
        super().__init__()
        self.project_path = project_path
        self.unit_cells = unit_cells
        self.selection = selection
        self.main_window = main_window
        self.menu_bar = menu_bar_view
        self.toolbar = toolbar_view
        self.status_bar = status_bar_view
        self.undo_stack = undo_stack

        # Create the action manager
        self.action_manager = ActionManager(
            undo_stack=self.undo_stack, parent=self
        )

        # Set up action handlers
        self._connect_action_handlers()

        # Set actions to views
        self.menu_bar.set_actions(self.action_manager)
        self.toolbar.set_actions(self.action_manager)

        # Connect spinbox signals
        self.toolbar.n1_spinbox.valueChanged.connect(
            lambda _: self.unit_cell_update_requested.emit()
        )
        self.toolbar.n2_spinbox.valueChanged.connect(
            lambda _: self.unit_cell_update_requested.emit()
        )
        self.toolbar.n3_spinbox.valueChanged.connect(
            lambda _: self.unit_cell_update_requested.emit()
        )

    def _connect_action_handlers(self):
        """Connect actions to their handler methods."""
        # Create a dictionary mapping action names to handler methods
        handlers = {
            # File actions
            "new_project": self._handle_new_project,
            "open_project": self._handle_open_project,
            "import_project": self._handle_import_project,
            "save_project": partial(
                self._handle_save_project, use_existing_path=True
            ),
            "save_project_as": partial(
                self._handle_save_project, use_existing_path=False
            ),
            # Undo/Redo actions
            "undo": self.undo_stack.undo,
            "redo": self.undo_stack.redo,
            # Unit cell actions
            "wireframe": self._handle_wireframe_toggle,
        }

        # Connect actions to handlers
        self.action_manager.connect_signals(handlers)

    # Methods to get information about the current state
    def get_uc_plot_properties(self):
        """
        Get the unit cell visualization properties.

        The function returns the number of unit cells to be plotted
        along each basis vector, as well as whether the wireframe
        is plotted.

        Returns
        -------
        int, int, int, bool
            Numbers of unit cells along each of the three vectors and
            a boolean for the wireframe.
        """
        n1, n2, n3 = [
            spinbox.value() if spinbox.isEnabled() else 1
            for spinbox in (
                self.toolbar.n1_spinbox,
                self.toolbar.n2_spinbox,
                self.toolbar.n3_spinbox,
            )
        ]
        wireframe_enabled = self.action_manager.unit_cell_actions[
            "wireframe"
        ].isChecked()
        return n1, n2, n3, wireframe_enabled

    def set_spinbox_status(self, n1_enabled, n2_enabled, n3_enabled):
        """Activate/deactivate the unit cell spinboxes"""
        self.toolbar.n1_spinbox.setEnabled(n1_enabled)
        self.toolbar.n2_spinbox.setEnabled(n2_enabled)
        self.toolbar.n3_spinbox.setEnabled(n3_enabled)

    # Handler methods for actions
    @Slot()
    def _handle_new_project(self):
        """
        Handle request to create a new project.

        A new project clears the current project, so the user has to
        respond to a warning. If the user confirms the creation of the project,
        the unit_cells dictionary is cleared, the project_path is set to None,
        and a request is set to reset all the models to the pristine state.
        """
        self.update_status("Creating new project...")
        reply = QMessageBox.question(
            self.main_window,
            "Start New Project?",
            """⚠️  This will clear your current project.\n\n
            Are you sure you want to continue?""",
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.No,
        )

        if reply == QMessageBox.Yes:
            self.project_path = None
            self.unit_cells.clear()
            self.undo_stack.clear()
            self.project_refresh_requested.emit()

    @Slot()
    def _handle_open_project(self):
        """
        Handle request to open a project from a JSON file.

        Opening a project clears the current project.
        The JSON data from the loaded file is deserialized so that the
        unit_cell dictionary can be filled. The project path is updated
        and a request is sent out to reset all other models
        to the pristine state (nothing selected).
        """
        self.update_status("Opening project...")

        # Open a file dialog for selecting a JSON file
        file_path, _ = QFileDialog.getOpenFileName(
            self.main_window,
            "Open Unit Cells JSON",
            os.getcwd(),  # starting directory
            "JSON Files (*.json);;All Files (*)",
        )

        if file_path:
            try:
                with open(file_path, "r", encoding="utf-8") as f:
                    json_string = f.read()
                unit_cells = deserialize_unit_cells(json_string)
                self.unit_cells.clear()
                self.unit_cells.update(unit_cells)
                self.project_path = file_path
                self.project_refresh_requested.emit()
            except Exception as e:
                QMessageBox.critical(
                    self.main_window,
                    "Error",
                    f"Failed to open file:\n{str(e)}",
                )
                self.update_status("Failed to open project.")

    @Slot()
    def _handle_import_project(self):
        """
        Handle request to import a project.

        Importing a project is similar to loading with the difference being
        that the imported unit cells are added to the current project
        rather than replacing them.
        To avoid UUID clashes (if one imports the same project twice),
        the newly-imported unit cells have their UUID's regenerated.
        """
        self.update_status("Importing project...")

        # Open a file dialog for selecting a JSON file
        file_path, _ = QFileDialog.getOpenFileName(
            self.main_window,
            "Open Unit Cells JSON",
            os.getcwd(),  # starting directory
            "JSON Files (*.json);;All Files (*)",
        )

        if file_path:
            try:
                with open(file_path, "r", encoding="utf-8") as f:
                    json_string = f.read()
                unit_cells = deserialize_unit_cells(json_string)

                # Go over the imported unit cells and regenerate their UUID's
                new_unit_cells = {}
                for _, uc in unit_cells.items():
                    new_id = uuid.uuid4()
                    uc.id = new_id
                    new_unit_cells[new_id] = uc

                self.unit_cells.update(new_unit_cells)
                self.project_refresh_requested.emit()
            except Exception as e:
                QMessageBox.critical(
                    self.main_window,
                    "Error",
                    f"Failed to open file:\n{str(e)}",
                )
                self.update_status("Failed to open project.")

    @Slot()
    def _handle_save_project(self, use_existing_path=True):
        """
        Handle request to save the current project.

        Save the current project to a JSON file. If the project already has
        a path, depending on whether the user clicks on Save or Save As,
        the project is either saved to that path or the user chooses a
        new file name. If there is no path, Save acts as Save As.
        """
        self.update_status("Saving project...")
        json_string = serialize_unit_cells(self.unit_cells)
        if use_existing_path:
            file_path = self.project_path
        else:
            file_path = None

        # If there is no path, open a dialog for the user to pick it
        if not file_path:
            # Open a save file dialog
            file_path, _ = QFileDialog.getSaveFileName(
                self.main_window,
                "Save Unit Cells As JSON",
                os.getcwd(),  # starting directory
                "JSON Files (*.json)",
            )

        if file_path:
            if file_path and not file_path.endswith(".json"):
                file_path += ".json"
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(json_string)
            self.project_path = file_path

    @Slot()
    def _handle_wireframe_toggle(self):
        """
        Handle wireframe toggle.

        Request a redrawing of the unit cell plot with/without the wireframe.
        """
        # Implementation will be added later
        self.unit_cell_update_requested.emit()

    # Methods to be called from other controllers
    def update_status(self, message):
        """
        Display a message in the status bar.

        Parameters
        ----------
        message : str
            Message to display
        """
        self.status_bar.update_status(message)

get_uc_plot_properties()

Get the unit cell visualization properties.

The function returns the number of unit cells to be plotted along each basis vector, as well as whether the wireframe is plotted.

Returns:

Type Description
(int, int, int, bool)

Numbers of unit cells along each of the three vectors and a boolean for the wireframe.

Source code in TiBi/controllers/main_ui_controller.py
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
def get_uc_plot_properties(self):
    """
    Get the unit cell visualization properties.

    The function returns the number of unit cells to be plotted
    along each basis vector, as well as whether the wireframe
    is plotted.

    Returns
    -------
    int, int, int, bool
        Numbers of unit cells along each of the three vectors and
        a boolean for the wireframe.
    """
    n1, n2, n3 = [
        spinbox.value() if spinbox.isEnabled() else 1
        for spinbox in (
            self.toolbar.n1_spinbox,
            self.toolbar.n2_spinbox,
            self.toolbar.n3_spinbox,
        )
    ]
    wireframe_enabled = self.action_manager.unit_cell_actions[
        "wireframe"
    ].isChecked()
    return n1, n2, n3, wireframe_enabled

set_spinbox_status(n1_enabled, n2_enabled, n3_enabled)

Activate/deactivate the unit cell spinboxes

Source code in TiBi/controllers/main_ui_controller.py
164
165
166
167
168
def set_spinbox_status(self, n1_enabled, n2_enabled, n3_enabled):
    """Activate/deactivate the unit cell spinboxes"""
    self.toolbar.n1_spinbox.setEnabled(n1_enabled)
    self.toolbar.n2_spinbox.setEnabled(n2_enabled)
    self.toolbar.n3_spinbox.setEnabled(n3_enabled)

update_status(message)

Display a message in the status bar.

Parameters:

Name Type Description Default
message str

Message to display

required
Source code in TiBi/controllers/main_ui_controller.py
324
325
326
327
328
329
330
331
332
333
def update_status(self, message):
    """
    Display a message in the status bar.

    Parameters
    ----------
    message : str
        Message to display
    """
    self.status_bar.update_status(message)

TiBi.controllers.PlotController

Bases: QObject

Controller for the 2D plot view.

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

plot_view PlotView

2D plot for displaying computed results.

Methods:

Name Description
plot_band_structure

Plot the band structure for the selected unit cell.

plot_dos

Plot the density of states for the selected unit cell.

Source code in TiBi/controllers/plot_controller.py
 10
 11
 12
 13
 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
class PlotController(QObject):
    """
    Controller for the 2D plot view.

    Attributes
    ----------
    unit_cells : dict[uuid.UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    plot_view : PlotView
        2D plot for displaying computed results.

    Methods
    -------
    plot_band_structure(states: list[int])
        Plot the band structure for the selected unit cell.
    plot_dos(num_bins: int, states: list[int], plot_type: int,\
             broadening: np.float64)
        Plot the density of states for the selected unit cell.
    """

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        plot_view: PlotView,
    ):
        super().__init__()
        self.unit_cells = unit_cells
        self.selection = selection
        self.plot_view = plot_view

    def plot_band_structure(self, states: list[int]):
        """
        Plot the band structure.

        Parameters
        ----------
        states : list[int]
            List of integers denoting onto which states
            the bands need to be projected.
        """

        self.plot_view.ax.clear()

        uc_id = self.selection.unit_cell
        if uc_id:
            bandstructure = self.unit_cells[uc_id].bandstructure
            # Set labels and grid
            self.plot_view.ax.set_xlabel("k-vector")
            self.plot_view.ax.set_ylabel("Energy")
            # Hide x-axis tick and keep the grid only for y-axis
            self.plot_view.ax.set_xticks([])
            self.plot_view.ax.grid(True, axis="y")
            # Extract the band structure quantities
            path = np.array(
                bandstructure.path
            )  # Array of momenta for which the bands were computed
            special_points = np.array(
                bandstructure.special_points
            )  # High symmetry points used to construct the BZ path
            bands = np.array(
                bandstructure.eigenvalues
            )  # Array of arrays of computed eigenvalues
            eigenvectors = np.array(
                bandstructure.eigenvectors
            )  # Array of 2D arrays of eigenvectors. Eigenvectors are columns

            if len(path) > 0 and len(bands) > 0:
                # Get the positions along the path reflecting the point spacing
                step = np.linalg.norm(np.diff(path, axis=0), axis=1)
                pos = np.hstack((0, np.cumsum(step)))

                pos = pos / pos[-1]  # Normalize the path length to 1

                # Repeat the same for special points
                step_special_points = np.linalg.norm(
                    np.diff(special_points, axis=0), axis=1
                )
                pos_special_points = np.hstack(
                    (0, np.cumsum(step_special_points))
                )
                pos_special_points = (
                    pos_special_points / pos_special_points[-1]
                )
                # Compute projection magnitude squared for each k-point
                projections = np.sum(
                    np.abs(eigenvectors[:, states, :]) ** 2, axis=1
                )
                for band_idx in range(bands.shape[1]):
                    sizes = DEFAULT_SCATTER_RADIUS * projections[:, band_idx]

                    # Plot the bands as lines
                    self.plot_view.ax.plot(
                        pos, bands[:, band_idx], linestyle="-", color=CF_SKY
                    )
                    # Plot the bands as scatterplots
                    self.plot_view.ax.scatter(
                        pos,
                        bands[:, band_idx],
                        s=sizes,
                        color=CF_VERMILLION,
                        alpha=0.6,
                    )

                # Plot vertical lines at special points
                for x in pos_special_points:
                    self.plot_view.ax.axvline(
                        x=x, color="gray", linestyle="--", linewidth=0.8
                    )
            # Draw the canvas
            self.plot_view.canvas.draw()

    def plot_dos(self, num_bins, states, plot_type, broadening):
        """
        Plot the density of states using the Brillouin zone grid.

        Parameters
        ----------
        num_bins : int
            Number of histogram bins
        states : list[int]
            List of integers denoting onto which states
            the bands need to be projected.
        plot_type : int
            Histogram (0) or Lorentzian (1)
        broadening : np.float64
            Broadening parameter for the Lorentzian DOS
        """
        self.plot_view.ax.clear()

        uc_id = self.selection.unit_cell

        if uc_id and self.unit_cells[uc_id].bz_grid.eigenvalues:
            bz_grid = self.unit_cells[uc_id].bz_grid

            # Set labels and grid
            self.plot_view.ax.set_xlabel("Energy")
            self.plot_view.ax.set_ylabel("DOS")

            # Extract the relevant qantities
            eigenvectors = np.array(
                bz_grid.eigenvectors
            )  # Array of 2D arrays of eigenvectors. Eigenvectors are columns
            # Create a single array of energies
            energies = np.concatenate(bz_grid.eigenvalues)
            # For each k-point and each eigenstate, keep only the selected
            # basis states. Sum over the squared amplitudes of the selected
            # basis states for each state

            # eigenvectors: shape (num_kpts, num_states, num_basis),
            # where num_states = num_basis
            # eigenvectors[:, states, :] selects the desired basis projections
            projections = np.concatenate(
                np.sum(np.abs(eigenvectors[:, states, :]) ** 2, axis=1), axis=0
            )

            bin_edges = np.histogram_bin_edges(energies, bins=num_bins)
            # Histogram or Lorentzian:
            if plot_type == 0:  # Histogram
                # Construct a histogram using the selected states' probability
                # for each eigenvalue as the weight
                hist, _ = np.histogram(
                    energies, bins=bin_edges, weights=projections
                )
                # Get the bind centers and normalize the histogram
                bin_centers = 0.5 * (bin_edges[1:] + bin_edges[:-1])
                bin_width = bin_edges[1] - bin_edges[0]

                dos = hist / len(bz_grid.k_points)
                self.plot_view.ax.bar(
                    bin_centers,
                    dos,
                    width=bin_width,
                    color=CF_SKY,
                    edgecolor=CF_BLUE,
                )
            else:
                # Get pairwise differences between the energy grid
                # and the centers of the Lorentzians
                delta = energies[:, None] - bin_edges[None, :]
                lorentzians = (broadening / np.pi) / (delta**2 + broadening**2)
                dos = projections @ lorentzians / len(bz_grid.k_points)
                self.plot_view.ax.plot(
                    bin_edges, dos, linestyle="-", color=CF_SKY
                )

            # Draw the canvas
            self.plot_view.canvas.draw()

plot_band_structure(states)

Plot the band structure.

Parameters:

Name Type Description Default
states list[int]

List of integers denoting onto which states the bands need to be projected.

required
Source code in TiBi/controllers/plot_controller.py
 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
def plot_band_structure(self, states: list[int]):
    """
    Plot the band structure.

    Parameters
    ----------
    states : list[int]
        List of integers denoting onto which states
        the bands need to be projected.
    """

    self.plot_view.ax.clear()

    uc_id = self.selection.unit_cell
    if uc_id:
        bandstructure = self.unit_cells[uc_id].bandstructure
        # Set labels and grid
        self.plot_view.ax.set_xlabel("k-vector")
        self.plot_view.ax.set_ylabel("Energy")
        # Hide x-axis tick and keep the grid only for y-axis
        self.plot_view.ax.set_xticks([])
        self.plot_view.ax.grid(True, axis="y")
        # Extract the band structure quantities
        path = np.array(
            bandstructure.path
        )  # Array of momenta for which the bands were computed
        special_points = np.array(
            bandstructure.special_points
        )  # High symmetry points used to construct the BZ path
        bands = np.array(
            bandstructure.eigenvalues
        )  # Array of arrays of computed eigenvalues
        eigenvectors = np.array(
            bandstructure.eigenvectors
        )  # Array of 2D arrays of eigenvectors. Eigenvectors are columns

        if len(path) > 0 and len(bands) > 0:
            # Get the positions along the path reflecting the point spacing
            step = np.linalg.norm(np.diff(path, axis=0), axis=1)
            pos = np.hstack((0, np.cumsum(step)))

            pos = pos / pos[-1]  # Normalize the path length to 1

            # Repeat the same for special points
            step_special_points = np.linalg.norm(
                np.diff(special_points, axis=0), axis=1
            )
            pos_special_points = np.hstack(
                (0, np.cumsum(step_special_points))
            )
            pos_special_points = (
                pos_special_points / pos_special_points[-1]
            )
            # Compute projection magnitude squared for each k-point
            projections = np.sum(
                np.abs(eigenvectors[:, states, :]) ** 2, axis=1
            )
            for band_idx in range(bands.shape[1]):
                sizes = DEFAULT_SCATTER_RADIUS * projections[:, band_idx]

                # Plot the bands as lines
                self.plot_view.ax.plot(
                    pos, bands[:, band_idx], linestyle="-", color=CF_SKY
                )
                # Plot the bands as scatterplots
                self.plot_view.ax.scatter(
                    pos,
                    bands[:, band_idx],
                    s=sizes,
                    color=CF_VERMILLION,
                    alpha=0.6,
                )

            # Plot vertical lines at special points
            for x in pos_special_points:
                self.plot_view.ax.axvline(
                    x=x, color="gray", linestyle="--", linewidth=0.8
                )
        # Draw the canvas
        self.plot_view.canvas.draw()

plot_dos(num_bins, states, plot_type, broadening)

Plot the density of states using the Brillouin zone grid.

Parameters:

Name Type Description Default
num_bins int

Number of histogram bins

required
states list[int]

List of integers denoting onto which states the bands need to be projected.

required
plot_type int

Histogram (0) or Lorentzian (1)

required
broadening float64

Broadening parameter for the Lorentzian DOS

required
Source code in TiBi/controllers/plot_controller.py
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
def plot_dos(self, num_bins, states, plot_type, broadening):
    """
    Plot the density of states using the Brillouin zone grid.

    Parameters
    ----------
    num_bins : int
        Number of histogram bins
    states : list[int]
        List of integers denoting onto which states
        the bands need to be projected.
    plot_type : int
        Histogram (0) or Lorentzian (1)
    broadening : np.float64
        Broadening parameter for the Lorentzian DOS
    """
    self.plot_view.ax.clear()

    uc_id = self.selection.unit_cell

    if uc_id and self.unit_cells[uc_id].bz_grid.eigenvalues:
        bz_grid = self.unit_cells[uc_id].bz_grid

        # Set labels and grid
        self.plot_view.ax.set_xlabel("Energy")
        self.plot_view.ax.set_ylabel("DOS")

        # Extract the relevant qantities
        eigenvectors = np.array(
            bz_grid.eigenvectors
        )  # Array of 2D arrays of eigenvectors. Eigenvectors are columns
        # Create a single array of energies
        energies = np.concatenate(bz_grid.eigenvalues)
        # For each k-point and each eigenstate, keep only the selected
        # basis states. Sum over the squared amplitudes of the selected
        # basis states for each state

        # eigenvectors: shape (num_kpts, num_states, num_basis),
        # where num_states = num_basis
        # eigenvectors[:, states, :] selects the desired basis projections
        projections = np.concatenate(
            np.sum(np.abs(eigenvectors[:, states, :]) ** 2, axis=1), axis=0
        )

        bin_edges = np.histogram_bin_edges(energies, bins=num_bins)
        # Histogram or Lorentzian:
        if plot_type == 0:  # Histogram
            # Construct a histogram using the selected states' probability
            # for each eigenvalue as the weight
            hist, _ = np.histogram(
                energies, bins=bin_edges, weights=projections
            )
            # Get the bind centers and normalize the histogram
            bin_centers = 0.5 * (bin_edges[1:] + bin_edges[:-1])
            bin_width = bin_edges[1] - bin_edges[0]

            dos = hist / len(bz_grid.k_points)
            self.plot_view.ax.bar(
                bin_centers,
                dos,
                width=bin_width,
                color=CF_SKY,
                edgecolor=CF_BLUE,
            )
        else:
            # Get pairwise differences between the energy grid
            # and the centers of the Lorentzians
            delta = energies[:, None] - bin_edges[None, :]
            lorentzians = (broadening / np.pi) / (delta**2 + broadening**2)
            dos = projections @ lorentzians / len(bz_grid.k_points)
            self.plot_view.ax.plot(
                bin_edges, dos, linestyle="-", color=CF_SKY
            )

        # Draw the canvas
        self.plot_view.canvas.draw()

TiBi.controllers.UnitCellController

Bases: QObject

Controller managing the UnitCell, Site, and State creation panel.

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

unit_cell_view UnitCellView

The main view component

undo_stack QUndoStack

QUndoStack to hold "undo-able" commands

v1, v2, v3 list[EnterKeySpinBox]

Lists of spinboxes for basis vector components

R, c1, c2, c3 EnterKeySpinBox

Spinboxes for site properties

tree_view_panel TreeViewPanel

The tree view panel component

tree_view SystemTree

The tree view component

tree_model QStandardItemModel

The model backing the tree view

unit_cell_parameter_changed Signal

Signal emitted when a unit cell parameter is changed. This triggers a redraw of the panels orchestrated by the app_controller. Whenever unit cell parameter changes, the derived quantities (band structure, BZ grid, etc), are discarded due to being stale. The clearing is handled by the associated commands.

site_parameter_changed Signal

Signal emitted when a site parameter is changed. This triggers a redraw only of the unit cell plot. Changing site parameters does not invalidate the derived quantities since the site parameters are purely cosmetic.

hopping_projection_update_requested Signal

Signal emitted when a tree item is renamed or the structure of the unit cell changes (adding/removing states), requiring an update of the hopping matrix and the projection selection.

Methods:

Name Description
refresh_tree

Redraw the system tree using the current system state.

select_item

Select a tree item using the ID's.

Source code in TiBi/controllers/uc_controller.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
class UnitCellController(QObject):
    """
    Controller managing the `UnitCell`, `Site`, and `State` creation panel.

    Attributes
    ----------
    unit_cells : dict[uuid.UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    unit_cell_view : UnitCellView
        The main view component
    undo_stack : QUndoStack
        `QUndoStack` to hold "undo-able" commands
    v1, v2, v3 : list[EnterKeySpinBox]
        Lists of spinboxes for basis vector components
    R, c1, c2, c3 : EnterKeySpinBox
        Spinboxes for site properties
    tree_view_panel : TreeViewPanel
        The tree view panel component
    tree_view : SystemTree
        The tree view component
    tree_model : QStandardItemModel
        The model backing the tree view
    unit_cell_parameter_changed : Signal
        Signal emitted when a unit cell parameter is changed.
        This triggers a redraw of the panels orchestrated by
        the app_controller. Whenever unit cell parameter changes,
        the derived quantities (band structure, BZ grid, etc),
        are discarded due to being stale. The clearing is handled
        by the associated commands.
    site_parameter_changed : Signal
        Signal emitted when a site parameter is changed.
        This triggers a redraw only of the unit cell plot.
        Changing site parameters does not invalidate the derived quantities
        since the site parameters are purely cosmetic.
    hopping_projection_update_requested : Signal
        Signal emitted when a tree item is renamed or the structure of the
        unit cell changes (adding/removing states), requiring
        an update of the hopping matrix and the projection selection.

    Methods
    -------
    refresh_tree()
        Redraw the system tree using the current system state.
    select_item(uc_id: uuid.UUID, site_id: uuid.UUID, state_id: uuid.UUID):
        Select a tree item using the ID's.
    """

    unit_cell_parameter_changed = Signal()
    site_parameter_changed = Signal()
    hopping_projection_update_requested = Signal()

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        unit_cell_view: UnitCellView,
        undo_stack: QUndoStack,
    ):
        super().__init__()
        self.unit_cells = unit_cells
        self.selection = selection
        self.unit_cell_view = unit_cell_view
        self.undo_stack = undo_stack

        # Get the fields from unit_cell_view for convenience
        # For the basis vectors, each reference has three spinboxes
        # corresponding to the Cartesian coordinates
        # Unit Cell fields
        self.v1 = self.unit_cell_view.unit_cell_panel.v1
        self.v2 = self.unit_cell_view.unit_cell_panel.v2
        self.v3 = self.unit_cell_view.unit_cell_panel.v3

        # Site fields
        self.R = self.unit_cell_view.site_panel.R
        self.c1 = self.unit_cell_view.site_panel.c1
        self.c2 = self.unit_cell_view.site_panel.c2
        self.c3 = self.unit_cell_view.site_panel.c3

        # Store the tree_view_panel, tree_view and tree_model as
        # attributes for convenience
        self.tree_view_panel = self.unit_cell_view.tree_view_panel
        self.tree_view = self.tree_view_panel.tree_view
        self.tree_model = self.tree_view.tree_model

        # Rebuild the tree view from scratch in the beginning
        self.tree_view.refresh_tree(self.unit_cells)

        # SIGNALS
        # Selection change.
        self.selection.selection_changed.connect(self._show_panels)

        # Tree view signals
        # When the tree selection changes, the selection model is updated
        self.tree_view.tree_selection_changed.connect(
            lambda x: self.selection.set_selection(
                uc_id=x["unit_cell"], site_id=x["site"], state_id=x["state"]
            )
        )

        # Triggered when a tree item's name is changed by double clicking on it
        self.tree_view_panel.name_edit_finished.connect(
            lambda x: self.undo_stack.push(
                RenameTreeItemCommand(
                    unit_cells=self.unit_cells,
                    selection=self.selection,
                    tree_view=self.tree_view,
                    signal=self.hopping_projection_update_requested,
                    item=self.tree_model.itemFromIndex(x),
                )
            )
        )

        # Triggered when the user presses Del or Backspace while
        # a tree item is highlighted, or clicks the Delete button
        # The signal is emitted only when there are states that are deleted
        # (either directly or as part of a site). If a state is deleted,
        # derived quantities (bands, BZ grid, etc.) are discarded
        # due to being stale.
        # If a unit cell is deleted,
        # the signal is not emitted as the unit cell deletion changes the
        # selection, which is handled separately.
        self.tree_view_panel.delete_requested.connect(
            lambda: self.undo_stack.push(
                DeleteItemCommand(
                    unit_cells=self.unit_cells,
                    selection=self.selection,
                    tree_view=self.tree_view,
                    signal=self.hopping_projection_update_requested,
                )
            )
        )
        # New item creation using the tree delegate.
        self.tree_view_panel.new_unit_cell_requested.connect(
            lambda: self.undo_stack.push(
                AddUnitCellCommand(
                    unit_cells=self.unit_cells, tree_view=self.tree_view
                )
            )
        )

        self.tree_view_panel.new_site_requested.connect(
            lambda: self.undo_stack.push(
                AddSiteCommand(
                    unit_cells=self.unit_cells,
                    selection=self.selection,
                    tree_view=self.tree_view,
                )
            )
        )
        self.tree_view_panel.new_state_requested.connect(
            lambda: self.undo_stack.push(
                AddStateCommand(
                    unit_cells=self.unit_cells,
                    selection=self.selection,
                    tree_view=self.tree_view,
                    signal=self.hopping_projection_update_requested,
                )
            )
        )

        # Unit Cell basis vector signals. Emitted when the user confirms
        # the change in the corresponding field but pressing Enter.
        def connect_vector_fields(
            vector_name, spinboxes: list[EnterKeySpinBox]
        ):
            for ii, axis in enumerate("xyz"):
                spinboxes[ii].editingConfirmed.connect(
                    lambda ii=ii, axis=axis: self.undo_stack.push(
                        UpdateUnitCellParameterCommand(
                            unit_cells=self.unit_cells,
                            selection=self.selection,
                            vector=vector_name,
                            coordinate=axis,
                            spinbox=spinboxes[ii],
                            signal=self.unit_cell_parameter_changed,
                        )
                    )
                )

        # Connect the fields for the three basis vectors
        connect_vector_fields("v1", self.v1)
        connect_vector_fields("v2", self.v2)
        connect_vector_fields("v3", self.v3)

        # Dimensionality radio buttons
        self.radio_buttons = [
            self.unit_cell_view.unit_cell_panel.radio0D,
            self.unit_cell_view.unit_cell_panel.radio1D,
            self.unit_cell_view.unit_cell_panel.radio2D,
            self.unit_cell_view.unit_cell_panel.radio3D,
        ]
        # Dimensionality change signals from the radio buttons.
        # Changes in dimensionality trigger the same response
        # as in the basis vector coordinates.
        for dim, radio in enumerate(self.radio_buttons):
            radio.toggled.connect(
                lambda checked, d=dim: (
                    self.undo_stack.push(
                        ChangeDimensionalityCommand(
                            unit_cells=self.unit_cells,
                            selection=self.selection,
                            unit_cell_view=self.unit_cell_view,
                            signal=self.unit_cell_parameter_changed,
                            dim=d,
                            buttons=self.radio_buttons,
                        )
                    )
                    if checked
                    else None
                )
            )

        # Site panel signals.
        # Signals for fractional site coordinates.
        for param in ["c1", "c2", "c3"]:
            spinbox: EnterKeySpinBox = getattr(self, param)
            spinbox.editingConfirmed.connect(
                lambda p=param, s=spinbox: self.undo_stack.push(
                    UpdateSiteParameterCommand(
                        unit_cells=self.unit_cells,
                        selection=self.selection,
                        param=p,
                        spinbox=s,
                        signal=self.site_parameter_changed,
                    )
                )
            )

        # Site radius
        self.R.editingConfirmed.connect(
            lambda: self.undo_stack.push(
                UpdateSiteParameterCommand(
                    unit_cells=self.unit_cells,
                    selection=self.selection,
                    param="R",
                    spinbox=self.R,
                    signal=self.site_parameter_changed,
                )
            )
        )

        # Button signals
        # Reduce button--LLL argorithm to obtain the primitive cell.
        self.unit_cell_view.unit_cell_panel.reduce_btn.clicked.connect(
            lambda: self.undo_stack.push(
                ReduceBasisCommand(
                    unit_cells=self.unit_cells,
                    selection=self.selection,
                    unit_cell_view=self.unit_cell_view,
                    signal=self.unit_cell_parameter_changed,
                )
            )
        )
        # Opens a color picker to change the color of the selected site
        self.unit_cell_view.site_panel.color_picker_btn.clicked.connect(
            self._pick_site_color
        )

    def _show_panels(self):
        """
        Update the UI panels based on the current selection state.

        This method is called whenever the selection changes. It determines
        which panels should be visible and populates them with data from
        the selected items.
        The panels are shown or hidden using a stacked widget approach.

        The method handles all three levels of the hierarchy:
        - When a unit cell is selected, its properties are shown in
        the unit cell panel
        - When a site is selected, its properties are shown in the site panel
        - When a state is selected, no additional panel is shown as the state
        is only described by its name

        Buttons are also enabled/disabled based on the selection context.
        """
        unit_cell_id = self.selection.unit_cell
        site_id = self.selection.site
        if unit_cell_id:
            # Get the selected unit cell
            uc = self.unit_cells[unit_cell_id]

            # Get the system dimensionality
            dim = uc.v1.is_periodic + uc.v2.is_periodic + uc.v3.is_periodic
            # Set the dimensionality radio button.
            # Suppress the dim_listener since we are updating the radio
            # button programmatically
            for btn in self.radio_buttons:
                btn.blockSignals(True)
            self.unit_cell_view.unit_cell_panel.radio_group.button(
                dim
            ).setChecked(True)
            # Enable the coordinate fields depending on the dimensionality
            self.unit_cell_view.unit_cell_panel.v1[0].setEnabled(True)
            self.unit_cell_view.unit_cell_panel.v1[1].setEnabled(dim > 1)
            self.unit_cell_view.unit_cell_panel.v1[2].setEnabled(dim > 2)

            self.unit_cell_view.unit_cell_panel.v2[0].setEnabled(dim > 1)
            self.unit_cell_view.unit_cell_panel.v2[1].setEnabled(True)
            self.unit_cell_view.unit_cell_panel.v2[2].setEnabled(dim > 2)

            self.unit_cell_view.unit_cell_panel.v3[0].setEnabled(dim > 2)
            self.unit_cell_view.unit_cell_panel.v3[1].setEnabled(dim > 2)
            self.unit_cell_view.unit_cell_panel.v3[2].setEnabled(True)

            for btn in self.radio_buttons:
                btn.blockSignals(False)

            # Set the basis vector fields
            self.unit_cell_view.unit_cell_panel.set_basis_vectors(
                uc.v1, uc.v2, uc.v3
            )

            # Show the UnitCellPanel
            self.unit_cell_view.uc_stack.setCurrentWidget(
                self.unit_cell_view.unit_cell_panel
            )

            if site_id:
                site = uc.sites[site_id]
                # Set the fractional coordinates and radius fields
                self.c1.setValue(site.c1)
                self.c2.setValue(site.c2)
                self.c3.setValue(site.c3)
                self.R.setValue(site.R)

                # Set the color for the color picker button
                site_color = site.color

                c = (
                    int(site_color[0] * 255),
                    int(site_color[1] * 255),
                    int(site_color[2] * 255),
                    int(site_color[3] * 255),
                )  # Color in 0-255 component range

                self.unit_cell_view.site_panel.color_picker_btn.setStyleSheet(
                    f"background-color: rgba({c[0]}, {c[1]}, {c[2]}, {c[3]});"
                )

                # Show the SitePanel
                self.unit_cell_view.site_stack.setCurrentWidget(
                    self.unit_cell_view.site_panel
                )

            else:
                # If no site is selected, hide the SitePanel
                self.unit_cell_view.site_stack.setCurrentWidget(
                    self.unit_cell_view.site_info_label
                )
        else:
            # If no unit cell is selected, hide the SitePanel and UnitCellPanel
            self.unit_cell_view.uc_stack.setCurrentWidget(
                self.unit_cell_view.uc_info_label
            )
            self.unit_cell_view.site_stack.setCurrentWidget(
                self.unit_cell_view.site_info_label
            )

    def _pick_site_color(self):
        """
        Open a color dialog to select a color for the selected site.

        After the color is picked, an undoable command is issued.
        """
        old_color = (
            self.unit_cells[self.selection.unit_cell]
            .sites[self.selection.site]
            .color
        )

        # Open the color dialog with the current color selected
        start_color = QColor(
            int(old_color[0] * 255),
            int(old_color[1] * 255),
            int(old_color[2] * 255),
            int(old_color[3] * 255),
        )
        new_color = QColorDialog.getColor(
            initial=start_color,
            options=QColorDialog.ShowAlphaChannel,
        )

        # Update the button color
        if new_color.isValid():
            self.undo_stack.push(
                ChangeSiteColorCommand(
                    unit_cells=self.unit_cells,
                    selection=self.selection,
                    new_color=new_color,
                    old_color=start_color,
                    unit_cell_view=self.unit_cell_view,
                    signal=self.site_parameter_changed,
                )
            )

    def refresh_tree(self):
        """
        Redraw the system tree using the current system state.
        """
        self.tree_view.refresh_tree(self.unit_cells)

    def select_item(self, uc_id, site_id, state_id):
        """
        Select a tree item using the ID's.
        """
        self.tree_view._select_item_by_id(uc_id, site_id, state_id)

refresh_tree()

Redraw the system tree using the current system state.

Source code in TiBi/controllers/uc_controller.py
421
422
423
424
425
def refresh_tree(self):
    """
    Redraw the system tree using the current system state.
    """
    self.tree_view.refresh_tree(self.unit_cells)

select_item(uc_id, site_id, state_id)

Select a tree item using the ID's.

Source code in TiBi/controllers/uc_controller.py
427
428
429
430
431
def select_item(self, uc_id, site_id, state_id):
    """
    Select a tree item using the ID's.
    """
    self.tree_view._select_item_by_id(uc_id, site_id, state_id)

TiBi.controllers.UnitCellPlotController

Bases: QObject

Controller for the unit cell 3D visualization.

This controller manages the 3D visualization of unit cells, handling the rendering of unit cell wireframes, site positions, and periodic repetitions.

Attributes:

Name Type Description
unit_cells dict[UUID, UnitCell]

Dictionary mapping UUIDs to UnitCell objects

selection Selection

Model tracking the currently selected unit cell, site, and state

uc_plot_view UnitCellPlotView

The view component for the 3D visualization

unit_cell UnitCell

The unit cell currently being visualized

uc_plot_items dict

Dictionary to store plot items

Methods:

Name Description
update_hopping_segments

Draw segments to indicate hopping connections.

update_unit_cell

Draw the selected UnitCell in the 3D view.

Source code in TiBi/controllers/uc_plot_controller.py
 12
 13
 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
class UnitCellPlotController(QObject):
    """
    Controller for the unit cell 3D visualization.

    This controller manages the 3D visualization of unit cells, handling the
    rendering of unit cell wireframes, site positions, and periodic
    repetitions.

    Attributes
    ----------
    unit_cells : dict[uuid.UUID, UnitCell]
        Dictionary mapping UUIDs to UnitCell objects
    selection : Selection
        Model tracking the currently selected unit cell, site, and state
    uc_plot_view : UnitCellPlotView
        The view component for the 3D visualization
    unit_cell : UnitCell
        The unit cell currently being visualized
    uc_plot_items : dict
        Dictionary to store plot items

    Methods
    -------
    update_hopping_segments(pair_selection: \
        tuple[tuple[str, uuid.UUID, str, uuid.UUID],\
            tuple[str, uuid.UUID, str, uuid.UUID]])
        Draw segments to indicate hopping connections.
    update_unit_cell(wireframe_shown: bool, n1: int, n2: int, n3: int)
        Draw the selected `UnitCell` in the 3D view.
    """

    def __init__(
        self,
        unit_cells: dict[uuid.UUID, UnitCell],
        selection: Selection,
        uc_plot_view: UnitCellPlotView,
    ):
        super().__init__()
        self.unit_cells = unit_cells
        self.selection = selection
        self.uc_plot_view = uc_plot_view

        # Internal controller state
        self.unit_cell = None  # Unit cell being plotted
        self.uc_plot_items = {}  # Dictionary to store plot items

    def update_unit_cell(
        self, wireframe_shown: bool, n1: int, n2: int, n3: int
    ):
        """
        Draw the selected `UnitCell` in the 3D view.

        Parameters
        ----------
        wireframe_shown : bool
            Denotes whether the primitive vector wireframe is drawn
        n1, n2, n3 : int
            Number of repetitions along the corresponding basis vector
        """
        uc_id = self.selection.unit_cell
        # Clear previous plot items except axes
        for key, item in list(self.uc_plot_items.items()):
            self.uc_plot_view.view.removeItem(item)
            del self.uc_plot_items[key]
        # Early exit if no unit cell selected
        if uc_id is None:
            return

        self.unit_cell = self.unit_cells[uc_id]
        self.n1, self.n2, self.n3 = n1, n2, n3

        # Collect line vertices
        unique_edges = (
            set()
        )  # Unique edges to avoid duplication from neighboring unit cells
        # Loop over the unit cell indices
        for jj, kk, ll in product(
            range(self.n1), range(self.n2), range(self.n3)
        ):
            # List of tuples with vertices defining edges
            edges = self._get_unit_cell_edges(jj, kk, ll)
            for edge in edges:
                unique_edges.add(edge)  # Keep only unique edges
            self._plot_sites(jj, kk, ll)

        # Convert edges to line vertices
        line_vertices = []
        for v1, v2 in unique_edges:
            line_vertices.extend([v1, v2])
        # Create the wireframe using GLLinePlotItem
        unit_cell_edges = gl.GLLinePlotItem(
            pos=line_vertices, color="w", width=1, mode="lines"  # White color
        )

        # Shift the unit cells so that they are centered around the origin
        shift = (
            -(
                self.n1 * self.unit_cell.v1.as_array()
                + self.n2 * self.unit_cell.v2.as_array()
                + self.n3 * self.unit_cell.v3.as_array()
            )
            / 2
        )

        unit_cell_edges.translate(shift[0], shift[1], shift[2])

        # Plot the wireframe if requested
        if wireframe_shown:
            self.uc_plot_view.view.addItem(unit_cell_edges)
            self.uc_plot_items["unit_cell_edges"] = unit_cell_edges

    def _plot_sites(self, a1, a2, a3):
        """
        Plot all `Site`s within the `UnitCell` at (a1,a2,a3).

        Each site is represented as a colored sphere positioned according to
        its fractional coordinates within the unit cell. Sites can be selected
        and change size when highlighted. Spheres also store the site id
        to draw the coupling links when pairs of states are selected from
        the hopping panel.

        Parameters
        ----------
        a1, a2, a3 : int
            Integer multiples of the unit cell basis vectors v1, v2, and v3.
        """
        # Early exit if no unit cell or it contains no sites
        if not self.unit_cell or not self.unit_cell.sites:
            return

        # Extract basis vectors
        v1 = self.unit_cell.v1.as_array()
        v2 = self.unit_cell.v2.as_array()
        v3 = self.unit_cell.v3.as_array()

        # Plot each site as a sphere
        for site_id, site in self.unit_cell.sites.items():
            # Calculate the position in Cartesian coordinates
            pos = (
                (a1 + site.c1) * v1 + (a2 + site.c2) * v2 + (a3 + site.c3) * v3
            )
            sphere_color = self.unit_cell.sites[site_id].color

            sphere_radius = (
                self.unit_cell.sites[site_id].R * DEFAULT_SITE_SCALING
                if site_id == self.selection.site
                else self.unit_cell.sites[site_id].R
            )
            # Create a sphere for the site.
            sphere = gl.GLMeshItem(
                meshdata=gl.MeshData.sphere(
                    rows=10, cols=10, radius=sphere_radius
                ),
                smooth=True,
                color=(
                    sphere_color[0],
                    sphere_color[1],
                    sphere_color[2],
                    sphere_color[3],
                ),
                shader="shaded",
                glOptions="translucent",
            )
            # Shift the objects so that the illustration is centered
            # at the origin
            shift = -(self.n1 * v1 + self.n2 * v2 + self.n3 * v3) / 2 + pos
            sphere.translate(shift[0], shift[1], shift[2])

            # Store site ID as user data for interaction
            sphere.site_id = site_id

            self.uc_plot_view.view.addItem(sphere)
            self.uc_plot_items[f"site_{site_id}_{a1}_{a2}_{a3}"] = sphere

    def _get_unit_cell_edges(
        self, a1, a2, a3
    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
        """
        Get the edges of the unit cell parallelepiped.

        Parameters
        ----------
        a1, a2, a3 : int
            Integer multiples of the unit cell basis vectors v1, v2, and v3.

        Returns
        -------
        list[tuple[tuple[float, float, float], tuple[float, float, float]]]
            A list of edges in the unit cell parallelepiped.
            Each edge is represented as a tuple of two vertices.
            Each vertex is a tuple of three floats (x, y, z).
        """
        if not self.unit_cell:
            return

        # Extract basis vectors
        v1 = self.unit_cell.v1.as_array()
        v2 = self.unit_cell.v2.as_array()
        v3 = self.unit_cell.v3.as_array()

        # Define the 8 corners of the parallelepiped
        verts = np.array(
            [
                [0, 0, 0],
                v1,
                v2,
                v1 + v2,  # Bottom 4 vertices
                v3,
                v1 + v3,
                v2 + v3,
                v1 + v2 + v3,  # Top 4 vertices
            ]
        )
        # Shift the corners by the appropriate multiples of basis vectors
        verts = [v + (a1 * v1 + a2 * v2 + a3 * v3) for v in verts]
        # Define the 12 edges of the parallelepiped
        edges = np.array(
            [
                [0, 1],
                [0, 2],
                [1, 3],
                [2, 3],  # Bottom square
                [4, 5],
                [4, 6],
                [5, 7],
                [6, 7],  # Top square
                [0, 4],
                [1, 5],
                [2, 6],
                [3, 7],  # Vertical edges
            ]
        )
        # Convert edges into line segments
        vertex_tuples = []
        for edge in edges:
            vert1 = tuple(verts[edge[0]])
            vert2 = tuple(verts[edge[1]])
            vertex_tuples.append(tuple(sorted((vert1, vert2))))

        return vertex_tuples

    def update_hopping_segments(self, pair_selection):
        """
        Draw segments to indicate hopping connections.

        When a pair is selected from the hopping matrix, this function
        draws lines starting from the site hosting the source state
        inside the unit cell around (0,0,0) to all the sites hosting
        the target sites.

        Parameters
        ----------
        pair_selection : tuple
            A tuple of (site_name, site_id, state_name, state_id)
            representing the selected pair of states.
            The first element is the source state and the second
            element is the target state.
        """

        # Clear previous hopping segments
        hopping_segments = self.uc_plot_items.get("hopping_segments")
        if hopping_segments is not None:
            self.uc_plot_view.view.removeItem(hopping_segments)
            del self.uc_plot_items["hopping_segments"]

        # s1 and s2 are tuples (site_name, site_id, state_name, state_id)
        s1, s2 = pair_selection
        hoppings = self.unit_cell.hoppings.get((s1[3], s2[3]))
        # Early exit if the states are not coupled
        if hoppings is None:
            return
        # Get the basis vectors
        v1 = self.unit_cell.v1.as_array()
        v2 = self.unit_cell.v2.as_array()
        v3 = self.unit_cell.v3.as_array()

        # Get the location of the source site in the (0,0,0) unit cell
        source = self.unit_cell.sites[s2[1]]
        source_pos = source.c1 * v1 + source.c2 * v2 + source.c3 * v3

        # Get the location of the target sites in the (0,0,0) unit cell
        target = self.unit_cell.sites[s1[1]]
        target_pos = target.c1 * v1 + target.c2 * v2 + target.c3 * v3

        segments = []
        # Extract the displacements for the selected state pair
        for (d1, d2, d3), _ in hoppings:
            target = target_pos + d1 * v1 + d2 * v2 + d3 * v3
            # Sequentially append the source and the target for each
            # coupling segment
            segments.append(source_pos)
            segments.append(target)

        hopping_segments = gl.GLLinePlotItem(
            pos=segments,
            color=CF_YELLOW,
            width=5,
            mode="lines",
        )
        # Shift so that the source state is in a unit cell at the origin
        shift = (
            (self.n1 % 2) * v1 + (self.n2 % 2) * v2 + (self.n3 % 2) * v3
        ) / 2

        hopping_segments.translate(-shift[0], -shift[1], -shift[2])

        # Add to the view
        self.uc_plot_view.view.addItem(hopping_segments)
        # Store it so we can remove it later
        self.uc_plot_items["hopping_segments"] = hopping_segments

update_hopping_segments(pair_selection)

Draw segments to indicate hopping connections.

When a pair is selected from the hopping matrix, this function draws lines starting from the site hosting the source state inside the unit cell around (0,0,0) to all the sites hosting the target sites.

Parameters:

Name Type Description Default
pair_selection tuple

A tuple of (site_name, site_id, state_name, state_id) representing the selected pair of states. The first element is the source state and the second element is the target state.

required
Source code in TiBi/controllers/uc_plot_controller.py
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
def update_hopping_segments(self, pair_selection):
    """
    Draw segments to indicate hopping connections.

    When a pair is selected from the hopping matrix, this function
    draws lines starting from the site hosting the source state
    inside the unit cell around (0,0,0) to all the sites hosting
    the target sites.

    Parameters
    ----------
    pair_selection : tuple
        A tuple of (site_name, site_id, state_name, state_id)
        representing the selected pair of states.
        The first element is the source state and the second
        element is the target state.
    """

    # Clear previous hopping segments
    hopping_segments = self.uc_plot_items.get("hopping_segments")
    if hopping_segments is not None:
        self.uc_plot_view.view.removeItem(hopping_segments)
        del self.uc_plot_items["hopping_segments"]

    # s1 and s2 are tuples (site_name, site_id, state_name, state_id)
    s1, s2 = pair_selection
    hoppings = self.unit_cell.hoppings.get((s1[3], s2[3]))
    # Early exit if the states are not coupled
    if hoppings is None:
        return
    # Get the basis vectors
    v1 = self.unit_cell.v1.as_array()
    v2 = self.unit_cell.v2.as_array()
    v3 = self.unit_cell.v3.as_array()

    # Get the location of the source site in the (0,0,0) unit cell
    source = self.unit_cell.sites[s2[1]]
    source_pos = source.c1 * v1 + source.c2 * v2 + source.c3 * v3

    # Get the location of the target sites in the (0,0,0) unit cell
    target = self.unit_cell.sites[s1[1]]
    target_pos = target.c1 * v1 + target.c2 * v2 + target.c3 * v3

    segments = []
    # Extract the displacements for the selected state pair
    for (d1, d2, d3), _ in hoppings:
        target = target_pos + d1 * v1 + d2 * v2 + d3 * v3
        # Sequentially append the source and the target for each
        # coupling segment
        segments.append(source_pos)
        segments.append(target)

    hopping_segments = gl.GLLinePlotItem(
        pos=segments,
        color=CF_YELLOW,
        width=5,
        mode="lines",
    )
    # Shift so that the source state is in a unit cell at the origin
    shift = (
        (self.n1 % 2) * v1 + (self.n2 % 2) * v2 + (self.n3 % 2) * v3
    ) / 2

    hopping_segments.translate(-shift[0], -shift[1], -shift[2])

    # Add to the view
    self.uc_plot_view.view.addItem(hopping_segments)
    # Store it so we can remove it later
    self.uc_plot_items["hopping_segments"] = hopping_segments

update_unit_cell(wireframe_shown, n1, n2, n3)

Draw the selected UnitCell in the 3D view.

Parameters:

Name Type Description Default
wireframe_shown bool

Denotes whether the primitive vector wireframe is drawn

required
n1 int

Number of repetitions along the corresponding basis vector

required
n2 int

Number of repetitions along the corresponding basis vector

required
n3 int

Number of repetitions along the corresponding basis vector

required
Source code in TiBi/controllers/uc_plot_controller.py
 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
def update_unit_cell(
    self, wireframe_shown: bool, n1: int, n2: int, n3: int
):
    """
    Draw the selected `UnitCell` in the 3D view.

    Parameters
    ----------
    wireframe_shown : bool
        Denotes whether the primitive vector wireframe is drawn
    n1, n2, n3 : int
        Number of repetitions along the corresponding basis vector
    """
    uc_id = self.selection.unit_cell
    # Clear previous plot items except axes
    for key, item in list(self.uc_plot_items.items()):
        self.uc_plot_view.view.removeItem(item)
        del self.uc_plot_items[key]
    # Early exit if no unit cell selected
    if uc_id is None:
        return

    self.unit_cell = self.unit_cells[uc_id]
    self.n1, self.n2, self.n3 = n1, n2, n3

    # Collect line vertices
    unique_edges = (
        set()
    )  # Unique edges to avoid duplication from neighboring unit cells
    # Loop over the unit cell indices
    for jj, kk, ll in product(
        range(self.n1), range(self.n2), range(self.n3)
    ):
        # List of tuples with vertices defining edges
        edges = self._get_unit_cell_edges(jj, kk, ll)
        for edge in edges:
            unique_edges.add(edge)  # Keep only unique edges
        self._plot_sites(jj, kk, ll)

    # Convert edges to line vertices
    line_vertices = []
    for v1, v2 in unique_edges:
        line_vertices.extend([v1, v2])
    # Create the wireframe using GLLinePlotItem
    unit_cell_edges = gl.GLLinePlotItem(
        pos=line_vertices, color="w", width=1, mode="lines"  # White color
    )

    # Shift the unit cells so that they are centered around the origin
    shift = (
        -(
            self.n1 * self.unit_cell.v1.as_array()
            + self.n2 * self.unit_cell.v2.as_array()
            + self.n3 * self.unit_cell.v3.as_array()
        )
        / 2
    )

    unit_cell_edges.translate(shift[0], shift[1], shift[2])

    # Plot the wireframe if requested
    if wireframe_shown:
        self.uc_plot_view.view.addItem(unit_cell_edges)
        self.uc_plot_items["unit_cell_edges"] = unit_cell_edges