fix(chain): 자동 설계 체인에 배수유역 분석·관 지점 확정 단계 추가

계획선 1차 로직(design_pipe_anchored_profile)은 저장된 관 지점을 변화점으로
쓰는데, 자동 설계 체인이 그 파일을 만들어 주지 않았다. 그래서 초기 계획선이
2차 폴백(지반 추종 직선 분할)으로 산출돼 관 자리 틸팅·R이 통째로 빠지고,
B05/B06에 들어와도 배수유역도가 비어 있었다.

- _prepare_drainage_pipes_and_reprofile() 신설: 배수유역 분석 → 기본 관 지점
  확정 저장 → 배관 정착 계획선으로 종횡단 재생성. 실패해도 체인을 멈추지 않는다.
- run_auto_design_chain: B05 경로 확정 다음, B06 확정 전에 호출.
- run_redesign_chain: 노선이 바뀌었으므로 refresh=True로 호출 — 저장된 배수유역
  분석 응답은 노선 변경을 스스로 알지 못한다.

검증(테스트 프로젝트 fc86f247, 노선 350m): 관 4개(계류 1 + 간격 보충 3) 확정 후
종횡단 재생성 → PVI 6개(BP + 관 4자리 + EP)가 관 측점과 일치하고 각 자리에
R(1108.57/180.79/450.0/580.97m)이 붙는 것을 확인. 반영 전에는 관과 무관한
지반 추종 변화점이었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 15:56:29 +09:00
co-authored by Claude Opus 5
parent c5d4b3f046
commit 2e8cf0d1c7
@@ -52,6 +52,81 @@ def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, f
return [{"x": x, "y": y} for x, y in points]
async def _prepare_drainage_pipes_and_reprofile(
project_id: UUID, route_id: int, *, refresh: bool = False
) -> bool:
"""배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성.
계획선 1차 로직(`design_pipe_anchored_profile`)은 **저장된 관 지점**을 변화점으로 쓴다.
자동 체인이 이 파일을 만들어 주지 않으면 초기 계획선이 2차 폴백(지반 추종 직선 분할)으로
산출돼 관 자리 틸팅·R이 통째로 빠진다(2026-08-08 사용자 보고). 같은 이유로 B05·B06에
들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다.
실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다.
"""
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_sections
from B06_Section.B06_Section_Schema import SectionRegenerateRequest
from config.config_db import get_db_pool
from config.config_system import SECTION_CROSS_HALF_WIDTH_M
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
# 2) 기본 관(도로 × 상류 세류선) + 최대 간격 자동 보충을 정본으로 저장한다.
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)
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
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_sections(
project_id, route_id, SectionRegenerateRequest(cross_half_width_m=half_width)
)
if isinstance(regenerated, JSONResponse):
logger.error(
"자동 설계 체인 배관 정착 계획선 재산출 실패: project_id=%s status=%s",
project_id,
regenerated.status_code,
)
return False
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
) -> dict[str, Any] | None:
@@ -134,6 +209,10 @@ async def run_auto_design_chain(
)
return None
# 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출.
# B06 확정 전에 끝내야 확정 스냅샷에 배관 정착 계획선이 담긴다.
await _prepare_drainage_pipes_and_reprofile(project_id, route_id)
# 5) B06 횡단 설계 확정 — 미지정 측점을 기본값으로 채워 저장. stage 3은
# IN_PROGRESS(스텝바 노란 표시)로 남겨 사용자 검토·확정을 기다린다.
sections_result = await confirm_sections(
@@ -315,6 +394,10 @@ async def run_redesign_chain(
confirm_result.status_code,
)
return
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True)
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
section_request = (
SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"])