import os # Fix httpx NO_PROXY parsing bug on Windows when it contains IPv6 loopback '::1' for env_var in ["NO_PROXY", "no_proxy"]: if env_var in os.environ: os.environ[env_var] = os.environ[env_var].replace(",::1", "").replace("::1", "") import sqlite3 import datetime import streamlit as st import pandas as pd from huggingface_hub import hf_hub_download from xgboost import XGBRegressor import plotly.graph_objects as go # --- KONFIGURASI HALAMAN --- st.set_page_config(page_title="BTA Smart Monitor", page_icon="🏗️", layout="wide") # ============================================================ # CUSTOM CSS — Estetika Warm Cream & Minimalis ala Replicate # ============================================================ st.markdown(""" """, unsafe_allow_html=True) # --- KONEKSI DATABASE --- def init_db(): conn = sqlite3.connect('furnace_data.db', check_same_thread=False) c = conn.cursor() c.execute('''CREATE TABLE IF NOT EXISTS cycles (id INTEGER PRIMARY KEY, start_date TEXT, initial_thickness REAL, active INTEGER)''') c.execute('''CREATE TABLE IF NOT EXISTS daily_logs (id INTEGER PRIMARY KEY, cycle_id INTEGER, log_date TEXT, raw_temp REAL)''') # Jalankan migrasi kolom untuk menambah kolom baru jika belum ada c.execute("PRAGMA table_info(daily_logs)") columns = [row[1] for row in c.fetchall()] for col, col_type in [('cone_front', 'REAL'), ('cone_back', 'REAL'), ('thickness', 'REAL'), ('lot_number', 'TEXT')]: if col not in columns: c.execute(f"ALTER TABLE daily_logs ADD COLUMN {col} {col_type}") conn.commit() return conn # --- LOAD MODEL --- HF_TOKEN = os.environ.get("HF_TOKEN") REPO_MODEL = "Rendhaputra/BTA_predictive" FILE_MODEL = "xgboost_bta.json" FILE_PROPHET_MODEL = "model_prophet_bta.json" @st.cache_resource def load_model(): try: path = hf_hub_download(repo_id=REPO_MODEL, filename=FILE_MODEL, token=HF_TOKEN) m = XGBRegressor() m.load_model(path) return m except Exception as e: st.error(f"Gagal muat model: {e}") return None @st.cache_resource def load_prophet_model(): try: import json from prophet.serialize import model_from_json path = hf_hub_download(repo_id=REPO_MODEL, filename=FILE_PROPHET_MODEL, token=HF_TOKEN) with open(path, "r") as f: model_json = json.load(f) return model_from_json(model_json) except Exception as e: st.warning(f"Gagal memuat model Prophet dari Hugging Face: {e}") return None model = load_model() prophet_model = load_prophet_model() db = init_db() # --- FUNGSI HELPER --- def get_active_cycle(): c = db.cursor() c.execute("SELECT id, start_date, initial_thickness FROM cycles WHERE active = 1 ORDER BY id DESC LIMIT 1") return c.fetchone() def get_status_html(pred_mm, model_name="Model 1", batas_kritis=115.0, batas_warning=130.0): """Return HTML for Replicate-style status pill based on predicted thickness and model name.""" if pred_mm < batas_kritis: return f'
Sistem mendeteksi belum ada siklus pemantauan yang aktif. Daftarkan tanggal pemasangan BTA baru beserta ketebalan awal untuk memulai.
', unsafe_allow_html=True) col1, col2 = st.columns(2) with col1: new_date = st.date_input("Tanggal Pemasangan BTA Baru", datetime.date.today()) with col2: new_thick = st.number_input("Ketebalan Awal (mm)", min_value=100.0, max_value=300.0, value=230.0) if st.button("Mulai Siklus Baru", type="primary"): db.execute("INSERT INTO cycles (start_date, initial_thickness, active) VALUES (?, ?, ?)", (new_date.isoformat(), new_thick, 1)) db.commit() st.success("Siklus pemantauan BTA baru berhasil dimulai!") st.rerun() else: cycle_id, start_date_str, initial_thickness = active_cycle try: start_date = pd.to_datetime(start_date_str).date() except Exception: start_date = datetime.date.fromisoformat(start_date_str) # --- QUERY DATA HISTORIS --- df_hist = pd.read_sql_query( f"SELECT log_date, raw_temp, cone_front, cone_back, thickness, lot_number FROM daily_logs WHERE cycle_id={cycle_id}", db) # Pre-computation jika data tidak kosong has_data = not df_hist.empty if has_data: df_hist['log_date'] = pd.to_datetime(df_hist['log_date'], format="mixed", dayfirst=False) df_hist = df_hist.sort_values('log_date').reset_index(drop=True) latest_row = df_hist.iloc[-1] latest_date = latest_row['log_date'].date() df_ukur = df_hist.dropna(subset=['thickness']).copy() if not df_ukur.empty: last_ukur_date = df_ukur.iloc[-1]['log_date'].date() last_ukur_thickness = float(df_ukur.iloc[-1]['thickness']) else: last_ukur_date = start_date last_ukur_thickness = float(initial_thickness) hari_ops = (latest_date - start_date).days # Prediksi XGBoost Anchor-Based Wear Rate laju_recent = -0.1359 tail_14 = df_hist['raw_temp'].tail(14) suhu_asumsi = tail_14.mean() if not tail_14.empty else 350.0 if model is not None: laju_proj_pred = float(model.predict(pd.DataFrame([[suhu_asumsi]], columns=['suhu_avg_periode']))[0]) else: laju_proj_pred = -0.1359 laju_proj_efektif = 0.4 * laju_proj_pred + 0.6 * laju_recent hari_sejak_ukur = (latest_date - last_ukur_date).days pred_mm = last_ukur_thickness + (laju_proj_efektif * hari_sejak_ukur) pred_mm = max(pred_mm, 100.0) BATAS_KRITIS = 115.0 if pred_mm > BATAS_KRITIS and laju_proj_efektif < 0: sisa_hari = int((pred_mm - BATAS_KRITIS) / abs(laju_proj_efektif)) else: sisa_hari = 0 # --- PREDIKSI MODEL PROPHET --- prophet_pred_mm = None prophet_sisa_hari = None df_forecast_prophet = None if prophet_model is not None: try: # Menghitung periods dari tanggal pengukuran terakhir ke latest_date + 90 hari forecast_periods = (latest_date - last_ukur_date).days + 90 future_df = prophet_model.make_future_dataframe(periods=forecast_periods, include_history=True) df_forecast_prophet = prophet_model.predict(future_df) # Filter untuk tanggal hari ini/terakhir pred_today_row = df_forecast_prophet[df_forecast_prophet['ds'].dt.date == latest_date] if not pred_today_row.empty: prophet_pred_mm = max(float(pred_today_row['yhat'].values[0]), 100.0) else: prophet_pred_mm = 100.0 # Hitung sisa hari dari hari ini ke batas kritis future_predictions = df_forecast_prophet[df_forecast_prophet['ds'].dt.date >= latest_date] critical_prophet = future_predictions[future_predictions['yhat'] <= BATAS_KRITIS] if not critical_prophet.empty: earliest_crit = pd.to_datetime(critical_prophet['ds'].min()).date() prophet_sisa_hari = max(0, (earliest_crit - latest_date).days) else: prophet_sisa_hari = 90 except Exception as pe: st.warning(f"Gagal memproses prediksi Prophet: {pe}") # Tentukan nilai prediksi aktif untuk dashboard if not is_xgboost_selected and prophet_pred_mm is not None: active_pred_mm = prophet_pred_mm active_sisa_hari = prophet_sisa_hari active_model_name = "Model 2" else: active_pred_mm = pred_mm active_sisa_hari = sisa_hari active_model_name = "Model 1" lot_display = str(latest_row['lot_number']) if pd.notna(latest_row['lot_number']) else "-" front_display = f"{latest_row['cone_front']:.0f}" if pd.notna(latest_row['cone_front']) else "-" mid_display = f"{latest_row['raw_temp']:.0f}" if pd.notna(latest_row['raw_temp']) else "-" back_display = f"{latest_row['cone_back']:.0f}" if pd.notna(latest_row['cone_back']) else "-" # ============================================================ # SUB-NAV PILLS (Menggunakan st.tabs bawaan dengan CSS Custom) # ============================================================ tab_dashboard, tab_cycles = st.tabs([ "Dashboard & Input Terpadu", "Manajemen Siklus BTA" ]) # ============================================================ # TAB 1: DASHBOARD & INPUT TERPADU (Satu Halaman Flow) # ============================================================ with tab_dashboard: if not has_data: st.info("Selamat datang di siklus baru! Belum ada data pemantauan harian. Silakan gunakan panel input di bawah untuk mengisi data perdana.") # Form Input Perdana Langsung Tampil di bawah info selamat datang col_manual, col_bulk = st.columns(2) with col_manual: with st.form("input_form_initial", clear_on_submit=False): st.markdown('Unggah file CSV dengan kolom utama: Tanggal dan Bodi Tengah (°C) untuk mengimpor seluruh data riwayat operasional secara massal.
', unsafe_allow_html=True) uploaded_file = st.file_uploader("Pilih file CSV dari komputer Anda", type="csv", key="init_uploader") if uploaded_file is not None: try: try: data_import = pd.read_csv(uploaded_file, encoding='utf-8-sig', skiprows=1) except UnicodeDecodeError: uploaded_file.seek(0) data_import = pd.read_csv(uploaded_file, encoding='latin1', skiprows=1) if len(data_import.columns) == 1: uploaded_file.seek(0) data_import = pd.read_csv(uploaded_file, encoding='utf-8-sig', sep=';', skiprows=1) data_import.columns = data_import.columns.str.strip() normalized_cols = {c.lower(): c for c in data_import.columns} col_tanggal = normalized_cols.get('tanggal') col_suhu = normalized_cols.get('bodi tengah (°c)') or normalized_cols.get('suhu') col_front = normalized_cols.get('cone depan (°c)') col_back = normalized_cols.get('cone belakang (°c)') col_tebal = normalized_cols.get('ketebalan bta (mm)') col_lot = normalized_cols.get('lot number') if not col_tanggal or not col_suhu: try: uploaded_file.seek(0) data_import = pd.read_csv(uploaded_file, encoding='utf-8-sig') data_import.columns = data_import.columns.str.strip() normalized_cols = {c.lower(): c for c in data_import.columns} col_tanggal = normalized_cols.get('tanggal') col_suhu = normalized_cols.get('bodi tengah (°c)') or normalized_cols.get('suhu') col_front = normalized_cols.get('cone depan (°c)') col_back = normalized_cols.get('cone belakang (°c)') col_tebal = normalized_cols.get('ketebalan bta (mm)') col_lot = normalized_cols.get('lot number') except Exception: pass if not col_tanggal or not col_suhu: st.error("Kolom 'Tanggal' and 'Bodi Tengah (°C)' wajib ada di dalam file CSV.") else: st.info("Pratinjau Data (Semua Kolom):") st.dataframe(data_import.head(3), use_container_width=True) if st.button("Konfirmasi & Simpan CSV", type="primary"): count = 0 for index, row in data_import.dropna(subset=[col_tanggal, col_suhu]).iterrows(): log_date = str(row[col_tanggal]).strip() try: raw_temp = float(row[col_suhu]) except ValueError: continue cone_front = float(row[col_front]) if col_front and pd.notna(row[col_front]) else None cone_back = float(row[col_back]) if col_back and pd.notna(row[col_back]) else None thickness = float(row[col_tebal]) if col_tebal and pd.notna(row[col_tebal]) else None lot_number = str(row[col_lot]).strip() if col_lot and pd.notna(row[col_lot]) else None db.execute(""" INSERT INTO daily_logs (cycle_id, log_date, raw_temp, cone_front, cone_back, thickness, lot_number) VALUES (?, ?, ?, ?, ?, ?, ?) """, (cycle_id, log_date, raw_temp, cone_front, cone_back, thickness, lot_number)) count += 1 db.commit() st.success(f"Berhasil mengimpor {count} baris data historis secara bulk!") st.rerun() except Exception as e: st.error(f"Gagal memproses file CSV: {e}") else: # Replicate Status Pill di bagian paling atas dashboard st.markdown('Tabel interaktif di bawah menampilkan seluruh baris data operasional aktif. Anda dapat melakukan pencarian, pengurutan, penyalinan data, atau mengunduhnya mirip dengan spreadsheet Excel.
', unsafe_allow_html=True) # Kita siapkan DataFrame yang akan ditampilkan dengan penamaan kolom yang rapi df_excel = df_hist.copy() # Urutkan berdasarkan tanggal terbaru di atas agar user langsung melihat input teranyar! df_excel = df_excel.sort_values('log_date', ascending=False).reset_index(drop=True) # Format tanggal agar nyaman dibaca df_excel['Tanggal'] = df_excel['log_date'].dt.strftime('%d %b %Y') # Pilih dan susun kolom yang relevan cols_to_show = [] rename_dict = {} if 'Tanggal' in df_excel.columns: cols_to_show.append('Tanggal') rename_dict['Tanggal'] = 'Tanggal' if 'lot_number' in df_excel.columns: cols_to_show.append('lot_number') rename_dict['lot_number'] = 'Lot Number' if 'cone_front' in df_excel.columns: cols_to_show.append('cone_front') rename_dict['cone_front'] = 'Suhu Cone Depan (°C)' if 'raw_temp' in df_excel.columns: cols_to_show.append('raw_temp') rename_dict['raw_temp'] = 'Suhu Body Tengah (°C)' if 'cone_back' in df_excel.columns: cols_to_show.append('cone_back') rename_dict['cone_back'] = 'Suhu Cone Belakang (°C)' if 'thickness' in df_excel.columns: cols_to_show.append('thickness') rename_dict['thickness'] = 'Tebal Aktual (mm)' if is_xgboost_selected: if 'thickness_pred' in df_excel.columns: cols_to_show.append('thickness_pred') rename_dict['thickness_pred'] = 'Estimasi Tebal (mm)' else: # Gabungkan prediksi Prophet ke Excel View jika ada if df_forecast_prophet is not None: df_forecast_prophet['log_date_dt'] = pd.to_datetime(df_forecast_prophet['ds']).dt.date df_excel['log_date_dt'] = df_excel['log_date'].dt.date df_excel = pd.merge( df_excel, df_forecast_prophet[['log_date_dt', 'yhat']].rename(columns={'yhat': 'thickness_prophet'}), on='log_date_dt', how='left' ).drop(columns=['log_date_dt']) if 'thickness_prophet' in df_excel.columns: cols_to_show.append('thickness_prophet') rename_dict['thickness_prophet'] = 'Estimasi Tebal (mm)' df_excel_subset = df_excel[cols_to_show].rename(columns=rename_dict) # Gunakan st.dataframe dengan format numerik yang rapi agar seperti Excel st.dataframe( df_excel_subset, use_container_width=True, height=280, column_config={ 'Tebal Aktual (mm)': st.column_config.NumberColumn(format="%.1f mm"), 'Estimasi Tebal (mm)': st.column_config.NumberColumn(format="%.1f mm"), 'Suhu Cone Depan (°C)': st.column_config.NumberColumn(format="%.0f °C"), 'Suhu Body Tengah (°C)': st.column_config.NumberColumn(format="%.0f °C"), 'Suhu Cone Belakang (°C)': st.column_config.NumberColumn(format="%.0f °C"), } ) st.markdown('', unsafe_allow_html=True) # ============================================================ # SUB-SECTION: INPUT DATA & FILE IMPORT (Terintegrasi Langsung!) # ============================================================ st.markdown('Masukkan parameter harian secara langsung untuk pembaruan instan, atau impor file CSV sekaligus untuk merekam data riwayat operasional secara massal.
', unsafe_allow_html=True) # Grid Columns col_manual, col_bulk = st.columns(2) # Column 1: Input Manual with col_manual: with st.form("input_form_integrated", clear_on_submit=False): st.markdown('Unggah file CSV dengan kolom utama: Tanggal dan Bodi Tengah (°C). Kolom pendukung seperti Cone Depan (°C), Cone Belakang (°C), Ketebalan BTA (mm), dan Lot Number akan terdeteksi secara otomatis.
', unsafe_allow_html=True) uploaded_file = st.file_uploader("Pilih file CSV dari komputer Anda", type="csv", key="int_uploader") if uploaded_file is not None: try: try: data_import = pd.read_csv(uploaded_file, encoding='utf-8-sig', skiprows=1) except UnicodeDecodeError: uploaded_file.seek(0) data_import = pd.read_csv(uploaded_file, encoding='latin1', skiprows=1) if len(data_import.columns) == 1: uploaded_file.seek(0) data_import = pd.read_csv(uploaded_file, encoding='utf-8-sig', sep=';', skiprows=1) data_import.columns = data_import.columns.str.strip() normalized_cols = {c.lower(): c for c in data_import.columns} col_tanggal = normalized_cols.get('tanggal') col_suhu = normalized_cols.get('bodi tengah (°c)') or normalized_cols.get('suhu') col_front = normalized_cols.get('cone depan (°c)') col_back = normalized_cols.get('cone belakang (°c)') col_tebal = normalized_cols.get('ketebalan bta (mm)') col_lot = normalized_cols.get('lot number') if not col_tanggal or not col_suhu: try: uploaded_file.seek(0) data_import = pd.read_csv(uploaded_file, encoding='utf-8-sig') data_import.columns = data_import.columns.str.strip() normalized_cols = {c.lower(): c for c in data_import.columns} col_tanggal = normalized_cols.get('tanggal') col_suhu = normalized_cols.get('bodi tengah (°c)') or normalized_cols.get('suhu') col_front = normalized_cols.get('cone depan (°c)') col_back = normalized_cols.get('cone belakang (°c)') col_tebal = normalized_cols.get('ketebalan bta (mm)') col_lot = normalized_cols.get('lot number') except Exception: pass if not col_tanggal or not col_suhu: st.error("Kolom 'Tanggal' dan 'Bodi Tengah (°C)' wajib ada di dalam file CSV.") else: st.info("Pratinjau Data (Semua Kolom):") st.dataframe(data_import.head(3), use_container_width=True) if st.button("Konfirmasi & Simpan CSV", type="primary", key="int_confirm_btn"): count = 0 for index, row in data_import.dropna(subset=[col_tanggal, col_suhu]).iterrows(): log_date = str(row[col_tanggal]).strip() try: raw_temp = float(row[col_suhu]) except ValueError: continue cone_front = float(row[col_front]) if col_front and pd.notna(row[col_front]) else None cone_back = float(row[col_back]) if col_back and pd.notna(row[col_back]) else None thickness = float(row[col_tebal]) if col_tebal and pd.notna(row[col_tebal]) else None lot_number = str(row[col_lot]).strip() if col_lot and pd.notna(row[col_lot]) else None db.execute(""" INSERT INTO daily_logs (cycle_id, log_date, raw_temp, cone_front, cone_back, thickness, lot_number) VALUES (?, ?, ?, ?, ?, ?, ?) """, (cycle_id, log_date, raw_temp, cone_front, cone_back, thickness, lot_number)) count += 1 db.commit() st.success(f"Berhasil mengimpor {count} baris data historis secara bulk!") st.rerun() except Exception as e: st.error(f"Gagal memproses file CSV: {e}") # ============================================================ # TAB 2: SIKLUS BTA & MANAJEMEN DATA (Administratif) # ============================================================ with tab_cycles: st.markdown('Gunakan menu ini untuk menutup siklus operasi saat ini dan meluncurkan BTA furnace lining baru, atau melihat daftar siklus historis dan log data mentah.
', unsafe_allow_html=True) col_c1, col_c2 = st.columns(2) # Column 1: Reset / Pasang BTA baru with col_c1: with st.container(border=True): st.markdown('Saat ini Anda berada di dalam siklus aktif yang dimulai pada {start_date_str} dengan ketebalan awal BTA sebesar {initial_thickness:.1f} mm.
', unsafe_allow_html=True) st.markdown('Menutup siklus aktif akan mengarsipkan seluruh riwayat log harian saat ini, dan membuka form registrasi untuk mendata lining furnace BTA baru.
', unsafe_allow_html=True) if st.button("Akhiri Siklus & Pasang BTA Baru"): db.execute("UPDATE cycles SET active = 0 WHERE active = 1") db.commit() st.success("Siklus aktif berhasil diarsipkan.") st.rerun() # Column 2: Log Tabel Mentah Database with col_c2: with st.container(border=True): st.markdown('