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'
CRITICAL ({model_name}) — Sisa Ketebalan {pred_mm:.1f} mm. Segera lakukan perbaikan!
' elif pred_mm < batas_warning: return f'
WARNING ({model_name}) — Sisa Ketebalan {pred_mm:.1f} mm. Siapkan jadwal pemeliharaan.
' else: return f'
AMAN ({model_name}) — Sisa Ketebalan {pred_mm:.1f} mm. Furnace dalam kondisi prima.
' # ============================================================ # HERO HEADER (Estetika Editorial Replicate) # ============================================================ st.markdown("""
PREDICTIVE ANALYTICS ENGINE

BTA Smart Monitor.

""", unsafe_allow_html=True) # --- MODEL SELECTION --- selected_model_type = st.selectbox( "Pilih Model Analisis", options=["Model 1", "Model 2"], index=0, help="Pilih model analitis untuk menghitung keausan dan memproyeksikan sisa umur BTA." ) is_xgboost_selected = (selected_model_type == "Model 1") # --- CHECK SIKLUS AKTIF --- active_cycle = get_active_cycle() if not active_cycle: # --- NO ACTIVE CYCLE WELCOME SCREEN (Premium Color Block) --- with st.container(border=True): st.markdown('
Mulai Siklus Pengukuran BTA Baru
', unsafe_allow_html=True) st.markdown('

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('
Entri Log Harian Perdana
', unsafe_allow_html=True) tgl_skrg = st.date_input("Tanggal Log", datetime.date.today(), key="init_tgl") lot_num = st.text_input("Lot Number", "RE 009", key="init_lot") st.markdown('
', unsafe_allow_html=True) col_sub3, col_sub4, col_sub5 = st.columns(3) with col_sub3: temp_front = st.number_input("Suhu Cone Depan (°C)", 100.0, 600.0, 350.0, key="init_front") with col_sub4: temp_raw = st.number_input("Suhu Body Tengah (°C)", 100.0, 600.0, 350.0, key="init_mid") with col_sub5: temp_back = st.number_input("Suhu Cone Belakang (°C)", 100.0, 600.0, 350.0, key="init_back") st.markdown('
', unsafe_allow_html=True) thick_val = st.number_input("Ketebalan BTA Aktual (mm) — Opsional", 0.0, 300.0, 0.0, key="init_thick") submit_manual = st.form_submit_button("Simpan Data Perdana", type="primary") if submit_manual: thickness_actual = float(thick_val) if thick_val > 0 else None db.execute(""" INSERT INTO daily_logs (cycle_id, log_date, raw_temp, cone_front, cone_back, thickness, lot_number) VALUES (?, ?, ?, ?, ?, ?, ?) """, (cycle_id, tgl_skrg.isoformat(), temp_raw, temp_front, temp_back, thickness_actual, lot_num)) if thickness_actual is not None: db.execute("UPDATE cycles SET initial_thickness = ?, start_date = ? WHERE id = ?", (thickness_actual, tgl_skrg.isoformat(), cycle_id)) db.commit() st.success("Log perdana berhasil disimpan!") st.rerun() with col_bulk: with st.container(border=True): st.markdown('
Bulk Import Data via CSV
', unsafe_allow_html=True) 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('
', unsafe_allow_html=True) st.markdown(get_status_html(active_pred_mm, model_name=active_model_name), unsafe_allow_html=True) st.markdown('
', unsafe_allow_html=True) # KPI Grid ala Replicate Model Cards st.markdown(f"""
Lot Number
{lot_display}
Suhu Cone Depan
{front_display} °C
Suhu Body Tengah
{mid_display} °C
Suhu Cone Belakang
{back_display} °C
Pengukuran Terakhir
{last_ukur_thickness:.1f} mm
""", unsafe_allow_html=True) # Ringkasan Parameter Prediktif & Status Operasional c1, c2, c3 = st.columns(3) c1.metric("Data Log Terakhir", latest_row['log_date'].strftime('%d %b %Y')) c2.metric("Estimasi Ketebalan", f"{active_pred_mm:.1f} mm") c3.metric("Estimasi Sisa Umur", f"{active_sisa_hari} Hari") # --- PLOTLY CHART (Estetika Minimalis & Editorial Replicate) --- st.markdown('
', unsafe_allow_html=True) fig = go.Figure() # Stepwise Ketebalan Aktual df_hist['thickness_step'] = df_hist['thickness'].ffill() if pd.isna(df_hist.loc[0, 'thickness_step']): df_hist.loc[0, 'thickness_step'] = float(initial_thickness) df_hist['thickness_step'] = df_hist['thickness_step'].ffill() fig.add_trace(go.Scatter( x=df_hist['log_date'], y=df_hist['thickness_step'], mode='lines', line=dict(color='#202020', width=3.5, shape='hv'), name='Ketebalan Aktual (Langkah)', hovertemplate='%{x|%d %b %Y}
Aktual: %{y:.1f} mm' )) # Titik Pengukuran Manual (dengan Tag Melayang & Panah Penunjuk Pointer) if not df_ukur.empty: # 1. Tambahkan titik bulat marker Pengukuran Manual (bersih tanpa teks bawaan) fig.add_trace(go.Scatter( x=df_ukur['log_date'], y=df_ukur['thickness'], mode='markers', marker=dict(color='#202020', size=9, line=dict(color='#f9f7f3', width=2), symbol='circle'), name='Pengukuran Manual', showlegend=True, hovertemplate='Pengukuran: %{x|%d %b %Y}
%{y:.1f} mm' )) # 2. Tambahkan Lencana Tag Melayang dengan Panah Pointer bergaya Zig-Zag Vertikal (Bebas Tabrakan) step_interval = max(1, len(df_ukur) // 10) for idx, row in df_ukur.reset_index(drop=True).iterrows(): # Tampilkan tag secara berkala, dan pastikan titik terakhir (terbaru) selalu tampil! if idx % step_interval == 0 or idx == len(df_ukur) - 1: val = float(row['thickness']) date_val = row['log_date'] # Pola zig-zag vertikal & horizontal bergantian untuk menghindari tabrakan antar teks yang berdekatan if idx % 2 == 0: offset_y = -35 # Tinggi sedang offset_x = -12 # Geser kiri sedikit else: offset_y = -70 # Sangat tinggi offset_x = 12 # Geser kanan sedikit fig.add_annotation( x=date_val, y=val, text=f"{val:.1f} mm", showarrow=True, arrowhead=2, arrowsize=1, arrowwidth=1.2, arrowcolor="rgba(32, 32, 32, 0.5)", ax=offset_x, ay=offset_y, font=dict(size=9, color="#202020", family="Inter"), bordercolor="rgba(32, 32, 32, 0.15)", borderwidth=1, borderpad=4, bgcolor="#ffffff", opacity=0.96 ) # Prediksi XGBoost Berkelanjutan pred_values = [] current_anchor = float(initial_thickness) anchor_date = start_date for index, row in df_hist.iterrows(): row_date = row['log_date'].date() if pd.notna(row['thickness']): current_anchor = float(row['thickness']) anchor_date = row_date sub_df = df_hist.iloc[max(0, index-29):index+1] avg_temp = sub_df['raw_temp'].mean() if not sub_df.empty else float(row['raw_temp']) df_inp = pd.DataFrame([[avg_temp]], columns=['suhu_avg_periode']) if model is not None: l_pred = float(model.predict(df_inp)[0]) else: l_pred = -0.1359 l_efektif = 0.4 * l_pred + 0.6 * laju_recent h_sejak = (row_date - anchor_date).days t_pred = current_anchor + (l_efektif * h_sejak) pred_values.append(max(t_pred, 100.0)) df_hist['thickness_pred'] = pred_values # Definisikan rentang waktu proyeksi masa depan (dipakai oleh grafik & label zona) proj_hari = 90 proj_dates = [latest_date + datetime.timedelta(days=i) for i in range(0, proj_hari+1)] if is_xgboost_selected: # Hitung proyeksi masa depan XGBoost proj_thickness = [] for i in range(0, proj_hari+1): t_proj = last_ukur_thickness + (laju_proj_efektif * (hari_sejak_ukur + i)) proj_thickness.append(max(t_proj, 100.0)) # Plot model Wear-rate (XGBoost) fig.add_trace(go.Scatter( x=df_hist['log_date'], y=df_hist['thickness_pred'], mode='lines', line=dict(color='#4A90D9', width=2), name='Model Wear-rate (Model 1)', hovertemplate='%{x|%d %b %Y}
Prediksi Model 1: %{y:.1f} mm' )) # Proyeksi Progresif Masa Depan XGBoost fig.add_trace(go.Scatter( x=proj_dates, y=proj_thickness, mode='lines', line=dict(color='#2b9a66', width=2.5), name=f'Proyeksi Operasi Model 1 ({laju_proj_efektif:.4f} mm/hari)', hovertemplate='%{x|%d %b %Y}
Proyeksi Model 1: %{y:.1f} mm', fill='tozeroy', fillcolor='rgba(43, 154, 102, 0.03)' )) else: # Plot model Prophet if df_forecast_prophet is not None: # Ambil data prophet bertepatan dengan rentang waktu min_date = df_hist['log_date'].min().date() mask_prophet = (df_forecast_prophet['ds'].dt.date >= min_date) & \ (df_forecast_prophet['ds'].dt.date <= latest_date + datetime.timedelta(days=90)) df_prophet_plot = df_forecast_prophet[mask_prophet] # Riwayat Prophet (sampai Hari Ini) df_prophet_past = df_prophet_plot[df_prophet_plot['ds'].dt.date <= latest_date] fig.add_trace(go.Scatter( x=df_prophet_past['ds'], y=df_prophet_past['yhat'], mode='lines', line=dict(color='#ea2804', width=2), name='Model 2 (History)', hovertemplate='%{x|%d %b %Y}
Prediksi Model 2: %{y:.1f} mm' )) # Proyeksi Prophet (dari Hari Ini ke Depan) df_prophet_future = df_prophet_plot[df_prophet_plot['ds'].dt.date >= latest_date] fig.add_trace(go.Scatter( x=df_prophet_future['ds'], y=df_prophet_future['yhat'], mode='lines', line=dict(color='#2b9a66', width=2.5, dash='dash'), name='Proyeksi Operasi Model 2', hovertemplate='%{x|%d %b %Y}
Proyeksi Model 2: %{y:.1f} mm', fill='tozeroy', fillcolor='rgba(43, 154, 102, 0.03)' )) # Shaded uncertainty band fig.add_trace(go.Scatter( x=df_prophet_plot['ds'], y=df_prophet_plot['yhat_upper'], mode='lines', line=dict(width=0), showlegend=False, hoverinfo='skip' )) fig.add_trace(go.Scatter( x=df_prophet_plot['ds'], y=df_prophet_plot['yhat_lower'], mode='lines', line=dict(width=0), showlegend=False, fill='tonexty', fillcolor='rgba(234, 40, 4, 0.07)', hoverinfo='skip' )) # Penanda Garis Vertical "Hari Ini" fig.add_vline(x=latest_date.strftime("%Y-%m-%d"), line_width=1.5, line_color="#202020", line_dash="dash") fig.add_annotation(x=latest_date.strftime("%Y-%m-%d"), y=238, text="Hari Ini", showarrow=False, font=dict(color="#ffffff", size=10, family="Inter"), bgcolor="#ea2804", bordercolor="#ea2804", borderwidth=1, borderpad=5) # Zone Labels mid_past = df_hist['log_date'].iloc[len(df_hist)//2].strftime("%Y-%m-%d") fig.add_annotation(x=mid_past, y=215, text="ACTUAL", showarrow=False, font=dict(color="rgba(32,32,32,0.25)", size=20, family="Bricolage Grotesque")) mid_future = proj_dates[proj_hari//2].strftime("%Y-%m-%d") fig.add_annotation(x=mid_future, y=170, text="PREDICTION", showarrow=False, font=dict(color="rgba(43, 154, 102, 0.18)", size=20, family="Bricolage Grotesque")) # Threshold Limit Lines fig.add_hline(y=115.0, line_width=1.5, line_color="#ea2804", line_dash="dot", annotation_text="Kritis (115 mm)", annotation_position="bottom left", annotation_font=dict(color="#ea2804", size=10)) fig.add_hline(y=130.0, line_width=1.2, line_color="#202020", line_dash="dash", annotation_text="Peringatan (130 mm)", annotation_position="bottom left", annotation_font=dict(color="#202020", size=10)) # Styling Chart Layout Modern fig.update_layout( xaxis=dict( title=None, gridcolor='rgba(32,32,32,0.04)', zeroline=False, showline=False, tickfont=dict(color='#575757', size=11, family="Inter") ), yaxis=dict( title='Ketebalan Furnace Lining (mm)', gridcolor='rgba(32,32,32,0.04)', zeroline=False, showline=False, range=[90, 245], tickfont=dict(color='#575757', size=11, family="Inter"), title_font=dict(color='#575757', size=12, family="Inter") ), paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1, font=dict(size=11, color='#575757', family="Inter"), bgcolor='rgba(0,0,0,0)' ), hovermode="x unified", hoverlabel=dict( bgcolor="#202020", bordercolor="rgba(255,255,255,0.1)", font=dict(family="Inter", size=12, color="#ffffff") ), margin=dict(l=50, r=20, t=20, b=40), height=480 ) st.plotly_chart(fig, use_container_width=True) st.markdown('
', unsafe_allow_html=True) # Metadata Info / Stats metode_badge = "Model 2" if not is_xgboost_selected else "Model 1" st.markdown(f"""
Total data historis tercatat: {len(df_hist)} log harian Metode Wear-rate: {metode_badge}
""", unsafe_allow_html=True) # --- EXCEL DATA VIEW PANEL (Membentang Penuh) --- st.markdown('
', unsafe_allow_html=True) with st.container(border=True): st.markdown('
Tabel Data Terpasang (Excel View)
', unsafe_allow_html=True) 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('
Pengumpulan Data Pemantauan
', unsafe_allow_html=True) 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('
Entri Log Harian Manual
', unsafe_allow_html=True) col_sub1, col_sub2 = st.columns(2) with col_sub1: tgl_skrg = st.date_input("Tanggal Log", datetime.date.today(), key="int_tgl") with col_sub2: lot_num = st.text_input("Lot Number", "RE 009", key="int_lot") st.markdown('
', unsafe_allow_html=True) col_sub3, col_sub4, col_sub5 = st.columns(3) with col_sub3: temp_front = st.number_input("Suhu Cone Depan (°C)", 100.0, 600.0, 350.0, key="int_front") with col_sub4: temp_raw = st.number_input("Suhu Body Tengah (°C)", 100.0, 600.0, 350.0, key="int_mid") with col_sub5: temp_back = st.number_input("Suhu Cone Belakang (°C)", 100.0, 600.0, 350.0, key="int_back") st.markdown('
', unsafe_allow_html=True) thick_val = st.number_input("Ketebalan BTA Aktual (mm) — Opsional", 0.0, 300.0, 0.0, key="int_thick", help="Hanya isi jika dilakukan pengukuran fisik manual pada tanggal bersangkutan.") st.markdown('
*Jika diisi, model akan menyetel titik ini sebagai dasar (anchor) baru untuk proyeksi selanjutnya.
', unsafe_allow_html=True) submit_manual = st.form_submit_button("Simpan & Perbarui Grafik", type="primary") if submit_manual: thickness_actual = float(thick_val) if thick_val > 0 else None db.execute(""" INSERT INTO daily_logs (cycle_id, log_date, raw_temp, cone_front, cone_back, thickness, lot_number) VALUES (?, ?, ?, ?, ?, ?, ?) """, (cycle_id, tgl_skrg.isoformat(), temp_raw, temp_front, temp_back, thickness_actual, lot_num)) if thickness_actual is not None: db.execute("UPDATE cycles SET initial_thickness = ?, start_date = ? WHERE id = ?", (thickness_actual, tgl_skrg.isoformat(), cycle_id)) db.commit() st.success("Log harian berhasil disimpan!") st.rerun() # Column 2: Bulk Import CSV with col_bulk: with st.container(border=True): st.markdown('
Bulk Import Data via CSV
', unsafe_allow_html=True) 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('
Siklus BTA & Manajemen Data
', unsafe_allow_html=True) 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('
Penutupan & Siklus Baru
', unsafe_allow_html=True) st.markdown(f'

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('
Riwayat Siklus & Log Mentah
', unsafe_allow_html=True) # Tampilkan list siklus df_cycles = pd.read_sql_query("SELECT id, start_date, initial_thickness, active FROM cycles ORDER BY id DESC", db) if not df_cycles.empty: st.markdown('
Daftar Siklus Tersimpan:
', unsafe_allow_html=True) df_cycles_styled = df_cycles.copy() df_cycles_styled['active'] = df_cycles_styled['active'].apply(lambda x: "AKTIF" if x == 1 else "ARSIP") st.dataframe(df_cycles_styled, use_container_width=True, height=140) # View data log mentah if has_data: st.markdown('
', unsafe_allow_html=True) with st.expander("Lihat Log Tabel Mentah Harian (10 Baris Terakhir)"): st.dataframe(df_hist.tail(10), use_container_width=True) # ============================================================ # FOOTER (Minimalist Editorial ala Replicate) # ============================================================ st.markdown(""" """, unsafe_allow_html=True)