irhamni commited on
Commit
c165048
Β·
verified Β·
1 Parent(s): 91c7d5c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +474 -823
app.py CHANGED
@@ -2,58 +2,39 @@
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,7 +42,9 @@ except Exception:
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,7 +52,6 @@ except Exception:
69
  HF_AVAILABLE = False
70
  InferenceClient = None
71
 
72
-
73
  # ============================================================
74
  # 1) KONFIGURASI
75
  # ============================================================
@@ -82,10 +64,8 @@ POP_KHUSUS = os.getenv("POP_KHUSUS", "Data_populasi_perp_khusus.xlsx")
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,14 +75,13 @@ 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,17 +92,19 @@ def _disp_text(x):
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,17 +125,16 @@ 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,31 +152,24 @@ 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,25 +181,15 @@ def norm_kab_label(s):
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,7 +214,6 @@ pengelolaan_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,12 +243,11 @@ alias_map_raw = {
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,12 +260,11 @@ def _mean_norm_cols(row, cols):
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,16 +289,17 @@ def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
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,42 +307,23 @@ def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
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,40 +374,32 @@ def _parse_pop_khusus(path_xlsx: str) -> 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,11 +410,11 @@ def load_default_files(force=False):
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,10 +425,10 @@ def load_default_files(force=False):
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,241 +440,178 @@ def load_default_files(force=False):
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,14 +623,9 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
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,106 +639,71 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
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,186 +717,78 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
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,12 +798,7 @@ def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, m
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,77 +808,31 @@ def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, m
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,67 +840,58 @@ def _make_bell_curve_entitas(
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,69 +900,51 @@ def _make_bell_curve_entitas(
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,83 +963,199 @@ def get_llm_client():
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,137 +1165,52 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
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,12 +1218,11 @@ def ui_load(force=False):
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,7 +1233,7 @@ def ui_load(force=False):
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,24 +1243,18 @@ def on_prov_change(prov_value):
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,8 +1268,8 @@ with gr.Blocks() as demo:
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,51 +1278,33 @@ with gr.Blocks() as demo:
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(
 
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
  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
  HF_AVAILABLE = False
53
  InferenceClient = None
54
 
 
55
  # ============================================================
56
  # 1) KONFIGURASI
57
  # ============================================================
 
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
  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
  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
  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
  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
  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
  ]
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
  }
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
 
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
  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
  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
  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
  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
  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
  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
 
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
 
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
  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
  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
  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
  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
  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
  _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
  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
  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
  )
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
  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
 
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
 
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(