ishaq101 commited on
Commit
fc4339d
Β·
1 Parent(s): b60b43d

update att

Browse files
.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
+ *.xlsx* filter=lfs diff=lfs merge=lfs -text
src/bad_actor_simulation.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dd6875d023d101d090a6aa3b4f1ac6e7b26329447c686d78d1e96d17cbf613ef
3
+ size 1212447
src/streamlit_app.py CHANGED
@@ -1,40 +1,490 @@
1
- import altair as alt
2
- import numpy as np
3
  import pandas as pd
 
4
  import streamlit as st
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import pandas as pd
2
+ import numpy as np
3
  import streamlit as st
4
 
5
+ st.set_page_config(
6
+ page_title="Bad Actor Simulation",
7
+ page_icon="⚠️",
8
+ layout="wide",
9
+ initial_sidebar_state="expanded",
10
+ )
11
+
12
+ st.markdown("""
13
+ <style>
14
+ /* ── Section card headers ─────────────────────────────────────────────────── */
15
+ .section-card {
16
+ background: #f8f9fa;
17
+ border-left: 4px solid #e63946;
18
+ border-radius: 6px;
19
+ padding: 10px 16px;
20
+ margin: 16px 0 8px 0;
21
+ }
22
+ .section-card h3 { margin: 0; font-size: 1.05rem; font-weight: 700; color: #1d3557; }
23
+ .section-card .sub { font-size: 0.78rem; color: #6c757d; margin-top: 3px; }
24
+
25
+ /* ── KPI metric cards ─────────────────────────────────────────────────────── */
26
+ [data-testid="metric-container"] {
27
+ background: #ffffff;
28
+ border: 1px solid #e9ecef;
29
+ border-radius: 10px;
30
+ padding: 14px 18px !important;
31
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
32
+ }
33
+ [data-testid="stMetricValue"] { color: #1d3557; font-weight: 700; }
34
+ [data-testid="stMetricLabel"] { color: #6c757d; font-size: 0.82rem; }
35
+
36
+ /* ── Welcome card ─────────────────────────────────────────────────────────── */
37
+ .welcome-card {
38
+ background: linear-gradient(135deg, #1d3557 0%, #457b9d 100%);
39
+ border-radius: 12px;
40
+ padding: 32px 36px;
41
+ color: white;
42
+ margin-bottom: 24px;
43
+ }
44
+ .welcome-card h2 { margin: 0 0 8px 0; font-size: 1.4rem; color: white; }
45
+ .welcome-card p { margin: 0 0 20px 0; color: rgba(255,255,255,0.8); font-size: 0.9rem; }
46
+ .step-list { list-style: none; padding: 0; margin: 0; }
47
+ .step-list li {
48
+ display: flex; align-items: center; gap: 10px;
49
+ padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.15);
50
+ color: rgba(255,255,255,0.9); font-size: 0.88rem;
51
+ }
52
+ .step-list li:last-child { border-bottom: none; }
53
+ .step-num {
54
+ background: #e63946; color: white; font-weight: 700;
55
+ border-radius: 50%; width: 22px; height: 22px;
56
+ display: flex; align-items: center; justify-content: center;
57
+ font-size: 0.75rem; flex-shrink: 0;
58
+ }
59
+
60
+ /* ── App header ───────────────────────────────────────────────────────────── */
61
+ .app-header { margin-bottom: 4px; }
62
+ .app-header h1 { margin: 0; font-size: 1.7rem; color: #1d3557; font-weight: 800; }
63
+ .app-header .tagline { color: #6c757d; font-size: 0.85rem; margin-top: 2px; }
64
+ .dataset-badge {
65
+ display: inline-block;
66
+ background: #e9ecef; color: #495057;
67
+ border-radius: 20px; padding: 4px 12px;
68
+ font-size: 0.78rem; margin-top: 4px;
69
+ }
70
+
71
+ /* ── Sidebar ──────────────────────────────────────────────────────────────── */
72
+ [data-testid="stSidebar"] { background: #f8f9fa; }
73
+ [data-testid="stSidebar"] .stButton > button {
74
+ background: #e63946 !important; color: white !important;
75
+ border: none !important; font-weight: 600 !important;
76
+ letter-spacing: 0.3px;
77
+ }
78
+ [data-testid="stSidebar"] .stButton > button:hover {
79
+ background: #c1121f !important;
80
+ }
81
+ </style>
82
+ """, unsafe_allow_html=True)
83
+
84
+
85
+ def _section(icon, title, subtitle=""):
86
+ sub_html = f'<div class="sub">{subtitle}</div>' if subtitle else ""
87
+ st.markdown(
88
+ f'<div class="section-card"><h3>{icon}&nbsp; {title}</h3>{sub_html}</div>',
89
+ unsafe_allow_html=True,
90
+ )
91
+
92
+ REQUIRED_COLS = {"year", "month", "site_id", "section", "model", "eqm_no", "MA", "MTBF"}
93
+
94
+ @st.cache_data
95
+ def load_default():
96
+ df = pd.read_excel("bad_actor_simulation.xlsx", sheet_name="data_badactor")
97
+ df.columns = df.columns.str.strip()
98
+ df["model"] = df["model"].astype(str)
99
+ return df
100
+
101
+ @st.cache_data
102
+ def load_csv(data: bytes) -> pd.DataFrame:
103
+ import io
104
+ df = pd.read_csv(io.BytesIO(data))
105
+ df.columns = df.columns.str.strip()
106
+ df["model"] = df["model"].astype(str)
107
+ return df
108
+
109
+ @st.cache_data
110
+ def load_xlsx(data: bytes, sheet: str) -> pd.DataFrame:
111
+ import io
112
+ df = pd.read_excel(io.BytesIO(data), sheet_name=sheet)
113
+ df.columns = df.columns.str.strip()
114
+ df["model"] = df["model"].astype(str)
115
+ return df
116
+
117
+ def get_xlsx_sheets(data: bytes) -> list:
118
+ import io
119
+ xf = pd.ExcelFile(io.BytesIO(data))
120
+ return xf.sheet_names
121
+
122
+ # ── Helpers ────────────────────────────────────────────────────────────────────
123
+ def minmax_norm(s):
124
+ lo, hi = s.min(), s.max()
125
+ if hi == lo:
126
+ return pd.Series(0.5, index=s.index)
127
+ return (s - lo) / (hi - lo)
128
+
129
+
130
+ def flag_consecutive(group, col_below, col_period, threshold):
131
+ """Mark rows belonging to a consecutive-True run of length >= threshold."""
132
+ g = group.sort_values(col_period)
133
+ below = g[col_below].values
134
+ periods = g[col_period].values
135
+ result = np.zeros(len(g), dtype=bool)
136
+ run_start = None
137
+ for i in range(len(g)):
138
+ if not below[i]:
139
+ run_start = None
140
+ continue
141
+ if run_start is None:
142
+ run_start = i
143
+ elif periods[i] != periods[i - 1] + 1:
144
+ run_start = i
145
+ if (i - run_start + 1) >= threshold:
146
+ result[run_start : i + 1] = True
147
+ return pd.Series(result, index=g.index)
148
+
149
+
150
+ # ── Default filter values (edit here to change the initial widget state) ───────
151
+ DEFAULT_YEARS = [2026]
152
+ DEFAULT_SITES = [2009]
153
+ DEFAULT_SECTIONS = ["OBLOADER"]
154
+ DEFAULT_MODELS = ["6015B", "6020B", "EX2500-5", "EX3600-6", "PC1250SP-7", "PC1250SP-8", "PC2000-8", "PC4000-6"]
155
+ DEFAULT_CONSECUTIVE_N = 2
156
+ DEFAULT_OBS_MONTH = 2
157
+
158
+ def run_simulation(years, sites, sections, models, consecutive_n, obs_month):
159
+ # 1. Filter (obs_month = observation cutoff: only months 1..obs_month)
160
+ mask = (
161
+ df["year"].isin(years) &
162
+ df["site_id"].isin(sites) &
163
+ df["section"].isin(sections) &
164
+ (df["month"] <= obs_month)
165
+ )
166
+ if "ALL" not in models:
167
+ mask &= df["model"].isin(models)
168
+ filt = df[mask].copy()
169
+
170
+ if filt.empty:
171
+ st.warning("No data found for the selected filters.")
172
+ return
173
+
174
+ # basis_key: used for per-model detail stats and Q1
175
+ # agg_key: used for normalization min/max (matches the aggregated Normalisation Reference table)
176
+ basis_key = ["month", "site_id", "section", "model"]
177
+ agg_key = ["month", "site_id", "section"]
178
+
179
+ # 2. Acuan Basis 1 β€” min-max normalise using section-level min/max (agg_key, no model)
180
+ filt["norm_MA"] = filt.groupby(agg_key)["MA"] .transform(minmax_norm)
181
+ filt["norm_MTBF"] = filt.groupby(agg_key)["MTBF"].transform(minmax_norm)
182
+
183
+ # Normalisation reference stats (min, max, avg) for display β€” detail per model
184
+ norm_stats = filt.groupby(basis_key).agg(
185
+ MA_min=("MA", "min"), MA_max=("MA", "max"), MA_avg=("MA", "mean"),
186
+ MTBF_min=("MTBF", "min"), MTBF_max=("MTBF", "max"), MTBF_avg=("MTBF", "mean"),
187
+ ).round(4).reset_index()
188
+
189
+ # 3. Bad actor score
190
+ filt["bad_actor_score"] = filt["norm_MA"] * filt["norm_MTBF"]
191
+
192
+ # 4. Acuan Basis 2 β€” Q1 threshold using the same basis_key
193
+ q1_df = (
194
+ filt.groupby(basis_key)["bad_actor_score"]
195
+ .quantile(0.25)
196
+ .reset_index()
197
+ .rename(columns={"bad_actor_score": "q1_threshold"})
198
+ )
199
+ filt = filt.merge(q1_df, on=basis_key, how="left")
200
+
201
+ # 5. Below-Q1 flag
202
+ filt["below_q1"] = filt["bad_actor_score"] < filt["q1_threshold"]
203
+
204
+ # 6. Consecutive detection (period = year*12 + month for cross-year safety)
205
+ filt["period"] = filt["year"] * 12 + filt["month"]
206
+ filt["is_bad_actor"] = (
207
+ filt.groupby("eqm_no", group_keys=False)
208
+ .apply(flag_consecutive,
209
+ col_below="below_q1",
210
+ col_period="period",
211
+ threshold=consecutive_n)
212
+ )
213
+
214
+ # ── Build bad actor summary ────────────────────────────────────────────────
215
+ bad_ids = filt.loc[filt["is_bad_actor"], "eqm_no"].unique()
216
+
217
+ def _streak(g):
218
+ periods = sorted(g.loc[g["below_q1"], "period"].tolist())
219
+ if not periods:
220
+ return 0
221
+ mx = cur = 1
222
+ for i in range(1, len(periods)):
223
+ cur = cur + 1 if periods[i] == periods[i - 1] + 1 else 1
224
+ mx = max(mx, cur)
225
+ return mx
226
+
227
+ rows = []
228
+ for eid, grp in filt[filt["eqm_no"].isin(bad_ids)].groupby("eqm_no"):
229
+ rows.append({
230
+ "eqm_no" : eid,
231
+ "site_id" : grp["site_id"].iloc[0],
232
+ "section" : grp["section"].iloc[0],
233
+ "model" : grp["model"].iloc[0],
234
+ "flagged_months" : int(grp["below_q1"].sum()),
235
+ "max_streak" : _streak(grp),
236
+ "bad_actor_months": ", ".join(
237
+ str(int(m)) for m in sorted(
238
+ grp.loc[grp["is_bad_actor"], "month"].unique())),
239
+ })
240
+
241
+ summary = (
242
+ pd.DataFrame(rows)
243
+ .sort_values(["section", "max_streak"], ascending=[True, False])
244
+ .reset_index(drop=True)
245
+ )
246
+ summary["last_bad_actor_month"] = summary["bad_actor_months"].apply(
247
+ lambda s: int(s.split(", ")[-1]) if s else None
248
+ )
249
+ summary = summary[summary["last_bad_actor_month"] == obs_month].reset_index(drop=True)
250
+
251
+ # ── KPI row ────────────────────────────────────────────────────────────────
252
+ total_eqm = filt["eqm_no"].nunique()
253
+ n_bad = len(summary)
254
+ rate = n_bad / total_eqm * 100 if total_eqm else 0
255
+ k1, k2, k3 = st.columns(3)
256
+ k1.metric("Equipment Evaluated", f"{total_eqm:,}")
257
+ k2.metric("Bad Actors Detected", f"{n_bad:,}")
258
+ k3.metric("Bad Actor Rate", f"{rate:.1f}%")
259
+ st.divider()
260
+
261
+ # ── Tabs ───────────────────────────────────────────────────────────────────
262
+ tab1, tab2, tab3 = st.tabs([
263
+ "⚠️ Bad Actors",
264
+ "πŸ“Š Reference Basis",
265
+ "πŸ”’ Scored Data",
266
+ ])
267
+
268
+ # ── Tab 1: Bad actor list ──────────────────────────────────────────────────
269
+ with tab1:
270
+ _section("⚠️", "Bad Actor List",
271
+ f"Min {consecutive_n} consecutive month(s) Β· last flagged = Month {obs_month}")
272
+ if summary.empty:
273
+ st.success(f"No bad actors with last flagged month = Month {obs_month}.")
274
+ else:
275
+ st.markdown(
276
+ f'<p style="color:#e63946;font-weight:600;margin:4px 0 12px">'
277
+ f'{n_bad} equipment flagged</p>',
278
+ unsafe_allow_html=True,
279
+ )
280
+ st.dataframe(summary, use_container_width=True)
281
+
282
+ # Bad actor rate per section
283
+ _section("πŸ“ˆ", "Bad Actor Rate by Section")
284
+ for sec, grp in summary.groupby("section"):
285
+ sec_total = filt.loc[filt["section"] == sec, "eqm_no"].nunique()
286
+ sec_rate = len(grp) / sec_total if sec_total else 0
287
+ st.caption(f"{sec} β€” {len(grp)} / {sec_total} ({sec_rate*100:.1f}%)")
288
+ st.progress(sec_rate)
289
+
290
+ # ── Tab 2: Reference basis ─────────────────────────────────────────────────
291
+ with tab2:
292
+ norm_agg = (
293
+ filt.groupby(agg_key).agg(
294
+ MA_min=("MA", "min"), MA_max=("MA", "max"), MA_avg=("MA", "mean"),
295
+ MTBF_min=("MTBF", "min"), MTBF_max=("MTBF", "max"), MTBF_avg=("MTBF", "mean"),
296
+ )
297
+ .round(4)
298
+ .reset_index()
299
+ )
300
+ _section("πŸ“", "Normalisation Reference",
301
+ "min / max / avg of MA & MTBF used for normalization β€” aggregated across models")
302
+ st.dataframe(
303
+ norm_agg.style.format({
304
+ "MA_min": "{:.4f}", "MA_max": "{:.4f}", "MA_avg": "{:.4f}",
305
+ "MTBF_min": "{:.4f}", "MTBF_max": "{:.4f}", "MTBF_avg": "{:.4f}",
306
+ }),
307
+ use_container_width=True,
308
+ )
309
+ with st.expander("Detail per model"):
310
+ st.dataframe(
311
+ norm_stats.style.format({
312
+ "MA_min": "{:.4f}", "MA_max": "{:.4f}", "MA_avg": "{:.4f}",
313
+ "MTBF_min": "{:.4f}", "MTBF_max": "{:.4f}", "MTBF_avg": "{:.4f}",
314
+ }),
315
+ use_container_width=True,
316
+ )
317
+
318
+ pivot_idx = [k for k in basis_key if k not in ("model", "month")]
319
+ q1_pivot = q1_df.pivot_table(
320
+ index=pivot_idx, columns="month", values="q1_threshold", aggfunc="mean"
321
+ ).round(4)
322
+ q1_pivot.columns = [f"Month {int(c)}" for c in q1_pivot.columns]
323
+
324
+ _section("πŸ“‰", "Q1 Threshold Table",
325
+ "25th percentile of bad actor score β€” aggregated across models")
326
+ st.dataframe(q1_pivot, use_container_width=True)
327
+ with st.expander("Detail per model"):
328
+ q1_pivot_detail = q1_df.pivot_table(
329
+ index=[k for k in basis_key if k != "month"],
330
+ columns="month", values="q1_threshold"
331
+ ).round(4)
332
+ q1_pivot_detail.columns = [f"Month {int(c)}" for c in q1_pivot_detail.columns]
333
+ st.dataframe(q1_pivot_detail, use_container_width=True)
334
+
335
+ # ── Tab 3: Scored data ─────────────────────────────────────────────────────
336
+ with tab3:
337
+ _section("πŸ”’", "Scored Data",
338
+ "norm_MA Γ— norm_MTBF = bad_actor_score Β· rows in red = bad actor")
339
+ show_cols = [
340
+ "year", "month", "site_id", "section", "model", "eqm_no",
341
+ "MA", "MTBF", "norm_MA", "norm_MTBF",
342
+ "bad_actor_score", "q1_threshold", "below_q1", "is_bad_actor",
343
+ ]
344
+ scored = (
345
+ filt[show_cols]
346
+ .sort_values(["site_id", "section", "month", "eqm_no"])
347
+ .reset_index(drop=True)
348
+ )
349
+
350
+ def _highlight(row):
351
+ color = "background-color: #ffe0e0" if row["is_bad_actor"] else ""
352
+ return [color] * len(row)
353
+
354
+ st.dataframe(
355
+ scored.style
356
+ .format({
357
+ "norm_MA": "{:.4f}", "norm_MTBF": "{:.4f}",
358
+ "bad_actor_score": "{:.4f}", "q1_threshold": "{:.4f}",
359
+ })
360
+ .apply(_highlight, axis=1),
361
+ use_container_width=True,
362
+ height=420,
363
+ )
364
+
365
+
366
+ # ── Sidebar controls ───────────────────────────────────────────────────────────
367
+ with st.sidebar:
368
+ st.markdown(
369
+ '<p style="font-size:1.15rem;font-weight:800;color:#1d3557;'
370
+ 'border-left:4px solid #e63946;padding-left:10px;margin-bottom:12px">'
371
+ 'Simulation Controls</p>',
372
+ unsafe_allow_html=True,
373
+ )
374
+
375
+ # ── Dataset upload ─────────────────────────────────────────────────────────
376
+ df = None
377
+ data_source = "bad_actor_simulation.xlsx (default)"
378
+
379
+ with st.expander("Dataset", expanded=False):
380
+ uploaded = st.file_uploader(
381
+ "Upload CSV or XLSX (leave empty to use default file)",
382
+ type=["csv", "xlsx"],
383
+ )
384
+
385
+ if uploaded is not None:
386
+ raw = uploaded.read()
387
+ ext = uploaded.name.rsplit(".", 1)[-1].lower()
388
+
389
+ if ext == "csv":
390
+ df = load_csv(raw)
391
+ data_source = uploaded.name
392
+ missing = REQUIRED_COLS - set(df.columns)
393
+ if missing:
394
+ st.error(f"CSV missing columns: {', '.join(sorted(missing))}")
395
+ df = None
396
+
397
+ elif ext == "xlsx":
398
+ sheets = get_xlsx_sheets(raw)
399
+ if len(sheets) == 1:
400
+ sheet = sheets[0]
401
+ else:
402
+ sheet = st.selectbox("Sheet name", sheets)
403
+ df = load_xlsx(raw, sheet)
404
+ data_source = f"{uploaded.name} [sheet: {sheet}]"
405
+ missing = REQUIRED_COLS - set(df.columns)
406
+ if missing:
407
+ st.error(f"XLSX missing columns: {', '.join(sorted(missing))}")
408
+ df = None
409
+
410
+ if df is None:
411
+ df = load_default()
412
+
413
+ st.caption(f"Loaded: {data_source} | {len(df):,} rows")
414
+
415
+ st.divider()
416
+
417
+ # ── Filters ────────────────────────────────────────────────────────────────
418
+ with st.expander("πŸŽ›οΈ Filters", expanded=True):
419
+ all_years = sorted(df["year"].dropna().unique().tolist())
420
+ all_sites = sorted(df["site_id"].dropna().unique().tolist())
421
+ all_sections = sorted(df["section"].dropna().unique().tolist())
422
+ all_models = ["ALL"] + sorted(df["model"].dropna().astype(str).unique().tolist())
423
+
424
+ def _default(lst, vals):
425
+ r = [v for v in vals if v in lst]
426
+ return r if r else lst[:1]
427
+
428
+ sel_years = st.multiselect(
429
+ "πŸ—“οΈ Year(s)", all_years, default=_default(all_years, DEFAULT_YEARS))
430
+
431
+ obs_month = st.slider("πŸ“… Observation Month (cutoff)", 1, 12, DEFAULT_OBS_MONTH,
432
+ help="Only data up to this month is included in the evaluation.")
433
+
434
+ sel_sites = st.multiselect(
435
+ "🏭 Site(s)", all_sites, default=_default(all_sites, DEFAULT_SITES))
436
+
437
+ sel_sections = st.multiselect(
438
+ "πŸ”§ Section(s)", all_sections,
439
+ default=_default(all_sections, DEFAULT_SECTIONS))
440
+
441
+ sel_models = st.multiselect(
442
+ "🚜 Model(s) (ALL = no model filter)",
443
+ all_models, default=_default(all_models, DEFAULT_MODELS))
444
+
445
+ consecutive_n = st.slider("πŸ” Min Consecutive Months", 1, 3, DEFAULT_CONSECUTIVE_N)
446
+
447
+ st.markdown("<br>", unsafe_allow_html=True)
448
+ run = st.button("β–Ά Run Simulation", type="primary", use_container_width=True)
449
+ st.caption("Adjust filters above, then click Run.")
450
+
451
+ # ── Main area ──────────────────────────────────────────────────────────────────
452
+ hcol1, hcol2 = st.columns([3, 1])
453
+ with hcol1:
454
+ st.markdown(
455
+ '<div class="app-header">'
456
+ '<h1>⚠️ Bad Actor Simulation</h1>'
457
+ '<div class="tagline">Equipment reliability scoring based on normalised MA Γ— MTBF</div>'
458
+ '</div>',
459
+ unsafe_allow_html=True,
460
+ )
461
+ with hcol2:
462
+ st.markdown(
463
+ f'<div style="text-align:right;padding-top:8px">'
464
+ f'<span class="dataset-badge">πŸ“ {data_source}</span><br>'
465
+ f'<span class="dataset-badge" style="margin-top:4px;display:inline-block">'
466
+ f'πŸ“… Month 1 – {obs_month}</span>'
467
+ f'</div>',
468
+ unsafe_allow_html=True,
469
+ )
470
+
471
+ st.divider()
472
+
473
+ if run:
474
+ if not sel_years or not sel_sites or not sel_sections or not sel_models:
475
+ st.warning("Please select at least one value for each filter.")
476
+ else:
477
+ run_simulation(sel_years, sel_sites, sel_sections, sel_models,
478
+ consecutive_n, obs_month)
479
+ else:
480
+ st.markdown("""
481
+ <div class="welcome-card">
482
+ <h2>Welcome to the Simulation Console</h2>
483
+ <p>Identify equipment that consistently underperforms relative to its peers.</p>
484
+ <ul class="step-list">
485
+ <li><span class="step-num">1</span> Open <strong>Dataset</strong> in the sidebar to upload a CSV or XLSX file, or use the default.</li>
486
+ <li><span class="step-num">2</span> Expand <strong>Filters</strong> to set year, site, section, model, and observation month.</li>
487
+ <li><span class="step-num">3</span> Click <strong>Run Simulation</strong> to compute scores and flag bad actors.</li>
488
+ </ul>
489
+ </div>
490
+ """, unsafe_allow_html=True)