4508 lines
188 KiB
Python
4508 lines
188 KiB
Python
import sys
|
||
import os
|
||
from PyQt5.QtWidgets import *
|
||
from PyQt5.QtCore import *
|
||
from PyQt5.QtCore import pyqtSignal
|
||
from PyQt5.QtGui import *
|
||
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QListWidget, QPushButton, QDialogButtonBox, QLabel, QStyledItemDelegate
|
||
from models.knn_classifier import JurusanKNNClassifier
|
||
from models.data_processor import DataProcessor
|
||
import config
|
||
import pandas as pd
|
||
from db.database import ambil_semua_hasil_prediksi, simpan_hasil_prediksi, hapus_hasil_prediksi, update_hasil_prediksi
|
||
import matplotlib.pyplot as plt
|
||
import seaborn as sns
|
||
import numpy as np
|
||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||
from matplotlib.figure import Figure
|
||
from sklearn.metrics import roc_curve, auc
|
||
from sklearn.preprocessing import label_binarize
|
||
from itertools import cycle
|
||
from gui.custom_notifications import CustomNotificationDialog, StudentNamePickerDialog
|
||
|
||
class LineEditDelegate(QStyledItemDelegate):
|
||
def createEditor(self, parent, option, index):
|
||
editor = QLineEdit(parent)
|
||
editor.setPlaceholderText("0-100")
|
||
editor.setAlignment(Qt.AlignCenter)
|
||
editor.setValidator(QIntValidator(0, 100, editor))
|
||
editor.setInputMethodHints(Qt.ImhDigitsOnly)
|
||
editor.setStyleSheet("""
|
||
QLineEdit {
|
||
border: none;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
padding: 6px;
|
||
font-size: 16px;
|
||
background-color: white;
|
||
color: #1e293b;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: 500;
|
||
}
|
||
QLineEdit:focus {
|
||
border: none;
|
||
border-bottom: 1px solid #667eea;
|
||
padding: 6px;
|
||
}
|
||
""")
|
||
return editor
|
||
|
||
def setEditorData(self, editor, index):
|
||
value = index.model().data(index, Qt.EditRole)
|
||
if value:
|
||
editor.setText(str(value))
|
||
else:
|
||
editor.setText("")
|
||
|
||
def setModelData(self, editor, model, index):
|
||
model.setData(index, editor.text(), Qt.EditRole)
|
||
|
||
def updateEditorGeometry(self, editor, option, index):
|
||
editor.setGeometry(option.rect)
|
||
|
||
class MainWindow(QMainWindow):
|
||
logout_signal = pyqtSignal()
|
||
|
||
def handle_logout(self):
|
||
# untuk memastikan hanya tab logout yang aktif saat konfirmasi, set checked dulu baru tampilkan dialog
|
||
self.btn_logout.setChecked(True)
|
||
# bersihkan semua tab lain agar tidak terlihat aktif saat dialog muncul
|
||
for tab in [self.tab_input, self.tab_data, self.tab_performa]:
|
||
tab.setChecked(False)
|
||
|
||
reply = CustomNotificationDialog.show_question(self, 'Konfirmasi Logout',
|
||
'Apakah Anda yakin ingin logout?',
|
||
'Ya, Logout', 'Batal')
|
||
if reply:
|
||
self.logout_confirmed()
|
||
else:
|
||
# jika batal logout, kembalikan ke tab yang sebelumnya aktif (default ke Input jika tidak ada yang aktif)
|
||
self.btn_logout.setChecked(False)
|
||
# Set kembali ke tab yang sebelumnya aktif, default ke Input jika tidak ada yang aktif
|
||
current_index = self.stacked_widget.currentIndex()
|
||
if current_index == 0:
|
||
self.tab_input.setChecked(True)
|
||
elif current_index == 1:
|
||
self.tab_data.setChecked(True)
|
||
elif current_index == 2:
|
||
self.tab_performa.setChecked(True)
|
||
|
||
def logout_confirmed(self):
|
||
# tutup halaman utama dan kembalikan ke login
|
||
self.close()
|
||
self.logout_signal.emit()
|
||
|
||
def parse_float_value(self, text):
|
||
"""
|
||
Helper function to parse float value from text input.
|
||
Supports both comma (,) and dot (.) as decimal separator.
|
||
Indonesian users can use 94,5 or 94.5 - both will work.
|
||
"""
|
||
if not text or text.strip() == "":
|
||
return 0.0
|
||
# Replace comma with dot for float conversion
|
||
text = text.strip().replace(',', '.')
|
||
try:
|
||
return float(text)
|
||
except ValueError:
|
||
return 0.0
|
||
|
||
def _is_valid_score_text(self, text):
|
||
"""Return True when score text is empty or an integer between 0 and 100."""
|
||
normalized = (text or "").strip()
|
||
if not normalized:
|
||
return True
|
||
return normalized.isdigit() and 0 <= int(normalized) <= 100
|
||
|
||
def _get_nilai_cell_text(self, row, column):
|
||
"""Ambil teks dari editor aktif jika ada, fallback ke item tabel."""
|
||
if not hasattr(self, 'nilai_table'):
|
||
return ''
|
||
|
||
index = self.nilai_table.model().index(row, column)
|
||
widget = self.nilai_table.indexWidget(index)
|
||
if widget and hasattr(widget, 'text'):
|
||
return widget.text().strip()
|
||
|
||
item = self.nilai_table.item(row, column)
|
||
return item.text().strip() if item else ''
|
||
|
||
def _set_nilai_cell_text(self, row, column, text):
|
||
"""Set teks ke item dan editor aktif agar tampilan tetap sinkron."""
|
||
if not hasattr(self, 'nilai_table'):
|
||
return
|
||
|
||
item = self.nilai_table.item(row, column)
|
||
if item:
|
||
item.setText(text)
|
||
|
||
index = self.nilai_table.model().index(row, column)
|
||
widget = self.nilai_table.indexWidget(index)
|
||
if widget and hasattr(widget, 'setText'):
|
||
widget.blockSignals(True)
|
||
widget.setText(text)
|
||
widget.blockSignals(False)
|
||
|
||
def _calculate_nilai_rata_row(self, row):
|
||
"""Hitung dan perbarui rata-rata untuk satu baris mapel."""
|
||
if not hasattr(self, 'nilai_table'):
|
||
return
|
||
if row < 0 or row >= self.nilai_table.rowCount():
|
||
return
|
||
|
||
sem1_text = self._get_nilai_cell_text(row, 1)
|
||
sem2_text = self._get_nilai_cell_text(row, 2)
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
if not rata_item:
|
||
return
|
||
|
||
sem1_value = self.parse_float_value(sem1_text)
|
||
sem2_value = self.parse_float_value(sem2_text)
|
||
|
||
if sem1_text or sem2_text:
|
||
rata_rata = (sem1_value + sem2_value) / 2
|
||
# Format rata-rata: hapus trailing zeros
|
||
# 100 -> "100", 90.50 -> "90.5", 88.55 -> "88.55"
|
||
formatted_rata = f"{rata_rata:.2f}".rstrip('0').rstrip('.')
|
||
rata_item.setText(formatted_rata)
|
||
rata_item.setForeground(QColor('#2c3e50'))
|
||
else:
|
||
rata_item.setText('otomatis')
|
||
rata_item.setForeground(QColor('#94a3b8'))
|
||
|
||
def _update_nilai_table_height(self):
|
||
"""Resize the input table so the last row ends at the bottom."""
|
||
if not hasattr(self, 'nilai_table'):
|
||
return
|
||
|
||
header_height = self.nilai_table.horizontalHeader().height()
|
||
row_height = self.nilai_table.verticalHeader().defaultSectionSize()
|
||
visible_rows = max(1, self.nilai_table.rowCount())
|
||
frame_height = self.nilai_table.frameWidth() * 2
|
||
self.nilai_table.setFixedHeight(header_height + (visible_rows * row_height) + frame_height + 2)
|
||
|
||
def _on_nilai_editor_changed(self, row, column, text):
|
||
"""Update item tabel dan rata-rata saat user mengetik di editor."""
|
||
if not hasattr(self, 'nilai_table') or self.loading_data:
|
||
return
|
||
if column not in (1, 2):
|
||
return
|
||
|
||
self._nilai_syncing = True
|
||
try:
|
||
item = self.nilai_table.item(row, column)
|
||
if item:
|
||
item.setText(text)
|
||
finally:
|
||
self._nilai_syncing = False
|
||
|
||
self._calculate_nilai_rata_row(row)
|
||
|
||
def on_nilai_changed(self, item):
|
||
"""Calculate rata-rata when semester 1 or 2 values change"""
|
||
if not hasattr(self, 'nilai_table') or getattr(self, '_nilai_syncing', False) or self.loading_data:
|
||
return
|
||
|
||
# Only process if item is in column 1 (Semester 1) or 2 (Semester 2)
|
||
if item.column() not in [1, 2]:
|
||
return
|
||
self._calculate_nilai_rata_row(item.row())
|
||
|
||
def open_student_name_picker(self):
|
||
"""Open the name picker dialog and fill the form when a name is chosen."""
|
||
current_name = self.nama_input.text().strip() if hasattr(self, 'nama_input') else ''
|
||
selected_name = StudentNamePickerDialog.show_picker(self, initial_name=current_name)
|
||
if selected_name:
|
||
self.nama_input.setText(selected_name)
|
||
self.statusBar().showMessage(f'Nama siswa dipilih: {selected_name}')
|
||
return selected_name
|
||
|
||
#main window membuat claasifier
|
||
def __init__(self, username="Admin"):
|
||
super().__init__()
|
||
icon_path = config.get_app_icon_path()
|
||
if icon_path:
|
||
self.setWindowIcon(QIcon(icon_path))
|
||
self.username = username # Store username
|
||
self.classifier = JurusanKNNClassifier()
|
||
self.data_processor = DataProcessor()
|
||
self.current_selected_id = None
|
||
self.loading_data = False # untuk mencegah reaksi berantai saat mengisi data ke tabel
|
||
self.edit_mode = False # untuk membedakan antara prediksi baru dan edit data yang sudah ada
|
||
self.original_edit_data = None # Snapshot data awal saat masuk mode edit
|
||
# Pagination variables
|
||
self.current_page = 1
|
||
self.items_per_page = 10
|
||
self.total_items = 0
|
||
self.all_data = [] # Store all data for pagination
|
||
|
||
self.initUI() #membangun tampilan
|
||
self.load_table_data() #menampilkan data dari database ke tabel
|
||
|
||
# Initialize button states - start in create mode
|
||
if hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(True)
|
||
self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru")
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
if hasattr(self, 'btn_update'):
|
||
self.btn_update.setEnabled(False)
|
||
self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate")
|
||
|
||
def _get_current_form_snapshot(self):
|
||
"""Ambil snapshot isi form saat ini untuk validasi perubahan."""
|
||
nilai_dict = {}
|
||
subject_keys = [
|
||
'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi',
|
||
'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut',
|
||
'nilai_sosiologi', 'nilai_geografi'
|
||
]
|
||
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(min(len(subject_keys), self.nilai_table.rowCount())):
|
||
key = subject_keys[row]
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
if rata_item:
|
||
nilai_dict[key] = self.parse_float_value(rata_item.text())
|
||
|
||
return {
|
||
'nama': self.nama_input.text().strip(),
|
||
'nilai_dict': nilai_dict
|
||
}
|
||
|
||
def _has_form_changes(self):
|
||
"""Cek apakah data form berbeda dari snapshot awal mode edit."""
|
||
if not self.original_edit_data:
|
||
return True
|
||
|
||
current_data = self._get_current_form_snapshot()
|
||
if current_data['nama'] != self.original_edit_data['nama']:
|
||
return True
|
||
|
||
for key, current_value in current_data['nilai_dict'].items():
|
||
original_value = self.original_edit_data['nilai_dict'].get(key, 0.0)
|
||
if not np.isclose(current_value, original_value, atol=1e-9):
|
||
return True
|
||
|
||
return False
|
||
|
||
def initUI(self):
|
||
self.setWindowTitle('Sistem Klasifikasi Jurusan')
|
||
self.setMinimumSize(1024, 700)
|
||
|
||
# Keep default look on normal monitors while fitting smaller screens safely.
|
||
screen = QApplication.primaryScreen()
|
||
if screen:
|
||
available = screen.availableGeometry()
|
||
width = min(1200, max(1024, int(available.width() * 0.95)))
|
||
height = min(800, max(720, int(available.height() * 0.95)))
|
||
self.resize(width, height)
|
||
self.move(
|
||
available.x() + (available.width() - width) // 2,
|
||
available.y() + (available.height() - height) // 2
|
||
)
|
||
else:
|
||
self.setGeometry(100, 100, 1200, 800)
|
||
|
||
# Central widget
|
||
central_widget = QWidget()
|
||
self.setCentralWidget(central_widget)
|
||
|
||
# Main horizontal layout (sidebar + content)
|
||
main_layout = QHBoxLayout()
|
||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||
main_layout.setSpacing(0)
|
||
|
||
# Create sidebar
|
||
sidebar = QWidget()
|
||
sidebar.setFixedWidth(250)
|
||
sidebar.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #667eea, stop:1 #764ba2);
|
||
}
|
||
""")
|
||
|
||
sidebar_layout = QVBoxLayout()
|
||
sidebar_layout.setContentsMargins(0, 0, 0, 0)
|
||
sidebar_layout.setSpacing(0)
|
||
|
||
# Header Section with logo and title in sidebar
|
||
header_widget = QWidget()
|
||
header_widget.setStyleSheet("background: transparent; padding: 25px 20px;")
|
||
header_layout = QVBoxLayout()
|
||
header_layout.setAlignment(Qt.AlignCenter)
|
||
header_layout.setSpacing(0)
|
||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Logo
|
||
logo_label = QLabel()
|
||
logo_label.setFixedSize(100, 100)
|
||
logo_label.setStyleSheet("background: transparent; border: none;")
|
||
logo_label.setAlignment(Qt.AlignCenter)
|
||
logo_path = config.get_resource_path(os.path.join('img', 'logo_sekolah.png'))
|
||
logo_pixmap = QPixmap(logo_path)
|
||
|
||
def crop_transparent(pixmap):
|
||
if pixmap.isNull():
|
||
return pixmap
|
||
img = pixmap.toImage().convertToFormat(QImage.Format_ARGB32)
|
||
w, h = img.width(), img.height()
|
||
left, top = w, h
|
||
right, bottom = 0, 0
|
||
for y in range(h):
|
||
for x in range(w):
|
||
alpha = (img.pixel(x, y) >> 24) & 0xFF
|
||
if alpha != 0:
|
||
if x < left:
|
||
left = x
|
||
if x > right:
|
||
right = x
|
||
if y < top:
|
||
top = y
|
||
if y > bottom:
|
||
bottom = y
|
||
if right < left or bottom < top:
|
||
return pixmap
|
||
cropped = img.copy(left, top, right - left + 1, bottom - top + 1)
|
||
return QPixmap.fromImage(cropped)
|
||
|
||
if not logo_pixmap.isNull():
|
||
# Crop away transparent padding so logo doesn't appear 'boxed'
|
||
logo_pixmap = crop_transparent(logo_pixmap)
|
||
logo_label.setPixmap(logo_pixmap.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation))
|
||
|
||
# Title section
|
||
title_main = QLabel("JURUSIFY")
|
||
title_main.setStyleSheet(
|
||
"color: white; font-size: 22px; font-weight: bold; margin: 0px; padding: 0px;"
|
||
" background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif; letter-spacing: 0.5px;"
|
||
)
|
||
title_main.setAlignment(Qt.AlignCenter)
|
||
|
||
# Layout adjustments for header - vertical stacking with no extra frames
|
||
header_layout = QVBoxLayout()
|
||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||
header_layout.setSpacing(0)
|
||
logo_label.setContentsMargins(0, 0, 0, 0)
|
||
title_main.setContentsMargins(0, 0, 0, 0)
|
||
header_layout.addWidget(logo_label, alignment=Qt.AlignHCenter)
|
||
header_layout.addWidget(title_main, alignment=Qt.AlignHCenter)
|
||
|
||
header_widget = QWidget()
|
||
# Increase top padding so the logo is positioned slightly lower above the title
|
||
header_widget.setStyleSheet("background: transparent; border: none; padding: 28px 0 6px 0;")
|
||
header_widget.setLayout(header_layout)
|
||
|
||
# Add header layout to sidebar layout and insert larger spacing below title
|
||
sidebar_layout.addWidget(header_widget)
|
||
sidebar_layout.addSpacing(30)
|
||
|
||
# Navigation menu
|
||
nav_widget = QWidget()
|
||
nav_widget.setStyleSheet("background: transparent;")
|
||
nav_layout = QVBoxLayout()
|
||
nav_layout.setContentsMargins(10, 15, 10, 10)
|
||
nav_layout.setSpacing(5)
|
||
|
||
# Tab buttons
|
||
self.tab_input = QPushButton("▸ Input Data")
|
||
self.tab_data = QPushButton("▸ Data Siswa")
|
||
self.tab_performa = QPushButton("▸ Performa")
|
||
self.btn_logout = QPushButton("▸ Logout")
|
||
|
||
# Style for sidebar navigation
|
||
sidebar_nav_style = """
|
||
QPushButton {
|
||
border: none;
|
||
padding: 16px 22px;
|
||
font-size: 18px;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
background-color: transparent;
|
||
text-align: left;
|
||
font-weight: 500;
|
||
margin: 0;
|
||
border-radius: 8px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: rgba(255, 255, 255, 0.15);
|
||
color: white;
|
||
}
|
||
QPushButton:checked {
|
||
background-color: rgba(255, 255, 255, 0.25);
|
||
color: white;
|
||
font-weight: 600;
|
||
border-left: 3px solid #ffffff;
|
||
border-radius: 8px 0 0 8px;
|
||
}
|
||
"""
|
||
|
||
logout_style = """
|
||
QPushButton {
|
||
border: none;
|
||
padding: 16px 22px;
|
||
font-size: 18px;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
background-color: transparent;
|
||
text-align: left;
|
||
font-weight: 500;
|
||
margin: 8px 0 0 0;
|
||
border-radius: 8px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: rgba(239, 68, 68, 0.9);
|
||
color: white;
|
||
}
|
||
"""
|
||
|
||
# Apply styles and make checkable
|
||
for tab in [self.tab_input, self.tab_data, self.tab_performa]:
|
||
tab.setCheckable(True)
|
||
tab.setFocusPolicy(Qt.NoFocus)
|
||
tab.setStyleSheet(sidebar_nav_style)
|
||
nav_layout.addWidget(tab)
|
||
|
||
# Add separator before logout
|
||
nav_layout.addStretch()
|
||
separator = QFrame()
|
||
separator.setFrameShape(QFrame.HLine)
|
||
separator.setStyleSheet("background-color: rgba(255, 255, 255, 0.2); max-height: 1px; margin: 10px 15px;")
|
||
nav_layout.addWidget(separator)
|
||
|
||
self.btn_logout.setStyleSheet(logout_style)
|
||
self.btn_logout.setFocusPolicy(Qt.NoFocus)
|
||
nav_layout.addWidget(self.btn_logout)
|
||
nav_layout.addSpacing(10)
|
||
|
||
nav_widget.setLayout(nav_layout)
|
||
sidebar_layout.addWidget(nav_widget)
|
||
|
||
sidebar.setLayout(sidebar_layout)
|
||
main_layout.addWidget(sidebar)
|
||
|
||
# Content area
|
||
content_widget = QWidget()
|
||
content_widget.setStyleSheet("background-color: #f8f9fa;")
|
||
content_layout = QVBoxLayout()
|
||
content_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Header for content area
|
||
content_header = QWidget()
|
||
content_header.setFixedHeight(65)
|
||
content_header.setStyleSheet("background-color: white; border-bottom: 2px solid #e0e6ed;")
|
||
content_header_layout = QHBoxLayout()
|
||
content_header_layout.setContentsMargins(30, 0, 30, 0)
|
||
|
||
# Spacer to push username and icon to the right
|
||
content_header_layout.addStretch()
|
||
|
||
# Username label
|
||
self.username_label = QLabel(f"{self.username}")
|
||
self.username_label.setStyleSheet("""
|
||
color: #64748b;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-right: 8px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
content_header_layout.addWidget(self.username_label)
|
||
|
||
# User icon with modern styling
|
||
user_icon_container = QWidget()
|
||
user_icon_container.setFixedSize(36, 36)
|
||
user_icon_container.setStyleSheet("""
|
||
QWidget {
|
||
background-color: #667eea;
|
||
border-radius: 18px;
|
||
border: none;
|
||
outline: none;
|
||
}
|
||
""")
|
||
user_icon_layout = QVBoxLayout(user_icon_container)
|
||
user_icon_layout.setContentsMargins(0, 0, 0, 0)
|
||
user_icon_layout.setAlignment(Qt.AlignCenter)
|
||
|
||
user_icon = QLabel("👤")
|
||
user_icon.setStyleSheet("color: white; font-size: 18px; background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif;")
|
||
user_icon.setAlignment(Qt.AlignCenter)
|
||
user_icon_layout.addWidget(user_icon)
|
||
|
||
content_header_layout.addWidget(user_icon_container)
|
||
|
||
content_header.setLayout(content_header_layout)
|
||
content_layout.addWidget(content_header)
|
||
|
||
# Stacked Widget for different tabs
|
||
self.stacked_widget = QStackedWidget()
|
||
self.stacked_widget.setStyleSheet("background-color: #f8f9fa;")
|
||
|
||
# Create tab pages
|
||
self.create_input_tab()
|
||
self.create_data_tab()
|
||
self.create_performa_tab()
|
||
|
||
content_layout.addWidget(self.stacked_widget)
|
||
content_widget.setLayout(content_layout)
|
||
main_layout.addWidget(content_widget)
|
||
|
||
central_widget.setLayout(main_layout)
|
||
|
||
# Connect signals
|
||
self.tab_input.clicked.connect(lambda: self.switch_tab(0))
|
||
self.tab_data.clicked.connect(lambda: self.switch_tab(1))
|
||
self.tab_performa.clicked.connect(lambda: self.switch_tab(2))
|
||
self.btn_logout.clicked.connect(self.handle_logout)
|
||
|
||
# Set default tab
|
||
self.switch_tab(0)
|
||
|
||
# Load initial stats and performance
|
||
print("Loading model and performance metrics...")
|
||
self.generate_performance_display() # Train first to get fresh accuracy
|
||
self.refresh_performa_stats() # Then update stats with fresh accuracy
|
||
print("✓ Application ready!\n")
|
||
|
||
# Load model from writable path first, then bundled model, then retrain.
|
||
self.initialize_model()
|
||
|
||
#load model dulu, train kalau belum ada
|
||
def initialize_model(self):
|
||
"""Inisialisasi classifier."""
|
||
model_candidates = [config.MODEL_PATH]
|
||
bundled_model = getattr(config, 'BUNDLED_MODEL_PATH', None)
|
||
if bundled_model and bundled_model not in model_candidates:
|
||
model_candidates.append(bundled_model)
|
||
|
||
for model_path in model_candidates:
|
||
try:
|
||
if os.path.exists(model_path):
|
||
self.classifier.load_model(model_path)
|
||
self.statusBar().showMessage('Model berhasil dimuat')
|
||
return
|
||
except Exception as e:
|
||
print(f"Gagal memuat model dari {model_path}: {e}")
|
||
|
||
# untuk training awal, coba muat dataset dari path yang bisa ditulis (jika ada), jika tidak fallback ke dataset sumber
|
||
self.train_model_background()
|
||
|
||
def switch_tab(self, index):
|
||
"""Switch between tabs"""
|
||
# If switching away from Input tab (index 0), reset edit mode
|
||
if hasattr(self, 'stacked_widget') and self.stacked_widget.currentIndex() == 0 and index != 0:
|
||
self.edit_mode = False
|
||
|
||
# Update tab appearance
|
||
tabs = [self.tab_input, self.tab_data, self.tab_performa]
|
||
for i, tab in enumerate(tabs):
|
||
tab.setChecked(i == index)
|
||
|
||
# Switch stacked widget page
|
||
self.stacked_widget.setCurrentIndex(index)
|
||
|
||
# Update status based on tab
|
||
if index == 0:
|
||
# When switching to Input tab, clear form if not in edit mode
|
||
if not self.edit_mode:
|
||
self.clear_form()
|
||
self.statusBar().showMessage('Input Data - Masukkan data siswa untuk prediksi')
|
||
elif index == 1:
|
||
self.statusBar().showMessage('Data Siswa - Kelola data hasil prediksi')
|
||
# Always reset search state when returning to Data Siswa.
|
||
if hasattr(self, 'search_input'):
|
||
self.search_input.blockSignals(True)
|
||
self.search_input.clear()
|
||
self.search_input.blockSignals(False)
|
||
self.current_page = 1
|
||
if hasattr(self, 'load_table_data'):
|
||
self.load_table_data()
|
||
# Clear table selection when switching to Data Siswa tab if no data should be selected
|
||
if hasattr(self, 'table') and not self.current_selected_id:
|
||
self.table.clearSelection()
|
||
# Ensure buttons are disabled
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
# Avoid auto-focus on search field so it doesn't look pre-clicked.
|
||
if hasattr(self, 'search_input'):
|
||
self.search_input.clearFocus()
|
||
if hasattr(self, 'table'):
|
||
self.table.clearFocus()
|
||
self.stacked_widget.setFocus()
|
||
elif index == 2:
|
||
self.statusBar().showMessage('Performa - Analisis performa sistem')
|
||
# Only refresh stats (lightweight), don't retrain model every time
|
||
self.refresh_performa_stats()
|
||
# Performance display already generated on init, no need to regenerate
|
||
|
||
def create_input_tab(self):
|
||
"""Create Input Data tab with modern simplified design"""
|
||
input_page = QWidget()
|
||
input_page.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 #f5f7fa, stop:1 #e8ecf1);
|
||
}
|
||
""")
|
||
|
||
page_layout = QVBoxLayout(input_page)
|
||
page_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
scroll_area = QScrollArea()
|
||
scroll_area.setWidgetResizable(True)
|
||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||
scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }")
|
||
|
||
input_widget = QWidget()
|
||
input_widget.setStyleSheet("background: transparent;")
|
||
|
||
# Main layout
|
||
input_layout = QVBoxLayout(input_widget)
|
||
input_layout.setContentsMargins(40, 30, 40, 30)
|
||
input_layout.setSpacing(20)
|
||
|
||
# Header with gradient
|
||
header_widget = QWidget()
|
||
header_widget.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #667eea, stop:1 #764ba2);
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
header_layout = QVBoxLayout(header_widget)
|
||
header_layout.setContentsMargins(30, 20, 30, 20)
|
||
header_layout.setSpacing(5)
|
||
|
||
title_label = QLabel("📝 Input Data Siswa")
|
||
title_label.setStyleSheet("""
|
||
font-size: 30px;
|
||
font-weight: bold;
|
||
color: white;
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
header_layout.addWidget(title_label)
|
||
|
||
subtitle_label = QLabel("Masukkan data siswa untuk mendapatkan prediksi jurusan yang sesuai")
|
||
subtitle_label.setStyleSheet("""
|
||
font-size: 18px;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
header_layout.addWidget(subtitle_label)
|
||
|
||
input_layout.addWidget(header_widget)
|
||
|
||
# Main Form Card - Simple white card
|
||
form_card = QWidget()
|
||
form_card.setStyleSheet("""
|
||
QWidget {
|
||
background-color: white;
|
||
border-radius: 12px;
|
||
border: 1px solid #e0e6ed;
|
||
}
|
||
""")
|
||
shadow = QGraphicsDropShadowEffect()
|
||
shadow.setBlurRadius(15)
|
||
shadow.setXOffset(0)
|
||
shadow.setYOffset(5)
|
||
shadow.setColor(QColor(0, 0, 0, 30))
|
||
form_card.setGraphicsEffect(shadow)
|
||
|
||
form_layout = QVBoxLayout(form_card)
|
||
form_layout.setContentsMargins(30, 30, 30, 30)
|
||
form_layout.setSpacing(25)
|
||
|
||
# Nama Siswa Section - plain label
|
||
nama_label = QLabel("Nama Lengkap Siswa")
|
||
nama_label.setStyleSheet("""
|
||
font-size: 19px;
|
||
color: #2c3e50;
|
||
font-weight: bold;
|
||
background: transparent;
|
||
border: none;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
form_layout.addWidget(nama_label)
|
||
|
||
self.nama_input = QLineEdit()
|
||
self.nama_input.setMinimumHeight(56)
|
||
self.nama_input.setMaximumHeight(56)
|
||
self.nama_input.setMaxLength(50)
|
||
self.nama_input.setReadOnly(True)
|
||
self.nama_input.setCursor(Qt.PointingHandCursor)
|
||
self.nama_input.setToolTip("Klik untuk memilih nama siswa")
|
||
self.nama_input.setStyleSheet("""
|
||
QLineEdit {
|
||
border: 2px solid #e0e6ed;
|
||
border-radius: 8px;
|
||
padding: 8px 14px;
|
||
font-size: 19px;
|
||
font-weight: 400;
|
||
background-color: #f8fafc;
|
||
color: #1e293b;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QLineEdit:focus {
|
||
border-color: #667eea;
|
||
background-color: white;
|
||
}
|
||
QLineEdit:hover {
|
||
border-color: #cbd5e1;
|
||
}
|
||
""")
|
||
self.nama_input.installEventFilter(self)
|
||
self.nama_input.setPlaceholderText("Klik untuk pilih nama siswa")
|
||
self.nama_input.textChanged.connect(self.validate_nama_input)
|
||
form_layout.addWidget(self.nama_input)
|
||
|
||
# Separator line
|
||
separator = QFrame()
|
||
separator.setFrameShape(QFrame.HLine)
|
||
separator.setStyleSheet("background-color: #e0e6ed; max-height: 1px;")
|
||
form_layout.addWidget(separator)
|
||
|
||
# Nilai Section - plain label
|
||
nilai_label = QLabel("Nilai Mata Pelajaran (Range: 0-100)")
|
||
nilai_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
nilai_label.setIndent(0)
|
||
nilai_label.setContentsMargins(0, 0, 0, 0)
|
||
nilai_label.setStyleSheet("""
|
||
font-size: 19px;
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
background: transparent;
|
||
border: none;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
margin-bottom: 5px;
|
||
""")
|
||
form_layout.addWidget(nilai_label)
|
||
|
||
# Create table widget for nilai input
|
||
self.nilai_table = QTableWidget()
|
||
self.nilai_table.setColumnCount(4)
|
||
self.nilai_table.setHorizontalHeaderLabels(['Mapel', 'Semester 1', 'Semester 2', 'Rata-rata'])
|
||
self.nilai_table.setMinimumHeight(350)
|
||
self.nilai_table.setStyleSheet("""
|
||
QTableWidget {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 6px;
|
||
background-color: #ffffff;
|
||
gridline-color: #e2e8f0;
|
||
}
|
||
QTableWidget::item {
|
||
padding: 4px;
|
||
background-color: white;
|
||
}
|
||
QHeaderView::section {
|
||
background-color: #f1f5f9;
|
||
color: #2c3e50;
|
||
padding: 14px 8px;
|
||
border: none;
|
||
border-right: 1px solid #e2e8f0;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
font-weight: bold;
|
||
font-size: 18px;
|
||
}
|
||
""")
|
||
|
||
# Set header alignment and properties
|
||
header = self.nilai_table.horizontalHeader()
|
||
header.setStretchLastSection(False)
|
||
header.setSectionResizeMode(0, QHeaderView.Stretch) # Mapel
|
||
header.setSectionResizeMode(1, QHeaderView.Stretch) # Semester 1
|
||
header.setSectionResizeMode(2, QHeaderView.Stretch) # Semester 2
|
||
header.setSectionResizeMode(3, QHeaderView.Stretch) # Rata-rata
|
||
|
||
self.nilai_table.verticalHeader().setVisible(False)
|
||
self.nilai_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||
self.nilai_table.setSelectionMode(QAbstractItemView.NoSelection)
|
||
self.nilai_table.setFocusPolicy(Qt.NoFocus)
|
||
# Set custom delegate for input columns (Semester 1 and 2)
|
||
# Must set delegate before creating persistent editors so the editors
|
||
# created by openPersistentEditor inherit the delegate's alignment/style.
|
||
delegate = LineEditDelegate()
|
||
self.nilai_table.setItemDelegateForColumn(1, delegate)
|
||
self.nilai_table.setItemDelegateForColumn(2, delegate)
|
||
|
||
# Mata pelajaran list
|
||
mapel_list = [
|
||
('Informatika', 'nilai_informatika'),
|
||
('Fisika', 'nilai_fisika'),
|
||
('Kimia', 'nilai_kimia'),
|
||
('Biologi', 'nilai_biologi'),
|
||
('BIG Lanjut', 'nilai_big_lanjut'),
|
||
('Ekonomi', 'nilai_ekonomi'),
|
||
('MAT Lanjut', 'nilai_mat_lanjut'),
|
||
('SEJ Lanjut', 'nilai_sej_lanjut'),
|
||
('Sosiologi', 'nilai_sosiologi'),
|
||
('Geografi', 'nilai_geografi')
|
||
]
|
||
|
||
self.nilai_inputs = {}
|
||
self.nilai_sem1_inputs = {} # Store semester 1 inputs
|
||
self.nilai_sem2_inputs = {} # Store semester 2 inputs
|
||
|
||
self.nilai_table.setRowCount(len(mapel_list))
|
||
|
||
for row, (mapel, key) in enumerate(mapel_list):
|
||
# Column 0: Mapel name (read-only)
|
||
mapel_item = QTableWidgetItem(mapel)
|
||
mapel_item.setFlags(mapel_item.flags() & ~Qt.ItemIsEditable)
|
||
mapel_item.setFont(QFont('Segoe UI', 10))
|
||
mapel_item.setForeground(QColor('#2c3e50'))
|
||
self.nilai_table.setItem(row, 0, mapel_item)
|
||
|
||
# Column 1: Semester 1 input
|
||
sem1_item = QTableWidgetItem('')
|
||
sem1_item.setFlags(sem1_item.flags() | Qt.ItemIsEditable)
|
||
sem1_item.setTextAlignment(Qt.AlignCenter)
|
||
sem1_item.setFont(QFont('Segoe UI', 14))
|
||
self.nilai_table.setItem(row, 1, sem1_item)
|
||
self.nilai_sem1_inputs[key] = sem1_item
|
||
self.nilai_table.openPersistentEditor(sem1_item)
|
||
|
||
# Column 2: Semester 2 input
|
||
sem2_item = QTableWidgetItem('')
|
||
sem2_item.setFlags(sem2_item.flags() | Qt.ItemIsEditable)
|
||
sem2_item.setTextAlignment(Qt.AlignCenter)
|
||
sem2_item.setFont(QFont('Segoe UI', 14))
|
||
self.nilai_table.setItem(row, 2, sem2_item)
|
||
self.nilai_sem2_inputs[key] = sem2_item
|
||
self.nilai_table.openPersistentEditor(sem2_item)
|
||
|
||
# Column 3: Rata-rata (read-only, calculated)
|
||
rata_item = QTableWidgetItem('otomatis')
|
||
rata_item.setFlags(rata_item.flags() & ~Qt.ItemIsEditable)
|
||
rata_item.setTextAlignment(Qt.AlignCenter)
|
||
rata_item.setFont(QFont('Segoe UI', 10))
|
||
rata_item.setForeground(QColor('#94a3b8'))
|
||
self.nilai_table.setItem(row, 3, rata_item)
|
||
|
||
# Store in nilai_inputs for backward compatibility with predict function
|
||
self.nilai_inputs[key] = rata_item
|
||
|
||
# Connect persistent editors so rata-rata updates as soon as user types
|
||
sem1_index = self.nilai_table.model().index(row, 1)
|
||
sem2_index = self.nilai_table.model().index(row, 2)
|
||
sem1_editor = self.nilai_table.indexWidget(sem1_index)
|
||
sem2_editor = self.nilai_table.indexWidget(sem2_index)
|
||
|
||
if sem1_editor and hasattr(sem1_editor, 'textChanged'):
|
||
sem1_editor.textChanged.connect(
|
||
lambda text, r=row: self._on_nilai_editor_changed(r, 1, text)
|
||
)
|
||
if sem2_editor and hasattr(sem2_editor, 'textChanged'):
|
||
sem2_editor.textChanged.connect(
|
||
lambda text, r=row: self._on_nilai_editor_changed(r, 2, text)
|
||
)
|
||
|
||
# Set row height
|
||
for row in range(len(mapel_list)):
|
||
self.nilai_table.setRowHeight(row, 45)
|
||
|
||
self._update_nilai_table_height()
|
||
|
||
# Connect item changed signal to calculate rata-rata
|
||
self.nilai_table.itemChanged.connect(self.on_nilai_changed)
|
||
|
||
form_layout.addWidget(self.nilai_table)
|
||
input_layout.addWidget(form_card)
|
||
|
||
# Action Buttons - Simple layout without card
|
||
btn_layout = QHBoxLayout()
|
||
btn_layout.setSpacing(15)
|
||
btn_layout.setContentsMargins(0, 10, 0, 0)
|
||
|
||
# Predict button
|
||
self.btn_predict = QPushButton("🔮 Prediksi")
|
||
self.btn_predict.setFixedHeight(50)
|
||
self.btn_predict.setCursor(Qt.PointingHandCursor)
|
||
self.btn_predict.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #667eea, stop:1 #764ba2);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 17px;
|
||
font-weight: bold;
|
||
padding: 12px 32px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #5568d3, stop:1 #6a3b91);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #4a5abc, stop:1 #5a3080);
|
||
}
|
||
QPushButton:disabled {
|
||
background: #cbd5e1;
|
||
color: #94a3b8;
|
||
}
|
||
""")
|
||
self.btn_predict.clicked.connect(self.predict_and_save_data)
|
||
self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru")
|
||
btn_layout.addWidget(self.btn_predict)
|
||
|
||
# Update button
|
||
self.btn_update = QPushButton("✏️ Update")
|
||
self.btn_update.setFixedHeight(50)
|
||
self.btn_update.setCursor(Qt.PointingHandCursor)
|
||
self.btn_update.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #11998e, stop:1 #38ef7d);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 17px;
|
||
font-weight: bold;
|
||
padding: 12px 32px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #0d7a72, stop:1 #2dd465);
|
||
}
|
||
QPushButton:disabled {
|
||
background: #cbd5e1;
|
||
color: #94a3b8;
|
||
}
|
||
""")
|
||
self.btn_update.clicked.connect(self.update_data)
|
||
self.btn_update.setToolTip("Mengupdate data yang dipilih dengan prediksi baru")
|
||
btn_layout.addWidget(self.btn_update)
|
||
|
||
# Clear Form button
|
||
self.btn_clear = QPushButton("🗑️ Clear Form")
|
||
self.btn_clear.setFixedHeight(50)
|
||
self.btn_clear.setCursor(Qt.PointingHandCursor)
|
||
self.btn_clear.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #ee0979, stop:1 #ff6a00);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 17px;
|
||
font-weight: bold;
|
||
padding: 12px 32px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #d4076a, stop:1 #e65900);
|
||
}
|
||
""")
|
||
self.btn_clear.clicked.connect(lambda: self.clear_form(show_empty_notification=True))
|
||
btn_layout.addWidget(self.btn_clear)
|
||
|
||
btn_layout.addStretch()
|
||
input_layout.addLayout(btn_layout)
|
||
input_layout.addStretch()
|
||
|
||
scroll_area.setWidget(input_widget)
|
||
page_layout.addWidget(scroll_area)
|
||
|
||
self.stacked_widget.addWidget(input_page)
|
||
|
||
#ui untuk tab data siswa
|
||
def create_data_tab(self):
|
||
data_widget = QWidget()
|
||
data_widget.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 #f5f7fa, stop:1 #e8ecf1);
|
||
}
|
||
""")
|
||
|
||
# Main layout
|
||
data_layout = QVBoxLayout(data_widget)
|
||
data_layout.setContentsMargins(32, 20, 32, 20)
|
||
data_layout.setSpacing(14)
|
||
|
||
# Header with gradient
|
||
header_widget = QWidget()
|
||
header_widget.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #667eea, stop:1 #764ba2);
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
header_layout = QVBoxLayout(header_widget)
|
||
header_layout.setContentsMargins(30, 20, 30, 20)
|
||
header_layout.setSpacing(5)
|
||
|
||
title_label = QLabel("📊 Data Siswa")
|
||
title_label.setStyleSheet("""
|
||
font-size: 30px;
|
||
font-weight: bold;
|
||
color: white;
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
header_layout.addWidget(title_label)
|
||
|
||
subtitle_label = QLabel("Kelola dan lihat semua data hasil prediksi siswa")
|
||
subtitle_label.setStyleSheet("""
|
||
font-size: 18px;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
header_layout.addWidget(subtitle_label)
|
||
|
||
data_layout.addWidget(header_widget)
|
||
|
||
# Main Content Card
|
||
content_card = QWidget()
|
||
content_card.setObjectName("dataContentCard")
|
||
content_card.setStyleSheet("""
|
||
QWidget#dataContentCard {
|
||
background-color: white;
|
||
border-radius: 12px;
|
||
border: 1px solid #e0e6ed;
|
||
}
|
||
""")
|
||
shadow = QGraphicsDropShadowEffect()
|
||
shadow.setBlurRadius(15)
|
||
shadow.setXOffset(0)
|
||
shadow.setYOffset(5)
|
||
shadow.setColor(QColor(0, 0, 0, 30))
|
||
content_card.setGraphicsEffect(shadow)
|
||
|
||
content_layout = QVBoxLayout(content_card)
|
||
content_layout.setContentsMargins(24, 18, 24, 18)
|
||
content_layout.setSpacing(14)
|
||
|
||
# Search Section
|
||
search_layout = QHBoxLayout()
|
||
search_layout.setSpacing(10)
|
||
|
||
search_label = QLabel("Pencarian:")
|
||
search_label.setStyleSheet("""
|
||
font-size: 16px;
|
||
color: #2c3e50;
|
||
font-weight: bold;
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
search_layout.addWidget(search_label)
|
||
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText("Cari nama siswa...")
|
||
self.search_input.setMinimumHeight(56)
|
||
self.search_input.setMaximumHeight(56)
|
||
self.search_input.setMinimumWidth(300)
|
||
self.search_input.setStyleSheet("""
|
||
QLineEdit {
|
||
border: 2px solid #e0e6ed;
|
||
border-radius: 8px;
|
||
padding: 8px 14px;
|
||
font-size: 19px;
|
||
background-color: #f8fafc;
|
||
color: #1e293b;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QLineEdit:focus {
|
||
border-color: #667eea;
|
||
background-color: white;
|
||
}
|
||
QLineEdit:hover {
|
||
border-color: #cbd5e1;
|
||
}
|
||
""")
|
||
|
||
# Validator untuk pencarian - hanya huruf dan spasi
|
||
search_validator = QRegExpValidator(QRegExp("[a-zA-Z ]*"))
|
||
self.search_input.setValidator(search_validator)
|
||
self.search_input.setFocusPolicy(Qt.ClickFocus)
|
||
|
||
# Connect search input signals
|
||
self.search_input.textChanged.connect(self.search_by_name)
|
||
self.search_input.returnPressed.connect(self.search_by_name)
|
||
|
||
search_layout.addWidget(self.search_input)
|
||
|
||
# Clear search button
|
||
btn_clear_search = QPushButton("✖ Clear")
|
||
btn_clear_search.setFixedHeight(56)
|
||
btn_clear_search.setCursor(Qt.PointingHandCursor)
|
||
btn_clear_search.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #dc3545;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
padding: 10px 20px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #c82333;
|
||
}
|
||
""")
|
||
btn_clear_search.clicked.connect(self.clear_search)
|
||
btn_clear_search.setToolTip("Hapus pencarian")
|
||
search_layout.addWidget(btn_clear_search)
|
||
search_layout.addStretch()
|
||
|
||
content_layout.addLayout(search_layout)
|
||
|
||
# Action buttons
|
||
action_layout = QHBoxLayout()
|
||
action_layout.setSpacing(12)
|
||
|
||
# Delete button
|
||
btn_delete = QPushButton("Delete")
|
||
btn_delete.setFixedHeight(45)
|
||
btn_delete.setCursor(Qt.PointingHandCursor)
|
||
btn_delete.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #ee0979, stop:1 #ff6a00);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
padding: 10px 24px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #d4076a, stop:1 #e65900);
|
||
}
|
||
""")
|
||
btn_delete.clicked.connect(self.delete_data)
|
||
action_layout.addWidget(btn_delete)
|
||
|
||
# Edit button
|
||
self.btn_edit = QPushButton("Edit")
|
||
self.btn_edit.setFixedHeight(45)
|
||
self.btn_edit.setCursor(Qt.PointingHandCursor)
|
||
self.btn_edit.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #667eea, stop:1 #764ba2);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
padding: 10px 24px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #5568d3, stop:1 #6a3b91);
|
||
}
|
||
QPushButton:disabled {
|
||
background: #cbd5e1;
|
||
color: #94a3b8;
|
||
}
|
||
""")
|
||
self.btn_edit.clicked.connect(self.edit_selected_data)
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
action_layout.addWidget(self.btn_edit)
|
||
|
||
# Preview button
|
||
self.btn_preview = QPushButton("Preview")
|
||
self.btn_preview.setFixedHeight(45)
|
||
self.btn_preview.setCursor(Qt.PointingHandCursor)
|
||
self.btn_preview.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #a770ef, stop:1 #cf8bf3);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
padding: 10px 24px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #9560d8, stop:1 #bd7ae0);
|
||
}
|
||
QPushButton:disabled {
|
||
background: #cbd5e1;
|
||
color: #94a3b8;
|
||
}
|
||
""")
|
||
self.btn_preview.clicked.connect(self.preview_prediction)
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
action_layout.addWidget(self.btn_preview)
|
||
|
||
# Export button
|
||
btn_export = QPushButton("Export")
|
||
btn_export.setFixedHeight(45)
|
||
btn_export.setCursor(Qt.PointingHandCursor)
|
||
btn_export.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #11998e, stop:1 #38ef7d);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
padding: 10px 24px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #0d7a72, stop:1 #2dd465);
|
||
}
|
||
""")
|
||
btn_export.clicked.connect(self.export_to_excel)
|
||
action_layout.addWidget(btn_export)
|
||
|
||
action_layout.addStretch()
|
||
content_layout.addLayout(action_layout)
|
||
|
||
# Hidden info label (masih dibutuhkan untuk fungsi internal tapi tidak ditampilkan)
|
||
self.info_label = QLabel()
|
||
self.info_label.setVisible(False)
|
||
|
||
# Table
|
||
self.table = QTableWidget()
|
||
self.setup_table()
|
||
self.table.installEventFilter(self)
|
||
self.table.viewport().installEventFilter(self)
|
||
content_layout.addWidget(self.table, 1)
|
||
|
||
# Pagination
|
||
pagination_layout = QHBoxLayout()
|
||
pagination_layout.setContentsMargins(0, 4, 0, 0)
|
||
pagination_layout.setSpacing(14)
|
||
pagination_layout.setAlignment(Qt.AlignCenter)
|
||
pagination_layout.addStretch()
|
||
|
||
self.btn_prev = QPushButton("◀ Previous")
|
||
self.btn_prev.setFixedHeight(40)
|
||
self.btn_prev.setCursor(Qt.PointingHandCursor)
|
||
self.btn_prev.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #64748b;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-weight: bold;
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #475569;
|
||
}
|
||
QPushButton:disabled {
|
||
background-color: #cbd5e1;
|
||
color: #94a3b8;
|
||
}
|
||
""")
|
||
self.btn_prev.clicked.connect(self.previous_page)
|
||
pagination_layout.addWidget(self.btn_prev)
|
||
|
||
self.page_label = QLabel()
|
||
self.page_label.setAlignment(Qt.AlignCenter)
|
||
self.page_label.setMinimumWidth(180)
|
||
self.page_label.setStyleSheet("""
|
||
margin: 0px 20px;
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
color: #2c3e50;
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
pagination_layout.addWidget(self.page_label)
|
||
|
||
self.btn_next = QPushButton("Next ▶")
|
||
self.btn_next.setFixedHeight(40)
|
||
self.btn_next.setCursor(Qt.PointingHandCursor)
|
||
self.btn_next.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #64748b;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-weight: bold;
|
||
font-weight: 600;
|
||
font-size: 18px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #475569;
|
||
}
|
||
QPushButton:disabled {
|
||
background-color: #cbd5e1;
|
||
color: #94a3b8;
|
||
}
|
||
""")
|
||
self.btn_next.clicked.connect(self.next_page)
|
||
pagination_layout.addWidget(self.btn_next)
|
||
|
||
pagination_layout.addStretch()
|
||
|
||
pagination_widget = QWidget()
|
||
pagination_widget.setStyleSheet("background: transparent;")
|
||
pagination_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||
pagination_widget.setFixedHeight(52)
|
||
pagination_widget.setLayout(pagination_layout)
|
||
content_layout.addWidget(pagination_widget)
|
||
|
||
# Let the content card consume available vertical space so table rows remain visible.
|
||
data_layout.addWidget(content_card, 1)
|
||
|
||
# Install event filter to detect clicks outside table
|
||
data_widget.installEventFilter(self)
|
||
|
||
self.stacked_widget.addWidget(data_widget)
|
||
|
||
#ui untuk tab performa sistem
|
||
def create_performa_tab(self):
|
||
performa_widget = QWidget()
|
||
performa_widget.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 #f5f7fa, stop:1 #e8ecf1);
|
||
}
|
||
""")
|
||
|
||
# Main layout dengan scroll area
|
||
main_performa_layout = QVBoxLayout(performa_widget)
|
||
main_performa_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Create scroll area
|
||
scroll_area = QScrollArea()
|
||
scroll_area.setWidgetResizable(True)
|
||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||
scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }")
|
||
|
||
# Content widget for scroll area
|
||
content_widget = QWidget()
|
||
content_widget.setStyleSheet("background: transparent;")
|
||
performa_layout = QVBoxLayout(content_widget)
|
||
performa_layout.setContentsMargins(40, 30, 40, 30)
|
||
performa_layout.setSpacing(25)
|
||
|
||
# Header with gradient
|
||
header_widget = QWidget()
|
||
header_widget.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 #667eea, stop:1 #764ba2);
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
header_layout = QVBoxLayout(header_widget)
|
||
header_layout.setContentsMargins(30, 20, 30, 20)
|
||
header_layout.setSpacing(5)
|
||
|
||
title_label = QLabel("📈 Performa Sistem")
|
||
title_label.setStyleSheet("""
|
||
font-size: 30px;
|
||
font-weight: bold;
|
||
color: white;
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
header_layout.addWidget(title_label)
|
||
|
||
subtitle_label = QLabel("Analisis dan evaluasi performa klasifikasi K-Nearest Neighbors")
|
||
subtitle_label.setStyleSheet("""
|
||
font-size: 18px;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
background: transparent;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
""")
|
||
header_layout.addWidget(subtitle_label)
|
||
|
||
performa_layout.addWidget(header_widget)
|
||
|
||
# Statistics Cards Container
|
||
self.stats_container = QWidget()
|
||
self.stats_container.setStyleSheet("background-color: transparent;")
|
||
self.stats_layout = QHBoxLayout(self.stats_container)
|
||
self.stats_layout.setSpacing(15)
|
||
self.stats_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
performa_layout.addWidget(self.stats_container)
|
||
|
||
# Section 1: Confusion Matrix
|
||
confusion_card = QWidget()
|
||
confusion_card.setStyleSheet("""
|
||
QWidget {
|
||
background-color: white;
|
||
border-radius: 12px;
|
||
border: 1px solid #e0e6ed;
|
||
}
|
||
""")
|
||
shadow1 = QGraphicsDropShadowEffect()
|
||
shadow1.setBlurRadius(15)
|
||
shadow1.setXOffset(0)
|
||
shadow1.setYOffset(5)
|
||
shadow1.setColor(QColor(0, 0, 0, 30))
|
||
confusion_card.setGraphicsEffect(shadow1)
|
||
|
||
confusion_card_layout = QVBoxLayout(confusion_card)
|
||
confusion_card_layout.setContentsMargins(25, 20, 25, 20)
|
||
confusion_card_layout.setSpacing(15)
|
||
|
||
confusion_title = QLabel("Confusion Matrix")
|
||
confusion_title.setStyleSheet("""
|
||
font-size: 17px;
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
background: transparent;
|
||
border: none;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
margin-bottom: 5px;
|
||
""")
|
||
confusion_card_layout.addWidget(confusion_title)
|
||
|
||
# Horizontal layout for matrix and explanation
|
||
confusion_content = QHBoxLayout()
|
||
confusion_content.setSpacing(20)
|
||
|
||
# Matrix visualization
|
||
self.perf_figure = Figure(figsize=(6, 4), dpi=100)
|
||
self.perf_figure.patch.set_facecolor('white')
|
||
self.perf_canvas = FigureCanvas(self.perf_figure)
|
||
self.perf_canvas.setFixedHeight(320)
|
||
confusion_content.addWidget(self.perf_canvas, 3)
|
||
|
||
# Explanation
|
||
self.perf_confusion_explanation = QLabel()
|
||
self.perf_confusion_explanation.setFont(QFont("Segoe UI", 11))
|
||
self.perf_confusion_explanation.setWordWrap(True)
|
||
self.perf_confusion_explanation.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||
self.perf_confusion_explanation.setStyleSheet("""
|
||
QLabel {
|
||
background: transparent;
|
||
color: #2c3e50;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
line-height: 1.6;
|
||
padding: 10px;
|
||
}
|
||
""")
|
||
confusion_content.addWidget(self.perf_confusion_explanation, 2)
|
||
confusion_card_layout.addLayout(confusion_content)
|
||
|
||
performa_layout.addWidget(confusion_card)
|
||
|
||
# Section 2: ROC Curves
|
||
roc_card = QWidget()
|
||
roc_card.setStyleSheet("""
|
||
QWidget {
|
||
background-color: white;
|
||
border-radius: 12px;
|
||
border: 1px solid #e0e6ed;
|
||
}
|
||
""")
|
||
shadow2 = QGraphicsDropShadowEffect()
|
||
shadow2.setBlurRadius(15)
|
||
shadow2.setXOffset(0)
|
||
shadow2.setYOffset(5)
|
||
shadow2.setColor(QColor(0, 0, 0, 30))
|
||
roc_card.setGraphicsEffect(shadow2)
|
||
|
||
roc_card_layout = QVBoxLayout(roc_card)
|
||
roc_card_layout.setContentsMargins(25, 20, 25, 20)
|
||
roc_card_layout.setSpacing(15)
|
||
|
||
roc_title = QLabel("ROC AUC Curve")
|
||
roc_title.setStyleSheet("""
|
||
font-size: 17px;
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
background: transparent;
|
||
border: none;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
margin-bottom: 5px;
|
||
""")
|
||
roc_card_layout.addWidget(roc_title)
|
||
|
||
# Horizontal layout for ROC and explanation
|
||
roc_content = QHBoxLayout()
|
||
roc_content.setSpacing(20)
|
||
|
||
# ROC visualization
|
||
self.roc_figure = Figure(figsize=(6, 4), dpi=100)
|
||
self.roc_figure.patch.set_facecolor('white')
|
||
self.roc_canvas = FigureCanvas(self.roc_figure)
|
||
self.roc_canvas.setFixedHeight(320)
|
||
roc_content.addWidget(self.roc_canvas, 3)
|
||
|
||
# Explanation
|
||
self.perf_roc_explanation = QLabel()
|
||
self.perf_roc_explanation.setFont(QFont("Segoe UI", 11))
|
||
self.perf_roc_explanation.setWordWrap(True)
|
||
self.perf_roc_explanation.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||
self.perf_roc_explanation.setStyleSheet("""
|
||
QLabel {
|
||
background: transparent;
|
||
color: #2c3e50;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
line-height: 1.6;
|
||
padding: 10px;
|
||
}
|
||
""")
|
||
roc_content.addWidget(self.perf_roc_explanation, 2)
|
||
roc_card_layout.addLayout(roc_content)
|
||
|
||
performa_layout.addWidget(roc_card)
|
||
|
||
# Section 3: Classification Report
|
||
class_card = QWidget()
|
||
class_card.setStyleSheet("""
|
||
QWidget {
|
||
background-color: white;
|
||
border-radius: 12px;
|
||
border: 1px solid #e0e6ed;
|
||
}
|
||
""")
|
||
shadow3 = QGraphicsDropShadowEffect()
|
||
shadow3.setBlurRadius(15)
|
||
shadow3.setXOffset(0)
|
||
shadow3.setYOffset(5)
|
||
shadow3.setColor(QColor(0, 0, 0, 30))
|
||
class_card.setGraphicsEffect(shadow3)
|
||
|
||
class_card_layout = QVBoxLayout(class_card)
|
||
class_card_layout.setContentsMargins(25, 20, 25, 20)
|
||
class_card_layout.setSpacing(15)
|
||
|
||
class_title = QLabel("Classification Report")
|
||
class_title.setStyleSheet("""
|
||
font-size: 17px;
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
background: transparent;
|
||
border: none;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
margin-bottom: 5px;
|
||
""")
|
||
class_card_layout.addWidget(class_title)
|
||
|
||
# Horizontal layout for report and explanation
|
||
class_content = QHBoxLayout()
|
||
class_content.setSpacing(20)
|
||
|
||
# Classification report
|
||
self.perf_report_text = QTextEdit()
|
||
self.perf_report_text.setReadOnly(True)
|
||
self.perf_report_text.setFont(QFont("Consolas", 11))
|
||
self.perf_report_text.setFixedHeight(300)
|
||
self.perf_report_text.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.perf_report_text.setWordWrapMode(QTextOption.NoWrap)
|
||
self.perf_report_text.setFrameShape(QFrame.NoFrame)
|
||
self.perf_report_text.setStyleSheet("""
|
||
QTextEdit {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 0px;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
line-height: 1.4;
|
||
color: #2c3e50;
|
||
}
|
||
""")
|
||
class_content.addWidget(self.perf_report_text, 3)
|
||
|
||
# Explanation
|
||
self.perf_class_explanation = QLabel()
|
||
self.perf_class_explanation.setFont(QFont("Segoe UI", 11))
|
||
self.perf_class_explanation.setWordWrap(True)
|
||
self.perf_class_explanation.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||
self.perf_class_explanation.setStyleSheet("""
|
||
QLabel {
|
||
background: transparent;
|
||
color: #2c3e50;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
line-height: 1.6;
|
||
padding: 10px;
|
||
}
|
||
""")
|
||
class_content.addWidget(self.perf_class_explanation, 2)
|
||
class_card_layout.addLayout(class_content)
|
||
|
||
performa_layout.addWidget(class_card)
|
||
performa_layout.addStretch()
|
||
|
||
# Set content widget to scroll area and add to main layout
|
||
scroll_area.setWidget(content_widget)
|
||
main_performa_layout.addWidget(scroll_area)
|
||
|
||
self.stacked_widget.addWidget(performa_widget)
|
||
|
||
#fungsi untuk update total siswa dan jumlah siswa per paket pada halaman performa
|
||
def refresh_performa_stats(self):
|
||
try:
|
||
# Clear existing cards
|
||
for i in reversed(range(self.stats_layout.count())):
|
||
child = self.stats_layout.itemAt(i).widget()
|
||
if child:
|
||
child.deleteLater()
|
||
|
||
# Get data from database
|
||
data = ambil_semua_hasil_prediksi()
|
||
total_siswa = len(data)
|
||
|
||
# Count students by paket
|
||
paket_counts = {}
|
||
for item in data:
|
||
paket = item.get('paket_prediksi', 0)
|
||
paket_counts[paket] = paket_counts.get(paket, 0) + 1
|
||
|
||
# Define modern colors for each paket
|
||
paket_colors = {
|
||
1: "#667eea", # Purple-blue (primary theme)
|
||
2: "#764ba2", # Purple
|
||
3: "#f093fb", # Pink
|
||
4: "#4facfe", # Light blue
|
||
5: "#43e97b", # Green
|
||
6: "#fa709a" # Rose
|
||
}
|
||
|
||
# Create Total Siswa card
|
||
total_card = self.create_stat_card("Total Siswa", str(total_siswa), "#2c3e50")
|
||
self.stats_layout.addWidget(total_card)
|
||
|
||
# Create cards for each paket (1-6)
|
||
for paket in range(1, 7):
|
||
count = paket_counts.get(paket, 0)
|
||
color = paket_colors.get(paket, "#95a5a6")
|
||
paket_card = self.create_stat_card(f"Paket {paket}", str(count), color)
|
||
self.stats_layout.addWidget(paket_card)
|
||
|
||
except Exception as e:
|
||
print(f"Error refreshing performa stats: {str(e)}")
|
||
# Create default cards if error
|
||
total_card = self.create_stat_card("Total Siswa", "0", "#34495e")
|
||
self.stats_layout.addWidget(total_card)
|
||
|
||
for paket in range(1, 7):
|
||
paket_card = self.create_stat_card(f"Paket {paket}", "0", "#95a5a6")
|
||
self.stats_layout.addWidget(paket_card)
|
||
|
||
#Fungsi halaman performa untuk latih ulang model dan tampilkan metrik performa terbaru
|
||
def generate_performance_display(self):
|
||
try:
|
||
# Always retrain model to get current accurate performance
|
||
# print("🔄 Retraining model for current performance metrics...")
|
||
|
||
# Create fresh classifier instance
|
||
fresh_classifier = JurusanKNNClassifier()
|
||
|
||
# Train with current dataset to get fresh results
|
||
accuracy, _ = fresh_classifier.train(
|
||
config.DATASET_PATH,
|
||
test_data_path=getattr(config, 'TEST_DATASET_PATH', None)
|
||
)
|
||
|
||
# Update main classifier with fresh results
|
||
self.classifier = fresh_classifier
|
||
|
||
# print(f"✅ Fresh model accuracy: {accuracy:.4f} ({accuracy:.2%})")
|
||
|
||
# Use fresh evaluation results
|
||
if (hasattr(self.classifier, 'confusion_matrix') and
|
||
hasattr(self.classifier, 'y_test') and
|
||
hasattr(self.classifier, 'y_pred')):
|
||
|
||
cm = self.classifier.confusion_matrix
|
||
y_test = self.classifier.y_test
|
||
y_pred = self.classifier.y_pred
|
||
|
||
# Generate confusion matrix plot
|
||
self.plot_confusion_matrix_perf(cm, sorted(set(y_test)))
|
||
self.display_confusion_matrix_explanation_perf()
|
||
|
||
# Generate ROC AUC curve plot
|
||
if hasattr(self.classifier, 'y_pred_proba'):
|
||
self.plot_roc_curve_perf(y_test, self.classifier.y_pred_proba)
|
||
else:
|
||
# Fallback: use predicted classes (less accurate for ROC)
|
||
self.plot_roc_curve_perf(y_test, y_pred)
|
||
self.display_roc_explanation_perf()
|
||
|
||
# Generate classification report with fresh data
|
||
from sklearn.metrics import classification_report
|
||
report = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
|
||
self.display_classification_report_perf(report)
|
||
|
||
# Update status bar with current accuracy
|
||
self.statusBar().showMessage(f'Performance updated - Current Accuracy: {accuracy:.2%}')
|
||
|
||
# print(f"✅ Performance display updated successfully with accuracy: {accuracy:.2%}")
|
||
|
||
else:
|
||
self.show_error_perf("Fresh training data not available")
|
||
|
||
except Exception as e:
|
||
# print(f"Error in generate_performance_display: {str(e)}")
|
||
self.show_error_perf(f"Error generating performance report: {str(e)}")
|
||
|
||
# Fungsi untuk plot confusion matrix pada halaman performa
|
||
def plot_confusion_matrix_perf(self, cm, classes):
|
||
# Clear previous plot
|
||
self.perf_figure.clear()
|
||
|
||
ax = self.perf_figure.add_subplot(111)
|
||
im = ax.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues, aspect='auto')
|
||
|
||
# Add colorbar
|
||
cbar = self.perf_figure.colorbar(im, ax=ax, shrink=0.8)
|
||
cbar.ax.tick_params(labelsize=10)
|
||
|
||
# Set labels
|
||
ax.set_xticks(np.arange(cm.shape[1]))
|
||
ax.set_yticks(np.arange(cm.shape[0]))
|
||
ax.set_xticklabels(classes, fontsize=11)
|
||
ax.set_yticklabels(classes, fontsize=11)
|
||
|
||
# Set axis labels
|
||
ax.set_ylabel('True Labels', fontsize=12, fontweight='bold')
|
||
ax.set_xlabel('Predicted Labels', fontsize=12, fontweight='bold')
|
||
|
||
# Rotate the tick labels
|
||
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
|
||
|
||
# Add text annotations
|
||
thresh = cm.max() / 2.
|
||
for i in range(cm.shape[0]):
|
||
for j in range(cm.shape[1]):
|
||
text_color = "white" if cm[i, j] > thresh else "black"
|
||
ax.text(j, i, format(cm[i, j], 'd'),
|
||
ha="center", va="center",
|
||
color=text_color,
|
||
fontweight='bold',
|
||
fontsize=11)
|
||
|
||
# Set title with accuracy information - REMOVED to avoid text cutoff
|
||
# accuracy = self.classifier.training_accuracy if hasattr(self.classifier, 'training_accuracy') else 0
|
||
# ax.set_title(f"Confusion Matrix\nAccuracy: {accuracy:.2%}", fontweight='bold', fontsize=14, pad=20)
|
||
|
||
# Adjust layout with better margins
|
||
self.perf_figure.subplots_adjust(left=0.12, bottom=0.25, right=0.9, top=0.95)
|
||
self.perf_canvas.draw()
|
||
|
||
# Fungsi untuk plot ROC curve pada halaman performa
|
||
def display_classification_report_perf(self, report_dict):
|
||
# Get accuracy for header
|
||
accuracy = report_dict['accuracy']
|
||
|
||
# Create header with prominent accuracy display
|
||
accuracy_header = f"🎯 SYSTEM ACCURACY: {accuracy:.4f} ({accuracy:.2%})\n"
|
||
accuracy_header += f"{'='*65}\n\n"
|
||
|
||
# Create formatted text report
|
||
header = f"{'':>12} {'precision':>10} {'recall':>10} {'f1-score':>10} {'support':>8}\n"
|
||
separator = "=" * 65 + "\n"
|
||
|
||
report_text = accuracy_header + header + separator
|
||
|
||
# Add metrics for each class
|
||
classes = [key for key in report_dict.keys() if key not in ['accuracy', 'macro avg', 'weighted avg']]
|
||
|
||
# Sort classes numerically
|
||
try:
|
||
sorted_classes = sorted(classes, key=lambda x: int(x))
|
||
except (ValueError, TypeError):
|
||
sorted_classes = sorted(classes)
|
||
|
||
for class_name in sorted_classes:
|
||
metrics = report_dict[class_name]
|
||
class_label = f"Paket {class_name}" if str(class_name).isdigit() else str(class_name)
|
||
report_text += f"{class_label:>12} {metrics['precision']:>10.2f} {metrics['recall']:>10.2f} {metrics['f1-score']:>10.2f} {int(metrics['support']):>8}\n"
|
||
|
||
# Add separator line
|
||
report_text += "\n" + "-" * 65 + "\n"
|
||
|
||
# Add accuracy
|
||
total_support = sum(report_dict[key]['support'] for key in classes)
|
||
report_text += f"{'accuracy':>12} {'':>10} {'':>10} {accuracy:>10.2f} {int(total_support):>8}\n"
|
||
|
||
# Add macro avg
|
||
macro_avg = report_dict['macro avg']
|
||
report_text += f"{'macro avg':>12} {macro_avg['precision']:>10.2f} {macro_avg['recall']:>10.2f} {macro_avg['f1-score']:>10.2f} {int(macro_avg['support']):>8}\n"
|
||
|
||
# Add weighted avg
|
||
weighted_avg = report_dict['weighted avg']
|
||
report_text += f"{'weighted avg':>12} {weighted_avg['precision']:>10.2f} {weighted_avg['recall']:>10.2f} {weighted_avg['f1-score']:>10.2f} {int(weighted_avg['support']):>8}\n"
|
||
|
||
# Add footer note
|
||
report_text += f"\n{'='*65}\n"
|
||
report_text += f"📊 Test samples: {int(total_support)} | Train source: {config.DATASET_PATH}\n"
|
||
report_text += (
|
||
f"🔧 Algorithm: K-NN (k={self.classifier.knn.n_neighbors}, "
|
||
f"weights={self.classifier.knn.weights}, metric={self.classifier.knn.metric}) | "
|
||
f"CV: {config.CROSS_VALIDATION_FOLDS}-fold\n"
|
||
)
|
||
|
||
self.perf_report_text.setPlainText(report_text)
|
||
|
||
# Also display explanation
|
||
self.display_classification_explanation_perf()
|
||
|
||
#penjelasan grafik
|
||
def display_confusion_matrix_explanation_perf(self):
|
||
cm = getattr(self.classifier, 'confusion_matrix', None)
|
||
if cm is not None and cm.sum() > 0:
|
||
total_prediksi_benar = int(np.trace(cm))
|
||
total_data = int(cm.sum())
|
||
summary = f"Total prediksi benar yang berada pada diagonal adalah {total_prediksi_benar} dari {total_data} data."
|
||
else:
|
||
summary = "Data confusion matrix belum tersedia."
|
||
|
||
explanation_text = (
|
||
"Grafik ini menggambarkan sebaran prediksi untuk setiap kelas.\n"
|
||
"Nilai pada diagonal menunjukkan prediksi yang tepat, sedangkan perbedaan intensitas warna biru menunjukkan banyaknya jumlah data pada setiap sel.\n\n"
|
||
f"{summary}"
|
||
)
|
||
|
||
self.perf_confusion_explanation.setText(explanation_text)
|
||
|
||
def display_roc_explanation_perf(self):
|
||
"""Display explanation for ROC curves in performa tab"""
|
||
auc_values = getattr(self, 'perf_auc_values', [])
|
||
if auc_values:
|
||
avg_auc = float(np.mean(auc_values))
|
||
min_auc = float(np.min(auc_values))
|
||
summary = f"Nilai AUC rata-rata sebesar {avg_auc:.3f} dan nilai minimum {min_auc:.3f}, yang menunjukkan masih ada kelas yang relatif lebih sulit dibedakan."
|
||
else:
|
||
summary = "Nilai AUC belum tersedia."
|
||
|
||
explanation_text = """
|
||
Grafik ini menunjukkan kemampuan model dalam membedakan setiap paket.
|
||
Semakin mendekati 1, nilai AUC menandakan kemampuan pemisahan kelas yang semakin baik.
|
||
|
||
{summary}
|
||
"""
|
||
|
||
self.perf_roc_explanation.setText(
|
||
explanation_text.strip().format(summary=summary)
|
||
)
|
||
|
||
def display_classification_explanation_perf(self):
|
||
acc = getattr(self.classifier, 'training_accuracy', None)
|
||
if acc is None:
|
||
result = "Akurasi belum tersedia."
|
||
else:
|
||
level = "Sangat Baik" if acc >= 0.85 else "Baik" if acc >= 0.75 else "Cukup" if acc >= 0.65 else "Perlu perbaikan"
|
||
result = f"Akurasi model mencapai {acc:.2%}, sehingga performanya tergolong {level.lower()}."
|
||
|
||
explanation_text = """
|
||
Grafik ini merangkum kualitas prediksi untuk setiap paket.
|
||
Precision menunjukkan ketepatan prediksi, recall menunjukkan kemampuan menemukan data yang benar, dan F1-score menggambarkan keseimbangan keduanya.
|
||
|
||
{result}
|
||
"""
|
||
|
||
self.perf_class_explanation.setText(explanation_text.strip().format(result=result))
|
||
|
||
def plot_roc_curve_perf(self, y_test, y_pred_proba):
|
||
"""Plot ROC AUC curve for performa tab"""
|
||
try:
|
||
# Clear previous plot
|
||
self.roc_figure.clear()
|
||
self.perf_auc_values = []
|
||
|
||
# Get unique classes from classifier
|
||
classes = self.classifier.knn.classes_
|
||
n_classes = len(classes)
|
||
|
||
# Binarize the true labels
|
||
y_test_bin = label_binarize(y_test, classes=classes)
|
||
|
||
# Handle single class case
|
||
if n_classes == 2:
|
||
# For binary classification, use probability of positive class
|
||
y_score = y_pred_proba[:, 1]
|
||
y_true = (y_test == classes[1]).astype(int)
|
||
|
||
# Compute ROC curve and ROC area
|
||
fpr, tpr, _ = roc_curve(y_true, y_score)
|
||
roc_auc = auc(fpr, tpr)
|
||
self.perf_auc_values = [float(roc_auc)]
|
||
|
||
# Plot ROC curve
|
||
ax = self.roc_figure.add_subplot(111)
|
||
ax.plot(fpr, tpr, color='darkorange', lw=2,
|
||
label=f'ROC curve (AUC = {roc_auc:.3f})')
|
||
ax.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--',
|
||
label='Random Classifier')
|
||
ax.set_xlim([0.0, 1.0])
|
||
ax.set_ylim([0.0, 1.05])
|
||
ax.set_xlabel('False Positive Rate', fontsize=10)
|
||
ax.set_ylabel('True Positive Rate', fontsize=10)
|
||
ax.set_title('ROC Curve (Binary)', fontweight='bold', fontsize=11)
|
||
ax.legend(loc="lower right", fontsize=9)
|
||
ax.grid(True, alpha=0.3)
|
||
|
||
else:
|
||
# Multi-class ROC
|
||
ax = self.roc_figure.add_subplot(111)
|
||
|
||
# Compute ROC curve and ROC area for each class
|
||
fpr = dict()
|
||
tpr = dict()
|
||
roc_auc = dict()
|
||
|
||
for i in range(n_classes):
|
||
# Get true binary labels for this class
|
||
y_true_class = (y_test == classes[i]).astype(int)
|
||
# Get predicted probabilities for this class
|
||
y_score_class = y_pred_proba[:, i]
|
||
|
||
fpr[i], tpr[i], _ = roc_curve(y_true_class, y_score_class)
|
||
roc_auc[i] = auc(fpr[i], tpr[i])
|
||
|
||
self.perf_auc_values = [float(roc_auc[i]) for i in range(n_classes)]
|
||
|
||
# Plot ROC curve for each class
|
||
colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown']
|
||
for i in range(n_classes):
|
||
color = colors[i % len(colors)]
|
||
ax.plot(fpr[i], tpr[i], color=color, lw=2,
|
||
label=f'Paket {classes[i]} (AUC = {roc_auc[i]:.3f})')
|
||
|
||
# Plot random classifier line
|
||
ax.plot([0, 1], [0, 1], 'k--', lw=2, label='Random Classifier', alpha=0.8)
|
||
|
||
# Compute macro-average ROC curve and ROC area
|
||
try:
|
||
all_fpr = np.unique(np.concatenate([
|
||
fpr[i] for i in range(n_classes)
|
||
]))
|
||
mean_tpr = np.zeros_like(all_fpr)
|
||
|
||
for i in range(n_classes):
|
||
mean_tpr += np.interp(all_fpr, fpr[i], tpr[i])
|
||
|
||
mean_tpr /= n_classes
|
||
roc_auc_macro = float(np.mean([roc_auc[i] for i in range(n_classes)]))
|
||
|
||
ax.plot(all_fpr, mean_tpr,
|
||
label=f'Macro-avg (AUC = {roc_auc_macro:.3f})',
|
||
color='gold', linestyle=':', linewidth=3)
|
||
except:
|
||
pass # Skip macro-average if calculation fails
|
||
|
||
ax.set_xlim([0.0, 1.0])
|
||
ax.set_ylim([0.0, 1.05])
|
||
ax.set_xlabel('False Positive Rate', fontsize=10)
|
||
ax.set_ylabel('True Positive Rate', fontsize=10)
|
||
ax.set_title('Multi-class ROC Curves', fontweight='bold', fontsize=11)
|
||
|
||
# Position legend to avoid overlap
|
||
ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), fontsize=8)
|
||
ax.grid(True, alpha=0.3)
|
||
|
||
# Adjust layout
|
||
self.roc_figure.subplots_adjust(left=0.15, bottom=0.15, right=0.75, top=0.9)
|
||
self.roc_canvas.draw()
|
||
|
||
except Exception as e:
|
||
print(f"Error plotting ROC curve: {e}")
|
||
# Show error in ROC canvas
|
||
ax = self.roc_figure.add_subplot(111)
|
||
ax.text(0.5, 0.5, f'Error plotting ROC curve:\\n{str(e)}',
|
||
ha='center', va='center', transform=ax.transAxes,
|
||
fontsize=10, color='red')
|
||
ax.set_title("ROC Plot Error", fontweight='bold')
|
||
self.roc_canvas.draw()
|
||
|
||
#ketika terjadi error pada halaman performa, tampilkan pesan error di semua area yang relevan
|
||
def show_error_perf(self, message):
|
||
# Show error in confusion matrix
|
||
ax1 = self.perf_figure.add_subplot(111)
|
||
ax1.text(0.5, 0.5, message,
|
||
ha='center', va='center', transform=ax1.transAxes,
|
||
fontsize=12, color='red')
|
||
ax1.set_title("Error", fontweight='bold')
|
||
self.perf_canvas.draw()
|
||
|
||
# Show error in ROC curve
|
||
ax2 = self.roc_figure.add_subplot(111)
|
||
ax2.text(0.5, 0.5, message,
|
||
ha='center', va='center', transform=ax2.transAxes,
|
||
fontsize=12, color='red')
|
||
ax2.set_title("Error", fontweight='bold')
|
||
self.roc_canvas.draw()
|
||
|
||
# Show error in all text areas
|
||
self.perf_report_text.setPlainText(f"Error: {message}")
|
||
self.perf_confusion_explanation.setText(f"Error: {message}")
|
||
self.perf_roc_explanation.setText(f"Error: {message}")
|
||
self.perf_class_explanation.setText(f"Error: {message}")
|
||
|
||
def create_stat_card(self, title, value, color):
|
||
"""Create a modern statistics card"""
|
||
card = QWidget()
|
||
card.setFixedHeight(110)
|
||
card.setStyleSheet(f"""
|
||
QWidget {{
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 {color}, stop:1 {self.darken_color(color)});
|
||
border-radius: 10px;
|
||
}}
|
||
""")
|
||
|
||
# Add shadow effect
|
||
shadow = QGraphicsDropShadowEffect()
|
||
shadow.setBlurRadius(12)
|
||
shadow.setXOffset(0)
|
||
shadow.setYOffset(4)
|
||
shadow.setColor(QColor(0, 0, 0, 25))
|
||
card.setGraphicsEffect(shadow)
|
||
|
||
layout = QVBoxLayout(card)
|
||
layout.setAlignment(Qt.AlignCenter)
|
||
layout.setContentsMargins(15, 15, 15, 15)
|
||
|
||
value_label = QLabel(value)
|
||
value_label.setStyleSheet("""
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-size: 40px;
|
||
font-weight: bold;
|
||
color: white;
|
||
background: transparent;
|
||
""")
|
||
value_label.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(value_label)
|
||
|
||
title_label = QLabel(title)
|
||
title_label.setStyleSheet("""
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-size: 18px;
|
||
color: rgba(255, 255, 255, 0.95);
|
||
background: transparent;
|
||
margin-top: -5px;
|
||
""")
|
||
title_label.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(title_label)
|
||
|
||
return card
|
||
|
||
def darken_color(self, hex_color):
|
||
"""Darken a hex color by 15% for gradient effect"""
|
||
# Remove # if present
|
||
hex_color = hex_color.lstrip('#')
|
||
# Convert to RGB
|
||
r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||
# Darken by 15%
|
||
r = max(0, int(r * 0.85))
|
||
g = max(0, int(g * 0.85))
|
||
b = max(0, int(b * 0.85))
|
||
# Convert back to hex
|
||
return f'#{r:02x}{g:02x}{b:02x}'
|
||
|
||
# Update existing data
|
||
self.btn_update = QPushButton("✏️ Update")
|
||
self.btn_update.setStyleSheet("""
|
||
background-color: #2196F3;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
""")
|
||
self.btn_update.clicked.connect(self.update_data)
|
||
self.btn_update.setToolTip("Mengupdate data yang dipilih dengan prediksi baru")
|
||
btn_row1_layout.addWidget(self.btn_update)
|
||
|
||
# Clear form
|
||
self.btn_clear = QPushButton("🗑️ Clear Form")
|
||
self.btn_clear.setStyleSheet("""
|
||
background-color: #607D8B;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
""")
|
||
self.btn_clear.clicked.connect(self.clear_form)
|
||
btn_row1_layout.addWidget(self.btn_clear)
|
||
|
||
btn_container.addLayout(btn_row1_layout)
|
||
|
||
# Add a small separator line between button rows
|
||
separator = QFrame()
|
||
separator.setFrameShape(QFrame.HLine)
|
||
separator.setFrameShadow(QFrame.Sunken)
|
||
separator.setStyleSheet("color: #cccccc; margin: 5px 0px;")
|
||
btn_container.addWidget(separator)
|
||
|
||
# Label for secondary actions
|
||
secondary_label = QLabel("🔧 Manajemen Tabel & Analisis:")
|
||
secondary_label.setStyleSheet("font-weight: bold; color: #1565C0; margin: 2px 0px 2px 0px; font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif;")
|
||
btn_container.addWidget(secondary_label)
|
||
|
||
# Row 2: Table Management & Analysis Actions (Secondary Actions)
|
||
btn_row2_layout = QHBoxLayout()
|
||
|
||
# Search data
|
||
self.btn_search = QPushButton("🔍 Search")
|
||
self.btn_search.setStyleSheet("""
|
||
background-color: #9C27B0;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
""")
|
||
self.btn_search.clicked.connect(self.search_data)
|
||
btn_row2_layout.addWidget(self.btn_search)
|
||
|
||
# Delete selected data
|
||
self.btn_delete_new = QPushButton("🗂️ Delete")
|
||
self.btn_delete_new.setStyleSheet("""
|
||
background-color: #FF9800;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
""")
|
||
self.btn_delete_new.clicked.connect(self.delete_data)
|
||
btn_row2_layout.addWidget(self.btn_delete_new)
|
||
|
||
# Refresh table
|
||
self.btn_refresh = QPushButton("🔄 Refresh Table")
|
||
self.btn_refresh.setStyleSheet("""
|
||
background-color: #00BCD4;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
""")
|
||
self.btn_refresh.clicked.connect(self.refresh_table)
|
||
btn_row2_layout.addWidget(self.btn_refresh)
|
||
|
||
# Show performance report
|
||
self.btn_performance = QPushButton("📊 Performance")
|
||
self.btn_performance.setStyleSheet("""
|
||
background-color: #FF5722;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
""")
|
||
self.btn_performance.clicked.connect(self.show_performance)
|
||
btn_row2_layout.addWidget(self.btn_performance)
|
||
|
||
# Export data to Excel
|
||
self.btn_export = QPushButton("📁 Export")
|
||
self.btn_export.setStyleSheet("""
|
||
background-color: #4CAF50;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
""")
|
||
self.btn_export.clicked.connect(self.export_to_excel)
|
||
btn_row2_layout.addWidget(self.btn_export)
|
||
|
||
btn_container.addLayout(btn_row2_layout)
|
||
layout.addLayout(btn_container)
|
||
|
||
# Table Section
|
||
self.table = QTableWidget()
|
||
self.setup_table()
|
||
layout.addWidget(self.table)
|
||
|
||
# Pagination Section
|
||
pagination_layout = QHBoxLayout()
|
||
pagination_layout.setAlignment(Qt.AlignCenter)
|
||
|
||
# Previous button
|
||
self.btn_prev = QPushButton("◀ Previous")
|
||
self.btn_prev.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #6c757d;
|
||
color: white;
|
||
padding: 8px 18px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #5a6268;
|
||
}
|
||
QPushButton:disabled {
|
||
background-color: #cccccc;
|
||
color: #666666;
|
||
}
|
||
""")
|
||
self.btn_prev.clicked.connect(self.previous_page)
|
||
pagination_layout.addWidget(self.btn_prev)
|
||
|
||
# Page info label
|
||
self.page_info_label = QLabel("Halaman 1 dari 1")
|
||
self.page_info_label.setStyleSheet("""
|
||
QLabel {
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
padding: 8px 16px;
|
||
color: #495057;
|
||
}
|
||
""")
|
||
pagination_layout.addWidget(self.page_info_label)
|
||
|
||
# Next button
|
||
self.btn_next = QPushButton("Next ▶")
|
||
self.btn_next.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #6c757d;
|
||
color: white;
|
||
padding: 8px 16px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-weight: bold;
|
||
border-radius: 5px;
|
||
margin: 2px;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #5a6268;
|
||
}
|
||
QPushButton:disabled {
|
||
background-color: #cccccc;
|
||
color: #666666;
|
||
}
|
||
""")
|
||
self.btn_next.clicked.connect(self.next_page)
|
||
pagination_layout.addWidget(self.btn_next)
|
||
|
||
# Items per page info
|
||
items_info_label = QLabel(f"Menampilkan {self.items_per_page} data per halaman")
|
||
items_info_label.setStyleSheet("""
|
||
QLabel {
|
||
font-style: italic;
|
||
color: #6c757d;
|
||
padding: 8px 16px;
|
||
}
|
||
""")
|
||
pagination_layout.addWidget(items_info_label)
|
||
|
||
layout.addLayout(pagination_layout)
|
||
|
||
# Status bar
|
||
self.statusBar().showMessage('Ready')
|
||
|
||
central_widget.setLayout(layout)
|
||
|
||
# Load trained model if exists
|
||
try:
|
||
self.classifier.load_model(config.MODEL_PATH)
|
||
except:
|
||
# Train model with dummy data if no model exists
|
||
self.train_model_background()
|
||
|
||
#struktur tabel data siswa
|
||
def setup_table(self):
|
||
columns = ['No', 'Nama Siswa', 'Informatika', 'Fisika', 'Kimia', 'Biologi',
|
||
'BIG Lanjut', 'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi',
|
||
'Geografi', 'Paket Prediksi', 'ID']
|
||
|
||
self.table.setColumnCount(len(columns))
|
||
self.table.setHorizontalHeaderLabels(columns)
|
||
|
||
# Make sure horizontal header is visible
|
||
self.table.horizontalHeader().setVisible(True)
|
||
|
||
# Set header properties
|
||
header = self.table.horizontalHeader()
|
||
header.setDefaultSectionSize(100)
|
||
header.setMinimumSectionSize(50)
|
||
header.setSectionResizeMode(QHeaderView.Interactive)
|
||
header.setStretchLastSection(True)
|
||
header.setDefaultAlignment(Qt.AlignCenter)
|
||
|
||
# Set header height
|
||
header.setMinimumHeight(55)
|
||
header.setMaximumHeight(55)
|
||
|
||
# Hide the ID column
|
||
self.table.setColumnHidden(13, True)
|
||
|
||
# Hide vertical header (row numbers) since we have our own No column
|
||
self.table.verticalHeader().setVisible(False)
|
||
|
||
# Set column widths - lebih lebar untuk menampung text header
|
||
self.table.setColumnWidth(0, 60) # No
|
||
self.table.setColumnWidth(1, 250) # Nama Siswa - diperlebar
|
||
self.table.setColumnWidth(2, 130) # Informatika
|
||
self.table.setColumnWidth(3, 90) # Fisika
|
||
self.table.setColumnWidth(4, 90) # Kimia
|
||
self.table.setColumnWidth(5, 90) # Biologi
|
||
self.table.setColumnWidth(6, 120) # BIG Lanjut - diperlebar lagi
|
||
self.table.setColumnWidth(7, 100) # Ekonomi
|
||
self.table.setColumnWidth(8, 120) # MAT Lanjut - diperlebar lagi
|
||
self.table.setColumnWidth(9, 120) # SEJ Lanjut - diperlebar lagi
|
||
self.table.setColumnWidth(10, 100) # Sosiologi
|
||
self.table.setColumnWidth(11, 100) # Geografi
|
||
self.table.setColumnWidth(12, 150) # Paket Prediksi
|
||
|
||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||
self.table.setFocusPolicy(Qt.NoFocus)
|
||
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
|
||
self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
|
||
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
self.table.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
|
||
# Connect signals
|
||
self.table.cellClicked.connect(self.on_table_cell_clicked)
|
||
self.table.itemSelectionChanged.connect(self.on_table_selection_changed)
|
||
|
||
# Table styling
|
||
self.table.setShowGrid(True)
|
||
self.table.setGridStyle(Qt.SolidLine)
|
||
self.table.setAlternatingRowColors(True)
|
||
|
||
# Keep row height compact so 10 rows can fit in the available viewport.
|
||
row_height = 36
|
||
self.table.verticalHeader().setDefaultSectionSize(row_height)
|
||
|
||
# Set stylesheet for table with modern clean styling
|
||
self.table.setStyleSheet("""
|
||
QTableWidget {
|
||
gridline-color: #e0e6ed;
|
||
background-color: white;
|
||
alternate-background-color: #f8fafc;
|
||
selection-background-color: #667eea;
|
||
selection-color: white;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-size: 18px;
|
||
border: 1px solid #e0e6ed;
|
||
border-radius: 8px;
|
||
}
|
||
QTableWidget::item {
|
||
padding: 14px 8px;
|
||
}
|
||
QTableWidget::item:selected {
|
||
background-color: #667eea;
|
||
color: white;
|
||
}
|
||
QTableWidget::item:hover {
|
||
background-color: #f1f5f9;
|
||
}
|
||
QTableWidget::item:focus {
|
||
outline: none;
|
||
border: none;
|
||
}
|
||
QHeaderView::section {
|
||
background-color: #f8fafc;
|
||
border: 0px;
|
||
border-right: 1px solid #e0e6ed;
|
||
border-bottom: 1px solid #e0e6ed;
|
||
padding: 14px 8px;
|
||
font-weight: bold;
|
||
font-size: 18px;
|
||
color: #2c3e50;
|
||
text-align: center;
|
||
}
|
||
QHeaderView::section:hover {
|
||
background-color: #e8ecf1;
|
||
}
|
||
""")
|
||
|
||
# Keep table responsive on smaller screens while preserving enough rows.
|
||
header_height = 55
|
||
table_min_height = header_height + (4 * row_height) + 8
|
||
self.table.setMinimumHeight(table_min_height)
|
||
self.table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||
|
||
def format_nilai(self, nilai):
|
||
"""
|
||
Format nilai untuk tampilan di tabel.
|
||
Menghilangkan trailing zeros, tetapi tetap mempertahankan dua digit
|
||
jika memang ada nilai desimal.
|
||
Contoh: 94.50 -> 94.50 ; 77.00 -> 77 ; 80.0 -> 80
|
||
"""
|
||
try:
|
||
# Convert to float first
|
||
if isinstance(nilai, str):
|
||
nilai = float(nilai.replace(',', '.'))
|
||
else:
|
||
nilai = float(nilai)
|
||
|
||
# Format: jika bulat tampilkan tanpa desimal, jika desimal hapus trailing zero
|
||
if nilai == int(nilai):
|
||
return str(int(nilai))
|
||
else:
|
||
# Format with two decimals then strip trailing zero if present
|
||
formatted = f"{nilai:.2f}".rstrip('0').rstrip('.')
|
||
return formatted
|
||
except (ValueError, TypeError):
|
||
return str(nilai)
|
||
|
||
#untuk load data ke tabel
|
||
def load_table_data(self):
|
||
try:
|
||
# Disconnect signals to prevent conflicts
|
||
self.table.itemSelectionChanged.disconnect()
|
||
|
||
# Set flag to prevent selection change handling during load
|
||
self.loading_data = True
|
||
|
||
# Load all data from database
|
||
self.all_data = ambil_semua_hasil_prediksi()
|
||
self.total_items = len(self.all_data)
|
||
|
||
# print(f"Loading {self.total_items} total rows of data")
|
||
|
||
# Display current page
|
||
self.display_current_page()
|
||
|
||
except Exception as e:
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal memuat data:\n{str(e)}', 'OK')
|
||
# Debug: print(f"Error loading table data: {str(e)}")
|
||
finally:
|
||
# Always reset the flag and reconnect signals
|
||
self.loading_data = False
|
||
# Reconnect the signal
|
||
self.table.itemSelectionChanged.connect(self.on_table_selection_changed)
|
||
|
||
def show_table_empty_message(self, message):
|
||
"""Show a centered message inside table when there is no data to display."""
|
||
self.table.clearContents()
|
||
self.table.clearSpans()
|
||
self.table.setSelectionMode(QAbstractItemView.NoSelection)
|
||
self.table.setRowCount(1)
|
||
|
||
# Keep the hidden ID column (index 13) excluded from span.
|
||
visible_columns = max(1, self.table.columnCount() - 1)
|
||
self.table.setSpan(0, 0, 1, visible_columns)
|
||
|
||
message_item = QTableWidgetItem(message)
|
||
message_item.setTextAlignment(Qt.AlignCenter)
|
||
message_item.setFlags(Qt.ItemIsEnabled)
|
||
|
||
font = message_item.font()
|
||
font.setPointSize(12)
|
||
font.setItalic(True)
|
||
message_item.setFont(font)
|
||
message_item.setForeground(QBrush(QColor("#64748b")))
|
||
|
||
self.table.setItem(0, 0, message_item)
|
||
self.table.setRowHeight(0, 55)
|
||
|
||
#menampilkan data sesuai halaman yang dipilih
|
||
def display_current_page(self):
|
||
try:
|
||
total_pages = max(1, (self.total_items + self.items_per_page - 1) // self.items_per_page)
|
||
if self.current_page > total_pages:
|
||
self.current_page = total_pages
|
||
|
||
# Calculate pagination
|
||
start_index = (self.current_page - 1) * self.items_per_page
|
||
end_index = start_index + self.items_per_page
|
||
current_page_data = self.all_data[start_index:end_index]
|
||
|
||
if not current_page_data:
|
||
search_text = self.search_input.text().strip() if hasattr(self, 'search_input') else ""
|
||
empty_message = "Data tidak ditemukan" if search_text else "Belum ada data siswa"
|
||
self.show_table_empty_message(empty_message)
|
||
self.update_pagination_controls()
|
||
|
||
self.current_selected_id = None
|
||
self.table.clearSelection()
|
||
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
if hasattr(self, 'btn_update'):
|
||
self.btn_update.setEnabled(False)
|
||
self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate")
|
||
if hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(True)
|
||
self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru")
|
||
return
|
||
|
||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||
self.table.clearContents()
|
||
self.table.clearSpans()
|
||
|
||
# Set table row count
|
||
self.table.setRowCount(len(current_page_data))
|
||
|
||
# Populate table with current page data
|
||
for row, item in enumerate(current_page_data):
|
||
# Calculate correct row number (continuous across pages)
|
||
actual_row_number = start_index + row + 1
|
||
|
||
# Create table items with proper alignment
|
||
no_item = QTableWidgetItem(str(actual_row_number))
|
||
no_item.setTextAlignment(Qt.AlignCenter)
|
||
self.table.setItem(row, 0, no_item)
|
||
|
||
nama_item = QTableWidgetItem(str(item.get('nama_siswa', '')))
|
||
nama_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
self.table.setItem(row, 1, nama_item)
|
||
|
||
# Mata pelajaran scores
|
||
for col, field in enumerate(['nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi',
|
||
'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut',
|
||
'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi'], start=2):
|
||
# Format nilai tanpa trailing zeros
|
||
nilai_raw = item.get(field, 0)
|
||
nilai_formatted = self.format_nilai(nilai_raw)
|
||
score_item = QTableWidgetItem(nilai_formatted)
|
||
score_item.setTextAlignment(Qt.AlignCenter)
|
||
self.table.setItem(row, col, score_item)
|
||
|
||
# Paket Prediksi
|
||
paket_item = QTableWidgetItem(str(item.get('paket_prediksi', '')))
|
||
paket_item.setTextAlignment(Qt.AlignCenter)
|
||
self.table.setItem(row, 12, paket_item)
|
||
|
||
# Hidden ID column
|
||
id_item = QTableWidgetItem(str(item.get('id', '')))
|
||
self.table.setItem(row, 13, id_item)
|
||
|
||
# Set row height consistently with default section size.
|
||
self.table.setRowHeight(row, 36)
|
||
|
||
# Update pagination controls
|
||
self.update_pagination_controls()
|
||
|
||
# Clear selection
|
||
self.current_selected_id = None
|
||
self.table.clearSelection()
|
||
|
||
# Disable edit and preview buttons when no selection
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
|
||
# print(f"Displayed page {self.current_page} with {len(current_page_data)} items")
|
||
|
||
except Exception as e:
|
||
# print(f"Error displaying current page: {str(e)}")
|
||
pass
|
||
|
||
#untuk update tombol pagination dan label halaman
|
||
def update_pagination_controls(self):
|
||
total_pages = max(1, (self.total_items + self.items_per_page - 1) // self.items_per_page)
|
||
|
||
# Update page info label
|
||
self.page_label.setText(f"Halaman {self.current_page} dari {total_pages}")
|
||
|
||
# Update button states
|
||
self.btn_prev.setEnabled(self.current_page > 1)
|
||
self.btn_next.setEnabled(self.current_page < total_pages)
|
||
|
||
if self.total_items == 0:
|
||
self.statusBar().showMessage('Tidak ada data untuk ditampilkan')
|
||
return
|
||
|
||
# Update status bar
|
||
start_item = (self.current_page - 1) * self.items_per_page + 1
|
||
end_item = min(self.current_page * self.items_per_page, self.total_items)
|
||
self.statusBar().showMessage(f'Menampilkan {start_item}-{end_item} dari {self.total_items} data')
|
||
|
||
#button pagination previous dan next
|
||
def previous_page(self):
|
||
if self.current_page > 1:
|
||
self.current_page -= 1
|
||
self.display_current_page()
|
||
|
||
def next_page(self):
|
||
total_pages = max(1, (self.total_items + self.items_per_page - 1) // self.items_per_page)
|
||
if self.current_page < total_pages:
|
||
self.current_page += 1
|
||
self.display_current_page()
|
||
|
||
def debug_table_contents(self, context=""):
|
||
"""Debug function - disabled for production"""
|
||
# Uncomment below for debugging
|
||
pass
|
||
# print(f"=== DEBUG TABLE CONTENTS ({context}) ===")
|
||
# print(f"Table row count: {self.table.rowCount()}")
|
||
# print(f"Table column count: {self.table.columnCount()}")
|
||
# for row in range(min(3, self.table.rowCount())):
|
||
# row_data = []
|
||
# for col in range(self.table.columnCount()):
|
||
# item = self.table.item(row, col)
|
||
# if item:
|
||
# row_data.append(f"Col{col}:'{item.text()}'")
|
||
# else:
|
||
# row_data.append(f"Col{col}:None")
|
||
# print(f"Row {row}: {', '.join(row_data)}")
|
||
# print("=== END DEBUG ===")
|
||
|
||
#untuk menangani perubahan seleksi pada tabel
|
||
def on_table_selection_changed(self):
|
||
# Prevent recursion when loading data
|
||
if self.loading_data:
|
||
# print("Loading data in progress, skipping selection change")
|
||
return
|
||
|
||
# Debug table contents before processing
|
||
self.debug_table_contents("before selection change")
|
||
|
||
current_row = self.table.currentRow()
|
||
# print(f"Selection changed - Current row: {current_row}")
|
||
# print(f"Table row count: {self.table.rowCount()}")
|
||
|
||
# Check if table has valid data
|
||
if self.table.rowCount() == 0:
|
||
# print("Table is empty, clearing form")
|
||
self.clear_form()
|
||
self.current_selected_id = None
|
||
return
|
||
|
||
# Only process if a valid row is selected
|
||
if current_row >= 0 and current_row < self.table.rowCount():
|
||
# Set flag to prevent recursion
|
||
self.loading_data = True
|
||
|
||
try:
|
||
# First check if the row has valid data (nama siswa di kolom 1)
|
||
nama_item = self.table.item(current_row, 1)
|
||
if nama_item is None or not nama_item.text().strip():
|
||
# print(f"Row {current_row} has no name data, keeping current form state")
|
||
self.loading_data = False # Reset flag before returning
|
||
return
|
||
|
||
# print(f"Loading data from valid row {current_row}")
|
||
|
||
# Clear current selection ID first
|
||
self.current_selected_id = None
|
||
|
||
# Don't load data to form here - only when Edit button is clicked
|
||
# self.load_selected_data_to_form(current_row)
|
||
|
||
# Get the ID (sekarang di kolom 13)
|
||
id_item = self.table.item(current_row, 13) # Use hidden ID column
|
||
print(f"ID item for row {current_row}: {id_item}")
|
||
|
||
if id_item is not None and id_item.text().strip():
|
||
self.current_selected_id = id_item.text().strip()
|
||
print(f"Successfully selected ID: {self.current_selected_id}")
|
||
self.statusBar().showMessage(f'Data dipilih: {self.current_selected_id}')
|
||
# Enable Edit, Preview, and Update buttons when data is selected
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(True)
|
||
self.btn_edit.setToolTip("Klik untuk mengedit data yang dipilih")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(True)
|
||
self.btn_preview.setToolTip("Klik untuk melihat preview prediksi data yang dipilih")
|
||
if hasattr(self, 'btn_update'):
|
||
self.btn_update.setEnabled(True)
|
||
self.btn_update.setToolTip("Klik untuk mengupdate data yang dipilih dengan prediksi baru")
|
||
# Keep predict button enabled when data is selected (not in edit mode yet)
|
||
if hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(True)
|
||
self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru")
|
||
else:
|
||
print(f"No valid ID found for row {current_row}")
|
||
self.statusBar().showMessage('Data dipilih tanpa ID')
|
||
# Disable Edit, Preview, and Update buttons if no valid ID
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
if hasattr(self, 'btn_update'):
|
||
self.btn_update.setEnabled(False)
|
||
|
||
except Exception as e:
|
||
print(f"Error in selection changed: {str(e)}")
|
||
self.statusBar().showMessage(f'Error: {str(e)}')
|
||
self.debug_table_contents("after error")
|
||
finally:
|
||
# Always reset the flag
|
||
self.loading_data = False
|
||
else:
|
||
# Only clear form if explicitly no selection (current_row == -1)
|
||
if current_row == -1:
|
||
print("No row selected, clearing form")
|
||
self.clear_form()
|
||
self.current_selected_id = None
|
||
self.statusBar().showMessage('Tidak ada data yang dipilih')
|
||
# Disable Edit, Preview, and Update buttons when no selection
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
if hasattr(self, 'btn_update'):
|
||
self.btn_update.setEnabled(False)
|
||
self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate")
|
||
# Enable predict button when no selection (create mode)
|
||
if hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(True)
|
||
self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru")
|
||
else:
|
||
print(f"Invalid row {current_row}, keeping current form state")
|
||
|
||
# Debug table contents after processing
|
||
self.debug_table_contents("after selection change")
|
||
|
||
#untuk load data yang dipilih ke form untuk diedit
|
||
def load_selected_data_to_form(self, row, edit_mode=False):
|
||
"""Load selected row data to form for editing"""
|
||
try:
|
||
self.loading_data = True
|
||
# Check if row is valid
|
||
if row < 0 or row >= self.table.rowCount():
|
||
print(f"Invalid row: {row}")
|
||
return
|
||
|
||
print(f"Loading data from row {row}, edit_mode: {edit_mode}")
|
||
|
||
# Fill nama siswa (kolom index 1, karena index 0 adalah kolom No)
|
||
nama_item = self.table.item(row, 1)
|
||
if nama_item and nama_item.text():
|
||
nama = nama_item.text().strip()
|
||
self.nama_input.setText(nama)
|
||
print(f"Nama yang dimuat: {nama}") # Logging tambahan
|
||
else:
|
||
print("Nama item is None or empty")
|
||
self.nama_input.clear()
|
||
|
||
# Fill nilai mata pelajaran (mulai dari kolom index 2)
|
||
# Fill into nilai_table semester values and auto-calculate rata-rata
|
||
subject_keys = [
|
||
'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi',
|
||
'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut',
|
||
'nilai_sosiologi', 'nilai_geografi'
|
||
]
|
||
|
||
if hasattr(self, 'nilai_table'):
|
||
for i, key in enumerate(subject_keys):
|
||
col_index = i + 2 # Mulai dari kolom 2 (karena 0=No, 1=Nama)
|
||
value_item = self.table.item(row, col_index)
|
||
if value_item and value_item.text().strip():
|
||
try:
|
||
# Parse nilai
|
||
value = self.parse_float_value(value_item.text().strip())
|
||
|
||
# Check if database has semester 1 and 2 values
|
||
# Get the data dict from current selected row in all_data
|
||
if hasattr(self, 'all_data') and len(self.all_data) > 0:
|
||
# Find the current data in all_data
|
||
current_data = None
|
||
current_page_start = (self.current_page - 1) * self.items_per_page
|
||
page_row_index = current_page_start + row
|
||
if page_row_index < len(self.all_data):
|
||
current_data = self.all_data[page_row_index]
|
||
|
||
if current_data:
|
||
# Try to get semester 1 and 2 values from database
|
||
sem1_key = f'{key}_sem1'
|
||
sem2_key = f'{key}_sem2'
|
||
sem1_value = current_data.get(sem1_key)
|
||
sem2_value = current_data.get(sem2_key)
|
||
|
||
sem1_item = self.nilai_table.item(i, 1)
|
||
sem2_item = self.nilai_table.item(i, 2)
|
||
|
||
# Fill semester 1 dan 2 secara sinkron ke item dan editor
|
||
if sem1_item:
|
||
if sem1_value is not None and sem1_value != '':
|
||
sem1_text = self.format_nilai(sem1_value)
|
||
else:
|
||
sem1_text = ""
|
||
self._set_nilai_cell_text(i, 1, sem1_text)
|
||
print(f"Loaded {key} semester 1: {sem1_value}")
|
||
|
||
if sem2_item:
|
||
if sem2_value is not None and sem2_value != '':
|
||
sem2_text = self.format_nilai(sem2_value)
|
||
else:
|
||
sem2_text = ""
|
||
self._set_nilai_cell_text(i, 2, sem2_text)
|
||
print(f"Loaded {key} semester 2: {sem2_value}")
|
||
|
||
self._calculate_nilai_rata_row(i)
|
||
except Exception as err:
|
||
print(f"Error converting {key} value: {value_item.text()} - {err}")
|
||
else:
|
||
print(f"Empty or None value for {key}")
|
||
|
||
print(f"Successfully loaded data from row {row}")
|
||
self.statusBar().showMessage('Data berhasil dimuat ke form untuk diedit')
|
||
|
||
if edit_mode:
|
||
self.original_edit_data = self._get_current_form_snapshot()
|
||
|
||
# Enable edit and preview buttons when data is loaded
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(True)
|
||
self.btn_edit.setToolTip("Mengedit data yang dipilih")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(True)
|
||
self.btn_preview.setToolTip("Melihat preview prediksi untuk data yang dipilih")
|
||
|
||
# Only disable predict button when explicitly in edit mode
|
||
if edit_mode and hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(False)
|
||
self.btn_predict.setToolTip("Gunakan tombol Update untuk memperbarui data yang dipilih")
|
||
elif hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(True)
|
||
self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru")
|
||
|
||
self.loading_data = False
|
||
except Exception as e:
|
||
self.loading_data = False
|
||
print(f"Error loading data to form: {str(e)}")
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal memuat data ke form:\n{str(e)}', 'OK')
|
||
|
||
def add_data(self):
|
||
"""Add new student data"""
|
||
if not self.validate_form():
|
||
return
|
||
|
||
try:
|
||
nama = self.nama_input.text().strip()
|
||
|
||
# Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata)
|
||
nilai_dict = {}
|
||
subject_keys = [
|
||
'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi',
|
||
'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut',
|
||
'nilai_sosiologi', 'nilai_geografi'
|
||
]
|
||
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(min(len(subject_keys), self.nilai_table.rowCount())):
|
||
key = subject_keys[row]
|
||
# Get semester 1 (kolom 1)
|
||
sem1_item = self.nilai_table.item(row, 1)
|
||
sem1_value = self.parse_float_value(sem1_item.text()) if sem1_item else 0
|
||
|
||
# Get semester 2 (kolom 2)
|
||
sem2_item = self.nilai_table.item(row, 2)
|
||
sem2_value = self.parse_float_value(sem2_item.text()) if sem2_item else 0
|
||
|
||
# Get rata-rata (kolom 3)
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
rata_value = self.parse_float_value(rata_item.text()) if rata_item else 0
|
||
|
||
# Store all three values
|
||
nilai_dict[f'{key}_sem1'] = sem1_value
|
||
nilai_dict[f'{key}_sem2'] = sem2_value
|
||
nilai_dict[key] = rata_value
|
||
|
||
# Predict paket (using rata-rata values)
|
||
prediction_dict = {key: nilai_dict[key] for key in subject_keys if key in nilai_dict}
|
||
if self.classifier.is_trained:
|
||
prediction, _ = self.classifier.predict(prediction_dict)
|
||
else:
|
||
prediction = 1 # Default
|
||
|
||
simpan_hasil_prediksi(nama, nilai_dict, prediction)
|
||
self.load_table_data()
|
||
self.clear_form()
|
||
CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil ditambahkan!', 'OK')
|
||
self.statusBar().showMessage('Data berhasil ditambahkan')
|
||
except Exception as e:
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal menambah data:\n{str(e)}', 'OK')
|
||
|
||
#Fungsi Update Data dengan Prediksi Baru
|
||
def update_data(self):
|
||
if not self.current_selected_id:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan diupdate!', 'OK')
|
||
return
|
||
|
||
if not self.validate_form():
|
||
return
|
||
|
||
if self.edit_mode and not self._has_form_changes():
|
||
CustomNotificationDialog.show_warning(
|
||
self,
|
||
'Tidak Ada Perubahan',
|
||
'Data belum diubah. Silakan lakukan perubahan terlebih dahulu sebelum update.',
|
||
'OK'
|
||
)
|
||
return
|
||
|
||
if not self.classifier.is_trained:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Model belum dilatih!\nLatih model terlebih dahulu.', 'OK')
|
||
return
|
||
|
||
try:
|
||
# Get form data
|
||
nama = self.nama_input.text().strip()
|
||
|
||
# Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata)
|
||
nilai_dict = {}
|
||
subject_keys = [
|
||
'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi',
|
||
'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut',
|
||
'nilai_sosiologi', 'nilai_geografi'
|
||
]
|
||
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(min(len(subject_keys), self.nilai_table.rowCount())):
|
||
key = subject_keys[row]
|
||
# Get semester 1 (kolom 1)
|
||
sem1_item = self.nilai_table.item(row, 1)
|
||
sem1_value = self.parse_float_value(sem1_item.text()) if sem1_item else 0
|
||
|
||
# Get semester 2 (kolom 2)
|
||
sem2_item = self.nilai_table.item(row, 2)
|
||
sem2_value = self.parse_float_value(sem2_item.text()) if sem2_item else 0
|
||
|
||
# Get rata-rata (kolom 3)
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
rata_value = self.parse_float_value(rata_item.text()) if rata_item else 0
|
||
|
||
# Store all three values
|
||
nilai_dict[f'{key}_sem1'] = sem1_value
|
||
nilai_dict[f'{key}_sem2'] = sem2_value
|
||
nilai_dict[key] = rata_value
|
||
|
||
# Make new prediction with updated values (using rata-rata)
|
||
prediction_dict = {key: nilai_dict[key] for key in subject_keys if key in nilai_dict}
|
||
prediction, top_3 = self.classifier.predict(prediction_dict)
|
||
|
||
# Show updated prediction result
|
||
paket_info = self.data_processor.get_paket_info(prediction)
|
||
kelas_info = f" ({paket_info.get('kelas_tersedia', 1)} kelas tersedia)"
|
||
|
||
result_text = f"Prediksi Ulang untuk {nama}:\n\n"
|
||
result_text += f"Paket Terpilih: {prediction}\n"
|
||
result_text += f"Detail: {paket_info.get('nama', 'Unknown')}{kelas_info}\n"
|
||
result_text += "\nData akan diupdate dengan hasil prediksi terbaru."
|
||
|
||
# Show prediction result
|
||
reply = CustomNotificationDialog.show_question(self, "Konfirmasi Update",
|
||
result_text + "\n\nLanjutkan update?",
|
||
"Ya, Update", "Batal")
|
||
|
||
if reply:
|
||
# Update data with new prediction
|
||
update_hasil_prediksi(self.current_selected_id, nama, nilai_dict, prediction)
|
||
|
||
# Refresh table and clear form
|
||
self.load_table_data()
|
||
self.clear_form()
|
||
|
||
# Show success message
|
||
CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil diupdate dengan prediksi terbaru!', 'OK')
|
||
self.statusBar().showMessage('Data berhasil diupdate dengan prediksi terbaru')
|
||
|
||
except Exception as e:
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mengupdate data:\n{str(e)}', 'OK')
|
||
|
||
def hapus_data(self):
|
||
"""Hapus data yang dipilih dari tabel"""
|
||
if not self.current_selected_id:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan dihapus!', 'OK')
|
||
return
|
||
|
||
reply = CustomNotificationDialog.show_question(self, 'Konfirmasi Hapus',
|
||
'Apakah Anda yakin ingin menghapus data ini?',
|
||
'Ya, Hapus', 'Batal')
|
||
if reply:
|
||
try:
|
||
hapus_hasil_prediksi(self.current_selected_id)
|
||
self.load_table_data()
|
||
self.clear_form()
|
||
CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil dihapus!', 'OK')
|
||
self.statusBar().showMessage('Data berhasil dihapus')
|
||
except Exception as e:
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal menghapus data:\n{str(e)}', 'OK')
|
||
|
||
def search_data(self):
|
||
"""Search data by name and update table to show only matching results"""
|
||
search_text, ok = QInputDialog.getText(self, 'Search', 'Masukkan nama siswa:')
|
||
if ok and search_text.strip():
|
||
try:
|
||
# Set loading flag to prevent selection events during search
|
||
self.loading_data = True
|
||
|
||
# Get all data and filter
|
||
all_data = ambil_semua_hasil_prediksi()
|
||
filtered_data = [item for item in all_data if search_text.lower() in item['nama_siswa'].lower()]
|
||
|
||
print(f"Search found {len(filtered_data)} results")
|
||
|
||
# Update pagination data with filtered results
|
||
self.all_data = filtered_data
|
||
self.total_items = len(filtered_data)
|
||
self.current_page = 1 # Reset to first page
|
||
|
||
# Display current page
|
||
self.display_current_page()
|
||
|
||
if not filtered_data:
|
||
CustomNotificationDialog.show_warning(self, 'Tidak Ditemukan', 'Data tidak ditemukan!\nCoba dengan kata kunci lain.', 'OK')
|
||
# Reset to all data if no results found
|
||
self.load_table_data()
|
||
else:
|
||
print(f"Search completed successfully - showing {len(filtered_data)} results")
|
||
|
||
except Exception as e:
|
||
print(f"Error in search_data: {str(e)}")
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mencari data:\n{str(e)}', 'OK')
|
||
finally:
|
||
# Reset loading flag
|
||
self.loading_data = False
|
||
elif ok and not search_text.strip():
|
||
# If empty search, reload all data
|
||
self.load_table_data()
|
||
|
||
#pencarian data berdasarkan nama
|
||
def search_by_name(self):
|
||
search_text = self.search_input.text().strip()
|
||
|
||
try:
|
||
# untuk mencegah diklik berkali-kali saat loading data
|
||
self.loading_data = True
|
||
|
||
# Ambil semua data dari database
|
||
all_data = ambil_semua_hasil_prediksi()
|
||
|
||
if search_text:
|
||
# Filter data berdasarkan nama siswa
|
||
filtered_data = [item for item in all_data if search_text.lower() in item['nama_siswa'].lower()]
|
||
print(f"Search by name found {len(filtered_data)} results for '{search_text}'")
|
||
else:
|
||
# Jika search kosong, tampilkan semua data
|
||
filtered_data = all_data
|
||
|
||
# Update data untuk pagination
|
||
self.all_data = filtered_data
|
||
self.total_items = len(filtered_data)
|
||
self.current_page = 1 # Reset ke halaman pertama
|
||
|
||
# Display current page
|
||
self.display_current_page()
|
||
self.update_pagination_controls()
|
||
|
||
# Update info label
|
||
if search_text:
|
||
self.info_label.setText(f"Menampilkan {len(filtered_data)} hasil pencarian untuk '{search_text}'")
|
||
else:
|
||
self.info_label.setText(f"Menampilkan semua data ({len(filtered_data)} total)")
|
||
|
||
except Exception as e:
|
||
print(f"Error in search_by_name: {str(e)}")
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mencari data:\n{str(e)}', 'OK')
|
||
finally:
|
||
# Reset loading flag
|
||
self.loading_data = False
|
||
|
||
#menghapus hasil pencarian dan menampilkan semua data kembali
|
||
def clear_search(self):
|
||
self.search_input.clear()
|
||
self.load_table_data()
|
||
self.info_label.setText("")
|
||
|
||
def clear_form(self, show_empty_notification=False):
|
||
"""Clear all form inputs"""
|
||
form_has_data = False
|
||
if hasattr(self, 'nama_input') and self.nama_input.text().strip():
|
||
form_has_data = True
|
||
elif hasattr(self, 'nilai_table'):
|
||
for row in range(self.nilai_table.rowCount()):
|
||
sem1_item = self.nilai_table.item(row, 1)
|
||
sem2_item = self.nilai_table.item(row, 2)
|
||
if (sem1_item and sem1_item.text().strip()) or (sem2_item and sem2_item.text().strip()):
|
||
form_has_data = True
|
||
break
|
||
|
||
if show_empty_notification and not form_has_data:
|
||
CustomNotificationDialog.show_warning(self, 'Info', 'Tidak ada data dalam form.', 'OK')
|
||
return
|
||
|
||
# Set loading flag to prevent recursion
|
||
self.loading_data = True
|
||
|
||
# Clear table selection first to prevent on_table_selection_changed from being called
|
||
if hasattr(self, 'table'):
|
||
self.table.clearSelection()
|
||
|
||
self.nama_input.clear()
|
||
|
||
# Clear nilai table
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(self.nilai_table.rowCount()):
|
||
sem1_item = self.nilai_table.item(row, 1)
|
||
sem2_item = self.nilai_table.item(row, 2)
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
|
||
if sem1_item:
|
||
self._set_nilai_cell_text(row, 1, '')
|
||
if sem2_item:
|
||
self._set_nilai_cell_text(row, 2, '')
|
||
if rata_item:
|
||
rata_item.setText('otomatis')
|
||
rata_item.setForeground(QColor('#94a3b8'))
|
||
|
||
self.current_selected_id = None
|
||
self.edit_mode = False # Reset edit mode when clearing form
|
||
self.original_edit_data = None
|
||
|
||
# Disable edit and preview buttons when form is cleared
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
if hasattr(self, 'btn_update'):
|
||
self.btn_update.setEnabled(False)
|
||
self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate")
|
||
|
||
# Enable predict button when form is cleared (create mode)
|
||
if hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(True)
|
||
self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru")
|
||
|
||
# Reset loading flag
|
||
self.loading_data = False
|
||
|
||
#mengahapus data terpilih dari database
|
||
def delete_data(self):
|
||
if not self.current_selected_id:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan dihapus!', 'OK')
|
||
return
|
||
|
||
reply = CustomNotificationDialog.show_question(self, 'Konfirmasi Hapus',
|
||
'Apakah Anda yakin ingin menghapus data ini?',
|
||
'Ya, Hapus', 'Batal')
|
||
if reply:
|
||
try:
|
||
hapus_hasil_prediksi(self.current_selected_id)
|
||
self.load_table_data()
|
||
self.clear_form()
|
||
CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil dihapus!', 'OK')
|
||
self.statusBar().showMessage('Data berhasil dihapus')
|
||
except Exception as e:
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal menghapus data:\n{str(e)}', 'OK')
|
||
|
||
#fungsi edit data
|
||
def edit_selected_data(self):
|
||
"""Edit selected data by switching to Input tab with data loaded"""
|
||
print(f"Edit button clicked. Current selected ID: {self.current_selected_id}")
|
||
|
||
if not self.current_selected_id:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan diedit!', 'OK')
|
||
return
|
||
|
||
try:
|
||
# Set edit mode flag BEFORE switching tab
|
||
self.edit_mode = True
|
||
print(f"Edit mode set to: {self.edit_mode}")
|
||
|
||
# Load data to form from the selected row
|
||
selected_rows = self.table.selectedIndexes()
|
||
print(f"Selected rows count: {len(selected_rows)}")
|
||
|
||
if selected_rows:
|
||
current_row = selected_rows[0].row()
|
||
print(f"Loading data from row: {current_row}")
|
||
self.load_selected_data_to_form(current_row, edit_mode=True)
|
||
else:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Tidak ada baris yang dipilih!', 'OK')
|
||
self.edit_mode = False
|
||
return
|
||
|
||
# Switch to Input Data tab (index 0)
|
||
print("Switching to Input tab...")
|
||
self.switch_tab(0)
|
||
|
||
# Update status to indicate edit mode
|
||
self.statusBar().showMessage(f'Mode Edit - Data ID {self.current_selected_id} dimuat untuk diedit')
|
||
|
||
# Ensure predict button is disabled in edit mode
|
||
if hasattr(self, 'btn_predict'):
|
||
self.btn_predict.setEnabled(False)
|
||
self.btn_predict.setToolTip("Gunakan tombol Update untuk memperbarui data yang dipilih")
|
||
|
||
# Enable update button in edit mode
|
||
if hasattr(self, 'btn_update'):
|
||
self.btn_update.setEnabled(True)
|
||
self.btn_update.setToolTip("Klik untuk update data dengan prediksi baru")
|
||
|
||
print("Data loaded successfully, showing info dialog...")
|
||
# Show info message - now using custom notification
|
||
message = "Data telah dimuat ke form untuk diedit.\n\n"
|
||
message += "Silakan ubah nilai yang diperlukan, kemudian:\n"
|
||
message += "• Klik 'Update' untuk menyimpan perubahan dengan prediksi baru\n"
|
||
message += "• Atau klik 'Clear Form' untuk kembali ke mode input baru"
|
||
CustomNotificationDialog.show_success(self, "Mode Edit", message, "OK")
|
||
|
||
except Exception as e:
|
||
print(f"Error in edit_selected_data: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal memuat data untuk edit:\n{str(e)}', 'OK')
|
||
self.edit_mode = False
|
||
|
||
#Fungsi Utama untuk Prediksi dan Validasi Simpan/Batal
|
||
def predict_and_save_data(self):
|
||
"""Predict and show confirmation dialog before saving"""
|
||
if not self.validate_form():
|
||
return
|
||
|
||
if not self.classifier.is_trained:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Model belum dilatih!\nLatih model terlebih dahulu.', 'OK')
|
||
return
|
||
|
||
try:
|
||
# mengambil nama siswa dari form input
|
||
nama = self.nama_input.text().strip()
|
||
|
||
# Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata)
|
||
nilai_dict = {}
|
||
subject_keys = [
|
||
'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi',
|
||
'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut',
|
||
'nilai_sosiologi', 'nilai_geografi'
|
||
]
|
||
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(min(len(subject_keys), self.nilai_table.rowCount())):
|
||
key = subject_keys[row]
|
||
# menyimpan nilai semester 1 (kolom 1)
|
||
sem1_item = self.nilai_table.item(row, 1)
|
||
sem1_value = self.parse_float_value(sem1_item.text()) if sem1_item else 0
|
||
|
||
# menyimpan nilai semester 2 (kolom 2)
|
||
sem2_item = self.nilai_table.item(row, 2)
|
||
sem2_value = self.parse_float_value(sem2_item.text()) if sem2_item else 0
|
||
|
||
# menyimpan rata-rata (kolom 3)
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
rata_value = self.parse_float_value(rata_item.text()) if rata_item else 0
|
||
|
||
# untuk prediksi, kita akan menggunakan nilai rata-rata (kolom 3), tapi kita juga menyimpan semester 1 dan 2 untuk referensi di database
|
||
nilai_dict[f'{key}_sem1'] = sem1_value
|
||
nilai_dict[f'{key}_sem2'] = sem2_value
|
||
nilai_dict[key] = rata_value
|
||
|
||
# membuat prediksi dengan nilai rata-rata (kolom 3) untuk setiap mata pelajaran
|
||
prediction_dict = {key: nilai_dict[key] for key in subject_keys if key in nilai_dict}
|
||
prediction, top_3 = self.classifier.predict(prediction_dict) #menghasilkan jurusan
|
||
|
||
# melihat hasil prediksi dengan informasi paket dan kelas yang tersedia
|
||
paket_info = self.data_processor.get_paket_info(prediction)
|
||
|
||
# untuk menampilkan hasil prediksi dengan opsi simpan atau batal, gunakan custom notification dialog
|
||
from gui.custom_notifications import CustomPredictionDialog
|
||
save_result = CustomPredictionDialog.show_prediction(self, nama, prediction, paket_info)
|
||
|
||
# Hasil dari dialog akan menentukan apakah kita menyimpan data baru dengan prediksi atau membatalkan dan tetap di form input
|
||
if save_result:
|
||
# untuk menyimpan hasil prediksi sebagai data baru, kita akan menggunakan fungsi simpan_hasil_prediksi dari database handler
|
||
simpan_hasil_prediksi(nama, nilai_dict, prediction)
|
||
success_msg = "Data berhasil diprediksi dan disimpan sebagai data baru!"
|
||
status_msg = 'Data berhasil disimpan sebagai data baru'
|
||
|
||
# Refresh table and clear form
|
||
self.load_table_data()
|
||
self.clear_form()
|
||
|
||
# Show success message
|
||
CustomNotificationDialog.show_success(self, 'Berhasil!', success_msg, 'OK')
|
||
self.statusBar().showMessage(status_msg)
|
||
else:
|
||
# Cancel - do nothing, stay on input form
|
||
self.statusBar().showMessage('Prediksi dibatalkan')
|
||
|
||
except Exception as e:
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal melakukan prediksi:\n{str(e)}', 'OK')
|
||
|
||
#Fungsi Preview Prediksi
|
||
def preview_prediction(self):
|
||
"""Preview prediction with option to save - works from both form input and selected table data"""
|
||
if not self.classifier.is_trained:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Model belum dilatih!\nLatih model terlebih dahulu.', 'OK')
|
||
return
|
||
|
||
try:
|
||
# Check if we're working with selected data from table or form input
|
||
if hasattr(self, 'current_selected_id') and self.current_selected_id:
|
||
# Working with selected data from table
|
||
current_row = self.table.currentRow()
|
||
if current_row < 0:
|
||
CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data dari tabel terlebih dahulu!', 'OK')
|
||
return
|
||
|
||
# Get data from table
|
||
nama = self.table.item(current_row, 1).text() # Nama siswa di kolom 1
|
||
|
||
# Get values from table (columns 2-11 untuk mata pelajaran)
|
||
# Use parse_float_value to support comma and dot format
|
||
nilai_dict = {
|
||
'nilai_informatika': self.parse_float_value(self.table.item(current_row, 2).text()),
|
||
'nilai_fisika': self.parse_float_value(self.table.item(current_row, 3).text()),
|
||
'nilai_kimia': self.parse_float_value(self.table.item(current_row, 4).text()),
|
||
'nilai_biologi': self.parse_float_value(self.table.item(current_row, 5).text()),
|
||
'nilai_big_lanjut': self.parse_float_value(self.table.item(current_row, 6).text()),
|
||
'nilai_ekonomi': self.parse_float_value(self.table.item(current_row, 7).text()),
|
||
'nilai_mat_lanjut': self.parse_float_value(self.table.item(current_row, 8).text()),
|
||
'nilai_sej_lanjut': self.parse_float_value(self.table.item(current_row, 9).text()),
|
||
'nilai_sosiologi': self.parse_float_value(self.table.item(current_row, 10).text()),
|
||
'nilai_geografi': self.parse_float_value(self.table.item(current_row, 11).text())
|
||
}
|
||
|
||
source_text = "dari data tabel yang dipilih"
|
||
else:
|
||
# Working with form input
|
||
if not self.validate_form():
|
||
return
|
||
|
||
# Get form data
|
||
nama = self.nama_input.text().strip()
|
||
|
||
# Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata)
|
||
nilai_dict = {}
|
||
subject_keys = [
|
||
'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi',
|
||
'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut',
|
||
'nilai_sosiologi', 'nilai_geografi'
|
||
]
|
||
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(min(len(subject_keys), self.nilai_table.rowCount())):
|
||
key = subject_keys[row]
|
||
# Get rata-rata (kolom 3) for prediction
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
if rata_item:
|
||
nilai_dict[key] = self.parse_float_value(rata_item.text())
|
||
|
||
source_text = "dari form input"
|
||
|
||
# Make prediction
|
||
prediction, top_3 = self.classifier.predict(nilai_dict)
|
||
|
||
# Show prediction result
|
||
paket_info = self.data_processor.get_paket_info(prediction)
|
||
|
||
result_text = f"Preview Prediksi untuk {nama} ({source_text}):\n\n"
|
||
result_text += f"Paket Terpilih: {prediction}\n"
|
||
result_text += f"Detail: {paket_info.get('nama', 'Unknown')}"
|
||
|
||
# Show preview with custom notification
|
||
CustomNotificationDialog.show_success(self, "Preview Prediksi", result_text, "OK")
|
||
|
||
# Just preview, don't save
|
||
self.statusBar().showMessage('Preview selesai - data tidak disimpan')
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal melakukan preview prediksi:\n{str(e)}', 'OK')
|
||
|
||
def show_performance(self):
|
||
"""Show performance report preview"""
|
||
if not self.classifier.is_trained:
|
||
# Train model first
|
||
self.train_model_background()
|
||
|
||
# Show performance report dialog
|
||
dialog = PerformanceDialog(self.classifier, self)
|
||
dialog.exec_()
|
||
|
||
#Fungsi Validasi Form
|
||
def validate_form(self):
|
||
"""Validate form inputs with enhanced validation"""
|
||
nama = self.nama_input.text().strip()
|
||
|
||
# Validasi nama siswa tidak boleh kosong
|
||
if not nama:
|
||
CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa harus diisi!', 'OK')
|
||
self.nama_input.setFocus()
|
||
return False
|
||
|
||
# Validasi nama siswa hanya boleh huruf, spasi, titik, tanda petik, dan tanda hubung
|
||
import re
|
||
if not re.match(r"^[a-zA-ZÀ-ÿ][a-zA-ZÀ-ÿ\s\.\-']*$", nama):
|
||
CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa hanya boleh berisi huruf, spasi, titik, tanda petik, dan tanda hubung!', 'OK')
|
||
self.nama_input.setFocus()
|
||
return False
|
||
|
||
# Validasi panjang nama minimal 2 karakter dan maksimal 50
|
||
if len(nama) < 2:
|
||
CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa minimal 2 karakter!', 'OK')
|
||
self.nama_input.setFocus()
|
||
return False
|
||
|
||
if len(nama) > 50:
|
||
CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa maksimal 50 karakter!', 'OK')
|
||
self.nama_input.setFocus()
|
||
return False
|
||
|
||
# Validasi nama tidak boleh hanya spasi
|
||
if nama.isspace():
|
||
CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa tidak boleh hanya berisi spasi!', 'OK')
|
||
self.nama_input.setFocus()
|
||
return False
|
||
|
||
# Validasi tidak boleh ada spasi berturut-turut
|
||
if ' ' in nama:
|
||
CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa tidak boleh mengandung spasi berturut-turut!', 'OK')
|
||
self.nama_input.setFocus()
|
||
return False
|
||
|
||
# Validasi WAJIB: Semua nilai mata pelajaran harus diisi (kedua semester atau salah satu)
|
||
empty_subjects = []
|
||
subject_names = [
|
||
'Informatika', 'Fisika', 'Kimia', 'Biologi', 'BIG Lanjut',
|
||
'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi', 'Geografi'
|
||
]
|
||
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(self.nilai_table.rowCount()):
|
||
sem1_item = self.nilai_table.item(row, 1)
|
||
sem2_item = self.nilai_table.item(row, 2)
|
||
mapel = subject_names[row] if row < len(subject_names) else f"Subject {row}"
|
||
|
||
sem1_text = sem1_item.text().strip() if sem1_item else ""
|
||
sem2_text = sem2_item.text().strip() if sem2_item else ""
|
||
|
||
# Cek input kosong (kedua semester belum diisi)
|
||
if sem1_text == "" and sem2_text == "":
|
||
empty_subjects.append(mapel)
|
||
|
||
# Jika ada mata pelajaran yang belum diisi
|
||
if empty_subjects:
|
||
if len(empty_subjects) == 1:
|
||
CustomNotificationDialog.show_warning(self, 'Validasi Input',
|
||
f"Nilai {empty_subjects[0]} harus diisi minimal satu semester!\n\n"
|
||
f"Isi minimal Semester 1 atau Semester 2 untuk setiap mata pelajaran.",
|
||
'OK')
|
||
else:
|
||
subjects_text = ", ".join(empty_subjects[:-1]) + f" dan {empty_subjects[-1]}"
|
||
CustomNotificationDialog.show_warning(self, 'Validasi Input',
|
||
f"Nilai {subjects_text} harus diisi!\n\n"
|
||
f"Isi minimal Semester 1 atau Semester 2 untuk setiap mata pelajaran.",
|
||
'OK')
|
||
return False
|
||
|
||
# Validasi nilai mata pelajaran dengan range yang tepat
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(self.nilai_table.rowCount()):
|
||
sem1_item = self.nilai_table.item(row, 1)
|
||
sem2_item = self.nilai_table.item(row, 2)
|
||
mapel = subject_names[row] if row < len(subject_names) else f"Subject {row}"
|
||
|
||
sem1_text = sem1_item.text().strip() if sem1_item else ""
|
||
sem2_text = sem2_item.text().strip() if sem2_item else ""
|
||
|
||
if not self._is_valid_score_text(sem1_text):
|
||
CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 1 harus berupa angka 0-100!', 'OK')
|
||
return False
|
||
if not self._is_valid_score_text(sem2_text):
|
||
CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 2 harus berupa angka 0-100!', 'OK')
|
||
return False
|
||
|
||
sem1_value = self.parse_float_value(sem1_text)
|
||
sem2_value = self.parse_float_value(sem2_text)
|
||
|
||
# Validasi nilai dalam range 0-100 (jika diisi)
|
||
if sem1_text and (sem1_value < 0 or sem1_value > 100):
|
||
CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 1 harus antara 0-100!', 'OK')
|
||
return False
|
||
if sem2_text and (sem2_value < 0 or sem2_value > 100):
|
||
CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 2 harus antara 0-100!', 'OK')
|
||
return False
|
||
|
||
# Validasi rata-rata nilai minimal
|
||
total_nilai = 0
|
||
count_nilai = 0
|
||
if hasattr(self, 'nilai_table'):
|
||
for row in range(self.nilai_table.rowCount()):
|
||
rata_item = self.nilai_table.item(row, 3)
|
||
if rata_item and rata_item.text() != 'otomatis':
|
||
try:
|
||
nilai = float(rata_item.text())
|
||
total_nilai += nilai
|
||
count_nilai += 1
|
||
except ValueError:
|
||
pass
|
||
|
||
if count_nilai > 0:
|
||
rata_rata = total_nilai / count_nilai
|
||
else:
|
||
rata_rata = 0
|
||
|
||
if rata_rata < 50:
|
||
reply = CustomNotificationDialog.show_question(self, 'Konfirmasi',
|
||
f"Rata-rata nilai ({rata_rata:.1f}) sangat rendah.\nApakah data sudah benar?",
|
||
'Ya, Benar', 'Cek Lagi')
|
||
if not reply:
|
||
return False
|
||
|
||
return True
|
||
|
||
def validate_nama_input(self, text):
|
||
"""Validate nama input in real-time"""
|
||
# Filter out invalid characters but keep common punctuation found in dataset names
|
||
import re
|
||
filtered_text = re.sub(r"[^a-zA-ZÀ-ÿ\s\.\-']", '', text)
|
||
|
||
# Remove multiple consecutive spaces
|
||
filtered_text = re.sub(r' +', ' ', filtered_text)
|
||
|
||
# If the text was changed, update the input
|
||
if filtered_text != text:
|
||
cursor_pos = self.nama_input.cursorPosition()
|
||
self.nama_input.setText(filtered_text)
|
||
# Restore cursor position (adjusted for removed characters)
|
||
new_pos = min(cursor_pos, len(filtered_text))
|
||
self.nama_input.setCursorPosition(new_pos)
|
||
|
||
def train_model_background(self):
|
||
"""Train model in background"""
|
||
try:
|
||
# Always train with current configured training dataset
|
||
data_path = config.DATASET_PATH
|
||
if os.path.exists(data_path):
|
||
os.makedirs(os.path.dirname(config.MODEL_PATH), exist_ok=True)
|
||
print(f"Training model with {data_path}")
|
||
accuracy, _ = self.classifier.train(
|
||
data_path,
|
||
test_data_path=getattr(config, 'TEST_DATASET_PATH', None)
|
||
)
|
||
self.classifier.save_model(config.MODEL_PATH)
|
||
print(f"Model trained with accuracy: {accuracy:.2%}")
|
||
self.statusBar().showMessage(f'Model trained - Accuracy: {accuracy:.2%}')
|
||
else:
|
||
print(f"Dataset not found: {data_path}")
|
||
self.statusBar().showMessage(f'Dataset not found: {data_path}')
|
||
except Exception as e:
|
||
print(f"Training failed: {str(e)}")
|
||
self.statusBar().showMessage(f'Training failed: {str(e)}')
|
||
|
||
def refresh_table(self):
|
||
"""Refresh table to show all data"""
|
||
self.current_page = 1 # Reset to first page
|
||
self.load_table_data()
|
||
self.statusBar().showMessage('Tabel berhasil dimuat ulang')
|
||
|
||
def eventFilter(self, source, event):
|
||
"""Event filter to detect mouse clicks outside table"""
|
||
if hasattr(self, 'nama_input') and source == self.nama_input and event.type() == QEvent.MouseButtonPress:
|
||
if event.button() == Qt.LeftButton:
|
||
self.open_student_name_picker()
|
||
return True
|
||
|
||
if event.type() == QEvent.MouseButtonPress:
|
||
# Check if click happened outside the table
|
||
if hasattr(self, 'table') and source == self.table.viewport():
|
||
# Get the position of the click relative to the table viewport
|
||
pos = event.pos()
|
||
item = self.table.itemAt(pos)
|
||
|
||
# If click is in empty area of table (no item), clear selection
|
||
if item is None:
|
||
self.table.clearSelection()
|
||
self.current_selected_id = None
|
||
self.load_table_data()
|
||
|
||
# Disable edit and preview buttons
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
|
||
self.statusBar().showMessage('Seleksi dibersihkan dan tabel direfresh')
|
||
|
||
elif hasattr(self, 'table') and source != self.table:
|
||
# Check if the click is outside the table area entirely
|
||
if hasattr(source, 'geometry'):
|
||
# Get global position of click
|
||
global_pos = event.globalPos()
|
||
# Get table geometry in global coordinates
|
||
table_rect = self.table.geometry()
|
||
table_global_pos = self.table.mapToGlobal(table_rect.topLeft())
|
||
table_global_rect = QRect(table_global_pos, table_rect.size())
|
||
|
||
# If click is outside table, clear selection
|
||
if not table_global_rect.contains(global_pos):
|
||
self.table.clearSelection()
|
||
self.current_selected_id = None
|
||
self.load_table_data()
|
||
|
||
# Disable edit and preview buttons
|
||
if hasattr(self, 'btn_edit'):
|
||
self.btn_edit.setEnabled(False)
|
||
self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit")
|
||
if hasattr(self, 'btn_preview'):
|
||
self.btn_preview.setEnabled(False)
|
||
self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi")
|
||
|
||
self.statusBar().showMessage('Seleksi dibersihkan dan tabel direfresh')
|
||
|
||
return super().eventFilter(source, event)
|
||
|
||
def on_table_cell_clicked(self, row, column):
|
||
"""Handle cell click event by selecting the row."""
|
||
print(f"Cell clicked - Row: {row}, Column: {column}")
|
||
|
||
# Ensure the row is valid and the table has data
|
||
if row >= 0 and row < self.table.rowCount() and self.table.rowCount() > 0:
|
||
print(f"Selecting row {row}")
|
||
# Select the entire row - this will trigger itemSelectionChanged
|
||
self.table.selectRow(row)
|
||
# Force the selection to be processed immediately
|
||
QApplication.processEvents()
|
||
else:
|
||
print(f"Invalid row clicked: {row} or table is empty")
|
||
|
||
def export_to_excel(self):
|
||
"""Export data hasil prediksi ke file Excel dengan rata-rata nilai dan keterangan paket"""
|
||
try:
|
||
# Ambil semua data dari database
|
||
data = ambil_semua_hasil_prediksi()
|
||
|
||
if not data:
|
||
CustomNotificationDialog.show_warning(self, 'Info', 'Tidak ada data untuk diekspor.\nTambahkan data terlebih dahulu.', 'OK')
|
||
return
|
||
|
||
# Konversi ke DataFrame
|
||
df = pd.DataFrame(data)
|
||
|
||
# Tentukan kolom yang akan diekspor (hanya rata-rata, tidak semester values)
|
||
columns_to_keep = [
|
||
'nama_siswa',
|
||
'nilai_informatika',
|
||
'nilai_fisika',
|
||
'nilai_kimia',
|
||
'nilai_biologi',
|
||
'nilai_big_lanjut',
|
||
'nilai_ekonomi',
|
||
'nilai_mat_lanjut',
|
||
'nilai_sej_lanjut',
|
||
'nilai_sosiologi',
|
||
'nilai_geografi',
|
||
'paket_prediksi'
|
||
]
|
||
|
||
# Hapus kolom yang tidak diperlukan
|
||
df = df[[col for col in columns_to_keep if col in df.columns]]
|
||
|
||
# Tambahkan kolom No sebagai nomor urut di awal
|
||
df.insert(0, 'No', range(1, len(df) + 1))
|
||
|
||
# Buat nama kolom yang lebih user-friendly
|
||
column_mapping = {
|
||
'No': 'No',
|
||
'nama_siswa': 'Nama Siswa',
|
||
'nilai_informatika': 'Informatika',
|
||
'nilai_fisika': 'Fisika',
|
||
'nilai_kimia': 'Kimia',
|
||
'nilai_biologi': 'Biologi',
|
||
'nilai_big_lanjut': 'BIG Lanjut',
|
||
'nilai_ekonomi': 'Ekonomi',
|
||
'nilai_mat_lanjut': 'MAT Lanjut',
|
||
'nilai_sej_lanjut': 'SEJ Lanjut',
|
||
'nilai_sosiologi': 'Sosiologi',
|
||
'nilai_geografi': 'Geografi',
|
||
'paket_prediksi': 'Paket Prediksi'
|
||
}
|
||
|
||
# Rename kolom
|
||
df = df.rename(columns=column_mapping)
|
||
|
||
# Tambahkan kolom keterangan paket
|
||
paket_info = {
|
||
1: '1 (Informatika,Fisika,Kimia,Biologi)',
|
||
2: '2 (Informatika,BIG Lanjut,Kimia,Biologi)',
|
||
3: '3 (Informatika,Ekonomi,BIG Lanjut,MAT Lanjut)',
|
||
4: '4 (Informatika,Ekonomi,MAT Lanjut,SEJ Lanjut)',
|
||
5: '5 (Informatika,Ekonomi,Sosiologi,SEJ Lanjut)',
|
||
6: '6 (Informatika,Ekonomi,Sosiologi,Geografi)'
|
||
}
|
||
|
||
df['Keterangan Paket'] = df['Paket Prediksi'].map(paket_info)
|
||
|
||
# Hapus kolom Paket Prediksi karena sudah ada Keterangan Paket
|
||
if 'Paket Prediksi' in df.columns:
|
||
df = df.drop(columns=['Paket Prediksi'])
|
||
|
||
# Tampilkan nilai numerik tanpa suffix .00, tetapi tetap simpan 2 desimal
|
||
# bila nilai memang memiliki pecahan.
|
||
score_columns = [
|
||
'Informatika', 'Fisika', 'Kimia', 'Biologi', 'BIG Lanjut',
|
||
'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi', 'Geografi'
|
||
]
|
||
for column_name in score_columns:
|
||
if column_name in df.columns:
|
||
df[column_name] = df[column_name].apply(self.format_nilai)
|
||
|
||
# Dialog untuk memilih lokasi penyimpanan
|
||
from datetime import datetime
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
file_path, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"Simpan File Excel",
|
||
f"Hasil_Prediksi_Jurusan_{timestamp}.xlsx",
|
||
"Excel Files (*.xlsx);;All Files (*)"
|
||
)
|
||
|
||
if file_path:
|
||
# Pastikan file path berakhiran .xlsx
|
||
if not file_path.endswith('.xlsx'):
|
||
file_path += '.xlsx'
|
||
|
||
# Export ke Excel dengan formatting
|
||
with pd.ExcelWriter(file_path, engine='openpyxl') as writer:
|
||
df.to_excel(writer, sheet_name='Hasil Prediksi', index=False)
|
||
|
||
# Get worksheet untuk formatting
|
||
worksheet = writer.sheets['Hasil Prediksi']
|
||
|
||
# Format header dengan openpyxl
|
||
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
|
||
|
||
# Define styles dengan border hitam yang tebal
|
||
header_font = Font(bold=True, size=11)
|
||
header_fill = PatternFill(start_color="D7E4BC", end_color="D7E4BC", fill_type="solid")
|
||
# Gunakan medium border untuk semua garis tabel
|
||
black_border = Border(
|
||
left=Side(style='medium', color='000000'),
|
||
right=Side(style='medium', color='000000'),
|
||
top=Side(style='medium', color='000000'),
|
||
bottom=Side(style='medium', color='000000')
|
||
)
|
||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||
left_alignment = Alignment(horizontal='left', vertical='center')
|
||
|
||
# Apply header formatting - baris pertama
|
||
for col in worksheet[1]:
|
||
col.font = header_font
|
||
col.fill = header_fill
|
||
col.border = black_border
|
||
col.alignment = center_alignment
|
||
|
||
# Kolom yang perlu center alignment di data
|
||
center_columns = ['No', 'Informatika', 'Fisika', 'Kimia', 'Biologi', 'BIG Lanjut',
|
||
'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi', 'Geografi']
|
||
|
||
# Get column indices untuk alignment
|
||
header_row = worksheet[1]
|
||
column_indices = {}
|
||
for idx, cell in enumerate(header_row, 1):
|
||
if cell.value in center_columns:
|
||
column_indices[cell.value] = idx
|
||
|
||
# Apply formatting ke semua data cells
|
||
for row_num in range(2, worksheet.max_row + 1):
|
||
for col_num in range(1, worksheet.max_column + 1):
|
||
cell = worksheet.cell(row=row_num, column=col_num)
|
||
# Apply black border ke semua cell
|
||
cell.border = black_border
|
||
|
||
# Get header value untuk cell ini
|
||
header_cell = worksheet.cell(row=1, column=col_num)
|
||
if header_cell.value in center_columns:
|
||
cell.alignment = center_alignment
|
||
else:
|
||
cell.alignment = left_alignment
|
||
|
||
# Auto-adjust column width
|
||
for column in worksheet.columns:
|
||
max_length = 0
|
||
column_letter = column[0].column_letter
|
||
|
||
for cell in column:
|
||
try:
|
||
if len(str(cell.value)) > max_length:
|
||
max_length = len(str(cell.value))
|
||
except:
|
||
pass
|
||
|
||
adjusted_width = min(max_length + 2, 50)
|
||
worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
|
||
CustomNotificationDialog.show_success(
|
||
self,
|
||
'Sukses!',
|
||
f"Data berhasil diekspor ke:\n{file_path}\n\nTotal {len(df)} data diekspor.",
|
||
'OK'
|
||
)
|
||
self.statusBar().showMessage(f'Data berhasil diekspor ke Excel: {len(df)} records')
|
||
|
||
except Exception as e:
|
||
CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mengekspor data:\n{str(e)}', 'OK')
|
||
self.statusBar().showMessage(f'Export gagal: {str(e)}')
|
||
|
||
class PerformanceDialog(QDialog):
|
||
def __init__(self, classifier, parent=None):
|
||
super().__init__(parent)
|
||
self.classifier = classifier
|
||
self.initUI()
|
||
|
||
def initUI(self):
|
||
self.setWindowTitle('Model Performance Report')
|
||
self.setGeometry(100, 100, 1200, 900) # Increased height for vertical layout
|
||
|
||
# Main vertical layout
|
||
main_layout = QVBoxLayout()
|
||
main_layout.setSpacing(15)
|
||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||
|
||
# Title
|
||
title_label = QLabel("📊 Model Performance Report")
|
||
title_label.setAlignment(Qt.AlignCenter)
|
||
title_label.setStyleSheet("""
|
||
font-weight: bold;
|
||
font-size: 18px;
|
||
margin-bottom: 15px;
|
||
color: #2c3e50;
|
||
background-color: #ecf0f1;
|
||
padding: 10px;
|
||
border-radius: 5px;
|
||
""")
|
||
main_layout.addWidget(title_label)
|
||
|
||
# Content layout (vertical with 3 sections)
|
||
content_layout = QVBoxLayout()
|
||
content_layout.setSpacing(20)
|
||
|
||
# Section 1: Confusion Matrix + Explanation
|
||
confusion_section = QHBoxLayout()
|
||
confusion_section.setSpacing(15)
|
||
|
||
# Left - Confusion Matrix
|
||
confusion_left = QWidget()
|
||
confusion_left.setFixedWidth(500)
|
||
confusion_left_layout = QVBoxLayout(confusion_left)
|
||
confusion_left_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
confusion_label = QLabel("🔄 Confusion Matrix")
|
||
confusion_label.setAlignment(Qt.AlignCenter)
|
||
confusion_label.setStyleSheet("""
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
color: #34495e;
|
||
background-color: #e8f4fd;
|
||
padding: 8px;
|
||
border-radius: 3px;
|
||
""")
|
||
confusion_left_layout.addWidget(confusion_label)
|
||
|
||
# Create matplotlib figure for confusion matrix
|
||
self.figure = Figure(figsize=(5, 4), dpi=80)
|
||
self.figure.patch.set_facecolor('white')
|
||
self.canvas = FigureCanvas(self.figure)
|
||
self.canvas.setMinimumHeight(320)
|
||
confusion_left_layout.addWidget(self.canvas)
|
||
|
||
# Right - Confusion Matrix Explanation
|
||
confusion_right = QWidget()
|
||
confusion_right_layout = QVBoxLayout(confusion_right)
|
||
confusion_right_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
confusion_exp_label = QLabel("📖 Penjelasan Confusion Matrix")
|
||
confusion_exp_label.setAlignment(Qt.AlignCenter)
|
||
confusion_exp_label.setStyleSheet("""
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
color: #34495e;
|
||
background-color: #f0f8ff;
|
||
padding: 8px;
|
||
border-radius: 3px;
|
||
""")
|
||
confusion_right_layout.addWidget(confusion_exp_label)
|
||
|
||
self.confusion_explanation = QTextEdit()
|
||
self.confusion_explanation.setReadOnly(True)
|
||
self.confusion_explanation.setFont(QFont("Segoe UI", 10))
|
||
self.confusion_explanation.setMaximumHeight(350)
|
||
self.confusion_explanation.setStyleSheet("""
|
||
QTextEdit {
|
||
background-color: #f8f9fa;
|
||
border: 2px solid #dee2e6;
|
||
border-radius: 5px;
|
||
padding: 12px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
line-height: 1.4;
|
||
}
|
||
""")
|
||
confusion_right_layout.addWidget(self.confusion_explanation)
|
||
|
||
confusion_section.addWidget(confusion_left)
|
||
confusion_section.addWidget(confusion_right)
|
||
content_layout.addLayout(confusion_section)
|
||
|
||
# Section 2: ROC Curves + Explanation
|
||
roc_section = QHBoxLayout()
|
||
roc_section.setSpacing(15)
|
||
|
||
# Left - ROC Curves
|
||
roc_left = QWidget()
|
||
roc_left.setFixedWidth(500)
|
||
roc_left_layout = QVBoxLayout(roc_left)
|
||
roc_left_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
roc_label = QLabel("📈 ROC AUC Curves")
|
||
roc_label.setAlignment(Qt.AlignCenter)
|
||
roc_label.setStyleSheet("""
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
color: #34495e;
|
||
background-color: #fff3e0;
|
||
padding: 8px;
|
||
border-radius: 3px;
|
||
""")
|
||
roc_left_layout.addWidget(roc_label)
|
||
|
||
# Create matplotlib figure for ROC curve
|
||
self.roc_figure = Figure(figsize=(5, 4), dpi=80)
|
||
self.roc_figure.patch.set_facecolor('white')
|
||
self.roc_canvas = FigureCanvas(self.roc_figure)
|
||
self.roc_canvas.setMinimumHeight(320)
|
||
roc_left_layout.addWidget(self.roc_canvas)
|
||
|
||
# Right - ROC Explanation
|
||
roc_right = QWidget()
|
||
roc_right_layout = QVBoxLayout(roc_right)
|
||
roc_right_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
roc_exp_label = QLabel("📖 Penjelasan ROC AUC")
|
||
roc_exp_label.setAlignment(Qt.AlignCenter)
|
||
roc_exp_label.setStyleSheet("""
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
color: #34495e;
|
||
background-color: #fffaf0;
|
||
padding: 8px;
|
||
border-radius: 3px;
|
||
""")
|
||
roc_right_layout.addWidget(roc_exp_label)
|
||
|
||
self.roc_explanation = QTextEdit()
|
||
self.roc_explanation.setReadOnly(True)
|
||
self.roc_explanation.setFont(QFont("Segoe UI", 10))
|
||
self.roc_explanation.setMaximumHeight(350)
|
||
self.roc_explanation.setStyleSheet("""
|
||
QTextEdit {
|
||
background-color: #f8f9fa;
|
||
border: 2px solid #dee2e6;
|
||
border-radius: 5px;
|
||
padding: 12px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
line-height: 1.4;
|
||
}
|
||
""")
|
||
roc_right_layout.addWidget(self.roc_explanation)
|
||
|
||
roc_section.addWidget(roc_left)
|
||
roc_section.addWidget(roc_right)
|
||
content_layout.addLayout(roc_section)
|
||
|
||
# Section 3: Classification Report + Explanation
|
||
class_section = QHBoxLayout()
|
||
class_section.setSpacing(15)
|
||
|
||
# Left - Classification Report
|
||
class_left = QWidget()
|
||
class_left.setFixedWidth(500)
|
||
class_left_layout = QVBoxLayout(class_left)
|
||
class_left_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
class_label = QLabel("📋 Classification Report")
|
||
class_label.setAlignment(Qt.AlignCenter)
|
||
class_label.setStyleSheet("""
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
color: #34495e;
|
||
background-color: #e8f6f3;
|
||
padding: 8px;
|
||
border-radius: 3px;
|
||
""")
|
||
class_left_layout.addWidget(class_label)
|
||
|
||
# Text area for classification report
|
||
self.report_text = QTextEdit()
|
||
self.report_text.setReadOnly(True)
|
||
self.report_text.setFont(QFont("Consolas", 10))
|
||
self.report_text.setMaximumHeight(300)
|
||
self.report_text.setStyleSheet("""
|
||
QTextEdit {
|
||
background-color: #f8f9fa;
|
||
border: 2px solid #dee2e6;
|
||
border-radius: 5px;
|
||
padding: 12px;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
line-height: 1.3;
|
||
}
|
||
""")
|
||
class_left_layout.addWidget(self.report_text)
|
||
|
||
# Right - Classification Report Explanation
|
||
class_right = QWidget()
|
||
class_right_layout = QVBoxLayout(class_right)
|
||
class_right_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
class_exp_label = QLabel("📖 Penjelasan Classification Report")
|
||
class_exp_label.setAlignment(Qt.AlignCenter)
|
||
class_exp_label.setStyleSheet("""
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
color: #34495e;
|
||
background-color: #f0fff0;
|
||
padding: 8px;
|
||
border-radius: 3px;
|
||
""")
|
||
class_right_layout.addWidget(class_exp_label)
|
||
|
||
self.class_explanation = QTextEdit()
|
||
self.class_explanation.setReadOnly(True)
|
||
self.class_explanation.setFont(QFont("Segoe UI", 10))
|
||
self.class_explanation.setMaximumHeight(300)
|
||
self.class_explanation.setStyleSheet("""
|
||
QTextEdit {
|
||
background-color: #f8f9fa;
|
||
border: 2px solid #dee2e6;
|
||
border-radius: 5px;
|
||
padding: 12px;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
line-height: 1.4;
|
||
}
|
||
""")
|
||
class_right_layout.addWidget(self.class_explanation)
|
||
|
||
class_section.addWidget(class_left)
|
||
class_section.addWidget(class_right)
|
||
content_layout.addLayout(class_section)
|
||
|
||
# Add content to main layout
|
||
main_layout.addLayout(content_layout)
|
||
|
||
# Close button at bottom
|
||
btn_layout = QHBoxLayout()
|
||
btn_layout.addStretch()
|
||
|
||
btn_close = QPushButton("✖️ Close")
|
||
btn_close.setFixedSize(120, 40)
|
||
btn_close.clicked.connect(self.close)
|
||
btn_close.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #e74c3c;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
font-weight: bold;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
border: none;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #c0392b;
|
||
}
|
||
QPushButton:pressed {
|
||
background-color: #a93226;
|
||
}
|
||
""")
|
||
btn_layout.addWidget(btn_close)
|
||
btn_layout.addStretch()
|
||
|
||
main_layout.addLayout(btn_layout)
|
||
|
||
self.setLayout(main_layout)
|
||
|
||
# Generate performance report
|
||
self.generate_performance_report()
|
||
|
||
def generate_performance_report(self):
|
||
"""Generate confusion matrix and classification report"""
|
||
try:
|
||
from sklearn.metrics import confusion_matrix, classification_report
|
||
import numpy as np
|
||
|
||
# Get test data predictions
|
||
|
||
data_path = config.DATASET_PATH
|
||
if os.path.exists(data_path):
|
||
test_path = getattr(config, 'TEST_DATASET_PATH', None)
|
||
if test_path and os.path.exists(test_path):
|
||
X_test, y_test = self.classifier.prepare_data(test_path)
|
||
else:
|
||
X, y = self.classifier.prepare_data(data_path)
|
||
from sklearn.model_selection import train_test_split
|
||
_, X_test, _, y_test = train_test_split(
|
||
X, y, test_size=config.TEST_SIZE, random_state=config.RANDOM_STATE, stratify=y
|
||
)
|
||
|
||
# Scale test data
|
||
X_test_scaled = self.classifier.scaler.transform(X_test)
|
||
y_pred = self.classifier.knn.predict(X_test_scaled)
|
||
y_pred_proba = self.classifier.knn.predict_proba(X_test_scaled)
|
||
|
||
# Generate confusion matrix
|
||
cm = confusion_matrix(y_test, y_pred)
|
||
self.plot_confusion_matrix(cm, sorted(set(y_test)))
|
||
self.display_confusion_matrix_explanation()
|
||
|
||
# Generate ROC curve
|
||
self.plot_roc_curve(y_test, y_pred_proba)
|
||
self.display_roc_explanation()
|
||
|
||
# Generate classification report
|
||
report = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
|
||
self.display_classification_report(report)
|
||
|
||
else:
|
||
self.show_error("No training data available")
|
||
|
||
except Exception as e:
|
||
self.show_error(f"Error generating performance report: {str(e)}")
|
||
|
||
def plot_confusion_matrix(self, cm, classes):
|
||
"""Plot confusion matrix"""
|
||
# Clear previous plot
|
||
self.figure.clear()
|
||
|
||
ax = self.figure.add_subplot(111)
|
||
im = ax.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues, aspect='auto')
|
||
|
||
# Add colorbar with proper sizing
|
||
cbar = self.figure.colorbar(im, ax=ax, shrink=0.8)
|
||
cbar.ax.tick_params(labelsize=9)
|
||
|
||
# Set labels with better formatting
|
||
ax.set_xticks(np.arange(cm.shape[1]))
|
||
ax.set_yticks(np.arange(cm.shape[0]))
|
||
ax.set_xticklabels(classes, fontsize=9)
|
||
ax.set_yticklabels(classes, fontsize=9)
|
||
|
||
# Set axis labels
|
||
ax.set_ylabel('True Labels', fontsize=11, fontweight='bold')
|
||
ax.set_xlabel('Predicted Labels', fontsize=11, fontweight='bold')
|
||
|
||
# Rotate the tick labels and set their alignment
|
||
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
|
||
|
||
# Add text annotations with better formatting
|
||
thresh = cm.max() / 2.
|
||
for i in range(cm.shape[0]):
|
||
for j in range(cm.shape[1]):
|
||
text_color = "white" if cm[i, j] > thresh else "black"
|
||
ax.text(j, i, format(cm[i, j], 'd'),
|
||
ha="center", va="center",
|
||
color=text_color,
|
||
fontweight='bold',
|
||
fontsize=10)
|
||
|
||
# Set title with better spacing
|
||
ax.set_title("Confusion Matrix", fontweight='bold', fontsize=12, pad=15)
|
||
|
||
# Adjust layout to prevent clipping
|
||
self.figure.subplots_adjust(left=0.15, bottom=0.2, right=0.85, top=0.9)
|
||
self.canvas.draw()
|
||
|
||
def plot_roc_curve(self, y_test, y_pred_proba):
|
||
"""Plot ROC AUC curve for performance dialog"""
|
||
try:
|
||
# Clear previous plot
|
||
self.roc_figure.clear()
|
||
|
||
# Get unique classes from classifier
|
||
classes = self.classifier.knn.classes_
|
||
n_classes = len(classes)
|
||
|
||
# Handle single class case
|
||
if n_classes == 2:
|
||
# For binary classification, use probability of positive class
|
||
y_score = y_pred_proba[:, 1]
|
||
y_true = (y_test == classes[1]).astype(int)
|
||
|
||
# Compute ROC curve and ROC area
|
||
fpr, tpr, _ = roc_curve(y_true, y_score)
|
||
roc_auc = auc(fpr, tpr)
|
||
|
||
# Plot ROC curve
|
||
ax = self.roc_figure.add_subplot(111)
|
||
ax.plot(fpr, tpr, color='darkorange', lw=2,
|
||
label=f'ROC curve (AUC = {roc_auc:.3f})')
|
||
ax.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--',
|
||
label='Random Classifier')
|
||
ax.set_xlim([0.0, 1.0])
|
||
ax.set_ylim([0.0, 1.05])
|
||
ax.set_xlabel('False Positive Rate', fontsize=9)
|
||
ax.set_ylabel('True Positive Rate', fontsize=9)
|
||
ax.set_title('ROC Curve (Binary)', fontweight='bold', fontsize=10)
|
||
ax.legend(loc="lower right", fontsize=8)
|
||
ax.grid(True, alpha=0.3)
|
||
|
||
else:
|
||
# Multi-class ROC
|
||
ax = self.roc_figure.add_subplot(111)
|
||
|
||
# Compute ROC curve and ROC area for each class
|
||
fpr = dict()
|
||
tpr = dict()
|
||
roc_auc = dict()
|
||
|
||
for i in range(n_classes):
|
||
# Get true binary labels for this class
|
||
y_true_class = (y_test == classes[i]).astype(int)
|
||
# Get predicted probabilities for this class
|
||
y_score_class = y_pred_proba[:, i]
|
||
|
||
fpr[i], tpr[i], _ = roc_curve(y_true_class, y_score_class)
|
||
roc_auc[i] = auc(fpr[i], tpr[i])
|
||
|
||
# Plot ROC curve for each class
|
||
colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown']
|
||
for i in range(n_classes):
|
||
color = colors[i % len(colors)]
|
||
ax.plot(fpr[i], tpr[i], color=color, lw=1.5,
|
||
label=f'Paket {classes[i]} (AUC = {roc_auc[i]:.3f})')
|
||
|
||
# Plot random classifier line
|
||
ax.plot([0, 1], [0, 1], 'k--', lw=1.5, label='Random', alpha=0.8)
|
||
|
||
ax.set_xlim([0.0, 1.0])
|
||
ax.set_ylim([0.0, 1.05])
|
||
ax.set_xlabel('False Positive Rate', fontsize=9)
|
||
ax.set_ylabel('True Positive Rate', fontsize=9)
|
||
ax.set_title('Multi-class ROC Curves', fontweight='bold', fontsize=10)
|
||
|
||
# Position legend to avoid overlap
|
||
ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), fontsize=7)
|
||
ax.grid(True, alpha=0.3)
|
||
|
||
# Adjust layout
|
||
self.roc_figure.subplots_adjust(left=0.15, bottom=0.15, right=0.7, top=0.9)
|
||
self.roc_canvas.draw()
|
||
|
||
except Exception as e:
|
||
print(f"Error plotting ROC curve: {e}")
|
||
# Show error in ROC canvas
|
||
ax = self.roc_figure.add_subplot(111)
|
||
ax.text(0.5, 0.5, f'Error plotting ROC curve:\\n{str(e)}',
|
||
ha='center', va='center', transform=ax.transAxes,
|
||
fontsize=9, color='red')
|
||
ax.set_title("ROC Plot Error", fontweight='bold')
|
||
self.roc_canvas.draw()
|
||
|
||
def display_confusion_matrix_explanation(self):
|
||
"""Display explanation for confusion matrix"""
|
||
explanation_text = """
|
||
🔍 CONFUSION MATRIX - Analisis Prediksi vs Aktual
|
||
|
||
Confusion Matrix adalah tabel yang menunjukkan performa klasifikasi dengan membandingkan prediksi model terhadap label aktual.
|
||
|
||
📊 INTERPRETASI:
|
||
• Baris (True Labels): Kelas sebenarnya dari data
|
||
• Kolom (Predicted Labels): Kelas yang diprediksi oleh model
|
||
• Diagonal utama: Prediksi yang BENAR
|
||
• Di luar diagonal: Prediksi yang SALAH
|
||
|
||
🎯 METRIK PENTING:
|
||
• True Positive (TP): Prediksi benar untuk kelas positif
|
||
• True Negative (TN): Prediksi benar untuk kelas negatif
|
||
• False Positive (FP): Salah prediksi sebagai positif
|
||
• False Negative (FN): Salah prediksi sebagai negatif
|
||
|
||
💡 CARA MEMBACA:
|
||
• Nilai tinggi di diagonal = performa bagus
|
||
• Nilai tinggi di luar diagonal = banyak kesalahan
|
||
• Semakin gelap warna biru = semakin banyak prediksi
|
||
|
||
✅ TARGET IDEAL:
|
||
Confusion matrix yang baik memiliki nilai tinggi di diagonal utama dan nilai rendah di tempat lain, menunjukkan model dapat membedakan kelas dengan akurat.
|
||
"""
|
||
|
||
self.confusion_explanation.setPlainText(explanation_text.strip())
|
||
|
||
def display_roc_explanation(self):
|
||
"""Display explanation for ROC curves"""
|
||
explanation_text = """
|
||
📈 ROC AUC CURVES - Evaluasi Performa Klasifikasi
|
||
|
||
ROC (Receiver Operating Characteristic) Curve menunjukkan performa model klasifikasi pada berbagai threshold dengan plot TPR vs FPR.
|
||
|
||
📊 KOMPONEN KURVA:
|
||
• True Positive Rate (TPR/Recall): TP/(TP+FN)
|
||
• False Positive Rate (FPR): FP/(FP+TN)
|
||
• AUC (Area Under Curve): Luas di bawah kurva ROC
|
||
|
||
🎯 INTERPRETASI AUC:
|
||
• AUC = 1.0: Klasifikasi sempurna
|
||
• AUC = 0.9-1.0: Performa sangat baik
|
||
• AUC = 0.8-0.9: Performa baik
|
||
• AUC = 0.7-0.8: Performa sedang
|
||
• AUC = 0.6-0.7: Performa buruk
|
||
• AUC = 0.5: Tidak lebih baik dari tebakan acak
|
||
|
||
📍 ANALISIS KURVA:
|
||
• Kurva mendekati sudut kiri atas = baik
|
||
• Kurva mendekati garis diagonal = buruk
|
||
• Garis putus-putus = Random Classifier (baseline)
|
||
|
||
🏆 MULTI-CLASS:
|
||
Untuk klasifikasi multi-class, setiap paket memiliki kurva ROC tersendiri dengan strategi One-vs-All.
|
||
|
||
✅ TARGET IDEAL:
|
||
AUC > 0.8 untuk setiap kelas menunjukkan model dapat membedakan kelas dengan baik.
|
||
"""
|
||
|
||
self.roc_explanation.setPlainText(explanation_text.strip())
|
||
|
||
def display_classification_report(self, report_dict):
|
||
"""Display classification report in table format similar to sklearn output"""
|
||
# Create formatted text report with better alignment
|
||
header = f"{'':>15} {'precision':>12} {'recall':>12} {'f1-score':>12} {'support':>12}\n"
|
||
separator = "=" * 75 + "\n"
|
||
|
||
report_text = header + separator
|
||
|
||
# Add metrics for each class (sorted by class number)
|
||
classes = [key for key in report_dict.keys() if key not in ['accuracy', 'macro avg', 'weighted avg']]
|
||
|
||
# Sort classes numerically if they are numbers, otherwise alphabetically
|
||
try:
|
||
sorted_classes = sorted(classes, key=lambda x: int(x))
|
||
except (ValueError, TypeError):
|
||
sorted_classes = sorted(classes)
|
||
|
||
for class_name in sorted_classes:
|
||
metrics = report_dict[class_name]
|
||
class_label = f"Paket {class_name}" if str(class_name).isdigit() else str(class_name)
|
||
report_text += f"{class_label:>15} {metrics['precision']:>12.2f} {metrics['recall']:>12.2f} {metrics['f1-score']:>12.2f} {int(metrics['support']):>12}\n"
|
||
|
||
# Add separator line
|
||
report_text += "\n" + "-" * 75 + "\n"
|
||
|
||
# Add accuracy
|
||
accuracy = report_dict['accuracy']
|
||
total_support = sum(report_dict[key]['support'] for key in classes)
|
||
report_text += f"{'accuracy':>15} {'':>12} {'':>12} {accuracy:>12.2f} {int(total_support):>12}\n"
|
||
|
||
# Add macro avg
|
||
macro_avg = report_dict['macro avg']
|
||
report_text += f"{'macro avg':>15} {macro_avg['precision']:>12.2f} {macro_avg['recall']:>12.2f} {macro_avg['f1-score']:>12.2f} {int(macro_avg['support']):>12}\n"
|
||
|
||
# Add weighted avg
|
||
weighted_avg = report_dict['weighted avg']
|
||
report_text += f"{'weighted avg':>15} {weighted_avg['precision']:>12.2f} {weighted_avg['recall']:>12.2f} {weighted_avg['f1-score']:>12.2f} {int(weighted_avg['support']):>12}\n"
|
||
|
||
# Add some explanation at the bottom
|
||
report_text += "\n" + "=" * 75 + "\n"
|
||
report_text += "\nPenjelasan:\n"
|
||
report_text += "• Precision: Proporsi prediksi positif yang benar\n"
|
||
report_text += "• Recall: Proporsi kelas aktual yang diprediksi dengan benar\n"
|
||
report_text += "• F1-score: Rata-rata harmonik dari precision dan recall\n"
|
||
report_text += "• Support: Jumlah sampel untuk setiap kelas\n"
|
||
report_text += "• Macro avg: Rata-rata tanpa mempertimbangkan ketidakseimbangan kelas\n"
|
||
report_text += "• Weighted avg: Rata-rata dengan mempertimbangkan support setiap kelas"
|
||
|
||
self.report_text.setPlainText(report_text)
|
||
|
||
# Also display detailed explanation
|
||
self.display_classification_explanation()
|
||
|
||
def display_classification_explanation(self):
|
||
"""Display detailed explanation for classification report"""
|
||
explanation_text = """
|
||
📋 CLASSIFICATION REPORT - Analisis Detail Performa
|
||
|
||
Classification Report memberikan metrik evaluasi yang komprehensif untuk setiap kelas dalam klasifikasi multi-class.
|
||
|
||
🎯 METRIK UTAMA:
|
||
|
||
1️⃣ PRECISION (Ketepatan)
|
||
• Formula: TP / (TP + FP)
|
||
• Arti: Dari semua yang diprediksi positif, berapa yang benar-benar positif?
|
||
• Tinggi = sedikit False Positive
|
||
|
||
2️⃣ RECALL (Sensitivitas/TPR)
|
||
• Formula: TP / (TP + FN)
|
||
• Arti: Dari semua yang sebenarnya positif, berapa yang berhasil diprediksi?
|
||
• Tinggi = sedikit False Negative
|
||
|
||
3️⃣ F1-SCORE (Keseimbangan)
|
||
• Formula: 2 × (Precision × Recall) / (Precision + Recall)
|
||
• Arti: Rata-rata harmonik precision dan recall
|
||
• Baik ketika precision dan recall seimbang tinggi
|
||
|
||
📊 RATA-RATA:
|
||
|
||
▶️ MACRO AVERAGE:
|
||
• Rata-rata sederhana dari semua kelas
|
||
• Tidak mempertimbangkan ketidakseimbangan data
|
||
• Memberikan bobot sama untuk setiap kelas
|
||
|
||
▶️ WEIGHTED AVERAGE:
|
||
• Rata-rata berbobot berdasarkan support
|
||
• Mempertimbangkan jumlah sampel per kelas
|
||
• Lebih representatif untuk data tidak seimbang
|
||
|
||
✅ INTERPRETASI SKOR:
|
||
• 0.9-1.0: Sangat Baik
|
||
• 0.8-0.9: Baik
|
||
• 0.7-0.8: Cukup Baik
|
||
• 0.6-0.7: Perlu Perbaikan
|
||
• <0.6: Kurang Baik
|
||
"""
|
||
|
||
self.class_explanation.setPlainText(explanation_text.strip())
|
||
|
||
def show_error(self, message):
|
||
"""Show error message"""
|
||
ax = self.figure.add_subplot(111)
|
||
ax.text(0.5, 0.5, message,
|
||
ha='center', va='center', transform=ax.transAxes)
|
||
self.canvas.draw()
|
||
|
||
ax2 = self.roc_figure.add_subplot(111)
|
||
ax2.text(0.5, 0.5, message,
|
||
ha='center', va='center', transform=ax2.transAxes)
|
||
self.roc_canvas.draw()
|
||
|
||
self.report_text.setPlainText(f"Error: {message}")
|
||
self.confusion_explanation.setPlainText(f"Error: {message}")
|
||
self.roc_explanation.setPlainText(f"Error: {message}")
|
||
self.class_explanation.setPlainText(f"Error: {message}")
|
||
|