""" 학교 지속 가능성 점수(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 # 0~100 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 # 교사 1인당 학생 15명이 이상적 _TEMP_TEACHER_RATIO_SAFE = 0.2 # 기간제 비율 20% 이하 양호 # 시설 노후도 기준값 (연) _FACILITY_AGE_NEW = 10 _FACILITY_AGE_OLD = 40 # 지역 연계 기준값 (소멸위험지수) _EXTINCTION_RISK_SAFE = 1.5 # 1.5 이상: 안전 _EXTINCTION_RISK_DANGER = 0.5 # 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 # 이상적인 학급당 학생수(20명) 기준 정규화, 최대 25명 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) # 교사 1인당 학생 수 점수 (이상적: 15명, 많으면 감점) 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, )