SHELLAPANDIANGANHUNGING commited on
Commit
3b082f2
·
verified ·
1 Parent(s): c89e6c6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +313 -344
app.py CHANGED
@@ -772,368 +772,286 @@ st.markdown(f"""
772
  </div>
773
  """, unsafe_allow_html=True)
774
  # ================= OBJECTIVE 3 =================
775
- st.markdown('<h3 class="objective-title">OBJECTIVE 3: Correlation — How Does Heat Influence Pressure and Which Tyres Trigger Red Alarms?</h3>', unsafe_allow_html=True)
 
 
776
 
777
- # Prepare data
778
- front_df = dff[dff['Position'].isin([1, 2])].copy()
779
- rear_df = dff[dff['Position'].isin([3, 4])].copy()
780
 
781
- col1, col2 = st.columns(2)
 
 
782
 
783
- # =============== COL 1: Front Temperature Pressure (Scatter + Regression Area) ===============
784
- with col1:
785
- st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
786
-
787
- if not front_df.empty:
788
- # Tambahkan kategori alarm status
789
- front_df['Category'] = front_df.apply(
790
- lambda row: f"Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
791
- else f"Amber Pressure Front Tyre" if 'Amber' in row['Alarm Status']
792
- else f"Red Pressure Front Tyre", axis=1
793
- )
794
- categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
795
- front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
796
-
797
- # Filter valid data
798
- valid_data = front_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
799
- if len(valid_data) > 1:
800
- X = valid_data[['Temperature (°C)']]
801
- y = valid_data['Pressure (psi)']
802
- model = LinearRegression().fit(X, y)
803
- x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
804
- y_line = model.predict(x_line)
805
- corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
806
-
807
- fig1 = px.scatter(
808
- valid_data,
809
- x='Temperature (°C)',
810
- y='Pressure (psi)',
811
- color='Category',
812
- color_discrete_map={
813
- "Normal Front Tyre": "#2E7D32", # Hijau
814
- "Amber Pressure Front Tyre": "#FFC107", # Kuning
815
- "Red Pressure Front Tyre": "#D32F2F" # Merah
816
- },
817
- category_orders={'Category': categories},
818
- template="plotly_white",
819
- labels={'Temperature (°C)': 'Temperature (°C)', 'Pressure (psi)': 'Pressure (psi)'}
820
- )
821
 
822
- fig1.update_traces(
823
- hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
824
- marker=dict(size=6)
825
- )
826
 
827
- # Tambahkan garis regresi
828
- fig1.add_trace(go.Scatter(
829
- x=x_line.flatten(), y=y_line,
830
- mode='lines', name='Trend Line',
831
- line=dict(color='#1976D2', dash='dot', width=2)
832
- ))
833
-
834
- # Tambahkan area confidence interval (soft background)
835
- y_pred = model.predict(X)
836
- residuals = y - y_pred
837
- mse = np.mean(residuals**2)
838
- std_error = np.sqrt(mse)
839
- y_upper = y_line + 1.96 * std_error
840
- y_lower = y_line - 1.96 * std_error
841
-
842
- fig1.add_trace(go.Scatter(
843
- x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
844
- y=np.concatenate([y_upper, y_lower[::-1]]),
845
- fill='toself',
846
- fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
847
- line=dict(color='rgba(255,255,255,0)'),
848
- showlegend=False,
849
- name='Confidence Interval'
850
- ))
851
-
852
- fig1.update_layout(
853
- margin=dict(t=40),
854
- annotations=[
855
- dict(
856
- x=0.95, y=0.95,
857
- xref="paper", yref="paper",
858
- text=f"r = {corr:.2f}",
859
- showarrow=False,
860
- bgcolor="white",
861
- bordercolor="black",
862
- borderwidth=1,
863
- font=dict(color="black")
864
- )
865
- ],
866
- legend=dict(
867
- title_text='Tyre Status',
868
- bgcolor="white",
869
- bordercolor="lightgray",
870
- borderwidth=1,
871
- itemclick=False,
872
- itemdoubleclick=False
873
- ),
874
- showlegend=True
875
- )
876
- st.plotly_chart(fig1, use_container_width=True)
877
- else:
878
- st.warning("Insufficient data for front tyres.")
879
- else:
880
- st.warning("No front tyre data.")
881
 
882
- # =============== COL 2: Front — Temperature / Speed (Boxplot) ===============
883
- with col2:
884
- st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
885
-
886
- if not front_df.empty:
887
- # Filter kecepatan > 0 untuk hindari pembagian dengan nol
888
- front_df = front_df[front_df['Speed (km/h)'] > 10]
889
- front_df['Temp_Speed_Ratio'] = front_df['Temperature (°C)'] / front_df['Speed (km/h)']
890
-
891
- # Tambahkan kategori alarm status
892
- front_df['Category'] = front_df.apply(
893
- lambda row: f"Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
894
- else f"Amber Pressure Front Tyre" if 'Amber' in row['Alarm Status']
895
- else f"Red Pressure Front Tyre", axis=1
896
- )
897
- categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
898
- front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
899
-
900
- valid_data = front_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
901
- if not valid_data.empty:
902
- fig2 = px.scatter(
903
- valid_data,
904
- x='Temp_Speed_Ratio',
905
- y='Pressure (psi)',
906
- color='Category',
907
- color_discrete_map={
908
- "Normal Front Tyre": "#2E7D32", # Hijau
909
- "Amber Pressure Front Tyre": "#FFC107", # Kuning
910
- "Red Pressure Front Tyre": "#D32F2F" # Merah
911
- },
912
- category_orders={'Category': categories},
913
- template="plotly_white",
914
- labels={'Temp_Speed_Ratio': 'Temperature / Speed', 'Pressure (psi)': 'Pressure (psi)'}
915
- )
916
 
