From f17db59ab6402ada351ec644aab24c17cf1bb3f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:12:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(D03):=20=EC=A7=80=ED=98=95=20=3D=20?= =?UTF-8?q?=EC=82=AC=EC=97=85=20=EC=A0=84=EC=B2=B4=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=20=C2=B7=20=EB=B0=9B=EA=B8=B0=20=EC=A0=84=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=20API=20(PLAN=20561-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지형 받기 — 연도 구간마다 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 --- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 77 +++++-- D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py | 204 +++++++++++++------ resources/tester/test_530_1_plan_terrain.py | 97 +++++---- resources/tester/test_530_2_plan_analyze.py | 4 +- resources/tester/test_530_2_plan_router.py | 57 +++++- 5 files changed, 315 insertions(+), 124 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 94fbe4563..172bc6250 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index d50044880..52ad42a9e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -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, } diff --git a/resources/tester/test_530_1_plan_terrain.py b/resources/tester/test_530_1_plan_terrain.py index 3958cbb58..0e61d34ce 100644 --- a/resources/tester/test_530_1_plan_terrain.py +++ b/resources/tester/test_530_1_plan_terrain.py @@ -1,10 +1,10 @@ -"""D 예정노선 지형 받기(PLAN 530-1) — 가짜 내려받기 · 옛 도엽 유지 · 새 도엽만 · 빠짐 없음. +"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · +옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q """ import json -import math from pathlib import Path import pytest @@ -60,56 +60,85 @@ def _zips(root): return sorted(p.stem for p in store_mod.get_project_map_sheets_dir(root).glob("*.zip")) -def test_more_points_keep_old_sheets_and_fetch_only_new(fake) -> None: +def _dem_box(seen) -> list[float]: + """가짜 DEM 이 받은 꼭짓점 상자 + B 절취 여유 = 실제 DEM 상자.""" + (x0, y0), (x1, y1) = seen["dem"][-1][1] + m = SHEET_SURFACE_MARGIN_M + return [x0 - m, y0 - m, x1 + m, y1 + m] + + +def test_one_set_and_extend_keeps_old(fake) -> None: root, seen = fake - first = terrain.ensure_plan_terrain(root, [{"year": 2026, "start": A, "end": B}], EPSG) + first = terrain.ensure_plan_terrain(root, [A, B], EPSG) old = _zips(root) assert sorted(seen["download"]) == old == sorted(first["sheets"]["downloaded"]) - assert first["satellite"] == "done" and list(first["years"]) == [2026] + assert first["satellite"] == "done" and len(first["models"]) == 1 + assert seen["dem"][-1][0] == "models_plan" # 연도 없는 한 벌 + m = terrain.DEFAULT_MARGIN_M + box = [min(A[0], B[0]) - m, min(A[1], B[1]) - m, max(A[0], B[0]) + m, max(A[1], B[1]) + m] + assert _dem_box(seen) == pytest.approx(box) == first["box_m"] + assert terrain.received_box(root) == pytest.approx(tuple(box)) # 작업 좌표계 .prj — B 좌표계 창구가 읽음 assert resolve_project_crs(root) == f"EPSG:{EPSG}" + # 그은 점이 범위 밖 — 더 받기 = 옛 범위 ∪ 새 범위 · 새 도엽만 seen["download"].clear() - segs = [{"year": 2026, "start": A, "end": B}, {"year": 2027, "start": B, "end": C}] - second = terrain.ensure_plan_terrain(root, segs, EPSG) + second = terrain.ensure_plan_terrain(root, [A, B, C], EPSG) now = _zips(root) assert set(old) <= set(now) # 옛 도엽 그대로(prune 없음) - assert sorted(seen["download"]) == sorted(set(now) - set(old)) # 새 도엽만 받음 + assert sorted(seen["download"]) == sorted(set(now) - set(old)) assert seen["merge"][-1] == now # 병합 = 옛 ∪ 새 - # 위성 = 옛 범위 ∪ 새 범위 - first_box, union = seen["satellite"][0], seen["satellite"][-1] - assert union["x"][0] <= first_box["x"][0] and union["x"][1] >= first_box["x"][1] - assert union["y"][0] <= first_box["y"][0] and union["y"][1] >= first_box["y"][1] - assert [d[0] for d in seen["dem"][-2:]] == ["models_2026", "models_2027"] + grown = second["box_m"] + assert grown[0] <= box[0] and grown[1] <= box[1] and grown[2] >= C[0] + m + first_sat, union = seen["satellite"][0], seen["satellite"][-1] + assert union["x"][0] <= first_sat["x"][0] and union["x"][1] >= first_sat["x"][1] assert second["sheets"]["failed"] == {} - # 같은 지점 다시 — 받기 0 · 위성 건너뜀 · 병합 열쇠 같음 + # 같은 범위 다시 — 받기 0 · 위성 건너뜀 · 병합 열쇠 같음 · 작은 점이어도 범위 줄지 않음 seen["download"].clear() merges = len(seen["merge"]) - third = terrain.ensure_plan_terrain(root, segs, EPSG) + third = terrain.ensure_plan_terrain(root, [A, B], EPSG) assert seen["download"] == [] and third["satellite"] == "skip" - assert len(seen["merge"]) == merges + assert len(seen["merge"]) == merges and third["box_m"] == grown -def test_far_points_leave_no_gap(fake) -> None: +def test_extent_no_download_and_covered(fake) -> None: + root, seen = fake + before = terrain.plan_extent(root, [A, B], EPSG) + assert seen["download"] == [] and before["have"] is None and before["covered"] is False + assert before["margin_m"] == terrain.DEFAULT_MARGIN_M and len(before["box"]) == 4 + x0, y0, x1, y1 = before["box_m"] + assert before["cells"] == int((x1 - x0) * (y1 - y0)) + assert all(not s["stored"] and len(s["ring"]) == 4 for s in before["sheets"]) + terrain.ensure_plan_terrain(root, [A, B], EPSG) + after = terrain.plan_extent(root, [A, B], EPSG) + assert after["covered"] and after["have_m"] == after["box_m"] + assert all(s["stored"] for s in after["sheets"]) + assert {s["no"] for s in after["sheets"]} == {s["no"] for s in before["sheets"]} + assert not terrain.plan_extent(root, [A, C], EPSG)["covered"] # 그은 점이 밖 = 더 받기 + # 여유 — 바닥 B 절취 여유 · 빈 값 = 처음 값 + assert terrain.clamp_margin(100.0) == SHEET_SURFACE_MARGIN_M + assert terrain.clamp_margin(None) == terrain.DEFAULT_MARGIN_M + assert terrain.clamp_margin(1e9) == terrain.MAX_MARGIN_M + + +def test_box_sheets_leave_no_gap(fake) -> None: root, _ = fake - far = (A[0] + 9000.0, A[1] + 7000.0) # 11 km — 3×3 두 벌이 안 닿는 거리 - got = terrain.ensure_plan_terrain(root, [{"year": 2026, "start": A, "end": far}], EPSG) + far = (A[0] + 9000.0, A[1] + 7000.0) # 11 km + got = terrain.ensure_plan_terrain(root, [A, far], EPSG) + x0, y0, x1, y1 = got["box_m"] to_wgs = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True) - on_line = [] - for i in range(201): - lon, lat = to_wgs.transform( - A[0] + (far[0] - A[0]) * i / 200, A[1] + (far[1] - A[1]) * i / 200 - ) - on_line.append((lat, lon)) - assert set(sheets_for_points(on_line)) <= set(got["sheets"]["merged"]) + inside = [] + for i in range(41): + for j in range(41): + lon, lat = to_wgs.transform(x0 + (x1 - x0) * i / 40, y0 + (y1 - y0) * j / 40) + inside.append((lat, lon)) + assert set(sheets_for_points(inside)) <= set(got["sheets"]["merged"]) -def test_dem_outline_covers_corridor() -> None: - box = terrain.dem_outline(A, B) - reach = terrain.PLAN_CORRIDOR_M + terrain.DEM_EXTRA_M - assert box[0][0] - SHEET_SURFACE_MARGIN_M <= min(A[0], B[0]) - reach + 1e-9 - assert box[1][1] + SHEET_SURFACE_MARGIN_M >= max(A[1], B[1]) + reach - 1e-9 - samples = terrain.segment_samples(A, C) - steps = [math.dist(p, q) for p, q in zip(samples, samples[1:])] - assert samples[0] == A and samples[-1] == C and max(steps) <= terrain.SAMPLE_STEP_M +def test_empty_surface_raises_and_keeps_no_box(fake, monkeypatch) -> None: + root, _ = fake + monkeypatch.setattr(terrain, "build_sheet_surface_model", lambda *a, **k: []) + with pytest.raises(ValueError, match="사업 지형면을 만들지 못했습니다"): + terrain.ensure_plan_terrain(root, [A, B], EPSG) + assert terrain.received_box(root) is None diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index cbab24f0f..222c12468 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -56,8 +56,8 @@ def run(tmp_path, hill): def test_boundary_chainage_and_line(run) -> None: results = run() - # 연도 구간마다 제 DEM · 제 비용면 캐시 - assert run.calls == [("models_2026", "cost_2026"), ("models_2027", "cost_2027")] + # DEM = 사업 전체 한 벌(561-2) · 비용면 캐시는 연도 구간마다 + assert run.calls == [("models_plan", "cost_2026"), ("models_plan", "cost_2027")] for seg, result in zip(SEGMENTS, results): assert set(result["routes"]) == set(analyze.PLAN_MODES) for route in result["routes"].values(): diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 0ade27d52..5003b85a9 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -1,6 +1,8 @@ -"""D 분석 API(PLAN 530-2 · 530-3) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · 모르는 갈래 -거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · [저장] 때만 지점 정본(산림청만) · -꺾임점 고침(543-3) = 서버가 마디로 선 · 지표를 다시 셈 · 누가가 고친 길이로 · GET 은 지문 같은 고침만. +"""D 분석 API(PLAN 530-2 · 530-3 · 561-2) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · +모르는 갈래 거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · +[저장] 때만 지점 정본(산림청만) · +꺾임점 고침(543-3) = 서버가 마디로 선 · 지표를 다시 셈 · 누가가 고친 길이로 · GET 은 지문 같은 +고침만 · 지형 = 전체 시점 · 종점 한 벌 · 범위 보기. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_router.py -q """ @@ -301,3 +303,52 @@ def test_moved_analysis_keeps_saved_store(client, tmp_path, monkeypatch) -> None assert len(keyed) == analyze.KEEP_PER_YEAR + 1 assert analyze.segment_path(tmp_path, client.segments[0]).name in keyed assert client.get(url).json() == after + + +def test_terrain_extent_uses_whole_ends(client) -> None: + got = client.get(f"/api/projects/{PID}/plan/terrain/extent?margin_m=400").json() + m = 400.0 + assert got["margin_m"] == m and got["have"] is None and got["covered"] is False + # 전체 시점 = 첫 연도 시점 · 전체 종점 = 끝 연도 종점(연도 구간 아님) + assert got["box_m"] == [P0[0] - m, P0[1] - m, P2[0] + m, P2[1] + m] + extra = client.get( + f"/api/projects/{PID}/plan/terrain/extent", params={"extra": json.dumps([[2000, 0]])} + ).json() + assert extra["box_m"][2] == 2000 + extra["margin_m"] + bad = client.get(f"/api/projects/{PID}/plan/terrain/extent", params={"extra": "[1]"}) + assert bad.status_code == 400 + + +def test_terrain_post_runs_one_set(client, monkeypatch) -> None: + import asyncio + + calls: list = [] + saved: list = [] + works: list = [] + + def ensure(root, points, epsg, margin_m, report): + calls.append((points, epsg, margin_m)) + return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]} + + async def save(project_id, models, epsg): + saved.append((models, epsg)) + + def start(project_id, kind, work): + works.append(work) + return {"status": "accepted", "kind": kind} + + def post(body): + res = client.post(f"/api/projects/{PID}/plan/terrain", json=body) + asyncio.run(works.pop()(lambda percent, message: None)) + return res + + monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure) + monkeypatch.setattr(router_mod, "_save_models", save) + monkeypatch.setattr(router_mod, "_start_job", start) + res = post({"margin_m": 500, "extra": [[5, 6]]}) + assert res.json() == {"status": "accepted", "kind": "terrain"} + assert calls == [([P0, P2, (5.0, 6.0)], 5186, 500.0)] + assert saved == [([{"model_file_path": "m"}], 5186)] + # 옛 화면 꼴(points 만 · 빈 몸) — 여유 처음 값 · 전체 시점 · 종점 + post({"points": []}) + assert calls[-1] == ([P0, P2], 5186, None)