"""초기 계획노선 갈래 하나 — `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, }