diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index 723e3f9d8..992b055ec 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -11,7 +11,6 @@ import asyncio import hashlib import json import logging -import math import pickle import time from pathlib import Path @@ -21,33 +20,18 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import Point from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages -from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( - drainage_dir, - write_stage, -) -from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( - mask_row_spans, -) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from B04_PreProcess.B04_PreProcess_Router_Watershed_Output import ( - _as_polygons, - _boundary_geometry, - _flow_payload, - _grid_bbox_lonlat, - _grid_bbox_polygon, - _line_lonlat, - _polygon_rings, - _write_road_routing, - _write_stage_arrays, + RESPONSE_SCHEMA_VERSION, + _response_path, + write_analysis, ) from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg from common_util.common_util_crs import resolve_project_crs -from common_util.common_util_drainage_pipes import pipe_signature, route_signature from common_util.common_util_route_geometry import ( - StructureCandidate, find_planned_route_file, load_design_route, read_planned_route, @@ -64,12 +48,10 @@ from common_util.common_util_wamis_station import ( ) from config.config_db import get_db_pool, run_with_connection from config.config_system import ( - DRAINAGE_ARROW_SPACING_M, DRAINAGE_DESIGN_RETURN_PERIOD_YR, DRAINAGE_RAINFALL_FILENAME, DRAINAGE_RAINFALL_IDF_METHOD, DRAINAGE_RAINFALL_STATION_DIRNAME, - DRAINAGE_RESPONSE_FILENAME, WAMIS_CONTOUR_CACHE_DIR, WAMIS_STATION_RADIUS_M, ) @@ -82,13 +64,6 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"]) CONTOUR_FILE = "도엽_등고선.geojson" STREAM_FILE = "도엽_하천중심선.geojson" -# 응답 형식 판(版). 응답에 항목을 더하거나 값의 의미를 바꾸면 이 값을 올린다 — -# 저장해 둔 옛 응답을 그대로 돌려주면 화면이 조용히 어긋나기 때문이다. -# 2 = 강도 곡선 간격 1m(구간 합) + 유입 집중점(inflow_hotspots) 추가 (2026-08-01) -# 3 = 강도 곡선 구간 기준을 round→floor로 바로잡고(2m 주기 빗살 제거), -# 유입 집중점이 기본 관 옆 최소간격 안쪽을 피하도록 수정 (2026-08-01) -RESPONSE_SCHEMA_VERSION = 3 - def _sheet_dir(stored_path: str) -> Path: return Path(resolve_stored_project_path(stored_path)) / "B04_PreProcess" / "processed" @@ -153,6 +128,17 @@ def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any: # 도엽을 사업지 좌표로 바꾼 저장본(347-1) — 원본 파일 자국 · 좌표계가 같으면 읽기만. SHEET_METRIC_FILENAME = "00_sheet_metric.pkl" +# 메모리 보관(520-2) — (저장본 경로 · 지문) → (하천, 등고선) · 피클 33 MB 읽기(0.3~0.6 s)를 건너뜀. +# 부르는 쪽은 피처를 읽기만 함(고치지 않음) · 최근 몇 벌만. +_SHEETS: dict[str, tuple[list[dict[str, Any]], list[dict[str, Any]]]] = {} +_SHEETS_SIZE = 2 + + +def _keep_sheets(slot: str, value: tuple[list[dict[str, Any]], list[dict[str, Any]]]) -> None: + _SHEETS.pop(slot, None) + _SHEETS[slot] = value + while len(_SHEETS) > _SHEETS_SIZE: + _SHEETS.pop(next(iter(_SHEETS))) def _metric_sheets( @@ -169,15 +155,22 @@ def _metric_sheets( text = "|".join([*marks, str(source_crs)]) key = hashlib.sha1(text.encode(), usedforsecurity=False).hexdigest()[:16] path = cache_dir / SHEET_METRIC_FILENAME + slot = f"{path}|{key}" + kept = _SHEETS.get(slot) + if kept is not None: + _keep_sheets(slot, kept) + return *kept, key try: with path.open("rb") as file: saved = pickle.load(file) if saved.get("key") == key: + _keep_sheets(slot, (saved["streams"], saved["contours"])) return saved["streams"], saved["contours"], key except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError): pass streams = _reproject_features(_load_features(directory, STREAM_FILE), transformer) contours = _reproject_features(_load_features(directory, CONTOUR_FILE), transformer) + _keep_sheets(slot, (streams, contours)) try: cache_dir.mkdir(parents=True, exist_ok=True) with path.open("wb") as file: @@ -187,22 +180,6 @@ def _metric_sheets( return streams, contours, key -def _candidate_payload( - candidate: StructureCandidate, - to_lonlat: Any, -) -> dict[str, Any]: - lon, lat = to_lonlat(candidate.x, candidate.y) - return { - "chainage_m": round(candidate.chainage_m, 2), - "x": candidate.x, - "y": candidate.y, - "lon": lon, - "lat": lat, - "reason": candidate.reason, - "stream_name": candidate.stream_name, - } - - async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: """계획 노선 파일과 도엽 피처, 좌표 변환기를 준비한다. @@ -267,11 +244,6 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: } -def _response_path(stored_path: str) -> Path: - """분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다.""" - return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME - - def _load_saved_response(stored_path: str) -> dict[str, Any] | None: path = _response_path(stored_path) if not path.exists(): @@ -290,16 +262,6 @@ def _load_saved_response(stored_path: str) -> dict[str, Any] | None: return saved -def _save_response(stored_path: str, payload: dict[str, Any]) -> None: - path = _response_path(stored_path) - try: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as file: - json.dump(payload, file, ensure_ascii=False) - except OSError: - logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path) - - def _rainfall_path(stored_path: str) -> Path: return drainage_dir(stored_path) / DRAINAGE_RAINFALL_FILENAME @@ -459,125 +421,9 @@ async def get_primary_region( content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, ) marks.append(("preview_stages(유역 산정)", time.perf_counter())) - region = preview.region - to_lonlat = prepared["to_lonlat"] - # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. - spec = preview.spec or region.spec - domain = preview.domain if preview.domain is not None else region.cell_mask - payload = { - "status": "success", - "schema_version": RESPONSE_SCHEMA_VERSION, - "project_id": str(project_id), - "route_source": prepared["route_source"], - # 지문(342-3) — 노선 · 기본 관 자리가 지금과 다르면 이 결과는 낡음(세부 유역 안 나눔). - "route_signature": route_signature(prepared["vertices"]), - "pipe_signature": pipe_signature([pipe.chainage_m for pipe in preview.pipes]), - "radius_m": region.radius_m, - # 채택된 상류 세류망 = 1차 영역의 기준선. - "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], - # 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다. - "downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream], - "no_contact_count": region.split.no_contact, - # 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. - "road_outside_m": round(region.road_outside_m, 1), - # 1차 영역(버퍼 합집합) 외곽 링 목록. - "region_rings": _polygon_rings(region.area, to_lonlat), - "grid": { - "cell_m": spec.cell_m, - "rows": spec.n_rows, - "cols": spec.n_cols, - # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). - "bbox_cells": spec.size, - "cells": int(domain.sum()) if domain is not None else 0, - "width_m": round(spec.n_cols * spec.cell_m, 1), - "height_m": round(spec.n_rows * spec.cell_m, 1), - # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. - "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), - # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. - # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. - "row_spans": [list(span) for span in mask_row_spans(domain)] - if domain is not None - else [], - }, - # 최외곽 적색 셀 주변 확장 결과. - "expansion": { - "rounds": preview.expand_rounds, - "closed": preview.expand_closed, - "added_cells": preview.expand_added_cells, - "initial_cells": region.active_cells, - }, - # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. - "flow": _flow_payload(preview, domain), - # ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적. - "basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy], - "basin_area_m2": round(preview.basin_area_m2, 1), - # ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. - "strength_profile": [ - [round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile - ], - # ⑥-1 유입 집중점 — 기본 관으로 나눈 구역마다 물이 많이 모이는 자리. - # [누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위]. 화면 마커·하이라이트 대상이다. - "inflow_hotspots": [ - [round(chainage, 1), round(area, 1), zone, rank] - for chainage, area, zone, rank in preview.inflow_hotspots - ], - # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. - "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], - # B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. - # 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다. - "flow_arrows": [ - [*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells] - for x, y, angle, reaches, cells in preview.flow_arrows - ], - # 화살표 간격(m). 화면이 화살표 크기를 정할 때 쓴다 — 서로 닿지 않게 이 값보다 짧게 그린다. - "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, - "from_cache": False, - } - # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. - payload["saved_to"] = write_stage( - prepared["stored_path"], - "primary_region", - { - "primary_region": _as_polygons(region.area), - "upstream": region.split.upstream, - "downstream": region.split.downstream, - "route": [prepared["route_line"]], - "grid_bbox": [_grid_bbox_polygon(spec)], - # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함). - "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), - "pipe": [ - ( - Point(pipe.x, pipe.y), - { - "chainage_m": round(pipe.chainage_m, 2), - "reason": pipe.reason, - "stream_name": pipe.stream_name, - }, - ) - for pipe in preview.pipes - ], - }, - { - "radius_m": region.radius_m, - "road_outside_m": payload["road_outside_m"], - "no_contact_count": region.split.no_contact, - "basin_area_m2": payload["basin_area_m2"], - "pipe_count": len(preview.pipes), - # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. - "grid": { - key: value - for key, value in payload["grid"].items() - if key not in {"bbox_lonlat", "row_spans"} - }, - }, - to_lonlat, - ) - _write_stage_arrays(prepared["stored_path"], preview, domain, spec) - _write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat) - # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. + payload = write_analysis(project_id, prepared, preview, prepared["vertices"]) marks.append(("응답 만들기·저장", time.perf_counter())) _log_steps("배수유역 분석 내부", marks) - _save_response(prepared["stored_path"], payload) return payload diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py index 2bd8f3510..336f6fb46 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py @@ -4,14 +4,20 @@ 답으로만 돌려준다(파생 저장본 = 흐름 방향 · 도엽 좌표만 · 347-1). 세부 유역 나눔까지 같은 요청에서 메모리 격자로 해 격자를 화면에 넘기지 않는다. +저장 노선 기준(초안 없음 · 지문 같음)이면 분석을 primary-region 과 같은 길(`write_analysis`)로 남김 +(520-2) — 노선 [저장] 뒤 [업데이트] 한 번이면 관 · 구조물 조작이 세부 유역만(0.2 s). design/save 가 +같은 프로젝트를 잡고 있으면(되돌림 스냅샷) 남기지 않음 · 남길 때는 같은 잠금을 쥠. + 프로젝트마다 「마지막 차례」를 적어 두고 단계 사이(1차 영역 뒤 · 확장 회차마다 · 세부 유역 앞)에서 -차례가 바뀌었으면 그만둔다(`superseded`). 화면은 늦은 답을 차례 · 노선 지문으로 한 번 더 거른다. +차례가 바뀌었으면 그만둔다(`superseded`). 화면이 요청을 끊으면 `/drainage/live/cancel` 이 그 차례를 +지움(520-2). 화면은 늦은 답을 차례 · 노선 지문으로 한 번 더 거른다. 라우터는 `B04_PreProcess_Router_Watershed.router` 에 붙는다(그 파일 끝에서 읽음). """ from __future__ import annotations import asyncio +import logging import math from dataclasses import replace from typing import Any @@ -24,14 +30,18 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import AnalysisStoppe from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from B04_PreProcess.B04_PreProcess_Router_Basins import _build, _payload from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare, router +from B04_PreProcess.B04_PreProcess_Router_Watershed_Output import write_analysis +from common_util import common_util_save_guard as guard from common_util.common_util_drainage_context import _open_sampler, load_drainage_context from common_util.common_util_drainage_detail import RoadRouting -from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature from common_util.common_util_route_geometry import RouteVertex, build_route_vertices from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_route_profile import resolve_route_profile from config.config_system import DRAINAGE_ARROW_SPACING_M +logger = logging.getLogger(__name__) + # 프로젝트 → 마지막으로 받은 차례 번호. ponytail: 서버 프로세스 하나 전제(여럿이면 DB · 파일로). _LATEST_GENERATION: dict[str, int] = {} SUPERSEDED = {"status": "superseded"} @@ -88,6 +98,10 @@ async def post_drainage_live( def superseded() -> bool: return _LATEST_GENERATION.get(key) != generation + def stopped(where: str) -> dict[str, str]: + logger.info("배수유역 지금 노선 셈 멈춤: project=%s 차례=%d (%s)", key, generation, where) + return SUPERSEDED + try: raw = body.get("points") points = None if raw is None else parse_pipe_points(raw) @@ -112,7 +126,7 @@ async def post_drainage_live( ) vertices, _ = await asyncio.to_thread(resolve_route_profile, drawn, sampler=sampler) if superseded(): - return SUPERSEDED + return stopped("노선") try: preview = await asyncio.to_thread( @@ -124,14 +138,14 @@ async def post_drainage_live( superseded, ) except AnalysisStopped: - return SUPERSEDED + return stopped("유역 셈 도중") if preview is None or preview.routing is None: return JSONResponse( status_code=400, content={"status": "error", "message": "지금 노선으로 배수유역을 정하지 못했습니다."}, ) if superseded(): - return SUPERSEDED + return stopped("세부 유역 앞") live = replace(context, vertices=vertices) to_lonlat = context.to_lonlat @@ -147,7 +161,37 @@ async def post_drainage_live( status_code=400, content={"status": "error", "message": "세부 유역을 나누지 못했습니다."}, ) + if superseded(): + return stopped("남기기 앞") + if route_signature(vertices) == route_signature(context.vertices): + await _keep_analysis(project_id, prepared, preview, vertices) result = _payload(project_id, live, detail, carried, False) result["generation"] = generation result["live"] = True return result + + +async def _keep_analysis( + project_id: UUID, prepared: dict[str, Any], preview: Any, vertices +) -> None: + """저장 노선 분석을 남김 — design/save 가 잡고 있으면 안 남김(되돌림 스냅샷과 안 부딪힘).""" + lock = guard.project_lock(str(project_id)) + if lock.locked(): + logger.info("배수유역 지금 노선 셈: 저장 중이라 결과를 남기지 않음 project=%s", project_id) + return + async with lock: + await asyncio.to_thread(write_analysis, project_id, prepared, preview, vertices) + + +@router.post("/{project_id}/drainage/live/cancel", response_model=None) +async def post_drainage_live_cancel( + project_id: UUID, payload: dict[str, Any] | None = None +) -> dict[str, Any]: + """화면이 끊은 차례(`generation`) — 아직 마지막 차례면 지워 서버 셈을 다음 단계 사이에서 멈춤. + 그 뒤 새 차례가 이미 왔으면 그대로(새 셈을 끊지 않음).""" + key = str(project_id) + generation = int((payload or {}).get("generation") or 0) + stopped = _LATEST_GENERATION.get(key) == generation + if stopped: + _LATEST_GENERATION.pop(key) + return {"status": "success", "stopped": stopped} diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py index 87affee58..658fcbe60 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py @@ -3,16 +3,21 @@ `B04_PreProcess_Router_Watershed` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). B05 가 읽을 `.npz`·스테이지 기하를 남기는 쓰기 함수와, 응답용 좌표 변환 헬퍼만 모았다. 함수 본문·값은 옮기기 전 그대로이고, 라우터가 이 모듈에서 가져다 쓴다. +분석 응답 · 저장 `write_analysis` 도 여기(520-2 · primary-region · 저장 노선 [업데이트] 같은 길). """ import base64 +import json +import logging import math +from pathlib import Path from typing import Any import numpy as np from shapely.geometry import Point, Polygon, box from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( + drainage_dir, write_grid_arrays, write_stage, ) @@ -20,8 +25,176 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( AZIMUTH_INVALID, AZIMUTH_SINK, AZIMUTH_STEPS, + mask_row_spans, ) -from config.config_system import DRAINAGE_ARROW_SPACING_M +from common_util.common_util_drainage_pipes import pipe_signature, route_signature +from common_util.common_util_route_geometry import StructureCandidate +from config.config_system import DRAINAGE_ARROW_SPACING_M, DRAINAGE_RESPONSE_FILENAME + +logger = logging.getLogger(__name__) + +# 응답 형식 판(版). 응답에 항목을 더하거나 값의 의미를 바꾸면 이 값을 올린다 — +# 저장해 둔 옛 응답을 그대로 돌려주면 화면이 조용히 어긋나기 때문이다. +# 2 = 강도 곡선 간격 1m(구간 합) + 유입 집중점(inflow_hotspots) 추가 (2026-08-01) +# 3 = 강도 곡선 구간 기준을 round→floor로 바로잡고(2m 주기 빗살 제거), +# 유입 집중점이 기본 관 옆 최소간격 안쪽을 피하도록 수정 (2026-08-01) +RESPONSE_SCHEMA_VERSION = 3 + + +def _response_path(stored_path: str) -> Path: + """분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다.""" + return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME + + +def _save_response(stored_path: str, payload: dict[str, Any]) -> None: + path = _response_path(stored_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(payload, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path) + + +def _candidate_payload( + candidate: StructureCandidate, + to_lonlat: Any, +) -> dict[str, Any]: + lon, lat = to_lonlat(candidate.x, candidate.y) + return { + "chainage_m": round(candidate.chainage_m, 2), + "x": candidate.x, + "y": candidate.y, + "lon": lon, + "lat": lat, + "reason": candidate.reason, + "stream_name": candidate.stream_name, + } + + +def write_analysis( + project_id: Any, prepared: dict[str, Any], preview: Any, vertices: list[Any] +) -> dict[str, Any]: + """분석 결과 → 응답 몸통 · 단계 산출물 · 응답 저장본(작업본 자리) — primary-region 과 저장 노선 + [업데이트](520-2 · `post_drainage_live`)가 같은 길. `vertices` = 셈한 노선(지문).""" + region = preview.region + to_lonlat = prepared["to_lonlat"] + # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. + spec = preview.spec or region.spec + domain = preview.domain if preview.domain is not None else region.cell_mask + payload = { + "status": "success", + "schema_version": RESPONSE_SCHEMA_VERSION, + "project_id": str(project_id), + "route_source": prepared["route_source"], + # 지문(342-3) — 노선 · 기본 관 자리가 지금과 다르면 이 결과는 낡음(세부 유역 안 나눔). + "route_signature": route_signature(vertices), + "pipe_signature": pipe_signature([pipe.chainage_m for pipe in preview.pipes]), + "radius_m": region.radius_m, + # 채택된 상류 세류망 = 1차 영역의 기준선. + "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], + # 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다. + "downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream], + "no_contact_count": region.split.no_contact, + # 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. + "road_outside_m": round(region.road_outside_m, 1), + # 1차 영역(버퍼 합집합) 외곽 링 목록. + "region_rings": _polygon_rings(region.area, to_lonlat), + "grid": { + "cell_m": spec.cell_m, + "rows": spec.n_rows, + "cols": spec.n_cols, + # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). + "bbox_cells": spec.size, + "cells": int(domain.sum()) if domain is not None else 0, + "width_m": round(spec.n_cols * spec.cell_m, 1), + "height_m": round(spec.n_rows * spec.cell_m, 1), + # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. + "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), + # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. + # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. + "row_spans": [list(span) for span in mask_row_spans(domain)] + if domain is not None + else [], + }, + # 최외곽 적색 셀 주변 확장 결과. + "expansion": { + "rounds": preview.expand_rounds, + "closed": preview.expand_closed, + "added_cells": preview.expand_added_cells, + "initial_cells": region.active_cells, + }, + # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. + "flow": _flow_payload(preview, domain), + # ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적. + "basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy], + "basin_area_m2": round(preview.basin_area_m2, 1), + # ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. + "strength_profile": [ + [round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile + ], + # ⑥-1 유입 집중점 — 기본 관으로 나눈 구역마다 물이 많이 모이는 자리. + # [누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위]. 화면 마커·하이라이트 대상이다. + "inflow_hotspots": [ + [round(chainage, 1), round(area, 1), zone, rank] + for chainage, area, zone, rank in preview.inflow_hotspots + ], + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], + # B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. + # 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다. + "flow_arrows": [ + [*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells] + for x, y, angle, reaches, cells in preview.flow_arrows + ], + # 화살표 간격(m). 화면이 화살표 크기를 정할 때 쓴다 — 서로 닿지 않게 이 값보다 짧게 그린다. + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, + "from_cache": False, + } + # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. + payload["saved_to"] = write_stage( + prepared["stored_path"], + "primary_region", + { + "primary_region": _as_polygons(region.area), + "upstream": region.split.upstream, + "downstream": region.split.downstream, + "route": [prepared["route_line"]], + "grid_bbox": [_grid_bbox_polygon(spec)], + # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함). + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + { + "chainage_m": round(pipe.chainage_m, 2), + "reason": pipe.reason, + "stream_name": pipe.stream_name, + }, + ) + for pipe in preview.pipes + ], + }, + { + "radius_m": region.radius_m, + "road_outside_m": payload["road_outside_m"], + "no_contact_count": region.split.no_contact, + "basin_area_m2": payload["basin_area_m2"], + "pipe_count": len(preview.pipes), + # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. + "grid": { + key: value + for key, value in payload["grid"].items() + if key not in {"bbox_lonlat", "row_spans"} + }, + }, + to_lonlat, + ) + _write_stage_arrays(prepared["stored_path"], preview, domain, spec) + _write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat) + # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. + _save_response(prepared["stored_path"], payload) + return payload def _write_road_routing( diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index bd0520232..5a2ce8d23 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -9,10 +9,11 @@ * (508-2 [3D 업데이트] 꼴). 낡은 동안 제안 [적용] · [자동 제안]은 끔(새 답이 오면 다시 켬). * - [업데이트] = 늘 지금 노선(초안 · 저장 노선)으로 배수유역을 처음부터 셈 — 노선이 그대로여도. * 뒤에서 조용히(덮개 · 잠금 없음) · 답이 오면 그림을 갈아 끼움. - * - 다시 조작하면 떠 있는 요청을 끊고(AbortController) 다시 낡음. 묶음이 같은 노선 · 관으로 다시 - * 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음. - * - 초안이 없어지면(되돌리기 · 버림) 저장 배수유역을 다시 받아(열 때와 같은 길 · 계산 아님) 그 답의 - * 낡음대로 — 떠 있던 초안용 답은 버림. + * - 다시 조작하면 떠 있는 요청을 끊고(AbortController) 다시 낡음 · 서버에도 끊김을 알려 셈을 멈춤 + * (`/drainage/live/cancel` · 520-2). 묶음이 같은 노선 · 관으로 다시 돌면(입력 지문 같음) 그대로 둠. + * - 저장 노선 [업데이트] 답은 서버가 남김(520-2) — 그 뒤 관 조작은 세부 유역만 자동. + * - 초안이 없어지면(되돌리기 · 버림) 저장분의 마지막 낡음으로 표시를 바로 바꾸고 저장 배수유역을 다시 + * 받아(열 때와 같은 길 · 계산 아님) 그 답의 낡음대로 — 떠 있던 초안용 답은 버림. * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 낡음 표시. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. * ========================================================================== */ @@ -84,6 +85,16 @@ async function fetchLive( return payload; } +/** 끊은 차례를 서버에 알림 — 서버가 다음 단계 사이에서 셈을 멈춤(답 안 기다림 · 실패 무시). */ +function cancelLive(projectId: string, generation: number): void { + void fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live/cancel`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation }), + }).catch(() => undefined); +} + const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; const UPDATE_LABEL = "업데이트"; @@ -111,12 +122,14 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { let stale = false; /** 마지막 단계에 노선 초안이 있었나 — 없어지면 저장분으로 돌아옴. */ let drafted = false; - /** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */ + /** 저장분의 마지막 낡음 — 저장분 답이 「낡음」이면 `markStale` 이 세우고 저장 노선 [업데이트] 답이 걷음. */ let savedStale = false; let generation = 0; /** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */ let key = ""; let inflight: AbortController | null = null; + /** 떠 있는 요청의 차례 — 끊을 때 서버에 알림. */ + let inflightGeneration = 0; const busy = createProgressCircle({ size: 22, label: "계산 중" }); busy.root.classList.add("b05-drainage__busy"); @@ -139,8 +152,11 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { }; function cancel(): void { - inflight?.abort(); + if (!inflight) return; + inflight.abort(); inflight = null; + const projectId = deps.runner.projectId(); + if (projectId) cancelLive(projectId, inflightGeneration); } /** 지금 노선으로 처음부터 셈 — 노선 초안이 있으면 그 노선 · 없으면 저장 노선. */ @@ -149,8 +165,10 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { if (!projectId) return Promise.resolve(false); cancel(); const sent = { generation: ++generation, key }; + const sentRoute = route; const controller = new AbortController(); inflight = controller; + inflightGeneration = sent.generation; paint(); const body = liveRequestBody(sent.generation, route, deps.pipeRequest()); return deps.runner @@ -165,6 +183,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { if (inflight !== controller) return applied; inflight = null; if (applied) stale = false; + if (applied && !draftMakesStale(sentRoute)) savedStale = false; // 서버가 남김(520-2) paint(); return applied; }); @@ -178,10 +197,11 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { paint(); } - /** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(표시는 답이 올 때까지 그대로). */ + /** 초안이 없어짐 — 표시는 저장분의 마지막 낡음으로 바로 · 저장분을 다시 받아 그 답의 낡음대로. */ function backToSaved(): void { cancel(); const sent = ++generation; + stale = savedStale; savedStale = false; // 답이 「낡음」이면 apply 가 markStale 로 세움 paint(); void deps.loadSaved().then((applied) => { diff --git a/resources/tester/helper_349_drainage_live_flow.cjs b/resources/tester/helper_349_drainage_live_flow.cjs index eb44b6f09..894f40bcc 100644 --- a/resources/tester/helper_349_drainage_live_flow.cjs +++ b/resources/tester/helper_349_drainage_live_flow.cjs @@ -69,9 +69,14 @@ function advance(ms) { /* ---- 가짜 fetch(배수유역 live) ---- */ const calls = []; +const cancels = []; // 끊김 알림(520-2) — 끊은 차례 번호 global.fetch = (url, init) => new Promise((resolve, reject) => { const call = { url, body: JSON.parse(init.body), aborted: false }; + if (url.endsWith("/drainage/live/cancel")) { + cancels.push(call.body.generation); + return resolve({ ok: true, status: 200, json: async () => ({ status: "success" }) }); + } call.answer = (payload) => resolve({ ok: true, status: 200, json: async () => payload }); init.signal.addEventListener("abort", () => { call.aborted = true; @@ -268,7 +273,8 @@ async function main() { await flush(); out.savedDone = look(); - // 되돌리기 — 끌기 → [업데이트] 요청 중 → Ctrl+Z = 초안용 답 끊고 저장분 · 표시는 답 올 때까지 그대로 + // 되돌리기 — 끌기 → [업데이트] 요청 중 → Ctrl+Z = 초안용 답 끊고(서버에도 알림) 저장분 · + // 표시는 저장분의 마지막 낡음으로 바로(520-2) const savedOk = { basins: [{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 }], pipe_points: pipes, @@ -279,6 +285,7 @@ async function main() { const draftCall = calls[calls.length - 1]; stage(null); out.undoWaiting = { ...look(), aborted: draftCall.aborted, savedLoads }; + out.undoCancelled = cancels.includes(draftCall.body.generation); savedAnswer(savedOk); await flush(); out.undo = { ...look(), savedLoads }; diff --git a/resources/tester/test_347_1_descent_store.py b/resources/tester/test_347_1_descent_store.py index efe344135..c1861b76d 100644 --- a/resources/tester/test_347_1_descent_store.py +++ b/resources/tester/test_347_1_descent_store.py @@ -125,3 +125,7 @@ def test_metric_sheets_cached_until_source_changes(tmp_path, monkeypatch): assert len(calls) == 2 and first == again # 두 번째는 저장본 router._metric_sheets(sheets, tmp_path / "drainage", "EPSG:5179", None) # 좌표계 바뀜 assert len(calls) == 4 + # 메모리 보관(520-2) — 저장본 파일이 없어도 다시 재투영하지 않음 + (tmp_path / "drainage" / router.SHEET_METRIC_FILENAME).unlink() + router._metric_sheets(sheets, tmp_path / "drainage", "EPSG:5179", None) + assert len(calls) == 4 diff --git a/resources/tester/test_347_2_drainage_live.py b/resources/tester/test_347_2_drainage_live.py index 948ff924a..f4eaa8856 100644 --- a/resources/tester/test_347_2_drainage_live.py +++ b/resources/tester/test_347_2_drainage_live.py @@ -1,6 +1,8 @@ """347-2 「지금 노선으로 셈」 서버 길 · 347-3 관 자리 후보. -- 늦은 차례: 계산 중 새 차례가 오면 단계 사이에서 그만둠(superseded) · 작업본에 안 씀. +- 늦은 차례: 계산 중 새 차례가 오면 단계 사이에서 그만둠(superseded) · 그만둔 셈은 안 남김. +- 저장 노선(초안 없음 · 지문 같음) 답만 분석을 남김(520-2) · 초안 노선 · design/save 잠금 중엔 안 남김. +- 끊김 알림 = 그 차례가 아직 마지막일 때만 지움(새 차례는 안 끊음). - 메모리 격자로 세부 유역(파일 안 읽음) · 노선 초안 → 그린 노선. - 관 자리 후보: 최대 간격(100 m)을 넘는 구간에만 · 기존 관 자리엔 안 냄 · 관 목록엔 안 넣음. - [확정](recompute) = 낡았으면 배수유역부터 다시 셈 · [저장] = 안 셈. @@ -167,3 +169,63 @@ def test_confirm_recomputes_only_when_stale(monkeypatch, recompute, stale, expec monkeypatch.setattr(basins, "_resolve", resolve) asyncio.run(basins.put_pipe_points(uuid4(), {"points": []}, None, recompute)) assert len(calls) == expected and all(calls) + + +def _patch_keep(monkeypatch): + preview = SimpleNamespace( + routing=object(), region=SimpleNamespace(split=SimpleNamespace(upstream=[])) + ) + _patch_live(monkeypatch, lambda *args: preview) + monkeypatch.setattr(live, "_build", lambda *args: (object(), [])) + monkeypatch.setattr(live, "live_routing", lambda *args: None) + monkeypatch.setattr(live, "_payload", lambda *args: {"status": "success"}) + writes = [] + monkeypatch.setattr(live, "write_analysis", lambda *args: writes.append(args[-1])) + return writes + + +def test_saved_route_answer_is_kept(monkeypatch): + writes = _patch_keep(monkeypatch) + result = asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": None})) + assert result["status"] == "success" and writes == [VERTICES] + + +def test_draft_route_answer_not_kept(monkeypatch): + from B05_Profile import B05_Profile_Router_Replan as replan + + writes = _patch_keep(monkeypatch) + moved = [RouteVertex(0.0, 5.0, 100.0, 0.0), RouteVertex(300.0, 5.0, 90.0, 300.0)] + + async def criteria(project_id): + return (30.0,) + + monkeypatch.setattr(replan, "_plan_criteria", criteria) + monkeypatch.setattr(live, "draft_vertices", lambda draft, radius: moved) + monkeypatch.setattr(live, "_open_sampler", lambda *args: None) + monkeypatch.setattr(live, "resolve_route_profile", lambda drawn, sampler: (drawn, "x")) + draft = {"nodes": [[0, 5], [300, 5]]} + asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": draft})) + assert writes == [] + monkeypatch.setattr(live, "draft_vertices", lambda draft, radius: VERTICES) # 지문 같음 + asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": draft})) + assert writes == [VERTICES] + + +def test_not_kept_while_design_save_holds_lock(monkeypatch): + writes = _patch_keep(monkeypatch) + pid = uuid4() + + async def run(): + async with live.guard.project_lock(str(pid)): + return await live.post_drainage_live(pid, {"generation": 1}) + + assert asyncio.run(run())["status"] == "success" and writes == [] + + +def test_cancel_clears_only_latest_generation(): + pid = uuid4() + live._LATEST_GENERATION[str(pid)] = 5 + assert asyncio.run(live.post_drainage_live_cancel(pid, {"generation": 4}))["stopped"] is False + assert live._LATEST_GENERATION[str(pid)] == 5 + assert asyncio.run(live.post_drainage_live_cancel(pid, {"generation": 5}))["stopped"] is True + assert str(pid) not in live._LATEST_GENERATION diff --git a/resources/tester/test_349_drainage_live_flow.py b/resources/tester/test_349_drainage_live_flow.py index b74d18a13..5cc5e820c 100644 --- a/resources/tester/test_349_drainage_live_flow.py +++ b/resources/tester/test_349_drainage_live_flow.py @@ -6,7 +6,8 @@ - 낡지 않아도 [업데이트] = 다시 셈(326-6) · 약속은 답이 오르면 참(B06 [>] 확인 창 뒤 이동). - 요청 중 또 옮기면 앞 요청 끊고 다시 낡음 · 낡은 동안 제안 [적용] · [자동 제안] 끔. - 노선 [저장] 뒤 서버 답이 「낡음」 = 낡음 표시 · 저절로 셈 없음. -- 되돌리기로 초안이 없어지면 저장분 다시 받기 · 그 답의 낡음대로 · 초안용 답 끊음(349-0i). +- 되돌리기로 초안이 없어지면 저장분 다시 받기 · 그 답의 낡음대로 · 초안용 답 끊음(349-0i) · 서버에 끊김 + 알림 · 표시는 저장분의 마지막 낡음으로 바로(520-2 · 저장 노선 [업데이트] 뒤 = 낡음 즉시 꺼짐). """ import json @@ -108,7 +109,8 @@ def test_saved_stale_marks_only(r): def test_undo_back_to_saved(r): waiting = r["undoWaiting"] - assert waiting["aborted"] and look(waiting, "aborted", "savedLoads") == state(True, False, 6) + assert waiting["aborted"] and look(waiting, "aborted", "savedLoads") == state(False, False, 6) + assert r["undoCancelled"] assert look(r["undo"], "savedLoads") == state(False, False, 6) assert r["undo"]["savedLoads"] == 2 assert look(r["undoSavedStale"], "savedLoads") == state(True, False, 6)