# -*- coding: utf-8 -*- """ Mesin pengukuran ketercapaian CPL/CPMK (inti OBE). Kebijakan (RANCANGAN.md §7, disepakati 13 Jul 2026): - Nilai dicatat per mahasiswa per Sub-CPMK (mengikuti format contoh portofolio); mode 'agregat' (rata-rata kelas per Sub-CPMK) diperbolehkan tapi ditandai. - Roll-up dalam MK : Sub-CPMK → CPMK → CPL, rata-rata sederhana (bagi rata). - Roll-up prodi : CPMK prodi = rata-rata lintas MK pengukurnya; CPL = rata-rata CPMK pendukungnya. Opsi tertimbang SKS tersedia via pengaturan 'metode_antar_mk' = 'sks' (butuh data SKS; tanpa data SKS jatuh ke bagi rata). - % kelulusan = persentase mahasiswa dengan skor ≥ ambang (hanya mode per-mahasiswa; mode agregat tidak menghasilkan % kelulusan). """ import statistics from . import db def _rata(xs): xs = [x for x in xs if x is not None] return sum(xs) / len(xs) if xs else None def parse_skala(teks: str) -> list[dict]: """'A+|91|100|4.00' per baris → [{'huruf','lo','hi','angka'}] terurut turun.""" out = [] for line in (teks or "").splitlines(): bag = [b.strip() for b in line.split("|")] if len(bag) >= 3: try: out.append({ "huruf": bag[0], "lo": float(bag[1]), "hi": float(bag[2]), "angka": float(bag[3]) if len(bag) > 3 else None, }) except ValueError: continue return sorted(out, key=lambda s: -s["lo"]) def huruf_mutu(skor: float, skala: list[dict]) -> str: for s in skala: if s["lo"] <= skor <= s["hi"]: return s["huruf"] return "?" def skor_sub(nilai_data: dict, ambang: float) -> dict: """Skor per Sub-CPMK dari data nilai tersimpan. nilai_data = {"mode": "per_mahasiswa"|"agregat", "mahasiswa": [{"nim","nama","skor":{sub: nilai}}] | "agregat": {sub: rata}} → {sub: {"rata": x, "n": jumlah_mhs, "lulus_pct": y|None}} """ out = {} if nilai_data.get("mode") == "per_mahasiswa": per_sub: dict[str, list[float]] = {} for m in nilai_data.get("mahasiswa", []): for sub, v in (m.get("skor") or {}).items(): if v is not None: per_sub.setdefault(sub, []).append(float(v)) for sub, xs in per_sub.items(): out[sub] = { "rata": _rata(xs), "n": len(xs), "lulus_pct": 100.0 * sum(1 for x in xs if x >= ambang) / len(xs), } else: for sub, v in (nilai_data.get("agregat") or {}).items(): if v in (None, ""): continue out[sub] = {"rata": float(v), "n": None, "lulus_pct": None} return out def bobot_sub_dari_rps(rps_data: dict | None) -> dict[str, float]: """Bobot (%) tiap Sub-CPMK = jumlah porsi bagi-rata dari komponen RPS (kisi-kisi).""" bobot: dict[str, float] = {} if not rps_data: return bobot for k in rps_data.get("komponen", []): subs = k.get("subs", []) try: b = float(k.get("bobot") or 0) except (TypeError, ValueError): continue for s in subs: bobot[s] = bobot.get(s, 0.0) + b / len(subs) return bobot def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[str, float]]: """nim -> {sub: skor}, dihitung dari log sesi input per-komponen (tombol 'isi per komponen' di halaman nilai): rata sederhana antar sesi dalam komponen yang sama (mis. beberapa kali Kuis), lalu rata tertimbang bobot antar-komponen bila satu Sub-CPMK diukur oleh >1 komponen (mis. Kuis dan Presentasi sama-sama menilai satu Sub-CPMK) — bobot mengikuti alokasi yang sama dengan bobot_sub_dari_rps (bobot komponen dibagi rata ke subnya). Tiap sesi boleh punya "subs" sendiri (subset Sub-CPMK komponen itu — dipakai komponen Observasi/Tugas untuk rincian ala RTM per tugas bernama); sesi tanpa "subs" (atau kosong) dianggap menyasar semua Sub-CPMK komponen itu (perilaku lama). Dengan begitu satu komponen boleh diisi lewat beberapa sesi yang masing-masing hanya menyasar sebagian Sub-CPMK, tanpa menyamaratakan nilainya ke Sub-CPMK lain yang tak disasar sesi itu. """ komponen_by_i = {k["i"]: k for k in komponen_daftar} kontribusi_per_sub: dict[str, list[tuple[float, dict]]] = {} for i, k in komponen_by_i.items(): daftar_sesi = (sesi or {}).get(i) or [] if not daftar_sesi: continue try: bobot = float(k.get("bobot") or 0) except (TypeError, ValueError): bobot = 0 n_subs = len(k["subs"]) or 1 w = bobot / n_subs if bobot else 1.0 for sub in k["subs"]: relevan = [s for s in daftar_sesi if sub in (s.get("subs") or k["subs"])] if not relevan: continue per_nim: dict[str, list[float]] = {} for s in relevan: for nim, v in (s.get("nilai") or {}).items(): if v is not None: per_nim.setdefault(nim, []).append(float(v)) avg_map = {nim: _rata(xs) for nim, xs in per_nim.items()} kontribusi_per_sub.setdefault(sub, []).append((w, avg_map)) out: dict[str, dict[str, float]] = {} for sub, kontribusi in kontribusi_per_sub.items(): nims: set[str] = set() for _, avg_map in kontribusi: nims |= avg_map.keys() for nim in nims: pasangan = [(w, avg_map[nim]) for w, avg_map in kontribusi if nim in avg_map] total_w = sum(w for w, _ in pasangan) nilai = (sum(w * v for w, v in pasangan) / total_w) if total_w \ else _rata([v for _, v in pasangan]) out.setdefault(nim, {})[sub] = nilai return out def nilai_akhir_mahasiswa(nilai_data: dict, bobot_sub: dict[str, float]) -> list[dict]: """Nilai akhir tertimbang bobot Sub-CPMK per mahasiswa (mode per_mahasiswa).""" out = [] for m in nilai_data.get("mahasiswa", []): skor = m.get("skor") or {} pasangan = [(bobot_sub.get(s), v) for s, v in skor.items() if v is not None] berbobot = [(b, v) for b, v in pasangan if b] if berbobot: total_b = sum(b for b, _ in berbobot) akhir = sum(b * v for b, v in berbobot) / total_b else: # RPS belum mengisi bobot → rata-rata sederhana akhir = _rata([v for _, v in pasangan]) if akhir is not None: out.append({"nim": m.get("nim", ""), "nama": m.get("nama", ""), "akhir": akhir}) return out def statistik_akhir(akhir: list[dict], skala: list[dict]) -> dict | None: xs = [a["akhir"] for a in akhir] if not xs: return None frek = {s["huruf"]: 0 for s in skala} for x in xs: h = huruf_mutu(x, skala) frek[h] = frek.get(h, 0) + 1 return { "n": len(xs), "mean": statistics.mean(xs), "median": statistics.median(xs), "stdev": statistics.stdev(xs) if len(xs) > 1 else 0.0, "min": min(xs), "max": max(xs), "range": max(xs) - min(xs), "frekuensi": frek, } def rollup_mk(peta: dict, nilai_data: dict, ambang: float) -> dict: """Ketercapaian satu MK: per Sub-CPMK → CPMK → CPL (bagi rata).""" sub = skor_sub(nilai_data, ambang) cpmk: dict[str, dict] = {} for c in peta["cpmks"]: skor = _rata([sub[s["kode"]]["rata"] for s in c["subs"] if s["kode"] in sub]) n_ternilai = sum(1 for s in c["subs"] if s["kode"] in sub) cpmk[c["kode"]] = {"skor": skor, "cpl": c["cpl"], "n_sub": len(c["subs"]), "n_ternilai": n_ternilai} cpl: dict[str, dict] = {} for cp in peta["cpls"]: pendukung = [v for v in cpmk.values() if cp["kode"] in v["cpl"]] ternilai = [v for v in pendukung if v["skor"] is not None] skor = _rata([v["skor"] for v in ternilai]) cpl[cp["kode"]] = {"skor": skor, "n_cpmk": len(pendukung), "n_cpmk_ternilai": len(ternilai)} return {"sub": sub, "cpmk": cpmk, "cpl": cpl, "mode": nilai_data.get("mode", "agregat")} def sub_terurut(peta: dict, rollup_sub: dict) -> list[dict]: """Sub-CPMK ternilai, terurut naik dari yang terlemah — bahan analisis proses asesmen di Portofolio (mana yang paling perlu perhatian).""" out = [] for s in peta["subs"]: info = rollup_sub.get(s["kode"]) if info and info["rata"] is not None: out.append({"kode": s["kode"], "uraian": s["uraian"], "rata": info["rata"], "lulus_pct": info["lulus_pct"]}) return sorted(out, key=lambda s: s["rata"]) def kesenjangan_rencana_realisasi(bobot_sub: dict[str, float], rollup_sub: dict) -> list[str]: """Sub-CPMK yang diberi bobot penilaian di RPS tapi belum ada nilai masuk sama sekali — kesenjangan antara rencana dan realisasi asesmen.""" return sorted( s for s in bobot_sub if bobot_sub[s] and (s not in rollup_sub or rollup_sub[s]["rata"] is None) ) def mahasiswa_berisiko(nilai_data: dict, akhir: list[dict], ambang: float, min_sub_dibawah: int = 2) -> list[dict]: """Mahasiswa dengan >= min_sub_dibawah Sub-CPMK di bawah ambang, atau nilai akhir tertimbangnya sendiri di bawah ambang (early-warning list).""" if nilai_data.get("mode") != "per_mahasiswa": return [] akhir_by_nim = {a["nim"]: a["akhir"] for a in akhir} out = [] for m in nilai_data.get("mahasiswa", []): skor = {s: v for s, v in (m.get("skor") or {}).items() if v is not None} if not skor: continue n_dibawah = sum(1 for v in skor.values() if v < ambang) nim = m.get("nim", "") nilai_akhir = akhir_by_nim.get(nim) if n_dibawah >= min_sub_dibawah or (nilai_akhir is not None and nilai_akhir < ambang): out.append({"nim": nim, "nama": m.get("nama", ""), "n_dibawah": n_dibawah, "total_sub": len(skor), "akhir": nilai_akhir}) return sorted(out, key=lambda m: (-m["n_dibawah"], m["akhir"] if m["akhir"] is not None else 999)) def tren_mk(kode: str, daftar_tahun: list[str], ambang: float) -> list[dict]: """Rata-rata nilai akhir & % kelulusan MK ini per tahun ajaran yang berdata — untuk melihat apakah capaian membaik atau tidak antar tahun.""" out = [] for t in daftar_tahun: nilai_t = db.ambil_dok("nilai", kode, t) if not nilai_t: continue nilai_data_t = nilai_t["data"] rps_t = db.ambil_rps(kode, t) bobot_t = bobot_sub_dari_rps(rps_t["data"] if rps_t else None) if nilai_data_t.get("mode") == "per_mahasiswa": akhir_t = nilai_akhir_mahasiswa(nilai_data_t, bobot_t) if akhir_t: xs = [a["akhir"] for a in akhir_t] out.append({"tahun": t, "mean": _rata(xs), "lulus_pct": 100.0 * sum(1 for x in xs if x >= ambang) / len(xs), "n": len(xs)}) continue sub_t = skor_sub(nilai_data_t, ambang) nilai_ada = [v["rata"] for v in sub_t.values() if v["rata"] is not None] if nilai_ada: out.append({"tahun": t, "mean": _rata(nilai_ada), "lulus_pct": None, "n": None}) return out def kelompok_capaian(akhir: list[dict], statistik: dict | None) -> dict | None: """Kelompokkan mahasiswa jadi Atas/Tengah/Bawah berdasarkan mean ± 1 SD (acuan norma, lazim dipakai dalam evaluasi pendidikan) — pelengkap 'mahasiswa berisiko' yang acuannya ambang kelulusan tetap, bukan sebaran kelas ini.""" if not statistik or not akhir: return None batas_bawah = statistik["mean"] - statistik["stdev"] batas_atas = statistik["mean"] + statistik["stdev"] atas, tengah, bawah = [], [], [] for a in akhir: if a["akhir"] > batas_atas: atas.append(a) elif a["akhir"] < batas_bawah: bawah.append(a) else: tengah.append(a) return { "batas_bawah": batas_bawah, "batas_atas": batas_atas, "atas": sorted(atas, key=lambda a: -a["akhir"]), "tengah": sorted(tengah, key=lambda a: -a["akhir"]), "bawah": sorted(bawah, key=lambda a: -a["akhir"]), } def saran_simpulan(akhir: list[dict], statistik: dict | None, sub_urut: list[dict], kesenjangan: list[str], berisiko: list[dict], tren: list[dict], ambang: float) -> str: """Rangkai draf narasi Simpulan Hasil Evaluasi dari hasil Analisis Proses Asesmen — dipakai sebagai placeholder awal, dosen tetap yang menulis versi finalnya (termasuk simpulan ketercapaian CPL/CPMK dan tindak lanjut).""" bagian = [] if statistik: lulus_pct = 100.0 * sum(1 for a in akhir if a["akhir"] >= ambang) / len(akhir) bagian.append( f"Rata-rata nilai akhir {statistik['mean']:.1f} (median {statistik['median']:.1f}) " f"dari {statistik['n']} mahasiswa; {lulus_pct:.0f}% mencapai ambang {ambang:.0f}." ) if sub_urut: terlemah, terkuat = sub_urut[0], sub_urut[-1] if terlemah["kode"] != terkuat["kode"]: bagian.append( f"Capaian terendah pada {terlemah['kode']} (skor {terlemah['rata']:.1f}), " f"tertinggi pada {terkuat['kode']} (skor {terkuat['rata']:.1f})." ) else: bagian.append(f"Satu-satunya Sub-CPMK ternilai: {terlemah['kode']} (skor {terlemah['rata']:.1f}).") if kesenjangan: bagian.append( f"{len(kesenjangan)} Sub-CPMK direncanakan di RPS namun belum ternilai: " f"{', '.join(kesenjangan)}." ) if berisiko: bagian.append(f"{len(berisiko)} mahasiswa teridentifikasi berisiko (nilai rendah di beberapa Sub-CPMK).") if len(tren) > 1: sekarang, sebelum = tren[-1], tren[-2] if sekarang["mean"] is not None and sebelum["mean"] is not None: selisih = sekarang["mean"] - sebelum["mean"] arah = "naik" if selisih > 0 else ("turun" if selisih < 0 else "stabil") bagian.append( f"Dibanding {sebelum['tahun']} ({sebelum['mean']:.1f}), rata-rata {arah} " f"{abs(selisih):.1f} poin di {sekarang['tahun']}." ) bagian.append( "Lengkapi dengan simpulan ketercapaian CPL/CPMK terkait dan rencana tindak lanjut " "untuk siklus berikutnya." ) return " ".join(bagian) def rollup_prodi(tahun: str | None = None) -> dict: """Ketercapaian tingkat prodi + kelengkapan bukti per CPL/CPMK.""" tahun = tahun or db.tahun_aktif() pengaturan = db.ambil_pengaturan() ambang = float(pengaturan["ambang"]) pakai_sks = pengaturan.get("metode_antar_mk") == "sks" sks = db.sks_mk() if pakai_sks else {} k = db.kurikulum() semua = db.semua_nilai(tahun) def rata_antar_mk(entri): """Skor CPMK lintas MK: bagi rata, atau tertimbang SKS bila diatur.""" if not entri: return None if pakai_sks and sks: bobot = [(float(sks.get(mk, {}).get("sks") or 0), s) for mk, s, _ in entri] total = sum(b for b, _ in bobot if b) if total: return sum(b * s for b, s in bobot if b) / total return _rata([s for _, s, _ in entri]) # skor CPMK per MK per_cpmk: dict[str, list[tuple[str, float, str]]] = {} # key cpmk → [(mk, skor, mode)] for mk_kode, nilai_data in semua.items(): peta = db.peta_mk(mk_kode) if not peta: continue hasil = rollup_mk(peta, nilai_data, ambang) for kode_cpmk, v in hasil["cpmk"].items(): if v["skor"] is not None: per_cpmk.setdefault(kode_cpmk, []).append( (mk_kode, v["skor"], hasil["mode"]) ) # CPMK prodi = bagi rata lintas MK pengukur; kelengkapan = pengukur/pemikul cpmk_prodi: dict[str, dict] = {} for ckey, info in k["cpmk"].items(): kode = info.get("kode", ckey) pemikul = info.get("mk", []) entri = per_cpmk.get(kode, []) cpmk_prodi[kode] = { "kode": kode, "cpl": info.get("cpl", []), "skor": rata_antar_mk(entri), "n_pemikul": len(pemikul), "n_setor": len(entri), "ada_agregat": any(m == "agregat" for _, _, m in entri), "mk_terlemah": min(entri, key=lambda e: e[1])[:2] if entri else None, } # CPL = bagi rata CPMK pendukung cpl_prodi: dict[str, dict] = {} for kode_cpl, info in k["cpl"].items(): pendukung = [c for c in cpmk_prodi.values() if kode_cpl in c["cpl"]] ternilai = [c for c in pendukung if c["skor"] is not None] skor = _rata([c["skor"] for c in ternilai]) terlemah = min(ternilai, key=lambda c: c["skor"]) if ternilai else None cpl_prodi[kode_cpl] = { "kode": kode_cpl, "desk": info["desk"], "skor": skor, "tercapai": (skor is not None and skor >= ambang), "n_cpmk": len(pendukung), "n_cpmk_ternilai": len(ternilai), "n_mk_pemikul": sum(c["n_pemikul"] for c in pendukung), "n_mk_setor": sum(c["n_setor"] for c in pendukung), "ada_agregat": any(c["ada_agregat"] for c in ternilai), "cpmk_terlemah": terlemah["kode"] if terlemah else None, "mk_terlemah": (terlemah["mk_terlemah"] if terlemah else None), } return {"cpl": cpl_prodi, "cpmk": cpmk_prodi, "ambang": ambang, "n_mk_setor": len(semua), "tahun": tahun, "metode": "sks" if (pakai_sks and sks) else "rata"}