refactor(510-2 서버 자동 관 옛 길 걷음): auto all · add 갈래 걷음(부르는 곳 0 · detail-basins · drainage/live · build_detail · 저장본 열쇠 · computeDetailBasins 셋째 인자) · 전처리 첫 자동 관 그대로 · test_369 옛 all/add 시험 걷음 · test_b05_structures_router 이관 5 = 가짜 함수를 (legacy, standing) 쌍으로 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 13:14:13 +09:00
co-authored by Claude Sonnet 5.5
parent 59c2907b73
commit 7b72dffd60
9 changed files with 39 additions and 144 deletions
+2 -4
View File
@@ -624,16 +624,14 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
*
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369) · `auto`([자동 제안]) = "all" 전체 자동
* (같은 자리 관은 속성 유지) · "add" 지금 관 + 빈 구간 보충. */
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369). */
export async function computeDetailBasins(
projectId: string,
points: DetailPipeInput[] | null,
auto?: "all" | "add",
): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
method: "POST",
body: JSON.stringify(auto ? { points, auto } : { points }),
body: JSON.stringify({ points }),
});
}
+13 -40
View File
@@ -73,8 +73,6 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
# 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3).
# 머리 「배수유역 낡음」 = 화면 `STALE_PREFIX`(B05 Drainage_Live · 369) 짝.
# [자동 제안] 방식(369) — None = 지금 목록 그대로(없으면 세류선 교차만) · all · add
AUTO_MODES = (None, "all", "add")
STALE_MESSAGE = (
"배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다."
)
@@ -86,22 +84,20 @@ def _build(
points: list[PipePoint] | None,
routing: Any = None,
upstream_lonlat: list | None = None,
auto: str | None = None,
) -> tuple[DrainageDetail | None, list[PipePoint]]:
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
`points`가 None 이면 B04 기본 관(세류선 교차)만. 있으면 그 자리를 그대로 쓰되, 관이 왜 거기
있는지(기본/자동/수동)는 넘겨받은 목록의 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로
두면 전부 "수동"이 된다. `auto`([자동 제안] · 369) = "all" 자리 새로 · "add" 빈 구간 보충 —
같은 자리(0.05 m) 관만 속성 · id 를 이어받고 나머지는 새 관(등록부 기본값).
두면 전부 "수동"이 된다. 원본에 없던 자리의 새 관은 등록부 기본값을 받는다.
"""
directory = drainage_dir(stored_path)
chainages = [point.chainage_m for point in points] if points is not None else None
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat, auto)
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat)
if detail is None:
return None, []
if points is not None:
_retag(detail.pipes, points, keep_new=auto is not None)
_retag(detail.pipes, points)
rebuilt = _rebuilt(detail)
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
fresh = [
@@ -142,14 +138,11 @@ _DETAIL_CACHE_SIZE = 4
DETAIL_CACHE_FILENAME = "04_detail_cache.pkl"
def _detail_key(
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None
) -> str:
def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str:
directory = drainage_dir(context.stored_path)
digest = hashlib.sha1(usedforsecurity=False)
listed = None if points is None else [point.as_dict() for point in points]
# auto 를 열쇠에 — 같은 관 목록이라도 [자동 제안] 결과와 섞이지 않게(369 · 옛 저장본도 비껴감)
digest.update(json.dumps([listed, auto], sort_keys=True, ensure_ascii=False).encode())
digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode())
for vertex in context.vertices:
digest.update(f"{vertex.x:.3f},{vertex.y:.3f},{vertex.z:.3f};".encode())
routing = STAGES["road_routing"]
@@ -180,10 +173,10 @@ def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None:
def _build_cached(
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None
context: DrainageContext, points: list[PipePoint] | None
) -> tuple[DrainageDetail | None, list[PipePoint]]:
"""`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로)."""
key = _detail_key(context, points, auto)
key = _detail_key(context, points)
hit = _DETAIL_CACHE.get(key)
if hit is None:
try:
@@ -193,7 +186,7 @@ def _build_cached(
except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError):
hit = None
if hit is None:
detail, carried = _build(context.stored_path, context, points, auto=auto)
detail, carried = _build(context.stored_path, context, points)
if detail is None:
return None, []
hit = (detail, carried)
@@ -289,20 +282,8 @@ def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None:
return math.ceil(depth * 100 - 1e-9) / 100
def _retag(
pipes: list[StructureCandidate], points: list[PipePoint], keep_new: bool = False
) -> None:
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.
`keep_new`([자동 제안]) = 같은 자리(0.05 m) 관만 · 새로 놓인 관은 계산기 사유
(stream · spacing) 그대로.
"""
if keep_new:
picks = carry_indices([pipe.chainage_m for pipe in pipes], [p.chainage_m for p in points])
for pipe, pick in zip(pipes, picks):
if pick is not None:
pipe.reason = points[pick].source
return
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
for pipe in pipes:
source = by_chainage.get(round(pipe.chainage_m, 2))
@@ -481,16 +462,11 @@ async def _resolve(
돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음.
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
요청 `auto`([자동 제안] · 369) = "all" · "add" — `_build` 참고 · 없으면 None.
"""
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
auto = (payload or {}).get("auto")
if auto not in AUTO_MODES:
message = f"auto 는 {', '.join(m for m in AUTO_MODES if m)} 중 하나입니다."
return JSONResponse(status_code=400, content={"status": "error", "message": message})
try:
raw = (payload or {}).get("points")
requested = None if raw is None else parse_pipe_points(raw)
@@ -504,7 +480,7 @@ async def _resolve(
saved = requested is None and stored is not None
if analysis_route_signature(context.stored_path) != signature:
if points is None or auto is not None: # 자동 자리 = 지금 노선 분석이 있어야(369)
if points is None:
return JSONResponse(
status_code=409, content={"status": "stale", "message": STALE_MESSAGE}
)
@@ -513,11 +489,9 @@ async def _resolve(
# [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만
if fresh:
result = await asyncio.to_thread(
lambda: _build(context.stored_path, context, points, auto=auto)
)
result = await asyncio.to_thread(lambda: _build(context.stored_path, context, points))
else:
result = await asyncio.to_thread(_build_cached, context, points, auto)
result = await asyncio.to_thread(_build_cached, context, points)
detail, resolved = result
if detail is None:
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
@@ -549,7 +523,6 @@ async def post_detail_basins(
`points` 칸 없이 부르면 저장분을 무시하고 기본 관(세류선 교차)만으로 되돌린다 —
[배관 배치 초기화] · 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
`auto` = [자동 제안](369) — "all" 전체 자동 · "add" 지금 관 + 빈 구간 보충.
"""
resolved = await _resolve(project_id, payload, use_stored=False)
if isinstance(resolved, JSONResponse):
@@ -22,7 +22,7 @@ from fastapi.responses import JSONResponse
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import AnalysisStopped
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from B04_PreProcess.B04_PreProcess_Router_Basins import AUTO_MODES, _build, _payload
from B04_PreProcess.B04_PreProcess_Router_Basins import _build, _payload
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare, router
from common_util.common_util_drainage_context import _open_sampler, load_drainage_context
from common_util.common_util_drainage_detail import RoadRouting
@@ -79,7 +79,7 @@ async def post_drainage_live(
project_id: UUID, payload: dict[str, Any] | None = None
) -> dict[str, Any] | JSONResponse:
"""몸통 = {generation, route: RouteDraft | null(저장 노선),
points: 관 목록 | null(세류선 교차만), auto: [자동 제안] 방식(all · add · 369) | 없음}."""
points: 관 목록 | null(세류선 교차만)}."""
body = payload or {}
generation = int(body.get("generation") or 0)
key = str(project_id)
@@ -88,9 +88,6 @@ async def post_drainage_live(
def superseded() -> bool:
return _LATEST_GENERATION.get(key) != generation
auto = body.get("auto")
if auto not in AUTO_MODES:
return JSONResponse(status_code=400, content={"status": "error", "message": "auto 틀림"})
try:
raw = body.get("points")
points = None if raw is None else parse_pipe_points(raw)
@@ -143,7 +140,7 @@ async def post_drainage_live(
[list(to_lonlat(x, y)) for x, y in line.coords] for line in preview.region.split.upstream
]
detail, carried = await asyncio.to_thread(
lambda: _build(context.stored_path, live, points, routing, upstream, auto=auto)
lambda: _build(context.stored_path, live, points, routing, upstream)
)
if detail is None:
return JSONResponse(