Files
Aislo/common_util/common_util_drainage_context.py
T
eomsangdon d5617a8b58 @
perf(B04): 배수유역 세부 계산의 헛일 제거 — 282→154ms

DB 가 원격(dsm.chemifactory.com)이라 질의 하나가 곧 왕복 12ms 임을 실측.
순차 6건 130ms 중 대부분이 왕복 대기였음.

- context: 서로 기다릴 이유가 없는 질의를 두 묶음으로 asyncio.gather
  (묶음마다 자기 커넥션 — 풀 최대 20 이라 여유). 계획노선 읽기·지표면
  샘플러 열기도 같이 보냄.
- 격자 산출물(npz 3.1MB): 관을 옮길 때마다 다시 읽던 것을 파일 자국
  (수정시각·크기) 열쇠로 재사용. 분석이 다시 돌면 자국이 바뀌어 저절로 새로 읽음.

자체검증 — 용화 프로젝트(관 11·유역 11) 5회 중앙 282→154ms.
같은 입력에 응답 전체가 한 글자도 안 달라짐(JSON 정렬 비교).
전체 테스트 390 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-06 20:57:51 +09:00

162 lines
7.2 KiB
Python

"""배수유역 세부 설계 입력 준비 (B04 관리자 화면 · B05 사용자 화면 공용).
두 화면이 같은 관 목록과 같은 세부유역을 보여 주려면 **입력이 한 글자도 달라선 안 된다**
(2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 한 곳에서 만들어 양쪽에 넘긴다.
노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 받은 원청 계획노선(정본)**이다. B04 격자
해석이 그 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 관 위치가
어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 → [[common_util_route_profile]].
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from uuid import UUID
from pyproj import Transformer
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import (
get_latest_route,
get_route_points,
get_surface_crs_epsg,
)
from B06_Section.B06_Section_Repository import get_longitudinal_section
from common_util.common_util_crs import resolve_project_crs
from common_util.common_util_route_geometry import (
RouteVertex,
build_route_vertices,
load_design_route,
)
from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
_INPUT_SUBDIR = Path("B03_FileInput") / "input"
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
@dataclass
class DrainageContext:
"""세부 설계 한 번에 필요한 입력 묶음."""
stored_path: str
project_root: Path
vertices: list[RouteVertex] = field(default_factory=list)
z_source: str = Z_SOURCE_CSV
# 사업지 좌표계 — pyproj 입력 문자열(`EPSG:n` 또는 .prj WKT). 노선 CSV의 `crs_epsg`
# 열은 표시용 라벨이라 실좌표계와 다를 수 있다(2026-09-01 실측: 라벨 5179, 실제 5176).
crs: str = "EPSG:5186"
route_id: int | None = None
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y)
# 1단계에서 확정한 지표면 선택(source_filter·method·smooth). B07 라이다 계획평면도가
# 어느 DTM 격자로 음영기복을 만들지 고르는 데 쓴다(2026-09-04).
surface_params: dict[str, Any] = field(default_factory=dict)
async def _query(repository_call: Callable[..., Any], *args: Any) -> Any:
"""저장소 함수 하나를 **자기 커넥션**으로 실행한다 — 같이 보내려면 커넥션이 갈려야 한다.
풀 최대치가 20이라 여기서 서너 개를 동시에 잡아도 여유가 있다(`config_system.DB_POOL_MAX`).
"""
pool = get_db_pool()
async with pool.acquire() as connection:
return await repository_call(connection, *args)
async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]:
"""노선·종단 Z·좌표계를 준비한다. 실패하면 (None, 사용자에게 보일 사유).
B05 확정 경로는 **있으면 쓰고 없으면 넘어간다** — B04는 WF1 화면이라 아직 경로가 없다.
"""
# DB 가 원격이라 질의 하나가 곧 왕복 12ms 다(2026-09-06 실측: 6건 순차 130ms).
# 서로 기다릴 이유가 없는 것끼리 묶어 두 묶음으로 보낸다 — 값은 그대로고 왕복만 겹친다.
stored_path, route, surface_params = await asyncio.gather(
_query(get_project_storage_relative_path, project_id),
_query(get_latest_route, project_id),
_query(get_surface_confirmation_params, str(project_id)),
)
if not stored_path:
return None, "프로젝트 저장 경로가 없습니다."
route_points: list[dict[str, Any]] = []
longitudinal: dict[str, Any] | None = None
surface_model_id = (route or {}).get("surface_model_id")
if route:
route_points, section, db_epsg = await asyncio.gather(
_query(get_route_points, int(route["id"])),
_query(get_longitudinal_section, project_id, int(route["id"])),
_query(
get_surface_crs_epsg,
project_id,
int(surface_model_id) if surface_model_id else 0,
),
)
longitudinal = (section or {}).get("data")
else:
db_epsg = await _query(get_surface_crs_epsg, project_id, 0)
project_root = Path(resolve_stored_project_path(stored_path))
# 설계 계통과 **같은 노선**을 쓴다 — 지표면 밖 구간을 자른 뒤의 노선이다. 원본을 그대로
# 쓰면 유역·관이 확정 노선 밖에도 찍혀 종단 계획선이 그 관을 버린다(2026-09-01).
planned, sampler = await asyncio.gather(
asyncio.to_thread(_read_planned_route, project_root, surface_params),
asyncio.to_thread(_open_sampler, project_root, surface_params),
)
if planned is None or len(planned.vertices) < 2:
return None, "계획노선을 읽지 못했습니다. B03에서 노선 파일을 확인하세요."
vertices, z_source = await asyncio.to_thread(
resolve_route_profile,
planned.vertices,
route_vertices=build_route_vertices(route_points) if route_points else None,
longitudinal=longitudinal,
sampler=sampler,
)
# 노선을 실제로 담고 있는 좌표계를 쓴다 — `load_design_route()`가 .prj 좌표계로
# 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다(라벨은 안 씀).
crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=db_epsg)
transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
return (
DrainageContext(
stored_path=stored_path,
project_root=project_root,
vertices=vertices,
z_source=z_source,
crs=crs,
route_id=int(route["id"]) if route else None,
to_lonlat=lambda x, y: transformer.transform(x, y),
surface_params=dict(surface_params),
),
"",
)
def _read_planned_route(project_root: Path, surface_params: dict[str, Any] | None = None):
"""설계용 계획노선을 읽는다(파일 접근이라 스레드에서 돈다)."""
return load_design_route(project_root, surface_params)
def _open_sampler(project_root: Path, surface_params: dict[str, Any]):
"""확정 지표면 표고 sampler를 연다. 모델이 없으면 None(종단 Z가 다른 경로로 폴백)."""
try:
return build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(surface_params["source_filter"]),
str(surface_params["method"]),
bool(surface_params["smooth"]),
)
except (FileNotFoundError, ValueError, OSError) as exc:
logger.warning("배수유역: 확정 지표면 sampler를 열지 못했습니다 — %s", exc)
return None