917
- fig2.update_traces(
918
- hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
919
- marker=dict(size=6)
920
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
 
922
- fig2.update_layout(
923
- margin=dict(t=40),
924
- legend=dict(
925
- title_text='Tyre Status',
926
- bgcolor="white",
927
- bordercolor="lightgray",
928
- borderwidth=1,
929
- itemclick=False,
930
- itemdoubleclick=False
931
- )
 
 
 
932
  )
933
- st.plotly_chart(fig2, use_container_width=True)
934
- else:
935
- st.warning("Insufficient data for front tyres.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
936
  else:
937
- st.warning("No front tyre data.")
938
 
939
- # =============== COL 3: Rear — Temperature → Pressure (Scatter + Regression Area) ===============
940
- col3, col4 = st.columns(2)
 
 
 
 
 
 
 
 
941
 
942
  with col3:
943
- st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
944
-
945
- if not rear_df.empty:
946
- rear_df['Category'] = rear_df.apply(
947
- lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
948
- else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
949
- else f"Red Pressure Rear Tyre", axis=1
950
- )
951
- categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
952
- rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
953
-
954
- valid_data = rear_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
955
- if len(valid_data) > 1:
956
- X = valid_data[['Temperature (°C)']]
957
- y = valid_data['Pressure (psi)']
958
- model = LinearRegression().fit(X, y)
959
- x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
960
- y_line = model.predict(x_line)
961
- corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
962
-
963
- fig3 = px.scatter(
964
- valid_data,
965
- x='Temperature (°C)',
966
- y='Pressure (psi)',
967
- color='Category',
968
- color_discrete_map={
969
- "Normal Rear Tyre": "#2E7D32",
970
- "Amber Pressure Rear Tyre": "#FFC107",
971
- "Red Pressure Rear Tyre": "#D32F2F"
972
- },
973
- category_orders={'Category': categories},
974
- template="plotly_white"
975
- )
976
 
977
- fig3.update_traces(
978
- hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
979
- marker=dict(size=6)
980
- )
 
 
 
 
 
 
981
 
982
- fig3.add_trace(go.Scatter(
983
- x=x_line.flatten(), y=y_line,
984
- mode='lines', name='Trend Line',
985
- line=dict(color='#1976D2', dash='dot', width=2)
986
- ))
987
-
988
- # Tambahkan area confidence interval (soft background)
989
- y_pred = model.predict(X)
990
- residuals = y - y_pred
991
- mse = np.mean(residuals**2)
992
- std_error = np.sqrt(mse)
993
- y_upper = y_line + 1.96 * std_error
994
- y_lower = y_line - 1.96 * std_error
995
-
996
- fig3.add_trace(go.Scatter(
997
- x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
998
- y=np.concatenate([y_upper, y_lower[::-1]]),
999
- fill='toself',
1000
- fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
1001
- line=dict(color='rgba(255,255,255,0)'),
1002
- showlegend=False,
1003
- name='Confidence Interval'
1004
- ))
1005
-
1006
- fig3.update_layout(
1007
- margin=dict(t=40),
1008
- annotations=[
1009
- dict(
1010
- x=0.95, y=0.95,
1011
- xref="paper", yref="paper",
1012
- text=f"r = {corr:.2f}",
1013
- showarrow=False,
1014
- bgcolor="white",
1015
- bordercolor="black",
1016
- borderwidth=1,
1017
- font=dict(color="black")
1018
- )
1019
- ],
1020
- legend=dict(
1021
- title_text='Tyre Status',
1022
- bgcolor="white",
1023
- bordercolor="lightgray",
1024
- borderwidth=1,
1025
- itemclick=False,
1026
- itemdoubleclick=False
1027
- ),
1028
- showlegend=True
1029
- )
1030
- st.plotly_chart(fig3, use_container_width=True)
1031
- else:
1032
- st.warning("Insufficient data for rear tyres.")
1033
  else:
1034
- st.warning("No rear tyre data.")
1035
 
1036
- # =============== COL 4: Rear — Pressure vs (Temperature / Speed) ===============
1037
- with col4:
1038
- st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
1039
-
1040
- if not rear_df.empty:
1041
- # Filter kecepatan > 0 untuk hindari pembagian dengan nol
1042
- rear_df = rear_df[rear_df['Speed (km/h)'] > 10]
1043
- rear_df['Temp_Speed_Ratio'] = rear_df['Temperature (°C)'] / rear_df['Speed (km/h)']
1044
-
1045
- # Tambahkan kategori alarm status
1046
- rear_df['Category'] = rear_df.apply(
1047
- lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
1048
- else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
1049
- else f"Red Pressure Rear Tyre", axis=1
1050
- )
1051
- categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
1052
- rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
1053
-
1054
- valid_data = rear_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
1055
- if not valid_data.empty:
1056
- fig4 = px.scatter(
1057
- valid_data,
1058
- x='Temp_Speed_Ratio',
1059
- y='Pressure (psi)',
1060
- color='Category',
1061
- color_discrete_map={
1062
- "Normal Rear Tyre": "#2E7D32",
1063
- "Amber Pressure Rear Tyre": "#FFC107",
1064
- "Red Pressure Rear Tyre": "#D32F2F"
1065
- },
1066
- category_orders={'Category': categories},
1067
- template="plotly_white"
1068
- )
1069
 
1070
- fig4.update_traces(
1071
- hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
1072
- marker=dict(size=6)
1073
- )
 
 
 
 
 
 
1074
 
1075
- fig4.update_layout(
1076
- margin=dict(t=40),
1077
- legend=dict(
1078
- title_text='Tyre Status',
1079
- bgcolor="white",
1080
- bordercolor="lightgray",
1081
- borderwidth=1,
1082
- itemclick=False,
1083
- itemdoubleclick=False
1084
- )
1085
- )
1086
- st.plotly_chart(fig4, use_container_width=True)
1087
- else:
1088
- st.warning("Insufficient data for rear tyres.")
1089
  else:
1090
- st.warning("No rear tyre data.")
1091
 
1092
- # =============== INSIGHT 4 ===============
1093
- def safe_corr(a, b):
1094
- mask = ~(np.isnan(a) | np.isnan(b))
1095
- if mask.sum() < 2:
1096
- return 0.0
1097
- return np.corrcoef(a[mask], b[mask])[0, 1]
1098
 
1099
- corr_p_t_front = safe_corr(front_df['Temperature (°C)'], front_df['Pressure (psi)'])
1100
- corr_p_t_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Pressure (psi)'])
 
 
 
1101
 
1102
- # Hitung jumlah alarm red saat suhu >= 52°C di front tyre
1103
- high_temp_front = front_df[front_df['Temperature (°C)'] >= 52]
1104
- red_high_pressure_count = high_temp_front[high_temp_front['Alarm Status'] == 'Red High Pressure'].shape[0]
1105
 
1106
- # Hitung korelasi Pressure vs T/V (Temperature / Speed) untuk front
1107
- if not front_df.empty and (front_df['Speed (km/h)'] > 0).any():
1108
- front_df_filtered = front_df[front_df['Speed (km/h)'] > 0].copy()
1109
- front_df_filtered['Temp_Speed_Ratio'] = front_df_filtered['Temperature (°C)'] / front_df_filtered['Speed (km/h)']
1110
- corr_p_tv_front = safe_corr(front_df_filtered['Pressure (psi)'], front_df_filtered['Temp_Speed_Ratio'])
1111
- else:
1112
- corr_p_tv_front = 0.0
1113
 
1114
- # Hitung korelasi Pressure vs T/V (Temperature / Speed) untuk rear
1115
- if not rear_df.empty and (rear_df['Speed (km/h)'] > 0).any():
1116
- rear_df_filtered = rear_df[rear_df['Speed (km/h)'] > 0].copy()
1117
- rear_df_filtered['Temp_Speed_Ratio'] = rear_df_filtered['Temperature (°C)'] / rear_df_filtered['Speed (km/h)']
1118
- corr_p_tv_rear = safe_corr(rear_df_filtered['Pressure (psi)'], rear_df_filtered['Temp_Speed_Ratio'])
1119
- else:
1120
- corr_p_tv_rear = 0.0
1121
 
1122
- # Insight
1123
- insight_text = f"""
1124
- Strong correlation between temperature and pressure in front tyres (r = {corr_p_t_front:.2f}) vs rear (r = {corr_p_t_rear:.2f}).
1125
- At temperatures ≥52°C, front tyres trigger {red_high_pressure_count} Red High Pressure alarms, indicating critical heat thresholds.
1126
- • Pressure vs (T/v) shows weak correlation in front (r = {corr_p_tv_front:.2f}) and rear (r = {corr_p_tv_rear:.2f}), suggesting speed alone is not primary heat factor.
1127
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1129
  st.markdown(f"""
1130
  <div class="insight-box">
1131
  <div class="content">
1132
- {insight_text.strip()}
1133
  </div>
1134
  </div>
1135
  """, unsafe_allow_html=True)
1136
-
1137
  # ================= OBJECTIVE 4 =================
1138
  st.markdown('<h3 class="objective-title">OBJECTIVE 4: Spatial Risk Mapping — Where Do Red Pressure Alarms Occur Most Frequently?</h3>', unsafe_allow_html=True)
1139
 
@@ -1243,6 +1161,7 @@ st.markdown(f"""
1243
  """, unsafe_allow_html=True)
1244
  # ================= OBJECTIVE 5 =================
1245
  # ================= OBJECTIVE 5 =================
 
1246
  st.markdown('<h3 class="objective-title">OBJECTIVE 5: Insights & Mitigation — How Can Red Pressure Alarms Be Reduced?</h3>', unsafe_allow_html=True)
1247
 
1248
  # --- DATA PREP ---
@@ -1270,6 +1189,57 @@ if not rear_df.empty and len(rear_df[['Speed (km/h)']].dropna()) > 1 and len(rea
1270
  else:
1271
  corr_rear = 0
1272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1273
  # Insight dari Objective 1-4
1274
  insight_text = f"""1. **Pressure & Temperature Distribution (Objective 1):**
1275
  • Front tyres (Pos 1 & 2) show lower pressure ({front_pressure_avg:.1f} psi) and higher temperature ({front_temp_avg:.1f}°C) due to higher stress from braking/steering.
@@ -1298,10 +1268,12 @@ try:
1298
  prompt = f"""
1299
  Role: Fleet Operations Risk Analyst
1300
  Insights:
1301
- - High-risk zone: {top_zone} ({percentage_obj4:.1f}% of alarms)
1302
  - Front tyres: {front_percentage_obj4:.1f}% of total alarms
1303
  - Peak alarm hour: {dominant_hour}:00 ({dominant_percentage:.1f}%)
1304
- - Front tyre pressure–temperature correlation r = {corr_p_t_front:.2f}
 
 
1305
  Task:
1306
  Generate:
1307
  1. Business Recommendations
@@ -1327,36 +1299,34 @@ Rules:
1327
  if recommendation_text == "":
1328
  recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation.
1329
  <br>
1330
- 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone} to reduce alarm frequency.
1331
  <br>
1332
- 3. Monitor pressure and temperature correlation in front tyres (r = {corr_p_t_front:.2f}) to prevent overheating and premature wear.
1333
  <br>
1334
- 4. Restrict vehicle access to {top_zone} until pavement maintenance is completed, as it contributes to {percentage_obj4:.1f}% of alarms."""
1335
  if risk_mitigation_text == "":
1336
  risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating.
1337
  <br>
1338
  2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur.
1339
  <br>
1340
- 3. Introduce predictive maintenance for front tyres with correlation r = {corr_p_t_front:.2f} to prevent unplanned downtime.
1341
- <br>
1342
- 4. Implement real-time monitoring in {top_zone} where {percentage_obj4:.1f}% of alarms are concentrated."""
1343
  except:
1344
  # Jika response dari model kosong atau gagal, gunakan versi manual
1345
  recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation.
1346
  <br>
1347
- 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone} to reduce alarm frequency.
1348
  <br>
1349
- 3. Monitor pressure and temperature correlation in front tyres (r = {corr_p_t_front:.2f}) to prevent overheating and premature wear.
1350
  <br>
1351
- 4. Restrict vehicle access to {top_zone} until pavement maintenance is completed, as it contributes to {percentage_obj4:.1f}% of alarms."""
1352
  # Risk Mitigation
1353
  risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating.
1354
  <br>
1355
  2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur.
1356
  <br>
1357
- 3. Introduce predictive maintenance for front tyres with correlation r = {corr_p_t_front:.2f} to prevent unplanned downtime.
1358
- <br>
1359
- 4. Implement real-time monitoring in {top_zone} where {percentage_obj4:.1f}% of alarms are concentrated."""
1360
 
1361
  # ============== SUBHEADER + BOX 1: INSIGHT ==============
1362
  st.markdown('<h4 style="text-align:left; margin:10px 0 5px 0; font-weight:bold;">INSIGHT</h4>', unsafe_allow_html=True)
@@ -1387,7 +1357,6 @@ st.markdown(f"""
1387
  </div>
1388
  </div>
1389
  """, unsafe_allow_html=True)
1390
-
1391
  # ================= FOOTER =================
1392
  st.markdown("""
1393
  <div class="footer">
 
772
  </div>
773
  """, unsafe_allow_html=True)
774
  # ================= OBJECTIVE 3 =================
775
+ st.markdown("""
776
+ <h3 class="objective-title">OBJECTIVE 2: Shift and Tyre Position - How Are Alarms Concentrated Across Shifts and Tyres?</h3>
777
+ """, unsafe_allow_html=True)
778
 
779
+ # Filter semua data (termasuk alarm normal)
780
+ alarm_data = dff.copy()
 
781
 
782
+ # Buat 2 baris × 4 kolom
783
+ col1, col2, col3, col4 = st.columns(4)
784
+ col5, col6, col7, col8 = st.columns(4)
785
 
786
+ # Fungsi helper untuk membuat radial chart per posisi dan shift
787
+ def create_radial_chart(pos_data, title, shift_hours, shift_type):
788
+ if pos_data.empty:
789
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
 
791
+ # Kelompokkan jam dan status
792
+ hourly_status_counts = pos_data.groupby(['hour', 'Alarm Status']).size().unstack(fill_value=0)
 
 
793
 
794
+ # Klasifikasi berdasarkan kata kunci
795
+ hourly_normal = hourly_status_counts.get('No Alarm', pd.Series(0, index=shift_hours)).reindex(shift_hours, fill_value=0)
796
+ hourly_amber = hourly_status_counts.filter(regex='Amber').sum(axis=1).reindex(shift_hours, fill_value=0) # Semua yang mengandung "Amber"
797
+ hourly_red = hourly_status_counts.filter(regex='Red').sum(axis=1).reindex(shift_hours, fill_value=0) # Semua yang mengandung "Red"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
 
799
+ # Total per jam
800
+ total_per_hour = hourly_normal + hourly_amber + hourly_red
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
 
802
+ # Sudut: sesuaikan agar jam 12 di bawah (180°), jam 6 di kanan (90°), jam 3 di atas (0°), jam 9 di kiri (270°)
803
+ if shift_type == 'pagi':
804
+ # Shift Pagi (06:00–18:00) → 0° = 03:00, 90° = 06:00, 180° = 12:00, 270° = 18:00
805
+ theta = [(h - 3) * 30 for h in shift_hours] # 12 jam * 30° = 360°
806
+ tickvals = [0, 90, 180, 270]
807
+ ticktext = ["03:00", "06:00", "12:00", "18:00"]
808
+ else: # Shift Sore (18:00–06:00)
809
+ # Shift Sore (18:00–06:00) → 0° = 18:00, 90° = 21:00, 180° = 00:00, 270° = 03:00
810
+ theta = [(h - 18) * 30 if h >= 18 else (h + 6) * 30 for h in shift_hours] # 12 jam * 30° = 360°
811
+ tickvals = [0, 90, 180, 270]
812
+ ticktext = ["18:00", "21:00", "00:00", "03:00"]
813
+
814
+ fig = go.Figure()
815
+
816
+ # Tambahkan trace untuk masing-masing kategori dengan hovertemplate custom
817
+ fig.add_trace(go.Barpolar(
818
+ r=hourly_normal.values,
819
+ theta=theta,
820
+ name='Normal',
821
+ marker_color='#2E7D32', # Hijau
822
+ opacity=0.8,
823
+ hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Normal<extra></extra>',
824
+ customdata=shift_hours
825
+ ))
826
+ fig.add_trace(go.Barpolar(
827
+ r=hourly_amber.values,
828
+ theta=theta,
829
+ name='Amber',
830
+ marker_color='#FFC107', # Kuning
831
+ opacity=0.8,
832
+ hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Amber<extra></extra>',
833
+ customdata=shift_hours
834
+ ))
835
+ fig.add_trace(go.Barpolar(
836
+ r=hourly_red.values,
837
+ theta=theta,
838
+ name='Red',
839
+ marker_color='#D32F2F', # Merah
840
+ opacity=0.8,
841
+ hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Red<extra></extra>',
842
+ customdata=shift_hours
843
+ ))
844
 
845
+ fig.update_layout(
846
+ polar=dict(
847
+ angularaxis=dict(
848
+ direction="clockwise",
849
+ period=len(shift_hours),
850
+ rotation=0,
851
+ tickvals=tickvals,
852
+ ticktext=ticktext,
853
+ tickfont=dict(size=12)
854
+ ),
855
+ radialaxis=dict(
856
+ visible=True,
857
+ range=[0, max(total_per_hour.max() * 1.1, 1)]
858
  )
859
+ ),
860
+ showlegend=False,
861
+ margin=dict(t=30, b=20, l=20, r=20),
862
+ height=250,
863
+ title_text=title,
864
+ title_x=0.5
865
+ )
866
+ return fig
867
+
868
+ # =============== ROW 1: Position 1 & 2 (Pagi & Sore) ===============
869
+ with col1:
870
+ # Position 1 Pagi (06:00–18:00)
871
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 1 (06:00–18:00)</div>', unsafe_allow_html=True)
872
+ pos1_data = alarm_data[alarm_data['Position'] == 1].copy()
873
+ pos1_data = pos1_data[pos1_data['hour'].between(6, 17, inclusive='both')]
874
+ fig1 = create_radial_chart(pos1_data, "Position 1 (06:00–18:00)", list(range(6, 18)), 'pagi')
875
+ if fig1 is not None:
876
+ st.plotly_chart(fig1, use_container_width=True)
877
  else:
878
+ st.warning("No data for Position 1 (06:00–18:00)")
879
 
880
+ with col2:
881
+ # Position 1 Sore (18:00–06:00)
882
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 1 (18:00–06:00)</div>', unsafe_allow_html=True)
883
+ pos1_data = alarm_data[alarm_data['Position'] == 1].copy()
884
+ pos1_data = pos1_data[~pos1_data['hour'].between(6, 17, inclusive='both')]
885
+ fig2 = create_radial_chart(pos1_data, "Position 1 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
886
+ if fig2 is not None:
887
+ st.plotly_chart(fig2, use_container_width=True)
888
+ else:
889
+ st.warning("No data for Position 1 (18:00–06:00)")
890
 
891
  with col3:
892
+ # Position 2 Pagi (06:00–18:00)
893
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 2 (06:00–18:00)</div>', unsafe_allow_html=True)
894
+ pos2_data = alarm_data[alarm_data['Position'] == 2].copy()
895
+ pos2_data = pos2_data[pos2_data['hour'].between(6, 17, inclusive='both')]
896
+ fig3 = create_radial_chart(pos2_data, "Position 2 (06:00–18:00)", list(range(6, 18)), 'pagi')
897
+ if fig3 is not None:
898
+ st.plotly_chart(fig3, use_container_width=True)
899
+ else:
900
+ st.warning("No data for Position 2 (06:00–18:00)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
901
 
902
+ with col4:
903
+ # Position 2 Sore (18:00–06:00)
904
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 2 (18:00–06:00)</div>', unsafe_allow_html=True)
905
+ pos2_data = alarm_data[alarm_data['Position'] == 2].copy()
906
+ pos2_data = pos2_data[~pos2_data['hour'].between(6, 17, inclusive='both')]
907
+ fig4 = create_radial_chart(pos2_data, "Position 2 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
908
+ if fig4 is not None:
909
+ st.plotly_chart(fig4, use_container_width=True)
910
+ else:
911
+ st.warning("No data for Position 2 (18:00–06:00)")
912
 
913
+ # =============== ROW 2: Position 3 & 4 (Pagi & Sore) ===============
914
+ with col5:
915
+ # Position 3 Pagi (06:00–18:00)
916
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 3 (06:00–18:00)</div>', unsafe_allow_html=True)
917
+ pos3_data = alarm_data[alarm_data['Position'] == 3].copy()
918
+ pos3_data = pos3_data[pos3_data['hour'].between(6, 17, inclusive='both')]
919
+ fig5 = create_radial_chart(pos3_data, "Position 3 (06:00–18:00)", list(range(6, 18)), 'pagi')
920
+ if fig5 is not None:
921
+ st.plotly_chart(fig5, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
922
  else:
923
+ st.warning("No data for Position 3 (06:00–18:00)")
924
 
925
+ with col6:
926
+ # Position 3 Sore (18:00–06:00)
927
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 3 (18:00–06:00)</div>', unsafe_allow_html=True)
928
+ pos3_data = alarm_data[alarm_data['Position'] == 3].copy()
929
+ pos3_data = pos3_data[~pos3_data['hour'].between(6, 17, inclusive='both')]
930
+ fig6 = create_radial_chart(pos3_data, "Position 3 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
931
+ if fig6 is not None:
932
+ st.plotly_chart(fig6, use_container_width=True)
933
+ else:
934
+ st.warning("No data for Position 3 (18:00–06:00)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
 
936
+ with col7:
937
+ # Position 4 Pagi (06:00–18:00)
938
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 4 (06:00–18:00)</div>', unsafe_allow_html=True)
939
+ pos4_data = alarm_data[alarm_data['Position'] == 4].copy()
940
+ pos4_data = pos4_data[pos4_data['hour'].between(6, 17, inclusive='both')]
941
+ fig7 = create_radial_chart(pos4_data, "Position 4 (06:00–18:00)", list(range(6, 18)), 'pagi')
942
+ if fig7 is not None:
943
+ st.plotly_chart(fig7, use_container_width=True)
944
+ else:
945
+ st.warning("No data for Position 4 (06:00–18:00)")
946
 
947
+ with col8:
948
+ # Position 4 Sore (18:00–06:00)
949
+ st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 4 (18:00–06:00)</div>', unsafe_allow_html=True)
950
+ pos4_data = alarm_data[alarm_data['Position'] == 4].copy()
951
+ pos4_data = pos4_data[~pos4_data['hour'].between(6, 17, inclusive='both')]
952
+ fig8 = create_radial_chart(pos4_data, "Position 4 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
953
+ if fig8 is not None:
954
+ st.plotly_chart(fig8, use_container_width=True)
 
 
 
 
 
 
955
  else:
956
+ st.warning("No data for Position 4 (18:00–06:00)")
957
 
958
+ # =============== INSIGHT 3 ===============
959
+ if alarm_data.empty:
960
+ insight_text = "• No data available for analysis."
961
+ else:
962
+ # Insight tetap sama
963
+ alarm_hours = alarm_data['hour']
964
 
965
+ def hour_to_band(h):
966
+ if 0 <= h < 6: return "00:00–06:00 (Night)"
967
+ if 6 <= h < 12: return "06:00–12:00 (Morning)"
968
+ if 12 <= h < 18: return "12:00–18:00 (Afternoon)"
969
+ return "18:00–00:00 (Evening)"
970
 
971
+ alarm_hours_df = pd.DataFrame({'hour': alarm_hours})
972
+ alarm_hours_df['band'] = alarm_hours_df['hour'].apply(hour_to_band)
973
+ band_counts = alarm_hours_df['band'].value_counts().sort_index()
974
 
975
+ top_bands = band_counts.nlargest(2)
976
+ dominant_band = top_bands.index[0] if len(top_bands) > 0 else "N/A"
977
+ second_dominant_band = top_bands.index[1] if len(top_bands) > 1 else "N/A"
 
 
 
 
978
 
979
+ dominant_pct = (top_bands.iloc[0] / band_counts.sum() * 100) if len(top_bands) > 0 else 0
980
+ second_pct = (top_bands.iloc[1] / band_counts.sum() * 100) if len(top_bands) > 1 else 0
 
 
 
 
 
981
 
982
+ # Hitung jumlah masing-masing jenis alarm
983
+ normal_alarms = alarm_data[alarm_data['Alarm Status'] == 'No Alarm'].shape[0]
984
+ red_alarms = alarm_data[alarm_data['Alarm Status'].str.contains('Red', na=False)].shape[0]
985
+ amber_alarms = alarm_data[alarm_data['Alarm Status'].str.contains('Amber', na=False)].shape[0]
986
+
987
+ # Insight Spesifik Per Position dan Shift
988
+ insight_lines = [
989
+ f"{dominant_band} is the dominant period ({dominant_pct:.1f}% of all data).",
990
+ f"{second_dominant_band} is the second-highest period ({second_pct:.1f}% of data).",
991
+ f"Total: Normal={normal_alarms}, Amber={amber_alarms}, Red={red_alarms}"
992
+ ]
993
+
994
+ # Position 1 (Shift Pagi)
995
+ pos1_pagi = alarm_data[(alarm_data['Position'] == 1) & (alarm_data['hour'].between(6, 17, inclusive='both'))]
996
+ if not pos1_pagi.empty:
997
+ pos1_pagi_total = pos1_pagi.groupby('hour').size()
998
+ if not pos1_pagi_total.empty:
999
+ dominant_hour_p1_pagi = pos1_pagi_total.idxmax()
1000
+ dominant_count_p1_pagi = pos1_pagi_total.max()
1001
+ insight_lines.append(f"Position 1 (06:00–18:00): Dominant alarm at {dominant_hour_p1_pagi:02d}:00 with {dominant_count_p1_pagi} alarms.")
1002
+
1003
+ # Position 1 (Shift Sore)
1004
+ pos1_sore = alarm_data[(alarm_data['Position'] == 1) & (~alarm_data['hour'].between(6, 17, inclusive='both'))]
1005
+ if not pos1_sore.empty:
1006
+ pos1_sore_red = pos1_sore[pos1_sore['Alarm Status'].str.contains('Red', na=False)]
1007
+ if not pos1_sore_red.empty:
1008
+ red_percentage_p1_sore = (len(pos1_sore_red) / len(pos1_sore)) * 100
1009
+ insight_lines.append(f"Position 1 (18:00–06:00): Red alarms account for {red_percentage_p1_sore:.1f}% of total alarms.")
1010
+
1011
+ # Position 3 (Shift Pagi)
1012
+ pos3_pagi = alarm_data[(alarm_data['Position'] == 3) & (alarm_data['hour'].between(6, 17, inclusive='both'))]
1013
+ if not pos3_pagi.empty:
1014
+ pos3_pagi_total = pos3_pagi.groupby('hour').size()
1015
+ if not pos3_pagi_total.empty:
1016
+ dominant_hour_p3_pagi = pos3_pagi_total.idxmax()
1017
+ dominant_count_p3_pagi = pos3_pagi_total.max()
1018
+ insight_lines.append(f"Position 3 (06:00–18:00): Dominant alarm at {dominant_hour_p3_pagi:02d}:00 with {dominant_count_p3_pagi} alarms.")
1019
 
1020
+ # Position 3 (Shift Sore)
1021
+ pos3_sore = alarm_data[(alarm_data['Position'] == 3) & (~alarm_data['hour'].between(6, 17, inclusive='both'))]
1022
+ if not pos3_sore.empty:
1023
+ pos3_sore_amber = pos3_sore[pos3_sore['Alarm Status'].str.contains('Amber', na=False)]
1024
+ if not pos3_sore_amber.empty:
1025
+ amber_percentage_p3_sore = (len(pos3_sore_amber) / len(pos3_sore)) * 100
1026
+ insight_lines.append(f"Position 3 (18:00–06:00): Amber alarms account for {amber_percentage_p3_sore:.1f}% of total alarms.")
1027
+
1028
+ # Position 4 (Shift Pagi)
1029
+ pos4_pagi = alarm_data[(alarm_data['Position'] == 4) & (alarm_data['hour'].between(6, 17, inclusive='both'))]
1030
+ if not pos4_pagi.empty:
1031
+ pos4_pagi_total = pos4_pagi.groupby('hour').size()
1032
+ if not pos4_pagi_total.empty:
1033
+ dominant_hour_p4_pagi = pos4_pagi_total.idxmax()
1034
+ dominant_count_p4_pagi = pos4_pagi_total.max()
1035
+ insight_lines.append(f"Position 4 (06:00–18:00): Dominant alarm at {dominant_hour_p4_pagi:02d}:00 with {dominant_count_p4_pagi} alarms.")
1036
+
1037
+ # Position 4 (Shift Sore)
1038
+ pos4_sore = alarm_data[(alarm_data['Position'] == 4) & (~alarm_data['hour'].between(6, 17, inclusive='both'))]
1039
+ if not pos4_sore.empty:
1040
+ pos4_sore_amber = pos4_sore[pos4_sore['Alarm Status'].str.contains('Amber', na=False)]
1041
+ if not pos4_sore_amber.empty:
1042
+ amber_percentage_p4_sore = (len(pos4_sore_amber) / len(pos4_sore)) * 100
1043
+ insight_lines.append(f"Position 4 (18:00–06:00): Amber alarms account for {amber_percentage_p4_sore:.1f}% of total alarms.")
1044
+
1045
+ insight_text = "\n".join(insight_lines)
1046
+
1047
+ # =============== DISPLAY INSIGHT ===============
1048
  st.markdown(f"""
1049
  <div class="insight-box">
1050
  <div class="content">
1051
+ {insight_text}
1052
  </div>
1053
  </div>
1054
  """, unsafe_allow_html=True)
 
1055
  # ================= OBJECTIVE 4 =================
1056
  st.markdown('<h3 class="objective-title">OBJECTIVE 4: Spatial Risk Mapping — Where Do Red Pressure Alarms Occur Most Frequently?</h3>', unsafe_allow_html=True)
1057
 
 
1161
  """, unsafe_allow_html=True)
1162
  # ================= OBJECTIVE 5 =================
1163
  # ================= OBJECTIVE 5 =================
1164
+ # ================= OBJECTIVE 5 =================
1165
  st.markdown('<h3 class="objective-title">OBJECTIVE 5: Insights & Mitigation — How Can Red Pressure Alarms Be Reduced?</h3>', unsafe_allow_html=True)
1166
 
1167
  # --- DATA PREP ---
 
1189
  else:
1190
  corr_rear = 0
1191
 
1192
+ # === DATA UNTUK OBJECTIVE 2 (Insight Spesifik Per Position dan Shift) ===
1193
+ # Position 1 (Shift Pagi)
1194
+ pos1_pagi = dff[(dff['Position'] == 1) & (dff['hour'].between(6, 17, inclusive='both'))]
1195
+ # Position 1 (Shift Sore)
1196
+ pos1_sore = dff[(dff['Position'] == 1) & (~dff['hour'].between(6, 17, inclusive='both'))]
1197
+ # Position 3 (Shift Pagi)
1198
+ pos3_pagi = dff[(dff['Position'] == 3) & (dff['hour'].between(6, 17, inclusive='both'))]
1199
+ # Position 3 (Shift Sore)
1200
+ pos3_sore = dff[(dff['Position'] == 3) & (~dff['hour'].between(6, 17, inclusive='both'))]
1201
+
1202
+ # === DATA UNTUK OBJECTIVE 3 (Correlation Analysis) ===
1203
+ # Hitung jumlah alarm red saat suhu >= 52°C di front tyre
1204
+ high_temp_front = front_df[front_df['Temperature (°C)'] >= 52]
1205
+ red_high_pressure_count = high_temp_front[high_temp_front['Alarm Status'] == 'Red High Pressure'].shape[0]
1206
+
1207
+ # Hitung korelasi Pressure vs T/V (Temperature / Speed) untuk front
1208
+ if not front_df.empty and (front_df['Speed (km/h)'] > 0).any():
1209
+ front_df_filtered = front_df[front_df['Speed (km/h)'] > 0].copy()
1210
+ front_df_filtered['Temp_Speed_Ratio'] = front_df_filtered['Temperature (°C)'] / front_df_filtered['Speed (km/h)']
1211
+ def safe_corr(a, b):
1212
+ mask = ~(np.isnan(a) | np.isnan(b))
1213
+ if mask.sum() < 2:
1214
+ return 0.0
1215
+ return np.corrcoef(a[mask], b[mask])[0, 1]
1216
+ corr_p_tv_front = safe_corr(front_df_filtered['Pressure (psi)'], front_df_filtered['Temp_Speed_Ratio'])
1217
+ else:
1218
+ corr_p_tv_front = 0.0
1219
+
1220
+ # === DATA UNTUK OBJECTIVE 4 (Spatial Risk Mapping) ===
1221
+ valid_gps = dff.dropna(subset=['Latitude_y', 'Longitude_y'])
1222
+ if not valid_gps.empty:
1223
+ # Hitung jumlah alarm per zona
1224
+ zone_counts_obj4 = valid_gps[valid_gps['is_alarm'] == 1]['Zone'].value_counts()
1225
+ top_zone_obj4 = zone_counts_obj4.index[0] if not zone_counts_obj4.empty else "N/A"
1226
+ top_zone_count_obj4 = zone_counts_obj4.iloc[0] if not zone_counts_obj4.empty else 0
1227
+ total_alarms_obj4 = valid_gps[valid_gps['is_alarm'] == 1].shape[0]
1228
+ percentage_obj4 = (top_zone_count_obj4 / total_alarms_obj4) * 100 if total_alarms_obj4 > 0 else 0
1229
+ # Hitung jumlah alarm per posisi (front vs rear)
1230
+ front_alarms_obj4 = valid_gps[(valid_gps['is_alarm'] == 1) & (valid_gps['Position'].isin([1, 2]))].shape[0]
1231
+ rear_alarms_obj4 = valid_gps[(valid_gps['is_alarm'] == 1) & (valid_gps['Position'].isin([3, 4]))].shape[0]
1232
+ total_alarms_obj4_total = front_alarms_obj4 + rear_alarms_obj4
1233
+ if total_alarms_obj4_total > 0:
1234
+ front_percentage_obj4 = (front_alarms_obj4 / total_alarms_obj4_total) * 100
1235
+ else:
1236
+ front_percentage_obj4 = 0
1237
+ else:
1238
+ top_zone_obj4 = "N/A"
1239
+ top_zone_count_obj4 = 0
1240
+ percentage_obj4 = 0
1241
+ front_percentage_obj4 = 0
1242
+
1243
  # Insight dari Objective 1-4
1244
  insight_text = f"""1. **Pressure & Temperature Distribution (Objective 1):**
1245
  • Front tyres (Pos 1 & 2) show lower pressure ({front_pressure_avg:.1f} psi) and higher temperature ({front_temp_avg:.1f}°C) due to higher stress from braking/steering.
 
1268
  prompt = f"""
1269
  Role: Fleet Operations Risk Analyst
1270
  Insights:
1271
+ - High-risk zone: {top_zone_obj4} ({percentage_obj4:.1f}% of alarms)
1272
  - Front tyres: {front_percentage_obj4:.1f}% of total alarms
1273
  - Peak alarm hour: {dominant_hour}:00 ({dominant_percentage:.1f}%)
1274
+ - Front tyre pressure–temperature correlation r = {corr_front:.2f}
1275
+ - At temperatures ≥52°C, {red_high_pressure_count} Red High Pressure alarms
1276
+ - Strong correlation between temperature and pressure in front tyres (r = {corr_front:.2f})
1277
  Task:
1278
  Generate:
1279
  1. Business Recommendations
 
1299
  if recommendation_text == "":
1300
  recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation.
1301
  <br>
1302
+ 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone_obj4} to reduce alarm frequency.
1303
  <br>
