Code: gui.py: import sys
import os
import glob
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import joblib
import json

from PyQt6.QtWidgets import (
    QApplication,
    QWidget,
    QLabel,
    QPushButton,
    QVBoxLayout,
    QHBoxLayout,
    QMessageBox,
    QComboBox,
    QGroupBox,
    QProgressBar,
    QButtonGroup,
    QRadioButton,
    QLineEdit,
    QSizePolicy,
    QTabWidget,
    QListWidget,
    QListWidgetItem,
    QCheckBox,
    QAbstractItemView,
)
from PyQt6.QtGui import (
    QFont,
    QPalette,
    QColor,
    QDoubleValidator,
    QIntValidator,
)
from PyQt6.QtCore import Qt, QEvent

import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure

# Set the backend if needed 
plt.switch_backend('QtAgg')

# Define architecture mapping here for consistency
ARCHITECTURE_MAPPING = {
    "PASCAL": 0,
    "TURING": 1,
    "AMPERE": 2,
    "ADA": 3,
    "GCN5": 4,
    "RDNA": 5,
    "RDNA2": 6,
    "RDNA3": 7,
}

RESOLUTION_MAPPING = {
    "1080p": 1,
    "1440p": 2,
    "4K": 3,
}

NUMERIC_FEATURES = [
    "Num Shaders",
    "Clock Speed (GHz)",
    "ROPs",
    "TMUs",
    "L2 Cache (MB)",
    "VRAM (GB)",
    "Bandwidth (GB/s)",
    "TFlops",
]

DEFAULT_MODEL_PATH = "models/Foundation_Model.pth"
DEFAULT_SCALER_PATH = "models/Foundation_Model_scaler.pkl"
GAME_MODEL_PATH = "models/Game_Model.pth"
GAME_SCALER_PATH = "models/Foundation_Model_scaler.pkl"
GPU_DATA_PATH = "gpu_data.csv"

# --- Model Architecture ---
class GPUPerformanceModel(nn.Module):
    def __init__(self, input_size, num_architectures, num_resolutions=0, num_games=0, mode='foundation'):
        super(GPUPerformanceModel, self).__init__()
        embedding_dim_arch = 4

        if mode == 'foundation':
            total_embedding_dim = embedding_dim_arch
            self.arch_embedding = nn.Embedding(num_architectures, embedding_dim=embedding_dim_arch)
        elif mode == 'game':
            embedding_dim_res = 2
            embedding_dim_game = 4
            self.arch_embedding = nn.Embedding(num_architectures, embedding_dim=embedding_dim_arch)
            self.res_embedding = nn.Embedding(num_resolutions, embedding_dim=embedding_dim_res)
            self.game_embedding = nn.Embedding(num_games, embedding_dim=embedding_dim_game)
            total_embedding_dim = embedding_dim_arch + embedding_dim_res + embedding_dim_game

        self.mode = mode

        self.model = nn.Sequential(
            nn.Linear(input_size + total_embedding_dim, 128),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(64, 1)
        )

    def forward(self, x_num, x_arch, x_res=None, x_game=None):
        x_arch = self.arch_embedding(x_arch)
        if self.mode == 'foundation':
            x = torch.cat([x_num, x_arch], dim=1)
        elif self.mode == 'game':
            x_res = self.res_embedding(x_res)
            x_game = self.game_embedding(x_game)
            x_cat = torch.cat([x_arch, x_res, x_game], dim=1)
            x = torch.cat([x_num, x_cat], dim=1)
        return self.model(x)

