E41222753_NinikYuniarsih_Ju.../gui/main_window.py

4508 lines
188 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}")