Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_Router.py
T
eomsangdonandClaude Opus 5.5 7d4a94a887 feat(D03 겹): 노선분석 지도 단층 · 지질구조선 · 0차곡 겹 — 묶음 셋째 · 처음 끔 (PLAN 584-1 · 요구 443)
- 서버: 선 겹 = 브이월드 WFS lt_l_gimsfault · lt_l_gimslinea 받은 범위로 잘라 가닥 · 길이 · 덮으면 다시 안 받음 · 실패 = 502 · 새 선 겹은 LINE_KINDS 한 줄
- 서버: 0차곡 = 사업 DEM 2 m 솎음 D8 흐름누적(웅덩이 메움 · 평지 넘김 · numpy) · 위 집수 0.1~1 ha 칸 세선화 선 · 10 m 미만 토막 거름 · 파생 저장본(격자 지문 · 임계) · 임계 = 요청 min_m2 · max_m2
- 화면: 단추 셋(bolt · lineDashed · arrowsJoin 부품 상자에 더함) · 색 --draw-map-fault · lineament · zero-order · 범례 = 가닥 · km · 첫 셈 늦으면 알림 · 바탕 캔버스
- 선 긋기 · 그림 겹 놓기 · 그림 읽기를 LayerDefs 로 옮김(700줄 한도)
- test_553_4: 겹 목록에 faults · lineaments · zeroOrder 더함(새 겹 · 기대 목록 갱신)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:54:51 +09:00

