DavMelchi commited on
Commit
bd51456
·
1 Parent(s): a8899bd

Add comprehensive site-level KPI analysis with heatmaps, distribution histograms, traffic-based filtering, and preset management functionality

Browse files
documentations/kpi_health_check_plan.md CHANGED
@@ -205,16 +205,16 @@ Objectif: **app modulaire**, pas un fichier monolithique.
205
  - [DONE] RESOLVED (dégradé puis OK)
206
  - [DONE] Support ZIP multi-CSV
207
  - [N/A] Support “cell-level” vs “site-level” (switch) (KPI confirmés par site)
208
- - [TODO] Score de criticité (pondérer par trafic, population, criticité client)
209
  - [DONE] Table “Top anomalies” multi-RAT (cross-RAT)
210
- - [TODO] Visualisations avancées (heatmap par jour, histogrammes, etc.)
211
 
212
  ### V3 (industrialisation)
213
 
214
- - [TODO] Presets de règles par opérateur
215
  - [TODO] Gestion profils / sauvegarde de configuration
216
- - [TODO] Import automatique de “liste des sites plaintes"
217
- - [TODO] Génération PDF (optionnel) et pack de preuves
218
 
219
  ## 8) Points ouverts à confirmer
220
 
 
205
  - [DONE] RESOLVED (dégradé puis OK)
206
  - [DONE] Support ZIP multi-CSV
207
  - [N/A] Support “cell-level” vs “site-level” (switch) (KPI confirmés par site)
208
+ - [DONE] Score de criticité (pondération trafic OK avec conversion 2G MB -> GB; pas de données population/criticité client)
209
  - [DONE] Table “Top anomalies” multi-RAT (cross-RAT)
210
+ - [DONE] Visualisations avancées (heatmap par jour, histogrammes, etc.)
211
 
212
  ### V3 (industrialisation)
213
 
214
+ - [DONE] Presets de règles (JSON local) + sauvegarde/chargement dans l'UI
215
  - [TODO] Gestion profils / sauvegarde de configuration
216
+ - [TODO] Import automatique de “liste des sites plaintes
217
+ - [N/A] Génération PDF (optionnel) et pack de preuves
218
 
219
  ## 8) Points ouverts à confirmer
220
 
panel_app/kpi_health_check_panel.py CHANGED
@@ -1,17 +1,23 @@
1
  import io
2
  import os
3
  import sys
4
- from datetime import date
5
 
 
6
  import pandas as pd
7
  import panel as pn
8
  import plotly.express as px
 
9
 
10
  ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
  if ROOT_DIR not in sys.path:
12
  sys.path.insert(0, ROOT_DIR)
13
 
14
- from process_kpi.kpi_health_check.engine import evaluate_health_check
 
 
 
 
15
  from process_kpi.kpi_health_check.export import build_export_bytes
16
  from process_kpi.kpi_health_check.io import read_bytes_to_df
17
  from process_kpi.kpi_health_check.multi_rat import compute_multirat_views
@@ -20,6 +26,12 @@ from process_kpi.kpi_health_check.normalization import (
20
  infer_date_col,
21
  infer_id_col,
22
  )
 
 
 
 
 
 
23
  from process_kpi.kpi_health_check.rules import infer_kpi_direction, infer_kpi_sla
24
 
25
  pn.extension("plotly", "tabulator")
@@ -39,7 +51,9 @@ current_rules_df: pd.DataFrame | None = None
39
  current_status_df: pd.DataFrame | None = None
40
  current_summary_df: pd.DataFrame | None = None
41
  current_multirat_df: pd.DataFrame | None = None
 
42
  current_top_anomalies_df: pd.DataFrame | None = None
 
43
  current_export_bytes: bytes | None = None
44
 
45
  file_2g = pn.widgets.FileInput(name="2G KPI report", accept=".csv,.zip")
@@ -56,6 +70,25 @@ min_consecutive_days = pn.widgets.IntInput(
56
  name="Min consecutive bad days (persistent)", value=3
57
  )
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  load_button = pn.widgets.Button(
60
  name="Load datasets & build rules", button_type="primary"
61
  )
@@ -111,6 +144,8 @@ site_kpi_table = pn.widgets.Tabulator(
111
  height=260, sizing_mode="stretch_width", layout="fit_data_table"
112
  )
113
  trend_plot_pane = pn.pane.Plotly(sizing_mode="stretch_both", config=PLOTLY_CONFIG)
 
 
114
 
