BTA_Prediction / src /streamlit_app.py
Rendhaputra's picture
Update src/streamlit_app.py
a1d6e80 verified
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"
@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'<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 &copy; 2026
</div>
""", unsafe_allow_html=True)