Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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: '#
|
| 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='#
|
| 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 |
-
|
| 685 |
else:
|
| 686 |
-
#
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 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 —
|
| 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:
|
| 780 |
-
else
|
| 781 |
-
else
|
| 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 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 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(
|
| 795 |
|
| 796 |
-
|
| 797 |
-
|
| 798 |
x='Temperature (°C)',
|
| 799 |
y='Pressure (psi)',
|
| 800 |
color='Category',
|
| 801 |
color_discrete_map={
|
| 802 |
-
"Normal Front Tyre": "#2E7D32",
|
| 803 |
-
"Amber Pressure Front Tyre": "#
|
| 804 |
-
"Red Pressure Front Tyre": "#D32F2F"
|
| 805 |
},
|
| 806 |
category_orders={'Category': categories},
|
| 807 |
-
template="plotly_white"
|
| 808 |
-
labels={'Temperature (°C)': 'Temperature (°C)', 'Pressure (psi)': 'Pressure (psi)'}
|
| 809 |
)
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 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 |
-
#
|
| 823 |
y_pred = model.predict(X)
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 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)',
|
| 835 |
line=dict(color='rgba(255,255,255,0)'),
|
| 836 |
-
showlegend=False
|
| 837 |
-
name='Confidence Interval'
|
| 838 |
))
|
| 839 |
|
| 840 |
-
|
| 841 |
margin=dict(t=40),
|
| 842 |
-
annotations=[
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 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(
|
| 864 |
else:
|
| 865 |
-
st.warning("Insufficient
|
| 866 |
else:
|
| 867 |
st.warning("No front tyre data.")
|
| 868 |
|
| 869 |
-
# =============== COL 2: Front — Pressure vs
|
| 870 |
with col2:
|
| 871 |
-
st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Pressure vs
|
| 872 |
|
| 873 |
if not front_df.empty:
|
| 874 |
-
# Filter kecepatan > 0
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 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 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
|
|
|
|
|
|
|
|
|
| 918 |
)
|
| 919 |
-
|
| 920 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
else:
|
| 922 |
-
st.warning("
|
| 923 |
else:
|
| 924 |
st.warning("No front tyre data.")
|
| 925 |
|
| 926 |
-
# =============== COL 3: Rear
|
| 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:
|
| 935 |
-
else
|
| 936 |
-
else
|
| 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 |
-
|
| 942 |
-
if len(
|
| 943 |
-
X =
|
| 944 |
-
y =
|
| 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(
|
| 949 |
|
| 950 |
-
|
| 951 |
-
|
| 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": "#
|
| 958 |
"Red Pressure Rear Tyre": "#D32F2F"
|
| 959 |
},
|
| 960 |
category_orders={'Category': categories},
|
| 961 |
template="plotly_white"
|
| 962 |
)
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 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 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 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)',
|
| 988 |
line=dict(color='rgba(255,255,255,0)'),
|
| 989 |
-
showlegend=False
|
| 990 |
-
name='Confidence Interval'
|
| 991 |
))
|
| 992 |
|
| 993 |
-
|
| 994 |
margin=dict(t=40),
|
| 995 |
-
annotations=[
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 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(
|
| 1017 |
else:
|
| 1018 |
-
st.warning("Insufficient
|
| 1019 |
else:
|
| 1020 |
st.warning("No rear tyre data.")
|
| 1021 |
|
| 1022 |
-
# =============== COL 4: Rear — Pressure vs
|
| 1023 |
with col4:
|
| 1024 |
-
st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Pressure vs
|
| 1025 |
|
| 1026 |
if not rear_df.empty:
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 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 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
|
|
|
|
|
|
|
|
|
| 1070 |
)
|
| 1071 |
-
|
| 1072 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1073 |
else:
|
| 1074 |
-
st.warning("
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 1091 |
-
Front tyres show stronger
|
| 1092 |
-
""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1093 |
|
| 1094 |
st.markdown(f"""
|
| 1095 |
<div class="insight-box">
|
| 1096 |
-
<div class="content">
|
| 1097 |
-
{insight_text
|
| 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 |
-
# ===
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1123 |
for _, r in valid_gps.iterrows():
|
| 1124 |
-
|
| 1125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
|
|
|
| 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 |
-
|
| 1174 |
-
|
| 1175 |
-
|
|
|
|
| 1176 |
top_zone = zone_counts.index[0]
|
| 1177 |
top_zone_count = zone_counts.iloc[0]
|
| 1178 |
-
total_alarms =
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
|
|
|
|
|
|
| 1190 |
else:
|
| 1191 |
-
|
| 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
|
| 1207 |
</div>
|
| 1208 |
</div>
|
| 1209 |
""", unsafe_allow_html=True)
|
|
|
|
| 1210 |
# ================= OBJECTIVE 5 =================
|
| 1211 |
-
# ================= OBJECTIVE
|
| 1212 |
-
st.markdown('<h3 class="objective-title">OBJECTIVE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1222 |
|
|
|
|
| 1223 |
zone_counts = dff[dff['is_alarm'] == 1]['Zone'].value_counts()
|
| 1224 |
-
|
| 1225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1226 |
|
| 1227 |
-
# Correlation analysis
|
| 1228 |
front_df = dff[dff['Position'].isin([1, 2])]
|
| 1229 |
rear_df = dff[dff['Position'].isin([3, 4])]
|
| 1230 |
|
| 1231 |
-
|
| 1232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1233 |
else:
|
| 1234 |
-
|
| 1235 |
|
| 1236 |
-
|
| 1237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
else:
|
| 1239 |
-
|
| 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 |
-
|
| 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 |
-
# ==============
|
| 1338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1339 |
st.markdown(f"""
|
| 1340 |
<div class="insight-box">
|
| 1341 |
<div class="content" style="text-align:left;">
|
| 1342 |
-
{
|
| 1343 |
</div>
|
| 1344 |
</div>
|
| 1345 |
""", unsafe_allow_html=True)
|
| 1346 |
|
| 1347 |
-
|
| 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 |
-
{
|
| 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
|