Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| """ | |
| TKM Dashboard — MSI pooled + Exclusion (STRICT) + Penyesuaian Sampel ala IPLM | |
| + Normalisasi MIN–MAX GLOBAL di LEVEL RESPONDEN sebelum agregasi wilayah | |
| + Export Excel + Export Word (.docx) dengan TABEL Interpretasi & Rekomendasi (Pra/Saat/Pasca/Indeks TKM) | |
| UPDATE UTAMA (sesuai instruksi terakhir Anda): | |
| ✅ Interpretasi & rekomendasi sekarang SELALU menyesuaikan: | |
| - NILAI (angka) dan KATEGORI (sangat rendah / rendah / sedang / tinggi / sangat tinggi) | |
| ✅ Isi antar kategori dibuat “beda signifikan”: | |
| - sangat rendah: pemulihan dasar (foundation recovery) | |
| - rendah : penguatan terstruktur (SOP + kapasitas) | |
| - sedang : stabilisasi mutu (QA, konsistensi, pemerataan) | |
| - tinggi : pemantapan + replikasi selektif | |
| - sangat tinggi: praktik unggul + skalabilitas & keberlanjutan | |
| ✅ Catatan n<400 (disesuaikan) hanya muncul bila memang baris berada pada kelompok disesuaikan. | |
| ✅ Tabel Word menampilkan per baris: | |
| Pra / Saat / Pasca / Indeks → interpretasi & rekomendasi yang relevan dengan kategori baris itu. | |
| Catatan: | |
| - FULL CODE (no ringkas). | |
| """ | |
| from pathlib import Path | |
| from typing import Dict, List, Tuple, Optional | |
| from datetime import datetime | |
| import re | |
| import numpy as np | |
| import pandas as pd | |
| from scipy import stats | |
| import matplotlib.pyplot as plt | |
| import gradio as gr | |
| # docx | |
| try: | |
| from docx import Document | |
| from docx.shared import Pt, Inches | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| from docx.enum.table import WD_TABLE_ALIGNMENT, WD_ALIGN_VERTICAL | |
| DOCX_AVAILABLE = True | |
| except Exception: | |
| DOCX_AVAILABLE = False | |
| import openpyxl # noqa: F401 | |
| np.random.seed(42) | |
| # ========================= | |
| # KONFIGURASI | |
| # ========================= | |
| DATA_PATH = "DATA_TKM_28_JANUARI_2026.xlsx" | |
| WEIGHTS = {"pra": 0.15, "saat": 0.50, "pasca": 0.35} | |
| LIKERT_MIN, LIKERT_MAX = 1, 4 | |
| MIN_FRAC_AVAILABLE_PER_SUBINDEX = 0.50 | |
| MIN_RESPONDEN_SLOVIN = 400 | |
| # ========================= | |
| # EXCLUSION LIST (STRICT) | |
| # ========================= | |
| EXCLUDE_PROVINSI_DROP = [] | |
| EXCLUDE_PROVINSI_NO_AGG = [ | |
| "Bali", | |
| "Papua Barat Daya", | |
| "Papua Pegunungan", | |
| "Papua Selatan", | |
| "Papua Tengah", | |
| ] | |
| EXCLUDE_KABKOTA_BY_PROV_TYPE: List[Tuple[str, str, str]] = [ | |
| ("Bali", "kab", "Bangli"), | |
| ("Bali", "kab", "Jembrana"), | |
| ("Sumatera Utara", "kab", "Humbang Hasundutan"), | |
| ("Lampung", "kab", "Pesisir Barat"), | |
| ("Papua", "kab", "Biak Numfor"), | |
| ("Papua Tengah", "kab", "Nabire"), | |
| ("Jawa Barat", "kab", "Tasikmalaya"), | |
| ("Kalimantan Timur", "kab", "Mahakam Ulu"), | |
| ("Papua Barat", "kab", "Sorong Selatan"), | |
| ("Papua Barat Daya", "kab", "Tambrauw"), | |
| ("Maluku Utara", "kab", "Halmahera Timur"), | |
| ("Papua", "kab", "Mamberamo Raya"), | |
| ("Papua Tengah", "kab", "Puncak"), | |
| ("Maluku", "kab", "Seram Bagian Barat"), | |
| ] | |
| # ========================= | |
| # AUTO-DETECT KOLOM WILAYAH | |
| # ========================= | |
| def _norm(s: str) -> str: | |
| return ( | |
| str(s).strip().lower() | |
| .replace(" ", "") | |
| .replace("-", "") | |
| .replace("/", "") | |
| .replace("\\", "") | |
| .replace(".", "") | |
| .replace(",", "") | |
| ) | |
| def detect_region_cols(df: pd.DataFrame) -> Tuple[str, str]: | |
| cols = list(df.columns) | |
| norm_map = {_norm(c): c for c in cols} | |
| prov_candidates = ["provinsiasal", "provinsi asal", "provinsi", "province", "namaprovinsi", "prov"] | |
| kab_candidates = [ | |
| "kabkota", "kab_kota", "kabkotaasal", "kabupatenkota", "kabupatenkotaasal", | |
| "kab/kota", "kabupaten/kota", "kabupaten", "kota", "kab" | |
| ] | |
| prov_col = None | |
| for cand in prov_candidates: | |
| if _norm(cand) in norm_map: | |
| prov_col = norm_map[_norm(cand)] | |
| break | |
| kab_col = None | |
| for cand in kab_candidates: | |
| if _norm(cand) in norm_map: | |
| kab_col = norm_map[_norm(cand)] | |
| break | |
| if prov_col is None or kab_col is None: | |
| raise ValueError( | |
| "Kolom wilayah tidak terdeteksi.\n" | |
| f"Kolom tersedia (contoh 40): {list(df.columns)[:40]}\n" | |
| ) | |
| return prov_col, kab_col | |
| # ========================= | |
| # NORMALISASI NAMA WILAYAH | |
| # ========================= | |
| def norm_region_name(x: str) -> str: | |
| if pd.isna(x): | |
| return "" | |
| s = str(x).strip().lower() | |
| s = re.sub(r"[^\w\s]", " ", s) | |
| s = re.sub(r"\s+", " ", s).strip() | |
| for pfx in ["provinsi ", "propinsi "]: | |
| if s.startswith(pfx): | |
| s = s[len(pfx):].strip() | |
| return s | |
| def split_kabkota_type_name(x: str) -> Tuple[str, str]: | |
| if pd.isna(x): | |
| return ("", "") | |
| raw = str(x).strip().lower() | |
| s = re.sub(r"[^\w\s]", " ", raw) | |
| s = re.sub(r"\s+", " ", s).strip() | |
| if s.startswith("kabupaten "): | |
| return ("kab", s[len("kabupaten "):].strip()) | |
| if s.startswith("kab. "): | |
| return ("kab", s[len("kab. "):].strip()) | |
| if s.startswith("kab "): | |
| return ("kab", s[len("kab "):].strip()) | |
| if s.startswith("kota. "): | |
| return ("kota", s[len("kota. "):].strip()) | |
| if s.startswith("kota "): | |
| return ("kota", s[len("kota "):].strip()) | |
| return ("", s) | |
| def apply_exclusions_strict(df: pd.DataFrame, prov_col: str, kab_col: str): | |
| d = df.copy() | |
| d["_prov_norm"] = d[prov_col].apply(norm_region_name) | |
| tmp = d[kab_col].apply(split_kabkota_type_name) | |
| d["_kab_type"] = tmp.apply(lambda t: t[0]) | |
| d["_kab_name"] = tmp.apply(lambda t: t[1]) | |
| d["_kab_name_norm"] = d["_kab_name"].apply(norm_region_name) | |
| ex_prov_drop = set(norm_region_name(p) for p in EXCLUDE_PROVINSI_DROP) | |
| mask_prov_drop = d["_prov_norm"].isin(ex_prov_drop) | |
| ex_triples = set( | |
| (norm_region_name(p), str(t).strip().lower(), norm_region_name(n)) | |
| for p, t, n in EXCLUDE_KABKOTA_BY_PROV_TYPE | |
| ) | |
| mask_triple = pd.Series(False, index=d.index) | |
| m_has_type = d["_kab_type"].isin(["kab", "kota"]) | |
| mask_triple.loc[m_has_type] = [ | |
| (pv, kt, kn) in ex_triples | |
| for pv, kt, kn in zip( | |
| d.loc[m_has_type, "_prov_norm"], | |
| d.loc[m_has_type, "_kab_type"].str.lower(), | |
| d.loc[m_has_type, "_kab_name_norm"], | |
| ) | |
| ] | |
| # pengecualian Tasikmalaya: jangan hapus "Kota Tasikmalaya" bila yang di-exclude kab | |
| raw_kab = d[kab_col].astype(str).str.lower() | |
| is_tasik = (d["_prov_norm"] == norm_region_name("Jawa Barat")) & (d["_kab_name_norm"] == norm_region_name("Tasikmalaya")) | |
| tasik_is_kota = raw_kab.str.contains(r"^\s*kota\b", regex=True) | |
| mask_triple = mask_triple & ~(is_tasik & tasik_is_kota) | |
| mask_exclude = mask_prov_drop | mask_triple | |
| stats_info = { | |
| "baris_awal": int(len(d)), | |
| "terhapus_total": int(mask_exclude.sum()), | |
| "terhapus_prov_drop": int(mask_prov_drop.sum()), | |
| "terhapus_kabkota_drop": int((~mask_prov_drop & mask_triple).sum()), | |
| "diolah": int((~mask_exclude).sum()), | |
| } | |
| audit_hapus = d.loc[mask_exclude, [prov_col, kab_col]].copy() | |
| out = d.loc[~mask_exclude].copy().reset_index(drop=True) | |
| out.drop(columns=["_prov_norm", "_kab_type", "_kab_name", "_kab_name_norm"], inplace=True, errors="ignore") | |
| return out, stats_info, audit_hapus | |
| # ========================= | |
| # UTIL | |
| # ========================= | |
| def mean_if_enough(row: pd.Series, min_frac: float) -> float: | |
| non_na = row.dropna() | |
| if len(row) == 0: | |
| return np.nan | |
| return float(non_na.mean()) if (len(non_na) / len(row) >= min_frac) else np.nan | |
| def detect_item_groups(columns: List[str]) -> Dict[str, List[str]]: | |
| cols = [str(c).strip() for c in columns] | |
| return { | |
| "pra": [c for c in cols if str(c).strip().upper().startswith("B")], | |
| "saat": [c for c in cols if str(c).strip().upper().startswith("C")], | |
| "pasca": [c for c in cols if str(c).strip().upper().startswith("D")], | |
| } | |
| def faktor_penyesuaian(n: int, target: int = MIN_RESPONDEN_SLOVIN) -> float: | |
| try: | |
| n = int(n) | |
| except Exception: | |
| return np.nan | |
| if target <= 0: | |
| return np.nan | |
| return float(min(max(n, 0) / target, 1.0)) | |
| def status_penyesuaian(n: int) -> str: | |
| f = faktor_penyesuaian(n) | |
| if not np.isfinite(f): | |
| return "" | |
| if f >= 1.0: | |
| return f"✅ n≥{MIN_RESPONDEN_SLOVIN} (tanpa penyesuaian)" | |
| return f"⚠️ n<{MIN_RESPONDEN_SLOVIN} (disesuaikan ×{f:.2f})" | |
| def kategori_indeks_final(score_0_100: float) -> str: | |
| x = pd.to_numeric(score_0_100, errors="coerce") | |
| if not np.isfinite(x): | |
| return "" | |
| if x < 50: | |
| return "sangat rendah" | |
| elif x < 65: | |
| return "rendah" | |
| elif x < 80: | |
| return "sedang" | |
| elif x < 90: | |
| return "tinggi" | |
| else: | |
| return "sangat tinggi" | |
| def _fmt2(x: float) -> str: | |
| v = pd.to_numeric(x, errors="coerce") | |
| return f"{float(v):.2f}" if np.isfinite(v) else "—" | |
| def _is_adjusted(status_sampel: str) -> bool: | |
| s = (status_sampel or "").strip() | |
| return ("⚠️" in s) or ("n<" in s.lower()) | |
| def _gap_profile(pra: float, saat: float, pasca: float) -> str: | |
| arr = np.array([pd.to_numeric(pra, errors="coerce"), | |
| pd.to_numeric(saat, errors="coerce"), | |
| pd.to_numeric(pasca, errors="coerce")], dtype=float) | |
| arr = arr[np.isfinite(arr)] | |
| if len(arr) < 2: | |
| return "tidak diketahui" | |
| gap = float(arr.max() - arr.min()) | |
| if gap >= 20: | |
| return "kesenjangan lebar" | |
| if gap >= 10: | |
| return "kesenjangan sedang" | |
| return "relatif seimbang" | |
| def _weakest_phase(pra: float, saat: float, pasca: float) -> str: | |
| vals = { | |
| "Pra": pd.to_numeric(pra, errors="coerce"), | |
| "Saat": pd.to_numeric(saat, errors="coerce"), | |
| "Pasca": pd.to_numeric(pasca, errors="coerce"), | |
| } | |
| clean = {k: float(v) for k, v in vals.items() if np.isfinite(v)} | |
| if not clean: | |
| return "lintas fase" | |
| return min(clean.items(), key=lambda kv: kv[1])[0] | |
| # ========================= | |
| # MSI pooled per fase | |
| # ========================= | |
| def compute_msi_mapping_from_values(values: pd.Series, | |
| min_cat: int = LIKERT_MIN, | |
| max_cat: int = LIKERT_MAX) -> Dict[int, float]: | |
| s = pd.to_numeric(values, errors="coerce").dropna().astype(int) | |
| if s.empty: | |
| return {cat: np.nan for cat in range(min_cat, max_cat + 1)} | |
| cats = list(range(min_cat, max_cat + 1)) | |
| N = len(s) | |
| counts = np.array([(s == cat).sum() for cat in cats], dtype=float) | |
| p = counts / N | |
| cum_p = np.cumsum(p) | |
| boundaries = np.concatenate([[0.0], cum_p]) | |
| eps = 0.5 / N | |
| boundaries[0] = max(boundaries[0], eps) | |
| boundaries[-1] = min(boundaries[-1], 1 - eps) | |
| z = stats.norm.ppf(boundaries) | |
| msi_vals = [(z[i] + z[i + 1]) / 2.0 for i in range(len(cats))] | |
| return {cat: float(val) for cat, val in zip(cats, msi_vals)} | |
| def apply_msi_pooled_phase(df_phase: pd.DataFrame, cols: List[str]) -> Tuple[pd.DataFrame, Dict[int, float]]: | |
| tmp = df_phase.copy() | |
| pooled = pd.Series(tmp[cols].values.ravel()) | |
| mapping = compute_msi_mapping_from_values(pooled) | |
| for c in cols: | |
| tmp[c] = pd.to_numeric(tmp[c], errors="coerce").map(mapping) | |
| return tmp, mapping | |
| # ========================= | |
| # MIN–MAX GLOBAL | |
| # ========================= | |
| def minmax_0_100_global(x: pd.Series) -> Tuple[pd.Series, float, float]: | |
| s = pd.to_numeric(x, errors="coerce") | |
| minv = float(s.min(skipna=True)) | |
| maxv = float(s.max(skipna=True)) | |
| if not np.isfinite(minv) or not np.isfinite(maxv) or maxv <= minv: | |
| y = pd.Series(np.nan, index=s.index) | |
| return y, minv, maxv | |
| z = (s - minv) / (maxv - minv) | |
| z = z.clip(0, 1) | |
| y = (z * 100).round(2) | |
| return y, minv, maxv | |
| # ========================= | |
| # AGREGASI | |
| # ========================= | |
| def weighted_mean(values: pd.Series, weights: pd.Series) -> float: | |
| v = pd.to_numeric(values, errors="coerce") | |
| w = pd.to_numeric(weights, errors="coerce") | |
| m = v.notna() & w.notna() & (w > 0) | |
| if not m.any(): | |
| return np.nan | |
| return float(np.average(v[m], weights=w[m])) | |
| def aggregate_prov_from_kab(kab_df: pd.DataFrame, prov_col: str) -> pd.DataFrame: | |
| rows = [] | |
| for prov, g in kab_df.groupby(prov_col, dropna=False): | |
| n_series = pd.to_numeric(g["n_responden"], errors="coerce").fillna(0) | |
| any_adjusted = (n_series < MIN_RESPONDEN_SLOVIN).any() | |
| rows.append({ | |
| prov_col: prov, | |
| "tkm_final_mean": weighted_mean(g["Indeks_TKM_0_100_final"], g["n_responden"]), | |
| "n_responden": int(n_series.sum()), | |
| "prov_status": ( | |
| f"⚠️ Ada Kab/Kota n<{MIN_RESPONDEN_SLOVIN} (nilai Kab/Kota disesuaikan)" | |
| if any_adjusted else | |
| f"✅ Semua Kab/Kota n≥{MIN_RESPONDEN_SLOVIN} (tanpa penyesuaian)" | |
| ) | |
| }) | |
| return pd.DataFrame(rows) | |
| # ========================= | |
| # TEMPLATE KEBIJAKAN (ADAPTIF PER KATEGORI) | |
| # ========================= | |
| def _frame_kategori(kat: str) -> Dict[str, str]: | |
| if kat == "sangat rendah": | |
| return { | |
| "label": "pemulihan dasar", | |
| "tekanan": "membangun fondasi program minimum yang terukur", | |
| "resiko": "risiko kegagalan program tinggi bila fondasi tidak dibenahi", | |
| "arah": "quick wins + paket minimum layanan", | |
| } | |
| if kat == "rendah": | |
| return { | |
| "label": "penguatan terstruktur", | |
| "tekanan": "standardisasi pelaksanaan dan penguatan kapasitas pelaksana", | |
| "resiko": "risiko inkonsistensi tinggi bila SOP dan pembinaan lemah", | |
| "arah": "SOP + pelatihan + pembinaan periodik", | |
| } | |
| if kat == "sedang": | |
| return { | |
| "label": "stabilisasi mutu", | |
| "tekanan": "kontrol mutu, konsistensi, dan pemerataan antar lokasi/pelaksana", | |
| "resiko": "risiko stagnasi bila mutu tidak distabilkan", | |
| "arah": "QA + monitoring rutin + perbaikan berbasis data", | |
| } | |
| if kat == "tinggi": | |
| return { | |
| "label": "pemantapan & replikasi selektif", | |
| "tekanan": "mempertahankan mutu dan memperluas praktik baik secara terkurasi", | |
| "resiko": "risiko penurunan bila kontrol mutu melemah", | |
| "arah": "pemeliharaan mutu + inovasi terarah", | |
| } | |
| return { | |
| "label": "praktik unggul & skalabilitas", | |
| "tekanan": "menjaga keberlanjutan dan memperluas dampak dengan kelembagaan", | |
| "resiko": "risiko utama pada keberlanjutan dan ketergantungan aktor", | |
| "arah": "institusionalisasi + kemitraan + replikasi luas", | |
| } | |
| def interpretasi_dimensi_adaptif(dimensi: str, | |
| nilai: float, | |
| kategori: str, | |
| status_sampel: str) -> str: | |
| """ | |
| Interpretasi netral-deskriptif, tetapi isi dan arah kalimat menyesuaikan kategori baris. | |
| """ | |
| f = _frame_kategori(kategori) | |
| ncat = f"“{kategori}”" | |
| vv = _fmt2(nilai) | |
| note = "" | |
| if _is_adjusted(status_sampel): | |
| note = " Catatan: baris berada pada kelompok n<400 (disesuaikan), sehingga nilai dipengaruhi faktor penyesuaian sampel." | |
| if dimensi == "Pra Membaca": | |
| return ( | |
| f"Subindeks Pra ({vv}) pada kategori {ncat} menggambarkan kondisi kesiapan sebelum membaca. " | |
| f"Pada tahap {f['label']}, fokus kebijakan diarahkan pada {f['tekanan']} terkait perencanaan sesi, kurasi bahan bacaan, " | |
| f"serta kesiapan fasilitator/materi agar pelaksanaan tahap inti dapat berjalan konsisten.{note}" | |
| ) | |
| if dimensi == "Saat Membaca": | |
| return ( | |
| f"Subindeks Saat ({vv}) pada kategori {ncat} menggambarkan mutu pelaksanaan inti saat sesi membaca berlangsung. " | |
| f"Pada tahap {f['label']}, kebutuhan utama berada pada {f['tekanan']} terkait strategi keterlibatan pembaca, fasilitasi, " | |
| f"dan penguatan pemahaman selama proses agar sesi efektif dan berulang.{note}" | |
| ) | |
| if dimensi == "Pasca Membaca": | |
| return ( | |
| f"Subindeks Pasca ({vv}) pada kategori {ncat} menggambarkan tindak lanjut setelah membaca. " | |
| f"Pada tahap {f['label']}, arah kebijakan menekankan {f['tekanan']} agar tindak lanjut tidak hanya terjadi, " | |
| f"tetapi menghasilkan keluaran yang memperkuat kebiasaan membaca berkelanjutan.{note}" | |
| ) | |
| # Indeks TKM | |
| return ( | |
| f"Indeks TKM ({vv}) pada kategori {ncat} merefleksikan capaian komposit lintas fase (pra–saat–pasca). " | |
| f"Pada tahap {f['label']}, kebijakan perlu difokuskan pada {f['tekanan']} agar capaian lintas fase terkonsolidasi " | |
| f"menjadi perilaku membaca yang lebih stabil pada skala populasi.{note}" | |
| ) | |
| def rekomendasi_dimensi_adaptif(dimensi: str, | |
| nilai: float, | |
| kategori: str, | |
| pra: float, | |
| saat: float, | |
| pasca: float) -> str: | |
| """ | |
| Rekomendasi spesifik per dimensi dan berbeda signifikan antar kategori. | |
| """ | |
| vv = _fmt2(nilai) | |
| f = _frame_kategori(kategori) | |
| # ===== PRA ===== | |
| if dimensi == "Pra Membaca": | |
| if kategori == "sangat rendah": | |
| return ( | |
| f"Paket {f['label']} Pra Membaca ({vv}):\n" | |
| "1) Susun kurasi minimum “starter pack” bacaan per titik layanan (berbasis sasaran).\n" | |
| "2) Terapkan rencana sesi 1 halaman + checklist kesiapan (buku, alat bantu, pertanyaan pemantik, penataan ruang).\n" | |
| "3) Pelatihan singkat fasilitator fokus persiapan (2–3 jam) agar standar minimum tercapai.\n" | |
| "Indikator: % sesi punya rencana sesi & judul tercatat; jumlah titik punya starter pack." | |
| ) | |
| if kategori == "rendah": | |
| return ( | |
| f"Paket {f['label']} Pra Membaca ({vv}):\n" | |
| "1) Standardisasi SOP perencanaan sesi + format rencana sesi seragam.\n" | |
| "2) Pelatihan fasilitator + coaching awal untuk memastikan penerapan SOP.\n" | |
| "3) Tetapkan penanggung jawab kualitas di tiap titik layanan.\n" | |
| "Indikator: kepatuhan SOP; audit rencana sesi berkala; ketersediaan paket bacaan." | |
| ) | |
| if kategori == "sedang": | |
| return ( | |
| f"Paket {f['label']} Pra Membaca ({vv}):\n" | |
| "1) Quality assurance perencanaan: review berkala rencana sesi & kurasi bacaan.\n" | |
| "2) Perkuat konsistensi lintas lokasi (materi standar + pembinaan berbasis umpan balik).\n" | |
| "3) Pemerataan kesiapan: pastikan titik layanan dengan nilai lebih rendah mendapat pendampingan.\n" | |
| "Indikator: variasi antar titik menurun; kepatuhan rencana sesi meningkat; distribusi paket bacaan merata." | |
| ) | |
| if kategori == "tinggi": | |
| return ( | |
| f"Paket {f['label']} Pra Membaca ({vv}):\n" | |
| "1) Pertahankan standar melalui QA rutin dan dokumentasi praktik baik.\n" | |
| "2) Optimalkan kurasi berbasis data peminjaman/akses dan profil sasaran.\n" | |
| "Indikator: mutu rencana sesi stabil; kepuasan fasilitator/peserta meningkat." | |
| ) | |
| return ( | |
| f"Paket {f['label']} Pra Membaca ({vv}):\n" | |
| "1) Institusionalisasikan standar kesiapan ke seluruh jejaring layanan.\n" | |
| "2) Replikasi model pelatihan fasilitator + modul siap pakai lintas wilayah.\n" | |
| "Indikator: replikasi bertambah; standar tetap terjaga; keberlanjutan pendanaan/SDM." | |
| ) | |
| # ===== SAAT ===== | |
| if dimensi == "Saat Membaca": | |
| if kategori == "sangat rendah": | |
| return ( | |
| f"Paket {f['label']} Saat Membaca ({vv}):\n" | |
| "1) Terapkan modul fasilitasi inti (membaca nyaring + tanya-jawab terstruktur + aktivitas 5 menit).\n" | |
| "2) Panduan langkah-demi-langkah untuk sesi agar pelaksana tidak bergantung improvisasi.\n" | |
| "3) Jadwal rutin minimal (mis. 2 sesi/minggu per titik) agar terbentuk kebiasaan.\n" | |
| "Indikator: frekuensi sesi; % sesi menggunakan pertanyaan pemantik; repeat attendance." | |
| ) | |
| if kategori == "rendah": | |
| return ( | |
| f"Paket {f['label']} Saat Membaca ({vv}):\n" | |
| "1) Standarkan teknik fasilitasi dan alat bantu (lembar aktivitas, daftar kosakata kunci).\n" | |
| "2) Coaching berbasis observasi ringan (5 aspek) + umpan balik periodik.\n" | |
| "3) Penguatan tata laksana ruang baca (zona nyaman, aturan sederhana, jadwal tetap).\n" | |
| "Indikator: kepatuhan standar; kualitas interaksi meningkat; partisipasi ulang naik." | |
| ) | |
| if kategori == "sedang": | |
| return ( | |
| f"Paket {f['label']} Saat Membaca ({vv}):\n" | |
| "1) QA pelaksanaan: peer review antar fasilitator + supervisi berkala.\n" | |
| "2) Perbaikan berbasis data: gunakan log sesi (judul, metode, peserta, umpan balik) untuk koreksi rutin.\n" | |
| "3) Pemerataan mutu: fokus pendampingan pada titik dengan variasi kualitas tinggi.\n" | |
| "Indikator: variasi mutu turun; kepuasan peserta meningkat; konsistensi metode terjaga." | |
| ) | |
| if kategori == "tinggi": | |
| return ( | |
| f"Paket {f['label']} Saat Membaca ({vv}):\n" | |
| "1) Pertahankan mutu melalui kontrol rutin dan inovasi aktivitas terarah.\n" | |
| "2) Replikasi selektif praktik baik ke titik yang tertinggal.\n" | |
| "Indikator: mutu stabil; inovasi tidak menurunkan konsistensi." | |
| ) | |
| return ( | |
| f"Paket {f['label']} Saat Membaca ({vv}):\n" | |
| "1) Skalakan model fasilitasi sebagai rujukan (pelatihan-of-trainers).\n" | |
| "2) Bangun standar kompetensi fasilitator + sertifikasi internal sederhana.\n" | |
| "Indikator: kapasitas meluas; kualitas tetap terjaga lintas wilayah." | |
| ) | |
| # ===== PASCA ===== | |
| if dimensi == "Pasca Membaca": | |
| if kategori == "sangat rendah": | |
| return ( | |
| f"Paket {f['label']} Pasca Membaca ({vv}):\n" | |
| "1) Aktifkan tindak lanjut minimum: refleksi singkat + tugas ringan (jurnal/kartu refleksi).\n" | |
| "2) Jadwalkan tindak lanjut terstruktur agar tidak sporadis.\n" | |
| "3) Hubungkan tindak lanjut dengan akses bacaan (pinjam/pojok baca/e-book legal).\n" | |
| "Indikator: % peserta melakukan tindak lanjut; retensi; peminjaman/akses meningkat." | |
| ) | |
| if kategori == "rendah": | |
| return ( | |
| f"Paket {f['label']} Pasca Membaca ({vv}):\n" | |
| "1) Standarkan format tindak lanjut (resume singkat, kartu refleksi, tantangan baca).\n" | |
| "2) Libatkan keluarga/sekolah/komunitas sebagai dukungan kebiasaan.\n" | |
| "3) Pelacakan sederhana (log tindak lanjut) untuk memastikan konsistensi.\n" | |
| "Indikator: kepatuhan tindak lanjut; retensi; akses bacaan pasca sesi." | |
| ) | |
| if kategori == "sedang": | |
| return ( | |
| f"Paket {f['label']} Pasca Membaca ({vv}):\n" | |
| "1) QA tindak lanjut: evaluasi keluaran (resume/refleksi) dan keterkaitan dengan akses bacaan.\n" | |
| "2) Perbaikan berbasis umpan balik peserta untuk menjaga dampak.\n" | |
| "3) Pemerataan kualitas tindak lanjut lintas titik layanan.\n" | |
| "Indikator: kualitas keluaran meningkat; kebiasaan membaca lebih berulang; variasi antar titik menurun." | |
| ) | |
| if kategori == "tinggi": | |
| return ( | |
| f"Paket {f['label']} Pasca Membaca ({vv}):\n" | |
| "1) Perkaya modul tindak lanjut (diskusi tematik, klub baca) sambil menjaga kontrol mutu.\n" | |
| "2) Replikasi selektif paket tindak lanjut ke titik yang tertinggal.\n" | |
| "Indikator: keberlanjutan terjaga; kualitas stabil." | |
| ) | |
| return ( | |
| f"Paket {f['label']} Pasca Membaca ({vv}):\n" | |
| "1) Institusionalisasikan tindak lanjut sebagai kultur (komunitas/klub baca berjejaring).\n" | |
| "2) Skalakan jejaring dukungan lintas OPD/komunitas untuk keberlanjutan.\n" | |
| "Indikator: dampak meluas; retensi tinggi; sistem dukungan kuat." | |
| ) | |
| # ===== INDEKS ===== | |
| gap = _gap_profile(pra, saat, pasca) | |
| weakest = _weakest_phase(pra, saat, pasca) | |
| if kategori == "sangat rendah": | |
| return ( | |
| f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n" | |
| "A. Quick wins 90 hari (terukur)\n" | |
| "1) Standarisasi “Siklus Membaca 3 Fase” di semua titik layanan (Pra→Saat→Pasca).\n" | |
| "2) Re-fokus pada fase terlemah (utama: Pra & Saat) melalui pelatihan fasilitator + paket bacaan + panduan fasilitasi.\n" | |
| "3) Target output jelas (mis. minimal 2 sesi/minggu per titik + minimal 1 tindak lanjut per sesi).\n" | |
| f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}." | |
| ) | |
| if kategori == "rendah": | |
| return ( | |
| f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n" | |
| "1) SOP pelaksanaan lintas fase + pembinaan periodik untuk memastikan standar dijalankan.\n" | |
| "2) Penguatan kapasitas pelaksana (TOT fasilitator, jadwal rutin, log pelaksanaan).\n" | |
| "3) Monitoring rutin berbasis indikator minimum (jumlah sesi, peserta, judul bacaan, tindak lanjut).\n" | |
| f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}." | |
| ) | |
| if kategori == "sedang": | |
| return ( | |
| f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n" | |
| "1) Quality assurance lintas fase (review rencana sesi, observasi fasilitasi, evaluasi tindak lanjut).\n" | |
| "2) Monitoring rutin + perbaikan berbasis data untuk menurunkan variasi antar titik layanan.\n" | |
| "3) Pemerataan: pendampingan ditargetkan pada titik dengan nilai fase terlemah.\n" | |
| f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}." | |
| ) | |
| if kategori == "tinggi": | |
| return ( | |
| f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n" | |
| "1) Pertahankan kontrol mutu dan konsistensi layanan.\n" | |
| "2) Replikasi selektif praktik baik ke titik yang tertinggal.\n" | |
| "3) Inovasi terarah tanpa mengurangi standar minimum.\n" | |
| f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}." | |
| ) | |
| return ( | |
| f"Rencana {f['label']} untuk Indeks TKM ({vv}):\n" | |
| "1) Institusionalisasikan program (kebijakan daerah, penganggaran berkelanjutan, standar SDM fasilitator).\n" | |
| "2) Skalakan jejaring titik layanan (perpusda+TBM+sekolah+kelurahan+ruang publik).\n" | |
| "3) Sistem monitoring sederhana yang stabil sebagai mekanisme akuntabilitas.\n" | |
| f"Catatan teknis: profil antarfase {gap}; fase terlemah saat ini: {weakest}." | |
| ) | |
| # ========================= | |
| # TABEL INTERPRETASI & REKOMENDASI (ADAPTIF) | |
| # ========================= | |
| def build_interpretasi_rekom_table_adaptif(sub_pra: float, | |
| sub_saat: float, | |
| sub_pasca: float, | |
| indeks_final: float, | |
| status_sampel: str) -> pd.DataFrame: | |
| k_pra = kategori_indeks_final(sub_pra) | |
| k_saat = kategori_indeks_final(sub_saat) | |
| k_pasca = kategori_indeks_final(sub_pasca) | |
| k_indeks = kategori_indeks_final(indeks_final) | |
| rows = [ | |
| {"No": 1, "Dimensi": "Pra Membaca", "Nilai": sub_pra, "Kategori": k_pra}, | |
| {"No": 2, "Dimensi": "Saat Membaca", "Nilai": sub_saat, "Kategori": k_saat}, | |
| {"No": 3, "Dimensi": "Pasca Membaca","Nilai": sub_pasca, "Kategori": k_pasca}, | |
| {"No": 4, "Dimensi": "Indeks TKM", "Nilai": indeks_final, "Kategori": k_indeks}, | |
| ] | |
| df = pd.DataFrame(rows) | |
| interps, reks = [], [] | |
| for _, r in df.iterrows(): | |
| dim = str(r["Dimensi"]) | |
| val = float(pd.to_numeric(r["Nilai"], errors="coerce")) if pd.notna(r["Nilai"]) else np.nan | |
| kat = str(r["Kategori"]) | |
| interps.append(interpretasi_dimensi_adaptif(dim, val, kat, status_sampel)) | |
| reks.append(rekomendasi_dimensi_adaptif(dim, val, kat, sub_pra, sub_saat, sub_pasca)) | |
| df["Interpretasi"] = interps | |
| df["Rekomendasi"] = reks | |
| return df | |
| # ========================= | |
| # DOCX TABLE HELPERS | |
| # ========================= | |
| def _docx_set_cell(cell, text: str, bold: bool = False, align_center: bool = False): | |
| cell.text = "" if text is None else str(text) | |
| for p in cell.paragraphs: | |
| for run in p.runs: | |
| run.bold = bold | |
| if align_center: | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER | |
| def add_interpretasi_rekom_table_docx(doc: "Document", df_table: pd.DataFrame): | |
| doc.add_heading("Tabel Interpretasi dan Rekomendasi Kebijakan (Pra/Saat/Pasca/Indeks TKM)", level=2) | |
| if df_table is None or df_table.empty: | |
| doc.add_paragraph("Tidak ada data untuk tabel interpretasi & rekomendasi.") | |
| doc.add_paragraph("") | |
| return | |
| cols = ["No", "Dimensi", "Nilai", "Kategori", "Interpretasi", "Rekomendasi"] | |
| df2 = df_table[cols].copy() | |
| table = doc.add_table(rows=1, cols=len(cols)) | |
| table.alignment = WD_TABLE_ALIGNMENT.CENTER | |
| widths = [Inches(0.5), Inches(1.3), Inches(0.85), Inches(1.0), Inches(3.0), Inches(3.0)] | |
| try: | |
| for i, w in enumerate(widths): | |
| table.columns[i].width = w | |
| except Exception: | |
| pass | |
| hdr = table.rows[0].cells | |
| for j, c in enumerate(cols): | |
| _docx_set_cell(hdr[j], c, bold=True, align_center=True) | |
| for _, r in df2.iterrows(): | |
| cells = table.add_row().cells | |
| _docx_set_cell(cells[0], str(int(r["No"])) if pd.notna(r["No"]) else "", align_center=True) | |
| _docx_set_cell(cells[1], str(r["Dimensi"]) if pd.notna(r["Dimensi"]) else "", align_center=False) | |
| v = pd.to_numeric(r["Nilai"], errors="coerce") | |
| _docx_set_cell(cells[2], f"{float(v):.2f}" if np.isfinite(v) else "", align_center=True) | |
| _docx_set_cell(cells[3], str(r["Kategori"]) if pd.notna(r["Kategori"]) else "", align_center=True) | |
| _docx_set_cell(cells[4], str(r["Interpretasi"]) if pd.notna(r["Interpretasi"]) else "", align_center=False) | |
| _docx_set_cell(cells[5], str(r["Rekomendasi"]) if pd.notna(r["Rekomendasi"]) else "", align_center=False) | |
| doc.add_paragraph("") | |
| # ========================= | |
| # PLOT HELPERS | |
| # ========================= | |
| def empty_figure(msg: str): | |
| fig, ax = plt.subplots(figsize=(8, 3)) | |
| ax.text(0.5, 0.5, msg, ha="center", va="center") | |
| ax.axis("off") | |
| fig.tight_layout() | |
| return fig | |
| def plot_dimensi_bar_0_100(pra_0_100: float, saat_0_100: float, pasca_0_100: float, title: str): | |
| fig, ax = plt.subplots(figsize=(6, 4)) | |
| labels = ["Pra (0–100)", "Saat (0–100)", "Pasca (0–100)"] | |
| vals = [pra_0_100, saat_0_100, pasca_0_100] | |
| ax.bar(labels, vals) | |
| ax.set_ylabel("Skor (0–100)") | |
| ax.set_title(title) | |
| ax.set_ylim(0, 100) | |
| ax.grid(True, linestyle="--", alpha=0.25) | |
| for i, v in enumerate(vals): | |
| if pd.isna(v) or not np.isfinite(v): | |
| continue | |
| ax.text(i, v + 1.2, f"{v:.2f}", ha="center", va="bottom") | |
| fig.tight_layout() | |
| return fig | |
| # ========================= | |
| # LOAD DATA + HITUNG SEKALI | |
| # ========================= | |
| DATA_FILE = Path(DATA_PATH) | |
| if not DATA_FILE.exists(): | |
| raise FileNotFoundError(f"File data tidak ditemukan: {DATA_PATH}") | |
| df_raw = pd.read_excel(DATA_FILE) | |
| PROV_COL, KABKOT_COL = detect_region_cols(df_raw) | |
| df_raw[PROV_COL] = df_raw[PROV_COL].astype(str).fillna("").replace({"nan": ""}).str.strip() | |
| df_raw[KABKOT_COL] = df_raw[KABKOT_COL].astype(str).fillna("").replace({"nan": ""}).str.strip() | |
| df_clean, excl_stats, audit_hapus = apply_exclusions_strict(df_raw, PROV_COL, KABKOT_COL) | |
| GROUP_COLS = detect_item_groups(df_clean.columns.tolist()) | |
| df_idx = df_clean.copy() | |
| phase_maps: Dict[str, Dict[int, float]] = {} | |
| for phase in ["pra", "saat", "pasca"]: | |
| cols = GROUP_COLS.get(phase, []) | |
| if not cols: | |
| df_idx[f"subidx_{phase}_msi"] = np.nan | |
| phase_maps[phase] = {1: np.nan, 4: np.nan} | |
| continue | |
| tmp = df_idx[cols].copy() | |
| tmp_msi, mapping = apply_msi_pooled_phase(tmp, cols) | |
| phase_maps[phase] = mapping | |
| df_idx[f"subidx_{phase}_msi"] = tmp_msi.apply( | |
| lambda r: mean_if_enough(r, MIN_FRAC_AVAILABLE_PER_SUBINDEX), | |
| axis=1 | |
| ) | |
| df_idx["subidx_pra_0_100"], MIN_PRA, MAX_PRA = minmax_0_100_global(df_idx["subidx_pra_msi"]) | |
| df_idx["subidx_saat_0_100"], MIN_SAAT, MAX_SAAT = minmax_0_100_global(df_idx["subidx_saat_msi"]) | |
| df_idx["subidx_pasca_0_100"],MIN_PAS, MAX_PAS = minmax_0_100_global(df_idx["subidx_pasca_msi"]) | |
| w_sum = float(sum(WEIGHTS.values())) | |
| df_idx["index_msi_raw"] = ( | |
| WEIGHTS["pra"] * df_idx["subidx_pra_msi"] + | |
| WEIGHTS["saat"] * df_idx["subidx_saat_msi"] + | |
| WEIGHTS["pasca"]* df_idx["subidx_pasca_msi"] | |
| ) / w_sum | |
| df_idx["index_0_100"], MIN_DATA, MAX_DATA = minmax_0_100_global(df_idx["index_msi_raw"]) | |
| # ========================= | |
| # CORE PIPELINE PER FILTER | |
| # ========================= | |
| def compute_outputs(provinsi: str, kabkota: str): | |
| sub = df_idx.copy() | |
| if provinsi != "(Semua)": | |
| sub = sub[sub[PROV_COL] == provinsi] | |
| if kabkota != "(Semua)": | |
| sub = sub[sub[KABKOT_COL] == kabkota] | |
| if sub.empty: | |
| hero = "## Ringkasan Eksekutif\n⚠️ Tidak ada data pada filter ini." | |
| info = "⚠️ Tidak ada data." | |
| empty = pd.DataFrame() | |
| return ( | |
| hero, info, | |
| empty, empty, empty, | |
| empty, pd.DataFrame(columns=["Komponen", "Nilai"]), | |
| empty_figure("Pilih Kab/Kota."), | |
| ) | |
| kab = ( | |
| sub.groupby([PROV_COL, KABKOT_COL], dropna=False) | |
| .agg( | |
| indeks_mean=("index_0_100", "mean"), | |
| n_responden=(KABKOT_COL, "size"), | |
| subidx_pra_0_100_mean=("subidx_pra_0_100", "mean"), | |
| subidx_saat_0_100_mean=("subidx_saat_0_100", "mean"), | |
| subidx_pasca_0_100_mean=("subidx_pasca_0_100", "mean"), | |
| ) | |
| .reset_index() | |
| ) | |
| kab["_faktor_internal"] = kab["n_responden"].apply(faktor_penyesuaian) | |
| kab["status_sampel"] = kab["n_responden"].apply(status_penyesuaian) | |
| kab["_raw_internal"] = pd.to_numeric(kab["indeks_mean"], errors="coerce") | |
| kab["Indeks_TKM_0_100_final"] = (kab["_raw_internal"] * kab["_faktor_internal"]).round(2) | |
| kab["Kategori_Indeks_TKM_FINAL"] = kab["Indeks_TKM_0_100_final"].apply(kategori_indeks_final) | |
| out_kab_all = kab.rename(columns={PROV_COL: "Provinsi", KABKOT_COL: "Wilayah"})[ | |
| [ | |
| "Provinsi", | |
| "Wilayah", | |
| "subidx_pra_0_100_mean", | |
| "subidx_saat_0_100_mean", | |
| "subidx_pasca_0_100_mean", | |
| "Indeks_TKM_0_100_final", | |
| "Kategori_Indeks_TKM_FINAL", | |
| "n_responden", | |
| "status_sampel", | |
| ] | |
| ].rename( | |
| columns={ | |
| "subidx_pra_0_100_mean": "SubIndeks_Pra_0_100", | |
| "subidx_saat_0_100_mean": "SubIndeks_Saat_0_100", | |
| "subidx_pasca_0_100_mean": "SubIndeks_Pasca_0_100", | |
| "Indeks_TKM_0_100_final": "Indeks_TKM_0_100", | |
| } | |
| ).sort_values(["Provinsi", "Wilayah"]) | |
| out_kab_valid = out_kab_all[out_kab_all["n_responden"] >= MIN_RESPONDEN_SLOVIN] | |
| out_kab_adjusted = out_kab_all[out_kab_all["n_responden"] < MIN_RESPONDEN_SLOVIN] | |
| prov_from_kab = aggregate_prov_from_kab(kab, PROV_COL).rename(columns={PROV_COL: "Provinsi"}) | |
| prov_from_kab["Indeks_TKM_0_100"] = pd.to_numeric(prov_from_kab["tkm_final_mean"], errors="coerce").round(2) | |
| prov_from_kab["Kategori_Indeks_TKM_FINAL"] = prov_from_kab["Indeks_TKM_0_100"].apply(kategori_indeks_final) | |
| prov_from_kab["Status"] = prov_from_kab["prov_status"] | |
| no_agg = set(norm_region_name(p) for p in EXCLUDE_PROVINSI_NO_AGG) | |
| mask_no_agg = prov_from_kab["Provinsi"].map(norm_region_name).isin(no_agg) | |
| prov_from_kab.loc[mask_no_agg, "Indeks_TKM_0_100"] = np.nan | |
| prov_from_kab.loc[mask_no_agg, "Kategori_Indeks_TKM_FINAL"] = "" | |
| prov_from_kab.loc[mask_no_agg, "Status"] = "🚫 EXCLUDED (kelembagaan): provinsi ditampilkan tetapi agregat tidak dihitung" | |
| out_prov = prov_from_kab[["Provinsi", "Indeks_TKM_0_100", "Kategori_Indeks_TKM_FINAL", "n_responden", "Status"]] | |
| hero = ( | |
| "## Ringkasan Eksekutif\n" | |
| f"- Exclusion (STRICT) sebelum MSI: terhapus **{excl_stats['terhapus_total']}** dari **{excl_stats['baris_awal']}**, diolah **{excl_stats['diolah']}**.\n" | |
| f"- Penyesuaian sampel Kab/Kota: target **{MIN_RESPONDEN_SLOVIN}**; n<{MIN_RESPONDEN_SLOVIN} → indeks dikali faktor min(n/{MIN_RESPONDEN_SLOVIN},1).\n" | |
| ) | |
| info = f"Responden pada filter: **{len(sub)}**" | |
| # detail plot untuk kab/kota terpilih | |
| detail_plot = empty_figure("Pilih Kab/Kota untuk melihat SubIndeks.") | |
| detail_df = pd.DataFrame(columns=["Komponen", "Nilai"]) | |
| if kabkota != "(Semua)": | |
| row = out_kab_all[out_kab_all["Wilayah"] == kabkota].head(1) | |
| if not row.empty: | |
| r0 = row.iloc[0] | |
| detail_df = pd.DataFrame([ | |
| {"Komponen": "SubIndeks_Pra_0_100", "Nilai": r0["SubIndeks_Pra_0_100"]}, | |
| {"Komponen": "SubIndeks_Saat_0_100", "Nilai": r0["SubIndeks_Saat_0_100"]}, | |
| {"Komponen": "SubIndeks_Pasca_0_100", "Nilai": r0["SubIndeks_Pasca_0_100"]}, | |
| {"Komponen": "Indeks_TKM_0_100", "Nilai": r0["Indeks_TKM_0_100"]}, | |
| {"Komponen": "Kategori_Indeks_TKM_FINAL", "Nilai": r0["Kategori_Indeks_TKM_FINAL"]}, | |
| {"Komponen": "n_responden", "Nilai": r0["n_responden"]}, | |
| {"Komponen": "status_sampel", "Nilai": r0["status_sampel"]}, | |
| ]) | |
| pra_v = pd.to_numeric(r0["SubIndeks_Pra_0_100"], errors="coerce") | |
| saat_v = pd.to_numeric(r0["SubIndeks_Saat_0_100"], errors="coerce") | |
| pas_v = pd.to_numeric(r0["SubIndeks_Pasca_0_100"], errors="coerce") | |
| detail_plot = plot_dimensi_bar_0_100( | |
| float(pra_v) if np.isfinite(pra_v) else np.nan, | |
| float(saat_v) if np.isfinite(saat_v) else np.nan, | |
| float(pas_v) if np.isfinite(pas_v) else np.nan, | |
| f"SubIndeks 0–100 — {kabkota}" | |
| ) | |
| return ( | |
| hero, info, | |
| out_kab_valid, out_prov, out_kab_adjusted, | |
| out_kab_all, | |
| detail_df, detail_plot | |
| ) | |
| # ========================= | |
| # EXPORT WORD | |
| # ========================= | |
| def _now_tag() -> str: | |
| return datetime.now().strftime("%Y%m%d_%H%M%S") | |
| def export_word_report(provinsi: str, kabkota: str): | |
| if not DOCX_AVAILABLE: | |
| out = str(Path.cwd() / f"LAPORAN_TKM_{_now_tag()}.txt") | |
| with open(out, "w", encoding="utf-8") as f: | |
| f.write("python-docx tidak tersedia. Install: pip install python-docx\n") | |
| return out | |
| hero, info, out_kab_valid, out_prov, out_kab_adjusted, out_kab_all, detail_df, _ = compute_outputs(provinsi, kabkota) | |
| chosen = None | |
| if kabkota != "(Semua)": | |
| pick = out_kab_all[out_kab_all["Wilayah"] == kabkota].head(1) | |
| if not pick.empty: | |
| chosen = pick.iloc[0] | |
| else: | |
| if len(out_kab_all) == 1: | |
| chosen = out_kab_all.iloc[0] | |
| doc = Document() | |
| style = doc.styles["Normal"] | |
| style.font.name = "Calibri" | |
| style.font.size = Pt(11) | |
| title = doc.add_paragraph("TABEL INTERPRETASI DAN REKOMENDASI") | |
| title.runs[0].bold = True | |
| title.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| subp = doc.add_paragraph(f"Tanggal: {datetime.now().strftime('%d %B %Y')} | Filter: Provinsi={provinsi}, Kab/Kota={kabkota}") | |
| subp.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.add_paragraph("") | |
| doc.add_heading("Ringkasan Dashboard", level=1) | |
| doc.add_paragraph(hero.replace("## ", "")) | |
| doc.add_paragraph(info.replace("**", "")) | |
| doc.add_paragraph("") | |
| doc.add_heading("Tabel Interpretasi dan Rekomendasi Kebijakan (Pra/Saat/Pasca/Indeks TKM)", level=1) | |
| if chosen is None: | |
| doc.add_paragraph( | |
| "Tabel interpretasi & rekomendasi memerlukan pemilihan Kab/Kota tertentu, " | |
| "atau kondisi filter menghasilkan tepat satu Kab/Kota." | |
| ) | |
| else: | |
| sub_pra = float(pd.to_numeric(chosen.get("SubIndeks_Pra_0_100", np.nan), errors="coerce")) | |
| sub_saat = float(pd.to_numeric(chosen.get("SubIndeks_Saat_0_100", np.nan), errors="coerce")) | |
| sub_pasca = float(pd.to_numeric(chosen.get("SubIndeks_Pasca_0_100", np.nan), errors="coerce")) | |
| idx_final = float(pd.to_numeric(chosen.get("Indeks_TKM_0_100", np.nan), errors="coerce")) | |
| status_sampel = str(chosen.get("status_sampel", "")).strip() | |
| df_tbl = build_interpretasi_rekom_table_adaptif(sub_pra, sub_saat, sub_pasca, idx_final, status_sampel) | |
| add_interpretasi_rekom_table_docx(doc, df_tbl) | |
| fn = f"LAPORAN_TKM_TABEL_{_now_tag()}_{provinsi.replace(' ', '_')}_{kabkota.replace(' ', '_')}.docx" | |
| fn = fn.replace("/", "_").replace("\\", "_") | |
| out_path = str(Path.cwd() / fn) | |
| doc.save(out_path) | |
| return out_path | |
| # ========================= | |
| # EXPORT EXCEL (opsional) | |
| # ========================= | |
| def _safe_sheet_name(name: str) -> str: | |
| bad = ['\\', '/', '*', '[', ']', ':', '?'] | |
| out = name | |
| for b in bad: | |
| out = out.replace(b, " ") | |
| out = " ".join(out.split()).strip() | |
| return out[:31] if out else "Sheet1" | |
| def export_excel(provinsi: str, kabkota: str): | |
| hero, info, out_kab_valid, out_prov, out_kab_adjusted, out_kab_all, detail_df, _ = compute_outputs(provinsi, kabkota) | |
| chosen = None | |
| if kabkota != "(Semua)": | |
| pick = out_kab_all[out_kab_all["Wilayah"] == kabkota].head(1) | |
| if not pick.empty: | |
| chosen = pick.iloc[0] | |
| else: | |
| if len(out_kab_all) == 1: | |
| chosen = out_kab_all.iloc[0] | |
| tbl = pd.DataFrame() | |
| if chosen is not None: | |
| sub_pra = float(pd.to_numeric(chosen.get("SubIndeks_Pra_0_100", np.nan), errors="coerce")) | |
| sub_saat = float(pd.to_numeric(chosen.get("SubIndeks_Saat_0_100", np.nan), errors="coerce")) | |
| sub_pasca = float(pd.to_numeric(chosen.get("SubIndeks_Pasca_0_100", np.nan), errors="coerce")) | |
| idx_final = float(pd.to_numeric(chosen.get("Indeks_TKM_0_100", np.nan), errors="coerce")) | |
| status_sampel = str(chosen.get("status_sampel", "")).strip() | |
| tbl = build_interpretasi_rekom_table_adaptif(sub_pra, sub_saat, sub_pasca, idx_final, status_sampel) | |
| fn = f"OUTPUT_TKM_{_now_tag()}_{provinsi.replace(' ', '_')}_{kabkota.replace(' ', '_')}.xlsx" | |
| fn = fn.replace("/", "_").replace("\\", "_") | |
| out_path = str(Path.cwd() / fn) | |
| with pd.ExcelWriter(out_path, engine="openpyxl") as writer: | |
| pd.DataFrame({"Hero_MD": [hero], "Info_MD": [info]}).to_excel(writer, sheet_name=_safe_sheet_name("RINGKASAN"), index=False) | |
| (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) | |
| (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) | |
| (out_prov if out_prov is not None else pd.DataFrame()).to_excel(writer, sheet_name=_safe_sheet_name("Provinsi"), index=False) | |
| (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) | |
| (tbl if tbl is not None else pd.DataFrame()).to_excel(writer, sheet_name=_safe_sheet_name("Tabel_Interpretasi"), index=False) | |
| return out_path | |
| # ========================= | |
| # GRADIO UI | |
| # ========================= | |
| def get_prov_choices(): | |
| provs = sorted([p for p in df_idx[PROV_COL].dropna().unique().tolist() if str(p).strip() != ""]) | |
| return ["(Semua)"] + provs | |
| def update_kab_dropdown(provinsi: str): | |
| if provinsi == "(Semua)": | |
| kabs = sorted([k for k in df_idx[KABKOT_COL].dropna().unique().tolist() if str(k).strip() != ""]) | |
| else: | |
| sub = df_idx[df_idx[PROV_COL] == provinsi] | |
| kabs = sorted([k for k in sub[KABKOT_COL].dropna().unique().tolist() if str(k).strip() != ""]) | |
| return gr.update(choices=["(Semua)"] + kabs, value="(Semua)") | |
| with gr.Blocks(title="Dashboard TKM (MSI) — Interpretasi & Rekomendasi Adaptif") as demo: | |
| gr.Markdown( | |
| f""" | |
| # Dashboard Indeks TKM (MSI) | |
| Kategori Final (0–100): | |
| - < 50 : sangat rendah | |
| - 50 – < 65 : rendah | |
| - 65 – < 80 : sedang | |
| - 80 – < 90 : tinggi | |
| - ≥ 90 : sangat tinggi | |
| **Interpretasi & rekomendasi selalu menyesuaikan NILAI dan KATEGORI tiap baris (Pra/Saat/Pasca/Indeks).** | |
| """ | |
| ) | |
| with gr.Row(): | |
| dd_prov = gr.Dropdown(label="Provinsi", choices=get_prov_choices(), value="(Semua)") | |
| dd_kab = gr.Dropdown(label="Kab/Kota", choices=["(Semua)"], value="(Semua)") | |
| run_btn = gr.Button("Jalankan", variant="primary") | |
| hero_md = gr.Markdown() | |
| info_md = gr.Markdown() | |
| with gr.Row(): | |
| btn_xlsx = gr.Button("⬇️ Download Excel", variant="secondary") | |
| file_xlsx = gr.File(label="File Excel", interactive=False) | |
| btn_docx = gr.Button("⬇️ Download Laporan Word (Tabel Interpretasi & Rekomendasi)", variant="secondary") | |
| file_docx = gr.File(label="File Word", interactive=False) | |
| with gr.Accordion(f"🟢 Kab/Kota n≥{MIN_RESPONDEN_SLOVIN}", open=True): | |
| out_kab_valid_ui = gr.DataFrame(interactive=False) | |
| with gr.Accordion("🟠 Kab/Kota n<400 (disesuaikan)", open=False): | |
| out_kab_adjusted_ui = gr.DataFrame(interactive=False) | |
| with gr.Accordion("🔵 Provinsi (dari Kab/Kota final)", open=False): | |
| out_prov_ui = gr.DataFrame(interactive=False) | |
| with gr.Accordion("🟢 Detail SubIndeks (Kab/Kota terpilih)", open=False): | |
| detail_df_ui = gr.DataFrame(interactive=False) | |
| detail_plot_ui = gr.Plot() | |
| dd_prov.change(fn=update_kab_dropdown, inputs=dd_prov, outputs=dd_kab) | |
| def _run(prov, kab): | |
| hero, info, out_kab_valid, out_prov, out_kab_adjusted, out_kab_all, detail_df, detail_plot = compute_outputs(prov, kab) | |
| return hero, info, out_kab_valid, out_kab_adjusted, out_prov, detail_df, detail_plot | |
| run_btn.click( | |
| fn=_run, | |
| inputs=[dd_prov, dd_kab], | |
| outputs=[hero_md, info_md, out_kab_valid_ui, out_kab_adjusted_ui, out_prov_ui, detail_df_ui, detail_plot_ui], | |
| ) | |
| dd_kab.change( | |
| fn=_run, | |
| inputs=[dd_prov, dd_kab], | |
| outputs=[hero_md, info_md, out_kab_valid_ui, out_kab_adjusted_ui, out_prov_ui, detail_df_ui, detail_plot_ui], | |
| ) | |
| btn_docx.click(fn=export_word_report, inputs=[dd_prov, dd_kab], outputs=[file_docx]) | |
| btn_xlsx.click(fn=export_excel, inputs=[dd_prov, dd_kab], outputs=[file_xlsx]) | |
| if __name__ == "__main__": | |
| demo.launch() |