SHELLAPANDIANGANHUNGING commited on
Commit
3f0e804
·
verified ·
1 Parent(s): f45416f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +166 -198
app.py CHANGED
@@ -1148,48 +1148,74 @@ except Exception as e:
1148
  st.exception(e)
1149
 
1150
 
1151
-
1152
  # =================== OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard =====================
1153
- st.subheader("OBJECTIVE 5:See your team’s fatigue Fatigue Hazard Profile!")
 
 
 
1154
 
1155
- # Custom CSS — tetap seperti sebelumnya (sudah sesuai preferensi)
1156
  st.markdown("""
1157
  <style>
1158
- .big-title {
1159
- font-size: 28px;
 
1160
  font-weight: bold;
1161
- color: #ffffff;
1162
  text-align: center;
1163
- margin-bottom: 10px;
1164
- background: linear-gradient(135deg, #2c3e50, #1a252c);
1165
- padding: 15px;
1166
  border-radius: 10px;
1167
- box-shadow: 0 4px 15px rgba(0,0,0,0.3);
 
1168
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1169
  .subnote {
1170
  font-size: 16px;
1171
  color: #7f8c8d;
1172
  text-align: center;
1173
  margin-bottom: 20px;
 
1174
  }
 
1175
  .section-divider {
1176
  height: 2px;
1177
  background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c);
1178
- margin: 20px 0;
1179
  }
 
 
1180
  .legend-container {
1181
  display: flex;
1182
- gap: 15px;
1183
- margin: 15px 0;
 
 
1184
  }
1185
  .legend-box {
1186
- background: white;
1187
  border: 1px solid #ddd;
1188
- border-radius: 8px;
1189
- padding: 15px;
1190
- flex: 1;
1191
- min-width: 300px;
1192
- box-shadow: 0 2px 10px rgba(0,0,0,0.05);
 
1193
  }
1194
  .legend-title {
1195
  font-weight: bold;
@@ -1202,25 +1228,34 @@ st.markdown("""
1202
  .legend-item {
1203
  display: flex;
1204
  align-items: center;
1205
- margin: 5px 0;
1206
- font-size: 12px;
1207
  }
1208
  .legend-color {
1209
  width: 18px;
1210
  height: 18px;
1211
  border-radius: 3px;
1212
- margin-right: 8px;
1213
  border: 1px solid #ccc;
1214
  }
 
 
 
 
 
 
 
 
 
1215
  .ai-insight-box {
1216
  background: #f8f9fa;
1217
  border: 1px solid #dee2e6;
1218
  border-radius: 8px;
1219
- padding: 15px;
1220
- margin: 10px 0;
1221
  color: #2c3e50;
1222
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1223
- box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1224
  }
1225
  .ai-insight-title {
1226
  font-weight: bold;
@@ -1232,20 +1267,14 @@ st.markdown("""
1232
  border-radius: 5px;
1233
  border-left: 4px solid #495057;
1234
  }
1235
- .trend-up {
1236
- color: #e74c3c;
1237
- font-weight: bold;
1238
- }
1239
- .trend-down {
1240
- color: #27ae60;
1241
- font-weight: bold;
1242
- }
1243
  .recommendation-box {
1244
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1245
  border: 1px solid #4a5568;
1246
  border-radius: 8px;
1247
- padding: 15px;
1248
- margin: 10px 0;
1249
  color: white;
1250
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1251
  box-shadow: 0 4px 15px rgba(0,0,0,0.1);
@@ -1261,18 +1290,23 @@ st.markdown("""
1261
  border-left: 4px solid white;
1262
  }
1263
  .recommendation-reason {
1264
- font-size: 12px;
1265
  margin-top: 10px;
1266
  padding: 8px;
1267
  background: rgba(255,255,255,0.1);
1268
  border-radius: 5px;
1269
  border-left: 3px solid rgba(255,255,255,0.3);
1270
  }
 
 
 
 
1271
  </style>
1272
  """, unsafe_allow_html=True)
1273
 
 
1274
  # ===============================================================
1275
- # LOGIC UTAMA
1276
  # ===============================================================
1277
  if df.empty:
1278
  st.info("No data available after applying filters.")
@@ -1283,7 +1317,17 @@ else:
1283
  st.warning("Required columns (operator, fleet_type, start) are missing.")
1284
  st.stop()
1285
 
 
1286
  df_op = df[[col_operator, col_fleet_type, "start"]].dropna()
 
 
 
 
 
 
 
 
 
1287
  if df_op.empty:
1288
  st.info("No operator data after filtering.")
1289
  st.stop()
@@ -1302,9 +1346,9 @@ else:
1302
  ob_data = df_op[df_op["is_ob"]]
1303
  coal_data = df_op[df_op["is_coal"]]
1304
 
 
1305
  def get_top10_with_slope(data):
1306
- if data.empty:
1307
- return pd.DataFrame()
1308
  if col_operator not in data.columns:
1309
  st.error(f"Operator column '{col_operator}' not found in data subset.")
1310
  return pd.DataFrame()
@@ -1312,22 +1356,20 @@ else:
1312
  weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1313
  metrics = []
1314
  for nik, grp in weekly.groupby(col_operator):
1315
- if pd.isna(nik):
1316
- continue
1317
  grp = grp.sort_values("year_week")
1318
  counts = grp["weekly_sum"].values
1319
  weeks = np.arange(len(counts))
1320
  weekly_avg = counts.mean()
1321
  total_events = counts.sum()
1322
  n_weeks = len(counts)
 
1323
  if n_weeks >= 2:
1324
  x_mean = weeks.mean()
1325
  y_mean = counts.mean()
1326
  numerator = np.sum((weeks - x_mean) * (counts - y_mean))
1327
  denominator = np.sum((weeks - x_mean) ** 2)
1328
  slope = numerator / denominator if denominator != 0 else 0.0
1329
- else:
1330
- slope = 0.0 # One Time Event
1331
  metrics.append({
1332
  col_operator: nik,
1333
  "weekly_avg": weekly_avg,
@@ -1335,50 +1377,15 @@ else:
1335
  "total_events": total_events,
1336
  "n_weeks": n_weeks
1337
  })
1338
- if not metrics:
1339
- return pd.DataFrame()
1340
- return pd.DataFrame(metrics).nlargest(10, "weekly_avg")
1341
 
1342
  top_ob = get_top10_with_slope(ob_data)
1343
  top_coal = get_top10_with_slope(coal_data)
1344
 
1345
- def get_all_operators_with_slope(data):
1346
- if data.empty:
1347
- return pd.DataFrame()
1348
- if col_operator not in data.columns:
1349
- return pd.DataFrame()
1350
-
1351
- weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1352
- metrics = []
1353
- for nik, grp in weekly.groupby(col_operator):
1354
- if pd.isna(nik):
1355
- continue
1356
- grp = grp.sort_values("year_week")
1357
- counts = grp["weekly_sum"].values
1358
- weeks = np.arange(len(counts))
1359
- weekly_avg = counts.mean()
1360
- total_events = counts.sum()
1361
- n_weeks = len(counts)
1362
- if n_weeks >= 2:
1363
- slope = np.cov(weeks, counts)[0, 1] / np.var(weeks) if np.var(weeks) != 0 else 0.0
1364
- else:
1365
- slope = 0.0
1366
- metrics.append({
1367
- col_operator: nik,
1368
- "weekly_avg": weekly_avg,
1369
- "slope": slope,
1370
- "total_events": total_events,
1371
- "n_weeks": n_weeks
1372
- })
1373
- return pd.DataFrame(metrics) if metrics else pd.DataFrame()
1374
-
1375
- all_ob = get_all_operators_with_slope(ob_data)
1376
- all_coal = get_all_operators_with_slope(coal_data)
1377
-
1378
  # ===============================================================
1379
- # LEGEND — UPDATED: Stable One Time Event, Gray → Yellow
1380
  # ===============================================================
1381
- st.subheader("Hazard Gradient Legend")
1382
  st.markdown("""
1383
  <div class="legend-container">
1384
  <!-- Worsening Trends -->
@@ -1386,22 +1393,22 @@ else:
1386
  <div class="legend-title">Worsening Trends (Positive Slope):</div>
1387
  <div class="legend-item">
1388
  <div class="legend-color" style="background-color: #d32f2f;"></div>
1389
- <span>Very High Worsening (≥1.5)</span>
1390
  </div>
1391
  <div class="legend-item">
1392
  <div class="legend-color" style="background-color: #e57373;"></div>
1393
- <span>High Worsening (1.0–1.5)</span>
1394
  </div>
1395
  <div class="legend-item">
1396
  <div class="legend-color" style="background-color: #ef9a9a;"></div>
1397
- <span>Moderate Worsening (0.5–1.0)</span>
1398
  </div>
1399
  <div class="legend-item">
1400
  <div class="legend-color" style="background-color: #ffcdd2;"></div>
1401
- <span>Slight Worsening (0–0.5)</span>
1402
  </div>
1403
  <p class="legend-note">
1404
- <i>Note: Positive slope indicates increasing fatigue events over time — escalating risk exposure.</i>
1405
  </p>
1406
  </div>
1407
 
@@ -1425,7 +1432,7 @@ else:
1425
  <span>Slight Improvement (−0.5 to 0)</span>
1426
  </div>
1427
  <p class="legend-note">
1428
- <i>Note: Negative slope reflects decreasing fatigue events — effective mitigation or behavioral improvement.</i>
1429
  </p>
1430
  </div>
1431
 
@@ -1437,77 +1444,57 @@ else:
1437
  <span>One Time Event (0)</span>
1438
  </div>
1439
  <p class="legend-note">
1440
- <i>Note: Slope = 0 by definition when data exists for only one week — insufficient for trend assessment.</i>
1441
  </p>
1442
  </div>
1443
  </div>
1444
-
1445
- <style>
1446
- .legend-note {
1447
- font-size: 12px;
1448
- color: #666;
1449
- margin-top: 12px;
1450
- margin-bottom: 0;
1451
- font-style: italic;
1452
- line-height: 1.4;
1453
- }
1454
- </style>
1455
  """, unsafe_allow_html=True)
1456
 
1457
  # ===============================================================
1458
- # PLOT FUNCTION — UPDATED: color for slope=0 is now #FFD700
1459
  # ===============================================================
1460
  def plot_chart(data, title):
1461
  if data.empty:
1462
  fig = go.Figure()
1463
- fig.add_annotation(
1464
- text="No Data",
1465
- x=0.5, y=0.5,
1466
- showarrow=False,
1467
- font_size=16
 
1468
  )
1469
- fig.update_layout(height=350, title=dict(text=title, x=0.5))
1470
  return fig
1471
 
1472
  data_sorted = data.sort_values('weekly_avg', ascending=False)
1473
 
 
1474
  def get_color(slope):
1475
  if slope == 0:
1476
- return "#FFD700" # ✅ Kuning untuk One Time Event
1477
- elif slope > 0:
1478
- if slope < 0.5:
1479
- return "#ffcdd2"
1480
- elif slope < 1.0:
1481
- return "#ef9a9a"
1482
- elif slope < 1.5:
1483
- return "#e57373"
1484
- else:
1485
- return "#d32f2f"
1486
- else: # slope < 0
1487
- if slope > -0.5:
1488
- return "#c8e6c9"
1489
- elif slope > -1.0:
1490
- return "#a5d6a7"
1491
- elif slope > -1.5:
1492
- return "#81c784"
1493
- else:
1494
- return "#388e3c"
1495
 
1496
  colors = [get_color(s) for s in data_sorted["slope"]]
1497
 
1498
  bar_trace = go.Bar(
1499
  x=data_sorted[col_operator].astype(str),
1500
  y=data_sorted["weekly_avg"],
1501
- marker=dict(
1502
- color=colors,
1503
- line=dict(width=2, color="rgba(0,0,0,0.2)")
1504
- ),
1505
  text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]],
1506
  textposition="outside",
 
1507
  hovertemplate=(
1508
  "<b>%{x}</b><br>" +
1509
  "Weekly Avg: %{y:.2f}<br>" +
1510
- "Trend Slope: %{customdata[0]:+.3f}<br>" +
1511
  "Total Events: %{customdata[1]}<br>" +
1512
  "Weeks Active: %{customdata[2]}<br>" +
1513
  "<extra></extra>"
@@ -1517,16 +1504,17 @@ else:
1517
 
1518
  fig = go.Figure(bar_trace)
1519
  fig.update_layout(
1520
- title=dict(text=f"<b>{title}</b>", x=0.5),
1521
- height=450,
1522
- margin=dict(l=50, r=20, t=60, b=120),
1523
- xaxis_title="<b>Operator Name</b>",
1524
- yaxis_title="<b>Weekly Avg Events</b>",
1525
  font=dict(family="Segoe UI", size=12),
1526
  bargap=0.3,
1527
  plot_bgcolor="rgba(0,0,0,0)",
1528
  paper_bgcolor="rgba(0,0,0,0)",
1529
- xaxis=dict(tickangle=45)
 
1530
  )
1531
  return fig
1532
 
@@ -1540,32 +1528,33 @@ else:
1540
  st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
1541
 
1542
  # ===============================================================
1543
- # AI INSIGHTS — tetap dalam bahasa Inggris, tanpa emoticon
1544
  # ===============================================================
1545
  col_insight1, col_insight2 = st.columns(2)
1546
 
1547
  with col_insight1:
1548
  if not top_ob.empty:
1549
- st.markdown("### OB HAULER Analysis")
1550
  ob_worsening = len(top_ob[top_ob['slope'] > 0])
1551
  ob_improving = len(top_ob[top_ob['slope'] < 0])
1552
  ob_one_time = len(top_ob[top_ob['slope'] == 0])
1553
  ob_avg_risk = top_ob['weekly_avg'].mean()
1554
  ob_max_risk = top_ob['weekly_avg'].max()
1555
- ob_insights = []
 
1556
  if ob_worsening > ob_improving:
1557
- ob_insights.append(f"{ob_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends.")
1558
  else:
1559
- ob_insights.append(f"{ob_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>.")
1560
  if ob_one_time > 0:
1561
- ob_insights.append(f"{ob_one_time} operators are classified as <b>One Time Event</b> (single-week activity).")
1562
- ob_insights.append(f"Average risk: {ob_avg_risk:.2f} events/week (max: {ob_max_risk:.2f}).")
1563
 
1564
- for insight in ob_insights:
1565
  st.markdown(f"""
1566
  <div class="ai-insight-box">
1567
  <div class="ai-insight-title">Risk Summary</div>
1568
- <p>{insight}</p>
1569
  </div>
1570
  """, unsafe_allow_html=True)
1571
  else:
@@ -1573,26 +1562,27 @@ else:
1573
 
1574
  with col_insight2:
1575
  if not top_coal.empty:
1576
- st.markdown("### HAULING COAL Analysis")
1577
  coal_worsening = len(top_coal[top_coal['slope'] > 0])
1578
  coal_improving = len(top_coal[top_coal['slope'] < 0])
1579
  coal_one_time = len(top_coal[top_coal['slope'] == 0])
1580
  coal_avg_risk = top_coal['weekly_avg'].mean()
1581
  coal_max_risk = top_coal['weekly_avg'].max()
1582
- coal_insights = []
 
1583
  if coal_worsening > coal_improving:
1584
- coal_insights.append(f"{coal_worsening} out of 10 top risk operators are showing <span class='trend-up'>worsening</span> trends.")
1585
  else:
1586
- coal_insights.append(f"{coal_improving} out of 10 top risk operators are showing <span class='trend-down'>improvement</span>.")
1587
  if coal_one_time > 0:
1588
- coal_insights.append(f"{coal_one_time} operators are classified as <b>One Time Event</b> (single-week activity).")
1589
- coal_insights.append(f"Average risk: {coal_avg_risk:.2f} events/week (max: {coal_max_risk:.2f}).")
1590
 
1591
- for insight in coal_insights:
1592
  st.markdown(f"""
1593
  <div class="ai-insight-box">
1594
  <div class="ai-insight-title">Risk Summary</div>
1595
- <p>{insight}</p>
1596
  </div>
1597
  """, unsafe_allow_html=True)
1598
  else:
@@ -1601,54 +1591,35 @@ else:
1601
  # ===============================================================
1602
  # RECOMMENDATIONS
1603
  # ===============================================================
1604
- col_rec1, col_rec2 = st.columns(2)
1605
-
1606
  def generate_recommendations(top_ob, top_coal):
1607
  rec = {}
1608
- if not top_ob.empty:
1609
- w = len(top_ob[top_ob['slope'] > 0])
1610
- ot = len(top_ob[top_ob['slope'] == 0])
1611
- avg = top_ob['weekly_avg'].mean()
1612
- if w > 5:
1613
- r = "Prioritize fatigue intervention for operators with worsening trends."
1614
- reason = "High proportion of deteriorating operators signals emerging fatigue risks."
1615
- elif ot > 4:
1616
- r = "Validate data completeness — high One Time Event count may indicate reporting gaps."
1617
- reason = "Operators with single-week data cannot yield reliable trend analysis."
1618
- elif avg > 8:
1619
- r = "Review scheduling and rest protocols to reduce event frequency."
1620
- reason = "Elevated average event rate increases cumulative fatigue exposure."
1621
- else:
1622
- r = "Maintain current protocols with targeted monitoring."
1623
- reason = "Risk profile is stable; focus on sustaining safe practices."
1624
- rec['ob'] = r
1625
- rec['ob_reason'] = reason
1626
-
1627
- if not top_coal.empty:
1628
- w = len(top_coal[top_coal['slope'] > 0])
1629
- ot = len(top_coal[top_coal['slope'] == 0])
1630
- avg = top_coal['weekly_avg'].mean()
1631
- if w > 5:
1632
- r = "Prioritize fatigue intervention for operators with worsening trends."
1633
- reason = "High proportion of deteriorating operators signals emerging fatigue risks."
1634
- elif ot > 4:
1635
- r = "Validate data completeness — high One Time Event count may indicate reporting gaps."
1636
- reason = "Operators with single-week data cannot yield reliable trend analysis."
1637
- elif avg > 8:
1638
- r = "Review scheduling and rest protocols to reduce event frequency."
1639
- reason = "Elevated average event rate increases cumulative fatigue exposure."
1640
- else:
1641
- r = "Maintain current protocols with targeted monitoring."
1642
- reason = "Risk profile is stable; focus on sustaining safe practices."
1643
- rec['coal'] = r
1644
- rec['coal_reason'] = reason
1645
  return rec
1646
 
1647
  ai_rec = generate_recommendations(top_ob, top_coal)
1648
 
 
1649
  with col_rec1:
1650
  if 'ob' in ai_rec:
1651
- st.markdown("### OB HAULER Recommendations")
1652
  st.markdown(f"""
1653
  <div class="recommendation-box">
1654
  <div class="recommendation-title">Action Plan</div>
@@ -1661,7 +1632,7 @@ else:
1661
 
1662
  with col_rec2:
1663
  if 'coal' in ai_rec:
1664
- st.markdown("### HAULING COAL Recommendations")
1665
  st.markdown(f"""
1666
  <div class="recommendation-box">
1667
  <div class="recommendation-title">Action Plan</div>
@@ -1674,10 +1645,7 @@ else:
1674
 
1675
  except Exception as e:
1676
  st.error(f"Error in Top 10 Operator analysis: {str(e)}")
1677
- st.exception(e) # optionally show full traceback during dev
1678
-
1679
- import re # Tambahkan ini jika belum ada
1680
-
1681
  # =================== OBJECTIVE 6: Automated Insights & AI Recommendations =====================
1682
  st.subheader("OBJECTIVE 6: Instant Insights & Recommendations")
1683
 
 
1148
  st.exception(e)
1149
 
1150
 
 
1151
  # =================== OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard =====================
1152
+ # ✅ DIPERBAIKI: Gunakan HTML + CSS (bukan st.subheader), centered, white bg, typo fixed
1153
+ st.markdown("""
1154
+ <h2 class="objective-header">OBJECTIVE 5: See Your Team’s Fatigue Hazard Profile!</h2>
1155
+ """, unsafe_allow_html=True)
1156
 
1157
+ # CUSTOM CSS — SEMUA STRUKTUR DIPERBAIKI & DIPERLUAS
1158
  st.markdown("""
1159
  <style>
1160
+ /* === OBJECTIVE HEADER === */
1161
+ .objective-header {
1162
+ font-size: 26px;
1163
  font-weight: bold;
1164
+ color: #2c3e50;
1165
  text-align: center;
1166
+ margin: 10px 0 25px 0;
1167
+ background: white;
1168
+ padding: 14px;
1169
  border-radius: 10px;
1170
+ box-shadow: 0 3px 12px rgba(0,0,0,0.1);
1171
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1172
  }
1173
+
1174
+ /* === BIG TITLE (e.g., "OB HAULER Analysis") === */
1175
+ .big-title {
1176
+ font-size: 22px;
1177
+ font-weight: bold;
1178
+ color: #2c3e50;
1179
+ text-align: center;
1180
+ margin: 25px 0 15px 0;
1181
+ background: white;
1182
+ padding: 12px;
1183
+ border-radius: 8px;
1184
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
1185
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1186
+ }
1187
+
1188
  .subnote {
1189
  font-size: 16px;
1190
  color: #7f8c8d;
1191
  text-align: center;
1192
  margin-bottom: 20px;
1193
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1194
  }
1195
+
1196
  .section-divider {
1197
  height: 2px;
1198
  background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c);
1199
+ margin: 25px 0;
1200
  }
1201
+
1202
+ /* === LEGEND === */
1203
  .legend-container {
1204
  display: flex;
1205
+ gap: 20px;
1206
+ flex-wrap: wrap;
1207
+ justify-content: center;
1208
+ margin: 20px 0;
1209
  }
1210
  .legend-box {
1211
+ background: #f9f9f9;
1212
  border: 1px solid #ddd;
1213
+ border-radius: 10px;
1214
+ padding: 16px;
1215
+ min-width: 290px;
1216
+ max-width: 330px;
1217
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1218
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1219
  }
1220
  .legend-title {
1221
  font-weight: bold;
 
1228
  .legend-item {
1229
  display: flex;
1230
  align-items: center;
1231
+ margin: 6px 0;
1232
+ font-size: 13px;
1233
  }
1234
  .legend-color {
1235
  width: 18px;
1236
  height: 18px;
1237
  border-radius: 3px;
1238
+ margin-right: 10px;
1239
  border: 1px solid #ccc;
1240
  }
1241
+ .legend-note {
1242
+ font-size: 12px;
1243
+ color: #666;
1244
+ margin-top: 12px;
1245
+ font-style: italic;
1246
+ line-height: 1.4;
1247
+ }
1248
+
1249
+ /* === AI INSIGHTS === */
1250
  .ai-insight-box {
1251
  background: #f8f9fa;
1252
  border: 1px solid #dee2e6;
1253
  border-radius: 8px;
1254
+ padding: 16px;
1255
+ margin: 12px 0;
1256
  color: #2c3e50;
1257
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1258
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
1259
  }
1260
  .ai-insight-title {
1261
  font-weight: bold;
 
1267
  border-radius: 5px;
1268
  border-left: 4px solid #495057;
1269
  }
1270
+
1271
+ /* === RECOMMENDATIONS === */
 
 
 
 
 
 
1272
  .recommendation-box {
1273
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1274
  border: 1px solid #4a5568;
1275
  border-radius: 8px;
1276
+ padding: 16px;
1277
+ margin: 12px 0;
1278
  color: white;
1279
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1280
  box-shadow: 0 4px 15px rgba(0,0,0,0.1);
 
1290
  border-left: 4px solid white;
1291
  }
1292
  .recommendation-reason {
1293
+ font-size: 13px;
1294
  margin-top: 10px;
1295
  padding: 8px;
1296
  background: rgba(255,255,255,0.1);
1297
  border-radius: 5px;
1298
  border-left: 3px solid rgba(255,255,255,0.3);
1299
  }
1300
+
1301
+ /* === TRENDS === */
1302
+ .trend-up { color: #e74c3c; font-weight: bold; }
1303
+ .trend-down { color: #27ae60; font-weight: bold; }
1304
  </style>
1305
  """, unsafe_allow_html=True)
1306
 
1307
+
1308
  # ===============================================================
1309
+ # LOGIC UTAMA — DIPERBAIKI: PENDEKAN NAMA OPERATOR & KONSISTENSI
1310
  # ===============================================================
1311
  if df.empty:
1312
  st.info("No data available after applying filters.")
 
1317
  st.warning("Required columns (operator, fleet_type, start) are missing.")
1318
  st.stop()
1319
 
1320
+ # ✅ Shorten operator names: "John Doe" → "John"
1321
  df_op = df[[col_operator, col_fleet_type, "start"]].dropna()
1322
+ if col_operator in df_op.columns:
1323
+ df_op[col_operator] = (
1324
+ df_op[col_operator]
1325
+ .astype(str)
1326
+ .str.strip()
1327
+ .str.split()
1328
+ .str[0] # Only first part
1329
+ )
1330
+
1331
  if df_op.empty:
1332
  st.info("No operator data after filtering.")
1333
  st.stop()
 
1346
  ob_data = df_op[df_op["is_ob"]]
1347
  coal_data = df_op[df_op["is_coal"]]
1348
 
1349
+ # Fungsi analisis — tetap sama (tidak ada perubahan logika)
1350
  def get_top10_with_slope(data):
1351
+ if data.empty: return pd.DataFrame()
 
1352
  if col_operator not in data.columns:
1353
  st.error(f"Operator column '{col_operator}' not found in data subset.")
1354
  return pd.DataFrame()
 
1356
  weekly = data.groupby([col_operator, "year_week"]).size().reset_index(name="weekly_sum")
1357
  metrics = []
1358
  for nik, grp in weekly.groupby(col_operator):
1359
+ if pd.isna(nik): continue
 
1360
  grp = grp.sort_values("year_week")
1361
  counts = grp["weekly_sum"].values
1362
  weeks = np.arange(len(counts))
1363
  weekly_avg = counts.mean()
1364
  total_events = counts.sum()
1365
  n_weeks = len(counts)
1366
+ slope = 0.0
1367
  if n_weeks >= 2:
1368
  x_mean = weeks.mean()
1369
  y_mean = counts.mean()
1370
  numerator = np.sum((weeks - x_mean) * (counts - y_mean))
1371
  denominator = np.sum((weeks - x_mean) ** 2)
1372
  slope = numerator / denominator if denominator != 0 else 0.0
 
 
1373
  metrics.append({
1374
  col_operator: nik,
1375
  "weekly_avg": weekly_avg,
 
1377
  "total_events": total_events,
1378
  "n_weeks": n_weeks
1379
  })
1380
+ return pd.DataFrame(metrics).nlargest(10, "weekly_avg") if metrics else pd.DataFrame()
 
 
1381
 
1382
  top_ob = get_top10_with_slope(ob_data)
1383
  top_coal = get_top10_with_slope(coal_data)
1384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
  # ===============================================================
1386
+ # LEGEND — SUDAH TERMASUK NOTES DI SETIAP KOTAK
1387
  # ===============================================================
1388
+ st.markdown('<h3 class="big-title">Hazard Gradient Legend</h3>', unsafe_allow_html=True)
1389
  st.markdown("""
1390
  <div class="legend-container">
1391
  <!-- Worsening Trends -->
 
1393
  <div class="legend-title">Worsening Trends (Positive Slope):</div>
1394
  <div class="legend-item">
1395
  <div class="legend-color" style="background-color: #d32f2f;"></div>
1396
+ <span>Very High Risk (≥1.5)</span>
1397
  </div>
1398
  <div class="legend-item">
1399
  <div class="legend-color" style="background-color: #e57373;"></div>
1400
+ <span>High Risk (1.0–1.5)</span>
1401
  </div>
1402
  <div class="legend-item">
1403
  <div class="legend-color" style="background-color: #ef9a9a;"></div>
1404
+ <span>Moderate Risk (0.5–1.0)</span>
1405
  </div>
1406
  <div class="legend-item">
1407
  <div class="legend-color" style="background-color: #ffcdd2;"></div>
1408
+ <span>Slight Risk (0–0.5)</span>
1409
  </div>
1410
  <p class="legend-note">
1411
+ <i>Note: Positive slope indicates increasing fatigue events over time — escalating operational risk.</i>
1412
  </p>
1413
  </div>
1414
 
 
1432
  <span>Slight Improvement (−0.5 to 0)</span>
1433
  </div>
1434
  <p class="legend-note">
1435
+ <i>Note: Negative slope reflects decreasing fatigue events — effective mitigation or behavioral adaptation.</i>
1436
  </p>
1437
  </div>
1438
 
 
1444
  <span>One Time Event (0)</span>
1445
  </div>
1446
  <p class="legend-note">
1447
+ <i>Note: Slope = 0 by definition when data exists for only one week — trend assessment not applicable.</i>
1448
  </p>
1449
  </div>
1450
  </div>
 
 
 
 
 
 
 
 
 
 
 
1451
  """, unsafe_allow_html=True)
1452
 
1453
  # ===============================================================
1454
+ # PLOT FUNCTION — DIPERBAIKI: LOGIKA WARNA SESUAI KATEGORI RISK
1455
  # ===============================================================
1456
  def plot_chart(data, title):
1457
  if data.empty:
1458
  fig = go.Figure()
1459
+ fig.add_annotation(text="No Data", x=0.5, y=0.5, showarrow=False, font_size=16, font_color="#888")
1460
+ fig.update_layout(
1461
+ height=350,
1462
+ title=dict(text=title, x=0.5, font=dict(size=18, family="Segoe UI")),
1463
+ plot_bgcolor="rgba(0,0,0,0)",
1464
+ paper_bgcolor="rgba(0,0,0,0)"
1465
  )
 
1466
  return fig
1467
 
1468
  data_sorted = data.sort_values('weekly_avg', ascending=False)
1469
 
1470
+ # ✅ Updated: Match memory — risk categories for worsening (not "worsening")
1471
  def get_color(slope):
1472
  if slope == 0:
1473
+ return "#FFD700" # One Time Event → yellow
1474
+ elif slope > 0: # Worsening = Risk
1475
+ if slope >= 1.5: return "#d32f2f" # Very High Risk
1476
+ elif slope >= 1.0: return "#e57373" # High Risk
1477
+ elif slope >= 0.5: return "#ef9a9a" # Moderate Risk
1478
+ else: return "#ffcdd2" # Slight Risk
1479
+ else: # slope < 0 → Improvement
1480
+ if slope <= -1.5: return "#388e3c" # Excellent Improvement
1481
+ elif slope <= -1.0: return "#81c784" # Great Improvement
1482
+ elif slope <= -0.5: return "#a5d6a7" # Good Improvement
1483
+ else: return "#c8e6c9" # Slight Improvement
 
 
 
 
 
 
 
 
1484
 
1485
  colors = [get_color(s) for s in data_sorted["slope"]]
1486
 
1487
  bar_trace = go.Bar(
1488
  x=data_sorted[col_operator].astype(str),
1489
  y=data_sorted["weekly_avg"],
1490
+ marker=dict(color=colors, line=dict(width=1.5, color="rgba(0,0,0,0.2)")),
 
 
 
1491
  text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]],
1492
  textposition="outside",
1493
+ textfont=dict(size=11, family="Segoe UI"),
1494
  hovertemplate=(
1495
  "<b>%{x}</b><br>" +
1496
  "Weekly Avg: %{y:.2f}<br>" +
1497
+ "Trend Slope: %{customdata[0]:+.2f}<br>" +
1498
  "Total Events: %{customdata[1]}<br>" +
1499
  "Weeks Active: %{customdata[2]}<br>" +
1500
  "<extra></extra>"
 
1504
 
1505
  fig = go.Figure(bar_trace)
1506
  fig.update_layout(
1507
+ title=dict(text=f"<b>{title}</b>", x=0.5, font=dict(size=18, color="#2c3e50")),
1508
+ height=460,
1509
+ margin=dict(l=50, r=20, t=70, b=130),
1510
+ xaxis_title=dict(text="<b>Operator</b>", font=dict(family="Segoe UI")),
1511
+ yaxis_title=dict(text="<b>Weekly Avg Events</b>", font=dict(family="Segoe UI")),
1512
  font=dict(family="Segoe UI", size=12),
1513
  bargap=0.3,
1514
  plot_bgcolor="rgba(0,0,0,0)",
1515
  paper_bgcolor="rgba(0,0,0,0)",
1516
+ xaxis=dict(tickangle=45, tickfont=dict(family="Segoe UI")),
1517
+ yaxis=dict(gridcolor="#eee")
1518
  )
1519
  return fig
1520
 
 
1528
  st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
1529
 
1530
  # ===============================================================
1531
+ # AI INSIGHTS — DIPERBAIKI: "Risk Summary", centered title
1532
  # ===============================================================
1533
  col_insight1, col_insight2 = st.columns(2)
1534
 
1535
  with col_insight1:
1536
  if not top_ob.empty:
1537
+ st.markdown('<h3 class="big-title">OB HAULER Analysis</h3>', unsafe_allow_html=True)
1538
  ob_worsening = len(top_ob[top_ob['slope'] > 0])
1539
  ob_improving = len(top_ob[top_ob['slope'] < 0])
1540
  ob_one_time = len(top_ob[top_ob['slope'] == 0])
1541
  ob_avg_risk = top_ob['weekly_avg'].mean()
1542
  ob_max_risk = top_ob['weekly_avg'].max()
1543
+
1544
+ insights = []
1545
  if ob_worsening > ob_improving:
1546
+ insights.append(f"{ob_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
1547
  else:
1548
+ insights.append(f"{ob_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1549
  if ob_one_time > 0:
1550
+ insights.append(f"{ob_one_time} operator(s) classified as <b>One Time Event</b>.")
1551
+ insights.append(f"Average risk: {ob_avg_risk:.2f} events/week (max: {ob_max_risk:.2f}).")
1552
 
1553
+ for txt in insights:
1554
  st.markdown(f"""
1555
  <div class="ai-insight-box">
1556
  <div class="ai-insight-title">Risk Summary</div>
1557
+ <p>{txt}</p>
1558
  </div>
1559
  """, unsafe_allow_html=True)
1560
  else:
 
1562
 
1563
  with col_insight2:
1564
  if not top_coal.empty:
1565
+ st.markdown('<h3 class="big-title">HAULING COAL Analysis</h3>', unsafe_allow_html=True)
1566
  coal_worsening = len(top_coal[top_coal['slope'] > 0])
1567
  coal_improving = len(top_coal[top_coal['slope'] < 0])
1568
  coal_one_time = len(top_coal[top_coal['slope'] == 0])
1569
  coal_avg_risk = top_coal['weekly_avg'].mean()
1570
  coal_max_risk = top_coal['weekly_avg'].max()
1571
+
1572
+ insights = []
1573
  if coal_worsening > coal_improving:
1574
+ insights.append(f"{coal_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
1575
  else:
1576
+ insights.append(f"{coal_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1577
  if coal_one_time > 0:
1578
+ insights.append(f"{coal_one_time} operator(s) classified as <b>One Time Event</b>.")
1579
+ insights.append(f"Average risk: {coal_avg_risk:.2f} events/week (max: {coal_max_risk:.2f}).")
1580
 
1581
+ for txt in insights:
1582
  st.markdown(f"""
1583
  <div class="ai-insight-box">
1584
  <div class="ai-insight-title">Risk Summary</div>
1585
+ <p>{txt}</p>
1586
  </div>
1587
  """, unsafe_allow_html=True)
1588
  else:
 
1591
  # ===============================================================
1592
  # RECOMMENDATIONS
1593
  # ===============================================================
 
 
1594
  def generate_recommendations(top_ob, top_coal):
1595
  rec = {}
1596
+ for label, data in [("ob", top_ob), ("coal", top_coal)]:
1597
+ if not data.empty:
1598
+ w = len(data[data['slope'] > 0])
1599
+ ot = len(data[data['slope'] == 0])
1600
+ avg = data['weekly_avg'].mean()
1601
+ if w > 5:
1602
+ r = "Prioritize fatigue intervention for operators with worsening trends."
1603
+ reason = "High proportion of deteriorating operators signals emerging fatigue risks."
1604
+ elif ot > 4:
1605
+ r = "Validate data completeness high One Time Event count may indicate reporting gaps."
1606
+ reason = "Operators with single-week data cannot yield reliable trend analysis."
1607
+ elif avg > 8:
1608
+ r = "Review scheduling and rest protocols to reduce event frequency."
1609
+ reason = "Elevated average event rate increases cumulative fatigue exposure."
1610
+ else:
1611
+ r = "Maintain current protocols with targeted monitoring."
1612
+ reason = "Risk profile is stable; focus on sustaining safe practices."
1613
+ rec[label] = r
1614
+ rec[f"{label}_reason"] = reason
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1615
  return rec
1616
 
1617
  ai_rec = generate_recommendations(top_ob, top_coal)
1618
 
1619
+ col_rec1, col_rec2 = st.columns(2)
1620
  with col_rec1:
1621
  if 'ob' in ai_rec:
1622
+ st.markdown('<h3 class="big-title">OB HAULER Recommendations</h3>', unsafe_allow_html=True)
1623
  st.markdown(f"""
1624
  <div class="recommendation-box">
1625
  <div class="recommendation-title">Action Plan</div>
 
1632
 
1633
  with col_rec2:
1634
  if 'coal' in ai_rec:
1635
+ st.markdown('<h3 class="big-title">HAULING COAL Recommendations</h3>', unsafe_allow_html=True)
1636
  st.markdown(f"""
1637
  <div class="recommendation-box">
1638
  <div class="recommendation-title">Action Plan</div>
 
1645
 
1646
  except Exception as e:
1647
  st.error(f"Error in Top 10 Operator analysis: {str(e)}")
1648
+ # st.exception(e) # Uncomment during development
 
 
 
1649
  # =================== OBJECTIVE 6: Automated Insights & AI Recommendations =====================
1650
  st.subheader("OBJECTIVE 6: Instant Insights & Recommendations")
1651