"""B05 경로 설계 요청·응답 검증 모델.""" from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator from config.config_system import ( GRADE_MAIN_DIRECTIONS, GRADE_TERRAIN_TYPES, ROUTE_GRADE_CLASSES, ) # 경사 입력은 화면·API·DB 모두 **퍼센트(%)** 로 통일한다(비율로 쓰던 과거와 다름). # 엔진 진입점에서만 100으로 나눠 비율로 바꾼다. GRADE_PERCENT_FIELDS = ( "max_uphill_grade", "max_downhill_grade", "min_uphill_grade", "min_downhill_grade", ) # 과거 버전은 같은 필드를 비율(0.14 = 14%)로 저장했다. 임도 종단기울기를 1% 이하로 # 두는 경우는 없으므로, 1 이하 값은 레거시 비율로 보고 퍼센트로 환산한다. LEGACY_GRADE_RATIO_MAX = 1.0 def normalize_grade_percent(value: Any) -> Any: """경사 값을 퍼센트로 정규화한다(레거시 비율 자동 환산).""" if value is None: return None try: number = float(value) except (TypeError, ValueError): return value if 0.0 < number <= LEGACY_GRADE_RATIO_MAX: # 0.14 * 100 = 14.000000000000002 같은 잔여 오차를 남기지 않는다. return round(number * 100.0, 6) return number class RoutePoint(BaseModel): """경로 제어점 (BP/CP/EP).""" model_config = ConfigDict(extra="forbid") x: float y: float z: float | None = None order: int | None = None class CirclePoint(BaseModel): """회피/금지 원 (AP/FP).""" model_config = ConfigDict(extra="forbid") x: float y: float radius_m: float = Field(gt=0) class RouteSolveRequest(BaseModel): """경로 탐색 실행 요청.""" model_config = ConfigDict(extra="forbid") filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)") method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)") smooth: bool = Field(default=False) surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id") algorithm: str = Field(default="dijkstra", description="경로 알고리즘 (dijkstra/ridge_valley)") bp: RoutePoint ep: RoutePoint cp: list[RoutePoint] = Field(default_factory=list) ap: list[CirclePoint] = Field(default_factory=list) fp: list[CirclePoint] = Field(default_factory=list) grade_class: str = Field(default="trunk") paved: bool = Field(default=False) min_curve_radius_m: float | None = None # 경로탐색 경사 제약 (단위: %). 과거 비율 저장분은 검증 단계에서 자동 환산된다. max_uphill_grade: float | None = Field(default=None, ge=0, le=100) max_downhill_grade: float | None = Field(default=None, ge=0, le=100) min_uphill_grade: float | None = Field(default=None, ge=0, le=100) min_downhill_grade: float | None = Field(default=None, ge=0, le=100) weights: dict[str, float] | None = None allow_avoid_pass_through: bool = Field(default=False) station_interval_m: float | None = Field(default=None, gt=0) cross_half_width_m: float | None = Field(default=None, gt=0) cross_sample_interval_m: float | None = Field(default=None, gt=0) long_sample_interval_m: float | None = Field(default=None, gt=0) # 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다. terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)") main_direction: str = Field( default="auto", description="주 진행방향 (auto/ascending/descending/none)" ) max_grade_pct: float | None = Field(default=None, gt=0) min_vertical_radius_m: float | None = Field(default=None, gt=0) min_tangent_length_m: float | None = Field(default=None, gt=0) balance_segment_length_m: float | None = Field(default=None, gt=0) start_elevation_offset_m: float | None = None end_elevation_offset_m: float | None = None @model_validator(mode="after") def validate_choices(self) -> "RouteSolveRequest": if self.grade_class not in ROUTE_GRADE_CLASSES: raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.") if self.algorithm not in ("dijkstra", "ridge_valley"): raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.") if self.terrain_type not in GRADE_TERRAIN_TYPES: raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") if self.main_direction not in GRADE_MAIN_DIRECTIONS: raise ValueError(f"주 진행방향은 {GRADE_MAIN_DIRECTIONS} 중 하나여야 합니다.") # 화면·DB에 남아 있던 비율 표기를 퍼센트로 맞춰 이후 단계를 단일 단위로 만든다. for field_name in GRADE_PERCENT_FIELDS: setattr(self, field_name, normalize_grade_percent(getattr(self, field_name))) return self def grade_options(self) -> dict[str, Any]: """계획선 엔진에 넘길 요청 측 재정의 값(사용자가 비우면 None).""" return { "max_grade_pct": self.max_grade_pct, "min_vertical_radius_m": self.min_vertical_radius_m, "min_tangent_length_m": self.min_tangent_length_m, "balance_segment_length_m": self.balance_segment_length_m, "start_elevation_offset_m": self.start_elevation_offset_m, "end_elevation_offset_m": self.end_elevation_offset_m, } def points_data(self) -> dict[str, Any]: return { "bp": self.bp.model_dump(), "ep": self.ep.model_dump(), "cp": [p.model_dump() for p in self.cp], "ap": [p.model_dump() for p in self.ap], "fp": [p.model_dump() for p in self.fp], } def options(self) -> dict[str, Any]: return { "grade_class": self.grade_class, "paved": self.paved, "terrain_type": self.terrain_type, "main_direction": self.main_direction, "min_curve_radius_m": self.min_curve_radius_m, "max_uphill_grade": self.max_uphill_grade, "max_downhill_grade": self.max_downhill_grade, "min_uphill_grade": self.min_uphill_grade, "min_downhill_grade": self.min_downhill_grade, "weights": self.weights, "allow_avoid_pass_through": self.allow_avoid_pass_through, } class ContourIntervalUpdateRequest(BaseModel): """등고선 간격 재적용 영속화 요청.""" model_config = ConfigDict(extra="forbid") contour_interval_m: float = Field(gt=0) class ContourIntervalUpdateResponse(BaseModel): """등고선 간격 영속화 결과.""" status: str = "success" project_id: str contour_interval_m: float class ProfileAlignmentSaveRequest(BaseModel): """종단 계획선 사용자 편집 저장 요청. 화면은 편집 결과를 즉시 계산해 보여주고, 확정 시점에 **편집 델타만** 보낸다. 서버가 저장된 자동 선형(base_pvi)에 델타를 다시 얹어 정본을 만들기 때문에 표고 전체를 주고받지 않아도 되고, 키를 지우면 자동 선형으로 원복된다. """ model_config = ConfigDict(extra="forbid") route_id: int = Field(gt=0) # {chainage 문자열(소수 3자리): 자동 선형 대비 계획고 델타(m)} station_offsets: dict[str, float] = Field(default_factory=dict) # {chainage 문자열(소수 3자리): 종단곡선 반경(m)}. 길이 L은 R × |대수차| 로 파생된다. curve_radii: dict[str, float] = Field(default_factory=dict) @model_validator(mode="after") def validate_edits(self) -> "ProfileAlignmentSaveRequest": for chainage, radius in self.curve_radii.items(): if radius <= 0: raise ValueError(f"종단곡선 반경은 0보다 커야 합니다 (chainage {chainage}).") return self def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} class ProfileAlignmentSaveResponse(BaseModel): """계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다).""" status: str = "success" project_id: str route_id: int profile_alignment: dict[str, Any] grade_summary: dict[str, Any] | None = None class RouteSolveResponse(BaseModel): """경로 탐색 실행 결과.""" status: str = "success" project_id: str route_id: int total_length_m: float metrics: dict[str, Any] required_points_ok: bool route_data_path: str # 종횡단 생성 실패 시 None (경로 자체는 저장됨) longitudinal_length_m: float | None = None cross_section_count: int | None = None # 계획선 산출 실패 시 None (종횡단·경로는 저장됨) grade_summary: dict[str, Any] | None = None class IrregularStationInput(BaseModel): """비정규 측점(구조물). 규칙 격자 밖 chainage 위치에 구조물 정보를 담는다.""" model_config = ConfigDict(extra="forbid") chainage_m: float = Field(ge=0) structure: str = Field(default="") class UphillSideOverride(BaseModel): """측점 상단측(=측구 방향) 사용자 변경값. 3D 원형 램프 클릭으로 지정된다.""" model_config = ConfigDict(extra="forbid") chainage_m: float = Field(ge=0) side: Literal["left", "right"] class RouteConfirmRequest(BaseModel): """경로 확정 요청. 확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값 (solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다. 모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다. """ model_config = ConfigDict(extra="forbid") filter_key: str | None = None method: str | None = None smooth: bool = False surface_model_id: int | None = None irregular_stations: list[IrregularStationInput] = Field(default_factory=list) # 측점 상단측(측구 방향) 사용자 변경분 — solve 자동 판정을 확정 시 덮어쓴다. uphill_overrides: list[UphillSideOverride] = Field(default_factory=list) def extra_stations(self) -> tuple[tuple[float, str], ...]: return tuple((item.chainage_m, item.structure) for item in self.irregular_stations) def can_regenerate(self) -> bool: return bool( self.irregular_stations and self.filter_key and self.method and self.surface_model_id is not None ) class RouteConfirmResponse(BaseModel): """경로 확정 결과.""" status: str = "success" project_id: str route_id: int confirmed: bool = True class RouteLatestResponse(BaseModel): """새로고침 복원을 위한 최신 경로·입력·통계 응답.""" status: str = "success" project_id: str route: dict[str, Any] | None = None route_points: list[dict[str, Any]] = Field(default_factory=list) surface_params: dict[str, Any] route_params: dict[str, Any] | None = None