SHELLAPANDIANGANHUNGING commited on
Commit
67b65ce
·
verified ·
1 Parent(s): 668c986

Upload 7 files

Browse files
Files changed (8) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +29 -9
  3. README.md +1 -1
  4. app.py +1279 -0
  5. df_final.xlsx +0 -0
  6. gitattributes +1 -0
  7. logo.png +3 -0
  8. requirements.txt +12 -3
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ logo.png filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -1,20 +1,40 @@
1
- FROM python:3.13.5-slim
 
2
 
3
- WORKDIR /app
 
 
 
4
 
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
  curl \
8
  git \
 
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
 
 
 
 
13
 
14
- RUN pip3 install -r requirements.txt
 
 
15
 
 
16
  EXPOSE 8501
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
 
 
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
 
 
 
 
 
1
+ # ✅ Gunakan Python 3.10 (versi stabil & didukung Streamlit)
2
+ FROM python:3.10-slim
3
 
4
+ # Non-interactive mode
5
+ ENV DEBIAN_FRONTEND=noninteractive \
6
+ PYTHONUNBUFFERED=1 \
7
+ PYTHONDONTWRITEBYTECODE=1
8
 
9
+ # Install deps minimal (tanpa build-essential berat)
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
  curl \
12
  git \
13
+ libgl1 \
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
+ # Set workdir
17
+ WORKDIR /app
18
+
19
+ # Salin requirements & install
20
+ COPY requirements.txt .
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
 
23
+ # Salin file app (langsung di root — bukan src/)
24
+ COPY app.py .
25
+ COPY df_final.xlsx .
26
 
27
+ # Port Streamlit (HF pakai 7860 atau 8501 — keduanya oke)
28
  EXPOSE 8501
29
 
30
+ # ✅ HEALTHCHECK aman: gunakan python, bukan curl
31
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
32
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8501/_stcore/health', timeout=3)"
33
 
34
+ # ✅ ENTRYPOINT: tambahkan opsi headless & server address
35
+ ENTRYPOINT ["streamlit", "run", "app.py", \
36
+ "--server.port=8501", \
37
+ "--server.address=0.0.0.0", \
38
+ "--server.headless=true", \
39
+ "--browser.serverAddress=0.0.0.0", \
40
+ "--browser.gatherUsageStats=false"]
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Tyre
3
  emoji: 🚀
4
  colorFrom: red
5
  colorTo: red
 
1
  ---
2
+ title: Michellin Str
3
  emoji: 🚀
4
  colorFrom: red
5
  colorTo: red
