irhamni commited on
Commit
3e40174
·
verified ·
1 Parent(s): c165048

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +823 -474
app.py CHANGED
@@ -2,39 +2,58 @@
2
  """
3
  IPLM 2025 — Final (Target Sampel 33.88% per Jenis) — TANPA Kinerja Relatif / Percentile
4
 
5
- VERSI TULIS ULANG (lebih sederhana & rapi)
6
- - Mode NO UPLOAD: baca file dari repo/server (env DATA_FILE/POP_*)
7
- - Pipeline:
8
- 1) Normalisasi indikator di LEVEL ENTITAS:
9
- Yeo-Johnson per indikator → MinMax global (0-1)
10
- sub-indeks → dimensi → Indeks_Dasar_0_100
11
- 2) Penyesuaian kecukupan sampel per wilayah×jenis:
12
- faktor = min(n_jenis / target_33.88_jenis, 1.0)
13
- Indeks_Final_Agregat_0_100 = Indeks_Dasar_Agregat_0_100 * faktor
14
- 3) Agregat Wilayah (Keseluruhan) = avg3 (sekolah+umum+khusus), missing dianggap 0 dan tetap /3
15
-
16
- UI:
17
- - KPI Dashboard: hanya 2 kartu (Indeks Final & Indeks Dasar)
18
- - Bell curve: Indeks_Dasar_0_100 per entitas per jenis + hover nama perpustakaan
19
- - Analisis LLM: tampil dalam format tabel "Format Data Pendukung IPLM" (No/Dimensi/Nilai/Interpretasi/Rekomendasi)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  """
21
 
22
  import os
23
  import re
24
  import time
25
- import json
26
  import tempfile
27
  from pathlib import Path
28
 
 
29
  import numpy as np
30
  import pandas as pd
31
  import plotly.graph_objects as go
32
- import gradio as gr
33
  from sklearn.preprocessing import PowerTransformer
34
 
35
- # =========================
36
- # OPTIONAL: python-docx
37
- # =========================
38
  DOCX_AVAILABLE = True
39
  try:
40
  from docx import Document
@@ -42,9 +61,7 @@ except Exception:
42
  DOCX_AVAILABLE = False
43
  Document = None
44
 
45
- # =========================
46
- # OPTIONAL: HuggingFace LLM
47
- # =========================
48
  HF_AVAILABLE = True
49
  try:
50
  from huggingface_hub import InferenceClient
@@ -52,6 +69,7 @@ except Exception:
52
  HF_AVAILABLE = False
53
  InferenceClient = None
54
 
 
55
  # ============================================================
56
  # 1) KONFIGURASI
57
  # ============================================================
@@ -64,8 +82,10 @@ POP_KHUSUS = os.getenv("POP_KHUSUS", "Data_populasi_perp_khusus.xlsx")
64
  W_KEPATUHAN = float(os.getenv("W_KEPATUHAN", "0.30"))
65
  W_KINERJA = float(os.getenv("W_KINERJA", "0.70"))
66
 
 
67
  TARGET_RATIO = float(os.getenv("TARGET_RATIO", "0.3388"))
68
 
 
69
  USE_LLM = True
70
  LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "meta-llama/Meta-Llama-3-8B-Instruct")
71
  HF_TOKEN = (
@@ -75,13 +95,14 @@ HF_TOKEN = (
75
  or os.getenv("HF_API_TOKEN")
76
  )
77
 
 
78
  # ============================================================
79
- # 2) UTIL KECIL
80
  # ============================================================
81
 
82
- def _mtime(p: str):
83
- pp = Path(p)
84
- return pp.stat().st_mtime if pp.exists() else None
85
 
86
  def _canon(s: str) -> str:
87
  return re.sub(r"[^a-z0-9]+", "", str(s).lower())
@@ -92,19 +113,17 @@ def _disp_text(x):
92
  t = str(x).strip().upper()
93
  return " ".join(t.split())
94
 
95
- def pick_col(df: pd.DataFrame, candidates: list[str]):
96
  if df is None or df.empty:
97
  return None
98
- # exact match
99
  for c in candidates:
100
  if c in df.columns:
101
  return c
102
- # canon match
103
- m = {_canon(c): c for c in df.columns}
104
  for c in candidates:
105
  k = _canon(c)
106
- if k in m:
107
- return m[k]
108
  return None
109
 
110
  def coerce_num(val):
@@ -125,16 +144,17 @@ def coerce_num(val):
125
  t = t.replace(",", ".")
126
  else:
127
  t = t.replace(",", "")
 
128
  try:
129
  return float(t)
130
  except Exception:
131
  return np.nan
132
 
133
- def minmax_norm(series: pd.Series) -> pd.Series:
134
- x = pd.to_numeric(series, errors="coerce").astype(float)
135
  mn, mx = x.min(skipna=True), x.max(skipna=True)
136
  if pd.isna(mn) or pd.isna(mx) or mx == mn:
137
- return pd.Series(0.0, index=series.index)
138
  return (x - mn) / (mx - mn)
139
 
140
  def norm_kew(v):
@@ -152,24 +172,31 @@ def norm_kew(v):
152
  def norm_prov_disp(s):
153
  if pd.isna(s):
154
  return None
155
- t = str(s).strip().upper().replace("\u00a0", " ")
156
- t = " ".join(t.split()).replace("PROPINSI", "PROVINSI")
 
 
 
 
157
  if t.startswith("PROVINSI "):
158
  name = t[len("PROVINSI "):].strip()
159
  else:
160
  name = t
161
  name = " ".join(name.split())
162
- return f"PROVINSI {name}" if name else None
 
 
163
 
164
- def norm_prov_key(s):
165
  if pd.isna(s):
166
  return None
167
  t = str(s).strip().upper().replace("\u00a0", " ")
168
- t = " ".join(t.split()).replace("PROPINSI", "PROVINSI")
 
169
  t = t.replace("PROVINSI", "").strip()
170
  return re.sub(r"[^A-Z0-9]+", "", t)
171
 
172
- def norm_kab_key(s):
173
  if pd.isna(s):
174
  return None
175
  t = str(s).upper()
@@ -181,15 +208,25 @@ def norm_kab_key(s):
181
  t = " ".join(t.split())
182
  return re.sub(r"[^A-Z0-9]+", "", t)
183
 
184
- def faktor_penyesuaian(n: float, target: float) -> float:
185
- if target is None or pd.isna(target) or float(target) <= 0:
 
 
 
 
 
 
 
 
 
186
  return 1.0
187
- if n is None or pd.isna(n) or float(n) < 0:
188
- n = 0.0
189
- return float(min(float(n) / float(target), 1.0))
 
190
 
191
  # ============================================================
192
- # 3) INDIKATOR IPLM + ALIAS
193
  # ============================================================
194
 
195
  koleksi_cols = [
@@ -214,6 +251,7 @@ pengelolaan_cols = [
214
  ]
215
  all_indicators = koleksi_cols + sdm_cols + pelayanan_cols + pengelolaan_cols
216
 
 
217
  alias_map_raw = {
218
  "j_judul_koleksi_tercetak": "JudulTercetak",
219
  "j_eksemplar_koleksi_tercetak": "EksemplarTercetak",
@@ -243,11 +281,12 @@ alias_map_raw = {
243
  }
244
  alias_map = {_canon(k): v for k, v in alias_map_raw.items()}
245
 
 
246
  # ============================================================
247
- # 4) PREPARE GLOBAL (LEVEL ENTITAS)
248
  # ============================================================
249
 
250
- def _mean_norm_cols(row: pd.Series, cols: list[str]) -> float:
251
  vals = []
252
  for c in cols:
253
  k = f"norm_{c}"
@@ -260,11 +299,12 @@ def _mean_norm_cols(row: pd.Series, cols: list[str]) -> float:
260
 
261
  def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
262
  """
263
- - Rename indikator via alias
264
- - Coerce numeric
265
- - Yeo-Johnson (standardize=False) per indikator
266
- - MinMax global (0-1)
267
- - sub/dim dan Indeks_Dasar_0_100
 
268
  """
269
  if df_src is None or df_src.empty:
270
  return df_src
@@ -289,17 +329,16 @@ def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
289
  for c in available:
290
  df[c] = df[c].apply(coerce_num)
291
 
 
292
  for c in available:
293
  x = pd.to_numeric(df[c], errors="coerce").astype(float).values
294
  mask = ~np.isnan(x)
295
  transformed = np.full_like(x, np.nan, dtype=float)
296
-
297
  if mask.sum() > 1:
298
  pt = PowerTransformer(method="yeo-johnson", standardize=False)
299
  transformed[mask] = pt.fit_transform(x[mask].reshape(-1, 1)).ravel()
300
  else:
301
  transformed[mask] = x[mask]
302
-
303
  df[f"norm_{c}"] = minmax_norm(pd.Series(transformed, index=df.index))
304
 
305
  df["sub_koleksi"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in koleksi_cols if c in available]), axis=1)
@@ -307,23 +346,42 @@ def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
307
  df["sub_pelayanan"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in pelayanan_cols if c in available]), axis=1)
308
  df["sub_pengelolaan"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in pengelolaan_cols if c in available]), axis=1)
309
 
310
- df["dim_kepatuhan"] = df[["sub_koleksi", "sub_sdm"]].mean(axis=1)
311
- df["dim_kinerja"] = df[["sub_pelayanan", "sub_pengelolaan"]].mean(axis=1)
312
 
313
- df["Indeks_Dasar_0_100"] = 100.0 * (W_KEPATUHAN * df["dim_kepatuhan"] + W_KINERJA * df["dim_kinerja"])
314
 
315
  for c in ["sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan","dim_kepatuhan","dim_kinerja","Indeks_Dasar_0_100"]:
316
  df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0.0)
317
 
318
  return df
319
 
 
320
  # ============================================================
321
- # 5) LOAD FILES + CACHE
322
  # ============================================================
323
 