115
  export_button = pn.widgets.FileDownload(
116
  label="Download KPI Health Check report",
@@ -190,6 +225,8 @@ def _update_site_view(event=None) -> None:
190
  if current_status_df is None or current_status_df.empty:
191
  site_kpi_table.value = pd.DataFrame()
192
  trend_plot_pane.object = None
 
 
193
  return
194
 
195
  code = site_select.value
@@ -199,6 +236,8 @@ def _update_site_view(event=None) -> None:
199
  if code is None or rat is None:
200
  site_kpi_table.value = pd.DataFrame()
201
  trend_plot_pane.object = None
 
 
202
  return
203
 
204
  site_df = current_status_df[
@@ -210,12 +249,16 @@ def _update_site_view(event=None) -> None:
210
  daily = current_daily_by_rat.get(rat)
211
  if daily is None or daily.empty or not kpi or kpi not in daily.columns:
212
  trend_plot_pane.object = None
 
 
213
  return
214
 
215
  d = _filtered_daily(daily)
216
  s = d[d["site_code"] == int(code)].copy().sort_values("date_only")
217
  if s.empty:
218
  trend_plot_pane.object = None
 
 
219
  return
220
 
221
  title = f"{rat} - {kpi} - site {int(code)}"
@@ -223,6 +266,440 @@ def _update_site_view(event=None) -> None:
223
  fig.update_layout(template="plotly_white", title=title)
224
  trend_plot_pane.object = fig
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
  def load_datasets(event=None) -> None:
228
  try:
@@ -231,14 +708,16 @@ def load_datasets(event=None) -> None:
231
 
232
  global current_daily_by_rat, current_rules_df
233
  global current_status_df, current_summary_df, current_export_bytes
234
- global current_multirat_df, current_top_anomalies_df
235
 
236
  current_daily_by_rat = {}
237
  current_rules_df = None
238
  current_status_df = None
239
  current_summary_df = None
240
  current_multirat_df = None
 
241
  current_top_anomalies_df = None
 
242
  current_export_bytes = None
243
 
244
  site_summary_table.value = pd.DataFrame()
@@ -246,6 +725,8 @@ def load_datasets(event=None) -> None:
246
  top_anomalies_table.value = pd.DataFrame()
247
  site_kpi_table.value = pd.DataFrame()
248
  trend_plot_pane.object = None
 
 
249
 
250
  inputs = {"2G": file_2g, "3G": file_3g, "LTE": file_lte}
251
  rows = []
@@ -329,7 +810,8 @@ def run_health_check(event=None) -> None:
329
  status_pane.object = "Running health check..."
330
 
331
  global current_status_df, current_summary_df, current_export_bytes
332
- global current_multirat_df, current_top_anomalies_df
 
333
 
334
  rules_df = (
335
  rules_table.value
@@ -366,11 +848,64 @@ def run_health_check(event=None) -> None:
366
  )
367
  site_summary_table.value = current_summary_df
368
 
369
- current_multirat_df, current_top_anomalies_df = compute_multirat_views(
370
  current_status_df
371
  )
372
- multirat_summary_table.value = current_multirat_df
373
- top_anomalies_table.value = current_top_anomalies_df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
  current_export_bytes = _build_export_bytes()
376
 
@@ -408,19 +943,35 @@ def _build_export_bytes() -> bytes:
408
 
409
 
410
  def _export_callback() -> io.BytesIO:
411
- data = current_export_bytes or b""
412
- if not data:
413
- return io.BytesIO()
414
- return io.BytesIO(data)
 
 
 
415
 
416
 
417
  load_button.on_click(load_datasets)
418
  run_button.on_click(run_health_check)
419
 
 
 
 
 
 
 
 
420
  rat_select.param.watch(lambda e: (_update_kpi_options(), _update_site_view()), "value")
421
  site_select.param.watch(_update_site_view, "value")
422
  kpi_select.param.watch(_update_site_view, "value")
423
 
 
 
 
 
 
 
424
  export_button.callback = _export_callback
425
 
426
 
@@ -436,6 +987,19 @@ sidebar = pn.Column(
436
  rel_threshold_pct,
437
  min_consecutive_days,
438
  "---",
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  load_button,
440
  run_button,
441
  "---",
@@ -461,6 +1025,10 @@ main = pn.Column(
461
  pn.Column(site_kpi_table, sizing_mode="stretch_width"),
462
  pn.Column(trend_plot_pane, sizing_mode="stretch_both"),
463
  ),
 
 
 
 
464
  )
465
 
466
 
 
1
  import io
2
  import os
3
  import sys
4
+ from datetime import date, timedelta
5
 
6
+ import numpy as np
7
  import pandas as pd
8
  import panel as pn
9
  import plotly.express as px
10
+ import plotly.graph_objects as go
11
 
12
  ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
13
  if ROOT_DIR not in sys.path:
14
  sys.path.insert(0, ROOT_DIR)
15
 
16
+ from process_kpi.kpi_health_check.engine import (
17
+ evaluate_health_check,
18
+ is_bad,
19
+ window_bounds,
20
+ )
21
  from process_kpi.kpi_health_check.export import build_export_bytes
22
  from process_kpi.kpi_health_check.io import read_bytes_to_df
23
  from process_kpi.kpi_health_check.multi_rat import compute_multirat_views
 
26
  infer_date_col,
27
  infer_id_col,
28
  )
29
+ from process_kpi.kpi_health_check.presets import (
30
+ delete_preset,
31
+ list_presets,
32
+ load_preset,
33
+ save_preset,
34
+ )
35
  from process_kpi.kpi_health_check.rules import infer_kpi_direction, infer_kpi_sla
36
 
37
  pn.extension("plotly", "tabulator")
 
51
  current_status_df: pd.DataFrame | None = None
52
  current_summary_df: pd.DataFrame | None = None
53
  current_multirat_df: pd.DataFrame | None = None
54
+ current_multirat_raw: pd.DataFrame | None = None
55
  current_top_anomalies_df: pd.DataFrame | None = None
56
+ current_top_anomalies_raw: pd.DataFrame | None = None
57
  current_export_bytes: bytes | None = None
58
 
59
  file_2g = pn.widgets.FileInput(name="2G KPI report", accept=".csv,.zip")
 
70
  name="Min consecutive bad days (persistent)", value=3
71
  )
72
 
