Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| from datetime import datetime, timedelta | |
| import requests | |
| import json | |
| import numpy as np | |
| import math | |
| import base64 | |
| # =================== CONFIG ===================== | |
| st.set_page_config( | |
| page_title="Advanced Fatigue Anfalytics", | |
| page_icon="🛡️", # Safety icon | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| # =================== LOGO ===================== | |
| logo_path = "btech.png" # File logo | |
| def get_base64(file_path): | |
| with open(file_path, "rb") as f: | |
| data = f.read() | |
| return base64.b64encode(data).decode() | |
| try: | |
| logo_base64 = get_base64(logo_path) | |
| logo_html = f'<img src="data:image/png;base64,{logo_base64}" style="max-height: 80px; max-width: 120px;">' | |
| except FileNotFoundError: | |
| st.warning(f"Logo file '{logo_path}' not found. Using placeholder text.") | |
| logo_html = '<div style="font-size: 18px; font-weight: bold; color: #2c3e50;">BTECH</div>' | |
| # # =================== GLOBAL CSS ===================== | |
| # st.markdown(""" | |
| # <style> | |
| # body { | |
| # background-color: #f6f8fa; | |
| # } | |
| # /* ===== HEADER WRAPPER ===== */ | |
| # .header-container { | |
| # display: flex; | |
| # justify-content: space-between; | |
| # align-items: center; | |
| # padding: 25px 35px; | |
| # background: white; /* Latar belakang utama diubah menjadi putih */ | |
| # border-radius: 0 0 14px 14px; /* Rounded bottom only */ | |
| # box-shadow: 0 5px 18px rgba(0,0,0,0.15); /* Bayangan lebih lembut */ | |
| # border: 1px solid #e0e0e0; /* Border tipis untuk definisi */ | |
| # margin-bottom: 25px; | |
| # position: relative; | |
| # overflow: hidden; /* Ensure rounded corners clip content */ | |
| # } | |
| # /* Optional: Subtle pattern or texture overlay (optional, can be removed) */ | |
| # /* .header-container::before { | |
| # content: ""; | |
| # position: absolute; | |
| # top: 0; | |
| # left: 0; | |
| # right: 0; | |
| # bottom: 0; | |
| # background: linear-gradient(45deg, rgba(255,255,255,0.03) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.03) 50%, rgba(255,255,255,0.03) 75%, transparent 75%, transparent); | |
| # background-size: 20px 20px; | |
| # pointer-events: none; | |
| # } */ | |
| # /* ===== HEADER TEXT ===== */ | |
| # .header-title { | |
| # color: #2c3e50; /* Teks header diubah agar kontras dengan latar putih */ | |
| # font-family: 'Segoe UI', sans-serif; | |
| # flex-grow: 1; /* Allow text to take up available space */ | |
| # margin-right: 20px; /* Space between text and logo */ | |
| # text-align: left; | |
| # } | |
| # .header-title h1 { | |
| # font-size: 2.7em; | |
| # font-weight: 650; | |
| # margin: 0; | |
| # text-shadow: 1px 1px 2px rgba(0,0,0,0.1); /* Bayangan teks lebih lembut */ | |
| # } | |
| # .header-title p { | |
| # font-size: 1.25em; | |
| # opacity: 0.85; /* Sedikit transparan untuk subjudul */ | |
| # margin-top: 6px; | |
| # font-style: italic; | |
| # color: #34495e; /* Warna subjudul disesuaikan */ | |
| # } | |
| # /* ===== LOGO WRAPPER ===== */ | |
| # .header-logo { | |
| # display: flex; | |
| # align-items: center; | |
| # justify-content: flex-end; /* Align logo to the right within its container */ | |
| # flex-shrink: 0; /* Prevent logo container from shrinking */ | |
| # } | |
| # /* ===== LOGO STYLE ===== */ | |
| # .header-logo img { | |
| # border-radius: 10px; | |
| # border: 2px solid rgba(44, 62, 80, 0.15); /* Border logo disesuaikan */ | |
| # box-shadow: 0 3px 10px rgba(0,0,0,0.1); /* Bayangan logo lebih lembut */ | |
| # max-height: 80px; /* Set max height */ | |
| # max-width: 120px; /* Set max width */ | |
| # } | |
| # /* ===== METRIC CARDS ===== */ | |
| # .metric-card { | |
| # background: #ffffff; | |
| # padding: 18px 22px; | |
| # border-radius: 12px; | |
| # border-left: 6px solid #1e3c72; | |
| # box-shadow: 0 3px 8px rgba(0,0,0,0.10); | |
| # transition: 0.25s ease-in-out; | |
| # } | |
| # .metric-card:hover { | |
| # transform: translateY(-4px); | |
| # box-shadow: 0 6px 15px rgba(0,0,0,0.18); | |
| # } | |
| # /* ===== INSIGHT BOX ===== */ | |
| # .insight-box { | |
| # background: #fafafa; | |
| # padding: 18px; | |
| # border-radius: 12px; | |
| # border-left: 6px solid #ff6b6b; | |
| # margin: 15px 0; | |
| # box-shadow: 0 2px 6px rgba(0,0,0,0.08); | |
| # } | |
| # /* ===== RISK MATRIX ===== */ | |
| # .risk-matrix { | |
| # border-collapse: collapse; | |
| # width: 100%; | |
| # margin: 20px 0; | |
| # } | |
| # .risk-matrix th, .risk-matrix td { | |
| # border: 1px solid #ddd; | |
| # padding: 12px; | |
| # text-align: center; | |
| # } | |
| # .risk-matrix th { | |
| # background-color: #f2f2f2; | |
| # } | |
| # .critical { background-color: #ffcccc; font-weight: bold; } | |
| # .high { background-color: #ffebcc; } | |
| # .medium { background-color: #ffffcc; } | |
| # .low { background-color: #e6ffe6; } | |
| # /* ===== CHAT UI ===== */ | |
| # .chat-container { | |
| # background: white; | |
| # padding: 20px; | |
| # border-radius: 12px; | |
| # height: 400px; | |
| # overflow-y: auto; | |
| # border: 1px solid #ccc; | |
| # } | |
| # .user-message { | |
| # background: #e3f2fd; | |
| # color: black; | |
| # padding: 12px; | |
| # border-radius: 12px; | |
| # margin: 10px 0; | |
| # text-align: right; | |
| # border: 1px solid #bbdefb; | |
| # } | |
| # .ai-message { | |
| # background: #f5f5f5; | |
| # color: black; | |
| # padding: 12px; | |
| # border-radius: 12px; | |
| # margin: 10px 0; | |
| # text-align: left; | |
| # border: 1px solid #e0e0e0; | |
| # } | |
| # /* ===== INPUT BOX ===== */ | |
| # .chat-box, .user-question, .ai-answer { | |
| # background: white; | |
| # border: 1px solid #ccc; | |
| # border-radius: 10px; | |
| # padding: 12px; | |
| # margin-bottom: 12px; | |
| # } | |
| # /* ===== FOOTER ===== */ | |
| # .footer { | |
| # text-align: center; | |
| # padding: 20px; | |
| # color: gray; | |
| # font-size: 0.9em; | |
| # } | |
| # /* ===== HOVER EFFECTS ===== */ | |
| # .metric-card:hover, .insight-box:hover { | |
| # box-shadow: 0 6px 15px rgba(0,0,0,0.2); | |
| # transition: all 0.3s ease-in-out; | |
| # } | |
| # </style> | |
| # """, unsafe_allow_html=True) | |
| # # =================== HEADER ===================== | |
| # st.markdown(f""" | |
| # <div class="header-container"> | |
| # <div class="header-title"> | |
| # <h1>Advanced Fatigue Analysis</h1> | |
| # <p>Proactive Safety Intelligence for Mining Operations</p> | |
| # </div> | |
| # <div class="header-logo"> | |
| # {logo_html} | |
| # </div> | |
| # </div> | |
| # """, unsafe_allow_html=True) | |
| # =================== GLOBAL CSS ===================== | |
| st.markdown(""" | |
| <style> | |
| body { | |
| background-color: #f6f8fa; | |
| } | |
| /* ===== HEADER WRAPPER ===== */ | |
| .header-container { | |
| display: flex; | |
| justify-content: space-between; /* Logo di kanan, teks di tengah */ | |
| align-items: center; | |
| padding: 25px 35px; | |
| background: white; | |
| border-radius: 0 0 14px 14px; | |
| box-shadow: 0 5px 18px rgba(0,0,0,0.15); | |
| border: 1px solid #e0e0e0; | |
| margin-bottom: 25px; | |
| position: relative; | |
| } | |
| /* ===== HEADER TEXT ===== */ | |
| .header-title { | |
| flex: 1; /* Mengambil ruang sebanyak mungkin */ | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; /* Center horizontal */ | |
| justify-content: center; /* Center vertical */ | |
| text-align: center; | |
| } | |
| .header-title h1 { | |
| font-size: 2.7em; | |
| font-weight: 650; | |
| margin: 0; | |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.1); | |
| color: #2c3e50; | |
| } | |
| .header-title p { | |
| font-size: 1.25em; | |
| opacity: 0.85; | |
| margin-top: 6px; | |
| font-style: italic; | |
| color: #34495e; | |
| } | |
| /* ===== LOGO WRAPPER ===== */ | |
| .header-logo { | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| flex-shrink: 0; | |
| } | |
| .header-logo img { | |
| border-radius: 10px; | |
| border: 2px solid rgba(44, 62, 80, 0.15); | |
| box-shadow: 0 3px 10px rgba(0,0,0,0.1); | |
| max-height: 80px; | |
| max-width: 120px; | |
| } | |
| /* Metric cards, insight box, dll. tetap sama... */ | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # =================== HEADER ===================== | |
| st.markdown(f""" | |
| <div class="header-container"> | |
| <div class="header-title"> | |
| <h1>Advanced Fatigue Analysis</h1> | |
| <p>Proactive Safety Intelligence for Mining Operations</p> | |
| </div> | |
| <div class="header-logo"> | |
| {logo_html} <!-- Logo tetap di kanan --> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # # ... (Kode selanjutnya disalin dari bagian bawah file Anda, misalnya LOAD DATA ke bawah) | |
| # # =================== LOAD DATA ====================== | |
| def load_data(): | |
| try: | |
| # ================================== | |
| # 1. LOAD CSV & NORMALIZE COLUMNS | |
| # ================================== | |
| df = pd.read_csv("data.csv") | |
| original_columns = df.columns.tolist() | |
| # Normalize: lower, strip, underscore | |
| df.columns = ( | |
| df.columns.astype(str) | |
| .str.strip() | |
| .str.lower() | |
| .str.replace(r"\s+", "_", regex=True) | |
| ) | |
| # ================================== | |
| # 2. AUTO-DETECT COLUMNS (case-insensitive) | |
| # ================================== | |
| col_operator = next((c for c in df.columns if "operator" in c or "driver" in c), None) | |
| col_shift = next((c for c in df.columns if "shift" in c), None) | |
| # ✅ FIX: Search for normalized "parent_fleet", NOT original "Parent Fleet" | |
| col_fleet_type = next((c for c in df.columns if "parent_fleet" in c), None) | |
| col_fleet_no = next((c for c in df.columns if "fleet_number" in c), None) | |
| # ================================== | |
| # 3. DERIVE COLUMNS | |
| # ================================== | |
| # Unit Number | |
| if col_fleet_no: | |
| df["unit_no"] = df[col_fleet_no].astype(str).str.split("-", n=1).str[-1].str.strip() | |
| else: | |
| df["unit_no"] = "UNKNOWN" | |
| # Speed | |
| col_speed = None | |
| for orig in original_columns: | |
| norm = orig.lower().replace(" ", "_") | |
| if "(in_km/hour).1" in norm or "speed" in norm: | |
| if norm in df.columns: | |
| col_speed = norm | |
| break | |
| if not col_speed: | |
| col_speed = next((c for c in df.columns if "speed" in c), None) | |
| # Time | |
| time_cols = [c for c in df.columns if "gmt" in c and "wita" in c] | |
| if len(time_cols) >= 2: | |
| df["start"] = pd.to_datetime(df[time_cols[0]], errors="coerce") | |
| df["end"] = pd.to_datetime(df[time_cols[1]], errors="coerce") | |
| elif len(time_cols) == 1: | |
| df["start"] = pd.to_datetime(df[time_cols[0]], errors="coerce") | |
| df["end"] = df["start"] + pd.Timedelta(minutes=1) | |
| else: | |
| df["start"] = pd.NaT | |
| df["end"] = pd.NaT | |
| # Time features | |
| if not df["start"].isna().all(): | |
| df["hour"] = df["start"].dt.hour | |
| df["date"] = df["start"].dt.date | |
| df["day_of_week"] = df["start"].dt.day_name() | |
| # df["week"], df["month"], df["year"] — optional, not used in filters | |
| else: | |
| df["hour"] = 0 | |
| df["date"] = None | |
| # Shift as int | |
| if col_shift: | |
| df[col_shift] = pd.to_numeric(df[col_shift], errors="coerce").astype("Int64") | |
| # ✅ FIX: CREATE site & group_model HERE (not in sidebar!) | |
| if col_fleet_type: | |
| # Split ONCE on first '-', keep FULL left part (e.g., "Amsterdam - CAT789" → "AMSTERDAM") | |
| split = df[col_fleet_type].astype(str).str.split("-", n=1, expand=True) | |
| df["site"] = split[0].str.strip().str.upper() | |
| df["group_model"] = split[1].str.strip().fillna("UNKNOWN").replace("", "UNKNOWN") | |
| else: | |
| df["site"] = "UNKNOWN" | |
| df["group_model"] = "UNKNOWN" | |
| return df, col_operator, col_shift, col_fleet_type, col_speed, col_fleet_no | |
| except Exception as e: | |
| st.error(f"Error loading data: {e}") | |
| return pd.DataFrame(), None, None, None, None, None | |
| # ================================== | |
| # CALL load_data() | |
| # ================================== | |
| df, col_operator, col_shift, col_fleet_type, col_speed, col_fleet_no = load_data() | |
| df_original_full = df.copy() | |
| if df.empty: | |
| st.stop() | |
| st.success("Data Loaded Successfully") | |
| df_full_report = df.copy() | |
| # =================== FILTERS (Sidebar) ===================== | |
| filter_dict = {} | |
| st.sidebar.markdown( | |
| """ | |
| <div style=" | |
| font-family: 'Segoe UI', sans-serif; | |
| font-size: 1.35em; | |
| font-weight: 600; | |
| color: #2c3e50; | |
| padding: 10px 0 14px 0; | |
| text-align: center; | |
| border-bottom: 2px solid #3498db; | |
| margin-bottom: 16px; | |
| "> | |
| Filter if Need Specific Conditions | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| with st.sidebar.form("filters_form"): | |
| # ---------------- Date Range ---------------- | |
| if 'date' in df.columns and not df['date'].isna().all(): | |
| min_date = pd.to_datetime(df['date']).min().date() | |
| max_date = pd.to_datetime(df['date']).max().date() | |
| date_range = st.date_input("Select Date Range", (min_date, max_date)) | |
| filter_dict['date_range'] = date_range | |
| else: | |
| filter_dict['date_range'] = (None, None) | |
| # ✅ FIXED: Use df['site'] & df['group_model'] (already created in load_data) | |
| # ---------------- Site Filter ---------------- | |
| all_sites = sorted(df['site'].dropna().unique()) | |
| selected_site = st.selectbox( | |
| "Filter Site", | |
| options=[None] + all_sites, | |
| format_func=lambda x: "All" if x is None else x | |
| ) | |
| filter_dict['site'] = selected_site | |
| # ---------------- Group Model Filter ✅ NOW WORKING ---------------- | |
| all_models = sorted(df['group_model'].dropna().unique()) | |
| # Define display names for specific values | |
| display_map = { | |
| "OB HAULLER": "OB HAULER", | |
| "HAULING COAL": "COAL HAULING" | |
| } | |
| # Create display options | |
| display_options = [display_map.get(model, model) for model in all_models] | |
| # Create reverse map to get original value back | |
| reverse_map = {v: k for k, v in display_map.items()} | |
| # Create selectbox with display names | |
| selected_display = st.selectbox( | |
| "Filter Group Model", | |
| options=[None] + display_options, | |
| format_func=lambda x: "All" if x is None else x | |
| ) | |
| # Map back to original value for filtering | |
| selected_model = reverse_map.get(selected_display, selected_display) if selected_display else None | |
| filter_dict['group_model'] = selected_model | |
| # ---------------- Shift ---------------- | |
| if col_shift: | |
| shifts = sorted(df[col_shift].dropna().unique()) | |
| selected_shift = st.selectbox( | |
| f"Select {col_shift.replace('_', ' ').title()}", | |
| options=[None] + shifts, | |
| format_func=lambda x: "All" if x is None else f"Shift {x}" | |
| ) | |
| filter_dict['shift'] = selected_shift | |
| else: | |
| filter_dict['shift'] = None | |
| # ---------------- Operator ---------------- | |
| if col_operator: | |
| ops = sorted(df[col_operator].dropna().unique()) | |
| selected_op = st.selectbox( | |
| f"Select {col_operator.replace('_', ' ').title()}", | |
| options=[None] + ops, | |
| format_func=lambda x: "All" if x is None else x | |
| ) | |
| filter_dict['operator'] = selected_op | |
| else: | |
| filter_dict['operator'] = None | |
| # ---------------- Hour ---------------- | |
| if 'hour' in df.columns and not df['hour'].isna().all(): | |
| hours = sorted(df['hour'].dropna().unique()) | |
| hour_range = st.slider("Select Hour Range", int(min(hours)), int(max(hours)), (int(min(hours)), int(max(hours)))) | |
| filter_dict['hour_range'] = hour_range | |
| else: | |
| filter_dict['hour_range'] = (0, 23) | |
| # ---------------- Unit No ---------------- | |
| if 'unit_no' in df.columns: | |
| units = sorted(df['unit_no'].dropna().unique()) | |
| selected_unit = st.selectbox("Select Unit Number", [None] + units, format_func=lambda x: "All" if x is None else x) | |
| filter_dict['unit_no'] = selected_unit | |
| else: | |
| filter_dict['unit_no'] = None | |
| # ---------------- Submit ---------------- | |
| apply_filters = st.form_submit_button("Apply Filters") | |
| # =================== APPLY FILTERS ===================== | |
| if apply_filters: | |
| # Filter Date Range | |
| if filter_dict.get('date_range'): | |
| start_date, end_date = filter_dict['date_range'] | |
| df = df[(df['date'] >= start_date) & (df['date'] <= end_date)] | |
| # Filter Site | |
| if filter_dict.get('site') is not None: | |
| df = df[df['site'] == filter_dict['site']] | |
| # Filter Group Model | |
| if filter_dict.get('group_model') is not None: | |
| df = df[df['group_model'] == filter_dict['group_model']] | |
| # UI display mapping (for rendering only — data remains unchanged) | |
| group_model_display = { | |
| 'OB HAULLER': 'OB HAULER', | |
| 'HAULING COAL': 'COAL HAULING' | |
| } | |
| # Filter Shift | |
| if filter_dict.get('shift') is not None: | |
| df = df[df[col_shift] == filter_dict['shift']] | |
| # Filter Operator | |
| if filter_dict.get('operator') is not None: | |
| df = df[df[col_operator] == filter_dict['operator']] | |
| # Filter Hour Range | |
| if filter_dict.get('hour_range'): | |
| hr_start, hr_end = filter_dict['hour_range'] | |
| df = df[(df['hour'] >= hr_start) & (df['hour'] <= hr_end)] | |
| # Filter Unit No | |
| if filter_dict.get('unit_no') is not None: | |
| df = df[df[col_fleet_no] == filter_dict['unit_no']] | |
| # Sisanya dari kode Anda (Visualisasi, dll.) tetap sama | |
| # Objective 1 | |
| # ===================== GLOBAL FUNCTION: Hour Category Labels ===================== | |
| def hour_range_label_full(hour): | |
| if not (0 <= hour < 24): | |
| return 'Unknown' | |
| if 6 <= hour < 9: | |
| return 'Shift 1 Morning Early (6-9)' | |
| elif 9 <= hour < 12: | |
| return 'Shift 1 Morning Late (9-12)' | |
| elif 12 <= hour < 15: | |
| return 'Shift 1 Afternoon Early (12-15)' | |
| elif 15 <= hour < 18: | |
| return 'Shift 1 Afternoon Late (15-18)' | |
| elif 18 <= hour < 21: | |
| return 'Shift 2 Evening Early (18-21)' | |
| elif 21 <= hour < 24: | |
| return 'Shift 2 Evening Late (21-24)' | |
| elif 0 <= hour < 3: | |
| return 'Shift 2 Dawn Early (0-3)' | |
| elif 3 <= hour < 6: | |
| return 'Shift 2 Dawn Late (3-6)' | |
| return 'Unknown' | |
| # ===================== MAIN VISUALIZATION ===================== | |
| st.subheader("OBJECTIVE 1: Want to see fatigue patterns across different shifts?") | |
| if 'start' in df.columns and not df.empty: | |
| try: | |
| # --- Data Preparation --- | |
| df_local = df.copy() | |
| if not pd.api.types.is_datetime64_any_dtype(df_local['start']): | |
| df_local['start'] = pd.to_datetime(df_local['start'], errors='coerce') | |
| df_local = df_local.dropna(subset=['start']) | |
| df_local['hour'] = df_local['start'].dt.hour | |
| # --- COLOR MAP: KUNING-ORANGE (Shift 1), BIRU (Shift 2) --- | |
| color_map_full = { | |
| 'Shift 1 Morning Early (6-9)': '#FFEB3B', # Yellow 300 | |
| 'Shift 1 Morning Late (9-12)': '#FFC107', # Amber 300 | |
| 'Shift 1 Afternoon Early (12-15)': '#FF9800', # Orange 300 | |
| 'Shift 1 Afternoon Late (15-18)': '#F57C00', # Deep Orange 300 | |
| 'Shift 2 Evening Early (18-21)': '#42A5F5', # Light Blue 300 | |
| 'Shift 2 Evening Late (21-24)': '#1976D2', # Blue 300 | |
| 'Shift 2 Dawn Early (0-3)': '#0288D1', # Cyan 300 | |
| 'Shift 2 Dawn Late (3-6)': '#01579B', # Blue 800 | |
| } | |
| # --- Define intervals in analog-clock order (12→3→6→9) --- | |
| intervals_shift1 = [(12, 15), (15, 18), (6, 9), (9, 12)] | |
| labels_shift1 = [ | |
| 'Shift 1 Afternoon Early (12-15)', | |
| 'Shift 1 Afternoon Late (15-18)', | |
| 'Shift 1 Morning Early (6-9)', | |
| 'Shift 1 Morning Late (9-12)', | |
| ] | |
| intervals_shift2 = [(0, 3), (3, 6), (18, 21), (21, 24)] | |
| labels_shift2 = [ | |
| 'Shift 2 Dawn Early (0-3)', | |
| 'Shift 2 Dawn Late (3-6)', | |
| 'Shift 2 Evening Early (18-21)', | |
| 'Shift 2 Evening Late (21-24)', | |
| ] | |
| # --- Compute frequencies --- | |
| def compute_counts(intervals): | |
| counts = [] | |
| for start_h, end_h in intervals: | |
| cnt = df_local[(df_local['hour'] >= start_h) & (df_local['hour'] < end_h)].shape[0] | |
| counts.append(cnt) | |
| return counts | |
| freq_shift1 = compute_counts(intervals_shift1) | |
| freq_shift2 = compute_counts(intervals_shift2) | |
| # --- Polar geometry --- | |
| theta_midpoints = [45, 135, 225, 315] # centers of 90° segments | |
| bar_width = [90] * 4 | |
| angular_tick_vals = [0, 90, 180, 270] # fixed angle positions | |
| # ✅ CUSTOM TICK LABELS PER SHIFT (sesuai permintaan Anda) | |
| angular_tick_text_shift1 = ["12", "15", "6/18", "9"] # 0°, 90°, 180°, 270° | |
| angular_tick_text_shift2 = ["24", "3", "18/6", "21"] # 0°=24, 90°=3, 180°=6, 270°=21 | |
| # --- Independent radial scales --- | |
| max_r1 = max(freq_shift1) if freq_shift1 and max(freq_shift1) > 0 else 1 | |
| max_r2 = max(freq_shift2) if freq_shift2 and max(freq_shift2) > 0 else 1 | |
| # ============== FIGURE: SHIFT 1 (KUNING-ORANGE) ============== | |
| fig1 = go.Figure() | |
| fig1.add_trace(go.Barpolar( | |
| r=freq_shift1, | |
| theta=theta_midpoints, | |
| width=bar_width, | |
| marker_color=[color_map_full.get(lbl, '#FFEB3B') for lbl in labels_shift1], | |
| marker_line_color="black", | |
| marker_line_width=1.5, | |
| opacity=0.93, | |
| hovertemplate="<b>%{text}</b><br>Fatigue Incidents: %{r}<extra></extra>", | |
| text=labels_shift1, | |
| )) | |
| fig1.update_layout( | |
| title=dict(text="Shift 1 (06:00–18:00)", font=dict(size=18, color="#FF9800", family="Segoe UI")), | |
| polar=dict( | |
| bgcolor="rgba(255,248,225,0.7)", | |
| angularaxis=dict( | |
| rotation=90, # 12 at top | |
| direction="clockwise", | |
| tickmode='array', | |
| tickvals=angular_tick_vals, | |
| ticktext=angular_tick_text_shift1, # ✅ 12, 15, 6, 9 | |
| tickfont=dict(size=14, color="#5D4037", weight="bold"), | |
| showline=True, | |
| linewidth=1.2, | |
| linecolor="#FFD54F", | |
| ), | |
| radialaxis=dict( | |
| visible=True, | |
| showticklabels=True, | |
| tickfont=dict(size=11), | |
| angle=45, | |
| gridcolor="#FFE082", | |
| gridwidth=0.8, | |
| range=[0, max_r1 * 1.15], | |
| ) | |
| ), | |
| showlegend=False, | |
| height=550, | |
| width=550, | |
| margin=dict(t=65, b=40, l=40, r=40), | |
| font=dict(family="Segoe UI, -apple-system, sans-serif"), | |
| ) | |
| # ============== FIGURE: SHIFT 2 (BIRU) ============== | |
| fig2 = go.Figure() | |
| fig2.add_trace(go.Barpolar( | |
| r=freq_shift2, | |
| theta=theta_midpoints, | |
| width=bar_width, | |
| marker_color=[color_map_full.get(lbl, '#42A5F5') for lbl in labels_shift2], | |
| marker_line_color="black", | |
| marker_line_width=1.5, | |
| opacity=0.93, | |
| hovertemplate="<b>%{text}</b><br>Fatigue Incidents: %{r}<extra></extra>", | |
| text=labels_shift2, | |
| )) | |
| fig2.update_layout( | |
| title=dict(text="Shift 2 (18:00–06:00)", font=dict(size=18, color="#1976D2", family="Segoe UI")), | |
| polar=dict( | |
| bgcolor="rgba(230,245,255,0.7)", | |
| angularaxis=dict( | |
| rotation=90, | |
| direction="clockwise", | |
| tickmode='array', | |
| tickvals=angular_tick_vals, | |
| ticktext=angular_tick_text_shift2, # ✅ 24, 3, 6, 21 | |
| tickfont=dict(size=14, color="#0D47A1", weight="bold"), | |
| showline=True, | |
| linewidth=1.2, | |
| linecolor="#64B5F6", | |
| ), | |
| radialaxis=dict( | |
| visible=True, | |
| showticklabels=True, | |
| tickfont=dict(size=11), | |
| angle=45, | |
| gridcolor="#BBDEFB", | |
| gridwidth=0.8, | |
| range=[0, max_r2 * 1.15], # ✅ SKALA INDEPENDEN | |
| ) | |
| ), | |
| showlegend=False, | |
| height=550, | |
| width=550, | |
| margin=dict(t=65, b=40, l=40, r=40), | |
| font=dict(family="Segoe UI, -apple-system, sans-serif"), | |
| ) | |
| # ============== EXPLANATION — URUTAN KRONOLOGIS, REMARK TETAP ============== | |
| st.markdown(""" | |
| <div style=" | |
| background: linear-gradient(135deg, #FFFDE7 0%, #E3F2FD 100%); | |
| padding: 20px; | |
| border-radius: 12px; | |
| border-left: 5px solid #FF9800; | |
| margin: 22px 0; | |
| box-shadow: 0 3px 10px rgba(0,0,0,0.06); | |
| "> | |
| <h4 style="color:#1976D2; margin:0 0 14px 0; display:flex; align-items:center;"> | |
| <span style="background:#FF9800; color:white; width:26px; height:26px; border-radius:50%; | |
| display:inline-flex; align-items:center; justify-content:center; margin-right:10px; font-weight:bold;">!</span> | |
| ⚠️ Clockwise Time Mapping (Analog Layout) | |
| </h4> | |
| <table style="width:100%; font-size:14px; border-collapse:collapse; color:#424242;"> | |
| <tr style="background-color:#FFF8E1;"> | |
| <th style="padding:8px; text-align:left; width:25%;">Time Block</th> | |
| <th style="padding:8px; text-align:left;">Shift 1 (Day)</th> | |
| <th style="padding:8px; text-align:left;">Shift 2 (Night)</th> | |
| </tr> | |
| <tr> | |
| <td style="padding:8px; font-weight:bold;">1st Block</td> | |
| <td><b>06 → 09</b></td> | |
| <td><b>18 → 21</b></td> | |
| </tr> | |
| <tr style="background-color:#F5F9FF;"> | |
| <td style="padding:8px; font-weight:bold;">2nd Block</td> | |
| <td><b>09 → 12</b></td> | |
| <td><b>21 → 24</b> (Alertness Decline)</td> | |
| </tr> | |
| <tr> | |
| <td style="padding:8px; font-weight:bold;">3rd Block</td> | |
| <td><b>12 → 15</b></td> | |
| <td><b>24 → 03</b> (Circadian Nadir)</td> | |
| </tr> | |
| <tr style="background-color:#F5F9FF;"> | |
| <td style="padding:8px; font-weight:bold;">4th Block</td> | |
| <td><b>15 → 18</b></td> | |
| <td><b>03 → 06</b></td> | |
| </tr> | |
| </table> | |
| <p style="margin-top:12px; font-size:13px; color:#546E7A;"> | |
| <b>Scale is independent per shift</b> — bar length shows relative risk <i>within</i> the shift. | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ============== RENDER CHARTS HORIZONTALLY (NO OVERLAP) ============== | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.plotly_chart(fig1, use_container_width=True, config={'displayModeBar': False}) | |
| with col2: | |
| st.plotly_chart(fig2, use_container_width=True, config={'displayModeBar': False}) | |
| # ============== FOOTNOTE (SEMINAR-READY) ============== | |
| st.caption( | |
| " *Safety Insight*: Highest fatigue risk occurs during **24→06** (Shift 2) — aligns with circadian trough (Czeisler, 1999). " | |
| ) | |
| except Exception as e: | |
| st.error(f"⚠️ Rendering error: {e}") | |
| st.code(f"{type(e).__name__}: {str(e)}", language="python") | |
| else: | |
| st.info("⏳ Awaiting data... Ensure column `'start'` contains valid timestamps (e.g., '2025-06-15 14:30:00').") | |
| #Objective | |
| # | |
| #Objective 1 | |
| st.subheader("OBJECTIVE 2: How does operator energy fluctuate from start to finish of each shift?") | |
| if 'start' in df.columns and not df.empty: | |
| try: | |
| df_local = df.copy() | |
| df_local['hour'] = df_local['start'].dt.hour | |
| df_local['date'] = df_local['start'].dt.normalize() | |
| # Kategorisasi jam menggunakan fungsi global | |
| df_local['hour_category'] = df_local['hour'].apply(hour_range_label_full) | |
| color_map = { | |
| 'Shift 1 Morning Early (6-9)': '#FFEB3B', | |
| 'Shift 1 Morning Late (9-12)': '#FFC107', | |
| 'Shift 1 Afternoon Early (12-15)':'#FF9800', | |
| 'Shift 1 Afternoon Late (15-18)': '#F57C00', | |
| 'Shift 2 Evening Early (18-21)': '#42A5F5', | |
| 'Shift 2 Evening Late (21-24)': '#1976D2', | |
| 'Shift 2 Dawn Early (0-3)': '#0288D1', | |
| 'Shift 2 Dawn Late (3-6)': '#01579B', | |
| 'Unknown': '#E0E0E0' | |
| } | |
| # Hitung jumlah fatigue per hari dan kategori jam | |
| daily_by_cat = df_local.groupby(['date', 'hour_category']).size().reset_index(name='fatigue_count') | |
| # --- TAMBAHAN: Ambil dominant hour_category per hari untuk Objective 3 --- | |
| # Kita gunakan data df_local yang sudah memiliki hour_category | |
| daily_dominant_cat = df_local.groupby('date')['hour_category'].agg( | |
| lambda x: x.value_counts().idxmax() | |
| ).reset_index() | |
| daily_dominant_cat.rename(columns={'hour_category': 'dominant_hour_category'}, inplace=True) | |
| # --- END TAMBAHAN --- | |
| all_dates = pd.date_range(start=daily_by_cat['date'].min(), end=daily_by_cat['date'].max(), freq='D') | |
| all_cats = list(color_map.keys()) | |
| full_index = pd.MultiIndex.from_product([all_dates, all_cats], names=['date', 'hour_category']) | |
| daily_by_cat = daily_by_cat.set_index(['date', 'hour_category']).reindex(full_index, fill_value=0).reset_index() | |
| daily_by_cat['day_of_week_num'] = daily_by_cat['date'].dt.dayofweek | |
| daily_by_cat['week_start'] = daily_by_cat['date'] - pd.to_timedelta(daily_by_cat['day_of_week_num'], unit='d') | |
| daily_by_cat['week_label'] = daily_by_cat['week_start'].dt.strftime('Week %U') | |
| fig = px.bar( | |
| daily_by_cat, | |
| x='date', | |
| y='fatigue_count', | |
| color='hour_category', | |
| title="Daily Fatigue Alerts by Detailed Hour Category", | |
| color_discrete_map=color_map, | |
| labels={'fatigue_count': 'Fatigue Alerts', 'date': 'Date'}, | |
| hover_data={'fatigue_count': True, 'week_label': True} | |
| ) | |
| fig.update_layout( | |
| barmode='stack', | |
| xaxis_title="Date", | |
| yaxis_title="Fatigue Alerts", | |
| height=400, | |
| legend_title="Hour Category" | |
| ) | |
| unique_weeks = daily_by_cat['week_start'].unique() | |
| shapes = [] | |
| week_labels = [] | |
| bg_colors = ['#f0e6ff', '#e6f0ff', '#e6fff0', '#fff0e6', '#ffe6e6', '#f0ffe6', '#e6e6ff'] | |
| for i, week in enumerate(sorted(unique_weeks)): | |
| week_days = daily_by_cat[daily_by_cat['week_start'] == week]['date'] | |
| if len(week_days) > 0: | |
| start_date = week_days.min() | |
| end_date = week_days.max() | |
| shapes.append(dict( | |
| type="rect", | |
| xref="x", | |
| yref="paper", | |
| x0=start_date, | |
| x1=end_date, | |
| y0=0, | |
| y1=1, | |
| fillcolor=bg_colors[i % len(bg_colors)], | |
| opacity=0.2, | |
| layer="below", | |
| line_width=0, | |
| )) | |
| week_labels.append( | |
| dict( | |
| xref='x', | |
| yref='paper', | |
| x=start_date + (end_date - start_date) / 2, | |
| y=1.02, | |
| text=f"Week {week.strftime('%U')}", | |
| showarrow=False, | |
| font=dict(size=10), | |
| xanchor='center', | |
| yanchor='bottom' | |
| ) | |
| ) | |
| fig.update_layout(shapes=shapes, annotations=week_labels) | |
| st.plotly_chart(fig, use_container_width=True) | |
| except Exception as e: | |
| st.error(f"⚠️ Error in Daily Fatigue by Detailed Hour Category: {e}") | |
| else: | |
| st.info("ℹ️ Insufficient time data to display this visualization.") | |
| # =================== OBJECTIVE 3: Daily Roster Insight per Week (Scatter Plot) ===================== | |
| # =================== OBJECTIVE 3: Daily Roster Insight per Week (Scatter Plot) ===================== | |
| st.subheader("OBJECTIVE 3: Looking for patterns in your team’s weekly roster?") | |
| if not df.empty and col_operator in df.columns and col_shift and col_shift in df.columns: | |
| try: | |
| df['date'] = pd.to_datetime(df['date']) | |
| # Hitung total event per hari | |
| daily_totals = df.groupby('date').size().reset_index(name='total_count') | |
| # Ambil dominant shift per hari | |
| dominant_shift = df.groupby('date')[col_shift].agg(lambda x: x.value_counts().idxmax()).reset_index() | |
| dominant_shift.rename(columns={col_shift: 'dominant_shift'}, inplace=True) | |
| daily_analysis = daily_totals.merge(dominant_shift, on='date', how='left') | |
| daily_analysis['week_start'] = daily_analysis['date'] - pd.to_timedelta(daily_analysis['date'].dt.weekday, unit='d') | |
| summary = [] | |
| weekly_groups = daily_analysis.groupby('week_start') | |
| for week_start, week_data in weekly_groups: | |
| # Urutkan data berdasarkan tanggal dalam minggu ini | |
| week_data_sorted = week_data.sort_values('date').reset_index(drop=True) | |
| for idx, row in week_data_sorted.iterrows(): | |
| current_date = row['date'] | |
| current_shift = row['dominant_shift'] | |
| current_count = row['total_count'] | |
| # --- CARI DATA DI HARI SEBELUM DAN SESUDAH (BERDASARKAN TANGGAL, BUKAN INDEKS) --- | |
| prev_date = current_date - pd.Timedelta(days=1) | |
| next_date = current_date + pd.Timedelta(days=1) | |
| # Cari shift di hari sebelumnya | |
| prev_row = week_data_sorted[week_data_sorted['date'] == prev_date] | |
| prev_shift = prev_row['dominant_shift'].iloc[0] if not prev_row.empty else None | |
| # Cari shift di hari berikutnya | |
| next_row = week_data_sorted[week_data_sorted['date'] == next_date] | |
| next_shift = next_row['dominant_shift'].iloc[0] if not next_row.empty else None | |
| # ---- LOGIKA REMARK BERDASARKAN PERUBAHAN SHIFT DALAM MINGGU YANG SAMA---- | |
| # Awal Roster: Ada data di hari sebelumnya (prev_date) dalam minggu, dan shift-nya berbeda | |
| # Akhir Roster: Ada data di hari berikutnya (next_date) dalam minggu, dan shift-nya berbeda | |
| # Bukan Awal/Akhir: Ada data di hari sebelumnya ATAU berikutnya, dan shift-nya sama | |
| # Unknown: Tidak ada data di hari sebelumnya (prev_date) DAN tidak ada data di hari berikutnya (next_date) dalam minggu yang sama | |
| if pd.isna(current_shift): | |
| remark = "Unknown" | |
| elif prev_shift is not None and prev_shift != current_shift: | |
| remark = "Start of Roster" | |
| elif next_shift is not None and next_shift != current_shift: | |
| remark = "End of Roster" | |
| elif (prev_shift is not None and prev_shift == current_shift) or (next_shift is not None and next_shift == current_shift): | |
| remark = "Neither Start nor End of Roster" | |
| elif prev_shift is None and next_shift is None: | |
| remark = "Unknown" | |
| else: | |
| remark = "Unknown" | |
| # --- Operator dari data df (YANG SUDAH DIFILTER) --- | |
| df_orig_for_date = df[df['date']==current_date] # Gunakan df yang difilter | |
| if not df_orig_for_date.empty: | |
| peak_nik_counts = df_orig_for_date[col_operator].value_counts() | |
| peak_nik = peak_nik_counts.index[0] if not peak_nik_counts.empty else "N/A" | |
| else: | |
| peak_nik = "N/A" | |
| summary.append({ | |
| 'week_start': week_start, | |
| 'date': current_date, | |
| 'day_name': current_date.strftime('%A'), | |
| 'total_count': current_count, | |
| 'shift_category': current_shift, | |
| 'remark': remark, | |
| 'operator': peak_nik | |
| }) | |
| summary_df = pd.DataFrame(summary) | |
| if not summary_df.empty: | |
| # Buat color map untuk remark (sesuai permintaan Anda) | |
| color_map_remark = { | |
| 'Start of Roster': '#ffcccc', # Merah muda | |
| 'End of Roster': '#cce5ff', # Biru muda | |
| 'Neither Start nor End of Roster': '#fff2cc', # Kuning muda | |
| 'Unknown': '#c0c0c0' # Abu-abu muda | |
| } | |
| # ===== SCATTER PLOT (WARNA BERDASARKAN remark) ===== | |
| fig = px.scatter( | |
| summary_df, | |
| x='date', | |
| y='remark', | |
| color='remark', # Warna berdasarkan remark (satu-satunya kolom di sumbu Y) | |
| color_discrete_map=color_map_remark, # Gunakan color_map_remark | |
| size='total_count', | |
| hover_data=['shift_category', 'operator', 'total_count'], | |
| title="Daily Roster Status by Date and Trend", | |
| category_orders={'remark': ['Start of Roster', 'End of Roster', 'Neither Start nor End of Roster', 'Unknown']} | |
| ) | |
| fig.update_layout(height=450, xaxis_title="Date", yaxis_title="Roster Status") | |
| st.plotly_chart(fig, use_container_width=True) | |
| # ===== TABEL ===== | |
| table_df = summary_df.rename(columns={ | |
| 'week_start':'Week Start', | |
| 'day_name':'Day', | |
| 'date':'Date', | |
| 'total_count':'Event Count', | |
| 'shift_category':'Dominant Shift', | |
| 'remark':'Roster Status', | |
| 'operator':'Operator' | |
| }) | |
| def highlight_remark(row): | |
| colors = { | |
| 'Start of Roster':'background-color: #ffcccc', | |
| 'End of Roster':'background-color: #cce5ff', | |
| 'Neither Start nor End of Roster':'background-color: #fff2cc', | |
| 'Unknown':'background-color: #c0c0c0' | |
| } | |
| return [colors.get(row['Roster Status'], '') for _ in row] | |
| st.dataframe(table_df.style.apply(highlight_remark, axis=1), use_container_width=True) | |
| else: | |
| st.info("ℹ️ No daily data to analyze.") | |
| except Exception as e: | |
| st.error(f"Error in Daily Roster Insight: {e}") | |
| else: | |
| if col_shift is None: | |
| st.info("ℹ️ Shift column not found, cannot display Daily Roster Insight.") | |
| elif col_shift not in df.columns: | |
| st.info(f"ℹ️ Column '{col_shift}' not found in the filtered data, cannot display Daily Roster Insight.") | |
| else: | |
| st.info("ℹ️ Insufficient data (date, operator, or shift column not found) to display daily roster insight.") | |
| # import plotly.express as px | |
| # from datetime import datetime | |
| st.subheader("OBJECTIVE 4: How is the Fatigue Event Risk Map per Operator?") | |
| import math | |
| import plotly.express as px | |
| try: | |
| # ============================ | |
| # 1. PREPROCESS & COPY DF | |
| # ============================ | |
| df_local = df.copy() | |
| df_local['date_only'] = df_local['start'].dt.normalize() | |
| df_local['week_number'] = df_local['date_only'].dt.isocalendar().week | |
| df_local['week_label'] = "Week " + df_local['week_number'].astype(str) | |
| # Unit cleanup | |
| df_local['unit_no'] = ( | |
| df_local[col_fleet_no] | |
| .astype(str) | |
| .str.split("-", n=1).str[-1].str.strip() | |
| ) | |
| if 'id' not in df_local.columns: | |
| st.error("❌ Column 'id' not found!") | |
| st.stop() | |
| # ============================ | |
| # 2. FILTER 8 MINGGU TERAKHIR | |
| # ============================ | |
| df_local['week_num_int'] = df_local['week_number'].astype(int) | |
| unique_weeks = sorted(df_local['week_num_int'].unique()) | |
| selected_last8 = unique_weeks[-8:] if len(unique_weeks) >= 8 else unique_weeks | |
| df_8w = df_local[df_local['week_num_int'].isin(selected_last8)].copy() | |
| # ===================================== | |
| # 3. FREQUENCY PER OPERATOR PER MINGGU | |
| # ===================================== | |
| weekly_freq = ( | |
| df_8w.groupby([col_operator, 'week_label'])['id'] | |
| .nunique() | |
| .reset_index(name='weekly_frequency') | |
| ) | |
| # ============================================ | |
| # 4. SUMMARY FREQUENCY & CEIL AVERAGE FREQ | |
| # ============================================ | |
| freq_summary = ( | |
| weekly_freq | |
| .groupby(col_operator)['weekly_frequency'] | |
| .agg(['sum', 'mean', 'count']) | |
| .reset_index() | |
| .rename(columns={ | |
| 'sum': 'frequency_by_shift', | |
| 'mean': 'avg_frequency', | |
| 'count': 'frequency_by_weeks' | |
| }) | |
| ) | |
| freq_summary['avg_frequency'] = freq_summary['avg_frequency'].apply(lambda x: math.ceil(x)) | |
| # ================================ | |
| # 5. RATA-RATA SPEED PER OPERATOR | |
| # ================================ | |
| speed_summary = ( | |
| df_8w.groupby(col_operator)[col_speed] | |
| .mean() | |
| .reset_index(name='avg_speed') | |
| ) | |
| # ===================== | |
| # 6. GABUNGKAN DATA | |
| # ===================== | |
| risk_matrix = freq_summary.merge(speed_summary, on=col_operator, how='left') | |
| risk_matrix = risk_matrix.rename(columns={col_operator: "Operator Name"}) | |
| # ================================ | |
| # 7. Tentukan Quadrant untuk Count | |
| # ================================ | |
| def assign_quadrant(row): | |
| if row['avg_frequency'] >= 2.5 and row['avg_speed'] >= 20: | |
| return "Quadrant I – Prevent at Source" | |
| elif row['avg_frequency'] < 2.5 and row['avg_speed'] >= 20: | |
| return "Quadrant II – Detect & Monitor" | |
| elif row['avg_frequency'] >= 2.5 and row['avg_speed'] < 20: | |
| return "Quadrant III – Monitor" | |
| else: | |
| return "Quadrant IV – Low Control" | |
| risk_matrix['quadrant'] = risk_matrix.apply(assign_quadrant, axis=1) | |
| quadrant_count = risk_matrix['quadrant'].value_counts().reindex([ | |
| "Quadrant I – Prevent at Source", | |
| "Quadrant II – Detect & Monitor", | |
| "Quadrant III – Monitor", | |
| "Quadrant IV – Low Control" | |
| ], fill_value=0) | |
| # ================================ | |
| # 8. VISUAL SCATTER PLOT | |
| # ================================ | |
| fig = px.scatter( | |
| risk_matrix, | |
| x='avg_frequency', | |
| y='avg_speed', | |
| hover_name="Operator Name", | |
| title="Operator Risk Matrix: Frequency vs Speed", | |
| size=[12] * len(risk_matrix), | |
| size_max=15 | |
| ) | |
| max_x = risk_matrix['avg_frequency'].max() + 1 | |
| max_y = risk_matrix['avg_speed'].max() + 1 | |
| # ================================ | |
| # 9. Quadrant Coloring | |
| # ================================ | |
| fig.add_shape(type="rect", x0=2.5, x1=max_x, y0=20, y1=max_y, | |
| fillcolor="rgba(255,0,0,0.25)", line_width=0) # I | |
| fig.add_shape(type="rect", x0=0, x1=2.5, y0=20, y1=max_y, | |
| fillcolor="rgba(255,150,50,0.25)", line_width=0) # II | |
| fig.add_shape(type="rect", x0=2.5, x1=max_x, y0=0, y1=20, | |
| fillcolor="rgba(255,200,200,0.25)", line_width=0) # III | |
| fig.add_shape(type="rect", x0=0, x1=2.5, y0=0, y1=20, | |
| fillcolor="rgba(0,120,255,0.15)", line_width=0) # IV | |
| # Garis batas | |
| fig.add_vline(x=2.5, line_dash="dash", line_color="black") | |
| fig.add_hline(y=20, line_dash="dash", line_color="black") | |
| # ================================ | |
| # 10. Tampilkan Count di Quadrant | |
| # ================================ | |
| fig.add_annotation( | |
| x=2.5 + (max_x-2.5)/2, y=20 + (max_y-20)/2, | |
| text=f"<b>{quadrant_count['Quadrant I – Prevent at Source']}</b>", | |
| showarrow=False, font=dict(size=20, color="red") | |
| ) | |
| fig.add_annotation( | |
| x=2.5/2, y=20 + (max_y-20)/2, | |
| text=f"<b>{quadrant_count['Quadrant II – Detect & Monitor']}</b>", | |
| showarrow=False, font=dict(size=20, color="orange") | |
| ) | |
| fig.add_annotation( | |
| x=2.5 + (max_x-2.5)/2, y=0 + (20-0)/2, | |
| text=f"<b>{quadrant_count['Quadrant III – Monitor']}</b>", | |
| showarrow=False, font=dict(size=20, color="darkred") | |
| ) | |
| fig.add_annotation( | |
| x=2.5/2, y=0 + (20-0)/2, | |
| text=f"<b>{quadrant_count['Quadrant IV – Low Control']}</b>", | |
| showarrow=False, font=dict(size=20, color="blue") | |
| ) | |
| # ================================ | |
| # 11. Label Quadrant | |
| # ================================ | |
| fig.add_annotation(x=4, y=max_y-2, text="Quadrant I<br>Prevent at Source", | |
| showarrow=False, font=dict(size=12)) | |
| fig.add_annotation(x=1.25, y=max_y-2, text="Quadrant II<br>Detect & Monitor", | |
| showarrow=False, font=dict(size=12)) | |
| fig.add_annotation(x=4, y=5, text="Quadrant III<br>Monitor", | |
| showarrow=False, font=dict(size=12)) | |
| fig.add_annotation(x=1.25, y=5, text="Quadrant IV<br>Low Control", | |
| showarrow=False, font=dict(size=12)) | |
| fig.update_xaxes(dtick=1) | |
| fig.update_layout( | |
| xaxis_title="Average Frequency (Ceil)", | |
| yaxis_title="Average Speed (km/h)", | |
| height=650 | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # ================================ | |
| # 12. DISPLAY TABLE | |
| # ================================ | |
| st.subheader("Operator Hazard Summary Table (8 Weeks Observed)") | |
| table_display = ( | |
| risk_matrix[[ | |
| "Operator Name", | |
| "frequency_by_shift", | |
| "avg_frequency", | |
| "frequency_by_weeks", | |
| "avg_speed", | |
| "quadrant" | |
| ]] | |
| .rename(columns={ | |
| "frequency_by_shift": "Frequency by Shift", | |
| "avg_frequency": "Avg Frequency", | |
| "frequency_by_weeks": "Frequency by Weeks", | |
| "avg_speed": "Avg Speed", | |
| "quadrant":"Quadrant" | |
| }) | |
| ) | |
| st.dataframe( | |
| table_display.sort_values("Avg Frequency", ascending=False), | |
| use_container_width=True | |
| ) | |
| except Exception as e: | |
| st.error(f"⚠️ Error Risk Map Objective 4: {e}") | |
| st.exception(e) | |
| # st.exception(e) # Uncomment during development | |
| import streamlit as st | |
| import pandas as pd | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| st.subheader("OBJECTIVE 5: See your team’s Fatigue Hazard Profile!") | |
| # Custom CSS — tetap seperti sebelumnya (sudah sesuai preferensi) | |
| st.markdown(""" | |
| <style> | |
| .big-title { | |
| font-size: 28px; | |
| font-weight: bold; | |
| color: #ffffff; | |
| text-align: center; | |
| margin-bottom: 10px; | |
| background: linear-gradient(135deg, #2c3e50, #1a252c); | |
| padding: 15px; | |
| border-radius: 10px; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.3); | |
| } | |
| .subnote { | |
| font-size: 16px; | |
| color: #7f8c8d; | |
| text-align: center; | |
| margin-bottom: 20px; | |
| } | |
| .section-divider { | |
| height: 2px; | |
| background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c); | |
| margin: 20px 0; | |
| } | |
| .legend-container { | |
| display: flex; | |
| gap: 15px; | |
| margin: 15px 0; | |
| } | |
| .legend-box { | |
| background: white; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| padding: 15px; | |
| flex: 1; | |
| min-width: 300px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.05); | |
| } | |
| .legend-title { | |
| font-weight: bold; | |
| color: #2c3e50; | |
| margin-bottom: 10px; | |
| font-size: 14px; | |
| border-bottom: 1px solid #eee; | |
| padding-bottom: 5px; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| margin: 5px 0; | |
| font-size: 12px; | |
| } | |
| .legend-color { | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 3px; | |
| margin-right: 8px; | |
| border: 1px solid #ccc; | |
| } | |
| .ai-insight-box { | |
| background: #f8f9fa; | |
| border: 1px solid #dee2e6; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin: 10px 0; | |
| color: #2c3e50; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
| } | |
| .ai-insight-title { | |
| font-weight: bold; | |
| color: #2c3e50; | |
| margin-bottom: 8px; | |
| font-size: 14px; | |
| background: #e9ecef; | |
| padding: 8px; | |
| border-radius: 5px; | |
| border-left: 4px solid #495057; | |
| } | |
| .trend-up { | |
| color: #e74c3c; | |
| font-weight: bold; | |
| } | |
| .trend-down { | |
| color: #27ae60; | |
| font-weight: bold; | |
| } | |
| .recommendation-box { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| border: 1px solid #4a5568; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin: 10px 0; | |
| color: white; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.1); | |
| } | |
| .recommendation-title { | |
| font-weight: bold; | |
| color: white; | |
| margin-bottom: 8px; | |
| font-size: 14px; | |
| background: rgba(255,255,255,0.2); | |
| padding: 8px; | |
| border-radius: 5px; | |
| border-left: 4px solid white; | |
| } | |
| .recommendation-reason { | |
| font-size: 12px; | |
| margin-top: 10px; | |
| padding: 8px; | |
| background: rgba(255,255,255,0.1); | |
| border-radius: 5px; | |
| border-left: 3px solid rgba(255,255,255,0.3); | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # =============================================================== | |
| # LOGIC UTAMA | |
| # =============================================================== | |
| if df.empty: | |
| st.info("No data available after applying filters.") | |
| else: | |
| try: | |
| required = [col_operator, col_fleet_type, "start"] | |
| if not all(c in df.columns for c in required if c is not None): | |
| st.warning("Required columns (operator, fleet_type, start) are missing.") | |
| st.stop() | |
| df_op = df[[col_operator, col_fleet_type, "start"]].dropna() | |
| if df_op.empty: | |
| st.info("No operator data after filtering.") | |
| st.stop() | |
| if col_operator is None: | |
| st.error("Operator column could not be auto-detected. Please check your data.") | |
| st.stop() | |
| df_op["year_week"] = df_op["start"].dt.strftime("%Y-W%U") | |
| # Fuzzy match fleet names | |
| fleet_clean = df_op[col_fleet_type].str.strip().str.upper() | |
| df_op["is_ob"] = fleet_clean.str.contains(r"OB HAULLER", na=False) | |
| df_op["is_coal"] = fleet_clean.str.contains(r"HAULING COAL", na=False) | |
| ob_data = df_op[df_op["is_ob"]] | |
| coal_data = df_op[df_op["is_coal"]] | |
| def get_top10_with_slope(data): | |
| if data.empty: | |
| return pd.DataFrame() | |
| if col_operator not in data.columns: | |
| st.error(f"Operator column '{col_operator}' not found in data subset.") | |
| return pd.DataFrame() | |
| weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum") | |
| metrics = [] | |
| for nik, grp in weekly.groupby(col_operator): | |
| if pd.isna(nik): | |
| continue | |
| grp = grp.sort_values("year_week") | |
| counts = grp["weekly_sum"].values | |
| weeks = np.arange(len(counts)) | |
| weekly_avg = counts.mean() | |
| total_events = counts.sum() | |
| n_weeks = len(counts) | |
| if n_weeks >= 2: | |
| x_mean = weeks.mean() | |
| y_mean = counts.mean() | |
| numerator = np.sum((weeks - x_mean) * (counts - y_mean)) | |
| denominator = np.sum((weeks - x_mean) ** 2) | |
| slope = numerator / denominator if denominator != 0 else 0.0 | |
| else: | |
| slope = 0.0 # One Time Event | |
| metrics.append({ | |
| col_operator: nik, | |
| "weekly_avg": weekly_avg, | |
| "slope": slope, | |
| "total_events": total_events, | |
| "n_weeks": n_weeks | |
| }) | |
| if not metrics: | |
| return pd.DataFrame() | |
| return pd.DataFrame(metrics).nlargest(10, "weekly_avg") | |
| top_ob = get_top10_with_slope(ob_data) | |
| top_coal = get_top10_with_slope(coal_data) | |
| def get_all_operators_with_slope(data): | |
| if data.empty: | |
| return pd.DataFrame() | |
| if col_operator not in data.columns: | |
| return pd.DataFrame() | |
| weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum") | |
| metrics = [] | |
| for nik, grp in weekly.groupby(col_operator): | |
| if pd.isna(nik): | |
| continue | |
| grp = grp.sort_values("year_week") | |
| counts = grp["weekly_sum"].values | |
| weeks = np.arange(len(counts)) | |
| weekly_avg = counts.mean() | |
| total_events = counts.sum() | |
| n_weeks = len(counts) | |
| if n_weeks >= 2: | |
| slope = np.cov(weeks, counts)[0, 1] / np.var(weeks) if np.var(weeks) != 0 else 0.0 | |
| else: | |
| slope = 0.0 | |
| metrics.append({ | |
| col_operator: nik, | |
| "weekly_avg": weekly_avg, | |
| "slope": slope, | |
| "total_events": total_events, | |
| "n_weeks": n_weeks | |
| }) | |
| return pd.DataFrame(metrics) if metrics else pd.DataFrame() | |
| all_ob = get_all_operators_with_slope(ob_data) | |
| all_coal = get_all_operators_with_slope(coal_data) | |
| # =============================================================== | |
| # LEGEND — UPDATED: Stable → One Time Event, Gray → Yellow | |
| # =============================================================== | |
| st.subheader("Legend of Frequency Trends") | |
| st.markdown(""" | |
| <div class="legend-container"> | |
| <div class="legend-box"> | |
| <div class="legend-title">Worsening Trends (Positive Slope):</div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #d32f2f;"></div> | |
| <span>Very High Worsening (≥1.5)</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #e57373;"></div> | |
| <span>High Worsening (1.0–1.5)</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #ef9a9a;"></div> | |
| <span>Moderate Worsening (0.5–1.0)</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #ffcdd2;"></div> | |
| <span>Slight Worsening (0–0.5)</span> | |
| </div> | |
| <i style="display: block; margin-top: 12px; font-size: 12px; color: #666; font-style: italic;"> | |
| Note: Positive slope indicates increasing fatigue event frequency over weeks. | |
| </i> | |
| </div> | |
| <div class="legend-box"> | |
| <div class="legend-title">Improving Trends (Negative Slope):</div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #388e3c;"></div> | |
| <span>Excellent Improvement (≤−1.5)</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #81c784;"></div> | |
| <span>Great Improvement (−1.5 to −1.0)</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #a5d6a7;"></div> | |
| <span>Good Improvement (−1.0 to −0.5)</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #c8e6c9;"></div> | |
| <span>Slight Improvement (−0.5 to 0)</span> | |
| </div> | |
| <i style="display: block; margin-top: 12px; font-size: 12px; color: #666; font-style: italic;"> | |
| Note: Negative slope reflects a consistent decline in fatigue events. | |
| </i> | |
| </div> | |
| <div class="legend-box"> | |
| <div class="legend-title">One-Time Events (Zero Slope):</div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background-color: #FFD700;"></div> | |
| <span>One Time Event (0)</span> | |
| </div> | |
| <i style="display: block; margin-top: 12px; font-size: 12px; color: #666; font-style: italic;"> | |
| Note: Slope = 0 by definition when data exists for only one week — trend assessment is not applicable. | |
| </i> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ✅ Function definition at correct indentation level | |
| def plot_chart(data, title): | |
| if data.empty: | |
| fig = go.Figure() | |
| fig.add_annotation( | |
| text="No Data", | |
| x=0.5, y=0.5, | |
| showarrow=False, | |
| font_size=16 | |
| ) | |
| fig.update_layout( | |
| height=350, | |
| title=dict(text=title, x=0.5, xanchor='center') # ← Ini wajib | |
| ) | |
| return fig | |
| data_sorted = data.sort_values('weekly_avg', ascending=False) | |
| def get_color(slope): | |
| if slope == 0: | |
| return "#FFD700" # ✅ Yellow for One Time Event | |
| elif slope > 0: | |
| if slope < 0.5: | |
| return "#ffcdd2" | |
| elif slope < 1.0: | |
| return "#ef9a9a" | |
| elif slope < 1.5: | |
| return "#e57373" | |
| else: | |
| return "#d32f2f" | |
| else: # slope < 0 | |
| if slope > -0.5: | |
| return "#c8e6c9" | |
| elif slope > -1.0: | |
| return "#a5d6a7" | |
| elif slope > -1.5: | |
| return "#81c784" | |
| else: | |
| return "#388e3c" | |
| colors = [get_color(s) for s in data_sorted["slope"]] | |
| bar_trace = go.Bar( | |
| x=data_sorted[col_operator].astype(str), | |
| y=data_sorted["weekly_avg"], | |
| marker=dict(color=colors, line=dict(width=2, color="rgba(0,0,0,0.2)")), | |
| text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]], | |
| textposition="outside", | |
| hovertemplate=( | |
| "<b>%{x}</b><br>" + | |
| "Weekly Avg: %{y:.2f}<br>" + | |
| "Trend Slope: %{customdata[0]:+.3f}<br>" + | |
| "Total Events: %{customdata[1]}<br>" + | |
| "Weeks Active: %{customdata[2]}<br>" + | |
| "<extra></extra>" | |
| ), | |
| customdata=np.stack([data_sorted["slope"], data_sorted["total_events"], data_sorted["n_weeks"]], axis=-1) | |
| ) | |
| fig = go.Figure(bar_trace) | |
| fig.update_layout( | |
| title=dict(text=f"<b>{title}</b>", x=0.5, xanchor='center'), # ← Ini wajib | |
| height=450, | |
| margin=dict(l=50, r=20, t=60, b=120), | |
| xaxis_title="<b>Operator Name</b>", | |
| yaxis_title="<b>Weekly Avg Events</b>", | |
| font=dict(family="Segoe UI", size=12), | |
| bargap=0.3, | |
| plot_bgcolor="rgba(0,0,0,0)", | |
| paper_bgcolor="rgba(0,0,0,0)", | |
| xaxis=dict(tickangle=45) | |
| ) | |
| return fig | |
| # =============================================================== | |
| # CHARTS | |
| # =============================================================== | |
| # =============================================================== | |
| # CHARTS | |
| # =============================================================== | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.plotly_chart( | |
| plot_chart(top_ob, "OB HAULER Operator Hazard Profile"), | |
| use_container_width=True, # ← Ini penting! | |
| config={'displayModeBar': False} | |
| ) | |
| with col2: | |
| st.plotly_chart( | |
| plot_chart(top_coal, "COAL HAULING Operator Hazard Profile"), | |
| use_container_width=True, # ← Ini penting! | |
| config={'displayModeBar': False} | |
| ) | |
| # =============================================================== | |
| # AI INSIGHTS — DIPERBAIKI: Risk Summary jadi 1 box + 3 list | |
| # =============================================================== | |
| col_insight1, col_insight2 = st.columns(2) | |
| with col_insight1: | |
| if not top_ob.empty: | |
| st.markdown("### OB HAULER Analysis") | |
| ob_worsening = len(top_ob[top_ob['slope'] > 0]) | |
| ob_improving = len(top_ob[top_ob['slope'] < 0]) | |
| ob_one_time = len(top_ob[top_ob['slope'] == 0]) | |
| ob_avg_risk = top_ob['weekly_avg'].mean() | |
| ob_max_risk = top_ob['weekly_avg'].max() | |
| ob_insights = [] | |
| if ob_worsening > ob_improving: | |
| ob_insights.append(f"{ob_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends.") | |
| else: | |
| ob_insights.append(f"{ob_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>.") | |
| if ob_one_time > 0: | |
| ob_insights.append(f"{ob_one_time} operators are classified as <b>One Time Event</b> (single-week activity).") | |
| else: | |
| ob_insights.append("No operators classified as <b>One Time Event</b>.") | |
| ob_insights.append(f"Average risk: {ob_avg_risk:.2f} events/week (max: {ob_max_risk:.2f}).") | |
| st.markdown(f""" | |
| <div class="ai-insight-box"> | |
| <div class="ai-insight-title">Hazard Summary</div> | |
| <ul style="padding-left: 20px; margin: 8px 0; line-height: 1.5;"> | |
| <li>{ob_insights[0]}</li> | |
| <li>{ob_insights[1]}</li> | |
| <li>{ob_insights[2]}</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No OB HAULER data for analysis.") | |
| with col_insight2: | |
| if not top_coal.empty: | |
| st.markdown("### HAULING COAL Analysis") | |
| coal_worsening = len(top_coal[top_coal['slope'] > 0]) | |
| coal_improving = len(top_coal[top_coal['slope'] < 0]) | |
| coal_one_time = len(top_coal[top_coal['slope'] == 0]) | |
| coal_avg_risk = top_coal['weekly_avg'].mean() | |
| coal_max_risk = top_coal['weekly_avg'].max() | |
| coal_insights = [] | |
| if coal_worsening > coal_improving: | |
| coal_insights.append(f"{coal_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends.") | |
| else: | |
| coal_insights.append(f"{coal_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>.") | |
| if coal_one_time > 0: | |
| coal_insights.append(f"{coal_one_time} operators are classified as <b>One Time Event</b> (single-week activity).") | |
| else: | |
| coal_insights.append("No operators classified as <b>One Time Event</b>.") | |
| coal_insights.append(f"Average risk: {coal_avg_risk:.2f} events/week (max: {coal_max_risk:.2f}).") | |
| st.markdown(f""" | |
| <div class="ai-insight-box"> | |
| <div class="ai-insight-title">Hazard Summary</div> | |
| <ul style="padding-left: 20px; margin: 8px 0; line-height: 1.5;"> | |
| <li>{coal_insights[0]}</li> | |
| <li>{coal_insights[1]}</li> | |
| <li>{coal_insights[2]}</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No HAULING COAL data for analysis.") | |
| # =============================================================== | |
| # RECOMMENDATIONS — DIPERBARUI: 3 list per fleet, sesuai 3 poin Risk Summary | |
| # =============================================================== | |
| col_rec1, col_rec2 = st.columns(2) | |
| with col_rec1: | |
| if not top_ob.empty: | |
| w = len(top_ob[top_ob['slope'] > 0]) | |
| ot = len(top_ob[top_ob['slope'] == 0]) | |
| avg = top_ob['weekly_avg'].mean() | |
| max_risk = top_ob['weekly_avg'].max() | |
| # 3 rekomendasi, paralel dengan 3 poin Risk Summary | |
| rec_list = [] | |
| # 1. Trend-driven action | |
| if w > 5: | |
| rec_list.append("Conduct targeted fatigue risk assessments for operators with worsening trends (slope > 0).") | |
| elif w > 0 and w <= 5: | |
| rec_list.append("Monitor worsening-trend operators weekly and schedule supervisor check-ins.") | |
| elif w == 0 and len(top_ob[top_ob['slope'] < 0]) > 0: | |
| rec_list.append("Recognize improving operators — consider sharing best practices internally.") | |
| else: | |
| rec_list.append("Maintain current monitoring for stable trend profile.") | |
| # 2. One-Time Event follow-up | |
| if ot > 0: | |
| rec_list.append(f"Re-engage {ot} <b>One Time Event</b> operators to verify data completeness and activity status.") | |
| else: | |
| rec_list.append("Trend analysis is reliable — all operators have multi-week activity.") | |
| # 3. Benchmark & sustain | |
| if avg > 8: | |
| rec_list.append("Initiate immediate review of shift scheduling and rest-break compliance.") | |
| elif avg > 5: | |
| rec_list.append("Conduct monthly fatigue KPI review using cohort average as baseline.") | |
| else: | |
| rec_list.append("Sustain current protocols — risk level is within acceptable range.") | |
| # Ensure exactly 3 items | |
| while len(rec_list) < 3: | |
| rec_list.append("—") | |
| st.markdown("### OB HAULER Recommendations") | |
| st.markdown(f""" | |
| <div class="recommendation-box"> | |
| <div class="recommendation-title">Action Plan</div> | |
| <ul style="padding-left: 20px; margin: 8px 0; line-height: 1.5;"> | |
| <li>{rec_list[0]}</li> | |
| <li>{rec_list[1]}</li> | |
| <li>{rec_list[2]}</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No OB HAULER recommendations.") | |
| with col_rec2: | |
| if not top_coal.empty: | |
| w = len(top_coal[top_coal['slope'] > 0]) | |
| ot = len(top_coal[top_coal['slope'] == 0]) | |
| avg = top_coal['weekly_avg'].mean() | |
| max_risk = top_coal['weekly_avg'].max() | |
| rec_list = [] | |
| # 1. Trend-driven action | |
| if w > 5: | |
| rec_list.append("Conduct targeted fatigue risk assessments for operators with worsening trends (slope > 0).") | |
| elif w > 0 and w <= 5: | |
| rec_list.append("Monitor worsening-trend operators weekly and schedule supervisor check-ins.") | |
| elif w == 0 and len(top_coal[top_coal['slope'] < 0]) > 0: | |
| rec_list.append("Recognize improving operators — consider sharing best practices internally.") | |
| else: | |
| rec_list.append("Maintain current monitoring for stable trend profile.") | |
| # 2. One-Time Event follow-up | |
| if ot > 0: | |
| rec_list.append(f"Re-engage {ot} <b>One Time Event</b> operators to verify data completeness and activity status.") | |
| else: | |
| rec_list.append("Trend analysis is reliable — all operators have multi-week activity.") | |
| # 3. Benchmark & sustain | |
| if avg > 8: | |
| rec_list.append("Initiate immediate review of shift scheduling and rest-break compliance.") | |
| elif avg > 5: | |
| rec_list.append("Conduct monthly fatigue KPI review using cohort average as baseline.") | |
| else: | |
| rec_list.append("Sustain current protocols — risk level is within acceptable range.") | |
| while len(rec_list) < 3: | |
| rec_list.append("—") | |
| st.markdown("### HAULING COAL Recommendations") | |
| st.markdown(f""" | |
| <div class="recommendation-box"> | |
| <div class="recommendation-title">Action Plan</div> | |
| <ul style="padding-left: 20px; margin: 8px 0; line-height: 1.5;"> | |
| <li>{rec_list[0]}</li> | |
| <li>{rec_list[1]}</li> | |
| <li>{rec_list[2]}</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No HAULING COAL recommendations.") | |
| except Exception as e: | |
| st.error(f"Error in Top 10 Operator analysis: {str(e)}") | |
| st.exception(e) | |
| # # =================== OBJECTIVE 6: Automated Insights & AI Recommendations ===================== | |
| # st.subheader("OBJECTIVE 6: Instant Insights & Recommendations") | |
| # # Membagi tampilan menjadi dua kolom | |
| # col_insights, col_recs = st.columns(2) | |
| # # ===================================================================== | |
| # # 🔹 KOLOM KIRI — INSIGHTS BY ADVANCED ANALYTICS | |
| # # ===================================================================== | |
| # with col_insights: | |
| # st.subheader("Insights by Advanced Analytics") | |
| # # ===================== 1. Critical Hour Analysis ===================== | |
| # critical_hours = [2, 3, 4, 5] | |
| # critical_alerts = df[df['hour'].isin(critical_hours)] | |
| # critical_pct = (len(critical_alerts) / len(df)) * 100 if len(df) > 0 else 0 | |
| # st.markdown(f"**Critical Hour Risk (3-6 AM)**") | |
| # bg_color = ( | |
| # "#ffcccc" if critical_pct > 50 else | |
| # "#ffebcc" if critical_pct > 25 else | |
| # "#ffffcc" if critical_pct > 10 else | |
| # "#e6ffe6" | |
| # ) | |
| # st.markdown( | |
| # f'<div style="background-color: {bg_color}; padding: 10px; border-radius: 5px;">' | |
| # f'Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}% of total alerts)</div>', | |
| # unsafe_allow_html=True | |
| # ) | |
| # if critical_pct > 10: | |
| # st.warning( | |
| # f"High risk: {critical_pct:.1f}% of fatigue alerts occur during critical hours (3-6 AM). " | |
| # f"This is a known circadian dip period." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"{critical_pct:.1f}% of alerts occur during critical hours. This is within acceptable range." | |
| # ) | |
| # # ===================== 2. High-Speed Fatigue Analysis ===================== | |
| # if col_speed and col_speed in df.columns: | |
| # high_speed_threshold = 20 | |
| # high_speed_fatigue = df[df[col_speed] >= high_speed_threshold] | |
| # high_speed_pct = (len(high_speed_fatigue) / len(df)) * 100 if len(df) > 0 else 0 | |
| # st.markdown(f"**High-Speed Fatigue Risk (Speed > {high_speed_threshold} km/h)**") | |
| # st.markdown( | |
| # f""" | |
| # <div style="font-size: 24px; font-weight: bold;">{len(high_speed_fatigue)}</div> | |
| # <div style="color: red; font-size: 14px; margin-top: -5px;">↑ {high_speed_pct:.1f}% of total alerts</div> | |
| # """, | |
| # unsafe_allow_html=True | |
| # ) | |
| # if high_speed_pct > 20: | |
| # st.warning( | |
| # f"High risk: {high_speed_pct:.1f}% of fatigue alerts occur at high speeds. " | |
| # f"This increases accident severity potential." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"{high_speed_pct:.1f}% of alerts occur at high speeds. This is within acceptable range." | |
| # ) | |
| # else: | |
| # st.info("Speed data not available for High-Speed Fatigue Analysis.") | |
| # # ===================== 3. Shift Pattern Analysis ===================== | |
| # if col_shift and col_shift in df.columns: | |
| # shift_counts = df[col_shift].value_counts() | |
| # st.markdown(f"**Shift Pattern Risk**") | |
| # for shift_val in shift_counts.index: | |
| # shift_pct = (shift_counts[shift_val] / len(df)) * 100 | |
| # st.markdown( | |
| # f""" | |
| # <div style="font-size: 24px; font-weight: bold;">{shift_counts[shift_val]}</div> | |
| # <div style="color: red; font-size: 14px; margin-top: -5px;">↑ {shift_pct:.1f}% of total alerts</div> | |
| # """, | |
| # unsafe_allow_html=True | |
| # ) | |
| # if shift_pct > 50: | |
| # st.warning( | |
| # f"Shift {shift_val} has disproportionately high alerts ({shift_pct:.1f}%). " | |
| # f"Review shift scheduling and workload." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"Shift {shift_val} alert distribution is acceptable ({shift_pct:.1f}%)." | |
| # ) | |
| # else: | |
| # st.info("Shift data not available for Shift Pattern Analysis.") | |
| # # ===================== 4. Operator Risk Profiling ===================== | |
| # if col_operator and col_operator in df.columns: | |
| # operator_alerts = df[col_operator].value_counts() | |
| # top_risk_operators = operator_alerts.head(5) | |
| # st.markdown("**High-Risk Operator Identification**") | |
| # colors = ["#d32f2f", "#e57373", "#ef9a9a", "#ffcdd2", "#ffe1e4"] | |
| # for idx, (op_name, count) in enumerate(top_risk_operators.items()): | |
| # op_pct = (count / len(df)) * 100 | |
| # color = colors[idx] if idx < len(colors) else colors[-1] | |
| # st.markdown( | |
| # f"**Operator:** {op_name} \n**Alerts:** {count}" | |
| # ) | |
| # st.markdown( | |
| # f"<span style='font-weight:600'>Share:</span> " | |
| # f"<span style='color:{color}; font-weight:700'>{op_pct:.1f}% of total alerts</span>", | |
| # unsafe_allow_html=True | |
| # ) | |
| # if op_pct > 5: | |
| # st.warning( | |
| # f"Operator {op_name} has high fatigue risk ({op_pct:.1f}%). " | |
| # f"Consider coaching or rest plan." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"Operator {op_name} fatigue risk is within acceptable range ({op_pct:.1f}%)." | |
| # ) | |
| # else: | |
| # st.info("Operator data not available for Operator Risk Profiling.") | |
| # # ===================================================================== | |
| # # 🔹 KOLOM KANAN — AI RECOMMENDATIONS (PER INSIGHT + PER OPERATOR) | |
| # # ===================================================================== | |
| # with col_recs: | |
| # st.subheader("Recommendations") | |
| # ai_recommendations = [] | |
| # # 1. Critical Hour Insight → AI Rec | |
| # if "hour" in df.columns and not df.empty: | |
| # peak_hour = df["hour"].value_counts().idxmax() | |
| # critical_hours = [2, 3, 4, 5] | |
| # if peak_hour in critical_hours: | |
| # ai_recommendations.append({ | |
| # "action": "Deploy enhanced fatigue monitoring systems during 3-6 AM.", | |
| # "data_point": f"Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}%)", | |
| # "reasoning": "High percentage of alerts during circadian low period." | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "action": "Monitor fatigue patterns around peak hour (Hour {peak_hour}).", | |
| # "data_point": f"Peak Hour: {peak_hour}:00 — {df['hour'].value_counts()[peak_hour]} alerts", | |
| # "reasoning": "This hour shows highest fatigue occurrence." | |
| # }) | |
| # # 2. High-Speed Insight → AI Rec | |
| # if col_speed and col_speed in df.columns and not df.empty: | |
| # high_speed_threshold = 20 | |
| # high_speed_fatigue = df[df[col_speed] >= high_speed_threshold] | |
| # high_speed_pct = (len(high_speed_fatigue) / len(df)) * 100 if len(df) > 0 else 0 | |
| # if high_speed_pct > 20: | |
| # ai_recommendations.append({ | |
| # "action": "Implement speed-reduction protocols during fatigue-prone hours.", | |
| # "data_point": f"High-Speed Alerts: {len(high_speed_fatigue)} ({high_speed_pct:.1f}%)", | |
| # "reasoning": "High-speed alerts increase accident severity potential." | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "action": "Maintain current speed monitoring — risk level is acceptable.", | |
| # "data_point": f"High-Speed Alerts: {len(high_speed_fatigue)} ({high_speed_pct:.1f}%)", | |
| # "reasoning": "Current high-speed fatigue rate is within acceptable range." | |
| # }) | |
| # # 3. Shift Pattern Insight → AI Rec | |
| # if col_shift and col_shift in df.columns and not df.empty: | |
| # worst_shift = df[col_shift].value_counts().idxmax() | |
| # shift_pct = (df[col_shift].value_counts()[worst_shift] / len(df)) * 100 | |
| # if shift_pct > 50: | |
| # ai_recommendations.append({ | |
| # "action": "Review shift rotation schedules for Shift {worst_shift}.", | |
| # "data_point": f"Shift {worst_shift}: {df[col_shift].value_counts()[worst_shift]} alerts ({shift_pct:.1f}%)", | |
| # "reasoning": "Disproportionately high fatigue alerts indicate scheduling imbalance." | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "action": "Continue monitoring all shifts — no dominant risk identified.", | |
| # "data_point": f"Shift {worst_shift}: {df[col_shift].value_counts()[worst_shift]} alerts ({shift_pct:.1f}%)", | |
| # "reasoning": "Shift distribution is balanced." | |
| # }) | |
| # # 4. Operator Risk Profiling → AI Rec for EACH of Top 5 Operators | |
| # if col_operator and col_operator in df.columns and not df.empty: | |
| # top_operators = df[col_operator].value_counts().head(5) | |
| # for op_name, count in top_operators.items(): | |
| # op_pct = (count / len(df)) * 100 | |
| # if op_pct > 5: | |
| # ai_recommendations.append({ | |
| # "action": f"Coaching or mandatory rest for Operator {op_name}.", | |
| # "data_point": f"Operator {op_name}: {count} alerts ({op_pct:.1f}%)", | |
| # "reasoning": f"Operator has high fatigue alerts — requires individual intervention." | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "action": f"Continue general monitoring for Operator {op_name}.", | |
| # "data_point": f"Operator {op_name}: {count} alerts ({op_pct:.1f}%)", | |
| # "reasoning": f"Risk is within acceptable range — no urgent action needed." | |
| # }) | |
| # # Render each recommendation as a card | |
| # for rec in ai_recommendations: | |
| # # Highlight percentages in red | |
| # data_point_colored = rec['data_point'].replace( | |
| # f"({rec['data_point'].split('(')[-1]}", | |
| # f"(<span style='color: red;'>{rec['data_point'].split('(')[-1]}" | |
| # ).replace(")", "</span>)") | |
| # reasoning_colored = rec['reasoning'].replace( | |
| # f"({rec['reasoning'].split('(')[-1]}", | |
| # f"(<span style='color: red;'>{rec['reasoning'].split('(')[-1]}" | |
| # ).replace(")", "</span>)") | |
| # st.markdown( | |
| # f""" | |
| # <div style=" | |
| # background: #f8f9fa; | |
| # border: 1px solid #dee2e6; | |
| # border-radius: 8px; | |
| # padding: 15px; | |
| # margin: 10px 0; | |
| # box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
| # "> | |
| # <div style=" | |
| # font-weight: bold; | |
| # background: #e9ecef; | |
| # padding: 8px; | |
| # border-radius: 5px; | |
| # margin-bottom: 8px; | |
| # border-left: 4px solid #495057; | |
| # "> | |
| # AI Recommendation | |
| # </div> | |
| # <div style="padding: 8px 0;"> | |
| # <strong>Action:</strong> {rec['action']} | |
| # </div> | |
| # <div style=" | |
| # padding: 8px; | |
| # background: #f1f1f1; | |
| # border-radius: 5px; | |
| # margin: 8px 0; | |
| # "> | |
| # <strong>Data Point:</strong> {data_point_colored} | |
| # </div> | |
| # <div style=" | |
| # padding: 8px; | |
| # background: #f1f1f1; | |
| # border-radius: 5px; | |
| # "> | |
| # <strong>AI Reasoning:</strong> {reasoning_colored} | |
| # </div> | |
| # </div> | |
| # """, | |
| # unsafe_allow_html=True | |
| # ) | |
| # if not ai_recommendations: | |
| # st.info( | |
| # "No specific data points available for AI recommendations. " | |
| # "Ensure relevant columns are present (hour, shift, operator, duration, speed)." | |
| # ) | |
| # # ================= FOOTER =========================== | |
| # st.markdown("---") | |
| # st.markdown( | |
| # '<div class="footer">FatigueAnalyzer - Transforming Mining Safety with Intelligent Analytics | Contact: info@bukittechnology.com</div>', | |
| # unsafe_allow_html=True | |
| # ) | |
| # # =================== OBJECTIVE 6: Automated Insights & AI Recommendations ===================== | |
| # st.subheader("OBJECTIVE 6: Instant Insights & Recommendations") | |
| # # Membagi tampilan menjadi dua kolom | |
| # col_insights, col_recs = st.columns(2) | |
| # # ===================================================================== | |
| # # 🔹 KOLOM KIRI — INSIGHTS BY ADVANCED ANALYTICS | |
| # # ===================================================================== | |
| # with col_insights: | |
| # st.subheader("Insights by Advanced Analytics") | |
| # # ===================== 1. Critical Hour Analysis ===================== | |
| # critical_hours = [2, 3, 4, 5] | |
| # critical_alerts = df[df['hour'].isin(critical_hours)] | |
| # critical_pct = (len(critical_alerts) / len(df)) * 100 if len(df) > 0 else 0 | |
| # st.markdown(f"**Critical Hour Risk (3-6 AM)**") | |
| # bg_color = ( | |
| # "#ffcccc" if critical_pct > 50 else | |
| # "#ffebcc" if critical_pct > 25 else | |
| # "#ffffcc" if critical_pct > 10 else | |
| # "#e6ffe6" | |
| # ) | |
| # st.markdown( | |
| # f'<div style="background-color: {bg_color}; padding: 10px; border-radius: 5px;">' | |
| # f'Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}% of total alerts)</div>', | |
| # unsafe_allow_html=True | |
| # ) | |
| # if critical_pct > 10: | |
| # st.warning( | |
| # f"High risk: {critical_pct:.1f}% of fatigue alerts occur during critical hours (3-6 AM). " | |
| # f"This is a known circadian dip period." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"{critical_pct:.1f}% of alerts occur during critical hours. This is within acceptable range." | |
| # ) | |
| # # ===================== 2. High-Speed Fatigue Analysis ===================== | |
| # if col_speed and col_speed in df.columns: | |
| # high_speed_threshold = 20 | |
| # high_speed_fatigue = df[df[col_speed] >= high_speed_threshold] | |
| # high_speed_pct = (len(high_speed_fatigue) / len(df)) * 100 if len(df) > 0 else 0 | |
| # st.markdown(f"**High-Speed Fatigue Risk (Speed > {high_speed_threshold} km/h)**") | |
| # st.markdown( | |
| # f""" | |
| # <div style="font-size: 24px; font-weight: bold;">{len(high_speed_fatigue)}</div> | |
| # <div style="color: red; font-size: 14px; margin-top: -5px;">↑ {high_speed_pct:.1f}% of total alerts</div> | |
| # """, | |
| # unsafe_allow_html=True | |
| # ) | |
| # if high_speed_pct > 20: | |
| # st.warning( | |
| # f"High risk: {high_speed_pct:.1f}% of fatigue alerts occur at high speeds. " | |
| # f"This increases accident severity potential." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"{high_speed_pct:.1f}% of alerts occur at high speeds. This is within acceptable range." | |
| # ) | |
| # else: | |
| # st.info("Speed data not available for High-Speed Fatigue Analysis.") | |
| # # ===================== 3. Shift Pattern Analysis ===================== | |
| # if col_shift and col_shift in df.columns: | |
| # shift_counts = df[col_shift].value_counts() | |
| # st.markdown(f"**Shift Pattern Risk**") | |
| # for shift_val in shift_counts.index: | |
| # shift_pct = (shift_counts[shift_val] / len(df)) * 100 | |
| # st.markdown( | |
| # f""" | |
| # <div style="font-size: 24px; font-weight: bold;">{shift_counts[shift_val]}</div> | |
| # <div style="color: red; font-size: 14px; margin-top: -5px;">↑ {shift_pct:.1f}% of total alerts</div> | |
| # """, | |
| # unsafe_allow_html=True | |
| # ) | |
| # if shift_pct > 50: | |
| # st.warning( | |
| # f"Shift {shift_val} has disproportionately high alerts ({shift_pct:.1f}%). " | |
| # f"Review shift scheduling and workload." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"Shift {shift_val} alert distribution is acceptable ({shift_pct:.1f}%)." | |
| # ) | |
| # else: | |
| # st.info("Shift data not available for Shift Pattern Analysis.") | |
| # # ===================== 4. Operator Risk Profiling ===================== | |
| # if col_operator and col_operator in df.columns: | |
| # operator_alerts = df[col_operator].value_counts() | |
| # top_risk_operators = operator_alerts.head(5) | |
| # st.markdown("**High-Risk Operator Identification**") | |
| # colors = ["#d32f2f", "#e57373", "#ef9a9a", "#ffcdd2", "#ffe1e4"] | |
| # for idx, (op_name, count) in enumerate(top_risk_operators.items()): | |
| # op_pct = (count / len(df)) * 100 | |
| # color = colors[idx] if idx < len(colors) else colors[-1] | |
| # st.markdown( | |
| # f"**Operator:** {op_name} \n**Alerts:** {count}" | |
| # ) | |
| # st.markdown( | |
| # f"<span style='font-weight:600'>Share:</span> " | |
| # f"<span style='color:{color}; font-weight:700'>{op_pct:.1f}% of total alerts</span>", | |
| # unsafe_allow_html=True | |
| # ) | |
| # if op_pct > 5: | |
| # st.warning( | |
| # f"Operator {op_name} has high fatigue risk ({op_pct:.1f}%). " | |
| # f"Consider coaching or rest plan." | |
| # ) | |
| # else: | |
| # st.info( | |
| # f"Operator {op_name} fatigue risk is within acceptable range ({op_pct:.1f}%)." | |
| # ) | |
| # else: | |
| # st.info("Operator data not available for Operator Risk Profiling.") | |
| # # ===================================================================== | |
| # # 🔹 KOLOM KANAN — AI RECOMMENDATIONS | |
| # # ===================================================================== | |
| # with col_recs: | |
| # st.subheader("Recommendations") | |
| # ai_recommendations = [] | |
| # # 1. Critical Hour Insight → AI Rec | |
| # if "hour" in df.columns and not df.empty: | |
| # peak_hour = df["hour"].value_counts().idxmax() | |
| # critical_hours = [2, 3, 4, 5] | |
| # if peak_hour in critical_hours: | |
| # ai_recommendations.append({ | |
| # "type": "critical_hour", | |
| # "action": "Deploy enhanced fatigue monitoring systems during 3-6 AM.", | |
| # "data_point": f"Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}%)", | |
| # "reasoning": "High percentage of alerts during circadian low period." | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "type": "critical_hour", | |
| # "action": "Monitor fatigue patterns around peak hour (Hour {peak_hour}).", | |
| # "data_point": f"Peak Hour: {peak_hour}:00 — {df['hour'].value_counts()[peak_hour]} alerts", | |
| # "reasoning": "This hour shows highest fatigue occurrence." | |
| # }) | |
| # # 2. High-Speed Insight → AI Rec | |
| # if col_speed and col_speed in df.columns and not df.empty: | |
| # high_speed_threshold = 20 | |
| # high_speed_fatigue = df[df[col_speed] >= high_speed_threshold] | |
| # high_speed_pct = (len(high_speed_fatigue) / len(df)) * 100 if len(df) > 0 else 0 | |
| # if high_speed_pct > 20: | |
| # ai_recommendations.append({ | |
| # "type": "high_speed", | |
| # "action": "Implement speed-reduction protocols during fatigue-prone hours.", | |
| # "data_point": f"High-Speed Alerts: {len(high_speed_fatigue)} ({high_speed_pct:.1f}%)", | |
| # "reasoning": "High-speed alerts increase accident severity potential." | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "type": "high_speed", | |
| # "action": "Maintain current speed monitoring — risk level is acceptable.", | |
| # "data_point": f"High-Speed Alerts: {len(high_speed_fatigue)} ({high_speed_pct:.1f}%)", | |
| # "reasoning": "Current high-speed fatigue rate is within acceptable range." | |
| # }) | |
| # # 3. Shift Pattern Insight → AI Rec | |
| # if col_shift and col_shift in df.columns and not df.empty: | |
| # worst_shift = df[col_shift].value_counts().idxmax() | |
| # shift_pct = (df[col_shift].value_counts()[worst_shift] / len(df)) * 100 | |
| # if shift_pct > 50: | |
| # ai_recommendations.append({ | |
| # "type": "shift_pattern", | |
| # "action": "Review shift rotation schedules for Shift {worst_shift}.", | |
| # "data_point": f"Shift {worst_shift}: {df[col_shift].value_counts()[worst_shift]} alerts ({shift_pct:.1f}%)", | |
| # "reasoning": "Disproportionately high fatigue alerts indicate scheduling imbalance." | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "type": "shift_pattern", | |
| # "action": "Continue monitoring all shifts — no dominant risk identified.", | |
| # "data_point": f"Shift {worst_shift}: {df[col_shift].value_counts()[worst_shift]} alerts ({shift_pct:.1f}%)", | |
| # "reasoning": "Shift distribution is balanced." | |
| # }) | |
| # # 4. Operator Risk Profiling → Simple Recommendations (No AI Reasoning, No Box) | |
| # if col_operator and col_operator in df.columns and not df.empty: | |
| # top_operators = df[col_operator].value_counts().head(5) | |
| # for op_name, count in top_operators.items(): | |
| # op_pct = (count / len(df)) * 100 | |
| # if op_pct > 5: | |
| # ai_recommendations.append({ | |
| # "type": "operator", | |
| # "action": f"Coaching or mandatory rest for Operator {op_name}.", | |
| # "data_point": f"Operator {op_name}: {count} alerts ({op_pct:.1f}%)" | |
| # }) | |
| # else: | |
| # ai_recommendations.append({ | |
| # "type": "operator", | |
| # "action": f"Continue general monitoring for Operator {op_name}.", | |
| # "data_point": f"Operator {op_name}: {count} alerts ({op_pct:.1f}%)" | |
| # }) | |
| # # Render each recommendation based on type | |
| # for rec in ai_recommendations: | |
| # if rec["type"] == "operator": | |
| # # Simple format: Action + Data Point only | |
| # data_point_colored = rec['data_point'].replace( | |
| # f"({rec['data_point'].split('(')[-1]}", | |
| # f"(<span style='color: red;'>{rec['data_point'].split('(')[-1]}" | |
| # ).replace(")", "</span>)") | |
| # st.markdown( | |
| # f""" | |
| # <div style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-left: 4px solid #495057; border-radius: 5px;"> | |
| # <strong>Action:</strong> {rec['action']}<br> | |
| # <strong>Data Point:</strong> {data_point_colored} | |
| # </div> | |
| # """, | |
| # unsafe_allow_html=True | |
| # ) | |
| # else: | |
| # # Standard format with AI Reasoning and box | |
| # data_point_colored = rec['data_point'].replace( | |
| # f"({rec['data_point'].split('(')[-1]}", | |
| # f"(<span style='color: red;'>{rec['data_point'].split('(')[-1]}" | |
| # ).replace(")", "</span>)") | |
| # reasoning_colored = rec['reasoning'].replace( | |
| # f"({rec['reasoning'].split('(')[-1]}", | |
| # f"(<span style='color: red;'>{rec['reasoning'].split('(')[-1]}" | |
| # ).replace(")", "</span>)") | |
| # st.markdown( | |
| # f""" | |
| # <div style=" | |
| # background: #f8f9fa; | |
| # border: 1px solid #dee2e6; | |
| # border-radius: 8px; | |
| # padding: 15px; | |
| # margin: 10px 0; | |
| # box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
| # "> | |
| # <div style=" | |
| # font-weight: bold; | |
| # background: #e9ecef; | |
| # padding: 8px; | |
| # border-radius: 5px; | |
| # margin-bottom: 8px; | |
| # border-left: 4px solid #495057; | |
| # "> | |
| # AI Recommendation | |
| # </div> | |
| # <div style="padding: 8px 0;"> | |
| # <strong>Action:</strong> {rec['action']} | |
| # </div> | |
| # <div style=" | |
| # padding: 8px; | |
| # background: #f1f1f1; | |
| # border-radius: 5px; | |
| # margin: 8px 0; | |
| # "> | |
| # <strong>Data Point:</strong> {data_point_colored} | |
| # </div> | |
| # <div style=" | |
| # padding: 8px; | |
| # background: #f1f1f1; | |
| # border-radius: 5px; | |
| # "> | |
| # <strong>AI Reasoning:</strong> {reasoning_colored} | |
| # </div> | |
| # </div> | |
| # """, | |
| # unsafe_allow_html=True | |
| # ) | |
| # if not ai_recommendations: | |
| # st.info( | |
| # "No specific data points available for AI recommendations. " | |
| # "Ensure relevant columns are present (hour, shift, operator, duration, speed)." | |
| # ) | |
| # # ================= FOOTER =========================== | |
| # st.markdown("---") | |
| # st.markdown( | |
| # '<div class="footer">FatigueAnalyzer - Transforming Mining Safety with Intelligent Analytics | Contact: info@bukittechnology.com</div>', | |
| # unsafe_allow_html=True | |
| # ) | |
| # =================== OBJECTIVE 6: Automated Insights & AI Recommendations ===================== | |
| st.subheader("OBJECTIVE 6: Instant Insights & Recommendations") | |
| # Membagi tampilan menjadi dua kolom | |
| col_insights, col_recs = st.columns(2) | |
| # ===================================================================== | |
| # 🔹 KOLOM KIRI — INSIGHTS BY ADVANCED ANALYTICS (TANPA SEMUA KOTAK BIRU) | |
| # ===================================================================== | |
| with col_insights: | |
| st.subheader("Insights by Advanced Analytics") | |
| # ===================== 1. Critical Hour Analysis ===================== | |
| critical_hours = [2, 3, 4, 5] | |
| critical_alerts = df[df['hour'].isin(critical_hours)] | |
| critical_pct = (len(critical_alerts) / len(df)) * 100 if len(df) > 0 else 0 | |
| st.markdown(f"**Critical Hour Risk (3-6 AM)**") | |
| bg_color = ( | |
| "#ffcccc" if critical_pct > 50 else | |
| "#ffebcc" if critical_pct > 25 else | |
| "#ffffcc" if critical_pct > 10 else | |
| "#e6ffe6" | |
| ) | |
| st.markdown( | |
| f'<div style="background-color: {bg_color}; padding: 10px; border-radius: 5px;">' | |
| f'Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}% of total alerts)</div>', | |
| unsafe_allow_html=True | |
| ) | |
| if critical_pct > 10: | |
| st.warning( | |
| f"High risk: {critical_pct:.1f}% of fatigue alerts occur during critical hours (3-6 AM). " | |
| f"This is a known circadian dip period." | |
| ) | |
| else: | |
| st.info( | |
| f"{critical_pct:.1f}% of alerts occur during critical hours. This is within acceptable range." | |
| ) | |
| # ===================== 2. High-Speed Fatigue Analysis ===================== | |
| if col_speed and col_speed in df.columns: | |
| high_speed_threshold = 20 | |
| high_speed_fatigue = df[df[col_speed] >= high_speed_threshold] | |
| high_speed_pct = (len(high_speed_fatigue) / len(df)) * 100 if len(df) > 0 else 0 | |
| st.markdown(f"**High-Speed Fatigue Risk (Speed > {high_speed_threshold} km/h)**") | |
| st.markdown( | |
| f""" | |
| <div style="font-size: 24px; font-weight: bold;">{len(high_speed_fatigue)}</div> | |
| <div style="color: red; font-size: 14px; margin-top: -5px;">↑ {high_speed_pct:.1f}% of total alerts</div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| if high_speed_pct > 20: | |
| st.warning( | |
| f"High risk: {high_speed_pct:.1f}% of fatigue alerts occur at high speeds. " | |
| f"This increases accident severity potential." | |
| ) | |
| else: | |
| st.info( | |
| f"{high_speed_pct:.1f}% of alerts occur at high speeds. This is within acceptable range." | |
| ) | |
| else: | |
| st.info("Speed data not available for High-Speed Fatigue Analysis.") | |
| # ===================== 3. Shift Pattern Analysis ===================== | |
| if col_shift and col_shift in df.columns: | |
| shift_counts = df[col_shift].value_counts() | |
| st.markdown(f"**Shift Pattern Risk**") | |
| for shift_val in shift_counts.index: | |
| shift_pct = (shift_counts[shift_val] / len(df)) * 100 | |
| st.markdown( | |
| f""" | |
| <div style="font-size: 24px; font-weight: bold;">{shift_counts[shift_val]}</div> | |
| <div style="color: red; font-size: 14px; margin-top: -5px;">↑ {shift_pct:.1f}% of total alerts</div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| if shift_pct > 50: | |
| st.warning( | |
| f"Shift {shift_val} has disproportionately high alerts ({shift_pct:.1f}%). " | |
| f"Review shift scheduling and workload." | |
| ) | |
| else: | |
| st.info( | |
| f"Shift {shift_val} alert distribution is acceptable ({shift_pct:.1f}%)." | |
| ) | |
| else: | |
| st.info("Shift data not available for Shift Pattern Analysis.") | |
| # ===================== 4. Operator Risk Profiling ===================== | |
| if col_operator and col_operator in df.columns: | |
| operator_alerts = df[col_operator].value_counts() | |
| top_risk_operators = operator_alerts.head(5) | |
| st.markdown("**High-Risk Operator Identification**") | |
| colors = ["#d32f2f", "#e57373", "#ef9a9a", "#ffcdd2", "#ffe1e4"] | |
| for idx, (op_name, count) in enumerate(top_risk_operators.items()): | |
| op_pct = (count / len(df)) * 100 | |
| color = colors[idx] if idx < len(colors) else colors[-1] | |
| st.markdown( | |
| f"**Operator:** {op_name} \n**Alerts:** {count}" | |
| ) | |
| st.markdown( | |
| f"<span style='font-weight:600'>Share:</span> " | |
| f"<span style='color:{color}; font-weight:700'>{op_pct:.1f}% of total alerts</span>", | |
| unsafe_allow_html=True | |
| ) | |
| if op_pct > 5: | |
| st.warning( | |
| f"Operator {op_name} has high fatigue risk ({op_pct:.1f}%). " | |
| f"Consider coaching or rest plan." | |
| ) | |
| else: | |
| # ❌ HILANGKAN TEKS "is within acceptable range" DAN KOTAK BIRU | |
| # Hanya tampilkan nama operator + alert count — tanpa tambahan teks | |
| st.markdown( | |
| f"<span style='color: #2c3e50;'>Operator {op_name}: {count} alerts ({op_pct:.1f}%)</span>", | |
| unsafe_allow_html=True | |
| ) | |
| else: | |
| st.info("Operator data not available for Operator Risk Profiling.") | |
| # ===================================================================== | |
| # 🔹 KOLOM KANAN — AI RECOMMENDATIONS (TIDAK BERUBAH) | |
| # ===================================================================== | |
| with col_recs: | |
| st.subheader("Recommendations") | |
| ai_recommendations = [] | |
| # 1. Critical Hour Insight → AI Rec | |
| if "hour" in df.columns and not df.empty: | |
| peak_hour = df["hour"].value_counts().idxmax() | |
| critical_hours = [2, 3, 4, 5] | |
| if peak_hour in critical_hours: | |
| ai_recommendations.append({ | |
| "type": "critical_hour", | |
| "action": "Deploy enhanced fatigue monitoring systems during 3-6 AM.", | |
| "data_point": f"Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}%)", | |
| "reasoning": "High percentage of alerts during circadian low period." | |
| }) | |
| else: | |
| ai_recommendations.append({ | |
| "type": "critical_hour", | |
| "action": "Monitor fatigue patterns around peak hour (Hour {peak_hour}).", | |
| "data_point": f"Peak Hour: {peak_hour}:00 — {df['hour'].value_counts()[peak_hour]} alerts", | |
| "reasoning": "This hour shows highest fatigue occurrence." | |
| }) | |
| # 2. High-Speed Insight → AI Rec | |
| if col_speed and col_speed in df.columns and not df.empty: | |
| high_speed_threshold = 20 | |
| high_speed_fatigue = df[df[col_speed] >= high_speed_threshold] | |
| high_speed_pct = (len(high_speed_fatigue) / len(df)) * 100 if len(df) > 0 else 0 | |
| if high_speed_pct > 20: | |
| ai_recommendations.append({ | |
| "type": "high_speed", | |
| "action": "Implement speed-reduction protocols during fatigue-prone hours.", | |
| "data_point": f"High-Speed Alerts: {len(high_speed_fatigue)} ({high_speed_pct:.1f}%)", | |
| "reasoning": "High-speed alerts increase accident severity potential." | |
| }) | |
| else: | |
| ai_recommendations.append({ | |
| "type": "high_speed", | |
| "action": "Maintain current speed monitoring — risk level is acceptable.", | |
| "data_point": f"High-Speed Alerts: {len(high_speed_fatigue)} ({high_speed_pct:.1f}%)", | |
| "reasoning": "Current high-speed fatigue rate is within acceptable range." | |
| }) | |
| # 3. Shift Pattern Insight → AI Rec | |
| if col_shift and col_shift in df.columns and not df.empty: | |
| worst_shift = df[col_shift].value_counts().idxmax() | |
| shift_pct = (df[col_shift].value_counts()[worst_shift] / len(df)) * 100 | |
| if shift_pct > 50: | |
| ai_recommendations.append({ | |
| "type": "shift_pattern", | |
| "action": "Review shift rotation schedules for Shift {worst_shift}.", | |
| "data_point": f"Shift {worst_shift}: {df[col_shift].value_counts()[worst_shift]} alerts ({shift_pct:.1f}%)", | |
| "reasoning": "Disproportionately high fatigue alerts indicate scheduling imbalance." | |
| }) | |
| else: | |
| ai_recommendations.append({ | |
| "type": "shift_pattern", | |
| "action": "Continue monitoring all shifts — no dominant risk identified.", | |
| "data_point": f"Shift {worst_shift}: {df[col_shift].value_counts()[worst_shift]} alerts ({shift_pct:.1f}%)", | |
| "reasoning": "Shift distribution is balanced." | |
| }) | |
| # 4. Operator Risk Profiling → Simple Recommendations (No AI Reasoning, No Box) | |
| if col_operator and col_operator in df.columns and not df.empty: | |
| top_operators = df[col_operator].value_counts().head(5) | |
| for op_name, count in top_operators.items(): | |
| op_pct = (count / len(df)) * 100 | |
| if op_pct > 5: | |
| ai_recommendations.append({ | |
| "type": "operator", | |
| "action": f"Coaching or mandatory rest for Operator {op_name}.", | |
| "data_point": f"Operator {op_name}: {count} alerts ({op_pct:.1f}%)" | |
| }) | |
| else: | |
| ai_recommendations.append({ | |
| "type": "operator", | |
| "action": f"Continue general monitoring for Operator {op_name}.", | |
| "data_point": f"Operator {op_name}: {count} alerts ({op_pct:.1f}%)" | |
| }) | |
| # Render each recommendation based on type | |
| for rec in ai_recommendations: | |
| if rec["type"] == "operator": | |
| # Simple format: Action + Data Point only | |
| data_point_colored = rec['data_point'].replace( | |
| f"({rec['data_point'].split('(')[-1]}", | |
| f"(<span style='color: red;'>{rec['data_point'].split('(')[-1]}" | |
| ).replace(")", "</span>)") | |
| st.markdown( | |
| f""" | |
| <div style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-left: 4px solid #495057; border-radius: 5px;"> | |
| <strong>Action:</strong> {rec['action']}<br> | |
| <strong>Data Point:</strong> {data_point_colored} | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| else: | |
| # Standard format with AI Reasoning and box | |
| data_point_colored = rec['data_point'].replace( | |
| f"({rec['data_point'].split('(')[-1]}", | |
| f"(<span style='color: red;'>{rec['data_point'].split('(')[-1]}" | |
| ).replace(")", "</span>)") | |
| reasoning_colored = rec['reasoning'].replace( | |
| f"({rec['reasoning'].split('(')[-1]}", | |
| f"(<span style='color: red;'>{rec['reasoning'].split('(')[-1]}" | |
| ).replace(")", "</span>)") | |
| st.markdown( | |
| f""" | |
| <div style=" | |
| background: #f8f9fa; | |
| border: 1px solid #dee2e6; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin: 10px 0; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
| "> | |
| <div style=" | |
| font-weight: bold; | |
| background: #e9ecef; | |
| padding: 8px; | |
| border-radius: 5px; | |
| margin-bottom: 8px; | |
| border-left: 4px solid #495057; | |
| "> | |
| AI Recommendation | |
| </div> | |
| <div style="padding: 8px 0;"> | |
| <strong>Action:</strong> {rec['action']} | |
| </div> | |
| <div style=" | |
| padding: 8px; | |
| background: #f1f1f1; | |
| border-radius: 5px; | |
| margin: 8px 0; | |
| "> | |
| <strong>Data Point:</strong> {data_point_colored} | |
| </div> | |
| <div style=" | |
| padding: 8px; | |
| background: #f1f1f1; | |
| border-radius: 5px; | |
| "> | |
| <strong>AI Reasoning:</strong> {reasoning_colored} | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| if not ai_recommendations: | |
| st.info( | |
| "No specific data points available for AI recommendations. " | |
| "Ensure relevant columns are present (hour, shift, operator, duration, speed)." | |
| ) | |
| # ================= FOOTER =========================== | |
| st.markdown("---") | |
| st.markdown( | |
| '<div class="footer">FatigueAnalyzer - Transforming Mining Safety with Intelligent Analytics | Contact: info@bukittechnology.com</div>', | |
| unsafe_allow_html=True | |
| ) |