Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_Router.py
T

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"}
)