73
+ min_criticality = pn.widgets.IntInput(name="Min criticality score", value=0)
74
+ min_anomaly_score = pn.widgets.IntInput(name="Min anomaly score", value=0)
75
+ city_filter = pn.widgets.TextInput(name="City contains (optional)", value="")
76
+ top_rat_filter = pn.widgets.CheckBoxGroup(
77
+ name="Top anomalies RAT", options=["2G", "3G", "LTE"], value=["2G", "3G", "LTE"]
78
+ )
79
+ top_status_filter = pn.widgets.CheckBoxGroup(
80
+ name="Top anomalies status",
81
+ options=["DEGRADED", "PERSISTENT_DEGRADED"],
82
+ value=["DEGRADED", "PERSISTENT_DEGRADED"],
83
+ )
84
+
85
+ preset_select = pn.widgets.Select(name="Rules preset", options=[], value=None)
86
+ preset_name_input = pn.widgets.TextInput(name="Save preset as", value="")
87
+ preset_refresh_button = pn.widgets.Button(name="Refresh presets", button_type="default")
88
+ preset_apply_button = pn.widgets.Button(name="Apply preset", button_type="primary")
89
+ preset_save_button = pn.widgets.Button(name="Save current rules", button_type="primary")
90
+ preset_delete_button = pn.widgets.Button(name="Delete preset", button_type="danger")
91
+
92
  load_button = pn.widgets.Button(
93
  name="Load datasets & build rules", button_type="primary"
94
  )
 
144
  height=260, sizing_mode="stretch_width", layout="fit_data_table"
145
  )
146
  trend_plot_pane = pn.pane.Plotly(sizing_mode="stretch_both", config=PLOTLY_CONFIG)
147
+ heatmap_plot_pane = pn.pane.Plotly(sizing_mode="stretch_both", config=PLOTLY_CONFIG)
148
+ hist_plot_pane = pn.pane.Plotly(sizing_mode="stretch_both", config=PLOTLY_CONFIG)
149
 