282 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""D 예정노선 2단계 지형 API (요구 345 · 389 · PLAN 530-1 · 561-2).
- `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위)
- `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행
- `GET …/plan/progress` — 일의 진행률
- `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade` ·
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` — 지도 겹
(PLAN 569-1 · 581-1 · 581-2 · 584-1)
범위 = 전체 시점 · 종점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유.
노선 정본(손 그은 선)은
`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D02_PlanBase import D02_PlanBase_Router as base
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["D03 Plan Analysis"])
# 진행 — (프로젝트, 일) → {running, percent, message, error} · 프로세스 메모리
_JOBS: dict[tuple[str, str], dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
KINDS = ("terrain", "analyze")
class TerrainBody(BaseModel):
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m)."""
margin_m: float | None = None
extra: list[tuple[float, float]] = []
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
"""(실경로, 좌표계, [전체 시점들 · 종점들]) — D02 정본(빈 칸이면 옛 연도 행 접음)."""
plan = await base.load_base(project_id)
if not plan["starts"] or not plan["ends"]:
raise HTTPException(
status_code=400, detail="전체 시점 · 종점이 없습니다 — 1단계에서 지정하세요."
)
async with get_db_pool().acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
ends = [(float(p["x"]), float(p["y"])) for p in (*plan["starts"], *plan["ends"])]
return Path(resolve_stored_project_path(stored)), int(plan["epsg"]), ends
def _query_xy(extra: str | None) -> list[tuple[float, float]]:
if not extra:
return []
try:
return [(float(x), float(y)) for x, y in json.loads(extra)]
except (ValueError, TypeError) as error:
raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error
def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
key = (str(project_id), kind)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None}
_JOBS[key] = job
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
async def run() -> None:
try:
await work(report)
job.update(percent=100)
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("D %s 실패: project_id=%s", kind, project_id)
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
task = asyncio.create_task(run(), name=f"D {kind} {project_id}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted", **job}
async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int) -> None:
"""지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로)."""
from B04_PreProcess.B04_PreProcess_Repository import _existing_models, _upsert_surface_model
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
existing = await _existing_models(connection, project_id)
for model in models:
await _upsert_surface_model(
connection,
existing,
project_id=project_id,
model=model,
source_file_id=None,
processed_cloud_id=None,
crs_epsg=epsg,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
@router.get("/{project_id}/plan/terrain/extent", response_model=None)
async def plan_terrain_extent(
project_id: UUID, margin_m: float | None = None, extra: str | None = None
) -> dict[str, Any]:
"""받기 전 범위 — 전체 시점 · 종점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위."""
root, epsg, ends = await _plan_ends(project_id)
view = await asyncio.to_thread(
terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m
)
return {"status": "success", **view}
@router.post("/{project_id}/plan/terrain", response_model=None)
async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]:
"""사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`."""
body = body or TerrainBody()
root, epsg, ends = await _plan_ends(project_id)
points = [*ends, *body.extra]
async def work(report: Any) -> None:
got = await asyncio.to_thread(
terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report
)
await _save_models(project_id, got["models"], epsg)
report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매")
return _start_job(project_id, "terrain", work)
@router.get("/{project_id}/plan/progress", response_model=None)
async def plan_progress(project_id: UUID) -> dict[str, Any]:
idle = {"running": False, "percent": 0, "message": None, "error": None}
return {
"status": "success",
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
}
async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
"""(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404."""
root, epsg, _ = await _plan_ends(project_id)
box = terrain.received_box(root)
if box is None:
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
return root, epsg, box
@router.get("/{project_id}/plan/layers/sheet", response_model=None)
async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Response:
"""도엽 겹 GeoJSON(하천중심선 · 도로중심선 …) — 없으면 지난 병합 도엽에서 풀어 냄."""
from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy
root, _, _ = await _layer_root(project_id)
try:
path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key)
except KeyError as error:
raise HTTPException(status_code=400, detail=f"없는 도엽 겹: {key}") from error
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
display = await asyncio.to_thread(_display_copy, path)
return cached_file_response(request, display, "application/geo+json")
@router.get("/{project_id}/plan/layers/cadastral", response_model=None)
async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
"""지적도 GeoJSON(브이월드 LX맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
try:
got = await asyncio.to_thread(layers.ensure_cadastral, processed, box, epsg)
except RuntimeError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return cached_file_response(request, got["path"], "application/geo+json")
@router.get("/{project_id}/plan/layers/raster", response_model=None)
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]:
"""급경사 단계 그림(15° · 35° · 40°) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본."""
if kind not in layers.RASTER_KINDS:
raise HTTPException(status_code=400, detail=f"없는 그림 겹: {kind}")
root, epsg, _ = await _layer_root(project_id)
try:
value = await asyncio.to_thread(
layers.slope_layer, root, terrain.models_dir_of(root), epsg, kind
)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "success", "kind": kind, **value}
@router.get("/{project_id}/plan/layers/zones", response_model=None)
async def plan_layer_zones(project_id: UUID, request: Request) -> Response:
"""보호 · 규제 구역 GeoJSON(여덟 종 · `summary` = 종류별 개수 · 넓이) — 범위 밖이면 받음."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
try:
got = await asyncio.to_thread(layers.ensure_zones, processed, box, epsg)
except RuntimeError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return cached_file_response(request, got["path"], "application/geo+json")
@router.get("/{project_id}/plan/layers/soils", response_model=None)
async def plan_layer_soils(project_id: UUID, request: Request) -> Response:
"""산림입지도 GeoJSON(토양형 · `summary` = 토양형별 개수 · 넓이) — 범위 밖이면 받음."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
try:
got = await asyncio.to_thread(layers.ensure_soils, processed, box, epsg)
except RuntimeError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return cached_file_response(request, got["path"], "application/geo+json")
@router.get("/{project_id}/plan/layers/bands", response_model=None)
async def plan_layer_bands(project_id: UUID) -> dict[str, Any]:
"""계류 100 m · 도로 300 m 띠(넓이 · 받은 범위 비율) — 두 도엽 겹 지문 같으면 저장본."""
root, epsg, box = await _layer_root(project_id)
try:
value = await asyncio.to_thread(layers.band_layer, root, epsg, box)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "success", **value}
@router.get("/{project_id}/plan/layers/lines", response_model=None)
async def plan_layer_lines(project_id: UUID, kind: str, request: Request) -> Response:
"""선 겹 GeoJSON(단층 · 지질구조선 · `summary` = 가닥 · 길이 m) — 범위 밖이면 받음."""
if kind not in layers.LINE_KINDS:
raise HTTPException(status_code=400, detail=f"없는 선 겹: {kind}")
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
try:
got = await asyncio.to_thread(layers.ensure_lines, processed, box, epsg, kind)
except RuntimeError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return cached_file_response(request, got["path"], "application/geo+json")
@router.get("/{project_id}/plan/layers/zero-order", response_model=None)
async def plan_layer_zero_order(
project_id: UUID,
min_m2: float = zero_order.ZERO_ORDER_MIN_M2,
max_m2: float = zero_order.ZERO_ORDER_MAX_M2,
) -> dict[str, Any]:
"""0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 격자 지문 · 임계 같으면 저장본."""
if not 0 < min_m2 < max_m2:
raise HTTPException(status_code=400, detail="0 < min_m2 < max_m2 여야 합니다.")
root, epsg, _ = await _layer_root(project_id)
try:
value = await asyncio.to_thread(
zero_order.zero_order_layer, root, terrain.models_dir_of(root), epsg, min_m2, max_m2
)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "success", **value}