Files
Aislo/B05_Profile/B05_Profile_Router_RouteInitial.py
eomsangdonandClaude Opus 5.5 a718c2d67c feat(B05): 추천 갈래 다시 — 영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소(PLAN 23-5a)
- 갈래 short · balance 뺌(요청 422) · gradeline · safe · crossings · cost 더함 — 각 목표로 제약 탐색한 후보를 묶음에 넣고 제 비교값으로 고름
- 탐색 목표 셋 더함: 목표 기울기 편차(영선 · 등경사선) · 위험(옆경사 35° 이상 · 계류 버퍼 안 통과 길이) · 계류 횡단 링크 · 공사비는 상대 단가 그대로
- 비교값 더함: side_slope_avg_pct · side_slope_max_pct · hazard_m · stream_crossings · stream_offset_avg_m · grade_dev_pct · cost_index
- 공사비 상대 단가(절성토 1 ㎥ = 1 · 연장 1 m = 2 · 계류 횡단 1 곳 = 400)는 임시 상대값 · 브레인 임의 판정
- 요청 criteria.target_grade_pct(영선 목표 기울기 · 없으면 상한) · 응답 criteria.target_grade_pct
- 시험 test_route_initial 31 · 왕복 시험은 화면이 아는 갈래만(새 넷은 23-5b 뒤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 09:12:05 +09:00

114 lines
5.0 KiB
Python

"""초기 계획노선 갈래 하나 — `POST /api/projects/{id}/route/initial`(PLAN 20 · 23장).
`B05_Profile_Router_Replan`(700줄)에서 나눈 길. 같은 `router` 에 붙고 그 모듈 끝에서 불려
등록된다 — main · 시험이 `Router_Replan.router` 하나만 실어도 이 길이 함께 실림.
"""
import asyncio
import functools
import logging
import time
from typing import Any
from uuid import UUID
from fastapi.responses import JSONResponse
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice
from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import expected_route_csv_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
@replan.router.post("/{project_id}/route/initial", response_model=None)
async def initial_route(
project_id: UUID, request: replan.RouteInitialRequest
) -> dict[str, Any] | JSONResponse:
"""초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장).
**파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]).
"""
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
from B05_Profile.B05_Profile_Engine_RouteInitial import (
generate_initial_route,
load_terrain_and_streams,
)
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
weights = request.weights.model_dump() if request.weights else None
if request.mode == "weighted" and weights and abs(sum(weights.values()) - 100.0) > 0.5:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "비중 세 값의 합은 100 이어야 합니다."},
)
try:
paths = await replan._project_paths(project_id)
except LookupError: # 없는 프로젝트 — 저장소 조회가 None 대신 던짐
paths = None
if paths is None:
return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING)
project_root, _ = paths
expected = await asyncio.to_thread(replan._vertices_of, expected_route_csv_path(project_root))
if not expected:
expected = await asyncio.to_thread(replan._vertices_of, design_route_csv_path(project_root))
if len(expected) < 2:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "예상노선이 없어 초기노선을 만들 수 없습니다."},
)
radius_m, _, _ = await replan._plan_criteria(project_id)
grade_class, design_speed, terrain_type = await replan._road_settings(project_id)
# 비포장 기준 — 포장 예외(18 %)는 포장 여부가 정해진 뒤 종단 설계가 따진다(사용자 확정 대기).
max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0
# 화면 제약 칸(PLAN 20장 확정) — 비운 칸은 위 프로젝트 기본값 그대로, 채운 칸만 겹쳐 쓴다.
criteria = request.criteria
if criteria and criteria.max_grade_pct is not None:
max_grade = criteria.max_grade_pct / 100.0
if criteria and criteria.min_radius_m is not None:
radius_m = criteria.min_radius_m
stream_offset_m = criteria.stream_offset_m if criteria else None
grade_smooth_m = criteria.grade_smooth_m if criteria else None
target_pct = criteria.target_grade_pct if criteria else None
pool = get_db_pool()
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
started = time.perf_counter()
try:
terrain, streams = await asyncio.to_thread(
load_terrain_and_streams, project_root, selection
)
result = await asyncio.to_thread(
functools.partial(
generate_initial_route,
[(x, y) for x, y in expected],
terrain,
streams,
mode=request.mode,
weights=weights,
max_grade=max_grade,
min_radius_m=radius_m,
stream_offset_m=stream_offset_m,
grade_smooth_m=grade_smooth_m,
target_grade=None if target_pct is None else target_pct / 100.0,
)
)
except (FileNotFoundError, ValueError) as error:
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
logger.info(
"초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
request.mode,
project_id,
time.perf_counter() - started,
result["metrics"]["length_m"],
result["metrics"]["offset_avg_m"],
)
current = await asyncio.to_thread(read_initial_choice, project_root)
return {
"status": "success",
"project_id": str(project_id),
**result,
"current_initial": current,
}