- 원인: 가파른 지형(옆경사 평균 67 %)에서 4 m 링크의 14 %뿐이 상한 안 · 반지름 규칙까지 지키는 길이 없어 풂(상한 29 %) · 풂 탐색 벌점(1 m 당 500 × 넘은 비율)이 목표(1 m 당 평균 1)를 덮어 네 갈래가 「넘은 기울기 합 최소」 한 길로 · 탐색 목표(4 m 링크)와 비교값(측점 20 m · 절성토)이 어긋나 벌점만 낮춰도 순서가 안 맞음 · follow 는 이격 최소 탐색이라 초기값과 다름 - 후보 묶음(목표 비중 셋 × 벌점 50 · 5 + 센 벌점 기준 길 · 엄격하면 비중마다 상한 단계)을 한 번 짜고 갈래는 비교값으로 고름 — 토공 최소 = 절성토 합 최소 · 기울기 순한 = 최대(같으면 평균) 최소 · 비중 = 이격 · 절성토 · 기울기를 후보 범위로 정규화한 가중합 · 기준 길보다 못 지킨 곳이 많은 후보는 뺌 - follow = build_planned_polyline(예상노선) — planned_route_initial.csv 와 같은 선(1078.01 m) · 넘은 곳은 「예상노선 추적」 사유로 목록 - 탐색을 B05_Profile_Engine_RouteInitial_Search.py 로 나눔 · 상태 그래프 한 번만 짜고 탐색마다 간선만 거름 · 막는 기울기 이분 탐색은 BFS · 후보 묶음은 같은 입력이면 프로세스 메모리에서 다시 씀 - initial_route 를 B05_Profile_Router_RouteInitial.py 로 나눔(Router_Replan 712 → 630줄, 같은 router 에 등록) · 없는 프로젝트 500 → 404 - 시험 test_route_initial 20(추종 = 초기값 · 갈래별 목적 순서 · 랩탑_보조 실데이터 갈래 차이) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
112 lines
4.8 KiB
Python
112 lines
4.8 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
|
|
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,
|
|
)
|
|
)
|
|
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,
|
|
}
|