- 검증_sub5 실측: 수치지형도 벌이 수치지형도로 자른 긴 노선(2110 m)으로 지어져 공유 예상노선(라이다로 자름 · 1377 m)과 어긋남 → 그 벌 [초기화] 뒤 초기 폴리라인이 예상노선(라이다)으로 다시 지어져 노선 1359.6 m · 종횡단 2110 m 로 갈림 - 예상노선(공유 · 추천 열쇠)은 하나 · 설계 지형으로 자름 → 옛 지형 칩처럼 평면 노선 같고 지형만 다름 - 다른 지형 벌 끝에 예상노선 지우던 길 걷음(먼저 적으니 필요 없음) - 시험 test_765_design_sets 체인 차례에 예상노선 먼저 적기 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
550 lines
29 KiB
Python
550 lines
29 KiB
Python
"""B03 업로드 이후 자동 설계 체인 — WF1 확정 다음을 잇는다.
|
|
|
|
WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어도 서버가 이어서
|
|
① B05 기본 경로 계산·확정(계획노선 정본 기반) ② B06 기본 횡단 설계 확정까지
|
|
기본값으로 진행해 영구저장소에 남긴다(2026-08-04 사용자 확정). 이후 사용자가
|
|
대시보드에서 B05/B06에 들어오면 저장본을 바로 로딩해 검토·수정만 하면 된다.
|
|
|
|
원칙:
|
|
- **수동 이력 보호**: 프로젝트에 경로가 하나라도 있으면 체인을 건너뛴다 — 사용자가
|
|
이미 작업한 것을 자동 계산이 덮어쓰면 안 된다.
|
|
- **단계별 실패 격리**: 각 단계는 해당 라우터가 자기 workflow stage 전이(실패 기록)를
|
|
책임진다. 체인은 실패한 단계에서 멈추고 뒤 단계로 오류를 전파하지 않는다 —
|
|
사용자는 그 페이지에서 수동으로 이어서 진행할 수 있다.
|
|
- 라우터 함수를 직접 호출한다(HTTP 재진입 없음). solve/confirm 엔드포인트는 인증
|
|
의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다.
|
|
- **지형마다 한 벌**(765-3): 전처리 체인 = `run_preprocess_chain` — 다른 지형 벌을 먼저 지어
|
|
`design_sets/` 묶음으로 떠 두고 설계 지형 벌을 지음(`common_util_design_sets`).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
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,
|
|
route_range: tuple[float | None, float | None] | None = None,
|
|
) -> list[dict[str, float]] | None:
|
|
"""설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None.
|
|
|
|
읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도
|
|
같은 함수를 쓰므로 여기만 트림되는 일이 없다. 사용자가 정한 사용 범위(`route_range`)는
|
|
서피스 트림보다 먼저 적용된다 (2026-09-04 사용자 지시).
|
|
"""
|
|
from common_util.common_util_route_geometry import load_design_route
|
|
|
|
planned = load_design_route(project_root, surface, route_range)
|
|
if planned is None:
|
|
if surface:
|
|
logger.warning(
|
|
"자동 설계 체인 중단(설계 노선 없음): 계획노선을 읽지 못했거나 라이다 측량"
|
|
" 범위와 겹치지 않습니다 — %s",
|
|
project_root.name,
|
|
)
|
|
return None
|
|
return [{"x": v.x, "y": v.y} for v in planned.vertices]
|
|
|
|
|
|
async def _prepare_drainage_pipes_and_reprofile(
|
|
project_id: UUID,
|
|
route_id: int,
|
|
*,
|
|
refresh: bool = False,
|
|
stored_options: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
"""배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성.
|
|
|
|
계획선 1차 로직(`design_pipe_anchored_profile`)은 **저장된 관 지점**을 변화점으로 쓴다.
|
|
자동 체인이 이 파일을 만들어 주지 않으면 초기 계획선이 2차 폴백(지반 추종 직선 분할)으로
|
|
산출돼 관 자리 틸팅·R이 통째로 빠진다(2026-08-08 사용자 보고). 같은 이유로 B05·B06에
|
|
들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다.
|
|
|
|
실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다.
|
|
|
|
`stored_options` = 노선 [확인] 체인이 건너뛴 첫 생성의 옵션(342-2) — 반폭 · 재생성 옵션.
|
|
"""
|
|
from B04_PreProcess.B04_PreProcess_Router_Basins import put_pipe_points
|
|
from B04_PreProcess.B04_PreProcess_Router_Watershed import get_primary_region
|
|
from B06_Section.B06_Section_Repository import get_latest_section_options
|
|
from B06_Section.B06_Section_Router import regenerate_route_sections
|
|
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로 부른다.
|
|
region = await get_primary_region(project_id, refresh=refresh)
|
|
if isinstance(region, JSONResponse):
|
|
logger.error(
|
|
"자동 설계 체인 배수유역 분석 실패(계획선 폴백 유지): project_id=%s status=%s",
|
|
project_id,
|
|
region.status_code,
|
|
)
|
|
return False
|
|
|
|
marks.append(("배수유역 분석", time.perf_counter()))
|
|
|
|
# 2) 기본 관(도로 × 상류 세류선 교차)만 정본으로 저장한다 — 간격 보충은 [자동 제안](369).
|
|
pipes = await put_pipe_points(project_id, None)
|
|
if isinstance(pipes, JSONResponse):
|
|
logger.error(
|
|
"자동 설계 체인 관 지점 확정 실패: project_id=%s status=%s",
|
|
project_id,
|
|
pipes.status_code,
|
|
)
|
|
return False
|
|
pipe_count = int(pipes.get("saved_count") or 0)
|
|
|
|
marks.append(("관 지점 확정", time.perf_counter()))
|
|
|
|
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
|
|
seeded = stored_options is not None
|
|
if not seeded:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_options = await get_latest_section_options(connection, project_id)
|
|
half_width = float(
|
|
(stored_options or {}).get("cross_half_width_m") or SECTION_CROSS_HALF_WIDTH_M
|
|
)
|
|
regenerated = await regenerate_route_sections(
|
|
project_id, route_id, half_width, stored_options=stored_options if seeded else None
|
|
)
|
|
if isinstance(regenerated, JSONResponse):
|
|
logger.error(
|
|
"자동 설계 체인 배관 정착 계획선 재산출 실패: project_id=%s status=%s",
|
|
project_id,
|
|
regenerated.status_code,
|
|
)
|
|
return False
|
|
marks.append(("종횡단 측점 재생성", time.perf_counter()))
|
|
_log_steps("배수·관·측점", marks)
|
|
logger.info(
|
|
"자동 설계 체인 배수유역·배관 정착 계획선 완료: project_id=%s route_id=%s 관=%d개",
|
|
project_id,
|
|
route_id,
|
|
pipe_count,
|
|
)
|
|
return True
|
|
except Exception:
|
|
logger.exception(
|
|
"자동 설계 체인 배수유역·관 지점 단계 실패(계획선은 폴백으로 유지): project_id=%s",
|
|
project_id,
|
|
)
|
|
return False
|
|
|
|
|
|
async def run_auto_design_chain(
|
|
project_id: UUID, surface_model_id: int | None = None, *, inner: bool = False
|
|
) -> dict[str, Any] | None:
|
|
"""B05 기본 경로 계산·확정 → B06 기본 횡단 설계 확정을 기본값으로 이어 실행한다.
|
|
|
|
`inner` = 두 벌 짓기 · 지형 칩 안에서 부름(765) — 진입 막음 마커 · 추천 미리 셈은 부르는 쪽.
|
|
|
|
WF1 자동 확정 직후 같은 백그라운드 태스크에서 호출된다. 어떤 단계가 실패해도
|
|
예외를 밖으로 던지지 않는다 — 로그와 각 단계의 workflow 상태 기록으로 남긴다.
|
|
|
|
성공하면 초기 분석 완료 메일에 실을 요약(노선 id·연장·측점 수)을 돌려주고,
|
|
건너뛰거나 실패하면 None을 돌려준다.
|
|
"""
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_Profile.B05_Profile_Repository import get_latest_route
|
|
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route
|
|
from B05_Profile.B05_Profile_Schema import RoutePoint, RouteSolveRequest
|
|
from B06_Section.B06_Section_Router_Confirm import confirm_sections
|
|
from common_util.common_util_initial_snapshot import (
|
|
clear_design_failed,
|
|
clear_designing,
|
|
mark_design_failed,
|
|
mark_designing,
|
|
save_initial_snapshot,
|
|
)
|
|
from common_util.common_util_route_geometry import (
|
|
expected_route_csv_path,
|
|
write_route_csv,
|
|
)
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
|
from config.config_db import get_db_pool
|
|
|
|
pool = get_db_pool()
|
|
project_root: Path | None = None
|
|
try:
|
|
# 1) 수동 이력 보호 — 경로가 이미 있으면(사용자 작업 또는 이전 자동 실행) 건너뛴다.
|
|
async with pool.acquire() as connection:
|
|
existing = await get_latest_route(connection, project_id)
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
if existing:
|
|
logger.info(
|
|
"자동 설계 체인 건너뜀(기존 경로 있음): project_id=%s route_id=%s",
|
|
project_id,
|
|
existing.get("id"),
|
|
)
|
|
return None
|
|
|
|
# 2) 계획노선 정본 → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다.
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
# 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면
|
|
# 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장).
|
|
mark_designing(project_root)
|
|
# 옛 실패 마커는 여기서 지운다 — 이번 체인의 결과로 다시 판정한다.
|
|
clear_design_failed(project_root)
|
|
# WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을
|
|
# 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이
|
|
# 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다.
|
|
async with pool.acquire() as connection:
|
|
defaults = await get_surface_confirmation_params(connection, str(project_id))
|
|
# 사용자가 B02·B01 에서 정한 계획노선 사용 범위 (2026-09-04 사용자 지시).
|
|
# 비어 있으면 전 구간 — 지금까지와 같다.
|
|
async with connection.cursor() as cursor:
|
|
# 설계속도 = 단계 2 params 에 먼저 들어온 값(예정노선 연결 · 531-4) — 없으면 None.
|
|
await cursor.execute(
|
|
"SELECT route_start_m, route_end_m,"
|
|
" (SELECT JSON_VALUE(s.params, '$.options.design_speed_kph')"
|
|
" FROM project_workflow_stages s"
|
|
" WHERE s.project_id = projects.id AND s.stage_no = 2)"
|
|
" FROM projects WHERE id = %s",
|
|
(str(project_id),),
|
|
)
|
|
range_row = await cursor.fetchone()
|
|
route_range = (range_row[0], range_row[1]) if range_row else None
|
|
seeded_speed = range_row[2] if range_row else None
|
|
|
|
points = _planned_route_points_in_project_crs(project_root, defaults, route_range)
|
|
if not points:
|
|
logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id)
|
|
mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.")
|
|
return None
|
|
|
|
# 2.5) 예상노선(원본) 정본을 남긴다 — 초기값 스냅샷과 달리 재확정 체인이 지우지
|
|
# 않는 자리라, 노선 초기화가 언제나 이 값으로 돌아갈 수 있다(2026-09-06 PLAN 0-7).
|
|
expected_path = expected_route_csv_path(project_root)
|
|
if not expected_path.is_file():
|
|
write_route_csv(expected_path, points)
|
|
|
|
# 2.6) 계획노선 **초기 폴리라인**도 여기서 세운다(2026-09-06 PLAN 0-10).
|
|
# 예상노선은 점 묶음이라 그대로는 설계선이 못 된다 — 지식DB 의 R 기준으로
|
|
# 곡선을 끼운 폴리라인이 불변 초기 데이터다. 화면이 처음 열릴 때 만들면
|
|
# 「화면을 안 열면 값이 없다」가 되므로 초기값은 서버가 낸다(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=[
|
|
RoutePoint(**point, order=index)
|
|
for index, point in enumerate(points[1:-1], start=1)
|
|
],
|
|
design_speed_kph=int(seeded_speed) if seeded_speed else None,
|
|
)
|
|
solve_result: Any = await solve_route(project_id, request)
|
|
if isinstance(solve_result, JSONResponse):
|
|
logger.error(
|
|
"자동 설계 체인 중단(B05 경로 계산 실패): project_id=%s status=%s",
|
|
project_id,
|
|
solve_result.status_code,
|
|
)
|
|
mark_design_failed(
|
|
project_root,
|
|
f"B05 초기 노선 계산 실패 (status={solve_result.status_code}).",
|
|
)
|
|
return None
|
|
route_id = int(solve_result.route_id)
|
|
logger.info(
|
|
"자동 설계 체인 B05 경로 계산 완료: project_id=%s route_id=%s length=%.1fm",
|
|
project_id,
|
|
route_id,
|
|
float(solve_result.total_length_m or 0.0),
|
|
)
|
|
|
|
# 4) B05 경로 확정 — 데이터만 CONFIRMED, stage 2는 IN_PROGRESS(사용자 검토 대기)로
|
|
# 남긴다. 완료 전이는 B06 [확정]이 stage 2·3을 함께 처리한다(2026-08-08 재정의).
|
|
confirm_result = await confirm_latest_route(project_id, None, mark_stage_complete=False)
|
|
if isinstance(confirm_result, JSONResponse):
|
|
logger.error(
|
|
"자동 설계 체인 중단(B05 경로 확정 실패): project_id=%s status=%s",
|
|
project_id,
|
|
confirm_result.status_code,
|
|
)
|
|
mark_design_failed(
|
|
project_root,
|
|
f"B05 초기 노선 확정 실패 (status={confirm_result.status_code}).",
|
|
)
|
|
return None
|
|
|
|
# 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출.
|
|
# B06 확정 전에 끝내야 확정 스냅샷에 배관 정착 계획선이 담긴다.
|
|
# 배수는 늘 다시 셈(765-3) — 저장분은 노선이 바뀐 줄 모름(두 벌 · 서피스 바꿈 뒤 묵음).
|
|
await _prepare_drainage_pipes_and_reprofile(project_id, route_id, refresh=True)
|
|
|
|
# 5) B06 횡단 설계 확정 — 미지정 측점을 기본값으로 채워 저장. stage 3은
|
|
# IN_PROGRESS(스텝바 노란 표시)로 남겨 사용자 검토·확정을 기다린다.
|
|
sections_result = await confirm_sections(
|
|
project_id, route_id, None, mark_stage_complete=False
|
|
)
|
|
if isinstance(sections_result, JSONResponse):
|
|
logger.error(
|
|
"자동 설계 체인 중단(B06 횡단 확정 실패): project_id=%s route_id=%s status=%s",
|
|
project_id,
|
|
route_id,
|
|
sections_result.status_code,
|
|
)
|
|
mark_design_failed(
|
|
project_root,
|
|
f"B06 초기 횡단 확정 실패 (status={sections_result.status_code}).",
|
|
)
|
|
return None
|
|
logger.info(
|
|
"자동 설계 체인 완료(B05·B06 기본값 확정): project_id=%s route_id=%s",
|
|
project_id,
|
|
route_id,
|
|
)
|
|
|
|
# 서버 재계산(구조물 면적 · 유토곡선)과 3D 길 모형 짓기는 [확정](confirm_sections) 끝에서
|
|
# 한 번씩 — 재계산 → 짓기 차례 · 초기값 스냅샷 **앞**이라 기준선에도 담긴다(342-2).
|
|
|
|
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
|
|
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
|
|
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
await save_initial_snapshot(connection, project_root, route_id, points)
|
|
except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다
|
|
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
|
|
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
|
|
|
|
return {
|
|
"route_id": route_id,
|
|
"length_m": float(solve_result.total_length_m or 0.0),
|
|
"cross_section_count": solve_result.cross_section_count,
|
|
}
|
|
except Exception as exc:
|
|
# 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다.
|
|
logger.exception("자동 설계 체인 실패: project_id=%s", project_id)
|
|
if project_root is not None:
|
|
mark_design_failed(project_root, f"초기 설계 처리 중 오류: {exc}")
|
|
finally:
|
|
# 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다.
|
|
if project_root is not None and not inner:
|
|
clear_designing(project_root)
|
|
# 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀
|
|
if not inner:
|
|
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
|
|
|
schedule_precompute(project_id)
|
|
|
|
|
|
async def run_preprocess_chain(
|
|
project_id: UUID, surface_model_id: int | None = None
|
|
) -> dict[str, Any] | None:
|
|
"""전처리 체인 = 지형마다 설계 한 벌(765-3 · 요구 605 · 606-1) — 결과 = 설계 지형 요약.
|
|
|
|
다른 지형 먼저 자동 설계 → 묶음으로 떠 둠(B05~B09 비움) → 설계 지형 자동 설계 → 추천 미리 셈
|
|
한 번(실패 길에도). 다른 지형이 없으면(LAS 만 · 수치지형도 못 받음 · 수치지형도만) 한 벌 ·
|
|
묶음 meta.json 에 까닭. 부르는 곳 = WF1 자동 확정 · 서피스 바꿈 체인(B04 다른 서피스 [확정] ·
|
|
LAS 나중 올리기 뒤 전처리 다시). 묶음은 처음부터 다시(옛 벌 버림).
|
|
"""
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
|
from common_util import common_util_design_sets as sets
|
|
from common_util.common_util_initial_snapshot import (
|
|
clear_designing,
|
|
mark_designing,
|
|
read_design_failure,
|
|
)
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_surface_confirmation import (
|
|
design_terrain,
|
|
get_surface_confirmation_params,
|
|
)
|
|
from config.config_db import get_db_pool
|
|
|
|
pool = get_db_pool()
|
|
root: Path | None = None
|
|
marks = [("시작", time.perf_counter())]
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
params = await get_surface_confirmation_params(connection, str(project_id))
|
|
if not stored_path:
|
|
return None
|
|
root = Path(resolve_stored_project_path(stored_path))
|
|
mark_designing(root)
|
|
sets.discard_sets(root)
|
|
design = design_terrain(params)
|
|
other = sets.other_terrain(params, root)
|
|
if other is None:
|
|
missing = "sheet" if design == "las" else "las"
|
|
sets.note_unbuilt(root, missing, f"{missing} 지표면 모델 없음 — 한 벌만")
|
|
else:
|
|
# 예상노선(공유 · 추천이 씀)은 설계 지형으로 자른 것 하나 — 먼저 적어 두면 두 벌이 같은
|
|
# 평면 노선으로 지어짐(지형만 다름 · 다른 지형 벌 [초기화]도 같은 노선)
|
|
await _seed_expected_route(project_id, root, params)
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
model_id = await sets.switch_terrain(connection, project_id, other)
|
|
built = await run_auto_design_chain(project_id, model_id, inner=True)
|
|
async with pool.acquire() as connection:
|
|
fingerprint = await sets.set_fingerprint(
|
|
connection, str(project_id), root, other
|
|
)
|
|
if built and fingerprint:
|
|
await sets.park_active_set(connection, root, str(project_id), fingerprint)
|
|
else:
|
|
await sets.clear_active_set(connection, root, str(project_id))
|
|
sets.note_unbuilt(root, other, read_design_failure(root) or "짓기 실패")
|
|
except Exception as exc: # noqa: BLE001 — 다른 벌 실패가 설계 지형 벌을 막지 않음
|
|
logger.exception("두 벌 짓기 — %s 벌 실패: project_id=%s", other, project_id)
|
|
async with pool.acquire() as connection:
|
|
await sets.clear_active_set(connection, root, str(project_id))
|
|
sets.note_unbuilt(root, other, f"짓기 오류: {exc}")
|
|
finally:
|
|
async with pool.acquire() as connection:
|
|
await sets.switch_terrain(connection, project_id, design)
|
|
marks.append((f"{other} 벌", time.perf_counter()))
|
|
summary = await run_auto_design_chain(project_id, surface_model_id, inner=True)
|
|
marks.append((f"{design} 벌", time.perf_counter()))
|
|
_log_steps("전처리 체인 두 벌", marks)
|
|
return summary
|
|
except Exception:
|
|
logger.exception("전처리 체인 실패: project_id=%s", project_id)
|
|
return None
|
|
finally:
|
|
if root is not None:
|
|
clear_designing(root)
|
|
schedule_precompute(project_id) # 추천은 지형 · 예정노선만 씀 — 실패 길에도(763-3)
|
|
|
|
|
|
async def _seed_expected_route(project_id: UUID, root: Path, surface: dict[str, Any]) -> None:
|
|
"""예상노선 정본을 설계 지형으로 잘라 적음(없을 때만 · 자동 체인 2.5 와 같은 값)."""
|
|
from common_util.common_util_route_geometry import expected_route_csv_path, write_route_csv
|
|
from config.config_db import get_db_pool
|
|
|
|
path = expected_route_csv_path(root)
|
|
if path.is_file():
|
|
return
|
|
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s", (str(project_id),)
|
|
)
|
|
row = await cursor.fetchone()
|
|
points = await asyncio.to_thread(
|
|
_planned_route_points_in_project_crs, root, surface, (row[0], row[1]) if row else None
|
|
)
|
|
if points:
|
|
write_route_csv(path, points)
|
|
|
|
|
|
async def _ensure_initial_polyline(
|
|
project_id: UUID, project_root: Path, points: list[dict[str, float]]
|
|
) -> None:
|
|
"""계획노선 초기 폴리라인을 세운다 — 이미 있으면 그대로 둔다. 실패는 비치명적."""
|
|
|
|
from B05_Profile.B05_Profile_Router_FollowLand import initial_land
|
|
from B05_Profile.B05_Profile_Router_Replan import _ensure_planned_initial, _min_plan_radius_m
|
|
|
|
try:
|
|
radius_m = await _min_plan_radius_m(project_id)
|
|
land = await initial_land(project_id, project_root)
|
|
summary = await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m, land)
|
|
if summary:
|
|
logger.info(
|
|
"초기 계획노선 폴리라인: project_id=%s 노드 %d · 곡선 %d · 위반 %d (R %.1fm)",
|
|
project_id,
|
|
summary["nodes"],
|
|
summary["curves"],
|
|
summary["violations"],
|
|
radius_m,
|
|
)
|
|
except Exception: # noqa: BLE001 — 없으면 화면이 처음 열릴 때 만든다
|
|
logger.exception("초기 계획노선 폴리라인 생성 실패(계속 진행): %s", project_id)
|
|
|
|
|
|
def redesign_solve_request(
|
|
params: dict[str, Any], selection: dict[str, Any], surface_model_id: int
|
|
) -> Any:
|
|
"""stage 2 params(사용자 저장분) + 새 확정 지표면 → as_planned 풀기 요청(노선 저장도 씀)."""
|
|
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
|
|
|
|
points = params.get("points") or {}
|
|
options = params.get("options") or {}
|
|
return RouteSolveRequest(
|
|
filter_key=str(selection.get("source_filter") or params.get("filter_key")),
|
|
method=str(selection.get("method") or params.get("method") or "dtm"),
|
|
smooth=bool(selection.get("smooth", params.get("smooth", False))),
|
|
surface_model_id=surface_model_id,
|
|
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사·
|
|
# 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측:
|
|
# 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음).
|
|
# PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다.
|
|
algorithm="as_planned",
|
|
bp=points["bp"],
|
|
ep=points["ep"],
|
|
cp=points.get("cp") or [],
|
|
ap=points.get("ap") or [],
|
|
fp=points.get("fp") or [],
|
|
station_interval_m=params.get("station_interval_m"),
|
|
cross_half_width_m=params.get("cross_half_width_m"),
|
|
cross_sample_interval_m=params.get("cross_sample_interval_m"),
|
|
long_sample_interval_m=params.get("long_sample_interval_m"),
|
|
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
|
|
paved=bool(options.get("paved", False)),
|
|
terrain_type=str(options.get("terrain_type") or "special"),
|
|
# 설계속도 · 최소고 강제 · 시작 측점도 옮김 — 빠지면 노선 저장마다 20 · 꺼짐으로(448 E1)
|
|
design_speed_kph=options.get("design_speed_kph"),
|
|
enforce_pipe_clearance=params.get("enforce_pipe_clearance"),
|
|
station_display=params.get("station_display"),
|
|
main_direction=str(options.get("main_direction") or "auto"),
|
|
min_curve_radius_m=options.get("min_curve_radius_m"),
|
|
max_uphill_grade=options.get("max_uphill_grade"),
|
|
max_downhill_grade=options.get("max_downhill_grade"),
|
|
min_uphill_grade=options.get("min_uphill_grade"),
|
|
min_downhill_grade=options.get("min_downhill_grade"),
|
|
weights=options.get("weights"),
|
|
allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)),
|
|
max_grade_pct=params.get("max_grade_pct"),
|
|
min_vertical_radius_m=params.get("min_vertical_radius_m"),
|
|
min_tangent_length_m=params.get("min_tangent_length_m"),
|
|
balance_segment_length_m=params.get("balance_segment_length_m"),
|
|
start_elevation_offset_m=params.get("start_elevation_offset_m"),
|
|
end_elevation_offset_m=params.get("end_elevation_offset_m"),
|
|
)
|