SHELLAPANDIANGANHUNGING commited on
Commit
c97fd8c
·
verified ·
1 Parent(s): 9843d3f

Upload 6 files

Browse files
Files changed (5) hide show
  1. README.md +42 -12
  2. app.py +1791 -0
  3. btech.png +0 -0
  4. data.csv +0 -0
  5. requirements.txt +7 -2
README.md CHANGED
@@ -1,19 +1,49 @@
1
  ---
2
- title: FatigueAnalyzer
3
- emoji: 🚀
4
- colorFrom: red
5
  colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: Track. Analyze. Prevent fatigue.
12
  ---
13
 
14
- # Welcome to Streamlit!
15
 
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
17
 
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MineVision AI - Advanced Fatigue Analytics
3
+ emoji: ⛏️
4
+ colorFrom: blue
5
  colorTo: red
6
+ sdk: streamlit
7
+ sdk_version: 1.38.0 # Ganti dengan versi streamlit yang digunakan
8
+ app_file: app.py
 
9
  pinned: false
10
+ license: apache-2.0
11
  ---
12
 
13
+ # MineVision AI - Advanced Fatigue Analytics
14
 
15
+ ## Deskripsi
16
+ Aplikasi ini adalah dashboard analitik kelelahan berbasis web yang dirancang untuk operasi pertambangan. Menggunakan data dari sistem deteksi kelelahan (seperti Wenco DSS), aplikasi ini menyediakan wawasan dan analisis real-time untuk membantu mengidentifikasi, menilai, dan mengelola risiko kelelahan operator. Tujuannya adalah untuk meningkatkan keselamatan kerja dan produktivitas dengan mengurangi kecelakaan yang terkait dengan kelelahan.
17
 
