Skip to content

GUI Pages

Dashboard

gui.pages.dashboard

Dashboard page for managing benchmark configurations.

Displays configuration cards in a grid layout with quick actions.

DashboardPage

Bases: QWidget

Main dashboard displaying all benchmark configurations.

Signals

config_selected: Emitted when a config card is clicked (path, data) run_requested: Emitted when Run is clicked (path, data) stop_requested: Emitted when Stop is clicked (path) edit_requested: Emitted when Edit is clicked (path)

Source code in gui/pages/dashboard.py
class DashboardPage(QWidget):
    """Main dashboard displaying all benchmark configurations.

    Signals:
        config_selected: Emitted when a config card is clicked (path, data)
        run_requested: Emitted when Run is clicked (path, data)
        stop_requested: Emitted when Stop is clicked (path)
        edit_requested: Emitted when Edit is clicked (path)
    """
    config_selected = pyqtSignal(str, dict)  # Path, Data
    run_requested = pyqtSignal(str, dict)    # Path, Data
    stop_requested = pyqtSignal(str)         # Path (no data needed for stop)
    edit_requested = pyqtSignal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(40, 40, 40, 40)
        self.cards = {} # Path -> Widget

        self.init_ui()
        self.refresh_configs()

    def init_ui(self):
        # Header
        header_row = QHBoxLayout()
        title = QLabel("Dashboard")
        title.setObjectName("headerLabel")
        header_row.addWidget(title)

        header_row.addStretch()

        self.create_btn = QPushButton("Create New")
        self.create_btn.setFixedSize(120, 36)
        self.create_btn.setStyleSheet("""
            QPushButton {
                background-color: #2563eb; color: #ffffff; border: none; border-radius: 8px; font-weight: 600;
            }
            QPushButton:hover { background-color: #1d4ed8; }
        """)
        self.create_btn.clicked.connect(self.open_wizard)
        header_row.addWidget(self.create_btn)

        self.refresh_btn = QPushButton("Refresh")
        self.refresh_btn.setFixedSize(100, 36)
        self.refresh_btn.setStyleSheet("""
            QPushButton {
                background-color: #334155; color: #f8fafc; border: 1px solid #475569; border-radius: 8px; font-weight: 600;
            }
            QPushButton:hover { background-color: #475569; }
        """)
        self.refresh_btn.clicked.connect(self.refresh_configs)
        header_row.addWidget(self.refresh_btn)

        self.layout.addLayout(header_row)

        # Grid Area
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setStyleSheet("background: transparent; border: none;")

        self.grid_container = QWidget()
        self.grid_layout = QGridLayout(self.grid_container)
        self.grid_layout.setSpacing(20)
        self.grid_layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)

        scroll.setWidget(self.grid_container)
        self.layout.addWidget(scroll)

    def open_wizard(self):
        wiz = ConfigWizard(self)
        wiz.config_created.connect(self.refresh_configs)
        wiz.exec_()

    def refresh_configs(self, running_config=None):
        # Clear existing
        for i in reversed(range(self.grid_layout.count())): 
            self.grid_layout.itemAt(i).widget().setParent(None)
        self.cards = {}

        matrices_dir = Path("configs/matrices")
        if not matrices_dir.exists():
            return

        row, col = 0, 0
        cols_per_row = 3

        for yaml_file in sorted(matrices_dir.glob("*.yaml")):
            try:
                from runner.resolve import load_yaml

                data = load_yaml(yaml_file)

                card = ConfigCard(str(yaml_file), data)
                card.card_clicked.connect(lambda p=str(yaml_file), d=data: self.config_selected.emit(p, d))
                card.run_clicked.connect(lambda p=str(yaml_file), d=data: self.run_requested.emit(p, d))
                card.stop_clicked.connect(lambda p=str(yaml_file): self.stop_requested.emit(p))
                card.edit_clicked.connect(lambda p=str(yaml_file): self.edit_requested.emit(p))

                # Restore running state
                if running_config and str(yaml_file) == running_config:
                    card.set_running(True)

                self.grid_layout.addWidget(card, row, col)
                self.cards[str(yaml_file)] = card

                col += 1
                if col >= cols_per_row:
                    col = 0
                    row += 1
            except Exception as e:
                print(f"Error loading {yaml_file}: {e}")

Configuration Details

gui.pages.details

Configuration details page with live monitoring and analysis.

Provides detailed view of benchmark configurations with: - Live CPU/RAM monitoring - Real-time trajectory visualization - Run logs and results analysis

ConfigDetailsPage

Bases: QWidget

Detailed configuration view with monitoring and analysis.

Provides tabs for: - Overview: Configuration summary and results table - Logs: Real-time execution logs - Monitor: Live CPU/RAM/trajectory visualization - Analysis: Post-run metrics and map comparison

Signals

