ilafi commited on
Commit
a9ade32
·
verified ·
1 Parent(s): 2dccec9

Upload 22 files

Browse files
.gitattributes ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DATA_iplm_final_05022026_update1.xlsx filter=lfs diff=lfs merge=lfs -text
2
+ DATA_iplm_final_05022026.xlsx filter=lfs diff=lfs merge=lfs -text
3
+ DATA_IPLM_FINAL_CLEAN_pusat_15012026.xlsx filter=lfs diff=lfs merge=lfs -text
4
+ DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL.xlsx filter=lfs diff=lfs merge=lfs -text
5
+ DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL190226.xlsx filter=lfs diff=lfs merge=lfs -text
6
+ DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL200226.xlsx filter=lfs diff=lfs merge=lfs -text
7
+ DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL200226%20%2B%20TORAJA%20UTARA.xlsx filter=lfs diff=lfs merge=lfs -text
8
+ DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALLL.xlsx filter=lfs diff=lfs merge=lfs -text
9
+ DATA%20IPLM%20FINAL_CLEAN_pusat.xlsx filter=lfs diff=lfs merge=lfs -text
10
+ DATA%20IPLM%20FINAL_CLEAN.xlsx filter=lfs diff=lfs merge=lfs -text
11
+ File_IPLM_090226_All.xlsx filter=lfs diff=lfs merge=lfs -text
12
+ IPLM_clean_manual_131225.xlsx filter=lfs diff=lfs merge=lfs -text
DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:259136a817e48feb2317cc10cbc9437f2e8ea77f2cbdf97e9a8f474216c3c3a8
3
+ size 33861015
DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL190226.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9d7c3b1a749bd7d7fd8140d3f2537d9c4d092b026ecac7d841fb82706ab1bb8f
3
+ size 34881155
DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL200226%20%2B%20TORAJA%20UTARA.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f4a6490c233059db656e186586b54102f98cebb6137db1aa6804a4b3cdb8ba7
3
+ size 34895620
DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALL200226.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a6b16755600b16048075f8bb71cb669b53d570f96edbd7c46cc0a52cbf594e59
3
+ size 34881277
DATA%20CLEAN%20GABUNGAN%20SANGGAH-TIDAK%20SANGGAH%20-%20ALLL.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:469f473a88e97921278293b7319b3f137ab3abf9021fdd9e3fbdc3840f4782c2
3
+ size 33987216
DATA%20IPLM%20FINAL_CLEAN.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:173f08fad648cce8455a9ad4659002be4e49cdce8fca3f436f6d3b1f91aa0ad5
3
+ size 29900129
DATA%20IPLM%20FINAL_CLEAN_pusat.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:822fcc274a633a7feb180eeadf2a5e9ab02963de7b8ea0156bc6cb4562e4a571
3
+ size 30828517
DATA_IPLM_FINAL_CLEAN_pusat_15012026.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d7705e1ec01b0458ac576a45cc2a76479780030949a7da618b1f1204ac6c2153
3
+ size 30899631
DATA_iplm_final_05022026.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:22ddfe02aef021d53925917326e482f1e9d7bd34336acb82cb8318f2b1c43965
3
+ size 34441568
DATA_iplm_final_05022026_update1.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c49f9e919a8485787242bc7a6c4307afb90a399d092444e8c4a066546b510cd2
3
+ size 36026291
Data_populasi_Kab_kota.xlsx ADDED
Binary file (74.8 kB). View file
 
Data_populasi_Kab_kota_fixed (1).xlsx ADDED
Binary file (74.8 kB). View file
 
Data_populasi_Kab_kota_fixed_lama.xlsx ADDED
Binary file (74.8 kB). View file
 
Data_populasi_perp_khusus (1).xlsx ADDED
Binary file (27 kB). View file
 
Data_populasi_propinsi (1).xlsx ADDED
Binary file (15.6 kB). View file
 
