SHELLAPANDIANGANHUNGING commited on
Commit
922abaa
·
verified ·
1 Parent(s): f2c9198

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +275 -355
app.py CHANGED
@@ -1156,80 +1156,46 @@ import pandas as pd
1156
  import numpy as np
1157
  import plotly.graph_objects as go
1158
 
1159
- # =================== OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard =====================
1160
- st.markdown("""
1161
- <h2 class="objective-header">OBJECTIVE 5: See Your Team’s Fatigue Hazard Profile!</h2>
1162
- """, unsafe_allow_html=True)
1163
 
1164
- # CUSTOM CSS — RESPONSIVE & SESUAI PREFERENSI
1165
  st.markdown("""
1166
  <style>
1167
- /* Responsiveness: base font & container */
1168
- @media (max-width: 768px) {
1169
- .objective-header { font-size: 22px; padding: 12px; }
1170
- .big-title { font-size: 20px; padding: 10px; }
1171
- .legend-container { flex-direction: column; gap: 15px; }
1172
- .legend-box { min-width: 100% !important; max-width: none; }
1173
- .ai-insight-box, .recommendation-box { padding: 14px; }
1174
- }
1175
- @media (max-width: 480px) {
1176
- .objective-header { font-size: 20px; }
1177
- .legend-item span { font-size: 12px; }
1178
- .legend-note { font-size: 11px; }
1179
- }
1180
-
1181
- /* === OBJECTIVE HEADER === */
1182
- .objective-header {
1183
- font-size: 26px;
1184
  font-weight: bold;
1185
- color: #2c3e50;
1186
  text-align: center;
1187
- margin: 10px 0 25px 0;
1188
- background: white;
1189
- padding: 14px;
1190
  border-radius: 10px;
1191
- box-shadow: 0 3px 12px rgba(0,0,0,0.1);
1192
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1193
  }
1194
-
1195
- /* === BIG TITLE === */
1196
- .big-title {
1197
- font-size: 22px;
1198
- font-weight: bold;
1199
- color: #2c3e50;
1200
  text-align: center;
1201
- margin: 25px 0 15px 0;
1202
- background: white;
1203
- padding: 12px;
1204
- border-radius: 8px;
1205
- box-shadow: 0 2px 8px rgba(0,0,0,0.08);
1206
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1207
  }
1208
-
1209
  .section-divider {
1210
  height: 2px;
1211
  background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c);
1212
- margin: 25px 0;
1213
  }
1214
-
1215
- /* === LEGEND — RESPONSIVE FLEXBOX === */
1216
  .legend-container {
1217
  display: flex;
1218
- gap: 20px;
1219
- flex-wrap: wrap;
1220
- justify-content: center;
1221
- margin: 20px 0;
1222
  }
1223
  .legend-box {
1224
- background: #f9f9f9;
1225
  border: 1px solid #ddd;
1226
- border-radius: 10px;
1227
- padding: 16px;
1228
- min-width: 280px;
1229
  flex: 1;
1230
- max-width: 340px;
1231
- box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1232
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1233
  }
1234
  .legend-title {
1235
  font-weight: bold;
@@ -1243,34 +1209,24 @@ st.markdown("""
1243
  display: flex;
1244
  align-items: center;
1245
  margin: 5px 0;
1246
- font-size: 13px;
1247
  }
1248
  .legend-color {
1249
  width: 18px;
1250
  height: 18px;
1251
  border-radius: 3px;
1252
- margin-right: 10px;
1253
  border: 1px solid #ccc;
1254
- flex-shrink: 0;
1255
- }
1256
- .legend-note {
1257
- font-size: 12px;
1258
- color: #666;
1259
- margin-top: 12px;
1260
- font-style: italic;
1261
- line-height: 1.4;
1262
  }
1263
-
1264
- /* === AI INSIGHTS === */
1265
  .ai-insight-box {
1266
  background: #f8f9fa;
1267
  border: 1px solid #dee2e6;
1268
  border-radius: 8px;
1269
- padding: 16px;
1270
- margin: 12px 0;
1271
  color: #2c3e50;
1272
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1273
- box-shadow: 0 2px 10px rgba(0,0,0,0.05);
1274
  }
