612 lines
29 KiB
Python
612 lines
29 KiB
Python
"""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/tile/{z}/{x}/{y}` — 위성 조각 대리 · 서버 기억(586 GL 바탕)
|
|
- `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` ·
|
|
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` ·
|
|
`…/forest-roads`(GET · POST 올리기 · `/bundles` 목록 · DELETE `/<번호>`) ·
|
|
`…/landslide-zones` — 지도 겹 (PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3 · 605-1)
|
|
|
|
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
|
|
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
|
|
위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 ·
|
|
받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고
|
|
center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어
|
|
서버가 다시 켜지면 「끊김」(interrupted).
|
|
노선 정본(손 그은 선)은
|
|
`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, uuid4
|
|
|
|
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile
|
|
from pydantic import BaseModel
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon
|
|
from common_util.common_util_http_cache import cached_file_response
|
|
from common_util.common_util_json import atomic_write_json
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import get_db_pool
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_LandslideZone as landslide_zone
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
|
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 Center(BaseModel):
|
|
lon: float
|
|
lat: float
|
|
|
|
|
|
class TerrainBody(BaseModel):
|
|
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m) ·
|
|
위치 칸 가운데(경위도 · 595) · 새로(옛 받은 범위 버림)."""
|
|
|
|
margin_m: float | None = None
|
|
extra: list[tuple[float, float]] = []
|
|
center: Center | None = None
|
|
reset: bool = False
|
|
|
|
|
|
Ends = tuple[Path, int, list[tuple[float, float]], int | None]
|
|
|
|
|
|
async def _plan_ends(
|
|
project_id: UUID, center: tuple[float, float] | None = None, reset: bool = False
|
|
) -> Ends:
|
|
"""(실경로, 좌표계, [지점들], 띠 넘음 epsg) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점
|
|
둘레 두 귀 · `center`(위치 칸) = 그 둘레를 더함(`reset` = 둘레만) · 다른 TM 띠 = 확인 전이면
|
|
넷째 값에 그 띠(셋은 `center` 없이) · 확인 뒤(받은 지형이 그 띠) = 그 띠 · 둘레만."""
|
|
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
|
|
site = (await network.project_info(project_id))["site"]
|
|
epsg = network.work_epsg(plan, site)
|
|
meta = terrain.received_meta(root)
|
|
if center is None and meta.get("center") and meta.get("epsg") not in (None, epsg):
|
|
center = tuple(meta["center"]) # 띠 넘어 받은 뒤 [저장] 전 — 받은 지형 띠 · 가운데
|
|
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
|
|
if center is not None:
|
|
band = tm_epsg_for_lon(center[0])
|
|
if epsg is not None and band != epsg:
|
|
if not (reset or meta.get("epsg") == band):
|
|
return (*(await _plan_ends(project_id))[:3], band)
|
|
epsg, ends, reset = band, [], True # 옛 띠 지점 · 선 = 「노선 지움」 확인으로 빠짐
|
|
epsg = epsg or band
|
|
corners = terrain.site_corners(*lonlat_to_tm(center[0], center[1], epsg))
|
|
return root, epsg, corners if reset else [*ends, *corners], None
|
|
if epsg is None:
|
|
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
|
|
if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음
|
|
ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg))
|
|
if not ends:
|
|
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
|
|
return root, epsg, ends, None
|
|
|
|
|
|
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
|
|
|
|
|
|
IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False}
|
|
|
|
|
|
def _job_path(root: Path, kind: str) -> Path:
|
|
return terrain.models_dir_of(root) / f"plan_job_{kind}.json"
|
|
|
|
|
|
def _note_job(path: Path | None, job: dict[str, Any]) -> None:
|
|
"""진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속)."""
|
|
if path is None:
|
|
return
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
atomic_write_json(path, job)
|
|
except OSError as error:
|
|
logger.warning("D 진행 기록 실패: %s", error)
|
|
|
|
|
|
def _saved_job(root: Path, kind: str) -> dict[str, Any]:
|
|
"""기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 ·
|
|
message 없음)."""
|
|
try:
|
|
saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return dict(IDLE)
|
|
if not (isinstance(saved, dict) and saved.get("running")):
|
|
return dict(IDLE)
|
|
percent = min(99, int(saved.get("percent") or 0))
|
|
return {**IDLE, "percent": percent, "interrupted": True}
|
|
|
|
|
|
def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> 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, "interrupted": False}
|
|
_JOBS[key] = job
|
|
path = _job_path(root, kind) if root is not None else None
|
|
_note_job(path, job)
|
|
|
|
def report(percent: int, message: str) -> None:
|
|
job.update(percent=int(percent), message=message)
|
|
_note_job(path, job)
|
|
|
|
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
|
|
_note_job(path, job)
|
|
|
|
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,
|
|
lon: float | None = None,
|
|
lat: float | None = None,
|
|
) -> dict[str, Any]:
|
|
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]` · `lon` `lat` 위치 칸 둘레) 상자 + 여유 ∪
|
|
받은 범위."""
|
|
center = (lon, lat) if lon is not None and lat is not None else None
|
|
root, epsg, ends, _ = await _plan_ends(project_id, center)
|
|
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` ·
|
|
위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413."""
|
|
body = body or TerrainBody()
|
|
center = (body.center.lon, body.center.lat) if body.center else None
|
|
root, epsg, ends, band = await _plan_ends(project_id, center, body.reset)
|
|
if band is not None:
|
|
return {
|
|
"status": "band",
|
|
"epsg": epsg,
|
|
"center_epsg": band,
|
|
"message": "다른 좌표 띠로 옮깁니다 — 그은 지점 · 선을 지우고 새로 받을까요?",
|
|
}
|
|
have = None if body.reset else terrain.received_box(root, epsg)
|
|
if center is not None and have is not None:
|
|
far = terrain.distance_to_box(lonlat_to_tm(center[0], center[1], epsg), have)
|
|
if far > terrain.FAR_M:
|
|
return {
|
|
"status": "far",
|
|
"distance_m": round(far),
|
|
"message": f"받은 지형에서 {far / 1000:.1f} km 떨어졌습니다 — "
|
|
"옛 받은 범위를 버리고 새로 받을까요?",
|
|
}
|
|
points = [*ends, *body.extra]
|
|
cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have))
|
|
if cells > terrain.MAX_CELLS:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · "
|
|
f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)",
|
|
)
|
|
|
|
async def work(report: Any) -> None:
|
|
got = await asyncio.to_thread(
|
|
terrain.ensure_plan_terrain,
|
|
root,
|
|
points,
|
|
epsg,
|
|
body.margin_m,
|
|
report,
|
|
reset=have is None,
|
|
center=center,
|
|
)
|
|
await _save_models(project_id, got["models"], epsg)
|
|
report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매")
|
|
|
|
return _start_job(project_id, "terrain", work, root)
|
|
|
|
|
|
@router.get("/{project_id}/plan/progress", response_model=None)
|
|
async def plan_progress(project_id: UUID) -> dict[str, Any]:
|
|
"""일마다 진행 — 기억에 없으면 기록을 봄(돌던 기록 = 끊김 · 없으면 percent 100)."""
|
|
jobs: dict[str, Any] = {}
|
|
for kind in KINDS:
|
|
job = _JOBS.get((str(project_id), kind))
|
|
if job is None:
|
|
try:
|
|
job = await asyncio.to_thread(_saved_job, await _tile_root(project_id), kind)
|
|
except HTTPException:
|
|
job = IDLE
|
|
jobs[kind] = dict(job)
|
|
return {"status": "success", **jobs}
|
|
|
|
|
|
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, epsg)
|
|
if box is None:
|
|
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
|
|
return root, epsg, box
|
|
|
|
|
|
SHEET_CLIP_KIND = "plan_sheet"
|
|
SHEET_CLIP_VERSION = 1
|
|
|
|
|
|
def _clip_to_box(root: Path, source: Path, box: terrain.Box, epsg: int) -> Path:
|
|
"""받은 범위(경위도 외접 상자)로 자른 사본(475) — 열쇠 = 사본 지문 + 범위 · 실패 = 통째."""
|
|
from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clip_collection
|
|
from common_util.common_util_derived_store import (
|
|
derived_dir,
|
|
derived_key,
|
|
file_stamp,
|
|
prune_derived,
|
|
)
|
|
|
|
try:
|
|
bounds = layers._lonlat_bounds(box, epsg)
|
|
rect = (bounds["min_lon"], bounds["min_lat"], bounds["max_lon"], bounds["max_lat"])
|
|
key = derived_key(
|
|
SHEET_CLIP_VERSION, source.name, file_stamp(source), [round(v, 1) for v in box]
|
|
)
|
|
target = derived_dir(root, SHEET_CLIP_KIND) / f"{key}.json"
|
|
if target.is_file():
|
|
return target
|
|
collection = json.loads(source.read_text(encoding="utf-8"))
|
|
text = json.dumps(
|
|
clip_collection(collection, rect), ensure_ascii=False, separators=(",", ":")
|
|
)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = target.with_suffix(f".{uuid4().hex}.tmp")
|
|
temporary.write_text(text, encoding="utf-8")
|
|
temporary.replace(target)
|
|
prune_derived(root, SHEET_CLIP_KIND, keep=2 * len(layers.SHEET_LAYERS))
|
|
return target
|
|
except Exception as error: # 자르기 실패가 화면을 막으면 안 됨
|
|
logger.warning("도엽 겹 받은 범위 자름 실패 (%s): %s", source.name, error)
|
|
return source
|
|
|
|
|
|
@router.get("/{project_id}/plan/layers/sheet", response_model=None)
|
|
async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Response:
|
|
"""도엽 겹 GeoJSON(등고선 · 하천중심선 · 도로중심선 …) — 없으면 지난 병합 도엽에서 풀어 냄 ·
|
|
받은 범위로 자름(475 · 범위가 늘면 새 사본)."""
|
|
from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy
|
|
|
|
root, epsg, box = 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)
|
|
clipped = await asyncio.to_thread(_clip_to_box, root, display, box, epsg)
|
|
return cached_file_response(request, clipped, "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]:
|
|
"""급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
|
|
산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본."""
|
|
if kind == "landslide":
|
|
root, epsg, box = await _layer_root(project_id)
|
|
processed = root / terrain.STAGE / "processed"
|
|
try:
|
|
value = await asyncio.to_thread(landslide.ensure_landslide, processed, box, epsg)
|
|
except landslide.MissingKeyError as error:
|
|
raise HTTPException(status_code=503, detail=str(error)) from error
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=502, detail=str(error)) from error
|
|
box_lonlat = layers.lonlat_corners(value["box_m"], epsg)
|
|
return {"status": "success", "kind": kind, **value, "box": box_lonlat}
|
|
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}
|
|
|
|
|
|
async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]:
|
|
"""(실경로, 좌표계, 자르는 범위, 기준점 m) — 기준점 = 위치 칸으로 받은 가운데 · 등록 기준점 ·
|
|
지점 가운데 차례 · 지형 받기 전에도(595-4)."""
|
|
root, epsg, ends, _ = await _plan_ends(project_id)
|
|
center = terrain.received_meta(root).get("center")
|
|
site = (await network.project_info(project_id))["site"]
|
|
if center:
|
|
origin = lonlat_to_tm(float(center[0]), float(center[1]), epsg)
|
|
elif site:
|
|
origin = lonlat_to_tm(site["lon"], site["lat"], epsg)
|
|
else:
|
|
xs, ys = [p[0] for p in ends], [p[1] for p in ends]
|
|
origin = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
|
|
box = forest_road.clip_box(origin, terrain.received_box(root, epsg))
|
|
return root, epsg, box, origin
|
|
|
|
|
|
@router.get("/{project_id}/plan/layers/forest-roads", response_model=None)
|
|
async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]:
|
|
"""올린 임도망도(묶음마다 기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 합친 가닥 · 길이 · 잘린 상자 ·
|
|
기준점 거리 · `bundles` 묶음 줄) — 올린 적 없으면 404."""
|
|
root, epsg, box, origin = await _road_frame(project_id)
|
|
try:
|
|
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin)
|
|
except FileNotFoundError as error:
|
|
raise HTTPException(status_code=404, detail=str(error)) from error
|
|
return {"status": "success", **value}
|
|
|
|
|
|
@router.get("/{project_id}/plan/layers/forest-roads/bundles", response_model=None)
|
|
async def plan_layer_forest_road_bundles(project_id: UUID) -> dict[str, Any]:
|
|
"""올린 묶음 목록(선 없이 · 올린 순서) — 올린 적 없으면 빈 목록."""
|
|
root, epsg, box, origin = await _road_frame(project_id)
|
|
try:
|
|
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin)
|
|
except FileNotFoundError:
|
|
return {"status": "success", "bundles": []}
|
|
return {"status": "success", "bundles": value["bundles"]}
|
|
|
|
|
|
@router.delete("/{project_id}/plan/layers/forest-roads/{bundle}", response_model=None)
|
|
async def plan_layer_forest_road_delete(project_id: UUID, bundle: str) -> dict[str, Any]:
|
|
"""올린 묶음 하나 지움 — 남은 묶음 합친 값(남은 것 없으면 `bundles` 빈 목록) · 없으면 404."""
|
|
root, epsg, box, origin = await _road_frame(project_id)
|
|
try:
|
|
await asyncio.to_thread(forest_road.delete_bundle, root, bundle)
|
|
except FileNotFoundError as error:
|
|
raise HTTPException(status_code=404, detail=str(error)) from error
|
|
try:
|
|
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin)
|
|
except FileNotFoundError:
|
|
return {"status": "success", "bundles": []}
|
|
return {"status": "success", **value}
|
|
|
|
|
|
@router.post("/{project_id}/plan/layers/forest-roads", response_model=None)
|
|
async def plan_layer_forest_roads_upload(
|
|
project_id: UUID, files: list[UploadFile] = File(...)
|
|
) -> dict[str, Any]:
|
|
"""임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나
|
|
자르는 범위 안 선이 없으면 400(옛 것 그대로 · 가장 가까운 임도 거리) · 넘치면 413."""
|
|
root, epsg, box, origin = await _road_frame(project_id)
|
|
got: list[tuple[str, bytes]] = []
|
|
total = 0
|
|
for upload in files:
|
|
chunks = []
|
|
while chunk := await upload.read(1024 * 1024):
|
|
total += len(chunk)
|
|
if total > forest_road.MAX_BYTES:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"파일이 너무 큽니다 — 모두 {forest_road.MAX_BYTES // 2**20} MB 까지",
|
|
)
|
|
chunks.append(chunk)
|
|
got.append((upload.filename or "", b"".join(chunks)))
|
|
if not total:
|
|
raise HTTPException(status_code=400, detail="빈 파일입니다.")
|
|
try:
|
|
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg, origin)
|
|
except ValueError as error:
|
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
|
return {"status": "success", **value}
|
|
|
|
|
|
@router.get("/{project_id}/plan/layers/landslide-zones", response_model=None)
|
|
async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]:
|
|
"""산사태취약지역(시도 표 · 점 + 지번 맞는 필지 테두리 · 표 없는 시도) — 지적도 못 받으면
|
|
점만."""
|
|
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)
|
|
cadastral = got["path"]
|
|
except RuntimeError:
|
|
cadastral = None
|
|
value = await asyncio.to_thread(landslide_zone.zone_layer, root, cadastral, box, epsg)
|
|
return {"status": "success", **value}
|
|
|
|
|
|
# 위성 조각 기억 — 프로젝트 → 실경로(조각마다 DB 를 안 부름)
|
|
_TILE_ROOTS: dict[UUID, Path] = {}
|
|
TILE_KIND = "plan_tile"
|
|
|
|
|
|
async def _tile_root(project_id: UUID) -> Path:
|
|
root = _TILE_ROOTS.get(project_id)
|
|
if root is None:
|
|
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="프로젝트 저장 경로를 찾을 수 없습니다.")
|
|
root = _TILE_ROOTS[project_id] = Path(resolve_stored_project_path(stored))
|
|
return root
|
|
|
|
|
|
@router.get("/{project_id}/plan/tile/{z}/{x}/{y}", response_model=None)
|
|
async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Response:
|
|
"""위성 조각(PLAN 586 GL 바탕 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리 ·
|
|
받은 조각 = `_derived/plan_tile/Satellite/{z}/{x}_{y}.jpeg`(지문 = 겹 · 조각 번호 ·
|
|
지워도 다시 받음 · 프로젝트당 `PLAN_TILE_CACHE_MB` 넘으면 오래 안 본 것부터 지움 · 590-2)."""
|
|
import urllib.request
|
|
|
|
from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY
|
|
from common_util import common_util_tile_cache as tile_cache
|
|
from common_util.common_util_derived_store import derived_dir
|
|
from config import config_system
|
|
|
|
if not (6 <= z <= 19 and 0 <= x < 2**z and 0 <= y < 2**z):
|
|
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.")
|
|
folder = derived_dir(await _tile_root(project_id), TILE_KIND)
|
|
path = folder / "Satellite" / str(z) / f"{x}_{y}.jpeg"
|
|
url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg"
|
|
limit = int(config_system.PLAN_TILE_CACHE_MB * 1024 * 1024)
|
|
|
|
def fetch() -> bytes:
|
|
if path.is_file():
|
|
tile_cache.note(folder, 0, limit) # 처음 열 때 한 번 셈 · 넘쳤으면 지움
|
|
tile_cache.seen(path)
|
|
return path.read_bytes()
|
|
request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"})
|
|
with urllib.request.urlopen(request, timeout=8) as response:
|
|
tile = response.read()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
part = path.with_suffix(f".{uuid4().hex}.part")
|
|
part.write_bytes(tile)
|
|
part.replace(path) # 반쯤 쓴 조각을 남기지 않음
|
|
tile_cache.note(folder, len(tile), limit)
|
|
return tile
|
|
|
|
try:
|
|
tile = await asyncio.to_thread(fetch)
|
|
except Exception as error: # noqa: BLE001 — 한 조각 빔은 화면을 막을 일이 아님
|
|
raise HTTPException(status_code=502, detail="위성 조각을 받지 못했습니다.") from error
|
|
return Response(
|
|
content=tile, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"}
|
|
)
|