File_IPLM_090226_All.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:259136a817e48feb2317cc10cbc9437f2e8ea77f2cbdf97e9a8f474216c3c3a8
3
+ size 33861015
IPLM_clean_manual_131225.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5560e8f700d983ca8b0b13d188ef06c2dab1cae364a24870226afd9c72b5c1db
3
+ size 21587518
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Iplm
3
+ emoji: 🌍
4
+ colorFrom: green
5
+ colorTo: red
6
+ sdk: gradio
7
+ sdk_version: 6.0.2
8
+ app_file: app.py
9
+ pinned: false
10
+ license: bsd
11
+ ---
12
+
13
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,1705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ IPLM 2025 — Final (Target Sampel 33.88% per Jenis) — TANPA Kinerja Relatif / Percentile
4
+ UPDATE (sesuai instruksi terbaru Anda) — TANPA mengubah pipeline lain:
5
+
6
+ FOKUS PEMBENAHAN (LLM + WORD):
7
+ 1) Nilai Kepatuhan, Koleksi, Tenaga, Kinerja, Pelayanan, Pengelolaan:
8
+ - TIDAK dikalikan 100.
9
+ - Ditulis APA ADANYA dari kolom agregat aplikasi:
10
+ Rata2_dim_kepatuhan, Rata2_sub_koleksi, Rata2_sub_sdm, Rata2_dim_kinerja,
11
+ Rata2_sub_pelayanan, Rata2_sub_pengelolaan.
12
+ 2) Nilai IPLM ditulis apa adanya: Indeks_Final_Wilayah_0_100.
13
+ 3) LLM mengisi Interpretasi & Rekomendasi:
14
+ - Interpretasi: deskriptif, kondisi riil berbasis relasi angka (lebih besar/kecil, gap, dominan, konsistensi),
15
+ plus pemaknaan substantif dimensi (koleksi/sdm/pelayanan/pengelolaan) TANPA label normatif.
16
+ - Rekomendasi: operasional, 2–3 butir ringkas, menaut ke pola angka (gap/ketimpangan/kontribusi).
17
+ 4) Di bawah tabel Word: tambah deskripsi jumlah perpustakaan sumber data (dari tabel agregat wilayah × jenis / “gambar 2”):
18
+ sekolah=..., umum=..., khusus=..., total=...
19
+
20
+ Catatan penting:
21
+ - Semua perhitungan dan dashboard tetap.
22
+ - Yang diubah hanya: (a) cara mengambil nilai untuk tabel Word (tanpa *100),
23
+ (b) prompt LLM untuk isi interpretasi/rekomendasi agar nyambung dengan angka,
24
+ (c) tambahan paragraf jumlah perpustakaan di bawah tabel Word.
25
+ """
26
+
27
+ import os
28
+ import re
29
+ import time
30
+ import json
31
+ import math
32
+ import tempfile
33
+ from pathlib import Path
34
+
35
+ import gradio as gr
36
+ import numpy as np
37
+ import pandas as pd
38
+ import plotly.graph_objects as go
39
+ from sklearn.preprocessing import PowerTransformer
40
+
41
+ # python-docx (wajib kalau mau Word)
42
+ DOCX_AVAILABLE = True
43
+ try:
44
+ from docx import Document
45
+ from docx.shared import Pt
46
+ from docx.oxml import OxmlElement
47
+ from docx.oxml.ns import qn
48
+ except Exception:
49
+ DOCX_AVAILABLE = False
50
+ Document = None
51
+
52
+ # huggingface client (opsional)
53
+ HF_AVAILABLE = True
54
+ try:
55
+ from huggingface_hub import InferenceClient
56
+ except Exception:
57
+ HF_AVAILABLE = False
58
+ InferenceClient = None
59
+
60
+
61
+ # ============================================================
62
+ # 1) KONFIGURASI
63
+ # ============================================================
64
+
65
+ DATA_FILE = os.getenv("DATA_FILE", "DATA CLEAN GABUNGAN SANGGAH-TIDAK SANGGAH - ALL200226 + TORAJA UTARA.xlsx")
66
+ POP_KAB = os.getenv("POP_KAB", "Data_populasi_Kab_kota_fixed.xlsx")
67
+ POP_PROV = os.getenv("POP_PROV", "Data_populasi_propinsi.xlsx")
68
+ POP_KHUSUS = os.getenv("POP_KHUSUS", "Data_populasi_perp_khusus.xlsx")
69
+
70
+ W_KEPATUHAN = float(os.getenv("W_KEPATUHAN", "0.30"))
71
+ W_KINERJA = float(os.getenv("W_KINERJA", "0.70"))
72
+
73
+ TARGET_RATIO = float(os.getenv("TARGET_RATIO", "0.3388"))
74
+
75
+ USE_LLM = True
76
+ LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "meta-llama/Meta-Llama-3-8B-Instruct")
77
+ HF_TOKEN = (
78
+ os.getenv("HF_SECRET")
79
+ or os.getenv("HF_TOKEN")
80
+ or os.getenv("HUGGINGFACEHUB_API_TOKEN")
81
+ or os.getenv("HF_API_TOKEN")
82
+ )
83
+
84
+
85
+ # ============================================================
86
+ # 2) UTIL
87
+ # ============================================================
88
+
89
+ def _mtime(path_str: str):
90
+ p = Path(path_str)
91
+ return p.stat().st_mtime if p.exists() else None
92
+
93
+ def _canon(s: str) -> str:
94
+ return re.sub(r"[^a-z0-9]+", "", str(s).lower())
95
+
96
+ def _disp_text(x):
97
+ if pd.isna(x):
98
+ return None
99
+ t = str(x).strip().upper()
100
+ return " ".join(t.split())
101
+
102
+ def pick_col(df, candidates):
103
+ if df is None or df.empty:
104
+ return None
105
+ for c in candidates:
106
+ if c in df.columns:
107
+ return c
108
+ can_map = {_canon(c): c for c in df.columns}
109
+ for c in candidates:
110
+ k = _canon(c)
111
+ if k in can_map:
112
+ return can_map[k]
113
+ return None
114
+
115
+ def coerce_num(val):
116
+ if pd.isna(val):
117
+ return np.nan
118
+ t = str(val).strip()
119
+ if t == "" or t in {"-", "–", "—", "NA", "N/A", "null", "NULL"}:
120
+ return np.nan
121
+ t = t.replace("\u00a0", " ").replace("Rp", "").replace("%", "")
122
+ t = re.sub(r"[^0-9,.\-]", "", t)
123
+
124
+ if t.count(".") > 1 and t.count(",") == 1:
125
+ t = t.replace(".", "").replace(",", ".")
126
+ elif t.count(",") > 1 and t.count(".") == 1:
127
+ t = t.replace(",", "")
128
+ elif t.count(",") == 1 and t.count(".") == 0:
129
+ t = t.replace(",", ".")
130
+ else:
131
+ t = t.replace(",", "")
132
+
133
+ try:
134
+ return float(t)
135
+ except Exception:
136
+ return np.nan
137
+
138
+ def minmax_norm(s: pd.Series) -> pd.Series:
139
+ x = pd.to_numeric(s, errors="coerce").astype(float)
140
+ mn, mx = x.min(skipna=True), x.max(skipna=True)
141
+ if pd.isna(mn) or pd.isna(mx) or mx == mn:
142
+ return pd.Series(0.0, index=s.index)
143
+ return (x - mn) / (mx - mn)
144
+
145
+ def norm_kew(v):
146
+ if pd.isna(v):
147
+ return None
148
+ t = str(v).strip().upper()
149
+ if "KAB" in t or "KOTA" in t:
150
+ return "KAB/KOTA"
151
+ if "PROV" in t:
152
+ return "PROVINSI"
153
+ if "PUSAT" in t or "NASIONAL" in t:
154
+ return "PUSAT"
155
+ return t
156
+
157
+ def norm_prov_disp(s):
158
+ if pd.isna(s):
159
+ return None
160
+ t = str(s).strip().upper()
161
+ t = t.replace("\u00a0", " ")
162
+ t = " ".join(t.split())
163
+ t = t.replace("PROPINSI", "PROVINSI")
164
+ while t.startswith("PROVINSI PROVINSI "):
165
+ t = t.replace("PROVINSI PROVINSI ", "PROVINSI ", 1)
166
+ if t.startswith("PROVINSI "):
167
+ name = t[len("PROVINSI "):].strip()
168
+ else:
169
+ name = t
170
+ name = " ".join(name.split())
171
+ if not name:
172
+ return None
173
+ return f"PROVINSI {name}"
174
+
175
+ def norm_prov_label(s):
176
+ if pd.isna(s):
177
+ return None
178
+ t = str(s).strip().upper().replace("\u00a0", " ")
179
+ t = " ".join(t.split())
180
+ t = t.replace("PROPINSI", "PROVINSI")
181
+ t = t.replace("PROVINSI", "").strip()
182
+ return re.sub(r"[^A-Z0-9]+", "", t)
183
+
184
+ def norm_kab_label(s):
185
+ if pd.isna(s):
186
+ return None
187
+ t = str(s).upper()
188
+ t = t.replace("KABUPATEN", "KAB")
189
+ t = t.replace("KAB.", "KAB")
190
+ t = t.replace("KOTA ADMINISTRASI", "KOTA")
191
+ t = t.replace("KOTA ADM.", "KOTA")
192
+ t = t.replace("KOTA.", "KOTA")
193
+ t = " ".join(t.split())
194
+ return re.sub(r"[^A-Z0-9]+", "", t)
195
+
196
+ def safe_div(num, den):
197
+ if den is None or pd.isna(den) or float(den) <= 0:
198
+ return np.nan
199
+ return float(num) / float(den)
200
+
201
+ def faktor_penyesuaian_total(n_total: float, target_total: float) -> float:
202
+ if target_total is None or pd.isna(target_total) or float(target_total) <= 0:
203
+ return 1.0
204
+ if n_total is None or pd.isna(n_total) or float(n_total) < 0:
205
+ n_total = 0.0
206
+ return float(min(float(n_total) / float(target_total), 1.0))
207
+
208
+
209
+ # ============================================================
210
+ # 3) INDIKATOR IPLM
211
+ # ============================================================
212
+
213
+ koleksi_cols = [
214
+ "JudulTercetak","EksemplarTercetak","JudulElektronik","EksemplarElektronik",
215
+ "TambahJudulTercetak","TambahEksemplarTercetak",
216
+ "TambahJudulElektronik","TambahEksemplarElektronik",
217
+ "KomitmenAnggaranKoleksi"
218
+ ]
219
+ sdm_cols = [
220
+ "TenagaKualifikasiIlmuPerpustakaan",
221
+ "TenagaFungsionalProfesional",
222
+ "TenagaPKB",
223
+ "AnggaranTenaga"
224
+ ]
225
+ pelayanan_cols = [
226
+ "PesertaBudayaBaca","PemustakaLuringDaring","PemustakaFasilitasTIK",
227
+ "PemanfaatanJudulTercetak","PemanfaatanEksemplarTercetak",
228
+ "PemanfaatanJudulElektronik","PemanfaatanEksemplarElektronik"
229
+ ]
230
+ pengelolaan_cols = [
231
+ "KegiatanBudayaBaca","KegiatanKerjasama","VariasiLayanan","Kebijakan","AnggaranLayanan"
232
+ ]
233
+ all_indicators = koleksi_cols + sdm_cols + pelayanan_cols + pengelolaan_cols
234
+
235
+ alias_map_raw = {
236
+ "j_judul_koleksi_tercetak": "JudulTercetak",
237
+ "j_eksemplar_koleksi_tercetak": "EksemplarTercetak",
238
+ "j_judul_koleksi_digital": "JudulElektronik",
239
+ "j_eksemplar_koleksi_digital": "EksemplarElektronik",
240
+ "tambah_judul_koleksi_tercetak": "TambahJudulTercetak",
241
+ "tambah_eksemplar_koleksi_tercetak": "TambahEksemplarTercetak",
242
+ "tambah_judul_koleksi_digital": "TambahJudulElektronik",
243
+ "tambah_eksemplar_koleksi_digital": "TambahEksemplarElektronik",
244
+ "j_anggaran_koleksi": "KomitmenAnggaranKoleksi",
245
+ "j_tenaga_ilmu_perpus": "TenagaKualifikasiIlmuPerpustakaan",
246
+ "j_tenaga_nonilmu_perpus": "TenagaFungsionalProfesional",
247
+ "j_tenaga_pkb": "TenagaPKB",
248
+ "j_anggaran_diklat_perpus": "AnggaranTenaga",
249
+ "j_peserta_budaya_baca": "PesertaBudayaBaca",
250
+ "j_pemustaka_luring_daring": "PemustakaLuringDaring",
251
+ "j_pemustaka_fasilitas_tik": "PemustakaFasilitasTIK",
252
+ "j_judul_koleksi_tercetak_termanfaat": "PemanfaatanJudulTercetak",
253
+ "j_eksemplar_koleksi_tercetak_termanfaat": "PemanfaatanEksemplarTercetak",
254
+ "j_judul_koleksi_digital_termanfaat": "PemanfaatanJudulElektronik",
255
+ "j_eksemplar_koleksi_digital_termanfaat": "PemanfaatanEksemplarElektronik",
256
+ "j_kegiatan_budaya_baca_peningkatan_literasi": "KegiatanBudayaBaca",
257
+ "j_kerjasama_pengembangan_perpus": "KegiatanKerjasama",
258
+ "j_variasi_layanan": "VariasiLayanan",
259
+ "j_kebijakan_prosedur_pelayanan": "Kebijakan",
260
+ "j_anggaran_peningkatan_pelayanan": "AnggaranLayanan",
261
+ }
262
+ alias_map = {_canon(k): v for k, v in alias_map_raw.items()}
263
+
264
+
265
+ # ============================================================
266
+ # 4) PIPELINE NASIONAL (LEVEL ENTITAS)
267
+ # ============================================================
268
+
269
+ def _mean_norm_cols(row, cols):
270
+ vals = []
271
+ for c in cols:
272
+ k = f"norm_{c}"
273
+ if k in row.index:
274
+ v = row[k]
275
+ if pd.isna(v):
276
+ v = 0.0
277
+ vals.append(float(v))
278
+ return float(np.mean(vals)) if vals else 0.0
279
+
280
+ def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
281
+ if df_src is None or df_src.empty:
282
+ return df_src
283
+
284
+ df = df_src.copy()
285
+
286
+ rename_map = {}
287
+ for col in df.columns:
288
+ c = _canon(col)
289
+ if c in alias_map:
290
+ rename_map[col] = alias_map[c]
291
+ else:
292
+ for tgt in all_indicators:
293
+ if c == _canon(tgt):
294
+ rename_map[col] = tgt
295
+ break
296
+ if rename_map:
297
+ df = df.rename(columns=rename_map)
298
+
299
+ available = [c for c in all_indicators if c in df.columns]
300
+ for c in available:
301
+ df[c] = df[c].apply(coerce_num)
302
+
303
+ for c in available:
304
+ x = pd.to_numeric(df[c], errors="coerce").astype(float).values
305
+ mask = ~np.isnan(x)
306
+ transformed = np.full_like(x, np.nan, dtype=float)
307
+ if mask.sum() > 1:
308
+ pt = PowerTransformer(method="yeo-johnson", standardize=False)
309
+ transformed[mask] = pt.fit_transform(x[mask].reshape(-1, 1)).ravel()
310
+ else:
311
+ transformed[mask] = x[mask]
312
+ df[f"norm_{c}"] = minmax_norm(pd.Series(transformed, index=df.index))
313
+
314
+ df["sub_koleksi"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in koleksi_cols if c in available]), axis=1)
315
+ df["sub_sdm"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in sdm_cols if c in available]), axis=1)
316
+ df["sub_pelayanan"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in pelayanan_cols if c in available]), axis=1)
317
+ df["sub_pengelolaan"] = df.apply(lambda r: _mean_norm_cols(r, [c for c in pengelolaan_cols if c in available]), axis=1)
318
+
319
+ df["dim_kepatuhan"] = df[["sub_koleksi","sub_sdm"]].mean(axis=1)
320
+ df["dim_kinerja"] = df[["sub_pelayanan","sub_pengelolaan"]].mean(axis=1)
321
+
322
+ df["Indeks_Dasar_0_100"] = 100 * (W_KEPATUHAN * df["dim_kepatuhan"] + W_KINERJA * df["dim_kinerja"])
323
+
324
+ for c in ["sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan","dim_kepatuhan","dim_kinerja","Indeks_Dasar_0_100"]:
325
+ df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0.0)
326
+
327
+ return df
328
+
329
+
330
+ # ============================================================
331
+ # 5) CACHE LOADER (NO UPLOAD)
332
+ # ============================================================
333
+
334
+ _CACHE = {"key": None, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": None, "info": None}
335
+
336
+ def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
337
+ df = pd.read_excel(path_xlsx)
338
+ if df is None or df.empty:
339
+ return pd.DataFrame()
340
+
341
+ c_mix = pick_col(df, [
342
+ "Propinsi/Kab/kota", "Propinsi/Kab/Kota", "Provinsi/Kab/Kota",
343
+ "Provinsi/Kab/kota", "Provinsi/Kabupaten/Kota",
344
+ "Wilayah", "Nama Wilayah"
345
+ ])
346
+ if c_mix is None:
347
+ raise ValueError("POP_KHUSUS: kolom gabungan Provinsi/Kab/Kota tidak ditemukan.")
348
+
349
+ c_pop = pick_col(df, ["POP_KHUSUS", "Pop_Khusus", "pop_khusus"])
350
+ if c_pop is None:
351
+ raise ValueError("POP_KHUSUS: kolom 'POP_KHUSUS' tidak ditemukan.")
352
+
353
+ mix = df[c_mix].astype(str).fillna("").str.strip()
354
+ pop_series = df[c_pop].apply(coerce_num)
355
+
356
+ rows = []
357
+ current_prov = None
358
+
359
+ for m, pval in zip(mix.tolist(), pop_series.tolist()):
360
+ mm = _disp_text(m) or ""
361
+ if mm == "":
362
+ continue
363
+
364
+ if mm.startswith("PROVINSI "):
365
+ prov_name = mm.replace("PROVINSI", "").strip()
366
+ current_prov = prov_name
367
+ rows.append({
368
+ "LEVEL": "PROV",
369
+ "Provinsi_Label": f"PROVINSI {prov_name}",
370
+ "Kab_Kota_Label": None,
371
+ "Pop_Total_Jenis": pval,
372
+ })
373
+ continue
374
+
375
+ rows.append({
376
+ "LEVEL": "KAB",
377
+ "Provinsi_Label": f"PROVINSI {current_prov}" if current_prov else None,
378
+ "Kab_Kota_Label": mm,
379
+ "Pop_Total_Jenis": pval,
380
+ })
381
+
382
+ pop = pd.DataFrame(rows)
383
+ if pop.empty:
384
+ return pop
385
+
386
+ pop["Pop_Total_Jenis"] = pd.to_numeric(pop["Pop_Total_Jenis"], errors="coerce").fillna(0.0)
387
+ pop["prov_key"] = pop["Provinsi_Label"].apply(norm_prov_label)
388
+ pop["kab_key"] = pop["Kab_Kota_Label"].apply(norm_kab_label) if "Kab_Kota_Label" in pop.columns else None
389
+ return pop
390
+
391
+ def load_default_files(force=False):
392
+ key = (
393
+ DATA_FILE, POP_KAB, POP_PROV, POP_KHUSUS,
394
+ _mtime(DATA_FILE), _mtime(POP_KAB), _mtime(POP_PROV), _mtime(POP_KHUSUS)
395
+ )
396
+
397
+ if (not force) and _CACHE["key"] == key and _CACHE["df_all"] is not None:
398
+ return _CACHE["df_all"], _CACHE["df_raw"], _CACHE["pop_kab"], _CACHE["pop_prov"], _CACHE["pop_khusus"], _CACHE["meta"], _CACHE["info"]
399
+
400
+ for p, label in [(DATA_FILE, "DM"), (POP_KAB, "POP_KAB"), (POP_PROV, "POP_PROV"), (POP_KHUSUS, "POP_KHUSUS")]:
401
+ if not Path(p).exists():
402
+ info = f"File tidak ditemukan ({label}): {p}"
403
+ _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
404
+ return None, None, None, None, None, {}, info
405
+
406
+ fp = Path(DATA_FILE)
407
+ xls = pd.ExcelFile(fp)
408
+ frames = [pd.read_excel(fp, sheet_name=s) for s in xls.sheet_names]
409
+ df_raw = pd.concat(frames, ignore_index=True, sort=False)
410
+
411
+ prov_col = pick_col(df_raw, ["provinsi", "Provinsi", "PROVINSI"])
412
+ kab_col = pick_col(df_raw, ["kab/kota", "Kab/Kota", "Kab_Kota", "KAB/KOTA", "kabupaten_kota", "Kabupaten/Kota", "kabupaten kota", "kota", "kab_kota"])
413
+ kew_col = pick_col(df_raw, ["kewenangan", "jenis_kewenangan", "Kewenangan", "KEWENANGAN"])
414
+ jenis_col = pick_col(df_raw, ["jenis_perpustakaan", "Jenis Perpustakaan", "JENIS_PERPUSTAKAAN"])
415
+ nama_col = pick_col(df_raw, ["nm_perpustakaan","nama_perpustakaan","Nama Perpustakaan","nm_instansi_lembaga","nm_perpus"])
416
+
417
+ missing = []
418
+ if prov_col is None: missing.append("Provinsi")
419
+ if kab_col is None: missing.append("Kab/Kota")
420
+ if kew_col is None: missing.append("Kewenangan")
421
+ if jenis_col is None: missing.append("Jenis Perpustakaan")
422
+ if missing:
423
+ info = f"Kolom wajib tidak ditemukan di DM: {', '.join(missing)}"
424
+ _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
425
+ return None, None, None, None, None, {}, info
426
+
427
+ val_map_jenis = {
428
+ "PERPUSTAKAAN SEKOLAH": "sekolah", "SEKOLAH": "sekolah",
429
+ "PERPUSTAKAAN UMUM": "umum", "UMUM": "umum", "PERPUSTAKAAN DAERAH": "umum",
430
+ "PERPUSTAKAAN KHUSUS": "khusus", "KHUSUS": "khusus",
431
+ }
432
+
433
+ df_raw["KEW_NORM"] = df_raw[kew_col].apply(norm_kew)
434
+ df_raw["_dataset"] = df_raw[jenis_col].astype(str).str.strip().str.upper().map(val_map_jenis)
435
+ df_raw["PROV_DISP"] = df_raw[prov_col].apply(norm_prov_disp)
436
+ df_raw["KAB_DISP"] = df_raw[kab_col].apply(_disp_text)
437
+ df_raw["prov_key"] = df_raw["PROV_DISP"].apply(norm_prov_label)
438
+ df_raw["kab_key"] = df_raw["KAB_DISP"].apply(norm_kab_label)
439
+
440
+ if nama_col and nama_col in df_raw.columns:
441
+ kcols = [prov_col, kab_col, kew_col, jenis_col, nama_col]
442
+ else:
443
+ kcols = [prov_col, kab_col, kew_col, jenis_col]
444
+
445
+ tmp = df_raw[kcols].astype(str).fillna("").apply(lambda s: s.str.strip(), axis=0)
446
+ df_raw["_row_key"] = tmp.apply(lambda r: "||".join(r.values.tolist()), axis=1).apply(_canon)
447
+ before = len(df_raw)
448
+ df_raw = df_raw.drop_duplicates(subset=["_row_key"], keep="first").copy()
449
+ after = len(df_raw)
450
+
451
+ # POP KAB
452
+ pk = pd.read_excel(POP_KAB)
453
+ c_kab = pick_col(pk, ["KABUPATEN_KOTA","Kab/Kota","Kabupaten/Kota","KAB/KOTA","Kabupaten_Kota","kab_kota","kabupaten_kota"])
454
+ c_prov = pick_col(pk, ["PROVINSI","Provinsi","provinsi"])
455
+ if c_kab is None:
456
+ info = "POP_KAB: wajib ada kolom Kab/Kota."
457
+ _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
458
+ return None, None, None, None, None, {}, info
459
+
460
+ pop_kab = pk.copy()
461
+ pop_kab["Kab_Kota_Label"] = pk[c_kab].astype(str).str.strip()
462
+ pop_kab["Provinsi_Label"] = pk[c_prov].astype(str).str.strip() if c_prov else ""
463
+ pop_kab["kab_key"] = pop_kab["Kab_Kota_Label"].apply(norm_kab_label)
464
+ pop_kab = pop_kab.groupby("kab_key", as_index=False).first()
465
+
466
+ # POP PROV
467
+ pp = pd.read_excel(POP_PROV)
468
+ c_pr = pick_col(pp, ["Provinsi","PROVINSI","provinsi","Propinsi","PROPINSI","propinsi"])
469
+ if c_pr is None:
470
+ info = "POP_PROV: wajib ada kolom Provinsi."
471
+ _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
472
+ return None, None, None, None, None, {}, info
473
+
474
+ pop_prov = pp.copy()
475
+ pop_prov["Provinsi_Label"] = pp[c_pr].astype(str).str.strip()
476
+ pop_prov["prov_key"] = pop_prov["Provinsi_Label"].apply(norm_prov_label)
477
+ pop_prov = pop_prov.groupby("prov_key", as_index=False).first()
478
+
479
+ # POP KHUSUS
480
+ try:
481
+ pop_khusus = _parse_pop_khusus(POP_KHUSUS)
482
+ except Exception as e:
483
+ info = f"POP_KHUSUS gagal dibaca: {repr(e)}"
484
+ _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
485
+ return None, None, None, None, None, {}, info
486
+
487
+ df_all = prepare_global(df_raw)
488
+ meta = dict(prov_col=prov_col, kab_col=kab_col, kew_col=kew_col, jenis_col=jenis_col, nama_col=nama_col)
489
+
490
+ info = (
491
+ f"Mode NO UPLOAD (cache aktif)\n"
492
+ f"DM: {fp.name} | Baris: {before} -> dedup: {after}\n"
493
+ f"POP_KAB: {Path(POP_KAB).name} (n={len(pop_kab)})\n"
494
+ f"POP_PROV: {Path(POP_PROV).name} (n={len(pop_prov)})\n"
495
+ f"POP_KHUSUS: {Path(POP_KHUSUS).name} (n={len(pop_khusus)})\n"
496
+ f"TARGET sampel per jenis: {TARGET_RATIO*100:.2f}%\n"
497
+ 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))}"
498
+ )
499
+
500
+ _CACHE.update({
501
+ "key": key,
502
+ "df_all": df_all,
503
+ "df_raw": df_raw,
504
+ "pop_kab": pop_kab,
505
+ "pop_prov": pop_prov,
506
+ "pop_khusus": pop_khusus,
507
+ "meta": meta,
508
+ "info": info
509
+ })
510
+ return df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info
511
+
512
+
513
+ # ============================================================
514
+ # 6) FAKTOR WILAYAH — PER JENIS (TARGET 33.88%)
515
+ # ============================================================
516
+
517
+ def build_faktor_wilayah_jenis(df_filtered, pop_kab, pop_prov, pop_khusus, kew_value):
518
+ if df_filtered is None or df_filtered.empty:
519
+ return pd.DataFrame()
520
+
521
+ kew_norm = str(kew_value or "").upper()
522
+ df = df_filtered.copy()
523
+ df = df[df["_dataset"].isin(["sekolah", "umum", "khusus"])].copy()
524
+ if df.empty:
525
+ return pd.DataFrame()
526
+
527
+ jenis_list = ["sekolah", "umum", "khusus"]
528
+
529
+ if "PROV" in kew_norm:
530
+ key_col, label_col, label_name, mode = "prov_key", "PROV_DISP", "Provinsi", "PROV"
531
+ base_pop = pop_prov.copy() if (pop_prov is not None and not pop_prov.empty) else pd.DataFrame()
532
+ if not base_pop.empty and "prov_key" not in base_pop.columns:
533
+ base_pop["prov_key"] = base_pop["Provinsi_Label"].apply(norm_prov_label)
534
+ 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([]))
535
+ else:
536
+ key_col, label_col, label_name, mode = "kab_key", "KAB_DISP", "Kab/Kota", "KAB"
537
+ base_pop = pop_kab.copy() if (pop_kab is not None and not pop_kab.empty) else pd.DataFrame()
538
+ if not base_pop.empty and "kab_key" not in base_pop.columns:
539
+ base_pop["kab_key"] = base_pop["Kab_Kota_Label"].apply(norm_kab_label)
540
+ 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([]))
541
+
542
+ base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
543
+ full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
544
+
545
+ cnt = (
546
+ df.groupby([key_col, label_col, "_dataset"], dropna=False)
547
+ .size()
548
+ .reset_index(name="n_jenis")
549
+ .rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
550
+ )
551
+ cnt["Jenis"] = cnt["Jenis"].astype(str).str.lower().str.strip()
552
+
553
+ base_n = full.merge(cnt, on=["group_key", label_name, "Jenis"], how="left")
554
+ base_n["n_jenis"] = pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(int)
555
+
556
+ base_n["target_total_33_88_jenis"] = 0.0
557
+ base_n["pop_total_jenis"] = 0.0
558
+
559
+ if not base_pop.empty:
560
+ if mode == "KAB":
561
+ pop_sekolah = pd.to_numeric(base_pop.get("jumlah_populasi_sekolah_base", base_pop.get("jumlah_populasi_sekolah", 0)), errors="coerce").fillna(0.0)
562
+ pop_umum = pd.to_numeric(base_pop.get("jumlah_populasi_umum_base", base_pop.get("jumlah_populasi_umum", 0)), errors="coerce").fillna(0.0)
563
+ tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
564
+ tgt_umum = pop_umum * float(TARGET_RATIO)
565
+ else:
566
+ sma = pd.to_numeric(base_pop.get("sma ", base_pop.get("sma", 0)), errors="coerce").fillna(0.0)
567
+ smk = pd.to_numeric(base_pop.get("smk", 0), errors="coerce").fillna(0.0)
568
+ slb = pd.to_numeric(base_pop.get("slb", 0), errors="coerce").fillna(0.0)
569
+ pop_sekolah = sma + smk + slb
570
+ tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
571
+ pop_umum = pd.to_numeric(base_pop.get("perpus_umum_prop", 0), errors="coerce").fillna(0.0)
572
+ tgt_umum = pop_umum * float(TARGET_RATIO)
573
+
574
+ m = base_n["Jenis"].eq("sekolah")
575
+ base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_sekolah).fillna(0.0).values
576
+ base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_sekolah).fillna(0.0).values
577
+
578
+ m = base_n["Jenis"].eq("umum")
579
+ base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_umum).fillna(0.0).values
580
+ base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_umum).fillna(0.0).values
581
+
582
+ if pop_khusus is not None and not pop_khusus.empty:
583
+ pk = pop_khusus.copy()
584
+ pk["Pop_Total_Jenis"] = pd.to_numeric(pk.get("Pop_Total_Jenis", 0), errors="coerce").fillna(0.0)
585
+
586
+ if mode == "PROV":
587
+ pk_prov = pk[pk["LEVEL"].astype(str).str.upper() == "PROV"].copy()
588
+ pk_map = pk_prov.groupby("prov_key", as_index=True).agg(pop=("Pop_Total_Jenis", "sum"))
589
+ pop_series = pk_map["pop"]
590
+ else:
591
+ pk_kab = pk[pk["LEVEL"].astype(str).str.upper() == "KAB"].copy()
592
+ pk_map = pk_kab.groupby("kab_key", as_index=True).agg(pop=("Pop_Total_Jenis", "sum"))
593
+ pop_series = pk_map["pop"]
594
+
595
+ tgt_series = pop_series * float(TARGET_RATIO)
596
+
597
+ m = base_n["Jenis"].eq("khusus")
598
+ base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_series).fillna(0.0).values
599
+ base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_series).fillna(0.0).values
600
+
601
+ base_n["target_total_33_88_jenis"] = pd.to_numeric(base_n["target_total_33_88_jenis"], errors="coerce").fillna(0.0)
602
+ base_n["pop_total_jenis"] = pd.to_numeric(base_n["pop_total_jenis"], errors="coerce").fillna(0.0)
603
+
604
+ m_need_pop = (base_n["pop_total_jenis"] <= 0) & (base_n["target_total_33_88_jenis"] > 0)
605
+ base_n.loc[m_need_pop, "pop_total_jenis"] = base_n.loc[m_need_pop, "target_total_33_88_jenis"] / float(TARGET_RATIO)
606
+
607
+ base_n["faktor_penyesuaian_jenis"] = [
608
+ faktor_penyesuaian_total(n, t)
609
+ for n, t in zip(
610
+ pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(float),
611
+ pd.to_numeric(base_n["target_total_33_88_jenis"], errors="coerce").fillna(0).astype(float),
612
+ )
613
+ ]
614
+
615
+ base_n["coverage_jenis_%"] = [
616
+ (safe_div(n, p) * 100.0) if (p is not None and not pd.isna(p) and float(p) > 0) else 0.0
617
+ for n, p in zip(
618
+ pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(float),
619
+ pd.to_numeric(base_n["pop_total_jenis"], errors="coerce").fillna(0).astype(float),
620
+ )
621
+ ]
622
+
623
+ base_n["gap_target33_88_jenis"] = [
624
+ max(float(t) - float(n), 0.0)
625
+ for n, t in zip(
626
+ pd.to_numeric(base_n["n_jenis"], errors="coerce").fillna(0).astype(float),
627
+ pd.to_numeric(base_n["target_total_33_88_jenis"], errors="coerce").fillna(0).astype(float),
628
+ )
629
+ ]
630
+
631
+ 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)
632
+ base_n["pop_total_jenis"] = pd.to_numeric(base_n["pop_total_jenis"], errors="coerce").fillna(0).round(0).astype(int)
633
+ base_n["coverage_jenis_%"] = pd.to_numeric(base_n["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
634
+ base_n["faktor_penyesuaian_jenis"] = pd.to_numeric(base_n["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
635
+ base_n["gap_target33_88_jenis"] = pd.to_numeric(base_n["gap_target33_88_jenis"], errors="coerce").fillna(0).round(0).astype(int)
636
+
637
+ return base_n
638
+
639
+
640
+ # ============================================================
641
+ # 7) AGREGAT WILAYAH × JENIS
642
+ # ============================================================
643
+
644
+ def build_agg_wilayah_jenis(df_filtered, faktor_wilayah_jenis, kew_value):
645
+ if df_filtered is None or df_filtered.empty:
646
+ return pd.DataFrame()
647
+
648
+ kew_norm = str(kew_value or "").upper()
649
+ df = df_filtered.copy()
650
+
651
+ if "PROV" in kew_norm:
652
+ key_col, label_col, label_name = "prov_key", "PROV_DISP", "Provinsi"
653
+ else:
654
+ key_col, label_col, label_name = "kab_key", "KAB_DISP", "Kab/Kota"
655
+
656
+ df = df[df["_dataset"].isin(["sekolah", "umum", "khusus"])].copy()
657
+ if df.empty:
658
+ return pd.DataFrame()
659
+
660
+ jenis_list = ["sekolah", "umum", "khusus"]
661
+
662
+ base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
663
+ full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
664
+
665
+ agg_real = df.groupby([key_col, label_col, "_dataset"], dropna=False).agg(
666
+ Jumlah=("Indeks_Dasar_0_100", "size"),
667
+ Rata2_sub_koleksi=("sub_koleksi", "mean"),
668
+ Rata2_sub_sdm=("sub_sdm", "mean"),
669
+ Rata2_sub_pelayanan=("sub_pelayanan", "mean"),
670
+ Rata2_sub_pengelolaan=("sub_pengelolaan", "mean"),
671
+ Rata2_dim_kepatuhan=("dim_kepatuhan", "mean"),
672
+ Rata2_dim_kinerja=("dim_kinerja", "mean"),
673
+ Indeks_Dasar_Agregat_0_100=("Indeks_Dasar_0_100", "mean"),
674
+ ).reset_index().rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
675
+
676
+ agg_real["Jenis"] = agg_real["Jenis"].astype(str).str.lower().str.strip()
677
+
678
+ agg = full.merge(agg_real, on=["group_key", label_name, "Jenis"], how="left")
679
+ for c in ["Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
680
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja","Indeks_Dasar_Agregat_0_100"]:
681
+ if c in agg.columns:
682
+ agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0)
683
+ agg["Jumlah"] = agg["Jumlah"].round(0).astype(int)
684
+
685
+ if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
686
+ agg["faktor_penyesuaian_jenis"] = 1.0
687
+ else:
688
+ fw = faktor_wilayah_jenis.copy()
689
+ fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
690
+ keep = ["group_key", label_name, "Jenis",
691
+ "faktor_penyesuaian_jenis", "target_total_33_88_jenis", "pop_total_jenis",
692
+ "coverage_jenis_%", "gap_target33_88_jenis", "n_jenis"]
693
+ fw = fw[[c for c in keep if c in fw.columns]].copy()
694
+ agg = agg.merge(fw, on=["group_key", label_name, "Jenis"], how="left")
695
+ agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
696
+
697
+ agg["Indeks_Final_Agregat_0_100"] = (
698
+ pd.to_numeric(agg["Indeks_Dasar_Agregat_0_100"], errors="coerce").fillna(0.0)
699
+ * pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
700
+ )
701
+
702
+ for c in [
703
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
704
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja"
705
+ ]:
706
+ if c in agg.columns:
707
+ agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(3)
708
+ for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100"]:
709
+ if c in agg.columns:
710
+ agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(2)
711
+
712
+ agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
713
+ return agg
714
+
715
+
716
+ # ============================================================
717
+ # 8) AGREGAT WILAYAH (KESELURUHAN) — avg3 dari 3 jenis
718
+ # ============================================================
719
+
720
+ def build_agg_wilayah_total_from_jenis(agg_jenis, faktor_wilayah_jenis, kew_value):
721
+ if agg_jenis is None or agg_jenis.empty:
722
+ return pd.DataFrame()
723
+
724
+ kew_norm = str(kew_value or "").upper()
725
+ label_name = "Provinsi" if "PROV" in kew_norm else "Kab/Kota"
726
+ jenis_list = ["sekolah", "umum", "khusus"]
727
+
728
+ a = agg_jenis.copy()
729
+ a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
730
+
731
+ base_keys = a[["group_key", label_name]].drop_duplicates()
732
+ full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
733
+
734
+ cols_present = [c for c in [
735
+ "Jumlah",
736
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
737
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
738
+ "Indeks_Dasar_Agregat_0_100",
739
+ "Indeks_Final_Agregat_0_100",
740
+ ] if c in a.columns]
741
+
742
+ full = full.merge(a[["group_key", label_name, "Jenis"] + cols_present],
743
+ on=["group_key", label_name, "Jenis"], how="left")
744
+ for c in cols_present:
745
+ full[c] = pd.to_numeric(full[c], errors="coerce").fillna(0.0)
746
+
747
+ out = full.groupby(["group_key", label_name], as_index=False).agg(
748
+ n_total=("Jumlah", "sum"),
749
+ Rata2_sub_koleksi=("Rata2_sub_koleksi", "mean"),
750
+ Rata2_sub_sdm=("Rata2_sub_sdm", "mean"),
751
+ Rata2_sub_pelayanan=("Rata2_sub_pelayanan", "mean"),
752
+ Rata2_sub_pengelolaan=("Rata2_sub_pengelolaan", "mean"),
753
+ Rata2_dim_kepatuhan=("Rata2_dim_kepatuhan", "mean"),
754
+ Rata2_dim_kinerja=("Rata2_dim_kinerja", "mean"),
755
+ Indeks_Dasar_Agregat_0_100=("Indeks_Dasar_Agregat_0_100", "mean"),
756
+ Indeks_Final_Wilayah_0_100=("Indeks_Final_Agregat_0_100", "mean"),
757
+ )
758
+
759
+ for c in ["Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja"]:
760
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
761
+ for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Wilayah_0_100"]:
762
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
763
+ out["n_total"] = pd.to_numeric(out["n_total"], errors="coerce").fillna(0).round(0).astype(int)
764
+
765
+ return out
766
+
767
+
768
+ # ============================================================
769
+ # 9) SUMMARY (PER JENIS) + KESELURUHAN
770
+ # ============================================================
771
+
772
+ def build_summary_per_jenis(agg_jenis, agg_total):
773
+ jenis_list = ["sekolah", "umum", "khusus"]
774
+
775
+ def _row_default(jenis):
776
+ return {
777
+ "Jenis": jenis,
778
+ "Jumlah_Wilayah": 0,
779
+ "Total_Perpus": 0,
780
+ "Pop_Total_Jenis": 0,
781
+ "Target33_88_Total_Jenis": 0,
782
+ "Terkumpul_Jenis": 0,
783
+ "Coverage_Target33_88_Jenis_%": 0.0,
784
+ "Indeks_Dasar_0_100": 0.0,
785
+ "Indeks_Final_Disesuaikan_0_100": 0.0,
786
+ "Penyesuaian_Poin": 0.0,
787
+ }
788
+
789
+ rows_by_jenis = {j: _row_default(j) for j in jenis_list}
790
+
791
+ if agg_jenis is not None and not agg_jenis.empty:
792
+ a = agg_jenis.copy()
793
+ a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
794
+ for c in ["Jumlah","Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100","pop_total_jenis","target_total_33_88_jenis"]:
795
+ if c in a.columns:
796
+ a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0)
797
+
798
+ for jenis in jenis_list:
799
+ sub = a[a["Jenis"] == jenis].copy()
800
+ if sub.empty:
801
+ continue
802
+
803
+ jumlah_wilayah = int(sub.shape[0])
804
+ terkumpul = int(pd.to_numeric(sub.get("Jumlah", 0), errors="coerce").fillna(0).sum())
805
+ pop_total = int(pd.to_numeric(sub.get("pop_total_jenis", 0), errors="coerce").fillna(0).sum())
806
+ target3388 = int(pd.to_numeric(sub.get("target_total_33_88_jenis", 0), errors="coerce").fillna(0).sum())
807
+
808
+ coverage = (terkumpul / target3388 * 100.0) if target3388 > 0 else 0.0
809
+ dasar = float(pd.to_numeric(sub.get("Indeks_Dasar_Agregat_0_100", 0), errors="coerce").fillna(0).mean())
810
+ final = float(pd.to_numeric(sub.get("Indeks_Final_Agregat_0_100", 0), errors="coerce").fillna(0).mean())
811
+
812
+ rows_by_jenis[jenis] = {
813
+ "Jenis": jenis,
814
+ "Jumlah_Wilayah": jumlah_wilayah,
815
+ "Total_Perpus": terkumpul,
816
+ "Pop_Total_Jenis": pop_total,
817
+ "Target33_88_Total_Jenis": target3388,
818
+ "Terkumpul_Jenis": terkumpul,
819
+ "Coverage_Target33_88_Jenis_%": float(coverage),
820
+ "Indeks_Dasar_0_100": float(dasar),
821
+ "Indeks_Final_Disesuaikan_0_100": float(final),
822
+ "Penyesuaian_Poin": float(final - dasar),
823
+ }
824
+
825
+ rows = [rows_by_jenis[j] for j in jenis_list]
826
+
827
+ dasar_all = (rows_by_jenis["sekolah"]["Indeks_Dasar_0_100"]
828
+ + rows_by_jenis["umum"]["Indeks_Dasar_0_100"]
829
+ + rows_by_jenis["khusus"]["Indeks_Dasar_0_100"]) / 3.0
830
+
831
+ final_all = (rows_by_jenis["sekolah"]["Indeks_Final_Disesuaikan_0_100"]
832
+ + rows_by_jenis["umum"]["Indeks_Final_Disesuaikan_0_100"]
833
+ + rows_by_jenis["khusus"]["Indeks_Final_Disesuaikan_0_100"]) / 3.0
834
+
835
+ pop_all = int(rows_by_jenis["sekolah"]["Pop_Total_Jenis"]
836
+ + rows_by_jenis["umum"]["Pop_Total_Jenis"]
837
+ + rows_by_jenis["khusus"]["Pop_Total_Jenis"])
838
+
839
+ target_all = int(rows_by_jenis["sekolah"]["Target33_88_Total_Jenis"]
840
+ + rows_by_jenis["umum"]["Target33_88_Total_Jenis"]
841
+ + rows_by_jenis["khusus"]["Target33_88_Total_Jenis"])
842
+
843
+ terkumpul_all = int(rows_by_jenis["sekolah"]["Terkumpul_Jenis"]
844
+ + rows_by_jenis["umum"]["Terkumpul_Jenis"]
845
+ + rows_by_jenis["khusus"]["Terkumpul_Jenis"])
846
+
847
+ coverage_all = (terkumpul_all / target_all * 100.0) if target_all > 0 else 0.0
848
+
849
+ jumlah_wilayah_all = int(agg_total.shape[0]) if (agg_total is not None and not agg_total.empty) else int(
850
+ max(rows_by_jenis["sekolah"]["Jumlah_Wilayah"],
851
+ rows_by_jenis["umum"]["Jumlah_Wilayah"],
852
+ rows_by_jenis["khusus"]["Jumlah_Wilayah"])
853
+ )
854
+
855
+ rows.append({
856
+ "Jenis": "keseluruhan",
857
+ "Jumlah_Wilayah": jumlah_wilayah_all,
858
+ "Total_Perpus": terkumpul_all,
859
+ "Pop_Total_Jenis": pop_all,
860
+ "Target33_88_Total_Jenis": target_all,
861
+ "Terkumpul_Jenis": terkumpul_all,
862
+ "Coverage_Target33_88_Jenis_%": float(coverage_all),
863
+ "Indeks_Dasar_0_100": float(dasar_all),
864
+ "Indeks_Final_Disesuaikan_0_100": float(final_all),
865
+ "Penyesuaian_Poin": float(final_all - dasar_all),
866
+ })
867
+
868
+ out = pd.DataFrame(rows)
869
+ for c in ["Jumlah_Wilayah","Total_Perpus","Pop_Total_Jenis","Target33_88_Total_Jenis","Terkumpul_Jenis"]:
870
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
871
+ for c in ["Coverage_Target33_88_Jenis_%","Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin"]:
872
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
873
+ return out
874
+
875
+
876
+ # ============================================================
877
+ # 10) DETAIL ENTITAS (Final menempel dari wilayah)
878
+ # ============================================================
879
+
880
+ def attach_final_to_detail(df_filtered, agg_total, meta, kew_value):
881
+ if df_filtered is None or df_filtered.empty:
882
+ return pd.DataFrame()
883
+
884
+ kew_norm = str(kew_value or "").upper()
885
+ df = df_filtered.copy()
886
+
887
+ if "PROV" in kew_norm:
888
+ key_col = "prov_key"
889
+ label_cols = ("PROV_DISP", "KAB_DISP")
890
+ else:
891
+ key_col = "kab_key"
892
+ label_cols = ("PROV_DISP", "KAB_DISP")
893
+
894
+ if agg_total is None or agg_total.empty:
895
+ df["Indeks_Final_0_100"] = df["Indeks_Dasar_0_100"]
896
+ else:
897
+ m = agg_total[["group_key", "Indeks_Final_Wilayah_0_100"]].copy()
898
+ df = df.merge(m, left_on=key_col, right_on="group_key", how="left")
899
+ df["Indeks_Final_0_100"] = df["Indeks_Final_Wilayah_0_100"].fillna(df["Indeks_Dasar_0_100"])
900
+ df = df.drop(columns=[c for c in ["group_key","Indeks_Final_Wilayah_0_100"] if c in df.columns])
901
+
902
+ base_cols = [label_cols[0], label_cols[1], "KEW_NORM", "_dataset"]
903
+ if meta.get("nama_col") and meta["nama_col"] in df.columns:
904
+ df["nm_perpustakaan"] = df[meta["nama_col"]].astype(str)
905
+ base_cols.insert(2, "nm_perpustakaan")
906
+
907
+ keep = base_cols + [
908
+ "sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan",
909
+ "dim_kepatuhan","dim_kinerja",
910
+ "Indeks_Dasar_0_100",
911
+ "Indeks_Final_0_100",
912
+ ]
913
+ keep = [c for c in keep if c in df.columns]
914
+
915
+ out = df[keep].copy()
916
+ out = out.rename(columns={label_cols[0]:"Provinsi", label_cols[1]:"Kab/Kota", "_dataset":"Jenis"})
917
+
918
+ for c in ["sub_koleksi","sub_sdm","sub_pelayanan","sub_pengelolaan","dim_kepatuhan","dim_kinerja"]:
919
+ if c in out.columns:
920
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
921
+ for c in ["Indeks_Dasar_0_100","Indeks_Final_0_100"]:
922
+ if c in out.columns:
923
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
924
+ return out
925
+
926
+
927
+ # ============================================================
928
+ # 11) VERIF (kecukupan sampel)
929
+ # ============================================================
930
+
931
+ def build_verif_jenis(faktor_wilayah_jenis, kew_value):
932
+ if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
933
+ return pd.DataFrame()
934
+
935
+ kew_norm = str(kew_value or "").upper()
936
+ label_col = "Provinsi" if "PROV" in kew_norm else "Kab/Kota"
937
+
938
+ out = faktor_wilayah_jenis.copy()
939
+ keep = [c for c in [
940
+ label_col, "Jenis",
941
+ "pop_total_jenis", "target_total_33_88_jenis", "n_jenis",
942
+ "coverage_jenis_%", "faktor_penyesuaian_jenis", "gap_target33_88_jenis"
943
+ ] if c in out.columns]
944
+
945
+ out = out[keep].copy()
946
+
947
+ for c in ["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis"]:
948
+ if c in out.columns:
949
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
950
+ if "coverage_jenis_%" in out.columns:
951
+ out["coverage_jenis_%"] = pd.to_numeric(out["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
952
+ if "faktor_penyesuaian_jenis" in out.columns:
953
+ out["faktor_penyesuaian_jenis"] = pd.to_numeric(out["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
954
+
955
+ return out
956
+
957
+
958
+ # ============================================================
959
+ # 12) BELL CURVE — Indeks Dasar per Entitas (per Jenis) + Hover
960
+ # ============================================================
961
+
962
+ def _make_bell_curve_entitas(dfp, title, xcol="Indeks_Dasar_0_100", label_col="nm_perpustakaan", hover_cols=None, min_points=2):
963
+ fig = go.Figure()
964
+ fig.update_layout(
965
+ title=title,
966
+ xaxis_title="Skor (0–100)",
967
+ yaxis_title="Kepadatan",
968
+ hovermode="closest",
969
+ margin=dict(l=40, r=20, t=60, b=40),
970
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
971
+ )
972
+
973
+ if dfp is None or dfp.empty or xcol not in dfp.columns:
974
+ fig.add_annotation(text="Tidak ada data untuk ditampilkan.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
975
+ fig.update_xaxes(range=[0, 100])
976
+ fig.update_yaxes(rangemode="tozero")
977
+ return fig
978
+
979
+ d = dfp.dropna(subset=[xcol]).copy()
980
+ if d.empty:
981
+ fig.add_annotation(text="Tidak ada data untuk ditampilkan.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
982
+ fig.update_xaxes(range=[0, 100])
983
+ fig.update_yaxes(rangemode="tozero")
984
+ return fig
985
+
986
+ x = pd.to_numeric(d[xcol], errors="coerce").astype(float)
987
+ d = d.loc[x.notna()].copy()
988
+ x = x.loc[x.notna()].values
989
+ if len(x) < 1:
990
+ fig.add_annotation(text="Tidak ada data untuk ditampilkan.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
991
+ fig.update_xaxes(range=[0, 100])
992
+ fig.update_yaxes(rangemode="tozero")
993
+ return fig
994
+
995
+ hover_cols = hover_cols or []
996
+ def _val(row, col):
997
+ if col not in row.index:
998
+ return ""
999
+ v = row[col]
1000
+ return "" if pd.isna(v) else str(v)
1001
+
1002
+ hover_text = []
1003
+ for _, row in d.iterrows():
1004
+ lines = []
1005
+ nm = _val(row, label_col) if (label_col and label_col in d.columns) else ""
1006
+ if nm:
1007
+ lines.append(f"<b>{nm}</b>")
1008
+ lines.append(f"{xcol}: {float(pd.to_numeric(row[xcol], errors='coerce')):.2f}")
1009
+ for hc in hover_cols:
1010
+ vv = _val(row, hc)
1011
+ if vv:
1012
+ lines.append(f"{hc}: {vv}")
1013
+ hover_text.append("<br>".join(lines))
1014
+
1015
+ if len(x) < min_points:
1016
+ x_single = float(x[0])
1017
+ fig.add_trace(go.Scatter(
1018
+ x=[x_single], y=[0],
1019
+ mode="markers", showlegend=False,
1020
+ hovertext=[hover_text[0]] if hover_text else None,
1021
+ hoverinfo="text"
1022
+ ))
1023
+ fig.add_vline(x=x_single, line_width=1, line_dash="dash", annotation_text=f"Nilai: {x_single:.1f}", annotation_position="top")
1024
+ fig.update_xaxes(range=[0, 100])
1025
+ fig.update_yaxes(rangemode="tozero")
1026
+ return fig
1027
+
1028
+ mu = float(np.mean(x))
1029
+ sigma = float(np.std(x, ddof=1)) if len(x) > 1 else 1.0
1030
+ sigma = max(sigma, 1e-3)
1031
+
1032
+ xmin = max(0.0, float(np.min(x)) - 5.0)
1033
+ xmax = min(100.0, float(np.max(x)) + 5.0)
1034
+ xs = np.linspace(xmin, xmax, 250)
1035
+ pdf = (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((xs - mu) / sigma) ** 2)
1036
+
1037
+ fig.add_trace(go.Scatter(x=xs, y=pdf, mode="lines", name="Kurva Normal (fit)"))
1038
+ fig.add_trace(go.Scatter(
1039
+ x=x, y=np.zeros_like(x),
1040
+ mode="markers", showlegend=False,
1041
+ hovertext=hover_text if hover_text else None,
1042
+ hoverinfo="text"
1043
+ ))
1044
+
1045
+ q1, q2, q3 = np.percentile(x, [25, 50, 75])
1046
+ for xv, lab in [(q1, "Q1"), (q2, "Q2 (Median)"), (q3, "Q3"), (mu, "Mean")]:
1047
+ fig.add_vline(x=float(xv), line_width=1, line_dash="dash", annotation_text=f"{lab}: {xv:.1f}", annotation_position="top")
1048
+
1049
+ fig.update_xaxes(range=[0, 100])
1050
+ fig.update_yaxes(rangemode="tozero")
1051
+ return fig
1052
+
1053
+
1054
+ # ============================================================
1055
+ # 13) KPI DASHBOARD (2 kartu: final + dasar)
1056
+ # ============================================================
1057
+
1058
+ def _safe_first(df, col, default=0.0, where=None):
1059
+ if df is None or df.empty or col not in df.columns:
1060
+ return default
1061
+ sub = df
1062
+ if where is not None:
1063
+ sub = df.loc[where]
1064
+ if sub is None or sub.empty:
1065
+ return default
1066
+ return float(pd.to_numeric(sub[col], errors="coerce").fillna(default).iloc[0])
1067
+
1068
+ def build_kpi_markdown(summary_jenis):
1069
+ if summary_jenis is None or summary_jenis.empty:
1070
+ return ""
1071
+ final_all = _safe_first(summary_jenis, "Indeks_Final_Disesuaikan_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1072
+ dasar_all = _safe_first(summary_jenis, "Indeks_Dasar_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1073
+
1074
+ def fmt(x, nd=2):
1075
+ return "NA" if pd.isna(x) else f"{x:.{nd}f}"
1076
+
1077
+ return f"""
1078
+ <div style="display:flex; gap:12px; flex-wrap:wrap;">
1079
+ <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1080
+ <div style="opacity:0.8;">Indeks IPLM FINAL (Disesuaikan 33.88%)</div>
1081
+ <div style="font-size:26px; font-weight:700;">{fmt(final_all,2)}</div>
1082
+ <div style="opacity:0.7;">Skor absolut (untuk akuntabilitas)</div>
1083
+ </div>
1084
+
1085
+ <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1086
+ <div style="opacity:0.8;">Indeks Dasar (Tanpa Penyesuaian)</div>
1087
+ <div style="font-size:26px; font-weight:700;">{fmt(dasar_all,2)}</div>
1088
+ <div style="opacity:0.7;">Sebelum faktor kecukupan sampel</div>
1089
+ </div>
1090
+ </div>
1091
+ """.strip()
1092
+
1093
+
1094
+ # ============================================================
1095
+ # 14) LLM: Isi Interpretasi & Rekomendasi (TABEL) + WORD
1096
+ # ============================================================
1097
+
1098
+ _HF_CLIENT = None
1099
+
1100
+ def get_llm_client():
1101
+ global _HF_CLIENT
1102
+ if _HF_CLIENT is not None:
1103
+ return _HF_CLIENT
1104
+ if (not HF_AVAILABLE) or (InferenceClient is None):
1105
+ _HF_CLIENT = None
1106
+ return None
1107
+ try:
1108
+ _HF_CLIENT = InferenceClient(model=LLM_MODEL_NAME, token=HF_TOKEN) if HF_TOKEN else InferenceClient(model=LLM_MODEL_NAME)
1109
+ return _HF_CLIENT
1110
+ except Exception:
1111
+ _HF_CLIENT = None
1112
+ return None
1113
+
1114
+ def _to_float(x, default=0.0):
1115
+ try:
1116
+ if x is None:
1117
+ return float(default)
1118
+ if isinstance(x, float) and math.isnan(x):
1119
+ return float(default)
1120
+ return float(x)
1121
+ except Exception:
1122
+ return float(default)
1123
+
1124
+ def summarize_jumlah_perpus_dari_agg_jenis(agg_jenis_full, wilayah_label, kew_value):
1125
+ """
1126
+ Ambil jumlah perpustakaan sumber data dari tabel agregat wilayah × jenis (gambar 2).
1127
+ Untuk filter 1 wilayah (kab/prov), agg_jenis_full biasanya 3 baris (sekolah/umum/khusus).
1128
+ Untuk nasional/semua wilayah, ini akan menjumlahkan seluruh wilayah per jenis.
1129
+ """
1130
+ if agg_jenis_full is None or agg_jenis_full.empty:
1131
+ return {"sekolah": 0, "umum": 0, "khusus": 0, "total": 0}
1132
+
1133
+ a = agg_jenis_full.copy()
1134
+ if "Jenis" not in a.columns:
1135
+ return {"sekolah": 0, "umum": 0, "khusus": 0, "total": 0}
1136
+
1137
+ a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
1138
+ if "Jumlah" in a.columns:
1139
+ a["Jumlah"] = pd.to_numeric(a["Jumlah"], errors="coerce").fillna(0).astype(int)
1140
+ else:
1141
+ a["Jumlah"] = 0
1142
+
1143
+ out = {}
1144
+ for j in ["sekolah", "umum", "khusus"]:
1145
+ out[j] = int(a.loc[a["Jenis"].eq(j), "Jumlah"].sum())
1146
+ out["total"] = int(out["sekolah"] + out["umum"] + out["khusus"])
1147
+ return out
1148
+
1149
+ def build_interpretasi_table_values(agg_total, wilayah_label, target_ratio):
1150
+ """
1151
+ MENGAMBIL NILAI APA ADANYA (tanpa *100) dari hasil aplikasi (agg_total):
1152
+ - Kepatuhan = Rata2_dim_kepatuhan
1153
+ - Koleksi = Rata2_sub_koleksi
1154
+ - Tenaga = Rata2_sub_sdm
1155
+ - Kinerja = Rata2_dim_kinerja
1156
+ - Pelayanan = Rata2_sub_pelayanan
1157
+ - Pengelolaan = Rata2_sub_pengelolaan
1158
+ - Nilai IPLM = Indeks_Final_Wilayah_0_100
1159
+
1160
+ Jika agg_total > 1 baris (mis. nasional), diambil mean kolom-kolom tersebut.
1161
+ """
1162
+ if agg_total is None or agg_total.empty:
1163
+ base = {
1164
+ "kepatuhan": 0.0, "koleksi": 0.0, "tenaga": 0.0,
1165
+ "kinerja": 0.0, "pelayanan": 0.0, "pengelolaan": 0.0,
1166
+ "iplm": 0.0
1167
+ }
1168
+ else:
1169
+ a = agg_total.copy()
1170
+ cols_needed = [
1171
+ "Rata2_dim_kepatuhan",
1172
+ "Rata2_sub_koleksi",
1173
+ "Rata2_sub_sdm",
1174
+ "Rata2_dim_kinerja",
1175
+ "Rata2_sub_pelayanan",
1176
+ "Rata2_sub_pengelolaan",
1177
+ "Indeks_Final_Wilayah_0_100",
1178
+ ]
1179
+ for c in cols_needed:
1180
+ if c in a.columns:
1181
+ a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0.0)
1182
+ else:
1183
+ a[c] = 0.0
1184
+
1185
+ base = {
1186
+ "kepatuhan": float(a["Rata2_dim_kepatuhan"].mean()),
1187
+ "koleksi": float(a["Rata2_sub_koleksi"].mean()),
1188
+ "tenaga": float(a["Rata2_sub_sdm"].mean()),
1189
+ "kinerja": float(a["Rata2_dim_kinerja"].mean()),
1190
+ "pelayanan": float(a["Rata2_sub_pelayanan"].mean()),
1191
+ "pengelolaan": float(a["Rata2_sub_pengelolaan"].mean()),
1192
+ "iplm": float(a["Indeks_Final_Wilayah_0_100"].mean()),
1193
+ }
1194
+
1195
+ # pembulatan display (nilai tetap "apa adanya", hanya format)
1196
+ # untuk sub/dim (0–1) biasanya 3 desimal; untuk IPLM (0–100) 2 desimal.
1197
+ base_disp = {
1198
+ "kepatuhan": round(_to_float(base["kepatuhan"]), 3),
1199
+ "koleksi": round(_to_float(base["koleksi"]), 3),
1200
+ "tenaga": round(_to_float(base["tenaga"]), 3),
1201
+ "kinerja": round(_to_float(base["kinerja"]), 3),
1202
+ "pelayanan": round(_to_float(base["pelayanan"]), 3),
1203
+ "pengelolaan": round(_to_float(base["pengelolaan"]), 3),
1204
+ "iplm": round(_to_float(base["iplm"]), 2),
1205
+ }
1206
+
1207
+ rows = [
1208
+ {"No":"1", "Dimensi":"Kepatuhan", "Nilai":base_disp["kepatuhan"], "SumberKolom":"Rata2_dim_kepatuhan"},
1209
+ {"No":"1.1", "Dimensi":"Variabel Koleksi", "Nilai":base_disp["koleksi"], "SumberKolom":"Rata2_sub_koleksi"},
1210
+ {"No":"1.2", "Dimensi":"Variabel Tenaga Perpustakaan", "Nilai":base_disp["tenaga"], "SumberKolom":"Rata2_sub_sdm"},
1211
+ {"No":"2", "Dimensi":"Kinerja", "Nilai":base_disp["kinerja"], "SumberKolom":"Rata2_dim_kinerja"},
1212
+ {"No":"2.1", "Dimensi":"Variabel Pelayanan", "Nilai":base_disp["pelayanan"], "SumberKolom":"Rata2_sub_pelayanan"},
1213
+ {"No":"2.2", "Dimensi":"Variabel Penyelenggaraan/Pengelolaan", "Nilai":base_disp["pengelolaan"], "SumberKolom":"Rata2_sub_pengelolaan"},
1214
+ {"No":"4", "Dimensi":"Nilai IPLM", "Nilai":base_disp["iplm"], "SumberKolom":"Indeks_Final_Wilayah_0_100"},
1215
+ ]
1216
+
1217
+ header = {
1218
+ "judul": f"Interpretasi dan Rekomendasi IPLM — {wilayah_label}",
1219
+ "target_sampel": f"{target_ratio*100:.2f}%"
1220
+ }
1221
+ return header, rows
1222
+
1223
+ def llm_fill_interpretasi_rekomendasi(header, rows, wilayah_label, kew_label, jumlah_perpus_by_jenis):
1224
+ """
1225
+ LLM diminta mengisi kolom Interpretasi dan Rekomendasi dengan narasi yang NYAMBUNG ke angka:
1226
+ - Interpretasi: jelaskan apa arti angka untuk kondisi operasional perpustakaan (koleksi/sdm/pelayanan/pengelolaan),
1227
+ memakai relasi angka antardimensi (lebih besar/kecil, selisih, dominan, gap, konsistensi) TANPA label normatif.
1228
+ - Rekomendasi: 2–3 aksi teknis per baris yang langsung meng-address pola angka (misal dimensi lebih kecil → prioritas aktivitas),
1229
+ serta mengaitkan dengan volume data (jumlah perpustakaan per jenis) bila relevan.
1230
+ Output wajib JSON.
1231
+ """
1232
+ client = get_llm_client()
1233
+ if client is None or (not USE_LLM):
1234
+ out = []
1235
+ for r in rows:
1236
+ out.append({k: r.get(k) for k in ["No","Dimensi","Nilai"]} | {"Interpretasi":"", "Rekomendasi":""})
1237
+ return out, "LLM tidak digunakan / tidak tersedia."
1238
+
1239
+ payload = {
1240
+ "wilayah": wilayah_label,
1241
+ "kewenangan": kew_label,
1242
+ "target_sampel_per_jenis": header["target_sampel"],
1243
+ "jumlah_perpustakaan_sumber_data": jumlah_perpus_by_jenis,
1244
+ "catatan_skala": (
1245
+ "Baris Kepatuhan/Koleksi/Tenaga/Kinerja/Pelayanan/Pengelolaan memakai nilai agregat 'apa adanya' "
1246
+ "(umumnya rentang 0–1 karena berasal dari sub/dim hasil normalisasi). "
1247
+ "Baris 'Nilai IPLM' memakai Indeks_Final_Wilayah_0_100 (rentang 0–100)."
1248
+ ),
1249
+ "baris": rows
1250
+ }
1251
+
1252
+ system = (
1253
+ "Anda adalah analis kebijakan perpustakaan di Indonesia.\n"
1254
+ "Tugas: isi kolom Interpretasi dan Rekomendasi untuk setiap baris tabel.\n"
1255
+ "ATURAN WAJIB:\n"
1256
+ "1) Jangan mengubah nilai angka. Jangan menghitung ulang skor.\n"
1257
+ "2) Netral-deskriptif: dilarang memakai label normatif seperti baik/buruk, tinggi/sedang/rendah, memuaskan/kurang, optimal/tidak optimal.\n"
1258
+ "3) Interpretasi harus nyambung langsung ke angka dan relasinya antardimensi: gunakan istilah lebih besar/kecil, selisih, gap, dominan, konsisten/tidak konsisten, kontribusi, proporsi.\n"
1259
+ "4) Interpretasi juga harus menjelaskan kondisi riil berbasis dimensi:\n"
1260
+ " - Koleksi: pengembangan, ketersediaan, pemanfaatan koleksi (sebagai fungsi layanan),\n"
1261
+ " - Tenaga: kecukupan/kapasitas SDM dan pengembangan kompetensi,\n"
1262
+ " - Pelayanan: aktivitas layanan dan pemanfaatan layanan,\n"
1263
+ " - Pengelolaan: tata kelola, kebijakan, kolaborasi, dukungan anggaran layanan,\n"
1264
+ " - Kepatuhan = gabungan koleksi+tenaga; Kinerja = gabungan pelayanan+pengelolaan.\n"
1265
+ " Jelaskan tanpa menghakimi; fokus pada apa yang angka itu representasikan.\n"
1266
+ "5) Rekomendasi harus operasional dan spesifik (2–3 butir singkat) untuk tiap baris. Gunakan pola angka untuk menurunkan aksi.\n"
1267
+ "6) Output HARUS JSON valid saja (tanpa teks tambahan), dengan struktur persis.\n"
1268
+ )
1269
+
1270
+ user = (
1271
+ "Kembalikan JSON:\n"
1272
+ "{\n"
1273
+ ' "rows": [\n'
1274
+ ' {"No":"...","Dimensi":"...","Nilai":..., "Interpretasi":"...","Rekomendasi":"..."}\n'
1275
+ " ]\n"
1276
+ "}\n"
1277
+ "- Urutan dan jumlah baris harus sama.\n"
1278
+ "- 'Rekomendasi' boleh berupa bullet dengan tanda '-' dalam satu string.\n\n"
1279
+ f"INPUT:\n{json.dumps(payload, ensure_ascii=False)}"
1280
+ )
1281
+
1282
+ try:
1283
+ resp = client.chat_completion(
1284
+ model=LLM_MODEL_NAME,
1285
+ messages=[
1286
+ {"role": "system", "content": system},
1287
+ {"role": "user", "content": user},
1288
+ ],
1289
+ max_tokens=1100,
1290
+ temperature=0.2,
1291
+ top_p=0.9,
1292
+ )
1293
+ text = resp.choices[0].message.content.strip()
1294
+ data = json.loads(text)
1295
+ rows_out = data.get("rows", [])
1296
+ if not isinstance(rows_out, list) or len(rows_out) != len(rows):
1297
+ raise ValueError("Format JSON rows tidak sesuai.")
1298
+ cleaned = []
1299
+ for i, r in enumerate(rows_out):
1300
+ cleaned.append({
1301
+ "No": str(r.get("No", rows[i]["No"])),
1302
+ "Dimensi": str(r.get("Dimensi", rows[i]["Dimensi"])),
1303
+ "Nilai": rows[i]["Nilai"], # paksa nilai dari aplikasi
1304
+ "Interpretasi": str(r.get("Interpretasi","") or ""),
1305
+ "Rekomendasi": str(r.get("Rekomendasi","") or ""),
1306
+ })
1307
+ return cleaned, "LLM mengisi Interpretasi & Rekomendasi."
1308
+ except Exception as e:
1309
+ out = []
1310
+ for r in rows:
1311
+ out.append({k: r.get(k) for k in ["No","Dimensi","Nilai"]} | {"Interpretasi":"", "Rekomendasi":""})
1312
+ return out, f"LLM error: {repr(e)}"
1313
+
1314
+
1315
+ def _set_cell_shading(cell, fill_hex="1F1F1F"):
1316
+ tcPr = cell._tc.get_or_add_tcPr()
1317
+ shd = OxmlElement("w:shd")
1318
+ shd.set(qn("w:val"), "clear")
1319
+ shd.set(qn("w:color"), "auto")
1320
+ shd.set(qn("w:fill"), fill_hex)
1321
+ tcPr.append(shd)
1322
+
1323
+ def _set_cell_text_color(cell, rgb_hex="FFFFFF"):
1324
+ for p in cell.paragraphs:
1325
+ for run in p.runs:
1326
+ rPr = run._r.get_or_add_rPr()
1327
+ color = OxmlElement("w:color")
1328
+ color.set(qn("w:val"), rgb_hex)
1329
+ rPr.append(color)
1330
+
1331
+ def _set_table_borders(table):
1332
+ tbl = table._tbl
1333
+ tblPr = tbl.tblPr
1334
+ if tblPr is None:
1335
+ tblPr = OxmlElement("w:tblPr")
1336
+ tbl.append(tblPr)
1337
+ tblBorders = OxmlElement("w:tblBorders")
1338
+ for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
1339
+ elem = OxmlElement(f"w:{edge}")
1340
+ elem.set(qn("w:val"), "single")
1341
+ elem.set(qn("w:sz"), "8")
1342
+ elem.set(qn("w:space"), "0")
1343
+ elem.set(qn("w:color"), "FFFFFF")
1344
+ tblBorders.append(elem)
1345
+ tblPr.append(tblBorders)
1346
+
1347
+ def generate_word_table_interpretasi(header, rows_filled, wilayah_label, jumlah_perpus_by_jenis):
1348
+ if (not DOCX_AVAILABLE) or (Document is None):
1349
+ return None
1350
+
1351
+ doc = Document()
1352
+
1353
+ # Title
1354
+ title = doc.add_paragraph()
1355
+ run = title.add_run(header["judul"])
1356
+ run.bold = True
1357
+ run.font.size = Pt(18)
1358
+
1359
+ doc.add_paragraph(f"Target sampel per jenis: {header['target_sampel']}")
1360
+
1361
+ # Table
1362
+ cols = ["No", "Dimensi", "Nilai", "Interpretasi", "Rekomendasi"]
1363
+ table = doc.add_table(rows=1, cols=len(cols))
1364
+ table.autofit = True
1365
+ _set_table_borders(table)
1366
+
1367
+ hdr_cells = table.rows[0].cells
1368
+ for i, c in enumerate(cols):
1369
+ hdr_cells[i].text = c
1370
+ _set_cell_shading(hdr_cells[i], "1A1A1A")
1371
+ _set_cell_text_color(hdr_cells[i], "FFFFFF")
1372
+ for p in hdr_cells[i].paragraphs:
1373
+ for rr in p.runs:
1374
+ rr.bold = True
1375
+
1376
+ for r in rows_filled:
1377
+ row_cells = table.add_row().cells
1378
+ row_cells[0].text = str(r.get("No",""))
1379
+ row_cells[1].text = str(r.get("Dimensi",""))
1380
+
1381
+ # format nilai:
1382
+ # - sub/dim biasanya 0–1 → tampilkan 3 desimal
1383
+ # - IPLM 0–100 → tampilkan 2 desimal
1384
+ try:
1385
+ dim = str(r.get("Dimensi","")).strip().lower()
1386
+ val = _to_float(r.get("Nilai", 0.0), 0.0)
1387
+ if dim == "nilai iplm":
1388
+ row_cells[2].text = f"{val:.2f}"
1389
+ else:
1390
+ row_cells[2].text = f"{val:.3f}"
1391
+ except Exception:
1392
+ row_cells[2].text = str(r.get("Nilai",""))
1393
+
1394
+ row_cells[3].text = str(r.get("Interpretasi","") or "")
1395
+ row_cells[4].text = str(r.get("Rekomendasi","") or "")
1396
+
1397
+ for c in row_cells:
1398
+ _set_cell_shading(c, "262626")
1399
+ _set_cell_text_color(c, "FFFFFF")
1400
+
1401
+ # ===== tambahan: deskripsi jumlah perpustakaan sumber data (gambar 2) =====
1402
+ doc.add_paragraph("") # spacer
1403
+ j = jumlah_perpus_by_jenis or {"sekolah":0,"umum":0,"khusus":0,"total":0}
1404
+ p = doc.add_paragraph()
1405
+ p.add_run("Sumber data (jumlah perpustakaan pada tabel agregat wilayah × jenis): ").bold = True
1406
+ doc.add_paragraph(
1407
+ f"Perpustakaan sekolah = {int(j.get('sekolah',0))}, "
1408
+ f"perpustakaan umum = {int(j.get('umum',0))}, "
1409
+ f"perpustakaan khusus = {int(j.get('khusus',0))}, "
1410
+ f"total = {int(j.get('total',0))}."
1411
+ )
1412
+
1413
+ outpath = tempfile.mktemp(suffix=".docx")
1414
+ doc.save(outpath)
1415
+ return outpath
1416
+
1417
+
1418
+ # ============================================================
1419
+ # 15) CORE RUN
1420
+ # ============================================================
1421
+
1422
+ def _empty_outputs(msg="Data belum siap."):
1423
+ empty = pd.DataFrame()
1424
+ empty_fig = go.Figure()
1425
+ return (
1426
+ "", # kpi_md
1427
+ empty, empty, empty, empty, empty,
1428
+ None, None, None, None, None,
1429
+ empty_fig, empty_fig, empty_fig,
1430
+ msg, # msg
1431
+ "LLM belum tersedia.", # status llm
1432
+ None # word path
1433
+ )
1434
+
1435
+ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta):
1436
+ try:
1437
+ if df_all is None or df_all.empty or df_raw is None or df_raw.empty:
1438
+ return _empty_outputs("Data belum ter-load. Pastikan file tersedia.")
1439
+
1440
+ # Filter
1441
+ df = df_all.copy()
1442
+ if prov_value and prov_value != "(Semua)":
1443
+ df = df[df["PROV_DISP"] == prov_value]
1444
+ if kab_value and kab_value != "(Semua)":
1445
+ df = df[df["KAB_DISP"] == kab_value]
1446
+ if kew_value and kew_value != "(Semua)":
1447
+ df = df[df["KEW_NORM"] == kew_value]
1448
+ if df.empty:
1449
+ return _empty_outputs("Tidak ada data untuk filter ini.")
1450
+
1451
+ kew_norm = kew_value if (kew_value and kew_value != "(Semua)") else "(Semua)"
1452
+ faktor_wilayah_jenis = build_faktor_wilayah_jenis(df, pop_kab, pop_prov, pop_khusus, kew_norm)
1453
+ agg_jenis_full = build_agg_wilayah_jenis(df, faktor_wilayah_jenis, kew_norm)
1454
+ agg_total = build_agg_wilayah_total_from_jenis(agg_jenis_full, faktor_wilayah_jenis, kew_norm)
1455
+
1456
+ summary_jenis = build_summary_per_jenis(agg_jenis_full, agg_total)
1457
+ verif_total = build_verif_jenis(faktor_wilayah_jenis, kew_norm)
1458
+ detail_view = attach_final_to_detail(df, agg_total, meta, kew_norm)
1459
+
1460
+ # agg_jenis view (UI hanya sampai indeks dasar)
1461
+ if agg_jenis_full is None or agg_jenis_full.empty:
1462
+ agg_jenis_view = agg_jenis_full
1463
+ else:
1464
+ kew_norm2 = str(kew_norm).upper()
1465
+ label_name = "Kab/Kota" if ("KAB" in kew_norm2 or "KOTA" in kew_norm2) else ("Provinsi" if "PROV" in kew_norm2 else "Kab/Kota")
1466
+ cols_upto = [
1467
+ "group_key",
1468
+ label_name,
1469
+ "Jenis",
1470
+ "Jumlah",
1471
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
1472
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
1473
+ "Indeks_Dasar_Agregat_0_100",
1474
+ ]
1475
+ cols_upto = [c for c in cols_upto if c in agg_jenis_full.columns]
1476
+ agg_jenis_view = agg_jenis_full[cols_upto].copy()
1477
+
1478
+ # RAW download (hasil filter)
1479
+ raw = df_raw.copy()
1480
+ if prov_value and prov_value != "(Semua)":
1481
+ raw = raw[raw["PROV_DISP"] == prov_value]
1482
+ if kab_value and kab_value != "(Semua)":
1483
+ raw = raw[raw["KAB_DISP"] == kab_value]
1484
+ if kew_value and kew_value != "(Semua)":
1485
+ raw = raw[raw["KEW_NORM"] == kew_value]
1486
+
1487
+ # Bell curve per jenis
1488
+ if detail_view is None or detail_view.empty:
1489
+ fig_umum = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Umum")
1490
+ fig_sekolah = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Sekolah")
1491
+ fig_khusus = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Khusus")
1492
+ else:
1493
+ hover_cols = [hc for hc in ["Provinsi", "Kab/Kota", "Jenis"] if hc in detail_view.columns]
1494
+
1495
+ def _fig(j):
1496
+ d = detail_view[detail_view["Jenis"].astype(str).str.lower() == j].copy()
1497
+ return _make_bell_curve_entitas(
1498
+ d,
1499
+ title=f"Bell Curve — Jenis: {j.title()} (Skor: Indeks_Dasar_0_100)",
1500
+ xcol="Indeks_Dasar_0_100",
1501
+ label_col=("nm_perpustakaan" if "nm_perpustakaan" in d.columns else "nm_perpustakaan"),
1502
+ hover_cols=hover_cols,
1503
+ min_points=2
1504
+ )
1505
+
1506
+ fig_sekolah = _fig("sekolah")
1507
+ fig_umum = _fig("umum")
1508
+ fig_khusus = _fig("khusus")
1509
+
1510
+ kpi_md = build_kpi_markdown(summary_jenis)
1511
+
1512
+ # Export xlsx
1513
+ tmpdir = tempfile.mkdtemp()
1514
+ prov_slug = (_canon(prov_value or "SEMUA").upper() or "SEMUA")
1515
+ kab_slug = (_canon(kab_value or "SEMUA").upper() or "SEMUA")
1516
+ kew_slug = (_canon(kew_value or "SEMUA").upper() or "SEMUA")
1517
+
1518
+ p_summary = str(Path(tmpdir) / f"IPLM_RingkasanJenisKeseluruhan_33_88_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1519
+ p_total = str(Path(tmpdir) / f"IPLM_AgregatWilayah_Keseluruhan_33_88_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1520
+ p_raw = str(Path(tmpdir) / f"IPLM_RAW_DATA_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1521
+ p_detail = str(Path(tmpdir) / f"IPLM_DetailEntitas_FinalMenempelWilayah_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1522
+ p_verif = str(Path(tmpdir) / f"IPLM_KecukupanSampel_33_88_{prov_slug}_{kab_slug}_{kew_slug}.xlsx")
1523
+
1524
+ summary_jenis.to_excel(p_summary, index=False)
1525
+ agg_total.to_excel(p_total, index=False)
1526
+ raw.to_excel(p_raw, index=False)
1527
+ detail_view.to_excel(p_detail, index=False)
1528
+ verif_total.to_excel(p_verif, index=False)
1529
+
1530
+ # ====== Word tabel interpretasi & rekomendasi ======
1531
+ wilayah_txt = kab_value if (kab_value and kab_value != "(Semua)") else (prov_value if (prov_value and prov_value != "(Semua)") else "Nasional/All")
1532
+ header, rows = build_interpretasi_table_values(agg_total, wilayah_txt, TARGET_RATIO)
1533
+
1534
+ # jumlah perpustakaan sumber data (gambar 2)
1535
+ jumlah_perpus = summarize_jumlah_perpus_dari_agg_jenis(agg_jenis_full, wilayah_txt, kew_norm)
1536
+
1537
+ rows_filled, llm_status = llm_fill_interpretasi_rekomendasi(
1538
+ header=header,
1539
+ rows=rows,
1540
+ wilayah_label=wilayah_txt,
1541
+ kew_label=(kew_value or "(Semua)"),
1542
+ jumlah_perpus_by_jenis=jumlah_perpus
1543
+ )
1544
+ word_path = generate_word_table_interpretasi(header, rows_filled, wilayah_txt, jumlah_perpus)
1545
+
1546
+ msg = (
1547
+ f"Selesai (TARGET {TARGET_RATIO*100:.2f}%): raw={len(raw)} | entitas={len(detail_view)} | "
1548
+ f"wilayah(keseluruhan)={len(agg_total)} | jenis(agregat)={len(agg_jenis_full)}"
1549
+ + ("" if DOCX_AVAILABLE else " | python-docx tidak tersedia (Word OFF)")
1550
+ )
1551
+
1552
+ return (
1553
+ kpi_md,
1554
+ summary_jenis, agg_total, agg_jenis_view, detail_view, verif_total,
1555
+ p_summary, p_total, p_raw, p_detail, p_verif,
1556
+ fig_umum, fig_sekolah, fig_khusus,
1557
+ msg,
1558
+ llm_status,
1559
+ (word_path if word_path else None)
1560
+ )
1561
+
1562
+ except Exception as e:
1563
+ return _empty_outputs(f"Runtime error: {repr(e)}")
1564
+
1565
+
1566
+ # ============================================================
1567
+ # 16) UI (NO UPLOAD)
1568
+ # ============================================================
1569
+
1570
+ def ui_load(force=False):
1571
+ df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info = load_default_files(force=force)
1572
+ if df_all is None or (isinstance(df_all, pd.DataFrame) and df_all.empty):
1573
+ return (
1574
+ None, None, None, None, None, {}, info,
1575
+ gr.update(choices=["(Semua)"], value="(Semua)"),
1576
+ gr.update(choices=["(Semua)"], value="(Semua)"),
1577
+ gr.update(choices=["(Semua)"], value="(Semua)"),
1578
+ )
1579
+
1580
+ prov_vals = df_all["PROV_DISP"].dropna().astype(str).tolist()
1581
+ prov_vals = [v for v in prov_vals if v and v.strip()]
1582
+ prov_choices = ["(Semua)"] + sorted(set(prov_vals))
1583
+
1584
+ kab_choices = ["(Semua)"] + sorted([x for x in df_all["KAB_DISP"].dropna().unique().tolist() if x])
1585
+ kew_choices = ["(Semua)"] + sorted([x for x in df_all["KEW_NORM"].dropna().unique().tolist() if x])
1586
+ default_kew = "KAB/KOTA" if "KAB/KOTA" in kew_choices else ("PROVINSI" if "PROVINSI" in kew_choices else "(Semua)")
1587
+
1588
+ return (
1589
+ df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info,
1590
+ gr.update(choices=prov_choices, value="(Semua)"),
1591
+ gr.update(choices=kab_choices, value="(Semua)"),
1592
+ gr.update(choices=kew_choices, value=default_kew),
1593
+ )
1594
+
1595
+ def on_prov_change(prov_value):
1596
+ df_all, _, _, _, _, _, _ = load_default_files(force=False)
1597
+ if df_all is None or df_all.empty:
1598
+ return gr.update(choices=["(Semua)"], value="(Semua)")
1599
+ if prov_value is None or prov_value == "(Semua)":
1600
+ vals = df_all["KAB_DISP"].dropna().unique().tolist()
1601
+ else:
1602
+ vals = df_all.loc[df_all["PROV_DISP"] == prov_value, "KAB_DISP"].dropna().unique().tolist()
1603
+ vals = sorted([v for v in vals if v])
1604
+ return gr.update(choices=["(Semua)"] + vals, value="(Semua)")
1605
+
1606
+
1607
+ with gr.Blocks() as demo:
1608
+ gr.Markdown(f"""
1609
+ # IPLM 2025 — Final (Target Sampel 33.88% per Jenis) — TANPA Kinerja Relatif / Percentile
1610
+ Mode NO UPLOAD (cache aktif). File dibaca dari repo/server:
1611
+ - DATA_FILE = {DATA_FILE}
1612
+ - POP_KAB = {POP_KAB}
1613
+ - POP_PROV = {POP_PROV}
1614
+ - POP_KHUSUS = {POP_KHUSUS}
1615
+
1616
+ TARGET RATIO (per jenis): {TARGET_RATIO*100:.2f}%
1617
+
1618
+ Dashboard KPI:
1619
+ - Indeks IPLM FINAL (disesuaikan 33.88%)
1620
+ - Indeks Dasar (tanpa penyesuaian)
1621
+
1622
+ UPDATE LLM + WORD:
1623
+ - Tabel Word "Interpretasi & Rekomendasi" memakai NILAI APA ADANYA (tanpa dikali 100) untuk sub/dim.
1624
+ - Baris "Nilai IPLM" memakai Indeks_Final_Wilayah_0_100 apa adanya.
1625
+ - Di bawah tabel Word ditambahkan ringkasan jumlah perpustakaan sumber data (sekolah/umum/khusus/total) dari tabel agregat wilayah × jenis.
1626
+ """)
1627
+
1628
+ state_df = gr.State(None)
1629
+ state_raw = gr.State(None)
1630
+ state_pop_kab = gr.State(None)
1631
+ state_pop_prov = gr.State(None)
1632
+ state_pop_khusus = gr.State(None)
1633
+ state_meta = gr.State({})
1634
+
1635
+ info_box = gr.Markdown()
1636
+
1637
+ with gr.Row():
1638
+ dd_prov = gr.Dropdown(label="Provinsi", choices=["(Semua)"], value="(Semua)")
1639
+ dd_kab = gr.Dropdown(label="Kab/Kota", choices=["(Semua)"], value="(Semua)")
1640
+ dd_kew = gr.Dropdown(label="Kewenangan", choices=["(Semua)"], value="(Semua)")
1641
+
1642
+ dd_prov.change(fn=on_prov_change, inputs=[dd_prov], outputs=dd_kab)
1643
+
1644
+ run_btn = gr.Button("Jalankan Perhitungan")
1645
+ msg_out = gr.Markdown()
1646
+
1647
+ kpi_out = gr.Markdown()
1648
+
1649
+ gr.Markdown("## Ringkasan (Jenis + Keseluruhan)")
1650
+ out_summary = gr.DataFrame(interactive=False)
1651
+
1652
+ gr.Markdown("## Agregat Wilayah (Keseluruhan) — FIX avg3")
1653
+ out_agg_total = gr.DataFrame(interactive=False)
1654
+
1655
+ gr.Markdown("## Agregat Wilayah x Jenis — (ditampilkan sampai Indeks Dasar)")
1656
+ out_agg_jenis = gr.DataFrame(interactive=False)
1657
+
1658
+ gr.Markdown("## Detail Entitas (Final menempel dari wilayah)")
1659
+ out_detail = gr.DataFrame(interactive=False)
1660
+
1661
+ gr.Markdown("## Kecukupan Sampel 33.88%")
1662
+ out_verif = gr.DataFrame(interactive=False)
1663
+
1664
+ gr.Markdown("## Bell Curve — Indeks Dasar per Entitas (per Jenis) + Nama Perpustakaan")
1665
+ gr.Markdown("### Perpustakaan Umum")
1666
+ bell_umum = gr.Plot(scale=1)
1667
+
1668
+ gr.Markdown("### Perpustakaan Sekolah")
1669
+ bell_sekolah = gr.Plot(scale=1)
1670
+
1671
+ gr.Markdown("### Perpustakaan Khusus")
1672
+ bell_khusus = gr.Plot(scale=1)
1673
+
1674
+ gr.Markdown("## Status LLM (Isi Interpretasi & Rekomendasi)")
1675
+ llm_status_out = gr.Markdown()
1676
+
1677
+ with gr.Row():
1678
+ dl_summary = gr.DownloadButton(label="Download Ringkasan (.xlsx)")
1679
+ dl_total = gr.DownloadButton(label="Download Agregat Wilayah (.xlsx)")
1680
+ dl_raw = gr.DownloadButton(label="Download Data Mentah (.xlsx)")
1681
+ dl_detail = gr.DownloadButton(label="Download Detail Entitas (.xlsx)")
1682
+ dl_verif = gr.DownloadButton(label="Download Kecukupan Sampel (.xlsx)")
1683
+ dl_word = gr.DownloadButton(label="Download Word: Interpretasi & Rekomendasi (.docx)" if DOCX_AVAILABLE else "Download Word (OFF)")
1684
+
1685
+ run_btn.click(
1686
+ fn=run_calc,
1687
+ inputs=[dd_prov, dd_kab, dd_kew, state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta],
1688
+ outputs=[
1689
+ kpi_out,
1690
+ out_summary, out_agg_total, out_agg_jenis, out_detail, out_verif,
1691
+ dl_summary, dl_total, dl_raw, dl_detail, dl_verif,
1692
+ bell_umum, bell_sekolah, bell_khusus,
1693
+ msg_out,
1694
+ llm_status_out,
1695
+ dl_word
1696
+ ]
1697
+ )
1698
+
1699
+ demo.load(
1700
+ fn=lambda: ui_load(force=False),
1701
+ inputs=[],
1702
+ outputs=[state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta, info_box, dd_prov, dd_kab, dd_kew]
1703
+ )
1704
+
1705
+ demo.launch()
gitattributes ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALL.xlsx filter=lfs diff=lfs merge=lfs -text
2
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALL190226.xlsx filter=lfs diff=lfs merge=lfs -text
3
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALL200226.xlsx filter=lfs diff=lfs merge=lfs -text
4
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALLL.xlsx filter=lfs diff=lfs merge=lfs -text
5
+ DATA[[:space:]]IPLM[[:space:]]FINAL_CLEAN_pusat.xlsx filter=lfs diff=lfs merge=lfs -text
6
+ DATA[[:space:]]IPLM[[:space:]]FINAL_CLEAN.xlsx filter=lfs diff=lfs merge=lfs -text
7
+ DATA_iplm_final_05022026_update1.xlsx filter=lfs diff=lfs merge=lfs -text
8
+ DATA_iplm_final_05022026.xlsx filter=lfs diff=lfs merge=lfs -text
9
+ DATA_IPLM_FINAL_CLEAN_pusat_15012026.xlsx filter=lfs diff=lfs merge=lfs -text
10
+ File_IPLM_090226_All.xlsx filter=lfs diff=lfs merge=lfs -text
11
+ IPLM_clean_manual_131225.xlsx filter=lfs diff=lfs merge=lfs -text
12
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALL200226[[:space:]]+[[:space:]]TORAJA[[:space:]]UTARA.xlsx filter=lfs diff=lfs merge=lfs -text
gitattributes (1) ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ DM[[:space:]](1).xlsx filter=lfs diff=lfs merge=lfs -text
37
+ DM.xlsx filter=lfs diff=lfs merge=lfs -text
38
+ DM[[:space:]](3).xlsx filter=lfs diff=lfs merge=lfs -text
39
+ data_iplm_clean_complete.xlsx filter=lfs diff=lfs merge=lfs -text
40
+ DMx.xlsx filter=lfs diff=lfs merge=lfs -text
41
+ DATA_IPLM_clean_SNP_jenis[[:space:]](2).xlsx filter=lfs diff=lfs merge=lfs -text
42
+ DATA_IPLM_clean_SNP_jenis.xlsx filter=lfs diff=lfs merge=lfs -text
43
+ IPLM_clean_manual_131225.xlsx filter=lfs diff=lfs merge=lfs -text
44
+ DATA[[:space:]]IPLM[[:space:]]FINAL_CLEAN.xlsx filter=lfs diff=lfs merge=lfs -text
45
+ DATA[[:space:]]IPLM[[:space:]]FINAL_CLEAN_pusat.xlsx filter=lfs diff=lfs merge=lfs -text
46
+ DATA_IPLM_FINAL_CLEAN_pusat_12012026.xlsx filter=lfs diff=lfs merge=lfs -text
47
+ DATA_IPLM_FINAL_CLEAN_pusat_15012026.xlsx filter=lfs diff=lfs merge=lfs -text
48
+ DATA_IPLM_FINAL_CLEAN_pusat_15012026[[:space:]]HASIL[[:space:]]SEMENTARA.xlsx filter=lfs diff=lfs merge=lfs -text
49
+ DATA_IPLM_FINAL_CLEAN_pusat_15012026x.xlsx filter=lfs diff=lfs merge=lfs -text
50
+ DATA_IPLM_GABUNGAN_DEDUP_FIX_SUPERGALAK.xlsx filter=lfs diff=lfs merge=lfs -text
51
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH.xlsx filter=lfs diff=lfs merge=lfs -text
52
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]](1).xlsx filter=lfs diff=lfs merge=lfs -text
53
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]](2).xlsx filter=lfs diff=lfs merge=lfs -text
54
+ DATA_iplm_final_05022026.xlsx filter=lfs diff=lfs merge=lfs -text
55
+ DATA_iplm_final_05022026_update1.xlsx filter=lfs diff=lfs merge=lfs -text
56
+ File_IPLM_090226_All.xlsx filter=lfs diff=lfs merge=lfs -text
57
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALL.xlsx filter=lfs diff=lfs merge=lfs -text
58
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALLL.xlsx filter=lfs diff=lfs merge=lfs -text
59
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALL190226.xlsx filter=lfs diff=lfs merge=lfs -text
60
+ DATA[[:space:]]CLEAN[[:space:]]GABUNGAN[[:space:]]SANGGAH-TIDAK[[:space:]]SANGGAH[[:space:]]-[[:space:]]ALL200226.xlsx filter=lfs diff=lfs merge=lfs -text
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ pandas
2
+ numpy
3
+ gradio
4
+ python-docx
5
+ openpyxl
6
+ huggingface-hub
7
+ scikit-learn
8
+ plotly
9
+ matplotlib