150
  export_button = pn.widgets.FileDownload(
151
  label="Download KPI Health Check report",
 
225
  if current_status_df is None or current_status_df.empty:
226
  site_kpi_table.value = pd.DataFrame()
227
  trend_plot_pane.object = None
228
+ heatmap_plot_pane.object = None
229
+ hist_plot_pane.object = None
230
  return
231
 
232
  code = site_select.value
 
236
  if code is None or rat is None:
237
  site_kpi_table.value = pd.DataFrame()
238
  trend_plot_pane.object = None
239
+ heatmap_plot_pane.object = None
240
+ hist_plot_pane.object = None
241
  return
242
 
243
  site_df = current_status_df[
 
249
  daily = current_daily_by_rat.get(rat)
250
  if daily is None or daily.empty or not kpi or kpi not in daily.columns:
251
  trend_plot_pane.object = None
252
+ heatmap_plot_pane.object = None
253
+ hist_plot_pane.object = None
254
  return
255
 
256
  d = _filtered_daily(daily)
257
  s = d[d["site_code"] == int(code)].copy().sort_values("date_only")
258
  if s.empty:
259
  trend_plot_pane.object = None
260
+ heatmap_plot_pane.object = None
261
+ hist_plot_pane.object = None
262
  return
263
 
264
  title = f"{rat} - {kpi} - site {int(code)}"
 
266
  fig.update_layout(template="plotly_white", title=title)
267
  trend_plot_pane.object = fig
268
 
269
+ rules_df = (
270
+ rules_table.value
271
+ if isinstance(rules_table.value, pd.DataFrame)
272
+ else pd.DataFrame()
273
+ )
274
+ kpis_for_heatmap = []
275
+ if (
276
+ isinstance(site_df, pd.DataFrame)
277
+ and not site_df.empty
278
+ and "KPI" in site_df.columns
279
+ ):
280
+ sev = {
281
+ "PERSISTENT_DEGRADED": 3,
282
+ "DEGRADED": 2,
283
+ "RESOLVED": 1,
284
+ "OK": 0,
285
+ "NO_DATA": -1,
286
+ }
287
+ tmp = site_df.copy()
288
+ if "status" in tmp.columns:
289
+ tmp["_sev"] = tmp["status"].map(sev).fillna(0).astype(int)
290
+ tmp = tmp.sort_values(by=["_sev", "KPI"], ascending=[False, True])
291
+ kpis_for_heatmap = tmp["KPI"].astype(str).tolist()[:30]
292
+
293
+ heatmap_plot_pane.object = _build_site_heatmap(
294
+ d, rules_df, int(code), rat, kpis_for_heatmap
295
+ )
296
+ hist_plot_pane.object = _build_baseline_recent_hist(d, int(code), str(kpi))
297
+
298
+
299
+ def _apply_city_filter(df: pd.DataFrame) -> pd.DataFrame:
300
+ if df is None or df.empty:
301
+ return pd.DataFrame()
302
+ q = (city_filter.value or "").strip()
303
+ if not q or "City" not in df.columns:
304
+ return df
305
+ return df[df["City"].astype(str).str.contains(q, case=False, na=False)].copy()
306
+
307
+
308
+ def _infer_rule_row(rules_df: pd.DataFrame, rat: str, kpi: str) -> dict:
309
+ if rules_df is None or rules_df.empty:
310
+ return {}
311
+ s = rules_df[(rules_df["RAT"] == rat) & (rules_df["KPI"] == kpi)]
312
+ if s.empty:
313
+ return {}
314
+ return dict(s.iloc[0].to_dict())
315
+
316
+
317
+ def _compute_site_windows(
318
+ daily_filtered: pd.DataFrame,
319
+ ) -> tuple[date, date, date, date] | None:
320
+ if daily_filtered is None or daily_filtered.empty:
321
+ return None
322
+ end_date = max(daily_filtered["date_only"])
323
+ recent_start, recent_end = window_bounds(end_date, int(recent_days.value))
324
+ baseline_end = recent_start - timedelta(days=1)
325
+ baseline_start = baseline_end - timedelta(days=int(baseline_days.value) - 1)
326
+ return baseline_start, baseline_end, recent_start, recent_end
327
+
328
+
329
+ def _build_site_heatmap(
330
+ daily_filtered: pd.DataFrame,
331
+ rules_df: pd.DataFrame,
332
+ site_code: int,
333
+ rat: str,
334
+ kpis: list[str],
335
+ ) -> go.Figure | None:
336
+ if daily_filtered is None or daily_filtered.empty:
337
+ return None
338
+ windows = _compute_site_windows(daily_filtered)
339
+ if windows is None:
340
+ return None
341
+ baseline_start, baseline_end, recent_start, recent_end = windows
342
+
343
+ site_daily = daily_filtered[daily_filtered["site_code"] == int(site_code)].copy()
344
+ if site_daily.empty:
345
+ return None
346
+ site_daily = site_daily.sort_values("date_only")
347
+
348
+ dates = []
349
+ cur = recent_start
350
+ while cur <= recent_end:
351
+ dates.append(cur)
352
+ cur = cur + timedelta(days=1)
353
+
354
+ z = []
355
+ hover = []
356
+ y_labels = []
357
+ for kpi in kpis:
358
+ if kpi not in site_daily.columns:
359
+ continue
360
+ rule = _infer_rule_row(rules_df, rat, kpi)
361
+ direction = str(rule.get("direction", "higher_is_better"))
362
+ sla_raw = rule.get("sla", None)
363
+ try:
364
+ sla_val = float(sla_raw) if pd.notna(sla_raw) else None
365
+ except Exception: # noqa: BLE001
366
+ sla_val = None
367
+
368
+ s = site_daily[["date_only", kpi]].dropna(subset=[kpi])
369
+ baseline_mask = (s["date_only"] >= baseline_start) & (
370
+ s["date_only"] <= baseline_end
371
+ )
372
+ baseline = s.loc[baseline_mask, kpi].median() if baseline_mask.any() else np.nan
373
+ baseline_val = float(baseline) if pd.notna(baseline) else None
374
+
375
+ row_z = []
376
+ row_h = []
377
+ for d in dates:
378
+ v_series = site_daily.loc[site_daily["date_only"] == d, kpi]
379
+ v = v_series.iloc[0] if not v_series.empty else np.nan
380
+ if v is None or (isinstance(v, float) and np.isnan(v)):
381
+ row_z.append(None)
382
+ row_h.append(f"{kpi}<br>{d}: NO_DATA")
383
+ continue
384
+ bad = is_bad(
385
+ float(v) if pd.notna(v) else None,
386
+ baseline_val,
387
+ direction,
388
+ float(rel_threshold_pct.value),
389
+ sla_val,
390
+ )
391
+ row_z.append(1 if bad else 0)
392
+ row_h.append(
393
+ f"{kpi}<br>{d}: {float(v):.3f}<br>baseline: {baseline_val if baseline_val is not None else 'NA'}<br>sla: {sla_val if sla_val is not None else 'NA'}"
394
+ )
395
+
396
+ z.append(row_z)
397
+ hover.append(row_h)
398
+ y_labels.append(kpi)
399
+
400
+ if not z:
401
+ return None
402
+
403
+ fig = go.Figure(
404
+ data=[
405
+ go.Heatmap(
406
+ z=z,
407
+ x=[str(d) for d in dates],
408
+ y=y_labels,
409
+ colorscale=[[0.0, "#2ca02c"], [1.0, "#d62728"]],
410
+ zmin=0,
411
+ zmax=1,
412
+ showscale=False,
413
+ hovertext=hover,
414
+ hovertemplate="%{hovertext}<extra></extra>",
415
+ )
416
+ ]
417
+ )
418
+ fig.update_layout(
419
+ template="plotly_white",
420
+ title=f"{rat} - Site {int(site_code)} - Recent window heatmap",
421
+ xaxis_title="date",
422
+ yaxis_title="KPI",
423
+ height=420,
424
+ margin=dict(l=40, r=20, t=60, b=40),
425
+ )
426
+ return fig
427
+
428
+
429
+ def _build_baseline_recent_hist(
430
+ daily_filtered: pd.DataFrame,
431
+ site_code: int,
432
+ kpi: str,
433
+ ) -> go.Figure | None:
434
+ if (
435
+ daily_filtered is None
436
+ or daily_filtered.empty
437
+ or not kpi
438
+ or kpi not in daily_filtered.columns
439
+ ):
440
+ return None
441
+
442
+ windows = _compute_site_windows(daily_filtered)
443
+ if windows is None:
444
+ return None
445
+ baseline_start, baseline_end, recent_start, recent_end = windows
446
+
447
+ site_daily = daily_filtered[daily_filtered["site_code"] == int(site_code)].copy()
448
+ if site_daily.empty:
449
+ return None
450
+
451
+ s = site_daily[["date_only", kpi]].dropna(subset=[kpi])
452
+ baseline_mask = (s["date_only"] >= baseline_start) & (
453
+ s["date_only"] <= baseline_end
454
+ )
455
+ recent_mask = (s["date_only"] >= recent_start) & (s["date_only"] <= recent_end)
456
+
457
+ baseline_vals = (
458
+ pd.to_numeric(s.loc[baseline_mask, kpi], errors="coerce").dropna().astype(float)
459
+ )
460
+ recent_vals = (
461
+ pd.to_numeric(s.loc[recent_mask, kpi], errors="coerce").dropna().astype(float)
462
+ )
463
+ if baseline_vals.empty and recent_vals.empty:
464
+ return None
465
+
466
+ dfh = pd.concat(
467
+ [
468
+ pd.DataFrame({"window": "baseline", "value": baseline_vals}),
469
+ pd.DataFrame({"window": "recent", "value": recent_vals}),
470
+ ],
471
+ ignore_index=True,
472
+ )
473
+ fig = px.histogram(
474
+ dfh,
475
+ x="value",
476
+ color="window",
477
+ barmode="overlay",
478
+ opacity=0.6,
479
+ )
480
+ fig.update_layout(
481
+ template="plotly_white",
482
+ title=f"{kpi} distribution (baseline vs recent)",
483
+ xaxis_title=kpi,
484
+ yaxis_title="count",
485
+ height=420,
486
+ margin=dict(l=40, r=20, t=60, b=40),
487
+ )
488
+ return fig
489
+
490
+
491
+ def _compute_site_traffic_gb(daily_by_rat: dict[str, pd.DataFrame]) -> pd.DataFrame:
492
+ MB_PER_GB = 1024.0
493
+ rows = []
494
+
495
+ for rat, daily in daily_by_rat.items():
496
+ if daily is None or daily.empty:
497
+ continue
498
+ d = _filtered_daily(daily)
499
+ if d.empty or "site_code" not in d.columns:
500
+ continue
501
+
502
+ cols: list[str] = []
503
+ if rat == "2G":
504
+ for c in d.columns:
505
+ key = str(c).lower().replace(" ", "_")
506
+ if "traffic_ps" in key:
507
+ cols.append(c)
508
+ elif rat == "3G":
509
+ if "Total_Data_Traffic" in d.columns:
510
+ cols.append("Total_Data_Traffic")
511
+ elif rat == "LTE":
512
+ for c in d.columns:
513
+ key = str(c).lower().replace(" ", "_")
514
+ if "traffic_volume" in key and "gbytes" in key:
515
+ cols.append(c)
516
+
517
+ cols = [c for c in cols if c in d.columns]
518
+ if not cols:
519
+ continue
520
+
521
+ traffic = pd.to_numeric(
522
+ d[cols].sum(axis=1, skipna=True), errors="coerce"
523
+ ).fillna(0)
524
+ if rat == "2G":
525
+ traffic = traffic / MB_PER_GB
526
+
527
+ tmp = pd.DataFrame(
528
+ {
529
+ "site_code": d["site_code"].astype(int),
530
+ "RAT": rat,
531
+ "traffic_gb": traffic.astype(float),
532
+ }
533
+ )
534
+ tmp = tmp.groupby(["site_code", "RAT"], as_index=False)["traffic_gb"].sum()
535
+ rows.append(tmp)
536
+
537
+ if not rows:
538
+ return pd.DataFrame(columns=["site_code", "traffic_gb_total"])
539
+
540
+ all_rows = pd.concat(rows, ignore_index=True)
541
+ pivot = (
542
+ all_rows.pivot_table(
543
+ index="site_code",
544
+ columns="RAT",
545
+ values="traffic_gb",
546
+ aggfunc="sum",
547
+ fill_value=0,
548
+ )
549
+ .reset_index()
550
+ .copy()
551
+ )
552
+
553
+ traffic_cols = []
554
+ for rat in ["2G", "3G", "LTE"]:
555
+ if rat in pivot.columns:
556
+ pivot = pivot.rename(columns={rat: f"traffic_gb_{rat}"})
557
+ traffic_cols.append(f"traffic_gb_{rat}")
558
+
559
+ if traffic_cols:
560
+ pivot["traffic_gb_total"] = (
561
+ pd.to_numeric(pivot[traffic_cols].sum(axis=1), errors="coerce")
562
+ .fillna(0)
563
+ .astype(float)
564
+ )
565
+ else:
566
+ pivot["traffic_gb_total"] = 0.0
567
+
568
+ return pivot
569
+
570
+
571
+ def _refresh_filtered_results(event=None) -> None:
572
+ global current_multirat_df, current_top_anomalies_df, current_export_bytes
573
+
574
+ if current_multirat_raw is not None and not current_multirat_raw.empty:
575
+ m = _apply_city_filter(current_multirat_raw)
576
+ score_col = (
577
+ "criticality_score_weighted"
578
+ if "criticality_score_weighted" in m.columns
579
+ else "criticality_score"
580
+ )
581
+ if score_col in m.columns:
582
+ m = m[
583
+ pd.to_numeric(m[score_col], errors="coerce").fillna(0)
584
+ >= int(min_criticality.value)
585
+ ]
586
+ m = m.sort_values(by=[score_col], ascending=False)
587
+ current_multirat_df = m
588
+ multirat_summary_table.value = current_multirat_df
589
+ else:
590
+ current_multirat_df = pd.DataFrame()
591
+ multirat_summary_table.value = current_multirat_df
592
+
593
+ if current_top_anomalies_raw is not None and not current_top_anomalies_raw.empty:
594
+ t = _apply_city_filter(current_top_anomalies_raw)
595
+ if top_rat_filter.value:
596
+ t = t[t["RAT"].isin(list(top_rat_filter.value))]
597
+ if top_status_filter.value and "status" in t.columns:
598
+ t = t[t["status"].isin(list(top_status_filter.value))]
599
+ score_col = (
600
+ "anomaly_score_weighted"
601
+ if "anomaly_score_weighted" in t.columns
602
+ else "anomaly_score"
603
+ )
604
+ if score_col in t.columns:
605
+ t = t[
606
+ pd.to_numeric(t[score_col], errors="coerce").fillna(0)
607
+ >= int(min_anomaly_score.value)
608
+ ]
609
+ t = t.sort_values(by=[score_col], ascending=False)
610
+ current_top_anomalies_df = t
611
+ top_anomalies_table.value = current_top_anomalies_df
612
+ else:
613
+ current_top_anomalies_df = pd.DataFrame()
614
+ top_anomalies_table.value = current_top_anomalies_df
615
+
616
+ current_export_bytes = None
617
+
618
+
619
+ def _refresh_presets(event=None) -> None:
620
+ names = list_presets()
621
+ preset_select.options = [""] + names
622
+ if preset_select.value not in preset_select.options:
623
+ preset_select.value = ""
624
+
625
+
626
+ def _apply_preset(event=None) -> None:
627
+ global current_export_bytes
628
+ try:
629
+ if not preset_select.value:
630
+ return
631
+ preset_df = load_preset(str(preset_select.value))
632
+ if preset_df is None or preset_df.empty:
633
+ return
634
+ except Exception as exc: # noqa: BLE001
635
+ status_pane.alert_type = "danger"
636
+ status_pane.object = f"Error loading preset: {exc}"
637
+ return
638
+
639
+ cur = (
640
+ rules_table.value
641
+ if isinstance(rules_table.value, pd.DataFrame)
642
+ else pd.DataFrame()
643
+ )
644
+ if cur is None or cur.empty:
645
+ rules_table.value = preset_df
646
+ return
647
+
648
+ key = ["RAT", "KPI"]
649
+ upd_cols = [c for c in ["direction", "sla"] if c in preset_df.columns]
650
+ preset_df2 = preset_df[key + upd_cols].copy()
651
+
652
+ merged = pd.merge(cur, preset_df2, on=key, how="left", suffixes=("", "_preset"))
653
+ for c in upd_cols:
654
+ pc = f"{c}_preset"
655
+ if pc in merged.columns:
656
+ merged[c] = merged[pc].where(merged[pc].notna(), merged[c])
657
+ merged = merged.drop(columns=[pc])
658
+
659
+ rules_table.value = merged
660
+ status_pane.alert_type = "success"
661
+ status_pane.object = f"Preset applied: {preset_select.value}"
662
+ current_export_bytes = None
663
+
664
+
665
+ def _save_current_rules_as_preset(event=None) -> None:
666
+ try:
667
+ name = (preset_name_input.value or "").strip()
668
+ if not name:
669
+ name = str(preset_select.value or "").strip()
670
+ if not name:
671
+ raise ValueError("Please provide a preset name")
672
+ cur = (
673
+ rules_table.value
674
+ if isinstance(rules_table.value, pd.DataFrame)
675
+ else pd.DataFrame()
676
+ )
677
+ save_preset(name, cur)
678
+ preset_name_input.value = ""
679
+ _refresh_presets()
680
+ preset_select.value = name
681
+ status_pane.alert_type = "success"
682
+ status_pane.object = f"Preset saved: {name}"
683
+ except Exception as exc: # noqa: BLE001
684
+ status_pane.alert_type = "danger"
685
+ status_pane.object = f"Error saving preset: {exc}"
686
+
687
+
688
+ def _delete_selected_preset(event=None) -> None:
689
+ global current_export_bytes
690
+ try:
691
+ name = str(preset_select.value or "").strip()
692
+ if not name:
693
+ return
694
+ delete_preset(name)
695
+ _refresh_presets()
696
+ status_pane.alert_type = "success"
697
+ status_pane.object = f"Preset deleted: {name}"
698
+ current_export_bytes = None
699
+ except Exception as exc: # noqa: BLE001
700
+ status_pane.alert_type = "danger"
701
+ status_pane.object = f"Error deleting preset: {exc}"
702
+
703
 
704
  def load_datasets(event=None) -> None:
705
  try:
 
708
 
709
  global current_daily_by_rat, current_rules_df
710
  global current_status_df, current_summary_df, current_export_bytes
711
+ global current_multirat_df, current_multirat_raw, current_top_anomalies_df, current_top_anomalies_raw
712
 
713
  current_daily_by_rat = {}
714
  current_rules_df = None
715
  current_status_df = None
716
  current_summary_df = None
717
  current_multirat_df = None
718
+ current_multirat_raw = None
719
  current_top_anomalies_df = None
720
+ current_top_anomalies_raw = None
721
  current_export_bytes = None
722
 
723
  site_summary_table.value = pd.DataFrame()
 
725
  top_anomalies_table.value = pd.DataFrame()
726
  site_kpi_table.value = pd.DataFrame()
727
  trend_plot_pane.object = None
728
+ heatmap_plot_pane.object = None
729
+ hist_plot_pane.object = None
730
 
731
  inputs = {"2G": file_2g, "3G": file_3g, "LTE": file_lte}
732
  rows = []
 
810
  status_pane.object = "Running health check..."
811
 
812
  global current_status_df, current_summary_df, current_export_bytes
813
+ global current_multirat_df, current_multirat_raw
814
+ global current_top_anomalies_df, current_top_anomalies_raw
815
 
816
  rules_df = (
817
  rules_table.value
 
848
  )
849
  site_summary_table.value = current_summary_df
850
 
851
+ current_multirat_raw, current_top_anomalies_raw = compute_multirat_views(
852
  current_status_df
853
  )
854
+
855
+ traffic_df = _compute_site_traffic_gb(current_daily_by_rat)
856
+ if traffic_df is not None and not traffic_df.empty:
857
+ if current_multirat_raw is not None and not current_multirat_raw.empty:
858
+ current_multirat_raw = pd.merge(
859
+ current_multirat_raw, traffic_df, on="site_code", how="left"
860
+ )
861
+ w = 1.0 + np.log1p(
862
+ pd.to_numeric(
863
+ current_multirat_raw["traffic_gb_total"], errors="coerce"
864
+ )
865
+ .fillna(0)
866
+ .astype(float)
867
+ )
868
+ current_multirat_raw["criticality_score_weighted"] = (
869
+ (
870
+ pd.to_numeric(
871
+ current_multirat_raw["criticality_score"], errors="coerce"
872
+ )
873
+ .fillna(0)
874
+ .astype(float)
875
+ * w
876
+ )
877
+ .round(0)
878
+ .astype(int)
879
+ )
880
+
881
+ if (
882
+ current_top_anomalies_raw is not None
883
+ and not current_top_anomalies_raw.empty
884
+ ):
885
+ current_top_anomalies_raw = pd.merge(
886
+ current_top_anomalies_raw, traffic_df, on="site_code", how="left"
887
+ )
888
+ w = 1.0 + np.log1p(
889
+ pd.to_numeric(
890
+ current_top_anomalies_raw["traffic_gb_total"], errors="coerce"
891
+ )
892
+ .fillna(0)
893
+ .astype(float)
894
+ )
895
+ current_top_anomalies_raw["anomaly_score_weighted"] = (
896
+ (
897
+ pd.to_numeric(
898
+ current_top_anomalies_raw["anomaly_score"], errors="coerce"
899
+ )
900
+ .fillna(0)
901
+ .astype(float)
902
+ * w
903
+ )
904
+ .round(0)
905
+ .astype(int)
906
+ )
907
+
908
+ _refresh_filtered_results()
909
 
910
  current_export_bytes = _build_export_bytes()
911
 
 
943
 
944
 
945
  def _export_callback() -> io.BytesIO:
946
+ global current_export_bytes
947
+ if current_export_bytes is None:
948
+ try:
949
+ current_export_bytes = _build_export_bytes()
950
+ except Exception: # noqa: BLE001
951
+ current_export_bytes = b""
952
+ return io.BytesIO(current_export_bytes or b"")
953
 
954
 
955
  load_button.on_click(load_datasets)
956
  run_button.on_click(run_health_check)
957
 
958
+ preset_refresh_button.on_click(_refresh_presets)
959
+ preset_apply_button.on_click(_apply_preset)
960
+ preset_save_button.on_click(_save_current_rules_as_preset)
961
+ preset_delete_button.on_click(_delete_selected_preset)
962
+
963
+ _refresh_presets()
964
+
965
  rat_select.param.watch(lambda e: (_update_kpi_options(), _update_site_view()), "value")
966
  site_select.param.watch(_update_site_view, "value")
967
  kpi_select.param.watch(_update_site_view, "value")
968
 
969
+ min_criticality.param.watch(_refresh_filtered_results, "value")
970
+ min_anomaly_score.param.watch(_refresh_filtered_results, "value")
971
+ city_filter.param.watch(_refresh_filtered_results, "value")
972
+ top_rat_filter.param.watch(_refresh_filtered_results, "value")
973
+ top_status_filter.param.watch(_refresh_filtered_results, "value")
974
+
975
  export_button.callback = _export_callback
976
 
977
 
 
987
  rel_threshold_pct,
988
  min_consecutive_days,
989
  "---",
990
+ pn.pane.Markdown("### Filters"),
991
+ min_criticality,
992
+ min_anomaly_score,
993
+ city_filter,
994
+ top_rat_filter,
995
+ top_status_filter,
996
+ "---",
997
+ pn.pane.Markdown("### Rule presets"),
998
+ preset_select,
999
+ pn.Row(preset_refresh_button, preset_apply_button),
1000
+ preset_name_input,
1001
+ pn.Row(preset_save_button, preset_delete_button),
1002
+ "---",
1003
  load_button,
1004
  run_button,
1005
  "---",
 
1025
  pn.Column(site_kpi_table, sizing_mode="stretch_width"),
1026
  pn.Column(trend_plot_pane, sizing_mode="stretch_both"),
1027
  ),
1028
+ pn.Row(
1029
+ pn.Column(heatmap_plot_pane, sizing_mode="stretch_both"),
1030
+ pn.Column(hist_plot_pane, sizing_mode="stretch_both"),
1031
+ ),
1032
  )