1304
+ 3. Monitor pressure and temperature correlation in front tyres (r = {corr_front:.2f}) to prevent overheating and premature wear.
1305
  <br>
1306
+ 4. Restrict vehicle access to {top_zone_obj4} until pavement maintenance is completed, as it contributes to {percentage_obj4:.1f}% of alarms."""
1307
  if risk_mitigation_text == "":
1308
  risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating.
1309
  <br>
1310
  2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur.
1311
  <br>
1312
+ 3. Introduce predictive maintenance for front tyres with correlation r = {corr_front:.2f} to prevent unplanned downtime.
1313
+ 4. Implement real-time monitoring in {top_zone_obj4} where {percentage_obj4:.1f}% of alarms are concentrated."""
 
1314
  except:
1315
  # Jika response dari model kosong atau gagal, gunakan versi manual
1316
  recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation.
1317
  <br>
1318
+ 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone_obj4} to reduce alarm frequency.
1319
  <br>
1320
+ 3. Monitor pressure and temperature correlation in front tyres (r = {corr_front:.2f}) to prevent overheating and premature wear.
1321
  <br>
1322
+ 4. Restrict vehicle access to {top_zone_obj4} until pavement maintenance is completed, as it contributes to {percentage_obj4:.1f}% of alarms."""
1323
  # Risk Mitigation
1324
  risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating.
1325
  <br>
1326
  2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur.
1327
  <br>
1328
+ 3. Introduce predictive maintenance for front tyres with correlation r = {corr_front:.2f} to prevent unplanned downtime.
1329
+ 4. Implement real-time monitoring in {top_zone_obj4} where {percentage_obj4:.1f}% of alarms are concentrated."""
 
1330
 
1331
  # ============== SUBHEADER + BOX 1: INSIGHT ==============
1332
  st.markdown('<h4 style="text-align:left; margin:10px 0 5px 0; font-weight:bold;">INSIGHT</h4>', unsafe_allow_html=True)
 
1357
  </div>
1358
  </div>
1359
  """, unsafe_allow_html=True)
 
1360
  # ================= FOOTER =================
1361
  st.markdown("""
1362
  <div class="footer">