| """ |
| ํ๊ต ์ง์ ๊ฐ๋ฅ์ฑ ์ ์(Sustainability Score) ์ฐ์ถ ๋ชจ๋. |
| |
| KEDI(ํ๊ตญ๊ต์ก๊ฐ๋ฐ์) ๊ณตํต์งํ๋ฅผ ๋ฐํ์ผ๋ก 4๊ฐ ์์ญ์ ๊ฐ์ค ํฉ์ฐ ์ ์๋ฅผ ๊ณ์ฐํฉ๋๋ค. |
| |
| ๊ฐ์ค์น: |
| ๊ต์ก๊ณผ์ ์ด์ (curriculum) : 45% |
| ์ธ๋ ฅ ๊ตฌ์ฑ (personnel) : 25% |
| ์์ค ํ๊ฒฝ (facility) : 20% |
| ์ง์ญ ์ฐ๊ณ (community) : 10% |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| from dataclasses import dataclass, field |
| from typing import Any |
|
|
| import numpy as np |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| WEIGHTS = { |
| "curriculum": 0.45, |
| "personnel": 0.25, |
| "facility": 0.20, |
| "community": 0.10, |
| } |
|
|
|
|
| def _clamp(value: float, lo: float = 0.0, hi: float = 100.0) -> float: |
| """๊ฐ์ [lo, hi] ๋ฒ์๋ก ์ ํํฉ๋๋ค.""" |
| return float(np.clip(value, lo, hi)) |
|
|
|
|
| @dataclass |
| class SustainabilityScore: |
| """์ง์ ๊ฐ๋ฅ์ฑ ์ ์ ๊ฒฐ๊ณผ ์ปจํ
์ด๋.""" |
|
|
| schul_code: str |
| total_score: float |
| curriculum_score: float |
| personnel_score: float |
| facility_score: float |
| community_score: float |
| sub_scores: dict[str, float] = field(default_factory=dict) |
| warnings: list[str] = field(default_factory=list) |
|
|
| def to_dict(self) -> dict[str, Any]: |
| return { |
| "schul_code": self.schul_code, |
| "total_score": round(self.total_score, 2), |
| "curriculum_score": round(self.curriculum_score, 2), |
| "personnel_score": round(self.personnel_score, 2), |
| "facility_score": round(self.facility_score, 2), |
| "community_score": round(self.community_score, 2), |
| "sub_scores": {k: round(v, 2) for k, v in self.sub_scores.items()}, |
| "warnings": self.warnings, |
| } |
|
|
|
|
| class SustainabilityScorer: |
| """ |
| ํ๊ต ์งํ๋ฅผ ์
๋ ฅ๋ฐ์ ์ง์ ๊ฐ๋ฅ์ฑ ์ ์๋ฅผ ์ฐ์ถํ๋ ํด๋์ค. |
| |
| ๊ฐ ํ์ ์ ์๋ 0~100 ๋ฒ์๋ก ์ ๊ทํ๋ฉ๋๋ค. |
| |
| ์ฌ์ฉ ์:: |
| |
| scorer = SustainabilityScorer() |
| score = scorer.compute( |
| schul_code="7431234", |
| school_data={ |
| "student_count": 45, |
| "class_count": 6, |
| "teacher_count": 5, |
| "temp_teacher_count": 1, |
| "established_year": 1980, |
| "transfer_net_avg": -5, |
| "population_risk_index": 0.6, |
| } |
| ) |
| """ |
|
|
| |
| _STUDENTS_PER_CLASS_IDEAL = 20.0 |
| _STUDENTS_PER_CLASS_MIN = 1.0 |
|
|
| |
| _TEACHER_STUDENT_RATIO_IDEAL = 15.0 |
| _TEMP_TEACHER_RATIO_SAFE = 0.2 |
|
|
| |
| _FACILITY_AGE_NEW = 10 |
| _FACILITY_AGE_OLD = 40 |
|
|
| |
| _EXTINCTION_RISK_SAFE = 1.5 |
| _EXTINCTION_RISK_DANGER = 0.5 |
|
|
| def _curriculum_score(self, data: dict[str, Any]) -> tuple[float, dict[str, float]]: |
| """๊ต์ก๊ณผ์ ์ด์ ์ ์ (0~100).""" |
| sub: dict[str, float] = {} |
|
|
| student = float(data.get("student_count", 0) or 0) |
| class_count = float(data.get("class_count", 0) or 0) |
|
|
| |
| if class_count > 0: |
| ratio = student / class_count |
| |
| sub["students_per_class"] = _clamp( |
| (ratio / self._STUDENTS_PER_CLASS_IDEAL) * 100 |
| if ratio <= self._STUDENTS_PER_CLASS_IDEAL |
| else max(0, 100 - (ratio - self._STUDENTS_PER_CLASS_IDEAL) * 5), |
| ) |
| else: |
| sub["students_per_class"] = max(0.0, min(100.0, student * 2)) |
|
|
| |
| if student >= 60: |
| sub["operation_viability"] = 100.0 |
| elif student >= 30: |
| sub["operation_viability"] = 60.0 + (student - 30) * (40 / 30) |
| elif student >= 10: |
| sub["operation_viability"] = 20.0 + (student - 10) * (40 / 20) |
| else: |
| sub["operation_viability"] = max(0.0, student * 2) |
|
|
| score = float(np.mean(list(sub.values()))) |
| return _clamp(score), sub |
|
|
| def _personnel_score(self, data: dict[str, Any]) -> tuple[float, dict[str, float]]: |
| """์ธ๋ ฅ ๊ตฌ์ฑ ์ ์ (0~100).""" |
| sub: dict[str, float] = {} |
|
|
| student = float(data.get("student_count", 1) or 1) |
| teacher = float(data.get("teacher_count", 1) or 1) |
| temp = float(data.get("temp_teacher_count", 0) or 0) |
|
|
| |
| ts_ratio = student / max(teacher, 1) |
| if ts_ratio <= self._TEACHER_STUDENT_RATIO_IDEAL: |
| sub["teacher_ratio"] = 100.0 |
| else: |
| sub["teacher_ratio"] = _clamp( |
| 100.0 - (ts_ratio - self._TEACHER_STUDENT_RATIO_IDEAL) * 3 |
| ) |
|
|
| |
| temp_ratio = temp / max(teacher, 1) |
| if temp_ratio <= self._TEMP_TEACHER_RATIO_SAFE: |
| sub["temp_teacher_ratio"] = 100.0 |
| else: |
| sub["temp_teacher_ratio"] = _clamp( |
| 100.0 - (temp_ratio - self._TEMP_TEACHER_RATIO_SAFE) * 100 |
| ) |
|
|
| score = float(np.mean(list(sub.values()))) |
| return _clamp(score), sub |
|
|
| def _facility_score(self, data: dict[str, Any]) -> tuple[float, dict[str, float]]: |
| """์์ค ํ๊ฒฝ ์ ์ (0~100).""" |
| sub: dict[str, float] = {} |
| import pandas as pd |
|
|
| established = int(data.get("established_year", 0) or 0) |
| current_year = pd.Timestamp.now().year |
|
|
| if established > 0: |
| age = current_year - established |
| if age <= self._FACILITY_AGE_NEW: |
| sub["facility_age"] = 100.0 |
| elif age >= self._FACILITY_AGE_OLD: |
| sub["facility_age"] = 20.0 |
| else: |
| sub["facility_age"] = 100.0 - (age - self._FACILITY_AGE_NEW) * ( |
| 80.0 / (self._FACILITY_AGE_OLD - self._FACILITY_AGE_NEW) |
| ) |
| else: |
| sub["facility_age"] = 50.0 |
|
|
| score = float(np.mean(list(sub.values()))) |
| return _clamp(score), sub |
|
|
| def _community_score(self, data: dict[str, Any]) -> tuple[float, dict[str, float]]: |
| """์ง์ญ ์ฐ๊ณ ์ ์ (0~100).""" |
| sub: dict[str, float] = {} |
|
|
| |
| risk_index = float(data.get("population_risk_index", 1.0) or 1.0) |
| if risk_index >= self._EXTINCTION_RISK_SAFE: |
| sub["extinction_risk"] = 100.0 |
| elif risk_index <= self._EXTINCTION_RISK_DANGER: |
| sub["extinction_risk"] = 0.0 |
| else: |
| sub["extinction_risk"] = ( |
| (risk_index - self._EXTINCTION_RISK_DANGER) |
| / (self._EXTINCTION_RISK_SAFE - self._EXTINCTION_RISK_DANGER) |
| ) * 100 |
|
|
| |
| transfer_net = float(data.get("transfer_net_avg", 0) or 0) |
| if transfer_net >= 0: |
| sub["transfer_net"] = min(100.0, 70.0 + transfer_net * 2) |
| else: |
| sub["transfer_net"] = _clamp(70.0 + transfer_net * 5) |
|
|
| score = float(np.mean(list(sub.values()))) |
| return _clamp(score), sub |
|
|
| def compute( |
| self, |
| schul_code: str, |
| school_data: dict[str, Any], |
| ) -> SustainabilityScore: |
| """ |
| ํ๊ต ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ง์ ๊ฐ๋ฅ์ฑ ์ ์๋ฅผ ์ฐ์ถํฉ๋๋ค. |
| |
| Parameters |
| ---------- |
| schul_code: |
| ๋์ ํ๊ต SD_SCHUL_CODE. |
| school_data: |
| ํ๊ต ์งํ ๋์
๋๋ฆฌ. ์ฃผ์ ํค: |
| student_count, class_count, teacher_count, |
| temp_teacher_count, established_year, |
| transfer_net_avg, population_risk_index. |
| |
| Returns |
| ------- |
| SustainabilityScore |
| """ |
| warnings: list[str] = [] |
|
|
| c_score, c_sub = self._curriculum_score(school_data) |
| p_score, p_sub = self._personnel_score(school_data) |
| f_score, f_sub = self._facility_score(school_data) |
| com_score, com_sub = self._community_score(school_data) |
|
|
| total = ( |
| c_score * WEIGHTS["curriculum"] |
| + p_score * WEIGHTS["personnel"] |
| + f_score * WEIGHTS["facility"] |
| + com_score * WEIGHTS["community"] |
| ) |
|
|
| |
| if school_data.get("student_count", 100) < 10: |
| warnings.append("ํ์ ์๊ฐ 10๋ช
๋ฏธ๋ง์ผ๋ก ๋งค์ฐ ์ํํ ์ํ์
๋๋ค.") |
| if school_data.get("data_quality_score", 1.0) < 0.5: |
| warnings.append("๋ฐ์ดํฐ ์ ๋ขฐ๋๊ฐ ๋ฎ์ ์ ์ ์ ํ๋๊ฐ ์ ํ๋ฉ๋๋ค.") |
|
|
| all_sub = { |
| **{f"curriculum_{k}": v for k, v in c_sub.items()}, |
| **{f"personnel_{k}": v for k, v in p_sub.items()}, |
| **{f"facility_{k}": v for k, v in f_sub.items()}, |
| **{f"community_{k}": v for k, v in com_sub.items()}, |
| } |
|
|
| logger.info( |
| "์ง์ ๊ฐ๋ฅ์ฑ ์ ์ ์ฐ์ถ: schul_code=%s total=%.1f", |
| schul_code, |
| total, |
| ) |
|
|
| return SustainabilityScore( |
| schul_code=schul_code, |
| total_score=_clamp(total), |
| curriculum_score=c_score, |
| personnel_score=p_score, |
| facility_score=f_score, |
| community_score=com_score, |
| sub_scores=all_sub, |
| warnings=warnings, |
| ) |
|
|