feat(B03): 초기 업로드부터 계획노선 폴리라인을 노선 정본으로 사용

자동 체인 2.7 단계 추가 — 초기 폴리라인을 세운 뒤 노선을 다시 읽어
그것을 BP·경유점·EP 로 세우고 algorithm=as_planned 로 넘김.

전에는 폴리라인 파일만 만들고 노선은 원본 점군으로 풀었음. 그래서
종횡단 측점·유토곡선·3D 코리도가 곡선 없는 점군 위에 섰음.
실측(용화 3건): DB 노선 정점 331·323 vs 초기 폴리라인 145.
노선을 한 번이라도 편집한 프로젝트만 145 로 맞아 있었음.

자체검증 — 2.7 재판독이 145 정점을 돌려주고 BP·EP 좌표가
expected_route.csv 첫·끝 줄과 같음. pytest 409 passed.

계측 로그(_log_steps) 동봉 — 재확정 체인 단계별 경과시간용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 02:56:45 +09:00
co-authored by Claude Opus 5
parent c52609417b
commit 41852da3b3
2 changed files with 51 additions and 0 deletions
@@ -16,6 +16,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
"""
import logging
import time
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -25,6 +26,18 @@ from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
def _log_steps(title: str, marks: list[tuple[str, float]]) -> None:
"""단계별 경과시간을 한 줄로 남긴다 — 어디서 시간을 쓰는지 보려는 계측용."""
total = (marks[-1][1] - marks[0][1]) or 1e-9
parts = [
f"{name} {marks[i][1] - marks[i - 1][1]:.1f}s"
f"({(marks[i][1] - marks[i - 1][1]) / total * 100:.0f}%)"
for i, (name, _) in enumerate(marks)
if i
]
logger.info("[계측] %s%.1fs = %s", title, total, " | ".join(parts))
def _planned_route_points_in_project_crs(
project_root: Path,
surface: dict[str, Any] | None = None,
@@ -70,6 +83,7 @@ async def _prepare_drainage_pipes_and_reprofile(
from config.config_db import get_db_pool
from config.config_system import SECTION_CROSS_HALF_WIDTH_M
marks = [("시작", time.perf_counter())]
try:
# 1) 배수유역 분석(30초 내외). 저장분이 있으면 그대로 쓴다.
# 저장분은 노선이 바뀌었는지 보지 않으므로, 노선을 다시 푼 뒤에는 refresh로 부른다.
@@ -82,6 +96,8 @@ async def _prepare_drainage_pipes_and_reprofile(
)
return False
marks.append(("배수유역 분석", time.perf_counter()))
# 2) 기본 관(도로 × 상류 세류선) + 최대 간격 자동 보충을 정본으로 저장한다.
pipes = await put_pipe_points(project_id, None)
if isinstance(pipes, JSONResponse):
@@ -93,6 +109,8 @@ async def _prepare_drainage_pipes_and_reprofile(
return False
pipe_count = int(pipes.get("saved_count") or 0)
marks.append(("관 지점 확정", time.perf_counter()))
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
pool = get_db_pool()
async with pool.acquire() as connection:
@@ -110,6 +128,8 @@ async def _prepare_drainage_pipes_and_reprofile(
regenerated.status_code,
)
return False
marks.append(("종횡단 측점 재생성", time.perf_counter()))
_log_steps("배수·관·측점", marks)
logger.info(
"자동 설계 체인 배수유역·배관 정착 계획선 완료: project_id=%s route_id=%s 관=%d",
project_id,
@@ -211,12 +231,24 @@ async def run_auto_design_chain(
# 「화면을 안 열면 값이 없다」가 되므로 초기값은 서버가 낸다(0-4 원칙).
await _ensure_initial_polyline(project_id, project_root, points)
# 2.7) 노선의 기준을 **초기 폴리라인**으로 갈아 끼운다(2026-09-06 사용자 지시).
# 다시 읽으면 `load_design_route` 가 방금 만든 초기 폴리라인을 집는다
# (읽는 순서: 수정본 → 초기 폴리라인 → 예상노선). 종횡단 측점·유토곡선·
# 3D 코리도가 전부 이 노선에서 나오므로, 여기서 갈아 끼우지 않으면 화면에는
# 곡선 없는 원본 점군(용화: 3.3m 간격 331점)이 그대로 선다.
polyline_points = _planned_route_points_in_project_crs(project_root, defaults, route_range)
if polyline_points and len(polyline_points) >= 2:
points = polyline_points
# 3) B05 경로 계산
request = RouteSolveRequest(
filter_key=str(defaults["source_filter"]),
method=str(defaults["method"]),
smooth=bool(defaults["smooth"]),
surface_model_id=surface_model_id,
# 계획노선을 **그대로** 쓴다 — 격자 재탐색은 이 선을 자기 제약으로 다시 풀어
# 곡선을 뭉개거나 통째로 막는다(재확정 체인과 같은 이유, 0-10).
algorithm="as_planned",
bp=RoutePoint(**points[0]),
ep=RoutePoint(**points[-1]),
cp=[
@@ -397,6 +429,7 @@ async def run_redesign_chain(
pool = get_db_pool()
project_root: Path | None = None
marks = [("시작", time.perf_counter())]
try:
async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id)
@@ -437,6 +470,8 @@ async def run_redesign_chain(
)
return "노선 제어점(BP·EP)이 없어 재계산할 수 없습니다."
marks.append(("준비·사용자입력 회수", time.perf_counter()))
# 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분.
request = RouteSolveRequest(
filter_key=str(selection.get("source_filter") or params.get("filter_key")),
@@ -491,6 +526,7 @@ async def run_redesign_chain(
)
return f"노선 재계산 실패({solve_result.status_code}): {reason}"
new_route_id = int(solve_result.route_id)
marks.append(("노선 재계산(solve)", time.perf_counter()))
logger.info(
"재확정 체인 B05 재계산 완료: project_id=%s %s%s",
project_id,
@@ -514,10 +550,13 @@ async def run_redesign_chain(
confirm_result.status_code,
)
return f"노선 확정 실패({confirm_result.status_code})"
marks.append(("B05 확정", time.perf_counter()))
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True)
marks.append(("배수·관·측점 재생성", time.perf_counter()))
# 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 설계를 **누가거리로** 얹는다.
# 묶어 쓰므로 행 수와 무관하게 왕복 두 번이다(`merge_cross_section_designs`).
try:
@@ -546,6 +585,8 @@ async def run_redesign_chain(
)
logger.info("재확정 체인 설계 이월: project_id=%s %d", project_id, carried)
marks.append(("설계 이월", time.perf_counter()))
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
section_request = (
SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"])
@@ -562,6 +603,8 @@ async def run_redesign_chain(
sections_result.status_code,
)
return f"종횡단 확정 실패({sections_result.status_code})"
marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter()))
_log_steps("재확정 체인", marks)
logger.info(
"재확정 체인 완료: project_id=%s route %s%s (설계 %d건 이월)",
project_id,