ilafi commited on
Commit
e9d4b6c
·
verified ·
1 Parent(s): e276e87

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1097 -0
app.py ADDED
@@ -0,0 +1,1097 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TKM Dashboard — MSI pooled + Exclusion (STRICT) + Penyesuaian Sampel ala IPLM
4
+ + Normalisasi MIN–MAX GLOBAL di LEVEL RESPONDEN sebelum agregasi wilayah
5
+ + Export Excel + Export Word (.docx) dengan TABEL Interpretasi & Rekomendasi (Pra/Saat/Pasca/Indeks TKM)
6
+
7
+ UPDATE UTAMA (sesuai instruksi terakhir Anda):
8
+ ✅ Interpretasi & rekomendasi sekarang SELALU menyesuaikan:
9
+ - NILAI (angka) dan KATEGORI (sangat rendah / rendah / sedang / tinggi / sangat tinggi)
10
+ ✅ Isi antar kategori dibuat “beda signifikan”:
11
+ - sangat rendah: pemulihan dasar (foundation recovery)
12
+ - rendah : penguatan terstruktur (SOP + kapasitas)
13
+ - sedang : stabilisasi mutu (QA, konsistensi, pemerataan)
14
+ - tinggi : pemantapan + replikasi selektif
15
+ - sangat tinggi: praktik unggul + skalabilitas & keberlanjutan
16
+ ✅ Catatan n<400 (disesuaikan) hanya muncul bila memang baris berada pada kelompok disesuaikan.
17
+ ✅ Tabel Word menampilkan per baris:
18
+ Pra / Saat / Pasca / Indeks → interpretasi & rekomendasi yang relevan dengan kategori baris itu.
19
+
20
+ Catatan:
21
+ - FULL CODE (no ringkas).
22
+ """
23
+
24
+ from pathlib import Path
25
+ from typing import Dict, List, Tuple, Optional
26
+ from datetime import datetime
27
+ import re
28
+
29
+ import numpy as np
30
+ import pandas as pd
31
+ from scipy import stats
32
+ import matplotlib.pyplot as plt
33
+ import gradio as gr
34
+
35
+ # docx
36
+ try:
37
+ from docx import Document
38
+ from docx.shared import Pt, Inches
39
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
40
+ from docx.enum.table import WD_TABLE_ALIGNMENT, WD_ALIGN_VERTICAL
41
+ DOCX_AVAILABLE = True
42
+ except Exception:
43
+ DOCX_AVAILABLE = False
44
+
45
+ import openpyxl # noqa: F401
46
+
47
+ np.random.seed(42)
48
+
49
+ # =========================
50
+ # KONFIGURASI
51
+ # =========================
52
+ DATA_PATH = "DATA_TKM_28_JANUARI_2026.xlsx"
53
+
54
+ WEIGHTS = {"pra": 0.15, "saat": 0.50, "pasca": 0.35}
55
+ LIKERT_MIN, LIKERT_MAX = 1, 4
56
+ MIN_FRAC_AVAILABLE_PER_SUBINDEX = 0.50
57
+ MIN_RESPONDEN_SLOVIN = 400
58
+
59
+ # =========================
60
+ # EXCLUSION LIST (STRICT)
61
+ # =========================
62
+ EXCLUDE_PROVINSI_DROP = []
63
+
64
+ EXCLUDE_PROVINSI_NO_AGG = [
65
+ "Bali",
66
+ "Papua Barat Daya",
67
+ "Papua Pegunungan",
68
+ "Papua Selatan",
69
+ "Papua Tengah",
70
+ ]
71
+
72
+ EXCLUDE_KABKOTA_BY_PROV_TYPE: List[Tuple[str, str, str]] = [
73
+ ("Bali", "kab", "Bangli"),
74
+ ("Bali", "kab", "Jembrana"),
75
+ ("Sumatera Utara", "kab", "Humbang Hasundutan"),
76
+ ("Lampung", "kab", "Pesisir Barat"),
77
+ ("Papua", "kab", "Biak Numfor"),
78
+ ("Papua Tengah", "kab", "Nabire"),
79
+ ("Jawa Barat", "kab", "Tasikmalaya"),
80
+
81
+ ("Kalimantan Timur", "kab", "Mahakam Ulu"),
82
+ ("Papua Barat", "kab", "Sorong Selatan"),
83
+ ("Papua Barat Daya", "kab", "Tambrauw"),
84
+ ("Maluku Utara", "kab", "Halmahera Timur"),
85
+ ("Papua", "kab", "Mamberamo Raya"),
86
+ ("Papua Tengah", "kab", "Puncak"),
87
+
88
+ ("Maluku", "kab", "Seram Bagian Barat"),
89
+ ]
90
+
91
+ # =========================
92
+ # AUTO-DETECT KOLOM WILAYAH
93
+ # =========================
94
+ def _norm(s: str) -> str:
95
+ return (
96
+ str(s).strip().lower()
97
+ .replace(" ", "")
98
+ .replace("-", "")
99
+ .replace("/", "")
100
+ .replace("\\", "")
101
+ .replace(".", "")
102
+ .replace(",", "")
103
+ )
104
+
105
+ def detect_region_cols(df: pd.DataFrame) -> Tuple[str, str]:
106
+ cols = list(df.columns)
107
+ norm_map = {_norm(c): c for c in cols}
108
+
109
+ prov_candidates = ["provinsiasal", "provinsi asal", "provinsi", "province", "namaprovinsi", "prov"]
110
+ kab_candidates = [
111
+ "kabkota", "kab_kota", "kabkotaasal", "kabupatenkota", "kabupatenkotaasal",
112
+ "kab/kota", "kabupaten/kota", "kabupaten", "kota", "kab"
113
+ ]
114
+
115
+ prov_col = None
116
+ for cand in prov_candidates:
117
+ if _norm(cand) in norm_map:
118
+ prov_col = norm_map[_norm(cand)]
119
+ break
120
+
121
+ kab_col = None
122
+ for cand in kab_candidates:
123
+ if _norm(cand) in norm_map:
124
+ kab_col = norm_map[_norm(cand)]
125
+ break
126
+
127
+ if prov_col is None or kab_col is None:
128
+ raise ValueError(
129
+ "Kolom wilayah tidak terdeteksi.\n"
130
+ f"Kolom tersedia (contoh 40): {list(df.columns)[:40]}\n"
131
+ )
132
+ return prov_col, kab_col
133
+
134
+ # =========================
135
+ # NORMALISASI NAMA WILAYAH
136
+ # =========================
137
+ def norm_region_name(x: str) -> str:
138
+ if pd.isna(x):
139
+ return ""
140
+ s = str(x).strip().lower()
141
+ s = re.sub(r"[^\w\s]", " ", s)
142
+ s = re.sub(r"\s+", " ", s).strip()
143
+ for pfx in ["provinsi ", "propinsi "]:
144
+ if s.startswith(pfx):
145
+ s = s[len(pfx):].strip()
146
+ return s
147
+
148
+ def split_kabkota_type_name(x: str) -> Tuple[str, str]:
149
+ if pd.isna(x):
150
+ return ("", "")
151
+ raw = str(x).strip().lower()
152
+ s = re.sub(r"[^\w\s]", " ", raw)
153
+ s = re.sub(r"\s+", " ", s).strip()
154
+
155
+ if s.startswith("kabupaten "):
156
+ return ("kab", s[len("kabupaten "):].strip())
157
+ if s.startswith("kab. "):
158
+ return ("kab", s[len("kab. "):].strip())
159
+ if s.startswith("kab "):
160
+ return ("kab", s[len("kab "):].strip())
161
+ if s.startswith("kota. "):
162
+ return ("kota", s[len("kota. "):].strip())
163
+ if s.startswith("kota "):
164
+ return ("kota", s[len("kota "):].strip())
165
+ return ("", s)
166
+
167
+ def apply_exclusions_strict(df: pd.DataFrame, prov_col: str, kab_col: str):
168
+ d = df.copy()
169
+ d["_prov_norm"] = d[prov_col].apply(norm_region_name)
170
+
171
+ tmp = d[kab_col].apply(split_kabkota_type_name)
172
+ d["_kab_type"] = tmp.apply(lambda t: t[0])
173
+ d["_kab_name"] = tmp.apply(lambda t: t[1])
174
+ d["_kab_name_norm"] = d["_kab_name"].apply(norm_region_name)
175
+
176
+ ex_prov_drop = set(norm_region_name(p) for p in EXCLUDE_PROVINSI_DROP)
177
+ mask_prov_drop = d["_prov_norm"].isin(ex_prov_drop)
178
+
179
+ ex_triples = set(
180
+ (norm_region_name(p), str(t).strip().lower(), norm_region_name(n))
181
+ for p, t, n in EXCLUDE_KABKOTA_BY_PROV_TYPE
182
+ )
183
+
184
+ mask_triple = pd.Series(False, index=d.index)
185
+ m_has_type = d["_kab_type"].isin(["kab", "kota"])
186
+ mask_triple.loc[m_has_type] = [
187
+ (pv, kt, kn) in ex_triples
188
+ for pv, kt, kn in zip(
189
+ d.loc[m_has_type, "_prov_norm"],
190
+ d.loc[m_has_type, "_kab_type"].str.lower(),
191
+ d.loc[m_has_type, "_kab_name_norm"],
192
+ )
193
+ ]
194
+
195
+ # pengecualian Tasikmalaya: jangan hapus "Kota Tasikmalaya" bila yang di-exclude kab
196
+ raw_kab = d[kab_col].astype(str).str.lower()
197
+ is_tasik = (d["_prov_norm"] == norm_region_name("Jawa Barat")) & (d["_kab_name_norm"] == norm_region_name("Tasikmalaya"))
198
+ tasik_is_kota = raw_kab.str.contains(r"^\s*kota\b", regex=True)
199
+ mask_triple = mask_triple & ~(is_tasik & tasik_is_kota)
200
+
201
+ mask_exclude = mask_prov_drop | mask_triple
202
+
203
+ stats_info = {
204
+ "baris_awal": int(len(d)),
205
+ "terhapus_total": int(mask_exclude.sum()),
206
+ "terhapus_prov_drop": int(mask_prov_drop.sum()),
207
+ "terhapus_kabkota_drop": int((~mask_prov_drop & mask_triple).sum()),
208
+ "diolah": int((~mask_exclude).sum()),
209
+ }
210
+
211
+ audit_hapus = d.loc[mask_exclude, [prov_col, kab_col]].copy()
212
+
213
+ out = d.loc[~mask_exclude].copy().reset_index(drop=True)
214
+ out.drop(columns=["_prov_norm", "_kab_type", "_kab_name", "_kab_name_norm"], inplace=True, errors="ignore")
215
+ return out, stats_info, audit_hapus
216
+
217
+ # =========================
218
+ # UTIL
219
+ # =========================
220
+ def mean_if_enough(row: pd.Series, min_frac: float) -> float:
221
+ non_na = row.dropna()
222
+ if len(row) == 0:
223
+ return np.nan
224
+ return float(non_na.mean()) if (len(non_na) / len(row) >= min_frac) else np.nan
225
+
226
+ def detect_item_groups(columns: List[str]) -> Dict[str, List[str]]:
227
+ cols = [str(c).strip() for c in columns]
228
+ return {
229
+ "pra": [c for c in cols if str(c).strip().upper().startswith("B")],
230
+ "saat": [c for c in cols if str(c).strip().upper().startswith("C")],
231
+ "pasca": [c for c in cols if str(c).strip().upper().startswith("D")],
232
+ }
233
+
234
+ def faktor_penyesuaian(n: int, target: int = MIN_RESPONDEN_SLOVIN) -> float:
235
+ try:
236
+ n = int(n)
237
+ except Exception:
238
+ return np.nan
239
+ if target <= 0:
240
+ return np.nan
241
+ return float(min(max(n, 0) / target, 1.0))
242
+
243
+ def status_penyesuaian(n: int) -> str:
244
+ f = faktor_penyesuaian(n)
245
+ if not np.isfinite(f):
246
+ return ""
247
+ if f >= 1.0:
248
+ return f"✅ n≥{MIN_RESPONDEN_SLOVIN} (tanpa penyesuaian)"
249
+ return f"⚠️ n<{MIN_RESPONDEN_SLOVIN} (disesuaikan ×{f:.2f})"
250
+
251
+ def kategori_indeks_final(score_0_100: float) -> str:
252
+ x = pd.to_numeric(score_0_100, errors="coerce")
253
+ if not np.isfinite(x):
254
+ return ""
255
+ if x < 50:
256
+ return "sangat rendah"
257
+ elif x < 65:
258
+ return "rendah"
259
+ elif x < 80:
260
+ return "sedang"
261
+ elif x < 90:
262
+ return "tinggi"
263
+ else:
264
+ return "sangat tinggi"
265
+
266
+ def _fmt2(x: float) -> str:
267
+ v = pd.to_numeric(x, errors="coerce")
268
+ return f"{float(v):.2f}" if np.isfinite(v) else "—"
269
+
270
+ def _is_adjusted(status_sampel: str) -> bool:
271
+ s = (status_sampel or "").strip()
272
+ return ("⚠️" in s) or ("n<" in s.lower())
273
+
274
+ def _gap_profile(pra: float, saat: float, pasca: float) -> str:
275
+ arr = np.array([pd.to_numeric(pra, errors="coerce"),
276
+ pd.to_numeric(saat, errors="coerce"),
277
+ pd.to_numeric(pasca, errors="coerce")], dtype=float)
278
+ arr = arr[np.isfinite(arr)]
279
+ if len(arr) < 2:
280
+ return "tidak diketahui"
281
+ gap = float(arr.max() - arr.min())
282
+ if gap >= 20:
283
+ return "kesenjangan lebar"
284
+ if gap >= 10:
285
+ return "kesenjangan sedang"
286
+ return "relatif seimbang"
287
+
288
+ def _weakest_phase(pra: float, saat: float, pasca: float) -> str:
289
+ vals = {
290
+ "Pra": pd.to_numeric(pra, errors="coerce"),
291
+ "Saat": pd.to_numeric(saat, errors="coerce"),
292
+ "Pasca": pd.to_numeric(pasca, errors="coerce"),
293
+ }
294
+ clean = {k: float(v) for k, v in vals.items() if np.isfinite(v)}
295
+ if not clean:
296
+ return "lintas fase"
297
+ return min(clean.items(), key=lambda kv: kv[1])[0]
298
+
299
+ # =========================
300
+ # MSI pooled per fase
301
+ # =========================
302
+ def compute_msi_mapping_from_values(values: pd.Series,
303
+ min_cat: int = LIKERT_MIN,
304
+ max_cat: int = LIKERT_MAX) -> Dict[int, float]:
305
+ s = pd.to_numeric(values, errors="coerce").dropna().astype(int)
306
+ if s.empty:
307
+ return {cat: np.nan for cat in range(min_cat, max_cat + 1)}
308
+
309
+ cats = list(range(min_cat, max_cat + 1))
310
+ N = len(s)
311
+
312
+ counts = np.array([(s == cat).sum() for cat in cats], dtype=float)
313
+ p = counts / N
314
+ cum_p = np.cumsum(p)
315
+ boundaries = np.concatenate([[0.0], cum_p])
316
+
317
+ eps = 0.5 / N
318
+ boundaries[0] = max(boundaries[0], eps)
319
+ boundaries[-1] = min(boundaries[-1], 1 - eps)
320
+
321
+ z = stats.norm.ppf(boundaries)
322
+ msi_vals = [(z[i] + z[i + 1]) / 2.0 for i in range(len(cats))]
323
+ return {cat: float(val) for cat, val in zip(cats, msi_vals)}
324
+
325
+ def apply_msi_pooled_phase(df_phase: pd.DataFrame, cols: List[str]) -> Tuple[pd.DataFrame, Dict[int, float]]:
326
+ tmp = df_phase.copy()
327
+ pooled = pd.Series(tmp[cols].values.ravel())
328
+ mapping = compute_msi_mapping_from_values(pooled)
329
+ for c in cols:
330
+ tmp[c] = pd.to_numeric(tmp[c], errors="coerce").map(mapping)
331
+ return tmp, mapping
332
+
333
+ # =========================
334
+ # MIN–MAX GLOBAL
335
+ # =========================
336
+ def minmax_0_100_global(x: pd.Series) -> Tuple[pd.Series, float, float]:
337
+ s = pd.to_numeric(x, errors="coerce")
338
+ minv = float(s.min(skipna=True))
339
+ maxv = float(s.max(skipna=True))
340
+ if not np.isfinite(minv) or not np.isfinite(maxv) or maxv <= minv:
341
+ y = pd.Series(np.nan, index=s.index)
342
+ return y, minv, maxv
343
+ z = (s - minv) / (maxv - minv)
344
+ z = z.clip(0, 1)
345
+ y = (z * 100).round(2)
346
+ return y, minv, maxv
347
+
348
+ # =========================
349
+ # AGREGASI
350
+ # =========================
351
+ def weighted_mean(values: pd.Series, weights: pd.Series) -> float:
352
+ v = pd.to_numeric(values, errors="coerce")
353
+ w = pd.to_numeric(weights, errors="coerce")
354
+ m = v.notna() & w.notna() & (w > 0)
355
+ if not m.any():
356
+ return np.nan
357
+ return float(np.average(v[m], weights=w[m]))
358
+
359
+ def aggregate_prov_from_kab(kab_df: pd.DataFrame, prov_col: str) -> pd.DataFrame:
360
+ rows = []
361
+ for prov, g in kab_df.groupby(prov_col, dropna=False):
362
+ n_series = pd.to_numeric(g["n_responden"], errors="coerce").fillna(0)
363
+ any_adjusted = (n_series < MIN_RESPONDEN_SLOVIN).any()
364
+ rows.append({
365
+ prov_col: prov,
366
+ "tkm_final_mean": weighted_mean(g["Indeks_TKM_0_100_final"], g["n_responden"]),
367
+ "n_responden": int(n_series.sum()),
368
+ "prov_status": (
369
+ f"⚠️ Ada Kab/Kota n<{MIN_RESPONDEN_SLOVIN} (nilai Kab/Kota disesuaikan)"
370
+ if any_adjusted else
371
+ f"✅ Semua Kab/Kota n≥{MIN_RESPONDEN_SLOVIN} (tanpa penyesuaian)"
372
+ )
373
+ })
374
+ return pd.DataFrame(rows)
375
+
376
+ # =========================
377
+ # TEMPLATE KEBIJAKAN (ADAPTIF PER KATEGORI)
378
+ # =========================
379
+ def _frame_kategori(kat: str) -> Dict[str, str]:
380
+ if kat == "sangat rendah":
381
+ return {
382
+ "label": "pemulihan dasar",
383
+ "tekanan": "membangun fondasi program minimum yang terukur",
384
+ "resiko": "risiko kegagalan program tinggi bila fondasi tidak dibenahi",
385
+ "arah": "quick wins + paket minimum layanan",
386
+ }
387
+ if kat == "rendah":
388
+ return {
389
+ "label": "penguatan terstruktur",
390
+ "tekanan": "standardisasi pelaksanaan dan penguatan kapasitas pelaksana",
391
+ "resiko": "risiko inkonsistensi tinggi bila SOP dan pembinaan lemah",
392
+ "arah": "SOP + pelatihan + pembinaan periodik",
393
+ }
394
+ if kat == "sedang":
395
+ return {
396
+ "label": "stabilisasi mutu",
397
+ "tekanan": "kontrol mutu, konsistensi, dan pemerataan antar lokasi/pelaksana",
398
+ "resiko": "risiko stagnasi bila mutu tidak distabilkan",
399
+ "arah": "QA + monitoring rutin + perbaikan berbasis data",
400
+ }
401
+ if kat == "tinggi":
402
+ return {
403
+ "label": "pemantapan & replikasi selektif",
404
+ "tekanan": "mempertahankan mutu dan memperluas praktik baik secara terkurasi",
405
+ "resiko": "risiko penurunan bila kontrol mutu melemah",
406
+ "arah": "pemeliharaan mutu + inovasi terarah",
407
+ }
408
+ return {
409
+ "label": "praktik unggul & skalabilitas",
410
+ "tekanan": "menjaga keberlanjutan dan memperluas dampak dengan kelembagaan",
411
+ "resiko": "risiko utama pada keberlanjutan dan ketergantungan aktor",
412
+ "arah": "institusionalisasi + kemitraan + replikasi luas",
413
+ }
414
+
415
+ def interpretasi_dimensi_adaptif(dimensi: str,
416
+ nilai: float,
417
+ kategori: str,
418
+ status_sampel: str) -> str:
419
+ """
420
+ Interpretasi netral-deskriptif, tetapi isi dan arah kalimat menyesuaikan kategori baris.
421
+ """
422
+ f = _frame_kategori(kategori)
423
+ ncat = f"“{kategori}”"
424
+ vv = _fmt2(nilai)
425
+ note = ""
426
+ if _is_adjusted(status_sampel):
427
+ note = " Catatan: baris berada pada kelompok n<400 (disesuaikan), sehingga nilai dipengaruhi faktor penyesuaian sampel."
428
+
429
+ if dimensi == "Pra Membaca":
430
+ return (
431
+ f"Subindeks Pra ({vv}) pada kategori {ncat} menggambarkan kondisi kesiapan sebelum membaca. "
432
+ f"Pada tahap {f['label']}, fokus kebijakan diarahkan pada {f['tekanan']} terkait perencanaan sesi, kurasi bahan bacaan, "
433
+ f"serta kesiapan fasilitator/materi agar pelaksanaan tahap inti dapat berjalan konsisten.{note}"
434
+ )
435
+
436
+ if dimensi == "Saat Membaca":
437
+ return (
438
+ f"Subindeks Saat ({vv}) pada kategori {ncat} menggambarkan mutu pelaksanaan inti saat sesi membaca berlangsung. "
439
+ f"Pada tahap {f['label']}, kebutuhan utama berada pada {f['tekanan']} terkait strategi keterlibatan pembaca, fasilitasi, "
440
+ f"dan penguatan pemahaman selama proses agar sesi efektif dan berulang.{note}"
441
+ )
442
+
443
+ if dimensi == "Pasca Membaca":
444
+ return (
445
+ f"Subindeks Pasca ({vv}) pada kategori {ncat} menggambarkan tindak lanjut setelah membaca. "
446
+ f"Pada tahap {f['label']}, arah kebijakan menekankan {f['tekanan']} agar tindak lanjut tidak hanya terjadi, "
447
+ f"tetapi menghasilkan keluaran yang memperkuat kebiasaan membaca berkelanjutan.{note}"
448
+ )
449
+
450
+ # Indeks TKM
451
+ return (
452
+ f"Indeks TKM ({vv}) pada kategori {ncat} merefleksikan capaian komposit lintas fase (pra–saat–pasca). "
453
+ f"Pada tahap {f['label']}, kebijakan perlu difokuskan pada {f['tekanan']} agar capaian lintas fase terkonsolidasi "
454
+ f"menjadi perilaku membaca yang lebih stabil pada skala populasi.{note}"
455
+ )
456
+
457
+ def rekomendasi_dimensi_adaptif(dimensi: str,
458
+ nilai: float,
459
+ kategori: str,
460
+ pra: float,
461
+ saat: float,
462
+ pasca: float) -> str:
463
+ """
464
+ Rekomendasi spesifik per dimensi dan berbeda signifikan antar kategori.
465
+ """
466
+ vv = _fmt2(nilai)
467
+ f = _frame_kategori(kategori)
468
+
469
+ # ===== PRA =====
470
+ if dimensi == "Pra Membaca":
471
+ if kategori == "sangat rendah":
472
+ return (
473
+ f"Paket {f['label']} Pra Membaca ({vv}):\n"
474
+ "1) Susun kurasi minimum “starter pack” bacaan per titik layanan (berbasis sasaran).\n"
475
+ "2) Terapkan rencana sesi 1 halaman + checklist kesiapan (buku, alat bantu, pertanyaan pemantik, penataan ruang).\n"
476
+ "3) Pelatihan singkat fasilitator fokus persiapan (2–3 jam) agar standar minimum tercapai.\n"
477
+ "Indikator: % sesi punya rencana sesi & judul tercatat; jumlah titik punya starter pack."
478
+ )
479
+ if kategori == "rendah":
480
+ return (
481
+ f"Paket {f['label']} Pra Membaca ({vv}):\n"
482
+ "1) Standardisasi SOP perencanaan sesi + format rencana sesi seragam.\n"
483
+ "2) Pelatihan fasilitator + coaching awal untuk memastikan penerapan SOP.\n"
484
+ "3) Tetapkan penanggung jawab kualitas di tiap titik layanan.\n"
485
+ "Indikator: kepatuhan SOP; audit rencana sesi berkala; ketersediaan paket bacaan."
486
+ )
487
+ if kategori == "sedang":
488
+ return (
489
+ f"Paket {f['label']} Pra Membaca ({vv}):\n"
490
+ "1) Quality assurance perencanaan: review berkala rencana sesi & kurasi bacaan.\n"
491
+ "2) Perkuat konsistensi lintas lokasi (materi standar + pembinaan berbasis umpan balik).\n"
492
+ "3) Pemerataan kesiapan: pastikan titik layanan dengan nilai lebih rendah mendapat pendampingan.\n"
493
+ "Indikator: variasi antar titik menurun; kepatuhan rencana sesi meningkat; distribusi paket bacaan merata."
494
+ )
495
+ if kategori == "tinggi":
496
+ return (
497
+ f"Paket {f['label']} Pra Membaca ({vv}):\n"
498
+ "1) Pertahankan standar melalui QA rutin dan dokumentasi praktik baik.\n"
499
+ "2) Optimalkan kurasi berbasis data peminjaman/akses dan profil sasaran.\n"
500
+ "Indikator: mutu rencana sesi stabil; kepuasan fasilitator/peserta meningkat."
501
+ )
502
+ return (
503
+ f"Paket {f['label']} Pra Membaca ({vv}):\n"
504
+ "1) Institusionalisasikan standar kesiapan ke seluruh jejaring layanan.\n"
505
+ "2) Replikasi model pelatihan fasilitator + modul siap pakai lintas wilayah.\n"
506
+ "Indikator: replikasi bertambah; standar tetap terjaga; keberlanjutan pendanaan/SDM."
507
+ )
508
+
509
+ # ===== SAAT =====
510
+ if dimensi == "Saat Membaca":
511
+ if kategori == "sangat rendah":
512
+ return (
513
+ f"Paket {f['label']} Saat Membaca ({vv}):\n"
514
+ "1) Terapkan modul fasilitasi inti (membaca nyaring + tanya-jawab terstruktur + aktivitas 5 menit).\n"
515
+ "2) Panduan langkah-demi-langkah untuk sesi agar pelaksana tidak bergantung improvisasi.\n"
516
+ "3) Jadwal rutin minimal (mis. 2 sesi/minggu per titik) agar terbentuk kebiasaan.\n"
517
+ "Indikator: frekuensi sesi; % sesi menggunakan pertanyaan pemantik; repeat attendance."
518
+ )
519
+ if kategori == "rendah":
520
+ return (
521
+ f"Paket {f['label']} Saat Membaca ({vv}):\n"
522
+ "1) Standarkan teknik fasilitasi dan alat bantu (lembar aktivitas, daftar kosakata kunci).\n"
523
+ "2) Coaching berbasis observasi ringan (5 aspek) + umpan balik periodik.\n"
524
+ "3) Penguatan tata laksana ruang baca (zona nyaman, aturan sederhana, jadwal tetap).\n"
525
+ "Indikator: kepatuhan standar; kualitas interaksi meningkat; partisipasi ulang naik."
526
+ )
527
+ if kategori == "sedang":
528
+ return (
529
+ f"Paket {f['label']} Saat Membaca ({vv}):\n"
530
+ "1) QA pelaksanaan: peer review antar fasilitator + supervisi berkala.\n"
531
+ "2) Perbaikan berbasis data: gunakan log sesi (judul, metode, peserta, umpan balik) untuk koreksi rutin.\n"
532
+ "3) Pemerataan mutu: fokus pendampingan pada titik dengan variasi kualitas tinggi.\n"
533
+ "Indikator: variasi mutu turun; kepuasan peserta meningkat; konsistensi metode terjaga."
534
+ )
535
+ if kategori == "tinggi":
536
+ return (
537
+ f"Paket {f['label']} Saat Membaca ({vv}):\n"
538
+ "1) Pertahankan mutu melalui kontrol rutin dan inovasi aktivitas terarah.\n"
539
+ "2) Replikasi selektif praktik baik ke titik yang tertinggal.\n"
540
+ "Indikator: mutu stabil; inovasi tidak menurunkan konsistensi."
541
+ )
542
+ return (
543
+ f"Paket {f['label']} Saat Membaca ({vv}):\n"
544
+ "1) Skalakan model fasilitasi sebagai rujukan (pelatihan-of-trainers).\n"
545
+ "2) Bangun standar kompetensi fasilitator + sertifikasi internal sederhana.\n"
546
+ "Indikator: kapasitas meluas; kualitas tetap terjaga lintas wilayah."
547
+ )
548
+
549
+ # ===== PASCA =====
550
+ if dimensi == "Pasca Membaca":
551
+ if kategori == "sangat rendah":
552
+ return (
553
+ f"Paket {f['label']} Pasca Membaca ({vv}):\n"
554
+ "1) Aktifkan tindak lanjut minimum: refleksi singkat + tugas ringan (jurnal/kartu refleksi).\n"
555
+ "2) Jadwalkan tindak lanjut terstruktur agar tidak sporadis.\n"
556
+ "3) Hubungkan tindak lanjut dengan akses bacaan (pinjam/pojok baca/e-book legal).\n"
557
+ "Indikator: % peserta melakukan tindak lanjut; retensi; peminjaman/akses meningkat."
558
+ )
559
+ if kategori == "rendah":
560
+ return (
561
+ f"Paket {f['label']} Pasca Membaca ({vv}):\n"
562
+ "1) Standarkan format tindak lanjut (resume singkat, kartu refleksi, tantangan baca).\n"
563
+ "2) Libatkan keluarga/sekolah/komunitas sebagai dukungan kebiasaan.\n"
564
+ "3) Pelacakan sederhana (log tindak lanjut) untuk memastikan konsistensi.\n"
565
+ "Indikator: kepatuhan tindak lanjut; retensi; akses bacaan pasca sesi."
566
+ )
567
+ if kategori == "sedang":
568
+ return (
569
+ f"Paket {f['label']} Pasca Membaca ({vv}):\n"
570
+ "1) QA tindak lanjut: evaluasi keluaran (resume/refleksi) dan keterkaitan dengan akses bacaan.\n"
571
+ "2) Perbaikan berbasis umpan balik peserta untuk menjaga dampak.\n"
572
+ "3) Pemerataan kualitas tindak lanjut lintas titik layanan.\n"
573
+ "Indikator: kualitas keluaran meningkat; kebiasaan membaca lebih berulang; variasi antar titik menurun."
574
+ )
575
+ if kategori == "tinggi":
576
+ return (
577
+ f"Paket {f['label']} Pasca Membaca ({vv}):\n"
578
+ "1) Perkaya modul tindak lanjut (diskusi tematik, klub baca) sambil menjaga kontrol mutu.\n"
579
+ "2) Replikasi selektif paket tindak lanjut ke titik yang tertinggal.\n"
580
+ "Indikator: keberlanjutan terjaga; kualitas stabil."
581
+ )
582
+ return (
583
+ f"Paket {f['label']} Pasca Membaca ({vv}):\n"
584
+ "1) Institusionalisasikan tindak lanjut sebagai kultur (komunitas/klub baca berjejaring).\n"
585
+ "2) Skalakan jejaring dukungan lintas OPD/komunitas untuk keberlanjutan.\n"
586
+ "Indikator: dampak meluas; retensi tinggi; sistem dukungan kuat."
587
+ )
588
+
589
+ # ===== INDEKS =====
590
+ gap = _gap_profile(pra, saat, pasca)
591
+ weakest = _weakest_phase(pra, saat, pasca)
592
+
593
+ if kategori == "sangat rendah":
594
+ return (
595
+ f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n"
596
+ "A. Quick wins 90 hari (terukur)\n"
597
+ "1) Standarisasi “Siklus Membaca 3 Fase” di semua titik layanan (Pra→Saat→Pasca).\n"
598
+ "2) Re-fokus pada fase terlemah (utama: Pra & Saat) melalui pelatihan fasilitator + paket bacaan + panduan fasilitasi.\n"
599
+ "3) Target output jelas (mis. minimal 2 sesi/minggu per titik + minimal 1 tindak lanjut per sesi).\n"
600
+ f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}."
601
+ )
602
+ if kategori == "rendah":
603
+ return (
604
+ f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n"
605
+ "1) SOP pelaksanaan lintas fase + pembinaan periodik untuk memastikan standar dijalankan.\n"
606
+ "2) Penguatan kapasitas pelaksana (TOT fasilitator, jadwal rutin, log pelaksanaan).\n"
607
+ "3) Monitoring rutin berbasis indikator minimum (jumlah sesi, peserta, judul bacaan, tindak lanjut).\n"
608
+ f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}."
609
+ )
610
+ if kategori == "sedang":
611
+ return (
612
+ f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n"
613
+ "1) Quality assurance lintas fase (review rencana sesi, observasi fasilitasi, evaluasi tindak lanjut).\n"
614
+ "2) Monitoring rutin + perbaikan berbasis data untuk menurunkan variasi antar titik layanan.\n"
615
+ "3) Pemerataan: pendampingan ditargetkan pada titik dengan nilai fase terlemah.\n"
616
+ f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}."
617
+ )
618
+ if kategori == "tinggi":
619
+ return (
620
+ f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n"
621
+ "1) Pertahankan kontrol mutu dan konsistensi layanan.\n"
622
+ "2) Replikasi selektif praktik baik ke titik yang tertinggal.\n"
623
+ "3) Inovasi terarah tanpa mengurangi standar minimum.\n"
624
+ f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}."
625
+ )
626
+ return (
627
+ f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n"
628
+ "1) Institusionalisasikan program (kebijakan daerah, penganggaran berkelanjutan, standar SDM fasilitator).\n"
629
+ "2) Skalakan jejaring titik layanan (perpusda+TBM+sekolah+kelurahan+ruang publik).\n"
630
+ "3) Sistem monitoring sederhana yang stabil sebagai mekanisme akuntabilitas.\n"
631
+ f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}."
632
+ )
633
+
634
+ # =========================
635
+ # TABEL INTERPRETASI & REKOMENDASI (ADAPTIF)
636
+ # =========================
637
+ def build_interpretasi_rekom_table_adaptif(sub_pra: float,
638
+ sub_saat: float,
639
+ sub_pasca: float,
640
+ indeks_final: float,
641
+ status_sampel: str) -> pd.DataFrame:
642
+ k_pra = kategori_indeks_final(sub_pra)
643
+ k_saat = kategori_indeks_final(sub_saat)
644
+ k_pasca = kategori_indeks_final(sub_pasca)
645
+ k_indeks = kategori_indeks_final(indeks_final)
646
+
647
+ rows = [
648
+ {"No": 1, "Dimensi": "Pra Membaca", "Nilai": sub_pra, "Kategori": k_pra},
649
+ {"No": 2, "Dimensi": "Saat Membaca", "Nilai": sub_saat, "Kategori": k_saat},
650
+ {"No": 3, "Dimensi": "Pasca Membaca","Nilai": sub_pasca, "Kategori": k_pasca},
651
+ {"No": 4, "Dimensi": "Indeks TKM", "Nilai": indeks_final, "Kategori": k_indeks},
652
+ ]
653
+ df = pd.DataFrame(rows)
654
+
655
+ interps, reks = [], []
656
+ for _, r in df.iterrows():
657
+ dim = str(r["Dimensi"])
658
+ val = float(pd.to_numeric(r["Nilai"], errors="coerce")) if pd.notna(r["Nilai"]) else np.nan
659
+ kat = str(r["Kategori"])
660
+
661
+ interps.append(interpretasi_dimensi_adaptif(dim, val, kat, status_sampel))
662
+ reks.append(rekomendasi_dimensi_adaptif(dim, val, kat, sub_pra, sub_saat, sub_pasca))
663
+
664
+ df["Interpretasi"] = interps
665
+ df["Rekomendasi"] = reks
666
+ return df
667
+
668
+ # =========================
669
+ # DOCX TABLE HELPERS
670
+ # =========================
671
+ def _docx_set_cell(cell, text: str, bold: bool = False, align_center: bool = False):
672
+ cell.text = "" if text is None else str(text)
673
+ for p in cell.paragraphs:
674
+ for run in p.runs:
675
+ run.bold = bold
676
+ if align_center:
677
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
678
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
679
+
680
+ def add_interpretasi_rekom_table_docx(doc: "Document", df_table: pd.DataFrame):
681
+ doc.add_heading("Tabel Interpretasi dan Rekomendasi Kebijakan (Pra/Saat/Pasca/Indeks TKM)", level=2)
682
+
683
+ if df_table is None or df_table.empty:
684
+ doc.add_paragraph("Tidak ada data untuk tabel interpretasi & rekomendasi.")
685
+ doc.add_paragraph("")
686
+ return
687
+
688
+ cols = ["No", "Dimensi", "Nilai", "Kategori", "Interpretasi", "Rekomendasi"]
689
+ df2 = df_table[cols].copy()
690
+
691
+ table = doc.add_table(rows=1, cols=len(cols))
692
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
693
+
694
+ widths = [Inches(0.5), Inches(1.3), Inches(0.85), Inches(1.0), Inches(3.0), Inches(3.0)]
695
+ try:
696
+ for i, w in enumerate(widths):
697
+ table.columns[i].width = w
698
+ except Exception:
699
+ pass
700
+
701
+ hdr = table.rows[0].cells
702
+ for j, c in enumerate(cols):
703
+ _docx_set_cell(hdr[j], c, bold=True, align_center=True)
704
+
705
+ for _, r in df2.iterrows():
706
+ cells = table.add_row().cells
707
+ _docx_set_cell(cells[0], str(int(r["No"])) if pd.notna(r["No"]) else "", align_center=True)
708
+ _docx_set_cell(cells[1], str(r["Dimensi"]) if pd.notna(r["Dimensi"]) else "", align_center=False)
709
+
710
+ v = pd.to_numeric(r["Nilai"], errors="coerce")
711
+ _docx_set_cell(cells[2], f"{float(v):.2f}" if np.isfinite(v) else "", align_center=True)
712
+
713
+ _docx_set_cell(cells[3], str(r["Kategori"]) if pd.notna(r["Kategori"]) else "", align_center=True)
714
+ _docx_set_cell(cells[4], str(r["Interpretasi"]) if pd.notna(r["Interpretasi"]) else "", align_center=False)
715
+ _docx_set_cell(cells[5], str(r["Rekomendasi"]) if pd.notna(r["Rekomendasi"]) else "", align_center=False)
716
+
717
+ doc.add_paragraph("")
718
+
719
+ # =========================
720
+ # PLOT HELPERS
721
+ # =========================
722
+ def empty_figure(msg: str):
723
+ fig, ax = plt.subplots(figsize=(8, 3))
724
+ ax.text(0.5, 0.5, msg, ha="center", va="center")
725
+ ax.axis("off")
726
+ fig.tight_layout()
727
+ return fig
728
+
729
+ def plot_dimensi_bar_0_100(pra_0_100: float, saat_0_100: float, pasca_0_100: float, title: str):
730
+ fig, ax = plt.subplots(figsize=(6, 4))
731
+ labels = ["Pra (0–100)", "Saat (0–100)", "Pasca (0–100)"]
732
+ vals = [pra_0_100, saat_0_100, pasca_0_100]
733
+ ax.bar(labels, vals)
734
+ ax.set_ylabel("Skor (0–100)")
735
+ ax.set_title(title)
736
+ ax.set_ylim(0, 100)
737
+ ax.grid(True, linestyle="--", alpha=0.25)
738
+ for i, v in enumerate(vals):
739
+ if pd.isna(v) or not np.isfinite(v):
740
+ continue
741
+ ax.text(i, v + 1.2, f"{v:.2f}", ha="center", va="bottom")
742
+ fig.tight_layout()
743
+ return fig
744
+
745
+ # =========================
746
+ # LOAD DATA + HITUNG SEKALI
747
+ # =========================
748
+ DATA_FILE = Path(DATA_PATH)
749
+ if not DATA_FILE.exists():
750
+ raise FileNotFoundError(f"File data tidak ditemukan: {DATA_PATH}")
751
+
752
+ df_raw = pd.read_excel(DATA_FILE)
753
+ PROV_COL, KABKOT_COL = detect_region_cols(df_raw)
754
+
755
+ df_raw[PROV_COL] = df_raw[PROV_COL].astype(str).fillna("").replace({"nan": ""}).str.strip()
756
+ df_raw[KABKOT_COL] = df_raw[KABKOT_COL].astype(str).fillna("").replace({"nan": ""}).str.strip()
757
+
758
+ df_clean, excl_stats, audit_hapus = apply_exclusions_strict(df_raw, PROV_COL, KABKOT_COL)
759
+
760
+ GROUP_COLS = detect_item_groups(df_clean.columns.tolist())
761
+ df_idx = df_clean.copy()
762
+
763
+ phase_maps: Dict[str, Dict[int, float]] = {}
764
+ for phase in ["pra", "saat", "pasca"]:
765
+ cols = GROUP_COLS.get(phase, [])
766
+ if not cols:
767
+ df_idx[f"subidx_{phase}_msi"] = np.nan
768
+ phase_maps[phase] = {1: np.nan, 4: np.nan}
769
+ continue
770
+ tmp = df_idx[cols].copy()
771
+ tmp_msi, mapping = apply_msi_pooled_phase(tmp, cols)
772
+ phase_maps[phase] = mapping
773
+ df_idx[f"subidx_{phase}_msi"] = tmp_msi.apply(
774
+ lambda r: mean_if_enough(r, MIN_FRAC_AVAILABLE_PER_SUBINDEX),
775
+ axis=1
776
+ )
777
+
778
+ df_idx["subidx_pra_0_100"], MIN_PRA, MAX_PRA = minmax_0_100_global(df_idx["subidx_pra_msi"])
779
+ df_idx["subidx_saat_0_100"], MIN_SAAT, MAX_SAAT = minmax_0_100_global(df_idx["subidx_saat_msi"])
780
+ df_idx["subidx_pasca_0_100"],MIN_PAS, MAX_PAS = minmax_0_100_global(df_idx["subidx_pasca_msi"])
781
+
782
+ w_sum = float(sum(WEIGHTS.values()))
783
+ df_idx["index_msi_raw"] = (
784
+ WEIGHTS["pra"] * df_idx["subidx_pra_msi"] +
785
+ WEIGHTS["saat"] * df_idx["subidx_saat_msi"] +
786
+ WEIGHTS["pasca"]* df_idx["subidx_pasca_msi"]
787
+ ) / w_sum
788
+
789
+ df_idx["index_0_100"], MIN_DATA, MAX_DATA = minmax_0_100_global(df_idx["index_msi_raw"])
790
+
791
+ # =========================
792
+ # CORE PIPELINE PER FILTER
793
+ # =========================
794
+ def compute_outputs(provinsi: str, kabkota: str):
795
+ sub = df_idx.copy()
796
+ if provinsi != "(Semua)":
797
+ sub = sub[sub[PROV_COL] == provinsi]
798
+ if kabkota != "(Semua)":
799
+ sub = sub[sub[KABKOT_COL] == kabkota]
800
+
801
+ if sub.empty:
802
+ hero = "## Ringkasan Eksekutif\n⚠️ Tidak ada data pada filter ini."
803
+ info = "⚠️ Tidak ada data."
804
+ empty = pd.DataFrame()
805
+ return (
806
+ hero, info,
807
+ empty, empty, empty,
808
+ empty, pd.DataFrame(columns=["Komponen", "Nilai"]),
809
+ empty_figure("Pilih Kab/Kota."),
810
+ )
811
+
812
+ kab = (
813
+ sub.groupby([PROV_COL, KABKOT_COL], dropna=False)
814
+ .agg(
815
+ indeks_mean=("index_0_100", "mean"),
816
+ n_responden=(KABKOT_COL, "size"),
817
+ subidx_pra_0_100_mean=("subidx_pra_0_100", "mean"),
818
+ subidx_saat_0_100_mean=("subidx_saat_0_100", "mean"),
819
+ subidx_pasca_0_100_mean=("subidx_pasca_0_100", "mean"),
820
+ )
821
+ .reset_index()
822
+ )
823
+
824
+ kab["_faktor_internal"] = kab["n_responden"].apply(faktor_penyesuaian)
825
+ kab["status_sampel"] = kab["n_responden"].apply(status_penyesuaian)
826
+ kab["_raw_internal"] = pd.to_numeric(kab["indeks_mean"], errors="coerce")
827
+ kab["Indeks_TKM_0_100_final"] = (kab["_raw_internal"] * kab["_faktor_internal"]).round(2)
828
+ kab["Kategori_Indeks_TKM_FINAL"] = kab["Indeks_TKM_0_100_final"].apply(kategori_indeks_final)
829
+
830
+ out_kab_all = kab.rename(columns={PROV_COL: "Provinsi", KABKOT_COL: "Wilayah"})[
831
+ [
832
+ "Provinsi",
833
+ "Wilayah",
834
+ "subidx_pra_0_100_mean",
835
+ "subidx_saat_0_100_mean",
836
+ "subidx_pasca_0_100_mean",
837
+ "Indeks_TKM_0_100_final",
838
+ "Kategori_Indeks_TKM_FINAL",
839
+ "n_responden",
840
+ "status_sampel",
841
+ ]
842
+ ].rename(
843
+ columns={
844
+ "subidx_pra_0_100_mean": "SubIndeks_Pra_0_100",
845
+ "subidx_saat_0_100_mean": "SubIndeks_Saat_0_100",
846
+ "subidx_pasca_0_100_mean": "SubIndeks_Pasca_0_100",
847
+ "Indeks_TKM_0_100_final": "Indeks_TKM_0_100",
848
+ }
849
+ ).sort_values(["Provinsi", "Wilayah"])
850
+
851
+ out_kab_valid = out_kab_all[out_kab_all["n_responden"] >= MIN_RESPONDEN_SLOVIN]
852
+ out_kab_adjusted = out_kab_all[out_kab_all["n_responden"] < MIN_RESPONDEN_SLOVIN]
853
+
854
+ prov_from_kab = aggregate_prov_from_kab(kab, PROV_COL).rename(columns={PROV_COL: "Provinsi"})
855
+ prov_from_kab["Indeks_TKM_0_100"] = pd.to_numeric(prov_from_kab["tkm_final_mean"], errors="coerce").round(2)
856
+ prov_from_kab["Kategori_Indeks_TKM_FINAL"] = prov_from_kab["Indeks_TKM_0_100"].apply(kategori_indeks_final)
857
+ prov_from_kab["Status"] = prov_from_kab["prov_status"]
858
+
859
+ no_agg = set(norm_region_name(p) for p in EXCLUDE_PROVINSI_NO_AGG)
860
+ mask_no_agg = prov_from_kab["Provinsi"].map(norm_region_name).isin(no_agg)
861
+ prov_from_kab.loc[mask_no_agg, "Indeks_TKM_0_100"] = np.nan
862
+ prov_from_kab.loc[mask_no_agg, "Kategori_Indeks_TKM_FINAL"] = ""
863
+ prov_from_kab.loc[mask_no_agg, "Status"] = "🚫 EXCLUDED (kelembagaan): provinsi ditampilkan tetapi agregat tidak dihitung"
864
+
865
+ out_prov = prov_from_kab[["Provinsi", "Indeks_TKM_0_100", "Kategori_Indeks_TKM_FINAL", "n_responden", "Status"]]
866
+
867
+ hero = (
868
+ "## Ringkasan Eksekutif\n"
869
+ f"- Exclusion (STRICT) sebelum MSI: terhapus **{excl_stats['terhapus_total']}** dari **{excl_stats['baris_awal']}**, diolah **{excl_stats['diolah']}**.\n"
870
+ f"- Penyesuaian sampel Kab/Kota: target **{MIN_RESPONDEN_SLOVIN}**; n<{MIN_RESPONDEN_SLOVIN} → indeks dikali faktor min(n/{MIN_RESPONDEN_SLOVIN},1).\n"
871
+ )
872
+ info = f"Responden pada filter: **{len(sub)}**"
873
+
874
+ # detail plot untuk kab/kota terpilih
875
+ detail_plot = empty_figure("Pilih Kab/Kota untuk melihat SubIndeks.")
876
+ detail_df = pd.DataFrame(columns=["Komponen", "Nilai"])
877
+ if kabkota != "(Semua)":
878
+ row = out_kab_all[out_kab_all["Wilayah"] == kabkota].head(1)
879
+ if not row.empty:
880
+ r0 = row.iloc[0]
881
+ detail_df = pd.DataFrame([
882
+ {"Komponen": "SubIndeks_Pra_0_100", "Nilai": r0["SubIndeks_Pra_0_100"]},
883
+ {"Komponen": "SubIndeks_Saat_0_100", "Nilai": r0["SubIndeks_Saat_0_100"]},
884
+ {"Komponen": "SubIndeks_Pasca_0_100", "Nilai": r0["SubIndeks_Pasca_0_100"]},
885
+ {"Komponen": "Indeks_TKM_0_100", "Nilai": r0["Indeks_TKM_0_100"]},
886
+ {"Komponen": "Kategori_Indeks_TKM_FINAL", "Nilai": r0["Kategori_Indeks_TKM_FINAL"]},
887
+ {"Komponen": "n_responden", "Nilai": r0["n_responden"]},
888
+ {"Komponen": "status_sampel", "Nilai": r0["status_sampel"]},
889
+ ])
890
+ pra_v = pd.to_numeric(r0["SubIndeks_Pra_0_100"], errors="coerce")
891
+ saat_v = pd.to_numeric(r0["SubIndeks_Saat_0_100"], errors="coerce")
892
+ pas_v = pd.to_numeric(r0["SubIndeks_Pasca_0_100"], errors="coerce")
893
+ detail_plot = plot_dimensi_bar_0_100(
894
+ float(pra_v) if np.isfinite(pra_v) else np.nan,
895
+ float(saat_v) if np.isfinite(saat_v) else np.nan,
896
+ float(pas_v) if np.isfinite(pas_v) else np.nan,
897
+ f"SubIndeks 0–100 — {kabkota}"
898
+ )
899
+
900
+ return (
901
+ hero, info,
902
+ out_kab_valid, out_prov, out_kab_adjusted,
903
+ out_kab_all,
904
+ detail_df, detail_plot
905
+ )
906
+
907
+ # =========================
908
+ # EXPORT WORD
909
+ # =========================
910
+ def _now_tag() -> str:
911
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
912
+
913
+ def export_word_report(provinsi: str, kabkota: str):
914
+ if not DOCX_AVAILABLE:
915
+ out = str(Path.cwd() / f"LAPORAN_TKM_{_now_tag()}.txt")
916
+ with open(out, "w", encoding="utf-8") as f:
917
+ f.write("python-docx tidak tersedia. Install: pip install python-docx\n")
918
+ return out
919
+
920
+ hero, info, out_kab_valid, out_prov, out_kab_adjusted, out_kab_all, detail_df, _ = compute_outputs(provinsi, kabkota)
921
+
922
+ chosen = None
923
+ if kabkota != "(Semua)":
924
+ pick = out_kab_all[out_kab_all["Wilayah"] == kabkota].head(1)
925
+ if not pick.empty:
926
+ chosen = pick.iloc[0]
927
+ else:
928
+ if len(out_kab_all) == 1:
929
+ chosen = out_kab_all.iloc[0]
930
+
931
+ doc = Document()
932
+ style = doc.styles["Normal"]
933
+ style.font.name = "Calibri"
934
+ style.font.size = Pt(11)
935
+
936
+ title = doc.add_paragraph("TABEL INTERPRETASI DAN REKOMENDASI")
937
+ title.runs[0].bold = True
938
+ title.alignment = WD_ALIGN_PARAGRAPH.CENTER
939
+
940
+ subp = doc.add_paragraph(f"Tanggal: {datetime.now().strftime('%d %B %Y')} | Filter: Provinsi={provinsi}, Kab/Kota={kabkota}")
941
+ subp.alignment = WD_ALIGN_PARAGRAPH.CENTER
942
+ doc.add_paragraph("")
943
+
944
+ doc.add_heading("Ringkasan Dashboard", level=1)
945
+ doc.add_paragraph(hero.replace("## ", ""))
946
+ doc.add_paragraph(info.replace("**", ""))
947
+
948
+ doc.add_paragraph("")
949
+ doc.add_heading("Tabel Interpretasi dan Rekomendasi Kebijakan (Pra/Saat/Pasca/Indeks TKM)", level=1)
950
+
951
+ if chosen is None:
952
+ doc.add_paragraph(
953
+ "Tabel interpretasi & rekomendasi memerlukan pemilihan Kab/Kota tertentu, "
954
+ "atau kondisi filter menghasilkan tepat satu Kab/Kota."
955
+ )
956
+ else:
957
+ sub_pra = float(pd.to_numeric(chosen.get("SubIndeks_Pra_0_100", np.nan), errors="coerce"))
958
+ sub_saat = float(pd.to_numeric(chosen.get("SubIndeks_Saat_0_100", np.nan), errors="coerce"))
959
+ sub_pasca = float(pd.to_numeric(chosen.get("SubIndeks_Pasca_0_100", np.nan), errors="coerce"))
960
+ idx_final = float(pd.to_numeric(chosen.get("Indeks_TKM_0_100", np.nan), errors="coerce"))
961
+ status_sampel = str(chosen.get("status_sampel", "")).strip()
962
+
963
+ df_tbl = build_interpretasi_rekom_table_adaptif(sub_pra, sub_saat, sub_pasca, idx_final, status_sampel)
964
+ add_interpretasi_rekom_table_docx(doc, df_tbl)
965
+
966
+ fn = f"LAPORAN_TKM_TABEL_{_now_tag()}_{provinsi.replace(' ', '_')}_{kabkota.replace(' ', '_')}.docx"
967
+ fn = fn.replace("/", "_").replace("\\", "_")
968
+ out_path = str(Path.cwd() / fn)
969
+ doc.save(out_path)
970
+ return out_path
971
+
972
+ # =========================
973
+ # EXPORT EXCEL (opsional)
974
+ # =========================
975
+ def _safe_sheet_name(name: str) -> str:
976
+ bad = ['\\', '/', '*', '[', ']', ':', '?']
977
+ out = name
978
+ for b in bad:
979
+ out = out.replace(b, " ")
980
+ out = " ".join(out.split()).strip()
981
+ return out[:31] if out else "Sheet1"
982
+
983
+ def export_excel(provinsi: str, kabkota: str):
984
+ hero, info, out_kab_valid, out_prov, out_kab_adjusted, out_kab_all, detail_df, _ = compute_outputs(provinsi, kabkota)
985
+
986
+ chosen = None
987
+ if kabkota != "(Semua)":
988
+ pick = out_kab_all[out_kab_all["Wilayah"] == kabkota].head(1)
989
+ if not pick.empty:
990
+ chosen = pick.iloc[0]
991
+ else:
992
+ if len(out_kab_all) == 1:
993
+ chosen = out_kab_all.iloc[0]
994
+
995
+ tbl = pd.DataFrame()
996
+ if chosen is not None:
997
+ sub_pra = float(pd.to_numeric(chosen.get("SubIndeks_Pra_0_100", np.nan), errors="coerce"))
998
+ sub_saat = float(pd.to_numeric(chosen.get("SubIndeks_Saat_0_100", np.nan), errors="coerce"))
999
+ sub_pasca = float(pd.to_numeric(chosen.get("SubIndeks_Pasca_0_100", np.nan), errors="coerce"))
1000
+ idx_final = float(pd.to_numeric(chosen.get("Indeks_TKM_0_100", np.nan), errors="coerce"))
1001
+ status_sampel = str(chosen.get("status_sampel", "")).strip()
1002
+ tbl = build_interpretasi_rekom_table_adaptif(sub_pra, sub_saat, sub_pasca, idx_final, status_sampel)
1003
+
1004
+ fn = f"OUTPUT_TKM_{_now_tag()}_{provinsi.replace(' ', '_')}_{kabkota.replace(' ', '_')}.xlsx"
1005
+ fn = fn.replace("/", "_").replace("\\", "_")
1006
+ out_path = str(Path.cwd() / fn)
1007
+
1008
+ with pd.ExcelWriter(out_path, engine="openpyxl") as writer:
1009
+ pd.DataFrame({"Hero_MD": [hero], "Info_MD": [info]}).to_excel(writer, sheet_name=_safe_sheet_name("RINGKASAN"), index=False)
1010
+ (out_kab_valid if out_kab_valid is not None else pd.DataFrame()).to_excel(writer, sheet_name=_safe_sheet_name("KabKota_Valid"), index=False)
1011
+ (out_kab_adjusted if out_kab_adjusted is not None else pd.DataFrame()).to_excel(writer, sheet_name=_safe_sheet_name("KabKota_Adjusted"), index=False)
1012
+ (out_prov if out_prov is not None else pd.DataFrame()).to_excel(writer, sheet_name=_safe_sheet_name("Provinsi"), index=False)
1013
+ (out_kab_all if out_kab_all is not None else pd.DataFrame()).to_excel(writer, sheet_name=_safe_sheet_name("KabKota_All"), index=False)
1014
+ (tbl if tbl is not None else pd.DataFrame()).to_excel(writer, sheet_name=_safe_sheet_name("Tabel_Interpretasi"), index=False)
1015
+
1016
+ return out_path
1017
+
1018
+ # =========================
1019
+ # GRADIO UI
1020
+ # =========================
1021
+ def get_prov_choices():
1022
+ provs = sorted([p for p in df_idx[PROV_COL].dropna().unique().tolist() if str(p).strip() != ""])
1023
+ return ["(Semua)"] + provs
1024
+
1025
+ def update_kab_dropdown(provinsi: str):
1026
+ if provinsi == "(Semua)":
1027
+ kabs = sorted([k for k in df_idx[KABKOT_COL].dropna().unique().tolist() if str(k).strip() != ""])
1028
+ else:
1029
+ sub = df_idx[df_idx[PROV_COL] == provinsi]
1030
+ kabs = sorted([k for k in sub[KABKOT_COL].dropna().unique().tolist() if str(k).strip() != ""])
1031
+ return gr.update(choices=["(Semua)"] + kabs, value="(Semua)")
1032
+
1033
+ with gr.Blocks(title="Dashboard TKM (MSI) — Interpretasi & Rekomendasi Adaptif") as demo:
1034
+ gr.Markdown(
1035
+ f"""
1036
+ # Dashboard Indeks TKM (MSI)
1037
+
1038
+ Kategori Final (0–100):
1039
+ - < 50 : sangat rendah
1040
+ - 50 – < 65 : rendah
1041
+ - 65 – < 80 : sedang
1042
+ - 80 – < 90 : tinggi
1043
+ - ≥ 90 : sangat tinggi
1044
+
1045
+ **Interpretasi & rekomendasi selalu menyesuaikan NILAI dan KATEGORI tiap baris (Pra/Saat/Pasca/Indeks).**
1046
+ """
1047
+ )
1048
+
1049
+ with gr.Row():
1050
+ dd_prov = gr.Dropdown(label="Provinsi", choices=get_prov_choices(), value="(Semua)")
1051
+ dd_kab = gr.Dropdown(label="Kab/Kota", choices=["(Semua)"], value="(Semua)")
1052
+
1053
+ run_btn = gr.Button("Jalankan", variant="primary")
1054
+ hero_md = gr.Markdown()
1055
+ info_md = gr.Markdown()
1056
+
1057
+ with gr.Row():
1058
+ btn_xlsx = gr.Button("⬇️ Download Excel", variant="secondary")
1059
+ file_xlsx = gr.File(label="File Excel", interactive=False)
1060
+ btn_docx = gr.Button("⬇️ Download Laporan Word (Tabel Interpretasi & Rekomendasi)", variant="secondary")
1061
+ file_docx = gr.File(label="File Word", interactive=False)
1062
+
1063
+ with gr.Accordion(f"🟢 Kab/Kota n≥{MIN_RESPONDEN_SLOVIN}", open=True):
1064
+ out_kab_valid_ui = gr.DataFrame(interactive=False)
1065
+
1066
+ with gr.Accordion("🟠 Kab/Kota n<400 (disesuaikan)", open=False):
1067
+ out_kab_adjusted_ui = gr.DataFrame(interactive=False)
1068
+
1069
+ with gr.Accordion("🔵 Provinsi (dari Kab/Kota final)", open=False):
1070
+ out_prov_ui = gr.DataFrame(interactive=False)
1071
+
1072
+ with gr.Accordion("🟢 Detail SubIndeks (Kab/Kota terpilih)", open=False):
1073
+ detail_df_ui = gr.DataFrame(interactive=False)
1074
+ detail_plot_ui = gr.Plot()
1075
+
1076
+ dd_prov.change(fn=update_kab_dropdown, inputs=dd_prov, outputs=dd_kab)
1077
+
1078
+ def _run(prov, kab):
1079
+ hero, info, out_kab_valid, out_prov, out_kab_adjusted, out_kab_all, detail_df, detail_plot = compute_outputs(prov, kab)
1080
+ return hero, info, out_kab_valid, out_kab_adjusted, out_prov, detail_df, detail_plot
1081
+
1082
+ run_btn.click(
1083
+ fn=_run,
1084
+ inputs=[dd_prov, dd_kab],
1085
+ outputs=[hero_md, info_md, out_kab_valid_ui, out_kab_adjusted_ui, out_prov_ui, detail_df_ui, detail_plot_ui],
1086
+ )
1087
+ dd_kab.change(
1088
+ fn=_run,
1089
+ inputs=[dd_prov, dd_kab],
1090
+ outputs=[hero_md, info_md, out_kab_valid_ui, out_kab_adjusted_ui, out_prov_ui, detail_df_ui, detail_plot_ui],
1091
+ )
1092
+
1093
+ btn_docx.click(fn=export_word_report, inputs=[dd_prov, dd_kab], outputs=[file_docx])
1094
+ btn_xlsx.click(fn=export_excel, inputs=[dd_prov, dd_kab], outputs=[file_xlsx])
1095
+
1096
+ if __name__ == "__main__":
1097
+ demo.launch()