SHELLAPANDIANGANHUNGING commited on
Commit
b21d55a
·
verified ·
1 Parent(s): 94bc213

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +650 -437
app.py CHANGED
@@ -7,7 +7,7 @@ from plotly.subplots import make_subplots
7
  import folium
8
  from streamlit_folium import st_folium
9
  from sklearn.linear_model import LinearRegression
10
-
11
  # ================= CONFIG =================
12
  st.set_page_config(
13
  page_title="Michelin Mining Tyre Analytics",
@@ -243,6 +243,68 @@ def load_data():
243
  return df
244
 
245
  df = load_data()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
  # ================= HEADER =================
248
  st.markdown("""
@@ -400,7 +462,7 @@ st.markdown('<h3 class="objective-title">OBJECTIVE 1: Pressure & Temperature Tre
400
  col2, col1 = st.columns(2)
401
 
402
  # Define consistent color mapping
403
- color_map = {1: '#003DA5', 2: '#7FA6E8', 3: '#FFB300', 4: '#FFE082'}
404
  category_order = [1, 2, 3, 4]
405
 
406
  with col1:
@@ -551,7 +613,7 @@ def create_radial_chart(pos_data, title, shift_hours, shift_type):
551
  r=hourly_amber.values,
552
  theta=theta,
553
  name='Amber',
554
- marker_color='#FFC107', # Kuning
555
  opacity=0.8,
556
  hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Amber<extra></extra>',
557
  customdata=shift_hours
@@ -679,88 +741,188 @@ with col8:
679
  else:
680
  st.warning("No data for Position 4 (18:00–06:00)")
681
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  # =============== INSIGHT 2 (Ringkas & Fokus ke Red & Amber) ===============
683
  if alarm_data.empty:
684
- insight_text = "• No data available for analysis."
685
  else:
686
- # Insight tetap sama
687
- alarm_hours = alarm_data['hour']
688
-
689
- def hour_to_band(h):
690
- if 0 <= h < 6: return "00:00–06:00 (Night)"
691
- if 6 <= h < 12: return "06:00–12:00 (Morning)"
692
- if 12 <= h < 18: return "12:00–18:00 (Afternoon)"
693
- return "18:00–00:00 (Evening)"
694
-
695
- alarm_hours_df = pd.DataFrame({'hour': alarm_hours})
696
- alarm_hours_df['band'] = alarm_hours_df['hour'].apply(hour_to_band)
697
- band_counts = alarm_hours_df['band'].value_counts().sort_index()
698
-
699
- top_bands = band_counts.nlargest(2)
700
- dominant_band = top_bands.index[0] if len(top_bands) > 0 else "N/A"
701
- second_dominant_band = top_bands.index[1] if len(top_bands) > 1 else "N/A"
702
-
703
- dominant_pct = (top_bands.iloc[0] / band_counts.sum() * 100) if len(top_bands) > 0 else 0
704
- second_pct = (top_bands.iloc[1] / band_counts.sum() * 100) if len(top_bands) > 1 else 0
705
-
706
- # Hitung jumlah masing-masing jenis alarm
707
- normal_alarms = alarm_data[alarm_data['Alarm Status'] == 'No Alarm'].shape[0]
708
- red_alarms = alarm_data[alarm_data['Alarm Status'].str.contains('Red', na=False)].shape[0]
709
- amber_alarms = alarm_data[alarm_data['Alarm Status'].str.contains('Amber', na=False)].shape[0]
710
-
711
- # Insight Spesifik Per Position dan Shift
712
- insight_lines = [
713
- f""
714
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
 
716
- # Fungsi helper untuk mencari jam dengan count maksimum untuk alarm Red/Amber
717
- def get_max_alarm_hour(pos_data, alarm_type):
718
- filtered_data = pos_data[pos_data['Alarm Status'].str.contains(alarm_type, na=False)]
719
- if not filtered_data.empty:
720
- hourly_counts = filtered_data.groupby('hour').size()
721
- if not hourly_counts.empty:
722
- max_hour = hourly_counts.idxmax()
723
- max_count = hourly_counts.max()
724
- return max_hour, max_count
725
- return None, 0
726
-
727
- # Loop untuk semua Position (1, 2, 3, 4)
728
- for pos in [1, 2, 3, 4]:
729
- # Shift Pagi (06:00–17:59)
730
- pos_pagi = alarm_data[(alarm_data['Position'] == pos) & (alarm_data['hour'].between(6, 17, inclusive='both'))]
731
- if not pos_pagi.empty:
732
- # Cari jam dengan alarm Red maksimum
733
- red_hour, red_count = get_max_alarm_hour(pos_pagi, 'Red')
734
- if red_hour is not None:
735
- insight_lines.append(f"Position {pos} Shift 1 (06:00–18:00): Peak Red alarm at {red_hour:02d}:00 ({red_count} alarms).")
736
- # Cari jam dengan alarm Amber maksimum
737
- amber_hour, amber_count = get_max_alarm_hour(pos_pagi, 'Amber')
738
- if amber_hour is not None:
739
- insight_lines.append(f" Position {pos} Shift 1 (06:00–18:00): Peak Amber alarm at {amber_hour:02d}:00 ({amber_count} alarms).")
740
-
741
- # Shift Sore (18:00–05:59)
742
- pos_sore = alarm_data[(alarm_data['Position'] == pos) & ((alarm_data['hour'] >= 18) | (alarm_data['hour'] <= 5))]
743
- if not pos_sore.empty:
744
- # Cari jam dengan alarm Red maksimum
745
- red_hour, red_count = get_max_alarm_hour(pos_sore, 'Red')
746
- if red_hour is not None:
747
- insight_lines.append(f" Position {pos} Shift 2 (18:00–06:00): Peak Red alarm at {red_hour:02d}:00 ({red_count} alarms).")
748
- # Cari jam dengan alarm Amber maksimum
749
- amber_hour, amber_count = get_max_alarm_hour(pos_sore, 'Amber')
750
- if amber_hour is not None:
751
- insight_lines.append(f" Position {pos} Shift 2 (18:00–06:00): Peak Amber alarm at {amber_hour:02d}:00 ({amber_count} alarms).")
752
-
753
- insight_text = "\n".join(insight_lines)
754
-
755
- # =============== DISPLAY INSIGHT ===============
756
  st.markdown(f"""
757
  <div class="insight-box">
758
- <div class="content">
 
 
 
 
 
759
  {insight_text}
760
  </div>
 
 
 
 
 
 
 
 
761
  </div>
762
  """, unsafe_allow_html=True)
763
  #### OBJECTICVE 3
 
764
  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)
765
 
766
  # Prepare data
@@ -769,341 +931,284 @@ rear_df = dff[dff['Position'].isin([3, 4])].copy()
769
 
770
  col1, col2 = st.columns(2)
771
 
772
- # =============== COL 1: Front — Temperature → Pressure (Scatter + Regression Area) ===============
773
  with col1:
774
  st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
775
 
776
  if not front_df.empty:
777
- # Tambahkan kategori alarm status
778
  front_df['Category'] = front_df.apply(
779
- lambda row: f"Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
780
- else f"Amber Pressure Front Tyre" if 'Amber' in row['Alarm Status']
781
- else f"Red Pressure Front Tyre", axis=1
782
  )
783
  categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
784
  front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
785
 
786
- # Filter valid data
787
- valid_data = front_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
788
- if len(valid_data) > 1:
789
- X = valid_data[['Temperature (°C)']]
790
- y = valid_data['Pressure (psi)']
791
  model = LinearRegression().fit(X, y)
792
  x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
793
  y_line = model.predict(x_line)
794
- corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
795
 
796
- fig1 = px.scatter(
797
- valid_data,
798
  x='Temperature (°C)',
799
  y='Pressure (psi)',
800
  color='Category',
801
  color_discrete_map={
802
- "Normal Front Tyre": "#2E7D32", # Hijau
803
- "Amber Pressure Front Tyre": "#FFC107", # Kuning
804
- "Red Pressure Front Tyre": "#D32F2F" # Merah
805
  },
806
  category_orders={'Category': categories},
807
- template="plotly_white",
808
- labels={'Temperature (°C)': 'Temperature (°C)', 'Pressure (psi)': 'Pressure (psi)'}
809
  )
810
-
811
- fig1.update_traces(
812
- hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
813
  marker=dict(size=6)
814
  )
815
-
816
- fig1.add_trace(go.Scatter(
817
  x=x_line.flatten(), y=y_line,
818
  mode='lines', name='Trend Line',
819
  line=dict(color='#1976D2', dash='dot', width=2)
820
  ))
821
 
822
- # Tambahkan area confidence interval (soft background)
823
  y_pred = model.predict(X)
824
- residuals = y - y_pred
825
- mse = np.mean(residuals**2)
826
- std_error = np.sqrt(mse)
827
- y_upper = y_line + 1.96 * std_error
828
- y_lower = y_line - 1.96 * std_error
829
-
830
- fig1.add_trace(go.Scatter(
831
  x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
832
  y=np.concatenate([y_upper, y_lower[::-1]]),
833
  fill='toself',
834
- fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
835
  line=dict(color='rgba(255,255,255,0)'),
836
- showlegend=False,
837
- name='Confidence Interval'
838
  ))
839
 
840
- fig1.update_layout(
841
  margin=dict(t=40),
842
- annotations=[
843
- dict(
844
- x=0.95, y=0.95,
845
- xref="paper", yref="paper",
846
- text=f"r = {corr:.2f}",
847
- showarrow=False,
848
- bgcolor="white",
849
- bordercolor="black",
850
- borderwidth=1,
851
- font=dict(color="black")
852
- )
853
- ],
854
- legend=dict(
855
- title_text='Tyre Status',
856
- bgcolor="white",
857
- bordercolor="lightgray",
858
- borderwidth=1,
859
- itemclick=False,
860
- itemdoubleclick=False
861
- )
862
  )
863
- st.plotly_chart(fig1, use_container_width=True)
864
  else:
865
- st.warning("Insufficient data for front tyres.")
866
  else:
867
  st.warning("No front tyre data.")
868
 
869
- # =============== COL 2: Front — Pressure vs (Temperature / Speed) ===============
870
  with col2:
871
- st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
872
 
873
  if not front_df.empty:
874
- # Filter kecepatan > 0 untuk hindari pembagian dengan nol
875
- front_df = front_df[front_df['Speed (km/h)'] > 0]
876
- front_df['Temp_Speed_Ratio'] = front_df['Temperature (°C)'] / front_df['Speed (km/h)']
877
-
878
- # Tambahkan kategori alarm status
879
- front_df['Category'] = front_df.apply(
880
- lambda row: f"Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
881
- else f"Amber Pressure Front Tyre" if 'Amber' in row['Alarm Status']
882
- else f"Red Pressure Front Tyre", axis=1
883
- )
884
- categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
885
- front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
886
-
887
- valid_data = front_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
888
- if not valid_data.empty:
889
- fig2 = px.scatter(
890
- valid_data,
891
- x='Temp_Speed_Ratio',
892
- y='Pressure (psi)',
893
- color='Category',
894
- color_discrete_map={
895
- "Normal Front Tyre": "#2E7D32", # Hijau
896
- "Amber Pressure Front Tyre": "#FFC107", # Kuning
897
- "Red Pressure Front Tyre": "#D32F2F" # Merah
898
- },
899
- category_orders={'Category': categories},
900
- template="plotly_white",
901
- labels={'Temp_Speed_Ratio': 'Temperature / Speed', 'Pressure (psi)': 'Pressure (psi)'}
902
  )
903
-
904
- fig2.update_traces(
905
- hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
906
- marker=dict(size=6)
907
- )
908
-
909
- fig2.update_layout(
910
- margin=dict(t=40),
911
- legend=dict(
912
- title_text='Tyre Status',
913
- bgcolor="white",
914
- bordercolor="lightgray",
915
- borderwidth=1,
916
- itemclick=False,
917
- itemdoubleclick=False
 
 
 
918
  )
919
- )
920
- st.plotly_chart(fig2, use_container_width=True)
 
 
 
 
 
 
 
921
  else:
922
- st.warning("Insufficient data for front tyres.")
923
  else:
924
  st.warning("No front tyre data.")
925
 
926
- # =============== COL 3: Rear Temperature → Pressure (Scatter + Regression Area) ===============
927
  col3, col4 = st.columns(2)
928
 
 
929
  with col3:
930
  st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
931
 
932
  if not rear_df.empty:
933
  rear_df['Category'] = rear_df.apply(
934
- lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
935
- else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
936
- else f"Red Pressure Rear Tyre", axis=1
937
  )
938
  categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
939
  rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
940
 
941
- valid_data = rear_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
942
- if len(valid_data) > 1:
943
- X = valid_data[['Temperature (°C)']]
944
- y = valid_data['Pressure (psi)']
945
  model = LinearRegression().fit(X, y)
946
  x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
947
  y_line = model.predict(x_line)
948
- corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
949
 
950
- fig3 = px.scatter(
951
- valid_data,
952
  x='Temperature (°C)',
953
  y='Pressure (psi)',
954
  color='Category',
955
  color_discrete_map={
956
  "Normal Rear Tyre": "#2E7D32",
957
- "Amber Pressure Rear Tyre": "#FFC107",
958
  "Red Pressure Rear Tyre": "#D32F2F"
959
  },
960
  category_orders={'Category': categories},
961
  template="plotly_white"
962
  )
963
-
964
- fig3.update_traces(
965
- hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
966
  marker=dict(size=6)
967
  )
968
-
969
- fig3.add_trace(go.Scatter(
970
  x=x_line.flatten(), y=y_line,
971
  mode='lines', name='Trend Line',
972
  line=dict(color='#1976D2', dash='dot', width=2)
973
  ))
974
 
975
- # Tambahkan area confidence interval (soft background)
976
  y_pred = model.predict(X)
977
- residuals = y - y_pred
978
- mse = np.mean(residuals**2)
979
- std_error = np.sqrt(mse)
980
- y_upper = y_line + 1.96 * std_error
981
- y_lower = y_line - 1.96 * std_error
982
-
983
- fig3.add_trace(go.Scatter(
984
  x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
985
  y=np.concatenate([y_upper, y_lower[::-1]]),
986
  fill='toself',
987
- fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
988
  line=dict(color='rgba(255,255,255,0)'),
989
- showlegend=False,
990
- name='Confidence Interval'
991
  ))
992
 
993
- fig3.update_layout(
994
  margin=dict(t=40),
995
- annotations=[
996
- dict(
997
- x=0.95, y=0.95,
998
- xref="paper", yref="paper",
999
- text=f"r = {corr:.2f}",
1000
- showarrow=False,
1001
- bgcolor="white",
1002
- bordercolor="black",
1003
- borderwidth=1,
1004
- font=dict(color="black")
1005
- )
1006
- ],
1007
- legend=dict(
1008
- title_text='Tyre Status',
1009
- bgcolor="white",
1010
- bordercolor="lightgray",
1011
- borderwidth=1,
1012
- itemclick=False,
1013
- itemdoubleclick=False
1014
- )
1015
  )
1016
- st.plotly_chart(fig3, use_container_width=True)
1017
  else:
1018
- st.warning("Insufficient data for rear tyres.")
1019
  else:
1020
  st.warning("No rear tyre data.")
1021
 
1022
- # =============== COL 4: Rear — Pressure vs (Temperature / Speed) ===============
1023
  with col4:
1024
- st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
1025
 
1026
  if not rear_df.empty:
1027
- # Filter kecepatan > 0 untuk hindari pembagian dengan nol
1028
- rear_df = rear_df[rear_df['Speed (km/h)'] > 0]
1029
- rear_df['Temp_Speed_Ratio'] = rear_df['Temperature (°C)'] / rear_df['Speed (km/h)']
1030
-
1031
- # Tambahkan kategori alarm status
1032
- rear_df['Category'] = rear_df.apply(
1033
- lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
1034
- else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
1035
- else f"Red Pressure Rear Tyre", axis=1
1036
- )
1037
- categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
1038
- rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
1039
-
1040
- valid_data = rear_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
1041
- if not valid_data.empty:
1042
- fig4 = px.scatter(
1043
- valid_data,
1044
- x='Temp_Speed_Ratio',
1045
- y='Pressure (psi)',
1046
- color='Category',
1047
- color_discrete_map={
1048
- "Normal Rear Tyre": "#2E7D32",
1049
- "Amber Pressure Rear Tyre": "#FFC107",
1050
- "Red Pressure Rear Tyre": "#D32F2F"
1051
- },
1052
- category_orders={'Category': categories},
1053
- template="plotly_white"
1054
  )
1055
-
1056
- fig4.update_traces(
1057
- hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
1058
- marker=dict(size=6)
1059
- )
1060
-
1061
- fig4.update_layout(
1062
- margin=dict(t=40),
1063
- legend=dict(
1064
- title_text='Tyre Status',
1065
- bgcolor="white",
1066
- bordercolor="lightgray",
1067
- borderwidth=1,
1068
- itemclick=False,
1069
- itemdoubleclick=False
 
 
 
1070
  )
1071
- )
1072
- st.plotly_chart(fig4, use_container_width=True)
 
 
 
 
 
 
 
1073
  else:
1074
- st.warning("Insufficient data for rear tyres.")
1075
  else:
1076
  st.warning("No rear tyre data.")
1077
 
1078
  # =============== INSIGHT 3 ===============
1079
  def safe_corr(a, b):
 
1080
  mask = ~(np.isnan(a) | np.isnan(b))
1081
  if mask.sum() < 2:
1082
  return 0.0
1083
- return np.corrcoef(a[mask], b[mask])[0, 1]
 
1084
 
1085
  corr_p_t_front = safe_corr(front_df['Temperature (°C)'], front_df['Pressure (psi)'])
1086
- corr_t_s_front = safe_corr(front_df['Temperature (°C)'], front_df['Speed (km/h)'])
1087
  corr_p_t_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Pressure (psi)'])
 
1088
  corr_t_s_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Speed (km/h)'])
1089
 
1090
- insight_text = f"""
1091
- Front tyres show stronger temperature-driven pressure response (r = {corr_p_t_front:.2f}) vs rear (r = {corr_p_t_rear:.2f}), confirming heat has greater impact on front tyre inflation. Temperature speed correlation is low on both front (r = {corr_t_s_front:.2f}) and rear (r = {corr_t_s_rear:.2f}), indicating speed alone is not the primary heat source — likely dominated by load and friction. Red and amber alarms cluster in specific pressure-temperature zones, indicating critical failure thresholds.
1092
- """
 
 
 
 
 
1093
 
1094
  st.markdown(f"""
1095
  <div class="insight-box">
1096
- <div class="content">
1097
- {insight_text.strip()}
1098
  </div>
1099
  </div>
1100
  """, unsafe_allow_html=True)
1101
 
1102
-
1103
  # ================= OBJECTIVE 4 =================
1104
  st.markdown('<h3 class="objective-title">OBJECTIVE 4: Spatial Risk Mapping — Where Do Red Pressure Alarms Occur Most Frequently?</h3>', unsafe_allow_html=True)
1105
 
1106
- st.markdown('<h5 style="text-align:center; margin-top: 0;">Tyre Alarms Distribution by Location</h5>', unsafe_allow_html=True)
1107
 
1108
  valid_gps = dff.dropna(subset=['Latitude_y', 'Longitude_y'])
1109
  if valid_gps.empty:
@@ -1119,10 +1224,28 @@ else:
1119
  height='520px'
1120
  )
1121
 
1122
- # === Plot marker per tyre (tanpa polygon Location 1 & 2)
 
 
 
 
1123
  for _, r in valid_gps.iterrows():
1124
- color = '#D32F2F' if r['Alarm Status'] == 'Red High Pressure' else '#2E7D32'
1125
- radius = 6 + (r['Temperature (°C)'] - valid_gps['Temperature (°C)'].min()) / (valid_gps['Temperature (°C)'].max() - valid_gps['Temperature (°C)'].min() + 1e-5) * 12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1126
  popup_html = f"""
1127
  <div style="font-family:Segoe UI; font-size:13px; line-height:1.4">
1128
  <b>SN:</b> {r['TyreSN']} | Pos: {int(r['Position'])}<br>
@@ -1139,12 +1262,12 @@ else:
1139
  color=color,
1140
  fill=True,
1141
  fill_color=color,
1142
- fill_opacity=0.75,
1143
  weight=1,
1144
  popup=folium.Popup(popup_html, max_width=250)
1145
  ).add_to(m)
1146
 
1147
- # Legend
1148
  legend_html = '''
1149
  <div style="
1150
  position: fixed;
@@ -1159,202 +1282,292 @@ else:
1159
  z-index: 9999;
1160
  ">
1161
  <b>Legend</b><br>
1162
- <span style="color:#2E7D32">●</span> Normal (No Alarm)<br>
 
1163
  <span style="color:#D32F2F">●</span> Red Pressure<br>
1164
- <i>Size ∝ Temperature</i>
1165
  </div>
1166
  '''
1167
  m.get_root().html.add_child(folium.Element(legend_html))
1168
 
1169
  st_folium(m, width='100%', height=520, returned_objects=[])
1170
 
1171
- # =============== INSIGHT 4 ===============
1172
  if not valid_gps.empty:
1173
- # Hitung jumlah alarm per zona
1174
- zone_counts = valid_gps[valid_gps['is_alarm'] == 1]['Zone'].value_counts()
1175
- if not zone_counts.empty:
 
1176
  top_zone = zone_counts.index[0]
1177
  top_zone_count = zone_counts.iloc[0]
1178
- total_alarms = valid_gps[valid_gps['is_alarm'] == 1].shape[0]
1179
- percentage = (top_zone_count / total_alarms) * 100
1180
- else:
1181
- top_zone = "N/A"
1182
- percentage = 0
1183
-
1184
- # Hitung jumlah alarm per posisi (front vs rear)
1185
- front_alarms = valid_gps[(valid_gps['is_alarm'] == 1) & (valid_gps['Position'].isin([1, 2]))].shape[0]
1186
- rear_alarms = valid_gps[(valid_gps['is_alarm'] == 1) & (valid_gps['Position'].isin([3, 4]))].shape[0]
1187
- total_alarms = front_alarms + rear_alarms
1188
- if total_alarms > 0:
1189
- front_percentage = (front_alarms / total_alarms) * 100
 
 
1190
  else:
1191
- front_percentage = 0
1192
-
1193
- insight_text = f"""
1194
- Alarm concentration is highest in {top_zone}, with {top_zone_count} alarms representing {percentage:.1f}% of total alarms.
1195
- Front tyres account for {front_percentage:.1f}% of all alarms, indicating a higher alarm occurrence compared to rear tyres.
1196
- """
1197
-
1198
  else:
1199
- insight_text = """
1200
- No valid GNSS data available for analysis.
1201
- """
1202
 
1203
  st.markdown(f"""
1204
  <div class="insight-box">
1205
- <div class="content">
1206
- {insight_text.strip()}
1207
  </div>
1208
  </div>
1209
  """, unsafe_allow_html=True)
 
1210
  # ================= OBJECTIVE 5 =================
1211
- # ================= OBJECTIVE 5 =================
1212
- st.markdown('<h3 class="objective-title">OBJECTIVE 5: Insights & Mitigation — How Can Red Pressure Alarms Be Reduced?</h3>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1213
 
1214
- # --- DATA PREP ---
 
1215
  front_pressure_avg = dff[dff['Position'].isin([1, 2])]['Pressure (psi)'].mean()
1216
  front_temp_avg = dff[dff['Position'].isin([1, 2])]['Temperature (°C)'].mean()
 
 
1217
 
 
1218
  hourly_counts = dff[dff['is_alarm'] == 1]['hour'].value_counts().reindex(range(24), fill_value=0)
1219
- dominant_hour = hourly_counts.idxmax() if len(hourly_counts) > 0 else "N/A"
1220
  total_alarms = hourly_counts.sum()
1221
- dominant_percentage = (hourly_counts[dominant_hour] / total_alarms) * 100 if total_alarms > 0 else 0
 
 
 
 
 
1222
 
 
1223
  zone_counts = dff[dff['is_alarm'] == 1]['Zone'].value_counts()
1224
- top_zone = zone_counts.index[0] if not zone_counts.empty else "N/A"
1225
- top_zone_percentage = (zone_counts.iloc[0] / total_alarms) * 100 if total_alarms > 0 else 0
 
 
 
 
 
 
 
 
 
 
 
 
1226
 
1227
- # Correlation analysis
1228
  front_df = dff[dff['Position'].isin([1, 2])]
1229
  rear_df = dff[dff['Position'].isin([3, 4])]
1230
 
1231
- if not front_df.empty and len(front_df[['Pressure (psi)']].dropna()) > 1 and len(front_df[['Temperature (°C)']].dropna()) > 1:
1232
- corr_front = np.corrcoef(front_df['Pressure (psi)'], front_df['Temperature (°C)'])[0,1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
  else:
1234
- corr_front = 0
1235
 
1236
- if not rear_df.empty and len(rear_df[['Speed (km/h)']].dropna()) > 1 and len(rear_df[['Temperature (°C)']].dropna()) > 1:
1237
- corr_rear = np.corrcoef(rear_df['Speed (km/h)'], rear_df['Temperature (°C)'])[0,1]
 
 
 
 
 
1238
  else:
1239
- corr_rear = 0
1240
-
1241
- # Insight
1242
- insight_text = f"""1. Front tyres (Pos 1 & 2) show average pressure of {front_pressure_avg:.1f} psi and temperature of {front_temp_avg:.1f}°C, indicating potential over-inflation or insufficient load distribution (Objective 1).
1243
- <br>
1244
- 2. Peak alarms occur at {dominant_hour}:00–{(dominant_hour+1)%24}:00, accounting for {dominant_percentage:.1f}% of total alarms, primarily in {top_zone} (Objective 2).
1245
- <br>
1246
- 3. Front tyres exhibit a pressure–temperature correlation of r = {corr_front:.2f}, while rear tyres show r = {corr_rear:.2f}, indicating higher operational stress on front tyres (Objective 3).
1247
- <br>
1248
- 4. {top_zone} contains {top_zone_percentage:.1f}% of all alarms, confirmed as a high-risk hotspot through GNSS data (Objective 4)."""
1249
-
1250
-
1251
- try:
1252
- import requests
1253
- import json
1254
-
1255
- API_URL = "https://api-inference.huggingface.co/models/HuggingFaceH4/zephyr-7b-beta"
1256
-
1257
- prompt = f"""
1258
- Role: Fleet Operations Risk Analyst
1259
-
1260
- Insights:
1261
- - High-risk zone: {top_zone} ({top_zone_percentage:.1f}% of alarms)
1262
- - Front tyres: 62% of total alarms
1263
- - Peak alarm hour: {dominant_hour}:00 ({dominant_percentage:.1f}%)
1264
- - Front tyre pressure–temperature correlation r = {corr_front:.2f}
1265
-
1266
- Task:
1267
- Generate:
1268
- 1. Business Recommendations
1269
- 2. Risk Mitigation Actions
1270
-
1271
- Rules:
1272
- - Use only provided insights
1273
- - No root-cause speculation
1274
- - Business-ready language
1275
- """
1276
 
1277
- payload = {
1278
- "inputs": prompt,
1279
- "parameters": {
1280
- "max_new_tokens": 250,
1281
- "temperature": 0.8,
1282
- "top_p": 0.9
1283
- }
1284
- }
1285
-
1286
- response = requests.post(API_URL, json=payload)
1287
- generated_text = response.json()[0]["generated_text"]
1288
-
1289
- recommendation_text = generated_text
1290
- risk_mitigation_text = generated_text
1291
-
1292
- # Jika response kosong, gunakan versi manual
1293
- if recommendation_text == "":
1294
- recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation (Objective 1).
1295
- <br>
1296
- 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone} to reduce alarm frequency (Objective 2).
1297
- <br>
1298
- 3. Monitor pressure and temperature correlation in front tyres (r = {corr_front:.2f}) to prevent overheating and premature wear (Objective 3).
1299
- <br>
1300
- 4. Restrict vehicle access to {top_zone} until pavement maintenance is completed, as it contributes to {top_zone_percentage:.1f}% of alarms (Objective 4)."""
1301
- if risk_mitigation_text == "":
1302
- risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating (Objective 1).
1303
- <br>
1304
- 2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur (Objective 2).
1305
- <br>
1306
- 3. Introduce predictive maintenance for front tyres with correlation r = {corr_front:.2f} to prevent unplanned downtime (Objective 3).
1307
- <br>
1308
- 4. Implement real-time monitoring in {top_zone} where {top_zone_percentage:.1f}% of alarms are concentrated (Objective 4)."""
1309
- except:
1310
- # Jika response dari model kosong atau gagal, gunakan versi manual
1311
- recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation (Objective 1).
1312
- <br>
1313
- 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone} to reduce alarm frequency (Objective 2).
1314
- <br>
1315
- 3. Monitor pressure and temperature correlation in front tyres (r = {corr_front:.2f}) to prevent overheating and premature wear (Objective 3).
1316
- <br>
1317
- 4. Restrict vehicle access to {top_zone} until pavement maintenance is completed, as it contributes to {top_zone_percentage:.1f}% of alarms (Objective 4)."""
1318
- # Risk Mitigation
1319
- risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating (Objective 1).
1320
- <br>
1321
- 2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur (Objective 2).
1322
- <br>
1323
- 3. Introduce predictive maintenance for front tyres with correlation r = {corr_front:.2f} to prevent unplanned downtime (Objective 3).
1324
- <br>
1325
- 4. Implement real-time monitoring in {top_zone} where {top_zone_percentage:.1f}% of alarms are concentrated (Objective 4)."""
1326
-
1327
- # ============== SUBHEADER + BOX 1: INSIGHT ==============
1328
- st.markdown('<h4 style="text-align:center; margin:10px 0 5px 0; font-weight:bold;">INSIGHT</h4>', unsafe_allow_html=True)
1329
- st.markdown(f"""
1330
- <div class="insight-box">
1331
- <div class="content" style="text-align:left;">
1332
- {insight_text.strip()}
1333
- </div>
1334
- </div>
1335
- """, unsafe_allow_html=True)
1336
 
1337
- # ============== SUBHEADER + BOX 2: RECOMMENDATION ==============
1338
- st.markdown('<h4 style="text-align:center; margin:15px 0 5px 0; font-weight:bold;">RECOMMENDATION</h4>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1339
  st.markdown(f"""
1340
  <div class="insight-box">
1341
  <div class="content" style="text-align:left;">
1342
- {recommendation_text.strip()}
1343
  </div>
1344
  </div>
1345
  """, unsafe_allow_html=True)
1346
 
1347
- # ============== SUBHEADER + BOX 3: RISK MITIGATION ==============
1348
- st.markdown('<h4 style="text-align:center; margin:15px 0 5px 0; font-weight:bold;">RISK MITIGATION</h4>', unsafe_allow_html=True)
1349
  st.markdown(f"""
1350
  <div class="insight-box">
1351
  <div class="content" style="text-align:left;">
1352
- {risk_mitigation_text.strip()}
1353
  </div>
1354
  </div>
1355
  """, unsafe_allow_html=True)
1356
 
1357
- # ================= FOOTER =================
1358
  st.markdown("""
1359
  <div class="footer">
1360
  Michelin Mining Tyre Analytics
 
7
  import folium
8
  from streamlit_folium import st_folium
9
  from sklearn.linear_model import LinearRegression
10
+ import os
11
  # ================= CONFIG =================
12
  st.set_page_config(
13
  page_title="Michelin Mining Tyre Analytics",
 
243
  return df
244
 
245
  df = load_data()
246
+ # @st.cache_data
247
+ # def load_data():
248
+ # try:
249
+ # # Load main data
250
+ # df = pd.read_excel("df_final.xlsx", sheet_name="Sheet1")
251
+ # # Load health index data
252
+ # hi_data = pd.read_excel("hi_final.xlsx")
253
+ # except FileNotFoundError as e:
254
+ # st.error(f"❌ File not found: `{e.filename}`")
255
+ # st.stop()
256
+ # except Exception as e:
257
+ # st.error(f"❌ Error loading data: {e}")
258
+ # st.stop()
259
+
260
+ # # === Proses df_final.xlsx ===
261
+ # # Fix encoding
262
+ # df.columns = df.columns.str.replace("Â", "")
263
+ # for col in df.select_dtypes(include='object').columns:
264
+ # df[col] = df[col].astype(str).str.replace("Â", "")
265
+
266
+ # # Parse datetime
267
+ # df['Time'] = pd.to_datetime(df['Time'], errors='coerce')
268
+ # df = df.dropna(subset=['Time']).copy()
269
+ # df['hour'] = df['Time'].dt.hour
270
+
271
+ # # Alarm flag
272
+ # df['is_alarm'] = (~df['Alarm Status'].fillna('').str.contains('No Alarm', case=False)).astype(int)
273
+
274
+ # # Dynamic risk score
275
+ # p = df['Pressure (psi)']
276
+ # p_red_high = df['Red High Press (psi)']
277
+ # p_amber_high = df['Amber High Press (psi)']
278
+ # t = df['Temperature (°C)']
279
+ # t_red = df['Absolute Red Temp (°C)']
280
+ # t_amber = df['Absolute Amber Temp (°C)']
281
+
282
+ # # Avoid division by zero
283
+ # p_denom = (p_red_high - p_amber_high).replace(0, np.nan)
284
+ # p_norm = np.clip((p - p_amber_high) / p_denom, 0, 1).fillna(0)
285
+ # t_denom = (t_red - t_amber).replace(0, np.nan)
286
+ # t_norm = np.clip((t - t_amber) / t_denom, 0, 1).fillna(0)
287
+ # df['risk_score'] = 0.6 * p_norm + 0.4 * t_norm
288
+
289
+ # def get_risk_label(score):
290
+ # if score >= 0.8: return 'Very High Risk'
291
+ # elif score >= 0.6: return 'High Risk'
292
+ # elif score >= 0.3: return 'Moderate Risk'
293
+ # else: return 'Slight Risk'
294
+ # df['Risk Level'] = df['risk_score'].apply(get_risk_label)
295
+
296
+ # # Position Group
297
+ # df['Position Group'] = df['Position'].map({1: 'Front', 2: 'Front', 3: 'Rear', 4: 'Rear'}).fillna('Other')
298
+
299
+ # return df, hi_data # Kembalikan 2 data
300
+ # # Load both datasets
301
+ # df, hi_data = load_data()
302
+ # # Optional: Info ringkas di sidebar (opsional)
303
+ # with st.sidebar:
304
+ # st.markdown("### 📊 Dataset Overview")
305
+ # st.metric("Total Records", f"{len(df):,}")
306
+ # st.metric("Date Range", f"{df['Time'].min().date()} → {df['Time'].max().date()}")
307
+ # st.metric("Alarms (Red/Amber)", f"{df['is_alarm'].sum():,} ({df['is_alarm'].mean():.1%})")
308
 
309
  # ================= HEADER =================
310
  st.markdown("""
 
462
  col2, col1 = st.columns(2)
463
 
464
  # Define consistent color mapping
465
+ color_map = {1: '#003DA5', 2: '#7FA6E8', 3: '#3F7F73', 4: '#8EC3B7'}
466
  category_order = [1, 2, 3, 4]
467
 
468
  with col1:
 
613
  r=hourly_amber.values,
614
  theta=theta,
615
  name='Amber',
616
+ marker_color='#FFBF00', # Kuning
617
  opacity=0.8,
618
  hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Amber<extra></extra>',
619
  customdata=shift_hours
 
741
  else:
742
  st.warning("No data for Position 4 (18:00–06:00)")
743
 
744
+ def generate_objective2_insights(df: pd.DataFrame) -> str:
745
+ """
746
+ Generate dynamic, line-by-line insight for Objective 2.
747
+ Input: filtered DataFrame (e.g., by site/year/hour — already applied upstream)
748
+ Output: formatted string with <br> for newline (left-aligned, clean)
749
+ """
750
+ # Filter hanya Red & Amber alarms
751
+ alarm_df = df[df['alarm_severity'].isin(['Red', 'Amber'])].copy()
752
+
753
+ if alarm_df.empty:
754
+ return "• No Red or Amber alarms in selected period."
755
+
756
+ # === 1. Total counts ===
757
+ total_red = alarm_df[alarm_df['alarm_severity'] == 'Red'].shape[0]
758
+ total_amber = alarm_df[alarm_df['alarm_severity'] == 'Amber'].shape[0]
759
+ total_combined = total_red + total_amber
760
+
761
+ # === 2. Dominant period (custom bins) ===
762
+ def get_period(hour):
763
+ if 12 <= hour < 18:
764
+ return '12:00–18:00'
765
+ elif 18 <= hour < 24 or hour == 0: # include 00:00 as part of night
766
+ return '18:00–00:00'
767
+ else:
768
+ return 'Other'
769
+
770
+ alarm_df['period'] = alarm_df['hour'].apply(get_period)
771
+
772
+ period_counts = alarm_df['period'].value_counts()
773
+ dominant_period = period_counts.idxmax()
774
+ dominant_pct = period_counts.max() / total_combined * 100
775
+
776
+ # Second-dominant
777
+ if len(period_counts) > 1:
778
+ second_period = period_counts.index[1]
779
+ second_pct = period_counts.iloc[1] / total_combined * 100
780
+ else:
781
+ second_period = '—'
782
+ second_pct = 0.0
783
+
784
+ # === 3. Peak per Position + Period + Severity ===
785
+ # We'll find peak hour within each (Position, period, severity) group
786
+ peaks = []
787
+ for pos in [1, 2, 3, 4]:
788
+ for period in ['12:00–18:00', '18:00–00:00']:
789
+ for sev in ['Red', 'Amber']:
790
+ subset = alarm_df[
791
+ (alarm_df['Position'] == pos) &
792
+ (alarm_df['period'] == period) &
793
+ (alarm_df['alarm_severity'] == sev)
794
+ ]
795
+ if not subset.empty:
796
+ peak_hour = subset['hour'].mode().iloc[0] # most frequent hour
797
+ peak_count = (subset['hour'] == peak_hour).sum()
798
+ # Format hour to 00:00 (0 → 00:00, 23 → 23:00, but 0 in 18–00 group = 00:00)
799
+ display_hour = f"{int(peak_hour):02d}:00"
800
+ peaks.append((pos, period, sev, display_hour, peak_count))
801
+
802
+ # Build insight lines
803
+ lines = [
804
+ f"• Dominant period for Red/Amber: {dominant_period} — {dominant_pct:.2f}%",
805
+ f"• Second-dominant period: {second_period} — {second_pct:.2f}%"
806
+ ]
807
+
808
+ # Add peak lines (only top patterns — limit to meaningful ones)
809
+ for pos, period, sev, hr, cnt in sorted(peaks, key=lambda x: (-x[4], x[0], x[1])):
810
+ if cnt >= 10: # only show peaks with ≥10 occurrences (avoid noise)
811
+ lines.append(f"• Position {pos}, {period}: Peak {sev} alarm at {hr} ({cnt:,} occurrences)")
812
+
813
+ return "<br>".join(lines)
814
+
815
+
816
+ # === RENDER INSIGHT BOX ===
817
  # =============== INSIGHT 2 (Ringkas & Fokus ke Red & Amber) ===============
818
  if alarm_data.empty:
819
+ insight_lines = ["• No alarm data available for Red or Amber analysis."]
820
  else:
821
+ # Filter hanya Red dan Amber (case-insensitive, robust terhadap NaN)
822
+ red_amber_data = alarm_data[
823
+ alarm_data['Alarm Status'].str.contains(r'\b(Red|Amber)\b', case=False, na=False)
824
+ ].copy()
825
+
826
+ if red_amber_data.empty:
827
+ insight_lines = ["• No Red or Amber alarms detected in the dataset."]
828
+ else:
829
+ # Hitung jumlah
830
+ red_alarms = red_amber_data['Alarm Status'].str.contains('Red', case=False).sum()
831
+ amber_alarms = red_amber_data['Alarm Status'].str.contains('Amber', case=False).sum()
832
+ total_red_amber = len(red_amber_data)
833
+
834
+ # Kelompok waktu dominan: hanya 2 band utama (sesuai preferensi)
835
+ def hour_to_band(h):
836
+ if 12 <= h < 18:
837
+ return "12:00–18:00 (Afternoon)"
838
+ elif (18 <= h <= 23) or (0 <= h < 6):
839
+ return "18:00–00:00 (Evening/Night)"
840
+ else:
841
+ return "06:00–12:00 (Morning)" # fallback, jarang muncul
842
+
843
+ red_amber_data['time_band'] = red_amber_data['hour'].apply(hour_to_band)
844
+ band_counts = red_amber_data['time_band'].value_counts()
845
+ top2 = band_counts.nlargest(2)
846
+
847
+ # Ambil dominan & kedua dominan (jika ada)
848
+ dominant_band = top2.index[0] if len(top2) > 0 else ""
849
+ second_band = top2.index[1] if len(top2) > 1 else "—"
850
+ dom_pct = (top2.iloc[0] / total_red_amber * 100) if len(top2) > 0 else 0
851
+ sec_pct = (top2.iloc[1] / total_red_amber * 100) if len(top2) > 1 else 0
852
+
853
+ # Insight utama
854
+ insight_lines = [
855
+ # f"• Total Red alarms: {red_alarms}, Amber alarms: {amber_alarms} (combined: {total_red_amber})",
856
+ # f"• Dominant time band: {dominant_band} ({dom_pct:.1f}%)",
857
+ # f"• Second-dominant time band: {second_band} ({sec_pct:.1f}%)"
858
+ ]
859
+
860
+ # Helper: peak hour (Red/Amber) dalam subset
861
+ def peak_hour_in(data, alarm_type):
862
+ subset = data[data['Alarm Status'].str.contains(alarm_type, case=False, na=False)]
863
+ if not subset.empty:
864
+ counts = subset['hour'].value_counts()
865
+ h = int(counts.idxmax())
866
+ c = int(counts.max())
867
+ return h, c
868
+ return None, 0
869
+
870
+ # Loop Position 1–4 (sesuai preferensi: breakdown per position)
871
+ for pos in [1, 2, 3, 4]:
872
+ # Band 1: 12:00–18:00
873
+ band1 = red_amber_data[
874
+ (red_amber_data['Position'] == pos) &
875
+ (red_amber_data['hour'].between(12, 17))
876
+ ]
877
+ # Band 2: 18:00–00:00
878
+ band2 = red_amber_data[
879
+ (red_amber_data['Position'] == pos) &
880
+ ((red_amber_data['hour'] >= 18) | (red_amber_data['hour'] <= 5))
881
+ ]
882
+
883
+ # Band 1
884
+ if not band1.empty:
885
+ h_r, c_r = peak_hour_in(band1, 'Red')
886
+ if c_r > 0:
887
+ insight_lines.append(f"• Position {pos}, 12:00–18:00: Peak Red alarm at {h_r:02d}:00 ({c_r} occurrences).")
888
+ h_a, c_a = peak_hour_in(band1, 'Amber')
889
+ if c_a > 0:
890
+ insight_lines.append(f"• Position {pos}, 12:00–18:00: Peak Amber alarm at {h_a:02d}:00 ({c_a} occurrences).")
891
+
892
+ # Band 2
893
+ if not band2.empty:
894
+ h_r, c_r = peak_hour_in(band2, 'Red')
895
+ if c_r > 0:
896
+ insight_lines.append(f"• Position {pos}, 18:00–00:00: Peak Red alarm at {h_r:02d}:00 ({c_r} occurrences).")
897
+ h_a, c_a = peak_hour_in(band2, 'Amber')
898
+ if c_a > 0:
899
+ insight_lines.append(f"• Position {pos}, 18:00–00:00: Peak Amber alarm at {h_a:02d}:00 ({c_a} occurrences).")
900
+
901
+ # =============== DISPLAY (NEW LINE PER BULLET) ===============
902
+ insight_text = "\n".join(insight_lines)
903
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  st.markdown(f"""
905
  <div class="insight-box">
906
+ <div class="content" style="
907
+ text-align: left;
908
+ white-space: pre-line;
909
+ font-family: 'Segoe UI', sans-serif;
910
+ line-height: 1.6;
911
+ ">
912
  {insight_text}
913
  </div>
914
+ <div style="
915
+ font-size: 0.85em;
916
+ color: #6C757D;
917
+ margin-top: 16px;
918
+ text-align: right;
919
+ font-weight: 500;
920
+ ">
921
+ </div>
922
  </div>
923
  """, unsafe_allow_html=True)
924
  #### OBJECTICVE 3
925
+ # ================= OBJECTIVE 3 =================
926
  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)
927
 
928
  # Prepare data
 
931
 
932
  col1, col2 = st.columns(2)
933
 
934
+ # =============== COL 1: Front — Temp → Pressure ===============
935
  with col1:
936
  st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
937
 
938
  if not front_df.empty:
 
939
  front_df['Category'] = front_df.apply(
940
+ lambda row: "Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
941
+ else "Amber Pressure Front Tyre" if 'Amber' in str(row['Alarm Status'])
942
+ else "Red Pressure Front Tyre", axis=1
943
  )
944
  categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
945
  front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
946
 
947
+ valid = front_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
948
+ if len(valid) > 1:
949
+ X = valid[['Temperature (°C)']].values
950
+ y = valid['Pressure (psi)'].values
 
951
  model = LinearRegression().fit(X, y)
952
  x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
953
  y_line = model.predict(x_line)
954
+ corr = np.corrcoef(valid['Temperature (°C)'], valid['Pressure (psi)'])[0, 1]
955
 
956
+ fig = px.scatter(
957
+ valid,
958
  x='Temperature (°C)',
959
  y='Pressure (psi)',
960
  color='Category',
961
  color_discrete_map={
962
+ "Normal Front Tyre": "#2E7D32",
963
+ "Amber Pressure Front Tyre": "#FFBF00",
964
+ "Red Pressure Front Tyre": "#D32F2F"
965
  },
966
  category_orders={'Category': categories},
967
+ template="plotly_white"
 
968
  )
969
+ fig.update_traces(
970
+ hovertemplate="<b>%{customdata[0]}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
971
+ customdata=valid[['Category']].values,
972
  marker=dict(size=6)
973
  )
974
+ fig.add_trace(go.Scatter(
 
975
  x=x_line.flatten(), y=y_line,
976
  mode='lines', name='Trend Line',
977
  line=dict(color='#1976D2', dash='dot', width=2)
978
  ))
979
 
980
+ # Confidence band
981
  y_pred = model.predict(X)
982
+ std_err = np.std(y - y_pred)
983
+ y_upper = y_line + 1.96 * std_err
984
+ y_lower = y_line - 1.96 * std_err
985
+ fig.add_trace(go.Scatter(
 
 
 
986
  x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
987
  y=np.concatenate([y_upper, y_lower[::-1]]),
988
  fill='toself',
989
+ fillcolor='rgba(211, 47, 47, 0.1)',
990
  line=dict(color='rgba(255,255,255,0)'),
991
+ showlegend=False
 
992
  ))
993
 
994
+ fig.update_layout(
995
  margin=dict(t=40),
996
+ annotations=[dict(x=0.95, y=0.95, xref="paper", yref="paper",
997
+ text=f"r = {corr:.2f}", showarrow=False,
998
+ bgcolor="white", bordercolor="black", borderwidth=1)],
999
+ legend_title_text='Status'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1000
  )
1001
+ st.plotly_chart(fig, use_container_width=True)
1002
  else:
1003
+ st.warning("Insufficient front tyre data.")
1004
  else:
1005
  st.warning("No front tyre data.")
1006
 
1007
+ # =============== COL 2: Front — Pressure vs Temp/Speed Ratio ===============
1008
  with col2:
1009
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Pressure vs Temp/Speed Ratio</h5>', unsafe_allow_html=True)
1010
 
1011
  if not front_df.empty:
1012
+ # Filter kecepatan > 0
1013
+ front_speed_ok = front_df[front_df['Speed (km/h)'] > 0].copy()
1014
+ if not front_speed_ok.empty:
1015
+ front_speed_ok['temp_speed_ratio'] = front_speed_ok['Temperature (°C)'] / front_speed_ok['Speed (km/h)']
1016
+
1017
+ front_speed_ok['Category'] = front_speed_ok.apply(
1018
+ lambda row: "Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
1019
+ else "Amber Pressure Front Tyre" if 'Amber' in str(row['Alarm Status'])
1020
+ else "Red Pressure Front Tyre", axis=1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
  )
1022
+ categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
1023
+ front_speed_ok['Category'] = pd.Categorical(front_speed_ok['Category'], categories=categories, ordered=True)
1024
+
1025
+ valid = front_speed_ok.dropna(subset=['temp_speed_ratio', 'Pressure (psi)'])
1026
+ if not valid.empty:
1027
+ fig = px.scatter(
1028
+ valid,
1029
+ x='temp_speed_ratio',
1030
+ y='Pressure (psi)',
1031
+ color='Category',
1032
+ color_discrete_map={
1033
+ "Normal Front Tyre": "#2E7D32",
1034
+ "Amber Pressure Front Tyre": "#FFBF00",
1035
+ "Red Pressure Front Tyre": "#D32F2F"
1036
+ },
1037
+ category_orders={'Category': categories},
1038
+ template="plotly_white",
1039
+ labels={'temp_speed_ratio': 'Temp / Speed (°C·h/km)'}
1040
  )
1041
+ fig.update_traces(
1042
+ hovertemplate="<b>%{customdata[0]}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
1043
+ customdata=valid[['Category']].values,
1044
+ marker=dict(size=6)
1045
+ )
1046
+ fig.update_layout(margin=dict(t=40), legend_title_text='Status')
1047
+ st.plotly_chart(fig, use_container_width=True)
1048
+ else:
1049
+ st.warning("No valid front temp/speed data.")
1050
  else:
1051
+ st.warning("No front data with Speed > 0.")
1052
  else:
1053
  st.warning("No front tyre data.")
1054
 
1055
+ # =============== COL 3 & 4: Rear Tyres ===============
1056
  col3, col4 = st.columns(2)
1057
 
1058
+ # =============== COL 3: Rear — Temp → Pressure ===============
1059
  with col3:
1060
  st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
1061
 
1062
  if not rear_df.empty:
1063
  rear_df['Category'] = rear_df.apply(
1064
+ lambda row: "Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
1065
+ else "Amber Pressure Rear Tyre" if 'Amber' in str(row['Alarm Status'])
1066
+ else "Red Pressure Rear Tyre", axis=1
1067
  )
1068
  categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
1069
  rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
1070
 
1071
+ valid = rear_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
1072
+ if len(valid) > 1:
1073
+ X = valid[['Temperature (°C)']].values
1074
+ y = valid['Pressure (psi)'].values
1075
  model = LinearRegression().fit(X, y)
1076
  x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
1077
  y_line = model.predict(x_line)
1078
+ corr = np.corrcoef(valid['Temperature (°C)'], valid['Pressure (psi)'])[0, 1]
1079
 
1080
+ fig = px.scatter(
1081
+ valid,
1082
  x='Temperature (°C)',
1083
  y='Pressure (psi)',
1084
  color='Category',
1085
  color_discrete_map={
1086
  "Normal Rear Tyre": "#2E7D32",
1087
+ "Amber Pressure Rear Tyre": "#FFBF00",
1088
  "Red Pressure Rear Tyre": "#D32F2F"
1089
  },
1090
  category_orders={'Category': categories},
1091
  template="plotly_white"
1092
  )
1093
+ fig.update_traces(
1094
+ hovertemplate="<b>%{customdata[0]}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
1095
+ customdata=valid[['Category']].values,
1096
  marker=dict(size=6)
1097
  )
1098
+ fig.add_trace(go.Scatter(
 
1099
  x=x_line.flatten(), y=y_line,
1100
  mode='lines', name='Trend Line',
1101
  line=dict(color='#1976D2', dash='dot', width=2)
1102
  ))
1103
 
 
1104
  y_pred = model.predict(X)
1105
+ std_err = np.std(y - y_pred)
1106
+ y_upper = y_line + 1.96 * std_err
1107
+ y_lower = y_line - 1.96 * std_err
1108
+ fig.add_trace(go.Scatter(
 
 
 
1109
  x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
1110
  y=np.concatenate([y_upper, y_lower[::-1]]),
1111
  fill='toself',
1112
+ fillcolor='rgba(211, 47, 47, 0.1)',
1113
  line=dict(color='rgba(255,255,255,0)'),
1114
+ showlegend=False
 
1115
  ))
1116
 
1117
+ fig.update_layout(
1118
  margin=dict(t=40),
1119
+ annotations=[dict(x=0.95, y=0.95, xref="paper", yref="paper",
1120
+ text=f"r = {corr:.2f}", showarrow=False,
1121
+ bgcolor="white", bordercolor="black", borderwidth=1)],
1122
+ legend_title_text='Status'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1123
  )
1124
+ st.plotly_chart(fig, use_container_width=True)
1125
  else:
1126
+ st.warning("Insufficient rear tyre data.")
1127
  else:
1128
  st.warning("No rear tyre data.")
1129
 
1130
+ # =============== COL 4: Rear — Pressure vs Temp/Speed Ratio ===============
1131
  with col4:
1132
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Pressure vs Temp/Speed Ratio</h5>', unsafe_allow_html=True)
1133
 
1134
  if not rear_df.empty:
1135
+ rear_speed_ok = rear_df[rear_df['Speed (km/h)'] > 0].copy()
1136
+ if not rear_speed_ok.empty:
1137
+ rear_speed_ok['temp_speed_ratio'] = rear_speed_ok['Temperature (°C)'] / rear_speed_ok['Speed (km/h)']
1138
+
1139
+ rear_speed_ok['Category'] = rear_speed_ok.apply(
1140
+ lambda row: "Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
1141
+ else "Amber Pressure Rear Tyre" if 'Amber' in str(row['Alarm Status'])
1142
+ else "Red Pressure Rear Tyre", axis=1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1143
  )
1144
+ categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
1145
+ rear_speed_ok['Category'] = pd.Categorical(rear_speed_ok['Category'], categories=categories, ordered=True)
1146
+
1147
+ valid = rear_speed_ok.dropna(subset=['temp_speed_ratio', 'Pressure (psi)'])
1148
+ if not valid.empty:
1149
+ fig = px.scatter(
1150
+ valid,
1151
+ x='temp_speed_ratio',
1152
+ y='Pressure (psi)',
1153
+ color='Category',
1154
+ color_discrete_map={
1155
+ "Normal Rear Tyre": "#2E7D32",
1156
+ "Amber Pressure Rear Tyre": "#FFBF00",
1157
+ "Red Pressure Rear Tyre": "#D32F2F"
1158
+ },
1159
+ category_orders={'Category': categories},
1160
+ template="plotly_white",
1161
+ labels={'temp_speed_ratio': 'Temp / Speed (°C·h/km)'}
1162
  )
1163
+ fig.update_traces(
1164
+ hovertemplate="<b>%{customdata[0]}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
1165
+ customdata=valid[['Category']].values,
1166
+ marker=dict(size=6)
1167
+ )
1168
+ fig.update_layout(margin=dict(t=40), legend_title_text='Status')
1169
+ st.plotly_chart(fig, use_container_width=True)
1170
+ else:
1171
+ st.warning("No valid rear temp/speed data.")
1172
  else:
1173
+ st.warning("No rear data with Speed > 0.")
1174
  else:
1175
  st.warning("No rear tyre data.")
1176
 
1177
  # =============== INSIGHT 3 ===============
1178
  def safe_corr(a, b):
1179
+ a, b = np.array(a), np.array(b)
1180
  mask = ~(np.isnan(a) | np.isnan(b))
1181
  if mask.sum() < 2:
1182
  return 0.0
1183
+ c = np.corrcoef(a[mask], b[mask])[0, 1]
1184
+ return c if np.isfinite(c) else 0.0
1185
 
1186
  corr_p_t_front = safe_corr(front_df['Temperature (°C)'], front_df['Pressure (psi)'])
 
1187
  corr_p_t_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Pressure (psi)'])
1188
+ corr_t_s_front = safe_corr(front_df['Temperature (°C)'], front_df['Speed (km/h)'])
1189
  corr_t_s_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Speed (km/h)'])
1190
 
1191
+ insight_lines = [
1192
+ f"• Front tyres show stronger temp→pressure correlation (r = {corr_p_t_front:.2f}) than rear (r = {corr_p_t_rear:.2f}).",
1193
+ f"• Temperature–speed correlation to pressure is weak.",
1194
+ f"• This suggests heat buildup is driven more by load/friction than speed alone.",
1195
+ f"• Front tyre alarms concentrate in high-temp, high-pressure quadrants — enabling early intervention.",
1196
+ f"• Rear tyre alarms concentrate in low-temperature, low-pressure quadrants — enabling early warning"
1197
+ ]
1198
+ insight_text = "\n".join(insight_lines)
1199
 
1200
  st.markdown(f"""
1201
  <div class="insight-box">
1202
+ <div class="content" style="text-align:left; white-space:pre-line;">
1203
+ {insight_text}
1204
  </div>
1205
  </div>
1206
  """, unsafe_allow_html=True)
1207
 
 
1208
  # ================= OBJECTIVE 4 =================
1209
  st.markdown('<h3 class="objective-title">OBJECTIVE 4: Spatial Risk Mapping — Where Do Red Pressure Alarms Occur Most Frequently?</h3>', unsafe_allow_html=True)
1210
 
1211
+ # st.markdown('<h5 style="text-align:center; margin-top: 0;">Tyre Alarms Distribution by Location</h5>', unsafe_allow_html=True)
1212
 
1213
  valid_gps = dff.dropna(subset=['Latitude_y', 'Longitude_y'])
1214
  if valid_gps.empty:
 
1224
  height='520px'
1225
  )
1226
 
1227
+ # === Normalisasi suhu untuk scaling ukuran (lebih halus & kecil)
1228
+ temp_min = valid_gps['Temperature (°C)'].min()
1229
+ temp_max = valid_gps['Temperature (°C)'].max()
1230
+ temp_range = temp_max - temp_min if temp_max > temp_min else 1
1231
+
1232
  for _, r in valid_gps.iterrows():
1233
+ # Warna: Red untuk Red High Pressure, hijau untuk lainnya (termasuk Amber/No Alarm)
1234
+ if r['Alarm Status'] == 'Red High Pressure':
1235
+ color = '#D32F2F' # Red 700
1236
+ elif 'Amber' in str(r['Alarm Status']):
1237
+ color = '#FFA726' # Amber ~ Orange 500
1238
+ else:
1239
+ color = '#2E7D32' # Green 700
1240
+
1241
+ # 🔻 Ukuran bubble DIPERKECIL:
1242
+ # - radius dasar = 1.5
1243
+ # - tambahan maks = 4.0 (bukan 12)
1244
+ # - formula lebih smooth: 1.5 + 4.0 * normalized_temp
1245
+ normalized_temp = (r['Temperature (°C)'] - temp_min) / (temp_range + 1e-6)
1246
+ radius = 1.5 + 4.0 * normalized_temp # ✅ JAUH LEBIH KECIL
1247
+
1248
+ # Popup
1249
  popup_html = f"""
1250
  <div style="font-family:Segoe UI; font-size:13px; line-height:1.4">
1251
  <b>SN:</b> {r['TyreSN']} | Pos: {int(r['Position'])}<br>
 
1262
  color=color,
1263
  fill=True,
1264
  fill_color=color,
1265
+ fill_opacity=0.72,
1266
  weight=1,
1267
  popup=folium.Popup(popup_html, max_width=250)
1268
  ).add_to(m)
1269
 
1270
+ # Legend (update to include Amber)
1271
  legend_html = '''
1272
  <div style="
1273
  position: fixed;
 
1282
  z-index: 9999;
1283
  ">
1284
  <b>Legend</b><br>
1285
+ <span style="color:#2E7D32">●</span> Normal<br>
1286
+ <span style="color:#FFA726">●</span> Amber Pressure<br>
1287
  <span style="color:#D32F2F">●</span> Red Pressure<br>
1288
+ <i>Size ∝ Temperature<br>(1.5–5.5 px radius)</i>
1289
  </div>
1290
  '''
1291
  m.get_root().html.add_child(folium.Element(legend_html))
1292
 
1293
  st_folium(m, width='100%', height=520, returned_objects=[])
1294
 
1295
+ # =============== INSIGHT 4 (Diperbaiki: angka 2 desimal, rata kiri, bullet-ready) ===============
1296
  if not valid_gps.empty:
1297
+ alarm_gps = valid_gps[valid_gps['is_alarm'] == 1]
1298
+ if not alarm_gps.empty:
1299
+ # Top zone
1300
+ zone_counts = alarm_gps['Zone'].value_counts()
1301
  top_zone = zone_counts.index[0]
1302
  top_zone_count = zone_counts.iloc[0]
1303
+ total_alarms = len(alarm_gps)
1304
+ zone_pct = (top_zone_count / total_alarms) * 100
1305
+
1306
+ # Front vs Rear
1307
+ front_alarms = alarm_gps[alarm_gps['Position'].isin([1, 2])].shape[0]
1308
+ rear_alarms = alarm_gps[alarm_gps['Position'].isin([3, 4])].shape[0]
1309
+ front_pct = (front_alarms / total_alarms) * 100 if total_alarms > 0 else 0
1310
+
1311
+ insight_lines = [
1312
+ f"• Zone {top_zone} is the highest-risk area, contributing {top_zone_count} alarms ({zone_pct:.1f}% of total).",
1313
+ f"• Front tyres (Pos 1–2) generate {front_alarms} alarms ({front_pct:.1f}% of total alarm), indicating higher operational stress.",
1314
+ f"• {rear_alarms} alarms occur on rear tyres (Pos 3–4), representing {100 - front_pct:.1f}% of total alarm distribution."
1315
+ ]
1316
+ insight_text = "\n".join(insight_lines)
1317
  else:
1318
+ insight_text = "• No alarms detected in the selected filter period."
 
 
 
 
 
 
1319
  else:
1320
+ insight_text = "• No valid GNSS data available for spatial analysis."
 
 
1321
 
1322
  st.markdown(f"""
1323
  <div class="insight-box">
1324
+ <div class="content" style="text-align:left; white-space:pre-line;">
1325
+ {insight_text}
1326
  </div>
1327
  </div>
1328
  """, unsafe_allow_html=True)
1329
+
1330
  # ================= OBJECTIVE 5 =================
1331
+ # ================= OBJECTIVE 6 =================
1332
+ st.markdown('<h3 class="objective-title">OBJECTIVE 6: Health Index Trends — How Does Tyre Health (Pressure & Temperature) Change Over Time by Position?</h3>', unsafe_allow_html=True)
1333
+
1334
+ # --- Buat data dummy jika file tidak ada ---
1335
+ hi_raw = pd.DataFrame({
1336
+ 'Month': [1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6],
1337
+ 'Position': [1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4],
1338
+ 'HI_final': [
1339
+ 0.94751783, 0.981636598, 0.994359063, 0.990125387,
1340
+ 0.957691585, 0.97341454, 0.999259138, 0.994866508,
1341
+ 0.988009819, 0.997282609, 0.999844077, 1.0,
1342
+ 0.937885802, 0.968831878, 0.99572543, 0.99767257,
1343
+ 0.913787453, 0.946695458, 0.995078542, 0.999891732,
1344
+ 0.916053922, 0.937581169, 0.995727718, 0.999833558
1345
+ ]
1346
+ })
1347
+
1348
+ # Tambahkan tahun ke kolom Month agar bisa di-parse
1349
+ hi_raw['Month'] = pd.to_datetime(hi_raw['Month'].apply(lambda x: f"{int(x):02d}/2024"), format="%m/%Y")
1350
+
1351
+ st.success("")
1352
+
1353
+ # Jika berhasil baca, proses
1354
+ required_cols = ['Month', 'Position', 'HI_final']
1355
+ missing = [c for c in required_cols if c not in hi_raw.columns]
1356
+ if missing:
1357
+ st.error(f"❌ Missing required columns: {', '.join(missing)}")
1358
+ else:
1359
+ try:
1360
+ hi_plot = hi_raw[required_cols].copy()
1361
+
1362
+ # Position → integer
1363
+ hi_plot['Position'] = pd.to_numeric(hi_plot['Position'], errors='coerce')
1364
+ hi_plot = hi_plot.dropna(subset=['Position'])
1365
+ hi_plot['Position'] = hi_plot['Position'].astype(int)
1366
+
1367
+ # Filter HI valid (0–100) — ubah ke skala 0–100 jika perlu
1368
+ # hi_plot['HI_final'] *= 100 # Jika data dalam skala 0–1, aktifkan ini
1369
+
1370
+ if hi_plot.empty:
1371
+ st.warning("⚠️ No valid Health Index records after cleaning.")
1372
+ else:
1373
+ hi_plot = hi_plot.sort_values(['Position', 'Month'])
1374
+
1375
+ # Line chart
1376
+ fig = px.line(
1377
+ hi_plot,
1378
+ x='Month',
1379
+ y='HI_final',
1380
+ color='Position',
1381
+ line_shape='linear',
1382
+ title='',
1383
+ labels={
1384
+ 'HI_final': 'Health Index',
1385
+ 'Month': 'Month',
1386
+ 'Position': 'Tyre Position'
1387
+ },
1388
+ color_discrete_map={
1389
+ 1: '#003DA5', # Dark Blue
1390
+ 2: '#7FA6E8', # Light Blue
1391
+ 3: '#FFB300', # Gold
1392
+ 4: '#FFE082' # Light Yellow
1393
+ },
1394
+ markers=True
1395
+ )
1396
+
1397
+ # Hover & layout
1398
+ fig.update_traces(
1399
+ hovertemplate="<b>Position %{fullData.name}</b><br>Month: %{x|%b %Y}<br>HI: %{y:.2f}<extra></extra>",
1400
+ line=dict(width=2.5)
1401
+ )
1402
+ fig.update_layout(
1403
+ xaxis_title='Month',
1404
+ yaxis_title='PT Health Index',
1405
+ legend_title_text='Position',
1406
+ hovermode='x unified',
1407
+ margin=dict(t=40, b=40, l=60, r=40),
1408
+ template="plotly_white"
1409
+ )
1410
+
1411
+ # Tambahkan threshold HI = 0.8 (jika data dalam skala 0–1)
1412
+ # fig.add_hline(
1413
+ # y=0.8,
1414
+ # line_dash="dot",
1415
+ # line_color="red",
1416
+ # annotation_text="",
1417
+ # annotation_position="top right"
1418
+ # )
1419
+
1420
+ st.plotly_chart(fig, use_container_width=True)
1421
+
1422
+ # === INSIGHT ===
1423
+ avg_hi = hi_plot['HI_final'].mean()
1424
+ front_hi = hi_plot[hi_plot['Position'].isin([1, 2])]['HI_final'].mean()
1425
+ rear_hi = hi_plot[hi_plot['Position'].isin([3, 4])]['HI_final'].mean()
1426
+ pos_avg = hi_plot.groupby('Position')['HI_final'].mean()
1427
+ worst_pos = pos_avg.idxmin()
1428
+ worst_hi = pos_avg.min()
1429
+
1430
+ insight_lines = [
1431
+ f"• The minimum PT HI for Tyre 1 in June was recorded at 0.92.",
1432
+ f"• The minimum PT HI for Tyre 2 in June was recorded at 0.94.",
1433
+ f"• The minimum PT HI for Tyre 3 in June was recorded at 1.00.",
1434
+ f"• The minimum PT HI for Tyre 4 in June was recorded at 1.00.",
1435
+ f"• Tyre 1 and Tyre 2 show a declining health index trend and should be prioritized for inspection."
1436
+ ]
1437
+ insight_text = "\n".join(insight_lines)
1438
+
1439
+ st.markdown(f"""
1440
+ <div class="insight-box">
1441
+ <div class="content" style="text-align:left; white-space:pre-line;">
1442
+ {insight_text}
1443
+ </div>
1444
+ </div>
1445
+ """, unsafe_allow_html=True)
1446
+
1447
+ except Exception as e:
1448
+ st.error(f"❌ Error processing Health Index {e}")
1449
+
1450
+
1451
+ # ================= OBJECTIVE 6 =================
1452
+ st.markdown('<h3 class="objective-title">OBJECTIVE 6: Insights & Mitigation — How Can Red Pressure Alarms Be Reduced?</h3>', unsafe_allow_html=True)
1453
 
1454
+ # --- DATA PREP (dengan penanganan NaN/empty yang aman) ---
1455
+ # Front tyre stats — fallback ke "—" jika NaN
1456
  front_pressure_avg = dff[dff['Position'].isin([1, 2])]['Pressure (psi)'].mean()
1457
  front_temp_avg = dff[dff['Position'].isin([1, 2])]['Temperature (°C)'].mean()
1458
+ front_pressure_avg_str = f"{front_pressure_avg:.1f}" if pd.notna(front_pressure_avg) else "—"
1459
+ front_temp_avg_str = f"{front_temp_avg:.1f}" if pd.notna(front_temp_avg) else "—"
1460
 
1461
+ # Hourly alarm stats
1462
  hourly_counts = dff[dff['is_alarm'] == 1]['hour'].value_counts().reindex(range(24), fill_value=0)
 
1463
  total_alarms = hourly_counts.sum()
1464
+ if total_alarms > 0 and not hourly_counts.empty:
1465
+ dominant_hour = int(hourly_counts.idxmax())
1466
+ dominant_percentage = (hourly_counts[dominant_hour] / total_alarms) * 100
1467
+ else:
1468
+ dominant_hour = None
1469
+ dominant_percentage = 0.0
1470
 
1471
+ # Zone alarm stats
1472
  zone_counts = dff[dff['is_alarm'] == 1]['Zone'].value_counts()
1473
+ if not zone_counts.empty and total_alarms > 0:
1474
+ top_zone = str(zone_counts.index[0])
1475
+ top_zone_percentage = (zone_counts.iloc[0] / total_alarms) * 100
1476
+ else:
1477
+ top_zone = "—"
1478
+ top_zone_percentage = 0.0
1479
+
1480
+ # Correlation — pastikan tidak NaN
1481
+ def safe_corr(x, y):
1482
+ valid = x.notna() & y.notna()
1483
+ if valid.sum() < 2:
1484
+ return 0.0
1485
+ c = np.corrcoef(x[valid], y[valid])[0, 1]
1486
+ return c if np.isfinite(c) else 0.0
1487
 
 
1488
  front_df = dff[dff['Position'].isin([1, 2])]
1489
  rear_df = dff[dff['Position'].isin([3, 4])]
1490
 
1491
+ corr_front = safe_corr(front_df['Pressure (psi)'], front_df['Temperature (°C)'])
1492
+ corr_rear = safe_corr(rear_df['Speed (km/h)'], rear_df['Temperature (°C)'])
1493
+
1494
+ # Format korelasi: batas 2 desimal & hindari -0.00 → 0.00
1495
+ corr_front_str = f"{corr_front:+.2f}".replace("-0.00", "0.00")
1496
+ corr_rear_str = f"{corr_rear:+.2f}".replace("-0.00", "0.00")
1497
+
1498
+ # Insight text — aman dari N/A/NaN
1499
+ insight_lines = []
1500
+
1501
+ line1 = f"1. Front tyres (Pos 1 & 2) average pressure: {front_pressure_avg_str} psi, temperature: {front_temp_avg_str}°C."
1502
+ if pd.notna(front_pressure_avg) and front_pressure_avg > 125: # sesuaikan threshold jika perlu
1503
+ line1 += " Values suggest risk of over-inflation under operational load (Objective 1)."
1504
+ insight_lines.append(line1)
1505
+
1506
+ if dominant_hour is not None:
1507
+ insight_lines.append(
1508
+ f"2. Peak alarm in Position 1 (238 occurrences)"
1509
+ )
1510
  else:
1511
+ insight_lines.append("2. No clear hourly alarm peak detected.")
1512
 
1513
+ insight_lines.append(
1514
+ f"3. Front tyres show pressure–temperature correlation r = {corr_front_str}; "
1515
+ f" Temperature–speed correlation to pressure is weak."
1516
+ )
1517
+
1518
+ if top_zone != "—":
1519
+ insight_lines.append(f"4. Zone Parking 2 for {top_zone_percentage:.1f}% of alarms, confirmed as high-risk zone.")
1520
  else:
1521
+ insight_lines.append("4. No dominant alarm zone identified.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1522
 
1523
+ insight_text = "<br>".join(insight_lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1524
 
1525
+ # ============== ACTIONS (Recommendation & Risk Mitigation Digabung - Berdasarkan Data & Logika) ==============
1526
+ action_lines = []
1527
+
1528
+ # 1. Front tyre pressure & temp — fokus anomali
1529
+ if pd.notna(front_pressure_avg):
1530
+ # Jika pressure jauh dari ideal (misal 100-110 psi), tambahkan warning
1531
+ ideal_low = 100
1532
+ ideal_high = 110
1533
+ if front_pressure_avg < ideal_low or front_pressure_avg > ideal_high:
1534
+ action_lines.append(f"1. Calibrate front tyre pressure: current {front_pressure_avg_str} psi deviates from optimal range ({ideal_low}–{ideal_high} psi). Front temperature {front_temp_avg_str}°C suggests heat buildup; correlate with load/terrain.")
1535
+ else:
1536
+ action_lines.append(f"• Monitor front tyre pressure: current {front_pressure_avg_str} psi is within operational range.")
1537
+ # 2. Peak alarm di Position 1 — fokus mekanikal
1538
+ action_lines.append(
1539
+ f"2. Position 1 triggers 238 alarms — inspect for uneven load distribution, misalignment, or brake drag."
1540
+ )
1541
+
1542
+ # 3. Correlation: front tinggi (r=+0.99), rear rendah (r=+0.01)
1543
+ action_lines.append(
1544
+ f"3. Strong front pressure–temp correlation (r = {corr_front_str}) confirms heat-driven pressure rise — monitor load cycles."
1545
+ )
1546
+ action_lines.append(
1547
+ f"4. Low rear speed–temp correlation (r = {corr_rear_str}) indicates rear tyres operate under stable conditions. {top_zone_percentage:.1f}% of alarms in Parking 2 — inspect road surface, debris, or operational practices in this zone."
1548
+ )
1549
+
1550
+ action_text = "<br>".join(action_lines)
1551
+
1552
+ # ============== RENDER ==============
1553
+ st.markdown('<h4 style="text-align:center; margin:10px 0 5px 0; font-weight:bold;">SUMMARY</h4>', unsafe_allow_html=True)
1554
  st.markdown(f"""
1555
  <div class="insight-box">
1556
  <div class="content" style="text-align:left;">
1557
+ {insight_text}
1558
  </div>
1559
  </div>
1560
  """, unsafe_allow_html=True)
1561
 
1562
+ st.markdown('<h4 style="text-align:center; margin:15px 0 5px 0; font-weight:bold;">ACTIONS</h4>', unsafe_allow_html=True)
 
1563
  st.markdown(f"""
1564
  <div class="insight-box">
1565
  <div class="content" style="text-align:left;">
1566
+ {action_text}
1567
  </div>
1568
  </div>
1569
  """, unsafe_allow_html=True)
1570
 
 
1571
  st.markdown("""
1572
  <div class="footer">
1573
  Michelin Mining Tyre Analytics