"""설정파일(config.yaml) 스키마와 로더. 사내 관행값·발주처 기준이 필요한 TODO 항목은 §9 표를 참고. 전부 이 모듈의 기본값으로 노출되어 있으며, config.yaml로 덮어쓴다. """ from __future__ import annotations import dataclasses from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional import yaml @dataclass class ConnectionsPerBlock: min: int = 500 # TODO: 편람에 수치 규정 없음. 사내 관행값 확정 필요 max: int = 3000 @dataclass class DemandPerBlock: max_cmd: Optional[float] = None # m3/day, null이면 미적용 @dataclass class PartitionConfig: level: str = "small" # large | medium | small k_min: int = 4 k_max: int = 20 method: str = "spectral" # spectral | louvain flow_weight_alpha: float = 0.7 @dataclass class ConstraintsConfig: connections_per_block: ConnectionsPerBlock = field(default_factory=ConnectionsPerBlock) max_pipe_length_per_block: float = 30000.0 # m, TODO demand_per_block: DemandPerBlock = field(default_factory=DemandPerBlock) elevation_band_m: float = 30.0 large_consumer_demand_lps: float = 5.0 # TODO large_consumer_policy: str = "isolate" # isolate | keep_together sources_in_separate_blocks: bool = True @dataclass class HydraulicsConfig: duration_hours: int = 24 timestep_minutes: int = 60 min_pressure_m: float = 15.0 # TODO: 수도법 시행규칙 / 상수도설계기준 확인 후 확정 max_pressure_m: float = 71.0 # TODO max_water_age_hours: float = 48.0 # TODO allow_isolated_nodes: bool = False @dataclass class RefineConfig: max_iterations: int = 20 moves_per_iteration: int = 50 @dataclass class ScoringWeights: n_valves: float = 0.25 n_meters: float = 0.15 pressure_violations: float = 0.30 max_water_age: float = 0.15 boundary_pipe_length: float = 0.15 @dataclass class ScoringConfig: weights: ScoringWeights = field(default_factory=ScoringWeights) @dataclass class BoundaryConfig: # 블록당 유입지점(유량계) 목표 개수. 편람에 수치 없음, 통상 1개 예비 포함 2개. TODO inlets_per_block: int = 1 @dataclass class Config: crs: str = "EPSG:5186" partition: PartitionConfig = field(default_factory=PartitionConfig) constraints: ConstraintsConfig = field(default_factory=ConstraintsConfig) hydraulics: HydraulicsConfig = field(default_factory=HydraulicsConfig) refine: RefineConfig = field(default_factory=RefineConfig) scoring: ScoringConfig = field(default_factory=ScoringConfig) boundary: BoundaryConfig = field(default_factory=BoundaryConfig) @classmethod def from_yaml(cls, path: str | Path) -> "Config": raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} return cls.from_dict(raw) @classmethod def from_dict(cls, raw: dict[str, Any]) -> "Config": cfg = cls() _apply(cfg, raw) return cfg def _apply(instance: Any, data: dict[str, Any]) -> None: for key, value in data.items(): if not hasattr(instance, key): continue current = getattr(instance, key) if dataclasses.is_dataclass(current) and isinstance(value, dict): _apply(current, value) else: setattr(instance, key, value)