Files
Aislo/B05_Profile/B05_Profile_Route_Save.py
T
eomsangdonandClaude Opus 5.5 8ae90ad7b6 feat(346-3): 노선 저장 서버 길 — 배수유역 뗌 · 관 지킴 · 측점 열쇠 이월 · 초기값은 추천 [적용] 때만
- POST /route/replan = 노선 저장 체인(B05_Profile_Route_Save) — 풀기 → 구조물 · 관 좌표 따라감 → 새 세류선 교차점 기본 관 · 옛 노선 밖 오목 지점 보충 → 종횡단 한 번 → 계획고 옮김(바뀐 구간만 새 지반) → 횡단 이월(측점 열쇠 grid · pipe · structure · end · CARRY 일곱 + 구조물 조정) → B06 확정 · 3D
- 관 정본 안 지움 · 배수유역 체인에서 뗌(관 정본 새 노선 지문 → B04 분석 낡음)
- 요청에 노선 초안(RouteDraft 모양 draft) · as_initial · 응답에 관 · 구조물 · 버린 횡단 수 · 초기값 여부 · 시간
- 초기값 = 자동설계 + 추천 [적용](as_initial)만 · 손 편집 저장은 안 바꿈
- 실패 = 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행 · stage 2 params 되살림
- 시험 test_346_3_route_save · test_133_2 새 체인으로 · 시간 재기 도구 run_346_3_route_save_timing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 11:43:27 +09:00

497 lines
22 KiB
Python

