Files
Aislo/B05_Profile/B05_Profile_Schema.py
T

385 lines
16 KiB
Python

"""B05 경로 설계 요청·응답 검증 모델."""
from typing import Any, Literal
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
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: SurfaceFilter = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
method: SurfaceMethod = 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/as_planned)",
)
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 기본값을 쓴다.
# 기본 특수지형(2026-09-02 사용자 지시) — B05 좌측 패널 기본 선택과 같은 값이다.
terrain_type: str = Field(default="special", description="지형 구분 (normal/special)")
# 설계속도(km/h) — 법정 종단 기준을 정하는 축이다. 임도는 속도를 낼 수 없어 20이
# 기본이며, 간선·산불진화만 30·40을 고를 수 있다(2026-08-19 사용자 확정).
# 미지정(None)이면 종류별 기본 설계속도(grade_to_design_speed)를 쓴다.
design_speed_kph: int | None = Field(default=None, description="설계속도 (20/30/40)")
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
# 횡단배수 최소고 강제 여부(2026-09-01 사용자 지시). 기본 해제 — 켜면 계획선이 배관
# 자리에서 시설 여유만큼 들리고, 그 아래로 내리는 편집도 화면에서 막힌다.
enforce_pipe_clearance: bool | None = Field(
default=None, description="횡단배수 최소 계획고 강제 (기본 해제)"
)
@model_validator(mode="after")
def validate_choices(self) -> "RouteSolveRequest":
if self.grade_class not in ROUTE_GRADE_CLASSES:
raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.")
# `as_planned` = 사용자가 고친 계획노선을 그대로 씀(PLAN 0-3 자동탐색 접기).
if self.algorithm not in ("dijkstra", "ridge_valley", "as_planned"):
raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.")
if self.design_speed_kph is not None and self.design_speed_kph not in (20, 30, 40):
raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.")
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,
"enforce_pipe_clearance": self.enforce_pipe_clearance,
}
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,
"design_speed_kph": self.design_speed_kph,
"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
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
dropped_edit_chainages: list[float] = Field(default_factory=list)
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 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록
전체로 다시 맞춘다(빈 목록이어도 · 272).
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
"""
model_config = ConfigDict(extra="forbid")
filter_key: SurfaceFilter | None = None
method: SurfaceMethod | 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:
# 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
return bool(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
# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ─────────────────────────────
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteInitialWeights(BaseModel):
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
expected: float = Field(50.0, ge=0, le=100)
earthwork: float = Field(25.0, ge=0, le=100)
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
terrain: Literal["las", "sheet"] | None = None
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
station_snap: bool = False
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""