1033
 
1034
 
process_kpi/kpi_health_check/multi_rat.py CHANGED
@@ -87,9 +87,31 @@ def compute_multirat_views(
87
 
88
  metric_cols = [c for c in out.columns if c != "City"]
89
  out[metric_cols] = out[metric_cols].fillna(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  out = out.sort_values(
91
- by=["persistent_kpis_total", "degraded_kpis_total", "impacted_rats"],
92
- ascending=[False, False, False],
 
 
 
 
 
93
  )
94
 
95
  top = df[df["is_degraded"]].copy()
@@ -100,14 +122,30 @@ def compute_multirat_views(
100
  if col not in top.columns:
101
  top[col] = pd.NA
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  top = top.sort_values(
104
- by=["severity", "max_streak_recent", "bad_days_recent"],
105
- ascending=[False, False, False],
106
  )
107
 
108
  top_cols = [
109
  c
110
  for c in [
 
111
  "severity",
112
  "RAT",
113
  "site_code",
 
87
 
88
  metric_cols = [c for c in out.columns if c != "City"]
89
  out[metric_cols] = out[metric_cols].fillna(0)
90
+
91
+ resolved_total = (
92
+ out["resolved_kpis_total"].astype(float)
93
+ if "resolved_kpis_total" in out.columns
94
+ else 0.0
95
+ )
96
+ out["criticality_score"] = (
97
+ (
98
+ out["persistent_kpis_total"].astype(float) * 5.0
99
+ + out["degraded_kpis_total"].astype(float) * 2.0
100
+ + out["impacted_rats"].astype(float) * 1.0
101
+ + resolved_total * 0.5
102
+ )
103
+ .round(0)
104
+ .astype(int)
105
+ )
106
+
107
  out = out.sort_values(
108
+ by=[
109
+ "criticality_score",
110
+ "persistent_kpis_total",
111
+ "degraded_kpis_total",
112
+ "impacted_rats",
113
+ ],
114
+ ascending=[False, False, False, False],
115
  )
116
 
117
  top = df[df["is_degraded"]].copy()
 
122
  if col not in top.columns:
123
  top[col] = pd.NA
124
 
125
+ top["anomaly_score"] = (
126
+ (
127
+ top["severity"].astype(float) * 100.0
128
+ + pd.to_numeric(top["max_streak_recent"], errors="coerce")
129
+ .fillna(0)
130
+ .astype(float)
131
+ * 10.0
132
+ + pd.to_numeric(top["bad_days_recent"], errors="coerce")
133
+ .fillna(0)
134
+ .astype(float)
135
+ )
136
+ .round(0)
137
+ .astype(int)
138
+ )
139
+
140
  top = top.sort_values(
141
+ by=["anomaly_score", "severity", "max_streak_recent", "bad_days_recent"],
142
+ ascending=[False, False, False, False],
143
  )
144
 
145
  top_cols = [
146
  c
147
  for c in [
148
+ "anomaly_score",
149
  "severity",
150
  "RAT",
151
  "site_code",
process_kpi/kpi_health_check/presets.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from datetime import datetime
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def presets_dir() -> str:
9
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+ return os.path.join(root, "data", "kpi_health_check_presets")
11
+
12
+
13
+ def _safe_name(name: str) -> str:
14
+ s = (name or "").strip()
15
+ s = s.replace("..", "")
16
+ s = s.replace("/", "_").replace("\\", "_")
17
+ s = "_".join([p for p in s.split() if p])
18
+ return s
19
+
20
+
21
+ def list_presets() -> list[str]:
22
+ d = presets_dir()
23
+ if not os.path.isdir(d):
24
+ return []
25
+ out = []
26
+ for fn in os.listdir(d):
27
+ if fn.lower().endswith(".json"):
28
+ out.append(os.path.splitext(fn)[0])
29
+ return sorted(set(out))
30
+
31
+
32
+ def load_preset(name: str) -> pd.DataFrame:
33
+ d = presets_dir()
34
+ safe = _safe_name(name)
35
+ path = os.path.join(d, f"{safe}.json")
36
+ with open(path, "r", encoding="utf-8") as f:
37
+ obj = json.load(f)
38
+ rows = obj.get("rules", []) if isinstance(obj, dict) else []
39
+ df = pd.DataFrame(rows)
40
+ if not df.empty:
41
+ df["RAT"] = df["RAT"].astype(str)
42
+ df["KPI"] = df["KPI"].astype(str)
43
+ return df
44
+
45
+
46
+ def save_preset(name: str, rules_df: pd.DataFrame) -> str:
47
+ safe = _safe_name(name)
48
+ if not safe:
49
+ raise ValueError("Preset name is empty")
50
+
51
+ d = presets_dir()
52
+ os.makedirs(d, exist_ok=True)
53
+ path = os.path.join(d, f"{safe}.json")
54
+
55
+ df = rules_df.copy() if isinstance(rules_df, pd.DataFrame) else pd.DataFrame()
56
+ if df.empty:
57
+ raise ValueError("Rules dataframe is empty")
58
+
59
+ keep = [c for c in ["RAT", "KPI", "direction", "sla"] if c in df.columns]
60
+ df = df[keep].copy()
61
+
62
+ obj = {
63
+ "name": safe,
64
+ "saved_at": datetime.utcnow().isoformat() + "Z",
65
+ "rules": df.to_dict(orient="records"),
66
+ }
67
+
68
+ with open(path, "w", encoding="utf-8") as f:
69
+ json.dump(obj, f, ensure_ascii=False, indent=2)
70
+
71
+ return path
72
+
73
+
74
+ def delete_preset(name: str) -> None:
75
+ d = presets_dir()
76
+ safe = _safe_name(name)
77
+ path = os.path.join(d, f"{safe}.json")
78
+ if os.path.isfile(path):
79
+ os.remove(path)