commit 68338dc58c3144e8a5c5cce96fa34a9849cab1a7 Author: ninikyuniarsih Date: Sun May 24 13:17:19 2026 +0700 Commit Pertama diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19be822 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Build files +dist/ +build/ + +# Python cache +__pycache__/ +*.pyc + +# Virtual environment +venv/ +env/ + +# VS Code +.vscode/ + +# PyInstaller +*.spec \ No newline at end of file diff --git a/DEPLOY_EXE.md b/DEPLOY_EXE.md new file mode 100644 index 0000000..5050cd7 --- /dev/null +++ b/DEPLOY_EXE.md @@ -0,0 +1,60 @@ +# Deploy ke EXE (Windows) + +## 1) Prasyarat +- Python 3.9+ terpasang +- PostgreSQL aktif dan database `db_klasifikasi` tersedia +- Dependensi bisa di-install via internet (minimal saat build pertama) + +## 2) Build EXE +Dari root project, jalankan: + +```bat +build_exe.bat +``` + +Script akan otomatis: +- install/update dependency dari `requirements.txt` +- install `pyinstaller` +- build aplikasi menjadi EXE +- menyertakan folder `data`, `models`, dan `img` + +## 3) Hasil Build +Output ada di: + +```text +dist/jurusify/ +``` + +File utama yang dijalankan: + +```text +dist/jurusify/jurusify.exe +``` + +## 4) Catatan penting +- Aplikasi ini memakai PostgreSQL (`db/database.py`). +- Saat dipindah ke komputer lain, pastikan: + - PostgreSQL terinstall + - database, user, password sesuai konfigurasi di `db/database.py` + - port/host database bisa diakses + +## 5) Build ulang +Jika ada perubahan kode, jalankan lagi `build_exe.bat`. + +## 6) Paket yang dibagikan ke user +Untuk user akhir, kirimkan: + +1. Folder aplikasi EXE: +- `dist/jurusify/` + +2. File setup database: +- `setup_db.bat` +- `db/schema.sql` + +3. Panduan user: +- `USER_INSTALL.md` + +Dengan ini user cukup: +1. Install PostgreSQL +2. Jalankan `setup_db.bat` +3. Jalankan `jurusify.exe` diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/USER_INSTALL.md b/USER_INSTALL.md new file mode 100644 index 0000000..4ed6b75 --- /dev/null +++ b/USER_INSTALL.md @@ -0,0 +1,36 @@ +# Panduan User (PostgreSQL + EXE) + +## Yang harus diberikan ke user +1. Folder hasil build EXE: +- `dist/jurusify/` + +2. File setup database: +- `setup_db.bat` +- `db/schema.sql` + +## Langkah instalasi di komputer user +1. Install PostgreSQL (versi 13+ disarankan). +2. Buka Command Prompt sebagai user biasa. +3. Masuk ke folder aplikasi, lalu jalankan: + +```bat +setup_db.bat +``` + +Catatan: +- Jika user PostgreSQL bukan `postgres`, jalankan: + +```bat +setup_db.bat nama_user_postgres +``` + +4. Setelah database selesai dibuat, jalankan: + +```text +dist/jurusify/jurusify.exe +``` + +## Penting +- Pastikan service PostgreSQL dalam keadaan running. +- Kredensial koneksi database aplikasi ada di `db/database.py`. +- Jika host/user/password berbeda, sesuaikan dulu `DB_CONFIG` di `db/database.py`, lalu build ulang EXE. diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 0000000..aadfcc1 --- /dev/null +++ b/build_exe.bat @@ -0,0 +1,97 @@ +@echo off +setlocal + +cd /d "%~dp0" + +echo ================================================ +echo Build EXE - Sistem Klasifikasi Jurusan Siswa +echo ================================================ + +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Python tidak ditemukan di PATH. + pause + exit /b 1 +) + +python -m pip install --upgrade pip +if errorlevel 1 ( + echo [ERROR] Gagal update pip. + pause + exit /b 1 +) + +python -m pip install -r requirements.txt +if errorlevel 1 ( + echo [ERROR] Gagal install dependency dari requirements.txt. + pause + exit /b 1 +) + +python -m pip install pyinstaller +if errorlevel 1 ( + echo [ERROR] Gagal install pyinstaller. + pause + exit /b 1 +) + +echo Menutup proses jurusify.exe jika masih berjalan... +taskkill /F /IM jurusify.exe >nul 2>&1 + +echo Menyiapkan ikon logo_sekolah.ico... +python convert_logo.py + +if not exist "img\logo_sekolah.png" ( + echo [ERROR] Ikon wajib tidak ditemukan: img\logo_sekolah.png + echo [ERROR] Build dibatalkan agar EXE tidak memakai ikon default. + pause + exit /b 1 +) + +if exist build ( + rmdir /s /q build + if exist build ( + echo [ERROR] Folder build tidak bisa dihapus. Tutup aplikasi/Explorer yang sedang membuka file build lalu coba lagi. + pause + exit /b 1 + ) +) + +if exist dist ( + rmdir /s /q dist + if exist dist ( + echo [ERROR] Folder dist tidak bisa dihapus. Pastikan jurusify.exe sudah ditutup lalu coba lagi. + pause + exit /b 1 + ) +) + +if exist "SistemKlasifikasiJurusan.spec" del /q "SistemKlasifikasiJurusan.spec" +if exist "jurusify.spec" del /q "jurusify.spec" + +pyinstaller ^ + --noconfirm ^ + --clean ^ + --windowed ^ + --name "jurusify" ^ + --icon "%CD%\img\logo_sekolah.png" ^ + --add-data "data;data" ^ + --add-data "models;models" ^ + --add-data "img;img" ^ + main.py + +if errorlevel 1 ( + echo [ERROR] Build gagal. + pause + exit /b 1 +) + +echo. +echo [OK] Build selesai. +echo Hasil ada di: dist\jurusify\ +echo Jalankan: dist\jurusify\jurusify.exe +echo. +echo [PENTING] Jangan jalankan file EXE dari folder build\ +echo [PENTING] Jalankan EXE hanya dari folder dist\jurusify\ +echo. +pause diff --git a/build_run.log b/build_run.log new file mode 100644 index 0000000..315b313 --- /dev/null +++ b/build_run.log @@ -0,0 +1,197 @@ +================================================ +Build EXE - Sistem Klasifikasi Jurusan Siswa +================================================ +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +Requirement already satisfied: pip in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (26.0.1) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +Requirement already satisfied: PyQt5==5.15.10 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 1)) (5.15.10) +Requirement already satisfied: pandas==2.1.4 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 2)) (2.1.4) +Requirement already satisfied: numpy==1.24.3 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 3)) (1.24.3) +Requirement already satisfied: scikit-learn==1.3.2 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 4)) (1.3.2) +Requirement already satisfied: matplotlib==3.7.2 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 5)) (3.7.2) +Requirement already satisfied: seaborn==0.12.2 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 6)) (0.12.2) +Requirement already satisfied: psycopg2-binary==2.9.9 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 7)) (2.9.9) +Requirement already satisfied: joblib==1.3.2 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 8)) (1.3.2) +Requirement already satisfied: xlsxwriter==3.1.9 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 9)) (3.1.9) +Requirement already satisfied: openpyxl==3.1.2 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 10)) (3.1.2) +Requirement already satisfied: Pillow==10.4.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from -r requirements.txt (line 11)) (10.4.0) +Requirement already satisfied: PyQt5-sip<13,>=12.13 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from PyQt5==5.15.10->-r requirements.txt (line 1)) (12.17.2) +Requirement already satisfied: PyQt5-Qt5>=5.15.2 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from PyQt5==5.15.10->-r requirements.txt (line 1)) (5.15.2) +Requirement already satisfied: python-dateutil>=2.8.2 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pandas==2.1.4->-r requirements.txt (line 2)) (2.9.0.post0) +Requirement already satisfied: pytz>=2020.1 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pandas==2.1.4->-r requirements.txt (line 2)) (2025.2) +Requirement already satisfied: tzdata>=2022.1 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pandas==2.1.4->-r requirements.txt (line 2)) (2025.3) +Requirement already satisfied: scipy>=1.5.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from scikit-learn==1.3.2->-r requirements.txt (line 4)) (1.15.3) +Requirement already satisfied: threadpoolctl>=2.0.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from scikit-learn==1.3.2->-r requirements.txt (line 4)) (3.6.0) +Requirement already satisfied: contourpy>=1.0.1 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from matplotlib==3.7.2->-r requirements.txt (line 5)) (1.3.2) +Requirement already satisfied: cycler>=0.10 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from matplotlib==3.7.2->-r requirements.txt (line 5)) (0.12.1) +Requirement already satisfied: fonttools>=4.22.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from matplotlib==3.7.2->-r requirements.txt (line 5)) (4.61.1) +Requirement already satisfied: kiwisolver>=1.0.1 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from matplotlib==3.7.2->-r requirements.txt (line 5)) (1.4.9) +Requirement already satisfied: packaging>=20.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from matplotlib==3.7.2->-r requirements.txt (line 5)) (25.0) +Requirement already satisfied: pyparsing<3.1,>=2.3.1 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from matplotlib==3.7.2->-r requirements.txt (line 5)) (3.0.9) +Requirement already satisfied: et-xmlfile in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from openpyxl==3.1.2->-r requirements.txt (line 10)) (2.0.0) +Requirement already satisfied: six>=1.5 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from python-dateutil>=2.8.2->pandas==2.1.4->-r requirements.txt (line 2)) (1.17.0) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +Requirement already satisfied: pyinstaller in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (6.19.0) +Requirement already satisfied: altgraph in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pyinstaller) (0.17.5) +Requirement already satisfied: packaging>=22.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pyinstaller) (25.0) +Requirement already satisfied: pefile>=2022.5.30 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pyinstaller) (2024.8.26) +Requirement already satisfied: pyinstaller-hooks-contrib>=2026.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pyinstaller) (2026.4) +Requirement already satisfied: pywin32-ctypes>=0.2.1 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pyinstaller) (0.2.3) +Requirement already satisfied: setuptools>=42.0.0 in C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages (from pyinstaller) (65.5.0) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +WARNING: Ignoring invalid distribution ~cikit-learn (C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages) +Menutup proses jurusify.exe jika masih berjalan... +Menyiapkan ikon logo_sekolah.ico... +[OK] Berhasil convert: img\logo_sekolah.png -> img\logo_sekolah.ico +234 INFO: PyInstaller: 6.19.0, contrib hooks: 2026.4 +234 INFO: Python: 3.11.9 +253 INFO: Platform: Windows-10-10.0.26200-SP0 +253 INFO: Python environment: C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311 +253 INFO: wrote D:\klasifikasi_jurusan_siswa\jurusify.spec +257 INFO: Removing temporary files and cleaning cache in C:\Users\Ninik Yuniarsih\AppData\Local\pyinstaller +257 INFO: Module search paths (PYTHONPATH): +['C:\\Users\\Ninik ' + 'Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Scripts\\pyinstaller.exe', + 'C:\\Users\\Ninik ' + 'Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\python311.zip', + 'C:\\Users\\Ninik ' + 'Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\DLLs', + 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib', + 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311', + 'C:\\Users\\Ninik ' + 'Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages', + 'D:\\klasifikasi_jurusan_siswa'] +663 INFO: Appending 'datas' from .spec +663 INFO: checking Analysis +663 INFO: Building Analysis because Analysis-00.toc is non existent +663 INFO: Looking for Python shared library... +663 INFO: Using Python shared library: C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\python311.dll +663 INFO: Running Analysis Analysis-00.toc +663 INFO: Target bytecode optimization level: 0 +663 INFO: Initializing module dependency graph... +663 INFO: Initializing module graph hook caches... +746 INFO: Analyzing modules for base_library.zip ... +1530 INFO: Processing standard module hook 'hook-encodings.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +1946 INFO: Processing standard module hook 'hook-heapq.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +3230 INFO: Processing standard module hook 'hook-pickle.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +4742 INFO: Caching module dependency graph... +4787 INFO: Analyzing D:\klasifikasi_jurusan_siswa\main.py +4792 INFO: Processing standard module hook 'hook-PyQt5.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +5661 INFO: Processing standard module hook 'hook-PyQt5.QtWidgets.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +8879 INFO: Processing standard module hook 'hook-PyQt5.QtCore.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +11833 INFO: Processing standard module hook 'hook-PyQt5.QtGui.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +28945 INFO: Processing standard module hook 'hook-psycopg2.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +29060 INFO: Processing standard module hook 'hook-platform.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +29095 INFO: Processing standard module hook 'hook-pandas.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +31211 INFO: Processing standard module hook 'hook-sysconfig.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +31228 INFO: Processing standard module hook 'hook-numpy.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +31811 INFO: Processing standard module hook 'hook-difflib.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +31978 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +32161 INFO: Processing standard module hook 'hook-xml.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +32415 INFO: Processing standard module hook 'hook-_ctypes.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +34542 INFO: Processing standard module hook 'hook-pytz.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +35042 INFO: Processing standard module hook 'hook-scipy.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +35659 INFO: Processing standard module hook 'hook-scipy.linalg.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +35778 INFO: Processing standard module hook 'hook-scipy.special._ufuncs.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +35944 INFO: Processing standard module hook 'hook-scipy.special._ellip_harm_2.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +36311 INFO: Processing standard module hook 'hook-scipy.spatial._ckdtree.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +36323 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module' +36325 INFO: SetuptoolsInfo: initializing cached setuptools info... +37127 INFO: Processing standard module hook 'hook-matplotlib.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +37594 INFO: Processing pre-safe-import-module hook 'hook-packaging.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module' +37677 INFO: Processing pre-safe-import-module hook 'hook-gi.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module' +37760 INFO: Processing standard module hook 'hook-PIL.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +37910 INFO: Processing standard module hook 'hook-PIL.Image.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +38649 INFO: Processing standard module hook 'hook-PIL.ImageFilter.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +39508 INFO: Processing standard module hook 'hook-matplotlib.pyplot.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +40008 INFO: Processing standard module hook 'hook-matplotlib.backend_bases.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +40744 INFO: Processing standard module hook 'hook-dateutil.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +40810 INFO: Processing pre-safe-import-module hook 'hook-six.moves.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module' +41911 INFO: Processing standard module hook 'hook-matplotlib.backends.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +42193 INFO: Processing pre-safe-import-module hook 'hook-importlib_resources.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module' +43455 INFO: Processing standard module hook 'hook-scipy.sparse.csgraph.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +43811 INFO: Processing standard module hook 'hook-scipy.spatial.transform.rotation.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +44801 INFO: Processing standard module hook 'hook-scipy.stats._stats.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +46970 INFO: Processing standard module hook 'hook-pandas.io.formats.style.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +49431 INFO: Processing standard module hook 'hook-pandas.plotting.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +50104 INFO: Processing standard module hook 'hook-openpyxl.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +50501 INFO: Processing standard module hook 'hook-xml.etree.cElementTree.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +52187 INFO: Processing standard module hook 'hook-sqlite3.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +52450 INFO: Processing standard module hook 'hook-pandas.io.clipboard.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +52835 INFO: Processing standard module hook 'hook-xml.dom.domreg.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +53398 INFO: Processing standard module hook 'hook-sklearn.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +54244 INFO: Processing standard module hook 'hook-sklearn.utils.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +55049 INFO: Processing standard module hook 'hook-sklearn.metrics.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +57735 INFO: Processing standard module hook 'hook-sklearn.metrics.cluster.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +57927 INFO: Processing standard module hook 'hook-sklearn.cluster.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +57987 INFO: Processing standard module hook 'hook-sklearn.metrics.pairwise.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +58380 INFO: Processing standard module hook 'hook-sklearn.neighbors.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +58455 INFO: Processing standard module hook 'hook-sklearn.linear_model.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +59578 INFO: Processing pre-safe-import-module hook 'hook-win32com.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\pre_safe_import_module' +60305 INFO: Processing standard module hook 'hook-matplotlib.backends.backend_qtagg.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +60325 INFO: Processing standard module hook 'hook-matplotlib.backends.qt_compat.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +60327 INFO: hook-matplotlib.backends.qt_compat: selected 'PyQt5' as Qt bindings because hook for 'PyQt5' has been run before. +60394 INFO: Processing module hooks (post-graph stage)... +60395 WARNING: Hidden import "mx.DateTime" not found! +60397 INFO: Processing standard module hook 'hook-sklearn.tree.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +60444 INFO: Processing standard module hook 'hook-matplotlib.backends.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +60445 INFO: Matplotlib backend selection method: automatic discovery of used backends +60905 INFO: Trying determine the default backend as first importable candidate from the list: ['QtAgg', 'Qt5Agg', 'Gtk4Agg', 'Gtk3Agg', 'TkAgg', 'WxAgg'] +61555 INFO: Selected matplotlib backends: ['QtAgg'] +61998 INFO: Processing standard module hook 'hook-PIL.SpiderImagePlugin.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +62071 WARNING: Hidden import "scipy.special._cdflib" not found! +62090 INFO: Performing binary vs. data reclassification (1072 entries) +62631 INFO: Looking for ctypes DLLs +62820 INFO: Analyzing run-time hooks ... +62834 INFO: Including run-time hook 'pyi_rth_inspect.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +62834 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +62834 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +62842 INFO: Including run-time hook 'pyi_rth_pyqt5.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +62842 INFO: Processing pre-find-module-path hook 'hook-_pyi_rth_utils.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\pre_find_module_path' +62842 INFO: Processing standard module hook 'hook-_pyi_rth_utils.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks' +62850 INFO: Including run-time hook 'pyi_rth_mplconfig.py' from 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +62898 INFO: Creating base_library.zip... +62930 INFO: Looking for dynamic libraries +C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages\PyInstaller\building\build_main.py:227: UserWarning: The numpy.array_api submodule is still experimental. See NEP 47. + __import__(package) +65679 INFO: Extra DLL search directories (AddDllDirectory): ['C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyQt5\\Qt5\\bin', 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\pandas.libs', 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\numpy\\.libs', 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\scipy.libs', 'C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\matplotlib.libs'] +65679 INFO: Extra DLL search directories (PATH): ['C:\\Users\\Ninik Yuniarsih\\AppData\\Local\\Programs\\Python\\Python311\\Lib\\site-packages\\PyQt5\\Qt5\\bin'] +68486 INFO: Warnings written to D:\klasifikasi_jurusan_siswa\build\jurusify\warn-jurusify.txt +68648 INFO: Graph cross-reference written to D:\klasifikasi_jurusan_siswa\build\jurusify\xref-jurusify.html +68736 INFO: checking PYZ +68736 INFO: Building PYZ because PYZ-00.toc is non existent +68736 INFO: Building PYZ (ZlibArchive) D:\klasifikasi_jurusan_siswa\build\jurusify\PYZ-00.pyz +70311 INFO: Building PYZ (ZlibArchive) D:\klasifikasi_jurusan_siswa\build\jurusify\PYZ-00.pyz completed successfully. +70346 INFO: checking PKG +70346 INFO: Building PKG because PKG-00.toc is non existent +70346 INFO: Building PKG (CArchive) jurusify.pkg +70378 INFO: Building PKG (CArchive) jurusify.pkg completed successfully. +70378 INFO: Bootloader C:\Users\Ninik Yuniarsih\AppData\Local\Programs\Python\Python311\Lib\site-packages\PyInstaller\bootloader\Windows-64bit-intel\runw.exe +70378 INFO: checking EXE +70378 INFO: Building EXE because EXE-00.toc is non existent +70378 INFO: Building EXE from EXE-00.toc +70378 INFO: Copying bootloader EXE to D:\klasifikasi_jurusan_siswa\build\jurusify\jurusify.exe +70637 INFO: Copying icon to EXE +70699 INFO: Copying 0 resources to EXE +70699 INFO: Embedding manifest in EXE +70731 INFO: Appending PKG archive to EXE +70932 INFO: Fixing EXE headers +72100 INFO: Building EXE from EXE-00.toc completed successfully. +72115 INFO: checking COLLECT +72115 INFO: Building COLLECT because COLLECT-00.toc is non existent +72115 INFO: Building COLLECT COLLECT-00.toc +74874 INFO: Building COLLECT COLLECT-00.toc completed successfully. +74902 INFO: Build complete! The results are available in: D:\klasifikasi_jurusan_siswa\dist + +[OK] Build selesai. +Hasil ada di: dist\jurusify\ +Jalankan: dist\jurusify\jurusify.exe + +[PENTING] Jangan jalankan file EXE dari folder build\ +[PENTING] Jalankan EXE hanya dari folder dist\jurusify\ + +Press any key to continue . . . diff --git a/config.py b/config.py new file mode 100644 index 0000000..95adfc6 --- /dev/null +++ b/config.py @@ -0,0 +1,76 @@ +# Konfigurasi untuk Sistem Klasifikasi Jurusan +import os +import sys + + +def _resource_base_dir(): + """Direktori dasar untuk sumber daya read-only yang dibundel.""" + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + return sys._MEIPASS + return os.path.dirname(os.path.abspath(__file__)) + + +def _writable_base_dir(): + """Direktori dasar untuk file runtime yang dapat ditulis (mis. model terlatih).""" + if getattr(sys, 'frozen', False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +RESOURCE_BASE_DIR = _resource_base_dir() +WRITABLE_BASE_DIR = _writable_base_dir() + + +def get_resource_path(relative_path): + return os.path.join(RESOURCE_BASE_DIR, relative_path) + + +def get_writable_path(relative_path): + return os.path.join(WRITABLE_BASE_DIR, relative_path) + + +def get_app_icon_path(): + """Tentukan path ikon terbaik untuk aplikasi/jendela jika tersedia.""" + candidates = [ + get_resource_path(os.path.join('img', 'logo_sekolah.ico')), + get_writable_path(os.path.join('img', 'logo_sekolah.ico')), + get_resource_path(os.path.join('img', 'Logo_skripsi.ico')), + get_writable_path(os.path.join('img', 'Logo_skripsi.ico')), + get_writable_path(os.path.join('img', 'logo_sekolah.png')), + get_resource_path(os.path.join('img', 'logo_sekolah.png')), + get_writable_path(os.path.join('img', 'Logo_skripsi.png')), + get_resource_path(os.path.join('img', 'Logo_skripsi.png')), + get_writable_path(os.path.join('img', 'logo_sma.ico')), + get_resource_path(os.path.join('img', 'logo_sma.ico')), + get_writable_path(os.path.join('img', 'logo_sma.png')), + get_resource_path(os.path.join('img', 'logo_sma.png')), + ] + for path in candidates: + if os.path.exists(path): + return path + return '' + + +# Konfigurasi dataset +TRAIN_DATASET_PATH = get_resource_path(os.path.join('data', 'dataset_smakom_final_train.csv')) +TEST_DATASET_PATH = get_resource_path(os.path.join('data', 'dataset_smakom_final_test.csv')) +DATASET_PATH = TRAIN_DATASET_PATH + +# Path model: dapat ditulis untuk retraining, sebagai fallback saat pertama kali dimuat +MODEL_PATH = get_writable_path(os.path.join('models', 'trained_model.pkl')) +BUNDLED_MODEL_PATH = get_resource_path(os.path.join('models', 'trained_model.pkl')) + +# Konfigurasi pelatihan +TEST_SIZE = 0.2 # 20% untuk testing, 80% untuk training (300 data: 240 training, 60 testing) +RANDOM_STATE = 42 # nomor acak tetap agar hasil random sama + +# Konfigurasi KNN +DEFAULT_K_NEIGHBORS = 27 +KNN_WEIGHTS = 'distance' +KNN_METRIC = 'euclidean' +OPTIMIZE_K = True +K_RANGE = range(3, 52, 2) # Kandidat k untuk GridSearchCV pada data ini: 3,5,7,...,51 + +# Konfigurasi basis data (sudah ada di db/database.py) +# Konfigurasi performa +CROSS_VALIDATION_FOLDS = 5 \ No newline at end of file diff --git a/convert_logo.py b/convert_logo.py new file mode 100644 index 0000000..aa82e51 --- /dev/null +++ b/convert_logo.py @@ -0,0 +1,87 @@ +"""Convert logo_sekolah.png ke logo_sekolah.ico multi-size untuk PyInstaller.""" +import os +import numpy as np +from PIL import Image, ImageEnhance, ImageFilter + + +def _trim_content(image, tolerance=18): + """Crop area kosong/warna latar agar simbol utama lebih dominan saat jadi ikon kecil.""" + rgba = image.convert("RGBA") + arr = np.array(rgba) + + alpha = arr[:, :, 3] + rgb = arr[:, :, :3].astype(np.int16) + + corners = np.array([ + rgb[0, 0], + rgb[0, -1], + rgb[-1, 0], + rgb[-1, -1], + ], dtype=np.int16) + bg = np.median(corners, axis=0) + + dist = np.max(np.abs(rgb - bg), axis=2) + mask = (alpha > 10) & (dist > tolerance) + + if not np.any(mask): + mask = alpha > 10 + if not np.any(mask): + return rgba + + ys, xs = np.where(mask) + left, right = int(xs.min()), int(xs.max()) + 1 + top, bottom = int(ys.min()), int(ys.max()) + 1 + return rgba.crop((left, top, right, bottom)) + + +def _prepare_square_icon(image): + """Trim margin lalu pasang ke canvas persegi tanpa mengubah warna/detail logo asli.""" + image = _trim_content(image) + image = image.convert("RGBA") + + # Penajaman ringan agar tetap asli namun lebih jelas di ukuran kecil. + alpha = image.split()[-1] + rgb = image.convert("RGB") + rgb = rgb.filter(ImageFilter.UnsharpMask(radius=1.2, percent=115, threshold=2)) + rgb = ImageEnhance.Contrast(rgb).enhance(1.06) + image = rgb.convert("RGBA") + image.putalpha(alpha) + + canvas_size = 1024 + canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0)) + + max_dim = int(canvas_size * 0.985) + image.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS) + + x = (canvas_size - image.width) // 2 + y = (canvas_size - image.height) // 2 + canvas.paste(image, (x, y), image) + return canvas + + +def main(): + img_dir = "img" + png_file = os.path.join(img_dir, "logo_sekolah.png") + ico_file = os.path.join(img_dir, "logo_sekolah.ico") + + if not os.path.exists(png_file): + print(f"[INFO] File tidak ditemukan: {png_file}. Konversi dilewati.") + return 0 + + try: + img = Image.open(png_file) + square_img = _prepare_square_icon(img) + sizes = [ + (16, 16), (20, 20), (24, 24), (32, 32), (40, 40), + (48, 48), (64, 64), (72, 72), (96, 96), (128, 128), (256, 256), + ] + square_img.save(ico_file, format="ICO", sizes=sizes) + print(f"[OK] Berhasil convert: {png_file} -> {ico_file}") + return 0 + except Exception as exc: + print(f"[ERROR] Gagal convert logo: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/data/dataset_distribution_comparison.png b/data/dataset_distribution_comparison.png new file mode 100644 index 0000000..36f590d Binary files /dev/null and b/data/dataset_distribution_comparison.png differ diff --git a/data/dataset_dummy.csv b/data/dataset_dummy.csv new file mode 100644 index 0000000..2110bbd --- /dev/null +++ b/data/dataset_dummy.csv @@ -0,0 +1,121 @@ +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_jurusan +Siswa_1,85,79,99,97,90,69,84,70,96,72,2 +Siswa_2,82,84,88,93,69,94,99,72,99,66,1 +Siswa_3,72,77,87,88,97,79,88,88,64,92,2 +Siswa_4,83,84,94,74,87,84,62,69,75,79,2 +Siswa_5,63,65,84,89,93,77,97,63,100,97,6 +Siswa_6,71,64,70,82,89,93,87,66,97,96,6 +Siswa_7,89,83,90,82,87,77,81,77,61,74,2 +Siswa_8,93,87,96,63,78,92,89,98,77,77,4 +Siswa_9,70,68,71,73,90,72,89,100,80,93,4 +Siswa_10,85,62,73,81,92,80,93,98,96,84,5 +Siswa_11,96,66,67,71,92,99,77,61,91,77,3 +Siswa_12,64,98,85,95,60,63,70,79,85,76,1 +Siswa_13,71,88,61,73,71,97,69,91,68,84,4 +Siswa_14,90,75,81,80,90,64,61,100,65,81,2 +Siswa_15,85,64,93,82,86,61,87,75,70,71,2 +Siswa_16,80,96,81,98,80,70,75,63,79,97,1 +Siswa_17,74,74,88,71,73,68,72,76,78,61,1 +Siswa_18,72,95,71,95,80,67,71,98,66,98,1 +Siswa_19,91,64,88,83,76,71,74,98,60,61,2 +Siswa_20,91,83,63,60,76,87,100,99,80,68,4 +Siswa_21,78,96,96,89,78,64,76,75,99,73,1 +Siswa_22,90,85,78,83,78,62,85,64,99,60,1 +Siswa_23,66,79,80,77,77,68,94,77,77,68,3 +Siswa_24,86,98,68,77,94,93,86,71,70,87,3 +Siswa_25,81,96,100,94,85,76,75,63,74,70,1 +Siswa_26,92,92,66,66,91,84,91,68,69,69,3 +Siswa_27,92,62,87,87,97,63,66,87,79,100,2 +Siswa_28,74,89,85,74,71,92,61,80,79,69,5 +Siswa_29,83,99,92,98,66,62,62,64,82,94,1 +Siswa_30,77,78,63,91,81,64,92,78,93,93,6 +Siswa_31,67,86,62,96,65,99,93,87,89,98,6 +Siswa_32,70,75,94,64,62,71,90,99,66,66,4 +Siswa_33,89,86,68,92,66,68,67,91,65,87,1 +Siswa_34,65,97,72,70,76,95,83,65,70,66,3 +Siswa_35,71,78,89,60,99,69,72,61,100,61,2 +Siswa_36,88,97,79,75,85,85,94,78,63,86,3 +Siswa_37,92,92,97,87,95,61,83,79,74,73,2 +Siswa_38,98,84,83,85,94,60,60,74,67,86,2 +Siswa_39,80,84,84,74,81,63,65,77,62,73,1 +Siswa_40,98,100,98,97,62,91,91,93,80,72,1 +Siswa_41,100,78,77,83,88,84,84,97,65,71,4 +Siswa_42,72,85,85,63,99,93,67,72,98,86,6 +Siswa_43,81,99,86,74,89,70,81,60,60,66,1 +Siswa_44,83,68,82,76,93,83,99,78,100,72,3 +Siswa_45,70,86,99,62,71,94,66,64,83,60,1 +Siswa_46,74,75,73,89,91,87,74,79,93,63,5 +Siswa_47,71,80,72,88,92,87,63,98,92,80,5 +Siswa_48,61,90,65,90,89,83,95,85,66,93,3 +Siswa_49,84,95,72,88,93,86,71,86,72,98,6 +Siswa_50,92,85,61,100,67,66,79,64,79,73,1 +Siswa_51,89,85,89,91,86,98,68,88,72,94,2 +Siswa_52,70,95,72,100,92,95,67,97,100,81,5 +Siswa_53,81,85,62,72,74,80,98,98,67,87,4 +Siswa_54,88,61,84,82,68,68,63,97,78,95,5 +Siswa_55,88,63,79,73,62,60,80,100,84,83,5 +Siswa_56,78,84,62,94,76,90,68,63,74,66,1 +Siswa_57,67,67,64,77,99,67,68,82,80,97,6 +Siswa_58,86,61,96,95,95,64,77,61,96,93,2 +Siswa_59,97,64,77,98,79,85,73,83,85,83,2 +Siswa_60,97,99,64,64,78,71,85,74,64,63,3 +Siswa_61,83,65,76,83,95,91,66,99,91,69,5 +Siswa_62,63,61,87,76,100,89,87,64,61,85,3 +Siswa_63,71,77,81,87,78,78,82,66,60,97,2 +Siswa_64,84,86,67,77,82,64,70,93,97,93,5 +Siswa_65,94,72,73,97,94,63,90,60,88,66,2 +Siswa_66,83,65,84,94,100,62,79,95,100,76,2 +Siswa_67,67,95,100,67,75,62,87,89,83,72,1 +Siswa_68,84,67,62,95,87,98,95,83,88,77,3 +Siswa_69,94,99,99,70,67,83,94,63,85,72,1 +Siswa_70,93,100,86,72,91,88,100,82,86,92,3 +Siswa_71,79,80,85,90,64,62,87,60,92,81,1 +Siswa_72,83,70,90,88,85,67,98,63,74,67,2 +Siswa_73,98,60,98,60,89,75,71,66,76,98,6 +Siswa_74,83,66,70,87,67,65,94,94,89,69,4 +Siswa_75,63,90,60,96,89,74,67,66,86,98,6 +Siswa_76,98,72,85,70,75,75,81,97,87,89,5 +Siswa_77,69,88,100,98,71,76,70,97,67,94,1 +Siswa_78,84,71,85,82,80,91,62,64,76,82,6 +Siswa_79,60,88,84,77,69,73,79,74,60,71,1 +Siswa_80,79,85,62,100,63,91,66,85,65,93,6 +Siswa_81,100,80,82,63,70,69,65,84,88,66,5 +Siswa_82,84,72,91,91,99,63,76,82,81,94,2 +Siswa_83,77,76,67,84,83,69,73,76,65,95,2 +Siswa_84,64,61,86,62,70,66,68,64,72,78,2 +Siswa_85,86,65,72,66,82,72,86,78,66,96,3 +Siswa_86,64,68,78,91,75,98,97,85,83,93,4 +Siswa_87,89,63,64,76,91,82,97,67,88,94,3 +Siswa_88,79,89,62,74,100,80,68,97,99,80,5 +Siswa_89,96,70,91,92,86,94,91,68,76,85,3 +Siswa_90,64,76,70,99,95,83,81,64,90,65,2 +Siswa_91,72,78,83,85,71,70,90,81,100,91,6 +Siswa_92,68,73,65,72,80,73,80,71,73,88,6 +Siswa_93,74,100,74,98,88,74,99,93,89,83,1 +Siswa_94,69,96,67,92,95,86,93,85,76,98,3 +Siswa_95,67,68,60,87,98,95,90,95,65,76,3 +Siswa_96,73,90,93,93,66,78,73,78,75,90,1 +Siswa_97,74,91,76,62,100,83,80,90,60,68,3 +Siswa_98,80,62,61,78,63,74,79,68,71,81,6 +Siswa_99,77,83,68,97,71,75,66,92,78,83,1 +Siswa_100,64,74,82,63,78,89,93,65,82,70,3 +Siswa_101,87,95,63,81,62,62,92,82,69,79,1 +Siswa_102,86,91,68,66,96,86,71,67,66,66,3 +Siswa_103,71,64,78,72,88,78,85,73,73,96,3 +Siswa_104,98,78,60,99,86,80,72,66,90,87,6 +Siswa_105,86,84,82,74,79,74,67,99,67,74,1 +Siswa_106,66,90,81,81,88,62,97,97,78,80,4 +Siswa_107,86,99,62,85,60,90,66,94,68,81,5 +Siswa_108,92,86,92,74,97,68,63,62,65,81,2 +Siswa_109,83,93,91,83,60,70,73,87,99,74,1 +Siswa_110,87,67,66,67,80,65,76,77,100,70,5 +Siswa_111,98,76,91,77,72,89,87,74,93,89,6 +Siswa_112,60,79,87,96,82,85,63,93,84,60,2 +Siswa_113,92,74,89,85,79,68,82,64,97,64,2 +Siswa_114,60,85,63,73,63,91,95,82,68,68,4 +Siswa_115,69,83,65,86,89,86,81,78,68,67,3 +Siswa_116,82,70,66,65,80,61,86,83,74,66,4 +Siswa_117,82,79,92,63,65,68,96,63,85,76,1 +Siswa_118,86,94,96,96,90,66,95,71,78,66,1 +Siswa_119,81,82,85,61,98,82,90,83,95,75,3 +Siswa_120,68,62,84,63,96,91,66,70,65,97,3 diff --git a/data/dataset_scatter_comparison.png b/data/dataset_scatter_comparison.png new file mode 100644 index 0000000..d0c693f Binary files /dev/null and b/data/dataset_scatter_comparison.png differ diff --git a/data/dataset_siswa.csv b/data/dataset_siswa.csv new file mode 100644 index 0000000..f89eafe --- /dev/null +++ b/data/dataset_siswa.csv @@ -0,0 +1,121 @@ +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_jurusan +Siswa_1,67,69,62,89,100,68,62,99,93,82,5 +Siswa_2,60,85,88,92,60,88,93,80,65,65,1 +Siswa_3,74,62,74,75,78,95,66,88,99,72,5 +Siswa_4,72,80,100,82,78,97,79,78,67,96,1 +Siswa_5,91,81,92,99,89,64,85,66,60,82,2 +Siswa_6,62,95,76,93,100,72,71,82,87,87,2 +Siswa_7,73,89,83,61,65,82,88,78,94,64,5 +Siswa_8,82,72,68,100,80,82,65,79,66,91,2 +Siswa_9,71,77,76,79,96,67,74,73,70,97,2 +Siswa_10,73,77,62,63,80,98,95,61,89,81,3 +Siswa_11,74,76,72,95,94,94,89,62,82,60,3 +Siswa_12,77,99,68,86,86,64,96,69,63,90,1 +Siswa_13,96,67,71,76,77,75,76,92,69,81,4 +Siswa_14,74,83,90,84,79,68,77,79,92,70,1 +Siswa_15,96,86,91,100,65,86,79,93,86,65,1 +Siswa_16,96,83,83,63,91,74,60,95,61,61,2 +Siswa_17,87,93,62,75,76,62,87,100,60,64,4 +Siswa_18,67,60,61,82,88,71,82,91,85,74,5 +Siswa_19,98,95,78,62,80,74,94,85,97,82,5 +Siswa_20,74,94,64,89,99,95,67,70,100,73,6 +Siswa_21,96,76,77,63,84,77,61,82,64,64,2 +Siswa_22,77,85,84,78,87,73,80,83,71,70,2 +Siswa_23,83,72,69,85,60,70,66,80,70,93,6 +Siswa_24,65,92,88,61,90,100,77,65,84,91,6 +Siswa_25,92,70,61,97,61,66,71,60,95,74,6 +Siswa_26,72,73,85,60,79,93,84,98,79,92,4 +Siswa_27,75,69,79,95,72,81,96,62,73,91,3 +Siswa_28,80,73,81,100,77,87,71,78,64,62,2 +Siswa_29,87,97,92,68,90,100,94,86,61,77,3 +Siswa_30,90,73,82,73,95,68,82,64,60,90,2 +Siswa_31,67,73,83,64,60,100,88,75,63,89,4 +Siswa_32,99,82,77,85,84,68,63,84,65,60,2 +Siswa_33,88,93,94,86,70,74,66,98,66,80,1 +Siswa_34,90,76,67,98,85,96,74,68,94,100,6 +Siswa_35,77,86,68,80,67,98,96,85,64,72,4 +Siswa_36,99,86,95,85,73,60,63,69,96,71,1 +Siswa_37,69,62,68,77,76,93,68,75,91,93,6 +Siswa_38,86,96,96,69,88,68,60,99,100,72,5 +Siswa_39,85,90,72,69,85,62,79,64,76,64,1 +Siswa_40,90,85,86,81,66,89,64,85,94,64,5 +Siswa_41,89,77,82,72,94,96,93,88,100,63,5 +Siswa_42,100,73,93,91,69,71,67,62,68,98,1 +Siswa_43,90,64,71,85,88,95,93,90,63,69,4 +Siswa_44,94,62,91,85,77,78,90,91,74,66,4 +Siswa_45,94,76,78,80,78,99,93,60,73,67,3 +Siswa_46,78,93,61,100,77,86,96,71,78,78,3 +Siswa_47,77,69,79,99,63,65,63,64,76,71,1 +Siswa_48,98,78,75,88,76,86,76,98,75,78,4 +Siswa_49,72,85,96,89,81,91,95,95,60,68,4 +Siswa_50,81,67,80,67,79,96,67,100,81,64,5 +Siswa_51,68,97,89,87,63,96,92,92,85,83,4 +Siswa_52,71,66,72,89,80,64,67,74,80,75,2 +Siswa_53,61,73,91,91,73,74,94,65,67,65,1 +Siswa_54,93,66,97,60,85,99,61,61,78,93,6 +Siswa_55,63,81,87,94,73,79,98,74,83,76,1 +Siswa_56,90,90,82,74,77,76,93,70,69,72,1 +Siswa_57,69,100,79,63,62,65,100,67,84,61,1 +Siswa_58,89,65,66,88,60,69,82,85,90,83,5 +Siswa_59,60,81,75,98,72,97,88,78,100,98,6 +Siswa_60,78,69,72,95,80,63,92,80,66,82,2 +Siswa_61,64,68,77,89,64,60,73,88,83,100,6 +Siswa_62,97,73,79,64,87,73,75,63,88,93,6 +Siswa_63,74,92,77,81,68,81,71,92,92,80,5 +Siswa_64,77,93,81,61,64,68,94,72,100,74,6 +Siswa_65,89,94,94,80,83,89,68,98,76,96,1 +Siswa_66,92,81,64,73,90,86,62,84,89,75,5 +Siswa_67,93,93,64,64,97,97,82,65,89,97,6 +Siswa_68,94,82,67,73,63,62,69,78,87,63,5 +Siswa_69,86,90,64,93,81,78,81,98,97,93,5 +Siswa_70,79,72,88,61,74,60,94,78,86,97,6 +Siswa_71,99,66,90,96,80,78,84,74,77,67,2 +Siswa_72,96,78,68,95,100,63,100,87,96,95,2 +Siswa_73,89,97,60,100,81,91,64,62,89,65,1 +Siswa_74,77,88,99,73,64,83,94,69,69,68,1 +Siswa_75,88,83,72,66,61,76,84,63,100,94,6 +Siswa_76,73,69,64,77,93,87,65,72,69,68,3 +Siswa_77,89,67,69,92,69,88,60,74,62,82,6 +Siswa_78,88,97,92,60,88,60,80,95,100,83,5 +Siswa_79,74,92,81,64,75,94,83,80,64,65,4 +Siswa_80,81,74,84,92,91,98,75,91,73,75,2 +Siswa_81,100,89,100,98,83,76,78,81,90,80,1 +Siswa_82,94,81,96,81,80,90,99,79,82,61,3 +Siswa_83,96,66,94,98,94,79,90,99,68,100,2 +Siswa_84,61,64,80,67,71,91,95,98,78,100,4 +Siswa_85,97,93,63,60,67,94,91,87,82,94,4 +Siswa_86,72,79,77,69,63,99,78,95,89,96,6 +Siswa_87,65,87,76,88,65,64,88,91,76,62,1 +Siswa_88,90,75,69,81,77,66,77,61,73,99,6 +Siswa_89,79,80,68,63,98,80,94,77,67,67,3 +Siswa_90,87,100,82,82,81,74,67,80,74,75,1 +Siswa_91,70,66,99,97,84,79,69,64,62,82,2 +Siswa_92,100,93,90,90,67,68,82,81,97,90,1 +Siswa_93,68,93,68,60,71,63,93,69,69,78,3 +Siswa_94,82,99,89,63,60,74,67,97,82,98,6 +Siswa_95,74,82,96,79,93,86,78,69,62,62,2 +Siswa_96,92,99,99,78,68,98,87,74,77,81,1 +Siswa_97,75,82,99,82,88,85,85,79,88,91,2 +Siswa_98,68,90,86,98,63,65,68,78,74,83,1 +Siswa_99,83,96,60,83,64,85,79,76,76,90,6 +Siswa_100,75,74,70,78,70,68,71,72,72,77,1 +Siswa_101,91,67,95,89,94,66,92,71,78,75,2 +Siswa_102,66,64,64,92,78,79,88,70,79,88,6 +Siswa_103,68,68,60,80,88,64,73,74,66,80,2 +Siswa_104,98,60,78,60,95,97,80,81,60,84,3 +Siswa_105,65,86,66,75,74,63,95,91,60,66,4 +Siswa_106,100,88,87,90,78,70,83,86,91,90,1 +Siswa_107,61,67,64,66,83,77,89,84,88,71,4 +Siswa_108,85,93,65,83,64,97,86,97,95,96,5 +Siswa_109,70,79,68,62,75,68,98,67,65,84,3 +Siswa_110,95,68,91,79,62,81,77,67,96,62,5 +Siswa_111,74,85,68,81,97,78,76,65,90,86,6 +Siswa_112,65,98,87,65,86,87,84,98,66,88,4 +Siswa_113,96,78,91,82,98,71,85,70,92,92,2 +Siswa_114,60,89,60,83,62,83,89,82,61,97,4 +Siswa_115,83,70,60,99,82,61,78,95,66,96,2 +Siswa_116,78,69,70,72,63,98,92,70,64,83,4 +Siswa_117,100,100,87,95,97,83,95,75,99,84,1 +Siswa_118,78,62,80,84,84,85,64,98,65,81,2 +Siswa_119,67,68,82,97,73,98,86,67,93,70,6 +Siswa_120,90,92,99,84,70,90,80,62,98,61,1 diff --git a/data/dataset_smakom.csv b/data/dataset_smakom.csv new file mode 100644 index 0000000..0c69100 --- /dev/null +++ b/data/dataset_smakom.csv @@ -0,0 +1,301 @@ +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_jurusan +Ahmad_Adib_Almuiz,80.5,76.0,75.5,72.5,78.5,83.0,71.5,79.0,78.5,79.5,4 +Amin_Ridho_Sholikin,83.5,82.5,81.5,78.5,81.0,82.5,82.0,86.5,81.5,86.0,1 +Arik_Wisnu_Pramuditiya,86.0,81.0,82.0,81.0,82.5,83.0,81.5,86.0,82.5,89.0,1 +Brian_Agung_Pamungkas,90.0,82.5,83.0,84.5,85.0,88.0,85.5,89.5,84.0,90.0,1 +Chesylya_Adinnata_Maylani,79.5,76.0,79.5,79.5,81.0,81.0,75.5,82.0,80.5,83.5,4 +Dewi_Marisah_Manggar_Retno,89.0,80.5,78.5,81.5,81.0,83.0,84.0,85.5,83.0,85.5,3 +Dwi_Andika,81.0,76.5,76.5,78.0,81.0,83.5,73.5,78.5,78.5,79.0,4 +Dyah_Ayu_Pratiwi,74.5,76.5,77.5,76.5,80.5,78.5,73.5,77.0,80.0,79.5,1 +Ermita_Trisya_Sari,81.0,83.0,81.5,82.0,83.0,87.5,86.5,87.5,80.0,89.0,4 +Fika_Nur_Rohmah,82.0,78.0,78.0,79.0,80.5,83.0,79.0,79.0,80.5,80.5,4 +Fino_Andreyan_Pratama,84.0,79.0,79.0,80.0,80.0,81.5,75.0,81.5,80.5,81.5,3 +Fitri_Yulita_Sari,88.5,79.5,78.0,82.5,82.5,84.5,78.0,84.5,82.0,85.0,4 +Frisa_Agiel_Kirania,81.0,78.0,78.0,78.0,80.5,83.0,78.0,79.0,80.0,80.5,4 +Handoko_Dimas_Wahyu_Nugroho,79.0,76.0,78.5,77.0,82.5,80.5,74.5,78.5,75.5,78.5,4 +Keyla_Chantika_Devy_Julieta,76.5,79.5,80.0,74.5,80.5,81.0,76.0,80.0,79.5,82.0,5 +Latifatul_Isma,83.0,82.0,77.0,80.5,81.0,85.0,86.5,86.0,79.0,86.5,4 +M._Faris_Ardiyanto,75.0,76.0,76.0,76.0,78.5,84.5,73.5,77.5,77.5,76.0,4 +Meriyas_Eka_Anjani,85.5,77.0,78.0,81.0,83.0,81.0,74.0,82.5,83.0,85.5,6 +Mitzy_Aurora_Azzahra,85.0,79.0,80.0,80.0,83.0,83.5,80.5,84.0,81.0,84.5,3 +Moch._Galih_Nur_Hidayatullah,77.0,77.0,77.0,77.0,80.5,78.5,74.0,78.0,79.5,78.5,4 +Mohammad_Trio_Subekti,77.5,76.5,76.5,76.0,79.5,82.0,73.0,77.5,79.5,78.0,4 +Muhamad_Rangga_Aji_Pratama,86.0,77.5,77.5,79.5,83.5,86.0,74.5,83.0,82.5,81.0,4 +Nidda'ul_Fijriah,82.5,78.5,78.5,79.5,80.0,79.5,74.0,82.5,82.5,86.0,3 +Novita_Eka_Ramandani,78.5,78.0,78.0,79.5,82.0,82.5,79.0,80.0,81.0,84.0,4 +Putri_Rahayu_Ningsih,79.5,79.5,79.5,79.5,84.0,80.0,79.5,80.0,80.0,85.5,2 +Quennata_Gladis_Bilbina,86.0,78.5,78.0,80.5,82.5,86.0,79.5,79.5,82.0,81.5,3 +Rizki_Andi_Pratama,85.5,79.5,80.5,81.0,81.5,83.0,84.5,86.0,82.0,86.0,1 +Rizqi_Pangestu_Arsyadipura,84.5,84.0,81.5,82.0,81.0,79.5,83.5,88.0,81.5,87.0,1 +Rohmatul_Fitria_Dewi,87.5,78.0,78.0,83.0,81.0,84.5,80.5,80.5,82.0,81.5,3 +Satrio_Damar_Anggono,77.5,78.5,77.0,76.5,80.5,80.0,73.5,83.5,80.0,85.5,1 +Shofiyyatul_El_Husna,87.5,78.5,79.0,80.5,79.0,77.5,82.0,83.5,81.5,83.5,1 +Tegar_Krido_Wibowo,79.5,77.0,79.5,77.5,80.0,77.5,77.0,78.5,79.5,80.0,1 +Tegar_Reka_Wijaya,82.0,79.5,79.5,81.0,82.5,83.5,81.5,84.0,82.0,86.5,1 +Vania_Ika_Melinda,80.5,79.5,79.5,80.5,81.0,80.5,82.0,84.0,79.0,83.5,2 +Zaraya_Jelita_Putri_Ardina,84.0,82.5,81.5,81.5,81.0,83.5,82.0,84.0,81.0,86.0,1 +Ahmad_Maulana_Rizky_Aditya_Wijaya,85.5,78.5,77.5,79.5,83.0,79.5,74.0,82.0,80.5,84.0,4 +Alhasby_Bachtiar,79.5,79.5,78.5,81.5,80.0,79.5,76.0,84.0,81.5,85.0,2 +Aprilia_Martalita_Wibowo,78.5,77.0,78.0,80.0,80.5,81.5,78.5,85.0,78.0,84.5,4 +Arshinta_Marthalie_Gunawan,77.0,77.0,79.0,80.5,80.0,79.5,80.0,78.5,79.0,80.0,1 +Atadea_Lie_Intan_Karina,86.5,80.5,78.0,81.0,81.0,78.0,76.5,84.0,77.5,81.0,3 +Azzahra_Dewi_Natria,83.5,79.5,80.0,82.5,82.5,84.5,81.5,89.0,81.5,86.5,4 +Bagus_Rohman_Maulana,81.5,82.5,80.5,79.5,80.0,81.5,81.5,83.5,81.5,86.0,6 +Binti_Yisha_Zahra_Futri,85.0,83.0,78.5,83.5,82.5,81.0,85.5,88.5,82.0,90.0,1 +Chalista_Ayunda_Sukmaningtyas,84.0,79.0,78.0,82.5,80.5,81.5,82.0,87.0,78.5,85.0,2 +Clara_Balqis_Putri_Febrianti,82.0,83.0,82.5,84.5,85.0,84.5,84.5,88.5,82.5,88.5,2 +Davin_Yudha_Pratama,82.5,78.0,78.5,81.5,83.0,81.0,73.5,84.0,79.5,82.5,3 +Dian_Noviyanti,87.5,77.5,79.0,79.5,79.5,85.5,77.5,81.0,81.0,83.5,6 +Dwi_Kevfri_Kenata,81.0,80.5,82.0,80.5,82.5,81.5,78.5,84.5,82.5,86.0,1 +Fahril_Rahmad_Tri_Setyawan,79.5,77.0,78.0,77.0,80.0,86.0,77.5,81.0,79.5,81.0,4 +Firstian_Vidya_Permana,79.5,77.5,79.0,78.0,80.5,83.0,77.5,78.0,78.5,78.5,4 +Galang_Aldiyansah_Putra,82.5,78.5,77.0,77.5,78.5,82.0,73.5,83.0,78.5,81.0,4 +Ghania_Hardiyanti,86.0,83.5,82.5,82.5,83.0,85.5,86.5,87.5,82.0,87.0,1 +Hamida_Ulviana_Dewi,79.5,79.5,78.5,81.0,79.5,85.5,75.0,83.0,77.5,83.0,6 +Hengky_Prasetyo,85.0,77.0,77.0,75.5,80.5,80.0,73.5,79.0,76.5,78.5,4 +Kellvi_Tiaska_Putri,78.0,77.5,77.5,79.5,80.0,83.0,76.5,81.0,80.5,80.5,4 +Kurniawan_Dyo_Tri_Warsono,75.0,76.5,77.0,74.5,79.5,80.0,74.5,79.5,78.5,76.5,4 +Mochamad_Ardiansyah_Saputra,85.5,77.0,77.0,78.5,79.0,83.0,73.5,77.5,81.0,80.5,4 +Mochammad_Yuda_Aditia,87.0,76.5,76.0,80.0,81.0,81.0,73.5,78.5,81.5,81.0,3 +Mohammad_Raihan_Nur_Fajri,81.0,77.0,76.0,80.0,81.0,82.5,73.5,81.0,80.5,81.0,4 +Muhammad_Tegar_Prayuda,81.5,78.5,77.0,80.5,82.0,80.0,76.5,82.5,80.5,85.5,1 +Muhammad_Zainal_Asrofi,78.0,76.5,78.0,80.0,80.0,81.0,74.5,79.5,78.5,78.0,4 +Nur_Aleeya_Afrina_Suwandi,83.5,79.5,80.5,83.5,83.0,84.0,84.0,89.0,81.5,87.0,4 +Prima_Dyah_Novanda,85.0,80.5,81.5,81.0,83.0,82.0,83.5,86.5,77.5,85.5,3 +Rika_Nur_Fauziah,80.5,79.5,79.5,81.0,83.5,85.0,80.5,81.0,79.5,85.5,2 +Satryo_Gito_Pratomo,87.0,79.5,81.5,82.0,81.5,83.0,84.5,90.0,82.5,89.0,2 +Septian_Daniarti_Fitri_Astutik,79.0,78.5,78.0,81.0,83.0,82.0,76.0,84.0,77.0,82.5,3 +Suci_Rahayu_Saputri,81.5,78.0,77.0,80.0,80.5,80.0,75.5,77.5,79.5,80.0,4 +Sujiati_Fitrianingsih,83.5,77.5,78.0,79.5,80.0,83.0,76.5,84.0,80.5,80.5,6 +Tata_Agata_Afga_Pratama,81.5,78.0,77.5,74.5,80.5,78.5,75.0,78.5,81.0,79.5,4 +Yahya_Handika_Aditia,87.0,78.0,78.0,75.5,81.0,82.0,74.0,79.0,80.5,80.5,4 +Yulia_Nur_Lismarita,80.5,77.5,78.0,80.5,81.0,80.0,80.5,83.0,77.5,83.0,4 +Aditya_Rizky_Romadhon,81.0,77.5,76.5,79.5,80.0,81.0,76.5,82.0,80.5,80.5,2 +Aisyah_Fidinna_Rahmatika,83.5,76.5,77.5,80.5,81.0,86.5,79.0,82.5,83.0,80.5,5 +Aldo_Widodo,81.5,76.5,77.0,75.0,77.5,76.5,71.5,77.0,76.0,77.0,5 +Alvino_Surya_Pratama,78.0,77.5,77.5,75.5,78.0,80.0,80.0,81.5,82.5,80.5,4 +Alvinza_Brian_Saputra,76.5,76.5,76.5,74.5,79.0,77.5,71.5,75.0,77.5,76.5,4 +Dhimas_Pandu_Wijaya,88.0,77.5,77.5,80.0,76.0,81.5,72.0,81.0,83.0,82.0,1 +Fauziah_Elsa_Putri_Purnomo,81.0,82.5,84.0,82.5,83.0,87.0,85.5,85.0,82.0,88.5,1 +Feisal_Wisnu_Raditya,78.0,77.0,77.5,75.0,78.5,81.0,74.5,79.0,78.5,79.5,4 +Fitriani_Nurul_Norhayati,79.0,77.5,80.0,77.0,80.5,82.0,76.5,79.0,76.5,82.5,6 +Fivi_Widyo_Wati,79.5,79.5,80.0,78.5,80.5,79.0,83.0,84.0,79.0,84.5,1 +Gloria_Fransisca,81.0,79.0,80.5,80.5,83.0,80.5,79.5,84.5,80.0,85.0,3 +Herlin_Faradila_Stifiana,78.5,78.5,79.0,80.0,81.5,81.0,80.5,83.0,80.0,82.5,1 +Karisma_Hardiyati_P._R.,80.5,79.5,78.0,78.5,80.5,81.0,78.0,78.5,79.5,79.5,4 +Kirana_Putri_Agustina,81.0,79.0,79.0,79.0,81.0,81.0,79.0,83.5,80.5,83.5,3 +Lala_Putri_Meilita,77.5,78.0,78.5,79.0,82.0,84.5,81.5,82.5,80.0,85.5,6 +M.fahcri_Affan_Ramadhani,80.5,80.5,80.0,77.0,82.5,86.0,74.0,84.5,80.5,82.5,1 +Mey_Lika_Hanaya_Putri,87.0,78.0,80.0,81.0,82.5,86.5,82.0,84.5,83.0,84.5,6 +Moch.afga_Afreza_Alfiansyah,81.0,79.5,80.5,80.0,83.0,85.0,79.5,86.0,81.0,85.0,4 +Mochammad_Fahri_Nur_Rizky,82.5,79.5,83.0,81.5,80.5,82.5,85.5,86.0,82.0,87.0,1 +Muhammad_Asril_Daffa_Saputra,77.5,77.5,78.0,78.5,80.5,81.5,72.0,78.0,79.5,79.5,4 +Nadya_Anandiva_Silvyani,81.0,82.5,81.0,81.0,81.5,85.5,80.0,87.5,82.5,85.5,6 +Nendra_Nur_Febrian,82.5,77.5,77.5,78.0,79.0,79.0,72.0,76.5,80.5,83.0,4 +Putri_Adelia_Sari,81.0,77.5,78.0,80.0,80.5,84.5,80.0,85.5,82.5,82.0,4 +Ramadhani_Silfi_Damaiyanti,80.0,77.5,78.0,78.5,80.5,86.5,78.0,82.0,81.5,81.0,5 +Rangga_Galuh_Octa_Pradana,79.0,77.0,78.0,77.5,80.0,80.0,72.5,81.0,81.5,79.5,2 +Rehan_Septa_Adi_Ramadhani,83.5,78.0,78.0,79.0,82.0,86.5,75.5,82.0,80.5,80.5,3 +Salsa_Laurintiya,81.0,78.5,79.0,78.0,81.0,78.5,77.0,82.0,79.0,80.5,5 +Salva_Andika,81.0,79.0,79.0,78.0,80.5,84.0,79.5,84.5,81.5,80.5,5 +Sandy_Dwi_Meilano,81.5,82.5,81.0,80.5,81.0,83.5,83.0,85.5,82.0,87.5,1 +Sera_Natalia,80.0,77.5,79.5,80.5,82.5,81.5,80.5,81.0,80.0,84.0,1 +Tasya_Dwi_Oktavia,77.5,77.5,78.0,80.5,82.5,82.5,79.5,79.0,81.5,82.0,5 +Varisha_Anastasya,86.5,80.5,81.5,81.5,80.5,85.0,84.0,85.5,82.5,87.0,1 +Vrananda_Ricky_Saputra,77.5,77.0,79.0,78.0,80.5,78.5,72.0,79.0,81.5,78.5,4 +Widya_Dwi_Astuti,79.5,77.5,79.0,80.5,80.5,81.5,76.0,79.0,80.5,80.5,5 +Agha_Hisyam_Al-haliim,78.5,78.0,77.5,77.5,80.5,78.0,77.5,80.0,75.5,82.0,1 +Ahmad_Rofi`an_Saputra,84.5,80.5,79.0,81.0,82.5,79.0,78.5,80.0,80.0,82.0,1 +Aldo_Dwi_Julianto,83.5,81.0,79.0,77.5,80.5,79.5,81.0,81.0,80.5,85.0,6 +Alvi_Sani,77.0,78.0,78.0,74.0,80.5,77.5,77.5,76.5,76.5,76.0,5 +Aprilia_Wahyuning_Agustin,80.0,77.5,78.5,80.5,82.0,78.0,78.5,82.0,79.5,81.0,3 +Arfian_Setya_Yusinda,77.5,78.0,78.5,78.5,82.5,79.5,77.5,81.0,77.5,81.0,4 +Arga_Wahyu_Prasetyo,79.5,79.5,79.0,80.5,83.0,80.0,78.5,82.5,78.5,82.0,6 +Athaya_Angel_Widodo,81.5,79.5,80.0,83.0,85.0,81.0,79.5,85.0,80.5,85.5,3 +Bambang_Dwi_Sri_Hatmoko,83.0,77.5,78.0,79.5,81.0,79.5,77.5,80.5,80.5,79.5,4 +Bilqiz_Namsya_Prasesta,80.0,77.5,78.0,79.0,81.0,77.5,77.5,80.5,76.5,80.0,5 +Bunga_Damir_Kalolong_Sulai_Manan,79.5,81.0,82.0,79.5,82.5,81.0,78.5,81.5,78.5,83.5,1 +Chalisha_Alqathrunnada_Winsputri,86.5,77.5,79.0,81.0,82.0,82.5,80.5,85.0,78.5,86.5,5 +Decha_Navita_Sari,78.0,77.5,78.0,80.0,80.5,79.0,77.5,81.0,79.5,83.5,6 +Dina_Aulia_Salsabila_Putri,83.5,79.5,78.5,79.0,81.0,80.0,77.5,84.0,81.0,84.0,2 +Dyah_Vivianti,83.0,81.0,79.5,79.5,82.5,84.0,79.5,85.5,80.0,87.5,2 +Endang_Rahayu_Ningsih,83.5,78.0,79.5,80.0,82.5,82.5,78.5,84.5,79.5,85.0,5 +Hildan_Tri_Agustino,77.0,77.5,78.0,77.0,79.5,80.5,77.5,78.0,76.0,79.5,5 +Juliani_Wati,76.5,77.5,78.0,77.0,80.0,78.5,77.5,79.0,77.0,79.5,5 +Kaiyla_Fera_Ayu_Moza,87.0,78.5,78.5,84.0,85.0,87.5,78.5,85.0,79.5,86.5,5 +Mexel_Farel_Erlangga,77.0,76.0,78.0,72.5,79.0,78.0,77.5,75.5,77.0,76.0,4 +Muhamad_Fauzan_Saputra,76.5,78.0,78.0,80.0,83.0,78.0,77.5,80.0,74.5,80.5,4 +Muhammad_Rangga_Saputra,76.0,78.5,78.0,78.5,80.5,81.0,78.5,78.0,79.5,78.5,4 +Muhammad_Rizky_Putra_Aditya,81.0,80.5,77.5,78.5,80.5,81.0,77.5,79.5,73.0,80.5,2 +Nayaka_Sutomo,82.5,84.0,84.0,80.5,84.5,82.5,83.5,87.5,80.0,86.0,1 +Naysilla_Dwi_Anggraini,88.0,78.0,78.0,80.0,81.5,79.5,79.5,83.5,81.0,84.5,3 +Nimas_Yayank_Putri_Prabowo,80.5,78.5,78.0,75.0,83.0,77.0,77.5,84.5,79.5,85.0,2 +Puja_Micky_Harianto,80.0,78.5,78.5,81.5,81.0,81.0,77.5,82.0,79.5,84.0,4 +Reva_Aidilla_Melani,85.0,84.0,83.0,83.5,82.0,81.5,82.5,88.5,80.5,88.5,2 +Rivaldi_Desta_Akbar_Pratama,79.5,80.5,79.0,79.0,80.5,85.0,78.5,80.0,79.5,83.0,4 +Roszhiana_Ramadhani,77.5,77.5,78.0,79.5,83.0,78.0,80.0,79.0,79.0,79.5,5 +Santy_Nurahma_Agustya_Rani_Putri,78.0,78.0,78.0,78.5,83.0,77.5,77.5,78.0,76.5,80.5,4 +Sindy_Aulia_Lestari,78.0,78.0,78.0,78.5,81.0,76.5,77.0,80.0,78.5,80.5,4 +Singgih_Ferdy_Ananda,87.5,79.5,82.5,79.0,84.5,83.5,81.5,89.0,81.5,89.5,1 +Susan_Amiliya_Khoirunisa,81.5,79.5,79.0,82.0,84.0,79.5,79.5,84.0,79.5,87.5,4 +Trisa_Adelia_Parera,83.5,78.0,78.5,79.5,82.5,84.0,77.5,84.0,80.5,84.0,5 +Yohan_Arinugraha,86.5,83.5,83.5,84.0,85.0,81.0,81.0,90.0,84.0,90.0,1 +Ana_Ainus_Sifa,81.0,77.5,79.0,76.0,80.0,81.5,78.5,82.0,79.0,80.5,6 +Andini_Rahmawati,78.5,79.5,79.0,75.5,81.0,83.0,79.5,83.0,77.0,82.0,6 +Angga_Jalu_Arizona,78.5,76.0,76.0,75.0,78.5,77.0,77.0,78.5,74.0,79.0,4 +Anggun_Kartika_Okta_Novita,81.5,76.5,78.0,76.0,80.5,83.5,78.5,84.0,79.5,85.0,6 +Aprian_Mahardika,82.0,79.5,78.0,75.5,79.0,79.5,78.5,78.5,79.5,79.5,1 +Arga_Reyhandika_Pratama,85.5,77.5,80.0,82.0,85.0,77.5,81.5,85.5,83.0,83.5,2 +Awwalun_Firdausi,82.5,84.0,84.0,82.0,81.5,86.0,82.0,89.0,83.0,89.0,6 +Della_Dwi_Pratiwi,80.0,78.5,79.0,80.0,79.5,80.0,78.5,81.0,77.0,82.0,4 +Dhafa_Brillyan_Gustafianto,77.0,76.5,77.0,75.5,78.5,81.0,77.0,79.0,78.5,81.0,4 +Dinda_Windy_Oktaviona,84.0,79.5,81.0,81.5,81.5,83.5,80.5,88.0,83.5,86.0,2 +Erlang_Danendra_Ari_Putra,84.5,76.5,78.0,78.0,78.5,82.5,77.0,79.0,79.5,79.0,1 +Gabriel_Desja_Alexsyata,79.0,76.5,77.0,75.0,79.0,79.5,77.5,77.5,77.0,79.5,4 +Ghea_Anzani_Siswanto,83.5,78.5,78.0,82.0,80.5,81.5,79.5,80.5,81.0,84.0,2 +Hera_Junia_Purnamasari,80.0,82.0,79.5,77.0,80.0,87.5,78.0,86.0,81.5,86.5,2 +Isna_Maulidiyah,83.5,76.5,77.5,77.0,78.5,80.5,77.5,80.0,74.5,79.5,5 +Johan_Satrio_Wicaksono,82.5,77.5,78.0,76.0,78.0,81.0,77.5,78.5,81.5,79.5,5 +Juliana_Wita,80.0,76.5,78.0,76.0,78.5,77.0,78.5,78.0,76.5,80.5,1 +Lucky_Fabrio_Saputra,81.0,84.0,80.5,78.0,79.5,81.0,80.0,82.0,79.5,84.0,1 +Lukman_Khakim_Noah,80.5,76.5,78.0,75.5,78.5,80.0,77.0,79.0,78.5,76.0,4 +Meirakha_Fernanda_Siswardhani,82.5,77.5,80.0,81.0,79.5,83.5,79.0,83.0,80.0,82.0,1 +Mohamad_Rifa'i_Wijayanto,80.5,77.5,78.0,75.5,78.5,81.5,78.5,78.5,79.0,79.5,6 +Mohammad_Iqbal_Hafidh_Amiruddin,82.5,76.5,78.0,75.5,78.5,80.5,77.5,77.5,76.0,81.0,4 +Muhammad_Nur_Rahman_Sya'abibah,85.5,76.5,78.0,81.0,78.5,82.5,77.0,82.0,79.0,78.5,6 +Nashwa_Mentari_Yuwana_Putri,83.5,81.5,81.5,83.5,82.0,85.5,83.0,86.0,82.0,85.5,6 +Nezza_Maura_Putri_Agung,83.5,79.5,81.0,75.5,79.5,78.5,80.0,81.0,79.0,83.0,2 +Revinda_Wisca_Natalie,86.0,81.5,81.0,85.0,80.5,87.0,80.0,85.5,82.5,85.5,3 +Sahal_Ahmad,82.0,84.0,82.5,80.0,79.0,80.0,80.0,81.0,82.0,84.5,4 +Salfa_Aulia_Putri,81.0,76.5,77.0,82.0,80.5,81.5,77.5,79.0,79.5,79.5,6 +Selly_Novtiana_Waty,84.0,76.5,77.5,75.0,79.0,79.5,77.5,79.5,79.5,81.0,1 +Septia_Ayu_Muliasih,77.5,76.5,77.5,74.5,79.5,80.5,77.5,77.0,76.0,79.5,5 +Shafa_Ayunda_Putri,85.5,76.5,80.0,80.0,80.5,80.5,79.5,82.0,81.5,83.0,4 +Shifa_Khayyuna_Qisthi,85.5,78.5,79.5,82.0,81.5,81.0,78.5,84.0,82.0,84.5,3 +Siti_Apriliani_Khairunnisa,80.5,80.5,79.5,78.0,80.5,84.0,79.5,82.0,80.5,82.0,4 +Siti_Aulia_Nur_Latifah,79.0,82.5,84.0,81.0,84.5,82.0,82.0,87.5,82.5,89.0,2 +Tio_Ainun_Romadhoni,81.5,76.5,76.5,79.0,78.5,81.5,77.0,80.0,79.5,80.0,4 +Yuma_Erlangga,79.5,76.0,76.0,77.0,77.5,76.5,77.0,76.5,74.5,79.0,4 +Abid_Fadlih_Hafiyyah_Faza,85.0,78.0,78.0,78.0,79.5,80.0,78.0,79.0,81.5,81.5,4 +Adinda_Dhea_Ochtavyana,86.0,81.5,80.5,81.0,81.0,82.5,80.0,85.0,81.0,83.5,3 +Ahmad_Risky_Maskur,84.5,77.0,77.0,79.0,79.5,80.0,77.5,79.0,80.0,81.5,4 +Ahmad_Rizal_Hanafi,86.5,80.5,80.5,79.0,78.5,81.0,80.5,81.0,79.5,82.0,3 +Ahmad_Rizky_Wijaya,79.0,80.5,81.0,80.0,83.5,79.0,81.0,85.5,81.0,85.0,1 +Alfiani_Mayasari,86.0,78.5,79.0,82.0,80.0,85.0,78.5,85.5,82.0,85.5,2 +Ardan_Pradipta,76.5,79.5,78.0,75.5,82.0,81.0,78.0,81.5,80.0,82.0,4 +Arya_Erlangga_Tino_Putra,80.0,77.0,77.5,80.5,79.0,81.0,78.0,82.5,79.5,80.5,4 +Azka_Febita_Zalfianty,84.5,82.5,83.5,85.0,81.5,81.0,82.5,89.0,82.0,89.0,1 +Bunga_Alikasari,81.0,79.5,80.5,81.0,84.5,81.0,79.5,89.0,83.0,89.0,2 +Cantika_Oktavia_Nurfitri_Mardiani,79.0,78.5,79.0,81.0,81.0,86.0,78.5,85.0,82.5,85.0,3 +Chiko_Bonanza_Cavan_Putra,80.0,78.5,77.5,77.0,79.5,78.5,78.0,79.0,79.0,80.0,1 +Dhea_Ayu_Permatasari,81.5,82.0,79.0,81.0,82.0,88.5,78.5,87.0,82.0,86.5,1 +Galang_Aldi_Ananta_Putra,83.5,80.5,79.5,77.0,79.0,86.5,78.5,85.5,79.5,85.5,3 +Jefana_Dwi_Jayanti,81.5,84.5,84.0,85.0,85.0,90.5,84.0,90.0,81.5,90.0,1 +Jhovani_Vina_Maharani,84.5,79.5,78.0,83.0,79.0,83.5,78.0,80.0,79.0,80.5,2 +Keiza_Olivia_Witari,83.5,83.0,83.0,83.0,83.0,80.5,82.5,88.5,83.0,87.0,1 +Kevin_Arfian_Vidyanata,80.5,77.0,77.5,75.0,78.5,81.5,78.0,80.0,77.0,79.5,4 +M._Alfin_Nur_Rahmad,80.0,84.0,78.5,85.0,78.5,83.0,79.5,86.0,81.5,81.0,1 +Marsha_Syifa'_Setiawati,80.5,77.5,79.0,75.5,79.5,81.5,78.5,82.0,77.5,82.0,6 +Mochammad_Rifky_Febri_Nur_Rohman,76.5,77.0,77.5,72.5,78.5,77.5,78.0,78.0,78.5,77.0,4 +Mohamad_Rizal_Permana_Putra,82.0,77.0,78.0,74.0,79.0,79.0,78.0,83.0,78.5,81.5,4 +Muhammad_Gara_Imam_Arifin,81.5,78.5,79.0,83.5,79.0,81.0,78.0,86.0,81.0,83.5,2 +Muhammad_Khafid_Nuriyanto,89.5,80.5,81.0,81.5,78.5,82.5,81.5,86.0,81.0,87.5,2 +Muhammad_Zennuri,79.5,77.0,79.0,74.5,78.5,83.5,78.5,77.5,78.0,78.0,4 +Rahma_Goespiaputri_Ramadhani,84.5,77.0,78.0,81.5,80.0,82.5,80.0,84.0,80.0,83.5,3 +Rayhan_Aditya,81.5,80.5,81.0,81.5,80.0,81.5,80.5,88.0,81.0,88.0,2 +Rehan_Dwi_Ferdiansyah,76.5,77.0,78.0,73.0,79.0,81.0,78.0,76.5,79.0,77.0,4 +Rima_Dwi_Oktavia,79.5,77.5,78.0,76.0,80.0,79.5,78.0,80.0,80.0,81.0,4 +Riska_Adelia_Dulrahman,80.5,73.0,71.5,71.5,77.0,79.5,77.5,71.0,77.0,72.5,4 +Syakela_Arifah_Azzahra,83.0,77.0,79.0,77.5,79.5,85.0,78.5,80.0,79.0,80.0,5 +Titin_Karunia_Putri,83.0,79.5,78.0,81.0,82.5,85.0,79.0,85.0,81.5,85.0,2 +Vandi_Aulia_Kadhafi,83.5,77.0,79.0,76.0,79.5,79.5,78.5,79.5,78.0,79.0,4 +Yuli_Evita_Wati,83.0,77.5,79.5,80.0,80.5,86.0,79.5,84.5,81.0,83.5,3 +Zulia_Ulifatul_Mahmadah,81.5,78.5,79.0,75.0,79.5,81.0,78.5,81.0,80.0,82.0,6 +Ameliya_Putri_Mentari,86.0,76.5,78.5,79.0,82.5,89.0,79.5,86.0,78.0,84.5,4 +Andika_Bayu_Abriansyah,81.0,72.5,77.5,75.0,79.0,81.0,77.5,78.5,78.5,81.5,4 +Azmi_Halim,84.5,74.5,80.0,81.0,85.0,81.5,79.5,83.0,79.5,83.5,1 +Bambang_Adi_Pramana,83.5,74.5,79.0,78.5,81.0,82.0,79.5,78.5,79.5,79.0,3 +Bryan_Maulana_Wibiantoro,86.0,77.0,82.5,79.0,84.5,84.5,81.0,86.5,80.5,86.5,1 +Dharisman_Bagus_Prastowo,84.0,75.0,79.0,81.5,82.0,82.0,78.5,84.5,82.0,82.5,3 +Ella_Syahra_Agustin,82.0,74.0,80.0,76.5,83.0,82.5,79.5,83.5,81.5,81.5,3 +Febian_Dwi_Andika,82.0,74.5,78.0,74.5,80.0,81.0,77.5,78.5,79.0,78.5,4 +Hawa_Aurelia_Firdaus,81.0,75.5,78.0,80.0,83.0,83.5,77.5,85.0,79.5,82.5,1 +Indri_Julia_Dwi_Dianitami,81.0,76.5,80.0,77.0,81.0,83.0,79.5,83.5,82.5,86.0,1 +Kusuma_Faridatulliya,85.5,75.0,80.0,78.0,83.0,84.5,78.5,81.0,81.5,84.0,6 +Laudia_Vera_Agustin,81.0,75.0,78.5,81.0,83.5,81.5,79.5,83.5,81.0,87.0,3 +M.deno_Bayu_Putra_Hardiansah,80.0,75.0,78.0,76.0,81.0,83.0,78.5,83.0,78.5,80.0,4 +Moch._Febri_Nur_Hidayatullah,78.5,74.0,77.5,72.0,80.0,81.0,77.5,78.0,77.0,76.0,5 +Mochamat_Agil_Desulton,81.5,74.0,77.5,77.0,81.0,81.0,78.5,79.0,78.0,78.5,5 +Moh._Rendi_Saputra,81.0,73.5,78.0,75.5,81.0,81.5,77.5,80.0,79.0,80.0,4 +Mohamad_Bagus_Hermawan,78.0,73.5,77.0,74.0,80.0,81.0,77.5,73.5,79.0,79.0,4 +Muchamad_Jaohar_Firdaus,82.5,74.5,77.0,81.0,82.0,82.0,78.0,79.5,79.5,81.0,3 +Muhamad_Rif'an_Mauludi,88.0,75.5,78.5,82.0,81.5,84.0,77.5,81.0,81.5,82.0,3 +Muhammad_Safi',82.0,77.0,78.0,79.0,80.0,81.5,77.5,79.5,81.5,79.5,4 +Rendi_Prastyo_Aditama,79.5,73.5,77.5,74.5,81.0,78.0,77.5,77.5,79.0,79.0,5 +Renno_Ilham_Revaldo,85.5,73.5,77.5,79.0,81.0,82.5,77.5,83.5,79.5,82.0,2 +Risma_Indira_Fernanda,89.0,77.0,78.5,83.5,83.0,84.0,81.5,84.5,76.5,85.5,3 +Safina_Ismafatul_Roida,78.5,73.5,79.0,80.0,82.0,80.5,79.0,84.0,80.0,81.0,4 +Silvia_Novitasari,88.5,77.0,78.0,77.0,82.0,81.0,77.5,83.0,82.5,81.5,3 +Sonya_Aulia_Dewi,84.5,75.5,82.5,79.0,82.0,82.0,82.0,83.0,81.0,85.0,1 +Talita_Bella_Safira,84.0,75.5,80.0,78.0,82.0,82.5,79.5,79.5,80.5,81.5,1 +Thalyta_Chelse_Olivia_Leovanny,86.5,74.5,80.0,80.0,84.0,82.0,79.5,84.5,83.0,83.5,3 +Ulfa_Kusuma_Dewi,84.5,75.0,78.5,78.0,82.0,81.0,77.5,80.0,81.5,82.0,6 +Wahyu_Subastyan,79.5,73.5,79.0,74.0,80.0,81.5,78.5,74.0,76.0,78.5,4 +Yanuarycha_Putri_Rifda,82.0,73.5,79.0,77.0,81.5,80.0,78.5,80.5,82.0,82.5,4 +Zulfatul_Ilmi_Sa'adah,80.0,73.5,79.5,77.0,84.5,84.0,79.5,82.0,81.5,82.0,4 +Abel_Senja_Agma_Subroto,82.0,77.0,80.0,79.0,80.5,86.0,79.5,83.0,81.5,85.5,2 +Ahmad_Fauzan_Maulana_Ibrahim,82.0,76.0,78.0,77.0,84.0,82.5,77.5,80.5,81.5,82.5,4 +Amin_Ridho_Shobirin,80.5,76.0,82.5,76.0,82.5,84.5,79.5,81.5,80.0,83.0,5 +Andika_Nugrahatama,81.5,72.5,79.0,76.0,79.5,82.0,78.5,82.5,78.5,82.0,4 +Aprilliana_Cahaya_Puspita,82.5,72.5,78.5,79.0,79.5,83.0,77.5,79.0,80.5,83.0,6 +Aulia_Dwi_Suci_Cahyani,82.5,72.5,79.0,80.0,83.0,83.0,78.5,81.0,80.5,82.0,1 +Bilqis_Syafiah_Ahmad,82.0,77.5,79.0,79.0,82.5,86.0,79.0,84.5,82.0,86.5,5 +Danendra_Zahran_Qurunul_Bahri,79.0,72.5,76.5,72.0,83.5,77.5,77.5,81.5,78.0,77.5,4 +Dania_Ismatul_Maula,82.5,77.0,80.5,80.0,84.5,84.0,81.0,86.0,81.0,85.0,1 +Diva_Maulidiya,81.0,77.5,79.0,74.0,81.0,85.0,78.5,80.0,78.0,84.0,6 +Gufroni_Ahmadin_Naja,80.5,74.0,78.0,72.5,80.5,81.5,77.5,80.0,78.0,80.5,4 +Handy_Miftakhul_Ullum,82.0,77.5,78.5,82.0,82.0,86.0,78.5,88.5,82.5,85.5,2 +Kharizma_Putri_Prilia,82.5,79.0,82.0,80.0,81.5,85.0,82.0,83.5,82.5,88.0,1 +Laela_Indriani,80.0,72.5,78.5,72.0,79.5,77.0,78.5,75.5,77.0,77.0,4 +M._Zeka_Azfar_Pahlawan,82.5,71.0,79.5,78.0,85.0,84.0,79.0,79.0,81.0,80.0,3 +Maysa_Purwadinata,81.0,72.5,79.5,76.0,81.5,81.5,79.5,79.5,80.5,80.0,1 +Medina_Putri_Febiani,81.0,77.5,79.5,81.0,84.5,84.5,79.0,85.0,82.5,83.5,4 +Moh._Raditya_Haria_Nugraha,81.0,72.5,77.5,78.0,82.5,83.5,77.5,84.0,79.0,83.5,1 +Mohammad_Reno_Zainul_Pratama,81.5,72.5,77.5,75.0,79.0,84.0,77.5,73.5,81.5,81.0,4 +Muhamad_Kafil_Lubawi,81.5,72.5,79.0,76.5,80.5,84.0,78.5,80.5,79.5,82.5,4 +Nafatria_Kirana,80.0,72.5,78.5,73.5,79.0,78.0,78.5,77.5,74.5,76.5,4 +Nanang_Mariyanto,79.5,73.5,77.5,79.0,79.0,82.0,77.5,80.0,80.0,78.5,6 +Quinnsha_Launada_Wibowo,82.0,77.0,79.0,78.0,82.0,83.5,79.0,84.0,79.0,82.0,1 +Rahardyan_Felix_Kurniawan_(kr),81.5,77.0,81.0,80.0,83.0,87.0,78.5,85.5,80.0,85.0,1 +Rangga_Risqi_Bintang_S.,82.0,76.0,79.0,79.0,80.5,85.0,78.5,82.0,80.0,85.0,1 +Saputra_Seftian_Rahmatdhani,80.5,76.0,79.0,76.5,82.5,87.0,79.5,84.5,81.0,88.0,6 +Siti_Dian_Rahmadani,79.5,72.5,78.5,74.5,79.5,83.0,78.5,75.0,77.5,82.0,5 +Surya_Mahadika,81.5,76.0,79.0,76.5,80.5,83.5,79.0,84.5,80.0,85.5,1 +Tribuana_Reza_Fanani,82.0,76.0,80.0,80.0,80.5,82.5,79.0,84.5,76.0,82.5,2 +Triyah_Bhuana,81.5,77.0,80.0,76.0,79.5,82.0,77.5,83.0,79.0,80.5,4 +Viasya_Aqnia_Zela,82.0,77.0,79.5,78.0,83.5,83.5,79.5,86.0,79.5,83.0,1 +Wahyu_Febrianti,83.0,77.0,80.5,83.5,84.5,88.5,79.5,86.5,83.5,89.5,4 +Wildan_Yongga_Jodyatmika,82.5,72.5,78.5,78.0,79.0,81.5,77.5,77.0,82.0,81.0,1 +Wisnu_Tirta_Kusuma,81.5,73.5,78.5,79.0,79.5,81.0,77.5,80.0,80.5,81.0,4 +Zaneta_Ayu_Nur_Harsya,82.5,72.5,79.0,78.0,82.0,80.5,78.5,80.5,81.5,84.5,5 +Ahmad_Dodik_Pratama,79.5,73.5,79.5,78.0,83.0,86.0,80.0,79.0,79.0,85.5,1 +Ahmad_Pramuja,81.5,76.5,79.0,83.5,84.0,84.0,80.5,84.0,79.5,84.5,3 +Aprita_Nur_Aini,79.5,77.0,78.5,78.0,84.5,85.5,82.0,85.0,80.5,83.0,6 +Ardian_Rama_Budi_Kusuma,78.5,73.5,78.0,79.0,82.0,84.0,78.5,78.5,76.0,79.5,5 +Arrasha_Raditya_Andani,81.0,75.0,80.5,78.0,82.5,85.5,82.0,81.5,81.5,84.0,2 +Bayu_Ananta_Julian_Wicaksono,79.5,74.0,78.5,79.0,84.0,80.5,78.5,76.5,71.5,75.5,5 +Cahya_Sasadara,80.5,75.0,78.0,78.0,82.5,85.5,82.5,82.5,72.5,81.5,5 +Dimas_Ragil_Saputra,80.0,73.5,77.5,74.5,79.0,79.5,80.5,75.5,75.0,77.0,4 +Dwi_Ka_Fauzian_Maulana_Ikhsan,80.5,75.0,77.5,77.0,79.5,83.5,81.5,79.5,80.5,81.0,4 +Dwi_Nurida_Syifa,80.0,75.0,82.0,74.5,82.5,86.0,80.0,76.0,82.5,87.5,1 +Fibriana_Fira_Rahmawati,80.5,77.0,78.5,77.0,83.0,85.5,81.0,84.5,81.5,83.0,6 +Helsabrina_Faj'ri_Mahmuna,80.5,76.5,79.0,78.0,80.5,85.0,82.0,82.0,82.0,82.0,4 +Hesnanda_Azeema_Naswa_Krisnasekha,79.5,77.0,78.5,79.0,84.5,87.5,84.0,86.5,80.5,85.5,6 +Irma_Rahayu,80.5,77.5,80.5,76.0,82.5,86.0,78.5,86.0,82.0,84.5,1 +Jimi_Pranata,79.5,74.0,77.5,75.5,79.5,80.0,82.0,76.5,72.5,81.0,1 +Kevin_Saputra,79.5,74.0,76.5,78.0,80.5,82.0,79.5,82.5,78.5,83.0,5 +Keylla_Aulia_Renata,80.0,77.5,81.0,76.5,83.5,85.5,81.0,86.0,83.0,85.5,2 +Lia_Puspitasari,79.5,75.5,78.0,77.0,79.5,83.5,81.0,82.0,79.0,84.0,6 +Luna_Aurelia_Romadhona_Putri,80.5,76.5,81.0,83.5,84.0,89.0,81.0,84.5,82.0,88.0,1 +Moch._Rangga_Ardiyansah,81.0,76.5,78.5,79.0,82.5,86.0,85.0,86.5,80.5,84.0,1 +Moh._Krisna_Hendra_Gunawan,80.5,75.5,78.0,74.5,82.5,80.5,82.5,75.5,75.5,79.5,4 diff --git a/data/dataset_smakom_final.csv b/data/dataset_smakom_final.csv new file mode 100644 index 0000000..d1c5805 --- /dev/null +++ b/data/dataset_smakom_final.csv @@ -0,0 +1,301 @@ +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_jurusan +Ahmad_Adib_Almuiz,80.5,76.0,75.5,72.5,78.5,83.0,71.5,79.0,78.5,79.5,1 +Amin_Ridho_Sholikin,83.5,82.5,81.5,78.5,81.0,82.5,82.0,86.5,81.5,86.0,6 +Arik_Wisnu_Pramuditiya,86.0,81.0,82.0,81.0,82.5,83.0,81.5,86.0,82.5,89.0,6 +Brian_Agung_Pamungkas,90.0,82.5,83.0,84.5,85.0,88.0,85.5,89.5,84.0,90.0,6 +Chesylya_Adinnata_Maylani,79.5,76.0,79.5,79.5,81.0,81.0,75.5,82.0,80.5,83.5,3 +Dewi_Marisah_Manggar_Retno,89.0,80.5,78.5,81.5,81.0,83.0,84.0,85.5,83.0,85.5,6 +Dwi_Andika,81.0,76.5,76.5,78.0,81.0,83.5,73.5,78.5,78.5,79.0,2 +Dyah_Ayu_Pratiwi,74.5,76.5,77.5,76.5,80.5,78.5,73.5,77.0,80.0,79.5,1 +Ermita_Trisya_Sari,81.0,83.0,81.5,82.0,83.0,87.5,86.5,87.5,80.0,89.0,6 +Fika_Nur_Rohmah,82.0,78.0,78.0,79.0,80.5,83.0,79.0,79.0,80.5,80.5,3 +Fino_Andreyan_Pratama,84.0,79.0,79.0,80.0,80.0,81.5,75.0,81.5,80.5,81.5,4 +Fitri_Yulita_Sari,88.5,79.5,78.0,82.5,82.5,84.5,78.0,84.5,82.0,85.0,6 +Frisa_Agiel_Kirania,81.0,78.0,78.0,78.0,80.5,83.0,78.0,79.0,80.0,80.5,3 +Handoko_Dimas_Wahyu_Nugroho,79.0,76.0,78.5,77.0,82.5,80.5,74.5,78.5,75.5,78.5,1 +Keyla_Chantika_Devy_Julieta,76.5,79.5,80.0,74.5,80.5,81.0,76.0,80.0,79.5,82.0,2 +Latifatul_Isma,83.0,82.0,77.0,80.5,81.0,85.0,86.5,86.0,79.0,86.5,6 +M._Faris_Ardiyanto,75.0,76.0,76.0,76.0,78.5,84.5,73.5,77.5,77.5,76.0,1 +Meriyas_Eka_Anjani,85.5,77.0,78.0,81.0,83.0,81.0,74.0,82.5,83.0,85.5,5 +Mitzy_Aurora_Azzahra,85.0,79.0,80.0,80.0,83.0,83.5,80.5,84.0,81.0,84.5,5 +Moch._Galih_Nur_Hidayatullah,77.0,77.0,77.0,77.0,80.5,78.5,74.0,78.0,79.5,78.5,1 +Mohammad_Trio_Subekti,77.5,76.5,76.5,76.0,79.5,82.0,73.0,77.5,79.5,78.0,1 +Muhamad_Rangga_Aji_Pratama,86.0,77.5,77.5,79.5,83.5,86.0,74.5,83.0,82.5,81.0,5 +Nidda'ul_Fijriah,82.5,78.5,78.5,79.5,80.0,79.5,74.0,82.5,82.5,86.0,4 +Novita_Eka_Ramandani,78.5,78.0,78.0,79.5,82.0,82.5,79.0,80.0,81.0,84.0,4 +Putri_Rahayu_Ningsih,79.5,79.5,79.5,79.5,84.0,80.0,79.5,80.0,80.0,85.5,4 +Quennata_Gladis_Bilbina,86.0,78.5,78.0,80.5,82.5,86.0,79.5,79.5,82.0,81.5,5 +Rizki_Andi_Pratama,85.5,79.5,80.5,81.0,81.5,83.0,84.5,86.0,82.0,86.0,6 +Rizqi_Pangestu_Arsyadipura,84.5,84.0,81.5,82.0,81.0,79.5,83.5,88.0,81.5,87.0,6 +Rohmatul_Fitria_Dewi,87.5,78.0,78.0,83.0,81.0,84.5,80.5,80.5,82.0,81.5,5 +Satrio_Damar_Anggono,77.5,78.5,77.0,76.5,80.5,80.0,73.5,83.5,80.0,85.5,2 +Shofiyyatul_El_Husna,87.5,78.5,79.0,80.5,79.0,77.5,82.0,83.5,81.5,83.5,4 +Tegar_Krido_Wibowo,79.5,77.0,79.5,77.5,80.0,77.5,77.0,78.5,79.5,80.0,2 +Tegar_Reka_Wijaya,82.0,79.5,79.5,81.0,82.5,83.5,81.5,84.0,82.0,86.5,6 +Vania_Ika_Melinda,80.5,79.5,79.5,80.5,81.0,80.5,82.0,84.0,79.0,83.5,5 +Zaraya_Jelita_Putri_Ardina,84.0,82.5,81.5,81.5,81.0,83.5,82.0,84.0,81.0,86.0,6 +Ahmad_Maulana_Rizky_Aditya_Wijaya,85.5,78.5,77.5,79.5,83.0,79.5,74.0,82.0,80.5,84.0,3 +Alhasby_Bachtiar,79.5,79.5,78.5,81.5,80.0,79.5,76.0,84.0,81.5,85.0,4 +Aprilia_Martalita_Wibowo,78.5,77.0,78.0,80.0,80.5,81.5,78.5,85.0,78.0,84.5,4 +Arshinta_Marthalie_Gunawan,77.0,77.0,79.0,80.5,80.0,79.5,80.0,78.5,79.0,80.0,3 +Atadea_Lie_Intan_Karina,86.5,80.5,78.0,81.0,81.0,78.0,76.5,84.0,77.5,81.0,4 +Azzahra_Dewi_Natria,83.5,79.5,80.0,82.5,82.5,84.5,81.5,89.0,81.5,86.5,6 +Bagus_Rohman_Maulana,81.5,82.5,80.5,79.5,80.0,81.5,81.5,83.5,81.5,86.0,5 +Binti_Yisha_Zahra_Futri,85.0,83.0,78.5,83.5,82.5,81.0,85.5,88.5,82.0,90.0,6 +Chalista_Ayunda_Sukmaningtyas,84.0,79.0,78.0,82.5,80.5,81.5,82.0,87.0,78.5,85.0,5 +Clara_Balqis_Putri_Febrianti,82.0,83.0,82.5,84.5,85.0,84.5,84.5,88.5,82.5,88.5,6 +Davin_Yudha_Pratama,82.5,78.0,78.5,81.5,83.0,81.0,73.5,84.0,79.5,82.5,4 +Dian_Noviyanti,87.5,77.5,79.0,79.5,79.5,85.5,77.5,81.0,81.0,83.5,4 +Dwi_Kevfri_Kenata,81.0,80.5,82.0,80.5,82.5,81.5,78.5,84.5,82.5,86.0,5 +Fahril_Rahmad_Tri_Setyawan,79.5,77.0,78.0,77.0,80.0,86.0,77.5,81.0,79.5,81.0,3 +Firstian_Vidya_Permana,79.5,77.5,79.0,78.0,80.5,83.0,77.5,78.0,78.5,78.5,2 +Galang_Aldiyansah_Putra,82.5,78.5,77.0,77.5,78.5,82.0,73.5,83.0,78.5,81.0,2 +Ghania_Hardiyanti,86.0,83.5,82.5,82.5,83.0,85.5,86.5,87.5,82.0,87.0,6 +Hamida_Ulviana_Dewi,79.5,79.5,78.5,81.0,79.5,85.5,75.0,83.0,77.5,83.0,4 +Hengky_Prasetyo,85.0,77.0,77.0,75.5,80.5,80.0,73.5,79.0,76.5,78.5,1 +Kellvi_Tiaska_Putri,78.0,77.5,77.5,79.5,80.0,83.0,76.5,81.0,80.5,80.5,3 +Kurniawan_Dyo_Tri_Warsono,75.0,76.5,77.0,74.5,79.5,80.0,74.5,79.5,78.5,76.5,1 +Mochamad_Ardiansyah_Saputra,85.5,77.0,77.0,78.5,79.0,83.0,73.5,77.5,81.0,80.5,2 +Mochammad_Yuda_Aditia,87.0,76.5,76.0,80.0,81.0,81.0,73.5,78.5,81.5,81.0,3 +Mohammad_Raihan_Nur_Fajri,81.0,77.0,76.0,80.0,81.0,82.5,73.5,81.0,80.5,81.0,3 +Muhammad_Tegar_Prayuda,81.5,78.5,77.0,80.5,82.0,80.0,76.5,82.5,80.5,85.5,4 +Muhammad_Zainal_Asrofi,78.0,76.5,78.0,80.0,80.0,81.0,74.5,79.5,78.5,78.0,2 +Nur_Aleeya_Afrina_Suwandi,83.5,79.5,80.5,83.5,83.0,84.0,84.0,89.0,81.5,87.0,6 +Prima_Dyah_Novanda,85.0,80.5,81.5,81.0,83.0,82.0,83.5,86.5,77.5,85.5,6 +Rika_Nur_Fauziah,80.5,79.5,79.5,81.0,83.5,85.0,80.5,81.0,79.5,85.5,5 +Satryo_Gito_Pratomo,87.0,79.5,81.5,82.0,81.5,83.0,84.5,90.0,82.5,89.0,6 +Septian_Daniarti_Fitri_Astutik,79.0,78.5,78.0,81.0,83.0,82.0,76.0,84.0,77.0,82.5,4 +Suci_Rahayu_Saputri,81.5,78.0,77.0,80.0,80.5,80.0,75.5,77.5,79.5,80.0,2 +Sujiati_Fitrianingsih,83.5,77.5,78.0,79.5,80.0,83.0,76.5,84.0,80.5,80.5,4 +Tata_Agata_Afga_Pratama,81.5,78.0,77.5,74.5,80.5,78.5,75.0,78.5,81.0,79.5,1 +Yahya_Handika_Aditia,87.0,78.0,78.0,75.5,81.0,82.0,74.0,79.0,80.5,80.5,2 +Yulia_Nur_Lismarita,80.5,77.5,78.0,80.5,81.0,80.0,80.5,83.0,77.5,83.0,3 +Aditya_Rizky_Romadhon,81.0,77.5,76.5,79.5,80.0,81.0,76.5,82.0,80.5,80.5,3 +Aisyah_Fidinna_Rahmatika,83.5,76.5,77.5,80.5,81.0,86.5,79.0,82.5,83.0,80.5,5 +Aldo_Widodo,81.5,76.5,77.0,75.0,77.5,76.5,71.5,77.0,76.0,77.0,1 +Alvino_Surya_Pratama,78.0,77.5,77.5,75.5,78.0,80.0,80.0,81.5,82.5,80.5,2 +Alvinza_Brian_Saputra,76.5,76.5,76.5,74.5,79.0,77.5,71.5,75.0,77.5,76.5,1 +Dhimas_Pandu_Wijaya,88.0,77.5,77.5,80.0,76.0,81.5,72.0,81.0,83.0,82.0,3 +Fauziah_Elsa_Putri_Purnomo,81.0,82.5,84.0,82.5,83.0,87.0,85.5,85.0,82.0,88.5,6 +Feisal_Wisnu_Raditya,78.0,77.0,77.5,75.0,78.5,81.0,74.5,79.0,78.5,79.5,1 +Fitriani_Nurul_Norhayati,79.0,77.5,80.0,77.0,80.5,82.0,76.5,79.0,76.5,82.5,2 +Fivi_Widyo_Wati,79.5,79.5,80.0,78.5,80.5,79.0,83.0,84.0,79.0,84.5,4 +Gloria_Fransisca,81.0,79.0,80.5,80.5,83.0,80.5,79.5,84.5,80.0,85.0,5 +Herlin_Faradila_Stifiana,78.5,78.5,79.0,80.0,81.5,81.0,80.5,83.0,80.0,82.5,4 +Karisma_Hardiyati_P._R.,80.5,79.5,78.0,78.5,80.5,81.0,78.0,78.5,79.5,79.5,3 +Kirana_Putri_Agustina,81.0,79.0,79.0,79.0,81.0,81.0,79.0,83.5,80.5,83.5,4 +Lala_Putri_Meilita,77.5,78.0,78.5,79.0,82.0,84.5,81.5,82.5,80.0,85.5,4 +M.fahcri_Affan_Ramadhani,80.5,80.5,80.0,77.0,82.5,86.0,74.0,84.5,80.5,82.5,4 +Mey_Lika_Hanaya_Putri,87.0,78.0,80.0,81.0,82.5,86.5,82.0,84.5,83.0,84.5,6 +Moch.afga_Afreza_Alfiansyah,81.0,79.5,80.5,80.0,83.0,85.0,79.5,86.0,81.0,85.0,5 +Mochammad_Fahri_Nur_Rizky,82.5,79.5,83.0,81.5,80.5,82.5,85.5,86.0,82.0,87.0,6 +Muhammad_Asril_Daffa_Saputra,77.5,77.5,78.0,78.5,80.5,81.5,72.0,78.0,79.5,79.5,2 +Nadya_Anandiva_Silvyani,81.0,82.5,81.0,81.0,81.5,85.5,80.0,87.5,82.5,85.5,6 +Nendra_Nur_Febrian,82.5,77.5,77.5,78.0,79.0,79.0,72.0,76.5,80.5,83.0,2 +Putri_Adelia_Sari,81.0,77.5,78.0,80.0,80.5,84.5,80.0,85.5,82.5,82.0,5 +Ramadhani_Silfi_Damaiyanti,80.0,77.5,78.0,78.5,80.5,86.5,78.0,82.0,81.5,81.0,4 +Rangga_Galuh_Octa_Pradana,79.0,77.0,78.0,77.5,80.0,80.0,72.5,81.0,81.5,79.5,2 +Rehan_Septa_Adi_Ramadhani,83.5,78.0,78.0,79.0,82.0,86.5,75.5,82.0,80.5,80.5,4 +Salsa_Laurintiya,81.0,78.5,79.0,78.0,81.0,78.5,77.0,82.0,79.0,80.5,3 +Salva_Andika,81.0,79.0,79.0,78.0,80.5,84.0,79.5,84.5,81.5,80.5,4 +Sandy_Dwi_Meilano,81.5,82.5,81.0,80.5,81.0,83.5,83.0,85.5,82.0,87.5,6 +Sera_Natalia,80.0,77.5,79.5,80.5,82.5,81.5,80.5,81.0,80.0,84.0,4 +Tasya_Dwi_Oktavia,77.5,77.5,78.0,80.5,82.5,82.5,79.5,79.0,81.5,82.0,4 +Varisha_Anastasya,86.5,80.5,81.5,81.5,80.5,85.0,84.0,85.5,82.5,87.0,6 +Vrananda_Ricky_Saputra,77.5,77.0,79.0,78.0,80.5,78.5,72.0,79.0,81.5,78.5,2 +Widya_Dwi_Astuti,79.5,77.5,79.0,80.5,80.5,81.5,76.0,79.0,80.5,80.5,3 +Agha_Hisyam_Al-haliim,78.5,78.0,77.5,77.5,80.5,78.0,77.5,80.0,75.5,82.0,2 +Ahmad_Rofi`an_Saputra,84.5,80.5,79.0,81.0,82.5,79.0,78.5,80.0,80.0,82.0,4 +Aldo_Dwi_Julianto,83.5,81.0,79.0,77.5,80.5,79.5,81.0,81.0,80.5,85.0,4 +Alvi_Sani,77.0,78.0,78.0,74.0,80.5,77.5,77.5,76.5,76.5,76.0,1 +Aprilia_Wahyuning_Agustin,80.0,77.5,78.5,80.5,82.0,78.0,78.5,82.0,79.5,81.0,3 +Arfian_Setya_Yusinda,77.5,78.0,78.5,78.5,82.5,79.5,77.5,81.0,77.5,81.0,2 +Arga_Wahyu_Prasetyo,79.5,79.5,79.0,80.5,83.0,80.0,78.5,82.5,78.5,82.0,4 +Athaya_Angel_Widodo,81.5,79.5,80.0,83.0,85.0,81.0,79.5,85.0,80.5,85.5,6 +Bambang_Dwi_Sri_Hatmoko,83.0,77.5,78.0,79.5,81.0,79.5,77.5,80.5,80.5,79.5,3 +Bilqiz_Namsya_Prasesta,80.0,77.5,78.0,79.0,81.0,77.5,77.5,80.5,76.5,80.0,2 +Bunga_Damir_Kalolong_Sulai_Manan,79.5,81.0,82.0,79.5,82.5,81.0,78.5,81.5,78.5,83.5,4 +Chalisha_Alqathrunnada_Winsputri,86.5,77.5,79.0,81.0,82.0,82.5,80.5,85.0,78.5,86.5,5 +Decha_Navita_Sari,78.0,77.5,78.0,80.0,80.5,79.0,77.5,81.0,79.5,83.5,3 +Dina_Aulia_Salsabila_Putri,83.5,79.5,78.5,79.0,81.0,80.0,77.5,84.0,81.0,84.0,4 +Dyah_Vivianti,83.0,81.0,79.5,79.5,82.5,84.0,79.5,85.5,80.0,87.5,5 +Endang_Rahayu_Ningsih,83.5,78.0,79.5,80.0,82.5,82.5,78.5,84.5,79.5,85.0,5 +Hildan_Tri_Agustino,77.0,77.5,78.0,77.0,79.5,80.5,77.5,78.0,76.0,79.5,1 +Juliani_Wati,76.5,77.5,78.0,77.0,80.0,78.5,77.5,79.0,77.0,79.5,1 +Kaiyla_Fera_Ayu_Moza,87.0,78.5,78.5,84.0,85.0,87.5,78.5,85.0,79.5,86.5,6 +Mexel_Farel_Erlangga,77.0,76.0,78.0,72.5,79.0,78.0,77.5,75.5,77.0,76.0,1 +Muhamad_Fauzan_Saputra,76.5,78.0,78.0,80.0,83.0,78.0,77.5,80.0,74.5,80.5,2 +Muhammad_Rangga_Saputra,76.0,78.5,78.0,78.5,80.5,81.0,78.5,78.0,79.5,78.5,2 +Muhammad_Rizky_Putra_Aditya,81.0,80.5,77.5,78.5,80.5,81.0,77.5,79.5,73.0,80.5,2 +Nayaka_Sutomo,82.5,84.0,84.0,80.5,84.5,82.5,83.5,87.5,80.0,86.0,6 +Naysilla_Dwi_Anggraini,88.0,78.0,78.0,80.0,81.5,79.5,79.5,83.5,81.0,84.5,4 +Nimas_Yayank_Putri_Prabowo,80.5,78.5,78.0,75.0,83.0,77.0,77.5,84.5,79.5,85.0,2 +Puja_Micky_Harianto,80.0,78.5,78.5,81.5,81.0,81.0,77.5,82.0,79.5,84.0,4 +Reva_Aidilla_Melani,85.0,84.0,83.0,83.5,82.0,81.5,82.5,88.5,80.5,88.5,6 +Rivaldi_Desta_Akbar_Pratama,79.5,80.5,79.0,79.0,80.5,85.0,78.5,80.0,79.5,83.0,4 +Roszhiana_Ramadhani,77.5,77.5,78.0,79.5,83.0,78.0,80.0,79.0,79.0,79.5,2 +Santy_Nurahma_Agustya_Rani_Putri,78.0,78.0,78.0,78.5,83.0,77.5,77.5,78.0,76.5,80.5,2 +Sindy_Aulia_Lestari,78.0,78.0,78.0,78.5,81.0,76.5,77.0,80.0,78.5,80.5,2 +Singgih_Ferdy_Ananda,87.5,79.5,82.5,79.0,84.5,83.5,81.5,89.0,81.5,89.5,6 +Susan_Amiliya_Khoirunisa,81.5,79.5,79.0,82.0,84.0,79.5,79.5,84.0,79.5,87.5,5 +Trisa_Adelia_Parera,83.5,78.0,78.5,79.5,82.5,84.0,77.5,84.0,80.5,84.0,5 +Yohan_Arinugraha,86.5,83.5,83.5,84.0,85.0,81.0,81.0,90.0,84.0,90.0,6 +Ana_Ainus_Sifa,81.0,77.5,79.0,76.0,80.0,81.5,78.5,82.0,79.0,80.5,2 +Andini_Rahmawati,78.5,79.5,79.0,75.5,81.0,83.0,79.5,83.0,77.0,82.0,3 +Angga_Jalu_Arizona,78.5,76.0,76.0,75.0,78.5,77.0,77.0,78.5,74.0,79.0,1 +Anggun_Kartika_Okta_Novita,81.5,76.5,78.0,76.0,80.5,83.5,78.5,84.0,79.5,85.0,3 +Aprian_Mahardika,82.0,79.5,78.0,75.5,79.0,79.5,78.5,78.5,79.5,79.5,2 +Arga_Reyhandika_Pratama,85.5,77.5,80.0,82.0,85.0,77.5,81.5,85.5,83.0,83.5,5 +Awwalun_Firdausi,82.5,84.0,84.0,82.0,81.5,86.0,82.0,89.0,83.0,89.0,6 +Della_Dwi_Pratiwi,80.0,78.5,79.0,80.0,79.5,80.0,78.5,81.0,77.0,82.0,3 +Dhafa_Brillyan_Gustafianto,77.0,76.5,77.0,75.5,78.5,81.0,77.0,79.0,78.5,81.0,1 +Dinda_Windy_Oktaviona,84.0,79.5,81.0,81.5,81.5,83.5,80.5,88.0,83.5,86.0,6 +Erlang_Danendra_Ari_Putra,84.5,76.5,78.0,78.0,78.5,82.5,77.0,79.0,79.5,79.0,2 +Gabriel_Desja_Alexsyata,79.0,76.5,77.0,75.0,79.0,79.5,77.5,77.5,77.0,79.5,1 +Ghea_Anzani_Siswanto,83.5,78.5,78.0,82.0,80.5,81.5,79.5,80.5,81.0,84.0,4 +Hera_Junia_Purnamasari,80.0,82.0,79.5,77.0,80.0,87.5,78.0,86.0,81.5,86.5,5 +Isna_Maulidiyah,83.5,76.5,77.5,77.0,78.5,80.5,77.5,80.0,74.5,79.5,1 +Johan_Satrio_Wicaksono,82.5,77.5,78.0,76.0,78.0,81.0,77.5,78.5,81.5,79.5,2 +Juliana_Wita,80.0,76.5,78.0,76.0,78.5,77.0,78.5,78.0,76.5,80.5,1 +Lucky_Fabrio_Saputra,81.0,84.0,80.5,78.0,79.5,81.0,80.0,82.0,79.5,84.0,4 +Lukman_Khakim_Noah,80.5,76.5,78.0,75.5,78.5,80.0,77.0,79.0,78.5,76.0,1 +Meirakha_Fernanda_Siswardhani,82.5,77.5,80.0,81.0,79.5,83.5,79.0,83.0,80.0,82.0,4 +Mohamad_Rifa'i_Wijayanto,80.5,77.5,78.0,75.5,78.5,81.5,78.5,78.5,79.0,79.5,1 +Mohammad_Iqbal_Hafidh_Amiruddin,82.5,76.5,78.0,75.5,78.5,80.5,77.5,77.5,76.0,81.0,1 +Muhammad_Nur_Rahman_Sya'abibah,85.5,76.5,78.0,81.0,78.5,82.5,77.0,82.0,79.0,78.5,3 +Nashwa_Mentari_Yuwana_Putri,83.5,81.5,81.5,83.5,82.0,85.5,83.0,86.0,82.0,85.5,6 +Nezza_Maura_Putri_Agung,83.5,79.5,81.0,75.5,79.5,78.5,80.0,81.0,79.0,83.0,3 +Revinda_Wisca_Natalie,86.0,81.5,81.0,85.0,80.5,87.0,80.0,85.5,82.5,85.5,6 +Sahal_Ahmad,82.0,84.0,82.5,80.0,79.0,80.0,80.0,81.0,82.0,84.5,5 +Salfa_Aulia_Putri,81.0,76.5,77.0,82.0,80.5,81.5,77.5,79.0,79.5,79.5,3 +Selly_Novtiana_Waty,84.0,76.5,77.5,75.0,79.0,79.5,77.5,79.5,79.5,81.0,1 +Septia_Ayu_Muliasih,77.5,76.5,77.5,74.5,79.5,80.5,77.5,77.0,76.0,79.5,1 +Shafa_Ayunda_Putri,85.5,76.5,80.0,80.0,80.5,80.5,79.5,82.0,81.5,83.0,4 +Shifa_Khayyuna_Qisthi,85.5,78.5,79.5,82.0,81.5,81.0,78.5,84.0,82.0,84.5,5 +Siti_Apriliani_Khairunnisa,80.5,80.5,79.5,78.0,80.5,84.0,79.5,82.0,80.5,82.0,4 +Siti_Aulia_Nur_Latifah,79.0,82.5,84.0,81.0,84.5,82.0,82.0,87.5,82.5,89.0,6 +Tio_Ainun_Romadhoni,81.5,76.5,76.5,79.0,78.5,81.5,77.0,80.0,79.5,80.0,2 +Yuma_Erlangga,79.5,76.0,76.0,77.0,77.5,76.5,77.0,76.5,74.5,79.0,1 +Abid_Fadlih_Hafiyyah_Faza,85.0,78.0,78.0,78.0,79.5,80.0,78.0,79.0,81.5,81.5,3 +Adinda_Dhea_Ochtavyana,86.0,81.5,80.5,81.0,81.0,82.5,80.0,85.0,81.0,83.5,5 +Ahmad_Risky_Maskur,84.5,77.0,77.0,79.0,79.5,80.0,77.5,79.0,80.0,81.5,2 +Ahmad_Rizal_Hanafi,86.5,80.5,80.5,79.0,78.5,81.0,80.5,81.0,79.5,82.0,4 +Ahmad_Rizky_Wijaya,79.0,80.5,81.0,80.0,83.5,79.0,81.0,85.5,81.0,85.0,5 +Alfiani_Mayasari,86.0,78.5,79.0,82.0,80.0,85.0,78.5,85.5,82.0,85.5,6 +Ardan_Pradipta,76.5,79.5,78.0,75.5,82.0,81.0,78.0,81.5,80.0,82.0,2 +Arya_Erlangga_Tino_Putra,80.0,77.0,77.5,80.5,79.0,81.0,78.0,82.5,79.5,80.5,3 +Azka_Febita_Zalfianty,84.5,82.5,83.5,85.0,81.5,81.0,82.5,89.0,82.0,89.0,6 +Bunga_Alikasari,81.0,79.5,80.5,81.0,84.5,81.0,79.5,89.0,83.0,89.0,6 +Cantika_Oktavia_Nurfitri_Mardiani,79.0,78.5,79.0,81.0,81.0,86.0,78.5,85.0,82.5,85.0,5 +Chiko_Bonanza_Cavan_Putra,80.0,78.5,77.5,77.0,79.5,78.5,78.0,79.0,79.0,80.0,2 +Dhea_Ayu_Permatasari,81.5,82.0,79.0,81.0,82.0,88.5,78.5,87.0,82.0,86.5,6 +Galang_Aldi_Ananta_Putra,83.5,80.5,79.5,77.0,79.0,86.5,78.5,85.5,79.5,85.5,5 +Jefana_Dwi_Jayanti,81.5,84.5,84.0,85.0,85.0,90.5,84.0,90.0,81.5,90.0,6 +Jhovani_Vina_Maharani,84.5,79.5,78.0,83.0,79.0,83.5,78.0,80.0,79.0,80.5,4 +Keiza_Olivia_Witari,83.5,83.0,83.0,83.0,83.0,80.5,82.5,88.5,83.0,87.0,6 +Kevin_Arfian_Vidyanata,80.5,77.0,77.5,75.0,78.5,81.5,78.0,80.0,77.0,79.5,1 +M._Alfin_Nur_Rahmad,80.0,84.0,78.5,85.0,78.5,83.0,79.5,86.0,81.5,81.0,6 +Marsha_Syifa'_Setiawati,80.5,77.5,79.0,75.5,79.5,81.5,78.5,82.0,77.5,82.0,2 +Mochammad_Rifky_Febri_Nur_Rohman,76.5,77.0,77.5,72.5,78.5,77.5,78.0,78.0,78.5,77.0,1 +Mohamad_Rizal_Permana_Putra,82.0,77.0,78.0,74.0,79.0,79.0,78.0,83.0,78.5,81.5,1 +Muhammad_Gara_Imam_Arifin,81.5,78.5,79.0,83.5,79.0,81.0,78.0,86.0,81.0,83.5,5 +Muhammad_Khafid_Nuriyanto,89.5,80.5,81.0,81.5,78.5,82.5,81.5,86.0,81.0,87.5,6 +Muhammad_Zennuri,79.5,77.0,79.0,74.5,78.5,83.5,78.5,77.5,78.0,78.0,1 +Rahma_Goespiaputri_Ramadhani,84.5,77.0,78.0,81.5,80.0,82.5,80.0,84.0,80.0,83.5,5 +Rayhan_Aditya,81.5,80.5,81.0,81.5,80.0,81.5,80.5,88.0,81.0,88.0,6 +Rehan_Dwi_Ferdiansyah,76.5,77.0,78.0,73.0,79.0,81.0,78.0,76.5,79.0,77.0,1 +Rima_Dwi_Oktavia,79.5,77.5,78.0,76.0,80.0,79.5,78.0,80.0,80.0,81.0,2 +Riska_Adelia_Dulrahman,80.5,73.0,71.5,71.5,77.0,79.5,77.5,71.0,77.0,72.5,1 +Syakela_Arifah_Azzahra,83.0,77.0,79.0,77.5,79.5,85.0,78.5,80.0,79.0,80.0,3 +Titin_Karunia_Putri,83.0,79.5,78.0,81.0,82.5,85.0,79.0,85.0,81.5,85.0,5 +Vandi_Aulia_Kadhafi,83.5,77.0,79.0,76.0,79.5,79.5,78.5,79.5,78.0,79.0,2 +Yuli_Evita_Wati,83.0,77.5,79.5,80.0,80.5,86.0,79.5,84.5,81.0,83.5,5 +Zulia_Ulifatul_Mahmadah,81.5,78.5,79.0,75.0,79.5,81.0,78.5,81.0,80.0,82.0,2 +Ameliya_Putri_Mentari,86.0,76.5,78.5,79.0,82.5,89.0,79.5,86.0,78.0,84.5,5 +Andika_Bayu_Abriansyah,81.0,72.5,77.5,75.0,79.0,81.0,77.5,78.5,78.5,81.5,1 +Azmi_Halim,84.5,74.5,80.0,81.0,85.0,81.5,79.5,83.0,79.5,83.5,5 +Bambang_Adi_Pramana,83.5,74.5,79.0,78.5,81.0,82.0,79.5,78.5,79.5,79.0,3 +Bryan_Maulana_Wibiantoro,86.0,77.0,82.5,79.0,84.5,84.5,81.0,86.5,80.5,86.5,6 +Dharisman_Bagus_Prastowo,84.0,75.0,79.0,81.5,82.0,82.0,78.5,84.5,82.0,82.5,5 +Ella_Syahra_Agustin,82.0,74.0,80.0,76.5,83.0,82.5,79.5,83.5,81.5,81.5,3 +Febian_Dwi_Andika,82.0,74.5,78.0,74.5,80.0,81.0,77.5,78.5,79.0,78.5,1 +Hawa_Aurelia_Firdaus,81.0,75.5,78.0,80.0,83.0,83.5,77.5,85.0,79.5,82.5,4 +Indri_Julia_Dwi_Dianitami,81.0,76.5,80.0,77.0,81.0,83.0,79.5,83.5,82.5,86.0,4 +Kusuma_Faridatulliya,85.5,75.0,80.0,78.0,83.0,84.5,78.5,81.0,81.5,84.0,4 +Laudia_Vera_Agustin,81.0,75.0,78.5,81.0,83.5,81.5,79.5,83.5,81.0,87.0,5 +M.deno_Bayu_Putra_Hardiansah,80.0,75.0,78.0,76.0,81.0,83.0,78.5,83.0,78.5,80.0,2 +Moch._Febri_Nur_Hidayatullah,78.5,74.0,77.5,72.0,80.0,81.0,77.5,78.0,77.0,76.0,1 +Mochamat_Agil_Desulton,81.5,74.0,77.5,77.0,81.0,81.0,78.5,79.0,78.0,78.5,2 +Moh._Rendi_Saputra,81.0,73.5,78.0,75.5,81.0,81.5,77.5,80.0,79.0,80.0,1 +Mohamad_Bagus_Hermawan,78.0,73.5,77.0,74.0,80.0,81.0,77.5,73.5,79.0,79.0,1 +Muchamad_Jaohar_Firdaus,82.5,74.5,77.0,81.0,82.0,82.0,78.0,79.5,79.5,81.0,3 +Muhamad_Rif'an_Mauludi,88.0,75.5,78.5,82.0,81.5,84.0,77.5,81.0,81.5,82.0,5 +Muhammad_Safi',82.0,77.0,78.0,79.0,80.0,81.5,77.5,79.5,81.5,79.5,3 +Rendi_Prastyo_Aditama,79.5,73.5,77.5,74.5,81.0,78.0,77.5,77.5,79.0,79.0,1 +Renno_Ilham_Revaldo,85.5,73.5,77.5,79.0,81.0,82.5,77.5,83.5,79.5,82.0,3 +Risma_Indira_Fernanda,89.0,77.0,78.5,83.5,83.0,84.0,81.5,84.5,76.5,85.5,6 +Safina_Ismafatul_Roida,78.5,73.5,79.0,80.0,82.0,80.5,79.0,84.0,80.0,81.0,3 +Silvia_Novitasari,88.5,77.0,78.0,77.0,82.0,81.0,77.5,83.0,82.5,81.5,3 +Sonya_Aulia_Dewi,84.5,75.5,82.5,79.0,82.0,82.0,82.0,83.0,81.0,85.0,5 +Talita_Bella_Safira,84.0,75.5,80.0,78.0,82.0,82.5,79.5,79.5,80.5,81.5,3 +Thalyta_Chelse_Olivia_Leovanny,86.5,74.5,80.0,80.0,84.0,82.0,79.5,84.5,83.0,83.5,5 +Ulfa_Kusuma_Dewi,84.5,75.0,78.5,78.0,82.0,81.0,77.5,80.0,81.5,82.0,3 +Wahyu_Subastyan,79.5,73.5,79.0,74.0,80.0,81.5,78.5,74.0,76.0,78.5,1 +Yanuarycha_Putri_Rifda,82.0,73.5,79.0,77.0,81.5,80.0,78.5,80.5,82.0,82.5,3 +Zulfatul_Ilmi_Sa'adah,80.0,73.5,79.5,77.0,84.5,84.0,79.5,82.0,81.5,82.0,3 +Abel_Senja_Agma_Subroto,82.0,77.0,80.0,79.0,80.5,86.0,79.5,83.0,81.5,85.5,5 +Ahmad_Fauzan_Maulana_Ibrahim,82.0,76.0,78.0,77.0,84.0,82.5,77.5,80.5,81.5,82.5,3 +Amin_Ridho_Shobirin,80.5,76.0,82.5,76.0,82.5,84.5,79.5,81.5,80.0,83.0,4 +Andika_Nugrahatama,81.5,72.5,79.0,76.0,79.5,82.0,78.5,82.5,78.5,82.0,2 +Aprilliana_Cahaya_Puspita,82.5,72.5,78.5,79.0,79.5,83.0,77.5,79.0,80.5,83.0,3 +Aulia_Dwi_Suci_Cahyani,82.5,72.5,79.0,80.0,83.0,83.0,78.5,81.0,80.5,82.0,3 +Bilqis_Syafiah_Ahmad,82.0,77.5,79.0,79.0,82.5,86.0,79.0,84.5,82.0,86.5,5 +Danendra_Zahran_Qurunul_Bahri,79.0,72.5,76.5,72.0,83.5,77.5,77.5,81.5,78.0,77.5,1 +Dania_Ismatul_Maula,82.5,77.0,80.5,80.0,84.5,84.0,81.0,86.0,81.0,85.0,5 +Diva_Maulidiya,81.0,77.5,79.0,74.0,81.0,85.0,78.5,80.0,78.0,84.0,2 +Gufroni_Ahmadin_Naja,80.5,74.0,78.0,72.5,80.5,81.5,77.5,80.0,78.0,80.5,1 +Handy_Miftakhul_Ullum,82.0,77.5,78.5,82.0,82.0,86.0,78.5,88.5,82.5,85.5,6 +Kharizma_Putri_Prilia,82.5,79.0,82.0,80.0,81.5,85.0,82.0,83.5,82.5,88.0,6 +Laela_Indriani,80.0,72.5,78.5,72.0,79.5,77.0,78.5,75.5,77.0,77.0,1 +M._Zeka_Azfar_Pahlawan,82.5,71.0,79.5,78.0,85.0,84.0,79.0,79.0,81.0,80.0,3 +Maysa_Purwadinata,81.0,72.5,79.5,76.0,81.5,81.5,79.5,79.5,80.5,80.0,2 +Medina_Putri_Febiani,81.0,77.5,79.5,81.0,84.5,84.5,79.0,85.0,82.5,83.5,5 +Moh._Raditya_Haria_Nugraha,81.0,72.5,77.5,78.0,82.5,83.5,77.5,84.0,79.0,83.5,3 +Mohammad_Reno_Zainul_Pratama,81.5,72.5,77.5,75.0,79.0,84.0,77.5,73.5,81.5,81.0,1 +Muhamad_Kafil_Lubawi,81.5,72.5,79.0,76.5,80.5,84.0,78.5,80.5,79.5,82.5,2 +Nafatria_Kirana,80.0,72.5,78.5,73.5,79.0,78.0,78.5,77.5,74.5,76.5,1 +Nanang_Mariyanto,79.5,73.5,77.5,79.0,79.0,82.0,77.5,80.0,80.0,78.5,2 +Quinnsha_Launada_Wibowo,82.0,77.0,79.0,78.0,82.0,83.5,79.0,84.0,79.0,82.0,4 +Rahardyan_Felix_Kurniawan_(kr),81.5,77.0,81.0,80.0,83.0,87.0,78.5,85.5,80.0,85.0,5 +Rangga_Risqi_Bintang_S.,82.0,76.0,79.0,79.0,80.5,85.0,78.5,82.0,80.0,85.0,4 +Saputra_Seftian_Rahmatdhani,80.5,76.0,79.0,76.5,82.5,87.0,79.5,84.5,81.0,88.0,5 +Siti_Dian_Rahmadani,79.5,72.5,78.5,74.5,79.5,83.0,78.5,75.0,77.5,82.0,1 +Surya_Mahadika,81.5,76.0,79.0,76.5,80.5,83.5,79.0,84.5,80.0,85.5,3 +Tribuana_Reza_Fanani,82.0,76.0,80.0,80.0,80.5,82.5,79.0,84.5,76.0,82.5,4 +Triyah_Bhuana,81.5,77.0,80.0,76.0,79.5,82.0,77.5,83.0,79.0,80.5,3 +Viasya_Aqnia_Zela,82.0,77.0,79.5,78.0,83.5,83.5,79.5,86.0,79.5,83.0,4 +Wahyu_Febrianti,83.0,77.0,80.5,83.5,84.5,88.5,79.5,86.5,83.5,89.5,6 +Wildan_Yongga_Jodyatmika,82.5,72.5,78.5,78.0,79.0,81.5,77.5,77.0,82.0,81.0,2 +Wisnu_Tirta_Kusuma,81.5,73.5,78.5,79.0,79.5,81.0,77.5,80.0,80.5,81.0,2 +Zaneta_Ayu_Nur_Harsya,82.5,72.5,79.0,78.0,82.0,80.5,78.5,80.5,81.5,84.5,3 +Ahmad_Dodik_Pratama,79.5,73.5,79.5,78.0,83.0,86.0,80.0,79.0,79.0,85.5,3 +Ahmad_Pramuja,81.5,76.5,79.0,83.5,84.0,84.0,80.5,84.0,79.5,84.5,5 +Aprita_Nur_Aini,79.5,77.0,78.5,78.0,84.5,85.5,82.0,85.0,80.5,83.0,5 +Ardian_Rama_Budi_Kusuma,78.5,73.5,78.0,79.0,82.0,84.0,78.5,78.5,76.0,79.5,2 +Arrasha_Raditya_Andani,81.0,75.0,80.5,78.0,82.5,85.5,82.0,81.5,81.5,84.0,4 +Bayu_Ananta_Julian_Wicaksono,79.5,74.0,78.5,79.0,84.0,80.5,78.5,76.5,71.5,75.5,1 +Cahya_Sasadara,80.5,75.0,78.0,78.0,82.5,85.5,82.5,82.5,72.5,81.5,3 +Dimas_Ragil_Saputra,80.0,73.5,77.5,74.5,79.0,79.5,80.5,75.5,75.0,77.0,1 +Dwi_Ka_Fauzian_Maulana_Ikhsan,80.5,75.0,77.5,77.0,79.5,83.5,81.5,79.5,80.5,81.0,2 +Dwi_Nurida_Syifa,80.0,75.0,82.0,74.5,82.5,86.0,80.0,76.0,82.5,87.5,3 +Fibriana_Fira_Rahmawati,80.5,77.0,78.5,77.0,83.0,85.5,81.0,84.5,81.5,83.0,4 +Helsabrina_Faj'ri_Mahmuna,80.5,76.5,79.0,78.0,80.5,85.0,82.0,82.0,82.0,82.0,4 +Hesnanda_Azeema_Naswa_Krisnasekha,79.5,77.0,78.5,79.0,84.5,87.5,84.0,86.5,80.5,85.5,5 +Irma_Rahayu,80.5,77.5,80.5,76.0,82.5,86.0,78.5,86.0,82.0,84.5,5 +Jimi_Pranata,79.5,74.0,77.5,75.5,79.5,80.0,82.0,76.5,72.5,81.0,1 +Kevin_Saputra,79.5,74.0,76.5,78.0,80.5,82.0,79.5,82.5,78.5,83.0,2 +Keylla_Aulia_Renata,80.0,77.5,81.0,76.5,83.5,85.5,81.0,86.0,83.0,85.5,5 +Lia_Puspitasari,79.5,75.5,78.0,77.0,79.5,83.5,81.0,82.0,79.0,84.0,3 +Luna_Aurelia_Romadhona_Putri,80.5,76.5,81.0,83.5,84.0,89.0,81.0,84.5,82.0,88.0,6 +Moch._Rangga_Ardiyansah,81.0,76.5,78.5,79.0,82.5,86.0,85.0,86.5,80.5,84.0,5 +Moh._Krisna_Hendra_Gunawan,80.5,75.5,78.0,74.5,82.5,80.5,82.5,75.5,75.5,79.5,1 diff --git a/data/dataset_smakom_final_test.csv b/data/dataset_smakom_final_test.csv new file mode 100644 index 0000000..b4bb049 --- /dev/null +++ b/data/dataset_smakom_final_test.csv @@ -0,0 +1,61 @@ +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_jurusan +Ahmad_Fauzan_Maulana_Ibrahim,82.0,76.0,78.0,77.0,84.0,82.5,77.5,80.5,81.5,82.5,3 +Alvinza_Brian_Saputra,76.5,76.5,76.5,74.5,79.0,77.5,71.5,75.0,77.5,76.5,1 +Ameliya_Putri_Mentari,86.0,76.5,78.5,79.0,82.5,89.0,79.5,86.0,78.0,84.5,5 +Amin_Ridho_Shobirin,80.5,76.0,82.5,76.0,82.5,84.5,79.5,81.5,80.0,83.0,4 +Amin_Ridho_Sholikin,83.5,82.5,81.5,78.5,81.0,82.5,82.0,86.5,81.5,86.0,6 +Aprian_Mahardika,82.0,79.5,78.0,75.5,79.0,79.5,78.5,78.5,79.5,79.5,2 +Aprilia_Martalita_Wibowo,78.5,77.0,78.0,80.0,80.5,81.5,78.5,85.0,78.0,84.5,4 +Aprita_Nur_Aini,79.5,77.0,78.5,78.0,84.5,85.5,82.0,85.0,80.5,83.0,5 +Arshinta_Marthalie_Gunawan,77.0,77.0,79.0,80.5,80.0,79.5,80.0,78.5,79.0,80.0,3 +Athaya_Angel_Widodo,81.5,79.5,80.0,83.0,85.0,81.0,79.5,85.0,80.5,85.5,6 +Aulia_Dwi_Suci_Cahyani,82.5,72.5,79.0,80.0,83.0,83.0,78.5,81.0,80.5,82.0,3 +Bambang_Adi_Pramana,83.5,74.5,79.0,78.5,81.0,82.0,79.5,78.5,79.5,79.0,3 +Bambang_Dwi_Sri_Hatmoko,83.0,77.5,78.0,79.5,81.0,79.5,77.5,80.5,80.5,79.5,3 +Bunga_Damir_Kalolong_Sulai_Manan,79.5,81.0,82.0,79.5,82.5,81.0,78.5,81.5,78.5,83.5,4 +Cahya_Sasadara,80.5,75.0,78.0,78.0,82.5,85.5,82.5,82.5,72.5,81.5,3 +Dhea_Ayu_Permatasari,81.5,82.0,79.0,81.0,82.0,88.5,78.5,87.0,82.0,86.5,6 +Dina_Aulia_Salsabila_Putri,83.5,79.5,78.5,79.0,81.0,80.0,77.5,84.0,81.0,84.0,4 +Endang_Rahayu_Ningsih,83.5,78.0,79.5,80.0,82.5,82.5,78.5,84.5,79.5,85.0,5 +Fivi_Widyo_Wati,79.5,79.5,80.0,78.5,80.5,79.0,83.0,84.0,79.0,84.5,4 +Gabriel_Desja_Alexsyata,79.0,76.5,77.0,75.0,79.0,79.5,77.5,77.5,77.0,79.5,1 +Irma_Rahayu,80.5,77.5,80.5,76.0,82.5,86.0,78.5,86.0,82.0,84.5,5 +Jefana_Dwi_Jayanti,81.5,84.5,84.0,85.0,85.0,90.5,84.0,90.0,81.5,90.0,6 +Juliana_Wita,80.0,76.5,78.0,76.0,78.5,77.0,78.5,78.0,76.5,80.5,1 +Juliani_Wati,76.5,77.5,78.0,77.0,80.0,78.5,77.5,79.0,77.0,79.5,1 +Keiza_Olivia_Witari,83.5,83.0,83.0,83.0,83.0,80.5,82.5,88.5,83.0,87.0,6 +Keyla_Chantika_Devy_Julieta,76.5,79.5,80.0,74.5,80.5,81.0,76.0,80.0,79.5,82.0,2 +Kharizma_Putri_Prilia,82.5,79.0,82.0,80.0,81.5,85.0,82.0,83.5,82.5,88.0,6 +Kurniawan_Dyo_Tri_Warsono,75.0,76.5,77.0,74.5,79.5,80.0,74.5,79.5,78.5,76.5,1 +Laela_Indriani,80.0,72.5,78.5,72.0,79.5,77.0,78.5,75.5,77.0,77.0,1 +M._Zeka_Azfar_Pahlawan,82.5,71.0,79.5,78.0,85.0,84.0,79.0,79.0,81.0,80.0,3 +Marsha_Syifa'_Setiawati,80.5,77.5,79.0,75.5,79.5,81.5,78.5,82.0,77.5,82.0,2 +Mitzy_Aurora_Azzahra,85.0,79.0,80.0,80.0,83.0,83.5,80.5,84.0,81.0,84.5,5 +Moch._Rangga_Ardiyansah,81.0,76.5,78.5,79.0,82.5,86.0,85.0,86.5,80.5,84.0,5 +Mochamad_Ardiansyah_Saputra,85.5,77.0,77.0,78.5,79.0,83.0,73.5,77.5,81.0,80.5,2 +Mochammad_Rifky_Febri_Nur_Rohman,76.5,77.0,77.5,72.5,78.5,77.5,78.0,78.0,78.5,77.0,1 +Mochammad_Yuda_Aditia,87.0,76.5,76.0,80.0,81.0,81.0,73.5,78.5,81.5,81.0,3 +Moh._Krisna_Hendra_Gunawan,80.5,75.5,78.0,74.5,82.5,80.5,82.5,75.5,75.5,79.5,1 +Mohamad_Rifa'i_Wijayanto,80.5,77.5,78.0,75.5,78.5,81.5,78.5,78.5,79.0,79.5,1 +Muhamad_Fauzan_Saputra,76.5,78.0,78.0,80.0,83.0,78.0,77.5,80.0,74.5,80.5,2 +Muhamad_Kafil_Lubawi,81.5,72.5,79.0,76.5,80.5,84.0,78.5,80.5,79.5,82.5,2 +Muhamad_Rangga_Aji_Pratama,86.0,77.5,77.5,79.5,83.5,86.0,74.5,83.0,82.5,81.0,5 +Muhammad_Gara_Imam_Arifin,81.5,78.5,79.0,83.5,79.0,81.0,78.0,86.0,81.0,83.5,5 +Muhammad_Zainal_Asrofi,78.0,76.5,78.0,80.0,80.0,81.0,74.5,79.5,78.5,78.0,2 +Nashwa_Mentari_Yuwana_Putri,83.5,81.5,81.5,83.5,82.0,85.5,83.0,86.0,82.0,85.5,6 +Nendra_Nur_Febrian,82.5,77.5,77.5,78.0,79.0,79.0,72.0,76.5,80.5,83.0,2 +Nidda'ul_Fijriah,82.5,78.5,78.5,79.5,80.0,79.5,74.0,82.5,82.5,86.0,4 +Rendi_Prastyo_Aditama,79.5,73.5,77.5,74.5,81.0,78.0,77.5,77.5,79.0,79.0,1 +Reva_Aidilla_Melani,85.0,84.0,83.0,83.5,82.0,81.5,82.5,88.5,80.5,88.5,6 +Rivaldi_Desta_Akbar_Pratama,79.5,80.5,79.0,79.0,80.5,85.0,78.5,80.0,79.5,83.0,4 +Safina_Ismafatul_Roida,78.5,73.5,79.0,80.0,82.0,80.5,79.0,84.0,80.0,81.0,3 +Salva_Andika,81.0,79.0,79.0,78.0,80.5,84.0,79.5,84.5,81.5,80.5,4 +Satryo_Gito_Pratomo,87.0,79.5,81.5,82.0,81.5,83.0,84.5,90.0,82.5,89.0,6 +Septian_Daniarti_Fitri_Astutik,79.0,78.5,78.0,81.0,83.0,82.0,76.0,84.0,77.0,82.5,4 +Shafa_Ayunda_Putri,85.5,76.5,80.0,80.0,80.5,80.5,79.5,82.0,81.5,83.0,4 +Shifa_Khayyuna_Qisthi,85.5,78.5,79.5,82.0,81.5,81.0,78.5,84.0,82.0,84.5,5 +Sindy_Aulia_Lestari,78.0,78.0,78.0,78.5,81.0,76.5,77.0,80.0,78.5,80.5,2 +Sonya_Aulia_Dewi,84.5,75.5,82.5,79.0,82.0,82.0,82.0,83.0,81.0,85.0,5 +Suci_Rahayu_Saputri,81.5,78.0,77.0,80.0,80.5,80.0,75.5,77.5,79.5,80.0,2 +Talita_Bella_Safira,84.0,75.5,80.0,78.0,82.0,82.5,79.5,79.5,80.5,81.5,3 +Zaraya_Jelita_Putri_Ardina,84.0,82.5,81.5,81.5,81.0,83.5,82.0,84.0,81.0,86.0,6 diff --git a/data/dataset_smakom_final_train.csv b/data/dataset_smakom_final_train.csv new file mode 100644 index 0000000..1d2a5c1 --- /dev/null +++ b/data/dataset_smakom_final_train.csv @@ -0,0 +1,241 @@ +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_jurusan +Abel_Senja_Agma_Subroto,82.0,77.0,80.0,79.0,80.5,86.0,79.5,83.0,81.5,85.5,5 +Abid_Fadlih_Hafiyyah_Faza,85.0,78.0,78.0,78.0,79.5,80.0,78.0,79.0,81.5,81.5,3 +Adinda_Dhea_Ochtavyana,86.0,81.5,80.5,81.0,81.0,82.5,80.0,85.0,81.0,83.5,5 +Aditya_Rizky_Romadhon,81.0,77.5,76.5,79.5,80.0,81.0,76.5,82.0,80.5,80.5,3 +Agha_Hisyam_Al-haliim,78.5,78.0,77.5,77.5,80.5,78.0,77.5,80.0,75.5,82.0,2 +Ahmad_Adib_Almuiz,80.5,76.0,75.5,72.5,78.5,83.0,71.5,79.0,78.5,79.5,1 +Ahmad_Dodik_Pratama,79.5,73.5,79.5,78.0,83.0,86.0,80.0,79.0,79.0,85.5,3 +Ahmad_Maulana_Rizky_Aditya_Wijaya,85.5,78.5,77.5,79.5,83.0,79.5,74.0,82.0,80.5,84.0,3 +Ahmad_Pramuja,81.5,76.5,79.0,83.5,84.0,84.0,80.5,84.0,79.5,84.5,5 +Ahmad_Risky_Maskur,84.5,77.0,77.0,79.0,79.5,80.0,77.5,79.0,80.0,81.5,2 +Ahmad_Rizal_Hanafi,86.5,80.5,80.5,79.0,78.5,81.0,80.5,81.0,79.5,82.0,4 +Ahmad_Rizky_Wijaya,79.0,80.5,81.0,80.0,83.5,79.0,81.0,85.5,81.0,85.0,5 +Ahmad_Rofi`an_Saputra,84.5,80.5,79.0,81.0,82.5,79.0,78.5,80.0,80.0,82.0,4 +Aisyah_Fidinna_Rahmatika,83.5,76.5,77.5,80.5,81.0,86.5,79.0,82.5,83.0,80.5,5 +Aldo_Dwi_Julianto,83.5,81.0,79.0,77.5,80.5,79.5,81.0,81.0,80.5,85.0,4 +Aldo_Widodo,81.5,76.5,77.0,75.0,77.5,76.5,71.5,77.0,76.0,77.0,1 +Alfiani_Mayasari,86.0,78.5,79.0,82.0,80.0,85.0,78.5,85.5,82.0,85.5,6 +Alhasby_Bachtiar,79.5,79.5,78.5,81.5,80.0,79.5,76.0,84.0,81.5,85.0,4 +Alvi_Sani,77.0,78.0,78.0,74.0,80.5,77.5,77.5,76.5,76.5,76.0,1 +Alvino_Surya_Pratama,78.0,77.5,77.5,75.5,78.0,80.0,80.0,81.5,82.5,80.5,2 +Ana_Ainus_Sifa,81.0,77.5,79.0,76.0,80.0,81.5,78.5,82.0,79.0,80.5,2 +Andika_Bayu_Abriansyah,81.0,72.5,77.5,75.0,79.0,81.0,77.5,78.5,78.5,81.5,1 +Andika_Nugrahatama,81.5,72.5,79.0,76.0,79.5,82.0,78.5,82.5,78.5,82.0,2 +Andini_Rahmawati,78.5,79.5,79.0,75.5,81.0,83.0,79.5,83.0,77.0,82.0,3 +Angga_Jalu_Arizona,78.5,76.0,76.0,75.0,78.5,77.0,77.0,78.5,74.0,79.0,1 +Anggun_Kartika_Okta_Novita,81.5,76.5,78.0,76.0,80.5,83.5,78.5,84.0,79.5,85.0,3 +Aprilia_Wahyuning_Agustin,80.0,77.5,78.5,80.5,82.0,78.0,78.5,82.0,79.5,81.0,3 +Aprilliana_Cahaya_Puspita,82.5,72.5,78.5,79.0,79.5,83.0,77.5,79.0,80.5,83.0,3 +Ardan_Pradipta,76.5,79.5,78.0,75.5,82.0,81.0,78.0,81.5,80.0,82.0,2 +Ardian_Rama_Budi_Kusuma,78.5,73.5,78.0,79.0,82.0,84.0,78.5,78.5,76.0,79.5,2 +Arfian_Setya_Yusinda,77.5,78.0,78.5,78.5,82.5,79.5,77.5,81.0,77.5,81.0,2 +Arga_Reyhandika_Pratama,85.5,77.5,80.0,82.0,85.0,77.5,81.5,85.5,83.0,83.5,5 +Arga_Wahyu_Prasetyo,79.5,79.5,79.0,80.5,83.0,80.0,78.5,82.5,78.5,82.0,4 +Arik_Wisnu_Pramuditiya,86.0,81.0,82.0,81.0,82.5,83.0,81.5,86.0,82.5,89.0,6 +Arrasha_Raditya_Andani,81.0,75.0,80.5,78.0,82.5,85.5,82.0,81.5,81.5,84.0,4 +Arya_Erlangga_Tino_Putra,80.0,77.0,77.5,80.5,79.0,81.0,78.0,82.5,79.5,80.5,3 +Atadea_Lie_Intan_Karina,86.5,80.5,78.0,81.0,81.0,78.0,76.5,84.0,77.5,81.0,4 +Awwalun_Firdausi,82.5,84.0,84.0,82.0,81.5,86.0,82.0,89.0,83.0,89.0,6 +Azka_Febita_Zalfianty,84.5,82.5,83.5,85.0,81.5,81.0,82.5,89.0,82.0,89.0,6 +Azmi_Halim,84.5,74.5,80.0,81.0,85.0,81.5,79.5,83.0,79.5,83.5,5 +Azzahra_Dewi_Natria,83.5,79.5,80.0,82.5,82.5,84.5,81.5,89.0,81.5,86.5,6 +Bagus_Rohman_Maulana,81.5,82.5,80.5,79.5,80.0,81.5,81.5,83.5,81.5,86.0,5 +Bayu_Ananta_Julian_Wicaksono,79.5,74.0,78.5,79.0,84.0,80.5,78.5,76.5,71.5,75.5,1 +Bilqis_Syafiah_Ahmad,82.0,77.5,79.0,79.0,82.5,86.0,79.0,84.5,82.0,86.5,5 +Bilqiz_Namsya_Prasesta,80.0,77.5,78.0,79.0,81.0,77.5,77.5,80.5,76.5,80.0,2 +Binti_Yisha_Zahra_Futri,85.0,83.0,78.5,83.5,82.5,81.0,85.5,88.5,82.0,90.0,6 +Brian_Agung_Pamungkas,90.0,82.5,83.0,84.5,85.0,88.0,85.5,89.5,84.0,90.0,6 +Bryan_Maulana_Wibiantoro,86.0,77.0,82.5,79.0,84.5,84.5,81.0,86.5,80.5,86.5,6 +Bunga_Alikasari,81.0,79.5,80.5,81.0,84.5,81.0,79.5,89.0,83.0,89.0,6 +Cantika_Oktavia_Nurfitri_Mardiani,79.0,78.5,79.0,81.0,81.0,86.0,78.5,85.0,82.5,85.0,5 +Chalisha_Alqathrunnada_Winsputri,86.5,77.5,79.0,81.0,82.0,82.5,80.5,85.0,78.5,86.5,5 +Chalista_Ayunda_Sukmaningtyas,84.0,79.0,78.0,82.5,80.5,81.5,82.0,87.0,78.5,85.0,5 +Chesylya_Adinnata_Maylani,79.5,76.0,79.5,79.5,81.0,81.0,75.5,82.0,80.5,83.5,3 +Chiko_Bonanza_Cavan_Putra,80.0,78.5,77.5,77.0,79.5,78.5,78.0,79.0,79.0,80.0,2 +Clara_Balqis_Putri_Febrianti,82.0,83.0,82.5,84.5,85.0,84.5,84.5,88.5,82.5,88.5,6 +Danendra_Zahran_Qurunul_Bahri,79.0,72.5,76.5,72.0,83.5,77.5,77.5,81.5,78.0,77.5,1 +Dania_Ismatul_Maula,82.5,77.0,80.5,80.0,84.5,84.0,81.0,86.0,81.0,85.0,5 +Davin_Yudha_Pratama,82.5,78.0,78.5,81.5,83.0,81.0,73.5,84.0,79.5,82.5,4 +Decha_Navita_Sari,78.0,77.5,78.0,80.0,80.5,79.0,77.5,81.0,79.5,83.5,3 +Della_Dwi_Pratiwi,80.0,78.5,79.0,80.0,79.5,80.0,78.5,81.0,77.0,82.0,3 +Dewi_Marisah_Manggar_Retno,89.0,80.5,78.5,81.5,81.0,83.0,84.0,85.5,83.0,85.5,6 +Dhafa_Brillyan_Gustafianto,77.0,76.5,77.0,75.5,78.5,81.0,77.0,79.0,78.5,81.0,1 +Dharisman_Bagus_Prastowo,84.0,75.0,79.0,81.5,82.0,82.0,78.5,84.5,82.0,82.5,5 +Dhimas_Pandu_Wijaya,88.0,77.5,77.5,80.0,76.0,81.5,72.0,81.0,83.0,82.0,3 +Dian_Noviyanti,87.5,77.5,79.0,79.5,79.5,85.5,77.5,81.0,81.0,83.5,4 +Dimas_Ragil_Saputra,80.0,73.5,77.5,74.5,79.0,79.5,80.5,75.5,75.0,77.0,1 +Dinda_Windy_Oktaviona,84.0,79.5,81.0,81.5,81.5,83.5,80.5,88.0,83.5,86.0,6 +Diva_Maulidiya,81.0,77.5,79.0,74.0,81.0,85.0,78.5,80.0,78.0,84.0,2 +Dwi_Andika,81.0,76.5,76.5,78.0,81.0,83.5,73.5,78.5,78.5,79.0,2 +Dwi_Ka_Fauzian_Maulana_Ikhsan,80.5,75.0,77.5,77.0,79.5,83.5,81.5,79.5,80.5,81.0,2 +Dwi_Kevfri_Kenata,81.0,80.5,82.0,80.5,82.5,81.5,78.5,84.5,82.5,86.0,5 +Dwi_Nurida_Syifa,80.0,75.0,82.0,74.5,82.5,86.0,80.0,76.0,82.5,87.5,3 +Dyah_Ayu_Pratiwi,74.5,76.5,77.5,76.5,80.5,78.5,73.5,77.0,80.0,79.5,1 +Dyah_Vivianti,83.0,81.0,79.5,79.5,82.5,84.0,79.5,85.5,80.0,87.5,5 +Ella_Syahra_Agustin,82.0,74.0,80.0,76.5,83.0,82.5,79.5,83.5,81.5,81.5,3 +Erlang_Danendra_Ari_Putra,84.5,76.5,78.0,78.0,78.5,82.5,77.0,79.0,79.5,79.0,2 +Ermita_Trisya_Sari,81.0,83.0,81.5,82.0,83.0,87.5,86.5,87.5,80.0,89.0,6 +Fahril_Rahmad_Tri_Setyawan,79.5,77.0,78.0,77.0,80.0,86.0,77.5,81.0,79.5,81.0,3 +Fauziah_Elsa_Putri_Purnomo,81.0,82.5,84.0,82.5,83.0,87.0,85.5,85.0,82.0,88.5,6 +Febian_Dwi_Andika,82.0,74.5,78.0,74.5,80.0,81.0,77.5,78.5,79.0,78.5,1 +Feisal_Wisnu_Raditya,78.0,77.0,77.5,75.0,78.5,81.0,74.5,79.0,78.5,79.5,1 +Fibriana_Fira_Rahmawati,80.5,77.0,78.5,77.0,83.0,85.5,81.0,84.5,81.5,83.0,4 +Fika_Nur_Rohmah,82.0,78.0,78.0,79.0,80.5,83.0,79.0,79.0,80.5,80.5,3 +Fino_Andreyan_Pratama,84.0,79.0,79.0,80.0,80.0,81.5,75.0,81.5,80.5,81.5,4 +Firstian_Vidya_Permana,79.5,77.5,79.0,78.0,80.5,83.0,77.5,78.0,78.5,78.5,2 +Fitri_Yulita_Sari,88.5,79.5,78.0,82.5,82.5,84.5,78.0,84.5,82.0,85.0,6 +Fitriani_Nurul_Norhayati,79.0,77.5,80.0,77.0,80.5,82.0,76.5,79.0,76.5,82.5,2 +Frisa_Agiel_Kirania,81.0,78.0,78.0,78.0,80.5,83.0,78.0,79.0,80.0,80.5,3 +Galang_Aldi_Ananta_Putra,83.5,80.5,79.5,77.0,79.0,86.5,78.5,85.5,79.5,85.5,5 +Galang_Aldiyansah_Putra,82.5,78.5,77.0,77.5,78.5,82.0,73.5,83.0,78.5,81.0,2 +Ghania_Hardiyanti,86.0,83.5,82.5,82.5,83.0,85.5,86.5,87.5,82.0,87.0,6 +Ghea_Anzani_Siswanto,83.5,78.5,78.0,82.0,80.5,81.5,79.5,80.5,81.0,84.0,4 +Gloria_Fransisca,81.0,79.0,80.5,80.5,83.0,80.5,79.5,84.5,80.0,85.0,5 +Gufroni_Ahmadin_Naja,80.5,74.0,78.0,72.5,80.5,81.5,77.5,80.0,78.0,80.5,1 +Hamida_Ulviana_Dewi,79.5,79.5,78.5,81.0,79.5,85.5,75.0,83.0,77.5,83.0,4 +Handoko_Dimas_Wahyu_Nugroho,79.0,76.0,78.5,77.0,82.5,80.5,74.5,78.5,75.5,78.5,1 +Handy_Miftakhul_Ullum,82.0,77.5,78.5,82.0,82.0,86.0,78.5,88.5,82.5,85.5,6 +Hawa_Aurelia_Firdaus,81.0,75.5,78.0,80.0,83.0,83.5,77.5,85.0,79.5,82.5,4 +Helsabrina_Faj'ri_Mahmuna,80.5,76.5,79.0,78.0,80.5,85.0,82.0,82.0,82.0,82.0,4 +Hengky_Prasetyo,85.0,77.0,77.0,75.5,80.5,80.0,73.5,79.0,76.5,78.5,1 +Hera_Junia_Purnamasari,80.0,82.0,79.5,77.0,80.0,87.5,78.0,86.0,81.5,86.5,5 +Herlin_Faradila_Stifiana,78.5,78.5,79.0,80.0,81.5,81.0,80.5,83.0,80.0,82.5,4 +Hesnanda_Azeema_Naswa_Krisnasekha,79.5,77.0,78.5,79.0,84.5,87.5,84.0,86.5,80.5,85.5,5 +Hildan_Tri_Agustino,77.0,77.5,78.0,77.0,79.5,80.5,77.5,78.0,76.0,79.5,1 +Indri_Julia_Dwi_Dianitami,81.0,76.5,80.0,77.0,81.0,83.0,79.5,83.5,82.5,86.0,4 +Isna_Maulidiyah,83.5,76.5,77.5,77.0,78.5,80.5,77.5,80.0,74.5,79.5,1 +Jhovani_Vina_Maharani,84.5,79.5,78.0,83.0,79.0,83.5,78.0,80.0,79.0,80.5,4 +Jimi_Pranata,79.5,74.0,77.5,75.5,79.5,80.0,82.0,76.5,72.5,81.0,1 +Johan_Satrio_Wicaksono,82.5,77.5,78.0,76.0,78.0,81.0,77.5,78.5,81.5,79.5,2 +Kaiyla_Fera_Ayu_Moza,87.0,78.5,78.5,84.0,85.0,87.5,78.5,85.0,79.5,86.5,6 +Karisma_Hardiyati_P._R.,80.5,79.5,78.0,78.5,80.5,81.0,78.0,78.5,79.5,79.5,3 +Kellvi_Tiaska_Putri,78.0,77.5,77.5,79.5,80.0,83.0,76.5,81.0,80.5,80.5,3 +Kevin_Arfian_Vidyanata,80.5,77.0,77.5,75.0,78.5,81.5,78.0,80.0,77.0,79.5,1 +Kevin_Saputra,79.5,74.0,76.5,78.0,80.5,82.0,79.5,82.5,78.5,83.0,2 +Keylla_Aulia_Renata,80.0,77.5,81.0,76.5,83.5,85.5,81.0,86.0,83.0,85.5,5 +Kirana_Putri_Agustina,81.0,79.0,79.0,79.0,81.0,81.0,79.0,83.5,80.5,83.5,4 +Kusuma_Faridatulliya,85.5,75.0,80.0,78.0,83.0,84.5,78.5,81.0,81.5,84.0,4 +Lala_Putri_Meilita,77.5,78.0,78.5,79.0,82.0,84.5,81.5,82.5,80.0,85.5,4 +Latifatul_Isma,83.0,82.0,77.0,80.5,81.0,85.0,86.5,86.0,79.0,86.5,6 +Laudia_Vera_Agustin,81.0,75.0,78.5,81.0,83.5,81.5,79.5,83.5,81.0,87.0,5 +Lia_Puspitasari,79.5,75.5,78.0,77.0,79.5,83.5,81.0,82.0,79.0,84.0,3 +Lucky_Fabrio_Saputra,81.0,84.0,80.5,78.0,79.5,81.0,80.0,82.0,79.5,84.0,4 +Lukman_Khakim_Noah,80.5,76.5,78.0,75.5,78.5,80.0,77.0,79.0,78.5,76.0,1 +Luna_Aurelia_Romadhona_Putri,80.5,76.5,81.0,83.5,84.0,89.0,81.0,84.5,82.0,88.0,6 +M._Alfin_Nur_Rahmad,80.0,84.0,78.5,85.0,78.5,83.0,79.5,86.0,81.5,81.0,6 +M._Faris_Ardiyanto,75.0,76.0,76.0,76.0,78.5,84.5,73.5,77.5,77.5,76.0,1 +M.deno_Bayu_Putra_Hardiansah,80.0,75.0,78.0,76.0,81.0,83.0,78.5,83.0,78.5,80.0,2 +M.fahcri_Affan_Ramadhani,80.5,80.5,80.0,77.0,82.5,86.0,74.0,84.5,80.5,82.5,4 +Maysa_Purwadinata,81.0,72.5,79.5,76.0,81.5,81.5,79.5,79.5,80.5,80.0,2 +Medina_Putri_Febiani,81.0,77.5,79.5,81.0,84.5,84.5,79.0,85.0,82.5,83.5,5 +Meirakha_Fernanda_Siswardhani,82.5,77.5,80.0,81.0,79.5,83.5,79.0,83.0,80.0,82.0,4 +Meriyas_Eka_Anjani,85.5,77.0,78.0,81.0,83.0,81.0,74.0,82.5,83.0,85.5,5 +Mexel_Farel_Erlangga,77.0,76.0,78.0,72.5,79.0,78.0,77.5,75.5,77.0,76.0,1 +Mey_Lika_Hanaya_Putri,87.0,78.0,80.0,81.0,82.5,86.5,82.0,84.5,83.0,84.5,6 +Moch._Febri_Nur_Hidayatullah,78.5,74.0,77.5,72.0,80.0,81.0,77.5,78.0,77.0,76.0,1 +Moch._Galih_Nur_Hidayatullah,77.0,77.0,77.0,77.0,80.5,78.5,74.0,78.0,79.5,78.5,1 +Moch.afga_Afreza_Alfiansyah,81.0,79.5,80.5,80.0,83.0,85.0,79.5,86.0,81.0,85.0,5 +Mochamat_Agil_Desulton,81.5,74.0,77.5,77.0,81.0,81.0,78.5,79.0,78.0,78.5,2 +Mochammad_Fahri_Nur_Rizky,82.5,79.5,83.0,81.5,80.5,82.5,85.5,86.0,82.0,87.0,6 +Moh._Raditya_Haria_Nugraha,81.0,72.5,77.5,78.0,82.5,83.5,77.5,84.0,79.0,83.5,3 +Moh._Rendi_Saputra,81.0,73.5,78.0,75.5,81.0,81.5,77.5,80.0,79.0,80.0,1 +Mohamad_Bagus_Hermawan,78.0,73.5,77.0,74.0,80.0,81.0,77.5,73.5,79.0,79.0,1 +Mohamad_Rizal_Permana_Putra,82.0,77.0,78.0,74.0,79.0,79.0,78.0,83.0,78.5,81.5,1 +Mohammad_Iqbal_Hafidh_Amiruddin,82.5,76.5,78.0,75.5,78.5,80.5,77.5,77.5,76.0,81.0,1 +Mohammad_Raihan_Nur_Fajri,81.0,77.0,76.0,80.0,81.0,82.5,73.5,81.0,80.5,81.0,3 +Mohammad_Reno_Zainul_Pratama,81.5,72.5,77.5,75.0,79.0,84.0,77.5,73.5,81.5,81.0,1 +Mohammad_Trio_Subekti,77.5,76.5,76.5,76.0,79.5,82.0,73.0,77.5,79.5,78.0,1 +Muchamad_Jaohar_Firdaus,82.5,74.5,77.0,81.0,82.0,82.0,78.0,79.5,79.5,81.0,3 +Muhamad_Rif'an_Mauludi,88.0,75.5,78.5,82.0,81.5,84.0,77.5,81.0,81.5,82.0,5 +Muhammad_Asril_Daffa_Saputra,77.5,77.5,78.0,78.5,80.5,81.5,72.0,78.0,79.5,79.5,2 +Muhammad_Khafid_Nuriyanto,89.5,80.5,81.0,81.5,78.5,82.5,81.5,86.0,81.0,87.5,6 +Muhammad_Nur_Rahman_Sya'abibah,85.5,76.5,78.0,81.0,78.5,82.5,77.0,82.0,79.0,78.5,3 +Muhammad_Rangga_Saputra,76.0,78.5,78.0,78.5,80.5,81.0,78.5,78.0,79.5,78.5,2 +Muhammad_Rizky_Putra_Aditya,81.0,80.5,77.5,78.5,80.5,81.0,77.5,79.5,73.0,80.5,2 +Muhammad_Safi',82.0,77.0,78.0,79.0,80.0,81.5,77.5,79.5,81.5,79.5,3 +Muhammad_Tegar_Prayuda,81.5,78.5,77.0,80.5,82.0,80.0,76.5,82.5,80.5,85.5,4 +Muhammad_Zennuri,79.5,77.0,79.0,74.5,78.5,83.5,78.5,77.5,78.0,78.0,1 +Nadya_Anandiva_Silvyani,81.0,82.5,81.0,81.0,81.5,85.5,80.0,87.5,82.5,85.5,6 +Nafatria_Kirana,80.0,72.5,78.5,73.5,79.0,78.0,78.5,77.5,74.5,76.5,1 +Nanang_Mariyanto,79.5,73.5,77.5,79.0,79.0,82.0,77.5,80.0,80.0,78.5,2 +Nayaka_Sutomo,82.5,84.0,84.0,80.5,84.5,82.5,83.5,87.5,80.0,86.0,6 +Naysilla_Dwi_Anggraini,88.0,78.0,78.0,80.0,81.5,79.5,79.5,83.5,81.0,84.5,4 +Nezza_Maura_Putri_Agung,83.5,79.5,81.0,75.5,79.5,78.5,80.0,81.0,79.0,83.0,3 +Nimas_Yayank_Putri_Prabowo,80.5,78.5,78.0,75.0,83.0,77.0,77.5,84.5,79.5,85.0,2 +Novita_Eka_Ramandani,78.5,78.0,78.0,79.5,82.0,82.5,79.0,80.0,81.0,84.0,4 +Nur_Aleeya_Afrina_Suwandi,83.5,79.5,80.5,83.5,83.0,84.0,84.0,89.0,81.5,87.0,6 +Prima_Dyah_Novanda,85.0,80.5,81.5,81.0,83.0,82.0,83.5,86.5,77.5,85.5,6 +Puja_Micky_Harianto,80.0,78.5,78.5,81.5,81.0,81.0,77.5,82.0,79.5,84.0,4 +Putri_Adelia_Sari,81.0,77.5,78.0,80.0,80.5,84.5,80.0,85.5,82.5,82.0,5 +Putri_Rahayu_Ningsih,79.5,79.5,79.5,79.5,84.0,80.0,79.5,80.0,80.0,85.5,4 +Quennata_Gladis_Bilbina,86.0,78.5,78.0,80.5,82.5,86.0,79.5,79.5,82.0,81.5,5 +Quinnsha_Launada_Wibowo,82.0,77.0,79.0,78.0,82.0,83.5,79.0,84.0,79.0,82.0,4 +Rahardyan_Felix_Kurniawan_(kr),81.5,77.0,81.0,80.0,83.0,87.0,78.5,85.5,80.0,85.0,5 +Rahma_Goespiaputri_Ramadhani,84.5,77.0,78.0,81.5,80.0,82.5,80.0,84.0,80.0,83.5,5 +Ramadhani_Silfi_Damaiyanti,80.0,77.5,78.0,78.5,80.5,86.5,78.0,82.0,81.5,81.0,4 +Rangga_Galuh_Octa_Pradana,79.0,77.0,78.0,77.5,80.0,80.0,72.5,81.0,81.5,79.5,2 +Rangga_Risqi_Bintang_S.,82.0,76.0,79.0,79.0,80.5,85.0,78.5,82.0,80.0,85.0,4 +Rayhan_Aditya,81.5,80.5,81.0,81.5,80.0,81.5,80.5,88.0,81.0,88.0,6 +Rehan_Dwi_Ferdiansyah,76.5,77.0,78.0,73.0,79.0,81.0,78.0,76.5,79.0,77.0,1 +Rehan_Septa_Adi_Ramadhani,83.5,78.0,78.0,79.0,82.0,86.5,75.5,82.0,80.5,80.5,4 +Renno_Ilham_Revaldo,85.5,73.5,77.5,79.0,81.0,82.5,77.5,83.5,79.5,82.0,3 +Revinda_Wisca_Natalie,86.0,81.5,81.0,85.0,80.5,87.0,80.0,85.5,82.5,85.5,6 +Rika_Nur_Fauziah,80.5,79.5,79.5,81.0,83.5,85.0,80.5,81.0,79.5,85.5,5 +Rima_Dwi_Oktavia,79.5,77.5,78.0,76.0,80.0,79.5,78.0,80.0,80.0,81.0,2 +Riska_Adelia_Dulrahman,80.5,73.0,71.5,71.5,77.0,79.5,77.5,71.0,77.0,72.5,1 +Risma_Indira_Fernanda,89.0,77.0,78.5,83.5,83.0,84.0,81.5,84.5,76.5,85.5,6 +Rizki_Andi_Pratama,85.5,79.5,80.5,81.0,81.5,83.0,84.5,86.0,82.0,86.0,6 +Rizqi_Pangestu_Arsyadipura,84.5,84.0,81.5,82.0,81.0,79.5,83.5,88.0,81.5,87.0,6 +Rohmatul_Fitria_Dewi,87.5,78.0,78.0,83.0,81.0,84.5,80.5,80.5,82.0,81.5,5 +Roszhiana_Ramadhani,77.5,77.5,78.0,79.5,83.0,78.0,80.0,79.0,79.0,79.5,2 +Sahal_Ahmad,82.0,84.0,82.5,80.0,79.0,80.0,80.0,81.0,82.0,84.5,5 +Salfa_Aulia_Putri,81.0,76.5,77.0,82.0,80.5,81.5,77.5,79.0,79.5,79.5,3 +Salsa_Laurintiya,81.0,78.5,79.0,78.0,81.0,78.5,77.0,82.0,79.0,80.5,3 +Sandy_Dwi_Meilano,81.5,82.5,81.0,80.5,81.0,83.5,83.0,85.5,82.0,87.5,6 +Santy_Nurahma_Agustya_Rani_Putri,78.0,78.0,78.0,78.5,83.0,77.5,77.5,78.0,76.5,80.5,2 +Saputra_Seftian_Rahmatdhani,80.5,76.0,79.0,76.5,82.5,87.0,79.5,84.5,81.0,88.0,5 +Satrio_Damar_Anggono,77.5,78.5,77.0,76.5,80.5,80.0,73.5,83.5,80.0,85.5,2 +Selly_Novtiana_Waty,84.0,76.5,77.5,75.0,79.0,79.5,77.5,79.5,79.5,81.0,1 +Septia_Ayu_Muliasih,77.5,76.5,77.5,74.5,79.5,80.5,77.5,77.0,76.0,79.5,1 +Sera_Natalia,80.0,77.5,79.5,80.5,82.5,81.5,80.5,81.0,80.0,84.0,4 +Shofiyyatul_El_Husna,87.5,78.5,79.0,80.5,79.0,77.5,82.0,83.5,81.5,83.5,4 +Silvia_Novitasari,88.5,77.0,78.0,77.0,82.0,81.0,77.5,83.0,82.5,81.5,3 +Singgih_Ferdy_Ananda,87.5,79.5,82.5,79.0,84.5,83.5,81.5,89.0,81.5,89.5,6 +Siti_Apriliani_Khairunnisa,80.5,80.5,79.5,78.0,80.5,84.0,79.5,82.0,80.5,82.0,4 +Siti_Aulia_Nur_Latifah,79.0,82.5,84.0,81.0,84.5,82.0,82.0,87.5,82.5,89.0,6 +Siti_Dian_Rahmadani,79.5,72.5,78.5,74.5,79.5,83.0,78.5,75.0,77.5,82.0,1 +Sujiati_Fitrianingsih,83.5,77.5,78.0,79.5,80.0,83.0,76.5,84.0,80.5,80.5,4 +Surya_Mahadika,81.5,76.0,79.0,76.5,80.5,83.5,79.0,84.5,80.0,85.5,3 +Susan_Amiliya_Khoirunisa,81.5,79.5,79.0,82.0,84.0,79.5,79.5,84.0,79.5,87.5,5 +Syakela_Arifah_Azzahra,83.0,77.0,79.0,77.5,79.5,85.0,78.5,80.0,79.0,80.0,3 +Tasya_Dwi_Oktavia,77.5,77.5,78.0,80.5,82.5,82.5,79.5,79.0,81.5,82.0,4 +Tata_Agata_Afga_Pratama,81.5,78.0,77.5,74.5,80.5,78.5,75.0,78.5,81.0,79.5,1 +Tegar_Krido_Wibowo,79.5,77.0,79.5,77.5,80.0,77.5,77.0,78.5,79.5,80.0,2 +Tegar_Reka_Wijaya,82.0,79.5,79.5,81.0,82.5,83.5,81.5,84.0,82.0,86.5,6 +Thalyta_Chelse_Olivia_Leovanny,86.5,74.5,80.0,80.0,84.0,82.0,79.5,84.5,83.0,83.5,5 +Tio_Ainun_Romadhoni,81.5,76.5,76.5,79.0,78.5,81.5,77.0,80.0,79.5,80.0,2 +Titin_Karunia_Putri,83.0,79.5,78.0,81.0,82.5,85.0,79.0,85.0,81.5,85.0,5 +Tribuana_Reza_Fanani,82.0,76.0,80.0,80.0,80.5,82.5,79.0,84.5,76.0,82.5,4 +Trisa_Adelia_Parera,83.5,78.0,78.5,79.5,82.5,84.0,77.5,84.0,80.5,84.0,5 +Triyah_Bhuana,81.5,77.0,80.0,76.0,79.5,82.0,77.5,83.0,79.0,80.5,3 +Ulfa_Kusuma_Dewi,84.5,75.0,78.5,78.0,82.0,81.0,77.5,80.0,81.5,82.0,3 +Vandi_Aulia_Kadhafi,83.5,77.0,79.0,76.0,79.5,79.5,78.5,79.5,78.0,79.0,2 +Vania_Ika_Melinda,80.5,79.5,79.5,80.5,81.0,80.5,82.0,84.0,79.0,83.5,5 +Varisha_Anastasya,86.5,80.5,81.5,81.5,80.5,85.0,84.0,85.5,82.5,87.0,6 +Viasya_Aqnia_Zela,82.0,77.0,79.5,78.0,83.5,83.5,79.5,86.0,79.5,83.0,4 +Vrananda_Ricky_Saputra,77.5,77.0,79.0,78.0,80.5,78.5,72.0,79.0,81.5,78.5,2 +Wahyu_Febrianti,83.0,77.0,80.5,83.5,84.5,88.5,79.5,86.5,83.5,89.5,6 +Wahyu_Subastyan,79.5,73.5,79.0,74.0,80.0,81.5,78.5,74.0,76.0,78.5,1 +Widya_Dwi_Astuti,79.5,77.5,79.0,80.5,80.5,81.5,76.0,79.0,80.5,80.5,3 +Wildan_Yongga_Jodyatmika,82.5,72.5,78.5,78.0,79.0,81.5,77.5,77.0,82.0,81.0,2 +Wisnu_Tirta_Kusuma,81.5,73.5,78.5,79.0,79.5,81.0,77.5,80.0,80.5,81.0,2 +Yahya_Handika_Aditia,87.0,78.0,78.0,75.5,81.0,82.0,74.0,79.0,80.5,80.5,2 +Yanuarycha_Putri_Rifda,82.0,73.5,79.0,77.0,81.5,80.0,78.5,80.5,82.0,82.5,3 +Yohan_Arinugraha,86.5,83.5,83.5,84.0,85.0,81.0,81.0,90.0,84.0,90.0,6 +Yuli_Evita_Wati,83.0,77.5,79.5,80.0,80.5,86.0,79.5,84.5,81.0,83.5,5 +Yulia_Nur_Lismarita,80.5,77.5,78.0,80.5,81.0,80.0,80.5,83.0,77.5,83.0,3 +Yuma_Erlangga,79.5,76.0,76.0,77.0,77.5,76.5,77.0,76.5,74.5,79.0,1 +Zaneta_Ayu_Nur_Harsya,82.5,72.5,79.0,78.0,82.0,80.5,78.5,80.5,81.5,84.5,3 +Zulfatul_Ilmi_Sa'adah,80.0,73.5,79.5,77.0,84.5,84.0,79.5,82.0,81.5,82.0,3 +Zulia_Ulifatul_Mahmadah,81.5,78.5,79.0,75.0,79.5,81.0,78.5,81.0,80.0,82.0,2 diff --git a/data/paket_info.json b/data/paket_info.json new file mode 100644 index 0000000..e69de29 diff --git a/data/student_name_catalog.json b/data/student_name_catalog.json new file mode 100644 index 0000000..032b9dc --- /dev/null +++ b/data/student_name_catalog.json @@ -0,0 +1,309 @@ +{ + "added": [ + "Alfa Beta", + "Park sunghoon", + "Park Jongseong Enha", + "Ninik yuniarsih", + "Ahmad Adib Almuiz", + "Amin Ridho Sholikin", + "Arik Wisnu Pramuditiya", + "Brian Agung Pamungkas", + "Chesylya Adinnata Maylani", + "Dewi Marisah Manggar Retno", + "Dwi Andika", + "Dyah Ayu Pratiwi", + "Ermita Trisya Sari", + "Fika Nur Rohmah", + "Fino Andreyan Pratama", + "Fitri Yulita Sari", + "Frisa Agiel Kirania", + "Handoko Dimas Wahyu Nugroho", + "Keyla Chantika Devy Julieta", + "Latifatul Isma", + "M. Faris Ardiyanto", + "Meriyas Eka Anjani", + "Mitzy Aurora Azzahra", + "Moch. Galih Nur Hidayatullah", + "Mohammad Trio Subekti", + "Muhamad Rangga Aji Pratama", + "Nidda'ul Fijriah", + "Novita Eka Ramandani", + "Putri Rahayu Ningsih", + "Quennata Gladis Bilbina", + "Rizki Andi Pratama", + "Rizqi Pangestu Arsyadipura", + "Rohmatul Fitria Dewi", + "Satrio Damar Anggono", + "Shofiyyatul El Husna", + "Tegar Krido Wibowo", + "Tegar Reka Wijaya", + "Vania Ika Melinda", + "Zaraya Jelita Putri Ardina", + "Ahmad Maulana Rizky Aditya Wijaya", + "Alhasby Bachtiar", + "Aprilia Martalita Wibowo", + "Arshinta Marthalie Gunawan", + "Atadea Lie Intan Karina", + "Azzahra Dewi Natria", + "Bagus Rohman Maulana", + "Binti Yisha Zahra Futri", + "Chalista Ayunda Sukmaningtyas", + "Clara Balqis Putri Febrianti", + "Davin Yudha Pratama", + "Dian Noviyanti", + "Dwi Kevfri Kenata", + "Fahril Rahmad Tri Setyawan", + "Firstian Vidya Permana", + "Galang Aldiyansah Putra", + "Ghania Hardiyanti", + "Hamida Ulviana Dewi", + "Hengky Prasetyo", + "Kellvi Tiaska Putri", + "Kurniawan Dyo Tri Warsono", + "Mochamad Ardiansyah Saputra", + "Mochammad Yuda Aditia", + "Mohammad Raihan Nur Fajri", + "Muhammad Tegar Prayuda", + "Muhammad Zainal Asrofi", + "Nur Aleeya Afrina Suwandi", + "Prima Dyah Novanda", + "Rika Nur Fauziah", + "Satryo Gito Pratomo", + "Septian Daniarti Fitri Astutik", + "Suci Rahayu Saputri", + "Sujiati Fitrianingsih", + "Tata Agata Afga Pratama", + "Yahya Handika Aditia", + "Yulia Nur Lismarita", + "Aditya Rizky Romadhon", + "Aisyah Fidinna Rahmatika", + "Aldo Widodo", + "Alvino Surya Pratama", + "Alvinza Brian Saputra", + "Dhimas Pandu Wijaya", + "Fauziah Elsa Putri Purnomo", + "Feisal Wisnu Raditya", + "Fitriani Nurul Norhayati", + "Fivi Widyo Wati", + "Gloria Fransisca", + "Herlin Faradila Stifiana", + "Karisma Hardiyati P. R.", + "Kirana Putri Agustina", + "Lala Putri Meilita", + "M.fahcri Affan Ramadhani", + "Mey Lika Hanaya Putri", + "Moch.afga Afreza Alfiansyah", + "Mochammad Fahri Nur Rizky", + "Muhammad Asril Daffa Saputra", + "Nadya Anandiva Silvyani", + "Nendra Nur Febrian", + "Putri Adelia Sari", + "Ramadhani Silfi Damaiyanti", + "Rangga Galuh Octa Pradana", + "Rehan Septa Adi Ramadhani", + "Salsa Laurintiya", + "Salva Andika", + "Sandy Dwi Meilano", + "Sera Natalia", + "Tasya Dwi Oktavia", + "Varisha Anastasya", + "Vrananda Ricky Saputra", + "Widya Dwi Astuti", + "Agha Hisyam Al-haliim", + "Ahmad Rofi`an Saputra", + "Aldo Dwi Julianto", + "Alvi Sani", + "Aprilia Wahyuning Agustin", + "Arfian Setya Yusinda", + "Arga Wahyu Prasetyo", + "Athaya Angel Widodo", + "Bambang Dwi Sri Hatmoko", + "Bilqiz Namsya Prasesta", + "Bunga Damir Kalolong Sulai Manan", + "Chalisha Alqathrunnada Winsputri", + "Decha Navita Sari", + "Dina Aulia Salsabila Putri", + "Dyah Vivianti", + "Endang Rahayu Ningsih", + "Hildan Tri Agustino", + "Juliani Wati", + "Kaiyla Fera Ayu Moza", + "Mexel Farel Erlangga", + "Muhamad Fauzan Saputra", + "Muhammad Rangga Saputra", + "Muhammad Rizky Putra Aditya", + "Nayaka Sutomo", + "Naysilla Dwi Anggraini", + "Nimas Yayank Putri Prabowo", + "Puja Micky Harianto", + "Reva Aidilla Melani", + "Rivaldi Desta Akbar Pratama", + "Roszhiana Ramadhani", + "Santy Nurahma Agustya Rani Putri", + "Sindy Aulia Lestari", + "Singgih Ferdy Ananda", + "Susan Amiliya Khoirunisa", + "Trisa Adelia Parera", + "Yohan Arinugraha", + "Ana Ainus Sifa", + "Andini Rahmawati", + "Angga Jalu Arizona", + "Anggun Kartika Okta Novita", + "Aprian Mahardika", + "Arga Reyhandika Pratama", + "Awwalun Firdausi", + "Della Dwi Pratiwi", + "Dhafa Brillyan Gustafianto", + "Dinda Windy Oktaviona", + "Erlang Danendra Ari Putra", + "Gabriel Desja Alexsyata", + "Ghea Anzani Siswanto", + "Hera Junia Purnamasari", + "Isna Maulidiyah", + "Johan Satrio Wicaksono", + "Juliana Wita", + "Lucky Fabrio Saputra", + "Lukman Khakim Noah", + "Meirakha Fernanda Siswardhani", + "Mohamad Rifa'i Wijayanto", + "Mohammad Iqbal Hafidh Amiruddin", + "Muhammad Nur Rahman Sya'abibah", + "Nashwa Mentari Yuwana Putri", + "Nezza Maura Putri Agung", + "Revinda Wisca Natalie", + "Sahal Ahmad", + "Salfa Aulia Putri", + "Selly Novtiana Waty", + "Septia Ayu Muliasih", + "Shafa Ayunda Putri", + "Shifa Khayyuna Qisthi", + "Siti Apriliani Khairunnisa", + "Siti Aulia Nur Latifah", + "Tio Ainun Romadhoni", + "Yuma Erlangga", + "Abid Fadlih Hafiyyah Faza", + "Adinda Dhea Ochtavyana", + "Ahmad Risky Maskur", + "Ahmad Rizal Hanafi", + "Ahmad Rizky Wijaya", + "Alfiani Mayasari", + "Ardan Pradipta", + "Arya Erlangga Tino Putra", + "Azka Febita Zalfianty", + "Bunga Alikasari", + "Cantika Oktavia Nurfitri Mardiani", + "Chiko Bonanza Cavan Putra", + "Dhea Ayu Permatasari", + "Galang Aldi Ananta Putra", + "Jefana Dwi Jayanti", + "Jhovani Vina Maharani", + "Keiza Olivia Witari", + "Kevin Arfian Vidyanata", + "M. Alfin Nur Rahmad", + "Marsha Syifa' Setiawati", + "Mochammad Rifky Febri Nur Rohman", + "Mohamad Rizal Permana Putra", + "Muhammad Gara Imam Arifin", + "Muhammad Khafid Nuriyanto", + "Muhammad Zennuri", + "Rahma Goespiaputri Ramadhani", + "Rayhan Aditya", + "Rehan Dwi Ferdiansyah", + "Rima Dwi Oktavia", + "Riska Adelia Dulrahman", + "Syakela Arifah Azzahra", + "Titin Karunia Putri", + "Vandi Aulia Kadhafi", + "Yuli Evita Wati", + "Zulia Ulifatul Mahmadah", + "Ameliya Putri Mentari", + "Andika Bayu Abriansyah", + "Azmi Halim", + "Bambang Adi Pramana", + "Bryan Maulana Wibiantoro", + "Dharisman Bagus Prastowo", + "Ella Syahra Agustin", + "Febian Dwi Andika", + "Hawa Aurelia Firdaus", + "Indri Julia Dwi Dianitami", + "Kusuma Faridatulliya", + "Laudia Vera Agustin", + "M.deno Bayu Putra Hardiansah", + "Moch. Febri Nur Hidayatullah", + "Mochamat Agil Desulton", + "Moh. Rendi Saputra", + "Mohamad Bagus Hermawan", + "Muchamad Jaohar Firdaus", + "Muhamad Rif'an Mauludi", + "Muhammad Safi'", + "Rendi Prastyo Aditama", + "Renno Ilham Revaldo", + "Risma Indira Fernanda", + "Safina Ismafatul Roida", + "Silvia Novitasari", + "Sonya Aulia Dewi", + "Talita Bella Safira", + "Thalyta Chelse Olivia Leovanny", + "Ulfa Kusuma Dewi", + "Wahyu Subastyan", + "Yanuarycha Putri Rifda", + "Zulfatul Ilmi Sa'adah", + "Abel Senja Agma Subroto", + "Ahmad Fauzan Maulana Ibrahim", + "Amin Ridho Shobirin", + "Andika Nugrahatama", + "Aprilliana Cahaya Puspita", + "Aulia Dwi Suci Cahyani", + "Bilqis Syafiah Ahmad", + "Danendra Zahran Qurunul Bahri", + "Dania Ismatul Maula", + "Diva Maulidiya", + "Gufroni Ahmadin Naja", + "Handy Miftakhul Ullum", + "Kharizma Putri Prilia", + "Laela Indriani", + "M. Zeka Azfar Pahlawan", + "Maysa Purwadinata", + "Medina Putri Febiani", + "Moh. Raditya Haria Nugraha", + "Mohammad Reno Zainul Pratama", + "Muhamad Kafil Lubawi", + "Nafatria Kirana", + "Nanang Mariyanto", + "Quinnsha Launada Wibowo", + "Rahardyan Felix Kurniawan (kr)", + "Rangga Risqi Bintang S.", + "Saputra Seftian Rahmatdhani", + "Siti Dian Rahmadani", + "Surya Mahadika", + "Tribuana Reza Fanani", + "Triyah Bhuana", + "Viasya Aqnia Zela", + "Wahyu Febrianti", + "Wildan Yongga Jodyatmika", + "Wisnu Tirta Kusuma", + "Zaneta Ayu Nur Harsya", + "Ahmad Dodik Pratama", + "Ahmad Pramuja", + "Aprita Nur Aini", + "Ardian Rama Budi Kusuma", + "Arrasha Raditya Andani", + "Bayu Ananta Julian Wicaksono", + "Cahya Sasadara", + "Dimas Ragil Saputra", + "Dwi Ka Fauzian Maulana Ikhsan", + "Dwi Nurida Syifa", + "Fibriana Fira Rahmawati", + "Helsabrina Faj'ri Mahmuna", + "Hesnanda Azeema Naswa Krisnasekha", + "Irma Rahayu", + "Jimi Pranata", + "Kevin Saputra", + "Keylla Aulia Renata", + "Lia Puspitasari", + "Luna Aurelia Romadhona Putri", + "Moch. Rangga Ardiyansah", + "Moh. Krisna Hendra Gunawan" + ], + "deleted": [] +} \ No newline at end of file diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..e8841eb --- /dev/null +++ b/db/database.py @@ -0,0 +1,303 @@ +import hashlib +def hash_password(password): + """Hash password dengan SHA-256.""" + return hashlib.sha256(password.encode('utf-8')).hexdigest() + +def register_admin(username, password): + """ + Tambah admin baru ke tabel admin. Password akan di-hash. + Akan melempar Exception jika username sudah ada. + """ + password_hash = hash_password(password) + with get_connection() as conn: + cur = conn.cursor() + cur.execute("INSERT INTO admin (username, password_hash) VALUES (%s, %s)", (username, password_hash)) + conn.commit() + +def verify_admin_login(username, password): + """ + Verifikasi login admin. Return True jika username & password cocok, else False. + """ + password_hash = hash_password(password) + with get_connection() as conn: + cur = conn.cursor() + cur.execute("SELECT id FROM admin WHERE username = %s AND password_hash = %s", (username, password_hash)) + result = cur.fetchone() + return result is not None + +def verify_admin_login_detail(username, password): + """ + Verifikasi login admin dengan status detail. + Return salah satu dari: + - 'success' + - 'username_not_found' + - 'wrong_password' + - 'wrong_username_and_password' + """ + input_password_hash = hash_password(password) + + with get_connection() as conn: + cur = conn.cursor() + cur.execute("SELECT password_hash FROM admin WHERE username = %s", (username,)) + result = cur.fetchone() + + if result is None: + cur.execute("SELECT id FROM admin WHERE password_hash = %s", (input_password_hash,)) + password_match = cur.fetchone() + if password_match is not None: + return 'username_not_found' + return 'wrong_username_and_password' + + stored_hash = result[0] + if stored_hash != input_password_hash: + return 'wrong_password' + + return 'success' +import psycopg2 +from contextlib import contextmanager + +DB_CONFIG = { + 'host': 'localhost', + 'database': 'db_klasifikasi', + 'user': 'postgres', + 'password': 'admin' +} + +@contextmanager +def get_connection(): + """ + Context manager untuk koneksi database PostgreSQL. + Otomatis menutup koneksi setelah digunakan. + Akan melempar Exception jika gagal koneksi. + """ + conn = None + try: + conn = psycopg2.connect(**DB_CONFIG) + yield conn + except psycopg2.Error as e: + raise Exception(f"Gagal koneksi database: {e}") + finally: + if conn: + conn.close() + +def test_connection(): + """Cek koneksi ke database, return True jika sukses, False jika gagal.""" + try: + with get_connection() as conn: + cur = conn.cursor() + cur.execute("SELECT 1") + return True + except Exception: + return False + +# untuk simpan hasil prediksi +def simpan_hasil_prediksi(nama, nilai_dict, paket_prediksi): + with get_connection() as conn: + cur = conn.cursor() + sql = """ + INSERT INTO hasil_prediksi ( + nama_siswa, + nilai_informatika_sem1, nilai_informatika_sem2, nilai_informatika, + nilai_fisika_sem1, nilai_fisika_sem2, nilai_fisika, + nilai_kimia_sem1, nilai_kimia_sem2, nilai_kimia, + nilai_biologi_sem1, nilai_biologi_sem2, nilai_biologi, + nilai_big_lanjut_sem1, nilai_big_lanjut_sem2, nilai_big_lanjut, + nilai_ekonomi_sem1, nilai_ekonomi_sem2, nilai_ekonomi, + nilai_mat_lanjut_sem1, nilai_mat_lanjut_sem2, nilai_mat_lanjut, + nilai_sej_lanjut_sem1, nilai_sej_lanjut_sem2, nilai_sej_lanjut, + nilai_sosiologi_sem1, nilai_sosiologi_sem2, nilai_sosiologi, + nilai_geografi_sem1, nilai_geografi_sem2, nilai_geografi, + paket_prediksi + ) VALUES ( + %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + """ + values = ( + str(nama), + # Informatika + float(nilai_dict.get('nilai_informatika_sem1', 0)), + float(nilai_dict.get('nilai_informatika_sem2', 0)), + float(nilai_dict.get('nilai_informatika', 0)), + # Fisika + float(nilai_dict.get('nilai_fisika_sem1', 0)), + float(nilai_dict.get('nilai_fisika_sem2', 0)), + float(nilai_dict.get('nilai_fisika', 0)), + # Kimia + float(nilai_dict.get('nilai_kimia_sem1', 0)), + float(nilai_dict.get('nilai_kimia_sem2', 0)), + float(nilai_dict.get('nilai_kimia', 0)), + # Biologi + float(nilai_dict.get('nilai_biologi_sem1', 0)), + float(nilai_dict.get('nilai_biologi_sem2', 0)), + float(nilai_dict.get('nilai_biologi', 0)), + # BIG Lanjut + float(nilai_dict.get('nilai_big_lanjut_sem1', 0)), + float(nilai_dict.get('nilai_big_lanjut_sem2', 0)), + float(nilai_dict.get('nilai_big_lanjut', 0)), + # Ekonomi + float(nilai_dict.get('nilai_ekonomi_sem1', 0)), + float(nilai_dict.get('nilai_ekonomi_sem2', 0)), + float(nilai_dict.get('nilai_ekonomi', 0)), + # MAT Lanjut + float(nilai_dict.get('nilai_mat_lanjut_sem1', 0)), + float(nilai_dict.get('nilai_mat_lanjut_sem2', 0)), + float(nilai_dict.get('nilai_mat_lanjut', 0)), + # SEJ Lanjut + float(nilai_dict.get('nilai_sej_lanjut_sem1', 0)), + float(nilai_dict.get('nilai_sej_lanjut_sem2', 0)), + float(nilai_dict.get('nilai_sej_lanjut', 0)), + # Sosiologi + float(nilai_dict.get('nilai_sosiologi_sem1', 0)), + float(nilai_dict.get('nilai_sosiologi_sem2', 0)), + float(nilai_dict.get('nilai_sosiologi', 0)), + # Geografi + float(nilai_dict.get('nilai_geografi_sem1', 0)), + float(nilai_dict.get('nilai_geografi_sem2', 0)), + float(nilai_dict.get('nilai_geografi', 0)), + int(paket_prediksi) + ) + cur.execute(sql, values) + conn.commit() #membuat data masuk ke database + +def ambil_semua_hasil_prediksi(): + """ + Ambil semua data hasil prediksi dari tabel hasil_prediksi. + Return: list of dict. + """ + with get_connection() as conn: + cur = conn.cursor() + sql = "SELECT * FROM hasil_prediksi ORDER BY id DESC" + cur.execute(sql) + columns = [desc[0] for desc in cur.description] + rows = cur.fetchall() + return [dict(zip(columns, row)) for row in rows] + +#untuk update hasil prediksi di database berdasarkan id. +def update_hasil_prediksi(id, nama, nilai_dict, paket_prediksi): + with get_connection() as conn: + cur = conn.cursor() + sql = """ + UPDATE hasil_prediksi SET + nama_siswa = %s, + nilai_informatika_sem1 = %s, nilai_informatika_sem2 = %s, nilai_informatika = %s, + nilai_fisika_sem1 = %s, nilai_fisika_sem2 = %s, nilai_fisika = %s, + nilai_kimia_sem1 = %s, nilai_kimia_sem2 = %s, nilai_kimia = %s, + nilai_biologi_sem1 = %s, nilai_biologi_sem2 = %s, nilai_biologi = %s, + nilai_big_lanjut_sem1 = %s, nilai_big_lanjut_sem2 = %s, nilai_big_lanjut = %s, + nilai_ekonomi_sem1 = %s, nilai_ekonomi_sem2 = %s, nilai_ekonomi = %s, + nilai_mat_lanjut_sem1 = %s, nilai_mat_lanjut_sem2 = %s, nilai_mat_lanjut = %s, + nilai_sej_lanjut_sem1 = %s, nilai_sej_lanjut_sem2 = %s, nilai_sej_lanjut = %s, + nilai_sosiologi_sem1 = %s, nilai_sosiologi_sem2 = %s, nilai_sosiologi = %s, + nilai_geografi_sem1 = %s, nilai_geografi_sem2 = %s, nilai_geografi = %s, + paket_prediksi = %s + WHERE id = %s + """ + values = ( + str(nama), + # Informatika + float(nilai_dict.get('nilai_informatika_sem1', 0)), + float(nilai_dict.get('nilai_informatika_sem2', 0)), + float(nilai_dict.get('nilai_informatika', 0)), + # Fisika + float(nilai_dict.get('nilai_fisika_sem1', 0)), + float(nilai_dict.get('nilai_fisika_sem2', 0)), + float(nilai_dict.get('nilai_fisika', 0)), + # Kimia + float(nilai_dict.get('nilai_kimia_sem1', 0)), + float(nilai_dict.get('nilai_kimia_sem2', 0)), + float(nilai_dict.get('nilai_kimia', 0)), + # Biologi + float(nilai_dict.get('nilai_biologi_sem1', 0)), + float(nilai_dict.get('nilai_biologi_sem2', 0)), + float(nilai_dict.get('nilai_biologi', 0)), + # BIG Lanjut + float(nilai_dict.get('nilai_big_lanjut_sem1', 0)), + float(nilai_dict.get('nilai_big_lanjut_sem2', 0)), + float(nilai_dict.get('nilai_big_lanjut', 0)), + # Ekonomi + float(nilai_dict.get('nilai_ekonomi_sem1', 0)), + float(nilai_dict.get('nilai_ekonomi_sem2', 0)), + float(nilai_dict.get('nilai_ekonomi', 0)), + # MAT Lanjut + float(nilai_dict.get('nilai_mat_lanjut_sem1', 0)), + float(nilai_dict.get('nilai_mat_lanjut_sem2', 0)), + float(nilai_dict.get('nilai_mat_lanjut', 0)), + # SEJ Lanjut + float(nilai_dict.get('nilai_sej_lanjut_sem1', 0)), + float(nilai_dict.get('nilai_sej_lanjut_sem2', 0)), + float(nilai_dict.get('nilai_sej_lanjut', 0)), + # Sosiologi + float(nilai_dict.get('nilai_sosiologi_sem1', 0)), + float(nilai_dict.get('nilai_sosiologi_sem2', 0)), + float(nilai_dict.get('nilai_sosiologi', 0)), + # Geografi + float(nilai_dict.get('nilai_geografi_sem1', 0)), + float(nilai_dict.get('nilai_geografi_sem2', 0)), + float(nilai_dict.get('nilai_geografi', 0)), + int(paket_prediksi), + int(id) + ) + cur.execute(sql, values) + conn.commit() + +def hapus_hasil_prediksi(id): + """ + Hapus data hasil prediksi berdasarkan id. + Akan melempar Exception jika data tidak ditemukan. + """ + with get_connection() as conn: + cur = conn.cursor() + sql = "DELETE FROM hasil_prediksi WHERE id = %s" + cur.execute(sql, (int(id),)) + if cur.rowcount == 0: + raise Exception(f"Data dengan ID {id} tidak ditemukan") + conn.commit() + +def backup_database(backup_path): + """ + Backup seluruh data hasil_prediksi ke file CSV. + Return True jika sukses, False jika gagal. + """ + try: + data = ambil_semua_hasil_prediksi() + import pandas as pd + df = pd.DataFrame(data) + df.to_csv(backup_path, index=False) + return True + except Exception: + return False + +def get_statistics(): + """ + Ambil statistik ringkas dari tabel hasil_prediksi. + Return dict statistik atau None jika gagal. + """ + try: + with get_connection() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM hasil_prediksi") + total_siswa = cur.fetchone()[0] + cur.execute(""" + SELECT paket_prediksi, COUNT(*) as jumlah + FROM hasil_prediksi + GROUP BY paket_prediksi + ORDER BY paket_prediksi + """) + distribusi_paket = cur.fetchall() + cur.execute(""" + SELECT + AVG(nilai_informatika), AVG(nilai_fisika), AVG(nilai_kimia), AVG(nilai_biologi), + AVG(nilai_big_lanjut), AVG(nilai_ekonomi), AVG(nilai_mat_lanjut), AVG(nilai_sej_lanjut), + AVG(nilai_sosiologi), AVG(nilai_geografi) + FROM hasil_prediksi + """) + avg_nilai = cur.fetchone() + return { + 'total_siswa': total_siswa, + 'distribusi_paket': distribusi_paket, + 'rata_rata_nilai': avg_nilai + } + except Exception: + return None \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..ed5ad9b --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,59 @@ +-- Schema database untuk Sistem Klasifikasi Jurusan Siswa +-- Jalankan file ini di database: db_klasifikasi + +CREATE TABLE IF NOT EXISTS admin ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS hasil_prediksi ( + id SERIAL PRIMARY KEY, + nama_siswa VARCHAR(100) NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk Informatika + nilai_informatika_sem1 DOUBLE PRECISION, + nilai_informatika_sem2 DOUBLE PRECISION, + nilai_informatika DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk Fisika + nilai_fisika_sem1 DOUBLE PRECISION, + nilai_fisika_sem2 DOUBLE PRECISION, + nilai_fisika DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk Kimia + nilai_kimia_sem1 DOUBLE PRECISION, + nilai_kimia_sem2 DOUBLE PRECISION, + nilai_kimia DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk Biologi + nilai_biologi_sem1 DOUBLE PRECISION, + nilai_biologi_sem2 DOUBLE PRECISION, + nilai_biologi DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk BIG Lanjut + nilai_big_lanjut_sem1 DOUBLE PRECISION, + nilai_big_lanjut_sem2 DOUBLE PRECISION, + nilai_big_lanjut DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk Ekonomi + nilai_ekonomi_sem1 DOUBLE PRECISION, + nilai_ekonomi_sem2 DOUBLE PRECISION, + nilai_ekonomi DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk MAT Lanjut + nilai_mat_lanjut_sem1 DOUBLE PRECISION, + nilai_mat_lanjut_sem2 DOUBLE PRECISION, + nilai_mat_lanjut DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk SEJ Lanjut + nilai_sej_lanjut_sem1 DOUBLE PRECISION, + nilai_sej_lanjut_sem2 DOUBLE PRECISION, + nilai_sej_lanjut DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk Sosiologi + nilai_sosiologi_sem1 DOUBLE PRECISION, + nilai_sosiologi_sem2 DOUBLE PRECISION, + nilai_sosiologi DOUBLE PRECISION NOT NULL, + -- Kolom Semester 1, 2, dan Rata-rata untuk Geografi + nilai_geografi_sem1 DOUBLE PRECISION, + nilai_geografi_sem2 DOUBLE PRECISION, + nilai_geografi DOUBLE PRECISION NOT NULL, + paket_prediksi INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_hasil_prediksi_nama ON hasil_prediksi (nama_siswa); +CREATE INDEX IF NOT EXISTS idx_hasil_prediksi_paket ON hasil_prediksi (paket_prediksi); diff --git a/gui/custom_notifications.py b/gui/custom_notifications.py new file mode 100644 index 0000000..02f9412 --- /dev/null +++ b/gui/custom_notifications.py @@ -0,0 +1,1366 @@ +import csv +import json +import os + +import config +from PyQt5.QtWidgets import ( + QAbstractItemView, + QDialog, + QFrame, + QHeaderView, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QStyle, + QStyleOptionViewItem, + QVBoxLayout, +) +from PyQt5.QtCore import Qt, QEvent +from PyQt5.QtGui import QFont + +class CustomNotificationDialog(QDialog): + """Custom styled notification dialog for success, error, warning, and question messages""" + + def __init__(self, parent=None, dialog_type="success", title="Success!", message="Operation completed successfully", + button_text="OK", show_cancel=False, cancel_text="Cancel", compact=False): + super().__init__(parent) + self.dialog_type = dialog_type # "success", "error", "warning", "question" + self.result_value = False + self.compact = compact + + # Remove window frame and set flags + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + + self.initUI(title, message, button_text, show_cancel, cancel_text) + + def initUI(self, title, message, button_text, show_cancel, cancel_text): + # Main layout with margins for shadow effect + main_layout = QVBoxLayout() + outer_margin = 14 if self.compact else 20 + main_layout.setContentsMargins(outer_margin, outer_margin, outer_margin, outer_margin) + + # Content widget (the card) + content_widget = QDialog() + content_widget.setFixedWidth(340 if self.compact else 450) + + # Set colors based on dialog type + if self.dialog_type == "success": + border_color = "#48bb78" # Green + icon_bg = "#48bb78" + icon = "✓" + button_color = "#48bb78" + button_hover = "#38a169" + elif self.dialog_type == "error": + border_color = "#f56565" # Red + icon_bg = "#f56565" + icon = "✕" + button_color = "#f56565" + button_hover = "#e53e3e" + elif self.dialog_type == "warning": + border_color = "#ed8936" # Orange + icon_bg = "#ed8936" + icon = "!" + button_color = "#ed8936" + button_hover = "#dd6b20" + else: # question + border_color = "#4299e1" # Blue + icon_bg = "#4299e1" + icon = "?" + button_color = "#4299e1" + button_hover = "#3182ce" + + content_widget.setStyleSheet(f""" + QDialog {{ + background-color: white; + border-top: 4px solid {border_color}; + border-radius: 8px; + }} + """) + + content_layout = QVBoxLayout() + if self.compact: + content_layout.setContentsMargins(24, 28, 24, 24) + content_layout.setSpacing(12) + else: + content_layout.setContentsMargins(40, 50, 40, 40) + content_layout.setSpacing(20) + + # Icon at the top + icon_label = QLabel(icon) + icon_label.setAlignment(Qt.AlignCenter) + icon_size = 54 if self.compact else 80 + icon_label.setFixedSize(icon_size, icon_size) + icon_label.setStyleSheet(f""" + QLabel {{ + background-color: {icon_bg}; + color: white; + font-size: {34 if self.compact else 48}px; + font-weight: bold; + border-radius: {icon_size // 2}px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + """) + + # Create a container to center the icon + icon_container = QHBoxLayout() + icon_container.addStretch() + icon_container.addWidget(icon_label) + icon_container.addStretch() + content_layout.addLayout(icon_container) + + # Title + title_label = QLabel(title) + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet(f""" + QLabel {{ + font-size: {22 if self.compact else 32}px; + font-weight: bold; + color: {border_color if self.dialog_type != 'question' else '#2d3748'}; + margin-top: {4 if self.compact else 10}px; + margin-bottom: {4 if self.compact else 10}px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + """) + content_layout.addWidget(title_label) + + # Separator line + separator = QLabel() + separator.setFixedHeight(3) + separator.setStyleSheet(f"background-color: {border_color}; border-radius: 1px;") + separator.setFixedWidth(100) + separator_container = QHBoxLayout() + separator_container.addStretch() + separator_container.addWidget(separator) + separator_container.addStretch() + content_layout.addLayout(separator_container) + + # Message + message_label = QLabel(message) + message_label.setAlignment(Qt.AlignCenter) + message_label.setWordWrap(True) + message_font_size = 17 if self.compact else 19 + message_label.setStyleSheet(f""" + QLabel {{ + font-size: {message_font_size}px; + color: #718096; + line-height: 1.5; + margin-top: 8px; + margin-bottom: 12px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + """) + content_layout.addWidget(message_label) + + # Buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(10 if self.compact else 15) + + if show_cancel: + # Cancel button (outlined) + cancel_btn = QPushButton(cancel_text) + cancel_btn.setFixedHeight(36 if self.compact else 45) + cancel_btn.setFixedWidth(120 if self.compact else 150) + cancel_btn.setCursor(Qt.PointingHandCursor) + cancel_btn.setStyleSheet(f""" + QPushButton {{ + background-color: white; + color: #718096; + border: 2px solid #e2e8f0; + border-radius: {18 if self.compact else 25}px; + font-size: {13 if self.compact else 16}px; + font-weight: 600; + padding: {6 if self.compact else 8}px {16 if self.compact else 24}px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + QPushButton:hover {{ + background-color: #f7fafc; + border-color: #cbd5e0; + color: #4a5568; + }} + QPushButton:pressed {{ + background-color: #edf2f7; + }} + """) + cancel_btn.clicked.connect(self.on_cancel) + button_layout.addWidget(cancel_btn) + + # Main button (filled) + ok_btn = QPushButton(button_text) + ok_btn.setFixedHeight(36 if self.compact else 45) + ok_btn.setFixedWidth(120 if self.compact else 150) + ok_btn.setCursor(Qt.PointingHandCursor) + ok_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {button_color}; + color: white; + border: none; + border-radius: {18 if self.compact else 25}px; + font-size: {13 if self.compact else 16}px; + font-weight: 600; + padding: {6 if self.compact else 8}px {16 if self.compact else 24}px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + QPushButton:hover {{ + background-color: {button_hover}; + }} + QPushButton:pressed {{ + background-color: {button_hover}; + padding-top: 10px; + padding-bottom: 6px; + }} + """) + ok_btn.clicked.connect(self.on_ok) + button_layout.addWidget(ok_btn) + + content_layout.addLayout(button_layout) + content_widget.setLayout(content_layout) + + main_layout.addWidget(content_widget) + self.setLayout(main_layout) + + # Apply shadow effect + from PyQt5.QtWidgets import QGraphicsDropShadowEffect + from PyQt5.QtGui import QColor + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(30) + shadow.setXOffset(0) + shadow.setYOffset(10) + shadow.setColor(QColor(0, 0, 0, 60)) + content_widget.setGraphicsEffect(shadow) + + def on_ok(self): + self.result_value = True + self.accept() + + def on_cancel(self): + self.result_value = False + self.reject() + + @staticmethod + def show_success(parent=None, title="Success!", message="Operation completed successfully", button_text="OK", compact=False): + """Show a success notification""" + dialog = CustomNotificationDialog(parent, "success", title, message, button_text, compact=compact) + dialog.exec_() + + @staticmethod + def show_error(parent=None, title="Error!", message="An error occurred", button_text="Try Again", compact=False): + """Show an error notification""" + dialog = CustomNotificationDialog(parent, "error", title, message, button_text, compact=compact) + dialog.exec_() + + @staticmethod + def show_warning(parent=None, title="Warning!", message="Please check your input", button_text="OK", compact=False): + """Show a warning notification""" + dialog = CustomNotificationDialog(parent, "warning", title, message, button_text, compact=compact) + dialog.exec_() + + @staticmethod + def show_question(parent=None, title="Confirm", message="Are you sure?", button_text="Yes", cancel_text="No", compact=False): + """Show a question dialog and return True if user clicks Yes""" + dialog = CustomNotificationDialog(parent, "question", title, message, button_text, True, cancel_text, compact=compact) + dialog.exec_() + return dialog.result_value + + +class CustomPredictionDialog(QDialog): + """Custom styled dialog for showing prediction results with save option""" + + def __init__(self, parent=None, nama="", prediction="", paket_info=None): + super().__init__(parent) + self.result_save = False + + # Remove window frame and set flags + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + + self.initUI(nama, prediction, paket_info) + + def initUI(self, nama, prediction, paket_info): + # Main layout with margins for shadow effect + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) + + # Content widget (the card) + content_widget = QDialog() + content_widget.setFixedWidth(500) + + border_color = "#4299e1" # Blue + + content_widget.setStyleSheet(f""" + QDialog {{ + background-color: white; + border-top: 4px solid {border_color}; + border-radius: 8px; + }} + """) + + content_layout = QVBoxLayout() + content_layout.setContentsMargins(40, 50, 40, 40) + content_layout.setSpacing(15) + + # Icon at the top + icon_label = QLabel("🎓") + icon_label.setAlignment(Qt.AlignCenter) + icon_label.setFixedSize(80, 80) + icon_label.setStyleSheet(f""" + QLabel {{ + background-color: {border_color}; + color: white; + font-size: 48px; + font-weight: bold; + border-radius: 40px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + """) + + # Create a container to center the icon + icon_container = QHBoxLayout() + icon_container.addStretch() + icon_container.addWidget(icon_label) + icon_container.addStretch() + content_layout.addLayout(icon_container) + + # Title + title_label = QLabel("Hasil Prediksi") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet(f""" + QLabel {{ + font-size: 32px; + font-weight: bold; + color: #2d3748; + margin-top: 10px; + margin-bottom: 10px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + """) + content_layout.addWidget(title_label) + + # Separator line + separator = QLabel() + separator.setFixedHeight(3) + separator.setStyleSheet(f"background-color: {border_color}; border-radius: 1px;") + separator.setFixedWidth(100) + separator_container = QHBoxLayout() + separator_container.addStretch() + separator_container.addWidget(separator) + separator_container.addStretch() + content_layout.addLayout(separator_container) + + # Student name + nama_label = QLabel(f"Nama Siswa: {nama}") + nama_label.setAlignment(Qt.AlignCenter) + nama_label.setStyleSheet(""" + QLabel { + font-size: 18px; + color: #2d3748; + font-weight: bold; + margin-top: 10px; + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + content_layout.addWidget(nama_label) + + # Prediction result + pred_label = QLabel(f"Paket Terpilih: {prediction}") + pred_label.setAlignment(Qt.AlignCenter) + pred_label.setStyleSheet(""" + QLabel { + font-size: 20px; + color: #4299e1; + font-weight: bold; + margin-top: 5px; + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + content_layout.addWidget(pred_label) + + # Paket detail + if paket_info: + kelas_info = f" ({paket_info.get('kelas_tersedia', 1)} kelas tersedia)" + detail_text = f"{paket_info.get('nama', 'Unknown')}{kelas_info}" + detail_label = QLabel(detail_text) + detail_label.setAlignment(Qt.AlignCenter) + detail_label.setWordWrap(True) + detail_label.setStyleSheet(""" + QLabel { + font-size: 16px; + color: #718096; + margin-top: 5px; + margin-bottom: 10px; + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + content_layout.addWidget(detail_label) + + # Question + question_label = QLabel("Apakah Anda ingin menyimpan hasil prediksi ini?") + question_label.setAlignment(Qt.AlignCenter) + question_label.setWordWrap(True) + question_label.setStyleSheet(""" + QLabel { + font-size: 16px; + color: #4a5568; + margin-top: 10px; + margin-bottom: 20px; + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + content_layout.addWidget(question_label) + + # Buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(15) + + # Cancel button (outlined) + cancel_btn = QPushButton("Batal") + cancel_btn.setFixedHeight(45) + cancel_btn.setFixedWidth(150) + cancel_btn.setCursor(Qt.PointingHandCursor) + cancel_btn.setStyleSheet(f""" + QPushButton {{ + background-color: white; + color: #718096; + border: 2px solid #e2e8f0; + border-radius: 25px; + font-size: 16px; + font-weight: 600; + padding: 8px 24px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + QPushButton:hover {{ + background-color: #f7fafc; + border-color: #cbd5e0; + color: #4a5568; + }} + QPushButton:pressed {{ + background-color: #edf2f7; + }} + """) + cancel_btn.clicked.connect(self.on_cancel) + button_layout.addWidget(cancel_btn) + + # Save button (filled) + save_btn = QPushButton("💾 Simpan") + save_btn.setFixedHeight(45) + save_btn.setFixedWidth(150) + save_btn.setCursor(Qt.PointingHandCursor) + save_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {border_color}; + color: white; + border: none; + border-radius: 25px; + font-size: 16px; + font-weight: 600; + padding: 8px 24px; + font-family: 'Segoe UI', Arial, sans-serif; + }} + QPushButton:hover {{ + background-color: #3182ce; + }} + QPushButton:pressed {{ + background-color: #3182ce; + padding-top: 10px; + padding-bottom: 6px; + }} + """) + save_btn.clicked.connect(self.on_save) + button_layout.addWidget(save_btn) + + content_layout.addLayout(button_layout) + content_widget.setLayout(content_layout) + + main_layout.addWidget(content_widget) + self.setLayout(main_layout) + + # Apply shadow effect + from PyQt5.QtWidgets import QGraphicsDropShadowEffect + from PyQt5.QtGui import QColor + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(30) + shadow.setXOffset(0) + shadow.setYOffset(10) + shadow.setColor(QColor(0, 0, 0, 60)) + content_widget.setGraphicsEffect(shadow) + + def on_save(self): + self.result_save = True + self.accept() + + def on_cancel(self): + self.result_save = False + self.reject() + + @staticmethod + def show_prediction(parent=None, nama="", prediction="", paket_info=None): + """Show a prediction result dialog and return True if user wants to save""" + dialog = CustomPredictionDialog(parent, nama, prediction, paket_info) + dialog.exec_() + return dialog.result_save + + +class StudentNamePickerDialog(QDialog): + """Dialog untuk memilih nama siswa dari dataset final.""" + + def __init__(self, parent=None, initial_name=""): + super().__init__(parent) + self.selected_name = "" + self.initial_name = initial_name.strip() + self.base_names = self._load_base_names() + self.catalog_state = self._load_catalog_state() + self.all_names = self._build_name_list() + self.filtered_names = list(self.all_names) + self.current_page = 1 + self.items_per_page = 10 + self.visible_table_rows = 6 + + self.setWindowTitle("Pilih Nama Siswa") + self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) + self.setModal(True) + self.setMinimumSize(760, 620) + self.resize(780, 640) + self.initUI() + self.apply_filter() + self._select_initial_name() + # Set window position to center of parent + if self.parent(): + parent_rect = self.parent().frameGeometry() + self.move(parent_rect.center() - self.rect().center()) + + def showEvent(self, event): + super().showEvent(event) + # Prevent QLineEdit from receiving focus automatically when dialog opens. + # Clear focus from search/add inputs and set focus to the dialog itself. + try: + if hasattr(self, 'search_input'): + self.search_input.clearFocus() + except Exception: + pass + try: + if hasattr(self, 'add_input'): + self.add_input.clearFocus() + except Exception: + pass + try: + self.setFocus() + except Exception: + pass + + def _dataset_path(self): + return config.get_resource_path(os.path.join("data", "dataset_smakom_final.csv")) + + def _catalog_path(self): + return config.get_writable_path(os.path.join("data", "student_name_catalog.json")) + + def _normalize_name(self, name): + return " ".join(str(name).strip().split()).casefold() + + def _display_name(self, raw_name): + return " ".join(str(raw_name).replace("_", " ").split()) + + def _load_base_names(self): + names = [] + seen = set() + dataset_path = self._dataset_path() + + if not os.path.exists(dataset_path): + return names + + try: + with open(dataset_path, "r", encoding="utf-8-sig", newline="") as csv_file: + reader = csv.DictReader(csv_file) + for row in reader: + raw_name = row.get("nama_siswa", "") + display_name = self._display_name(raw_name) + if not display_name: + continue + normalized = self._normalize_name(display_name) + if normalized in seen: + continue + seen.add(normalized) + names.append(display_name) + except Exception: + return names + + return names + + def _load_catalog_state(self): + default_state = {"added": [], "deleted": []} + catalog_path = self._catalog_path() + + if not os.path.exists(catalog_path): + return default_state + + try: + with open(catalog_path, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + except Exception: + return default_state + + if isinstance(data, list): + return {"added": [str(item).strip() for item in data if str(item).strip()], "deleted": []} + + if isinstance(data, dict): + added = data.get("added") or data.get("names") or [] + deleted = data.get("deleted") or [] + return { + "added": [str(item).strip() for item in added if str(item).strip()], + "deleted": [str(item).strip() for item in deleted if str(item).strip()], + } + + return default_state + + def _save_catalog_state(self): + catalog_path = self._catalog_path() + catalog_dir = os.path.dirname(catalog_path) + if catalog_dir: + os.makedirs(catalog_dir, exist_ok=True) + + payload = { + "added": self.catalog_state.get("added", []), + "deleted": self.catalog_state.get("deleted", []), + } + + with open(catalog_path, "w", encoding="utf-8") as json_file: + json.dump(payload, json_file, ensure_ascii=False, indent=2) + + def _build_name_list(self): + deleted = {self._normalize_name(name) for name in self.catalog_state.get("deleted", [])} + result = [] + seen = set() + + for name in self.catalog_state.get("added", []): + normalized = self._normalize_name(name) + if not normalized or normalized in deleted or normalized in seen: + continue + seen.add(normalized) + result.append(name) + + for name in self.base_names: + normalized = self._normalize_name(name) + if normalized in deleted or normalized in seen: + continue + seen.add(normalized) + result.append(name) + + return result + + def initUI(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(12, 12, 12, 12) + + card = QFrame() + card.setStyleSheet(""" + QFrame { + background: white; + border-radius: 16px; + } + """) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(20, 18, 20, 16) + card_layout.setSpacing(10) + + header_layout = QVBoxLayout() + header_layout.setSpacing(0) + header_layout.setContentsMargins(0, 0, 0, 0) + + title = QLabel("Pilih Nama Siswa") + title.setContentsMargins(0, 0, 0, 0) + title.setStyleSheet("font-size: 28px; font-weight: 700; color: #1f3b66; font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 0; line-height: 1;") + header_layout.addWidget(title) + + subtitle = QLabel("Cari, tambah, hapus, lalu pilih nama yang akan dimasukkan ke form.") + subtitle.setWordWrap(True) + subtitle.setContentsMargins(0, 0, 0, 0) + subtitle.setStyleSheet("font-size: 17px; color: #56708f; font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 0; line-height: 1;") + header_layout.addWidget(subtitle) + + card_layout.addLayout(header_layout) + + search_row = QHBoxLayout() + search_row.setSpacing(10) + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Cari nama siswa...") + self.search_input.setMinimumHeight(42) + self.search_input.setStyleSheet(""" + QLineEdit { + border: 1px solid #d6deea; + border-radius: 10px; + padding: 9px 14px; + font-size: 17px; + background: #f8fbff; + color: #1f2937; + font-family: 'Segoe UI', Arial, sans-serif; + } + QLineEdit:focus { + border-color: #2c7be5; + background: white; + } + """) + self.search_input.textChanged.connect(self.apply_filter) + self.search_input.returnPressed.connect(self.apply_filter) + search_row.addWidget(self.search_input, 1) + + self.add_input = QLineEdit() + self.add_input.setPlaceholderText("Tambah nama baru...") + self.add_input.setMinimumHeight(42) + self.add_input.setStyleSheet(self.search_input.styleSheet()) + self.add_input.returnPressed.connect(self.add_name) + search_row.addWidget(self.add_input, 1) + + card_layout.addLayout(search_row) + + action_row = QHBoxLayout() + action_row.setContentsMargins(0, 2, 0, 0) + action_row.setSpacing(12) + + add_btn = QPushButton("Tambah") + add_btn.setFixedHeight(40) + add_btn.setFixedWidth(110) + add_btn.setCursor(Qt.PointingHandCursor) + add_btn.setStyleSheet(""" + QPushButton { + background: #118a7e; + color: white; + border: none; + border-radius: 10px; + padding: 0 16px; + font-size: 16px; + font-weight: 600; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + background: #0e7369; + } + """) + add_btn.clicked.connect(self.add_name) + action_row.addWidget(add_btn) + + self.delete_btn = QPushButton("Hapus Terpilih") + self.delete_btn.setFixedHeight(40) + self.delete_btn.setFixedWidth(160) + self.delete_btn.setCursor(Qt.PointingHandCursor) + self.delete_btn.setStyleSheet(""" + QPushButton { + background: #ef5a5a; + color: white; + border: none; + border-radius: 10px; + padding: 0 14px; + font-size: 16px; + font-weight: 600; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + background: #d94b4b; + } + QPushButton:disabled { + background: #c8d3e2; + color: #7b8aa0; + } + """) + self.delete_btn.clicked.connect(self.delete_selected_names) + action_row.addWidget(self.delete_btn) + + self.pilih_btn = QPushButton("Pilih") + self.pilih_btn.setFixedHeight(40) + self.pilih_btn.setFixedWidth(96) + self.pilih_btn.setCursor(Qt.PointingHandCursor) + self.pilih_btn.setStyleSheet(""" + QPushButton { + background: #1f3b66; + color: white; + border: none; + border-radius: 10px; + padding: 0 14px; + font-size: 16px; + font-weight: 600; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + background: #182f53; + } + QPushButton:disabled { + background: #c8d3e2; + color: #7b8aa0; + } + """) + self.pilih_btn.clicked.connect(self.pick_selected_name) + action_row.addWidget(self.pilih_btn) + + self.edit_btn = QPushButton("Edit") + self.edit_btn.setFixedHeight(40) + self.edit_btn.setFixedWidth(96) + self.edit_btn.setCursor(Qt.PointingHandCursor) + self.edit_btn.setStyleSheet(""" + QPushButton { + background: #16a34a; + color: white; + border: none; + border-radius: 10px; + padding: 0 14px; + font-size: 16px; + font-weight: 600; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + background: #15803d; + } + QPushButton:disabled { + background: #c8d3e2; + color: #7b8aa0; + } + """) + self.edit_btn.clicked.connect(self.edit_selected_name) + action_row.addWidget(self.edit_btn) + + tutup_btn = QPushButton("Tutup") + tutup_btn.setFixedHeight(40) + tutup_btn.setFixedWidth(96) + tutup_btn.setCursor(Qt.PointingHandCursor) + tutup_btn.setStyleSheet(""" + QPushButton { + background: #6c7a92; + color: white; + border: none; + border-radius: 10px; + padding: 0 14px; + font-size: 16px; + font-weight: 600; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + background: #5d6a7f; + } + """) + tutup_btn.clicked.connect(self.reject) + action_row.addWidget(tutup_btn) + + action_row.addStretch() + + card_layout.addLayout(action_row) + + self.table = QTableWidget(0, 3) + self.table.setHorizontalHeaderLabels(["No", "Nama Siswa", "Hapus"]) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.table.setAlternatingRowColors(True) + self.table.verticalHeader().setVisible(False) + self.table.setShowGrid(False) + self.table.setStyleSheet(""" + QTableWidget { + border: 1px solid #dbe4f0; + border-radius: 12px; + background: white; + alternate-background-color: #f7faff; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 17px; + color: #1f2937; + } + QTableWidget::item { + padding: 8px 10px; + } + QHeaderView::section { + background: #eef4fb; + color: #173458; + font-weight: 700; + border: none; + border-bottom: 1px solid #dbe4f0; + padding: 8px 3px; + font-size: 17px; + } + """) + self.table.verticalHeader().setDefaultSectionSize(42) + header_height = self.table.horizontalHeader().sizeHint().height() + table_frame = self.table.frameWidth() * 2 + visible_height = header_height + (self.visible_table_rows * self.table.verticalHeader().defaultSectionSize()) + table_frame + 2 + self.table.setFixedHeight(visible_height) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header_item = self.table.horizontalHeaderItem(0) + if header_item: + header_item.setTextAlignment(Qt.AlignCenter | Qt.AlignVCenter) + header_item = self.table.horizontalHeaderItem(1) + if header_item: + header_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) + header_item = self.table.horizontalHeaderItem(2) + if header_item: + header_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.table.cellDoubleClicked.connect(self._handle_cell_double_clicked) + self.table.cellClicked.connect(self._handle_cell_clicked) + self.table.itemSelectionChanged.connect(self._sync_action_state) + self.table.itemChanged.connect(self._sync_action_state) + self.table.viewport().installEventFilter(self) + card_layout.addWidget(self.table) + + pagination_row = QHBoxLayout() + pagination_row.setSpacing(16) + pagination_row.setContentsMargins(2, 8, 2, 0) + pagination_row.addStretch() + + self.prev_btn = QPushButton("◀ Previous") + self.prev_btn.setFixedHeight(40) + self.prev_btn.setFixedWidth(132) + self.prev_btn.setCursor(Qt.PointingHandCursor) + self.prev_btn.setStyleSheet(""" + QPushButton { + background: #e8eef6; + color: #38506d; + border: none; + border-radius: 10px; + padding: 0 14px; + font-size: 16px; + font-weight: 600; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + background: #dce6f1; + } + QPushButton:disabled { + background: #edf2f7; + color: #a0aec0; + } + """) + self.prev_btn.clicked.connect(self.previous_page) + pagination_row.addWidget(self.prev_btn) + + self.page_label = QLabel() + self.page_label.setAlignment(Qt.AlignCenter) + self.page_label.setStyleSheet("font-size: 16px; color: #3d5574; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif;") + pagination_row.addWidget(self.page_label) + + self.next_btn = QPushButton("Next ▶") + self.next_btn.setFixedHeight(40) + self.next_btn.setFixedWidth(132) + self.next_btn.setCursor(Qt.PointingHandCursor) + self.next_btn.setStyleSheet(self.prev_btn.styleSheet()) + self.next_btn.clicked.connect(self.next_page) + pagination_row.addWidget(self.next_btn) + pagination_row.addStretch() + + card_layout.addLayout(pagination_row) + main_layout.addWidget(card) + + def _sync_action_state(self): + checked = self._has_checked_rows() + selected = bool(self.get_selected_name()) + + if checked and selected: + self.table.blockSignals(True) + self.table.clearSelection() + self.table.setCurrentCell(-1, -1) + self.table.blockSignals(False) + selected = False + + if hasattr(self, "pilih_btn"): + self.pilih_btn.setEnabled(selected and not checked) + if hasattr(self, "edit_btn"): + self.edit_btn.setEnabled(selected and not checked) + if hasattr(self, "delete_btn"): + self.delete_btn.setEnabled(checked) + + def _clear_checked_rows(self): + for row in range(self.table.rowCount()): + item = self.table.item(row, 2) + if item and item.checkState() == Qt.Checked: + item.setCheckState(Qt.Unchecked) + + def _handle_cell_clicked(self, row, column): + del row + if column == 2: + self.table.blockSignals(True) + self.table.clearSelection() + self.table.setCurrentCell(-1, -1) + self.table.blockSignals(False) + self._sync_action_state() + return + + def _handle_cell_double_clicked(self, row, column): + # Ignore double-clicks on the checkbox column so they don't trigger name picking + if column == 2: + return + self.pick_selected_name() + + if column in (0, 1) and self._has_checked_rows(): + self.table.blockSignals(True) + self._clear_checked_rows() + self.table.blockSignals(False) + + self._sync_action_state() + + def _has_checked_rows(self): + for row in range(self.table.rowCount()): + item = self.table.item(row, 2) + if item and item.checkState() == Qt.Checked: + return True + return False + + def _select_initial_name(self): + if not self.initial_name: + return + normalized_initial = self._normalize_name(self.initial_name) + for row, name in enumerate(self.filtered_names): + if self._normalize_name(name) == normalized_initial: + self.current_page = row // self.items_per_page + 1 + self.render_table() + self.table.selectRow(row % self.items_per_page) + break + + def clear_search(self): + self.search_input.clear() + self.apply_filter() + + def apply_filter(self): + search_text = self.search_input.text().strip().casefold() + if search_text: + self.filtered_names = [name for name in self.all_names if search_text in name.casefold()] + else: + self.filtered_names = list(self.all_names) + self.current_page = 1 + self.render_table() + + def render_table(self): + self.table.blockSignals(True) + self.table.clearContents() + self.table.clearSpans() + + total_items = len(self.filtered_names) + total_pages = max(1, (total_items + self.items_per_page - 1) // self.items_per_page) + self.current_page = min(max(1, self.current_page), total_pages) + start_index = (self.current_page - 1) * self.items_per_page + end_index = min(start_index + self.items_per_page, total_items) + page_items = self.filtered_names[start_index:end_index] + + self.table.setRowCount(len(page_items) if page_items else 1) + self.table.setSpan(0, 0, 1, 1) + + if not page_items: + empty_item = QTableWidgetItem("Tidak ada nama ditemukan") + empty_item.setTextAlignment(Qt.AlignCenter) + empty_item.setFlags(Qt.ItemIsEnabled) + self.table.setItem(0, 0, empty_item) + self.table.setSpan(0, 0, 1, 3) + else: + for row, name in enumerate(page_items): + no_item = QTableWidgetItem(str(start_index + row + 1)) + no_item.setTextAlignment(Qt.AlignCenter) + no_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.table.setItem(row, 0, no_item) + + name_item = QTableWidgetItem(name) + name_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.table.setItem(row, 1, name_item) + + delete_item = QTableWidgetItem() + delete_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) + delete_item.setCheckState(Qt.Unchecked) + delete_item.setTextAlignment(Qt.AlignCenter) + self.table.setItem(row, 2, delete_item) + + self.table.blockSignals(False) + self.table.clearSelection() + self.table.setCurrentCell(-1, -1) + self.update_pagination_controls() + self._sync_action_state() + + def eventFilter(self, obj, event): + if obj is getattr(self, "table", None).viewport(): + if event.type() in (QEvent.MouseButtonPress, QEvent.MouseButtonRelease): + index = self.table.indexAt(event.pos()) + if index.isValid() and index.column() == 2: + # Toggle checkbox state on mouse release and consume the event so + # the table's default selection behavior doesn't interfere. + if event.type() == QEvent.MouseButtonRelease: + item = self.table.item(index.row(), 2) + if item: + new_state = Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked + item.setCheckState(new_state) + self._sync_action_state() + return True + return super().eventFilter(obj, event) + + def update_pagination_controls(self): + total_items = len(self.filtered_names) + total_pages = max(1, (total_items + self.items_per_page - 1) // self.items_per_page) + self.page_label.setText(f"Halaman {self.current_page} dari {total_pages} | {total_items} data") + self.prev_btn.setEnabled(self.current_page > 1) + self.next_btn.setEnabled(self.current_page < total_pages) + + def previous_page(self): + if self.current_page > 1: + self.current_page -= 1 + self.render_table() + + def next_page(self): + total_items = len(self.filtered_names) + total_pages = max(1, (total_items + self.items_per_page - 1) // self.items_per_page) + if self.current_page < total_pages: + self.current_page += 1 + self.render_table() + + def get_selected_name(self): + current_row = self.table.currentRow() + if current_row < 0: + return "" + name_item = self.table.item(current_row, 1) + if not name_item: + return "" + return name_item.text().strip() + + def add_name(self): + new_name = " ".join(self.add_input.text().strip().split()) + if not new_name: + QMessageBox.warning(self, "Validasi", "Nama baru masih kosong.") + return + + normalized_new = self._normalize_name(new_name) + if any(self._normalize_name(name) == normalized_new for name in self.all_names): + QMessageBox.warning(self, "Validasi", "Nama sudah ada di daftar.") + return + + deleted = self.catalog_state.setdefault("deleted", []) + deleted[:] = [name for name in deleted if self._normalize_name(name) != normalized_new] + + added = self.catalog_state.setdefault("added", []) + # Insert new names at the beginning so the newest appears at the top + added.insert(0, new_name) + self.add_input.clear() + self._save_catalog_state() + self.all_names = self._build_name_list() + self.apply_filter() + + def delete_selected_names(self): + checked_names = [] + for row in range(self.table.rowCount()): + name_item = self.table.item(row, 1) + delete_item = self.table.item(row, 2) + if not name_item or not delete_item: + continue + if delete_item.checkState() == Qt.Checked: + checked_names.append(name_item.text().strip()) + + if not checked_names: + QMessageBox.information(self, "Info", "Centang minimal satu nama untuk dihapus.") + return + + deleted = self.catalog_state.setdefault("deleted", []) + added = self.catalog_state.setdefault("added", []) + checked_normalized = {self._normalize_name(name) for name in checked_names} + + for name in checked_names: + normalized = self._normalize_name(name) + if any(self._normalize_name(item) == normalized for item in added): + added[:] = [item for item in added if self._normalize_name(item) != normalized] + elif not any(self._normalize_name(item) == normalized for item in deleted): + deleted.append(name) + + if self.selected_name and self._normalize_name(self.selected_name) in checked_normalized: + self.selected_name = "" + + self._save_catalog_state() + self.all_names = self._build_name_list() + self.apply_filter() + + class EditNameDialog(QDialog): + """Custom dialog to edit a student name with improved styling.""" + def __init__(self, parent=None, initial_name=""): + super().__init__(parent) + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.initial_name = initial_name + self.result_name = None + self.initUI() + + def initUI(self): + main_layout = QVBoxLayout() + main_layout.setContentsMargins(16, 16, 16, 16) + + card = QDialog() + card.setFixedWidth(420) + card.setStyleSheet(""" + QDialog { + background: white; + border-radius: 10px; + } + """) + + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(20, 18, 20, 18) + card_layout.setSpacing(10) + + title = QLabel("Edit Nama") + title.setStyleSheet("font-size: 24px; font-weight: 700; color: #1f3b66; font-family: 'Segoe UI', Arial, sans-serif;") + title.setAlignment(Qt.AlignLeft) + card_layout.addWidget(title) + + label = QLabel("Nama baru:") + label.setStyleSheet("font-size: 15px; color: #334155; font-weight: 600;") + card_layout.addWidget(label) + + self.input = QLineEdit() + self.input.setText(self.initial_name) + self.input.setMinimumHeight(42) + self.input.setStyleSheet(""" + QLineEdit { + border: 1px solid #d6deea; + border-radius: 8px; + padding: 7px 10px; + font-size: 16px; + color: #0f172a; + background: #fbfdff; + font-family: 'Segoe UI', Arial, sans-serif; + } + QLineEdit:focus { + border-color: #2c7be5; + background: white; + } + """) + self.input.selectAll() + card_layout.addWidget(self.input) + + # Buttons: Close on the left, Edit on the right (Close placed to left of Edit) + btn_row = QHBoxLayout() + btn_row.addStretch() + + cancel_btn = QPushButton("Tutup") + cancel_btn.setFixedHeight(38) + cancel_btn.setFixedWidth(96) + cancel_btn.setCursor(Qt.PointingHandCursor) + cancel_btn.setStyleSheet(""" + QPushButton { + background-color: white; + color: #718096; + border: 2px solid #e2e8f0; + border-radius: 20px; + font-size: 14px; + font-weight: 600; + } + QPushButton:hover { + background-color: #f7fafc; + } + """) + cancel_btn.clicked.connect(self.on_cancel) + btn_row.addWidget(cancel_btn) + + btn_row.addSpacing(10) + + edit_btn = QPushButton("Edit") + edit_btn.setFixedHeight(38) + edit_btn.setFixedWidth(96) + edit_btn.setCursor(Qt.PointingHandCursor) + edit_btn.setStyleSheet(""" + QPushButton { + background-color: #16a34a; + color: white; + border: none; + border-radius: 20px; + font-size: 16px; + font-weight: 600; + } + QPushButton:hover { + background-color: #15803d; + } + """) + edit_btn.clicked.connect(self.on_edit) + btn_row.addWidget(edit_btn) + + card_layout.addLayout(btn_row) + + from PyQt5.QtWidgets import QGraphicsDropShadowEffect + from PyQt5.QtGui import QColor + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(24) + shadow.setXOffset(0) + shadow.setYOffset(8) + shadow.setColor(QColor(0, 0, 0, 60)) + card.setGraphicsEffect(shadow) + + main_layout.addWidget(card) + self.setLayout(main_layout) + + def on_edit(self): + text = " ".join(self.input.text().strip().split()) + if not text: + QMessageBox.warning(self, "Validasi", "Nama baru tidak boleh kosong.") + return + self.result_name = text + self.accept() + + def on_cancel(self): + self.result_name = None + self.reject() + + def edit_selected_name(self): + old_name = self.get_selected_name() + if not old_name: + QMessageBox.information(self, "Info", "Pilih satu nama terlebih dahulu.") + return + + dialog = StudentNamePickerDialog.EditNameDialog(self, initial_name=old_name) + result = dialog.exec_() + if result != QDialog.Accepted or not dialog.result_name: + return + + new_name = " ".join(dialog.result_name.strip().split()) + if not new_name: + QMessageBox.warning(self, "Validasi", "Nama baru tidak boleh kosong.") + return + + normalized_new = self._normalize_name(new_name) + normalized_old = self._normalize_name(old_name) + + if normalized_new == normalized_old: + return + + if any(self._normalize_name(name) == normalized_new for name in self.all_names): + QMessageBox.warning(self, "Validasi", "Nama baru sudah ada di daftar.") + return + + added = self.catalog_state.setdefault("added", []) + deleted = self.catalog_state.setdefault("deleted", []) + + if any(self._normalize_name(item) == normalized_old for item in added): + added[:] = [item for item in added if self._normalize_name(item) != normalized_old] + else: + if not any(self._normalize_name(item) == normalized_old for item in deleted): + deleted.append(old_name) + + deleted[:] = [item for item in deleted if self._normalize_name(item) != normalized_new] + added[:] = [item for item in added if self._normalize_name(item) != normalized_new] + added.insert(0, new_name) + + self.selected_name = new_name + self._save_catalog_state() + self.all_names = self._build_name_list() + self.apply_filter() + + def pick_selected_name(self, *_): + name = self.get_selected_name() + if not name: + QMessageBox.information(self, "Info", "Pilih nama dari tabel terlebih dahulu.") + return + + self.selected_name = name + self.accept() + + def accept(self): + if not self.selected_name: + self.selected_name = self.get_selected_name() + super().accept() + + @staticmethod + def show_picker(parent=None, initial_name=""): + dialog = StudentNamePickerDialog(parent, initial_name=initial_name) + result = dialog.exec_() + if result == QDialog.Accepted and dialog.selected_name: + return dialog.selected_name + return None diff --git a/gui/login_dialog.py b/gui/login_dialog.py new file mode 100644 index 0000000..51b065d --- /dev/null +++ b/gui/login_dialog.py @@ -0,0 +1,309 @@ +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QApplication, QSizePolicy +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIcon +import os +from db.database import verify_admin_login_detail +from gui.register_dialog import RegisterDialog +from gui.custom_notifications import CustomNotificationDialog +import config + +class LoginDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('Login') + + # Ensure this dialog is a real top-level window so taskbar uses app icon. + self.setWindowFlags((self.windowFlags() | Qt.Window) & ~Qt.WindowContextHelpButtonHint | Qt.WindowMinimizeButtonHint) + icon_path = config.get_app_icon_path() + if icon_path: + self.setWindowIcon(QIcon(icon_path)) + self._apply_responsive_size(600, 420) + if icon_path: + self.setWindowIcon(QIcon(icon_path)) + + # Set background color + self.setStyleSheet("QDialog { background-color: #fafafa; }") + + # Main layout + layout = QVBoxLayout() + layout.setContentsMargins(40, 40, 40, 40) + layout.setSpacing(5) + + # Title + title = QLabel('Sistem Klasifikasi Jurusan') + title.setStyleSheet("font-size: 28px; font-weight: bold; color: #1a202c; margin-bottom: 5px; margin-top: 0px; font-family: 'Segoe UI', Arial, sans-serif;") + title.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + title.setWordWrap(True) + layout.addWidget(title) + + subtitle = QLabel('Login ke akun Anda') + subtitle.setStyleSheet("font-size: 18px; color: #4a5568; margin-bottom: 30px; font-family: 'Segoe UI', Arial, sans-serif;") + subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(subtitle) + + # Username + self.label_user = QLabel('Username') + self.label_user.setStyleSheet("font-size: 18px; color: #2d3748; font-weight: bold; margin-bottom: 0px; font-family: 'Segoe UI', Arial, sans-serif;") + layout.addWidget(self.label_user) + + self.input_user = QLineEdit() + self.input_user.setFixedHeight(64) + self.input_user.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.input_user.setPlaceholderText('Masukkan username') + self.input_user.setStyleSheet("font-size: 20px; padding: 0px 12px; margin-bottom: 20px; color: #2d3748; background-color: white; border: 1px solid #e2e8f0; border-radius: 6px; font-family: 'Segoe UI', Arial, sans-serif;") + layout.addWidget(self.input_user) + + # Password + self.label_pass = QLabel('Password') + self.label_pass.setStyleSheet("font-size: 18px; color: #2d3748; font-weight: bold; margin-bottom: 0px; font-family: 'Segoe UI', Arial, sans-serif;") + layout.addWidget(self.label_pass) + + # Password container with toggle button + password_container = QHBoxLayout() + password_container.setSpacing(0) + password_container.setContentsMargins(0, 0, 0, 0) + + self.input_pass = QLineEdit() + self.input_pass.setFixedHeight(74) + self.input_pass.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.input_pass.setMinimumWidth(0) + self.input_pass.setPlaceholderText('Masukkan password') + self.input_pass.setEchoMode(QLineEdit.Password) + self.input_pass.setStyleSheet("font-size: 20px; padding: 0px 12px; color: #2d3748; background-color: white; border: 1px solid #e2e8f0; border-top-left-radius: 6px; border-bottom-left-radius: 6px; border-top-right-radius: 0px; border-bottom-right-radius: 0px; border-right: none; font-family: 'Segoe UI', Arial, sans-serif;") + + self.btn_toggle_pass = QPushButton('👁') + self.btn_toggle_pass.setFixedSize(74, 74) + self.btn_toggle_pass.setStyleSheet(""" + QPushButton { + font-size: 18px; + border: 1px solid #e2e8f0; + border-left: none; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + background-color: white; + color: #718096; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + background-color: #f7fafc; + color: #4a5568; + } + QPushButton:pressed { + background-color: #edf2f7; + } + """) + self.btn_toggle_pass.setCursor(Qt.PointingHandCursor) + self.btn_toggle_pass.clicked.connect(self.toggle_password_visibility) + self.password_visible = False + + password_container.addWidget(self.input_pass) + password_container.addWidget(self.btn_toggle_pass) + password_container.setStretch(0, 1) + + from PyQt5.QtWidgets import QWidget + self.password_widget = QWidget() + self.password_widget.setLayout(password_container) + self.password_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.password_widget.setMinimumWidth(0) + self.password_widget.setMaximumWidth(16777215) + self.password_widget.setStyleSheet("margin-bottom: 30px;") + layout.addWidget(self.password_widget) + + # Login button + self.btn_login = QPushButton('Login') + self.btn_login.setFixedHeight(60) + self.btn_login.setCursor(Qt.PointingHandCursor) + self.btn_login.setStyleSheet(""" + QPushButton { + font-size: 18px; + font-weight: bold; + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #667eea, stop:1 #764ba2); + color: white; + border: none; + border-radius: 6px; + margin-bottom: 20px; + 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); + } + """) + self.btn_login.clicked.connect(self.handle_login) + layout.addWidget(self.btn_login) + + # Register link container + register_container = QHBoxLayout() + register_container.setContentsMargins(0, 0, 0, 0) + register_container.setSpacing(5) + register_container.addStretch() # Center the content + + # Normal text part + self.label_register_text = QLabel('Belum punya akun?') + self.label_register_text.setStyleSheet(""" + QLabel { + font-size: 16px; + color: #4a5568; + background-color: transparent; + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + register_container.addWidget(self.label_register_text) + + # Clickable register link + self.btn_register = QPushButton('Register disini') + self.btn_register.setFixedHeight(36) + self.btn_register.setStyleSheet(""" + QPushButton { + font-size: 16px; + font-weight: bold; + color: #667eea; + background-color: transparent; + border: none; + text-decoration: underline; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + color: #764ba2; + } + """) + self.btn_register.clicked.connect(self.show_register_dialog) # Menghubungkan tombol Register dengan dialog registrasi + self.btn_register.setCursor(Qt.PointingHandCursor) + register_container.addWidget(self.btn_register) + register_container.addStretch() # Center the content + + register_widget = QWidget() + register_widget.setLayout(register_container) + layout.addWidget(register_widget) + + # Enter key triggers login + self.input_user.returnPressed.connect(self.handle_login) + self.input_pass.returnPressed.connect(self.handle_login) + + self.setLayout(layout) + self.login_success = False + self.username = None + self._went_to_register = False + self.should_show_register = False # Flag untuk menunjukkan bahwa user ingin ke halaman register + self._sync_password_row_width() + self._native_icon_applied = False + + def _apply_native_windows_icon(self): + """Set native HWND icons to avoid default taskbar icon on first window show.""" + if os.name != 'nt': + return + icon_path = config.get_app_icon_path() + if not icon_path or not os.path.exists(icon_path): + return + try: + import ctypes + WM_SETICON = 0x0080 + ICON_SMALL = 0 + ICON_BIG = 1 + IMAGE_ICON = 1 + LR_LOADFROMFILE = 0x0010 + LR_DEFAULTSIZE = 0x0040 + + hicon = ctypes.windll.user32.LoadImageW( + None, + icon_path, + IMAGE_ICON, + 0, + 0, + LR_LOADFROMFILE | LR_DEFAULTSIZE, + ) + if hicon: + hwnd = int(self.winId()) + ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon) + ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon) + except Exception: + pass + + def showEvent(self, event): + super().showEvent(event) + if not self._native_icon_applied: + self._apply_native_windows_icon() + self._native_icon_applied = True + + def _apply_responsive_size(self, base_width, base_height): + """Apply fixed size that adapts for smaller screens.""" + screen = QApplication.primaryScreen() + if not screen: + self.setFixedSize(base_width, base_height) + return + + available = screen.availableGeometry() + width = min(base_width, max(520, int(available.width() * 0.90))) + height = min(base_height, max(380, int(available.height() * 0.85))) + self.setFixedSize(width, height) + + def _sync_password_row_width(self): + """Keep password row width exactly aligned with username input.""" + if hasattr(self, 'input_user') and hasattr(self, 'password_widget'): + self.password_widget.setFixedWidth(self.input_user.width()) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._sync_password_row_width() + + #sembunyikan/tampilkan password + def toggle_password_visibility(self): + """Toggle password visibility""" + if self.password_visible: + self.input_pass.setEchoMode(QLineEdit.Password) + self.password_visible = False + else: + self.input_pass.setEchoMode(QLineEdit.Normal) + self.password_visible = True + + #proses ambil data input + def handle_login(self): + username = self.input_user.text().strip() + password = self.input_pass.text() + if not username or not password: + CustomNotificationDialog.show_warning(self, 'Warning!', 'Username dan password harus diisi!', 'OK', compact=True) + return + + login_status = verify_admin_login_detail(username, password) + + if login_status == 'success': + self.login_success = True + self.username = username + CustomNotificationDialog.show_success(self, 'Login Berhasil!', f'Selamat datang, {username}!', 'Lanjutkan', compact=True) + self.accept() + elif login_status == 'username_not_found': + CustomNotificationDialog.show_error(self, 'Login Gagal!', 'Username tidak terdaftar!', 'Coba Lagi', compact=True) + self.input_user.setFocus() + elif login_status == 'wrong_password': + CustomNotificationDialog.show_error(self, 'Login Gagal!', 'Password yang dimasukkan salah!', 'Coba Lagi', compact=True) + self.input_pass.setFocus() + elif login_status == 'wrong_username_and_password': + CustomNotificationDialog.show_error(self, 'Login Gagal!', 'Username dan password salah!', 'Coba Lagi', compact=True) + self.input_user.setFocus() + else: + CustomNotificationDialog.show_error(self, 'Login Gagal!', 'Terjadi kesalahan saat proses login.', 'Coba Lagi', compact=True) + self.input_user.setFocus() + + def clear_form(self): + """Clear all input fields in the login form""" + self.input_user.clear() + self.input_pass.clear() + # Reset password visibility + if self.password_visible: + self.input_pass.setEchoMode(QLineEdit.Password) + self.password_visible = False + self.btn_toggle_pass.setText('👁') + # Set focus to username field + self.input_user.setFocus() + + def show_register_dialog(self): + """Tutup login dialog dan set flag untuk menampilkan register dialog""" + self.should_show_register = True + self.reject() \ No newline at end of file diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..48d119b --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,4507 @@ +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}") + diff --git a/gui/register_dialog.py b/gui/register_dialog.py new file mode 100644 index 0000000..be58cf2 --- /dev/null +++ b/gui/register_dialog.py @@ -0,0 +1,392 @@ +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QWidget, QApplication +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIcon +import os +from db.database import register_admin +from gui.custom_notifications import CustomNotificationDialog +import config + +class RegisterDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('Register') + # Ensure this dialog is a real top-level window so taskbar uses app icon. + self.setWindowFlags((self.windowFlags() | Qt.Window) & ~Qt.WindowContextHelpButtonHint | Qt.WindowMinimizeButtonHint) + icon_path = config.get_app_icon_path() + if icon_path: + self.setWindowIcon(QIcon(icon_path)) + self._apply_responsive_size(600, 500) + if icon_path: + self.setWindowIcon(QIcon(icon_path)) + + # Set background color + self.setStyleSheet("QDialog { background-color: #fafafa; }") + + # Main layout + layout = QVBoxLayout() + layout.setContentsMargins(40, 40, 40, 40) + layout.setSpacing(5) + + # Title + title = QLabel('Sistem Klasifikasi Jurusan') + title.setStyleSheet("font-size: 28px; font-weight: bold; color: #1a202c; margin-bottom: 5px; margin-top: 0px;font-family: 'Segoe UI', Arial, sans-serif;") + title.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + title.setWordWrap(True) + layout.addWidget(title) + + subtitle = QLabel('Registrasi Akun Baru') + subtitle.setStyleSheet("font-size: 18px; color: #4a5568; margin-bottom: 30px;font-family: 'Segoe UI', Arial, sans-serif;") + subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(subtitle) + + # Username + self.label_user = QLabel('Username') + self.label_user.setStyleSheet("font-size: 18px; color: #2d3748; font-weight: bold; margin-bottom: 0px;font-family: 'Segoe UI', Arial, sans-serif;") + layout.addWidget(self.label_user) + + self.input_user = QLineEdit() + self.input_user.setFixedHeight(64) + from PyQt5.QtWidgets import QSizePolicy + self.input_user.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + #self.input_user.setFixedWidth(520) + self.input_user.setPlaceholderText('Masukkan username') + self.input_user.setStyleSheet("font-size: 20px; padding: 0px 12px; margin-bottom: 20px; color: #2d3748; background-color: white; border: 1px solid #e2e8f0; border-radius: 6px;font-family: 'Segoe UI', Arial, sans-serif;") + layout.addWidget(self.input_user) + + # Password + self.label_pass = QLabel('Password') + self.label_pass.setStyleSheet("font-size: 18px; color: #2d3748; font-weight: bold; margin-bottom: 0px;font-family: 'Segoe UI', Arial, sans-serif;") + layout.addWidget(self.label_pass) + + # Password container with toggle button + password_container = QHBoxLayout() + password_container.setSpacing(0) + password_container.setContentsMargins(0, 0, 0, 0) + + self.input_pass = QLineEdit() + self.input_pass.setFixedHeight(64) + from PyQt5.QtWidgets import QSizePolicy + self.input_pass.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + #self.input_pass.setFixedWidth(465) + self.input_pass.setPlaceholderText('Masukkan password') + + self.input_pass.setEchoMode(QLineEdit.Password) + self.input_pass.setStyleSheet("font-size: 20px; padding: 0px 12px; color: #2d3748; background-color: white; border: 1px solid #e2e8f0; border-top-left-radius: 6px; border-bottom-left-radius: 6px; border-top-right-radius: 0px; border-bottom-right-radius: 0px; border-right: none;font-family: 'Segoe UI', Arial, sans-serif;") + self.input_pass.setMinimumWidth(0) + + self.btn_toggle_pass = QPushButton('👁') + self.btn_toggle_pass.setFixedSize(64, 64) + self.btn_toggle_pass.setStyleSheet(""" + QPushButton { + font-size: 18px; + border: 1px solid #e2e8f0; + border-left: none; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + background-color: white; + color: #718096; + margin-left: -8px; + } + QPushButton:hover { + background-color: #f7fafc; + color: #4a5568; + } + QPushButton:pressed { + background-color: #edf2f7; + } + """) + self.btn_toggle_pass.setCursor(Qt.PointingHandCursor) + self.btn_toggle_pass.clicked.connect(self.toggle_password_visibility) + self.password_visible = False + + password_container.addWidget(self.input_pass) + password_container.addWidget(self.btn_toggle_pass) + password_container.setStretch(0, 1) + + password_widget = QWidget() + password_widget.setLayout(password_container) + password_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + password_widget.setStyleSheet("margin-bottom: 20px;") + layout.addWidget(password_widget) + + + # Confirm Password + self.label_confirm = QLabel('Konfirmasi Password') + self.label_confirm.setStyleSheet("font-size: 18px; color: #2d3748; font-weight: bold; margin-bottom: 0px;font-family: 'Segoe UI', Arial, sans-serif;") + layout.addWidget(self.label_confirm) + + # Confirm password container with toggle button + confirm_container = QHBoxLayout() + confirm_container.setSpacing(0) + confirm_container.setContentsMargins(0, 0, 0, 0) + + self.input_confirm = QLineEdit() + self.input_confirm.setFixedHeight(74) + self.input_confirm.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + #self.input_confirm.setFixedWidth(465) + self.input_confirm.setPlaceholderText('Masukkan password kembali') + self.input_confirm.setEchoMode(QLineEdit.Password) + self.input_confirm.setStyleSheet("font-size: 20px; padding: 0px 12px; color: #2d3748; background-color: white; border: 1px solid #e2e8f0; border-top-left-radius: 6px; border-bottom-left-radius: 6px; border-top-right-radius: 0px; border-bottom-right-radius: 0px; border-right: none;font-family: 'Segoe UI', Arial, sans-serif;") + self.input_confirm.setMinimumWidth(0) + + self.btn_toggle_confirm = QPushButton('👁') + self.btn_toggle_confirm.setFixedSize(74, 74) + self.btn_toggle_confirm.setStyleSheet(""" + QPushButton { + font-size: 18px; + border: 1px solid #e2e8f0; + border-left: none; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + background-color: white; + color: #718096; + margin-left: 0px; + } + QPushButton:hover { + background-color: #f7fafc; + color: #4a5568; + } + QPushButton:pressed { + background-color: #edf2f7; + } + """) + self.btn_toggle_confirm.setCursor(Qt.PointingHandCursor) + self.btn_toggle_confirm.clicked.connect(self.toggle_confirm_visibility) + self.confirm_visible = False + + confirm_container.addWidget(self.input_confirm) + confirm_container.addWidget(self.btn_toggle_confirm) + confirm_container.setStretch(0, 1) + + self.confirm_widget = QWidget() + self.confirm_widget.setLayout(confirm_container) + self.confirm_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.confirm_widget.setMinimumWidth(0) + self.confirm_widget.setMaximumWidth(16777215) + self.confirm_widget.setStyleSheet("margin-bottom: 30px;") + layout.addWidget(self.confirm_widget) + + + # Register button + self.btn_register = QPushButton('Register') + self.btn_register.setFixedHeight(60) + self.btn_register.setCursor(Qt.PointingHandCursor) + self.btn_register.setStyleSheet(""" + QPushButton { + font-size: 18px; + font-weight: bold; + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #667eea, stop:1 #764ba2); + color: white; + border: none; + border-radius: 6px; + margin-bottom: 20px; + 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); + } + """) + self.btn_register.clicked.connect(self.handle_register) + layout.addWidget(self.btn_register) + + # Back to login link container + login_container = QHBoxLayout() + login_container.setContentsMargins(0, 0, 0, 0) + login_container.setSpacing(5) + login_container.addStretch() # Center the content + + # Normal text part + self.label_login_text = QLabel('Sudah punya akun?') + self.label_login_text.setStyleSheet(""" + QLabel { + font-size: 16px; + color: #4a5568; + background-color: transparent; + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + login_container.addWidget(self.label_login_text) + + # Clickable login link + self.btn_back = QPushButton('Login disini') + self.btn_back.setFixedHeight(36) + self.btn_back.setStyleSheet(""" + QPushButton { + font-size: 16px; + font-weight: bold; + color: #667eea; + background-color: transparent; + border: none; + text-decoration: underline; + font-family: 'Segoe UI', Arial, sans-serif; + } + QPushButton:hover { + color: #764ba2; + } + """) + self.btn_back.clicked.connect(self.back_to_login) + self.btn_back.setCursor(Qt.PointingHandCursor) + login_container.addWidget(self.btn_back) + login_container.addStretch() # Center the content + + login_widget = QWidget() + login_widget.setLayout(login_container) + layout.addWidget(login_widget) + + # Enter key triggers register + self.input_user.returnPressed.connect(self.handle_register) + self.input_pass.returnPressed.connect(self.handle_register) + self.input_confirm.returnPressed.connect(self.handle_register) + + self.setLayout(layout) + self.register_success = False + self.back_to_login_clicked = False + self.should_show_login = False # Flag untuk menunjukkan bahwa user ingin kembali ke halaman login + self._sync_confirm_row_width() + self._native_icon_applied = False + + def _apply_native_windows_icon(self): + """Set native HWND icons to avoid default taskbar icon on first window show.""" + if os.name != 'nt': + return + icon_path = config.get_app_icon_path() + if not icon_path or not os.path.exists(icon_path): + return + try: + import ctypes + WM_SETICON = 0x0080 + ICON_SMALL = 0 + ICON_BIG = 1 + IMAGE_ICON = 1 + LR_LOADFROMFILE = 0x0010 + LR_DEFAULTSIZE = 0x0040 + + hicon = ctypes.windll.user32.LoadImageW( + None, + icon_path, + IMAGE_ICON, + 0, + 0, + LR_LOADFROMFILE | LR_DEFAULTSIZE, + ) + if hicon: + hwnd = int(self.winId()) + ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon) + ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon) + except Exception: + pass + + def showEvent(self, event): + super().showEvent(event) + if not self._native_icon_applied: + self._apply_native_windows_icon() + self._native_icon_applied = True + # Clear form setiap kali dialog ditampilkan + self.clear_form() + + def clear_form(self): + """Clear all input fields in the register form""" + self.input_user.clear() + self.input_pass.clear() + self.input_confirm.clear() + # Reset password visibility + if self.password_visible: + self.input_pass.setEchoMode(QLineEdit.Password) + self.password_visible = False + if self.confirm_visible: + self.input_confirm.setEchoMode(QLineEdit.Password) + self.confirm_visible = False + self.btn_toggle_pass.setText('👁') + self.btn_toggle_confirm.setText('👁') + # Set focus to username field + self.input_user.setFocus() + + def _apply_responsive_size(self, base_width, base_height): + """Apply fixed size that adapts for smaller screens.""" + screen = QApplication.primaryScreen() + if not screen: + self.setFixedSize(base_width, base_height) + return + + available = screen.availableGeometry() + width = min(base_width, max(540, int(available.width() * 0.92))) + height = min(base_height, max(430, int(available.height() * 0.90))) + self.setFixedSize(width, height) + + def _sync_confirm_row_width(self): + """Keep confirm-password row width exactly aligned with username input.""" + if hasattr(self, 'input_user') and hasattr(self, 'confirm_widget'): + self.confirm_widget.setFixedWidth(self.input_user.width()) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._sync_confirm_row_width() + + def toggle_password_visibility(self): + """Toggle password visibility""" + if self.password_visible: + self.input_pass.setEchoMode(QLineEdit.Password) + self.password_visible = False + else: + self.input_pass.setEchoMode(QLineEdit.Normal) + self.password_visible = True + + def toggle_confirm_visibility(self): + """Toggle confirm password visibility""" + if self.confirm_visible: + self.input_confirm.setEchoMode(QLineEdit.Password) + self.confirm_visible = False + else: + self.input_confirm.setEchoMode(QLineEdit.Normal) + self.confirm_visible = True + + #proses ambil data input + def handle_register(self): + username = self.input_user.text().strip() + password = self.input_pass.text() + confirm_password = self.input_confirm.text() + + # Validasi input + if not username or not password or not confirm_password: + CustomNotificationDialog.show_warning(self, 'Warning!', 'Semua field harus diisi!', 'OK', compact=True) + return + + if len(username) < 3: + CustomNotificationDialog.show_warning(self, 'Warning!', 'Username minimal 3 karakter!', 'OK', compact=True) + return + + if len(password) < 6: + CustomNotificationDialog.show_warning(self, 'Warning!', 'Password minimal 6 karakter!', 'OK', compact=True) + return + + if password != confirm_password: + CustomNotificationDialog.show_warning(self, 'Warning!', 'Password dan konfirmasi password tidak cocok!', 'OK', compact=True) + return + + # Coba register + try: + register_admin(username, password) + self.register_success = True + CustomNotificationDialog.show_success(self, 'Registrasi Berhasil!', f'Akun {username} berhasil dibuat!\nSilakan login untuk melanjutkan.', 'OK', compact=True) + self.accept() + except Exception as e: + error_msg = str(e) + if 'duplicate' in error_msg.lower() or 'unique' in error_msg.lower(): + CustomNotificationDialog.show_error(self, 'Error!', 'Username sudah digunakan!\nPilih username lain.', 'Coba Lagi', compact=True) + else: + CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mendaftar:\n{error_msg}', 'Coba Lagi', compact=True) + + def back_to_login(self): + """Tutup register dialog dan set flag untuk menampilkan login dialog""" + self.should_show_login = True + self.reject() diff --git a/img/Logo_skripsi.ico b/img/Logo_skripsi.ico new file mode 100644 index 0000000..007c658 Binary files /dev/null and b/img/Logo_skripsi.ico differ diff --git a/img/Logo_skripsi.png b/img/Logo_skripsi.png new file mode 100644 index 0000000..036aecb Binary files /dev/null and b/img/Logo_skripsi.png differ diff --git a/img/logo_sekolah.ico b/img/logo_sekolah.ico new file mode 100644 index 0000000..c06269f Binary files /dev/null and b/img/logo_sekolah.ico differ diff --git a/img/logo_sekolah.png b/img/logo_sekolah.png new file mode 100644 index 0000000..775535e Binary files /dev/null and b/img/logo_sekolah.png differ diff --git a/img/logo_sekolah_preview_32.png b/img/logo_sekolah_preview_32.png new file mode 100644 index 0000000..1915a0a Binary files /dev/null and b/img/logo_sekolah_preview_32.png differ diff --git a/img/logo_sma.png b/img/logo_sma.png new file mode 100644 index 0000000..cadad83 Binary files /dev/null and b/img/logo_sma.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..9383885 --- /dev/null +++ b/main.py @@ -0,0 +1,69 @@ +import sys +import os +from PyQt5.QtWidgets import QApplication +from gui.main_window import MainWindow +from gui.login_dialog import LoginDialog +from gui.register_dialog import RegisterDialog + +def main(): + # Create necessary directories + os.makedirs('data', exist_ok=True) + os.makedirs('models', exist_ok=True) + + app = QApplication(sys.argv) + + # Show login and main window with logout loop support + def show_next_window(): + # Tampilkan login dialog terlebih dahulu + show_login = True + while True: + if show_login: + login_dialog = LoginDialog() + result = login_dialog.exec_() + + # Cek apakah user ingin ke halaman register + if login_dialog.should_show_register: + show_login = False # Tampilkan register dialog next + continue + + # Cek apakah login berhasil + if result == LoginDialog.Accepted and login_dialog.login_success: + # Create and show main window + window = MainWindow(login_dialog.username or "Admin") + + # Connect logout signal to show login again + window.logout_signal.connect(show_next_window) + + window.show() + break + else: + # Login canceled, quit application + app.quit() + break + else: + # Tampilkan register dialog + register_dialog = RegisterDialog() + result = register_dialog.exec_() + + # Cek apakah user ingin kembali ke login + if register_dialog.should_show_login: + show_login = True # Kembali ke login + continue + + # Cek apakah register berhasil + if result == RegisterDialog.Accepted and register_dialog.register_success: + # Setelah register berhasil, kembali ke login + show_login = True + continue + else: + # Register canceled, quit application + app.quit() + break + + # Start the application by showing login dialog + show_next_window() + + sys.exit(app.exec_()) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/models/data_processor.py b/models/data_processor.py new file mode 100644 index 0000000..c9ea8a4 --- /dev/null +++ b/models/data_processor.py @@ -0,0 +1,104 @@ +import pandas as pd +import json + +class DataProcessor: + def __init__(self): + self.paket_info = { + 1: { + 'nama': 'Paket Informatika-Fisika-Kimia-Biologi', + 'mapel': ['Informatika', 'Fisika', 'Kimia', 'Biologi'], + 'kelas_tersedia': 2, + 'kapasitas_per_kelas': 36, + 'jumlah_siswa': 50 + }, + 2: { + 'nama': 'Paket Informatika-BIG Lanjut-Kimia-Biologi', + 'mapel': ['Informatika', 'BIG Lanjut', 'Kimia', 'Biologi'], + 'kelas_tersedia': 1, + 'kapasitas_per_kelas': 36, + 'jumlah_siswa': 50 + }, + 3: { + 'nama': 'Paket Informatika-Ekonomi-BIG Lanjut-MAT Lanjut', + 'mapel': ['Informatika', 'Ekonomi', 'BIG Lanjut', 'MAT Lanjut'], + 'kelas_tersedia': 1, + 'kapasitas_per_kelas': 36, + 'jumlah_siswa': 50 + }, + 4: { + 'nama': 'Paket Informatika-Ekonomi-MAT Lanjut-SEJ Lanjut', + 'mapel': ['Informatika', 'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut'], + 'kelas_tersedia': 3, + 'kapasitas_per_kelas': 36, + 'jumlah_siswa': 50 + }, + 5: { + 'nama': 'Paket Informatika-Ekonomi-Sosiologi-SEJ Lanjut', + 'mapel': ['Informatika', 'Ekonomi', 'Sosiologi', 'SEJ Lanjut'], + 'kelas_tersedia': 1, + 'kapasitas_per_kelas': 36, + 'jumlah_siswa': 50 + }, + 6: { + 'nama': 'Paket Informatika-Ekonomi-Sosiologi-Geografi', + 'mapel': ['Informatika', 'Ekonomi', 'Sosiologi', 'Geografi'], + 'kelas_tersedia': 1, + 'kapasitas_per_kelas': 36, + 'jumlah_siswa': 50 + } + } + + def create_sample_dataset(self, output_path, num_samples=500): + """Membuat dataset contoh untuk training""" + import random + + data = [] + for i in range(num_samples): + # Generate random grades (60-100) + nilai = { + 'nama_siswa': f'Siswa_{i+1}', + 'nilai_informatika': random.randint(60, 100), + 'nilai_fisika': random.randint(60, 100), + 'nilai_kimia': random.randint(60, 100), + 'nilai_biologi': random.randint(60, 100), + 'nilai_big_lanjut': random.randint(60, 100), + 'nilai_ekonomi': random.randint(60, 100), + 'nilai_mat_lanjut': random.randint(60, 100), + 'nilai_sej_lanjut': random.randint(60, 100), + 'nilai_sosiologi': random.randint(60, 100), + 'nilai_geografi': random.randint(60, 100) + } + + # Logic untuk menentukan paket berdasarkan nilai tertinggi + paket = self._determine_best_paket(nilai) + nilai['paket_jurusan'] = paket + + data.append(nilai) + + df = pd.DataFrame(data) + df.to_csv(output_path, index=False) + return df + + def _determine_best_paket(self, nilai): + """Logika sederhana untuk menentukan paket terbaik""" + scores = {} + + # Hitung skor untuk setiap paket berdasarkan mata pelajaran yang relevan + scores[1] = (nilai['nilai_informatika'] + nilai['nilai_fisika'] + nilai['nilai_kimia'] + nilai['nilai_biologi']) / 4 + scores[2] = (nilai['nilai_informatika'] + nilai['nilai_big_lanjut'] + nilai['nilai_kimia'] + nilai['nilai_biologi']) / 4 + scores[3] = (nilai['nilai_informatika'] + nilai['nilai_ekonomi'] + nilai['nilai_big_lanjut'] + nilai['nilai_mat_lanjut']) / 4 + scores[4] = (nilai['nilai_informatika'] + nilai['nilai_ekonomi'] + nilai['nilai_mat_lanjut'] + nilai['nilai_sej_lanjut']) / 4 + scores[5] = (nilai['nilai_informatika'] + nilai['nilai_ekonomi'] + nilai['nilai_sosiologi'] + nilai['nilai_sej_lanjut']) / 4 + scores[6] = (nilai['nilai_informatika'] + nilai['nilai_ekonomi'] + nilai['nilai_sosiologi'] + nilai['nilai_geografi']) / 4 + + return max(scores, key=scores.get) + + def get_paket_info(self, paket_num): + """Mendapatkan informasi paket""" + return self.paket_info.get(paket_num, {}) + + def save_paket_info(self, path): + """Simpan informasi paket ke file JSON""" + with open(path, 'w') as f: + json.dump(self.paket_info, f, indent=2) + \ No newline at end of file diff --git a/models/knn_classifier.py b/models/knn_classifier.py new file mode 100644 index 0000000..d3be46e --- /dev/null +++ b/models/knn_classifier.py @@ -0,0 +1,445 @@ +import pandas as pd +import numpy as np +from sklearn.neighbors import KNeighborsClassifier +from sklearn.preprocessing import StandardScaler +from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV +from sklearn.metrics import accuracy_score, classification_report, confusion_matrix +import joblib +import os +from datetime import datetime +import config + +#inisialisasi KNN +class JurusanKNNClassifier: + def __init__(self, n_neighbors=None, weights=None, metric=None): + if n_neighbors is None: + #menetapkan parameter KNN default dari config jika tidak diberikan saat inisialisasi + n_neighbors = config.DEFAULT_K_NEIGHBORS + if weights is None: + weights = getattr(config, 'KNN_WEIGHTS', 'uniform') + #semua tetangga punya bobot yang sama saat voting + if metric is None: + metric = getattr(config, 'KNN_METRIC', 'minkowski') + #cara hitung jarak antar data + self.knn = KNeighborsClassifier(n_neighbors=n_neighbors, weights=weights, metric=metric) + #membuat scaler untuk normalisasi data + self.scaler = StandardScaler() + #menandai model belum dilatih + self.is_trained = False + self.feature_columns = [] + self.training_accuracy = 0.0 + self.model_last_trained = None + + def check_model_exists(self, model_path): + """Check if trained model exists""" + return os.path.exists(model_path) + + def _get_expected_knn_params(self): + """Get current expected KNN params from config.""" + return { + 'n_neighbors': config.DEFAULT_K_NEIGHBORS, + 'weights': getattr(config, 'KNN_WEIGHTS', 'uniform'), + 'metric': getattr(config, 'KNN_METRIC', 'minkowski') + } + + def _saved_model_matches_config(self, model_data): + """Check whether saved model KNN params match current config.""" + knn = model_data.get('knn') if isinstance(model_data, dict) else None + if knn is None: + return False + + expected = self._get_expected_knn_params() + return ( + getattr(knn, 'n_neighbors', None) == expected['n_neighbors'] and + getattr(knn, 'weights', None) == expected['weights'] and + getattr(knn, 'metric', None) == expected['metric'] + ) + + def needs_retraining(self, data_path, model_path): + """Check if model needs retraining based on data freshness""" + if not self.check_model_exists(model_path): + return True + + # Retrain when saved model parameters differ from current configuration. + try: + model_data = joblib.load(model_path) + if not self._saved_model_matches_config(model_data): + return True + except Exception: + return True + + # Check if data file is newer than model file + if os.path.exists(data_path): + data_modified = os.path.getmtime(data_path) + model_modified = os.path.getmtime(model_path) + return data_modified > model_modified + + return False + + def auto_train_if_needed(self, data_path, model_path=None, test_data_path=None): + """Automatically train model if needed""" + if model_path is None: + model_path = config.MODEL_PATH + try: + if self.needs_retraining(data_path, model_path): + # print("Training model...") + accuracy, report = self.train(data_path, test_data_path=test_data_path) + self.save_model(model_path) + # print(f"Model trained with accuracy: {accuracy:.4f}") + return True, accuracy, report + else: + # print("Loading existing model...") + self.load_model(model_path) + return False, self.training_accuracy, None + except Exception as e: + # print(f"Error in auto training: {str(e)}") + return False, 0.0, None + + #siapkan data fitur dan label untuk training + def prepare_data(self, data_path): + #Membaca dataset dari file CSV. + self.df = pd.read_csv(data_path) + + # kolom fitur (nilai mata pelajaran) + self.feature_columns = ['nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', + 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', + 'nilai_sosiologi', 'nilai_geografi'] + #data nilai mata pelajaran yang digunakan untuk prediksi + X = self.df[self.feature_columns] + y = self.df['paket_jurusan'] + #label paket jurusan + + return X, y + + #untuk melatih model KNN dengan opsi optimasi hyperparameter, cv std + def _print_testing_report(self, y_test, y_pred, labels, accuracy=None, cv_scores=None): + cm = confusion_matrix(y_test, y_pred, labels=labels) + + print("\n" + "=" * 80) + print("DETAIL HASIL TESTING") + print("=" * 80) + + # Model and evaluation context + print("\n0) Konfigurasi Evaluasi") + print(f"Model KNN : k={self.knn.n_neighbors}, weights={self.knn.weights}, metric={self.knn.metric}") + print(f"Cross Validation : {config.CROSS_VALIDATION_FOLDS}-fold") + if accuracy is not None: + print(f"Test Accuracy : {accuracy:.4f} ({accuracy * 100:.2f}%)") + if cv_scores is not None and len(cv_scores) > 0: + cv_mean = float(np.mean(cv_scores)) + cv_std = float(np.std(cv_scores)) + print(f"CV Mean Accuracy : {cv_mean:.4f} ({cv_mean * 100:.2f}%)") + print(f"CV Std Dev : {cv_std:.4f}") + + # 1) Confusion matrix table + cm_df = pd.DataFrame( + cm, + index=[f"Actual {label}" for label in labels], + columns=[f"Pred {label}" for label in labels] + ) + print("\n1) Confusion Matrix") + print(cm_df.to_string()) + + # 1b) Normalized confusion matrix (% per actual class) + cm_row_sum = cm.sum(axis=1, keepdims=True) + cm_row_sum[cm_row_sum == 0] = 1 + cm_norm = (cm / cm_row_sum) * 100.0 + cm_norm_df = pd.DataFrame( + cm_norm, + index=[f"Actual {label}" for label in labels], + columns=[f"Pred {label}" for label in labels] + ) + print("\n1b) Confusion Matrix Normalized (%)") + print(cm_norm_df.to_string(float_format=lambda x: f"{x:.2f}")) + + # 2) TP, FN, FP, TN table per class (one-vs-rest) + tf_table = [] + total = int(cm.sum()) + for idx, label in enumerate(labels): + tp = int(cm[idx, idx]) + fn = int(cm[idx, :].sum() - tp) + fp = int(cm[:, idx].sum() - tp) + tn = int(total - tp - fn - fp) + tf_table.append({ + 'kelas': label, + 'TP': tp, + 'FN': fn, + 'FP': fp, + 'TN': tn + }) + + tf_df = pd.DataFrame(tf_table) + print("\n2) Tabel TP/FN/FP/TN per Kelas") + print(tf_df.to_string(index=False)) + + # 3) Actual vs predicted for all test rows + result_df = pd.DataFrame({ + 'no_data_test': np.arange(1, len(y_test) + 1), + 'nilai_sebenarnya': np.array(y_test), + 'nilai_prediksi': np.array(y_pred) + }) + + def row_status(actual, pred): + return "BENAR" if actual == pred else "SALAH" + + def row_confusion_status(actual, pred): + if actual == pred: + return "TP (kelas aktual), TN" + return f"FN (kelas {actual}), FP (kelas {pred})" + + result_df['status'] = [ + row_status(actual, pred) + for actual, pred in zip(result_df['nilai_sebenarnya'], result_df['nilai_prediksi']) + ] + result_df['status_tpfnfp_tn'] = [ + row_confusion_status(actual, pred) + for actual, pred in zip(result_df['nilai_sebenarnya'], result_df['nilai_prediksi']) + ] + + print(f"\n3) Nilai Sebenarnya vs Nilai Prediksi ({len(result_df)} data test)") + print(result_df.to_string(index=False)) + + # 4) Classification metrics + report_dict = classification_report( + y_test, + y_pred, + labels=labels, + output_dict=True, + zero_division=0 + ) + + metrics_rows = [] + for label in labels: + key = str(label) + metrics_rows.append({ + 'kelas': label, + 'precision': report_dict[key]['precision'], + 'recall': report_dict[key]['recall'], + 'f1-score': report_dict[key]['f1-score'], + 'support': int(report_dict[key]['support']) + }) + + metrics_rows.append({ + 'kelas': 'accuracy', + 'precision': np.nan, + 'recall': np.nan, + 'f1-score': report_dict['accuracy'], + 'support': int(sum(report_dict[str(label)]['support'] for label in labels)) + }) + metrics_rows.append({ + 'kelas': 'macro avg', + 'precision': report_dict['macro avg']['precision'], + 'recall': report_dict['macro avg']['recall'], + 'f1-score': report_dict['macro avg']['f1-score'], + 'support': int(report_dict['macro avg']['support']) + }) + metrics_rows.append({ + 'kelas': 'weighted avg', + 'precision': report_dict['weighted avg']['precision'], + 'recall': report_dict['weighted avg']['recall'], + 'f1-score': report_dict['weighted avg']['f1-score'], + 'support': int(report_dict['weighted avg']['support']) + }) + + metrics_df = pd.DataFrame(metrics_rows) + print("\n4) Precision, Recall, F1-Score, Support, Accuracy, Macro Avg, Weighted Avg") + print(metrics_df.to_string(index=False, float_format=lambda x: f"{x:.4f}" if pd.notna(x) else "")) + print("=" * 80 + "\n") + + #training model knn + def train(self, data_path, optimize_k=None, test_data_path=None): + """Melatih model KNN dengan opsi optimasi hyperparameter""" + if optimize_k is None: + optimize_k = config.OPTIMIZE_K + + X, y = self.prepare_data(data_path) + + # gunakan test_data_path jika diberikan dan valid, jika tidak lakukan split train/test seperti biasa + if test_data_path and os.path.exists(test_data_path): + X_train, y_train = X, y + X_test, y_test = self.prepare_data(test_data_path) + else: + # Split train/test + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=config.TEST_SIZE, random_state=config.RANDOM_STATE, stratify=y + ) + + # Normalisasi data + X_train_scaled = self.scaler.fit_transform(X_train) + X_test_scaled = self.scaler.transform(X_test) + + # Optimasi hyperparameter k jika diaktifkan + if optimize_k: + best_k = self.find_optimal_k(X_train_scaled, y_train) + self.knn = KNeighborsClassifier( + n_neighbors=best_k, + weights=self.knn.weights, + metric=self.knn.metric + ) + print(f"Optimal k value found: {best_k}") + + # Training + self.knn.fit(X_train_scaled, y_train) + + # Evaluasi, menguji pada data test untuk menghitung akurasi + y_pred = self.knn.predict(X_test_scaled) + y_pred_proba = self.knn.predict_proba(X_test_scaled) + accuracy = accuracy_score(y_test, y_pred) + labels = sorted(set(np.array(y_test)).union(set(np.array(y_pred)))) + + # Cross-validation score + cv_scores = cross_val_score( + self.knn, + X_train_scaled, + y_train, + cv=config.CROSS_VALIDATION_FOLDS + ) + print(f"Cross-validation scores: {cv_scores}") + print(f"Average CV score: {cv_scores.mean():.4f} (+/- {cv_scores.std() * 2:.4f})") + + self.is_trained = True + self.training_accuracy = accuracy + self.model_last_trained = datetime.now() + self.cv_scores = cv_scores + self.cv_mean = float(cv_scores.mean()) + self.cv_std = float(cv_scores.std()) + + # Store test data for performance evaluation + self.X_test = X_test + self.y_test = y_test + self.y_pred = y_pred + self.y_pred_proba = y_pred_proba + self.confusion_matrix = confusion_matrix(y_test, y_pred, labels=labels) + + self._print_testing_report(y_test, y_pred, labels, accuracy=accuracy, cv_scores=cv_scores) + + return accuracy, classification_report(y_test, y_pred, zero_division=0) + + #mencari nilai k terbaik dengan GridSearchCV + def find_optimal_k(self, X_train, y_train, k_range=None): + """Find optimal k using GridSearchCV (uses config.K_RANGE by default).""" + if k_range is None: + k_range = list(config.K_RANGE) + else: + k_range = list(k_range) + + param_grid = {'n_neighbors': k_range} + knn_temp = KNeighborsClassifier(weights=self.knn.weights, metric=self.knn.metric) + + grid = GridSearchCV( + knn_temp, + param_grid, + cv=config.CROSS_VALIDATION_FOLDS, + scoring='accuracy', + n_jobs=-1, + refit=True + ) + + grid.fit(X_train, y_train) + best_k = int(grid.best_params_['n_neighbors']) + # store grid object for inspection or display in UI + self.grid_search_results = grid + return best_k + + #untuk mendapatkan hasil evaluasi model yang sudah dilatih, termasuk akurasi, classification report, confusion matrix, dan waktu pelatihan terakhir + def evaluate_model(self): + if not self.is_trained: + return None + + return { + 'accuracy': accuracy_score(self.y_test, self.y_pred), + 'classification_report': classification_report(self.y_test, self.y_pred, output_dict=True, zero_division=0), + 'confusion_matrix': self.confusion_matrix, + 'training_time': self.model_last_trained + } + + #prediksi paket jurusan untuk siswa baru + def predict(self, nilai_siswa): + if not self.is_trained: + raise ValueError("Model belum dilatih!") + + # Konversi input ke DataFrame + input_df = pd.DataFrame([nilai_siswa], columns=self.feature_columns) + + # Data siswa di normalisasi dengan scaler yang sama seperti saat training + input_scaled = self.scaler.transform(input_df) + + # Prediksi + prediction = self.knn.predict(input_scaled)[0] + probabilities = self.knn.predict_proba(input_scaled)[0] + + # untuk menampilkan semua prediksi dengan probabilitasnya, urutkan berdasarkan probabilitas tertinggi + all_indices = np.argsort(probabilities)[::-1] + all_pakets = [self.knn.classes_[i] for i in all_indices] + all_probs = [probabilities[i] for i in all_indices] + + # untuk menampilkan 3 prediksi teratas dengan probabilitasnya + top_3_pakets = [] + top_3_probs = [] + + # Tambahkan prediksi utama terlebih dahulu + main_prediction_index = np.where(self.knn.classes_ == prediction)[0][0] + top_3_pakets.append(prediction) + top_3_probs.append(probabilities[main_prediction_index]) + + # Tambahkan prediksi lain berdasarkan probabilitas tertinggi, kecuali yang sudah menjadi prediksi utama + for paket, prob in zip(all_pakets, all_probs): + if paket != prediction and len(top_3_pakets) < 3: + top_3_pakets.append(paket) + top_3_probs.append(prob) + + return prediction, list(zip(top_3_pakets, top_3_probs)) + + def save_model(self, path): + """Simpan model dengan informasi tambahan""" + os.makedirs(os.path.dirname(path), exist_ok=True) + model_data = { + 'knn': self.knn, + 'scaler': self.scaler, + 'feature_columns': self.feature_columns, + 'is_trained': self.is_trained, + 'training_accuracy': self.training_accuracy, + 'model_last_trained': self.model_last_trained, + 'n_neighbors': self.knn.n_neighbors if self.is_trained else 5 + } + + # Save evaluation results if available + if hasattr(self, 'X_test') and hasattr(self, 'y_test'): + model_data.update({ + 'X_test': self.X_test, + 'y_test': self.y_test, + 'y_pred': self.y_pred, + 'confusion_matrix': self.confusion_matrix + }) + + joblib.dump(model_data, path) + + def load_model(self, path): + """Load model dengan informasi tambahan""" + model_data = joblib.load(path) + + if not self._saved_model_matches_config(model_data): + expected = self._get_expected_knn_params() + saved_knn = model_data.get('knn') if isinstance(model_data, dict) else None + saved_params = { + 'n_neighbors': getattr(saved_knn, 'n_neighbors', None), + 'weights': getattr(saved_knn, 'weights', None), + 'metric': getattr(saved_knn, 'metric', None) + } + raise ValueError( + f"Saved model configuration {saved_params} does not match current config {expected}." + ) + + self.knn = model_data['knn'] + self.scaler = model_data['scaler'] + self.feature_columns = model_data['feature_columns'] + self.is_trained = model_data['is_trained'] + self.training_accuracy = model_data.get('training_accuracy', 0.0) + self.model_last_trained = model_data.get('model_last_trained', None) + + # Load evaluation results if available + if 'X_test' in model_data: + self.X_test = model_data['X_test'] + self.y_test = model_data['y_test'] + self.y_pred = model_data['y_pred'] + self.confusion_matrix = model_data['confusion_matrix'] \ No newline at end of file diff --git a/models/knn_model.pkl b/models/knn_model.pkl new file mode 100644 index 0000000..fc64866 Binary files /dev/null and b/models/knn_model.pkl differ diff --git a/models/trained_model.pkl b/models/trained_model.pkl new file mode 100644 index 0000000..fe62a6a Binary files /dev/null and b/models/trained_model.pkl differ diff --git a/redistribute_smakom_dataset.py b/redistribute_smakom_dataset.py new file mode 100644 index 0000000..6f37b60 --- /dev/null +++ b/redistribute_smakom_dataset.py @@ -0,0 +1,283 @@ +import pandas as pd +import numpy as np +import random +from collections import Counter + +def rebalance_smakom_without_duplication(): + """ + Rebalance dataset_smakom tanpa duplikasi + - Mengambil siswa dari paket berlebih untuk melengkapi paket yang kurang + - Mempertahankan urutan data seperti dataset asli + - Label disesuaikan berdasarkan redistribusi + """ + + # Muat dataset asli + df_original = pd.read_csv('data/dataset_smakom.csv') + + print("=== REBALANCE DATASET SMAKOM (TANPA DUPLIKASI) ===") + print(f"Dataset asli: {len(df_original)} siswa") + + original_dist = df_original['paket_jurusan'].value_counts().sort_index() + print("Distribusi asli:") + + surplus_students = [] # Siswa dari paket berlebih + deficit_pakets = [] # Paket yang kekurangan + + for paket in sorted(original_dist.index): + count = original_dist[paket] + if count > 50: + surplus = count - 50 + print(f" Paket {paket}: {count} siswa (SURPLUS: {surplus})") + elif count < 50: + deficit = 50 - count + deficit_pakets.append((paket, deficit, count)) + print(f" Paket {paket}: {count} siswa (KURANG: {deficit})") + else: + print(f" Paket {paket}: {count} siswa (SEIMBANG)") + + print(f"\nPaket yang kekurangan: {[p[0] for p in deficit_pakets]}") + + # Atur seed untuk reproducibility + np.random.seed(42) + random.seed(42) + + # Kumpulkan siswa surplus dari paket berlebih + available_students = [] + + for paket in [1, 4]: # Paket yang berlebih (67 dan 100 siswa) + paket_students = df_original[df_original['paket_jurusan'] == paket].copy() + if paket == 1: # 67 siswa, ambil 17 untuk redistribusi + selected = paket_students.sample(n=50, random_state=42) # Siswa yang tetap di paket 1 + surplus = paket_students.drop(selected.index) # 17 siswa untuk redistribusi + elif paket == 4: # 100 siswa, ambil 50 untuk redistribusi + selected = paket_students.sample(n=50, random_state=42) # Siswa yang tetap di paket 4 + surplus = paket_students.drop(selected.index) # 50 siswa untuk redistribusi + + available_students.extend(surplus.to_dict('records')) + + print(f"Siswa tersedia untuk redistribusi: {len(available_students)}") + + # Hitung kebutuhan redistribusi + total_needed = sum([deficit for _, deficit, _ in deficit_pakets]) + print(f"Total kebutuhan: {total_needed} siswa") + + if len(available_students) >= total_needed: + print("✅ Cukup siswa untuk redistribusi tanpa duplikasi") + else: + print("❌ Tidak cukup siswa, akan perlu duplikasi") + return None + + # Buat dataset baru + df_rebalanced = df_original.copy() + + # Redistribute siswa + used_students = 0 + + for target_paket, needed, current_count in deficit_pakets: + print(f"\nMelengkapi Paket {target_paket}: {current_count} → 50 (+{needed})") + + # Ambil siswa yang dibutuhkan + students_to_add = available_students[used_students:used_students + needed] + used_students += needed + + # Perbarui label paket untuk siswa yang dipindah + for student in students_to_add: + # Cari siswa di dataframe dan perbarui label + mask = (df_rebalanced['nama_siswa'] == student['nama_siswa']) + if mask.any(): + old_paket = df_rebalanced.loc[mask, 'paket_jurusan'].iloc[0] + df_rebalanced.loc[mask, 'paket_jurusan'] = target_paket + print(f" - {student['nama_siswa'][:25]:25} | {old_paket} → {target_paket}") + + # Validasi distribusi final + final_dist = df_rebalanced['paket_jurusan'].value_counts().sort_index() + print(f"\n=== DISTRIBUSI FINAL ===") + for paket in sorted(final_dist.index): + print(f" Paket {paket}: {final_dist[paket]} siswa") + + # Normalisasi nilai berdasarkan label baru + df_normalized = normalize_values_by_new_labels(df_rebalanced) + + return df_normalized + +def normalize_values_by_new_labels(df): + """ + Normalisasi nilai berdasarkan label paket yang sudah direbalance + """ + print(f"\n=== NORMALISASI NILAI BERDASARKAN LABEL BARU ===") + + # Definisi mata pelajaran utama per paket + paket_subjects = { + 1: ['nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi'], # MIPA + 2: ['nilai_informatika', 'nilai_big_lanjut', 'nilai_kimia', 'nilai_biologi'], # MIPA + BIG + 3: ['nilai_informatika', 'nilai_ekonomi', 'nilai_big_lanjut', 'nilai_mat_lanjut'], # Teknik/Ekonomi + 4: ['nilai_informatika', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut'], # Ekonomi/Sosial + 5: ['nilai_informatika', 'nilai_ekonomi', 'nilai_sosiologi', 'nilai_sej_lanjut'], # Sosial/Ekonomi + 6: ['nilai_informatika', 'nilai_ekonomi', 'nilai_sosiologi', 'nilai_geografi'] # Sosial/Geografi + } + + all_subjects = ['nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', + 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', + 'nilai_sosiologi', 'nilai_geografi'] + + df_result = df.copy() + + # Track perubahan untuk siswa yang dipindah paket + redistribution_log = [] + + for paket in range(1, 7): + mask = df_result['paket_jurusan'] == paket + students_count = mask.sum() + target_subjects = paket_subjects[paket] + non_target_subjects = [s for s in all_subjects if s not in target_subjects] + + print(f"Paket {paket} ({students_count} siswa):") + print(f" - Mata pelajaran utama: {[s.replace('nilai_', '') for s in target_subjects]}") + + # Normalisasi mata pelajaran UTAMA: tinggi (85-98) + for subject in target_subjects: + if subject == 'nilai_informatika': + # Informatika prioritas utama + new_values = np.random.normal(92, 3, size=students_count) + new_values = np.clip(new_values, 85, 98) + else: + # Mata pelajaran utama lainnya + new_values = np.random.normal(89, 4, size=students_count) + new_values = np.clip(new_values, 85, 98) + + df_result.loc[mask, subject] = np.round(new_values, 1) + + # Normalisasi mata pelajaran NON-UTAMA: rendah (70-82) + for subject in non_target_subjects: + new_values = np.random.normal(76, 3, size=students_count) + new_values = np.clip(new_values, 70, 82) + df_result.loc[mask, subject] = np.round(new_values, 1) + + return df_result + +def sort_by_preference(df, sort_by='original'): + """ + Urutkan dataset berdasarkan preferensi + - 'original': urutan sama seperti dataset_smakom asli + - 'label': diurutkan berdasarkan paket_jurusan kemudian nama + """ + + if sort_by == 'label': + print("Mengurutkan berdasarkan label paket...") + df_sorted = df.sort_values(['paket_jurusan', 'nama_siswa']).reset_index(drop=True) + else: # 'original' + print("Mempertahankan urutan asli dari dataset_smakom...") + # Muat urutan asli + df_original = pd.read_csv('data/dataset_smakom.csv') + + # Buat pemetaan urutan berdasarkan nama siswa + name_order = {name: idx for idx, name in enumerate(df_original['nama_siswa'])} + + # Tetapkan urutan untuk pengurutan + df['sort_order'] = df['nama_siswa'].map(name_order) + + # Urutkan berdasarkan urutan asli + df_sorted = df.sort_values('sort_order').drop('sort_order', axis=1).reset_index(drop=True) + + return df_sorted + +def validate_rebalanced_dataset(df): + """Validasi dataset yang sudah direbalance""" + print(f"\n=== VALIDASI DATASET REBALANCED ===") + + # Distribusi + dist = df['paket_jurusan'].value_counts().sort_index() + print("Distribusi final:") + for paket in sorted(dist.index): + print(f" Paket {paket}: {dist[paket]} siswa") + + # Gap nilai + paket_subjects = { + 1: ['nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi'], + 2: ['nilai_informatika', 'nilai_big_lanjut', 'nilai_kimia', 'nilai_biologi'], + 3: ['nilai_informatika', 'nilai_ekonomi', 'nilai_big_lanjut', 'nilai_mat_lanjut'], + 4: ['nilai_informatika', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut'], + 5: ['nilai_informatika', 'nilai_ekonomi', 'nilai_sosiologi', 'nilai_sej_lanjut'], + 6: ['nilai_informatika', 'nilai_ekonomi', 'nilai_sosiologi', 'nilai_geografi'] + } + + all_subjects = ['nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', + 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', + 'nilai_sosiologi', 'nilai_geografi'] + + print("\nGap nilai (Target vs Non-Target):") + for paket in range(1, 7): + data_paket = df[df['paket_jurusan'] == paket] + target_subjects = paket_subjects[paket] + non_target_subjects = [s for s in all_subjects if s not in target_subjects] + + avg_target = data_paket[target_subjects].mean().mean() + avg_non_target = data_paket[non_target_subjects].mean().mean() + gap = avg_target - avg_non_target + + print(f" Paket {paket}: Target={avg_target:.1f}, Non-target={avg_non_target:.1f}, Gap={gap:.1f}") + +def test_knn_accuracy(dataset_path): + """Test akurasi KNN""" + print(f"\n=== TEST AKURASI KNN ===") + + try: + from models.knn_classifier import JurusanKNNClassifier + + classifier = JurusanKNNClassifier() + accuracy, report = classifier.train(dataset_path, optimize_k=True) + + print(f"Akurasi KNN: {accuracy:.4f} ({accuracy*100:.2f}%)") + return accuracy + + except Exception as e: + print(f"Error: {str(e)}") + return 0.0 + +if __name__ == "__main__": + # Rebalance tanpa duplikasi + df_rebalanced = rebalance_smakom_without_duplication() + + if df_rebalanced is not None: + # Pilih urutan data + print(f"\n=== PENGATURAN URUTAN DATA ===") + sort_preference = 'original' # atau 'label' + df_final = sort_by_preference(df_rebalanced, sort_by=sort_preference) + + # Validasi + validate_rebalanced_dataset(df_final) + + # Simpan dataset + output_path = 'data/dataset_smakom_final.csv' + df_final.to_csv(output_path, index=False) + print(f"\nDataset tersimpan di: {output_path}") + + # Test akurasi + new_accuracy = test_knn_accuracy(output_path) + + # Bandingkan dengan dataset lainnya + print(f"\n=== PERBANDINGAN AKURASI ===") + try: + smakom_accuracy = test_knn_accuracy('data/dataset_smakom.csv') + siswa_accuracy = test_knn_accuracy('data/dataset_siswa.csv') + + print(f"Dataset SMAKOM asli : {smakom_accuracy:.4f} ({smakom_accuracy*100:.2f}%)") + print(f"Dataset siswa sebelumnya : {siswa_accuracy:.4f} ({siswa_accuracy*100:.2f}%)") + print(f"Dataset SMAKOM final : {new_accuracy:.4f} ({new_accuracy*100:.2f}%)") + + improvement = (new_accuracy - smakom_accuracy) * 100 + print(f"\nPeningkatan dari SMAKOM : +{improvement:.2f} pp") + + except Exception as e: + print(f"Error saat membandingkan: {str(e)}") + + print(f"\n=== RINGKASAN ===") + print("✅ Tidak ada duplikasi siswa") + print("✅ Semua siswa dari dataset_smakom asli") + print("✅ Distribusi seimbang 50 per paket") + print("✅ Urutan data sesuai dataset asli") + print("✅ Label disesuaikan untuk keseimbangan") + print(f"✅ Akurasi KNN: {new_accuracy:.1%}") + + else: + print("❌ Gagal melakukan rebalancing tanpa duplikasi") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e86e1e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +PyQt5==5.15.10 +pandas==2.1.4 +numpy==1.24.3 +scikit-learn==1.3.2 +matplotlib==3.7.2 +seaborn==0.12.2 +psycopg2-binary==2.9.9 +joblib==1.3.2 +xlsxwriter==3.1.9 +openpyxl==3.1.2 \ No newline at end of file diff --git a/scripts/plot_dataset_distribution.py b/scripts/plot_dataset_distribution.py new file mode 100644 index 0000000..e15c651 --- /dev/null +++ b/scripts/plot_dataset_distribution.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Plot class distribution before and after undersampling. + +Usage: python scripts/plot_dataset_distribution.py +""" +import os +import sys +import pandas as pd +import matplotlib.pyplot as plt +from sklearn.decomposition import PCA +from sklearn.preprocessing import StandardScaler + + +def safe_read_csv(path): + if not os.path.exists(path): + print(f"File not found: {path}") + sys.exit(1) + return pd.read_csv(path) + + +def plot_distribution_scatter(before_df, after_df, label_col='paket_jurusan'): + # Select numeric feature columns (exclude name and label) + numeric_cols = [c for c in before_df.columns if c.startswith('nilai_')] + + # Prepare data for PCA: combine both datasets to get consistent projection + combined = pd.concat([before_df[numeric_cols], after_df[numeric_cols]], ignore_index=True) + scaler = StandardScaler() + combined_scaled = scaler.fit_transform(combined.values) + + pca = PCA(n_components=2) + combined_2d = pca.fit_transform(combined_scaled) + + before_2d = combined_2d[: len(before_df)] + after_2d = combined_2d[len(before_df) :] + + # plotting + fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharex=True, sharey=True) + + def draw_scatter(ax, points, labels, title): + classes = sorted(labels.unique()) + cmap = plt.get_cmap('tab10') + for i, cls in enumerate(classes): + mask = labels == cls + ax.scatter(points[mask, 0], points[mask, 1], s=40, alpha=0.8, + color=cmap(i % 10), label=f'Kelas {cls}') + ax.set_title(title) + ax.set_xlabel('PCA 1') + ax.set_ylabel('PCA 2') + ax.legend(loc='best', fontsize='small') + + draw_scatter(axes[0], before_2d, before_df[label_col], 'Sebelum Under-sampling') + draw_scatter(axes[1], after_2d, after_df[label_col], 'Sesudah Under-sampling') + + fig.suptitle('Sebaran Titik Data (2D PCA) — Sebelum vs Sesudah Under-sampling') + fig.tight_layout(rect=[0, 0.03, 1, 0.95]) + + out_dir = os.path.join(os.path.dirname(__file__), '..', 'data') + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, 'dataset_scatter_comparison.png') + fig.savefig(out_path, dpi=150) + print(f"Saved scatter comparison plot to: {out_path}") + + plt.show() + + +def main(): + repo_root = os.path.dirname(os.path.dirname(__file__)) + data_dir = os.path.join(repo_root, 'data') + before_path = os.path.join(data_dir, 'dataset_smakom.csv') + after_path = os.path.join(data_dir, 'dataset_smakom_final.csv') + + before_df = safe_read_csv(before_path) + after_df = safe_read_csv(after_path) + + if 'paket_jurusan' not in before_df.columns or 'paket_jurusan' not in after_df.columns: + print('Expected column "paket_jurusan" not found in one of the files.') + sys.exit(1) + + plot_distribution_scatter(before_df, after_df, label_col='paket_jurusan') + + +if __name__ == '__main__': + main() diff --git a/setup_db.bat b/setup_db.bat new file mode 100644 index 0000000..01dccab --- /dev/null +++ b/setup_db.bat @@ -0,0 +1,51 @@ +@echo off +setlocal + +cd /d "%~dp0" + +set "DB_NAME=db_klasifikasi" +set "DB_USER=postgres" + +if not "%~1"=="" set "DB_USER=%~1" + +echo ================================================ +echo Setup Database PostgreSQL + echo Database : %DB_NAME% + echo User : %DB_USER% +echo ================================================ + +where psql >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] psql tidak ditemukan di PATH. + echo Install PostgreSQL dulu dan pastikan psql bisa dipanggil dari CMD. + pause + exit /b 1 +) + +for /f %%i in ('psql -U %DB_USER% -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='"'"'%DB_NAME%'"'"'"') do set DB_EXISTS=%%i + +if "%DB_EXISTS%"=="1" ( + echo [INFO] Database %DB_NAME% sudah ada. +) else ( + echo [INFO] Membuat database %DB_NAME%... + psql -U %DB_USER% -d postgres -c "CREATE DATABASE %DB_NAME%;" + if errorlevel 1 ( + echo [ERROR] Gagal membuat database. + pause + exit /b 1 + ) +) + +echo [INFO] Menjalankan schema: db\schema.sql ... +psql -U %DB_USER% -d %DB_NAME% -f "db\schema.sql" +if errorlevel 1 ( + echo [ERROR] Gagal menjalankan schema SQL. + pause + exit /b 1 +) + +echo. +echo [OK] Database siap. +echo Selanjutnya jalankan aplikasi EXE. +echo. +pause diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000..e69de29