18
+ ## Fitur Utama
19
+ * **Dashboard Eksekutif**: Menampilkan metrik keselamatan utama seperti total alert, jumlah operator dan aset, serta durasi rata-rata kejadian.
20
+ * **Analisis Tren**: Visualisasi tren kelelahan berdasarkan jam, shift, hari dalam seminggu, dan minggu.
21
+ * **Analisis Lanjutan**: Analisis berdasarkan jenis armada, kecepatan vs jam, durasi vs jam, distribusi kecepatan, dan distribusi operator per shift.
22
+ * **Kategorisasi Risiko Kelelahan**: Menganalisis kejadian berdasarkan matriks risiko kelelahan (Kritis, Tinggi, Sedang, Rendah) berdasarkan kecepatan dan waktu.
23
+ * **Wawasan Berbasis AI**: Ringkasan otomatis dan wawasan berdasarkan data yang dianalisis.
24
+ * **Asisten AI Interaktif**: Chatbot sederhana untuk menanyakan informasi tentang data kelelahan (operator terbanyak, shift terbanyak, dll.).
25
+
26
+ ## Teknologi yang Digunakan
27
+ * **Streamlit**: Framework untuk membuat aplikasi web interaktif dalam Python.
28
+ * **Pandas**: Manipulasi dan analisis data.
29
+ * **Plotly/Plotly Express**: Visualisasi data interaktif.
30
+ * **Openpyxl**: Pembacaan file Excel.
31
+
32
+ ## Cara Menggunakan
33
+ 1. Akses aplikasi melalui URL Hugging Face Spaces.
34
+ 2. Gunakan filter di sidebar untuk menyaring data berdasarkan Tahun, Bulan, Minggu, Rentang Tanggal, Operator, Shift, dan Rentang Jam.
35
+ 3. Jelajahi berbagai bagian dashboard untuk memahami pola kelelahan.
36
+ 4. Gunakan kotak chat "MineVision AI Assistant" di bagian atas untuk menanyakan pertanyaan spesifik tentang data.
37
+
38
+ ## Struktur Proyek
39
+ * `app.py`: File utama yang berisi kode aplikasi Streamlit.
40
+ * `requirements.txt`: File yang berisi daftar dependensi Python yang diperlukan untuk menjalankan aplikasi.
41
+ * `manual fatique.xlsx`: File data input contoh (jika disertakan dalam repositori).
42
+
43
+ ## Catatan
44
+ * Aplikasi ini dirancang untuk menganalisis data kelelahan operator dari file Excel. Pastikan struktur data masukan sesuai atau sesuaikan kode untuk membaca data dari sumber lain.
45
+ * Wawasan dan rekomendasi didasarkan pada analisis data historis dan prinsip-prinsip manajemen risiko kelelahan (FRMS).
46
+ * Asisten AI saat ini menyediakan jawaban berbasis aturan sederhana berdasarkan data yang tersedia dan informasi umum tentang FRMS. Ini bukan model AI canggih seperti GPT.
47
+
48
+ ## Lisensi
49
+ Apache 2.0
app.py ADDED
@@ -0,0 +1,1791 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import plotly.express as px
4
+ import plotly.graph_objects as go
5
+ from datetime import datetime, timedelta
6
+ import requests
7
+ import json
8
+ import numpy as np
9
+ import math
10
+ import base64
11
+
12
+ # =================== CONFIG =====================
13
+ st.set_page_config(
14
+ page_title="MineVision AI - Advanced Fatigue Analytics",
15
+ page_icon="🛡️", # Safety icon
16
+ layout="wide",
17
+ initial_sidebar_state="expanded"
18
+ )
19
+
20
+ # =================== LOGO =====================
21
+ logo_path = "btech.png" # File logo
22
+ def get_base64(file_path):
23
+ with open(file_path, "rb") as f:
24
+ data = f.read()
25
+ return base64.b64encode(data).decode()
26
+
27
+ try:
28
+ logo_base64 = get_base64(logo_path)
29
+ logo_html = f'<img src="data:image/png;base64,{logo_base64}" style="max-height: 80px; max-width: 120px;">'
30
+ except FileNotFoundError:
31
+ st.warning(f"Logo file '{logo_path}' not found. Using placeholder text.")
32
+ logo_html = '<div style="font-size: 18px; font-weight: bold; color: #2c3e50;">BTECH</div>'
33
+
34
+ # =================== GLOBAL CSS =====================
35
+ st.markdown("""
36
+ <style>
37
+ body {
38
+ background-color: #f6f8fa;
39
+ }
40
+
41
+ /* ===== HEADER WRAPPER ===== */
42
+ .header-container {
43
+ display: flex;
44
+ justify-content: space-between;
45
+ align-items: center;
46
+ padding: 25px 35px;
47
+ background: white; /* Latar belakang utama diubah menjadi putih */
48
+ border-radius: 0 0 14px 14px; /* Rounded bottom only */
49
+ box-shadow: 0 5px 18px rgba(0,0,0,0.15); /* Bayangan lebih lembut */
50
+ border: 1px solid #e0e0e0; /* Border tipis untuk definisi */
51
+ margin-bottom: 25px;
52
+ position: relative;
53
+ overflow: hidden; /* Ensure rounded corners clip content */
54
+ }
55
+
56
+ /* Optional: Subtle pattern or texture overlay (optional, can be removed) */
57
+ /* .header-container::before {
58
+ content: "";
59
+ position: absolute;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ 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);
65
+ background-size: 20px 20px;
66
+ pointer-events: none;
67
+ } */
68
+
69
+ /* ===== HEADER TEXT ===== */
70
+ .header-title {
71
+ color: #2c3e50; /* Teks header diubah agar kontras dengan latar putih */
72
+ font-family: 'Segoe UI', sans-serif;
73
+ flex-grow: 1; /* Allow text to take up available space */
74
+ margin-right: 20px; /* Space between text and logo */
75
+ text-align: left;
76
+ }
77
+
78
+ .header-title h1 {
79
+ font-size: 2.7em;
80
+ font-weight: 650;
81
+ margin: 0;
82
+ text-shadow: 1px 1px 2px rgba(0,0,0,0.1); /* Bayangan teks lebih lembut */
83
+ }
84
+
85
+ .header-title p {
86
+ font-size: 1.25em;
87
+ opacity: 0.85; /* Sedikit transparan untuk subjudul */
88
+ margin-top: 6px;
89
+ font-style: italic;
90
+ color: #34495e; /* Warna subjudul disesuaikan */
91
+ }
92
+
93
+ /* ===== LOGO WRAPPER ===== */
94
+ .header-logo {
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: flex-end; /* Align logo to the right within its container */
98
+ flex-shrink: 0; /* Prevent logo container from shrinking */
99
+ }
100
+
101
+ /* ===== LOGO STYLE ===== */
102
+ .header-logo img {
103
+ border-radius: 10px;
104
+ border: 2px solid rgba(44, 62, 80, 0.15); /* Border logo disesuaikan */
105
+ box-shadow: 0 3px 10px rgba(0,0,0,0.1); /* Bayangan logo lebih lembut */
106
+ max-height: 80px; /* Set max height */
107
+ max-width: 120px; /* Set max width */
108
+ }
109
+
110
+ /* ===== METRIC CARDS ===== */
111
+ .metric-card {
112
+ background: #ffffff;
113
+ padding: 18px 22px;
114
+ border-radius: 12px;
115
+ border-left: 6px solid #1e3c72;
116
+ box-shadow: 0 3px 8px rgba(0,0,0,0.10);
117
+ transition: 0.25s ease-in-out;
118
+ }
119
+ .metric-card:hover {
120
+ transform: translateY(-4px);
121
+ box-shadow: 0 6px 15px rgba(0,0,0,0.18);
122
+ }
123
+
124
+ /* ===== INSIGHT BOX ===== */
125
+ .insight-box {
126
+ background: #fafafa;
127
+ padding: 18px;
128
+ border-radius: 12px;
129
+ border-left: 6px solid #ff6b6b;
130
+ margin: 15px 0;
131
+ box-shadow: 0 2px 6px rgba(0,0,0,0.08);
132
+ }
133
+
134
+ /* ===== RISK MATRIX ===== */
135
+ .risk-matrix {
136
+ border-collapse: collapse;
137
+ width: 100%;
138
+ margin: 20px 0;
139
+ }
140
+ .risk-matrix th, .risk-matrix td {
141
+ border: 1px solid #ddd;
142
+ padding: 12px;
143
+ text-align: center;
144
+ }
145
+ .risk-matrix th {
146
+ background-color: #f2f2f2;
147
+ }
148
+ .critical { background-color: #ffcccc; font-weight: bold; }
149
+ .high { background-color: #ffebcc; }
150
+ .medium { background-color: #ffffcc; }
151
+ .low { background-color: #e6ffe6; }
152
+
153
+ /* ===== CHAT UI ===== */
154
+ .chat-container {
155
+ background: white;
156
+ padding: 20px;
157
+ border-radius: 12px;
158
+ height: 400px;
159
+ overflow-y: auto;
160
+ border: 1px solid #ccc;
161
+ }
162
+ .user-message {
163
+ background: #e3f2fd;
164
+ color: black;
165
+ padding: 12px;
166
+ border-radius: 12px;
167
+ margin: 10px 0;
168
+ text-align: right;
169
+ border: 1px solid #bbdefb;
170
+ }
171
+ .ai-message {
172
+ background: #f5f5f5;
173
+ color: black;
174
+ padding: 12px;
175
+ border-radius: 12px;
176
+ margin: 10px 0;
177
+ text-align: left;
178
+ border: 1px solid #e0e0e0;
179
+ }
180
+
181
+ /* ===== INPUT BOX ===== */
182
+ .chat-box, .user-question, .ai-answer {
183
+ background: white;
184
+ border: 1px solid #ccc;
185
+ border-radius: 10px;
186
+ padding: 12px;
187
+ margin-bottom: 12px;
188
+ }
189
+
190
+ /* ===== FOOTER ===== */
191
+ .footer {
192
+ text-align: center;
193
+ padding: 20px;
194
+ color: gray;
195
+ font-size: 0.9em;
196
+ }
197
+
198
+ /* ===== HOVER EFFECTS ===== */
199
+ .metric-card:hover, .insight-box:hover {
200
+ box-shadow: 0 6px 15px rgba(0,0,0,0.2);
201
+ transition: all 0.3s ease-in-out;
202
+ }
203
+
204
+ </style>
205
+ """, unsafe_allow_html=True)
206
+
207
+ # =================== HEADER =====================
208
+ st.markdown(f"""
209
+ <div class="header-container">
210
+ <div class="header-title">
211
+ <h1>Safety Analysis and AI - Advanced Fatigue Analysis</h1>
212
+ <p>Proactive Safety Intelligence for Mining Operations</p>
213
+ </div>
214
+ <div class="header-logo">
215
+ {logo_html}
216
+ </div>
217
+ </div>
218
+ """, unsafe_allow_html=True)
219
+
220
+ # Sisa kode Anda (LOAD DATA, FILTERS, VISUALISASI, dll.) tetap sama di bawah ini
221
+ # ... (Kode selanjutnya disalin dari bagian bawah file Anda, misalnya LOAD DATA ke bawah)
222
+ # =================== LOAD DATA ======================
223
+ @st.cache_data
224
+ def load_data():
225
+ try:
226
+ # ==================================
227
+ # 1. LOAD CSV & NORMALIZE COLUMNS
228
+ # ==================================
229
+ df = pd.read_csv("data.csv")
230
+ original_columns = df.columns.tolist()
231
+ # Normalize: lower, strip, underscore
232
+ df.columns = (
233
+ df.columns.astype(str)
234
+ .str.strip()
235
+ .str.lower()
236
+ .str.replace(r"\s+", "_", regex=True)
237
+ )
238
+
239
+ # ==================================
240
+ # 2. AUTO-DETECT COLUMNS (case-insensitive)
241
+ # ==================================
242
+ col_operator = next((c for c in df.columns if "operator" in c or "driver" in c), None)
243
+ col_shift = next((c for c in df.columns if "shift" in c), None)
244
+ # ✅ FIX: Search for normalized "parent_fleet", NOT original "Parent Fleet"
245
+ col_fleet_type = next((c for c in df.columns if "parent_fleet" in c), None)
246
+ col_fleet_no = next((c for c in df.columns if "fleet_number" in c), None)
247
+
248
+ # ==================================
249
+ # 3. DERIVE COLUMNS
250
+ # ==================================
251
+ # Unit Number
252
+ if col_fleet_no:
253
+ df["unit_no"] = df[col_fleet_no].astype(str).str.split("-", n=1).str[-1].str.strip()
254
+ else:
255
+ df["unit_no"] = "UNKNOWN"
256
+
257
+ # Speed
258
+ col_speed = None
259
+ for orig in original_columns:
260
+ norm = orig.lower().replace(" ", "_")
261
+ if "(in_km/hour).1" in norm or "speed" in norm:
262
+ if norm in df.columns:
263
+ col_speed = norm
264
+ break
265
+ if not col_speed:
266
+ col_speed = next((c for c in df.columns if "speed" in c), None)
267
+
268
+ # Time
269
+ time_cols = [c for c in df.columns if "gmt" in c and "wita" in c]
270
+ if len(time_cols) >= 2:
271
+ df["start"] = pd.to_datetime(df[time_cols[0]], errors="coerce")
272
+ df["end"] = pd.to_datetime(df[time_cols[1]], errors="coerce")
273
+ elif len(time_cols) == 1:
274
+ df["start"] = pd.to_datetime(df[time_cols[0]], errors="coerce")
275
+ df["end"] = df["start"] + pd.Timedelta(minutes=1)
276
+ else:
277
+ df["start"] = pd.NaT
278
+ df["end"] = pd.NaT
279
+
280
+ # Time features
281
+ if not df["start"].isna().all():
282
+ df["hour"] = df["start"].dt.hour
283
+ df["date"] = df["start"].dt.date
284
+ df["day_of_week"] = df["start"].dt.day_name()
285
+ # df["week"], df["month"], df["year"] — optional, not used in filters
286
+ else:
287
+ df["hour"] = 0
288
+ df["date"] = None
289
+
290
+ # Shift as int
291
+ if col_shift:
292
+ df[col_shift] = pd.to_numeric(df[col_shift], errors="coerce").astype("Int64")
293
+
294
+ # ✅ FIX: CREATE site & group_model HERE (not in sidebar!)
295
+ if col_fleet_type:
296
+ # Split ONCE on first '-', keep FULL left part (e.g., "Amsterdam - CAT789" → "AMSTERDAM")
297
+ split = df[col_fleet_type].astype(str).str.split("-", n=1, expand=True)
298
+ df["site"] = split[0].str.strip().str.upper()
299
+ df["group_model"] = split[1].str.strip().fillna("UNKNOWN").replace("", "UNKNOWN")
300
+ else:
301
+ df["site"] = "UNKNOWN"
302
+ df["group_model"] = "UNKNOWN"
303
+
304
+ return df, col_operator, col_shift, col_fleet_type, col_speed, col_fleet_no
305
+
306
+ except Exception as e:
307
+ st.error(f"Error loading data: {e}")
308
+ return pd.DataFrame(), None, None, None, None, None
309
+
310
+ # ==================================
311
+ # CALL load_data()
312
+ # ==================================
313
+ df, col_operator, col_shift, col_fleet_type, col_speed, col_fleet_no = load_data()
314
+ df_original_full = df.copy()
315
+ if df.empty:
316
+ st.stop()
317
+ st.success("Data Loaded Successfully")
318
+ df_full_report = df.copy()
319
+
320
+ # =================== FILTERS (Sidebar) =====================
321
+ filter_dict = {}
322
+
323
+ with st.sidebar.form("filters_form"):
324
+ # ---------------- Date Range ----------------
325
+ if 'date' in df.columns and not df['date'].isna().all():
326
+ min_date = pd.to_datetime(df['date']).min().date()
327
+ max_date = pd.to_datetime(df['date']).max().date()
328
+ date_range = st.date_input("Select Date Range", (min_date, max_date))
329
+ filter_dict['date_range'] = date_range
330
+ else:
331
+ filter_dict['date_range'] = (None, None)
332
+
333
+ # ✅ FIXED: Use df['site'] & df['group_model'] (already created in load_data)
334
+ # ---------------- Site Filter ----------------
335
+ all_sites = sorted(df['site'].dropna().unique())
336
+ selected_site = st.selectbox(
337
+ "Filter Site",
338
+ options=[None] + all_sites,
339
+ format_func=lambda x: "All" if x is None else x
340
+ )
341
+ filter_dict['site'] = selected_site
342
+
343
+ # ---------------- Group Model Filter ✅ NOW WORKING ----------------
344
+ all_models = sorted(df['group_model'].dropna().unique())
345
+ selected_model = st.selectbox(
346
+ "Filter Group Model",
347
+ options=[None] + all_models,
348
+ format_func=lambda x: "All" if x is None else x
349
+ )
350
+ filter_dict['group_model'] = selected_model
351
+
352
+ # ---------------- Shift ----------------
353
+ if col_shift:
354
+ shifts = sorted(df[col_shift].dropna().unique())
355
+ selected_shift = st.selectbox(
356
+ f"Select {col_shift.replace('_', ' ').title()}",
357
+ options=[None] + shifts,
358
+ format_func=lambda x: "All" if x is None else f"Shift {x}"
359
+ )
360
+ filter_dict['shift'] = selected_shift
361
+ else:
362
+ filter_dict['shift'] = None
363
+
364
+ # ---------------- Operator ----------------
365
+ if col_operator:
366
+ ops = sorted(df[col_operator].dropna().unique())
367
+ selected_op = st.selectbox(
368
+ f"Select {col_operator.replace('_', ' ').title()}",
369
+ options=[None] + ops,
370
+ format_func=lambda x: "All" if x is None else x
371
+ )
372
+ filter_dict['operator'] = selected_op
373
+ else:
374
+ filter_dict['operator'] = None
375
+
376
+ # ---------------- Hour ----------------
377
+ if 'hour' in df.columns and not df['hour'].isna().all():
378
+ hours = sorted(df['hour'].dropna().unique())
379
+ hour_range = st.slider("Select Hour Range", int(min(hours)), int(max(hours)), (int(min(hours)), int(max(hours))))
380
+ filter_dict['hour_range'] = hour_range
381
+ else:
382
+ filter_dict['hour_range'] = (0, 23)
383
+
384
+ # ---------------- Unit No ----------------
385
+ if 'unit_no' in df.columns:
386
+ units = sorted(df['unit_no'].dropna().unique())
387
+ selected_unit = st.selectbox("Select Unit Number", [None] + units, format_func=lambda x: "All" if x is None else x)
388
+ filter_dict['unit_no'] = selected_unit
389
+ else:
390
+ filter_dict['unit_no'] = None
391
+
392
+ # ---------------- Submit ----------------
393
+ apply_filters = st.form_submit_button("Apply Filters")
394
+ # =================== APPLY FILTERS =====================
395
+ if apply_filters:
396
+ # Filter Date Range
397
+ if filter_dict.get('date_range'):
398
+ start_date, end_date = filter_dict['date_range']
399
+ df = df[(df['date'] >= start_date) & (df['date'] <= end_date)]
400
+
401
+ # Filter Site
402
+ if filter_dict.get('site') is not None:
403
+ df = df[df['site'] == filter_dict['site']]
404
+
405
+ # Filter Group Model
406
+ if filter_dict.get('group_model') is not None:
407
+ df = df[df['group_model'] == filter_dict['group_model']]
408
+
409
+ # Filter Shift
410
+ if filter_dict.get('shift') is not None:
411
+ df = df[df[col_shift] == filter_dict['shift']]
412
+
413
+ # Filter Operator
414
+ if filter_dict.get('operator') is not None:
415
+ df = df[df[col_operator] == filter_dict['operator']]
416
+
417
+ # Filter Hour Range
418
+ if filter_dict.get('hour_range'):
419
+ hr_start, hr_end = filter_dict['hour_range']
420
+ df = df[(df['hour'] >= hr_start) & (df['hour'] <= hr_end)]
421
+
422
+ # Filter Unit No
423
+ if filter_dict.get('unit_no') is not None:
424
+ df = df[df[col_fleet_no] == filter_dict['unit_no']]
425
+
426
+
427
+ # Sisanya dari kode Anda (Visualisasi, dll.) tetap sama
428
+ # Objective 1
429
+ # ===================== GLOBAL FUNCTION: Hour Category Labels =====================
430
+ def hour_range_label_full(hour):
431
+ if not (0 <= hour < 24):
432
+ return 'Unknown'
433
+ if 6 <= hour < 9:
434
+ return 'Shift 1 Morning Early (6-9)'
435
+ elif 9 <= hour < 12:
436
+ return 'Shift 1 Morning Late (9-12)'
437
+ elif 12 <= hour < 15:
438
+ return 'Shift 1 Afternoon Early (12-15)'
439
+ elif 15 <= hour < 18:
440
+ return 'Shift 1 Afternoon Late (15-18)'
441
+ elif 18 <= hour < 21:
442
+ return 'Shift 2 Evening Early (18-21)'
443
+ elif 21 <= hour < 24:
444
+ return 'Shift 2 Evening Late (21-24)'
445
+ elif 0 <= hour < 3:
446
+ return 'Shift 2 Dawn Early (0-3)'
447
+ elif 3 <= hour < 6:
448
+ return 'Shift 2 Dawn Late (3-6)'
449
+ return 'Unknown'
450
+
451
+ # ===================== MAIN VISUALIZATION =====================
452
+ st.subheader("Objective 1: Visualizing Operator Fatigue Patterns by Shift Hours")
453
+ if 'start' in df.columns and not df.empty:
454
+ try:
455
+ # --- Data Preparation ---
456
+ df_local = df.copy()
457
+ if not pd.api.types.is_datetime64_any_dtype(df_local['start']):
458
+ df_local['start'] = pd.to_datetime(df_local['start'], errors='coerce')
459
+ df_local = df_local.dropna(subset=['start'])
460
+ df_local['hour'] = df_local['start'].dt.hour
461
+ # --- COLOR MAP: KUNING-ORANGE (Shift 1), BIRU (Shift 2) ---
462
+ color_map_full = {
463
+ 'Shift 1 Morning Early (6-9)': '#FFEB3B', # Yellow 300
464
+ 'Shift 1 Morning Late (9-12)': '#FFC107', # Amber 300
465
+ 'Shift 1 Afternoon Early (12-15)': '#FF9800', # Orange 300
466
+ 'Shift 1 Afternoon Late (15-18)': '#F57C00', # Deep Orange 300
467
+ 'Shift 2 Evening Early (18-21)': '#42A5F5', # Light Blue 300
468
+ 'Shift 2 Evening Late (21-24)': '#1976D2', # Blue 300
469
+ 'Shift 2 Dawn Early (0-3)': '#0288D1', # Cyan 300
470
+ 'Shift 2 Dawn Late (3-6)': '#01579B', # Blue 800
471
+ }
472
+ # --- Define intervals in analog-clock order (12→3→6→9) ---
473
+ intervals_shift1 = [(12, 15), (15, 18), (6, 9), (9, 12)]
474
+ labels_shift1 = [
475
+ 'Shift 1 Afternoon Early (12-15)',
476
+ 'Shift 1 Afternoon Late (15-18)',
477
+ 'Shift 1 Morning Early (6-9)',
478
+ 'Shift 1 Morning Late (9-12)',
479
+ ]
480
+ intervals_shift2 = [(0, 3), (3, 6), (18, 21), (21, 24)]
481
+ labels_shift2 = [
482
+ 'Shift 2 Dawn Early (0-3)',
483
+ 'Shift 2 Dawn Late (3-6)',
484
+ 'Shift 2 Evening Early (18-21)',
485
+ 'Shift 2 Evening Late (21-24)',
486
+ ]
487
+ # --- Compute frequencies ---
488
+ def compute_counts(intervals):
489
+ counts = []
490
+ for start_h, end_h in intervals:
491
+ cnt = df_local[(df_local['hour'] >= start_h) & (df_local['hour'] < end_h)].shape[0]
492
+ counts.append(cnt)
493
+ return counts
494
+ freq_shift1 = compute_counts(intervals_shift1)
495
+ freq_shift2 = compute_counts(intervals_shift2)
496
+ # --- Polar geometry ---
497
+ theta_midpoints = [45, 135, 225, 315] # centers of 90° segments
498
+ bar_width = [90] * 4
499
+ angular_tick_vals = [0, 90, 180, 270] # fixed angle positions
500
+ # ✅ CUSTOM TICK LABELS PER SHIFT (sesuai permintaan Anda)
501
+ angular_tick_text_shift1 = ["12", "15", "6/18", "9"] # 0°, 90°, 180°, 270°
502
+ angular_tick_text_shift2 = ["24", "3", "18/6", "21"] # 0°=24, 90°=3, 180°=6, 270°=21
503
+ # --- Independent radial scales ---
504
+ max_r1 = max(freq_shift1) if freq_shift1 and max(freq_shift1) > 0 else 1
505
+ max_r2 = max(freq_shift2) if freq_shift2 and max(freq_shift2) > 0 else 1
506
+ # ============== FIGURE: SHIFT 1 (KUNING-ORANGE) ==============
507
+ fig1 = go.Figure()
508
+ fig1.add_trace(go.Barpolar(
509
+ r=freq_shift1,
510
+ theta=theta_midpoints,
511
+ width=bar_width,
512
+ marker_color=[color_map_full.get(lbl, '#FFEB3B') for lbl in labels_shift1],
513
+ marker_line_color="black",
514
+ marker_line_width=1.5,
515
+ opacity=0.93,
516
+ hovertemplate="<b>%{text}</b><br>Fatigue Incidents: %{r}<extra></extra>",
517
+ text=labels_shift1,
518
+ ))
519
+ fig1.update_layout(
520
+ title=dict(text="Shift 1 (06:00–18:00)", font=dict(size=18, color="#FF9800", family="Segoe UI")),
521
+ polar=dict(
522
+ bgcolor="rgba(255,248,225,0.7)",
523
+ angularaxis=dict(
524
+ rotation=90, # 12 at top
525
+ direction="clockwise",
526
+ tickmode='array',
527
+ tickvals=angular_tick_vals,
528
+ ticktext=angular_tick_text_shift1, # ✅ 12, 15, 6, 9
529
+ tickfont=dict(size=14, color="#5D4037", weight="bold"),
530
+ showline=True,
531
+ linewidth=1.2,
532
+ linecolor="#FFD54F",
533
+ ),
534
+ radialaxis=dict(
535
+ visible=True,
536
+ showticklabels=True,
537
+ tickfont=dict(size=11),
538
+ angle=45,
539
+ gridcolor="#FFE082",
540
+ gridwidth=0.8,
541
+ range=[0, max_r1 * 1.15],
542
+ )
543
+ ),
544
+ showlegend=False,
545
+ height=550,
546
+ width=550,
547
+ margin=dict(t=65, b=40, l=40, r=40),
548
+ font=dict(family="Segoe UI, -apple-system, sans-serif"),
549
+ )
550
+ # ============== FIGURE: SHIFT 2 (BIRU) ==============
551
+ fig2 = go.Figure()
552
+ fig2.add_trace(go.Barpolar(
553
+ r=freq_shift2,
554
+ theta=theta_midpoints,
555
+ width=bar_width,
556
+ marker_color=[color_map_full.get(lbl, '#42A5F5') for lbl in labels_shift2],
557
+ marker_line_color="black",
558
+ marker_line_width=1.5,
559
+ opacity=0.93,
560
+ hovertemplate="<b>%{text}</b><br>Fatigue Incidents: %{r}<extra></extra>",
561
+ text=labels_shift2,
562
+ ))
563
+ fig2.update_layout(
564
+ title=dict(text="Shift 2 (18:00–06:00)", font=dict(size=18, color="#1976D2", family="Segoe UI")),
565
+ polar=dict(
566
+ bgcolor="rgba(230,245,255,0.7)",
567
+ angularaxis=dict(
568
+ rotation=90,
569
+ direction="clockwise",
570
+ tickmode='array',
571
+ tickvals=angular_tick_vals,
572
+ ticktext=angular_tick_text_shift2, # ✅ 24, 3, 6, 21
573
+ tickfont=dict(size=14, color="#0D47A1", weight="bold"),
574
+ showline=True,
575
+ linewidth=1.2,
576
+ linecolor="#64B5F6",
577
+ ),
578
+ radialaxis=dict(
579
+ visible=True,
580
+ showticklabels=True,
581
+ tickfont=dict(size=11),
582
+ angle=45,
583
+ gridcolor="#BBDEFB",
584
+ gridwidth=0.8,
585
+ range=[0, max_r2 * 1.15], # ✅ SKALA INDEPENDEN
586
+ )
587
+ ),
588
+ showlegend=False,
589
+ height=550,
590
+ width=550,
591
+ margin=dict(t=65, b=40, l=40, r=40),
592
+ font=dict(family="Segoe UI, -apple-system, sans-serif"),
593
+ )
594
+ # ============== EXPLANATION — URUTAN KRONOLOGIS, REMARK TETAP ==============
595
+ st.markdown("""
596
+ <div style="
597
+ background: linear-gradient(135deg, #FFFDE7 0%, #E3F2FD 100%);
598
+ padding: 20px;
599
+ border-radius: 12px;
600
+ border-left: 5px solid #FF9800;
601
+ margin: 22px 0;
602
+ box-shadow: 0 3px 10px rgba(0,0,0,0.06);
603
+ ">
604
+ <h4 style="color:#1976D2; margin:0 0 14px 0; display:flex; align-items:center;">
605
+ <span style="background:#FF9800; color:white; width:26px; height:26px; border-radius:50%;
606
+ display:inline-flex; align-items:center; justify-content:center; margin-right:10px; font-weight:bold;">!</span>
607
+ ⚠️ Clockwise Time Mapping (Analog Layout)
608
+ </h4>
609
+ <table style="width:100%; font-size:14px; border-collapse:collapse; color:#424242;">
610
+ <tr style="background-color:#FFF8E1;">
611
+ <th style="padding:8px; text-align:left; width:25%;">Time Block</th>
612
+ <th style="padding:8px; text-align:left;">Shift 1 (Day)</th>
613
+ <th style="padding:8px; text-align:left;">Shift 2 (Night)</th>
614
+ </tr>
615
+ <tr>
616
+ <td style="padding:8px; font-weight:bold;">1st Block</td>
617
+ <td><b>06 → 09</b></td>
618
+ <td><b>18 → 21</b> (Shift Start)</td>
619
+ </tr>
620
+ <tr style="background-color:#F5F9FF;">
621
+ <td style="padding:8px; font-weight:bold;">2nd Block</td>
622
+ <td><b>09 → 12</b></td>
623
+ <td><b>21 → 24</b> (Alertness Decline)</td>
624
+ </tr>
625
+ <tr>
626
+ <td style="padding:8px; font-weight:bold;">3rd Block</td>
627
+ <td><b>12 → 15</b></td>
628
+ <td><b>24 → 03</b> (Circadian Nadir)</td>
629
+ </tr>
630
+ <tr style="background-color:#F5F9FF;">
631
+ <td style="padding:8px; font-weight:bold;">4th Block</td>
632
+ <td><b>15 → 18</b></td>
633
+ <td><b>03 → 06</b></td>
634
+ </tr>
635
+ </table>
636
+ <p style="margin-top:12px; font-size:13px; color:#546E7A;">
637
+ <b>Scale is independent per shift</b> — bar length shows relative risk <i>within</i> the shift.
638
+ </p>
639
+ </div>
640
+ """, unsafe_allow_html=True)
641
+ # ============== RENDER CHARTS HORIZONTALLY (NO OVERLAP) ==============
642
+ col1, col2 = st.columns(2)
643
+ with col1:
644
+ st.plotly_chart(fig1, use_container_width=True, config={'displayModeBar': False})
645
+ with col2:
646
+ st.plotly_chart(fig2, use_container_width=True, config={'displayModeBar': False})
647
+ # ============== FOOTNOTE (SEMINAR-READY) ==============
648
+ st.caption(
649
+ " *Safety Insight*: Highest fatigue risk occurs during **24→06** (Shift 2) — aligns with circadian trough (Czeisler, 1999). "
650
+ )
651
+ except Exception as e:
652
+ st.error(f"⚠️ Rendering error: {e}")
653
+ st.code(f"{type(e).__name__}: {str(e)}", language="python")
654
+ else:
655
+ st.info("⏳ Awaiting data... Ensure column `'start'` contains valid timestamps (e.g., '2025-06-15 14:30:00').")
656
+
657
+ #Objective
658
+ #
659
+ #Objective 1
660
+ st.subheader("OBJECTIVE 2: Identify Fatigue Patterns at the Start, Middle, and End of Shifts by Hourly Categories by Date")
661
+ if 'start' in df.columns and not df.empty:
662
+ try:
663
+ df_local = df.copy()
664
+ df_local['hour'] = df_local['start'].dt.hour
665
+ df_local['date'] = df_local['start'].dt.normalize()
666
+ # Kategorisasi jam menggunakan fungsi global
667
+ df_local['hour_category'] = df_local['hour'].apply(hour_range_label_full)
668
+ color_map = {
669
+ 'Shift 1 Morning Early (6-9)': '#FFEB3B',
670
+ 'Shift 1 Morning Late (9-12)': '#FFC107',
671
+ 'Shift 1 Afternoon Early (12-15)':'#FF9800',
672
+ 'Shift 1 Afternoon Late (15-18)': '#F57C00',
673
+ 'Shift 2 Evening Early (18-21)': '#42A5F5',
674
+ 'Shift 2 Evening Late (21-24)': '#1976D2',
675
+ 'Shift 2 Dawn Early (0-3)': '#0288D1',
676
+ 'Shift 2 Dawn Late (3-6)': '#01579B',
677
+ 'Unknown': '#E0E0E0'
678
+ }
679
+ # Hitung jumlah fatigue per hari dan kategori jam
680
+ daily_by_cat = df_local.groupby(['date', 'hour_category']).size().reset_index(name='fatigue_count')
681
+ # --- TAMBAHAN: Ambil dominant hour_category per hari untuk Objective 3 ---
682
+ # Kita gunakan data df_local yang sudah memiliki hour_category
683
+ daily_dominant_cat = df_local.groupby('date')['hour_category'].agg(
684
+ lambda x: x.value_counts().idxmax()
685
+ ).reset_index()
686
+ daily_dominant_cat.rename(columns={'hour_category': 'dominant_hour_category'}, inplace=True)
687
+ # --- END TAMBAHAN ---
688
+ all_dates = pd.date_range(start=daily_by_cat['date'].min(), end=daily_by_cat['date'].max(), freq='D')
689
+ all_cats = list(color_map.keys())
690
+ full_index = pd.MultiIndex.from_product([all_dates, all_cats], names=['date', 'hour_category'])
691
+ daily_by_cat = daily_by_cat.set_index(['date', 'hour_category']).reindex(full_index, fill_value=0).reset_index()
692
+ daily_by_cat['day_of_week_num'] = daily_by_cat['date'].dt.dayofweek
693
+ daily_by_cat['week_start'] = daily_by_cat['date'] - pd.to_timedelta(daily_by_cat['day_of_week_num'], unit='d')
694
+ daily_by_cat['week_label'] = daily_by_cat['week_start'].dt.strftime('Week %U')
695
+ fig = px.bar(
696
+ daily_by_cat,
697
+ x='date',
698
+ y='fatigue_count',
699
+ color='hour_category',
700
+ title="Daily Fatigue Alerts by Detailed Hour Category",
701
+ color_discrete_map=color_map,
702
+ labels={'fatigue_count': 'Fatigue Alerts', 'date': 'Date'},
703
+ hover_data={'fatigue_count': True, 'week_label': True}
704
+ )
705
+ fig.update_layout(
706
+ barmode='stack',
707
+ xaxis_title="Date",
708
+ yaxis_title="Fatigue Alerts",
709
+ height=400,
710
+ legend_title="Hour Category"
711
+ )
712
+ unique_weeks = daily_by_cat['week_start'].unique()
713
+ shapes = []
714
+ week_labels = []
715
+ bg_colors = ['#f0e6ff', '#e6f0ff', '#e6fff0', '#fff0e6', '#ffe6e6', '#f0ffe6', '#e6e6ff']
716
+ for i, week in enumerate(sorted(unique_weeks)):
717
+ week_days = daily_by_cat[daily_by_cat['week_start'] == week]['date']
718
+ if len(week_days) > 0:
719
+ start_date = week_days.min()
720
+ end_date = week_days.max()
721
+ shapes.append(dict(
722
+ type="rect",
723
+ xref="x",
724
+ yref="paper",
725
+ x0=start_date,
726
+ x1=end_date,
727
+ y0=0,
728
+ y1=1,
729
+ fillcolor=bg_colors[i % len(bg_colors)],
730
+ opacity=0.2,
731
+ layer="below",
732
+ line_width=0,
733
+ ))
734
+ week_labels.append(
735
+ dict(
736
+ xref='x',
737
+ yref='paper',
738
+ x=start_date + (end_date - start_date) / 2,
739
+ y=1.02,
740
+ text=f"Week {week.strftime('%U')}",
741
+ showarrow=False,
742
+ font=dict(size=10),
743
+ xanchor='center',
744
+ yanchor='bottom'
745
+ )
746
+ )
747
+ fig.update_layout(shapes=shapes, annotations=week_labels)
748
+ st.plotly_chart(fig, use_container_width=True)
749
+ except Exception as e:
750
+ st.error(f"⚠️ Error in Daily Fatigue by Detailed Hour Category: {e}")
751
+ else:
752
+ st.info("ℹ️ Insufficient time data to display this visualization.")
753
+
754
+ # =================== OBJECTIVE 3: Daily Roster Insight per Week (Scatter Plot) =====================
755
+ # =================== OBJECTIVE 3: Daily Roster Insight per Week (Scatter Plot) =====================
756
+ st.subheader("OBJECTIVE 3: Daily Roster Insight per Week")
757
+ if not df.empty and col_operator in df.columns and col_shift and col_shift in df.columns:
758
+ try:
759
+ df['date'] = pd.to_datetime(df['date'])
760
+ # Hitung total event per hari
761
+ daily_totals = df.groupby('date').size().reset_index(name='total_count')
762
+ # Ambil dominant shift per hari
763
+ dominant_shift = df.groupby('date')[col_shift].agg(lambda x: x.value_counts().idxmax()).reset_index()
764
+ dominant_shift.rename(columns={col_shift: 'dominant_shift'}, inplace=True)
765
+ daily_analysis = daily_totals.merge(dominant_shift, on='date', how='left')
766
+ daily_analysis['week_start'] = daily_analysis['date'] - pd.to_timedelta(daily_analysis['date'].dt.weekday, unit='d')
767
+ summary = []
768
+ weekly_groups = daily_analysis.groupby('week_start')
769
+ for week_start, week_data in weekly_groups:
770
+ # Urutkan data berdasarkan tanggal dalam minggu ini
771
+ week_data_sorted = week_data.sort_values('date').reset_index(drop=True)
772
+ for idx, row in week_data_sorted.iterrows():
773
+ current_date = row['date']
774
+ current_shift = row['dominant_shift']
775
+ current_count = row['total_count']
776
+ # --- CARI DATA DI HARI SEBELUM DAN SESUDAH (BERDASARKAN TANGGAL, BUKAN INDEKS) ---
777
+ prev_date = current_date - pd.Timedelta(days=1)
778
+ next_date = current_date + pd.Timedelta(days=1)
779
+ # Cari shift di hari sebelumnya
780
+ prev_row = week_data_sorted[week_data_sorted['date'] == prev_date]
781
+ prev_shift = prev_row['dominant_shift'].iloc[0] if not prev_row.empty else None
782
+ # Cari shift di hari berikutnya
783
+ next_row = week_data_sorted[week_data_sorted['date'] == next_date]
784
+ next_shift = next_row['dominant_shift'].iloc[0] if not next_row.empty else None
785
+ # ---- LOGIKA REMARK BERDASARKAN PERUBAHAN SHIFT DALAM MINGGU YANG SAMA----
786
+ # Awal Roster: Ada data di hari sebelumnya (prev_date) dalam minggu, dan shift-nya berbeda
787
+ # Akhir Roster: Ada data di hari berikutnya (next_date) dalam minggu, dan shift-nya berbeda
788
+ # Bukan Awal/Akhir: Ada data di hari sebelumnya ATAU berikutnya, dan shift-nya sama
789
+ # Unknown: Tidak ada data di hari sebelumnya (prev_date) DAN tidak ada data di hari berikutnya (next_date) dalam minggu yang sama
790
+ if pd.isna(current_shift):
791
+ remark = "Unknown"
792
+ elif prev_shift is not None and prev_shift != current_shift:
793
+ remark = "Start of Roster"
794
+ elif next_shift is not None and next_shift != current_shift:
795
+ remark = "End of Roster"
796
+ elif (prev_shift is not None and prev_shift == current_shift) or (next_shift is not None and next_shift == current_shift):
797
+ remark = "Neither Start nor End of Roster"
798
+ elif prev_shift is None and next_shift is None:
799
+ remark = "Unknown"
800
+ else:
801
+ remark = "Unknown"
802
+ # --- Operator dari data df (YANG SUDAH DIFILTER) ---
803
+ df_orig_for_date = df[df['date']==current_date] # Gunakan df yang difilter
804
+ if not df_orig_for_date.empty:
805
+ peak_nik_counts = df_orig_for_date[col_operator].value_counts()
806
+ peak_nik = peak_nik_counts.index[0] if not peak_nik_counts.empty else "N/A"
807
+ else:
808
+ peak_nik = "N/A"
809
+ summary.append({
810
+ 'week_start': week_start,
811
+ 'date': current_date,
812
+ 'day_name': current_date.strftime('%A'),
813
+ 'total_count': current_count,
814
+ 'shift_category': current_shift,
815
+ 'remark': remark,
816
+ 'operator': peak_nik
817
+ })
818
+ summary_df = pd.DataFrame(summary)
819
+ if not summary_df.empty:
820
+ # Buat color map untuk remark (sesuai permintaan Anda)
821
+ color_map_remark = {
822
+ 'Start of Roster': '#ffcccc', # Merah muda
823
+ 'End of Roster': '#cce5ff', # Biru muda
824
+ 'Neither Start nor End of Roster': '#fff2cc', # Kuning muda
825
+ 'Unknown': '#c0c0c0' # Abu-abu muda
826
+ }
827
+ # ===== SCATTER PLOT (WARNA BERDASARKAN remark) =====
828
+ fig = px.scatter(
829
+ summary_df,
830
+ x='date',
831
+ y='remark',
832
+ color='remark', # Warna berdasarkan remark (satu-satunya kolom di sumbu Y)
833
+ color_discrete_map=color_map_remark, # Gunakan color_map_remark
834
+ size='total_count',
835
+ hover_data=['shift_category', 'operator', 'total_count'],
836
+ title="Daily Roster Status by Date and Trend",
837
+ category_orders={'remark': ['Start of Roster', 'End of Roster', 'Neither Start nor End of Roster', 'Unknown']}
838
+ )
839
+ fig.update_layout(height=450, xaxis_title="Date", yaxis_title="Roster Status")
840
+ st.plotly_chart(fig, use_container_width=True)
841
+ # ===== TABEL =====
842
+ table_df = summary_df.rename(columns={
843
+ 'week_start':'Week Start',
844
+ 'day_name':'Day',
845
+ 'date':'Date',
846
+ 'total_count':'Event Count',
847
+ 'shift_category':'Dominant Shift',
848
+ 'remark':'Roster Status',
849
+ 'operator':'Operator'
850
+ })
851
+ def highlight_remark(row):
852
+ colors = {
853
+ 'Start of Roster':'background-color: #ffcccc',
854
+ 'End of Roster':'background-color: #cce5ff',
855
+ 'Neither Start nor End of Roster':'background-color: #fff2cc',
856
+ 'Unknown':'background-color: #c0c0c0'
857
+ }
858
+ return [colors.get(row['Roster Status'], '') for _ in row]
859
+ st.dataframe(table_df.style.apply(highlight_remark, axis=1), use_container_width=True)
860
+ else:
861
+ st.info("ℹ️ No daily data to analyze.")
862
+ except Exception as e:
863
+ st.error(f"Error in Daily Roster Insight: {e}")
864
+ else:
865
+ if col_shift is None:
866
+ st.info("ℹ️ Shift column not found, cannot display Daily Roster Insight.")
867
+ elif col_shift not in df.columns:
868
+ st.info(f"ℹ️ Column '{col_shift}' not found in the filtered data, cannot display Daily Roster Insight.")
869
+ else:
870
+ st.info("ℹ️ Insufficient data (date, operator, or shift column not found) to display daily roster insight.")
871
+ # import plotly.express as px
872
+ # from datetime import datetime
873
+ st.subheader("OBJECTIVE 4: How is the Fatigue Event Risk Map per Operator?")
874
+ import math
875
+ import plotly.express as px
876
+ try:
877
+ # ============================
878
+ # 1. PREPROCESS & COPY DF
879
+ # ============================
880
+ df_local = df.copy()
881
+ df_local['date_only'] = df_local['start'].dt.normalize()
882
+ df_local['week_number'] = df_local['date_only'].dt.isocalendar().week
883
+ df_local['week_label'] = "Week " + df_local['week_number'].astype(str)
884
+ # Unit cleanup
885
+ df_local['unit_no'] = (
886
+ df_local[col_fleet_no]
887
+ .astype(str)
888
+ .str.split("-", n=1).str[-1].str.strip()
889
+ )
890
+ if 'id' not in df_local.columns:
891
+ st.error("❌ Column 'id' not found!")
892
+ st.stop()
893
+ # ============================
894
+ # 2. FILTER 8 MINGGU TERAKHIR
895
+ # ============================
896
+ df_local['week_num_int'] = df_local['week_number'].astype(int)
897
+ unique_weeks = sorted(df_local['week_num_int'].unique())
898
+ selected_last8 = unique_weeks[-8:] if len(unique_weeks) >= 8 else unique_weeks
899
+ df_8w = df_local[df_local['week_num_int'].isin(selected_last8)].copy()
900
+ # =====================================
901
+ # 3. FREQUENCY PER OPERATOR PER MINGGU
902
+ # =====================================
903
+ weekly_freq = (
904
+ df_8w.groupby([col_operator, 'week_label'])['id']
905
+ .nunique()
906
+ .reset_index(name='weekly_frequency')
907
+ )
908
+ # ============================================
909
+ # 4. SUMMARY FREQUENCY & CEIL AVERAGE FREQ
910
+ # ============================================
911
+ freq_summary = (
912
+ weekly_freq
913
+ .groupby(col_operator)['weekly_frequency']
914
+ .agg(['sum', 'mean', 'count'])
915
+ .reset_index()
916
+ .rename(columns={
917
+ 'sum': 'frequency_by_shift',
918
+ 'mean': 'avg_frequency',
919
+ 'count': 'frequency_by_weeks'
920
+ })
921
+ )
922
+ freq_summary['avg_frequency'] = freq_summary['avg_frequency'].apply(lambda x: math.ceil(x))
923
+ # ================================
924
+ # 5. RATA-RATA SPEED PER OPERATOR
925
+ # ================================
926
+ speed_summary = (
927
+ df_8w.groupby(col_operator)[col_speed]
928
+ .mean()
929
+ .reset_index(name='avg_speed')
930
+ )
931
+ # =====================
932
+ # 6. GABUNGKAN DATA
933
+ # =====================
934
+ risk_matrix = freq_summary.merge(speed_summary, on=col_operator, how='left')
935
+ risk_matrix = risk_matrix.rename(columns={col_operator: "Operator Name"})
936
+ # ================================
937
+ # 7. Tentukan Quadrant untuk Count
938
+ # ================================
939
+ def assign_quadrant(row):
940
+ if row['avg_frequency'] >= 2.5 and row['avg_speed'] >= 20:
941
+ return "Quadrant I – Prevent at Source"
942
+ elif row['avg_frequency'] < 2.5 and row['avg_speed'] >= 20:
943
+ return "Quadrant II – Detect & Monitor"
944
+ elif row['avg_frequency'] >= 2.5 and row['avg_speed'] < 20:
945
+ return "Quadrant III – Monitor"
946
+ else:
947
+ return "Quadrant IV – Low Control"
948
+ risk_matrix['quadrant'] = risk_matrix.apply(assign_quadrant, axis=1)
949
+ quadrant_count = risk_matrix['quadrant'].value_counts().reindex([
950
+ "Quadrant I – Prevent at Source",
951
+ "Quadrant II – Detect & Monitor",
952
+ "Quadrant III – Monitor",
953
+ "Quadrant IV – Low Control"
954
+ ], fill_value=0)
955
+ # ================================
956
+ # 8. VISUAL SCATTER PLOT
957
+ # ================================
958
+ fig = px.scatter(
959
+ risk_matrix,
960
+ x='avg_frequency',
961
+ y='avg_speed',
962
+ hover_name="Operator Name",
963
+ title="Operator Risk Matrix: Frequency vs Speed",
964
+ size=[12] * len(risk_matrix),
965
+ size_max=15
966
+ )
967
+ max_x = risk_matrix['avg_frequency'].max() + 1
968
+ max_y = risk_matrix['avg_speed'].max() + 1
969
+ # ================================
970
+ # 9. Quadrant Coloring
971
+ # ================================
972
+ fig.add_shape(type="rect", x0=2.5, x1=max_x, y0=20, y1=max_y,
973
+ fillcolor="rgba(255,0,0,0.25)", line_width=0) # I
974
+ fig.add_shape(type="rect", x0=0, x1=2.5, y0=20, y1=max_y,
975
+ fillcolor="rgba(255,150,50,0.25)", line_width=0) # II
976
+ fig.add_shape(type="rect", x0=2.5, x1=max_x, y0=0, y1=20,
977
+ fillcolor="rgba(255,200,200,0.25)", line_width=0) # III
978
+ fig.add_shape(type="rect", x0=0, x1=2.5, y0=0, y1=20,
979
+ fillcolor="rgba(0,120,255,0.15)", line_width=0) # IV
980
+ # Garis batas
981
+ fig.add_vline(x=2.5, line_dash="dash", line_color="black")
982
+ fig.add_hline(y=20, line_dash="dash", line_color="black")
983
+ # ================================
984
+ # 10. Tampilkan Count di Quadrant
985
+ # ================================
986
+ fig.add_annotation(
987
+ x=2.5 + (max_x-2.5)/2, y=20 + (max_y-20)/2,
988
+ text=f"<b>{quadrant_count['Quadrant I – Prevent at Source']}</b>",
989
+ showarrow=False, font=dict(size=20, color="red")
990
+ )
991
+ fig.add_annotation(
992
+ x=2.5/2, y=20 + (max_y-20)/2,
993
+ text=f"<b>{quadrant_count['Quadrant II – Detect & Monitor']}</b>",
994
+ showarrow=False, font=dict(size=20, color="orange")
995
+ )
996
+ fig.add_annotation(
997
+ x=2.5 + (max_x-2.5)/2, y=0 + (20-0)/2,
998
+ text=f"<b>{quadrant_count['Quadrant III – Monitor']}</b>",
999
+ showarrow=False, font=dict(size=20, color="darkred")
1000
+ )
1001
+ fig.add_annotation(
1002
+ x=2.5/2, y=0 + (20-0)/2,
1003
+ text=f"<b>{quadrant_count['Quadrant IV – Low Control']}</b>",
1004
+ showarrow=False, font=dict(size=20, color="blue")
1005
+ )
1006
+ # ================================
1007
+ # 11. Label Quadrant
1008
+ # ================================
1009
+ fig.add_annotation(x=4, y=max_y-2, text="Quadrant I<br>Prevent at Source",
1010
+ showarrow=False, font=dict(size=12))
1011
+ fig.add_annotation(x=1.25, y=max_y-2, text="Quadrant II<br>Detect & Monitor",
1012
+ showarrow=False, font=dict(size=12))
1013
+ fig.add_annotation(x=4, y=5, text="Quadrant III<br>Monitor",
1014
+ showarrow=False, font=dict(size=12))
1015
+ fig.add_annotation(x=1.25, y=5, text="Quadrant IV<br>Low Control",
1016
+ showarrow=False, font=dict(size=12))
1017
+ fig.update_xaxes(dtick=1)
1018
+ fig.update_layout(
1019
+ xaxis_title="Average Frequency (Ceil)",
1020
+ yaxis_title="Average Speed (km/h)",
1021
+ height=650
1022
+ )
1023
+ st.plotly_chart(fig, use_container_width=True)
1024
+ # ================================
1025
+ # 12. DISPLAY TABLE
1026
+ # ================================
1027
+ st.subheader("Operator Risk Summary Table (8 Weeks Observed)")
1028
+ table_display = (
1029
+ risk_matrix[[
1030
+ "Operator Name",
1031
+ "frequency_by_shift",
1032
+ "avg_frequency",
1033
+ "frequency_by_weeks",
1034
+ "avg_speed",
1035
+ "quadrant"
1036
+ ]]
1037
+ .rename(columns={
1038
+ "frequency_by_shift": "Frequency by Shift",
1039
+ "avg_frequency": "Avg Frequency",
1040
+ "frequency_by_weeks": "Frequency by Weeks",
1041
+ "avg_speed": "Avg Speed"
1042
+ })
1043
+ )
1044
+ st.dataframe(
1045
+ table_display.sort_values("Avg Frequency", ascending=False),
1046
+ use_container_width=True
1047
+ )
1048
+ except Exception as e:
1049
+ st.error(f"⚠️ Error Risk Map Objective 4: {e}")
1050
+ st.exception(e)
1051
+
1052
+ # ... (kode sebelumnya tetap sama) ...
1053
+ # ... (kode sebelumnya tetap sama) ...
1054
+ # ... (kode sebelumnya tetap sama) ...
1055
+
1056
+ # =================== OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard =====================
1057
+
1058
+ # ... (kode sebelumnya tetap sama) ...
1059
+
1060
+ # =================== OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard =====================
1061
+ st.subheader("OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard (Weekly Average Events & Trend Analysis)")
1062
+ # Custom CSS untuk tampilan ala market saham yang sangat fancy dan profesional
1063
+ st.markdown("""
1064
+ <style>
1065
+ .big-title {
1066
+ font-size: 28px;
1067
+ font-weight: bold;
1068
+ color: #ffffff;
1069
+ text-align: center;
1070
+ margin-bottom: 10px;
1071
+ background: linear-gradient(135deg, #2c3e50, #1a252c);
1072
+ padding: 15px;
1073
+ border-radius: 10px;
1074
+ box-shadow: 0 4px 15px rgba(0,0,0,0.3);
1075
+ }
1076
+ .subnote {
1077
+ font-size: 16px;
1078
+ color: #7f8c8d;
1079
+ text-align: center;
1080
+ margin-bottom: 20px;
1081
+ }
1082
+ .section-divider {
1083
+ height: 2px;
1084
+ background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c);
1085
+ margin: 20px 0;
1086
+ }
1087
+ .legend-container {
1088
+ display: flex;
1089
+ gap: 15px;
1090
+ margin: 15px 0;
1091
+ }
1092
+ .legend-box {
1093
+ background: white;
1094
+ border: 1px solid #ddd;
1095
+ border-radius: 8px;
1096
+ padding: 15px;
1097
+ flex: 1;
1098
+ min-width: 300px;
1099
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
1100
+ }
1101
+ .legend-title {
1102
+ font-weight: bold;
1103
+ color: #2c3e50;
1104
+ margin-bottom: 10px;
1105
+ font-size: 14px;
1106
+ border-bottom: 1px solid #eee;
1107
+ padding-bottom: 5px;
1108
+ }
1109
+ .legend-item {
1110
+ display: flex;
1111
+ align-items: center;
1112
+ margin: 5px 0;
1113
+ font-size: 12px;
1114
+ }
1115
+ .legend-color {
1116
+ width: 18px;
1117
+ height: 18px;
1118
+ border-radius: 3px;
1119
+ margin-right: 8px;
1120
+ border: 1px solid #ccc;
1121
+ }
1122
+ .ai-insight-box {
1123
+ background: #f8f9fa;
1124
+ border: 1px solid #dee2e6;
1125
+ border-radius: 8px;
1126
+ padding: 15px;
1127
+ margin: 10px 0;
1128
+ color: #2c3e50;
1129
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1130
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1131
+ }
1132
+ .ai-insight-title {
1133
+ font-weight: bold;
1134
+ color: #2c3e50;
1135
+ margin-bottom: 8px;
1136
+ font-size: 14px;
1137
+ background: #e9ecef;
1138
+ padding: 8px;
1139
+ border-radius: 5px;
1140
+ border-left: 4px solid #495057;
1141
+ }
1142
+ .trend-up {
1143
+ color: #e74c3c;
1144
+ font-weight: bold;
1145
+ }
1146
+ .trend-down {
1147
+ color: #27ae60;
1148
+ font-weight: bold;
1149
+ }
1150
+ .recommendation-box {
1151
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1152
+ border: 1px solid #4a5568;
1153
+ border-radius: 8px;
1154
+ padding: 15px;
1155
+ margin: 10px 0;
1156
+ color: white;
1157
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1158
+ box-shadow: 0 4px 15px rgba(0,0,0,0.1);
1159
+ }
1160
+ .recommendation-title {
1161
+ font-weight: bold;
1162
+ color: white;
1163
+ margin-bottom: 8px;
1164
+ font-size: 14px;
1165
+ background: rgba(255,255,255,0.2);
1166
+ padding: 8px;
1167
+ border-radius: 5px;
1168
+ border-left: 4px solid white;
1169
+ }
1170
+ .recommendation-reason {
1171
+ font-size: 12px;
1172
+ margin-top: 10px;
1173
+ padding: 8px;
1174
+ background: rgba(255,255,255,0.1);
1175
+ border-radius: 5px;
1176
+ border-left: 3px solid rgba(255,255,255,0.3);
1177
+ }
1178
+ </style>
1179
+ """, unsafe_allow_html=True)
1180
+
1181
+ # ===============================================================
1182
+ # LOGIC UTAMA
1183
+ # ===============================================================
1184
+ if df.empty:
1185
+ st.info("No data available after applying filters.")
1186
+ else:
1187
+ try:
1188
+ # Validasi kolom
1189
+ required = [col_operator, col_fleet_type, "start"]
1190
+ if not all(c in df.columns for c in required if c is not None):
1191
+ st.warning("Required columns (operator, fleet_type, start) are missing.")
1192
+ st.stop()
1193
+
1194
+ df_op = df[[col_operator, col_fleet_type, "start"]].dropna()
1195
+ if df_op.empty:
1196
+ st.info("No operator data after filtering.")
1197
+ st.stop()
1198
+
1199
+ # Pastikan col_operator bukan None sebelum digunakan
1200
+ if col_operator is None:
1201
+ st.error(f"Operator column could not be auto-detected. Please check your data.")
1202
+ st.stop()
1203
+
1204
+ df_op["year_week"] = df_op["start"].dt.strftime("%Y-W%U")
1205
+
1206
+ # Fuzzy match fleet names
1207
+ fleet_clean = df_op[col_fleet_type].str.strip().str.upper()
1208
+ df_op["is_ob"] = fleet_clean.str.contains(r"OB HAULLER", na=False)
1209
+ df_op["is_coal"] = fleet_clean.str.contains(r"HAULING COAL", na=False)
1210
+
1211
+ ob_data = df_op[df_op["is_ob"]]
1212
+ coal_data = df_op[df_op["is_coal"]]
1213
+
1214
+ # Fungsi hitung top 10 (untuk bar chart) - berdasarkan weekly avg events tertinggi
1215
+ def get_top10_with_slope(data):
1216
+ if data.empty:
1217
+ st.warning("Data is empty in get_top10_with_slope.")
1218
+ return pd.DataFrame()
1219
+ # Pastikan col_operator tidak None dan ada di data
1220
+ if col_operator is None or col_operator not in data.columns:
1221
+ st.error(f"Operator column '{col_operator}' not found in data subset for get_top10.")
1222
+ return pd.DataFrame()
1223
+
1224
+ weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1225
+ metrics = []
1226
+ try:
1227
+ for nik, grp in weekly.groupby(col_operator):
1228
+ # Lewati jika nik adalah None
1229
+ if pd.isna(nik):
1230
+ continue
1231
+ grp = grp.sort_values("year_week")
1232
+ counts = grp["weekly_sum"].values
1233
+ weeks = np.arange(len(counts))
1234
+ weekly_avg = counts.mean()
1235
+ total_events = counts.sum()
1236
+ n_weeks = len(counts)
1237
+ if n_weeks >= 2:
1238
+ x_mean = weeks.mean()
1239
+ y_mean = counts.mean()
1240
+ numerator = np.sum((weeks - x_mean) * (counts - y_mean))
1241
+ denominator = np.sum((weeks - x_mean) ** 2)
1242
+ slope = numerator / denominator if denominator != 0 else 0.0
1243
+ else:
1244
+ slope = 0.0
1245
+ metrics.append({
1246
+ col_operator: nik,
1247
+ "weekly_avg": weekly_avg,
1248
+ "slope": slope,
1249
+ "total_events": total_events,
1250
+ "n_weeks": n_weeks
1251
+ })
1252
+ except KeyError as e:
1253
+ st.error(f"KeyError in get_top10_with_slope: {e}. This might happen if the operator column contains invalid data types or unexpected values.")
1254
+ return pd.DataFrame()
1255
+ # Ambil top 10 berdasarkan weekly_avg (descending order)
1256
+ if not metrics:
1257
+ st.warning("No valid operator data found for slope calculation in get_top10.")
1258
+ return pd.DataFrame()
1259
+ return pd.DataFrame(metrics).nlargest(10, "weekly_avg")
1260
+
1261
+ top_ob = get_top10_with_slope(ob_data)
1262
+ top_coal = get_top10_with_slope(coal_data)
1263
+
1264
+ # Fungsi hitung semua operator (untuk summary)
1265
+ def get_all_operators_with_slope(data):
1266
+ if data.empty:
1267
+ st.warning("Data is empty in get_all_operators_with_slope.")
1268
+ return pd.DataFrame()
1269
+ # Pastikan col_operator tidak None dan ada di data
1270
+ if col_operator is None or col_operator not in data.columns:
1271
+ st.error(f"Operator column '{col_operator}' not found in data subset for get_all.")
1272
+ return pd.DataFrame()
1273
+
1274
+ weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1275
+ metrics = []
1276
+ try:
1277
+ for nik, grp in weekly.groupby(col_operator):
1278
+ # Lewati jika nik adalah None
1279
+ if pd.isna(nik):
1280
+ continue
1281
+ grp = grp.sort_values("year_week")
1282
+ counts = grp["weekly_sum"].values
1283
+ weeks = np.arange(len(counts))
1284
+ weekly_avg = counts.mean()
1285
+ total_events = counts.sum()
1286
+ n_weeks = len(counts)
1287
+ if n_weeks >= 2:
1288
+ x_mean = weeks.mean()
1289
+ y_mean = counts.mean()
1290
+ numerator = np.sum((weeks - x_mean) * (counts - y_mean))
1291
+ denominator = np.sum((weeks - x_mean) ** 2)
1292
+ slope = numerator / denominator if denominator != 0 else 0.0
1293
+ else:
1294
+ slope = 0.0
1295
+ metrics.append({
1296
+ col_operator: nik,
1297
+ "weekly_avg": weekly_avg,
1298
+ "slope": slope,
1299
+ "total_events": total_events,
1300
+ "n_weeks": n_weeks
1301
+ })
1302
+ except KeyError as e:
1303
+ st.error(f"KeyError in get_all_operators_with_slope: {e}. This might happen if the operator column contains invalid data types or unexpected values.")
1304
+ return pd.DataFrame()
1305
+ if not metrics:
1306
+ st.warning("No valid operator data found for slope calculation in get_all.")
1307
+ return pd.DataFrame()
1308
+ return pd.DataFrame(metrics)
1309
+
1310
+ all_ob = get_all_operators_with_slope(ob_data)
1311
+ all_coal = get_all_operators_with_slope(coal_data)
1312
+
1313
+ # ===============================================================
1314
+ # LEGEND DI LUAR CHART - 3 KOTAK DENGAN UKURAN SAMA
1315
+ # ===============================================================
1316
+ st.subheader("Risk Gradient Legend")
1317
+ st.markdown("""
1318
+ <div class="legend-container">
1319
+ <div class="legend-box">
1320
+ <div class="legend-title">Worsening Trends (Positive Slope):</div>
1321
+ <div class="legend-item">
1322
+ <div class="legend-color" style="background-color: #d32f2f;"></div>
1323
+ <span>Very High Risk (≥1.5)</span>
1324
+ </div>
1325
+ <div class="legend-item">
1326
+ <div class="legend-color" style="background-color: #e57373;"></div>
1327
+ <span>High Risk (1.0-1.5)</span>
1328
+ </div>
1329
+ <div class="legend-item">
1330
+ <div class="legend-color" style="background-color: #ef9a9a;"></div>
1331
+ <span>Moderate Risk (0.5-1.0)</span>
1332
+ </div>
1333
+ <div class="legend-item">
1334
+ <div class="legend-color" style="background-color: #ffcdd2;"></div>
1335
+ <span>Slight Risk (0-0.5)</span>
1336
+ </div>
1337
+ </div>
1338
+ <div class="legend-box">
1339
+ <div class="legend-title">Improving Trends (Negative Slope):</div>
1340
+ <div class="legend-item">
1341
+ <div class="legend-color" style="background-color: #388e3c;"></div>
1342
+ <span>Excellent Improvement (≤-1.5)</span>
1343
+ </div>
1344
+ <div class="legend-item">
1345
+ <div class="legend-color" style="background-color: #81c784;"></div>
1346
+ <span>Great Improvement (-1.5 to -1.0)</span>
1347
+ </div>
1348
+ <div class="legend-item">
1349
+ <div class="legend-color" style="background-color: #a5d6a7;"></div>
1350
+ <span>Good Improvement (-1.0 to -0.5)</span>
1351
+ </div>
1352
+ <div class="legend-item">
1353
+ <div class="legend-color" style="background-color: #c8e6c9;"></div>
1354
+ <span>Slight Improvement (-0.5-0)</span>
1355
+ </div>
1356
+ </div>
1357
+ <div class="legend-box">
1358
+ <div class="legend-title">Stable Trend (Zero Slope):</div>
1359
+ <div class="legend-item">
1360
+ <div class="legend-color" style="background-color: #95a5a6;"></div>
1361
+ <span>Stable (0)</span>
1362
+ </div>
1363
+ <br>
1364
+ <i>Note: Only appears when operator data shows consistent behavior within a single week observation period.</i>
1365
+ </div>
1366
+ </div>
1367
+ """, unsafe_allow_html=True)
1368
+
1369
+ # ===============================================================
1370
+ # PLOT FUNCTION (Bar Chart with Risk Gradient Colors) - PERBAIKAN DI SINI
1371
+ # ===============================================================
1372
+ def plot_chart(data, title):
1373
+ if data.empty:
1374
+ fig = go.Figure()
1375
+ fig.add_annotation(
1376
+ text="No Data",
1377
+ x=0.5, y=0.5,
1378
+ showarrow=False,
1379
+ font_size=16
1380
+ )
1381
+ # Gunakan update_layout untuk menetapkan judul
1382
+ fig.update_layout(height=350, title=title)
1383
+ return fig
1384
+
1385
+ # Urutkan data berdasarkan weekly_avg dari besar ke kecil
1386
+ data_sorted = data.sort_values('weekly_avg', ascending=False)
1387
+
1388
+ # Kategorisasi warna berdasarkan slope dengan gradasi yang berbeda
1389
+ def get_color(slope):
1390
+ if slope == 0:
1391
+ return "#95a5a6" # Abu-abu (Stabil)
1392
+ elif slope > 0:
1393
+ # Gradasi merah untuk slope positif
1394
+ if slope < 0.5:
1395
+ return "#ffcdd2" # Merah sangat muda
1396
+ elif slope < 1.0:
1397
+ return "#ef9a9a" # Merah muda
1398
+ elif slope < 1.5:
1399
+ return "#e57373" # Merah sedang
1400
+ else:
1401
+ return "#d32f2f" # Merah gelap
1402
+ else: # slope < 0
1403
+ # Gradasi hijau untuk slope negatif
1404
+ if slope > -0.5:
1405
+ return "#c8e6c9" # Hijau sangat muda
1406
+ elif slope > -1.0:
1407
+ return "#a5d6a7" # Hijau muda
1408
+ elif slope > -1.5:
1409
+ return "#81c784" # Hijau sedang
1410
+ else:
1411
+ return "#388e3c" # Hijau gelap
1412
+
1413
+ colors = [get_color(s) for s in data_sorted["slope"]]
1414
+
1415
+ # Buat trace bar, TANPA argumen 'title'
1416
+ bar_trace = go.Bar(
1417
+ x=data_sorted[col_operator].astype(str),
1418
+ y=data_sorted["weekly_avg"],
1419
+ marker=dict(
1420
+ color=colors,
1421
+ line=dict(width=2, color="rgba(0,0,0,0.2)")
1422
+ ),
1423
+ text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]],
1424
+ textposition="outside",
1425
+ hovertemplate=(
1426
+ "<b>%{x}</b><br>" +
1427
+ "Weekly Avg: %{y:.2f}<br>" +
1428
+ "Trend Slope: %{customdata[0]:+.3f}<br>" +
1429
+ "Total Events: %{customdata[1]}<br>" +
1430
+ "Weeks Active: %{customdata[2]}<br>" +
1431
+ "<extra></extra>"
1432
+ ),
1433
+ customdata=np.stack([data_sorted["slope"], data_sorted["total_events"], data_sorted["n_weeks"]], axis=-1)
1434
+ )
1435
+
1436
+ # Buat figure dan tambahkan trace
1437
+ fig = go.Figure(bar_trace)
1438
+
1439
+ # Gunakan update_layout untuk menetapkan judul dan layout lainnya
1440
+ fig.update_layout(
1441
+ title=f"<b>{title}</b>",
1442
+ title_x=0.5, # Pusatkan judul
1443
+ height=450,
1444
+ margin=dict(l=50, r=20, t=60, b=120),
1445
+ xaxis_title="<b>Operator ID</b>",
1446
+ yaxis_title="<b>Weekly Avg Events</b>",
1447
+ font=dict(family="Segoe UI", size=12),
1448
+ bargap=0.3,
1449
+ plot_bgcolor="rgba(0,0,0,0)",
1450
+ paper_bgcolor="rgba(0,0,0,0)"
1451
+ )
1452
+ return fig
1453
+
1454
+ # ===============================================================
1455
+ # TAMPILKAN BAR CHART
1456
+ # ===============================================================
1457
+ col1, col2 = st.columns(2)
1458
+ with col1:
1459
+ st.plotly_chart(plot_chart(top_ob, "OB HAULER Operators (Risk Gradient)"), use_container_width=True)
1460
+ with col2:
1461
+ st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Risk Gradient)"), use_container_width=True)
1462
+
1463
+ # ===============================================================
1464
+ # AI INSIGHTS - DIBEDAKAN UNTUK OB HAULER DAN COAL HAULING - SEKARANG BERSEBELAHAN
1465
+ # ===============================================================
1466
+ st.markdown("---")
1467
+ st.subheader("Data Insight Automation")
1468
+
1469
+ # Gunakan kolom untuk menampilkan analisis secara bersebelahan
1470
+ col_insight1, col_insight2 = st.columns(2)
1471
+
1472
+ # Insight untuk OB HAULER - Ditampilkan di kolom kiri
1473
+ with col_insight1:
1474
+ if not top_ob.empty:
1475
+ st.markdown("### OB HAULER Analysis")
1476
+ ob_worsening = len(top_ob[top_ob['slope'] > 0])
1477
+ ob_improving = len(top_ob[top_ob['slope'] < 0])
1478
+ ob_avg_risk = top_ob['weekly_avg'].mean()
1479
+ ob_max_risk = top_ob['weekly_avg'].max()
1480
+ ob_insights = []
1481
+ if ob_worsening > ob_improving:
1482
+ ob_insights.append(f"{ob_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends, indicating potential fatigue issues in this fleet type.")
1483
+ else:
1484
+ ob_insights.append(f"{ob_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>, suggesting effective fatigue management strategies.")
1485
+ ob_insights.append(f"Average risk level among top 10 operators is {ob_avg_risk:.2f} events per week with maximum {ob_max_risk:.2f}.")
1486
+
1487
+ for insight in ob_insights:
1488
+ st.markdown(f"""
1489
+ <div class="ai-insight-box">
1490
+ <div class="ai-insight-title">Risk Analysis</div>
1491
+ <p>{insight}</p>
1492
+ </div>
1493
+ """, unsafe_allow_html=True)
1494
+ else:
1495
+ st.info("No OB HAULER data for analysis.")
1496
+
1497
+ # Insight untuk HAULING COAL - Ditampilkan di kolom kanan
1498
+ with col_insight2:
1499
+ if not top_coal.empty:
1500
+ st.markdown("### HAULING COAL Analysis")
1501
+ coal_worsening = len(top_coal[top_coal['slope'] > 0])
1502
+ coal_improving = len(top_coal[top_coal['slope'] < 0])
1503
+ coal_avg_risk = top_coal['weekly_avg'].mean()
1504
+ coal_max_risk = top_coal['weekly_avg'].max()
1505
+ coal_insights = []
1506
+ if coal_worsening > coal_improving:
1507
+ coal_insights.append(f"{coal_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends, requiring immediate attention.")
1508
+ else:
1509
+ coal_insights.append(f"{coal_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>, indicating positive trends in safety management.")
1510
+ coal_insights.append(f"Average risk level among top 10 operators is {coal_avg_risk:.2f} events per week with maximum {coal_max_risk:.2f}.")
1511
+
1512
+ for insight in coal_insights:
1513
+ st.markdown(f"""
1514
+ <div class="ai-insight-box">
1515
+ <div class="ai-insight-title">Risk Analysis</div>
1516
+ <p>{insight}</p>
1517
+ </div>
1518
+ """, unsafe_allow_html=True)
1519
+ else:
1520
+ st.info("No HAULING COAL data for analysis.")
1521
+
1522
+ # ===============================================================
1523
+ # AI RECOMMENDATIONS - JUGA BERSEBELAHAN
1524
+ # ===============================================================
1525
+ st.markdown("---")
1526
+ st.subheader("AI Recommendations")
1527
+
1528
+ # Gunakan kolom untuk menampilkan rekomendasi secara bersebelahan
1529
+ col_rec1, col_rec2 = st.columns(2)
1530
+
1531
+ def generate_recommendations(top_ob, top_coal):
1532
+ recommendations = {}
1533
+ if not top_ob.empty:
1534
+ ob_worsening = len(top_ob[top_ob['slope'] > 0])
1535
+ ob_avg_risk = top_ob['weekly_avg'].mean()
1536
+ if ob_worsening > 5: # Lebih dari setengah
1537
+ recommendations['ob'] = "Implement immediate fatigue monitoring protocols for operators showing worsening trends."
1538
+ reason_ob = "High percentage of operators showing increasing risk trends indicates potential systemic fatigue issues requiring immediate intervention."
1539
+ elif ob_avg_risk > 10: # High average risk
1540
+ recommendations['ob'] = "Consider workload redistribution to reduce average risk levels."
1541
+ reason_ob = "High average risk levels suggest operational adjustments are needed to maintain optimal safety standards."
1542
+ else:
1543
+ recommendations['ob'] = "Continue current safety protocols with enhanced monitoring for early detection."
1544
+ reason_ob = "Stable risk profile indicates current protocols are effective, but continuous monitoring ensures early detection of potential issues."
1545
+ recommendations['ob_reason'] = reason_ob
1546
+
1547
+ if not top_coal.empty:
1548
+ coal_worsening = len(top_coal[top_coal['slope'] > 0])
1549
+ coal_avg_risk = top_coal['weekly_avg'].mean()
1550
+ if coal_worsening > 5: # Lebih dari setengah
1551
+ recommendations['coal'] = "Implement immediate fatigue monitoring protocols for operators showing worsening trends."
1552
+ reason_coal = "High percentage of operators showing increasing risk trends indicates potential systemic fatigue issues requiring immediate intervention."
1553
+ elif coal_avg_risk > 10: # High average risk
1554
+ recommendations['coal'] = "Consider workload redistribution to reduce average risk levels."
1555
+ reason_coal = "High average risk levels suggest operational adjustments are needed to maintain optimal safety standards."
1556
+ else:
1557
+ recommendations['coal'] = "Continue current safety protocols with enhanced monitoring for early detection."
1558
+ reason_coal = "Stable risk profile indicates current protocols are effective, but continuous monitoring ensures early detection of potential issues."
1559
+ recommendations['coal_reason'] = reason_coal
1560
+
1561
+ return recommendations
1562
+
1563
+ ai_recommendations = generate_recommendations(top_ob, top_coal)
1564
+
1565
+ # Recommendation untuk OB HAULER - Ditampilkan di kolom kiri
1566
+ with col_rec1:
1567
+ if 'ob' in ai_recommendations:
1568
+ st.markdown("### OB HAULER Recommendations")
1569
+ st.markdown(f"""
1570
+ <div style="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); height: 150px; display: flex; flex-direction: column; justify-content: space-between;">
1571
+ <div style="font-weight: bold; font-size: 14px; background: rgba(255,255,255,0.2); padding: 8px; border-radius: 5px; border-left: 4px solid white;">Recommendation</div>
1572
+ <div style="padding-top: 8px; font-size: 14px;">{ai_recommendations['ob']}</div>
1573
+ <div style="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);">AI Reasoning: {ai_recommendations['ob_reason']}</div>
1574
+ </div>
1575
+ """, unsafe_allow_html=True)
1576
+ else:
1577
+ st.info("No OB HAULER recommendations generated.")
1578
+
1579
+ # Recommendation untuk HAULING COAL - Ditampilkan di kolom kanan
1580
+ with col_rec2:
1581
+ if 'coal' in ai_recommendations:
1582
+ st.markdown("### HAULING COAL Recommendations")
1583
+ st.markdown(f"""
1584
+ <div style="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); height: 150px; display: flex; flex-direction: column; justify-content: space-between;">
1585
+ <div style="font-weight: bold; font-size: 14px; background: rgba(255,255,255,0.2); padding: 8px; border-radius: 5px; border-left: 4px solid white;">Recommendation</div>
1586
+ <div style="padding-top: 8px; font-size: 14px;">{ai_recommendations['coal']}</div>
1587
+ <div style="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);">AI Reasoning: {ai_recommendations['coal_reason']}</div>
1588
+ </div>
1589
+ """, unsafe_allow_html=True)
1590
+ else:
1591
+ st.info("No HAULING COAL recommendations generated.")
1592
+
1593
+ except Exception as e:
1594
+ st.error(f"Error in Top 10 Operator analysis: {str(e)}")
1595
+ st.code(f"Error: {e}", language="python")
1596
+
1597
+ # ... (kode setelah Objective 5 tetap sama) ...
1598
+
1599
+ # =================== OBJECTIVE 6: Automated Insights & AI Recommendations =====================
1600
+ st.subheader("OBJECTIVE 6: Automated Insight Summary & AI Recommendations")
1601
+
1602
+ # Membagi tampilan menjadi dua kolom
1603
+ col_insights, col_recs = st.columns(2)
1604
+
1605
+ # Kolom kiri: Insights by Advanced Analytics
1606
+ with col_insights:
1607
+ st.subheader("Insights by Advanced Analytics")
1608
+
1609
+ # 1. Critical Hour Analysis (2-5 AM)
1610
+ critical_hours = [2, 3, 4, 5]
1611
+ critical_alerts = df[df['hour'].isin(critical_hours)]
1612
+ critical_pct = (len(critical_alerts) / len(df)) * 100 if len(df) > 0 else 0
1613
+
1614
+ st.markdown(f"**Critical Hour Risk (2-5 AM)**")
1615
+ # Use conditional formatting for background color
1616
+ bg_color = "#ffcccc" if critical_pct > 50 else "#ffebcc" if critical_pct > 25 else "#ffffcc" if critical_pct > 10 else "#e6ffe6"
1617
+ st.markdown(f'<div style="background-color: {bg_color}; padding: 10px; border-radius: 5px;">Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}% of total alerts)</div>', unsafe_allow_html=True)
1618
+ if critical_pct > 10: # If more than 10% of alerts happen in critical hours
1619
+ st.warning(f"High risk: {critical_pct:.1f}% of fatigue alerts occur during critical hours (2-5 AM). This is a known circadian dip period.")
1620
+ else:
1621
+ st.info(f"{critical_pct:.1f}% of alerts occur during critical hours. This is within acceptable range.")
1622
+
1623
+ # 2. High-Speed Fatigue Analysis (Environmental Risk)
1624
+ if col_speed and col_speed in df.columns:
1625
+ high_speed_threshold = df[col_speed].quantile(0.75) if not df[col_speed].dropna().empty else 0 # Handle empty series
1626
+ high_speed_fatigue = df[df[col_speed] >= high_speed_threshold] if high_speed_threshold > 0 else pd.DataFrame()
1627
+ high_speed_pct = (len(high_speed_fatigue) / len(df)) * 100 if len(df) > 0 else 0
1628
+
1629
+ st.markdown(f"**High-Speed Fatigue Risk (Speed > {high_speed_threshold:.0f} km/h)**")
1630
+ st.metric("High-Speed Fatigue Events", f"{len(high_speed_fatigue)}", f"{high_speed_pct:.1f}% of total alerts")
1631
+ if high_speed_pct > 20: # If more than 20% of alerts happen at high speed
1632
+ st.warning(f"High risk: {high_speed_pct:.1f}% of fatigue alerts occur at high speeds. This increases accident severity potential.")
1633
+ else:
1634
+ st.info(f"{high_speed_pct:.1f}% of alerts occur at high speeds. This is within acceptable range.")
1635
+ else:
1636
+ st.info("Speed data not available for High-Speed Fatigue Analysis.")
1637
+
1638
+ # 3. Shift Pattern Analysis
1639
+ if col_shift and col_shift in df.columns:
1640
+ shift_counts = df[col_shift].value_counts()
1641
+ # shift_alerts_by_hour = df.groupby([col_shift, 'hour']).size().reset_index(name='alerts') # Tidak digunakan dalam tampilan ini
1642
+
1643
+ st.markdown(f"**Shift Pattern Risk**")
1644
+ for shift_val in shift_counts.index:
1645
+ shift_pct = (shift_counts[shift_val] / len(df)) * 100
1646
+ st.metric(f"Shift {shift_val} Alerts", f"{shift_counts[shift_val]}", f"{shift_pct:.1f}% of total alerts")
1647
+ if shift_pct > 50: # If one shift has more than 50% of alerts
1648
+ st.warning(f"Shift {shift_val} has disproportionately high alerts ({shift_pct:.1f}%). Review shift scheduling and workload.")
1649
+ else:
1650
+ st.info(f"Shift {shift_val} alert distribution is acceptable ({shift_pct:.1f}%).")
1651
+ else:
1652
+ st.info("Shift data not available for Shift Pattern Analysis.")
1653
+
1654
+ # 4. Operator Risk Profiling
1655
+ if col_operator and col_operator in df.columns:
1656
+ operator_alerts = df[col_operator].value_counts()
1657
+ top_risk_operators = operator_alerts.head(5) # Top 5 operators by alerts
1658
+
1659
+ st.markdown(f"**High-Risk Operator Identification**")
1660
+ for op_name, count in top_risk_operators.items():
1661
+ op_pct = (count / len(df)) * 100
1662
+ st.metric(f"Operator: {op_name}", f"{count} alerts", f"{op_pct:.1f}% of total alerts")
1663
+ if op_pct > 5: # If an operator has more than 5% of all alerts
1664
+ st.warning(f"Operator {op_name} has high fatigue risk ({op_pct:.1f}% of alerts). Consider coaching or rest plan.")
1665
+ else:
1666
+ st.info(f"Operator {op_name} fatigue risk is within acceptable range ({op_pct:.1f}%).")
1667
+ else:
1668
+ st.info("Operator data not available for Operator Risk Profiling.")
1669
+
1670
+
1671
+ # Kolom kanan: AI Recommendations
1672
+ with col_recs:
1673
+ st.subheader("AI Recommendations")
1674
+ ai_recs = []
1675
+ insights_found = [] # Untuk menyimpan insight yang ditemukan
1676
+
1677
+ # Peak hour
1678
+ if "hour" in df.columns and not df.empty:
1679
+ peak_hour = df["hour"].value_counts().idxmax()
1680
+ critical_hours = [2, 3, 4, 5]
1681
+ if peak_hour in critical_hours:
1682
+ insights_found.append(f" Most fatigue risk occurs at **{peak_hour}:00** — during critical circadian low period (2-5 AM). Consider enhanced monitoring.")
1683
+ else:
1684
+ insights_found.append(f"Most fatigue risk occurs at **{peak_hour}:00** — likely due to circadian drop.")
1685
+
1686
+ # Risk shift
1687
+ if col_shift and not df.empty:
1688
+ worst_shift = df[col_shift].value_counts().idxmax()
1689
+ insights_found.append(f" Highest fatigue recorded in **Shift {worst_shift}** — review scheduling & workload.")
1690
+
1691
+ # Worst operator
1692
+ if col_operator and not df.empty:
1693
+ worst_operator = df[col_operator].value_counts().idxmax()
1694
+ insights_found.append(f" Operator at highest risk: **{worst_operator}** — suggested coaching or rest plan.")
1695
+
1696
+ # Duration risk
1697
+ if "duration_sec" in df.columns and not df.empty:
1698
+ avg_duration = df["duration_sec"].mean()
1699
+ if not pd.isna(avg_duration) and avg_duration > 10:
1700
+ insights_found.append(" Long fatigue event duration suggests slow response — improve alerting training.")
1701
+
1702
+ # Generate recommendations based on found insights
1703
+ if insights_found:
1704
+ # Contoh rekomendasi berdasarkan insight
1705
+ if any("circadian low" in i.lower() for i in insights_found):
1706
+ ai_recs.append({
1707
+ "recommendation": "Deploy enhanced fatigue monitoring systems (e.g., EOR) specifically during 2-5 AM shifts.",
1708
+ "data_point": f"Critical Hour Alerts: {len(critical_alerts)} ({critical_pct:.1f}% of total alerts)",
1709
+ "reason": "High percentage of alerts occurring during the known circadian low period (2-5 AM) indicates increased risk during these hours."
1710
+ })
1711
+ if any("shift" in i.lower() for i in insights_found):
1712
+ ai_recs.append({
1713
+ "recommendation": "Review shift rotation schedules to minimize consecutive high-risk shifts.",
1714
+ "data_point": f"Shift {worst_shift} Alerts: {df[col_shift].value_counts()[worst_shift]} ({(df[col_shift].value_counts()[worst_shift] / len(df)) * 100:.1f}% of total alerts)",
1715
+ "reason": f"The identified high-risk shift ({worst_shift}) has the highest number of fatigue alerts, suggesting scheduling or workload issues."
1716
+ })
1717
+ if any("operator" in i.lower() for i in insights_found):
1718
+ ai_recs.append({
1719
+ "recommendation": "Initiate individual coaching or mandatory rest periods for high-risk operators.",
1720
+ "data_point": f"Operator {worst_operator} Alerts: {df[col_operator].value_counts()[worst_operator]} ({(df[col_operator].value_counts()[worst_operator] / len(df)) * 100:.1f}% of total alerts)",
1721
+ "reason": f"The identified high-risk operator ({worst_operator}) has the highest number of fatigue alerts, indicating a need for targeted intervention."
1722
+ })
1723
+ if any("duration" in i.lower() for i in insights_found):
1724
+ ai_recs.append({
1725
+ "recommendation": "Review and improve alert response protocols and training.",
1726
+ "data_point": f"Average Fatigue Event Duration: {avg_duration:.2f} seconds",
1727
+ "reason": "Long average duration suggests potential delays in response time or alert acknowledgment, requiring protocol review."
1728
+ })
1729
+ if any("high-speed" in i.lower() for i in insights_found):
1730
+ ai_recs.append({
1731
+ "recommendation": "Implement speed management strategies in conjunction with fatigue monitoring.",
1732
+ "data_point": f"High-Speed Fatigue Events: {len(high_speed_fatigue)} ({high_speed_pct:.1f}% of total alerts)",
1733
+ "reason": "A significant percentage of alerts occur at high speeds, increasing accident severity risk. Speed control is crucial."
1734
+ })
1735
+ if not ai_recs:
1736
+ ai_recs.append({
1737
+ "recommendation": "Data quality is sufficient. Focus on implementing recommendations from Objectives 1-5.",
1738
+ "data_point": "General Data Quality Check",
1739
+ "reason": "No specific high-impact insights were automatically identified from the aggregated data in this section."
1740
+ })
1741
+
1742
+ # Menampilkan rekomendasi dalam format kotak yang sesuai dengan permintaan
1743
+ for rec in ai_recs:
1744
+ # Gunakan div dengan class khusus untuk membuat kotak rekomendasi di kolom kanan
1745
+ # Gaya diambil dari .insight-box untuk konsistensi dan menghindari warna ungu
1746
+ st.markdown(f"""
1747
+ <div style="
1748
+ background: #f8f9fa;
1749
+ border: 1px solid #dee2e6;
1750
+ border-radius: 8px;
1751
+ padding: 15px;
1752
+ margin: 10px 0;
1753
+ color: #2c3e50;
1754
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1755
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1756
+ display: flex;
1757
+ flex-direction: column;
1758
+ justify-content: space-between;
1759
+ ">
1760
+ <div style="
1761
+ font-weight: bold;
1762
+ color: #2c3e50;
1763
+ margin-bottom: 8px;
1764
+ font-size: 14px;
1765
+ background: #e9ecef;
1766
+ padding: 8px;
1767
+ border-radius: 5px;
1768
+ border-left: 4px solid #495057;
1769
+ ">AI Recommendation</div>
1770
+ <div style="padding-top: 8px; font-size: 14px; margin-bottom: 10px;">
1771
+ <strong>Action:</strong> {rec['recommendation']}
1772
+ </div>
1773
+ <div style="font-size: 12px; padding: 8px; background: #e9ecef; border-radius: 5px; margin-top: 5px;">
1774
+ <strong>Data Point:</strong> {rec['data_point']}
1775
+ </div>
1776
+ <div style="font-size: 12px; padding: 8px; background: #f1f1f1; border-radius: 5px; margin-top: 5px;">
1777
+ <strong>AI Reasoning:</strong> {rec['reason']}
1778
+ </div>
1779
+ </div>
1780
+ """, unsafe_allow_html=True)
1781
+ else:
1782
+ st.info("No specific data points available for AI recommendations. Ensure relevant columns (hour, shift, operator, duration, speed) are present and populated.")
1783
+
1784
+ # ================= FOOTER ===========================
1785
+ st.markdown("---")
1786
+ st.markdown('<div class="footer">MineVision AI - Transforming Mining Safety with Intelligent Analytics | Contact: info@bukittechnology.com</div>', unsafe_allow_html=True)
1787
+
1788
+
1789
+ # ================= FOOTER ===========================
1790
+ st.markdown("---")
1791
+ st.markdown('<div class="footer">MineVision AI - Transforming Mining Safety with Intelligent Analytics | Contact: info@bukittechnology.com</div>', unsafe_allow_html=True)
btech.png ADDED
data.csv ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,3 +1,8 @@
1
  altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
1
  altair
2
+ streamlit>=1.38.0
3
+ pandas>=2.2.2
4
+ numpy>=1.26.4
5
+ plotly>=5.24.1
6
+ plotly-express>=0.4.1
7
+ openpyxl>=3.1.5
8
+ python-dateutil>=2.9.0