back_clicked: Emitted when back button is clicked stop_requested: Emitted when stop button is clicked edit_requested: Emitted when edit button is clicked run_requested: Emitted when run is requested (path, options)

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

    Provides tabs for:
    - Overview: Configuration summary and results table
    - Logs: Real-time execution logs
    - Monitor: Live CPU/RAM/trajectory visualization
    - Analysis: Post-run metrics and map comparison

    Signals:
        back_clicked: Emitted when back button is clicked
        stop_requested: Emitted when stop button is clicked
        edit_requested: Emitted when edit button is clicked
        run_requested: Emitted when run is requested (path, options)
    """
    back_clicked = pyqtSignal()
    stop_requested = pyqtSignal()
    edit_requested = pyqtSignal()
    run_requested = pyqtSignal(str, dict)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.config_path = ""
        self.config_data = {}
        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.setStyleSheet("background-color: #0f172a;") # Force background
        print("DEBUG: ConfigDetailsPage initialized")

        self.log_view = QTextEdit() # Pre-init
        self.stop_btn = QPushButton("STOP RUN") # Pre-init
        self.summary_text = QLabel("Loading...") # Pre-init
        self.results_table = ResultsTableWidget() # Pre-init
        self.run_combo = QComboBox() # Pre-init

        self.init_ui()

    def init_ui(self):
        # 1. Top Bar
        self.top_bar = QFrame()
        self.top_bar.setStyleSheet("background-color: #1e293b; border-bottom: 1px solid #334155;")
        self.top_bar.setFixedHeight(60) # Enforce height

        top_layout = QHBoxLayout(self.top_bar)
        top_layout.setContentsMargins(20, 10, 20, 10)

        # Back Button
        back_btn = QPushButton("← Dashboard")
        back_btn.setStyleSheet("background: transparent; color: #94a3b8; font-weight: 600; border: none; font-size: 14px;")
        back_btn.setCursor(Qt.PointingHandCursor)
        back_btn.clicked.connect(self.back_clicked.emit)
        top_layout.addWidget(back_btn)

        # Title
        self.title_label = QLabel("Configuration Details")
        self.title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #f8fafc; margin-left: 15px;")
        top_layout.addWidget(self.title_label)

        top_layout.addStretch()

        # Options Button
        self.btn_opts = QPushButton("⚙")
        self.btn_opts.setFixedSize(40, 32)
        self.btn_opts.setStyleSheet("QPushButton { background-color: #334155; color: #f8fafc; border: none; border-radius: 6px; font-weight: bold; font-size: 16px; } QPushButton:hover { background-color: #475569; } QPushButton::menu-indicator { image: none; }")
        self.btn_opts.setCursor(Qt.PointingHandCursor)

        # Options Menu
        from PyQt5.QtWidgets import QMenu, QAction
        self.opts_menu = QMenu(self)
        self.opts_menu.setStyleSheet("QMenu { background-color: #1e293b; color: #f8fafc; border: 1px solid #334155; } QMenu::item:selected { background-color: #3b82f6; }")

        self.opt_show_results = QAction("Show Results after Run", self, checkable=True)
        self.opt_show_results.setChecked(True)
        self.opt_gazebo_gui = QAction("Enable Gazebo GUI", self, checkable=True)
        self.opt_gazebo_gui.setChecked(False)
        self.opt_rviz_gui = QAction("Enable RViz", self, checkable=True)
        self.opt_rviz_gui.setChecked(False)

        self.opts_menu.addAction(self.opt_show_results)
        self.opts_menu.addSeparator()
        self.opts_menu.addAction(self.opt_gazebo_gui)
        self.opts_menu.addAction(self.opt_rviz_gui)
        self.btn_opts.setMenu(self.opts_menu)
        top_layout.addWidget(self.btn_opts)

        # Run Button
        self.run_btn = QPushButton("RUN BENCHMARK")
        self.run_btn.setFixedSize(140, 32)
        self.run_btn.setStyleSheet("QPushButton { background-color: #22c55e; color: #ffffff; border: none; border-radius: 6px; font-weight: bold; } QPushButton:hover { background-color: #16a34a; }")
        self.run_btn.setCursor(Qt.PointingHandCursor)
        self.run_btn.clicked.connect(self.on_run_clicked)
        top_layout.addWidget(self.run_btn)

        # Edit Button
        edit_btn = QPushButton("Edit Config")
        edit_btn.setFixedSize(110, 32)
        edit_btn.setStyleSheet("QPushButton { background-color: #334155; color: #f8fafc; border: none; border-radius: 6px; font-weight: bold; } QPushButton:hover { background-color: #475569; }")
        edit_btn.clicked.connect(self.edit_requested.emit)
        top_layout.addWidget(edit_btn)

        # Stop Button (Pre-inited)
        self.stop_btn.setFixedSize(100, 32)
        self.stop_btn.setStyleSheet("QPushButton { background-color: rgba(239, 68, 68, 0.2); color: #ef4444; border: 1px solid #ef4444; border-radius: 6px; font-weight: bold; } QPushButton:hover { background-color: rgba(239, 68, 68, 0.3); }")
        self.stop_btn.clicked.connect(self.stop_requested.emit)
        self.stop_btn.hide()
        top_layout.addWidget(self.stop_btn)

        # Add TopBar to Main Layout
        self.layout.addWidget(self.top_bar)

        # 2. Key Content (Tabs)
        self.tabs = QTabWidget()
        self.tabs.setStyleSheet("""
            QTabWidget::pane { border: none; background: transparent; }
            QTabBar::tab { background: transparent; color: #94a3b8; padding: 12px 20px; font-size: 14px; font-weight: 600; border-bottom: 2px solid transparent; }
            QTabBar::tab:selected { color: #6366f1; border-bottom: 2px solid #6366f1; }
            QTabBar::tab:hover:!selected { color: #cbd5e1; }
        """)

        self.overview_tab = QWidget()
        self.logs_tab = QWidget()
        self.monitor_tab = QWidget() # New tab
        self.analysis_tab = QWidget()

        self.tabs.addTab(self.overview_tab, "Overview")
        self.tabs.addTab(self.logs_tab, "Logs")
        self.tabs.addTab(self.monitor_tab, "Monitor") # New tab
        self.tabs.addTab(self.analysis_tab, "Analysis")

        # Add Tabs to Main Layout
        content_layout = QVBoxLayout()
        content_layout.setContentsMargins(40, 30, 40, 40)
        content_layout.addWidget(self.tabs)
        self.layout.addLayout(content_layout)

        # 3. Setup Tab Contents
        self.setup_logs_tab()
        self.setup_overview_tab()
        self.setup_monitor_tab()
        self.setup_analysis_tab()


    def on_run_clicked(self):
        opts = {
            "show_results": self.opt_show_results.isChecked(),
            "use_gazebo": self.opt_gazebo_gui.isChecked(),
            "use_rviz": self.opt_rviz_gui.isChecked()
        }
        self.run_requested.emit(self.config_path, opts)

    def setup_overview_tab(self):
        l = QVBoxLayout(self.overview_tab)
        l.setContentsMargins(0, 20, 0, 0)
        l.setSpacing(20)

        # 1. Config Summary Card
        summary_group = QGroupBox("Configuration Summary")
        summary_group.setStyleSheet("QGroupBox { border: 1px solid #334155; border-radius: 8px; margin-top: 10px; font-weight: bold; color: #f8fafc; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; }")
        sg_layout = QVBoxLayout(summary_group)

        # self.summary_text pre-inited

        # self.summary_text pre-inited
        self.summary_text.setWordWrap(True)
        self.summary_text.setStyleSheet("color: #cbd5e1; font-size: 13px; padding: 10px;")
        sg_layout.addWidget(self.summary_text)

        l.addWidget(summary_group)

        # 2. Results Table
        results_group = QGroupBox("Run Results")
        results_group.setStyleSheet("QGroupBox { border: 1px solid #334155; border-radius: 8px; margin-top: 10px; font-weight: bold; color: #f8fafc; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; }")
        rg_layout = QVBoxLayout(results_group)

        # self.results_table pre-inited

        # self.results_table pre-inited
        rg_layout.addWidget(self.results_table)
        self.results_table.run_deleted.connect(self.delete_run_folder)

        # Refresh Button for table
        refresh_btn = QPushButton("Refresh Results")
        refresh_btn.setFixedSize(120, 28)
        refresh_btn.setStyleSheet("QPushButton { background-color: #334155; color: #f8fafc; border: none; border-radius: 4px; } QPushButton:hover { background-color: #475569; }")
        refresh_btn.clicked.connect(self.scan_results) 
        rg_layout.addWidget(refresh_btn, alignment=Qt.AlignRight)

        l.addWidget(results_group)


    def setup_logs_tab(self):
        l = QVBoxLayout(self.logs_tab)
        l.setContentsMargins(0, 20, 0, 0)
        # self.log_view already created
        self.log_view.setReadOnly(True)
        self.log_view.setStyleSheet("font-family: Monospace; font-size: 12px; background-color: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 10px;")
        l.addWidget(self.log_view)

    def setup_monitor_tab(self):
        l = QVBoxLayout(self.monitor_tab)
        l.setContentsMargins(0, 20, 0, 0)
        l.setSpacing(20)

        # 1. Stats Row
        stats_layout = QHBoxLayout()
        self.cpu_card = self._create_stat_card("Current CPU", "0.0 %", "#6366f1")
        self.ram_card = self._create_stat_card("Current RAM", "0.0 MB", "#10b981")
        stats_layout.addWidget(self.cpu_card)
        stats_layout.addWidget(self.ram_card)
        l.addLayout(stats_layout)

        # 2. Charts
        self.max_points = 60
        self.cpu_history = collections.deque([0.0] * self.max_points, maxlen=self.max_points)
        self.ram_history = collections.deque([0.0] * self.max_points, maxlen=self.max_points)
        self.traj_x = []
        self.traj_y = []
        self.time_data = list(range(self.max_points))

        main_viz_layout = QHBoxLayout() # Split Charts and Trajectory

        # Left: CPU/RAM
        perf_frame = QFrame()
        perf_frame.setStyleSheet("background-color: #1e293b; border-radius: 12px; border: 1px solid #334155;")
        perf_vbox = QVBoxLayout(perf_frame)

        self.mon_figure = Figure(figsize=(6, 6), facecolor='#1e293b')
        self.mon_canvas = FigureCanvasQTAgg(self.mon_figure)
        perf_vbox.addWidget(self.mon_canvas)

        self.ax_cpu = self.mon_figure.add_subplot(211)
        self.ax_ram = self.mon_figure.add_subplot(212)

        for ax in [self.ax_cpu, self.ax_ram]:
            ax.set_facecolor('#1e293b')
            ax.tick_params(colors='#94a3b8', labelsize=8)
            for spine in ax.spines.values():
                spine.set_color('#334155')

        self.mon_cpu_line, = self.ax_cpu.plot(self.time_data, list(self.cpu_history), color='#6366f1', linewidth=2)
        self.mon_ram_line, = self.ax_ram.plot(self.time_data, list(self.ram_history), color='#10b981', linewidth=2)

        self.ax_cpu.set_title("CPU Usage (%)", color='#f1f5f9', fontsize=10, loc='left')
        self.ax_ram.set_title("RAM Usage (MB)", color='#f1f5f9', fontsize=10, loc='left')
        self.mon_figure.tight_layout(pad=3.0)

        main_viz_layout.addWidget(perf_frame, 2)

        # Right: Trajectory
        traj_frame = QFrame()
        traj_frame.setStyleSheet("background-color: #1e293b; border-radius: 12px; border: 1px solid #334155;")
        traj_vbox = QVBoxLayout(traj_frame)

        self.traj_figure = Figure(figsize=(6, 6), facecolor='#1e293b')
        self.traj_canvas = FigureCanvasQTAgg(self.traj_figure)
        traj_vbox.addWidget(self.traj_canvas)

        self.ax_traj = self.traj_figure.add_subplot(111)
        self.ax_traj.set_facecolor('#0f172a')
        self.ax_traj.tick_params(colors='#94a3b8', labelsize=8)
        self.ax_traj.grid(True, color='#334155', linestyle='--', alpha=0.5)
        self.ax_traj.set_title("Live Trajectory (Odom)", color='#f1f5f9', fontsize=10)

        self.traj_line, = self.ax_traj.plot([], [], color='#f43f5e', linewidth=2, label='Path')
        self.robot_dot, = self.ax_traj.plot([], [], 'o', color='#ffffff', markersize=6, label='Robot')

        self.traj_figure.tight_layout()
        main_viz_layout.addWidget(traj_frame, 3)

        l.addLayout(main_viz_layout)

        self.mon_info_lbl = QLabel("No active run.")
        self.mon_info_lbl.setStyleSheet("color: #94a3b8; font-style: italic;")
        l.addWidget(self.mon_info_lbl)

    def _create_stat_card(self, title, value, color):
        card = QFrame()
        card.setStyleSheet(f"""
            QFrame {{ background-color: #1e293b; border: 1px solid #334155; border-left: 4px solid {color}; border-radius: 8px; padding: 15px; }}
        """)
        l = QVBoxLayout(card)
        t = QLabel(title)
        t.setStyleSheet("color: #94a3b8; font-size: 12px; font-weight: bold; border: none;")
        v = QLabel(value)
        v.setStyleSheet(f"color: {color}; font-size: 24px; font-weight: bold; border: none;")
        v.setObjectName("valueLabel")
        l.addWidget(t)
        l.addWidget(v)
        return card

    def update_monitor(self, data):
        cpu = data.get('cpu', 0.0)
        ram = data.get('ram', 0.0)
        pose = data.get('pose', {})

        # Update Cards
        self.cpu_card.findChild(QLabel, "valueLabel").setText(f"{cpu} %")
        self.ram_card.findChild(QLabel, "valueLabel").setText(f"{ram} MB")

        # Update Stats Data
        self.cpu_history.append(cpu)
        self.ram_history.append(ram)

        # Update Perf Plots
        self.mon_cpu_line.set_ydata(list(self.cpu_history))
        self.mon_ram_line.set_ydata(list(self.ram_history))
        self.ax_cpu.relim()
        self.ax_cpu.autoscale_view()
        self.ax_ram.relim()
        self.ax_ram.autoscale_view()
        self.mon_canvas.draw()

        # Update Trajectory
        if pose:
            px, py = pose.get('x', 0.0), pose.get('y', 0.0)
            self.traj_x.append(px)
            self.traj_y.append(py)

            self.traj_line.set_data(self.traj_x, self.traj_y)
            self.robot_dot.set_data([px], [py])

            # Auto-scroll/zoom traj
            self.ax_traj.relim()
            self.ax_traj.autoscale_view()
            self.traj_canvas.draw()

    def setup_analysis_tab(self):
        l = QVBoxLayout(self.analysis_tab)
        l.setContentsMargins(0, 20, 0, 0)

        if not EVALUATION_AVAILABLE:
            l.addWidget(QLabel("Evaluation tools unavailable."))
            return

        # Controls
        controls = QHBoxLayout()
        controls.addWidget(QLabel("Select Run:"))

        # self.run_combo pre-inited
        # self.run_combo pre-inited
        self.run_combo.setFixedWidth(300)
        self.run_combo.setStyleSheet("""
            QComboBox { background-color: #1e293b; border: 1px solid #334155; padding: 5px 10px; border-radius: 6px; color: white; }
            QComboBox::drop-down { border: none; }
        """)
        controls.addWidget(self.run_combo)

        analyze_btn = QPushButton("Analyze")
        analyze_btn.setObjectName("actionButton")
        analyze_btn.clicked.connect(self.run_analysis)
        controls.addWidget(analyze_btn)

        controls.addStretch()
        l.addLayout(controls)

        # Content
        splitter = QSplitter(Qt.Vertical)

        # Results Text
        self.results_text = QTextEdit()
        self.results_text.setReadOnly(True)
        self.results_text.setMaximumHeight(150)
        self.results_text.setFont(QFont("Monospace", 10))
        splitter.addWidget(self.results_text)

        # Viz
        self.figure = Figure(figsize=(10, 6))
        self.canvas = FigureCanvasQTAgg(self.figure)
        self.ax_gt = self.figure.add_subplot(121)
        self.ax_est = self.figure.add_subplot(122)

        # Styling plots
        self.figure.patch.set_facecolor('#0f172a')
        self.ax_gt.set_facecolor('#0f172a')
        self.ax_est.set_facecolor('#0f172a')

        splitter.addWidget(self.canvas)
        l.addWidget(splitter)

    def load_config(self, path, data):
        print(f"DEBUG: loading config {path}")
        self.config_path = path
        self.config_data = data
        self.title_label.setText(f"Configuration: {data.get('name', 'Unknown')}")
        self.log_view.clear()

        # Reset Monitor
        self.cpu_history = collections.deque([0.0] * self.max_points, maxlen=self.max_points)
        self.ram_history = collections.deque([0.0] * self.max_points, maxlen=self.max_points)
        self.traj_x = []
        self.traj_y = []
        self.mon_info_lbl.setText("Ready to monitor.")

        # Try to background-load GT map for Trajectory plot
        self.ax_traj.clear()
        self.ax_traj.set_facecolor('#0f172a')
        self.ax_traj.grid(True, color='#334155', linestyle='--', alpha=0.5)
        self.ax_traj.set_title("Live Trajectory (Odom)", color='#f1f5f9', fontsize=10)
        self.traj_line, = self.ax_traj.plot([], [], color='#f43f5e', linewidth=2, label='Path')
        self.robot_dot, = self.ax_traj.plot([], [], 'o', color='#ffffff', markersize=6, zorder=5)

        # Optimization: Check for GT map
        gt_map_path = None
        for ds in self.config_data.get("datasets", []):
            if "ground_truth" in ds:
                gt_map_path = ds["ground_truth"].get("map_path")
                break

        if gt_map_path and EVALUATION_AVAILABLE:
            try:
                full_gt = (Path.cwd() / gt_map_path).resolve()
                if full_gt.exists():
                    gt_map, gt_res, gt_origin = load_gt_map(str(full_gt))
                    # Plot GT in background
                    extents = [
                        gt_origin[0], 
                        gt_origin[0] + gt_map.shape[1] * gt_res,
                        gt_origin[1],
                        gt_origin[1] + gt_map.shape[0] * gt_res
                    ]
                    # Convert map to vis (0-1 free/occ)
                    vis = np.zeros(gt_map.shape)
                    vis[gt_map == 0] = 0.8 # Free -> Light Gray
                    vis[gt_map > 50] = 0.2 # Occ -> Dark Gray
                    vis[gt_map == -1] = 0.0 # Unknown -> Black

                    self.ax_traj.imshow(np.flipud(vis), extent=extents, origin='lower', cmap='gray', alpha=0.3)
            except Exception as e:
                print(f"DEBUG: Could not preview GT map: {e}")

        self.mon_canvas.draw()
        self.traj_canvas.draw()

        # Fill Overview Summary
        name = data.get('name', 'Unknown')
        datasets = data.get('datasets', [])
        slams = data.get('slams', [])
        slam_ids = [s.get('id') for s in slams]

        matrix = data.get('matrix', {})
        repeats = 1
        try:
             first_inc = matrix.get('include', [])[0]
             repeats = first_inc.get('repeats', 1)
        except:
             pass

        summary = (
            f"<b>Name:</b> {name}<br>"
            f"<b>Datasets:</b> {len(datasets)} items<br>"
            f"<b>SLAM Algorithms:</b> {', '.join(slam_ids)}<br>"
            f"<b>Repeats:</b> {repeats}<br>"
            f"<b>Output Root:</b> {data.get('output', {}).get('root_dir', 'results/runs')}"
        )
        self.summary_text.setText(summary)

        self.scan_results()

    def set_running(self, is_running):
        if is_running:
            self.stop_btn.show()
            self.run_btn.hide()
        else:
            self.stop_btn.hide()
            self.run_btn.show()

    def add_log(self, text):
        self.log_view.append(text)

    def set_logs(self, logs):
        self.log_view.clear()
        self.log_view.append("\n".join(logs))
        # Scroll to bottom
        sb = self.log_view.verticalScrollBar()
        sb.setValue(sb.maximum())

    def clear_logs(self):
        self.log_view.clear()

    def scan_results(self):
        self.run_combo.clear()

        output_root = self.config_data.get("output", {}).get("root_dir", "results/runs")
        root_path = Path(output_root)

        if not root_path.exists():
            return

        runs = sorted([d for d in root_path.iterdir() if d.is_dir()], reverse=True)

        # Get allowed IDs for filtering
        datasets = self.config_data.get('datasets', [])
        allowed_datasets = [d.get('id') for d in datasets]

        slams = self.config_data.get('slams', [])
        allowed_slams = [s.get('id') for s in slams]

        # Update Table
        self.results_table.setRowCount(0)

        for r in runs:
            # Parse directory name: TIMESTAMP__DATASET__SLAM__...
            parts = r.name.split("__")
            if len(parts) < 3: 
                continue

            date_str = parts[0]
            dataset_id = parts[1]
            slam_id = parts[2]

            # Filter: Show only runs belonging to this config's components
            if dataset_id not in allowed_datasets or slam_id not in allowed_slams:
                continue

            self.run_combo.addItem(r.name, str(r))

            metrics = {}
            if (r / "metrics.json").exists():
                try:
                    with open(r / "metrics.json") as f:
                        metrics = json.load(f)
                except:
                    pass

            val = metrics.get('ate_rmse')
            rmse = f"{val:.4f}" if val is not None else "-"
            # We could fetch path length from metrics if saved, or just leave as -
            path_len = "-"

            status = "Completed" if (r / "config_resolved.yaml").exists() else "Incomplete"
            if not (r / "bags" / "output").exists():
                status = "No Data"

            self.results_table.add_run(date_str, dataset_id, slam_id, status, rmse, path_len, str(r))

    def delete_run_folder(self, path):
        import shutil
        try:
            shutil.rmtree(path)
            self.scan_results()
        except Exception as e:
            from PyQt5.QtWidgets import QMessageBox
            QMessageBox.critical(self, "Error", f"Failed to delete run: {e}")

    def run_analysis(self):
        run_name = self.run_combo.currentText()
        run_path = self.run_combo.currentData()

        if not run_path:
            return

        self.results_text.setText(f"Analyzing {run_name}...")
        self.results_text.append(f"Path: {run_path}")

        # 1. Find Rosbag
        from pathlib import Path
        import os

        bag_dir = Path(run_path) / "bags" / "output"
        if not bag_dir.exists():
            self.results_text.append("❌ Status: No rosbag found (bags/output missing).")
            return

        # 2. Find GT Map from Config
        # We need to find which dataset was used in this run to get the GT path
        # We can try to read the resolved config from the run folder
        resolved_config_path = Path(run_path) / "config_resolved.yaml"
        gt_path = None

        if resolved_config_path.exists():
            import yaml
            try:
                with open(resolved_config_path, 'r') as f:
                    cfg = yaml.safe_load(f)
                    # Check dataset for ground_truth
                    ds = cfg.get("dataset", {})
                    gt_def = ds.get("ground_truth", {})
                    if gt_def:
                        gt_path = gt_def.get("map_path")
            except Exception as e:
                self.results_text.append(f"⚠️ Warning: Could not read config_resolved.yaml: {e}")

        # Fallback: check raw matrix config if resolved missing (less reliable)
        if not gt_path:
             # Try to find first dataset in matrix with GT
             for ds in self.config_data.get("datasets", []):
                 if "ground_truth" in ds:
                     gt_path = ds["ground_truth"].get("map_path")
                     break

        if not gt_path:
            self.results_text.append("❌ Error: No Ground Truth map defined in configuration.")
            self.results_text.append("Tip: Add 'ground_truth: {map_path: ...}' to your dataset definition.")
            return

        # Resolve GT Path (relative to project root)
        project_root = Path.cwd() # Assuming CWD is project root
        full_gt_path = (project_root / gt_path).resolve()

        if not full_gt_path.exists():
            self.results_text.append(f"❌ Error: GT Map file not found at: {full_gt_path}")
            return

        self.results_text.append(f"✅ GT Map found: {gt_path}")

        # Run Evaluation
        try:
            self.results_text.append("Loading data... (this may take a moment)")
            # Force UI update
            from PyQt5.QtWidgets import QApplication
            QApplication.processEvents()

            # 1. Load GT
            gt_map, gt_res, gt_origin = load_gt_map(str(full_gt_path))

            # 2. Read Bag
            self.results_text.append(f"Reading rosbag: {bag_dir}")

            # Auto-detect db3 file or just pass the directory
            # The reader usually expects the directory for split bags, or the db3 file
            # Let's pass the directory string which is standard for ROS2 bag reader
            bag_path_str = str(bag_dir)

            map_data = read_messages_by_topic(bag_path_str, ["/map"])
            map_msgs = map_data.get("/map", [])

            odom_data = read_messages_by_topic(bag_path_str, ["/odom"])
            odom_msgs = odom_data.get("/odom", [])

            if not map_msgs:
                self.results_text.append("❌ Error: No /map messages found in rosbag.")
                return

            # 3. Compute Metrics
            self.results_text.append("Aligning and computing metrics...")
            est_map = occupancy_arrays_from_msgs(map_msgs, gt_map, gt_res, gt_origin)
            _, last_map_msg = map_msgs[-1]

            final_cov = compute_coverage(gt_map, est_map)
            accessible_cov = compute_accessible_coverage(
                gt_map, est_map, gt_res, gt_origin,
                (last_map_msg.info.origin.position.x, last_map_msg.info.origin.position.y),
                last_map_msg.info.width, last_map_msg.info.height, last_map_msg.info.resolution
            )
            final_iou = compute_iou(gt_map, est_map)
            path_len = compute_path_length(odom_msgs, bag_path_str)

            # 4. Display Results
            self.results_text.append("\n=== BENCHMARK RESULTS ===")
            self.results_text.append(f"Total Map Coverage       : {final_cov*100:.2f} %")
            self.results_text.append(f"Accessible Area Coverage : {accessible_cov*100:.2f} %")
            self.results_text.append(f"Final Occupancy IoU      : {final_iou:.4f}")
            self.results_text.append(f"Total Path Length        : {path_len:.2f} m")

            # 5. Visualize
            self.update_maps(gt_map, est_map)

        except Exception as e:
            self.results_text.append(f"\n❌ Evaluation Failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def update_maps(self, gt_map, est_map):
        # Helper to visualize
        def to_vis(grid):
            vis = np.zeros((*grid.shape, 4), dtype=np.uint8)
            # -1 -> transparent
            # 0 (free) -> white (255)
            # 100 (occ) -> black (0)

            # Background transparent
            vis[:, :, 3] = 0

            # Free cells -> White, Opaque
            mask_free = (grid == 0)
            vis[mask_free] = [255, 255, 255, 255]

            # Occupied -> Black, Opaque
            mask_occ = (grid > 50)
            vis[mask_occ] = [0, 0, 0, 255]

            # Unknown -> keep transparent (or gray if preferred)
            return vis

        # GT map is already flipped by the generator (np.flipud in save)
        # So we need to flip the estimated map to match for visualization
        gt_vis = to_vis(gt_map)
        est_vis = to_vis(np.flipud(est_map))

        self.ax_gt.clear()
        self.ax_est.clear()

        self.ax_gt.set_title("Ground Truth", color='white')
        self.ax_est.set_title("Estimated", color='white')

        self.ax_gt.imshow(gt_vis, cmap='gray')
        self.ax_est.imshow(est_vis, cmap='gray')

        self.ax_gt.axis('off')
        self.ax_est.axis('off')

        self.canvas.draw()

    def open_run_results(self, run_path):
        self.tabs.setCurrentIndex(2) # Analysis
        self.scan_results() # Refresh list

        # Find run in combo
        idx = self.run_combo.findData(str(run_path))
        if idx >= 0:
            self.run_combo.setCurrentIndex(idx)
            self.run_analysis()
        else:
            self.results_text.setText(f"Could not find run {run_path} in list.")

Benchmark Page

gui.pages.benchmark

Global benchmark page for viewing all runs.

Provides a comprehensive table of all benchmark runs with filtering and PDF report generation capabilities.

BenchmarkPage

Bases: QWidget

Global benchmark results page.

Displays all benchmark runs in a filterable table with metrics: - ATE, Coverage, IoU, Path Length - CPU and RAM usage - Run status and filtering - PDF report export

Source code in gui/pages/benchmark.py
class BenchmarkPage(QWidget):
    """Global benchmark results page.

    Displays all benchmark runs in a filterable table with metrics:
    - ATE, Coverage, IoU, Path Length
    - CPU and RAM usage
    - Run status and filtering
    - PDF report export
    """
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setObjectName("benchmarkPage")
        self.latest_runs = []
        self.init_ui()

    def init_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(30, 30, 30, 30)
        layout.setSpacing(15)

        # Header
        header_layout = QHBoxLayout()
        title = QLabel("Global Benchmark")
        title.setStyleSheet("font-size: 24px; font-weight: bold; color: #f8fafc;")
        header_layout.addWidget(title)

        header_layout.addStretch()

        self.btn_export = QPushButton("Export PDF Report")
        self.btn_export.setObjectName("actionButton")
        self.btn_export.setStyleSheet("background-color: #6366f1; font-weight: bold;")
        self.btn_export.setFixedWidth(200)
        self.btn_export.clicked.connect(self.export_pdf_report)
        header_layout.addWidget(self.btn_export)

        self.btn_refresh = QPushButton("Refresh Data")
        self.btn_refresh.setObjectName("actionButton")
        self.btn_refresh.setFixedWidth(150)
        self.btn_refresh.clicked.connect(self.refresh_data)
        header_layout.addWidget(self.btn_refresh)

        layout.addLayout(header_layout)

        # --- Filter Bar ---
        filter_bar = QFrame()
        filter_bar.setStyleSheet("""
            QFrame {
                background-color: #1e293b;
                border-radius: 8px;
                padding: 10px;
            }
            QLabel { color: #94a3b8; font-weight: bold; font-size: 12px; }
            QLineEdit, QComboBox {
                background-color: #0f172a;
                color: #e2e8f0;
                border: 1px solid #334155;
                padding: 5px 10px;
                border-radius: 4px;
            }
        """)
        filter_layout = QHBoxLayout(filter_bar)

        # Search Run ID
        filter_layout.addWidget(QLabel("SEARCH:"))
        self.search_id = QLineEdit()
        self.search_id.setPlaceholderText("Filter by ID...")
        self.search_id.textChanged.connect(self.apply_filters)
        filter_layout.addWidget(self.search_id)

        # SLAM Filter
        filter_layout.addSpacing(20)
        filter_layout.addWidget(QLabel("SLAM:"))
        self.combo_slam = QComboBox()
        self.combo_slam.addItem("All")
        self.combo_slam.currentTextChanged.connect(self.apply_filters)
        filter_layout.addWidget(self.combo_slam)

        # Dataset Filter
        filter_layout.addSpacing(20)
        filter_layout.addWidget(QLabel("DATASET:"))
        self.combo_dataset = QComboBox()
        self.combo_dataset.addItem("All")
        self.combo_dataset.currentTextChanged.connect(self.apply_filters)
        filter_layout.addWidget(self.combo_dataset)

        filter_layout.addStretch()

        self.btn_clear = QPushButton("Reset")
        self.btn_clear.setStyleSheet("background: transparent; color: #6366f1; border: 1px solid #6366f1; padding: 4px 10px;")
        self.btn_clear.clicked.connect(self.reset_filters)
        filter_layout.addWidget(self.btn_clear)

        layout.addWidget(filter_bar)

        # Table
        self.table = QTableWidget()
        self.table.setColumnCount(10)
        self.table.setHorizontalHeaderLabels([
            "Run ID", "SLAM", "Dataset", "Duration", 
            "ATE (m)", "Coverage (%)", "IoU", "Path (m)",
            "CPU (%)", "RAM (MB)"
        ])
        self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
        self.table.horizontalHeader().setCascadingSectionResizes(True)
        self.table.horizontalHeader().setStretchLastSection(False)
        self.table.verticalHeader().setVisible(False)
        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.table.setAlternatingRowColors(True)

        # Style
        self.table.setStyleSheet("""
            QTableWidget {
                background-color: #1e293b;
                alternate-background-color: #0f172a;
                color: #e2e8f0;
                gridline-color: #334155;
                font-size: 13px;
                border: none;
                border-radius: 8px;
            }
            QHeaderView::section {
                background-color: #0f172a;
                color: #94a3b8;
                padding: 12px;
                border: none;
                font-weight: bold;
                text-transform: uppercase;
            }
            QTableWidget::item {
                padding: 8px;
            }
            QTableWidget::item:selected {
                background-color: #3b82f6;
                color: white;
            }
        """)

        self.table.itemDoubleClicked.connect(self.open_result_window)
        layout.addWidget(self.table)

    def reset_filters(self):
        self.search_id.clear()
        self.combo_slam.setCurrentIndex(0)
        self.combo_dataset.setCurrentIndex(0)
        self.apply_filters()

    def apply_filters(self):
        search_text = self.search_id.text().lower()
        slam_filter = self.combo_slam.currentText()
        dataset_filter = self.combo_dataset.currentText()

        filtered_runs = []
        for run in self.latest_runs:
            match_search = not search_text or search_text in run['id'].lower()
            match_slam = slam_filter == "All" or run['slam'] == slam_filter
            match_dataset = dataset_filter == "All" or run['dataset'] == dataset_filter

            if match_search and match_slam and match_dataset:
                filtered_runs.append(run)

        self.populate_table(filtered_runs)

    def export_pdf_report(self):
        if not self.latest_runs:
            QMessageBox.warning(self, "No Data", "Please refresh data first.")
            return

        path, _ = QFileDialog.getSaveFileName(self, "Save Comparison Report", "slam_comparison_report.pdf", "PDF Files (*.pdf)")

        if path:
            self.progress = QProgressDialog("Generating PDF Report...\nThis may take a moment.", None, 0, 0, self)
            self.progress.setWindowTitle("Please Wait")
            self.progress.setWindowModality(Qt.WindowModal)
            self.progress.setMinimumDuration(0)
            self.progress.show()

            self.report_thread = ReportThread(self.latest_runs, path)
            self.report_thread.finished.connect(self.on_report_finished)
            self.report_thread.start()

    def on_report_finished(self, success, result):
        if hasattr(self, "progress"):
            self.progress.close()
        if success:
            QMessageBox.information(self, "Success", f"Report exported to:\n{result}")
        else:
            QMessageBox.critical(self, "Error", f"Failed to generate report:\n{result}")

    def open_result_window(self, item):
        row = item.row()
        run_id = self.table.item(row, 0).text()
        root = Path.cwd()
        run_dir = root / "results" / "runs" / run_id
        if run_dir.exists():
            from gui.results_window import ResultWindow
            if not hasattr(self, "result_windows"):
                self.result_windows = []
            try:
                self.result_windows = [w for w in self.result_windows if w.isVisible()]
                win = ResultWindow(run_dir, self)
                win.show()
                self.result_windows.append(win)
                self.refresh_data()
            except Exception as e:
                print(f"Error opening results: {e}")

    def refresh_data(self):
        root = Path.cwd()
        runs_dir = root / "results" / "runs"
        if not runs_dir.exists(): return

        runs = []
        slams = set()
        datasets = set()

        for run_path in runs_dir.iterdir():
            if run_path.is_dir():
                run_data = self.parse_run(run_path)
                if run_data:
                    runs.append(run_data)
                    slams.add(run_data['slam'])
                    datasets.add(run_data['dataset'])

        runs.sort(key=lambda x: x['id'], reverse=True)
        self.latest_runs = runs

        # Update combo boxes while preserving selection if possible
        cur_slam = self.combo_slam.currentText()
        cur_ds = self.combo_dataset.currentText()

        self.combo_slam.blockSignals(True)
        self.combo_dataset.blockSignals(True)

        self.combo_slam.clear()
        self.combo_slam.addItem("All")
        self.combo_slam.addItems(sorted(list(slams)))

        self.combo_dataset.clear()
        self.combo_dataset.addItem("All")
        self.combo_dataset.addItems(sorted(list(datasets)))

        # Restore selections
        idx_slam = self.combo_slam.findText(cur_slam)
        if idx_slam >= 0: self.combo_slam.setCurrentIndex(idx_slam)

        idx_ds = self.combo_dataset.findText(cur_ds)
        if idx_ds >= 0: self.combo_dataset.setCurrentIndex(idx_ds)

        self.combo_slam.blockSignals(False)
        self.combo_dataset.blockSignals(False)

        self.apply_filters()


    def parse_run(self, path: Path):
        data = {
            "id": path.name, "slam": "N/A", "dataset": "N/A", "duration": None,
            "ate": None, "coverage": None, "iou": None, "path": None, "cpu": None, "ram": None,
            "accessible_coverage": None, "occupancy_iou": None, 
            "map_image_path": None, "gt_map_image_path": None, "wall_thick": None, "ssim": None,
            "status": "Unknown", "reasons": []
        }
        parts = path.name.split("__")
        if len(parts) >= 2: data["dataset"] = parts[1]
        if len(parts) >= 3: data["slam"] = parts[2]

        metrics_path = path / "metrics.json"
        if metrics_path.exists():
            try:
                with open(metrics_path, 'r') as f:
                    m = json.load(f)
                    print(f"[DEBUG] Loaded {path.name}: IoU={m.get('occupancy_iou')}, AccCov={m.get('accessible_coverage')}")
                    data["ate"] = m.get("ate_rmse")

                    # Handle Coverage (prefer percent, fallback to ratio*100)
                    cov = m.get("coverage_percent")
                    if cov is None and m.get("coverage") is not None:
                        cov = m.get("coverage") * 100.0
                    data["coverage"] = cov

                    data["iou"] = m.get("iou") # Legacy?
                    data["occupancy_iou"] = m.get("occupancy_iou", m.get("iou")) # Prefer specific, fallback legacy

                    # Accessible Coverage
                    acc_cov = m.get("accessible_coverage")
                    if acc_cov is not None and acc_cov <= 1.0: acc_cov *= 100.0 # Convert to %
                    data["accessible_coverage"] = acc_cov

                    data["path"] = m.get("path_length_m")
                    data["duration"] = m.get("duration_s")
                    data["cpu"] = m.get("max_cpu_percent")
                    data["ram"] = m.get("max_ram_mb")
                    data["wall_thick"] = m.get("wall_thickness_m") * 100 if m.get("wall_thickness_m") else None # cm
                    data["ssim"] = m.get("map_ssim")

                    data["map_image_path"] = m.get("map_image_path")
                    data["gt_map_image_path"] = m.get("gt_map_image_path")

                    data["status"] = "ANOMALY" if m.get("is_failure") else "SUCCESS"
                    data["reasons"] = m.get("failure_reasons", [])

            except Exception as e: 
                print(f"Error parsing metrics for {path.name}: {e}")
        return data

    def populate_table(self, runs):
        self.table.setRowCount(0)
        self.table.setRowCount(len(runs))
        for r, run in enumerate(runs):
            self.table.setItem(r, 0, QTableWidgetItem(run["id"]))
            self.table.setItem(r, 1, QTableWidgetItem(run["slam"]))
            self.table.setItem(r, 2, QTableWidgetItem(run["dataset"]))
            dur_val = run.get("duration")
            item_dur = QTableWidgetItem(f"{dur_val:.1f} s" if isinstance(dur_val, (int, float)) else "-")
            if dur_val is not None: item_dur.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
            self.table.setItem(r, 3, item_dur)
            ate_val = run["ate"]
            item_ate = QTableWidgetItem(f"{ate_val:.3f}" if ate_val is not None else "-")
            if ate_val is not None:
                item_ate.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
                if ate_val < 0.1: item_ate.setForeground(QColor("#4ade80"))
                elif ate_val > 1.0: item_ate.setForeground(QColor("#f87171"))
            self.table.setItem(r, 4, item_ate)
            cov_val = run["coverage"]
            item_cov = QTableWidgetItem(f"{cov_val:.1f}%" if cov_val is not None else "-")
            if cov_val is not None:
                item_cov.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
                if cov_val > 95: item_cov.setForeground(QColor("#4ade80"))
            self.table.setItem(r, 5, item_cov)
            iou_val = run["iou"]
            item_iou = QTableWidgetItem(f"{iou_val:.3f}" if iou_val is not None else "-")
            if iou_val is not None:
                item_iou.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
                if iou_val > 0.8: item_iou.setForeground(QColor("#4ade80"))
            self.table.setItem(r, 6, item_iou)
            path_val = run["path"]
            item_path = QTableWidgetItem(f"{path_val:.1f}" if path_val is not None else "-")
            if path_val is not None: item_path.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
            self.table.setItem(r, 7, item_path)
            cpu_val = run["cpu"]
            item_cpu = QTableWidgetItem(f"{cpu_val:.1f}%" if cpu_val is not None else "-")
            if cpu_val is not None:
                item_cpu.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
                if cpu_val > 150: item_cpu.setForeground(QColor("#f87171"))
            self.table.setItem(r, 8, item_cpu)
            ram_val = run["ram"]
            item_ram = QTableWidgetItem(f"{ram_val:.0f}" if ram_val is not None else "-")
            if ram_val is not None:
                item_ram.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
                if ram_val > 2000: item_ram.setForeground(QColor("#f87171"))
            self.table.setItem(r, 9, item_ram)
        self.table.resizeColumnsToContents()

Comparison Page

gui.pages.comparison

Benchmark comparison page for side-by-side analysis.

Allows users to compare up to 3 runs with: - Trajectory overlay visualization - Metrics comparison table - PDF report generation

ComparisonPage

Bases: QWidget

Side-by-side comparison of multiple benchmark runs.

Features: - Select up to 3 runs for comparison - Trajectory overlay on ground truth map - Detailed metrics comparison table - Export comparison report to PDF

Source code in gui/pages/comparison.py
class ComparisonPage(QWidget):
    """Side-by-side comparison of multiple benchmark runs.

    Features:
    - Select up to 3 runs for comparison
    - Trajectory overlay on ground truth map
    - Detailed metrics comparison table
    - Export comparison report to PDF
    """
    def __init__(self, parent=None):
        super().__init__(parent)
        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(30, 30, 30, 30)
        self.layout.setSpacing(20)

        self.init_ui()
        self.last_runs_data = []

    def init_ui(self):
        # 1. Header
        header = QLabel("Benchmark Comparison")
        header.setStyleSheet("font-size: 24px; font-weight: bold; color: #f8fafc;")
        self.layout.addWidget(header)

        # 2. Selection Row
        selection_frame = QFrame()
        selection_frame.setStyleSheet("background-color: #1e293b; border-radius: 8px; border: 1px solid #334155;")
        sel_layout = QHBoxLayout(selection_frame)

        self.combos = []
        colors = ["#3b82f6", "#ef4444", "#10b981"] # blue, red, green

        for i in range(3):
            vbox = QVBoxLayout()
            lbl = QLabel(f"Run {i+1}")
            lbl.setStyleSheet(f"color: {colors[i]}; font-weight: bold; border: none;")
            combo = QComboBox()
            combo.setFixedWidth(300)
            combo.setStyleSheet("""
                QComboBox { background-color: #0f172a; border: 1px solid #475569; padding: 8px; border-radius: 6px; color: white; }
                QComboBox::drop-down { border: none; }
            """)
            vbox.addWidget(lbl)
            vbox.addWidget(combo)
            sel_layout.addLayout(vbox)
            self.combos.append(combo)

        compare_btn = QPushButton("Compare Runs")
        compare_btn.setFixedHeight(45)
        compare_btn.setFixedWidth(150)
        compare_btn.setStyleSheet("""
            QPushButton { background-color: #6366f1; color: white; border: none; border-radius: 6px; font-weight: bold; font-size: 14px; margin-top: 15px; }
            QPushButton:hover { background-color: #4f46e5; }
        """)
        compare_btn.setCursor(Qt.PointingHandCursor)
        compare_btn.clicked.connect(self.run_comparison)
        sel_layout.addWidget(compare_btn)

        refresh_btn = QPushButton("Refresh List")
        refresh_btn.setFixedHeight(45)
        refresh_btn.setFixedWidth(120)
        refresh_btn.setStyleSheet("""
            QPushButton { background-color: #334155; color: white; border: none; border-radius: 6px; font-weight: bold; margin-top: 15px; }
            QPushButton:hover { background-color: #475569; }
        """)
        refresh_btn.clicked.connect(self.scan_runs)
        sel_layout.addWidget(refresh_btn)

        self.export_btn = QPushButton("Export PDF Report")
        self.export_btn.setFixedHeight(45)
        self.export_btn.setFixedWidth(160)
        self.export_btn.setEnabled(False)
        self.export_btn.setStyleSheet("""
            QPushButton { background-color: #059669; color: white; border: none; border-radius: 6px; font-weight: bold; margin-top: 15px; }
            QPushButton:hover { background-color: #047857; }
            QPushButton:disabled { background-color: #334155; color: #94a3b8; }
        """)
        self.export_btn.clicked.connect(self.export_report)
        sel_layout.addWidget(self.export_btn)

        sel_layout.addStretch()

        self.layout.addWidget(selection_frame)

        # 3. Main Content (Plot & Table)
        main_h_layout = QHBoxLayout()

        # Plot Frame
        plot_frame = QFrame()
        plot_frame.setStyleSheet("background-color: #1e293b; border-radius: 8px; border: 1px solid #334155;")
        plot_vbox = QVBoxLayout(plot_frame)

        self.figure = Figure(figsize=(8, 8), facecolor='#1e293b')
        self.canvas = FigureCanvasQTAgg(self.figure)
        self.ax = self.figure.add_subplot(111)
        self.ax.set_facecolor('#0f172a')
        self.ax.tick_params(colors='#94a3b8', labelsize=8)
        self.ax.grid(True, color='#334155', linestyle='--', alpha=0.5)
        plot_vbox.addWidget(self.canvas)

        main_h_layout.addWidget(plot_frame, 3) # 60% width

        # Table Frame
        table_frame = QFrame()
        table_frame.setStyleSheet("background-color: #1e293b; border-radius: 8px; border: 1px solid #334155;")
        table_vbox = QVBoxLayout(table_frame)

        table_title = QLabel("Metrics Comparison")
        table_title.setStyleSheet("color: #f8fafc; font-weight: bold; padding: 10px; border: none; border-bottom: 1px solid #334155;")
        table_vbox.addWidget(table_title)

        self.table = QTableWidget()
        self.table.setColumnCount(3)
        self.table.setHorizontalHeaderLabels(["Run 1", "Run 2", "Run 3"])
        self.table.setVerticalHeaderLabels([
            "Health Status",
            "SLAM (Algo)", 
            "Dataset (Scenario)", 
            "ATE (Abs. Traj. Error m)", 
            "Coverage (% Area)", 
            "Max RAM (Peak MB)",
            "Max CPU (Peak %)"
        ])
        self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
        self.table.verticalHeader().setStyleSheet("color: #94a3b8; font-weight: bold;")
        self.table.setStyleSheet("""
            QTableWidget { background: transparent; border: none; color: #e2e8f0; gridline-color: #334155; }
            QHeaderView::section { background-color: #0f172a; color: #94a3b8; padding: 10px; border: none; font-weight: bold; }
        """)
        table_vbox.addWidget(self.table)

        main_h_layout.addWidget(table_frame, 2) # 40% width

        self.layout.addLayout(main_h_layout)

        self.scan_runs()

    def scan_runs(self):
        root_path = Path("results/runs")
        if not root_path.exists():
            return

        runs = sorted([d for d in root_path.iterdir() if d.is_dir()], reverse=True)

        for combo in self.combos:
            combo.clear()
            combo.addItem("-- Select Run --", None)
            for r in runs:
                # Preview name: timestamp + slam
                parts = r.name.split("__")
                display = r.name
                if len(parts) >= 3:
                    display = f"{parts[0]} - {parts[2]} ({parts[1]})"
                combo.addItem(display, str(r))

    def run_comparison(self):
        if not EVALUATION_AVAILABLE:
            return

        self.ax.clear()
        self.ax.set_facecolor('#0f172a')
        self.ax.grid(True, color='#334155', linestyle='--', alpha=0.5)
        self.ax.grid(True, color='#334155', linestyle='--', alpha=0.5)



        self.table.setRowCount(15)
        # Update row labels
        self.table.setVerticalHeaderLabels([
            "Status", "SLAM", "Dataset", "Duration (s)", "ATE RMSE", "Coverage", "Acc. Coverage", "Occupancy IoU", "SSIM", "Wall Thick.", "Max RAM", "Max CPU", "Lidar Noise (std)", "Max Range (m)", "Speed Scale (%)"
        ])

        colors = ["#3b82f6", "#ef4444", "#10b981"]
        gt_loaded = False
        runs_to_report = []

        for i, combo in enumerate(self.combos):
            run_path_str = combo.currentData()
            if not run_path_str:
                for row in range(11):
                    self.table.setItem(row, i, QTableWidgetItem("-"))
                continue

            run_path = Path(run_path_str)
            metrics = {}
            if (run_path / "metrics.json").exists():
                with open(run_path / "metrics.json") as f:
                    metrics = json.load(f)

            # 1. Info extraction from name
            parts = run_path.name.split("__")
            dataset = parts[1] if len(parts) > 1 else "?"
            slam = parts[2] if len(parts) > 2 else "?"

            # 2. Metrics implementation
            ate = metrics.get('ate_rmse')
            ram = metrics.get('max_ram_mb')
            cpu = metrics.get('max_cpu_percent')
            cov = metrics.get('coverage')
            if cov is None and 'coverage_percent' in metrics:
                cov = metrics['coverage_percent'] / 100.0

            # New metrics
            acc_cov = metrics.get('accessible_coverage')
            if acc_cov is not None and acc_cov <= 1.0: acc_cov *= 100.0 # to %
            elif acc_cov is None and 'accessible_coverage_percent' in metrics:
                 acc_cov = metrics['accessible_coverage_percent']

            iou = metrics.get('occupancy_iou')
            if iou is None and 'iou' in metrics: iou = metrics['iou']

            ssim_val = metrics.get('map_ssim')
            thick = metrics.get('wall_thickness_m')

            # Duration
            duration = metrics.get('duration_s')

            # 7. Status & Failure detection (NOW AT ROW 0)
            is_failure = metrics.get('is_failure', False)
            reasons = metrics.get('failure_reasons', [])

            if is_failure:
                status_item = QTableWidgetItem("❌ ANOMALY [ℹ️ details]")
                status_item.setForeground(Qt.red)
                status_item.setFont(QFont("Segoe UI", 9, QFont.Bold))
                status_item.setToolTip("ANOMALIES DETECTED (Hover for details):\n\n" + "\n".join([f"• {r}" for r in reasons]))
            else:
                status_item = QTableWidgetItem("✅ VALID RUN")
                status_item.setForeground(Qt.green)

            self.table.setItem(0, i, status_item)
            self.table.setItem(1, i, QTableWidgetItem(slam))
            self.table.setItem(2, i, QTableWidgetItem(dataset))
            self.table.setItem(3, i, QTableWidgetItem(f"{duration:.1f} s" if duration is not None else "N/A"))
            self.table.setItem(4, i, QTableWidgetItem(f"{ate:.4f} m" if ate is not None else "N/A"))
            self.table.setItem(5, i, QTableWidgetItem(f"{cov*100:.1f} %" if cov is not None else "N/A"))
            self.table.setItem(6, i, QTableWidgetItem(f"{acc_cov:.1f} %" if acc_cov is not None else "N/A"))
            self.table.setItem(7, i, QTableWidgetItem(f"{iou:.4f}" if iou is not None else "N/A"))
            self.table.setItem(8, i, QTableWidgetItem(f"{ssim_val:.4f}" if ssim_val is not None else "N/A"))

            self.table.setItem(9, i, QTableWidgetItem(f"{thick*100:.2f} cm" if thick is not None else "N/A"))
            self.table.setItem(10, i, QTableWidgetItem(f"{ram:.1f} MB" if ram is not None else "N/A"))
            self.table.setItem(11, i, QTableWidgetItem(f"{cpu:.1f} %" if cpu is not None else "N/A"))


            # Extract degradation settings from metrics.json (already saved by orchestrator)
            lidar_noise = metrics.get('lidar_noise')
            lidar_range = metrics.get('lidar_range')
            speed_scale = metrics.get('speed_scale')

            self.table.setItem(12, i, QTableWidgetItem(f"{lidar_noise:.3f}" if lidar_noise is not None else "-"))
            self.table.setItem(13, i, QTableWidgetItem(f"{lidar_range:.1f} m" if lidar_range is not None else "-"))
            self.table.setItem(14, i, QTableWidgetItem(f"{speed_scale*100:.0f} %" if speed_scale is not None else "-"))

            # Store for PDF report
            ate_plot = run_path / "bags" / "output" / "ate_plot.png"
            runs_to_report.append({
                'name': run_path.name,
                'slam': slam,
                'dataset': dataset,
                'duration': duration,
                'ate': ate,
                'coverage': cov*100.0 if cov is not None else None,
                'accessible_coverage': acc_cov, 
                'occupancy_iou': iou,
                'ssim': ssim_val,
                'wall_thick': thick*100.0 if thick is not None else None,
                'ram': ram,
                'cpu': cpu,
                'status': "❌ ANOMALY" if is_failure else "✅ VALID",
                'is_failure': is_failure,
                'reasons': reasons,
                'map_image_path': metrics.get('map_image_path'),
                'gt_map_image_path': metrics.get('gt_map_image_path'),
                'ate_image_path': str(ate_plot) if ate_plot.exists() else None,
                'lidar_noise': lidar_noise,
                'lidar_range': lidar_range,
                'speed_scale': speed_scale * 100 if speed_scale is not None else None
            })

            # 3. Trajectory extraction
            bag_dir = run_path / "bags" / "output"
            if bag_dir.exists():
                try:
                    msgs = read_messages_by_topic(str(bag_dir), ["/odom"])
                    tx, ty = get_trajectory(msgs.get("/odom", []))
                    self.ax.plot(tx, ty, color=colors[i], label=f"Run {i+1} ({slam})", linewidth=2)
                except Exception as e:
                    print(f"Error loading trajectory for {run_path.name}: {e}")

            # 4. Load GT Map background (only once)
            if not gt_loaded:
                resolved_cfg = run_path / "config_resolved.yaml"
                if resolved_cfg.exists():
                    try:
                        with open(resolved_cfg) as f:
                            cfg = yaml.safe_load(f)
                            gt_def = cfg.get("dataset", {}).get("ground_truth", {})
                            if gt_def:
                                full_gt = (Path.cwd() / gt_def.get("map_path")).resolve()
                                if full_gt.exists():

                                    gt_map, gt_res, gt_origin = load_gt_map(str(full_gt))
                                    # load_gt_map returns Bottom-Up ROS Convention (Row 0 is Bottom)
                                    # imshow(origin='lower') expects Row 0 to be Bottom.
                                    # So we do NOT need flipud if load_gt_map is already flipped.
                                    # BUT load_gt_map in metrics.py does flipud.
                                    # So gt_map is Bottom-Up.
                                    # vis is Bottom-Up.
                                    # imshow(vis, origin='lower') shows correctly.
                                    # So why user complains?
                                    # Maybe load_gt_map is NOT consistent across files?
                                    # gui/pages/comparison.py imports load_gt_map from EVALUATION (metrics.py).
                                    # I verified metrics.py has flipud.

                                    # Wait, look at LINE 304 in original file:
                                    # self.ax.imshow(np.flipud(vis), extent=extents, origin='lower', cmap='gray', alpha=0.3)
                                    # It HAS flipud!
                                    # If vis is Bottom-Up, and we flipud, it becomes Top-Down.
                                    # And imshow origin='lower' puts Row 0 (Top) at Bottom.
                                    # So it displays Top-Down data UPSIDE DOWN.
                                    # This is the bug!

                                    # Fix: REMOVE np.flipud.

                                    extents = [
                                        gt_origin[0], 
                                        gt_origin[0] + gt_map.shape[1] * gt_res,
                                        gt_origin[1],
                                        gt_origin[1] + gt_map.shape[0] * gt_res
                                    ]
                                    vis = np.zeros(gt_map.shape)
                                    vis[gt_map == 0] = 0.8
                                    vis[gt_map > 50] = 0.2
                                    self.ax.imshow(vis, extent=extents, origin='lower', cmap='gray', alpha=0.3)
                                    gt_loaded = True
                    except:
                        pass

        self.ax.legend(facecolor='#1e293b', edgecolor='#334155', labelcolor='white')
        self.ax.set_title("Trajectory Comparison Overlay", color='white')
        self.ax.relim()
        self.ax.autoscale_view()
        self.canvas.draw()

        # Save data for report
        self.last_runs_data = runs_to_report
        self.export_btn.setEnabled(len(self.last_runs_data) > 0)

    def export_report(self):
        if not self.last_runs_data:
            return

        file_path, _ = QFileDialog.getSaveFileName(self, "Export Comparison Report", "comparison_report.pdf", "PDF Files (*.pdf)")
        if not file_path:
            return

        try:
            # 1. Save current plot to temp image
            temp_plot = Path("/tmp/comparison_plot.png")
            self.figure.savefig(str(temp_plot), dpi=150, facecolor=self.figure.get_facecolor())

            # 2. Generate PDF
            from tools.report_generator import generate_full_report
            generate_full_report(file_path, self.last_runs_data, str(temp_plot))

            # 3. Success notification
            QMessageBox.information(self, "Export Success", f"Report successfully generated at:\n{file_path}")

            # Cleanup
            if temp_plot.exists():
                temp_plot.unlink()

        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to generate report: {str(e)}")

Tools Page

gui.pages.tools

SimulatorManagementPage

Bases: QWidget

Page for managing simulators (Gazebo, O3DE)

Source code in gui/pages/tools.py
class SimulatorManagementPage(QWidget):
    """Page for managing simulators (Gazebo, O3DE)"""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(20, 20, 20, 20)

        # Store widget references instead of using findChild()
        self.widgets = {}  # {sim_name: {'status': QLabel, 'details': QTextEdit, 'install_btn': QPushButton}}

        self.init_ui()

    def init_ui(self):
        # Header
        header = QLabel("Simulator Management")
        header.setObjectName("headerLabel")
        self.layout.addWidget(header)

        # Import simulator manager
        try:
            from tools.simulator_manager import SimulatorManager
            self.sim_mgr = SimulatorManager()
        except ImportError as e:
            error_label = QLabel(f"❌ Error loading SimulatorManager: {e}")
            error_label.setStyleSheet("color: #ef4444; padding: 20px;")
            self.layout.addWidget(error_label)
            return

        # Simulator Cards
        for sim_name in ['gazebo', 'o3de']:
            card = self._create_simulator_card(sim_name)
            self.layout.addWidget(card)

        self.layout.addStretch()

        # Refresh status AFTER widgets are in layout (use QTimer to ensure Qt event loop is ready)
        from PyQt5.QtCore import QTimer
        QTimer.singleShot(100, self.refresh_all_statuses)

    def refresh_all_statuses(self):
        """Refresh all simulator statuses"""
        for sim_name in self.widgets.keys():
            self.refresh_simulator_status(sim_name)

    def _create_simulator_card(self, sim_name: str) -> QFrame:
        """Create a card for a simulator"""
        card = QFrame()
        card.setProperty("class", "card")
        card_layout = QVBoxLayout(card)

        # Title
        title = QLabel(sim_name.upper())
        title.setStyleSheet("font-size: 18px; font-weight: bold; color: #f8fafc;")
        card_layout.addWidget(title)

        # Status label
        status_label = QLabel("Checking...")
        status_label.setStyleSheet("color: #94a3b8; font-size: 14px; margin: 10px 0;")
        card_layout.addWidget(status_label)

        # Details
        details_text = QTextEdit()
        details_text.setReadOnly(True)
        details_text.setMaximumHeight(120)
        details_text.setStyleSheet("background-color: #1e293b; color: #cbd5e1; border: 1px solid #334155; border-radius: 4px; font-family: monospace; font-size: 12px;")
        card_layout.addWidget(details_text)

        # Action buttons
        btn_layout = QHBoxLayout()

        install_btn = QPushButton(f"Install {sim_name.upper()}")
        install_btn.setStyleSheet("""
            QPushButton { background-color: #6366f1; color: white; border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; }
            QPushButton:hover { background-color: #4f46e5; }
            QPushButton:disabled { background-color: #334155; color: #64748b; }
        """)
        install_btn.clicked.connect(lambda: self.install_simulator(sim_name))
        btn_layout.addWidget(install_btn)

        refresh_btn = QPushButton("Refresh")
        refresh_btn.setStyleSheet("""
            QPushButton { background-color: #334155; color: #f8fafc; border: none; border-radius: 6px; padding: 8px 16px; }
            QPushButton:hover { background-color: #475569; }
        """)
        refresh_btn.clicked.connect(lambda: self.refresh_simulator_status(sim_name))
        btn_layout.addWidget(refresh_btn)

        btn_layout.addStretch()
        card_layout.addLayout(btn_layout)

        # Store widget references
        self.widgets[sim_name] = {
            'status': status_label,
            'details': details_text,
            'install_btn': install_btn
        }

        return card

    def refresh_simulator_status(self, sim_name: str):
        """Refresh simulator status"""
        try:
            sim = self.sim_mgr.get_simulator(sim_name)
            if not sim:
                return

            # Get widgets
            if sim_name not in self.widgets:
                return

            status_label = self.widgets[sim_name]['status']
            details_text = self.widgets[sim_name]['details']
            install_btn = self.widgets[sim_name]['install_btn']

            # Check installation
            installed = sim.is_installed()
            version = sim.get_version()
            deps = sim.verify_dependencies()
            size_mb = sim.get_install_size_mb()

            # Update status label
            if installed:
                status_label.setText(f"Installed - Version: {version or 'Unknown'}")
                status_label.setStyleSheet("color: #10b981; font-size: 14px; margin: 10px 0;")
            else:
                status_label.setText(f"Not Installed (Size: ~{size_mb} MB)")
                status_label.setStyleSheet("color: #ef4444; font-size: 14px; margin: 10px 0;")

            # Update details
            details = f"Installation Directory: {sim.install_dir}\n\n"
            details += "Dependencies:\n"
            for dep, available in deps.items():
                status = "OK" if available else "MISSING"
                details += f"  [{status}] {dep}\n"
            details_text.setText(details)

            # Update button
            if installed:
                install_btn.setEnabled(False)
                install_btn.setText(f"{sim_name.upper()} Already Installed")
            else:
                install_btn.setEnabled(True)
                install_btn.setText(f"Install {sim_name.upper()}")

        except Exception as e:
            # Show error in details instead of crashing
            if sim_name in self.widgets:
                self.widgets[sim_name]['details'].setText(f"Error checking status: {e}")
                self.widgets[sim_name]['status'].setText("⚠️ Error")
                self.widgets[sim_name]['status'].setStyleSheet("color: #f59e0b; font-size: 14px;")


    def install_simulator(self, sim_name: str):
        """Trigger simulator installation"""
        from PyQt5.QtCore import QThread, pyqtSignal

        # Confirm installation
        sim = self.sim_mgr.get_simulator(sim_name)
        size_mb = sim.get_install_size_mb()

        reply = QMessageBox.question(
            self, 
            f"Install {sim_name.upper()}", 
            f"This will download and build {sim_name.upper()} (~{size_mb} MB).\n"
            f"This may take 30-60 minutes depending on your system.\n\n"
            f"Continue?",
            QMessageBox.Yes | QMessageBox.No
        )

        if reply != QMessageBox.Yes:
            return

        # Create installation worker thread
        class InstallWorker(QThread):
            progress = pyqtSignal(str, int)
            finished = pyqtSignal(bool)

            def __init__(self, sim):
                super().__init__()
                self.sim = sim

            def run(self):
                success = self.sim.install(progress_callback=self.emit_progress)
                self.finished.emit(success)

            def emit_progress(self, message, percent):
                self.progress.emit(message, percent)

        # Disable button during installation
        install_btn = self.widgets[sim_name]['install_btn']
        install_btn.setEnabled(False)
        install_btn.setText("Installing...")

        # Create enhanced progress dialog
        from PyQt5.QtWidgets import QProgressDialog, QLabel
        progress_dialog = QProgressDialog(self)
        progress_dialog.setWindowTitle(f"Installing {sim_name.upper()}")
        progress_dialog.setWindowModality(Qt.WindowModal)
        progress_dialog.setAutoClose(False)  # Don't auto-close
        progress_dialog.setMinimumDuration(0)
        progress_dialog.setMinimumWidth(500)
        progress_dialog.setCancelButton(None)  # No cancel during install
        progress_dialog.setRange(0, 100)

        # Custom label for detailed info
        info_label = QLabel("Starting installation...")
        info_label.setStyleSheet("color: #1e293b; padding: 10px; font-size: 13px;")
        info_label.setWordWrap(True)
        progress_dialog.setLabel(info_label)

        # Start installation
        self.install_worker = InstallWorker(sim)  # Keep reference to avoid GC
        worker = self.install_worker

        import time
        start_time = time.time()
        last_percent = 0

        def format_time(seconds):
            """Format seconds into human-readable time"""
            if seconds < 60:
                return f"{seconds}s"
            elif seconds < 3600:
                mins = seconds // 60
                secs = seconds % 60
                return f"{mins}m {secs}s"
            else:
                hours = seconds // 3600
                mins = (seconds % 3600) // 60
                return f"{hours}h {mins}m"

        def update_progress(message, percent):
            nonlocal last_percent

            # Calculate elapsed and remaining time
            elapsed = time.time() - start_time

            if percent > 5 and percent != last_percent:  # Avoid division by zero
                # Estimate total time based on current progress
                estimated_total = (elapsed / percent) * 100
                remaining = estimated_total - elapsed

                # Format time
                elapsed_str = format_time(int(elapsed))
                remaining_str = format_time(int(remaining))

                # Update label with detailed info
                detailed_msg = f"{message}\n\n"
                detailed_msg += f"Progress: {percent}%\n"
                detailed_msg += f"Elapsed: {elapsed_str}\n"
                detailed_msg += f"Estimated remaining: {remaining_str}"

                info_label.setText(detailed_msg)
            else:
                info_label.setText(f"{message}\n\nProgress: {percent}%")

            progress_dialog.setValue(percent)
            last_percent = percent

        def on_finished(success):
            progress_dialog.close()
            if success:
                elapsed_total = time.time() - start_time
                QMessageBox.information(
                    self, 
                    "Success", 
                    f"{sim_name.upper()} installed successfully!\n\n"
                    f"Total time: {format_time(int(elapsed_total))}"
                )
            else:
                QMessageBox.warning(
                    self, 
                    "Installation Failed", 
                    f"Failed to install {sim_name.upper()}.\n"
                    f"Check the console for details."
                )
            self.refresh_simulator_status(sim_name)

        worker.progress.connect(update_progress)
        worker.finished.connect(on_finished)
        worker.start()

install_simulator(sim_name)

Trigger simulator installation

Source code in gui/pages/tools.py
def install_simulator(self, sim_name: str):
    """Trigger simulator installation"""
    from PyQt5.QtCore import QThread, pyqtSignal

    # Confirm installation
    sim = self.sim_mgr.get_simulator(sim_name)
    size_mb = sim.get_install_size_mb()

    reply = QMessageBox.question(
        self, 
        f"Install {sim_name.upper()}", 
        f"This will download and build {sim_name.upper()} (~{size_mb} MB).\n"
        f"This may take 30-60 minutes depending on your system.\n\n"
        f"Continue?",
        QMessageBox.Yes | QMessageBox.No
    )

    if reply != QMessageBox.Yes:
        return

    # Create installation worker thread
    class InstallWorker(QThread):
        progress = pyqtSignal(str, int)
        finished = pyqtSignal(bool)

        def __init__(self, sim):
            super().__init__()
            self.sim = sim

        def run(self):
            success = self.sim.install(progress_callback=self.emit_progress)
            self.finished.emit(success)

        def emit_progress(self, message, percent):
            self.progress.emit(message, percent)

    # Disable button during installation
    install_btn = self.widgets[sim_name]['install_btn']
    install_btn.setEnabled(False)
    install_btn.setText("Installing...")

    # Create enhanced progress dialog
    from PyQt5.QtWidgets import QProgressDialog, QLabel
    progress_dialog = QProgressDialog(self)
    progress_dialog.setWindowTitle(f"Installing {sim_name.upper()}")
    progress_dialog.setWindowModality(Qt.WindowModal)
    progress_dialog.setAutoClose(False)  # Don't auto-close
    progress_dialog.setMinimumDuration(0)
    progress_dialog.setMinimumWidth(500)
    progress_dialog.setCancelButton(None)  # No cancel during install
    progress_dialog.setRange(0, 100)

    # Custom label for detailed info
    info_label = QLabel("Starting installation...")
    info_label.setStyleSheet("color: #1e293b; padding: 10px; font-size: 13px;")
    info_label.setWordWrap(True)
    progress_dialog.setLabel(info_label)

    # Start installation
    self.install_worker = InstallWorker(sim)  # Keep reference to avoid GC
    worker = self.install_worker

    import time
    start_time = time.time()
    last_percent = 0

    def format_time(seconds):
        """Format seconds into human-readable time"""
        if seconds < 60:
            return f"{seconds}s"
        elif seconds < 3600:
            mins = seconds // 60
            secs = seconds % 60
            return f"{mins}m {secs}s"
        else:
            hours = seconds // 3600
            mins = (seconds % 3600) // 60
            return f"{hours}h {mins}m"

    def update_progress(message, percent):
        nonlocal last_percent

        # Calculate elapsed and remaining time
        elapsed = time.time() - start_time

        if percent > 5 and percent != last_percent:  # Avoid division by zero
            # Estimate total time based on current progress
            estimated_total = (elapsed / percent) * 100
            remaining = estimated_total - elapsed

            # Format time
            elapsed_str = format_time(int(elapsed))
            remaining_str = format_time(int(remaining))

            # Update label with detailed info
            detailed_msg = f"{message}\n\n"
            detailed_msg += f"Progress: {percent}%\n"
            detailed_msg += f"Elapsed: {elapsed_str}\n"
            detailed_msg += f"Estimated remaining: {remaining_str}"

            info_label.setText(detailed_msg)
        else:
            info_label.setText(f"{message}\n\nProgress: {percent}%")

        progress_dialog.setValue(percent)
        last_percent = percent

    def on_finished(success):
        progress_dialog.close()
        if success:
            elapsed_total = time.time() - start_time
            QMessageBox.information(
                self, 
                "Success", 
                f"{sim_name.upper()} installed successfully!\n\n"
                f"Total time: {format_time(int(elapsed_total))}"
            )
        else:
            QMessageBox.warning(
                self, 
                "Installation Failed", 
                f"Failed to install {sim_name.upper()}.\n"
                f"Check the console for details."
            )
        self.refresh_simulator_status(sim_name)

    worker.progress.connect(update_progress)
    worker.finished.connect(on_finished)
    worker.start()

refresh_all_statuses()

Refresh all simulator statuses

Source code in gui/pages/tools.py
def refresh_all_statuses(self):
    """Refresh all simulator statuses"""
    for sim_name in self.widgets.keys():
        self.refresh_simulator_status(sim_name)

refresh_simulator_status(sim_name)

Refresh simulator status

Source code in gui/pages/tools.py
def refresh_simulator_status(self, sim_name: str):
    """Refresh simulator status"""
    try:
        sim = self.sim_mgr.get_simulator(sim_name)
        if not sim:
            return

        # Get widgets
        if sim_name not in self.widgets:
            return

        status_label = self.widgets[sim_name]['status']
        details_text = self.widgets[sim_name]['details']
        install_btn = self.widgets[sim_name]['install_btn']

        # Check installation
        installed = sim.is_installed()
        version = sim.get_version()
        deps = sim.verify_dependencies()
        size_mb = sim.get_install_size_mb()

        # Update status label
        if installed:
            status_label.setText(f"Installed - Version: {version or 'Unknown'}")
            status_label.setStyleSheet("color: #10b981; font-size: 14px; margin: 10px 0;")
        else:
            status_label.setText(f"Not Installed (Size: ~{size_mb} MB)")
            status_label.setStyleSheet("color: #ef4444; font-size: 14px; margin: 10px 0;")

        # Update details
        details = f"Installation Directory: {sim.install_dir}\n\n"
        details += "Dependencies:\n"
        for dep, available in deps.items():
            status = "OK" if available else "MISSING"
            details += f"  [{status}] {dep}\n"
        details_text.setText(details)

        # Update button
        if installed:
            install_btn.setEnabled(False)
            install_btn.setText(f"{sim_name.upper()} Already Installed")
        else:
            install_btn.setEnabled(True)
            install_btn.setText(f"Install {sim_name.upper()}")

    except Exception as e:
        # Show error in details instead of crashing
        if sim_name in self.widgets:
            self.widgets[sim_name]['details'].setText(f"Error checking status: {e}")
            self.widgets[sim_name]['status'].setText("⚠️ Error")
            self.widgets[sim_name]['status'].setStyleSheet("color: #f59e0b; font-size: 14px;")

3D Visualizer

gui.pages.visualizer

VisualizerNode

Bases: Node

ROS 2 Node to bridge data to the 3D GUI

Source code in gui/pages/visualizer.py
class VisualizerNode(Node):
    """ROS 2 Node to bridge data to the 3D GUI"""
    scan_received = pyqtSignal(object)
    pose_received = pyqtSignal(object)

    def __init__(self, signals):
        super().__init__('gui_visualizer_node')
        self.signals = signals
        # Subscribe to multiple possible names for robustness
        self.create_subscription(LaserScan, '/scan', self.scan_callback, 10)
        self.create_subscription(LaserScan, 'scan', self.scan_callback, 10)
        self.create_subscription(Odometry, '/odom', self.odom_callback, 10)
        self.create_subscription(Odometry, 'odom', self.odom_callback, 10)

    def scan_callback(self, msg):
        # Convert LaserScan to 3D Points
        ranges = np.array(msg.ranges)
        angles = np.linspace(msg.angle_min, msg.angle_max, len(ranges))

        # Filter out inf/nan
        mask = np.isfinite(ranges)
        ranges = ranges[mask]
        angles = angles[mask]

        x = ranges * np.cos(angles)
        y = ranges * np.sin(angles)
        z = np.zeros_like(x)

        points = np.stack((x, y, z), axis=-1)
        self.signals.scan_received.emit(points)

    def odom_callback(self, msg):
        pos = msg.pose.pose.position
        ori = msg.pose.pose.orientation
        self.signals.pose_received.emit({
            'x': pos.x, 'y': pos.y, 'z': pos.z,
            'qx': ori.x, 'qy': ori.y, 'qz': ori.z, 'qw': ori.w
        })

VisualizerPage

Bases: QWidget

Source code in gui/pages/visualizer.py
class VisualizerPage(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)

        # UI Elements
        self.init_ui()

        # ROS 2 Integration
        self.signals = VisualizerSignals()
        self.signals.scan_received.connect(self.update_scan)
        self.signals.pose_received.connect(self.update_pose)

        self.ros_node = None
        self.ros_thread = None
        self.active = False

        # Data
        self.traj_points = []
        self.latest_pose = None

        # Timer to check if we need to start ROS
        self.check_timer = QTimer()
        self.check_timer.timeout.connect(self.ensure_ros)
        self.check_timer.start(2000)

    def init_ui(self):
        # Header / Controls
        ctrl_frame = QFrame()
        ctrl_frame.setStyleSheet("background-color: #1e293b; border-bottom: 1px solid #334155;")
        ctrl_frame.setFixedHeight(60)
        ctrl_layout = QHBoxLayout(ctrl_frame)

        self.status_lbl = QLabel("3D Visualizer (Waiting for Odom/Scan...)")
        self.status_lbl.setStyleSheet("color: #94a3b8; font-weight: bold; margin-left: 20px;")
        ctrl_layout.addWidget(self.status_lbl)

        ctrl_layout.addStretch()

        reset_btn = QPushButton("Reset View")
        reset_btn.setStyleSheet("background-color: #334155; color: white; padding: 5px 15px; border-radius: 4px;")
        reset_btn.clicked.connect(self.reset_camera)
        ctrl_layout.addWidget(reset_btn)

        self.follow_cb = QCheckBox("Follow Robot")
        self.follow_cb.setStyleSheet("color: white; margin-right: 15px;")
        ctrl_layout.addWidget(self.follow_cb)

        clear_btn = QPushButton("Clear Trajectory")
        clear_btn.setStyleSheet("background-color: #ef4444; color: white; padding: 5px 15px; border-radius: 4px; margin-right: 20px;")
        clear_btn.clicked.connect(self.clear_data)
        ctrl_layout.addWidget(clear_btn)

        self.layout.addWidget(ctrl_frame)

        # 3. GL View
        self.view = gl.GLViewWidget()
        self.view.setBackgroundColor('#0f172a')
        self.view.setCameraPosition(distance=15, elevation=30, azimuth=45)
        self.layout.addWidget(self.view)

        # Grid (Map floor)
        self.grid = gl.GLGridItem()
        self.grid.setSize(50, 50) # Larger grid
        self.grid.setSpacing(1, 1)
        self.grid.setColor((51, 65, 85, 120))
        self.view.addItem(self.grid)

        # Lidar Points
        self.scan_item = gl.GLScatterPlotItem(pos=np.array([[0,0,0]]), color=(0.4, 0.5, 1.0, 1.0), size=3, pxMode=True)
        self.view.addItem(self.scan_item)

        # Trajectory
        # Initialize with None/Empty to avoid line from origin
        self.traj_item = gl.GLLinePlotItem(pos=np.array([[0,0,0]]), color=(0.1, 0.8, 0.4, 1.0), width=2, antialias=True)
        self.view.addItem(self.traj_item)

        # Robot Representation (Axis + Box)
        self.robot_axis = gl.GLAxisItem()
        self.robot_axis.setSize(1.0, 1.0, 1.0) # Larger axes
        self.view.addItem(self.robot_axis)

        self.robot_box = gl.GLBoxItem()
        self.robot_box.setSize(0.4, 0.4, 0.2)
        self.robot_box.translate(-0.2, -0.2, 0)
        self.robot_box.setParentItem(self.robot_axis)
        self.robot_box.setColor((59, 130, 246, 200)) # Solid blue body

    def reset_camera(self):
        self.view.setCameraPosition(distance=15, elevation=30, azimuth=45)

    def clear_data(self):
        self.traj_points = []
        self.traj_item.setData(pos=np.array([[0,0,0]]))

    def ensure_ros(self):
        if not self.active:
            try:
                # ROS is now initialized in main.py
                self.ros_node = VisualizerNode(self.signals)
                self.ros_thread = threading.Thread(target=rclpy.spin, args=(self.ros_node,), daemon=True)
                self.ros_thread.start()
                self.active = True
                self.status_lbl.setText("3D Visualizer (ROS 2 Connected)")
                self.status_lbl.setStyleSheet("color: #10b981; font-weight: bold; margin-left: 20px;")
            except Exception as e:
                self.status_lbl.setText(f"3D Visualizer (ROS 2 Error: {str(e)[:30]})")
                self.status_lbl.setStyleSheet("color: #ef4444; font-weight: bold; margin-left: 20px;")

    def inject_pose(self, pose_data):
        """Deprecated: Avoid dual pose sources to prevent artifacts"""
        pass

    def update_scan(self, points):
        # Transform scan points from local robot frame to world frame (odom)
        if self.latest_pose:
            px, py = self.latest_pose['x'], self.latest_pose['y']
            # Only use Yaw for stable 2D visualization
            qw, qx, qy, qz = self.latest_pose['qw'], self.latest_pose['qx'], self.latest_pose['qy'], self.latest_pose['qz']

            # Simple 2D rotation (Yaw) from quaternion
            siny_cosp = 2 * (qw * qz + qx * qy)
            cosy_cosp = 1 - 2 * (qy * qy + qz * qz)
            yaw = math.atan2(siny_cosp, cosy_cosp)

            R = np.array([[np.cos(yaw), -np.sin(yaw), 0],
                          [np.sin(yaw),  np.cos(yaw), 0],
                          [0,            0,           1]])

            # Project scan slightly above ground
            world_points = points @ R.T + np.array([px, py, 0.05])
            self.scan_item.setData(pos=world_points)
        else:
            self.scan_item.setData(pos=points)

    def update_pose(self, pose):
        self.latest_pose = pose
        # Force Z=0 for ground plane visualization
        p_ground = np.array([pose['x'], pose['y'], 0.0])

        # Build 4x4 Transformation Matrix
        tr = pg.Transform3D()
        tr.translate(p_ground[0], p_ground[1], p_ground[2])

        # Only use Yaw for stable 2D Cap
        qw, qx, qy, qz = pose['qw'], pose['qx'], pose['qy'], pose['qz']
        siny_cosp = 2 * (qw * qz + qx * qy)
        cosy_cosp = 1 - 2 * (qy * qy + qz * qz)
        yaw_deg = math.degrees(math.atan2(siny_cosp, cosy_cosp))
        tr.rotate(yaw_deg, 0, 0, 1)

        # Apply transform to the axis (and its child box)
        self.robot_axis.setTransform(tr)

        # Update Trajectory (Stored with Z=0)
        # 1. Skip if the pose is at the exact origin (avoid start noise)
        if abs(p_ground[0]) < 0.001 and abs(p_ground[1]) < 0.001:
            return

        # 2. Initialize if empty
        if not self.traj_points:
             # Use current position as starting point (not 0,0,0)
             self.traj_points.append(p_ground)
             return

        # 3. Filter distance to previous point
        dist = np.linalg.norm(self.traj_points[-1] - p_ground)
        # If we jump more than 2 meters instantly, it's a simulation spike - ignore it
        if dist > 2.0:
            return

        if np.linalg.norm(self.traj_points[-1] - p_ground) > 0.05:
            # Detect huge time jump or new run start (distance > 5m)
            if np.linalg.norm(self.traj_points[-1] - p_ground) > 5.0:
                self.traj_points = [p_ground]
            else:
                self.traj_points.append(p_ground)

            # Limit history
            if len(self.traj_points) > 5000:
                self.traj_points.pop(0)

            if len(self.traj_points) > 1:
                self.traj_item.setData(pos=np.array(self.traj_points, dtype=np.float32))

        # Move camera to follow robot if requested
        if self.follow_cb.isChecked():
            # Set the center to the robot position
            self.view.opts['center'] = pg.Vector(p_ground[0], p_ground[1], p_ground[2])
            self.view.update()

    def closeEvent(self, event):
        if self.ros_node:
            self.ros_node.destroy_node()
        super().closeEvent(event)

inject_pose(pose_data)

Deprecated: Avoid dual pose sources to prevent artifacts

Source code in gui/pages/visualizer.py
def inject_pose(self, pose_data):
    """Deprecated: Avoid dual pose sources to prevent artifacts"""
    pass

VisualizerSignals

Bases: QWidget

Simple wrapper to provide Qt Signals for the ROS Node

Source code in gui/pages/visualizer.py
class VisualizerSignals(QWidget):
    """Simple wrapper to provide Qt Signals for the ROS Node"""
    scan_received = pyqtSignal(object)
    pose_received = pyqtSignal(object)

Robot Manager

gui.pages.robot_manager

Robot and sensor degradation manager page.

Allows users to simulate hardware limitations and sensor noise for SLAM robustness testing.

RobotManagerPage

Bases: QWidget

Page for configuring robot hardware degradation parameters.

Provides controls for: - LIDAR range and noise - Chassis speed scaling - Preset configurations - Saving to configuration files

Source code in gui/pages/robot_manager.py
class RobotManagerPage(QWidget):
    """Page for configuring robot hardware degradation parameters.

    Provides controls for:
    - LIDAR range and noise
    - Chassis speed scaling
    - Preset configurations
    - Saving to configuration files
    """


    def __init__(self, parent=None):
        super().__init__(parent)
        self.current_config_path = None
        self.init_ui()

    def init_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(30, 30, 30, 30)
        layout.setSpacing(20)

        # Header
        header = QLabel("Robot & Sensor Manager")
        header.setStyleSheet("font-size: 24px; font-weight: bold; color: #f8fafc;")
        layout.addWidget(header)

        # Description
        desc = QLabel("Simulate hardware limitations and sensor noise to test SLAM robustness.")
        desc.setStyleSheet("color: #94a3b8; font-style: italic;")

        layout.addWidget(desc)


        # Target Configuration
        file_group = QGroupBox("Target Configuration")
        file_group.setStyleSheet("QGroupBox { font-weight: bold; color: #cbd5e1; border: 1px solid #334155; padding: 10px; }")
        fg_layout = QHBoxLayout(file_group)

        self.lbl_path = QLabel("No configuration loaded. Please open a matrix.yaml file.")
        self.lbl_path.setStyleSheet("color: #ef4444; font-style: italic; font-weight: bold;")

        self.btn_load = QPushButton("Open Config File")
        self.btn_load.clicked.connect(self.browse_config)
        self.btn_load.setStyleSheet("background-color: #3b82f6; color: white; border: none; padding: 8px 16px; border-radius: 4px; font-weight: bold;")

        fg_layout.addWidget(self.lbl_path)
        fg_layout.addWidget(self.btn_load)

        layout.addWidget(file_group)

        # Main Switch
        self.enable_cb = QCheckBox("Enable Hardware Degradation")
        self.enable_cb.setStyleSheet("""
            QCheckBox { color: #f1f5f9; font-weight: bold; font-size: 16px; padding: 10px; }
            QCheckBox::indicator { width: 20px; height: 20px; }
        """)
        layout.addWidget(self.enable_cb)

        # Grid for Parameters
        content_layout = QGridLayout()

        # --- LIDAR Settings ---
        lidar_group = QGroupBox("LIDAR Sensor Emulation")
        lidar_group.setStyleSheet("QGroupBox { font-weight: bold; color: #6366f1; border: 1px solid #334155; margin-top: 15px; padding: 15px; }")
        lidar_layout = QGridLayout(lidar_group)

        # Range
        lidar_layout.addWidget(QLabel("Max Range (meters):"), 0, 0)
        self.range_spin = QDoubleSpinBox()
        self.range_spin.setRange(0.1, 30.0)
        self.range_spin.setValue(10.0)
        lidar_layout.addWidget(self.range_spin, 0, 1)

        # Noise
        lidar_layout.addWidget(QLabel("Gaussian Noise (std dev):"), 1, 0)
        self.noise_spin = QDoubleSpinBox()
        self.noise_spin.setRange(0.0, 1.0)
        self.noise_spin.setSingleStep(0.01)
        self.noise_spin.setValue(0.0)
        lidar_layout.addWidget(self.noise_spin, 1, 1)

        layout.addWidget(lidar_group)

        # --- Chassis Settings ---
        chassis_group = QGroupBox("Chassis & Actuators")
        chassis_group.setStyleSheet("QGroupBox { font-weight: bold; color: #10b981; border: 1px solid #334155; margin-top: 15px; padding: 15px; }")
        chassis_layout = QGridLayout(chassis_group)

        # Speed Scale
        chassis_layout.addWidget(QLabel("Speed Scaling (%):"), 0, 0)
        self.speed_slider = QSlider(Qt.Horizontal)
        self.speed_slider.setRange(10, 200)
        self.speed_slider.setValue(100)
        self.speed_label = QLabel("100%")
        self.speed_slider.valueChanged.connect(lambda v: self.speed_label.setText(f"{v}%"))
        chassis_layout.addWidget(self.speed_slider, 0, 1)
        chassis_layout.addWidget(self.speed_label, 0, 2)

        layout.addWidget(chassis_group)

        # Presets
        preset_layout = QHBoxLayout()
        for name, values in [
            ("Default (Clean)", {"range": 10.0, "noise": 0.0, "speed": 100}),
            ("Bad LIDAR (Short & Noisy)", {"range": 3.0, "noise": 0.05, "speed": 100}),
            ("Weak Motors (Slow)", {"range": 10.0, "noise": 0.0, "speed": 40}),
            ("Extreme (Stress Test)", {"range": 1.5, "noise": 0.15, "speed": 60}),
        ]:
            btn = QPushButton(name)
            btn.setStyleSheet("background-color: #334155; color: white; border: 1px solid #475569; padding: 8px; border-radius: 4px;")
            btn.clicked.connect(lambda checked, v=values: self.apply_preset(v))
            preset_layout.addWidget(btn)

        layout.addLayout(preset_layout)


        # Action Buttons Layout
        action_layout = QHBoxLayout()

        # Copy Button
        copy_btn = QPushButton("Copy Config (YAML)")
        copy_btn.setStyleSheet("""
            QPushButton { background-color: #334155; color: white; border: 1px solid #475569; padding: 12px; border-radius: 6px; font-weight: bold; }
            QPushButton:hover { background-color: #475569; }
        """)
        copy_btn.clicked.connect(self.copy_config)
        action_layout.addWidget(copy_btn)


        # Save Button
        save_btn = QPushButton("Save to Configuration File")
        save_btn.setStyleSheet("""
            QPushButton { background-color: #6366f1; color: white; font-weight: bold; border-radius: 6px; padding: 12px; font-size: 14px; }
            QPushButton:hover { background-color: #4f46e5; }
        """)
        save_btn.clicked.connect(self.save_settings)
        action_layout.addWidget(save_btn)

        layout.addLayout(action_layout)

        layout.addStretch()


    def copy_config(self):
        data = {
            "enabled": self.enable_cb.isChecked(),
            "max_range": self.range_spin.value(),
            "noise_std": self.noise_spin.value(),
            "speed_scale": self.speed_slider.value() / 100.0
        }
        # Format as YAML snippet for matrix
        yaml_str = f"""# Paste this into your matrix.yaml (under dataset or run)
degradation:
  enabled: {str(data['enabled']).lower()}
  max_range: {data['max_range']}
  noise_std: {data['noise_std']}
  speed_scale: {data['speed_scale']}
"""
        QApplication.clipboard().setText(yaml_str)

    def apply_preset(self, v):
        self.range_spin.setValue(v["range"])
        self.noise_spin.setValue(v["noise"])
        self.speed_slider.setValue(v["speed"])



    def browse_config(self):
        f, _ = QFileDialog.getOpenFileName(self, "Select Config", "configs/matrices", "YAML (*.yaml);;JSON (*.json)")
        if f:
             self.current_config_path = Path(f)
             self.lbl_path.setText(self.current_config_path.name)
             self.lbl_path.setStyleSheet("color: #22c55e; font-weight: bold;")
             self.load_settings()

    def load_settings(self):
        data = {}
        # Reset Defaults
        self.enable_cb.setChecked(False)
        self.range_spin.setValue(10.0)
        self.noise_spin.setValue(0.0)
        self.speed_slider.setValue(100)

        if not self.current_config_path or not self.current_config_path.exists():
            return

        path = self.current_config_path
        try:
            with open(path) as f:
                if path.suffix == '.json':
                        data = json.load(f)
                else:
                        full_data = yaml.safe_load(f) or {}
                        data = full_data.get("degradation", {})
                        if not data and "matrix" in full_data and "include" in full_data["matrix"]:
                            items = full_data["matrix"]["include"]
                            if items and isinstance(items, list):
                                data = items[0].get("degradation", {})
        except Exception as e:
            print(f"Error loading settings: {e}")

        if data:
             self.enable_cb.setChecked(data.get("enabled", False))
             self.range_spin.setValue(data.get("max_range", 10.0))
             self.noise_spin.setValue(data.get("noise_std", 0.0))
             self.speed_slider.setValue(int(data.get("speed_scale", 1.0) * 100))

    def save_settings(self):
        vals = {
            "enabled": self.enable_cb.isChecked(),
            "max_range": self.range_spin.value(),
            "noise_std": self.noise_spin.value(),
            "speed_scale": self.speed_slider.value() / 100.0
        }

        if not self.current_config_path:
             QMessageBox.warning(self, "No File", "Please load a configuration file first.")
             return

        # Edit YAML
        path = self.current_config_path
        try:
            full_data = {}
            if path.exists():
                with open(path) as f:
                    full_data = yaml.safe_load(f) or {}

            # Save logic (Matrix vs Root)
            if "matrix" in full_data and "include" in full_data["matrix"]:
                items = full_data["matrix"]["include"]
                if items and isinstance(items, list):
                    if "degradation" not in items[0]: items[0]["degradation"] = {}
                    items[0]["degradation"].update(vals)
            else:
                if "degradation" not in full_data: full_data["degradation"] = {}
                full_data["degradation"].update(vals)

            with open(path, "w") as f:
                yaml.dump(full_data, f, sort_keys=False)
            QMessageBox.information(self, "Saved", f"Updated config in {path.name}")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to save: {e}")

Settings

gui.pages.settings

Settings page for application configuration.

Provides controls for theme, simulator management, and Docker execution.

SettingsPage

Bases: QWidget

Application settings and preferences page.

Allows users to configure: - Theme (Dark/Light) - Simulator installations - Docker execution mode

Source code in gui/pages/settings.py
class SettingsPage(QWidget):
    """Application settings and preferences page.

    Allows users to configure:
    - Theme (Dark/Light)
    - Simulator installations
    - Docker execution mode
    """
    def __init__(self, main_window=None, parent=None):
        super().__init__(parent)
        self.main_window = main_window
        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(30, 30, 30, 30)

        self.settings = QSettings("SlamBench", "Orchestrator")

        self.init_ui()

    def init_ui(self):
        # Header
        header = QLabel("Settings")
        header.setStyleSheet("font-size: 24px; font-weight: bold; color: #f8fafc; margin-bottom: 20px;")
        self.layout.addWidget(header)

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.NoFrame)
        scroll.setStyleSheet("background: transparent;")

        content = QWidget()
        content_layout = QVBoxLayout(content)
        content_layout.setSpacing(20)

        # --- Appearance ---
        app_card = self._create_card("Appearance")
        app_layout = QVBoxLayout(app_card)

        # Theme Toggle
        theme_row = QHBoxLayout()
        theme_label = QLabel("Theme")
        theme_label.setStyleSheet("color: #e2e8f0; font-size: 14px;")

        self.theme_combo = QComboBox()
        self.theme_combo.addItems(["Dark (Default)", "Light"])

        # Load saved theme
        saved_theme = self.settings.value("theme", "Dark (Default)")
        self.theme_combo.setCurrentText(saved_theme)

        self.theme_combo.currentTextChanged.connect(self.on_theme_changed)

        theme_row.addWidget(theme_label)
        theme_row.addStretch()
        theme_row.addWidget(self.theme_combo)
        app_layout.addLayout(theme_row)

        content_layout.addWidget(app_card)

        # --- Simulator Management ---
        # We reuse the SimulatorManagementPage logic but embed it here
        # Or better: we create a wrapper around it
        from gui.pages.tools import SimulatorManagementPage

        sim_card = self._create_card("Simulators")
        sim_layout = QVBoxLayout(sim_card)

        # Instantiate the page but strip margins because it's inside a card
        self.sim_mgr_widget = SimulatorManagementPage()
        self.sim_mgr_widget.layout.setContentsMargins(0, 0, 0, 0)

        # Hide the internal header of SimulatorManagementPage since we have the card title
        for child in self.sim_mgr_widget.children():
            if isinstance(child, QLabel) and child.objectName() == "headerLabel":
                child.hide()

        sim_layout.addWidget(self.sim_mgr_widget)
        content_layout.addWidget(sim_card)

        # --- Execution / Docker ---
        exec_card = self._create_card("Execution")
        exec_layout = QVBoxLayout(exec_card)

        docker_row = QHBoxLayout()
        docker_label = QLabel("Run in Docker (Experimental)")
        docker_label.setStyleSheet("color: #e2e8f0; font-size: 14px;")

        self.docker_cb = QCheckBox()
        self.docker_cb.setChecked(self.settings.value("run_in_docker", "false") == "true")
        self.docker_cb.toggled.connect(self.on_docker_toggled)

        docker_row.addWidget(docker_label)
        docker_row.addStretch()
        docker_row.addWidget(self.docker_cb)
        exec_layout.addLayout(docker_row)

        self.build_btn = QPushButton("Build Docker Image")
        self.build_btn.setStyleSheet("background-color: #334155; color: white; padding: 5px; border-radius: 4px; font-size: 11px;")
        self.build_btn.clicked.connect(self.build_docker_image)
        exec_layout.addWidget(self.build_btn)

        docker_info = QLabel("Isolation & Portability. Requires Docker installed.")
        docker_info.setStyleSheet("color: #94a3b8; font-size: 11px; font-style: italic;")
        exec_layout.addWidget(docker_info)

        content_layout.addWidget(exec_card)

        content_layout.addStretch()
        scroll.setWidget(content)
        self.layout.addWidget(scroll)

        # Apply initial theme
        self.on_theme_changed(saved_theme)

    def _create_card(self, title):
        card = QFrame()
        card.setObjectName("settingsCard")
        card.setStyleSheet("""
            #settingsCard {
                background-color: #1e293b;
                border: 1px solid #334155;
                border-radius: 8px;
            }
        """)

        # Add title
        l = QVBoxLayout()
        t = QLabel(title)
        t.setStyleSheet("font-size: 16px; font-weight: bold; color: #f1f5f9; padding: 10px; border-bottom: 1px solid #334155;")
        l.addWidget(t)

        # Container for content
        # We set the card's layout to this wrapper
        # wait, standard widgets have one layout.
        # So we return the card, caller sets layout. No.
        # We return the card widget.
        return card

    def on_theme_changed(self, text):
        self.settings.setValue("theme", text)
        is_dark = "Dark" in text

        if self.main_window:
            self.apply_theme(is_dark)

    def on_docker_toggled(self, checked):
        self.settings.setValue("run_in_docker", "true" if checked else "false")
        QMessageBox.information(self, "Docker Execution", 
            "Docker execution mode " + ("ENABLED" if checked else "DISABLED") + 
            ".\nNote: Benchmarks will now use 'docker-compose' for isolation.")

    def build_docker_image(self):
        # We can't easily show streaming output in a QMessageBox, but we can launch it
        # and tell the user it started.
        reply = QMessageBox.question(self, "Build Docker", 
            "This will build the 'slam-bench-orchestrator:latest' image.\nIt may take several minutes. Continue?",
            QMessageBox.Yes | QMessageBox.No)

        if reply == QMessageBox.Yes:
            import subprocess
            try:
                # We could use a thread but for now just a simple blocking-ish start
                # Better: print to logs if we had a global log area
                self.build_btn.setEnabled(False)
                self.build_btn.setText("Building (check terminal)...")
                # Non-blocking-ish
                cmd = ["docker", "build", "-t", "slam-bench-orchestrator:latest", "."]
                subprocess.Popen(cmd, cwd=str(self.main_window.PROJECT_ROOT if hasattr(self.main_window, 'PROJECT_ROOT') else "."))
                QMessageBox.information(self, "Build Started", "Docker build started in background.\nPlease check your terminal for progress.")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Could not start docker build: {e}")

    def apply_theme(self, is_dark):
        # We define stylesheets for both modes
        if is_dark:
            from gui.utils import STYLE_SHEET # Default dark
            self.main_window.setStyleSheet(STYLE_SHEET)
            # Update local styles specific to this page if needed
            self.theme_combo.setStyleSheet("")
        else:
            # Simple Light Theme
            light_style = """
            QMainWindow, QWidget#mainScreen { background-color: #f1f5f9; color: #0f172a; }
            QWidget#sidebar { background-color: #ffffff; border-right: 1px solid #cbd5e1; }
            QLabel { color: #0f172a; }
            QPushButton#navButton {
                text-align: left;
                padding: 12px 30px;
                border: none;
                background-color: transparent;
                color: #475569;
                font-size: 14px;
                font-weight: 500;
                margin: 4px 10px;
                border-radius: 6px;
            }
            QPushButton#navButton:checked {
                background-color: #e2e8f0;
                color: #2563eb;
                font-weight: 600;
            }
            QPushButton#navButton:hover {
                background-color: #f8fafc;
            }
            QFrame[class="card"] {
                background-color: #ffffff;
                border: 1px solid #e2e8f0;
                border-radius: 12px;
            }
            QPushButton#actionButton {
                background-color: #2563eb;
                color: white;
                border: none;
                padding: 10px 20px;
                border-radius: 6px;
                font-weight: 600;
            }
            QPushButton#actionButton:hover { background-color: #1d4ed8; }
            QLineEdit {
                padding: 10px;
                background-color: #ffffff;
                border: 1px solid #cbd5e1;
                border-radius: 6px;
                color: #0f172a;
            }
            QTextEdit {
                background-color: #ffffff;
                color: #0f172a;
                border: 1px solid #cbd5e1;
            }
            QTableWidget {
                background-color: #ffffff;
                alternate-background-color: #f8fafc;
                color: #0f172a;
                gridline-color: #e2e8f0;
            }
            QHeaderView::section {
                background-color: #f1f5f9;
                color: #475569;
                border: none;
            }
            /* Override Settings Card for Light */
            #settingsCard {
                background-color: #ffffff;
                border: 1px solid #cbd5e1;
            }
            QLabel { color: #0f172a; } 
            """
            self.main_window.setStyleSheet(light_style)