app.py ADDED
@@ -0,0 +1,1279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import numpy as np
4
+ import plotly.express as px
5
+ import plotly.graph_objects as go
6
+ 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",
14
+ page_icon="",
15
+ layout="wide",
16
+ initial_sidebar_state="expanded"
17
+ )
18
+
19
+ # ================= CUSTOM CSS (Dark White + Power BI Style Filters + LLM Insight Boxes) =================
20
+ st.markdown("""
21
+ <style>
22
+ /* ================= ROOT & COLORS ================= */
23
+ :root {
24
+ --michelin-blue: #003A8F;
25
+ --surface: #FFFFFF;
26
+ --surface-alt: #F9FAFB;
27
+ --text-dark: #1F2D3D;
28
+ --text-muted: #6C757D;
29
+ --border: #E9ECEF;
30
+ --shadow-sm: 0 2px 6px rgba(0,0,0,0.04);
31
+ --shadow: 0 6px 16px rgba(0,0,0,0.06);
32
+ --accent-yellow: #FFD100;
33
+ --filter-bg: #F5F7FA;
34
+ }
35
+
36
+ /* ================= GLOBAL TYPOGRAPHY & ALIGNMENT ================= */
37
+ .stApp {
38
+ background-color: var(--surface);
39
+ font-family: 'Segoe UI', system-ui, sans-serif;
40
+ color: var(--text-dark);
41
+ }
42
+
43
+ /* Force center alignment for ALL headings */
44
+ h1, h2, h3, h4, h5, h6,
45
+ .stMarkdown, .stText, p, div[data-testid="stMarkdownContainer"],
46
+ label, .stSelectbox label, .stMultiselect label, .stCheckbox label {
47
+ text-align: center !important;
48
+ }
49
+
50
+ /* Fix bullet/number list centering */
51
+ .stMarkdown ul, .stMarkdown ol {
52
+ text-align: left !important;
53
+ margin-left: auto;
54
+ margin-right: auto;
55
+ max-width: 800px;
56
+ }
57
+
58
+ /* ================= SIDEBAR (Power BI Style) ================= */
59
+ [data-testid="stSidebar"] {
60
+ background: var(--filter-bg) !important;
61
+ color: var(--text-dark);
62
+ padding: 20px 12px;
63
+ }
64
+
65
+ [data-testid="stSidebar"] h3 {
66
+ color: var(--michelin-blue);
67
+ font-weight: 700;
68
+ margin-bottom: 12px;
69
+ }
70
+
71
+ /* Power BI-style dropdowns */
72
+ [data-testid="stSelectbox"] div[data-baseweb="select"],
73
+ [data-testid="stMultiselect"] div[data-baseweb="select"] {
74
+ background-color: white !important;
75
+ border-radius: 8px !important;
76
+ border: 1px solid var(--border) !important;
77
+ box-shadow: var(--shadow-sm);
78
+ padding: 6px 10px !important;
79
+ min-height: 40px !important;
80
+ }
81
+
82
+ [data-testid="stSelectbox"] div[data-baseweb="select"] > div,
83
+ [data-testid="stMultiselect"] div[data-baseweb="select"] > div {
84
+ color: var(--text-dark) !important;
85
+ font-weight: 500;
86
+ }
87
+
88
+ /* Remove red tags from multiselect */
89
+ [data-testid="stMultiselect"] div[data-baseweb="select"] .stMultiSelectTag {
90
+ display: none !important;
91
+ }
92
+
93
+ /* Submit button */
94
+ [data-testid="stSidebar"] .stButton > button {
95
+ width: 100%;
96
+ background: var(--accent-yellow);
97
+ color: var(--michelin-blue);
98
+ font-weight: 700;
99
+ border-radius: 10px;
100
+ padding: 12px 0;
101
+ margin-top: 16px;
102
+ box-shadow: var(--shadow);
103
+ border: none;
104
+ font-size: 1.05rem;
105
+ }
106
+ [data-testid="stSidebar"] .stButton > button:hover {
107
+ background: #FFC107;
108
+ transform: translateY(-1px);
109
+ box-shadow: 0 8px 16px rgba(0,0,0,0.12);
110
+ }
111
+
112
+ /* ================= HEADER ================= */
113
+ .main-header h1 {
114
+ font-size: 2.4rem;
115
+ margin-bottom: 6px;
116
+ font-weight: 800;
117
+ color: var(--michelin-blue);
118
+ }
119
+ .main-header p {
120
+ font-size: 1.15rem;
121
+ color: var(--text-muted);
122
+ margin-top: 0;
123
+ }
124
+
125
+ /* ================= OBJECTIVE TITLE (NO BACKGROUND BOX) ================= */
126
+ .objective-title {
127
+ text-align: center !important;
128
+ font-size: 1.6rem;
129
+ font-weight: 800;
130
+ color: var(--michelin-blue);
131
+ margin: 40px 0 24px 0;
132
+ }
133
+
134
+ /* ================= INSIGHT LLM-STYLE (Like Screenshot) ================= */
135
+ .insight-box {
136
+ background: var(--surface-alt);
137
+ border: 1px solid var(--border);
138
+ border-radius: 12px;
139
+ padding: 20px;
140
+ box-shadow: var(--shadow-sm);
141
+ margin: 20px 0 30px 0;
142
+ position: relative;
143
+ display: flex;
144
+ align-items: flex-start;
145
+ gap: 12px;
146
+ }
147
+
148
+ .insight-box .content {
149
+ flex: 1;
150
+ font-size: 1.05rem;
151
+ line-height: 1.65;
152
+ color: var(--text-dark);
153
+ text-align: left;
154
+ }
155
+
156
+ .insight-box .tag {
157
+ position: absolute;
158
+ top: 12px;
159
+ right: 16px;
160
+ background: var(--michelin-blue);
161
+ color: white;
162
+ font-size: 0.85rem;
163
+ font-weight: 700;
164
+ padding: 6px 12px;
165
+ border-radius: 8px;
166
+ letter-spacing: 0.5px;
167
+ }
168
+
169
+ /* ================= PLOTLY ================= */
170
+ .plotly-graph-div {
171
+ border-radius: 12px;
172
+ overflow: hidden;
173
+ box-shadow: var(--shadow-sm);
174
+ border: 1px solid var(--border);
175
+ }
176
+
177
+ /* ================= LOGO ================= */
178
+ .logo-container {
179
+ position: fixed;
180
+ top: 20px;
181
+ right: 20px;
182
+ z-index: 9999;
183
+ background: white;
184
+ padding: 6px;
185
+ border-radius: 10px;
186
+ box-shadow: var(--shadow);
187
+ border: 1px solid var(--border);
188
+ }
189
+ .logo-container img {
190
+ width: 120px;
191
+ height: auto;
192
+ }
193
+
194
+ /* ================= FOOTER ================= */
195
+ .footer {
196
+ text-align: center;
197
+ font-size: 0.9rem;
198
+ color: var(--text-muted);
199
+ margin-top: 50px;
200
+ padding: 20px 0;
201
+ border-top: 1px solid var(--border);
202
+ }
203
+
204
+ /* ================= STREAMLIT TWEAKS ================= */
205
+ div.block-container {
206
+ padding-top: 2rem;
207
+ }
208
+ section[data-testid="stSidebar"] {
209
+ width: 280px !important;
210
+ min-width: 280px !important;
211
+ }
212
+ </style>
213
+ """, unsafe_allow_html=True)
214
+
215
+
216
+ # ================= LOAD DATA =================
217
+ @st.cache_data
218
+ def load_data():
219
+ try:
220
+ df = pd.read_excel("df_final.xlsx", sheet_name="Sheet1")
221
+ except FileNotFoundError:
222
+ st.error("❌ File `df_final.xlsx` not found. Please ensure it's in the same directory.")
223
+ st.stop()
224
+
225
+ # Fix encoding (e.g., '°C' → '°C')
226
+ df.columns = df.columns.str.replace("Â", "")
227
+ for col in df.select_dtypes(include='object').columns:
228
+ df[col] = df[col].astype(str).str.replace("Â", "")
229
+
230
+ # Parse datetime
231
+ df['Time'] = pd.to_datetime(df['Time'], errors='coerce')
232
+ df = df.dropna(subset=['Time'])
233
+ df['hour'] = df['Time'].dt.hour
234
+
235
+ # Alarm flag
236
+ df['is_alarm'] = (~df['Alarm Status'].str.contains('No Alarm', na=False)).astype(int)
237
+
238
+ # Dynamic risk score
239
+ p = df['Pressure (psi)']
240
+ p_red_high = df['Red High Press (psi)']
241
+ p_amber_high = df['Amber High Press (psi)']
242
+ t = df['Temperature (°C)']
243
+ t_red = df['Absolute Red Temp (°C)']
244
+ t_amber = df['Absolute Amber Temp (°C)']
245
+
246
+ p_norm = np.clip((p - p_amber_high) / (p_red_high - p_amber_high), 0, 1)
247
+ t_norm = np.clip((t - t_amber) / (t_red - t_amber), 0, 1)
248
+ df['risk_score'] = 0.6 * p_norm + 0.4 * t_norm
249
+
250
+ def get_risk_label(score):
251
+ if score >= 0.8: return 'Very High Risk'
252
+ elif score >= 0.6: return 'High Risk'
253
+ elif score >= 0.3: return 'Moderate Risk'
254
+ else: return 'Slight Risk'
255
+ df['Risk Level'] = df['risk_score'].apply(get_risk_label)
256
+
257
+ # Add Position Group
258
+ df['Position Group'] = df['Position'].apply(lambda x: 'Front' if x in [1, 2] else 'Rear')
259
+
260
+ return df
261
+
262
+ df = load_data()
263
+
264
+ # ================= HEADER =================
265
+ st.markdown("""
266
+ <div class="main-header" style="text-align:center;">
267
+ <h1>Michelin Mining Tyre Analytics</h1>
268
+ <p style="font-size:14px; color:#b0b0b0; margin-top:-10px;">
269
+ Analysis is based on daily aggregated data
270
+ </p>
271
+ </div>
272
+ """, unsafe_allow_html=True)
273
+
274
+
275
+ # ================= LOGO =================
276
+ st.markdown("""
277
+ <div style="text-align:right;">
278
+ <img src="logo.png" width="120">
279
+ </div>
280
+ """, unsafe_allow_html=True)
281
+
282
+ # ================= SIDEBAR FILTERS =================
283
+ with st.sidebar:
284
+ st.markdown("### Filter")
285
+
286
+ # Tyre Type: with 'All' option, behaves like before
287
+ tyre_types = st.selectbox(
288
+ "Tyre Type", options=['All'] + sorted(df['Tyre Type'].dropna().unique()), index=0
289
+ )
290
+ if tyre_types != 'All':
291
+ tyre_types = [tyre_types]
292
+ else:
293
+ tyre_types = sorted(df['Tyre Type'].dropna().unique())
294
+
295
+ # Date: behaves like Tyre Type — show 'All' by default
296
+ date_options = sorted(df['date'].astype(str).unique())
297
+ dates_selected = st.selectbox(
298
+ "Date", options=['All'] + date_options, index=0
299
+ )
300
+ if dates_selected != 'All':
301
+ dates = [dates_selected]
302
+ else:
303
+ dates = date_options
304
+
305
+ # Zone: behaves like Tyre Type — show 'All' by default
306
+ zone_options = sorted(df['Zone'].dropna().unique())
307
+ zones_selected = st.selectbox(
308
+ "Zone", options=['All'] + zone_options, index=0
309
+ )
310
+ if zones_selected != 'All':
311
+ zones = [zones_selected]
312
+ else:
313
+ zones = zone_options
314
+
315
+ # Position: behaves like Tyre Type — show 'All' by default
316
+ pos_options = sorted(df['Position'].astype(int).dropna().unique())
317
+ positions_selected = st.selectbox(
318
+ "Position", options=['All'] + pos_options, index=0
319
+ )
320
+ if positions_selected != 'All':
321
+ positions = [positions_selected]
322
+ else:
323
+ positions = pos_options
324
+
325
+ # Alarm Status: behaves like Tyre Type — show 'All' by default
326
+ alarm_options = ["No Alarm", "Red High Pressure"]
327
+ alarms_selected = st.selectbox(
328
+ "Alarm Status", options=['All'] + alarm_options, index=0
329
+ )
330
+ if alarms_selected != 'All':
331
+ alarms = [alarms_selected]
332
+ else:
333
+ alarms = alarm_options
334
+
335
+ submit = st.button("Submit")
336
+
337
+ # Apply filters
338
+ if submit:
339
+ dff = df.copy()
340
+ if dates: dff = dff[dff['date'].astype(str).isin(dates)]
341
+ if zones: dff = dff[dff['Zone'].isin(zones)]
342
+ if positions: dff = dff[dff['Position'].astype(int).isin(positions)]
343
+ if tyre_types: dff = dff[dff['Tyre Type'].isin(tyre_types)]
344
+ if alarms: dff = dff[dff['Alarm Status'].isin(alarms)]
345
+ else:
346
+ dff = df
347
+
348
+
349
+ # ================= OBJECTIVE 1 =================
350
+ # ================= OBJECTIVE 1 =================
351
+ # st.markdown('<h3 class="objective-title">OBJECTIVE 1: Pressure & Temperature Trends — How Do Front and Rear Tyres Distributin?</h3>', unsafe_allow_html=True)
352
+
353
+ # col1, col2 = st.columns(2)
354
+
355
+ # with col1:
356
+ # st.markdown('<h5 style="text-align:center; margin-top: 0;">Pressure Distribution per Tyre Position</h5>', unsafe_allow_html=True)
357
+ # fig1 = px.box(
358
+ # dff,
359
+ # x='Position',
360
+ # y='Pressure (psi)',
361
+ # color='Position',
362
+ # color_discrete_map={1: '#d50000', 2: '#ff6d00', 3: '#ffcc00', 4: '#007acc'},
363
+ # template="plotly_white"
364
+ # )
365
+ # red_high = dff['Red High Press (psi)'].min()
366
+ # amber_high = dff['Amber High Press (psi)'].min()
367
+ # fig1.add_hline(y=red_high, line_dash="dash", line_color="red", annotation_text="Red High Press", annotation_position="top right")
368
+ # fig1.add_hline(y=amber_high, line_color="orange", annotation_text="Amber High Press", annotation_position="bottom right")
369
+ # fig1.update_layout(margin=dict(t=40))
370
+ # st.plotly_chart(fig1, use_container_width=True)
371
+
372
+ # with col2:
373
+ # st.markdown('<h5 style="text-align:center; margin-top: 0;">Temperature Distribution per Tyre Position</h5>', unsafe_allow_html=True)
374
+ # fig2 = px.box(
375
+ # dff,
376
+ # x='Position',
377
+ # y='Temperature (°C)',
378
+ # color='Position',
379
+ # color_discrete_map={1: '#d50000', 2: '#ff6d00', 3: '#ffcc00', 4: '#007acc'},
380
+ # template="plotly_white"
381
+ # )
382
+ # red_temp = dff['Absolute Red Temp (°C)'].min()
383
+ # amber_temp = dff['Absolute Amber Temp (°C)'].min()
384
+ # fig2.add_hline(y=red_temp, line_dash="dash", line_color="red", annotation_text="Red Temp", annotation_position="top right")
385
+ # fig2.add_hline(y=amber_temp, line_color="orange", annotation_text="Amber Temp", annotation_position="bottom right")
386
+ # fig2.update_layout(margin=dict(t=40))
387
+ # st.plotly_chart(fig2, use_container_width=True)
388
+
389
+ # Insight 1
390
+ # Insight 1
391
+ # ================= OBJECTIVE 1 =================
392
+ # Ensure 'Position' is treated as ordered categorical for consistent sorting
393
+ dff = dff.copy()
394
+ dff['Position'] = pd.Categorical(dff['Position'], categories=[1, 2, 3, 4], ordered=True)
395
+
396
+ # Optional: Use descriptive labels (if desired)
397
+ position_labels = {
398
+ 1: '1: Front Left',
399
+ 2: '2: Front Right',
400
+ 3: '3: Rear Left',
401
+ 4: '4: Rear Right'
402
+ }
403
+ # Or keep as just '1', '2'... if minimal
404
+ # position_labels = {1: '1', 2: '2', 3: '3', 4: '4'}
405
+ dff['Position_Label'] = dff['Position'].map(position_labels)
406
+
407
+ st.markdown('<h3 class="objective-title">OBJECTIVE 1: Pressure & Temperature Trends — How Do Front and Rear Tyres Distribute?</h3>', unsafe_allow_html=True)
408
+
409
+ col1, col2 = st.columns(2)
410
+
411
+ # Define consistent color mapping
412
+ color_map = {1: '#d50000', 2: '#ff6d00', 3: '#ffcc00', 4: '#007acc'}
413
+ category_order = [1, 2, 3, 4]
414
+
415
+ with col1:
416
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Pressure Distribution per Tyre Position</h5>', unsafe_allow_html=True)
417
+ fig1 = px.box(
418
+ dff,
419
+ x='Position_Label',
420
+ y='Pressure (psi)',
421
+ color='Position',
422
+ color_discrete_map=color_map,
423
+ category_orders={'Position': category_order},
424
+ template="plotly_white",
425
+ labels={'Position_Label': 'Position'}
426
+ )
427
+ red_high = dff['Red High Press (psi)'].min()
428
+ amber_high = dff['Amber High Press (psi)'].min()
429
+ fig1.add_hline(y=red_high, line_dash="dash", line_color="red", annotation_text="Red High Press", annotation_position="top right")
430
+ fig1.add_hline(y=amber_high, line_color="orange", annotation_text="Amber High Press", annotation_position="bottom right")
431
+ fig1.update_layout(
432
+ margin=dict(t=40),
433
+ legend_title_text='Position',
434
+ legend=dict(
435
+ yanchor="top",
436
+ y=0.99,
437
+ xanchor="left",
438
+ x=1.02 # Place legend outside plot to avoid overlap
439
+ )
440
+ )
441
+ st.plotly_chart(fig1, use_container_width=True)
442
+
443
+ with col2:
444
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Temperature Distribution per Tyre Position</h5>', unsafe_allow_html=True)
445
+ fig2 = px.box(
446
+ dff,
447
+ x='Position_Label',
448
+ y='Temperature (°C)',
449
+ color='Position',
450
+ color_discrete_map=color_map,
451
+ category_orders={'Position': category_order},
452
+ template="plotly_white",
453
+ labels={'Position_Label': 'Position'}
454
+ )
455
+ red_temp = dff['Absolute Red Temp (°C)'].min()
456
+ amber_temp = dff['Absolute Amber Temp (°C)'].min()
457
+ fig2.add_hline(y=red_temp, line_dash="dash", line_color="red", annotation_text="Red Temp", annotation_position="top right")
458
+ fig2.add_hline(y=amber_temp, line_color="orange", annotation_text="Amber Temp", annotation_position="bottom right")
459
+ fig2.update_layout(
460
+ margin=dict(t=40),
461
+ legend_title_text='Position',
462
+ legend=dict(
463
+ yanchor="top",
464
+ y=0.99,
465
+ xanchor="left",
466
+ x=1.02
467
+ )
468
+ )
469
+ st.plotly_chart(fig2, use_container_width=True)
470
+ # Insight 1
471
+ # Analisis data untuk menentukan pola
472
+ front_pressure_avg = dff[dff['Position'].isin([1, 2])]['Pressure (psi)'].mean()
473
+ rear_pressure_avg = dff[dff['Position'].isin([3, 4])]['Pressure (psi)'].mean()
474
+ front_temp_avg = dff[dff['Position'].isin([1, 2])]['Temperature (°C)'].mean()
475
+ rear_temp_avg = dff[dff['Position'].isin([3, 4])]['Temperature (°C)'].mean()
476
+
477
+ if front_pressure_avg < rear_pressure_avg and front_temp_avg > rear_temp_avg:
478
+ insight_text = f"""
479
+ Front tyres (Pos 1 & 2): Higher average pressure and temperature ({front_pressure_avg:.1f} psi, {front_temp_avg:.1f}°C) indicate higher loading and heat generation. Rear tyres (Pos 3 & 4): Lower average temperature ({rear_temp_avg:.1f}°C) suggests lighter effective loading.
480
+ """
481
+ elif front_pressure_avg > rear_pressure_avg and front_temp_avg < rear_temp_avg:
482
+ insight_text = f"""
483
+ Front tyres (Pos 1 & 2): Average pressure {front_pressure_avg:.1f} psi and average temperature {front_temp_avg:.1f}°C show lower heat levels compared to the rear tyres. Rear tyres (Pos 3 & 4): Average pressure {rear_pressure_avg:.1f} psi and average temperature {rear_temp_avg:.1f}°C show higher heat levels.
484
+ """
485
+ else:
486
+ insight_text = f"""
487
+ Front tyres: Pressure {front_pressure_avg:.1f} psi, temperature {front_temp_avg:.1f}°C. Rear tyres: Pressure {rear_pressure_avg:.1f} psi, temperature {rear_temp_avg:.1f}°C.
488
+ """
489
+ st.markdown(f"""
490
+ <div class="insight-box">
491
+ <div class="content">
492
+ {insight_text.strip()}
493
+ </div>
494
+ </div>
495
+ """, unsafe_allow_html=True)
496
+ # ================= OBJECTIVE 2 =================
497
+ # ================= OBJECTIVE 2 =================
498
+ # ================= OBJECTIVE 2 =================
499
+ st.markdown('<h3 class="objective-title">OBJECTIVE 2: Alarm Frequency Analysis — When, Where, and Which Tyres Matter Most?</h3>', unsafe_allow_html=True)
500
+
501
+ col_a, col_b = st.columns(2)
502
+
503
+ # --- COL A: Alarm Distribution by Hour (Polar Chart) ---
504
+ with col_a:
505
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Alarm Distribution by Hour</h5>', unsafe_allow_html=True)
506
+
507
+ alarm_hour_pos = dff[dff['is_alarm'] == 1][['hour', 'Position']].copy()
508
+
509
+ if alarm_hour_pos.empty:
510
+ st.warning("No alarm data to display.")
511
+ else:
512
+ # Hitung alarm per jam & posisi
513
+ hourly_pos_counts = alarm_hour_pos.groupby(['hour', 'Position']).size().unstack(fill_value=0)
514
+ positions = sorted([p for p in [1, 2, 3, 4] if p in hourly_pos_counts.columns]) # enforce 1-4 order
515
+ color_map = {1: '#d50000', 2: '#ff6d00', 3: '#ffcc00', 4: '#007acc'}
516
+
517
+ fig_polar = go.Figure()
518
+ max_r = max(hourly_pos_counts.sum(axis=1)) * 1.05 if not hourly_pos_counts.empty else 10
519
+
520
+ for pos in positions:
521
+ if pos in hourly_pos_counts.columns:
522
+ counts = hourly_pos_counts[pos].reindex(range(24), fill_value=0).values
523
+ theta = [h * 15 for h in range(24)] # 24 jam → 360° / 24 = 15° per jam
524
+ fig_polar.add_trace(go.Barpolar(
525
+ r=counts,
526
+ theta=theta,
527
+ width=15,
528
+ name=f'Position {pos}',
529
+ marker_color=color_map[pos],
530
+ opacity=0.85,
531
+ hovertemplate='<b>Hour:</b> %{theta:0f}:00<br><b>Alarms:</b> %{r}<extra></extra>'
532
+ ))
533
+
534
+ fig_polar.update_layout(
535
+ polar=dict(
536
+ radialaxis=dict(visible=True, range=[0, max_r], tickfont=dict(size=10)),
537
+ angularaxis=dict(
538
+ direction="clockwise",
539
+ tickvals=[0, 90, 180, 270],
540
+ ticktext=["00:00", "06:00", "12:00", "18:00"],
541
+ tickfont=dict(size=11)
542
+ )
543
+ ),
544
+ legend=dict(
545
+ title_text='Tyre Position',
546
+ yanchor="top",
547
+ y=0.98,
548
+ xanchor="left",
549
+ x=1.02,
550
+ bgcolor="rgba(255,255,255,0.7)",
551
+ borderwidth=0.5,
552
+ itemclick=False, # prevent accidental legend toggle
553
+ itemdoubleclick=False
554
+ ),
555
+ margin=dict(t=40, b=20, l=20, r=120),
556
+ hovermode="closest"
557
+ )
558
+ st.plotly_chart(fig_polar, use_container_width=True)
559
+
560
+ # --- COL B: Alarm Hotspots (Front Tyres Only: Pos 1 & 2) ---
561
+ with col_b:
562
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Alarm Hotspots by Tyre, Position & Zone</h5>', unsafe_allow_html=True)
563
+
564
+ # Filter hanya alarm di front tyres (Pos 1 & 2)
565
+ front_alarm_data = dff[(dff['is_alarm'] == 1) & (dff['Position'].isin([1, 2]))].copy()
566
+
567
+ if front_alarm_data.empty:
568
+ st.warning("No alarm data for front tyres to display.")
569
+ else:
570
+ agg_data = (
571
+ front_alarm_data
572
+ .groupby(['TyreSN', 'Position', 'Zone'])
573
+ .size()
574
+ .reset_index(name='Count')
575
+ )
576
+ agg_data['Percentage'] = (agg_data['Count'] / agg_data['Count'].sum()) * 100
577
+
578
+ # Warna eksplisit untuk 1 & 2
579
+ color_map_front = {1: '#d50000', 2: '#ff6d00'}
580
+
581
+ fig_bubble = px.scatter(
582
+ agg_data,
583
+ x='Position',
584
+ y='Zone',
585
+ size='Count',
586
+ color='Position',
587
+ color_discrete_map=color_map_front,
588
+ hover_name='TyreSN',
589
+ hover_data={'Position': True, 'Zone': True, 'Count': True, 'Percentage': ':.1f%'},
590
+ size_max=55,
591
+ template='plotly_white',
592
+ category_orders={'Position': [1, 2]}
593
+ )
594
+
595
+ # Tambahkan label singkat di tengah bubble (4 digit akhir SN)
596
+ fig_bubble.update_traces(
597
+ text=agg_data['TyreSN'].str[-4:],
598
+ textposition='middle center',
599
+ textfont=dict(color='white', size=9)
600
+ )
601
+
602
+ fig_bubble.update_layout(
603
+ xaxis=dict(
604
+ title='Position',
605
+ tickmode='array',
606
+ tickvals=[1, 2],
607
+ ticktext=['1', '2'],
608
+ tickfont=dict(size=12)
609
+ ),
610
+ yaxis=dict(title='Zone', tickfont=dict(size=12)),
611
+ legend=dict(
612
+ title_text='Tyre Position',
613
+ yanchor="top",
614
+ y=0.98,
615
+ xanchor="left",
616
+ x=1.02,
617
+ bgcolor="rgba(255,255,255,0.7)",
618
+ borderwidth=0.5
619
+ ),
620
+ margin=dict(t=40, b=20, l=20, r=120),
621
+ showlegend=True
622
+ )
623
+
624
+ # Rename legend entries
625
+ fig_bubble.for_each_trace(lambda t: t.update(
626
+ name=f'Position {int(t.name)}'
627
+ ))
628
+
629
+ st.plotly_chart(fig_bubble, use_container_width=True)
630
+
631
+ # --- INSIGHT 2: Actionable, Numeric, Time-Group Based ---
632
+ alarm_hours = dff[dff['is_alarm'] == 1]['hour']
633
+
634
+ if alarm_hours.empty:
635
+ insight_text = "• No alarm data available for analysis."
636
+ else:
637
+ # Group hours into time bands
638
+ def hour_to_band(h):
639
+ if 0 <= h < 6: return "00:00–06:00 (Night)"
640
+ if 6 <= h < 12: return "06:00–12:00 (Morning)"
641
+ if 12 <= h < 18: return "12:00–18:00 (Afternoon)"
642
+ return "18:00–00:00 (Evening)"
643
+
644
+ alarm_hours_df = pd.DataFrame({'hour': alarm_hours})
645
+ alarm_hours_df['band'] = alarm_hours_df['hour'].apply(hour_to_band)
646
+ band_counts = alarm_hours_df['band'].value_counts().sort_index() # sort by natural order
647
+
648
+ # Identify dominant & second-dominant bands
649
+ top_bands = band_counts.nlargest(2)
650
+ dominant_band = top_bands.index[0] if len(top_bands) > 0 else "N/A"
651
+ second_dominant_band = top_bands.index[1] if len(top_bands) > 1 else "N/A"
652
+
653
+ dominant_pct = (top_bands.iloc[0] / band_counts.sum() * 100) if len(top_bands) > 0 else 0
654
+ second_pct = (top_bands.iloc[1] / band_counts.sum() * 100) if len(top_bands) > 1 else 0
655
+
656
+ # Front vs Rear alarm share
657
+ front_alarms = dff[(dff['is_alarm'] == 1) & (dff['Position'].isin([1, 2]))].shape[0]
658
+ rear_alarms = dff[(dff['is_alarm'] == 1) & (dff['Position'].isin([3, 4]))].shape[0]
659
+ total_alarms = front_alarms + rear_alarms
660
+ front_pct = front_alarms / total_alarms * 100 if total_alarms > 0 else 0
661
+
662
+ # Top zone
663
+ top_zone = dff[dff['is_alarm'] == 1]['Zone'].value_counts().index[0] if not dff[dff['is_alarm'] == 1].empty else "N/A"
664
+
665
+ # Build insight bullets
666
+ insight_lines = [
667
+ f"{dominant_band} is the dominant alarm period ({dominant_pct:.1f}% of all alarms).",
668
+ f"{second_dominant_band} is the second-highest period ({second_pct:.1f}% of alarms)."
669
+ ]
670
+ if front_alarms > 0:
671
+ insight_lines.append(f"Front tyres (Pos 1 & 2) account for {front_pct:.1f}% of all alarms, indicating higher stress or usage intensity upfront.")
672
+ if top_zone != "N/A":
673
+ insight_lines.append(f"Zone {top_zone} records the highest alarm frequency across all positions.")
674
+ insight_lines.append("• Alarm clustering in specific hours and front positions suggests opportunity for targeted inspection scheduling.")
675
+
676
+ insight_text = "\n".join(insight_lines)
677
+
678
+ # --- Display Insight Box ---
679
+ st.markdown(f"""
680
+ <div class="insight-box">
681
+ <div class="content">
682
+ {insight_text}
683
+ </div>
684
+ </div>
685
+ """, unsafe_allow_html=True)
686
+ # ================= OBJECTIVE 3 =================
687
+ 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)
688
+
689
+ # Prepare data
690
+ front_df = dff[dff['Position'].isin([1, 2])].copy()
691
+ rear_df = dff[dff['Position'].isin([3, 4])].copy()
692
+
693
+ col1, col2 = st.columns(2)
694
+
695
+ # =============== COL 1: Front — Temperature → Pressure ===============
696
+ with col1:
697
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
698
+
699
+ if not front_df.empty:
700
+ front_df['Category'] = front_df.apply(
701
+ lambda row: f"{'Normal' if row['Alarm Status'] == 'No Alarm' else 'Red Pressure'} Front Tyre", axis=1
702
+ )
703
+ categories = ["Normal Front Tyre", "Red Pressure Front Tyre"]
704
+ front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
705
+
706
+ # Filter valid data
707
+ valid_data = front_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
708
+ if len(valid_data) > 1:
709
+ X = valid_data[['Temperature (°C)']]
710
+ y = valid_data['Pressure (psi)']
711
+ model = LinearRegression().fit(X, y)
712
+ x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
713
+ y_line = model.predict(x_line)
714
+ corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
715
+
716
+ fig1 = px.scatter(
717
+ valid_data,
718
+ x='Temperature (°C)',
719
+ y='Pressure (psi)',
720
+ color='Category',
721
+ color_discrete_map={
722
+ "Normal Front Tyre": "#2E7D32",
723
+ "Red Pressure Front Tyre": "#D32F2F"
724
+ },
725
+ category_orders={'Category': categories},
726
+ template="plotly_white",
727
+ labels={'Temperature (°C)': 'Temperature (°C)', 'Pressure (psi)': 'Pressure (psi)'}
728
+ )
729
+
730
+ fig1.update_traces(
731
+ hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
732
+ marker=dict(size=6)
733
+ )
734
+
735
+ fig1.add_trace(go.Scatter(
736
+ x=x_line.flatten(), y=y_line,
737
+ mode='lines', name='Trend Line',
738
+ line=dict(color='#1976D2', dash='dot', width=2)
739
+ ))
740
+
741
+ fig1.update_layout(
742
+ margin=dict(t=40),
743
+ annotations=[
744
+ dict(
745
+ x=0.95, y=0.95,
746
+ xref="paper", yref="paper",
747
+ text=f"r = {corr:.2f}",
748
+ showarrow=False,
749
+ bgcolor="white",
750
+ bordercolor="black",
751
+ borderwidth=1,
752
+ font=dict(color="black")
753
+ )
754
+ ],
755
+ legend=dict(
756
+ title_text='Tyre Status',
757
+ bgcolor="white",
758
+ bordercolor="lightgray",
759
+ borderwidth=1,
760
+ itemclick=False,
761
+ itemdoubleclick=False
762
+ )
763
+ )
764
+ st.plotly_chart(fig1, use_container_width=True)
765
+ else:
766
+ st.warning("Insufficient data for front tyres.")
767
+ else:
768
+ st.warning("No front tyre data.")
769
+
770
+ # =============== COL 2: Front — Temperature vs Speed ===============
771
+ with col2:
772
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Front Tyres: Temperature → Speed</h5>', unsafe_allow_html=True)
773
+
774
+ if not front_df.empty:
775
+ front_df['Category'] = front_df.apply(
776
+ lambda row: f"{'Normal' if row['Alarm Status'] == 'No Alarm' else 'Red Pressure'} Front Tyre", axis=1
777
+ )
778
+ categories = ["Normal Front Tyre", "Red Pressure Front Tyre"]
779
+ front_df['Category'] = pd.Categorical(front_df['Category'], categories=categories, ordered=True)
780
+
781
+ valid_data = front_df.dropna(subset=['Temperature (°C)', 'Speed (km/h)'])
782
+ if len(valid_data) > 1:
783
+ X = valid_data[['Temperature (°C)']]
784
+ y = valid_data['Speed (km/h)']
785
+ model = LinearRegression().fit(X, y)
786
+ x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
787
+ y_line = model.predict(x_line)
788
+ corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Speed (km/h)'])[0, 1]
789
+
790
+ fig2 = px.scatter(
791
+ valid_data,
792
+ x='Temperature (°C)',
793
+ y='Speed (km/h)',
794
+ color='Category',
795
+ color_discrete_map={
796
+ "Normal Front Tyre": "#2E7D32",
797
+ "Red Pressure Front Tyre": "#D32F2F"
798
+ },
799
+ category_orders={'Category': categories},
800
+ template="plotly_white"
801
+ )
802
+
803
+ fig2.update_traces(
804
+ hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Speed: %{y:.1f} km/h<extra></extra>",
805
+ marker=dict(size=6)
806
+ )
807
+
808
+ fig2.add_trace(go.Scatter(
809
+ x=x_line.flatten(), y=y_line,
810
+ mode='lines', name='Trend Line',
811
+ line=dict(color='#1976D2', dash='dot', width=2)
812
+ ))
813
+
814
+ fig2.update_layout(
815
+ margin=dict(t=40),
816
+ annotations=[
817
+ dict(
818
+ x=0.95, y=0.95,
819
+ xref="paper", yref="paper",
820
+ text=f"r = {corr:.2f}",
821
+ showarrow=False,
822
+ bgcolor="white",
823
+ bordercolor="black",
824
+ borderwidth=1,
825
+ font=dict(color="black")
826
+ )
827
+ ],
828
+ legend=dict(
829
+ title_text='Tyre Status',
830
+ bgcolor="white",
831
+ bordercolor="lightgray",
832
+ borderwidth=1,
833
+ itemclick=False,
834
+ itemdoubleclick=False
835
+ )
836
+ )
837
+ st.plotly_chart(fig2, use_container_width=True)
838
+ else:
839
+ st.warning("Insufficient data for front tyres.")
840
+ else:
841
+ st.warning("No front tyre data.")
842
+
843
+ # =============== COL 3: Rear — Temperature → Pressure ===============
844
+ col3, col4 = st.columns(2)
845
+
846
+ with col3:
847
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Temperature → Pressure</h5>', unsafe_allow_html=True)
848
+
849
+ if not rear_df.empty:
850
+ rear_df['Category'] = rear_df.apply(
851
+ lambda row: f"{'Normal' if row['Alarm Status'] == 'No Alarm' else 'Red Pressure'} Rear Tyre", axis=1
852
+ )
853
+ categories = ["Normal Rear Tyre", "Red Pressure Rear Tyre"]
854
+ rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
855
+
856
+ valid_data = rear_df.dropna(subset=['Temperature (°C)', 'Pressure (psi)'])
857
+ if len(valid_data) > 1:
858
+ X = valid_data[['Temperature (°C)']]
859
+ y = valid_data['Pressure (psi)']
860
+ model = LinearRegression().fit(X, y)
861
+ x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
862
+ y_line = model.predict(x_line)
863
+ corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Pressure (psi)'])[0, 1]
864
+
865
+ fig3 = px.scatter(
866
+ valid_data,
867
+ x='Temperature (°C)',
868
+ y='Pressure (psi)',
869
+ color='Category',
870
+ color_discrete_map={
871
+ "Normal Rear Tyre": "#2E7D32",
872
+ "Red Pressure Rear Tyre": "#D32F2F"
873
+ },
874
+ category_orders={'Category': categories},
875
+ template="plotly_white"
876
+ )
877
+
878
+ fig3.update_traces(
879
+ hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Pressure: %{y:.1f} psi<extra></extra>",
880
+ marker=dict(size=6)
881
+ )
882
+
883
+ fig3.add_trace(go.Scatter(
884
+ x=x_line.flatten(), y=y_line,
885
+ mode='lines', name='Trend Line',
886
+ line=dict(color='#1976D2', dash='dot', width=2)
887
+ ))
888
+
889
+ fig3.update_layout(
890
+ margin=dict(t=40),
891
+ annotations=[
892
+ dict(
893
+ x=0.95, y=0.95,
894
+ xref="paper", yref="paper",
895
+ text=f"r = {corr:.2f}",
896
+ showarrow=False,
897
+ bgcolor="white",
898
+ bordercolor="black",
899
+ borderwidth=1,
900
+ font=dict(color="black")
901
+ )
902
+ ],
903
+ legend=dict(
904
+ title_text='Tyre Status',
905
+ bgcolor="white",
906
+ bordercolor="lightgray",
907
+ borderwidth=1,
908
+ itemclick=False,
909
+ itemdoubleclick=False
910
+ )
911
+ )
912
+ st.plotly_chart(fig3, use_container_width=True)
913
+ else:
914
+ st.warning("Insufficient data for rear tyres.")
915
+ else:
916
+ st.warning("No rear tyre data.")
917
+
918
+ # =============== COL 4: Rear — Temperature vs Speed ===============
919
+ with col4:
920
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Rear Tyres: Temperature → Speed</h5>', unsafe_allow_html=True)
921
+
922
+ if not rear_df.empty:
923
+ rear_df['Category'] = rear_df.apply(
924
+ lambda row: f"{'Normal' if row['Alarm Status'] == 'No Alarm' else 'Red Pressure'} Rear Tyre", axis=1
925
+ )
926
+ categories = ["Normal Rear Tyre", "Red Pressure Rear Tyre"]
927
+ rear_df['Category'] = pd.Categorical(rear_df['Category'], categories=categories, ordered=True)
928
+
929
+ valid_data = rear_df.dropna(subset=['Temperature (°C)', 'Speed (km/h)'])
930
+ if len(valid_data) > 1:
931
+ X = valid_data[['Temperature (°C)']]
932
+ y = valid_data['Speed (km/h)']
933
+ model = LinearRegression().fit(X, y)
934
+ x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
935
+ y_line = model.predict(x_line)
936
+ corr = np.corrcoef(valid_data['Temperature (°C)'], valid_data['Speed (km/h)'])[0, 1]
937
+
938
+ fig4 = px.scatter(
939
+ valid_data,
940
+ x='Temperature (°C)',
941
+ y='Speed (km/h)',
942
+ color='Category',
943
+ color_discrete_map={
944
+ "Normal Rear Tyre": "#2E7D32",
945
+ "Red Pressure Rear Tyre": "#D32F2F"
946
+ },
947
+ category_orders={'Category': categories},
948
+ template="plotly_white"
949
+ )
950
+
951
+ fig4.update_traces(
952
+ hovertemplate="<b>%{marker.color}</b><br>Temp: %{x:.1f}°C<br>Speed: %{y:.1f} km/h<extra></extra>",
953
+ marker=dict(size=6)
954
+ )
955
+
956
+ fig4.add_trace(go.Scatter(
957
+ x=x_line.flatten(), y=y_line,
958
+ mode='lines', name='Trend Line',
959
+ line=dict(color='#1976D2', dash='dot', width=2)
960
+ ))
961
+
962
+ fig4.update_layout(
963
+ margin=dict(t=40),
964
+ annotations=[
965
+ dict(
966
+ x=0.95, y=0.95,
967
+ xref="paper", yref="paper",
968
+ text=f"r = {corr:.2f}",
969
+ showarrow=False,
970
+ bgcolor="white",
971
+ bordercolor="black",
972
+ borderwidth=1,
973
+ font=dict(color="black")
974
+ )
975
+ ],
976
+ legend=dict(
977
+ title_text='Tyre Status',
978
+ bgcolor="white",
979
+ bordercolor="lightgray",
980
+ borderwidth=1,
981
+ itemclick=False,
982
+ itemdoubleclick=False
983
+ )
984
+ )
985
+ st.plotly_chart(fig4, use_container_width=True)
986
+ else:
987
+ st.warning("Insufficient data for rear tyres.")
988
+ else:
989
+ st.warning("No rear tyre data.")
990
+
991
+ # =============== INSIGHT 3 ===============
992
+ # Compute correlations safely
993
+ def safe_corr(a, b):
994
+ mask = ~(np.isnan(a) | np.isnan(b))
995
+ if mask.sum() < 2:
996
+ return 0.0
997
+ return np.corrcoef(a[mask], b[mask])[0, 1]
998
+
999
+ corr_p_t_front = safe_corr(front_df['Temperature (°C)'], front_df['Pressure (psi)'])
1000
+ corr_t_s_front = safe_corr(front_df['Temperature (°C)'], front_df['Speed (km/h)'])
1001
+ corr_p_t_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Pressure (psi)'])
1002
+ corr_t_s_rear = safe_corr(rear_df['Temperature (°C)'], rear_df['Speed (km/h)'])
1003
+
1004
+ insight_text = f"""
1005
+ 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.
1006
+ """
1007
+
1008
+ st.markdown(f"""
1009
+ <div class="insight-box">
1010
+ <div class="content">
1011
+ {insight_text.strip()}
1012
+ </div>
1013
+ </div>
1014
+ """, unsafe_allow_html=True)
1015
+ # ================= OBJECTIVE 4 =================
1016
+ st.markdown('<h3 class="objective-title">OBJECTIVE 4: Spatial Risk Mapping — Where Do Red Pressure Alarms Occur Most Frequently?</h3>', unsafe_allow_html=True)
1017
+
1018
+ st.markdown('<h5 style="text-align:center; margin-top: 0;">Tyre Alarms Distribution by Location</h5>', unsafe_allow_html=True)
1019
+
1020
+ valid_gps = dff.dropna(subset=['Latitude_y', 'Longitude_y'])
1021
+ if valid_gps.empty:
1022
+ st.warning("No valid GNSS coordinates for selected filters.")
1023
+ else:
1024
+ center_lat = valid_gps['Latitude_y'].mean()
1025
+ center_lon = valid_gps['Longitude_y'].mean()
1026
+ m = folium.Map(
1027
+ location=[center_lat, center_lon],
1028
+ zoom_start=16,
1029
+ tiles='CartoDB positron',
1030
+ width='100%',
1031
+ height='520px'
1032
+ )
1033
+
1034
+ for _, r in valid_gps.iterrows():
1035
+ color = '#D32F2F' if r['Alarm Status'] == 'Red High Pressure' else '#2E7D32'
1036
+ radius = 6 + (r['Temperature (°C)'] - valid_gps['Temperature (°C)'].min()) / (valid_gps['Temperature (°C)'].max() - valid_gps['Temperature (°C)'].min() + 1e-5) * 12
1037
+ popup_html = f"""
1038
+ <div style="font-family:Segoe UI; font-size:13px; line-height:1.4">
1039
+ <b>SN:</b> {r['TyreSN']} | Pos: {int(r['Position'])}<br>
1040
+ <b>Zone:</b> {r['Zone']}<br>
1041
+ <b>Press:</b> {r['Pressure (psi)']:.1f} psi<br>
1042
+ <b>Temp:</b> {r['Temperature (°C)']:.1f} °C<br>
1043
+ <b>Speed:</b> {r['Speed (km/h)']:.1f} km/h<br>
1044
+ <b>Alarm:</b> {r['Alarm Status']}
1045
+ </div>
1046
+ """
1047
+ folium.CircleMarker(
1048
+ location=[r['Latitude_y'], r['Longitude_y']],
1049
+ radius=radius,
1050
+ color=color,
1051
+ fill=True,
1052
+ fill_color=color,
1053
+ fill_opacity=0.75,
1054
+ weight=1,
1055
+ popup=folium.Popup(popup_html, max_width=250)
1056
+ ).add_to(m)
1057
+
1058
+ # Legend
1059
+ legend_html = '''
1060
+ <div style="
1061
+ position: fixed;
1062
+ bottom: 60px; right: 20px;
1063
+ background: white;
1064
+ border: 1px solid #E9ECEF;
1065
+ border-radius: 10px;
1066
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
1067
+ padding: 12px;
1068
+ font-family: Segoe UI;
1069
+ font-size: 13px;
1070
+ z-index: 9999;
1071
+ ">
1072
+ <b>Legend</b><br>
1073
+ <span style="color:#2E7D32">●</span> Normal (No Alarm)<br>
1074
+ <span style="color:#D32F2F">●</span> Red Pressure<br>
1075
+ <span style="color:#1976D2">▲</span> Front Tyre<br>
1076
+ <span style="color:#1976D2">★</span> Rear Tyre<br>
1077
+ <i>Size ∝ Temperature</i>
1078
+ </div>
1079
+ '''
1080
+ m.get_root().html.add_child(folium.Element(legend_html))
1081
+
1082
+ st_folium(m, width='100%', height=520, returned_objects=[])
1083
+
1084
+ # Insight 4
1085
+ # Analisis data untuk menentukan pola spasial
1086
+ if not valid_gps.empty:
1087
+ # Hitung jumlah alarm per zona
1088
+ zone_counts = valid_gps[valid_gps['is_alarm'] == 1]['Zone'].value_counts()
1089
+ if not zone_counts.empty:
1090
+ top_zone = zone_counts.index[0]
1091
+ top_zone_count = zone_counts.iloc[0]
1092
+ total_alarms = valid_gps[valid_gps['is_alarm'] == 1].shape[0]
1093
+ percentage = (top_zone_count / total_alarms) * 100
1094
+ else:
1095
+ top_zone = "N/A"
1096
+ percentage = 0
1097
+
1098
+ # Hitung jumlah alarm per posisi (front vs rear)
1099
+ front_alarms = valid_gps[(valid_gps['is_alarm'] == 1) & (valid_gps['Position'].isin([1, 2]))].shape[0]
1100
+ rear_alarms = valid_gps[(valid_gps['is_alarm'] == 1) & (valid_gps['Position'].isin([3, 4]))].shape[0]
1101
+ total_alarms = front_alarms + rear_alarms
1102
+ if total_alarms > 0:
1103
+ front_percentage = (front_alarms / total_alarms) * 100
1104
+ else:
1105
+ front_percentage = 0
1106
+
1107
+ insight_text = f"""
1108
+ Alarm concentration is highest in {top_zone}, with {top_zone_count} alarms representing {percentage:.1f}% of total alarms.
1109
+ Front tyres account for {front_percentage:.1f}% of all alarms, indicating a higher alarm occurrence compared to rear tyres.
1110
+ GNSS data confirms alarm clustering within specific operational zones. Alarm events are concentrated by location and tyre position based on observed data distribution.
1111
+ """
1112
+ else:
1113
+ insight_text = """
1114
+ No valid GNSS data available for analysis.
1115
+ """
1116
+
1117
+ st.markdown(f"""
1118
+ <div class="insight-box">
1119
+ <div class="content">
1120
+ {insight_text.strip()}
1121
+ </div>
1122
+ </div>
1123
+ """, unsafe_allow_html=True)
1124
+ # ================= OBJECTIVE 5 =================
1125
+ # ================= OBJECTIVE 5 =================
1126
+ st.markdown('<h3 class="objective-title">OBJECTIVE 5: Insights & Mitigation — How Can Red Pressure Alarms Be Reduced?</h3>', unsafe_allow_html=True)
1127
+
1128
+ # --- DATA PREP ---
1129
+ front_pressure_avg = dff[dff['Position'].isin([1, 2])]['Pressure (psi)'].mean()
1130
+ front_temp_avg = dff[dff['Position'].isin([1, 2])]['Temperature (°C)'].mean()
1131
+
1132
+ hourly_counts = dff[dff['is_alarm'] == 1]['hour'].value_counts().reindex(range(24), fill_value=0)
1133
+ dominant_hour = hourly_counts.idxmax() if len(hourly_counts) > 0 else "N/A"
1134
+ total_alarms = hourly_counts.sum()
1135
+ dominant_percentage = (hourly_counts[dominant_hour] / total_alarms) * 100 if total_alarms > 0 else 0
1136
+
1137
+ zone_counts = dff[dff['is_alarm'] == 1]['Zone'].value_counts()
1138
+ top_zone = zone_counts.index[0] if not zone_counts.empty else "N/A"
1139
+ top_zone_percentage = (zone_counts.iloc[0] / total_alarms) * 100 if total_alarms > 0 else 0
1140
+
1141
+ # Correlation analysis
1142
+ front_df = dff[dff['Position'].isin([1, 2])]
1143
+ rear_df = dff[dff['Position'].isin([3, 4])]
1144
+
1145
+ if not front_df.empty and len(front_df[['Pressure (psi)']].dropna()) > 1 and len(front_df[['Temperature (°C)']].dropna()) > 1:
1146
+ corr_front = np.corrcoef(front_df['Pressure (psi)'], front_df['Temperature (°C)'])[0,1]
1147
+ else:
1148
+ corr_front = 0
1149
+
1150
+ if not rear_df.empty and len(rear_df[['Speed (km/h)']].dropna()) > 1 and len(rear_df[['Temperature (°C)']].dropna()) > 1:
1151
+ corr_rear = np.corrcoef(rear_df['Speed (km/h)'], rear_df['Temperature (°C)'])[0,1]
1152
+ else:
1153
+ corr_rear = 0
1154
+
1155
+ # Insight
1156
+ 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).
1157
+ <br>
1158
+ 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).
1159
+ <br>
1160
+ 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).
1161
+ <br>
1162
+ 4. {top_zone} contains {top_zone_percentage:.1f}% of all alarms, confirmed as a high-risk hotspot through GNSS data (Objective 4)."""
1163
+
1164
+
1165
+ try:
1166
+ import requests
1167
+ import json
1168
+
1169
+ API_URL = "https://api-inference.huggingface.co/models/HuggingFaceH4/zephyr-7b-beta "
1170
+
1171
+ prompt = f"""
1172
+ Role: Fleet Operations Risk Analyst
1173
+
1174
+ Insights:
1175
+ - High-risk zone: {top_zone} ({top_zone_percentage:.1f}% of alarms)
1176
+ - Front tyres: 62% of total alarms
1177
+ - Peak alarm hour: {dominant_hour}:00 ({dominant_percentage:.1f}%)
1178
+ - Front tyre pressure–temperature correlation r = {corr_front:.2f}
1179
+
1180
+ Task:
1181
+ Generate:
1182
+ 1. Business Recommendations
1183
+ 2. Risk Mitigation Actions
1184
+
1185
+ Rules:
1186
+ - Use only provided insights
1187
+ - No root-cause speculation
1188
+ - Business-ready language
1189
+ """
1190
+
1191
+ payload = {
1192
+ "inputs": prompt,
1193
+ "parameters": {
1194
+ "max_new_tokens": 25000,
1195
+ "temperature": 0.8,
1196
+ "top_p": 0.9
1197
+ }
1198
+ }
1199
+
1200
+ response = requests.post(API_URL, json=payload)
1201
+ generated_text = response.json()[0]["generated_text"]
1202
+
1203
+ # Pisahkan recommendation dan risk mitigation dari generated text
1204
+ # Di sini Anda bisa menambahkan kode untuk memisahkan recommendation dan risk mitigation dari generated text
1205
+ # Contoh: recommendation_text, risk_mitigation_text = generated_text.split("Risk Mitigation")
1206
+ recommendation_text = generated_text
1207
+ risk_mitigation_text = generated_text
1208
+
1209
+ # Jika response kosong, gunakan versi manual
1210
+ if recommendation_text == "":
1211
+ recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation (Objective 1).
1212
+ <br>
1213
+ 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone} to reduce alarm frequency (Objective 2).
1214
+ <br>
1215
+ 3. Monitor pressure and temperature correlation in front tyres (r = {corr_front:.2f}) to prevent overheating and premature wear (Objective 3).
1216
+ <br>
1217
+ 4. Restrict vehicle access to {top_zone} until pavement maintenance is completed, as it contributes to {top_zone_percentage:.1f}% of alarms (Objective 4)."""
1218
+ if risk_mitigation_text == "":
1219
+ risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating (Objective 1).
1220
+ <br>
1221
+ 2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur (Objective 2).
1222
+ <br>
1223
+ 3. Introduce predictive maintenance for front tyres with correlation r = {corr_front:.2f} to prevent unplanned downtime (Objective 3).
1224
+ <br>
1225
+ 4. Implement real-time monitoring in {top_zone} where {top_zone_percentage:.1f}% of alarms are concentrated (Objective 4)."""
1226
+ except:
1227
+ # Jika response dari model kosong atau gagal, gunakan versi manual
1228
+ recommendation_text = f"""1. Calibrate front tyre pressure regularly to maintain optimal {front_pressure_avg:.1f} psi and prevent over-inflation (Objective 1).
1229
+ <br>
1230
+ 2. Implement operational restrictions during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) in {top_zone} to reduce alarm frequency (Objective 2).
1231
+ <br>
1232
+ 3. Monitor pressure and temperature correlation in front tyres (r = {corr_front:.2f}) to prevent overheating and premature wear (Objective 3).
1233
+ <br>
1234
+ 4. Restrict vehicle access to {top_zone} until pavement maintenance is completed, as it contributes to {top_zone_percentage:.1f}% of alarms (Objective 4)."""
1235
+ # Risk Mitigation
1236
+ risk_mitigation_text = f"""1. Adjust front tyre load distribution to reduce {front_temp_avg:.1f}°C temperature and prevent overheating (Objective 1).
1237
+ <br>
1238
+ 2. Schedule additional inspections during peak hours ({dominant_hour}:00–{(dominant_hour+1)%24}:00) when {dominant_percentage:.1f}% of alarms occur (Objective 2).
1239
+ <br>
1240
+ 3. Introduce predictive maintenance for front tyres with correlation r = {corr_front:.2f} to prevent unplanned downtime (Objective 3).
1241
+ <br>
1242
+ 4. Implement real-time monitoring in {top_zone} where {top_zone_percentage:.1f}% of alarms are concentrated (Objective 4)."""
1243
+
1244
+ # ============== SUBHEADER + BOX 1: INSIGHT ==============
1245
+ st.markdown('<h4 style="text-align:center; margin:10px 0 5px 0; font-weight:bold;">INSIGHT</h4>', unsafe_allow_html=True)
1246
+ st.markdown(f"""
1247
+ <div class="insight-box">
1248
+ <div class="content">
1249
+ {insight_text.strip()}
1250
+ </div>
1251
+ </div>
1252
+ """, unsafe_allow_html=True)
1253
+
1254
+ # ============== SUBHEADER + BOX 2: RECOMMENDATION ==============
1255
+ st.markdown('<h4 style="text-align:center; margin:15px 0 5px 0; font-weight:bold;">RECOMMENDATION</h4>', unsafe_allow_html=True)
1256
+ st.markdown(f"""
1257
+ <div class="insight-box">
1258
+ <div class="content">
1259
+ {recommendation_text.strip()}
1260
+ </div>
1261
+ </div>
1262
+ """, unsafe_allow_html=True)
1263
+
1264
+ # ============== SUBHEADER + BOX 3: RISK MITIGATION ==============
1265
+ st.markdown('<h4 style="text-align:center; margin:15px 0 5px 0; font-weight:bold;">RISK MITIGATION</h4>', unsafe_allow_html=True)
1266
+ st.markdown(f"""
1267
+ <div class="insight-box">
1268
+ <div class="content">
1269
+ {risk_mitigation_text.strip()}
1270
+ </div>
1271
+ </div>
1272
+ """, unsafe_allow_html=True)
1273
+
1274
+ # ================= FOOTER =================
1275
+ st.markdown("""
1276
+ <div class="footer">
1277
+ Michelin Mining Tyre Analytics
1278
+ </div>
1279
+ """, unsafe_allow_html=True)
df_final.xlsx ADDED
Binary file (31.3 kB). View file
 
gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ logo.png filter=lfs diff=lfs merge=lfs -text
logo.png ADDED

Git LFS Details

  • SHA256: 36431ef7825f68b49270fdb0948fa0079689cdf9dbf1feedff7a893f4ac4ce4d
  • Pointer size: 131 Bytes
  • Size of remote file: 479 kB
requirements.txt CHANGED
@@ -1,3 +1,12 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
1
+ streamlit==1.39.0
2
+ pandas==2.2.2
3
+ numpy==1.26.4
4
+ openpyxl==3.1.5
5
+ plotly==5.22.0
6
+ folium==0.17.0
7
+ streamlit-folium==0.17.0
8
+ torch==2.1.0
9
+ transformers==4.35.0
10
+ accelerate==0.24.0
11
+ sentencepiece==0.2.0
12
+ scikit-learn==1.3.0