TIF_E41253317/views/perbandingan_konfigurasi.py

245 lines
9.4 KiB
Python

"""
views/perbandingan_konfigurasi.py
==================================
KHUSUS ADMIN. Halaman ini TIDAK PERNAH dikirim ke role 'pemilik' -- lihat
app.py, daftar `pages` untuk role tsb tidak menyertakan Page ini sama
sekali (bukan cuma disembunyikan di sidebar).
Isi (3 tab):
1. "Evaluasi Metrik" -- tabel perbandingan MAE/RMSE/MAPE untuk 4 skenario
x N produk (skenario optimal tiap produk ditandai hijau), + tabel
parameter hasil grid search terbaik.
2. "Status Model" -- ringkasan skenario optimal, ketersediaan scaler,
dan kapan terakhir tiap produk diperbarui.
3. "Perbarui Model" -- panel upload file all_results.pkl baru: validasi
struktur -> preview dampak merge -> konfirmasi -> backup otomatis lalu
tulis & reload (lihat utils/model_manager.py untuk detail prosesnya).
"""
from __future__ import annotations
import streamlit as st
from utils import model_manager, ui_components
from utils.data_loader import (
DEFAULT_DATA_PATH,
get_hyperparameter_table,
get_last_updated_info,
get_metric_comparison_table,
)
METRIC_OPTIONS = ["MAPE", "MAE", "RMSE"]
# ---------------------------------------------------------------------------
# Tab 1: Evaluasi Metrik
# ---------------------------------------------------------------------------
def _highlight_optimal_row(row):
"""Beri background hijau pada kolom skenario yang jadi optimal_scenario baris ini."""
optimal = row.get("optimal_scenario")
return [
"background-color:#dcfce7;font-weight:600;" if col == optimal else ""
for col in row.index
]
def _render_metric_table(all_results: dict) -> None:
ui_components.section_header(
"📊", "Perbandingan Metrik Antar Skenario",
"**Kinerja 4 skenario pemodelan** (model standar, model dengan transformasi logaritmik, model dengan penambahan regresor eksternal, dan model dengan kombinasi transformasi logaritmik dan regresor eksternal) **untuk tiap produk.**",
)
metric = st.radio("Metrik", METRIC_OPTIONS, horizontal=True, key="metric_choice")
table = get_metric_comparison_table(all_results, metric=metric)
if table.empty:
st.info("Belum ada data metrik untuk ditampilkan.")
return
# 'optimal_scenario' berisi teks (nama skenario), jadi HARUS dikecualikan
# dari .format(precision=...) -- kalau ikut, akan error karena bukan angka.
numeric_cols = [c for c in table.columns if c != "optimal_scenario"]
styled = (
table.style
.apply(_highlight_optimal_row, axis=1)
.format(subset=numeric_cols, precision=2)
)
st.dataframe(styled, width="stretch")
st.caption("🟩 Sel hijau menunjukkan skenario optimal.")
def _render_hyperparameter_table(all_results: dict) -> None:
ui_components.section_header(
"🧪", "Hyperparameter Skenario Optimal",
"Hasil grid search terbaik -- hanya untuk skenario yang sedang dipakai tiap produk.",
)
table = get_hyperparameter_table(all_results)
st.dataframe(table, width="stretch", hide_index=True)
# ---------------------------------------------------------------------------
# Tab 2: Status Model
# ---------------------------------------------------------------------------
def _render_status_table(all_results: dict) -> None:
ui_components.section_header(
"🕘", "Status Model per Produk",
"Skenario optimal, ketersediaan scaler, dan kapan terakhir diperbarui lewat panel upload.",
)
table = get_last_updated_info(all_results)
st.dataframe(table, width="stretch", hide_index=True)
# ---------------------------------------------------------------------------
# Tab 3: Perbarui Model (upload & merge)
# ---------------------------------------------------------------------------
@st.dialog("✅ Model Berhasil Diperbarui")
def _show_success_dialog(message: str) -> None:
"""
Popup konfirmasi setelah update model berhasil. Muncul di tengah layar,
bisa ditutup dengan klik "Oke" ATAU klik di luar area popup (perilaku
bawaan st.dialog) -- keduanya sama-sama menutup dialog & mengosongkan
kembali panel upload seperti baru pertama kali dibuka.
"""
st.write(message)
if st.button("Oke", type="primary", width="stretch", key="btn_dialog_ok"):
st.rerun()
def _render_upload_panel(all_results: dict) -> None:
ui_components.section_header(
"📤", "Perbarui Model (Upload .pkl)",
"Sistem hanya akan memperbarui produk yang berubah. Data produk lainnya tetap aman.",
)
# >>> FIX (baca): kalau ada pesan sukses yang "dititipkan" dari proses
# SEBELUM rerun (lihat >>> FIX (tulis) di bagian tombol "Terapkan" di
# bawah), tampilkan sebagai POPUP (bukan banner biasa). Dihapus dari
# session_state setelah dibaca, supaya tidak muncul lagi tiap buka tab.
pending_success = st.session_state.pop("_merge_success_msg", None)
if pending_success:
_show_success_dialog(pending_success)
# >>> FIX: key file_uploader disisipi angka versi yang berubah tiap
# kali update berhasil -- ini "trik" resmi Streamlit untuk mengosongkan
# file_uploader secara total (termasuk nama file yang tampil), bukan
# cuma pesannya saja yang hilang.
uploader_version = st.session_state.get("_uploader_version", 0)
uploaded = st.file_uploader(
"Pilih file all_results.pkl", type=["pkl"], key=f"uploader_pkl_{uploader_version}",
)
if uploaded is None:
# File dibatalkan/dihapus dari uploader -> buang state pending
# supaya tombol "Terapkan" tidak bisa dipakai dengan data basi.
st.session_state.pop("_pending_merge_data", None)
return
raw_bytes = uploaded.getvalue()
new_data, errors = model_manager.validate_new_data(raw_bytes)
if errors:
st.error(f"❌ File ditolak, ditemukan {len(errors)} masalah:")
for e in errors:
st.write(f"- {e}")
st.session_state.pop("_pending_merge_data", None)
return
st.success("✅ File siap diterapkan! Berikut rincian perubahannya:")
diff = model_manager.preview_merge(all_results, new_data)
c1, c2, c3 = st.columns(3)
c1.metric("Produk baru", len(diff["produk_baru"]))
c2.metric("Produk diperbarui", len(diff["produk_diperbarui"]))
c3.metric("Produk tidak berubah", len(diff["produk_tidak_berubah"]))
if diff["produk_baru"]:
st.caption("🆕 Baru: " + ", ".join(p.title() for p in diff["produk_baru"]))
if diff["produk_diperbarui"]:
st.caption("♻️ Diperbarui: " + ", ".join(p.title() for p in diff["produk_diperbarui"]))
# Simpan data yang sudah divalidasi ke session_state supaya tombol
# konfirmasi di bawah tidak perlu parse ulang bytes yang sama.
st.session_state["_pending_merge_data"] = new_data
st.warning("🛡️ Jangan khawatir, data yang sedang digunakan akan disimpan otomatis (ke folder `backups/`) sebagai cadangan sebelum diperbarui.")
if st.button("✅ Terapkan pembaruan", type="primary", width="stretch", key="btn_apply_merge"):
pending = st.session_state.get("_pending_merge_data")
if pending is None:
st.error("Tidak ada data valid. Upload ulang filenya!")
return
try:
merged, backup_path = model_manager.apply_merge(
all_results, pending, path=DEFAULT_DATA_PATH,
)
except Exception as e: # noqa: BLE001
st.error(f"Gagal menerapkan pembaruan: {e}")
return
st.session_state["all_results"] = merged
st.session_state.pop("_pending_merge_data", None)
# >>> FIX: naikkan versi supaya file_uploader di render berikutnya
# jadi widget BARU yang kosong (bukan cuma pesannya yang hilang).
st.session_state["_uploader_version"] = uploader_version + 1
# >>> FIX (tulis): pesan sukses DITITIPKAN ke session_state dulu
# (bukan langsung st.success()/dialog di sini), karena st.rerun()
# di baris terakhir akan memotong render sebelum sempat tampil.
# Nanti dibaca & ditampilkan sebagai popup di atas (>>> FIX (baca)).
if backup_path:
st.session_state["_merge_success_msg"] = (
f"Model berhasil diperbarui. File lama disimpan di `{backup_path}`."
)
else:
st.session_state["_merge_success_msg"] = (
"Model berhasil diperbarui (belum ada file lama untuk di-backup)."
)
st.rerun()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def render() -> None:
st.markdown(
"""
<style>
[data-testid="stElementToolbar"] button svg {
transform: scale(1.3) !important;
}
[data-testid="stElementToolbar"] button {
padding: 6px 10px !important;
}
[data-testid="stElementToolbar"] {
padding: 4px 6px !important;
gap: 4px !important;
}
</style>
""",
unsafe_allow_html=True,
)
st.title("Perbandingan Konfigurasi")
all_results = st.session_state.get("all_results", {})
if not all_results:
st.error("Data model belum dimuat.")
return
tab1, tab2, tab3 = st.tabs(["📊 Evaluasi Metrik", "🕘 Status Model", "📤 Perbarui Model"])
with tab1:
_render_metric_table(all_results)
st.divider()
_render_hyperparameter_table(all_results)
with tab2:
_render_status_table(all_results)
with tab3:
_render_upload_panel(all_results)