Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user