324
- _CACHE = {"key": None, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": None, "info": None}
 
 
 
 
 
 
 
 
 
325
 
326
  def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
 
 
 
 
 
 
 
 
 
327
  df = pd.read_excel(path_xlsx)
328
  if df is None or df.empty:
329
  return pd.DataFrame()
@@ -374,32 +432,40 @@ def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
374
  return pop
375
 
376
  pop["Pop_Total_Jenis"] = pd.to_numeric(pop["Pop_Total_Jenis"], errors="coerce").fillna(0.0)
377
- pop["prov_key"] = pop["Provinsi_Label"].apply(norm_prov_key)
378
- pop["kab_key"] = pop["Kab_Kota_Label"].apply(norm_kab_key) if "Kab_Kota_Label" in pop.columns else None
379
  return pop
380
 
381
- def load_default_files(force: bool = False):
 
 
 
 
 
 
 
 
382
  key = (
383
  DATA_FILE, POP_KAB, POP_PROV, POP_KHUSUS,
384
  _mtime(DATA_FILE), _mtime(POP_KAB), _mtime(POP_PROV), _mtime(POP_KHUSUS)
385
  )
 
386
  if (not force) and _CACHE["key"] == key and _CACHE["df_all"] is not None:
387
  return _CACHE["df_all"], _CACHE["df_raw"], _CACHE["pop_kab"], _CACHE["pop_prov"], _CACHE["pop_khusus"], _CACHE["meta"], _CACHE["info"]
388
 
389
  for p, label in [(DATA_FILE, "DM"), (POP_KAB, "POP_KAB"), (POP_PROV, "POP_PROV"), (POP_KHUSUS, "POP_KHUSUS")]:
390
  if not Path(p).exists():
391
- info = f"File {label} tidak ditemukan: {p}"
392
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
393
  return None, None, None, None, None, {}, info
394
 
395
- # DM multi-sheet -> concat
396
  fp = Path(DATA_FILE)
397
  xls = pd.ExcelFile(fp)
398
  frames = [pd.read_excel(fp, sheet_name=s) for s in xls.sheet_names]
399
  df_raw = pd.concat(frames, ignore_index=True, sort=False)
400
 
401
  prov_col = pick_col(df_raw, ["provinsi", "Provinsi", "PROVINSI"])
402
- kab_col = pick_col(df_raw, ["kab/kota", "Kab/Kota", "Kab_Kota", "KAB/KOTA", "kabupaten_kota", "Kabupaten/Kota", "kabupaten kota", "kab_kota"])
403
  kew_col = pick_col(df_raw, ["kewenangan", "jenis_kewenangan", "Kewenangan", "KEWENANGAN"])
404
  jenis_col = pick_col(df_raw, ["jenis_perpustakaan", "Jenis Perpustakaan", "JENIS_PERPUSTAKAAN"])
405
  nama_col = pick_col(df_raw, ["nm_perpustakaan","nama_perpustakaan","Nama Perpustakaan","nm_instansi_lembaga","nm_perpus"])
@@ -410,11 +476,11 @@ def load_default_files(force: bool = False):
410
  if kew_col is None: missing.append("Kewenangan")
411
  if jenis_col is None: missing.append("Jenis Perpustakaan")
412
  if missing:
413
- info = f"Kolom wajib tidak ditemukan di DM: {', '.join(missing)}"
414
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
415
  return None, None, None, None, None, {}, info
416
 
417
- # map jenis -> sekolah/umum/khusus
418
  val_map_jenis = {
419
  "PERPUSTAKAAN SEKOLAH": "sekolah", "SEKOLAH": "sekolah",
420
  "PERPUSTAKAAN UMUM": "umum", "UMUM": "umum", "PERPUSTAKAAN DAERAH": "umum",
@@ -425,10 +491,10 @@ def load_default_files(force: bool = False):
425
  df_raw["_dataset"] = df_raw[jenis_col].astype(str).str.strip().str.upper().map(val_map_jenis)
426
  df_raw["PROV_DISP"] = df_raw[prov_col].apply(norm_prov_disp)
427
  df_raw["KAB_DISP"] = df_raw[kab_col].apply(_disp_text)
428
- df_raw["prov_key"] = df_raw["PROV_DISP"].apply(norm_prov_key)
429
- df_raw["kab_key"] = df_raw["KAB_DISP"].apply(norm_kab_key)
430
 
431
- # dedup aman (prov,kab,kew,jenis,nama)
432
  if nama_col and nama_col in df_raw.columns:
433
  kcols = [prov_col, kab_col, kew_col, jenis_col, nama_col]
434
  else:
@@ -440,178 +506,241 @@ def load_default_files(force: bool = False):
440
  df_raw = df_raw.drop_duplicates(subset=["_row_key"], keep="first").copy()
441
  after = len(df_raw)
442
 
443
- # POP_KAB
444
  pk = pd.read_excel(POP_KAB)
445
  c_kab = pick_col(pk, ["KABUPATEN_KOTA","Kab/Kota","Kabupaten/Kota","KAB/KOTA","Kabupaten_Kota","kab_kota","kabupaten_kota"])
446
  c_prov = pick_col(pk, ["PROVINSI","Provinsi","provinsi"])
447
  if c_kab is None:
448
- info = "POP_KAB: kolom Kab/Kota tidak ditemukan."
449
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
450
  return None, None, None, None, None, {}, info
451
 
452
  pop_kab = pk.copy()
453
  pop_kab["Kab_Kota_Label"] = pk[c_kab].astype(str).str.strip()
454
  pop_kab["Provinsi_Label"] = pk[c_prov].astype(str).str.strip() if c_prov else ""
455
- pop_kab["kab_key"] = pop_kab["Kab_Kota_Label"].apply(norm_kab_key)
456
  pop_kab = pop_kab.groupby("kab_key", as_index=False).first()
457
 
458
- # POP_PROV
459
  pp = pd.read_excel(POP_PROV)
460
  c_pr = pick_col(pp, ["Provinsi","PROVINSI","provinsi","Propinsi","PROPINSI","propinsi"])
461
  if c_pr is None:
462
- info = "POP_PROV: kolom Provinsi tidak ditemukan."
463
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
464
  return None, None, None, None, None, {}, info
465
 
466
  pop_prov = pp.copy()
467
  pop_prov["Provinsi_Label"] = pp[c_pr].astype(str).str.strip()
468
- pop_prov["prov_key"] = pop_prov["Provinsi_Label"].apply(norm_prov_key)
469
  pop_prov = pop_prov.groupby("prov_key", as_index=False).first()
470
 
471
- # POP_KHUSUS
472
  try:
473
  pop_khusus = _parse_pop_khusus(POP_KHUSUS)
474
  except Exception as e:
475
- info = f"POP_KHUSUS gagal dibaca: {repr(e)}"
476
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
477
  return None, None, None, None, None, {}, info
478
 
479
  df_all = prepare_global(df_raw)
480
-
481
  meta = dict(prov_col=prov_col, kab_col=kab_col, kew_col=kew_col, jenis_col=jenis_col, nama_col=nama_col)
482
 
483
  info = (
484
- f"Mode NO UPLOAD (cache)\n"
485
- f"DM: {fp.name} | baris {before} -> dedup {after}\n"
486
- f"POP_KAB: {Path(POP_KAB).name} (n={len(pop_kab)})\n"
487
- f"POP_PROV: {Path(POP_PROV).name} (n={len(pop_prov)})\n"
488
- f"POP_KHUSUS: {Path(POP_KHUSUS).name} (n={len(pop_khusus)})\n"
489
- f"TARGET per jenis: {TARGET_RATIO*100:.2f}%\n"
490
- f"mtime: DM={time.ctime(_mtime(DATA_FILE))}"
491
  )
492
 
493
- _CACHE.update({"key": key, "df_all": df_all, "df_raw": df_raw, "pop_kab": pop_kab, "pop_prov": pop_prov, "pop_khusus": pop_khusus, "meta": meta, "info": info})
 
 
 
 
 
 
 
 
 
494
  return df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info
495
 
 
496
  # ============================================================
497
- # 6) FAKTOR WILAYAH×JENIS (TARGET 33.88%)
498
  # ============================================================
499
 
500
- def build_faktor_wilayah_jenis(df: pd.DataFrame, pop_kab: pd.DataFrame, pop_prov: pd.DataFrame, pop_khusus: pd.DataFrame, kew_value: str):
501
- if df is None or df.empty:
 
 
 
 
 
 
 
 
 
 
 
 
502
  return pd.DataFrame()
503
 
504
  kew_norm = str(kew_value or "").upper()
 
505
  df = df[df["_dataset"].isin(["sekolah", "umum", "khusus"])].copy()
506
  if df.empty:
507
  return pd.DataFrame()
508
 
509
  jenis_list = ["sekolah", "umum", "khusus"]
510
 
 
511
  if "PROV" in kew_norm:
512
  key_col, label_col, label_name, mode = "prov_key", "PROV_DISP", "Provinsi", "PROV"
513
  base_pop = pop_prov.copy() if (pop_prov is not None and not pop_prov.empty) else pd.DataFrame()
514
  if not base_pop.empty and "prov_key" not in base_pop.columns:
515
- base_pop["prov_key"] = base_pop["Provinsi_Label"].apply(norm_prov_key)
516
  base_pop = base_pop.set_index("prov_key") if (not base_pop.empty and "prov_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
517
  else:
518
  key_col, label_col, label_name, mode = "kab_key", "KAB_DISP", "Kab/Kota", "KAB"
519
  base_pop = pop_kab.copy() if (pop_kab is not None and not pop_kab.empty) else pd.DataFrame()
520
  if not base_pop.empty and "kab_key" not in base_pop.columns:
521
- base_pop["kab_key"] = base_pop["Kab_Kota_Label"].apply(norm_kab_key)
522
  base_pop = base_pop.set_index("kab_key") if (not base_pop.empty and "kab_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
523
 
 
524
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
525
- full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
 
 
 
526
 
 
527
  cnt = (
528
  df.groupby([key_col, label_col, "_dataset"], dropna=False)
529
- .size().reset_index(name="n_jenis")
 
530
  .rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
531
  )
532
  cnt["Jenis"] = cnt["Jenis"].astype(str).str.lower().str.strip()
533
 
534
- out = full.merge(cnt, on=["group_key", label_name, "Jenis"], how="left")
535
- out["n_jenis"] = pd.to_numeric(out["n_jenis"], errors="coerce").fillna(0).astype(int)
536
 
537
- out["pop_total_jenis"] = 0.0
538
- out["target_total_33_88_jenis"] = 0.0
539
 
540
- # sekolah & umum dari pop_kab/pop_prov
541
  if not base_pop.empty:
542
  if mode == "KAB":
543
  pop_sekolah = pd.to_numeric(base_pop.get("jumlah_populasi_sekolah", 0), errors="coerce").fillna(0.0)
544
  pop_umum = pd.to_numeric(base_pop.get("jumlah_populasi_umum", 0), errors="coerce").fillna(0.0)
 
 
 
545
  else:
 
546
  sma = pd.to_numeric(base_pop.get("sma ", base_pop.get("sma", 0)), errors="coerce").fillna(0.0)
547
  smk = pd.to_numeric(base_pop.get("smk", 0), errors="coerce").fillna(0.0)
548
  slb = pd.to_numeric(base_pop.get("slb", 0), errors="coerce").fillna(0.0)
 
549
  pop_sekolah = sma + smk + slb
550
- pop_umum = pd.to_numeric(base_pop.get("perpus_umum_prop", 0), errors="coerce").fillna(0.0)
551
 
552
- tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
553
- tgt_umum = pop_umum * float(TARGET_RATIO)
554
 
555
- m = out["Jenis"].eq("sekolah")
556
- out.loc[m, "pop_total_jenis"] = out.loc[m, "group_key"].map(pop_sekolah).fillna(0.0).values
557
- out.loc[m, "target_total_33_88_jenis"] = out.loc[m, "group_key"].map(tgt_sekolah).fillna(0.0).values
558
 
559
- m = out["Jenis"].eq("umum")
560
- out.loc[m, "pop_total_jenis"] = out.loc[m, "group_key"].map(pop_umum).fillna(0.0).values
561
- out.loc[m, "target_total_33_88_jenis"] = out.loc[m, "group_key"].map(tgt_umum).fillna(0.0).values
562
 
563
- # khusus dari pop_khusus
564
  if pop_khusus is not None and not pop_khusus.empty:
565
  pk = pop_khusus.copy()
566
  pk["Pop_Total_Jenis"] = pd.to_numeric(pk.get("Pop_Total_Jenis", 0), errors="coerce").fillna(0.0)
567
 
568
  if mode == "PROV":
569
- pk2 = pk[pk["LEVEL"].astype(str).str.upper() == "PROV"].copy()
570
- pop_series = pk2.groupby("prov_key")["Pop_Total_Jenis"].sum()
 
571
  else:
572
- pk2 = pk[pk["LEVEL"].astype(str).str.upper() == "KAB"].copy()
573
- pop_series = pk2.groupby("kab_key")["Pop_Total_Jenis"].sum()
 
574
 
575
  tgt_series = pop_series * float(TARGET_RATIO)
576
 
577
- m = out["Jenis"].eq("khusus")
578
- out.loc[m, "pop_total_jenis"] = out.loc[m, "group_key"].map(pop_series).fillna(0.0).values
579
- out.loc[m, "target_total_33_88_jenis"] = out.loc[m, "group_key"].map(tgt_series).fillna(0.0).values
 
 
 
580
 
581
- out["pop_total_jenis"] = pd.to_numeric(out["pop_total_jenis"], errors="coerce").fillna(0.0)
582
- out["target_total_33_88_jenis"] = pd.to_numeric(out["target_total_33_88_jenis"], errors="coerce").fillna(0.0)
 
583
 
584
- out["faktor_penyesuaian_jenis"] = [
585
- faktor_penyesuaian(n, t)
586
- for n, t in zip(out["n_jenis"].astype(float), out["target_total_33_88_jenis"].astype(float))
 
 
 
 
587
  ]
588
 
589
- out["coverage_jenis_%"] = np.where(
590
- out["pop_total_jenis"].values > 0,
591
- (out["n_jenis"].astype(float).values / out["pop_total_jenis"].values) * 100.0,
592
- 0.0
593
- )
 
 
594
 
595
- out["gap_target33_88_jenis"] = np.maximum(out["target_total_33_88_jenis"].values - out["n_jenis"].astype(float).values, 0.0)
 
 
 
 
 
 
596
 
597
- # format
598
- out["pop_total_jenis"] = out["pop_total_jenis"].round(0).astype(int)
599
- out["target_total_33_88_jenis"] = out["target_total_33_88_jenis"].round(0).astype(int)
600
- out["coverage_jenis_%"] = pd.to_numeric(out["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
601
- out["faktor_penyesuaian_jenis"] = pd.to_numeric(out["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
602
- out["gap_target33_88_jenis"] = pd.to_numeric(out["gap_target33_88_jenis"], errors="coerce").fillna(0.0).round(0).astype(int)
 
 
603
 
604
- return out
605
 
606
  # ============================================================
607
- # 7) AGREGAT WILAYAH×JENIS
608
  # ============================================================
609
 
610
- def build_agg_wilayah_jenis(df: pd.DataFrame, faktor_wj: pd.DataFrame, kew_value: str):
611
- if df is None or df.empty:
 
 
 
 
 
 
 
 
612
  return pd.DataFrame()
613
 
614
  kew_norm = str(kew_value or "").upper()
 
 
615
  if "PROV" in kew_norm:
616
  key_col, label_col, label_name = "prov_key", "PROV_DISP", "Provinsi"
617
  else:
@@ -623,9 +752,14 @@ def build_agg_wilayah_jenis(df: pd.DataFrame, faktor_wj: pd.DataFrame, kew_value
623
 
624
  jenis_list = ["sekolah", "umum", "khusus"]
625
 
 
626
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
627
- full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
 
 
 
628
 
 
629
  agg_real = df.groupby([key_col, label_col, "_dataset"], dropna=False).agg(
630
  Jumlah=("Indeks_Dasar_0_100", "size"),
631
  Rata2_sub_koleksi=("sub_koleksi", "mean"),
@@ -639,71 +773,106 @@ def build_agg_wilayah_jenis(df: pd.DataFrame, faktor_wj: pd.DataFrame, kew_value
639
 
640
  agg_real["Jenis"] = agg_real["Jenis"].astype(str).str.lower().str.strip()
641
 
642
- out = full.merge(agg_real, on=["group_key", label_name, "Jenis"], how="left")
643
- for c in ["Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja","Indeks_Dasar_Agregat_0_100"]:
644
- out[c] = pd.to_numeric(out.get(c, 0), errors="coerce").fillna(0.0)
645
-
646
- out["Jumlah"] = out["Jumlah"].round(0).astype(int)
647
-
648
- # merge faktor
649
- if faktor_wj is None or faktor_wj.empty:
650
- out["faktor_penyesuaian_jenis"] = 1.0
651
- out["pop_total_jenis"] = 0
652
- out["target_total_33_88_jenis"] = 0
653
- out["n_jenis"] = 0
654
- out["coverage_jenis_%"] = 0.0
655
- out["gap_target33_88_jenis"] = 0
 
 
656
  else:
657
- fw = faktor_wj.copy()
658
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
659
- keep = ["group_key", label_name, "Jenis","faktor_penyesuaian_jenis","pop_total_jenis","target_total_33_88_jenis","n_jenis","coverage_jenis_%","gap_target33_88_jenis"]
 
 
 
660
  fw = fw[[c for c in keep if c in fw.columns]].copy()
661
- out = out.merge(fw, on=["group_key", label_name, "Jenis"], how="left")
662
 
663
- out["faktor_penyesuaian_jenis"] = pd.to_numeric(out["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
664
- for c in ["pop_total_jenis","target_total_33_88_jenis","n_jenis","gap_target33_88_jenis"]:
665
- out[c] = pd.to_numeric(out.get(c, 0), errors="coerce").fillna(0).round(0).astype(int)
666
- out["coverage_jenis_%"] = pd.to_numeric(out.get("coverage_jenis_%", 0), errors="coerce").fillna(0.0).round(2)
 
 
667
 
668
- out["Indeks_Final_Agregat_0_100"] = out["Indeks_Dasar_Agregat_0_100"] * out["faktor_penyesuaian_jenis"]
 
 
 
 
 
 
 
669
 
670
  # rounding
671
- for c in ["Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja"]:
672
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
673
- out["Indeks_Dasar_Agregat_0_100"] = pd.to_numeric(out["Indeks_Dasar_Agregat_0_100"], errors="coerce").fillna(0.0).round(2)
674
- out["Indeks_Final_Agregat_0_100"] = pd.to_numeric(out["Indeks_Final_Agregat_0_100"], errors="coerce").fillna(0.0).round(2)
675
- out["faktor_penyesuaian_jenis"] = pd.to_numeric(out["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
 
 
 
 
 
 
 
 
676
 
677
- return out
678
 
679
  # ============================================================
680
- # 8) AGREGAT WILAYAH KESELURUHAN — avg3 FIX
681
  # ============================================================
682
 
683
- def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, kew_value: str):
 
 
 
 
 
684
  if agg_jenis is None or agg_jenis.empty:
685
  return pd.DataFrame()
686
 
687
  kew_norm = str(kew_value or "").upper()
688
  label_name = "Provinsi" if "PROV" in kew_norm else "Kab/Kota"
 
689
 
690
  a = agg_jenis.copy()
691
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
692
 
693
- jenis_list = ["sekolah", "umum", "khusus"]
694
  base_keys = a[["group_key", label_name]].drop_duplicates()
695
- full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
 
 
 
696
 
697
- cols = [
698
  "Jumlah",
699
  "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
700
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
701
- "Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100"
 
702
  ]
703
- full = full.merge(a[["group_key", label_name, "Jenis"] + cols], on=["group_key", label_name, "Jenis"], how="left")
704
 
705
- for c in cols:
706
- full[c] = pd.to_numeric(full.get(c, 0), errors="coerce").fillna(0.0)
 
 
 
 
 
 
707
 
708
  out = full.groupby(["group_key", label_name], as_index=False).agg(
709
  n_total=("Jumlah", "sum"),
@@ -717,78 +886,186 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, kew_value: str):
717
  Indeks_Final_Wilayah_0_100=("Indeks_Final_Agregat_0_100", "mean"),
718
  )
719
 
720
- for c in ["Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja"]:
721
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
 
 
722
 
723
- out["Indeks_Dasar_Agregat_0_100"] = pd.to_numeric(out["Indeks_Dasar_Agregat_0_100"], errors="coerce").fillna(0.0).round(2)
724
- out["Indeks_Final_Wilayah_0_100"] = pd.to_numeric(out["Indeks_Final_Wilayah_0_100"], errors="coerce").fillna(0.0).round(2)
725
- out["n_total"] = pd.to_numeric(out["n_total"], errors="coerce").fillna(0).round(0).astype(int)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
 
 
 
 
 
 
727
  return out
728
 
 
729
  # ============================================================
730
- # 9) SUMMARY (PER JENIS + KESELURUHAN)
731
  # ============================================================
732
 
733
  def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
734
  jenis_list = ["sekolah", "umum", "khusus"]
735
- rows = []
736
 
737
- if agg_jenis is None or agg_jenis.empty:
738
- for j in jenis_list:
739
- rows.append({
740
- "Jenis": j,
741
- "Jumlah_Wilayah": 0,
742
- "Total_Perpus": 0,
743
- "Indeks_Dasar_0_100": 0.0,
744
- "Indeks_Final_Disesuaikan_0_100": 0.0,
745
- "Penyesuaian_Poin": 0.0
746
- })
747
- else:
 
 
 
 
 
 
748
  a = agg_jenis.copy()
749
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
750
 
751
- for j in jenis_list:
752
- sub = a[a["Jenis"] == j].copy()
 
 
 
 
 
 
 
753
  jumlah_wilayah = int(sub.shape[0])
754
- total_perpus = int(pd.to_numeric(sub["Jumlah"], errors="coerce").fillna(0).sum())
755
- dasar = float(pd.to_numeric(sub["Indeks_Dasar_Agregat_0_100"], errors="coerce").fillna(0).mean())
756
- final = float(pd.to_numeric(sub["Indeks_Final_Agregat_0_100"], errors="coerce").fillna(0).mean())
757
- rows.append({
758
- "Jenis": j,
759
- "Jumlah_Wilayah": jumlah_wilayah,
760
- "Total_Perpus": total_perpus,
761
- "Indeks_Dasar_0_100": round(dasar, 2),
762
- "Indeks_Final_Disesuaikan_0_100": round(final, 2),
763
- "Penyesuaian_Poin": round(final - dasar, 2),
764
- })
765
 
766
- # keseluruhan = avg3 FIX
767
- dasar_all = (rows[0]["Indeks_Dasar_0_100"] + rows[1]["Indeks_Dasar_0_100"] + rows[2]["Indeks_Dasar_0_100"]) / 3.0
768
- final_all = (rows[0]["Indeks_Final_Disesuaikan_0_100"] + rows[1]["Indeks_Final_Disesuaikan_0_100"] + rows[2]["Indeks_Final_Disesuaikan_0_100"]) / 3.0
769
 
770
- jumlah_wilayah_all = int(agg_total.shape[0]) if (agg_total is not None and not agg_total.empty) else int(max(rows[0]["Jumlah_Wilayah"], rows[1]["Jumlah_Wilayah"], rows[2]["Jumlah_Wilayah"]))
771
- total_perpus_all = int(rows[0]["Total_Perpus"] + rows[1]["Total_Perpus"] + rows[2]["Total_Perpus"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
 
773
  rows.append({
774
  "Jenis": "keseluruhan",
775
  "Jumlah_Wilayah": jumlah_wilayah_all,
776
- "Total_Perpus": total_perpus_all,
777
- "Indeks_Dasar_0_100": round(dasar_all, 2),
778
- "Indeks_Final_Disesuaikan_0_100": round(final_all, 2),
779
- "Penyesuaian_Poin": round(final_all - dasar_all, 2),
 
 
 
 
780
  })
781
 
782
  out = pd.DataFrame(rows)
783
- out["Jumlah_Wilayah"] = pd.to_numeric(out["Jumlah_Wilayah"], errors="coerce").fillna(0).astype(int)
784
- out["Total_Perpus"] = pd.to_numeric(out["Total_Perpus"], errors="coerce").fillna(0).astype(int)
785
- for c in ["Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin"]:
786
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
 
 
 
 
787
 
788
  return out
789
 
 
790
  # ============================================================
791
- # 10) DETAIL ENTITAS (Final menempel dari wilayah)
792
  # ============================================================
793
 
794
  def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, meta: dict, kew_value: str):
@@ -798,7 +1075,12 @@ def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, m
798
  kew_norm = str(kew_value or "").upper()
799
  df = df_filtered.copy()
800
 
801
- key_col = "prov_key" if "PROV" in kew_norm else "kab_key"
 
 
 
 
 
802
 
803
  if agg_total is None or agg_total.empty:
804
  df["Indeks_Final_0_100"] = df["Indeks_Dasar_0_100"]
@@ -808,31 +1090,77 @@ def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, m
808
  df["Indeks_Final_0_100"] = df["Indeks_Final_Wilayah_0_100"].fillna(df["Indeks_Dasar_0_100"])
809
  df = df.drop(columns=[c for c in ["group_key","Indeks_Final_Wilayah_0_100"] if c in df.columns])
810
 
 
811
  if meta.get("nama_col") and meta["nama_col"] in df.columns:
812
  df["nm_perpustakaan"] = df[meta["nama_col"]].astype(str)
813
- else:
814
- df["nm_perpustakaan"] = ""
815
 
816
- out = df[[
817
- "PROV_DISP","KAB_DISP","KEW_NORM","_dataset","nm_perpustakaan",
818
  "sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan",
819
  "dim_kepatuhan","dim_kinerja",
820
- "Indeks_Dasar_0_100","Indeks_Final_0_100"
821
- ]].copy()
 
 
 
 
 
822
 
823
- out = out.rename(columns={"PROV_DISP":"Provinsi","KAB_DISP":"Kab/Kota","_dataset":"Jenis"})
824
  for c in ["sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan","dim_kepatuhan","dim_kinerja"]:
825
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
 
826
  for c in ["Indeks_Dasar_0_100","Indeks_Final_0_100"]:
827
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
 
829
  return out
830
 
 
831
  # ============================================================
832
- # 11) BELL CURVE (ENTITAS) + HOVER NAMA
833
  # ============================================================
834
 
835
- def make_bell_curve_entitas(df: pd.DataFrame, title: str):
 
 
 
 
 
 
 
836
  fig = go.Figure()
837
  fig.update_layout(
838
  title=title,
@@ -840,58 +1168,67 @@ def make_bell_curve_entitas(df: pd.DataFrame, title: str):
840
  yaxis_title="Kepadatan",
841
  hovermode="closest",
842
  margin=dict(l=40, r=20, t=60, b=40),
 
843
  )
844
 
845
- if df is None or df.empty or "Indeks_Dasar_0_100" not in df.columns:
846
- fig.add_annotation(text="Tidak ada data.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
847
  fig.update_xaxes(range=[0, 100])
848
  fig.update_yaxes(rangemode="tozero")
849
  return fig
850
 
851
- d = df.dropna(subset=["Indeks_Dasar_0_100"]).copy()
852
  if d.empty:
853
- fig.add_annotation(text="Tidak ada data.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
854
  fig.update_xaxes(range=[0, 100])
855
  fig.update_yaxes(rangemode="tozero")
856
  return fig
857
 
858
- x = pd.to_numeric(d["Indeks_Dasar_0_100"], errors="coerce").astype(float)
859
  d = d.loc[x.notna()].copy()
860
  x = x.loc[x.notna()].values
861
  if len(x) < 1:
862
- fig.add_annotation(text="Tidak ada data.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
863
  fig.update_xaxes(range=[0, 100])
864
  fig.update_yaxes(rangemode="tozero")
865
  return fig
866
 
 
 
 
 
 
 
 
867
  hover_text = []
868
- for _, r in d.iterrows():
869
- nm = str(r.get("nm_perpustakaan", "") or "")
870
- prov = str(r.get("Provinsi", "") or "")
871
- kab = str(r.get("Kab/Kota", "") or "")
872
- jenis = str(r.get("Jenis", "") or "")
873
- val = float(pd.to_numeric(r.get("Indeks_Dasar_0_100", 0), errors="coerce") or 0.0)
874
  lines = []
875
- if nm.strip():
 
876
  lines.append(f"<b>{nm}</b>")
877
- if prov.strip():
878
- lines.append(f"Provinsi: {prov}")
879
- if kab.strip():
880
- lines.append(f"Kab/Kota: {kab}")
881
- if jenis.strip():
882
- lines.append(f"Jenis: {jenis}")
883
- lines.append(f"Indeks_Dasar_0_100: {val:.2f}")
884
  hover_text.append("<br>".join(lines))
885
 
886
- if len(x) < 2:
887
- xs = [float(x[0])]
888
- fig.add_trace(go.Scatter(x=xs, y=[0], mode="markers", hovertext=hover_text, hoverinfo="text", showlegend=False))
 
 
 
 
 
 
889
  fig.update_xaxes(range=[0, 100])
890
  fig.update_yaxes(rangemode="tozero")
891
  return fig
892
 
 
893
  mu = float(np.mean(x))
894
- sigma = float(np.std(x, ddof=1))
895
  sigma = max(sigma, 1e-3)
896
 
897
  xmin = max(0.0, float(np.min(x)) - 5.0)
@@ -900,51 +1237,69 @@ def make_bell_curve_entitas(df: pd.DataFrame, title: str):
900
  pdf = (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((xs - mu) / sigma) ** 2)
901
 
902
  fig.add_trace(go.Scatter(x=xs, y=pdf, mode="lines", name="Kurva Normal (fit)"))
903
- fig.add_trace(go.Scatter(x=x, y=np.zeros_like(x), mode="markers", hovertext=hover_text, hoverinfo="text", showlegend=False))
 
 
 
 
 
904
 
905
  q1, q2, q3 = np.percentile(x, [25, 50, 75])
906
- for xv, lab in [(q1, "Q1"), (q2, "Q2"), (q3, "Q3"), (mu, "Mean")]:
907
  fig.add_vline(x=float(xv), line_width=1, line_dash="dash", annotation_text=f"{lab}: {xv:.1f}", annotation_position="top")
908
 
909
  fig.update_xaxes(range=[0, 100])
910
  fig.update_yaxes(rangemode="tozero")
911
  return fig
912
 
 
913
  # ============================================================
914
- # 12) KPI DASHBOARD (2 kartu saja)
915
  # ============================================================
916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
917
  def build_kpi_markdown(summary_jenis: pd.DataFrame) -> str:
918
  if summary_jenis is None or summary_jenis.empty:
919
  return ""
920
 
921
- m = summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan")
922
- if not m.any():
923
- final_all = 0.0
924
- dasar_all = 0.0
925
- else:
926
- final_all = float(pd.to_numeric(summary_jenis.loc[m, "Indeks_Final_Disesuaikan_0_100"], errors="coerce").fillna(0).iloc[0])
927
- dasar_all = float(pd.to_numeric(summary_jenis.loc[m, "Indeks_Dasar_0_100"], errors="coerce").fillna(0).iloc[0])
928
 
929
- def fmt(x): return f"{x:.2f}"
 
930
 
931
  return f"""
932
  <div style="display:flex; gap:12px; flex-wrap:wrap;">
933
- <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:280px;">
934
  <div style="opacity:0.8;">Indeks IPLM FINAL (Disesuaikan 33.88%)</div>
935
- <div style="font-size:26px; font-weight:700;">{fmt(final_all)}</div>
936
  <div style="opacity:0.7;">Skor absolut (untuk akuntabilitas)</div>
937
  </div>
938
- <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:280px;">
 
939
  <div style="opacity:0.8;">Indeks Dasar (Tanpa Penyesuaian)</div>
940
- <div style="font-size:26px; font-weight:700;">{fmt(dasar_all)}</div>
941
  <div style="opacity:0.7;">Sebelum faktor kecukupan sampel</div>
942
  </div>
943
  </div>
944
  """.strip()
945
 
 
946
  # ============================================================
947
- # 13) LLM OUTPUT: FORMAT DATA PENDUKUNG (tabel seperti gambar)
948
  # ============================================================
949
 
950
  _HF_CLIENT = None
@@ -963,199 +1318,83 @@ def get_llm_client():
963
  _HF_CLIENT = None
964
  return None
965
 
966
- def _safe_float(x, default=0.0):
967
- try:
968
- if x is None or pd.isna(x):
969
- return float(default)
970
- return float(x)
971
- except Exception:
972
- return float(default)
973
-
974
- def _pick_wilayah_row(agg_total: pd.DataFrame, prov_value: str, kab_value: str):
975
- """
976
- Ambil 1 baris agg_total sesuai dropdown.
977
- Kalau tidak ketemu -> buat pseudo-row dari mean numeric.
978
- """
979
- if agg_total is None or agg_total.empty:
980
- return None
981
-
982
- cols = agg_total.columns.tolist()
983
- has_kab = "Kab/Kota" in cols
984
- has_prov = "Provinsi" in cols
985
-
986
- if kab_value and kab_value != "(Semua)" and has_kab:
987
- sub = agg_total[agg_total["Kab/Kota"].astype(str) == str(kab_value)]
988
- if not sub.empty:
989
- return sub.iloc[0]
990
-
991
- if prov_value and prov_value != "(Semua)" and has_prov:
992
- sub = agg_total[agg_total["Provinsi"].astype(str) == str(prov_value)]
993
- if not sub.empty:
994
- return sub.iloc[0]
995
-
996
- # fallback mean row
997
- num = agg_total.select_dtypes(include=[np.number]).mean(numeric_only=True)
998
- row = pd.Series({**{c: None for c in agg_total.columns}, **num.to_dict()})
999
- return row
1000
-
1001
- def build_table_rows_ipml(summary_jenis: pd.DataFrame, agg_total: pd.DataFrame, prov_value: str, kab_value: str):
1002
- """
1003
- NILAI diambil dari hasil hitung (bukan LLM):
1004
- - Kepatuhan/Kinerja dari agg_total (Rata2_dim_*)
1005
- - Variabel dari agg_total (Rata2_sub_*)
1006
- - Nilai IPLM dari summary_jenis (keseluruhan) (final)
1007
- sub/dim (0-1) dikonversi ke 0-100 supaya konsisten.
1008
- """
1009
- row = _pick_wilayah_row(agg_total, prov_value, kab_value)
1010
- if row is None:
1011
- kep = kin = sub_kol = sub_sdm = sub_pel = sub_png = 0.0
1012
- else:
1013
- kep = 100.0 * _safe_float(row.get("Rata2_dim_kepatuhan", 0))
1014
- kin = 100.0 * _safe_float(row.get("Rata2_dim_kinerja", 0))
1015
- sub_kol = 100.0 * _safe_float(row.get("Rata2_sub_koleksi", 0))
1016
- sub_sdm = 100.0 * _safe_float(row.get("Rata2_sub_sdm", 0))
1017
- sub_pel = 100.0 * _safe_float(row.get("Rata2_sub_pelayanan", 0))
1018
- sub_png = 100.0 * _safe_float(row.get("Rata2_sub_pengelolaan", 0))
1019
-
1020
- nilai_iplm = 0.0
1021
- if summary_jenis is not None and not summary_jenis.empty:
1022
- m = summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan")
1023
- if m.any():
1024
- nilai_iplm = _safe_float(summary_jenis.loc[m, "Indeks_Final_Disesuaikan_0_100"].iloc[0], 0.0)
1025
-
1026
- rows = [
1027
- {"no": "1", "dimensi": "Kepatuhan", "nilai": f"{kep:.2f}", "interpretasi": "", "rekomendasi": ""},
1028
- {"no": "1.1", "dimensi": "Variabel Koleksi", "nilai": f"{sub_kol:.2f}", "interpretasi": "", "rekomendasi": ""},
1029
- {"no": "1.2", "dimensi": "Variabel Tenaga Perpustakaan", "nilai": f"{sub_sdm:.2f}", "interpretasi": "", "rekomendasi": ""},
1030
- {"no": "2", "dimensi": "Kinerja", "nilai": f"{kin:.2f}", "interpretasi": "", "rekomendasi": ""},
1031
- {"no": "2.1", "dimensi": "Variabel Pelayanan", "nilai": f"{sub_pel:.2f}", "interpretasi": "", "rekomendasi": ""},
1032
- {"no": "2.2", "dimensi": "Variabel Penyelenggaraan/Pengelolaan","nilai": f"{sub_png:.2f}", "interpretasi": "", "rekomendasi": ""},
1033
- {"no": "4", "dimensi": "Nilai IPLM", "nilai": f"{nilai_iplm:.2f}","interpretasi": "", "rekomendasi": ""},
1034
- ]
1035
- return rows
1036
-
1037
- def llm_fill_interpretasi_rekomendasi(rows: list[dict], wilayah_txt: str, kew: str):
1038
- """
1039
- LLM hanya mengisi interpretasi + rekomendasi.
1040
- Output wajib JSON agar gampang diparsing.
1041
- """
1042
  client = get_llm_client()
1043
  if client is None or (not USE_LLM):
1044
- for r in rows:
1045
- r["interpretasi"] = "—"
1046
- r["rekomendasi"] = "—"
1047
- return rows
1048
-
1049
- payload = {"wilayah": wilayah_txt, "kewenangan": kew, "rows_input": [{"no": r["no"], "dimensi": r["dimensi"], "nilai": r["nilai"]} for r in rows]}
1050
- system = (
1051
- "Anda adalah analis kebijakan perpustakaan di Indonesia.\n"
1052
- "Isi kolom 'interpretasi' dan 'rekomendasi' secara NETRAL dan DESKRIPTIF.\n"
1053
- "DILARANG memakai label normatif: baik/buruk, tinggi/sedang/rendah, maju/tertinggal.\n"
1054
- "Interpretasi: 1–2 kalimat menjelaskan apa yang tercermin dari nilai.\n"
1055
- "Rekomendasi: 1–2 kalimat tindakan operasional.\n"
1056
- "Keluaran: JSON valid saja dengan format:\n"
1057
- "{\"rows\":[{\"no\":\"1\",\"interpretasi\":\"...\",\"rekomendasi\":\"...\"}, ...]}\n"
1058
- )
1059
  try:
1060
  resp = client.chat_completion(
1061
  model=LLM_MODEL_NAME,
1062
  messages=[
1063
- {"role": "system", "content": system},
1064
- {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}
1065
  ],
1066
- max_tokens=750,
1067
  temperature=0.25,
1068
  top_p=0.9,
1069
  )
1070
- text = (resp.choices[0].message.content or "").strip()
1071
- text = text.replace("```json", "").replace("```", "").strip()
1072
- obj = json.loads(text)
1073
-
1074
- mp = {str(x.get("no","")).strip(): x for x in obj.get("rows", [])}
1075
- for r in rows:
1076
- k = str(r["no"]).strip()
1077
- rr = mp.get(k, {})
1078
- r["interpretasi"] = str(rr.get("interpretasi", "—") or "—").strip()
1079
- r["rekomendasi"] = str(rr.get("rekomendasi", "—") or "—").strip()
1080
- return rows
1081
- except Exception:
1082
- for r in rows:
1083
- r["interpretasi"] = "—"
1084
- r["rekomendasi"] = "—"
1085
- return rows
1086
-
1087
- def render_format_data_pendukung_markdown(rows: list[dict], wilayah_txt: str, tahun_txt: str = "2025"):
1088
- md = []
1089
- md.append("**KOP SURAT** ")
1090
- md.append(f"**DINAS PERPUSTAKAAN DAN ARSIP {wilayah_txt}** ")
1091
- md.append("**INDEKS PEMBANGUNAN LITERASI MASYARAKAT** ")
1092
- md.append(f"**TAHUN {tahun_txt}**")
1093
- md.append("")
1094
- md.append("| No | Dimensi | Nilai | Interpretasi | Rekomendasi |")
1095
- md.append("|---:|---|---:|---|---|")
1096
- for r in rows:
1097
- md.append(f"| {r['no']} | {r['dimensi']} | {r['nilai']} | {r['interpretasi']} | {r['rekomendasi']} |")
1098
- return "\n".join(md)
1099
-
1100
- # ============================================================
1101
- # 14) WORD REPORT (opsional)
1102
- # ============================================================
1103
 
1104
- def generate_word_report(wilayah_txt: str, kpi_md: str, summary_jenis: pd.DataFrame, format_table_md: str):
1105
  if (not DOCX_AVAILABLE) or (Document is None):
1106
  return None
1107
-
1108
  doc = Document()
1109
- doc.add_heading(f"Laporan IPLM — {wilayah_txt}", level=1)
1110
  doc.add_paragraph(f"Target sampel per jenis: {TARGET_RATIO*100:.2f}%")
1111
-
1112
- doc.add_heading("KPI", level=2)
1113
- doc.add_paragraph("Indeks Final (Disesuaikan 33.88%) dan Indeks Dasar (tanpa penyesuaian) ditampilkan pada dashboard.")
1114
-
1115
  doc.add_heading("Ringkasan (Jenis + Keseluruhan)", level=2)
1116
  if summary_jenis is not None and not summary_jenis.empty:
1117
- tbl = doc.add_table(rows=1, cols=len(summary_jenis.columns))
1118
- for i, c in enumerate(summary_jenis.columns):
1119
- tbl.rows[0].cells[i].text = str(c)
1120
- for _, rr in summary_jenis.iterrows():
1121
- cells = tbl.add_row().cells
1122
- for i, c in enumerate(summary_jenis.columns):
1123
- v = rr[c]
 
1124
  if pd.isna(v):
1125
  cells[i].text = ""
1126
  elif isinstance(v, (float, np.floating)):
1127
  cells[i].text = f"{float(v):.2f}"
 
 
1128
  else:
1129
  cells[i].text = str(v)
1130
-
1131
- doc.add_heading("Format Data Pendukung (LLM)", level=2)
1132
- # simpan markdown sebagai teks (sederhana)
1133
- doc.add_paragraph(format_table_md)
1134
-
1135
  outpath = tempfile.mktemp(suffix=".docx")
1136
  doc.save(outpath)
1137
  return outpath
1138
 
 
1139
  # ============================================================
1140
  # 15) CORE RUN
1141
  # ============================================================
1142
 
1143
- def empty_outputs(msg="Data belum siap."):
1144
  empty = pd.DataFrame()
1145
  empty_fig = go.Figure()
1146
  return (
1147
- "", # kpi_md
1148
- empty, empty, empty, empty,
 
1149
  empty_fig, empty_fig, empty_fig,
1150
- msg,
1151
- "" # format_data_pendukung_md
1152
  )
1153
 
1154
  def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta):
1155
  try:
1156
- if df_all is None or df_all.empty:
1157
- return empty_outputs("Data belum ter-load. Pastikan file tersedia di server/repo.")
1158
 
 
 
 
1159
  df = df_all.copy()
1160
  if prov_value and prov_value != "(Semua)":
1161
  df = df[df["PROV_DISP"] == prov_value]
@@ -1165,52 +1404,137 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1165
  df = df[df["KEW_NORM"] == kew_value]
1166
 
1167
  if df.empty:
1168
- return empty_outputs("Tidak ada data untuk filter ini.")
1169
 
 
 
 
1170
  kew_norm = kew_value if (kew_value and kew_value != "(Semua)") else "(Semua)"
1171
-
1172
- # faktor -> agg jenis -> agg total -> summary -> detail
1173
- faktor_wj = build_faktor_wilayah_jenis(df, pop_kab, pop_prov, pop_khusus, kew_norm)
1174
- agg_jenis = build_agg_wilayah_jenis(df, faktor_wj, kew_norm)
1175
- agg_total = build_agg_wilayah_total_from_jenis(agg_jenis, kew_norm)
1176
- summary_jenis = build_summary_per_jenis(agg_jenis, agg_total)
1177
- detail = attach_final_to_detail(df, agg_total, meta, kew_norm)
1178
-
1179
- # KPI md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1180
  kpi_md = build_kpi_markdown(summary_jenis)
1181
 
1182
- # bell curves per jenis
1183
- d = detail.copy() if (detail is not None and not detail.empty) else pd.DataFrame()
1184
- fig_umum = make_bell_curve_entitas(d[d["Jenis"].astype(str).str.lower()=="umum"], "Bell Curve — Perpustakaan Umum (Indeks_Dasar_0_100)")
1185
- fig_sek = make_bell_curve_entitas(d[d["Jenis"].astype(str).str.lower()=="sekolah"], "Bell Curve — Perpustakaan Sekolah (Indeks_Dasar_0_100)")
1186
- fig_khu = make_bell_curve_entitas(d[d["Jenis"].astype(str).str.lower()=="khusus"], "Bell Curve — Perpustakaan Khusus (Indeks_Dasar_0_100)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1187
 
1188
- # Format Data Pendukung (LLM)
1189
  wilayah_txt = kab_value if (kab_value and kab_value != "(Semua)") else (prov_value if (prov_value and prov_value != "(Semua)") else "Nasional/All")
1190
- rows = build_table_rows_ipml(summary_jenis, agg_total, prov_value, kab_value)
1191
- rows = llm_fill_interpretasi_rekomendasi(rows, wilayah_txt, kew_norm)
1192
- format_md = render_format_data_pendukung_markdown(rows, wilayah_txt, tahun_txt="2025")
1193
 
1194
- msg = f"Selesai. Entitas={len(detail)} | Wilayah(keseluruhan)={len(agg_total)} | Wilayah×Jenis={len(agg_jenis)}"
 
 
 
 
1195
 
1196
  return (
1197
  kpi_md,
1198
- summary_jenis, agg_total, agg_jenis, detail,
1199
- fig_umum, fig_sek, fig_khu,
1200
- msg,
1201
- format_md
1202
  )
1203
 
1204
  except Exception as e:
1205
- return empty_outputs(f"Runtime error: {repr(e)}")
 
1206
 
1207
  # ============================================================
1208
- # 16) UI
1209
  # ============================================================
1210
 
1211
  def ui_load(force=False):
1212
  df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info = load_default_files(force=force)
1213
- if df_all is None or df_all.empty:
1214
  return (
1215
  None, None, None, None, None, {}, info,
1216
  gr.update(choices=["(Semua)"], value="(Semua)"),
@@ -1218,11 +1542,12 @@ def ui_load(force=False):
1218
  gr.update(choices=["(Semua)"], value="(Semua)"),
1219
  )
1220
 
1221
- prov_vals = [v for v in df_all["PROV_DISP"].dropna().astype(str).tolist() if v and v.strip()]
 
1222
  prov_choices = ["(Semua)"] + sorted(set(prov_vals))
 
1223
  kab_choices = ["(Semua)"] + sorted([x for x in df_all["KAB_DISP"].dropna().unique().tolist() if x])
1224
  kew_choices = ["(Semua)"] + sorted([x for x in df_all["KEW_NORM"].dropna().unique().tolist() if x])
1225
-
1226
  default_kew = "KAB/KOTA" if "KAB/KOTA" in kew_choices else ("PROVINSI" if "PROVINSI" in kew_choices else "(Semua)")
1227
 
1228
  return (
@@ -1233,7 +1558,7 @@ def ui_load(force=False):
1233
  )
1234
 
1235
  def on_prov_change(prov_value):
1236
- df_all, *_ = load_default_files(force=False)
1237
  if df_all is None or df_all.empty:
1238
  return gr.update(choices=["(Semua)"], value="(Semua)")
1239
  if prov_value is None or prov_value == "(Semua)":
@@ -1243,18 +1568,24 @@ def on_prov_change(prov_value):
1243
  vals = sorted([v for v in vals if v])
1244
  return gr.update(choices=["(Semua)"] + vals, value="(Semua)")
1245
 
 
1246
  with gr.Blocks() as demo:
1247
  gr.Markdown(f"""
1248
- # IPLM 2025 — Final (Target Sampel {TARGET_RATIO*100:.2f}% per Jenis) — TANPA Kinerja Relatif / Percentile
 
 
 
 
 
 
 
1249
 
1250
- Mode NO UPLOAD (cache). File dibaca dari server/repo:
1251
- - DATA_FILE = {DATA_FILE}
1252
- - POP_KAB = {POP_KAB}
1253
- - POP_PROV = {POP_PROV}
1254
- - POP_KHUSUS = {POP_KHUSUS}
1255
 
1256
- Dashboard KPI: hanya Indeks Final + Indeks Dasar
1257
- Analisis LLM: format tabel "Format Data Pendukung IPLM" (No/Dimensi/Nilai/Interpretasi/Rekomendasi)
1258
  """)
1259
 
1260
  state_df = gr.State(None)
@@ -1268,8 +1599,8 @@ Analisis LLM: format tabel "Format Data Pendukung IPLM" (No/Dimensi/Nilai/Interp
1268
 
1269
  with gr.Row():
1270
  dd_prov = gr.Dropdown(label="Provinsi", choices=["(Semua)"], value="(Semua)")
1271
- dd_kab = gr.Dropdown(label="Kab/Kota", choices=["(Semua)"], value="(Semua)")
1272
- dd_kew = gr.Dropdown(label="Kewenangan", choices=["(Semua)"], value="(Semua)")
1273
 
1274
  dd_prov.change(fn=on_prov_change, inputs=[dd_prov], outputs=dd_kab)
1275
 
@@ -1278,33 +1609,51 @@ Analisis LLM: format tabel "Format Data Pendukung IPLM" (No/Dimensi/Nilai/Interp
1278
 
1279
  kpi_out = gr.Markdown()
1280
 
1281
- gr.Markdown("## Ringkasan (Jenis + Keseluruhan)")
1282
  out_summary = gr.DataFrame(interactive=False)
1283
 
1284
  gr.Markdown("## Agregat Wilayah (Keseluruhan) — FIX avg3")
1285
  out_agg_total = gr.DataFrame(interactive=False)
1286
 
1287
- gr.Markdown("## Agregat Wilayah × Jenis")
1288
  out_agg_jenis = gr.DataFrame(interactive=False)
1289
 
1290
  gr.Markdown("## Detail Entitas (Final menempel dari wilayah)")
1291
  out_detail = gr.DataFrame(interactive=False)
1292
 
1293
- gr.Markdown("## Bell Curve Indeks Dasar per Entitas (per Jenis)")
 
 
 
1294
  gr.Markdown("### Perpustakaan Umum")
1295
  bell_umum = gr.Plot(scale=1)
 
1296
  gr.Markdown("### Perpustakaan Sekolah")
1297
  bell_sekolah = gr.Plot(scale=1)
 
1298
  gr.Markdown("### Perpustakaan Khusus")
1299
  bell_khusus = gr.Plot(scale=1)
1300
 
1301
- gr.Markdown("## Format Data Pendukung (LLM)")
1302
- format_md_out = gr.Markdown()
 
 
 
 
 
 
 
1303
 
1304
  run_btn.click(
1305
  fn=run_calc,
1306
  inputs=[dd_prov, dd_kab, dd_kew, state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta],
1307
- outputs=[kpi_out, out_summary, out_agg_total, out_agg_jenis, out_detail, bell_umum, bell_sekolah, bell_khusus, msg_out, format_md_out]
 
 
 
 
 
 
1308
  )
1309
 
1310
  demo.load(
 
2
  """
3
  IPLM 2025 — Final (Target Sampel 33.88% per Jenis) — TANPA Kinerja Relatif / Percentile
4
 
5
+ ───────────────────────────────────────────────────────────────────────────────
6
+ KONSEP / DOKUMENTASI
7
+
8
+ A. Skor ABSOLUT (untuk akuntabilitas)
9
+ ------------------------------------
10
+ 1) Indeks_Dasar_0_100
11
+ - Dihitung pada LEVEL ENTITAS (baris perpustakaan) dari indikator:
12
+ Yeo-Johnson transform (per indikator) MinMax global (0–1) → sub-indeks → dimensi → indeks.
13
+ - Rumus:
14
+ dim_kepatuhan = mean(sub_koleksi, sub_sdm)
15
+ dim_kinerja = mean(sub_pelayanan, sub_pengelolaan)
16
+ Indeks_Dasar_0_100 = 100 * (W_KEPATUHAN*dim_kepatuhan + W_KINERJA*dim_kinerja)
17
+
18
+ 2) Penyesuaian kecukupan sampel berbasis TARGET 33.88% (per JENIS)
19
+ - TARGET_RATIO = 0.3388
20
+ - Untuk setiap wilayah × jenis:
21
+ pop_total_jenis = populasi perpustakaan jenis tsb (dari tabel POP)
22
+ target_total_33_88_jenis = pop_total_jenis * TARGET_RATIO
23
+ n_jenis = jumlah entitas (baris) terkumpul pada wilayah × jenis
24
+ faktor_penyesuaian_jenis = min(n_jenis / target_total_33_88_jenis, 1.0)
25
+ - Indeks_Final_Agregat_0_100 (wilayah×jenis):
26
+ Indeks_Final_Agregat_0_100 = Indeks_Dasar_Agregat_0_100 * faktor_penyesuaian_jenis
27
+
28
+ 3) AGREGAT WILAYAH (KESELURUHAN) = rata-rata 3 jenis (FIX)
29
+ - Keseluruhan wajib avg3:
30
+ Indeks_Dasar_Agregat_0_100(keseluruhan) = (dasar_sekolah + dasar_umum + dasar_khusus) / 3
31
+ Indeks_Final_Wilayah_0_100(keseluruhan) = (final_sekolah + final_umum + final_khusus) / 3
32
+ - Missing jenis dianggap 0 tetapi tetap dibagi 3 (sesuai requirement).
33
+
34
+ B. UI (Permintaan)
35
+ ------------------
36
+ ✅ Dashboard KPI: hanya 2 kartu (Indeks Final & Indeks Dasar)
37
+ ❌ Tidak ada KPI Coverage di dashboard
38
+ ✅ Bell curve: kembali menampilkan Indeks_Dasar_0_100 per entitas per jenis
39
+ ✅ Hover bell curve menampilkan nama perpustakaan (nm_perpustakaan) per jenis
40
+
41
+ ───────────────────────────────────────────────────────────────────────────────
42
  """
43
 
44
  import os
45
  import re
46
  import time
 
47
  import tempfile
48
  from pathlib import Path
49
 
50
+ import gradio as gr
51
  import numpy as np
52
  import pandas as pd
53
  import plotly.graph_objects as go
 
54
  from sklearn.preprocessing import PowerTransformer
55
 
56
+ # python-docx opsional (di HF Space kadang belum ter-install)
 
 
57
  DOCX_AVAILABLE = True
58
  try:
59
  from docx import Document
 
61
  DOCX_AVAILABLE = False
62
  Document = None
63
 
64
+ # huggingface client opsional
 
 
65
  HF_AVAILABLE = True
66
  try:
67
  from huggingface_hub import InferenceClient
 
69
  HF_AVAILABLE = False
70
  InferenceClient = None
71
 
72
+
73
  # ============================================================
74
  # 1) KONFIGURASI
75
  # ============================================================
 
82
  W_KEPATUHAN = float(os.getenv("W_KEPATUHAN", "0.30"))
83
  W_KINERJA = float(os.getenv("W_KINERJA", "0.70"))
84
 
85
+ # ✅ target sampel 33.88% per jenis
86
  TARGET_RATIO = float(os.getenv("TARGET_RATIO", "0.3388"))
87
 
88
+ # LLM opsional
89
  USE_LLM = True
90
  LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "meta-llama/Meta-Llama-3-8B-Instruct")
91
  HF_TOKEN = (
 
95
  or os.getenv("HF_API_TOKEN")
96
  )
97
 
98
+
99
  # ============================================================
100
+ # 2) UTIL
101
  # ============================================================
102
 
103
+ def _mtime(path_str: str):
104
+ p = Path(path_str)
105
+ return p.stat().st_mtime if p.exists() else None
106
 
107
  def _canon(s: str) -> str:
108
  return re.sub(r"[^a-z0-9]+", "", str(s).lower())
 
113
  t = str(x).strip().upper()
114
  return " ".join(t.split())
115
 
116
+ def pick_col(df, candidates):
117
  if df is None or df.empty:
118
  return None
 
119
  for c in candidates:
120
  if c in df.columns:
121
  return c
122
+ can_map = {_canon(c): c for c in df.columns}
 
123
  for c in candidates:
124
  k = _canon(c)
125
+ if k in can_map:
126
+ return can_map[k]
127
  return None
128
 
129
  def coerce_num(val):
 
144
  t = t.replace(",", ".")
145
  else:
146
  t = t.replace(",", "")
147
+
148
  try:
149
  return float(t)
150
  except Exception:
151
  return np.nan
152
 
153
+ def minmax_norm(s: pd.Series) -> pd.Series:
154
+ x = pd.to_numeric(s, errors="coerce").astype(float)
155
  mn, mx = x.min(skipna=True), x.max(skipna=True)
156
  if pd.isna(mn) or pd.isna(mx) or mx == mn:
157
+ return pd.Series(0.0, index=s.index)
158
  return (x - mn) / (mx - mn)
159
 
160
  def norm_kew(v):
 
172
  def norm_prov_disp(s):
173
  if pd.isna(s):
174
  return None
175
+ t = str(s).strip().upper()
176
+ t = t.replace("\u00a0", " ")
177
+ t = " ".join(t.split())
178
+ t = t.replace("PROPINSI", "PROVINSI")
179
+ while t.startswith("PROVINSI PROVINSI "):
180
+ t = t.replace("PROVINSI PROVINSI ", "PROVINSI ", 1)
181
  if t.startswith("PROVINSI "):
182
  name = t[len("PROVINSI "):].strip()
183
  else:
184
  name = t
185
  name = " ".join(name.split())
186
+ if not name:
187
+ return None
188
+ return f"PROVINSI {name}"
189
 
190
+ def norm_prov_label(s):
191
  if pd.isna(s):
192
  return None
193
  t = str(s).strip().upper().replace("\u00a0", " ")
194
+ t = " ".join(t.split())
195
+ t = t.replace("PROPINSI", "PROVINSI")
196
  t = t.replace("PROVINSI", "").strip()
197
  return re.sub(r"[^A-Z0-9]+", "", t)
198
 
199
+ def norm_kab_label(s):
200
  if pd.isna(s):
201
  return None
202
  t = str(s).upper()
 
208
  t = " ".join(t.split())
209
  return re.sub(r"[^A-Z0-9]+", "", t)
210
 
211
+ def safe_div(num, den):
212
+ if den is None or pd.isna(den) or float(den) <= 0:
213
+ return np.nan
214
+ return float(num) / float(den)
215
+
216
+ def faktor_penyesuaian_total(n_total: float, target_total: float) -> float:
217
+ """
218
+ faktor = min(n / target, 1.0)
219
+ - Jika target <= 0 → default 1.0 (tidak menghukum)
220
+ """
221
+ if target_total is None or pd.isna(target_total) or float(target_total) <= 0:
222
  return 1.0
223
+ if n_total is None or pd.isna(n_total) or float(n_total) < 0:
224
+ n_total = 0.0
225
+ return float(min(float(n_total) / float(target_total), 1.0))
226
+
227
 
228
  # ============================================================
229
+ # 3) INDIKATOR IPLM
230
  # ============================================================
231
 
232
  koleksi_cols = [
 
251
  ]
252
  all_indicators = koleksi_cols + sdm_cols + pelayanan_cols + pengelolaan_cols
253
 
254
+ # alias kolom DM → nama baku indikator
255
  alias_map_raw = {
256
  "j_judul_koleksi_tercetak": "JudulTercetak",
257
  "j_eksemplar_koleksi_tercetak": "EksemplarTercetak",
 
281
  }
282
  alias_map = {_canon(k): v for k, v in alias_map_raw.items()}
283
 
284
+
285
  # ============================================================
286
+ # 4) PIPELINE NASIONAL (LEVEL ENTITAS)
287
  # ============================================================
288
 
289
+ def _mean_norm_cols(row, cols):
290
  vals = []
291
  for c in cols:
292
  k = f"norm_{c}"
 
299
 
300
  def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
301
  """
302
+ Transform + normalisasi indikator pada level entitas:
303
+ - rename kolom indikator (alias)
304
+ - coerce numeric
305
+ - Yeo-Johnson per indikator (standardize=False)
306
+ - MinMax global 0-1
307
+ - hitung sub_*, dim_*, Indeks_Dasar_0_100
308
  """
309
  if df_src is None or df_src.empty:
310
  return df_src
 
329
  for c in available:
330
  df[c] = df[c].apply(coerce_num)
331
 
332
+ # YJ per indikator + MinMax global
333
  for c in available:
334
  x = pd.to_numeric(df[c], errors="coerce").astype(float).values
335
  mask = ~np.isnan(x)
336
  transformed = np.full_like(x, np.nan, dtype=float)
 
337
  if mask.sum() > 1:
338
  pt = PowerTransformer(method="yeo-johnson", standardize=False)
339
  transformed[mask] = pt.fit_transform(x[mask].reshape(-1, 1)).ravel()
340
  else:
341
  transformed[mask] = x[mask]
 
342
  df[f"norm_{c}"] = minmax_norm(pd.Series(transformed, index=df.index))
343
 
344
  df["sub_koleksi"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in koleksi_cols if c in available]), axis=1)
 
346
  df["sub_pelayanan"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in pelayanan_cols if c in available]), axis=1)
347
  df["sub_pengelolaan"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in pengelolaan_cols if c in available]), axis=1)
348
 
349
+ df["dim_kepatuhan"] = df[["sub_koleksi","sub_sdm"]].mean(axis=1)
350
+ df["dim_kinerja"] = df[["sub_pelayanan","sub_pengelolaan"]].mean(axis=1)
351
 
352
+ df["Indeks_Dasar_0_100"] = 100 * (W_KEPATUHAN * df["dim_kepatuhan"] + W_KINERJA * df["dim_kinerja"])
353
 
354
  for c in ["sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan","dim_kepatuhan","dim_kinerja","Indeks_Dasar_0_100"]:
355
  df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0.0)
356
 
357
  return df
358
 
359
+
360
  # ============================================================
361
+ # 5) CACHE LOADER (NO UPLOAD)
362
  # ============================================================
363
 
364
+ _CACHE = {
365
+ "key": None,
366
+ "df_all": None,
367
+ "df_raw": None,
368
+ "pop_kab": None,
369
+ "pop_prov": None,
370
+ "pop_khusus": None,
371
+ "meta": None,
372
+ "info": None
373
+ }
374
 
375
  def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
376
+ """
377
+ POP_KHUSUS format campuran:
378
+ - Baris 'PROVINSI X' → level PROV
379
+ - Baris berikutnya → KAB/KOTA dibawah prov tsb
380
+ Output standar:
381
+ LEVEL: PROV / KAB
382
+ prov_key / kab_key
383
+ Pop_Total_Jenis
384
+ """
385
  df = pd.read_excel(path_xlsx)
386
  if df is None or df.empty:
387
  return pd.DataFrame()
 
432
  return pop
433
 
434
  pop["Pop_Total_Jenis"] = pd.to_numeric(pop["Pop_Total_Jenis"], errors="coerce").fillna(0.0)
435
+ pop["prov_key"] = pop["Provinsi_Label"].apply(norm_prov_label)
436
+ pop["kab_key"] = pop["Kab_Kota_Label"].apply(norm_kab_label) if "Kab_Kota_Label" in pop.columns else None
437
  return pop
438
 
439
+ def load_default_files(force=False):
440
+ """
441
+ Load 4 file:
442
+ - DM (DATA_FILE) multi-sheet → concat
443
+ - POP_KAB, POP_PROV, POP_KHUSUS
444
+ + Standarisasi kolom wilayah & jenis
445
+ + Dedup baris DM
446
+ + prepare_global() (YJ+MinMax+Indeks_Dasar)
447
+ """
448
  key = (
449
  DATA_FILE, POP_KAB, POP_PROV, POP_KHUSUS,
450
  _mtime(DATA_FILE), _mtime(POP_KAB), _mtime(POP_PROV), _mtime(POP_KHUSUS)
451
  )
452
+
453
  if (not force) and _CACHE["key"] == key and _CACHE["df_all"] is not None:
454
  return _CACHE["df_all"], _CACHE["df_raw"], _CACHE["pop_kab"], _CACHE["pop_prov"], _CACHE["pop_khusus"], _CACHE["meta"], _CACHE["info"]
455
 
456
  for p, label in [(DATA_FILE, "DM"), (POP_KAB, "POP_KAB"), (POP_PROV, "POP_PROV"), (POP_KHUSUS, "POP_KHUSUS")]:
457
  if not Path(p).exists():
458
+ info = f"File {label} tidak ditemukan: `{p}`"
459
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
460
  return None, None, None, None, None, {}, info
461
 
 
462
  fp = Path(DATA_FILE)
463
  xls = pd.ExcelFile(fp)
464
  frames = [pd.read_excel(fp, sheet_name=s) for s in xls.sheet_names]
465
  df_raw = pd.concat(frames, ignore_index=True, sort=False)
466
 
467
  prov_col = pick_col(df_raw, ["provinsi", "Provinsi", "PROVINSI"])
468
+ kab_col = pick_col(df_raw, ["kab/kota", "Kab/Kota", "Kab_Kota", "KAB/KOTA", "kabupaten_kota", "Kabupaten/Kota", "kabupaten kota", "kota", "kab_kota"])
469
  kew_col = pick_col(df_raw, ["kewenangan", "jenis_kewenangan", "Kewenangan", "KEWENANGAN"])
470
  jenis_col = pick_col(df_raw, ["jenis_perpustakaan", "Jenis Perpustakaan", "JENIS_PERPUSTAKAAN"])
471
  nama_col = pick_col(df_raw, ["nm_perpustakaan","nama_perpustakaan","Nama Perpustakaan","nm_instansi_lembaga","nm_perpus"])
 
476
  if kew_col is None: missing.append("Kewenangan")
477
  if jenis_col is None: missing.append("Jenis Perpustakaan")
478
  if missing:
479
+ info = f"Kolom wajib tidak ditemukan di DM: {', '.join(missing)}"
480
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
481
  return None, None, None, None, None, {}, info
482
 
483
+ # mapping jenis baku (sekolah/umum/khusus)
484
  val_map_jenis = {
485
  "PERPUSTAKAAN SEKOLAH": "sekolah", "SEKOLAH": "sekolah",
486
  "PERPUSTAKAAN UMUM": "umum", "UMUM": "umum", "PERPUSTAKAAN DAERAH": "umum",
 
491
  df_raw["_dataset"] = df_raw[jenis_col].astype(str).str.strip().str.upper().map(val_map_jenis)
492
  df_raw["PROV_DISP"] = df_raw[prov_col].apply(norm_prov_disp)
493
  df_raw["KAB_DISP"] = df_raw[kab_col].apply(_disp_text)
494
+ df_raw["prov_key"] = df_raw["PROV_DISP"].apply(norm_prov_label)
495
+ df_raw["kab_key"] = df_raw["KAB_DISP"].apply(norm_kab_label)
496
 
497
+ # Dedup aman berdasarkan (prov,kab,kew,jenis,nama_perpus)
498
  if nama_col and nama_col in df_raw.columns:
499
  kcols = [prov_col, kab_col, kew_col, jenis_col, nama_col]
500
  else:
 
506
  df_raw = df_raw.drop_duplicates(subset=["_row_key"], keep="first").copy()
507
  after = len(df_raw)
508
 
509
+ # POP KAB
510
  pk = pd.read_excel(POP_KAB)
511
  c_kab = pick_col(pk, ["KABUPATEN_KOTA","Kab/Kota","Kabupaten/Kota","KAB/KOTA","Kabupaten_Kota","kab_kota","kabupaten_kota"])
512
  c_prov = pick_col(pk, ["PROVINSI","Provinsi","provinsi"])
513
  if c_kab is None:
514
+ info = "POP_KAB: wajib ada kolom Kab/Kota."
515
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
516
  return None, None, None, None, None, {}, info
517
 
518
  pop_kab = pk.copy()
519
  pop_kab["Kab_Kota_Label"] = pk[c_kab].astype(str).str.strip()
520
  pop_kab["Provinsi_Label"] = pk[c_prov].astype(str).str.strip() if c_prov else ""
521
+ pop_kab["kab_key"] = pop_kab["Kab_Kota_Label"].apply(norm_kab_label)
522
  pop_kab = pop_kab.groupby("kab_key", as_index=False).first()
523
 
524
+ # POP PROV
525
  pp = pd.read_excel(POP_PROV)
526
  c_pr = pick_col(pp, ["Provinsi","PROVINSI","provinsi","Propinsi","PROPINSI","propinsi"])
527
  if c_pr is None:
528
+ info = "POP_PROV: wajib ada kolom Provinsi."
529
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
530
  return None, None, None, None, None, {}, info
531
 
532
  pop_prov = pp.copy()
533
  pop_prov["Provinsi_Label"] = pp[c_pr].astype(str).str.strip()
534
+ pop_prov["prov_key"] = pop_prov["Provinsi_Label"].apply(norm_prov_label)
535
  pop_prov = pop_prov.groupby("prov_key", as_index=False).first()
536
 
537
+ # POP KHUSUS
538
  try:
539
  pop_khusus = _parse_pop_khusus(POP_KHUSUS)
540
  except Exception as e:
541
+ info = f"POP_KHUSUS gagal dibaca: {repr(e)}"
542
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
543
  return None, None, None, None, None, {}, info
544
 
545
  df_all = prepare_global(df_raw)
 
546
  meta = dict(prov_col=prov_col, kab_col=kab_col, kew_col=kew_col, jenis_col=jenis_col, nama_col=nama_col)
547
 
548
  info = (
549
+ f"Mode NO UPLOAD (cache aktif)<br>"
550
+ f"DM: <b>{fp.name}</b> | Baris: {before} dedup: {after}<br>"
551
+ f"POP_KAB: <b>{Path(POP_KAB).name}</b> (n={len(pop_kab)})<br>"
552
+ f"POP_PROV: <b>{Path(POP_PROV).name}</b> (n={len(pop_prov)})<br>"
553
+ f"POP_KHUSUS: <b>{Path(POP_KHUSUS).name}</b> (n={len(pop_khusus)}) — (PROV row ikut dihitung)<br>"
554
+ f"TARGET sampel per jenis: <b>{TARGET_RATIO*100:.2f}%</b><br>"
555
+ f"🕒 mtime: DM={time.ctime(_mtime(DATA_FILE))} | Kab={time.ctime(_mtime(POP_KAB))} | Prov={time.ctime(_mtime(POP_PROV))} | Khusus={time.ctime(_mtime(POP_KHUSUS))}"
556
  )
557
 
558
+ _CACHE.update({
559
+ "key": key,
560
+ "df_all": df_all,
561
+ "df_raw": df_raw,
562
+ "pop_kab": pop_kab,
563
+ "pop_prov": pop_prov,
564
+ "pop_khusus": pop_khusus,
565
+ "meta": meta,
566
+ "info": info
567
+ })
568
  return df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info
569
 
570
+
571
  # ============================================================
572
+ # 6) FAKTOR WILAYAH — PER JENIS (TARGET 33.88%)
573
  # ============================================================
574
 
575
+ def build_faktor_wilayah_jenis(
576
+ df_filtered: pd.DataFrame,
577
+ pop_kab: pd.DataFrame,
578
+ pop_prov: pd.DataFrame,
579
+ pop_khusus: pd.DataFrame,
580
+ kew_value: str
581
+ ):
582
+ """
583
+ Output tabel:
584
+ group_key + (Kab/Kota atau Provinsi) + Jenis
585
+ n_jenis, pop_total_jenis, target_total_33_88_jenis,
586
+ coverage_jenis_%, faktor_penyesuaian_jenis, gap_target33_88_jenis
587
+ """
588
+ if df_filtered is None or df_filtered.empty:
589
  return pd.DataFrame()
590
 
591
  kew_norm = str(kew_value or "").upper()
592
+ df = df_filtered.copy()
593
  df = df[df["_dataset"].isin(["sekolah", "umum", "khusus"])].copy()
594
  if df.empty:
595
  return pd.DataFrame()
596
 
597
  jenis_list = ["sekolah", "umum", "khusus"]
598
 
599
+ # tentukan level berdasarkan kewenangan
600
  if "PROV" in kew_norm:
601
  key_col, label_col, label_name, mode = "prov_key", "PROV_DISP", "Provinsi", "PROV"
602
  base_pop = pop_prov.copy() if (pop_prov is not None and not pop_prov.empty) else pd.DataFrame()
603
  if not base_pop.empty and "prov_key" not in base_pop.columns:
604
+ base_pop["prov_key"] = base_pop["Provinsi_Label"].apply(norm_prov_label) if "Provinsi_Label" in base_pop.columns else base_pop.iloc[:, 0].apply(norm_prov_label)
605
  base_pop = base_pop.set_index("prov_key") if (not base_pop.empty and "prov_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
606
  else:
607
  key_col, label_col, label_name, mode = "kab_key", "KAB_DISP", "Kab/Kota", "KAB"
608
  base_pop = pop_kab.copy() if (pop_kab is not None and not pop_kab.empty) else pd.DataFrame()
609
  if not base_pop.empty and "kab_key" not in base_pop.columns:
610
+ base_pop["kab_key"] = base_pop["Kab_Kota_Label"].apply(norm_kab_label) if "Kab_Kota_Label" in base_pop.columns else base_pop.iloc[:, 0].apply(norm_kab_label)
611
  base_pop = base_pop.set_index("kab_key") if (not base_pop.empty and "kab_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
612
 
613
+ # GRID: semua wilayah × 3 jenis (berdasarkan yang muncul di data filter)
614
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
615
+ full = base_keys.assign(_tmp=1).merge(
616
+ pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}),
617
+ on="_tmp"
618
+ ).drop(columns="_tmp")
619
 
620
+ # count entitas per wilayah×jenis
621
  cnt = (
622
  df.groupby([key_col, label_col, "_dataset"], dropna=False)
623
+ .size()
624
+ .reset_index(name="n_jenis")
625
  .rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
626
  )
627
  cnt["Jenis"] = cnt["Jenis"].astype(str).str.lower().str.strip()
628
 
629
+ base_n = full.merge(cnt, on=["group_key", label_name, "Jenis"], how="left")
630
+ base_n["n_jenis"] = pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(int)
631
 
632
+ base_n["target_total_33_88_jenis"] = 0.0
633
+ base_n["pop_total_jenis"] = 0.0
634
 
635
+ # SEKOLAH + UMUM dari POP_KAB/POP_PROV
636
  if not base_pop.empty:
637
  if mode == "KAB":
638
  pop_sekolah = pd.to_numeric(base_pop.get("jumlah_populasi_sekolah", 0), errors="coerce").fillna(0.0)
639
  pop_umum = pd.to_numeric(base_pop.get("jumlah_populasi_umum", 0), errors="coerce").fillna(0.0)
640
+
641
+ tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
642
+ tgt_umum = pop_umum * float(TARGET_RATIO)
643
  else:
644
+ # PROV: sekolah = sma + smk + slb (sesuai pola file Anda)
645
  sma = pd.to_numeric(base_pop.get("sma ", base_pop.get("sma", 0)), errors="coerce").fillna(0.0)
646
  smk = pd.to_numeric(base_pop.get("smk", 0), errors="coerce").fillna(0.0)
647
  slb = pd.to_numeric(base_pop.get("slb", 0), errors="coerce").fillna(0.0)
648
+
649
  pop_sekolah = sma + smk + slb
650
+ tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
651
 
652
+ pop_umum = pd.to_numeric(base_pop.get("perpus_umum_prop", 0), errors="coerce").fillna(0.0)
653
+ tgt_umum = pop_umum * float(TARGET_RATIO)
654
 
655
+ m = base_n["Jenis"].eq("sekolah")
656
+ base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_sekolah).fillna(0.0).values
657
+ base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_sekolah).fillna(0.0).values
658
 
659
+ m = base_n["Jenis"].eq("umum")
660
+ base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_umum).fillna(0.0).values
661
+ base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_umum).fillna(0.0).values
662
 
663
+ # KHUSUS dari POP_KHUSUS
664
  if pop_khusus is not None and not pop_khusus.empty:
665
  pk = pop_khusus.copy()
666
  pk["Pop_Total_Jenis"] = pd.to_numeric(pk.get("Pop_Total_Jenis", 0), errors="coerce").fillna(0.0)
667
 
668
  if mode == "PROV":
669
+ pk_prov = pk[pk["LEVEL"].astype(str).str.upper() == "PROV"].copy()
670
+ pk_map = pk_prov.groupby("prov_key", as_index=True).agg(pop=("Pop_Total_Jenis", "sum"))
671
+ pop_series = pk_map["pop"]
672
  else:
673
+ pk_kab = pk[pk["LEVEL"].astype(str).str.upper() == "KAB"].copy()
674
+ pk_map = pk_kab.groupby("kab_key", as_index=True).agg(pop=("Pop_Total_Jenis", "sum"))
675
+ pop_series = pk_map["pop"]
676
 
677
  tgt_series = pop_series * float(TARGET_RATIO)
678
 
679
+ m = base_n["Jenis"].eq("khusus")
680
+ base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_series).fillna(0.0).values
681
+ base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_series).fillna(0.0).values
682
+
683
+ base_n["target_total_33_88_jenis"] = pd.to_numeric(base_n["target_total_33_88_jenis"], errors="coerce").fillna(0.0)
684
+ base_n["pop_total_jenis"] = pd.to_numeric(base_n["pop_total_jenis"], errors="coerce").fillna(0.0)
685
 
686
+ # fallback pop jika 0 tapi target ada
687
+ m_need_pop = (base_n["pop_total_jenis"] <= 0) & (base_n["target_total_33_88_jenis"] > 0)
688
+ base_n.loc[m_need_pop, "pop_total_jenis"] = base_n.loc[m_need_pop, "target_total_33_88_jenis"] / float(TARGET_RATIO)
689
 
690
+ # faktor penyesuaian
691
+ base_n["faktor_penyesuaian_jenis"] = [
692
+ faktor_penyesuaian_total(n, t)
693
+ for n, t in zip(
694
+ pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(float),
695
+ pd.to_numeric(base_n["target_total_33_88_jenis"], errors="coerce").fillna(0).astype(float),
696
+ )
697
  ]
698
 
699
+ base_n["coverage_jenis_%"] = [
700
+ (safe_div(n, p) * 100.0) if (p is not None and not pd.isna(p) and float(p) > 0) else 0.0
701
+ for n, p in zip(
702
+ pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(float),
703
+ pd.to_numeric(base_n["pop_total_jenis"], errors="coerce").fillna(0).astype(float),
704
+ )
705
+ ]
706
 
707
+ base_n["gap_target33_88_jenis"] = [
708
+ max(float(t) - float(n), 0.0)
709
+ for n, t in zip(
710
+ pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(float),
711
+ pd.to_numeric(base_n["target_total_33_88_jenis"], errors="coerce").fillna(0).astype(float),
712
+ )
713
+ ]
714
 
715
+ # display formatting
716
+ base_n["target_total_33_88_jenis"] = pd.to_numeric(base_n["target_total_33_88_jenis"], errors="coerce").fillna(0).round(0).astype(int)
717
+ base_n["pop_total_jenis"] = pd.to_numeric(base_n["pop_total_jenis"], errors="coerce").fillna(0).round(0).astype(int)
718
+ base_n["coverage_jenis_%"] = pd.to_numeric(base_n["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
719
+ base_n["faktor_penyesuaian_jenis"] = pd.to_numeric(base_n["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
720
+ base_n["gap_target33_88_jenis"] = pd.to_numeric(base_n["gap_target33_88_jenis"], errors="coerce").fillna(0).round(0).astype(int)
721
+
722
+ return base_n
723
 
 
724
 
725
  # ============================================================
726
+ # 7) AGREGAT WILAYAH × JENIS
727
  # ============================================================
728
 
729
+ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
730
+ """
731
+ Agregasi:
732
+ wilayah × jenis:
733
+ - Jumlah (n entitas)
734
+ - rata-rata sub/dim
735
+ - Indeks_Dasar_Agregat_0_100 = mean(Indeks_Dasar_0_100)
736
+ - Indeks_Final_Agregat_0_100 = Indeks_Dasar_Agregat_0_100 * faktor_penyesuaian_jenis
737
+ """
738
+ if df_filtered is None or df_filtered.empty:
739
  return pd.DataFrame()
740
 
741
  kew_norm = str(kew_value or "").upper()
742
+ df = df_filtered.copy()
743
+
744
  if "PROV" in kew_norm:
745
  key_col, label_col, label_name = "prov_key", "PROV_DISP", "Provinsi"
746
  else:
 
752
 
753
  jenis_list = ["sekolah", "umum", "khusus"]
754
 
755
+ # GRID semua wilayah × 3 jenis
756
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
757
+ full = base_keys.assign(_tmp=1).merge(
758
+ pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}),
759
+ on="_tmp"
760
+ ).drop(columns="_tmp")
761
 
762
+ # agregat real
763
  agg_real = df.groupby([key_col, label_col, "_dataset"], dropna=False).agg(
764
  Jumlah=("Indeks_Dasar_0_100", "size"),
765
  Rata2_sub_koleksi=("sub_koleksi", "mean"),
 
773
 
774
  agg_real["Jenis"] = agg_real["Jenis"].astype(str).str.lower().str.strip()
775
 
776
+ agg = full.merge(agg_real, on=["group_key", label_name, "Jenis"], how="left")
777
+ for c in ["Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
778
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja","Indeks_Dasar_Agregat_0_100"]:
779
+ if c in agg.columns:
780
+ agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0)
781
+
782
+ agg["Jumlah"] = agg["Jumlah"].round(0).astype(int)
783
+
784
+ # merge faktor jenis
785
+ if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
786
+ agg["faktor_penyesuaian_jenis"] = 1.0
787
+ agg["target_total_33_88_jenis"] = 0
788
+ agg["pop_total_jenis"] = 0
789
+ agg["coverage_jenis_%"] = 0.0
790
+ agg["gap_target33_88_jenis"] = 0
791
+ agg["n_jenis"] = 0
792
  else:
793
+ fw = faktor_wilayah_jenis.copy()
794
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
795
+
796
+ keep = ["group_key", label_name, "Jenis",
797
+ "faktor_penyesuaian_jenis", "target_total_33_88_jenis", "pop_total_jenis",
798
+ "coverage_jenis_%", "gap_target33_88_jenis", "n_jenis"]
799
  fw = fw[[c for c in keep if c in fw.columns]].copy()
 
800
 
801
+ agg = agg.merge(fw, on=["group_key", label_name, "Jenis"], how="left")
802
+ agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
803
+
804
+ for c in ["target_total_33_88_jenis","pop_total_jenis","gap_target33_88_jenis","n_jenis"]:
805
+ if c in agg.columns:
806
+ agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0).round(0).astype(int)
807
 
808
+ if "coverage_jenis_%" in agg.columns:
809
+ agg["coverage_jenis_%"] = pd.to_numeric(agg["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
810
+
811
+ # Indeks FINAL per jenis
812
+ agg["Indeks_Final_Agregat_0_100"] = (
813
+ pd.to_numeric(agg["Indeks_Dasar_Agregat_0_100"], errors="coerce").fillna(0.0)
814
+ * pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
815
+ )
816
 
817
  # rounding
818
+ for c in [
819
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
820
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja"
821
+ ]:
822
+ if c in agg.columns:
823
+ agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(3)
824
+
825
+ for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100"]:
826
+ if c in agg.columns:
827
+ agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(2)
828
+
829
+ agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
830
+ return agg
831
 
 
832
 
833
  # ============================================================
834
+ # 8) AGREGAT WILAYAH (KESELURUHAN) — avg3 dari 3 jenis
835
  # ============================================================
836
 
837
+ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
838
+ """
839
+ Membentuk tabel wilayah keseluruhan dari agg_jenis, dengan FIX avg3:
840
+ Indeks_Dasar_Agregat_0_100 (keseluruhan) = mean(dasar_3jenis) [missing=0, tetap /3]
841
+ Indeks_Final_Wilayah_0_100 (keseluruhan) = mean(final_3jenis) [missing=0, tetap /3]
842
+ """
843
  if agg_jenis is None or agg_jenis.empty:
844
  return pd.DataFrame()
845
 
846
  kew_norm = str(kew_value or "").upper()
847
  label_name = "Provinsi" if "PROV" in kew_norm else "Kab/Kota"
848
+ jenis_list = ["sekolah", "umum", "khusus"]
849
 
850
  a = agg_jenis.copy()
851
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
852
 
 
853
  base_keys = a[["group_key", label_name]].drop_duplicates()
854
+ full = base_keys.assign(_tmp=1).merge(
855
+ pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}),
856
+ on="_tmp"
857
+ ).drop(columns="_tmp")
858
 
859
+ cols_need = [
860
  "Jumlah",
861
  "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
862
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
863
+ "Indeks_Dasar_Agregat_0_100",
864
+ "Indeks_Final_Agregat_0_100",
865
  ]
866
+ cols_present = [c for c in cols_need if c in a.columns]
867
 
868
+ full = full.merge(
869
+ a[["group_key", label_name, "Jenis"] + cols_present],
870
+ on=["group_key", label_name, "Jenis"],
871
+ how="left"
872
+ )
873
+
874
+ for c in cols_present:
875
+ full[c] = pd.to_numeric(full[c], errors="coerce").fillna(0.0)
876
 
877
  out = full.groupby(["group_key", label_name], as_index=False).agg(
878
  n_total=("Jumlah", "sum"),
 
886
  Indeks_Final_Wilayah_0_100=("Indeks_Final_Agregat_0_100", "mean"),
887
  )
888
 
889
+ # Tempel info Pop/Target/N per jenis + total (tetap ada untuk verif/ekspor, meski dashboard coverage dihapus)
890
+ if faktor_wilayah_jenis is not None and not faktor_wilayah_jenis.empty:
891
+ fw = faktor_wilayah_jenis.copy()
892
+ fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
893
 
894
+ piv = fw.pivot_table(
895
+ index=["group_key", label_name],
896
+ columns="Jenis",
897
+ values=["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis", "faktor_penyesuaian_jenis"],
898
+ aggfunc="first"
899
+ )
900
+ piv.columns = [f"{v}_{k}" for v, k in piv.columns]
901
+ piv = piv.reset_index()
902
+ out = out.merge(piv, on=["group_key", label_name], how="left")
903
+
904
+ for j in ["sekolah", "umum", "khusus"]:
905
+ for basecol in ["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis"]:
906
+ c = f"{basecol}_{j}"
907
+ if c in out.columns:
908
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
909
+ cfac = f"faktor_penyesuaian_jenis_{j}"
910
+ if cfac in out.columns:
911
+ out[cfac] = pd.to_numeric(out[cfac], errors="coerce").fillna(1.0).round(3)
912
+
913
+ out["pop_total_all"] = (
914
+ out.get("pop_total_jenis_sekolah", 0)
915
+ + out.get("pop_total_jenis_umum", 0)
916
+ + out.get("pop_total_jenis_khusus", 0)
917
+ ).astype(int)
918
+
919
+ out["target_total_33_88_all"] = (
920
+ out.get("target_total_33_88_jenis_sekolah", 0)
921
+ + out.get("target_total_33_88_jenis_umum", 0)
922
+ + out.get("target_total_33_88_jenis_khusus", 0)
923
+ ).astype(int)
924
+
925
+ out["terkumpul_all"] = (
926
+ out.get("n_jenis_sekolah", 0)
927
+ + out.get("n_jenis_umum", 0)
928
+ + out.get("n_jenis_khusus", 0)
929
+ ).astype(int)
930
+
931
+ out["coverage_target33_88_all_%"] = np.where(
932
+ pd.to_numeric(out["target_total_33_88_all"], errors="coerce").fillna(0).values > 0,
933
+ (pd.to_numeric(out["terkumpul_all"], errors="coerce").fillna(0).values / pd.to_numeric(out["target_total_33_88_all"], errors="coerce").fillna(0).values) * 100.0,
934
+ 0.0
935
+ )
936
+ out["coverage_target33_88_all_%"] = pd.to_numeric(out["coverage_target33_88_all_%"], errors="coerce").fillna(0.0).round(2)
937
+
938
+ for c in [
939
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
940
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja"
941
+ ]:
942
+ if c in out.columns:
943
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
944
 
945
+ for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Wilayah_0_100"]:
946
+ if c in out.columns:
947
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
948
+
949
+ out["n_total"] = pd.to_numeric(out["n_total"], errors="coerce").fillna(0).round(0).astype(int)
950
  return out
951
 
952
+
953
  # ============================================================
954
+ # 9) SUMMARY (PER JENIS) + KESELURUHAN
955
  # ============================================================
956
 
957
  def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
958
  jenis_list = ["sekolah", "umum", "khusus"]
 
959
 
960
+ def _row_default(jenis):
961
+ return {
962
+ "Jenis": jenis,
963
+ "Jumlah_Wilayah": 0,
964
+ "Total_Perpus": 0,
965
+ "Pop_Total_Jenis": 0,
966
+ "Target33_88_Total_Jenis": 0,
967
+ "Terkumpul_Jenis": 0,
968
+ "Coverage_Target33_88_Jenis_%": 0.0,
969
+ "Indeks_Dasar_0_100": 0.0,
970
+ "Indeks_Final_Disesuaikan_0_100": 0.0,
971
+ "Penyesuaian_Poin": 0.0,
972
+ }
973
+
974
+ rows_by_jenis = {j: _row_default(j) for j in jenis_list}
975
+
976
+ if agg_jenis is not None and not agg_jenis.empty:
977
  a = agg_jenis.copy()
978
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
979
 
980
+ for c in ["Jumlah","Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100","pop_total_jenis","target_total_33_88_jenis"]:
981
+ if c in a.columns:
982
+ a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0)
983
+
984
+ for jenis in jenis_list:
985
+ sub = a[a["Jenis"] == jenis].copy()
986
+ if sub.empty:
987
+ continue
988
+
989
  jumlah_wilayah = int(sub.shape[0])
990
+ terkumpul = int(pd.to_numeric(sub.get("Jumlah", 0), errors="coerce").fillna(0).sum())
991
+ pop_total = int(pd.to_numeric(sub.get("pop_total_jenis", 0), errors="coerce").fillna(0).sum())
992
+ target3388 = int(pd.to_numeric(sub.get("target_total_33_88_jenis", 0), errors="coerce").fillna(0).sum())
 
 
 
 
 
 
 
 
993
 
994
+ coverage = (terkumpul / target3388 * 100.0) if target3388 > 0 else 0.0
995
+ dasar = float(pd.to_numeric(sub.get("Indeks_Dasar_Agregat_0_100", 0), errors="coerce").fillna(0).mean())
996
+ final = float(pd.to_numeric(sub.get("Indeks_Final_Agregat_0_100", 0), errors="coerce").fillna(0).mean())
997
 
998
+ rows_by_jenis[jenis] = {
999
+ "Jenis": jenis,
1000
+ "Jumlah_Wilayah": jumlah_wilayah,
1001
+ "Total_Perpus": terkumpul,
1002
+ "Pop_Total_Jenis": pop_total,
1003
+ "Target33_88_Total_Jenis": target3388,
1004
+ "Terkumpul_Jenis": terkumpul,
1005
+ "Coverage_Target33_88_Jenis_%": float(coverage),
1006
+ "Indeks_Dasar_0_100": float(dasar),
1007
+ "Indeks_Final_Disesuaikan_0_100": float(final),
1008
+ "Penyesuaian_Poin": float(final - dasar),
1009
+ }
1010
+
1011
+ rows = [rows_by_jenis[j] for j in jenis_list]
1012
+
1013
+ dasar_all = (rows_by_jenis["sekolah"]["Indeks_Dasar_0_100"]
1014
+ + rows_by_jenis["umum"]["Indeks_Dasar_0_100"]
1015
+ + rows_by_jenis["khusus"]["Indeks_Dasar_0_100"]) / 3.0
1016
+
1017
+ final_all = (rows_by_jenis["sekolah"]["Indeks_Final_Disesuaikan_0_100"]
1018
+ + rows_by_jenis["umum"]["Indeks_Final_Disesuaikan_0_100"]
1019
+ + rows_by_jenis["khusus"]["Indeks_Final_Disesuaikan_0_100"]) / 3.0
1020
+
1021
+ pop_all = int(rows_by_jenis["sekolah"]["Pop_Total_Jenis"]
1022
+ + rows_by_jenis["umum"]["Pop_Total_Jenis"]
1023
+ + rows_by_jenis["khusus"]["Pop_Total_Jenis"])
1024
+
1025
+ target_all = int(rows_by_jenis["sekolah"]["Target33_88_Total_Jenis"]
1026
+ + rows_by_jenis["umum"]["Target33_88_Total_Jenis"]
1027
+ + rows_by_jenis["khusus"]["Target33_88_Total_Jenis"])
1028
+
1029
+ terkumpul_all = int(rows_by_jenis["sekolah"]["Terkumpul_Jenis"]
1030
+ + rows_by_jenis["umum"]["Terkumpul_Jenis"]
1031
+ + rows_by_jenis["khusus"]["Terkumpul_Jenis"])
1032
+
1033
+ coverage_all = (terkumpul_all / target_all * 100.0) if target_all > 0 else 0.0
1034
+
1035
+ jumlah_wilayah_all = int(agg_total.shape[0]) if (agg_total is not None and not agg_total.empty) else int(
1036
+ max(rows_by_jenis["sekolah"]["Jumlah_Wilayah"],
1037
+ rows_by_jenis["umum"]["Jumlah_Wilayah"],
1038
+ rows_by_jenis["khusus"]["Jumlah_Wilayah"])
1039
+ )
1040
 
1041
  rows.append({
1042
  "Jenis": "keseluruhan",
1043
  "Jumlah_Wilayah": jumlah_wilayah_all,
1044
+ "Total_Perpus": terkumpul_all,
1045
+ "Pop_Total_Jenis": pop_all,
1046
+ "Target33_88_Total_Jenis": target_all,
1047
+ "Terkumpul_Jenis": terkumpul_all,
1048
+ "Coverage_Target33_88_Jenis_%": float(coverage_all),
1049
+ "Indeks_Dasar_0_100": float(dasar_all),
1050
+ "Indeks_Final_Disesuaikan_0_100": float(final_all),
1051
+ "Penyesuaian_Poin": float(final_all - dasar_all),
1052
  })
1053
 
1054
  out = pd.DataFrame(rows)
1055
+
1056
+ for c in ["Jumlah_Wilayah","Total_Perpus","Pop_Total_Jenis","Target33_88_Total_Jenis","Terkumpul_Jenis"]:
1057
+ if c in out.columns:
1058
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
1059
+
1060
+ for c in ["Coverage_Target33_88_Jenis_%","Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin"]:
1061
+ if c in out.columns:
1062
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
1063
 
1064
  return out
1065
 
1066
+
1067
  # ============================================================
1068
+ # 10) DETAIL ENTITAS: Final menempel dari agg_total (wilayah)
1069
  # ============================================================
1070
 
1071
  def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, meta: dict, kew_value: str):
 
1075
  kew_norm = str(kew_value or "").upper()
1076
  df = df_filtered.copy()
1077
 
1078
+ if "PROV" in kew_norm:
1079
+ key_col = "prov_key"
1080
+ label_cols = ("PROV_DISP", "KAB_DISP")
1081
+ else:
1082
+ key_col = "kab_key"
1083
+ label_cols = ("PROV_DISP", "KAB_DISP")
1084
 
1085
  if agg_total is None or agg_total.empty:
1086
  df["Indeks_Final_0_100"] = df["Indeks_Dasar_0_100"]
 
1090
  df["Indeks_Final_0_100"] = df["Indeks_Final_Wilayah_0_100"].fillna(df["Indeks_Dasar_0_100"])
1091
  df = df.drop(columns=[c for c in ["group_key","Indeks_Final_Wilayah_0_100"] if c in df.columns])
1092
 
1093
+ base_cols = [label_cols[0], label_cols[1], "KEW_NORM", "_dataset"]
1094
  if meta.get("nama_col") and meta["nama_col"] in df.columns:
1095
  df["nm_perpustakaan"] = df[meta["nama_col"]].astype(str)
1096
+ base_cols.insert(2, "nm_perpustakaan")
 
1097
 
1098
+ keep = base_cols + [
 
1099
  "sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan",
1100
  "dim_kepatuhan","dim_kinerja",
1101
+ "Indeks_Dasar_0_100",
1102
+ "Indeks_Final_0_100",
1103
+ ]
1104
+ keep = [c for c in keep if c in df.columns]
1105
+
1106
+ out = df[keep].copy()
1107
+ out = out.rename(columns={label_cols[0]:"Provinsi", label_cols[1]:"Kab/Kota", "_dataset":"Jenis"})
1108
 
 
1109
  for c in ["sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan","dim_kepatuhan","dim_kinerja"]:
1110
+ if c in out.columns:
1111
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
1112
  for c in ["Indeks_Dasar_0_100","Indeks_Final_0_100"]:
1113
+ if c in out.columns:
1114
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
1115
+
1116
+ return out
1117
+
1118
+
1119
+ # ============================================================
1120
+ # 11) VERIFIKASI PER JENIS (TARGET 33.88%)
1121
+ # ============================================================
1122
+
1123
+ def build_verif_jenis(faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
1124
+ if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
1125
+ return pd.DataFrame()
1126
+
1127
+ kew_norm = str(kew_value or "").upper()
1128
+ label_col = "Provinsi" if "PROV" in kew_norm else "Kab/Kota"
1129
+
1130
+ out = faktor_wilayah_jenis.copy()
1131
+ keep = [c for c in [
1132
+ label_col, "Jenis",
1133
+ "pop_total_jenis", "target_total_33_88_jenis", "n_jenis",
1134
+ "coverage_jenis_%", "faktor_penyesuaian_jenis", "gap_target33_88_jenis"
1135
+ ] if c in out.columns]
1136
+
1137
+ out = out[keep].copy()
1138
+
1139
+ for c in ["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis"]:
1140
+ if c in out.columns:
1141
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
1142
+
1143
+ if "coverage_jenis_%" in out.columns:
1144
+ out["coverage_jenis_%"] = pd.to_numeric(out["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
1145
+
1146
+ if "faktor_penyesuaian_jenis" in out.columns:
1147
+ out["faktor_penyesuaian_jenis"] = pd.to_numeric(out["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
1148
 
1149
  return out
1150
 
1151
+
1152
  # ============================================================
1153
+ # 12) BELL CURVE — Indeks Dasar per Entitas (per Jenis) + Hover Nama Perpus
1154
  # ============================================================
1155
 
1156
+ def _make_bell_curve_entitas(
1157
+ dfp: pd.DataFrame,
1158
+ title: str,
1159
+ xcol: str = "Indeks_Dasar_0_100",
1160
+ label_col: str = "nm_perpustakaan",
1161
+ hover_cols: list | None = None,
1162
+ min_points: int = 2
1163
+ ):
1164
  fig = go.Figure()
1165
  fig.update_layout(
1166
  title=title,
 
1168
  yaxis_title="Kepadatan",
1169
  hovermode="closest",
1170
  margin=dict(l=40, r=20, t=60, b=40),
1171
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
1172
  )
1173
 
1174
+ if dfp is None or dfp.empty or xcol not in dfp.columns:
1175
+ fig.add_annotation(text="Tidak ada data untuk ditampilkan.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
1176
  fig.update_xaxes(range=[0, 100])
1177
  fig.update_yaxes(rangemode="tozero")
1178
  return fig
1179
 
1180
+ d = dfp.dropna(subset=[xcol]).copy()
1181
  if d.empty:
1182
+ fig.add_annotation(text="Tidak ada data untuk ditampilkan.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
1183
  fig.update_xaxes(range=[0, 100])
1184
  fig.update_yaxes(rangemode="tozero")
1185
  return fig
1186
 
1187
+ x = pd.to_numeric(d[xcol], errors="coerce").astype(float)
1188
  d = d.loc[x.notna()].copy()
1189
  x = x.loc[x.notna()].values
1190
  if len(x) < 1:
1191
+ fig.add_annotation(text="Tidak ada data untuk ditampilkan.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
1192
  fig.update_xaxes(range=[0, 100])
1193
  fig.update_yaxes(rangemode="tozero")
1194
  return fig
1195
 
1196
+ hover_cols = hover_cols or []
1197
+ def _val(row, col):
1198
+ if col not in row.index:
1199
+ return ""
1200
+ v = row[col]
1201
+ return "" if pd.isna(v) else str(v)
1202
+
1203
  hover_text = []
1204
+ for _, row in d.iterrows():
 
 
 
 
 
1205
  lines = []
1206
+ nm = _val(row, label_col) if (label_col and label_col in d.columns) else ""
1207
+ if nm:
1208
  lines.append(f"<b>{nm}</b>")
1209
+ lines.append(f"{xcol}: {float(pd.to_numeric(row[xcol], errors='coerce')):.2f}")
1210
+ for hc in hover_cols:
1211
+ vv = _val(row, hc)
1212
+ if vv:
1213
+ lines.append(f"{hc}: {vv}")
 
 
1214
  hover_text.append("<br>".join(lines))
1215
 
1216
+ if len(x) < min_points:
1217
+ x_single = float(x[0])
1218
+ fig.add_trace(go.Scatter(
1219
+ x=[x_single], y=[0],
1220
+ mode="markers", showlegend=False,
1221
+ hovertext=[hover_text[0]] if hover_text else None,
1222
+ hoverinfo="text"
1223
+ ))
1224
+ fig.add_vline(x=x_single, line_width=1, line_dash="dash", annotation_text=f"Nilai: {x_single:.1f}", annotation_position="top")
1225
  fig.update_xaxes(range=[0, 100])
1226
  fig.update_yaxes(rangemode="tozero")
1227
  return fig
1228
 
1229
+ # fit normal curve (untuk visual)
1230
  mu = float(np.mean(x))
1231
+ sigma = float(np.std(x, ddof=1)) if len(x) > 1 else 1.0
1232
  sigma = max(sigma, 1e-3)
1233
 
1234
  xmin = max(0.0, float(np.min(x)) - 5.0)
 
1237
  pdf = (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((xs - mu) / sigma) ** 2)
1238
 
1239
  fig.add_trace(go.Scatter(x=xs, y=pdf, mode="lines", name="Kurva Normal (fit)"))
1240
+ fig.add_trace(go.Scatter(
1241
+ x=x, y=np.zeros_like(x),
1242
+ mode="markers", showlegend=False,
1243
+ hovertext=hover_text if hover_text else None,
1244
+ hoverinfo="text"
1245
+ ))
1246
 
1247
  q1, q2, q3 = np.percentile(x, [25, 50, 75])
1248
+ for xv, lab in [(q1, "Q1"), (q2, "Q2 (Median)"), (q3, "Q3"), (mu, "Mean")]:
1249
  fig.add_vline(x=float(xv), line_width=1, line_dash="dash", annotation_text=f"{lab}: {xv:.1f}", annotation_position="top")
1250
 
1251
  fig.update_xaxes(range=[0, 100])
1252
  fig.update_yaxes(rangemode="tozero")
1253
  return fig
1254
 
1255
+
1256
  # ============================================================
1257
+ # 13) KPI DASHBOARD (HANYA 2 KARTU: FINAL + DASAR)
1258
  # ============================================================
1259
 
1260
+ def _safe_first(df, col, default=0.0, where=None):
1261
+ if df is None or df.empty or col not in df.columns:
1262
+ return default
1263
+ sub = df
1264
+ if where is not None:
1265
+ sub = df.loc[where]
1266
+ if sub is None or sub.empty:
1267
+ return default
1268
+ return float(pd.to_numeric(sub[col], errors="coerce").fillna(default).iloc[0])
1269
+
1270
+ def compute_dashboard_kpis(summary_jenis: pd.DataFrame):
1271
+ final_all = _safe_first(summary_jenis, "Indeks_Final_Disesuaikan_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1272
+ dasar_all = _safe_first(summary_jenis, "Indeks_Dasar_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1273
+ return {"final_all": final_all, "dasar_all": dasar_all}
1274
+
1275
  def build_kpi_markdown(summary_jenis: pd.DataFrame) -> str:
1276
  if summary_jenis is None or summary_jenis.empty:
1277
  return ""
1278
 
1279
+ k = compute_dashboard_kpis(summary_jenis)
 
 
 
 
 
 
1280
 
1281
+ def fmt(x, nd=2):
1282
+ return "NA" if pd.isna(x) else f"{x:.{nd}f}"
1283
 
1284
  return f"""
1285
  <div style="display:flex; gap:12px; flex-wrap:wrap;">
1286
+ <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1287
  <div style="opacity:0.8;">Indeks IPLM FINAL (Disesuaikan 33.88%)</div>
1288
+ <div style="font-size:26px; font-weight:700;">{fmt(k["final_all"],2)}</div>
1289
  <div style="opacity:0.7;">Skor absolut (untuk akuntabilitas)</div>
1290
  </div>
1291
+
1292
+ <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1293
  <div style="opacity:0.8;">Indeks Dasar (Tanpa Penyesuaian)</div>
1294
+ <div style="font-size:26px; font-weight:700;">{fmt(k["dasar_all"],2)}</div>
1295
  <div style="opacity:0.7;">Sebelum faktor kecukupan sampel</div>
1296
  </div>
1297
  </div>
1298
  """.strip()
1299
 
1300
+
1301
  # ============================================================
1302
+ # 14) LLM + WORD (OPSIONAL)
1303
  # ============================================================
1304
 
1305
  _HF_CLIENT = None
 
1318
  _HF_CLIENT = None
1319
  return None
1320
 
1321
+ def generate_llm_analysis(summary_jenis, agg_total, verif_total, wilayah, kew):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1322
  client = get_llm_client()
1323
  if client is None or (not USE_LLM):
1324
+ return "Analisis otomatis (LLM) tidak digunakan / tidak tersedia."
1325
+ ctx = f"Wilayah={wilayah} | Kewenangan={kew} | Target={TARGET_RATIO*100:.2f}%"
 
 
 
 
 
 
 
 
 
 
 
 
 
1326
  try:
1327
  resp = client.chat_completion(
1328
  model=LLM_MODEL_NAME,
1329
  messages=[
1330
+ {"role":"system","content":"Anda adalah analis kebijakan perpustakaan di Indonesia. Tulis analisis ringkas berbasis data tanpa menyebut angka hanya deskripsi saja namun tanpa memberikan penilaian tinggi ."},
1331
+ {"role":"user","content":f"{ctx}\nBuat analisis 3 paragraf: (1) indeks dasar, (2) penyesuaian 33.88% dan implikasinya, (3) rekomendasi singkat."}
1332
  ],
1333
+ max_tokens=500,
1334
  temperature=0.25,
1335
  top_p=0.9,
1336
  )
1337
+ text = resp.choices[0].message.content.strip()
1338
+ return text if text else "LLM mengembalikan respon kosong."
1339
+ except Exception as e:
1340
+ return f"⚠️ Error LLM: {repr(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1341
 
1342
+ def generate_word_report(wilayah, summary_jenis, analysis_text):
1343
  if (not DOCX_AVAILABLE) or (Document is None):
1344
  return None
 
1345
  doc = Document()
1346
+ doc.add_heading(f"Laporan IPLM — {wilayah}", level=1)
1347
  doc.add_paragraph(f"Target sampel per jenis: {TARGET_RATIO*100:.2f}%")
 
 
 
 
1348
  doc.add_heading("Ringkasan (Jenis + Keseluruhan)", level=2)
1349
  if summary_jenis is not None and not summary_jenis.empty:
1350
+ show = summary_jenis.copy()
1351
+ table = doc.add_table(rows=1, cols=len(show.columns))
1352
+ for i, c in enumerate(show.columns):
1353
+ table.rows[0].cells[i].text = str(c)
1354
+ for _, row in show.iterrows():
1355
+ cells = table.add_row().cells
1356
+ for i, c in enumerate(show.columns):
1357
+ v = row[c]
1358
  if pd.isna(v):
1359
  cells[i].text = ""
1360
  elif isinstance(v, (float, np.floating)):
1361
  cells[i].text = f"{float(v):.2f}"
1362
+ elif isinstance(v, (int, np.integer)):
1363
+ cells[i].text = str(int(v))
1364
  else:
1365
  cells[i].text = str(v)
1366
+ doc.add_heading("Analisis (opsional)", level=2)
1367
+ for p in (analysis_text or "").split("\n"):
1368
+ if p.strip():
1369
+ doc.add_paragraph(p.strip())
 
1370
  outpath = tempfile.mktemp(suffix=".docx")
1371
  doc.save(outpath)
1372
  return outpath
1373
 
1374
+
1375
  # ============================================================
1376
  # 15) CORE RUN
1377
  # ============================================================
1378
 
1379
+ def _empty_outputs(msg="⚠️ Data belum siap."):
1380
  empty = pd.DataFrame()
1381
  empty_fig = go.Figure()
1382
  return (
1383
+ "", # kpi_md
1384
+ empty, empty, empty, empty, empty,
1385
+ None, None, None, None, None,
1386
  empty_fig, empty_fig, empty_fig,
1387
+ msg, "Analisis belum tersedia."
 
1388
  )
1389
 
1390
  def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta):
1391
  try:
1392
+ if df_all is None or df_all.empty or df_raw is None or df_raw.empty:
1393
+ return _empty_outputs("⚠️ Data belum ter-load. Pastikan file tersedia di repo/server.")
1394
 
1395
+ # =========================================================
1396
+ # 1) FILTER df_all (entitas) sesuai dropdown
1397
+ # =========================================================
1398
  df = df_all.copy()
1399
  if prov_value and prov_value != "(Semua)":
1400
  df = df[df["PROV_DISP"] == prov_value]
 
1404
  df = df[df["KEW_NORM"] == kew_value]
1405
 
1406
  if df.empty:
1407
+ return _empty_outputs("Tidak ada data untuk filter ini.")
1408
 
1409
+ # =========================================================
1410
+ # 2) PIPELINE FILTER → faktor → agg_jenis → agg_total
1411
+ # =========================================================
1412
  kew_norm = kew_value if (kew_value and kew_value != "(Semua)") else "(Semua)"
1413
+ faktor_wilayah_jenis = build_faktor_wilayah_jenis(df, pop_kab, pop_prov, pop_khusus, kew_norm)
1414
+ agg_jenis_full = build_agg_wilayah_jenis(df, faktor_wilayah_jenis, kew_norm)
1415
+ agg_total = build_agg_wilayah_total_from_jenis(agg_jenis_full, faktor_wilayah_jenis, kew_norm)
1416
+
1417
+ # =========================================================
1418
+ # 3) OUTPUT TABLES
1419
+ # =========================================================
1420
+ summary_jenis = build_summary_per_jenis(agg_jenis_full, agg_total)
1421
+ verif_total = build_verif_jenis(faktor_wilayah_jenis, kew_norm)
1422
+ detail_view = attach_final_to_detail(df, agg_total, meta, kew_norm)
1423
+
1424
+ # =========================================================
1425
+ # 4) agg_jenis view (UI hanya sampai indeks dasar)
1426
+ # =========================================================
1427
+ if agg_jenis_full is None or agg_jenis_full.empty:
1428
+ agg_jenis_view = agg_jenis_full
1429
+ else:
1430
+ kew_norm2 = str(kew_norm).upper()
1431
+ label_name = "Kab/Kota" if ("KAB" in kew_norm2 or "KOTA" in kew_norm2) else ("Provinsi" if "PROV" in kew_norm2 else "Kab/Kota")
1432
+ cols_upto = [
1433
+ "group_key",
1434
+ label_name,
1435
+ "Jenis",
1436
+ "Jumlah",
1437
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
1438
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
1439
+ "Indeks_Dasar_Agregat_0_100",
1440
+ ]
1441
+ cols_upto = [c for c in cols_upto if c in agg_jenis_full.columns]
1442
+ agg_jenis_view = agg_jenis_full[cols_upto].copy()
1443
+
1444
+ # =========================================================
1445
+ # 5) FILTER RAW DOWNLOAD (harus raw hasil filter)
1446
+ # =========================================================
1447
+ raw = df_raw.copy()
1448
+ if prov_value and prov_value != "(Semua)":
1449
+ raw = raw[raw["PROV_DISP"] == prov_value]
1450
+ if kab_value and kab_value != "(Semua)":
1451
+ raw = raw[raw["KAB_DISP"] == kab_value]
1452
+ if kew_value and kew_value != "(Semua)":
1453
+ raw = raw[raw["KEW_NORM"] == kew_value]
1454
+
1455
+ # =========================================================
1456
+ # 6) Bell curve — kembali ke Indeks_Dasar_0_100 per entitas per jenis
1457
+ # + hover nama perpustakaan
1458
+ # =========================================================
1459
+ if detail_view is None or detail_view.empty:
1460
+ fig_umum = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Umum")
1461
+ fig_sekolah = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Sekolah")
1462
+ fig_khusus = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Khusus")
1463
+ else:
1464
+ hover_cols = []
1465
+ for hc in ["Provinsi", "Kab/Kota", "Jenis"]:
1466
+ if hc in detail_view.columns:
1467
+ hover_cols.append(hc)
1468
+
1469
+ def _fig(j):
1470
+ d = detail_view[detail_view["Jenis"].astype(str).str.lower() == j].copy()
1471
+ return _make_bell_curve_entitas(
1472
+ d,
1473
+ title=f"Bell Curve — Jenis: {j.title()} (Skor: Indeks_Dasar_0_100)",
1474
+ xcol="Indeks_Dasar_0_100",
1475
+ label_col=("nm_perpustakaan" if "nm_perpustakaan" in d.columns else "nm_perpustakaan"),
1476
+ hover_cols=hover_cols,
1477
+ min_points=2
1478
+ )
1479
+
1480
+ fig_sekolah = _fig("sekolah")
1481
+ fig_umum = _fig("umum")
1482
+ fig_khusus = _fig("khusus")
1483
+
1484
+ # =========================================================
1485
+ # 7) KPI (HANYA FINAL + DASAR)
1486
+ # =========================================================
1487
  kpi_md = build_kpi_markdown(summary_jenis)
1488
 
1489
+ # =========================================================
1490
+ # 8) Export (xlsx + opsional docx)
1491
+ # =========================================================
1492
+ tmpdir = tempfile.mkdtemp()
1493
+ prov_slug = (_canon(prov_value or "SEMUA").upper() or "SEMUA")
1494
+ kab_slug = (_canon(kab_value or "SEMUA").upper() or "SEMUA")
1495
+ kew_slug = (_canon(kew_value or "SEMUA").upper() or "SEMUA")
1496
+
1497
+ p_summary = str(Path(tmpdir) / f"IPLM_RingkasanJenisKeseluruhan_33_88_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1498
+ p_total = str(Path(tmpdir) / f"IPLM_AgregatWilayah_Keseluruhan_33_88_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1499
+ p_raw = str(Path(tmpdir) / f"IPLM_RAW_DATA_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1500
+ p_detail = str(Path(tmpdir) / f"IPLM_DetailEntitas_FinalMenempelWilayah_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1501
+ p_verif = str(Path(tmpdir) / f"IPLM_KecukupanSampel_33_88_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1502
+
1503
+ summary_jenis.to_excel(p_summary, index=False)
1504
+ agg_total.to_excel(p_total, index=False)
1505
+ raw.to_excel(p_raw, index=False)
1506
+ detail_view.to_excel(p_detail, index=False)
1507
+ verif_total.to_excel(p_verif, index=False)
1508
 
 
1509
  wilayah_txt = kab_value if (kab_value and kab_value != "(Semua)") else (prov_value if (prov_value and prov_value != "(Semua)") else "Nasional/All")
1510
+ analysis_text = generate_llm_analysis(summary_jenis, agg_total, verif_total, wilayah_txt, kew_value or "(Semua)")
1511
+ word_path = generate_word_report(wilayah_txt, summary_jenis, analysis_text)
 
1512
 
1513
+ msg = (
1514
+ f"✅ Selesai (TARGET {TARGET_RATIO*100:.2f}%): raw={len(raw)} | entitas={len(detail_view)} | "
1515
+ f"wilayah(keseluruhan)={len(agg_total)} | jenis(agregat)={len(agg_jenis_full)}"
1516
+ + ("" if DOCX_AVAILABLE else "<br>⚠️ python-docx tidak tersedia → laporan Word dimatikan.")
1517
+ )
1518
 
1519
  return (
1520
  kpi_md,
1521
+ summary_jenis, agg_total, agg_jenis_view, detail_view, verif_total,
1522
+ p_summary, p_total, p_raw, p_detail, (word_path if word_path else None),
1523
+ fig_umum, fig_sekolah, fig_khusus,
1524
+ msg, analysis_text
1525
  )
1526
 
1527
  except Exception as e:
1528
+ return _empty_outputs(f"⚠️ Runtime error: {repr(e)}")
1529
+
1530
 
1531
  # ============================================================
1532
+ # 16) UI (NO UPLOAD)
1533
  # ============================================================
1534
 
1535
  def ui_load(force=False):
1536
  df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info = load_default_files(force=force)
1537
+ if df_all is None or (isinstance(df_all, pd.DataFrame) and df_all.empty):
1538
  return (
1539
  None, None, None, None, None, {}, info,
1540
  gr.update(choices=["(Semua)"], value="(Semua)"),
 
1542
  gr.update(choices=["(Semua)"], value="(Semua)"),
1543
  )
1544
 
1545
+ prov_vals = df_all["PROV_DISP"].dropna().astype(str).tolist()
1546
+ prov_vals = [v for v in prov_vals if v and v.strip()]
1547
  prov_choices = ["(Semua)"] + sorted(set(prov_vals))
1548
+
1549
  kab_choices = ["(Semua)"] + sorted([x for x in df_all["KAB_DISP"].dropna().unique().tolist() if x])
1550
  kew_choices = ["(Semua)"] + sorted([x for x in df_all["KEW_NORM"].dropna().unique().tolist() if x])
 
1551
  default_kew = "KAB/KOTA" if "KAB/KOTA" in kew_choices else ("PROVINSI" if "PROVINSI" in kew_choices else "(Semua)")
1552
 
1553
  return (
 
1558
  )
1559
 
1560
  def on_prov_change(prov_value):
1561
+ df_all, _, _, _, _, _, _ = load_default_files(force=False)
1562
  if df_all is None or df_all.empty:
1563
  return gr.update(choices=["(Semua)"], value="(Semua)")
1564
  if prov_value is None or prov_value == "(Semua)":
 
1568
  vals = sorted([v for v in vals if v])
1569
  return gr.update(choices=["(Semua)"] + vals, value="(Semua)")
1570
 
1571
+
1572
  with gr.Blocks() as demo:
1573
  gr.Markdown(f"""
1574
+ # IPLM 2025 — Final (Target Sampel **33.88%** per Jenis) — TANPA Kinerja Relatif / Percentile
1575
+ **Mode NO UPLOAD (cache aktif).** File dibaca dari repo/server:
1576
+ - `DATA_FILE` = **{DATA_FILE}**
1577
+ - `POP_KAB` = **{POP_KAB}**
1578
+ - `POP_PROV` = **{POP_PROV}**
1579
+ - `POP_KHUSUS` = **{POP_KHUSUS}**
1580
+
1581
+ **TARGET RATIO (per jenis): {TARGET_RATIO*100:.2f}%**
1582
 
1583
+ Dashboard KPI hanya menampilkan:
1584
+ - Indeks IPLM FINAL (disesuaikan 33.88%)
1585
+ - Indeks Dasar (tanpa penyesuaian)
 
 
1586
 
1587
+ Bell Curve kembali menampilkan:
1588
+ - Indeks_Dasar_0_100 per entitas (per jenis), hover menampilkan nama perpustakaan.
1589
  """)
1590
 
1591
  state_df = gr.State(None)
 
1599
 
1600
  with gr.Row():
1601
  dd_prov = gr.Dropdown(label="Provinsi", choices=["(Semua)"], value="(Semua)")
1602
+ dd_kab = gr.Dropdown(label="Kab/Kota", choices=["(Semua)"], value="(Semua)")
1603
+ dd_kew = gr.Dropdown(label="Kewenangan", choices=["(Semua)"], value="(Semua)")
1604
 
1605
  dd_prov.change(fn=on_prov_change, inputs=[dd_prov], outputs=dd_kab)
1606
 
 
1609
 
1610
  kpi_out = gr.Markdown()
1611
 
1612
+ gr.Markdown("## Ringkasan (Jenis + Keseluruhan) — Pop/Target33.88/Terkumpul/Coverage + Penyesuaian")
1613
  out_summary = gr.DataFrame(interactive=False)
1614
 
1615
  gr.Markdown("## Agregat Wilayah (Keseluruhan) — FIX avg3")
1616
  out_agg_total = gr.DataFrame(interactive=False)
1617
 
1618
+ gr.Markdown("## Agregat Wilayah × Jenis — (ditampilkan sampai Indeks_Dasar_Agregat_0_100)")
1619
  out_agg_jenis = gr.DataFrame(interactive=False)
1620
 
1621
  gr.Markdown("## Detail Entitas (Final menempel dari wilayah)")
1622
  out_detail = gr.DataFrame(interactive=False)
1623
 
1624
+ gr.Markdown("## Kecukupan Sampel 33.88% (tanpa angka koma untuk integer)")
1625
+ out_verif = gr.DataFrame(interactive=False)
1626
+
1627
+ gr.Markdown("## Bell Curve — Indeks Dasar per Entitas (per Jenis) + Nama Perpustakaan")
1628
  gr.Markdown("### Perpustakaan Umum")
1629
  bell_umum = gr.Plot(scale=1)
1630
+
1631
  gr.Markdown("### Perpustakaan Sekolah")
1632
  bell_sekolah = gr.Plot(scale=1)
1633
+
1634
  gr.Markdown("### Perpustakaan Khusus")
1635
  bell_khusus = gr.Plot(scale=1)
1636
 
1637
+ gr.Markdown("## Analisis Otomatis (opsional)")
1638
+ analysis_out = gr.Markdown()
1639
+
1640
+ with gr.Row():
1641
+ dl_summary = gr.DownloadButton(label="Download Ringkasan (.xlsx)")
1642
+ dl_total = gr.DownloadButton(label="Download Agregat Wilayah (.xlsx)")
1643
+ dl_raw = gr.DownloadButton(label="Download Data Mentah (.xlsx)")
1644
+ dl_detail = gr.DownloadButton(label="Download Detail Entitas (.xlsx)")
1645
+ dl_word = gr.DownloadButton(label="Download Laporan Word (.docx)" if DOCX_AVAILABLE else "Download Laporan Word (OFF)")
1646
 
1647
  run_btn.click(
1648
  fn=run_calc,
1649
  inputs=[dd_prov, dd_kab, dd_kew, state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta],
1650
+ outputs=[
1651
+ kpi_out,
1652
+ out_summary, out_agg_total, out_agg_jenis, out_detail, out_verif,
1653
+ dl_summary, dl_total, dl_raw, dl_detail, dl_word,
1654
+ bell_umum, bell_sekolah, bell_khusus,
1655
+ msg_out, analysis_out
1656
+ ]
1657
  )
1658
 
1659
  demo.load(