feat(D03): 지형 = 사업 전체 한 벌 · 받기 전 범위 API (PLAN 561-2)

- 지형 받기 — 연도 구간마다 models_<연도> 대신 한 벌 models_plan · 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 ∪ 받은 범위(줄지 않음) · 받은 범위 plan_terrain.json
- 도엽 = 범위 상자에 닿는 도엽 모두(3×3 이웃 대신) · 위성 = 그 도엽 도곽
- 새 GET …/plan/terrain/extent — 상자 네 귀 경위도 · 도엽 도곽 · 칸 수 어림 · 받은 범위 · covered(더 받기 판정) · 내려받기 없음
- POST …/plan/terrain 몸 = {margin_m, extra} · 여유 처음 값 350 m(옛 DEM 폭) · 바닥 300 m · 실패 글에 병합 도엽 매수
- 전체 시점 · 종점 = 임시로 첫 연도 시점 · 끝 연도 종점(_plan_ends · 560-2 받은 뒤 바꿈)
- 시험 530_1 다시 씀 · 530_2_plan_router 범위 · 받기 둘 더함 · 530_2_plan_analyze 옛 연도 DEM 기대 한 줄 고침(한 벌로 바뀜)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:12:21 +09:00
co-authored by Claude Opus 5.5
parent 70d3a69847
commit f17db59ab6
5 changed files with 315 additions and 124 deletions
+57 -20
View File
@@ -1,6 +1,7 @@
"""D 예정노선 2단계 분석 API (요구 345 · PLAN 530-1 · 530-2).
- `POST /api/projects/{id}/plan/terrain` — 지형 받기(뒤에서) · 끝나면 연도별 `surface_models` 행
- `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위)
- `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행
- `POST …/plan/analyze` — 연도 구간마다 노선 추천 갈래 넷(뒤에서 · 파생 저장본)
- `GET …/plan/progress` — 두 일의 진행률
- `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선
@@ -58,6 +59,13 @@ class PointsBody(BaseModel):
points: list[PlanPoint] | None = None
class TerrainBody(BaseModel):
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m)."""
margin_m: float | None = None
extra: list[tuple[float, float]] = []
class PlanEditBody(BaseModel):
"""연도 하나의 꺾임점 고침 — 고친 갈래 · 그 분석 지문 · 마디(작업 좌표계 m) · 곡선 켬끔 · R."""
@@ -140,6 +148,21 @@ async def _plan_inputs(
return Path(resolve_stored_project_path(stored)), project, segments
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
"""(실경로, 좌표계, [전체 시점, 전체 종점]) — 지금은 첫 연도 시점 · 끝 연도 종점."""
root, _, segments = await _plan_inputs(project_id)
return root, segments[0]["epsg"], [segments[0]["start"], segments[-1]["end"]]
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"):
@@ -166,40 +189,54 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
return {"status": "accepted", **job}
async def _save_models(project_id: UUID, years: dict[int, list[dict[str, Any]]], epsg: int) -> None:
"""연도별 지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로)."""
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 models in years.values():
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,
)
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: PointsBody | None = None) -> dict[str, Any]:
"""지점 기준 수치지형도 · 위성 · 연도별 DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`."""
root, _, segments = await _plan_inputs(project_id, body.points if body else None)
epsg = segments[0]["epsg"]
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, segments, epsg, report)
await _save_models(project_id, got["years"], epsg)
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)
+139 -65
View File
@@ -1,12 +1,14 @@
"""D 예정노선 지형 받기 — 지점 기준 수치지형도 · 위성 · 연도 구간별 DEM (요구 345 · PLAN 530-1).
"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2).
지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0):
- 도엽 = 모든 지점 + 구간 표본(`SAMPLE_STEP_M`) 각 3×3 → `ensure_sheets`(없는 것만 · prune 없음)
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음)
- 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음)
- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀)
- 위성 = 기준 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
- DEM = 연도 구간마다 `models_<연도>/`(긴 노선 메모리 · 비용면 한도) · 범위 = 구간 상자 + 탐색 복도
- 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
- DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json`
- 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음)
- 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만
계산 자리 = ③ 서버 단독. DB 행(`surface_models`)은 라우터가 씀.
"""
@@ -22,7 +24,7 @@ from typing import Any, Callable
import numpy as np
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import (
neighbors_for_points,
SHEET5K_SIZE_DEG,
sheet5k_to_bounds,
sheets_for_points,
)
@@ -35,50 +37,123 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetStore import (
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import build_sheet_surface_model
from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map
from common_util.common_util_json import atomic_write_json
from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M
from config.config_system_terrain import (
SHEET_SURFACE_DEFAULT_METHOD,
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
)
logger = logging.getLogger(__name__)
# 구간 표본 간격 — 도엽(≈2.8 × 2.3 km) 3×3 묶음이 끊기지 않게
SAMPLE_STEP_M = 1000.0
# 탐색 복도 반폭(D 만 · B 기본 100 m) — 실측(530-2): 100 · 200 m 는 못 지킨 곳 남음 · 300 m 0~1
PLAN_CORRIDOR_M = 300.0
# DEM 이 복도 밖으로 더 덮는 폭(복도 끝 칸 기울기 · 횡단 표본 ±20 m)
DEM_EXTRA_M = 50.0
# 여유 처음 값 = 복도 + 더 덮는 폭(옛 연도 구간 DEM 과 같은 폭) · 바닥 = B 절취 여유
DEFAULT_MARGIN_M = PLAN_CORRIDOR_M + DEM_EXTRA_M
MAX_MARGIN_M = 5000.0
STAGE = Path("B04_PreProcess")
PLAN_MODELS = "models_plan"
TERRAIN_META = "plan_terrain.json"
PRJ_NAME = "plan.prj"
Progress = Callable[[int, str], None]
Box = tuple[float, float, float, float] # x0, y0, x1, y1(작업 좌표계 m)
XY = tuple[float, float]
def models_dir_of(project_root: Path, year: int) -> Path:
"""연도 구간 DEM 자리."""
return project_root / STAGE / f"models_{int(year)}"
def models_dir_of(project_root: Path, year: int | None = None) -> Path:
"""사업 전체 DEM 자리 — `year` 는 옛 연도 분석 부르는 꼴 맞춤(안 씀)."""
return project_root / STAGE / PLAN_MODELS
def segment_samples(
start: tuple[float, float], end: tuple[float, float]
) -> list[tuple[float, float]]:
"""구간 위 표본(끝점 둘 포함 · `SAMPLE_STEP_M` 간격 이하)."""
length = math.hypot(end[0] - start[0], end[1] - start[1])
count = max(1, math.ceil(length / SAMPLE_STEP_M))
return [
(start[0] + (end[0] - start[0]) * i / count, start[1] + (end[1] - start[1]) * i / count)
for i in range(count + 1)
def clamp_margin(margin_m: float | None) -> float:
"""여유 — 빈 값 = 처음 값 · B 절취 여유 ~ 5 km."""
if margin_m is None or not math.isfinite(margin_m):
return DEFAULT_MARGIN_M
return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m)))
def received_box(project_root: Path) -> Box | None:
"""받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None."""
meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {}
try:
x0, y0, x1, y1 = (float(v) for v in meta["box"])
except (KeyError, TypeError, ValueError):
return None
return x0, y0, x1, y1
def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box:
"""받을 범위 = 점 상자 + 여유 ∪ 받은 범위."""
xs, ys = [p[0] for p in points], [p[1] for p in points]
box = (min(xs) - margin_m, min(ys) - margin_m, max(xs) + margin_m, max(ys) + margin_m)
if have is None:
return box
return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3])
def covers(have: Box | None, box: Box) -> bool:
return (
have is not None
and all(have[i] <= box[i] for i in (0, 1))
and all(have[i] >= box[i] for i in (2, 3))
)
def box_ring(box: Box, epsg: int) -> list[list[float]]:
"""상자 네 귀 경위도([경도, 위도] · 왼아래부터 반시계)."""
from common_util.common_util_crs import tm_to_lonlat
x0, y0, x1, y1 = box
return [list(tm_to_lonlat(x, y, epsg)) for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))]
def sheets_for_box(box: Box, epsg: int) -> list[str]:
"""상자에 닿는 도엽 — 경위도 외접 상자를 도엽 반 칸 간격으로 짚음."""
ring = box_ring(box, epsg)
lons, lats = [p[0] for p in ring], [p[1] for p in ring]
step = SHEET5K_SIZE_DEG / 2
def ticks(lo: float, hi: float) -> list[float]:
return [lo + step * i for i in range(int((hi - lo) / step) + 1)] + [hi]
grid = [
(lat, lon) for lat in ticks(min(lats), max(lats)) for lon in ticks(min(lons), max(lons))
]
return sorted(set(sheets_for_points(grid)))
def reference_points(segments: list[dict[str, Any]], epsg: int) -> list[tuple[float, float]]:
"""도엽 기준 좌표(위도, 경도) — 모든 지점 + 구간 표본."""
from pyproj import Transformer
to_wgs = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True)
points: list[tuple[float, float]] = []
for seg in segments:
for x, y in segment_samples(seg["start"], seg["end"]):
lon, lat = to_wgs.transform(x, y)
points.append((lat, lon))
return points
def plan_extent(
project_root: Path, points: list[XY], epsg: int, margin_m: float | None = None
) -> dict[str, Any]:
"""받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m)."""
margin = clamp_margin(margin_m)
have = received_box(project_root)
box = wanted_box(points, margin, have)
store = get_project_map_sheets_dir(project_root)
sheets = []
for no in sheets_for_box(box, epsg):
lon0, lat0, lon1, lat1 = sheet5k_to_bounds(no)
sheets.append(
{
"no": no,
"ring": [[lon0, lat0], [lon1, lat0], [lon1, lat1], [lon0, lat1]],
"stored": get_sheet_path(store, no) is not None,
}
)
return {
"epsg": int(epsg),
"margin_m": margin,
"box_m": list(box),
"box": box_ring(box, epsg),
"have_m": list(have) if have else None,
"have": box_ring(have, epsg) if have else None,
"covered": covers(have, box),
"sheets": sheets,
# DEM 칸 수 어림 — 받기 시간 · 메모리 가늠
"cells": int((box[2] - box[0]) * (box[3] - box[1]) / SHEET_SURFACE_GRID_M**2),
}
def write_plan_prj(project_root: Path, epsg: int) -> Path:
@@ -93,15 +168,6 @@ def write_plan_prj(project_root: Path, epsg: int) -> Path:
return path
def dem_outline(start: tuple[float, float], end: tuple[float, float]) -> np.ndarray:
"""DEM 상자 꼭짓점 둘 — B 여유(`SHEET_SURFACE_MARGIN_M`)를 더하면 복도 + `DEM_EXTRA_M`."""
pad = max(0.0, PLAN_CORRIDOR_M + DEM_EXTRA_M - SHEET_SURFACE_MARGIN_M)
xs, ys = (start[0], end[0]), (start[1], end[1])
return np.array(
[[min(xs) - pad, min(ys) - pad], [max(xs) + pad, max(ys) + pad]], dtype=np.float64
)
def _read_json(path: Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
@@ -158,29 +224,30 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep
def ensure_plan_terrain(
project_root: Path,
segments: list[dict[str, Any]],
points: list[XY],
epsg: int,
margin_m: float | None = None,
on_progress: Progress | None = None,
) -> dict[str, Any]:
"""지점 기준 지형 받기 — `segments` = [{year, start: (x, y), end: (x, y)}](작업 좌표계 m).
"""사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).
돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 연도별 모델 등록용 dict 목록.
돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록.
"""
def report(percent: int, message: str) -> None:
if on_progress is not None:
on_progress(percent, message)
if not segments:
raise ValueError("연도 구간이 없습니다.")
if not points:
raise ValueError("시점 · 종점이 없습니다.")
processed = project_root / STAGE / "processed"
processed.mkdir(parents=True, exist_ok=True)
prj = write_plan_prj(project_root, epsg)
crs = f"EPSG:{int(epsg)}"
margin = clamp_margin(margin_m)
box = wanted_box(points, margin, received_box(project_root))
report(5, "수치지형도 도엽 고르는 중")
points = reference_points(segments, epsg)
wanted = neighbors_for_points(points)
wanted = sheets_for_box(box, epsg)
store = get_project_map_sheets_dir(project_root)
before = {s for s in wanted if get_sheet_path(store, s) is not None}
report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
@@ -190,24 +257,30 @@ def ensure_plan_terrain(
report(45, "도엽 레이어 병합 중")
merged = _merge_sheets(processed, store, list(got["available"]))
report(60, "위성 지도 받는 중")
satellite = _satellite(processed, prj, points, epsg)
satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg)
years: dict[int, list[dict[str, Any]]] = {}
for i, seg in enumerate(segments):
year = int(seg["year"])
report(65 + 30 * i // len(segments), f"{year}년 구간 지형면 만드는 중")
models_dir = models_dir_of(project_root, year)
models_dir.mkdir(parents=True, exist_ok=True)
years[year] = build_sheet_surface_model(
project_root,
processed,
models_dir,
dem_outline(seg["start"], seg["end"]),
crs,
[SHEET_SURFACE_DEFAULT_METHOD],
report(65, "사업 지형면 만드는 중")
models_dir = models_dir_of(project_root)
models_dir.mkdir(parents=True, exist_ok=True)
# B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값)
inner = SHEET_SURFACE_MARGIN_M
outline = np.array(
[[box[0] + inner, box[1] + inner], [box[2] - inner, box[3] - inner]], dtype=np.float64
)
models = build_sheet_surface_model(
project_root,
processed,
models_dir,
outline,
f"EPSG:{int(epsg)}",
[SHEET_SURFACE_DEFAULT_METHOD],
)
if not models:
raise ValueError(
"사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인"
f"(병합 도엽 {len(merged)}매)"
)
if not years[year]:
raise ValueError(f"{year}년 구간 지형면을 만들지 못했습니다 — 도엽 등고선 확인")
atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin})
report(100, "지형 받기 끝")
return {
"sheets": {
@@ -217,5 +290,6 @@ def ensure_plan_terrain(
"merged": merged,
},
"satellite": satellite,
"years": years,
"box_m": list(box),
"models": models,
}