"""노선 저장 서버 길(346-3) — 노선 초안을 정본으로: 노선 → 풀기 → 관 · 구조물 따라감 →
종 · 횡단 한 번 → 계획고 옮김 → 횡단 이월 → B06 확정(서버 재계산 · 3D). 배수유역은 체인에서 뗌.
API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan_route`):
요청 `RouteSaveRequest` = 옛 칸(`vertices` · `initial` · `balanced_profile` · `terrain` ·
`station_snap`) +
`draft` — 계약 `RouteDraft`(`A00_Common/design/design_pipeline_types.ts`) 그대로
`{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc,
stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것.
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped,
drainage_stale: true, seconds}`.
오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) ·
409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을
옛것으로 되살린 뒤).
규칙:
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry`) · 추천 「종단 반영」 켬이면 균형 종단
(`apply_profile_target`).
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
"""
from __future__ import annotations
import asyncio
import json
import logging
import shutil
import tempfile
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
import numpy as np
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B05_Profile.B05_Profile_Route_Save_Parts import (
add_crossing_pipes,
carry_cross_inputs,
concave_chainages,
fill_concave_pipes,
follow_pipes,
station_keys,
vertices_of,
)
from B05_Profile.B05_Profile_Schema import RouteReplanRequest, RouteVertexInput
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
class RouteDraftInput(BaseModel):
"""노선 초안 — 계약 `RouteDraft` 모양 그대로(칸 이름 camelCase)."""
rev: int = 0
nodes: list[tuple[float, float]] = Field(default_factory=list)
curveOn: list[bool] = Field(default_factory=list) # noqa: N815 — 계약 칸 이름
curveRadius: list[float | None] = Field(default_factory=list) # noqa: N815
curveLock: list[str | None] = Field(default_factory=list) # noqa: N815
curveArc: list[float | None] = Field(default_factory=list) # noqa: N815
stationSnap: bool = False # noqa: N815
terrain: Literal["las", "sheet"] | None = None
class RouteSaveRequest(RouteReplanRequest):
"""노선 저장 요청 — 옛 [노선 반영] 칸 + 초안(`draft`) · 추천 [적용](`as_initial`)."""
draft: RouteDraftInput | None = None
as_initial: bool = False
def resolved(self) -> RouteReplanRequest:
"""초안이 오면 노드 · 곡선 · 스냅 · 지형을 옛 칸으로 편다. 칸 길이가 어긋나면 ValueError."""
if self.draft is None:
return self
draft = self.draft
count = len(draft.nodes)
for name in ("curveOn", "curveRadius"):
values = getattr(draft, name)
if values and len(values) != count:
raise ValueError(
f"노선 초안 {name} 길이({len(values)})가 노드 수({count})와 다릅니다."
)
vertices = [
RouteVertexInput(
x=x,
y=y,
curve=draft.curveOn[index] if draft.curveOn else True,
radius_m=draft.curveRadius[index] if draft.curveRadius else None,
)
for index, (x, y) in enumerate(draft.nodes)
]
return self.model_copy(
update={
"vertices": vertices,
"station_snap": draft.stationSnap,
"terrain": draft.terrain,
}
)
class _Backup:
"""체인이 고치는 파일을 손에 쥠 — 실패하면 그대로 되돌림(노선 · 관 · 구조물 · 종횡단)."""
def __init__(self, project_root: Path, paths: list[Path], folders: list[Path]) -> None:
self.held = tempfile.mkdtemp(prefix="route_save_", dir=str(project_root))
self.files = {path: path.read_bytes() if path.is_file() else None for path in paths}
self.folders: dict[Path, Path | None] = {}
for index, folder in enumerate(folders):
copy = Path(self.held) / f"dir{index}"
self.folders[folder] = shutil.copytree(folder, copy) if folder.is_dir() else None
def restore(self) -> None:
for path, saved in self.files.items():
if saved is None:
path.unlink(missing_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(saved)
for folder, copy in self.folders.items():
shutil.rmtree(folder, ignore_errors=True)
if copy is not None:
shutil.copytree(copy, folder)
def drop(self) -> None:
shutil.rmtree(self.held, ignore_errors=True)
class _Fail(RuntimeError):
"""체인 한 단계 실패 — 사유를 그대로 화면까지(까닭을 덮지 않음)."""
def _reason(response: JSONResponse) -> str:
try:
return bytes(response.body).decode("utf-8", "replace")[:300]
except Exception: # noqa: BLE001 — 사유는 로그 · 응답용
return f"status={response.status_code}"
def _read_json(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]:
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
if not route_data_path:
return []
try:
return [[x, y] for x, y, _ in _load_route_polyline(project_root, route_data_path)]
except (OSError, ValueError):
return []
def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple:
"""새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀)."""
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M
from common_util.common_util_surface_sampler import build_surface_sampler
try:
sampler = build_surface_sampler(
project_root / "B04_PreProcess" / "models",
str(selection.get("source_filter")),
str(selection.get("method") or "dtm"),
bool(selection.get("smooth")),
)
except (FileNotFoundError, KeyError, OSError, ValueError):
logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀")
return np.zeros(0), np.zeros(0)
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M)
xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64)
z, valid = sampler.sample_xy(xy)
return chainage[valid], np.asarray(z, dtype=np.float64)[valid]
async def _place_pipes(
project_id: UUID,
project_root: Path,
stored_path: str,
selection: dict[str, Any],
old_pipes: list,
old_xy: list[list[float]],
points: list[dict[str, float]],
) -> tuple[list, dict[str, int]]:
"""관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문)."""
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만
vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), []
else:
vertices, streams = prepared["vertices"], prepared["streams"]
pairs = follow_pipes(old_pipes, vertices)
total = float(vertices[-1].chainage_m)
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices)
concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy)
await asyncio.to_thread(
save_pipe_points,
stored_path,
route_signature(vertices),
[pipe for _, pipe in pairs],
vertices,
)
return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave}
async def _carry_profile(
project_root: Path,
relative: str,
route_id: int,
old_alignment: dict[str, Any],
old_xy: list[list[float]],
new_xy: list[list[float]],
) -> None:
"""바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴."""
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
from common_util.common_util_json import atomic_write_json
if not old_alignment.get("base_pvi"):
return # 옛 선형 없음 — 새 지반 첫 계획선 그대로
path = project_root / relative
longitudinal = _read_json(path)
alignment = longitudinal.get("profile_alignment") or {}
span = changed_span([tuple(p) for p in old_xy], [tuple(p) for p in new_xy])
if span is None: # 같은 노선 — 옛 계획선 통째
base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {}
else:
base, edits = carry_profile_edits(
old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"]
)
alignment["base_pvi"] = base
await asyncio.to_thread(atomic_write_json, path, longitudinal)
_alignment, summary, _dropped = await asyncio.to_thread(
_apply_alignment_edits, project_root, relative, edits
)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
await update_longitudinal_grade_summary(
connection, route_id=route_id, grade_summary=summary
)
await connection.commit()
async def _carry_cross(
project_id: UUID,
project_root: Path,
route_id: int,
relative: str,
old_long: dict[str, Any],
old_designs: list[dict[str, Any]],
old_pipes: list,
old_structures: list,
pairs: list,
interval_m: float,
standard: dict[str, Any] | None,
) -> int:
"""측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages
from B06_Section.B06_Section_Router_Design import (
CARRY_INPUT_KEYS,
STRUCTURE_ADJUST_KEYS,
compute_default_designs,
)
new_long = _read_json(project_root / relative)
_, new_structures = load_structures(str(project_root))
old_keys = station_keys(
old_long.get("stations") or [], list(enumerate(old_pipes)), old_structures, interval_m
)
new_keys = station_keys(new_long.get("stations") or [], pairs, new_structures, interval_m)
designs = {
round(float(row["chainage_m"]), 3): row["design"]
for row in old_designs
if isinstance(row.get("design"), dict)
}
inputs, adjusts, dropped = carry_cross_inputs(
old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS
)
chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, [])
entries = await asyncio.to_thread(
compute_default_designs, project_root, relative, chainages, standard, inputs
)
entries = [(at, {**design, **adjusts.get(round(at, 3), {})}) for at, design in entries]
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
await merge_cross_section_designs(
connection, route_id=route_id, entries=entries, replace=True, project_id=project_id
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return dropped
async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) -> None:
"""새 노선 행(종횡단 함께 지워짐)을 지우고 stage 2 params 를 옛것으로 — 최신 노선 = 옛 노선."""
from common_util.common_util_workflow_state import start_stage
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
async with connection.cursor() as cursor:
await cursor.execute(
"DELETE FROM routes WHERE project_id = %s AND id > %s",
(str(project_id), old_route_id),
)
await start_stage(cursor, str(project_id), 2, params)
await connection.commit()
except Exception:
await connection.rollback()
logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id)
async def run_route_save_chain(
project_id: UUID,
project_root: Path,
stored_path: str,
surface_model_id: int,
selection: dict[str, Any],
old_route: dict[str, Any],
points: list[dict[str, float]],
previous_params: dict[str, Any],
profile_target: tuple[list[float], list[float]] | None = None,
) -> dict[str, Any]:
"""노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤)."""
import aiomysql
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request
from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR
from B05_Profile.B05_Profile_Router import (
_section_options,
confirm_latest_route,
solve_route_record,
)
from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_latest_section_options,
get_longitudinal_section,
)
from B06_Section.B06_Section_Router import regenerate_route_sections
from B06_Section.B06_Section_Router_Confirm import confirm_sections
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file
from common_util.common_util_initial_snapshot import clear_designing, mark_designing
from common_util.common_util_workflow_state import get_workflow_state
marks = [("시작", time.perf_counter())]
old_route_id = int(old_route["id"])
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
old_long_row = await get_longitudinal_section(connection, project_id, old_route_id)
old_designs = await get_cross_section_designs(connection, old_route_id)
old_section_options = await get_latest_section_options(connection, project_id)
stage2 = next((s for s in state.get("stages", []) if int(s.get("stage_no", -1)) == 2), None)
params = (stage2 or {}).get("params") or {}
relative_old = str((old_long_row or {}).get("longitudinal_file_path") or "")
old_long = _read_json(project_root / relative_old) if relative_old else {}
old_xy = _xy(project_root, old_route.get("route_data_path"))
old_pipes = read_pipe_points_file(pipe_points_path(stored_path))
_, old_structures = load_structures(str(project_root))
stage_dir = project_root / _STAGE_SUBDIR
backup = _Backup(
project_root,
[
project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"),
pipe_points_path(stored_path),
Path(structures_file_path(str(project_root))),
],
[stage_dir / "longitudinal", stage_dir / "cross_sections"],
)
mark_designing(project_root)
try:
# 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움)
try:
structures = await asyncio.to_thread(
follow_structures_to_route, project_root, old_route.get("route_data_path"), points
)
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
structures = {"moved": 0, "far": 0}
request = redesign_solve_request(params, selection, surface_model_id)
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solved, JSONResponse):
raise _Fail(f"노선 풀기 실패({solved.status_code}): {_reason(solved)}")
new_route_id = int(solved.route_id)
confirmed = await confirm_latest_route(project_id, None, mark_stage_complete=False)
if isinstance(confirmed, JSONResponse):
raise _Fail(f"노선 확정 실패({confirmed.status_code})")
new_xy = _xy(project_root, solved.route_data_path)
marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter()))
pairs, pipes = await _place_pipes(
project_id, project_root, stored_path, selection, old_pipes, old_xy, points
)
marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter()))
seed = asdict(_section_options(request, old_section_options))
seed.pop("extra_stations", None)
made = await regenerate_route_sections(
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
)
if isinstance(made, JSONResponse):
raise _Fail(f"종횡단 생성 실패({made.status_code}): {_reason(made)}")
async with pool.acquire() as connection:
new_long_row = await get_longitudinal_section(connection, project_id, new_route_id)
relative = str((new_long_row or {}).get("longitudinal_file_path") or "")
marks.append(("종횡단 한 번", time.perf_counter()))
if profile_target is not None:
from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target
await apply_profile_target(project_id, new_route_id, profile_target)
else:
await _carry_profile(
project_root,
relative,
new_route_id,
old_long.get("profile_alignment") or {},
old_xy,
new_xy,
)
marks.append(("계획고 옮김", time.perf_counter()))
standard = stored_standard_cross_section(old_long_row)
dropped = await _carry_cross(
project_id,
project_root,
new_route_id,
relative,
old_long,
old_designs,
old_pipes,
old_structures,
pairs,
float(seed["station_interval_m"]),
standard,
)
marks.append(("횡단 이월(측점 열쇠)", time.perf_counter()))
done = await confirm_sections(
project_id,
new_route_id,
SectionConfirmRequest(standard_cross_section=standard) if standard else None,
mark_stage_complete=False,
)
if isinstance(done, JSONResponse):
raise _Fail(f"종횡단 확정 실패({done.status_code})")
marks.append(("B06 확정 · 서버 재계산 · 3D", time.perf_counter()))
except Exception as exc: # noqa: BLE001 — 어느 단계든 옛 노선으로 되살림
if not isinstance(exc, _Fail):
logger.exception("노선 저장 체인 실패: project_id=%s", project_id)
await asyncio.to_thread(backup.restore)
await _rollback(project_id, old_route_id, previous_params)
return {"error": str(exc) if isinstance(exc, _Fail) else f"노선 저장 중 오류: {exc}"}
finally:
backup.drop()
clear_designing(project_root)
_log_steps("재확정 체인(노선 저장)", marks)
return {
"route_id": new_route_id,
"total_length_m": float(solved.total_length_m or 0.0),
"pipes": pipes,
"structures": structures,
"cross_dropped": dropped,
"drainage_stale": True,
"seconds": round(marks[-1][1] - marks[0][1], 2),
}