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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +318 -260
app.py CHANGED
@@ -10,7 +10,7 @@ from sklearn.linear_model import LinearRegression
10
 
11
  # ================= CONFIG =================
12
  st.set_page_config(
13
- page_title="Michelin Mining Tyre Analytics",
14
  page_icon="",
15
  layout="wide",
16
  initial_sidebar_state="expanded"
@@ -518,17 +518,17 @@ def create_radial_chart(pos_data, title, shift_hours, shift_type):
518
  # Total per jam
519
  total_per_hour = hourly_normal + hourly_amber + hourly_red
520
 
521
- # Sudut: sesuaikan agar jam aktual muncul di tooltip
522
  if shift_type == 'pagi':
523
- # Shift Pagi (06:00–18:00) → 0° = 06:00, 90° = 12:00, 180° = 18:00, 270° = 24:00 (alias 00:00)
524
- theta = [(h - 6) * 30 for h in shift_hours] # 12 jam * 30° = 360°
525
  tickvals = [0, 90, 180, 270]
526
- ticktext = ["06:00", "12:00", "18:00", "00:00"]
527
  else: # Shift Sore (18:00–06:00)
528
- # Shift Sore (18:00–06:00) → 0° = 18:00, 90° = 00:00, 180° = 06:00, 270° = 12:00
529
  theta = [(h - 18) * 30 if h >= 18 else (h + 6) * 30 for h in shift_hours] # 12 jam * 30° = 360°
530
  tickvals = [0, 90, 180, 270]
531
- ticktext = ["18:00", "00:00", "06:00", "12:00"]
532
 
533
  fig = go.Figure()
534
 
@@ -763,7 +763,7 @@ else:
763
 
764
  insight_text = "\n".join(insight_lines)
765
 
766
- # =============== DISPLAY INSIGHT ===corr_p_tv_rear============
767
  st.markdown(f"""
768
  <div class="insight-box">
769
  <div class="content">
@@ -771,287 +771,345 @@ st.markdown(f"""
771
  </div>
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
 
 
10
 
11
  # ================= CONFIG =================
12
  st.set_page_config(
13
+ page_title="Michelin Mining Tyre Analytics,
14
  page_icon="",
15
  layout="wide",
16
  initial_sidebar_state="expanded"
 
518
  # Total per jam
519
  total_per_hour = hourly_normal + hourly_amber + hourly_red
520
 
521
+ # Sudut: sesuaikan agar jam 12 di bawah (180°), jam 6 di kanan (90°), jam 3 di atas (0°), jam 9 di kiri (270°)
522
  if shift_type == 'pagi':
523
+ # Shift Pagi (06:00–18:00) → 0° = 03:00, 90° = 06:00, 180° = 12:00, 270° = 18:00
524
+ theta = [(h - 3) * 30 for h in shift_hours] # 12 jam * 30° = 360°
525
  tickvals = [0, 90, 180, 270]
526
+ ticktext = ["03:00", "06:00", "12:00", "18:00"]
527
  else: # Shift Sore (18:00–06:00)
528
+ # Shift Sore (18:00–06:00) → 0° = 18:00, 90° = 21:00, 180° = 00:00, 270° = 03:00
529
  theta = [(h - 18) * 30 if h >= 18 else (h + 6) * 30 for h in shift_hours] # 12 jam * 30° = 360°
530
  tickvals = [0, 90, 180, 270]
531
+ ticktext = ["18:00", "21:00", "00:00", "03:00"]
532
 
533
  fig = go.Figure()
534
 
 
763
 
764
  insight_text = "\n".join(insight_lines)
765
 
766
+ # =============== DISPLAY INSIGHT ===============
767
  st.markdown(f"""
768
  <div class="insight-box">
769
  <div class="content">
 
771
  </div>
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
+ fig1.add_trace(go.Scatter(
828
+ x=x_line.flatten(), y=y_line,
829
+ mode='lines', name='Trend Line',
830
+ line=dict(color='#1976D2', dash='dot', width=2)
831
+ ))
832
+
833
+ # Tambahkan area confidence interval (soft background)
834
+ y_pred = model.predict(X)
835
+ residuals = y - y_pred
836
+ mse = np.mean(residuals**2)
837
+ std_error = np.sqrt(mse)
838
+ y_upper = y_line + 1.96 * std_error
839
+ y_lower = y_line - 1.96 * std_error
840
+
841
+ fig1.add_trace(go.Scatter(
842
+ x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
843
+ y=np.concatenate([y_upper, y_lower[::-1]]),
844
+ fill='toself',
845
+ fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
846
+ line=dict(color='rgba(255,255,255,0)'),
847
+ showlegend=False,
848
+ name='Confidence Interval'
849
+ ))
850
+
851
+ fig1.update_layout(
852
+ margin=dict(t=40),
853
+ annotations=[
854
+ dict(
855
+ x=0.95, y=0.95,
856
+ xref="paper", yref="paper",
857
+ text=f"r = {corr:.2f}",
858
+ showarrow=False,
859
+ bgcolor="white",
860
+ bordercolor="black",
861
+ borderwidth=1,
862
+ font=dict(color="black")
863
+ )
864
+ ],
865
+ legend=dict(
866
+ title_text='Tyre Status',
867
+ bgcolor="white",
868
+ bordercolor="lightgray",
869
+ borderwidth=1,
870
+ itemclick=False,
871
+ itemdoubleclick=False
872
+ )
873
+ )
874
+ st.plotly_chart(fig1, use_container_width=True)
875
+ else:
876
+ st.warning("Insufficient data for front tyres.")
877
+ else:
878
+ st.warning("No front tyre data.")
879
 
880
+ # =============== COL 2: Front Pressure vs (Temperature / Speed) ===============
881
+ with col2:
882
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
883
+
884
+ if not front_df.empty:
885
+ # Filter kecepatan > 0 untuk hindari pembagian dengan nol
886
+ front_df = front_df[front_df['Speed (km/h)'] > 0]
887
+ front_df['Temp_Speed_Ratio'] = front_df['Temperature (°C)'] / front_df['Speed (km/h)']
888
+
889
+ # Tambahkan kategori alarm status
890
+ front_df['Category'] = front_df.apply(
891
+ lambda row: f"Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
892
+ else f"Amber Pressure Front Tyre" if 'Amber' in row['Alarm Status']
893
+ else f"Red Pressure Front Tyre", axis=1
894
+ )
895
+ categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
896
+ front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
897
+
898
+ valid_data = front_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
899
+ if not valid_data.empty:
900
+ fig2 = px.scatter(
901
+ valid_data,
902
+ x='Temp_Speed_Ratio',
903
+ y='Pressure (psi)',
904
+ color='Category',
905
+ color_discrete_map={
906
+ "Normal Front Tyre": "#2E7D32", # Hijau
907
+ "Amber Pressure Front Tyre": "#FFC107", # Kuning
908
+ "Red Pressure Front Tyre": "#D32F2F" # Merah
909
+ },
910
+ category_orders={'Category': categories},
911
+ template="plotly_white",
912
+ labels={'Temp_Speed_Ratio': 'Temperature / Speed', 'Pressure (psi)': 'Pressure (psi)'}
913
+ )
914
 
915
+ fig2.update_traces(
916
+ hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
917
+ marker=dict(size=6)
 
 
 
 
 
 
 
 
 
 
918
  )
 
 
 
 
 
 
 
 
919
 
920
+ fig2.update_layout(
921
+ margin=dict(t=40),
922
+ legend=dict(
923
+ title_text='Tyre Status',
924
+ bgcolor="white",
925
+ bordercolor="lightgray",
926
+ borderwidth=1,
927
+ itemclick=False,
928
+ itemdoubleclick=False
929
+ )
930
+ )
931
+ st.plotly_chart(fig2, use_container_width=True)
932
+ else:
933
+ st.warning("Insufficient data for front tyres.")
934
  else:
935
+ st.warning("No front tyre data.")
936
 
937
+ # =============== COL 3: Rear — Temperature → Pressure (Scatter + Regression Area) ===============
938
+ col3, col4 = st.columns(2)
 
 
 
 
 
 
 
 
939
 
940
  with col3:
941
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
942
+
943
+ if not rear_df.empty:
944
+ rear_df['Category'] = rear_df.apply(
945
+ lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
946
+ else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
947
+ else f"Red Pressure Rear Tyre", axis=1
948
+ )
949
+ categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
950
+ rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
951
+
952
+ valid_data = rear_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
953
+ if len(valid_data) > 1:
954
+ X = valid_data[['Temperature (°C)']]
955
+ y = valid_data['Pressure (psi)']
956
+ model = LinearRegression().fit(X, y)
957
+ x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
958
+ y_line = model.predict(x_line)
959
+ corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
960
+
961
+ fig3 = px.scatter(
962
+ valid_data,
963
+ x='Temperature (°C)',
964
+ y='Pressure (psi)',
965
+ color='Category',
966
+ color_discrete_map={
967
+ "Normal Rear Tyre": "#2E7D32",
968
+ "Amber Pressure Rear Tyre": "#FFC107",
969
+ "Red Pressure Rear Tyre": "#D32F2F"
970
+ },
971
+ category_orders={'Category': categories},
972
+ template="plotly_white"
973
+ )
974
 
975
+ fig3.update_traces(
976
+ hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
977
+ marker=dict(size=6)
978
+ )
 
 
 
 
 
 
979
 
980
+ fig3.add_trace(go.Scatter(
981
+ x=x_line.flatten(), y=y_line,
982
+ mode='lines', name='Trend Line',
983
+ line=dict(color='#1976D2', dash='dot', width=2)
984
+ ))
985
+
986
+ # Tambahkan area confidence interval (soft background)
987
+ y_pred = model.predict(X)
988
+ residuals = y - y_pred
989
+ mse = np.mean(residuals**2)
990
+ std_error = np.sqrt(mse)
991
+ y_upper = y_line + 1.96 * std_error
992
+ y_lower = y_line - 1.96 * std_error
993
+
994
+ fig3.add_trace(go.Scatter(
995
+ x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
996
+ y=np.concatenate([y_upper, y_lower[::-1]]),
997
+ fill='toself',
998
+ fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
999
+ line=dict(color='rgba(255,255,255,0)'),
1000
+ showlegend=False,
1001
+ name='Confidence Interval'
1002
+ ))
1003
+
1004
+ fig3.update_layout(
1005
+ margin=dict(t=40),
1006
+ annotations=[
1007
+ dict(
1008
+ x=0.95, y=0.95,
1009
+ xref="paper", yref="paper",
1010
+ text=f"r = {corr:.2f}",
1011
+ showarrow=False,
1012
+ bgcolor="white",
1013
+ bordercolor="black",
1014
+ borderwidth=1,
1015
+ font=dict(color="black")
1016
+ )
1017
+ ],
1018
+ legend=dict(
1019
+ title_text='Tyre Status',
1020
+ bgcolor="white",
1021
+ bordercolor="lightgray",
1022
+ borderwidth=1,
1023
+ itemclick=False,
1024
+ itemdoubleclick=False
1025
+ )
1026
+ )
1027
+ st.plotly_chart(fig3, use_container_width=True)
1028
+ else:
1029
+ st.warning("Insufficient data for rear tyres.")
1030
  else:
1031
+ st.warning("No rear tyre data.")
1032
 
1033
+ # =============== COL 4: Rear — Pressure vs (Temperature / Speed) ===============
1034
+ with col4:
1035
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
1036
+
1037
+ if not rear_df.empty:
1038
+ # Filter kecepatan > 0 untuk hindari pembagian dengan nol
1039
+ rear_df = rear_df[rear_df['Speed (km/h)'] > 0]
1040
+ rear_df['Temp_Speed_Ratio'] = rear_df['Temperature (°C)'] / rear_df['Speed (km/h)']
1041
+
1042
+ # Tambahkan kategori alarm status
1043
+ rear_df['Category'] = rear_df.apply(
1044
+ lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
1045
+ else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
1046
+ else f"Red Pressure Rear Tyre", axis=1
1047
+ )
1048
+ categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
1049
+ rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
1050
+
1051
+ valid_data = rear_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
1052
+ if not valid_data.empty:
1053
+ fig4 = px.scatter(
1054
+ valid_data,
1055
+ x='Temp_Speed_Ratio',
1056
+ y='Pressure (psi)',
1057
+ color='Category',
1058
+ color_discrete_map={
1059
+ "Normal Rear Tyre": "#2E7D32",
1060
+ "Amber Pressure Rear Tyre": "#FFC107",
1061
+ "Red Pressure Rear Tyre": "#D32F2F"
1062
+ },
1063
+ category_orders={'Category': categories},
1064
+ template="plotly_white"
1065
+ )
1066
 
1067
+ fig4.update_traces(
1068
+ hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
1069
+ marker=dict(size=6)
1070
+ )
 
 
 
 
 
 
1071
 
1072
+ fig4.update_layout(
1073
+ margin=dict(t=40),
1074
+ legend=dict(
1075
+ title_text='Tyre Status',
1076
+ bgcolor="white",
1077
+ bordercolor="lightgray",
1078
+ borderwidth=1,
1079
+ itemclick=False,
1080
+ itemdoubleclick=False
1081
+ )
1082
+ )
1083
+ st.plotly_chart(fig4, use_container_width=True)
1084
+ else:
1085
+ st.warning("Insufficient data for rear tyres.")
1086
  else:
1087
+ st.warning("No rear tyre data.")
1088
 
1089
  # =============== INSIGHT 3 ===============
1090
+ def safe_corr(a, b):
1091
+ mask = ~(np.isnan(a) | np.isnan(b))
1092
+ if mask.sum() < 2:
1093
+ return 0.0
1094
+ return np.corrcoef(a[mask], b[mask])[0, 1]
1095
+
1096
+ corr_p_t_front = safe_corr(front_df['Temperature (°C)'], front_df['Pressure (psi)'])
1097
+ corr_t_s_front = safe_corr(front_df['Temperature (°C)'], front_df['Speed (km/h)'])
1098
+ corr_p_t_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Pressure (psi)'])
1099
+ corr_t_s_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Speed (km/h)'])
1100
+
1101
+ insight_text = f"""
1102
+ 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.
1103
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1104
 
 
1105
  st.markdown(f"""
1106
  <div class="insight-box">
1107
  <div class="content">
1108
+ {insight_text.strip()}
1109
  </div>
1110
  </div>
1111
  """, unsafe_allow_html=True)
1112
+
1113
  # ================= OBJECTIVE 4 =================
1114
  st.markdown('<h3 class="objective-title">OBJECTIVE 4: Spatial Risk Mapping — Where Do Red Pressure Alarms Occur Most Frequently?</h3>', unsafe_allow_html=True)
1115