Spaces:
Running
Running
| 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(""" | |
| <style> | |
| /* Google Fonts */ | |
| @import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@600;700;800&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); | |
| /* Root Variables - Replicate Color System */ | |
| :root { | |
| --colors-canvas: #f9f7f3; /* Warm Cream Canvas */ | |
| --colors-surface-bone: #f3f0e8; /* Half-step deeper cream */ | |
| --colors-surface-card: #ffffff; /* Pure White */ | |
| --colors-surface-dark: #202020; /* Surface Dark / Ink */ | |
| --colors-primary: #ea2804; /* Replicate Brand Orange */ | |
| --colors-primary-deep: #c01f00; /* Orange Pressed */ | |
| --colors-badge-success: #2b9a66; /* Semantic Green */ | |
| --colors-hairline: rgba(32,32,32,0.12); /* 1px low-contrast dividers */ | |
| --colors-hairline-strong: #202020; | |
| --text-ink: #202020; | |
| --text-body: #3a3a3a; | |
| --text-charcoal: #575757; | |
| --text-mute: #646464; | |
| --text-on-dark: #fcfcfc; | |
| --text-on-dark-mute: rgba(252,252,252,0.72); | |
| } | |
| /* Global App Styles */ | |
| .stApp { | |
| background-color: var(--colors-canvas) !important; | |
| color: var(--text-ink) !important; | |
| font-family: 'Inter', sans-serif !important; | |
| } | |
| /* Hide Streamlit Default UI Elements */ | |
| #MainMenu {visibility: hidden;} | |
| footer {visibility: hidden;} | |
| header {visibility: hidden;} | |
| /* Remove Default Sidebar Padding since we are NOT using it */ | |
| section[data-testid="stSidebar"] { | |
| display: none !important; | |
| } | |
| .main .block-container { | |
| padding-top: 48px !important; | |
| padding-bottom: 60px !important; | |
| max-width: 1280px !important; /* Max content width */ | |
| margin: 0 auto; | |
| } | |
| /* Hero Block styling */ | |
| .hero-container { | |
| margin-bottom: 32px; | |
| position: relative; | |
| } | |
| .orange-stamp { | |
| display: inline-flex; | |
| align-items: center; | |
| background-color: var(--colors-primary); | |
| color: var(--colors-surface-card); | |
| font-family: 'Bricolage Grotesque', sans-serif; | |
| font-size: 11px; | |
| font-weight: 800; | |
| padding: 4px 12px; | |
| border-radius: 9999px; /* rounded.full */ | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| margin-bottom: 16px; | |
| } | |
| .display-title { | |
| font-family: 'Bricolage Grotesque', sans-serif; | |
| font-size: 64px; | |
| font-weight: 800; | |
| line-height: 0.95; | |
| letter-spacing: -2.5px; | |
| color: var(--text-ink); | |
| margin: 0 0 12px 0; | |
| } | |
| .display-subtitle { | |
| font-family: 'Inter', sans-serif; | |
| font-size: 17px; | |
| font-weight: 400; | |
| color: var(--text-charcoal); | |
| line-height: 1.5; | |
| max-width: 720px; | |
| margin: 0 0 24px 0; | |
| } | |
| /* Streamlit Tabs Customization to Match Replicate Sub-nav-pills */ | |
| .stTabs [data-baseweb="tab-list"] { | |
| gap: 8px; | |
| border-bottom: 1px solid var(--colors-hairline) !important; | |
| padding-bottom: 12px; | |
| margin-bottom: 32px; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| background-color: var(--colors-surface-bone) !important; | |
| color: var(--text-charcoal) !important; | |
| font-family: 'Inter', sans-serif; | |
| font-weight: 600 !important; | |
| font-size: 14px !important; | |
| padding: 8px 22px !important; | |
| border-radius: 9999px !important; /* rounded.full */ | |
| border: 1px solid rgba(32,32,32,0.06) !important; | |
| transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); | |
| height: auto !important; | |
| } | |
| .stTabs [data-baseweb="tab"]:hover { | |
| background-color: var(--colors-surface-card) !important; | |
| color: var(--text-ink) !important; | |
| border-color: var(--colors-hairline-strong) !important; | |
| } | |
| .stTabs [aria-selected="true"] { | |
| background-color: var(--colors-surface-dark) !important; /* Surface Dark */ | |
| border-color: var(--colors-surface-dark) !important; | |
| } | |
| .stTabs [aria-selected="true"], | |
| .stTabs [aria-selected="true"] p, | |
| .stTabs [aria-selected="true"] span, | |
| .stTabs [aria-selected="true"] div, | |
| .stTabs [aria-selected="true"] * { | |
| color: var(--text-on-dark) !important; | |
| font-weight: 600 !important; | |
| } | |
| /* Tab active indicator bar removal */ | |
| .stTabs [data-baseweb="tab-highlight-indicator"] { | |
| display: none !important; | |
| } | |
| /* Replicate Model Cards / KPI Grid */ | |
| .rep-kpi-container { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 16px; | |
| margin-bottom: 24px; | |
| } | |
| .rep-kpi-card { | |
| background-color: var(--colors-surface-card); /* Surface Card */ | |
| border: 1px solid var(--colors-hairline); /* Hairline */ | |
| border-radius: 10px; /* rounded.md */ | |
| padding: 20px; | |
| transition: all 0.2s ease-in-out; | |
| position: relative; | |
| } | |
| .rep-kpi-card:hover { | |
| border-color: var(--colors-hairline-strong); | |
| transform: translateY(-2px); | |
| } | |
| .rep-kpi-label { | |
| font-family: 'Inter', sans-serif; | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: var(--text-mute); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| margin-bottom: 8px; | |
| } | |
| .rep-kpi-value { | |
| font-family: 'Bricolage Grotesque', sans-serif; | |
| font-size: 32px; | |
| font-weight: 700; | |
| color: var(--text-ink); | |
| line-height: 1; | |
| } | |
| .rep-kpi-value-mono { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 28px; | |
| font-weight: 600; | |
| color: var(--text-ink); | |
| line-height: 1; | |
| } | |
| .rep-kpi-unit { | |
| font-family: 'Inter', sans-serif; | |
| font-size: 13px; | |
| font-weight: 400; | |
| color: var(--text-charcoal); | |
| } | |
| /* Status Badge System */ | |
| .rep-status-container { | |
| margin-bottom: 24px; | |
| } | |
| .rep-status-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 16px; | |
| border-radius: 9999px; /* rounded.full */ | |
| font-family: 'Inter', sans-serif; | |
| font-size: 13px; | |
| font-weight: 600; | |
| } | |
| .rep-status-safe { | |
| background-color: rgba(43, 154, 102, 0.1); | |
| color: var(--colors-badge-success); | |
| border: 1px solid rgba(43, 154, 102, 0.2); | |
| } | |
| .rep-status-warning { | |
| background-color: rgba(234, 40, 4, 0.06); | |
| color: var(--colors-primary); | |
| border: 1px solid rgba(234, 40, 4, 0.12); | |
| } | |
| .rep-status-critical { | |
| background-color: rgba(234, 40, 4, 0.12); | |
| color: var(--colors-primary); | |
| border: 1px solid var(--colors-primary); | |
| animation: pulse-border 1.5s infinite; | |
| } | |
| .rep-pulse { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| } | |
| .rep-pulse-green { background-color: var(--colors-badge-success); } | |
| .rep-pulse-orange { background-color: var(--colors-primary); } | |
| @keyframes pulse-border { | |
| 0%, 100% { transform: scale(1); opacity: 1; } | |
| 50% { transform: scale(0.95); opacity: 0.8; } | |
| } | |
| /* Chart Block (Print-style pull quote container) */ | |
| .rep-chart-well { | |
| background-color: var(--colors-surface-card); | |
| border: 1px solid var(--colors-hairline); | |
| border-radius: 10px; /* rounded.md */ | |
| padding: 24px; | |
| margin-bottom: 28px; | |
| } | |
| /* Section Openers inside tabs */ | |
| .tab-section-header { | |
| font-family: 'Bricolage Grotesque', sans-serif; | |
| font-size: 28px; | |
| font-weight: 700; | |
| letter-spacing: -0.8px; | |
| color: var(--text-ink); | |
| margin-bottom: 8px; | |
| line-height: 1.1; | |
| } | |
| .tab-section-desc { | |
| font-family: 'Inter', sans-serif; | |
| font-size: 14px; | |
| color: var(--text-charcoal); | |
| margin-bottom: 24px; | |
| line-height: 1.5; | |
| } | |
| /* Style Form & Border Containers bawaan Streamlit secara langsung (Native integration) */ | |
| div[data-testid="stForm"], | |
| div[data-testid="stVerticalBlockBorder"] { | |
| background-color: var(--colors-surface-bone) !important; | |
| border: 1px solid rgba(32,32,32,0.06) !important; | |
| border-radius: 10px !important; | |
| padding: 24px !important; | |
| box-shadow: none !important; | |
| margin-bottom: 16px !important; | |
| } | |
| /* Style Streamlit Inputs to align with Replicate Form System and prevent dark corner leak */ | |
| div[data-baseweb="input"] { | |
| border-radius: 9999px !important; /* rounded.full */ | |
| border: 1px solid var(--colors-hairline) !important; | |
| background-color: var(--colors-surface-card) !important; | |
| padding: 0px 10px !important; | |
| transition: border-color 0.2s ease-in-out; | |
| } | |
| div[data-baseweb="input"]:focus-within { | |
| border-color: var(--colors-hairline-strong) !important; | |
| box-shadow: 0 0 0 2px rgba(32,32,32,0.06) !important; | |
| } | |
| /* Force BaseWeb inner wrapper to be transparent to prevent dark background leak on number_input/date_input */ | |
| div[data-baseweb="input"] > div { | |
| background-color: transparent !important; | |
| } | |
| /* Remove default inner input border and backgrounds */ | |
| .stTextInput input, | |
| .stNumberInput input, | |
| .stDateInput input { | |
| border: none !important; | |
| background-color: transparent !important; | |
| box-shadow: none !important; | |
| border-radius: 9999px !important; | |
| color: var(--text-ink) !important; | |
| font-family: 'Inter', sans-serif !important; | |
| height: 40px !important; | |
| padding: 8px 12px !important; | |
| } | |
| /* Style Selectbox to match rounded.full input */ | |
| div[data-baseweb="select"] { | |
| border-radius: 9999px !important; | |
| border: 1px solid var(--colors-hairline) !important; | |
| background-color: var(--colors-surface-card) !important; | |
| } | |
| div[data-baseweb="select"] div[role="button"] { | |
| border-radius: 9999px !important; | |
| border: none !important; | |
| background-color: transparent !important; | |
| height: 40px !important; | |
| color: var(--text-ink) !important; | |
| } | |
| /* Override number input +/- buttons to light mode design */ | |
| .stNumberInput button, | |
| div[data-testid="stNumberInputStepDown"], | |
| div[data-testid="stNumberInputStepUp"], | |
| div[data-testid="stNumberInputStepDown"] *, | |
| div[data-testid="stNumberInputStepUp"] * { | |
| background-color: var(--colors-surface-bone) !important; | |
| color: var(--text-ink) !important; | |
| border-color: rgba(32,32,32,0.12) !important; | |
| } | |
| .stNumberInput button:hover { | |
| background-color: var(--colors-surface-card) !important; | |
| color: var(--colors-primary) !important; | |
| } | |
| /* Custom outline and solid buttons */ | |
| div.stButton > button { | |
| background-color: var(--colors-surface-card) !important; | |
| color: var(--text-ink) !important; | |
| border: 1px solid var(--colors-hairline-strong) !important; | |
| border-radius: 9999px !important; /* rounded.full */ | |
| font-family: 'Inter', sans-serif !important; | |
| font-weight: 600 !important; | |
| padding: 10px 28px !important; | |
| font-size: 15px !important; | |
| height: 44px !important; | |
| transition: all 0.2s ease-in-out !important; | |
| box-shadow: none !important; | |
| } | |
| div.stButton > button:hover { | |
| background-color: var(--colors-surface-bone) !important; | |
| border-color: var(--colors-hairline-strong) !important; | |
| } | |
| /* Primary Accent Button Style (Replicate Stamp Orange) */ | |
| div.stButton > button[kind="primary"] { | |
| background-color: var(--colors-primary) !important; | |
| color: #ffffff !important; | |
| border: 1px solid var(--colors-primary) !important; | |
| } | |
| div.stButton > button[kind="primary"]:hover { | |
| background-color: var(--colors-primary-deep) !important; | |
| border-color: var(--colors-primary-deep) !important; | |
| } | |
| /* File Uploader styling - White drop area with hairline strong border */ | |
| div[data-testid="stFileUploader"] > section { | |
| background-color: var(--colors-surface-card) !important; | |
| border: 1px dashed var(--colors-hairline-strong) !important; | |
| border-radius: 10px !important; | |
| padding: 24px !important; | |
| } | |
| /* File Uploader inner buttons color fixing (preventing dark mode overlay) */ | |
| [data-testid="stFileUploader"] button, | |
| [data-testid="stFileUploader"] [role="button"] { | |
| background-color: var(--colors-surface-bone) !important; | |
| color: var(--text-ink) !important; | |
| border: 1px solid var(--colors-hairline-strong) !important; | |
| border-radius: 9999px !important; | |
| padding: 8px 20px !important; | |
| box-shadow: none !important; | |
| } | |
| [data-testid="stFileUploader"] button *, | |
| [data-testid="stFileUploader"] [role="button"] * { | |
| background-color: transparent !important; | |
| color: var(--text-ink) !important; | |
| } | |
| [data-testid="stFileUploader"] button:hover, | |
| [data-testid="stFileUploader"] [role="button"]:hover { | |
| background-color: var(--colors-surface-card) !important; | |
| border-color: var(--colors-primary) !important; | |
| } | |
| /* Footer styling */ | |
| .rep-footer { | |
| border-top: 1px solid var(--colors-hairline); | |
| padding-top: 24px; | |
| margin-top: 48px; | |
| text-align: center; | |
| font-family: 'Inter', sans-serif; | |
| font-size: 12px; | |
| color: var(--text-mute); | |
| } | |
| .rep-footer a { | |
| color: var(--colors-primary); | |
| text-decoration: none; | |
| font-weight: 500; | |
| } | |
| .rep-footer a:hover { | |
| text-decoration: underline; | |
| } | |
| /* Highlight badge label */ | |
| .rep-badge { | |
| display: inline-block; | |
| background-color: var(--colors-surface-bone); | |
| color: var(--text-charcoal); | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 11px; | |
| font-weight: 500; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| border: 1px solid rgba(32,32,32,0.06); | |
| } | |
| /* Force global light theme text contrast to override Streamlit dark mode fallbacks */ | |
| div[data-testid="stWidgetLabel"] p, | |
| label[data-testid="stWidgetLabel"], | |
| label[data-testid="stWidgetLabel"] p, | |
| div[data-testid="stMetricLabel"], | |
| div[data-testid="stMetricLabel"] p, | |
| div[data-testid="stMetricValue"], | |
| div[data-testid="stMetricValue"] div, | |
| div[data-testid="stMetricDelta"] div, | |
| div[data-testid="stFileUploaderText"], | |
| div[data-testid="stFileUploaderText"] small, | |
| div[data-testid="stFileUploaderText"] span, | |
| div[data-testid="stFileUploaderText"] p, | |
| [data-testid="stFileUploader"] label, | |
| .stMarkdown p, | |
| .stMarkdown li, | |
| .stMarkdown span, | |
| .stMarkdown h1, | |
| .stMarkdown h2, | |
| .stMarkdown h3, | |
| .stMarkdown h4, | |
| .stMarkdown h5, | |
| .stMarkdown h6, | |
| .stExpander summary, | |
| .stExpander summary p, | |
| .stExpander p, | |
| div[data-testid="stExpander"] p, | |
| div[data-testid="stExpander"] summary, | |
| div[data-testid="stExpander"] summary p, | |
| .stDataFrame td, | |
| .stDataFrame th, | |
| .stDataFrame div, | |
| div[data-testid="stTable"] td, | |
| div[data-testid="stTable"] th, | |
| div[data-testid="stNotification"] p, | |
| div[data-testid="stNotification"] span, | |
| div[data-testid="stHelpText"] p, | |
| div[data-testid="stForm"] label, | |
| .stForm p, | |
| .stForm span, | |
| .stTabs p, | |
| .stTabs span { | |
| color: #202020 !important; | |
| } | |
| /* Keep explicit dark backgrounds legible and prevent active/hover states from darkening text */ | |
| div.stButton > button[kind="primary"], | |
| div.stButton > button[kind="primary"]:hover, | |
| div.stButton > button[kind="primary"]:active, | |
| div.stButton > button[kind="primary"]:focus, | |
| div.stButton > button[kind="primary"] p, | |
| div.stButton > button[kind="primary"] span, | |
| div.stButton > button[kind="primary"] div, | |
| div.stButton > button[kind="primary"] * { | |
| color: #ffffff !important; | |
| } | |
| /* Force default outline button to always have dark legible text */ | |
| div.stButton > button, | |
| div.stButton > button:hover, | |
| div.stButton > button:active, | |
| div.stButton > button:focus, | |
| div.stButton > button p, | |
| div.stButton > button span, | |
| div.stButton > button div, | |
| div.stButton > button * { | |
| color: #202020 !important; | |
| } | |
| .rep-status-pill.rep-status-safe, | |
| .rep-status-pill.rep-status-safe span { | |
| color: var(--colors-badge-success) !important; | |
| } | |
| .rep-status-pill.rep-status-warning, | |
| .rep-status-pill.rep-status-warning span, | |
| .rep-status-pill.rep-status-critical, | |
| .rep-status-pill.rep-status-critical span { | |
| color: var(--colors-primary) !important; | |
| } | |
| </style> | |
| """, 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" | |
| 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 | |
| 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'<div class="rep-status-pill rep-status-critical"><span class="rep-pulse rep-pulse-orange"></span> CRITICAL ({model_name}) — Sisa Ketebalan {pred_mm:.1f} mm. Segera lakukan perbaikan!</div>' | |
| elif pred_mm < batas_warning: | |
| return f'<div class="rep-status-pill rep-status-warning"><span class="rep-pulse rep-pulse-orange"></span> WARNING ({model_name}) — Sisa Ketebalan {pred_mm:.1f} mm. Siapkan jadwal pemeliharaan.</div>' | |
| else: | |
| return f'<div class="rep-status-pill rep-status-safe"><span class="rep-pulse rep-pulse-green"></span> AMAN ({model_name}) — Sisa Ketebalan {pred_mm:.1f} mm. Furnace dalam kondisi prima.</div>' | |
| # ============================================================ | |
| # HERO HEADER (Estetika Editorial Replicate) | |
| # ============================================================ | |
| st.markdown(""" | |
| <div class="hero-container"> | |
| <div class="orange-stamp">PREDICTIVE ANALYTICS ENGINE</div> | |
| <h1 class="display-title">BTA Smart Monitor.</h1> | |
| </div> | |
| """, 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('<div class="tab-section-header">Mulai Siklus Pengukuran BTA Baru</div>', unsafe_allow_html=True) | |
| st.markdown('<p class="tab-section-desc">Sistem mendeteksi belum ada siklus pemantauan yang aktif. Daftarkan tanggal pemasangan BTA baru beserta ketebalan awal untuk memulai.</p>', 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('<div style="font-size: 18px; font-weight: 700; margin-bottom: 16px; font-family: \'Bricolage Grotesque\'; color: #202020;">Entri Log Harian Perdana</div>', 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('<div style="height: 8px;"></div>', 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('<div style="height: 8px;"></div>', 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('<div style="font-size: 18px; font-weight: 700; margin-bottom: 8px; font-family: \'Bricolage Grotesque\'; color: #202020;">Bulk Import Data via CSV</div>', unsafe_allow_html=True) | |
| st.markdown('<p style="font-size: 13px; color: var(--text-charcoal); margin-bottom: 16px;">Unggah file CSV dengan kolom utama: <b>Tanggal</b> dan <b>Bodi Tengah (°C)</b> untuk mengimpor seluruh data riwayat operasional secara massal.</p>', 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('<div class="rep-status-container">', unsafe_allow_html=True) | |
| st.markdown(get_status_html(active_pred_mm, model_name=active_model_name), unsafe_allow_html=True) | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| # KPI Grid ala Replicate Model Cards | |
| st.markdown(f""" | |
| <div class="rep-kpi-container"> | |
| <div class="rep-kpi-card"> | |
| <div class="rep-kpi-label">Lot Number</div> | |
| <div class="rep-kpi-value">{lot_display}</div> | |
| </div> | |
| <div class="rep-kpi-card"> | |
| <div class="rep-kpi-label">Suhu Cone Depan</div> | |
| <div class="rep-kpi-value-mono">{front_display}<span class="rep-kpi-unit"> °C</span></div> | |
| </div> | |
| <div class="rep-kpi-card"> | |
| <div class="rep-kpi-label">Suhu Body Tengah</div> | |
| <div class="rep-kpi-value-mono">{mid_display}<span class="rep-kpi-unit"> °C</span></div> | |
| </div> | |
| <div class="rep-kpi-card"> | |
| <div class="rep-kpi-label">Suhu Cone Belakang</div> | |
| <div class="rep-kpi-value-mono">{back_display}<span class="rep-kpi-unit"> °C</span></div> | |
| </div> | |
| <div class="rep-kpi-card"> | |
| <div class="rep-kpi-label">Pengukuran Terakhir</div> | |
| <div class="rep-kpi-value-mono">{last_ukur_thickness:.1f}<span class="rep-kpi-unit"> mm</span></div> | |
| </div> | |
| </div> | |
| """, 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('<div class="rep-chart-well">', 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}<br><b>Aktual: %{y:.1f} mm</b><extra></extra>' | |
| )) | |
| # 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}<br><b>%{y:.1f} mm</b><extra></extra>' | |
| )) | |
| # 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"<b>{val:.1f} mm</b>", | |
| 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}<br>Prediksi Model 1: %{y:.1f} mm<extra></extra>' | |
| )) | |
| # 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}<br>Proyeksi Model 1: %{y:.1f} mm<extra></extra>', | |
| 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}<br>Prediksi Model 2: %{y:.1f} mm<extra></extra>' | |
| )) | |
| # 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}<br>Proyeksi Model 2: %{y:.1f} mm<extra></extra>', | |
| 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="<b>Hari Ini</b>", 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="<b>ACTUAL</b>", 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="<b>PREDICTION</b>", 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('</div>', unsafe_allow_html=True) | |
| # Metadata Info / Stats | |
| metode_badge = "Model 2" if not is_xgboost_selected else "Model 1" | |
| st.markdown(f""" | |
| <div style="display: flex; justify-content: space-between; font-size: 13px; color: var(--text-charcoal); margin-top: -12px; margin-bottom: 24px; padding: 0 12px;"> | |
| <span>Total data historis tercatat: <span class="rep-badge">{len(df_hist)} log harian</span></span> | |
| <span>Metode Wear-rate: <span class="rep-badge">{metode_badge}</span></span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- EXCEL DATA VIEW PANEL (Membentang Penuh) --- | |
| st.markdown('<div style="height: 8px;"></div>', unsafe_allow_html=True) | |
| with st.container(border=True): | |
| st.markdown('<div style="font-size: 18px; font-weight: 700; margin-bottom: 6px; font-family: \'Bricolage Grotesque\'; color: #202020;">Tabel Data Terpasang (Excel View)</div>', unsafe_allow_html=True) | |
| st.markdown('<p style="font-size: 13px; color: var(--text-charcoal); margin-bottom: 16px;">Tabel interaktif di bawah menampilkan seluruh baris data operasional aktif. Anda dapat melakukan pencarian, pengurutan, penyalinan data, atau mengunduhnya mirip dengan spreadsheet Excel.</p>', 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('<div style="height: 24px;"></div>', unsafe_allow_html=True) | |
| # ============================================================ | |
| # SUB-SECTION: INPUT DATA & FILE IMPORT (Terintegrasi Langsung!) | |
| # ============================================================ | |
| st.markdown('<div class="tab-section-header">Pengumpulan Data Pemantauan</div>', unsafe_allow_html=True) | |
| st.markdown('<p class="tab-section-desc">Masukkan parameter harian secara langsung untuk pembaruan instan, atau impor file CSV sekaligus untuk merekam data riwayat operasional secara massal.</p>', 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('<div style="font-size: 18px; font-weight: 700; margin-bottom: 16px; font-family: \'Bricolage Grotesque\'; color: #202020;">Entri Log Harian Manual</div>', 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('<div style="height: 8px;"></div>', 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('<div style="height: 8px;"></div>', 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('<div style="font-size: 12px; color: var(--text-mute); margin-bottom: 18px;">*Jika diisi, model akan menyetel titik ini sebagai dasar (anchor) baru untuk proyeksi selanjutnya.</div>', 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('<div style="font-size: 18px; font-weight: 700; margin-bottom: 8px; font-family: \'Bricolage Grotesque\'; color: #202020;">Bulk Import Data via CSV</div>', unsafe_allow_html=True) | |
| st.markdown('<p style="font-size: 13px; color: var(--text-charcoal); margin-bottom: 16px;">Unggah file CSV dengan kolom utama: <b>Tanggal</b> dan <b>Bodi Tengah (°C)</b>. Kolom pendukung seperti <i>Cone Depan (°C)</i>, <i>Cone Belakang (°C)</i>, <i>Ketebalan BTA (mm)</i>, dan <i>Lot Number</i> akan terdeteksi secara otomatis.</p>', 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('<div class="tab-section-header">Siklus BTA & Manajemen Data</div>', unsafe_allow_html=True) | |
| st.markdown('<p class="tab-section-desc">Gunakan menu ini untuk menutup siklus operasi saat ini dan meluncurkan BTA furnace lining baru, atau melihat daftar siklus historis dan log data mentah.</p>', 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('<div style="font-size: 18px; font-weight: 700; color: var(--colors-primary); margin-bottom: 12px; font-family: \'Bricolage Grotesque\';">Penutupan & Siklus Baru</div>', unsafe_allow_html=True) | |
| st.markdown(f'<p style="font-size: 13px; color: var(--text-charcoal); margin-bottom: 18px;">Saat ini Anda berada di dalam siklus aktif yang dimulai pada <b>{start_date_str}</b> dengan ketebalan awal BTA sebesar <b>{initial_thickness:.1f} mm</b>.</p>', unsafe_allow_html=True) | |
| st.markdown('<p style="font-size: 13px; color: var(--text-charcoal); margin-bottom: 24px;">Menutup siklus aktif akan mengarsipkan seluruh riwayat log harian saat ini, dan membuka form registrasi untuk mendata lining furnace BTA baru.</p>', 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('<div style="font-size: 18px; font-weight: 700; margin-bottom: 12px; font-family: \'Bricolage Grotesque\';">Riwayat Siklus & Log Mentah</div>', 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('<div style="font-size: 13px; font-weight: 600; margin-bottom: 6px;">Daftar Siklus Tersimpan:</div>', 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('<div style="height: 12px;"></div>', 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(""" | |
| <div class="rep-footer"> | |
| BTA Smart Monitor © 2026 | |
| </div> | |
| """, unsafe_allow_html=True) |