1275
  .ai-insight-title {
1276
  font-weight: bold;
@@ -1282,14 +1238,20 @@ st.markdown("""
1282
  border-radius: 5px;
1283
  border-left: 4px solid #495057;
1284
  }
1285
-
1286
- /* === RECOMMENDATIONS === */
 
 
 
 
 
 
1287
  .recommendation-box {
1288
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1289
  border: 1px solid #4a5568;
1290
  border-radius: 8px;
1291
- padding: 16px;
1292
- margin: 12px 0;
1293
  color: white;
1294
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1295
  box-shadow: 0 4px 15px rgba(0,0,0,0.1);
@@ -1305,49 +1267,18 @@ st.markdown("""
1305
  border-left: 4px solid white;
1306
  }
1307
  .recommendation-reason {
1308
- font-size: 13px;
1309
  margin-top: 10px;
1310
  padding: 8px;
1311
  background: rgba(255,255,255,0.1);
1312
  border-radius: 5px;
1313
  border-left: 3px solid rgba(255,255,255,0.3);
1314
  }
1315
-
1316
- /* === TRENDS === */
1317
- .trend-up { color: #e74c3c; font-weight: bold; }
1318
- .trend-down { color: #27ae60; font-weight: bold; }
1319
-
1320
- /* Plotly responsive fix */
1321
- .js-plotly-plot .plotly > div { max-width: 100% !important; }
1322
  </style>
1323
  """, unsafe_allow_html=True)
1324
 
1325
  # ===============================================================
1326
- # CONTOH DATA (GANTI DENGAN DATA ANDA)
1327
- # ===============================================================
1328
- # Simulasi data — ganti dengan df Anda
1329
- @st.cache_data
1330
- def generate_sample_data():
1331
- np.random.seed(42)
1332
- operators = [f"OP{i:03d}" for i in range(1, 51)]
1333
- fleets = ["OB HAULLER"] * 25 + ["HAULING COAL"] * 25
1334
- dates = pd.date_range("2025-01-01", "2025-03-31", freq="D")
1335
-
1336
- data = []
1337
- for op, fleet in zip(operators, fleets):
1338
- n_events = np.random.randint(5, 50)
1339
- for _ in range(n_events):
1340
- start = np.random.choice(dates)
1341
- data.append({"Operator": op, "Fleet_Type": fleet, "start": start})
1342
- return pd.DataFrame(data)
1343
-
1344
- # Ganti ini dengan df Anda
1345
- df = generate_sample_data()
1346
- col_operator = "Operator"
1347
- col_fleet_type = "Fleet_Type"
1348
-
1349
- # ===============================================================
1350
- # LOGIC UTAMA — RESPONSIVE & SESUAI PREFERENSI
1351
  # ===============================================================
1352
  if df.empty:
1353
  st.info("No data available after applying filters.")
@@ -1358,21 +1289,15 @@ else:
1358
  st.warning("Required columns (operator, fleet_type, start) are missing.")
1359
  st.stop()
1360
 
1361
- # ✅ Shorten operator names: "John Doe" → "John"
1362
  df_op = df[[col_operator, col_fleet_type, "start"]].dropna()
1363
- if col_operator in df_op.columns:
1364
- df_op[col_operator] = (
1365
- df_op[col_operator]
1366
- .astype(str)
1367
- .str.strip()
1368
- .str.split()
1369
- .str[0]
1370
- )
1371
-
1372
  if df_op.empty:
1373
  st.info("No operator data after filtering.")
1374
  st.stop()
1375
 
 
 
 
 
1376
  df_op["year_week"] = df_op["start"].dt.strftime("%Y-W%U")
1377
 
1378
  # Fuzzy match fleet names
@@ -1384,7 +1309,8 @@ else:
1384
  coal_data = df_op[df_op["is_coal"]]
1385
 
1386
  def get_top10_with_slope(data):
1387
- if data.empty: return pd.DataFrame()
 
1388
  if col_operator not in data.columns:
1389
  st.error(f"Operator column '{col_operator}' not found in data subset.")
1390
  return pd.DataFrame()
@@ -1392,20 +1318,22 @@ else:
1392
  weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1393
  metrics = []
1394
  for nik, grp in weekly.groupby(col_operator):
1395
- if pd.isna(nik): continue
 
1396
  grp = grp.sort_values("year_week")
1397
  counts = grp["weekly_sum"].values
1398
  weeks = np.arange(len(counts))
1399
  weekly_avg = counts.mean()
1400
  total_events = counts.sum()
1401
  n_weeks = len(counts)
1402
- slope = 0.0
1403
  if n_weeks >= 2:
1404
  x_mean = weeks.mean()
1405
  y_mean = counts.mean()
1406
  numerator = np.sum((weeks - x_mean) * (counts - y_mean))
1407
  denominator = np.sum((weeks - x_mean) ** 2)
1408
  slope = numerator / denominator if denominator != 0 else 0.0
 
 
1409
  metrics.append({
1410
  col_operator: nik,
1411
  "weekly_avg": weekly_avg,
@@ -1413,129 +1341,156 @@ else:
1413
  "total_events": total_events,
1414
  "n_weeks": n_weeks
1415
  })
1416
- return pd.DataFrame(metrics).nlargest(10, "weekly_avg") if metrics else pd.DataFrame()
 
 
1417
 
1418
  top_ob = get_top10_with_slope(ob_data)
1419
  top_coal = get_top10_with_slope(coal_data)
1420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1421
  # ===============================================================
1422
- # LEGEND — RESPONSIVE & SESUAI PREFERENSI
1423
  # ===============================================================
1424
- st.markdown('<h3 class="big-title">Hazard Gradient Legend</h3>', unsafe_allow_html=True)
1425
  st.markdown("""
1426
- <div class="legend-container">
1427
- <!-- Worsening Trends -->
1428
- <div class="legend-box">
1429
- <div class="legend-title">Worsening Trends (Positive Slope):</div>
1430
- <div class="legend-item">
1431
- <div class="legend-color" style="background-color: #d32f2f;"></div>
1432
- <span>Very High Risk (≥ 1.5)</span>
1433
- </div>
1434
- <div class="legend-item">
1435
- <div class="legend-color" style="background-color: #e57373;"></div>
1436
- <span>High Risk (1.0 – 1.5)</span>
1437
- </div>
1438
- <div class="legend-item">
1439
- <div class="legend-color" style="background-color: #ef9a9a;"></div>
1440
- <span>Moderate Risk (0.5 – 1.0)</span>
1441
- </div>
1442
- <div class="legend-item">
1443
- <div class="legend-color" style="background-color: #ffcdd2;"></div>
1444
- <span>Slight Risk (0 – 0.5)</span>
1445
- </div>
1446
- <p class="legend-note">
1447
- <i>Note: Positive slope indicates increasing fatigue events over time — escalating operational risk.</i>
1448
- </p>
1449
- </div>
1450
-
1451
- <!-- Improving Trends -->
1452
- <div class="legend-box">
1453
- <div class="legend-title">Improving Trends (Negative Slope):</div>
1454
- <div class="legend-item">
1455
- <div class="legend-color" style="background-color: #388e3c;"></div>
1456
- <span>Excellent Improvement (≤ −1.5)</span>
1457
- </div>
1458
- <div class="legend-item">
1459
- <div class="legend-color" style="background-color: #81c784;"></div>
1460
- <span>Great Improvement (−1.5 to −1.0)</span>
1461
- </div>
1462
- <div class="legend-item">
1463
- <div class="legend-color" style="background-color: #a5d6a7;"></div>
1464
- <span>Good Improvement (−1.0 to −0.5)</span>
1465
- </div>
1466
- <div class="legend-item">
1467
- <div class="legend-color" style="background-color: #c8e6c9;"></div>
1468
- <span>Slight Improvement (−0.5 to 0)</span>
1469
- </div>
1470
- <p class="legend-note">
1471
- <i>Note: Negative slope reflects decreasing fatigue events — effective mitigation or behavioral adaptation.</i>
1472
- </p>
1473
- </div>
1474
-
1475
- <!-- One-Time Events -->
1476
- <div class="legend-box">
1477
- <div class="legend-title">One-Time Events (Zero Slope):</div>
1478
- <div class="legend-item">
1479
- <div class="legend-color" style="background-color: #FFD700;"></div>
1480
- <span>One Time Event (0)</span>
1481
  </div>
1482
- <p class="legend-note">
1483
- <i>Note: Slope = 0 by definition when data exists for only one week — trend assessment not applicable.</i>
1484
- </p>
1485
- </div>
1486
- </div>
1487
- """, unsafe_allow_html=True)
1488
 
1489
  # ===============================================================
1490
- # PLOT FUNCTION — RESPONSIVE
1491
  # ===============================================================
1492
  def plot_chart(data, title):
1493
  if data.empty:
1494
  fig = go.Figure()
1495
  fig.add_annotation(
1496
- text="No Data", x=0.5, y=0.5,
 
1497
  showarrow=False,
1498
- font_size=16,
1499
- font_color="#888"
1500
- )
1501
- fig.update_layout(
1502
- height=350,
1503
- title=dict(text=title, x=0.5, font=dict(size=18, family="Segoe UI")),
1504
- plot_bgcolor="rgba(0,0,0,0)",
1505
- paper_bgcolor="rgba(0,0,0,0)",
1506
- margin=dict(l=40, r=20, t=60, b=100),
1507
  )
 
1508
  return fig
1509
 
1510
  data_sorted = data.sort_values('weekly_avg', ascending=False)
1511
 
1512
  def get_color(slope):
1513
  if slope == 0:
1514
- return "#FFD700"
1515
  elif slope > 0:
1516
- if slope >= 1.5: return "#d32f2f"
1517
- elif slope >= 1.0: return "#e57373"
1518
- elif slope >= 0.5: return "#ef9a9a"
1519
- else: return "#ffcdd2"
1520
- else:
1521
- if slope <= -1.5: return "#388e3c"
1522
- elif slope <= -1.0: return "#81c784"
1523
- elif slope <= -0.5: return "#a5d6a7"
1524
- else: return "#c8e6c9"
 
 
 
 
 
 
 
 
1525
 
1526
  colors = [get_color(s) for s in data_sorted["slope"]]
1527
 
1528
  bar_trace = go.Bar(
1529
  x=data_sorted[col_operator].astype(str),
1530
  y=data_sorted["weekly_avg"],
1531
- marker=dict(color=colors, line=dict(width=1.2, color="rgba(0,0,0,0.2)")),
 
 
 
1532
  text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]],
1533
  textposition="outside",
1534
- textfont=dict(size=11, family="Segoe UI"),
1535
  hovertemplate=(
1536
  "<b>%{x}</b><br>" +
1537
  "Weekly Avg: %{y:.2f}<br>" +
1538
- "Trend Slope: %{customdata[0]:+.2f}<br>" +
1539
  "Total Events: %{customdata[1]}<br>" +
1540
  "Weeks Active: %{customdata[2]}<br>" +
1541
  "<extra></extra>"
@@ -1545,140 +1500,138 @@ else:
1545
 
1546
  fig = go.Figure(bar_trace)
1547
  fig.update_layout(
1548
- title=dict(text=f"<b>{title}</b>", x=0.5, font=dict(size=18, color="#2c3e50")),
1549
  height=450,
1550
  margin=dict(l=50, r=20, t=60, b=120),
1551
- xaxis_title=dict(text="<b>Operator</b>", font=dict(family="Segoe UI")),
1552
- yaxis_title=dict(text="<b>Weekly Avg Events</b>", font=dict(family="Segoe UI")),
1553
  font=dict(family="Segoe UI", size=12),
1554
  bargap=0.3,
1555
  plot_bgcolor="rgba(0,0,0,0)",
1556
  paper_bgcolor="rgba(0,0,0,0)",
1557
- xaxis=dict(
1558
- tickangle=45,
1559
- tickfont=dict(family="Segoe UI", size=11),
1560
- automargin=True
1561
- ),
1562
- yaxis=dict(gridcolor="#eee")
1563
  )
1564
- # Responsif: nonaktifkan zoom & pan di mobile
1565
- fig.update_yaxes(fixedrange=True)
1566
  return fig
1567
 
1568
  # ===============================================================
1569
- # CHARTS — RESPONSIVE COLUMN
1570
  # ===============================================================
1571
- if st.session_state.get("is_mobile", False) or st._get_query_params().get("mobile"):
1572
- # Force single column on mobile
1573
  st.plotly_chart(plot_chart(top_ob, "OB HAULER Operators (Hazard Gradient)"), use_container_width=True)
 
1574
  st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
1575
- else:
1576
- col1, col2 = st.columns(2)
1577
- with col1:
1578
- st.plotly_chart(plot_chart(top_ob, "OB HAULER Operators (Hazard Gradient)"), use_container_width=True)
1579
- with col2:
1580
- st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
1581
 
1582
  # ===============================================================
1583
- # AI INSIGHTS — RESPONSIVE
1584
  # ===============================================================
1585
- if st.session_state.get("is_mobile", False) or st._get_query_params().get("mobile"):
1586
- # Single column
 
1587
  if not top_ob.empty:
1588
- st.markdown('<h3 class="big-title">OB HAULER Analysis</h3>', unsafe_allow_html=True)
1589
- # ... (same logic as below)
1590
- if not top_coal.empty:
1591
- st.markdown('<h3 class="big-title">HAULING COAL Analysis</h3>', unsafe_allow_html=True)
1592
- # ...
1593
- else:
1594
- col_insight1, col_insight2 = st.columns(2)
1595
- with col_insight1:
1596
- if not top_ob.empty:
1597
- st.markdown('<h3 class="big-title">OB HAULER Analysis</h3>', unsafe_allow_html=True)
1598
- ob_worsening = len(top_ob[top_ob['slope'] > 0])
1599
- ob_improving = len(top_ob[top_ob['slope'] < 0])
1600
- ob_one_time = len(top_ob[top_ob['slope'] == 0])
1601
- ob_avg_risk = top_ob['weekly_avg'].mean()
1602
- ob_max_risk = top_ob['weekly_avg'].max()
1603
-
1604
- insights = []
1605
- if ob_worsening > ob_improving:
1606
- insights.append(f"{ob_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
1607
- else:
1608
- insights.append(f"{ob_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1609
- if ob_one_time > 0:
1610
- insights.append(f"{ob_one_time} operator(s) classified as <b>One Time Event</b>.")
1611
- insights.append(f"Average risk: {ob_avg_risk:.2f} events/week (max: {ob_max_risk:.2f}).")
1612
-
1613
- for txt in insights:
1614
- st.markdown(f"""
1615
- <div class="ai-insight-box">
1616
- <div class="ai-insight-title">Risk Summary</div>
1617
- <p>{txt}</p>
1618
- </div>
1619
- """, unsafe_allow_html=True)
1620
  else:
1621
- st.info("No OB HAULER data for analysis.")
1622
-
1623
- with col_insight2:
1624
- if not top_coal.empty:
1625
- st.markdown('<h3 class="big-title">HAULING COAL Analysis</h3>', unsafe_allow_html=True)
1626
- coal_worsening = len(top_coal[top_coal['slope'] > 0])
1627
- coal_improving = len(top_coal[top_coal['slope'] < 0])
1628
- coal_one_time = len(top_coal[top_coal['slope'] == 0])
1629
- coal_avg_risk = top_coal['weekly_avg'].mean()
1630
- coal_max_risk = top_coal['weekly_avg'].max()
1631
-
1632
- insights = []
1633
- if coal_worsening > coal_improving:
1634
- insights.append(f"{coal_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
1635
- else:
1636
- insights.append(f"{coal_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1637
- if coal_one_time > 0:
1638
- insights.append(f"{coal_one_time} operator(s) classified as <b>One Time Event</b>.")
1639
- insights.append(f"Average risk: {coal_avg_risk:.2f} events/week (max: {coal_max_risk:.2f}).")
1640
-
1641
- for txt in insights:
1642
- st.markdown(f"""
1643
- <div class="ai-insight-box">
1644
- <div class="ai-insight-title">Risk Summary</div>
1645
- <p>{txt}</p>
1646
- </div>
1647
- """, unsafe_allow_html=True)
1648
  else:
1649
- st.info("No HAULING COAL data for analysis.")
 
 
 
 
 
 
 
 
 
 
 
 
 
1650
 
1651
  # ===============================================================
1652
- # RECOMMENDATIONS — RESPONSIVE
1653
  # ===============================================================
 
 
1654
  def generate_recommendations(top_ob, top_coal):
1655
  rec = {}
1656
- for label, data in [("ob", top_ob), ("coal", top_coal)]:
1657
- if not data.empty:
1658
- w = len(data[data['slope'] > 0])
1659
- ot = len(data[data['slope'] == 0])
1660
- avg = data['weekly_avg'].mean()
1661
- if w > 5:
1662
- r = "Prioritize fatigue intervention for operators with worsening trends."
1663
- reason = "High proportion of deteriorating operators signals emerging fatigue risks."
1664
- elif ot > 4:
1665
- r = "Validate data completeness high One Time Event count may indicate reporting gaps."
1666
- reason = "Operators with single-week data cannot yield reliable trend analysis."
1667
- elif avg > 8:
1668
- r = "Review scheduling and rest protocols to reduce event frequency."
1669
- reason = "Elevated average event rate increases cumulative fatigue exposure."
1670
- else:
1671
- r = "Maintain current protocols with targeted monitoring."
1672
- reason = "Risk profile is stable; focus on sustaining safe practices."
1673
- rec[label] = r
1674
- rec[f"{label}_reason"] = reason
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1675
  return rec
1676
 
1677
  ai_rec = generate_recommendations(top_ob, top_coal)
1678
 
1679
- if st.session_state.get("is_mobile", False) or st._get_query_params().get("mobile"):
1680
  if 'ob' in ai_rec:
1681
- st.markdown('<h3 class="big-title">OB HAULER Recommendations</h3>', unsafe_allow_html=True)
1682
  st.markdown(f"""
1683
  <div class="recommendation-box">
1684
  <div class="recommendation-title">Action Plan</div>
@@ -1686,8 +1639,12 @@ else:
1686
  <div class="recommendation-reason">AI Reasoning: {ai_rec['ob_reason']}</div>
1687
  </div>
1688
  """, unsafe_allow_html=True)
 
 
 
 
1689
  if 'coal' in ai_rec:
1690
- st.markdown('<h3 class="big-title">HAULING COAL Recommendations</h3>', unsafe_allow_html=True)
1691
  st.markdown(f"""
1692
  <div class="recommendation-box">
1693
  <div class="recommendation-title">Action Plan</div>
@@ -1695,51 +1652,14 @@ else:
1695
  <div class="recommendation-reason">AI Reasoning: {ai_rec['coal_reason']}</div>
1696
  </div>
1697
  """, unsafe_allow_html=True)
1698
- else:
1699
- col_rec1, col_rec2 = st.columns(2)
1700
- with col_rec1:
1701
- if 'ob' in ai_rec:
1702
- st.markdown('<h3 class="big-title">OB HAULER Recommendations</h3>', unsafe_allow_html=True)
1703
- st.markdown(f"""
1704
- <div class="recommendation-box">
1705
- <div class="recommendation-title">Action Plan</div>
1706
- <div>{ai_rec['ob']}</div>
1707
- <div class="recommendation-reason">AI Reasoning: {ai_rec['ob_reason']}</div>
1708
- </div>
1709
- """, unsafe_allow_html=True)
1710
- else:
1711
- st.info("No OB HAULER recommendations.")
1712
- with col_rec2:
1713
- if 'coal' in ai_rec:
1714
- st.markdown('<h3 class="big-title">HAULING COAL Recommendations</h3>', unsafe_allow_html=True)
1715
- st.markdown(f"""
1716
- <div class="recommendation-box">
1717
- <div class="recommendation-title">Action Plan</div>
1718
- <div>{ai_rec['coal']}</div>
1719
- <div class="recommendation-reason">AI Reasoning: {ai_rec['coal_reason']}</div>
1720
- </div>
1721
- """, unsafe_allow_html=True)
1722
- else:
1723
- st.info("No HAULING COAL recommendations.")
1724
 
1725
  except Exception as e:
1726
  st.error(f"Error in Top 10 Operator analysis: {str(e)}")
1727
- # st.exception(e) # Uncomment for debugging
 
1728
 
1729
- # ✅ Auto-detect mobile (opsional)
1730
- def detect_mobile():
1731
- try:
1732
- from streamlit.runtime.scriptrunner import get_script_run_ctx
1733
- ctx = get_script_run_ctx()
1734
- if ctx and ctx.session_id:
1735
- user_agent = st.context.headers.get("User-Agent", "").lower()
1736
- return any(x in user_agent for x in ["mobile", "android", "iphone", "ipad"])
1737
- except:
1738
- pass
1739
- return False
1740
-
1741
- if "is_mobile" not in st.session_state:
1742
- st.session_state.is_mobile = detect_mobile()
1743
  # =================== OBJECTIVE 6: Automated Insights & AI Recommendations =====================
1744
  st.subheader("OBJECTIVE 6: Instant Insights & Recommendations")
1745
 
 
1156
  import numpy as np
1157
  import plotly.graph_objects as go
1158
 
1159
+ st.subheader("OBJECTIVE 5:See your team’s fatigue Fatigue Hazard Profile!")
 
 
 
1160
 
1161
+ # Custom CSS — tetap seperti sebelumnya (sudah sesuai preferensi)
1162
  st.markdown("""
1163
  <style>
1164
+ .big-title {
1165
+ font-size: 28px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1166
  font-weight: bold;
1167
+ color: #ffffff;
1168
  text-align: center;
1169
+ margin-bottom: 10px;
1170
+ background: linear-gradient(135deg, #2c3e50, #1a252c);
1171
+ padding: 15px;
1172
  border-radius: 10px;
1173
+ box-shadow: 0 4px 15px rgba(0,0,0,0.3);
 
1174
  }
1175
+ .subnote {
1176
+ font-size: 16px;
1177
+ color: #7f8c8d;
 
 
 
1178
  text-align: center;
1179
+ margin-bottom: 20px;
 
 
 
 
 
1180
  }
 
1181
  .section-divider {
1182
  height: 2px;
1183
  background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c);
1184
+ margin: 20px 0;
1185
  }
 
 
1186
  .legend-container {
1187
  display: flex;
1188
+ gap: 15px;
1189
+ margin: 15px 0;
 
 
1190
  }
1191
  .legend-box {
1192
+ background: white;
1193
  border: 1px solid #ddd;
1194
+ border-radius: 8px;
1195
+ padding: 15px;
 
1196
  flex: 1;
1197
+ min-width: 300px;
1198
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
 
1199
  }
1200
  .legend-title {
1201
  font-weight: bold;
 
1209
  display: flex;
1210
  align-items: center;
1211
  margin: 5px 0;
1212
+ font-size: 12px;
1213
  }
1214
  .legend-color {
1215
  width: 18px;
1216
  height: 18px;
1217
  border-radius: 3px;
1218
+ margin-right: 8px;
1219
  border: 1px solid #ccc;
 
 
 
 
 
 
 
 
1220
  }
 
 
1221
  .ai-insight-box {
1222
  background: #f8f9fa;
1223
  border: 1px solid #dee2e6;
1224
  border-radius: 8px;
1225
+ padding: 15px;
1226
+ margin: 10px 0;
1227
  color: #2c3e50;
1228
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1229
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1230
  }
1231
  .ai-insight-title {
1232
  font-weight: bold;
 
1238
  border-radius: 5px;
1239
  border-left: 4px solid #495057;
1240
  }
1241
+ .trend-up {
1242
+ color: #e74c3c;
1243
+ font-weight: bold;
1244
+ }
1245
+ .trend-down {
1246
+ color: #27ae60;
1247
+ font-weight: bold;
1248
+ }
1249
  .recommendation-box {
1250
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1251
  border: 1px solid #4a5568;
1252
  border-radius: 8px;
1253
+ padding: 15px;
1254
+ margin: 10px 0;
1255
  color: white;
1256
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1257
  box-shadow: 0 4px 15px rgba(0,0,0,0.1);
 
1267
  border-left: 4px solid white;
1268
  }
1269
  .recommendation-reason {
1270
+ font-size: 12px;
1271
  margin-top: 10px;
1272
  padding: 8px;
1273
  background: rgba(255,255,255,0.1);
1274
  border-radius: 5px;
1275
  border-left: 3px solid rgba(255,255,255,0.3);
1276
  }
 
 
 
 
 
 
 
1277
  </style>
1278
  """, unsafe_allow_html=True)
1279
 
1280
  # ===============================================================
1281
+ # LOGIC UTAMA
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1282
  # ===============================================================
1283
  if df.empty:
1284
  st.info("No data available after applying filters.")
 
1289
  st.warning("Required columns (operator, fleet_type, start) are missing.")
1290
  st.stop()
1291
 
 
1292
  df_op = df[[col_operator, col_fleet_type, "start"]].dropna()
 
 
 
 
 
 
 
 
 
1293
  if df_op.empty:
1294
  st.info("No operator data after filtering.")
1295
  st.stop()
1296
 
1297
+ if col_operator is None:
1298
+ st.error("Operator column could not be auto-detected. Please check your data.")
1299
+ st.stop()
1300
+
1301
  df_op["year_week"] = df_op["start"].dt.strftime("%Y-W%U")
1302
 
1303
  # Fuzzy match fleet names
 
1309
  coal_data = df_op[df_op["is_coal"]]
1310
 
1311
  def get_top10_with_slope(data):
1312
+ if data.empty:
1313
+ return pd.DataFrame()
1314
  if col_operator not in data.columns:
1315
  st.error(f"Operator column '{col_operator}' not found in data subset.")
1316
  return pd.DataFrame()
 
1318
  weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1319
  metrics = []
1320
  for nik, grp in weekly.groupby(col_operator):
1321
+ if pd.isna(nik):
1322
+ continue
1323
  grp = grp.sort_values("year_week")
1324
  counts = grp["weekly_sum"].values
1325
  weeks = np.arange(len(counts))
1326
  weekly_avg = counts.mean()
1327
  total_events = counts.sum()
1328
  n_weeks = len(counts)
 
1329
  if n_weeks >= 2:
1330
  x_mean = weeks.mean()
1331
  y_mean = counts.mean()
1332
  numerator = np.sum((weeks - x_mean) * (counts - y_mean))
1333
  denominator = np.sum((weeks - x_mean) ** 2)
1334
  slope = numerator / denominator if denominator != 0 else 0.0
1335
+ else:
1336
+ slope = 0.0 # One Time Event
1337
  metrics.append({
1338
  col_operator: nik,
1339
  "weekly_avg": weekly_avg,
 
1341
  "total_events": total_events,
1342
  "n_weeks": n_weeks
1343
  })
1344
+ if not metrics:
1345
+ return pd.DataFrame()
1346
+ return pd.DataFrame(metrics).nlargest(10, "weekly_avg")
1347
 
1348
  top_ob = get_top10_with_slope(ob_data)
1349
  top_coal = get_top10_with_slope(coal_data)
1350
 
1351
+ def get_all_operators_with_slope(data):
1352
+ if data.empty:
1353
+ return pd.DataFrame()
1354
+ if col_operator not in data.columns:
1355
+ return pd.DataFrame()
1356
+
1357
+ weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1358
+ metrics = []
1359
+ for nik, grp in weekly.groupby(col_operator):
1360
+ if pd.isna(nik):
1361
+ continue
1362
+ grp = grp.sort_values("year_week")
1363
+ counts = grp["weekly_sum"].values
1364
+ weeks = np.arange(len(counts))
1365
+ weekly_avg = counts.mean()
1366
+ total_events = counts.sum()
1367
+ n_weeks = len(counts)
1368
+ if n_weeks >= 2:
1369
+ slope = np.cov(weeks, counts)[0, 1] / np.var(weeks) if np.var(weeks) != 0 else 0.0
1370
+ else:
1371
+ slope = 0.0
1372
+ metrics.append({
1373
+ col_operator: nik,
1374
+ "weekly_avg": weekly_avg,
1375
+ "slope": slope,
1376
+ "total_events": total_events,
1377
+ "n_weeks": n_weeks
1378
+ })
1379
+ return pd.DataFrame(metrics) if metrics else pd.DataFrame()
1380
+
1381
+ all_ob = get_all_operators_with_slope(ob_data)
1382
+ all_coal = get_all_operators_with_slope(coal_data)
1383
+
1384
  # ===============================================================
1385
+ # LEGEND — UPDATED: Stable One Time Event, Gray → Yellow
1386
  # ===============================================================
1387
+ st.subheader("Hazard Gradient Legend")
1388
  st.markdown("""
1389
+ <div class="legend-container">
1390
+ <div class="legend-box">
1391
+ <div class="legend-title">Worsening Trends (Positive Slope):</div>
1392
+ <div class="legend-item">
1393
+ <div class="legend-color" style="background-color: #d32f2f;"></div>
1394
+ <span>Very High Worsening (≥1.5)</span>
1395
+ </div>
1396
+ <div class="legend-item">
1397
+ <div class="legend-color" style="background-color: #e57373;"></div>
1398
+ <span>High Worsening (1.0–1.5)</span>
1399
+ </div>
1400
+ <div class="legend-item">
1401
+ <div class="legend-color" style="background-color: #ef9a9a;"></div>
1402
+ <span>Moderate Worsening (0.5–1.0)</span>
1403
+ </div>
1404
+ <div class="legend-item">
1405
+ <div class="legend-color" style="background-color: #ffcdd2;"></div>
1406
+ <span>Slight Worsening (0–0.5)</span>
1407
+ </div>
1408
+ </div>
1409
+ <div class="legend-box">
1410
+ <div class="legend-title">Improving Trends (Negative Slope):</div>
1411
+ <div class="legend-item">
1412
+ <div class="legend-color" style="background-color: #388e3c;"></div>
1413
+ <span>Excellent Improvement (≤−1.5)</span>
1414
+ </div>
1415
+ <div class="legend-item">
1416
+ <div class="legend-color" style="background-color: #81c784;"></div>
1417
+ <span>Great Improvement (−1.5 to −1.0)</span>
1418
+ </div>
1419
+ <div class="legend-item">
1420
+ <div class="legend-color" style="background-color: #a5d6a7;"></div>
1421
+ <span>Good Improvement (−1.0 to −0.5)</span>
1422
+ </div>
1423
+ <div class="legend-item">
1424
+ <div class="legend-color" style="background-color: #c8e6c9;"></div>
1425
+ <span>Slight Improvement (−0.5 to 0)</span>
1426
+ </div>
1427
+ </div>
1428
+ <div class="legend-box">
1429
+ <div class="legend-title">One-Time Events (Zero Slope):</div>
1430
+ <div class="legend-item">
1431
+ <div class="legend-color" style="background-color: #FFD700;"></div>
1432
+ <span>One Time Event (0)</span>
1433
+ </div>
1434
+ <br>
1435
+ <i>Note: Applies when an operator has data in only one week — slope is set to 0 by definition.</i>
1436
+ </div>
 
 
 
 
 
 
 
1437
  </div>
1438
+ """, unsafe_allow_html=True)
 
 
 
 
 
1439
 
1440
  # ===============================================================
1441
+ # PLOT FUNCTION — UPDATED: color for slope=0 is now #FFD700
1442
  # ===============================================================
1443
  def plot_chart(data, title):
1444
  if data.empty:
1445
  fig = go.Figure()
1446
  fig.add_annotation(
1447
+ text="No Data",
1448
+ x=0.5, y=0.5,
1449
  showarrow=False,
1450
+ font_size=16
 
 
 
 
 
 
 
 
1451
  )
1452
+ fig.update_layout(height=350, title=dict(text=title, x=0.5))
1453
  return fig
1454
 
1455
  data_sorted = data.sort_values('weekly_avg', ascending=False)
1456
 
1457
  def get_color(slope):
1458
  if slope == 0:
1459
+ return "#FFD700" # ✅ Kuning untuk One Time Event
1460
  elif slope > 0:
1461
+ if slope < 0.5:
1462
+ return "#ffcdd2"
1463
+ elif slope < 1.0:
1464
+ return "#ef9a9a"
1465
+ elif slope < 1.5:
1466
+ return "#e57373"
1467
+ else:
1468
+ return "#d32f2f"
1469
+ else: # slope < 0
1470
+ if slope > -0.5:
1471
+ return "#c8e6c9"
1472
+ elif slope > -1.0:
1473
+ return "#a5d6a7"
1474
+ elif slope > -1.5:
1475
+ return "#81c784"
1476
+ else:
1477
+ return "#388e3c"
1478
 
1479
  colors = [get_color(s) for s in data_sorted["slope"]]
1480
 
1481
  bar_trace = go.Bar(
1482
  x=data_sorted[col_operator].astype(str),
1483
  y=data_sorted["weekly_avg"],
1484
+ marker=dict(
1485
+ color=colors,
1486
+ line=dict(width=2, color="rgba(0,0,0,0.2)")
1487
+ ),
1488
  text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]],
1489
  textposition="outside",
 
1490
  hovertemplate=(
1491
  "<b>%{x}</b><br>" +
1492
  "Weekly Avg: %{y:.2f}<br>" +
1493
+ "Trend Slope: %{customdata[0]:+.3f}<br>" +
1494
  "Total Events: %{customdata[1]}<br>" +
1495
  "Weeks Active: %{customdata[2]}<br>" +
1496
  "<extra></extra>"
 
1500
 
1501
  fig = go.Figure(bar_trace)
1502
  fig.update_layout(
1503
+ title=dict(text=f"<b>{title}</b>", x=0.5),
1504
  height=450,
1505
  margin=dict(l=50, r=20, t=60, b=120),
1506
+ xaxis_title="<b>Operator Name</b>",
1507
+ yaxis_title="<b>Weekly Avg Events</b>",
1508
  font=dict(family="Segoe UI", size=12),
1509
  bargap=0.3,
1510
  plot_bgcolor="rgba(0,0,0,0)",
1511
  paper_bgcolor="rgba(0,0,0,0)",
1512
+ xaxis=dict(tickangle=45)
 
 
 
 
 
1513
  )
 
 
1514
  return fig
1515
 
1516
  # ===============================================================
1517
+ # CHARTS
1518
  # ===============================================================
1519
+ col1, col2 = st.columns(2)
1520
+ with col1:
1521
  st.plotly_chart(plot_chart(top_ob, "OB HAULER Operators (Hazard Gradient)"), use_container_width=True)
1522
+ with col2:
1523
  st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
 
 
 
 
 
 
1524
 
1525
  # ===============================================================
1526
+ # AI INSIGHTS — tetap dalam bahasa Inggris, tanpa emoticon
1527
  # ===============================================================
1528
+ col_insight1, col_insight2 = st.columns(2)
1529
+
1530
+ with col_insight1:
1531
  if not top_ob.empty:
1532
+ st.markdown("### OB HAULER Analysis")
1533
+ ob_worsening = len(top_ob[top_ob['slope'] > 0])
1534
+ ob_improving = len(top_ob[top_ob['slope'] < 0])
1535
+ ob_one_time = len(top_ob[top_ob['slope'] == 0])
1536
+ ob_avg_risk = top_ob['weekly_avg'].mean()
1537
+ ob_max_risk = top_ob['weekly_avg'].max()
1538
+ ob_insights = []
1539
+ if ob_worsening > ob_improving:
1540
+ ob_insights.append(f"{ob_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1541
  else:
1542
+ ob_insights.append(f"{ob_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>.")
1543
+ if ob_one_time > 0:
1544
+ ob_insights.append(f"{ob_one_time} operators are classified as <b>One Time Event</b> (single-week activity).")
1545
+ ob_insights.append(f"Average risk: {ob_avg_risk:.2f} events/week (max: {ob_max_risk:.2f}).")
1546
+
1547
+ for insight in ob_insights:
1548
+ st.markdown(f"""
1549
+ <div class="ai-insight-box">
1550
+ <div class="ai-insight-title">Risk Summary</div>
1551
+ <p>{insight}</p>
1552
+ </div>
1553
+ """, unsafe_allow_html=True)
1554
+ else:
1555
+ st.info("No OB HAULER data for analysis.")
1556
+
1557
+ with col_insight2:
1558
+ if not top_coal.empty:
1559
+ st.markdown("### HAULING COAL Analysis")
1560
+ coal_worsening = len(top_coal[top_coal['slope'] > 0])
1561
+ coal_improving = len(top_coal[top_coal['slope'] < 0])
1562
+ coal_one_time = len(top_coal[top_coal['slope'] == 0])
1563
+ coal_avg_risk = top_coal['weekly_avg'].mean()
1564
+ coal_max_risk = top_coal['weekly_avg'].max()
1565
+ coal_insights = []
1566
+ if coal_worsening > coal_improving:
1567
+ coal_insights.append(f"{coal_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends.")
 
1568
  else:
1569
+ coal_insights.append(f"{coal_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>.")
1570
+ if coal_one_time > 0:
1571
+ coal_insights.append(f"{coal_one_time} operators are classified as <b>One Time Event</b> (single-week activity).")
1572
+ coal_insights.append(f"Average risk: {coal_avg_risk:.2f} events/week (max: {coal_max_risk:.2f}).")
1573
+
1574
+ for insight in coal_insights:
1575
+ st.markdown(f"""
1576
+ <div class="ai-insight-box">
1577
+ <div class="ai-insight-title">Risk Summary</div>
1578
+ <p>{insight}</p>
1579
+ </div>
1580
+ """, unsafe_allow_html=True)
1581
+ else:
1582
+ st.info("No HAULING COAL data for analysis.")
1583
 
1584
  # ===============================================================
1585
+ # RECOMMENDATIONS
1586
  # ===============================================================
1587
+ col_rec1, col_rec2 = st.columns(2)
1588
+
1589
  def generate_recommendations(top_ob, top_coal):
1590
  rec = {}
1591
+ if not top_ob.empty:
1592
+ w = len(top_ob[top_ob['slope'] > 0])
1593
+ ot = len(top_ob[top_ob['slope'] == 0])
1594
+ avg = top_ob['weekly_avg'].mean()
1595
+ if w > 5:
1596
+ r = "Prioritize fatigue intervention for operators with worsening trends."
1597
+ reason = "High proportion of deteriorating operators signals emerging fatigue risks."
1598
+ elif ot > 4:
1599
+ r = "Validate data completeness — high One Time Event count may indicate reporting gaps."
1600
+ reason = "Operators with single-week data cannot yield reliable trend analysis."
1601
+ elif avg > 8:
1602
+ r = "Review scheduling and rest protocols to reduce event frequency."
1603
+ reason = "Elevated average event rate increases cumulative fatigue exposure."
1604
+ else:
1605
+ r = "Maintain current protocols with targeted monitoring."
1606
+ reason = "Risk profile is stable; focus on sustaining safe practices."
1607
+ rec['ob'] = r
1608
+ rec['ob_reason'] = reason
1609
+
1610
+ if not top_coal.empty:
1611
+ w = len(top_coal[top_coal['slope'] > 0])
1612
+ ot = len(top_coal[top_coal['slope'] == 0])
1613
+ avg = top_coal['weekly_avg'].mean()
1614
+ if w > 5:
1615
+ r = "Prioritize fatigue intervention for operators with worsening trends."
1616
+ reason = "High proportion of deteriorating operators signals emerging fatigue risks."
1617
+ elif ot > 4:
1618
+ r = "Validate data completeness — high One Time Event count may indicate reporting gaps."
1619
+ reason = "Operators with single-week data cannot yield reliable trend analysis."
1620
+ elif avg > 8:
1621
+ r = "Review scheduling and rest protocols to reduce event frequency."
1622
+ reason = "Elevated average event rate increases cumulative fatigue exposure."
1623
+ else:
1624
+ r = "Maintain current protocols with targeted monitoring."
1625
+ reason = "Risk profile is stable; focus on sustaining safe practices."
1626
+ rec['coal'] = r
1627
+ rec['coal_reason'] = reason
1628
  return rec
1629
 
1630
  ai_rec = generate_recommendations(top_ob, top_coal)
1631
 
1632
+ with col_rec1:
1633
  if 'ob' in ai_rec:
1634
+ st.markdown("### OB HAULER Recommendations")
1635
  st.markdown(f"""
1636
  <div class="recommendation-box">
1637
  <div class="recommendation-title">Action Plan</div>
 
1639
  <div class="recommendation-reason">AI Reasoning: {ai_rec['ob_reason']}</div>
1640
  </div>
1641
  """, unsafe_allow_html=True)
1642
+ else:
1643
+ st.info("No OB HAULER recommendations.")
1644
+
1645
+ with col_rec2:
1646
  if 'coal' in ai_rec:
1647
+ st.markdown("### HAULING COAL Recommendations")
1648
  st.markdown(f"""
1649
  <div class="recommendation-box">
1650
  <div class="recommendation-title">Action Plan</div>
 
1652
  <div class="recommendation-reason">AI Reasoning: {ai_rec['coal_reason']}</div>
1653
  </div>
1654
  """, unsafe_allow_html=True)
1655
+ else:
1656
+ st.info("No HAULING COAL recommendations.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1657
 
1658
  except Exception as e:
1659
  st.error(f"Error in Top 10 Operator analysis: {str(e)}")
1660
+ st.exception(e) # optionally show full traceback during dev
1661
+
1662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1663
  # =================== OBJECTIVE 6: Automated Insights & AI Recommendations =====================
1664
  st.subheader("OBJECTIVE 6: Instant Insights & Recommendations")
1665