class PredictionApp(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("GPU Performance Predictor")
        self.setMinimumSize(800, 600)

        self.foundation_model = None
        self.game_model = None
        self.scaler = None
        self.game_model_config = None
        self.foundation_model_config = None

        # For the plots
        self.gpu_data = None  # To be loaded from gpu_data.csv
        self.current_prediction = None  # To store the last prediction result

        # --- Initialize GUI ---
        self.init_ui()

    def init_ui(self):

        # --- Input Fields with Validation ---
        self.create_input_fields()

        # Other initializations
        self.create_architecture_dropdown()
        self.create_resolution_dropdown()
        self.create_mode_selection()
        self.create_game_dropdown()

        # --- Buttons ---
        self.predict_button = QPushButton("Predict Performance")
        self.predict_button.clicked.connect(self.predict_performance)
        self.predict_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

        # --- Layout ---
        # Create the main tab widget
        self.tabs = QTabWidget()

        # Main tab
        self.main_tab = QWidget()
        self.tabs.addTab(self.main_tab, "Main")

        # Charts tab
        self.charts_tab = QWidget()
        self.tabs.addTab(self.charts_tab, "Charts")

        # Now set up layouts for the main tab and charts tab
        self.setup_main_tab()
        self.setup_charts_tab()

        # Now create the main layout
        main_layout = QVBoxLayout()
        main_layout.addWidget(self.tabs)

        self.setLayout(main_layout)
        self.apply_dark_mode()

        # --- Load Models ---
        self.load_models()

        # --- Load GPU data for charts ---
        self.load_gpu_data()

        # --- Initially set the mode ---
        self.update_mode()

    def create_input_fields(self):
        self.num_shaders_edit = self.create_validated_field(
            "Num Shaders:", validator=QIntValidator(0, 100000)
        )
        self.clock_speed_edit = self.create_validated_field(
            "Clock Speed (GHz):", validator=QDoubleValidator(0.0, 5.0, 4)
        )
        self.rops_edit = self.create_validated_field(
            "ROPs:", validator=QIntValidator(0, 256)
        )
        self.tmus_edit = self.create_validated_field(
            "TMUs:", validator=QIntValidator(0, 640)
        )
        self.l2_cache_edit = self.create_validated_field(
            "L2 Cache (MB):", validator=QDoubleValidator(0.0, 128.0, 3)
        )
        self.vram_edit = self.create_validated_field(
            "VRAM (GB):", validator=QDoubleValidator(0.0, 128.0, 2)
        )
        self.bandwidth_edit = self.create_validated_field(
            "Bandwidth (GB/s):", validator=QDoubleValidator(0.0, 2000.0, 2)
        )
        self.tflops_edit = self.create_validated_field(
            "TFlops:", validator=QDoubleValidator(0.0, 100.0, 4)
        )

    def create_validated_field(self, label_text, validator):
        layout = QVBoxLayout()

        label = QLabel(label_text)
        label.setToolTip(self.get_tooltip(label_text))
        layout.addWidget(label)

        edit = QLineEdit()
        edit.setValidator(validator)
        edit.textChanged.connect(lambda: self.validate_input(edit))
        edit.installEventFilter(self)
        edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        layout.addWidget(edit)

        widget = QWidget()
        widget.setLayout(layout)
        widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        return widget

    def get_tooltip(self, field_name):
        tooltips = {
            "Num Shaders:": "The number of shader cores in the GPU.",
            "Clock Speed (GHz):": "The clock speed of the GPU's core, in GHz.",
            "ROPs:": "Render Output Units: Responsible for writing pixels to memory.",
            "TMUs:": "Texture Mapping Units: Handle texture operations.",
            "TFlops:": "Teraflops: A measure of theoretical computing performance.",
            "L2 Cache (MB):": "The size of the GPU's L2 cache, in MB.",
            "VRAM (GB):": "The amount of video memory on the GPU, in GB.",
            "Bandwidth (GB/s):": "The speed of data transfer between the GPU and memory, in GB/s."
        }
        return tooltips.get(field_name, "")

    def eventFilter(self, source, event):
        if event.type() == QEvent.Type.KeyPress:
            if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
                self.focusNextChild()
                return True
        return super().eventFilter(source, event)

    def apply_dark_mode(self):
        palette = QPalette()
        palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
        palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
        palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
        palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
        palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
        self.setPalette(palette)

    def create_architecture_dropdown(self):
        # --- Architecture Dropdown ---
        self.architecture_label = QLabel("Architecture:")
        self.architecture_combo = QComboBox()
        self.architecture_combo.addItems(list(ARCHITECTURE_MAPPING.keys()))
        self.architecture_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

    def create_resolution_dropdown(self):
        # --- Resolution Dropdown ---
        self.resolution_label = QLabel("Resolution:")
        self.resolution_combo = QComboBox()
        self.resolution_combo.addItems(['1080p', '1440p', '4K'])
        self.resolution_combo.setVisible(False)
        self.resolution_label.setVisible(False)

    def create_mode_selection(self):
        # --- Mode Selection Radio Buttons ---
        self.mode_group = QButtonGroup(self)
        self.foundation_mode_radio = QRadioButton("Foundation Model", self)
        self.game_mode_radio = QRadioButton("Game Model", self)
        self.mode_group.addButton(self.foundation_mode_radio)
        self.mode_group.addButton(self.game_mode_radio)
        self.foundation_mode_radio.setChecked(True)
        self.foundation_mode_radio.toggled.connect(self.update_mode)
        self.game_mode_radio.toggled.connect(self.update_mode)

    def create_game_dropdown(self):
        # --- Game Selection ---
        self.game_label = QLabel("Select Game:")
        self.game_combo = QComboBox()
        self.game_combo.addItem("Select Game")
        self.game_label.setVisible(False)
        self.game_combo.setVisible(False)
        self.game_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

        # Load game mappings
        self.load_game_mappings()

    def setup_main_tab(self):
        # --- Main Tab Layout ---

        main_layout = QHBoxLayout()

        # --- Left Column Layout ---
        left_column_layout = QVBoxLayout()

        # Prediction Mode
        mode_group = QGroupBox("Prediction Mode")
        mode_layout = QVBoxLayout()
        mode_layout.addWidget(self.foundation_mode_radio)
        mode_layout.addWidget(self.game_mode_radio)
        mode_layout.addWidget(self.game_label)
        mode_layout.addWidget(self.game_combo)
        mode_group.setLayout(mode_layout)
        left_column_layout.addWidget(mode_group)

        # Now add the new widget for game selection and geomean FPS display
        # Create game selection list with checkboxes
        self.game_selection_list_widget = QListWidget()
        self.game_selection_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.game_selection_list_widget.itemChanged.connect(self.update_geomean_fps)

        # Load games into the list widget
        for game_id in range(len(self.GAME_MAPPING)):
            game_name = self.GAME_MAPPING_REV[game_id]
            item = QListWidgetItem(game_name)
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(Qt.CheckState.Checked)
            self.game_selection_list_widget.addItem(item)

        # Group box for game selection
        game_selection_group = QGroupBox("Select Games for Geomean")
        game_selection_layout = QVBoxLayout()
        game_selection_layout.addWidget(self.game_selection_list_widget)
        game_selection_group.setLayout(game_selection_layout)
        left_column_layout.addWidget(game_selection_group)

        # Add labels for geomean FPS display
        self.geomean_label_1080p = QLabel("Geomean FPS at 1080p: N/A")
        self.geomean_label_1440p = QLabel("Geomean FPS at 1440p: N/A")
        self.geomean_label_4k = QLabel("Geomean FPS at 4K: N/A")

        left_column_layout.addWidget(self.geomean_label_1080p)
        left_column_layout.addWidget(self.geomean_label_1440p)
        left_column_layout.addWidget(self.geomean_label_4k)

        left_column_layout.addStretch(1)

        # --- Right Column Layout ---
        right_column_layout = QVBoxLayout()

        # Input Fields Group
        input_group = QGroupBox("GPU Specifications")
        input_layout = QVBoxLayout()

        # Performance Characteristics Group
        perf_group = QGroupBox("Performance Characteristics")
        perf_layout = QVBoxLayout()
        perf_layout.addWidget(self.num_shaders_edit)
        perf_layout.addWidget(self.clock_speed_edit)
        perf_layout.addWidget(self.rops_edit)
        perf_layout.addWidget(self.tmus_edit)
        perf_layout.addWidget(self.tflops_edit)
        perf_group.setLayout(perf_layout)
        input_layout.addWidget(perf_group)

        # Memory Characteristics Group
        memory_group = QGroupBox("Memory Characteristics")
        memory_layout = QVBoxLayout()
        memory_layout.addWidget(self.l2_cache_edit)
        memory_layout.addWidget(self.vram_edit)
        memory_layout.addWidget(self.bandwidth_edit)
        memory_group.setLayout(memory_layout)
        input_layout.addWidget(memory_group)

        input_layout.addWidget(self.architecture_label)
        input_layout.addWidget(self.architecture_combo)
        input_layout.addWidget(self.resolution_label)
        input_layout.addWidget(self.resolution_combo)
        input_group.setLayout(input_layout)
        right_column_layout.addWidget(input_group)

        right_column_layout.addWidget(self.predict_button)

        # --- Output ---
        self.output_label = QLabel("Predicted Performance:")
        self.output_label.setFont(QFont("Arial", 14))
        self.output_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        right_column_layout.addWidget(self.output_label)

        self.performance_bar = QProgressBar(self)
        self.performance_bar.setTextVisible(False)
        right_column_layout.addWidget(self.performance_bar)

        right_column_layout.addStretch(1)

        # --- Combine Columns ---
        main_layout.addLayout(left_column_layout, 1)
        main_layout.addLayout(right_column_layout, 2)

        self.main_tab.setLayout(main_layout)

    def setup_charts_tab(self):
        # --- Charts Tab Layout ---

        charts_layout = QVBoxLayout()

        # Drop-down menu to select chart
        self.chart_selection_combo = QComboBox()
        self.chart_selection_combo.addItems([
            "Relative Performance Chart",
            "1080p FPS Chart",
            "1440p FPS Chart",
            "4K FPS Chart"
        ])
        self.chart_selection_combo.currentIndexChanged.connect(self.update_chart)
        charts_layout.addWidget(self.chart_selection_combo)

        # Figure canvas for plotting
        self.figure = Figure()
        self.canvas = FigureCanvas(self.figure)
        charts_layout.addWidget(self.canvas)

        self.charts_tab.setLayout(charts_layout)

    def load_models(self):
        # Load Foundation Model
        if os.path.exists(DEFAULT_MODEL_PATH) and os.path.exists(DEFAULT_SCALER_PATH):
            self.foundation_model = self.load_model_from_path(DEFAULT_MODEL_PATH, mode='foundation')
            self.scaler = joblib.load(DEFAULT_SCALER_PATH)
        else:
            QMessageBox.warning(self, "Warning", "Foundation model files not found.")

        # Load Game Model
        if os.path.exists(GAME_MODEL_PATH) and os.path.exists(GAME_SCALER_PATH):
            self.game_model = self.load_model_from_path(GAME_MODEL_PATH, mode='game')
            # Use the same scaler as foundation model
        else:
            QMessageBox.warning(self, "Warning", "Game model files not found.")

    def load_model_from_path(self, model_path, mode):
        try:
            config_path = model_path.replace(".pth", ".json")
            with open(config_path, "r") as f:
                if mode == 'foundation':
                    self.foundation_model_config = json.load(f)
                    config = self.foundation_model_config
                elif mode == 'game':
                    self.game_model_config = json.load(f)
                    config = self.game_model_config
                    self.GAME_MAPPING = config['GAME_MAPPING']
                    self.NORMALIZATION_PARAMS = config['normalization_params']
                    self.GAME_MAPPING_REV = {v: k for k, v in self.GAME_MAPPING.items()}
            input_size = config["model_params"]["input_size"]
            num_architectures = config["model_params"]["num_architectures"]

            if mode == 'foundation':
                model = GPUPerformanceModel(input_size, num_architectures, mode='foundation')
                model.load_state_dict(torch.load(model_path))
                model.eval()
                return model
            elif mode == 'game':
                num_resolutions = config["model_params"]["num_resolutions"]
                num_games = config["model_params"]["num_games"]
                model = GPUPerformanceModel(input_size, num_architectures, num_resolutions, num_games, mode='game')
                model.load_state_dict(torch.load(model_path))
                model.eval()
                return model
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Error loading model or config: {str(e)}")
            return None

    def load_game_mappings(self):
        # Load game mappings from the game model config
        if os.path.exists('models/Game_Model.json'):
            with open('models/Game_Model.json', 'r') as f:
                self.game_model_config = json.load(f)
                self.GAME_MAPPING = self.game_model_config['GAME_MAPPING']
                self.NORMALIZATION_PARAMS = self.game_model_config['normalization_params']
                self.GAME_MAPPING_REV = {v: k for k, v in self.GAME_MAPPING.items()}
                self.game_combo.addItems([self.GAME_MAPPING_REV[i] for i in range(len(self.GAME_MAPPING))])
        else:
            QMessageBox.warning(self, "Warning", "Game model configuration not found.")

    def load_gpu_data(self):
        if os.path.exists(GPU_DATA_PATH):
            self.gpu_data = pd.read_csv(GPU_DATA_PATH)
        else:
            QMessageBox.warning(self, "Warning", "GPU data file not found.")
            self.gpu_data = pd.DataFrame()

    def update_mode(self):
        if self.foundation_mode_radio.isChecked():
            self.set_foundation_mode()
        else:
            self.set_game_specific_mode()

    def set_foundation_mode(self):
        # Enable input fields
        self.enable_input_fields(True)
        self.architecture_combo.setEnabled(True)
        self.architecture_label.setVisible(True)
        self.architecture_combo.setVisible(True)
        self.resolution_label.setVisible(False)
        self.resolution_combo.setVisible(False)
        self.game_label.setVisible(False)
        self.game_combo.setVisible(False)
        self.predict_button.setEnabled(True)
        # Hide game selection and geomean FPS labels
        self.game_selection_list_widget.setVisible(False)
        self.geomean_label_1080p.setVisible(False)
        self.geomean_label_1440p.setVisible(False)
        self.geomean_label_4k.setVisible(False)

    def set_game_specific_mode(self):
        # Enable input fields
        self.enable_input_fields(True)
        self.architecture_combo.setEnabled(True)
        self.architecture_label.setVisible(True)
        self.architecture_combo.setVisible(True)
        self.resolution_label.setVisible(True)
        self.resolution_combo.setVisible(True)
        self.game_label.setVisible(True)
        self.game_combo.setVisible(True)
        self.predict_button.setEnabled(True)
        # Show game selection and geomean FPS labels
        self.game_selection_list_widget.setVisible(True)
        self.geomean_label_1080p.setVisible(True)
        self.geomean_label_1440p.setVisible(True)
        self.geomean_label_4k.setVisible(True)

    def enable_input_fields(self, enable):
        fields = [
            self.num_shaders_edit,
            self.clock_speed_edit,
            self.rops_edit,
            self.tmus_edit,
            self.tflops_edit,
            self.l2_cache_edit,
            self.vram_edit,
            self.bandwidth_edit,
        ]
        for field in fields:
            field.layout().itemAt(1).widget().setEnabled(enable)
            field.setEnabled(enable)
        self.architecture_combo.setEnabled(enable)

    def validate_input(self, edit):
        if not edit.hasAcceptableInput():
            edit.setStyleSheet("background-color: pink;")
        else:
            edit.setStyleSheet("")

    def predict_performance(self):
        try:
            num_shaders = int(self.num_shaders_edit.layout().itemAt(1).widget().text())
            clock_speed = float(self.clock_speed_edit.layout().itemAt(1).widget().text())
            rops = int(self.rops_edit.layout().itemAt(1).widget().text())
            tmus = int(self.tmus_edit.layout().itemAt(1).widget().text())
            l2_cache = float(self.l2_cache_edit.layout().itemAt(1).widget().text())
            vram = float(self.vram_edit.layout().itemAt(1).widget().text())
            bandwidth = float(self.bandwidth_edit.layout().itemAt(1).widget().text())
            tflops = float(self.tflops_edit.layout().itemAt(1).widget().text())
            architecture = ARCHITECTURE_MAPPING[self.architecture_combo.currentText()]
            architecture_tensor = torch.tensor([architecture], dtype=torch.long)

            input_data_num = np.array([[num_shaders, clock_speed, rops, tmus, l2_cache, vram, bandwidth, tflops]])
            input_data_num_scaled = self.scaler.transform(input_data_num)
            input_data_num_tensor = torch.tensor(input_data_num_scaled, dtype=torch.float32)

            # Store input data for later use
            self.input_data_num = input_data_num
            self.architecture = architecture

            if self.foundation_mode_radio.isChecked():
                # Foundation Model Prediction
                if self.foundation_model is None or self.scaler is None:
                    QMessageBox.warning(self, "Warning", "Foundation model or scaler not loaded.")
                    return

                # Make prediction
                with torch.no_grad():
                    prediction = self.foundation_model(input_data_num_tensor, architecture_tensor).item()

                # Store current prediction
                self.current_prediction = prediction

                # Display the prediction
                self.output_label.setText(
                    f"Predicted Relative Performance: {prediction:.2f}"
                )
                # Update the progress bar
                performance_percentage = int(min(max(prediction / 7.0 * 100, 0), 100))
                self.performance_bar.setValue(performance_percentage)

                # Compute relative performance data for charts
                self.compute_relative_performance()

            else:
                # Game Model Prediction
                if self.game_model is None or self.scaler is None:
                    QMessageBox.warning(self, "Warning", "Game model or scaler not loaded.")
                    return

                resolution_text = self.resolution_combo.currentText()
                resolution = RESOLUTION_MAPPING.get(resolution_text)
                if resolution is None:
                    QMessageBox.warning(self, "Warning", f"Invalid resolution selected: {resolution_text}")
                    return
                resolution_tensor = torch.tensor([resolution], dtype=torch.long)

                game_name = self.game_combo.currentText()
                if game_name == "Select Game":
                    QMessageBox.warning(self, "Warning", "Please select a game.")
                    return
                game_id = self.GAME_MAPPING.get(game_name)
                if game_id is None:
                    QMessageBox.warning(self, "Warning", f"Game {game_name} not recognized.")
                    return
                game_tensor = torch.tensor([game_id], dtype=torch.long)

                # Make prediction
                with torch.no_grad():
                    prediction_normalized = self.game_model(
                        input_data_num_tensor, architecture_tensor, resolution_tensor, game_tensor
                    ).item()

                # Rescale prediction back to original FPS
                fps_min = self.NORMALIZATION_PARAMS['fps_min']
                fps_max = self.NORMALIZATION_PARAMS['fps_max']

                if fps_max == fps_min:
                    prediction = fps_min  # All FPS values are the same
                else:
                    prediction = (prediction_normalized - 0.3) / (7 - 0.3)
                    prediction = prediction * (fps_max - fps_min) + fps_min

                # Store current prediction
                self.current_prediction = prediction

                # Display the prediction
                self.output_label.setText(
                    f"Predicted FPS: {prediction:.2f} fps at {resolution_text}"
                )
                # Update the progress bar
                performance_percentage = int(min(max(prediction / 240.0 * 100, 0), 100))
                self.performance_bar.setValue(performance_percentage)

                # Predict FPS for all games and resolutions
                self.predict_fps_for_all_games()

                # Update geomean FPS labels
                self.update_geomean_fps()

                # Compute relative performance data for charts
                self.compute_relative_performance()

            # Update the charts
            self.update_chart()

        except ValueError as e:
            QMessageBox.critical(self, "Error", f"Invalid input: {str(e)}")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"An error occurred during prediction: {str(e)}")

    def predict_fps_for_all_games(self):
        # Predict FPS for all games and resolutions
        if self.game_model is None or self.scaler is None:
            QMessageBox.warning(self, "Warning", "Game model or scaler not loaded.")
            return

        input_data_num_scaled = self.scaler.transform(self.input_data_num)
        input_data_num_tensor = torch.tensor(input_data_num_scaled, dtype=torch.float32)
        architecture_tensor = torch.tensor([self.architecture], dtype=torch.long)

        fps_predictions = []

        for res_name, res_id in RESOLUTION_MAPPING.items():
            resolution_tensor = torch.tensor([res_id], dtype=torch.long)
            for game_name, game_id in self.GAME_MAPPING.items():
                game_tensor = torch.tensor([game_id], dtype=torch.long)

                # Make prediction
                with torch.no_grad():
                    prediction_normalized = self.game_model(
                        input_data_num_tensor, architecture_tensor, resolution_tensor, game_tensor
                    ).item()

                # Rescale prediction back to original FPS
                fps_min = self.NORMALIZATION_PARAMS['fps_min']
                fps_max = self.NORMALIZATION_PARAMS['fps_max']

                if fps_max == fps_min:
                    prediction = fps_min  # All FPS values are the same
                else:
                    prediction = (prediction_normalized - 0.3) / (7 - 0.3)
                    prediction = prediction * (fps_max - fps_min) + fps_min

                fps_predictions.append({
                    'Game': game_name,
                    'Resolution': res_name,
                    'FPS': prediction
                })

        self.fps_predictions_df = pd.DataFrame(fps_predictions)

    def update_geomean_fps(self):
        if not hasattr(self, 'fps_predictions_df'):
            return

        # Get selected games
        selected_games = []
        for index in range(self.game_selection_list_widget.count()):
            item = self.game_selection_list_widget.item(index)
            if item.checkState() == Qt.CheckState.Checked:
                selected_games.append(item.text())

        if not selected_games:
            self.geomean_label_1080p.setText("Geomean FPS at 1080p: N/A")
            self.geomean_label_1440p.setText("Geomean FPS at 1440p: N/A")
            self.geomean_label_4k.setText("Geomean FPS at 4K: N/A")
            return

        geomean_fps = self.fps_predictions_df[self.fps_predictions_df['Game'].isin(selected_games)]
        geomean_fps_1080p = geomean_fps[geomean_fps['Resolution'] == '1080p']['FPS']
        geomean_fps_1440p = geomean_fps[geomean_fps['Resolution'] == '1440p']['FPS']
        geomean_fps_4k = geomean_fps[geomean_fps['Resolution'] == '4K']['FPS']

        if not geomean_fps_1080p.empty:
            geomean_1080p = geomean_fps_1080p.prod() ** (1.0 / len(geomean_fps_1080p))
            self.geomean_label_1080p.setText(f"Geomean FPS at 1080p: {geomean_1080p:.2f}")
        else:
            self.geomean_label_1080p.setText("Geomean FPS at 1080p: N/A")

        if not geomean_fps_1440p.empty:
            geomean_1440p = geomean_fps_1440p.prod() ** (1.0 / len(geomean_fps_1440p))
            self.geomean_label_1440p.setText(f"Geomean FPS at 1440p: {geomean_1440p:.2f}")
        else:
            self.geomean_label_1440p.setText("Geomean FPS at 1440p: N/A")

        if not geomean_fps_4k.empty:
            geomean_4k = geomean_fps_4k.prod() ** (1.0 / len(geomean_fps_4k))
            self.geomean_label_4k.setText(f"Geomean FPS at 4K: {geomean_4k:.2f}")
        else:
            self.geomean_label_4k.setText("Geomean FPS at 4K: N/A")

    def compute_relative_performance(self):
        # Compute the relative performance compared to other GPUs

        if self.foundation_model is None or self.scaler is None:
            QMessageBox.warning(self, "Warning", "Foundation model or scaler not loaded.")
            return

        # Ensure gpu_data is loaded
        if self.gpu_data is None or self.gpu_data.empty:
            QMessageBox.warning(self, "Warning", "GPU data not loaded for charts.")
            return

        # Prepare data
        gpu_specs = self.gpu_data.copy()
        num_features = NUMERIC_FEATURES.copy()

        # Map Architecture to integer IDs
        gpu_specs['Architecture'] = gpu_specs['Architecture'].map(ARCHITECTURE_MAPPING)

        # Extract features
        X_num = gpu_specs[NUMERIC_FEATURES].values
        X_arch = gpu_specs['Architecture'].values

        # Scale numeric features
        X_num_scaled = self.scaler.transform(X_num)
        X_num_tensor = torch.tensor(X_num_scaled, dtype=torch.float32)
        X_arch_tensor = torch.tensor(X_arch, dtype=torch.long)

        # Make predictions
        with torch.no_grad():
            other_gpu_predictions = self.foundation_model(X_num_tensor, X_arch_tensor).squeeze().numpy()

        # Create DataFrame with predictions
        results_df = gpu_specs.copy()
        results_df['Predicted Performance'] = other_gpu_predictions

        # Append current GPU
        input_arch_tensor = torch.tensor([self.architecture], dtype=torch.long)
        input_num_scaled = self.scaler.transform(self.input_data_num)
        input_num_tensor = torch.tensor(input_num_scaled, dtype=torch.float32)
        with torch.no_grad():
            current_gpu_prediction = self.foundation_model(input_num_tensor, input_arch_tensor).item()

        # Create DataFrame for current GPU
        current_gpu_data = {
            'GPU Name': 'Inputted GPU',
            'Predicted Performance': current_gpu_prediction
        }
        for i, feature in enumerate(NUMERIC_FEATURES):
            current_gpu_data[feature] = self.input_data_num[0][i]
        current_gpu_data['Architecture'] = self.architecture

        results_df = pd.concat([results_df, pd.DataFrame([current_gpu_data])], ignore_index=True)

        # Sort GPUs by performance
        results_df = results_df.sort_values(by='Predicted Performance').reset_index(drop=True)

        # Find index of inputted GPU
        input_gpu_index = results_df[results_df['GPU Name'] == 'Inputted GPU'].index[0]

        # Get closest 3 higher and 3 lower GPUs
        lower_index = max(input_gpu_index - 3, 0)
        upper_index = min(input_gpu_index + 4, len(results_df))

        self.chart_data = results_df.iloc[lower_index:upper_index]
        self.input_gpu_name = 'Inputted GPU'

    def update_chart(self):
        chart_type = self.chart_selection_combo.currentText()

        if not hasattr(self, 'chart_data'):
            return

        # Clear the figure
        self.figure.clear()

        ax = self.figure.add_subplot(111)

        if chart_type == "Relative Performance Chart":
            # Plot relative performance
            gpu_names = self.chart_data['GPU Name']
            performances = self.chart_data['Predicted Performance']

            bars = ax.barh(gpu_names, performances, color='blue')

            # Highlight the inputted GPU
            for i, bar in enumerate(bars):
                if gpu_names.iloc[i] == self.input_gpu_name:
                    bar.set_color('green')

            ax.set_xlabel('Relative Performance')
            ax.set_ylabel('GPU Name')
            ax.set_title('Relative Performance Chart')

        elif chart_type in ["1080p FPS Chart", "1440p FPS Chart", "4K FPS Chart"]:
            # For FPS charts, we need to have FPS data for the GPUs
            # Assuming we can predict FPS for these GPUs
            # For simplicity, we will reconstruct the method similar to compute_relative_performance

            resolution_text = chart_type.split()[0]
            resolution_id = RESOLUTION_MAPPING.get(resolution_text)

            if resolution_id is None:
                return

            # Prepare data
            gpu_specs = self.gpu_data.copy()
            num_features = NUMERIC_FEATURES.copy()

            # Map Architecture to integer IDs
            gpu_specs['Architecture'] = gpu_specs['Architecture'].map(ARCHITECTURE_MAPPING)

            # Extract features
            X_num = gpu_specs[NUMERIC_FEATURES].values
            X_arch = gpu_specs['Architecture'].values

            # Scale numeric features
            X_num_scaled = self.scaler.transform(X_num)
            X_num_tensor = torch.tensor(X_num_scaled, dtype=torch.float32)
            X_arch_tensor = torch.tensor(X_arch, dtype=torch.long)
            resolution_tensor = torch.tensor([resolution_id], dtype=torch.long).repeat(len(X_num_tensor))
            game_tensor = torch.tensor([0], dtype=torch.long).repeat(len(X_num_tensor))  # Assuming game ID 0 for comparison

            # Make predictions
            with torch.no_grad():
                fps_normalized = self.game_model(
                    X_num_tensor, X_arch_tensor, resolution_tensor, game_tensor
                ).squeeze().numpy()

            # Rescale FPS
            fps_min = self.NORMALIZATION_PARAMS['fps_min']
            fps_max = self.NORMALIZATION_PARAMS['fps_max']
            fps = (fps_normalized - 0.3) / (7 - 0.3) * (fps_max - fps_min) + fps_min

            # Create DataFrame with FPS predictions
            results_df = gpu_specs.copy()
            results_df['Predicted FPS'] = fps

            # Append current GPU
            input_architecture = self.architecture
            input_num_scaled = self.scaler.transform(self.input_data_num)
            input_num_tensor = torch.tensor(input_num_scaled, dtype=torch.float32)
            input_arch_tensor = torch.tensor([input_architecture], dtype=torch.long)
            resolution_tensor = torch.tensor([resolution_id], dtype=torch.long)
            game_tensor = torch.tensor([0], dtype=torch.long)  # Assuming game ID 0

            # Make prediction for current GPU
            with torch.no_grad():
                input_fps_normalized = self.game_model(
                    input_num_tensor, input_arch_tensor, resolution_tensor, game_tensor
                ).item()

            input_fps = (input_fps_normalized - 0.3) / (7 - 0.3) * (fps_max - fps_min) + fps_min

            # Create DataFrame for inputted GPU
            current_gpu_data = {
                'GPU Name': 'Inputted GPU',
                'Predicted FPS': input_fps
            }
            for i, feature in enumerate(NUMERIC_FEATURES):
                current_gpu_data[feature] = self.input_data_num[0][i]
            current_gpu_data['Architecture'] = input_architecture

            results_df = results_df.append(current_gpu_data, ignore_index=True)

            # Sort GPUs by FPS
            results_df = results_df.sort_values(by='Predicted FPS').reset_index(drop=True)

            # Find index of inputted GPU
            input_gpu_index = results_df[results_df['GPU Name'] == 'Inputted GPU'].index[0]

            # Get closest 3 higher and 3 lower GPUs
            lower_index = max(input_gpu_index - 3, 0)
            upper_index = min(input_gpu_index + 4, len(results_df))

            chart_data = results_df.iloc[lower_index:upper_index]
            gpu_names = chart_data['GPU Name']
            fps_values = chart_data['Predicted FPS']

            bars = ax.barh(gpu_names, fps_values, color='blue')

            # Highlight the inputted GPU
            for i, bar in enumerate(bars):
                if gpu_names.iloc[i] == 'Inputted GPU':
                    bar.set_color('green')

            ax.set_xlabel('FPS')
            ax.set_ylabel('GPU Name')
            ax.set_title(f'FPS at {resolution_text}')

        else:
            return

        self.figure.tight_layout()
        self.canvas.draw()

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = PredictionApp()
    window.show()
    sys.exit(app.exec())  ; Error: An error occured during prediction: 'value' must be an instance of str or bytes, not a float