SHELLAPANDIANGANHUNGING commited on
Commit
116b3c3
·
verified ·
1 Parent(s): e28f0e0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +316 -259
app.py CHANGED
@@ -491,283 +491,340 @@ st.markdown(f"""
491
  """, unsafe_allow_html=True)
492
 
493
  #### OBJECTICVE 2
494
- st.markdown("""
495
- <h3 class="objective-title">OBJECTIVE 2: Shift and Tyre Position - How Are Alarms Concentrated Across Shifts and Tyres?</h3>
496
- """, unsafe_allow_html=True)
497
 
498
- # Filter semua data (termasuk alarm normal)
499
- alarm_data = dff.copy()
500
-
501
- # Buat 2 baris × 4 kolom
502
- col1, col2, col3, col4 = st.columns(4)
503
- col5, col6, col7, col8 = st.columns(4)
504
-
505
- # Fungsi helper untuk membuat radial chart per posisi dan shift
506
- def create_radial_chart(pos_data, title, shift_hours, shift_type):
507
- if pos_data.empty:
508
- return None
509
-
510
- # Kelompokkan jam dan status
511
- hourly_status_counts = pos_data.groupby(['hour', 'Alarm Status']).size().unstack(fill_value=0)
512
-
513
- # Klasifikasi berdasarkan kata kunci
514
- hourly_normal = hourly_status_counts.get('No Alarm', pd.Series(0, index=shift_hours)).reindex(shift_hours, fill_value=0)
515
- hourly_amber = hourly_status_counts.filter(regex='Amber').sum(axis=1).reindex(shift_hours, fill_value=0) # Semua yang mengandung "Amber"
516
- hourly_red = hourly_status_counts.filter(regex='Red').sum(axis=1).reindex(shift_hours, fill_value=0) # Semua yang mengandung "Red"
517
-
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
-
535
- # Tambahkan trace untuk masing-masing kategori dengan hovertemplate custom
536
- fig.add_trace(go.Barpolar(
537
- r=hourly_normal.values,
538
- theta=theta,
539
- name='Normal',
540
- marker_color='#2E7D32', # Hijau
541
- opacity=0.8,
542
- hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Normal<extra></extra>',
543
- customdata=shift_hours
544
- ))
545
- fig.add_trace(go.Barpolar(
546
- r=hourly_amber.values,
547
- theta=theta,
548
- name='Amber',
549
- marker_color='#FFC107', # Kuning
550
- opacity=0.8,
551
- hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Amber<extra></extra>',
552
- customdata=shift_hours
553
- ))
554
- fig.add_trace(go.Barpolar(
555
- r=hourly_red.values,
556
- theta=theta,
557
- name='Red',
558
- marker_color='#D32F2F', # Merah
559
- opacity=0.8,
560
- hovertemplate='<b>Hour:</b> %{customdata}:00<br><b>Count:</b> %{r}<br><b>Status:</b> Red<extra></extra>',
561
- customdata=shift_hours
562
- ))
563
-
564
- fig.update_layout(
565
- polar=dict(
566
- angularaxis=dict(
567
- direction="clockwise",
568
- period=len(shift_hours),
569
- rotation=0,
570
- tickvals=tickvals,
571
- ticktext=ticktext,
572
- tickfont=dict(size=12)
573
- ),
574
- radialaxis=dict(
575
- visible=True,
576
- range=[0, max(total_per_hour.max() * 1.1, 1)]
577
- )
578
- ),
579
- showlegend=False,
580
- margin=dict(t=30, b=20, l=20, r=20),
581
- height=250,
582
- title_text=title,
583
- title_x=0.5
584
- )
585
- return fig
586
 
587
- # =============== ROW 1: Position 1 & 2 (Pagi & Sore) ===============
 
 
588
  with col1:
589
- # Position 1 Pagi (06:00–18:00)
590
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 1 (06:00–18:00)</div>', unsafe_allow_html=True)
591
- pos1_data = alarm_data[alarm_data['Position'] == 1].copy()
592
- pos1_data = pos1_data[pos1_data['hour'].between(6, 17, inclusive='both')]
593
- fig1 = create_radial_chart(pos1_data, "Position 1 (06:00–18:00)", list(range(6, 18)), 'pagi')
594
- if fig1 is not None:
595
- st.plotly_chart(fig1, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  else:
597
- st.warning("No data for Position 1 (06:00–18:00)")
598
 
 
599
  with col2:
600
- # Position 1 Sore (18:00–06:00)
601
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 1 (18:00–06:00)</div>', unsafe_allow_html=True)
602
- pos1_data = alarm_data[alarm_data['Position'] == 1].copy()
603
- pos1_data = pos1_data[~pos1_data['hour'].between(6, 17, inclusive='both')]
604
- fig2 = create_radial_chart(pos1_data, "Position 1 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
605
- if fig2 is not None:
606
- st.plotly_chart(fig2, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
607
  else:
608
- st.warning("No data for Position 1 (18:00–06:00)")
 
 
 
609
 
610
  with col3:
611
- # Position 2 Pagi (06:00–18:00)
612
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 2 (06:00–18:00)</div>', unsafe_allow_html=True)
613
- pos2_data = alarm_data[alarm_data['Position'] == 2].copy()
614
- pos2_data = pos2_data[pos2_data['hour'].between(6, 17, inclusive='both')]
615
- fig3 = create_radial_chart(pos2_data, "Position 2 (06:00–18:00)", list(range(6, 18)), 'pagi')
616
- if fig3 is not None:
617
- st.plotly_chart(fig3, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
  else:
619
- st.warning("No data for Position 2 (06:00–18:00)")
620
 
 
621
  with col4:
622
- # Position 2 Sore (18:00–06:00)
623
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 2 (18:00–06:00)</div>', unsafe_allow_html=True)
624
- pos2_data = alarm_data[alarm_data['Position'] == 2].copy()
625
- pos2_data = pos2_data[~pos2_data['hour'].between(6, 17, inclusive='both')]
626
- fig4 = create_radial_chart(pos2_data, "Position 2 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
627
- if fig4 is not None:
628
- st.plotly_chart(fig4, use_container_width=True)
629
- else:
630
- st.warning("No data for Position 2 (18:00–06:00)")
631
-
632
- # =============== ROW 2: Position 3 & 4 (Pagi & Sore) ===============
633
- with col5:
634
- # Position 3 Pagi (06:00–18:00)
635
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 3 (06:00–18:00)</div>', unsafe_allow_html=True)
636
- pos3_data = alarm_data[alarm_data['Position'] == 3].copy()
637
- pos3_data = pos3_data[pos3_data['hour'].between(6, 17, inclusive='both')]
638
- fig5 = create_radial_chart(pos3_data, "Position 3 (06:00–18:00)", list(range(6, 18)), 'pagi')
639
- if fig5 is not None:
640
- st.plotly_chart(fig5, use_container_width=True)
641
- else:
642
- st.warning("No data for Position 3 (06:00–18:00)")
643
-
644
- with col6:
645
- # Position 3 Sore (18:00–06:00)
646
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 3 (18:00–06:00)</div>', unsafe_allow_html=True)
647
- pos3_data = alarm_data[alarm_data['Position'] == 3].copy()
648
- pos3_data = pos3_data[~pos3_data['hour'].between(6, 17, inclusive='both')]
649
- fig6 = create_radial_chart(pos3_data, "Position 3 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
650
- if fig6 is not None:
651
- st.plotly_chart(fig6, use_container_width=True)
652
- else:
653
- st.warning("No data for Position 3 (18:00–06:00)")
654
-
655
- with col7:
656
- # Position 4 Pagi (06:00–18:00)
657
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 4 (06:00–18:00)</div>', unsafe_allow_html=True)
658
- pos4_data = alarm_data[alarm_data['Position'] == 4].copy()
659
- pos4_data = pos4_data[pos4_data['hour'].between(6, 17, inclusive='both')]
660
- fig7 = create_radial_chart(pos4_data, "Position 4 (06:00–18:00)", list(range(6, 18)), 'pagi')
661
- if fig7 is not None:
662
- st.plotly_chart(fig7, use_container_width=True)
663
- else:
664
- st.warning("No data for Position 4 (06:00–18:00)")
665
-
666
- with col8:
667
- # Position 4 Sore (18:00–06:00)
668
- st.markdown('<div style="text-align:center; font-weight:bold; margin-bottom: 8px;">Position 4 (18:00–06:00)</div>', unsafe_allow_html=True)
669
- pos4_data = alarm_data[alarm_data['Position'] == 4].copy()
670
- pos4_data = pos4_data[~pos4_data['hour'].between(6, 17, inclusive='both')]
671
- fig8 = create_radial_chart(pos4_data, "Position 4 (18:00–06:00)", list(range(18, 24)) + list(range(0, 6)), 'sore')
672
- if fig8 is not None:
673
- st.plotly_chart(fig8, use_container_width=True)
674
  else:
675
- st.warning("No data for Position 4 (18:00–06:00)")
676
 
677
  # =============== INSIGHT 3 ===============
678
- if alarm_data.empty:
679
- insight_text = "• No data available for analysis."
680
- else:
681
- # Insight tetap sama
682
- alarm_hours = alarm_data['hour']
683
-
684
- def hour_to_band(h):
685
- if 0 <= h < 6: return "00:00–06:00 (Night)"
686
- if 6 <= h < 12: return "06:00–12:00 (Morning)"
687
- if 12 <= h < 18: return "12:00–18:00 (Afternoon)"
688
- return "18:00–00:00 (Evening)"
689
-
690
- alarm_hours_df = pd.DataFrame({'hour': alarm_hours})
691
- alarm_hours_df['band'] = alarm_hours_df['hour'].apply(hour_to_band)
692
- band_counts = alarm_hours_df['band'].value_counts().sort_index()
693
-
694
- top_bands = band_counts.nlargest(2)
695
- dominant_band = top_bands.index[0] if len(top_bands) > 0 else "N/A"
696
- second_dominant_band = top_bands.index[1] if len(top_bands) > 1 else "N/A"
697
-
698
- dominant_pct = (top_bands.iloc[0] / band_counts.sum() * 100) if len(top_bands) > 0 else 0
699
- second_pct = (top_bands.iloc[1] / band_counts.sum() * 100) if len(top_bands) > 1 else 0
700
-
701
- # Hitung jumlah masing-masing jenis alarm
702
- normal_alarms = alarm_data[alarm_data['Alarm Status'] == 'No Alarm'].shape[0]
703
- red_alarms = alarm_data[alarm_data['Alarm Status'].str.contains('Red', na=False)].shape[0]
704
- amber_alarms = alarm_data[alarm_data['Alarm Status'].str.contains('Amber', na=False)].shape[0]
705
-
706
- # Insight Spesifik Per Position dan Shift
707
- insight_lines = [
708
- f"{dominant_band} is the dominant period ({dominant_pct:.1f}% of all data).",
709
- f"{second_dominant_band} is the second-highest period ({second_pct:.1f}% of data).",
710
- f"Total: Normal={normal_alarms}, Amber={amber_alarms}, Red={red_alarms}"
711
- ]
712
-
713
- # Position 1 (Shift Pagi)
714
- pos1_pagi = alarm_data[(alarm_data['Position'] == 1) & (alarm_data['hour'].between(6, 17, inclusive='both'))]
715
- if not pos1_pagi.empty:
716
- pos1_pagi_total = pos1_pagi.groupby('hour').size()
717
- if not pos1_pagi_total.empty:
718
- dominant_hour_p1_pagi = pos1_pagi_total.idxmax()
719
- dominant_count_p1_pagi = pos1_pagi_total.max()
720
- 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.")
721
-
722
- # Position 1 (Shift Sore)
723
- pos1_sore = alarm_data[(alarm_data['Position'] == 1) & (~alarm_data['hour'].between(6, 17, inclusive='both'))]
724
- if not pos1_sore.empty:
725
- pos1_sore_red = pos1_sore[pos1_sore['Alarm Status'].str.contains('Red', na=False)]
726
- if not pos1_sore_red.empty:
727
- red_percentage_p1_sore = (len(pos1_sore_red) / len(pos1_sore)) * 100
728
- insight_lines.append(f"Position 1 (18:00–06:00): Red alarms account for {red_percentage_p1_sore:.1f}% of total alarms.")
729
-
730
- # Position 3 (Shift Pagi)
731
- pos3_pagi = alarm_data[(alarm_data['Position'] == 3) & (alarm_data['hour'].between(6, 17, inclusive='both'))]
732
- if not pos3_pagi.empty:
733
- pos3_pagi_total = pos3_pagi.groupby('hour').size()
734
- if not pos3_pagi_total.empty:
735
- dominant_hour_p3_pagi = pos3_pagi_total.idxmax()
736
- dominant_count_p3_pagi = pos3_pagi_total.max()
737
- 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.")
738
-
739
- # Position 3 (Shift Sore)
740
- pos3_sore = alarm_data[(alarm_data['Position'] == 3) & (~alarm_data['hour'].between(6, 17, inclusive='both'))]
741
- if not pos3_sore.empty:
742
- pos3_sore_amber = pos3_sore[pos3_sore['Alarm Status'].str.contains('Amber', na=False)]
743
- if not pos3_sore_amber.empty:
744
- amber_percentage_p3_sore = (len(pos3_sore_amber) / len(pos3_sore)) * 100
745
- insight_lines.append(f"Position 3 (18:00–06:00): Amber alarms account for {amber_percentage_p3_sore:.1f}% of total alarms.")
746
-
747
- # Position 4 (Shift Pagi)
748
- pos4_pagi = alarm_data[(alarm_data['Position'] == 4) & (alarm_data['hour'].between(6, 17, inclusive='both'))]
749
- if not pos4_pagi.empty:
750
- pos4_pagi_total = pos4_pagi.groupby('hour').size()
751
- if not pos4_pagi_total.empty:
752
- dominant_hour_p4_pagi = pos4_pagi_total.idxmax()
753
- dominant_count_p4_pagi = pos4_pagi_total.max()
754
- 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.")
755
-
756
- # Position 4 (Shift Sore)
757
- pos4_sore = alarm_data[(alarm_data['Position'] == 4) & (~alarm_data['hour'].between(6, 17, inclusive='both'))]
758
- if not pos4_sore.empty:
759
- pos4_sore_amber = pos4_sore[pos4_sore['Alarm Status'].str.contains('Amber', na=False)]
760
- if not pos4_sore_amber.empty:
761
- amber_percentage_p4_sore = (len(pos4_sore_amber) / len(pos4_sore)) * 100
762
- insight_lines.append(f"Position 4 (18:00–06:00): Amber alarms account for {amber_percentage_p4_sore:.1f}% of total alarms.")
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">
770
- {insight_text}
771
  </div>
772
  </div>
773
  """, unsafe_allow_html=True)
 
491
  """, unsafe_allow_html=True)
492
 
493
  #### OBJECTICVE 2
494
+ 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)
 
 
495
 
496
+ # Prepare data
497
+ front_df = dff[dff['Position'].isin([1, 2])].copy()
498
+ rear_df = dff[dff['Position'].isin([3, 4])].copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
 
500
+ col1, col2 = st.columns(2)
501
+
502
+ # =============== COL 1: Front — Temperature → Pressure (Scatter + Regression Area) ===============
503
  with col1:
504
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
505
+
506
+ if not front_df.empty:
507
+ # Tambahkan kategori alarm status
508
+ front_df['Category'] = front_df.apply(
509
+ lambda row: f"Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
510
+ else f"Amber Pressure Front Tyre" if 'Amber' in row['Alarm Status']
511
+ else f"Red Pressure Front Tyre", axis=1
512
+ )
513
+ categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
514
+ front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
515
+
516
+ # Filter valid data
517
+ valid_data = front_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
518
+ if len(valid_data) > 1:
519
+ X = valid_data[['Temperature (°C)']]
520
+ y = valid_data['Pressure (psi)']
521
+ model = LinearRegression().fit(X, y)
522
+ x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
523
+ y_line = model.predict(x_line)
524
+ corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
525
+
526
+ fig1 = px.scatter(
527
+ valid_data,
528
+ x='Temperature (°C)',
529
+ y='Pressure (psi)',
530
+ color='Category',
531
+ color_discrete_map={
532
+ "Normal Front Tyre": "#2E7D32", # Hijau
533
+ "Amber Pressure Front Tyre": "#FFC107", # Kuning
534
+ "Red Pressure Front Tyre": "#D32F2F" # Merah
535
+ },
536
+ category_orders={'Category': categories},
537
+ template="plotly_white",
538
+ labels={'Temperature (°C)': 'Temperature (°C)', 'Pressure (psi)': 'Pressure (psi)'}
539
+ )
540
+
541
+ fig1.update_traces(
542
+ hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
543
+ marker=dict(size=6)
544
+ )
545
+
546
+ fig1.add_trace(go.Scatter(
547
+ x=x_line.flatten(), y=y_line,
548
+ mode='lines', name='Trend Line',
549
+ line=dict(color='#1976D2', dash='dot', width=2)
550
+ ))
551
+
552
+ # Tambahkan area confidence interval (soft background)
553
+ y_pred = model.predict(X)
554
+ residuals = y - y_pred
555
+ mse = np.mean(residuals**2)
556
+ std_error = np.sqrt(mse)
557
+ y_upper = y_line + 1.96 * std_error
558
+ y_lower = y_line - 1.96 * std_error
559
+
560
+ fig1.add_trace(go.Scatter(
561
+ x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
562
+ y=np.concatenate([y_upper, y_lower[::-1]]),
563
+ fill='toself',
564
+ fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
565
+ line=dict(color='rgba(255,255,255,0)'),
566
+ showlegend=False,
567
+ name='Confidence Interval'
568
+ ))
569
+
570
+ fig1.update_layout(
571
+ margin=dict(t=40),
572
+ annotations=[
573
+ dict(
574
+ x=0.95, y=0.95,
575
+ xref="paper", yref="paper",
576
+ text=f"r = {corr:.2f}",
577
+ showarrow=False,
578
+ bgcolor="white",
579
+ bordercolor="black",
580
+ borderwidth=1,
581
+ font=dict(color="black")
582
+ )
583
+ ],
584
+ legend=dict(
585
+ title_text='Tyre Status',
586
+ bgcolor="white",
587
+ bordercolor="lightgray",
588
+ borderwidth=1,
589
+ itemclick=False,
590
+ itemdoubleclick=False
591
+ )
592
+ )
593
+ st.plotly_chart(fig1, use_container_width=True)
594
+ else:
595
+ st.warning("Insufficient data for front tyres.")
596
  else:
597
+ st.warning("No front tyre data.")
598
 
599
+ # =============== COL 2: Front — Pressure vs (Temperature / Speed) ===============
600
  with col2:
601
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
602
+
603
+ if not front_df.empty:
604
+ # Filter kecepatan > 0 untuk hindari pembagian dengan nol
605
+ front_df = front_df[front_df['Speed (km/h)'] > 0]
606
+ front_df['Temp_Speed_Ratio'] = front_df['Temperature (°C)'] / front_df['Speed (km/h)']
607
+
608
+ # Tambahkan kategori alarm status
609
+ front_df['Category'] = front_df.apply(
610
+ lambda row: f"Normal Front Tyre" if row['Alarm Status'] == 'No Alarm'
611
+ else f"Amber Pressure Front Tyre" if 'Amber' in row['Alarm Status']
612
+ else f"Red Pressure Front Tyre", axis=1
613
+ )
614
+ categories = ["Normal Front Tyre", "Amber Pressure Front Tyre", "Red Pressure Front Tyre"]
615
+ front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
616
+
617
+ valid_data = front_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
618
+ if not valid_data.empty:
619
+ fig2 = px.scatter(
620
+ valid_data,
621
+ x='Temp_Speed_Ratio',
622
+ y='Pressure (psi)',
623
+ color='Category',
624
+ color_discrete_map={
625
+ "Normal Front Tyre": "#2E7D32", # Hijau
626
+ "Amber Pressure Front Tyre": "#FFC107", # Kuning
627
+ "Red Pressure Front Tyre": "#D32F2F" # Merah
628
+ },
629
+ category_orders={'Category': categories},
630
+ template="plotly_white",
631
+ labels={'Temp_Speed_Ratio': 'Temperature / Speed', 'Pressure (psi)': 'Pressure (psi)'}
632
+ )
633
+
634
+ fig2.update_traces(
635
+ hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
636
+ marker=dict(size=6)
637
+ )
638
+
639
+ fig2.update_layout(
640
+ margin=dict(t=40),
641
+ legend=dict(
642
+ title_text='Tyre Status',
643
+ bgcolor="white",
644
+ bordercolor="lightgray",
645
+ borderwidth=1,
646
+ itemclick=False,
647
+ itemdoubleclick=False
648
+ )
649
+ )
650
+ st.plotly_chart(fig2, use_container_width=True)
651
+ else:
652
+ st.warning("Insufficient data for front tyres.")
653
  else:
654
+ st.warning("No front tyre data.")
655
+
656
+ # =============== COL 3: Rear — Temperature → Pressure (Scatter + Regression Area) ===============
657
+ col3, col4 = st.columns(2)
658
 
659
  with col3:
660
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
661
+
662
+ if not rear_df.empty:
663
+ rear_df['Category'] = rear_df.apply(
664
+ lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
665
+ else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
666
+ else f"Red Pressure Rear Tyre", axis=1
667
+ )
668
+ categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
669
+ rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
670
+
671
+ valid_data = rear_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
672
+ if len(valid_data) > 1:
673
+ X = valid_data[['Temperature (°C)']]
674
+ y = valid_data['Pressure (psi)']
675
+ model = LinearRegression().fit(X, y)
676
+ x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
677
+ y_line = model.predict(x_line)
678
+ corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
679
+
680
+ fig3 = px.scatter(
681
+ valid_data,
682
+ x='Temperature (°C)',
683
+ y='Pressure (psi)',
684
+ color='Category',
685
+ color_discrete_map={
686
+ "Normal Rear Tyre": "#2E7D32",
687
+ "Amber Pressure Rear Tyre": "#FFC107",
688
+ "Red Pressure Rear Tyre": "#D32F2F"
689
+ },
690
+ category_orders={'Category': categories},
691
+ template="plotly_white"
692
+ )
693
+
694
+ fig3.update_traces(
695
+ hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
696
+ marker=dict(size=6)
697
+ )
698
+
699
+ fig3.add_trace(go.Scatter(
700
+ x=x_line.flatten(), y=y_line,
701
+ mode='lines', name='Trend Line',
702
+ line=dict(color='#1976D2', dash='dot', width=2)
703
+ ))
704
+
705
+ # Tambahkan area confidence interval (soft background)
706
+ y_pred = model.predict(X)
707
+ residuals = y - y_pred
708
+ mse = np.mean(residuals**2)
709
+ std_error = np.sqrt(mse)
710
+ y_upper = y_line + 1.96 * std_error
711
+ y_lower = y_line - 1.96 * std_error
712
+
713
+ fig3.add_trace(go.Scatter(
714
+ x=np.concatenate([x_line.flatten(), x_line.flatten()[::-1]]),
715
+ y=np.concatenate([y_upper, y_lower[::-1]]),
716
+ fill='toself',
717
+ fillcolor='rgba(211, 47, 47, 0.1)', # Merah transparan
718
+ line=dict(color='rgba(255,255,255,0)'),
719
+ showlegend=False,
720
+ name='Confidence Interval'
721
+ ))
722
+
723
+ fig3.update_layout(
724
+ margin=dict(t=40),
725
+ annotations=[
726
+ dict(
727
+ x=0.95, y=0.95,
728
+ xref="paper", yref="paper",
729
+ text=f"r = {corr:.2f}",
730
+ showarrow=False,
731
+ bgcolor="white",
732
+ bordercolor="black",
733
+ borderwidth=1,
734
+ font=dict(color="black")
735
+ )
736
+ ],
737
+ legend=dict(
738
+ title_text='Tyre Status',
739
+ bgcolor="white",
740
+ bordercolor="lightgray",
741
+ borderwidth=1,
742
+ itemclick=False,
743
+ itemdoubleclick=False
744
+ )
745
+ )
746
+ st.plotly_chart(fig3, use_container_width=True)
747
+ else:
748
+ st.warning("Insufficient data for rear tyres.")
749
  else:
750
+ st.warning("No rear tyre data.")
751
 
752
+ # =============== COL 4: Rear — Pressure vs (Temperature / Speed) ===============
753
  with col4:
754
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Pressure vs (Temperature / Speed)</h5>', unsafe_allow_html=True)
755
+
756
+ if not rear_df.empty:
757
+ # Filter kecepatan > 0 untuk hindari pembagian dengan nol
758
+ rear_df = rear_df[rear_df['Speed (km/h)'] > 0]
759
+ rear_df['Temp_Speed_Ratio'] = rear_df['Temperature (°C)'] / rear_df['Speed (km/h)']
760
+
761
+ # Tambahkan kategori alarm status
762
+ rear_df['Category'] = rear_df.apply(
763
+ lambda row: f"Normal Rear Tyre" if row['Alarm Status'] == 'No Alarm'
764
+ else f"Amber Pressure Rear Tyre" if 'Amber' in row['Alarm Status']
765
+ else f"Red Pressure Rear Tyre", axis=1
766
+ )
767
+ categories = ["Normal Rear Tyre", "Amber Pressure Rear Tyre", "Red Pressure Rear Tyre"]
768
+ rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
769
+
770
+ valid_data = rear_df.dropna(subset=['Temp_Speed_Ratio', 'Pressure (psi)'])
771
+ if not valid_data.empty:
772
+ fig4 = px.scatter(
773
+ valid_data,
774
+ x='Temp_Speed_Ratio',
775
+ y='Pressure (psi)',
776
+ color='Category',
777
+ color_discrete_map={
778
+ "Normal Rear Tyre": "#2E7D32",
779
+ "Amber Pressure Rear Tyre": "#FFC107",
780
+ "Red Pressure Rear Tyre": "#D32F2F"
781
+ },
782
+ category_orders={'Category': categories},
783
+ template="plotly_white"
784
+ )
785
+
786
+ fig4.update_traces(
787
+ hovertemplate="<b>%{marker.color}</b><br>T/S: %{x:.2f}<br>Pressure: %{y:.1f} psi<extra></extra>",
788
+ marker=dict(size=6)
789
+ )
790
+
791
+ fig4.update_layout(
792
+ margin=dict(t=40),
793
+ legend=dict(
794
+ title_text='Tyre Status',
795
+ bgcolor="white",
796
+ bordercolor="lightgray",
797
+ borderwidth=1,
798
+ itemclick=False,
799
+ itemdoubleclick=False
800
+ )
801
+ )
802
+ st.plotly_chart(fig4, use_container_width=True)
803
+ else:
804
+ st.warning("Insufficient data for rear tyres.")
 
805
  else:
806
+ st.warning("No rear tyre data.")
807
 
808
  # =============== INSIGHT 3 ===============
809
+ def safe_corr(a, b):
810
+ mask = ~(np.isnan(a) | np.isnan(b))
811
+ if mask.sum() < 2:
812
+ return 0.0
813
+ return np.corrcoef(a[mask], b[mask])[0, 1]
814
+
815
+ corr_p_t_front = safe_corr(front_df['Temperature (°C)'], front_df['Pressure (psi)'])
816
+ corr_t_s_front = safe_corr(front_df['Temperature (°C)'], front_df['Speed (km/h)'])
817
+ corr_p_t_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Pressure (psi)'])
818
+ corr_t_s_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Speed (km/h)'])
819
+
820
+ insight_text = f"""
821
+ 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.
822
+ """
823
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824
  st.markdown(f"""
825
  <div class="insight-box">
826
  <div class="content">
827
+ {insight_text.strip()}
828
  </div>
829
  </div>
830
  """, unsafe_allow_html=True)