Files
Aislo/resources/tester/test_624_1_layers_any_project.py
T
eomsangdonandClaude Opus 5.5 e073e575f3 feat(D03): 예정노선 겹 길 임도 프로젝트도 · 지형 원천 인자 · 받은 겹 D03 몫 자리 (PLAN 624-1 · 요구 530 · 531)
- 겹 길(`/plan/layers/*`)이 프로젝트 종류를 안 가림 — 예정노선 = 받은 범위 · 수치지형도 · 임도 = B04 배경 지도 상자 · 설계 지형
- `terrain=sheet|lidar|design` 인자(raster · zero-order) · 응답 `source` · 파생 저장본 열쇠 = 원천 격자 파일 이름
- 라이다 0차곡 = 격자 통째(2 m 솎음 · 파생 저장본) · 수치지형도 = 창고 도엽 선 그대로
- 받은 겹(지적 · 보호구역 · 산림입지 · 단층 · 지질구조선 · 산사태) 자리 = `D03_PlanAnalysis/layers/` — B04 `processed/` 파일 덮음 0 · D 옛 자리 파일은 한 번 복사
- 새 `D03_PlanAnalysis_TerrainSource.py` · 라우터 661 → 658 줄 · 시험 `test_624_1_layers_any_project.py`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:19:14 +09:00

197 lines
8.9 KiB
Python

"""D03 지도 겹 길 — 임도 프로젝트도 받음 · 지형 원천 인자 · 받은 겹 자리(요구 530 · PLAN 624-1).
- 예정노선(D) · 임도(B) 둘 다 급경사 · 음영 · 지적 받음 · 원천 머리(`source`)
- 원천별 파생 저장본 열쇠 다름 · 없는 원천 404 · 틀린 원천 400
- 받은 겹 = `D03_PlanAnalysis/layers/` — B04 `processed/` 파일 안 덮음 · 새 파일 0
- D 옛 자리 파일은 한 번 복사(다시 안 받음) · D 급경사 값 = 옛 길 값
- 라이다 0차곡 = 격자 통째 · 저장본
./venv/Scripts/python.exe -m pytest resources/tester/test_624_1_layers_any_project.py -q
"""
import json
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
import numpy as np
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as source
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
EPSG = 5186
X0, Y0 = 200000.0, 450000.0
BOX = (X0, Y0, X0 + 400.0, Y0 + 400.0)
B04_CADASTRAL = b'{"type": "FeatureCollection", "features": [], "b04": true}'
def _dem(path, tilt: float, n: int = 60, cell: float = 2.0) -> None:
x = X0 + np.arange(n) * cell
y = Y0 + np.arange(n) * cell
z = np.add.outer(np.zeros(n), np.arange(n) * cell * tilt)
path.parent.mkdir(parents=True, exist_ok=True)
np.savez(path, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[cell])
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
class FakeWfs:
def __init__(self) -> None:
self.calls = 0
def __call__(self, box, epsg, typename):
self.calls += 1
lon, lat = 127.0, 37.0
ring = [[lon, lat], [lon + 1e-4, lat], [lon + 1e-4, lat + 1e-4], [lon, lat]]
geom = {"type": "Polygon", "coordinates": [ring]}
return [{"type": "Feature", "geometry": geom, "properties": {"pnu": str(box)}}]
@pytest.fixture
def world(tmp_path, monkeypatch):
"""D 프로젝트(수치지형도) · B 프로젝트(라이다 csf 확정 + 수치지형도 서피스 · B04 지적도)."""
import common_util.common_util_crs as crs
import common_util.common_util_surface_confirmation as confirm
import config.config_db as config_db
d_root, b_root = tmp_path / "D", tmp_path / "B"
_dem(d_root / "B04_PreProcess" / "models_plan" / "dtm_sheet_laplace.npz", 0.3)
b_models = b_root / "B04_PreProcess" / "models"
_dem(b_models / "dtm_csf.npz", 0.6)
_dem(b_models / "dtm_csf_smooth.npz", 0.5)
_dem(b_models / "dtm_sheet_laplace_smooth.npz", 0.2)
processed = b_root / "B04_PreProcess" / "processed"
processed.mkdir(parents=True)
(processed / layers.CADASTRAL_FILE).write_bytes(B04_CADASTRAL)
meta = {"x_min": BOX[0], "y_min": BOX[1], "x_max": BOX[2], "y_max": BOX[3]}
(processed / "vworld_white_meta.json").write_text(json.dumps(meta), "utf-8")
kinds = {"D": "PLAN", "B": "ROAD"}
state = {"pid": "D"}
params = {"source_filter": "csf", "method": "dtm", "smooth": True}
async def project_row(project_id):
return {"project_kind": kinds[state["pid"]], "storage_path": state["pid"]}
async def plan_ends(project_id, center=None, reset=False):
return d_root, EPSG, [], None
async def surface_params(connection, project_id):
return dict(params)
monkeypatch.setattr(router_mod.store, "project_row", project_row)
monkeypatch.setattr(router_mod, "_plan_ends", plan_ends)
monkeypatch.setattr(router_mod.terrain, "received_box", lambda root, epsg: BOX)
monkeypatch.setattr(source, "resolve_stored_project_path", lambda p: str(tmp_path / p))
monkeypatch.setattr(confirm, "get_surface_confirmation_params", surface_params)
monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(crs, "resolve_project_crs", lambda root: f"EPSG:{EPSG}")
wfs = FakeWfs()
monkeypatch.setattr(layers, "_wfs", wfs)
monkeypatch.setattr(zero_order, "heavy_pool", lambda: ThreadPoolExecutor(1))
app = FastAPI()
app.include_router(router_mod.router)
client = TestClient(app)
def get(pid: str, path: str, **query):
state["pid"] = pid
return client.get(f"/api/projects/{PID}/plan/layers/{path}", params=query)
return {"get": get, "D": d_root, "B": b_root, "wfs": wfs, "params": params}
def _raster(res) -> dict:
assert res.status_code == 200, res.text
return json.loads(res.headers["X-Plan-Raster"])
def test_both_kinds_get_slope_with_source(world) -> None:
get = world["get"]
assert _raster(get("D", "raster", kind="slope"))["source"] == "sheet"
assert _raster(get("B", "raster", kind="slope"))["source"] == "lidar" # 설계 지형 = 라이다
assert _raster(get("B", "raster", kind="slope", terrain="sheet"))["source"] == "sheet"
shade = get("B", "raster", kind="hillshade", terrain="lidar").json()
assert shade["source"] == "lidar" and len(shade["box"]) == 4
assert get("D", "raster", kind="slope", terrain="lidar").status_code == 404 # 624-4 전
assert get("B", "raster", kind="slope", terrain="las").status_code == 400
def test_source_keys_differ(world) -> None:
get = world["get"]
for terrain in ("lidar", "sheet", "design"):
get("B", "raster", kind="slope", terrain=terrain)
saved = sorted((world["B"] / "_derived" / layers.SLOPE_KIND).glob("*.json"))
assert len(saved) == 2 # design = lidar 같은 격자 → 같은 열쇠
lidar = _raster(get("B", "raster", kind="slope", terrain="lidar"))
sheet = _raster(get("B", "raster", kind="slope", terrain="sheet"))
assert lidar["counts"] != sheet["counts"]
def test_road_lidar_missing_when_sheet_only(world) -> None:
world["params"].update(source_filter="sheet_laplace") # 라이다 없는 임도
assert get_status(world, "lidar") == 404
assert get_status(world, "design") == 200
def get_status(world, terrain: str) -> int:
return world["get"]("B", "raster", kind="slope", terrain=terrain).status_code
def test_road_layers_never_touch_b04_files(world) -> None:
processed = world["B"] / "B04_PreProcess" / "processed"
before = {p.name: p.read_bytes() for p in processed.iterdir()}
res = world["get"]("B", "cadastral")
assert res.status_code == 200 and res.json()["features"]
after = {p.name: p.read_bytes() for p in processed.iterdir()}
assert after == before # 같은 이름 덮음 0 · 새 파일 0
mine = world["B"] / source.LAYER_DIR / layers.CADASTRAL_FILE
assert mine.is_file() and mine.read_bytes() != B04_CADASTRAL
def test_plan_old_files_copied_once(world) -> None:
"""D 옛 자리(B04 processed) 지적도 + 열쇠 = 새 자리로 복사 · 다시 받지 않음 · 값 같음."""
old = world["D"] / "B04_PreProcess" / "processed"
old.mkdir(parents=True)
body = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}}]}
(old / layers.CADASTRAL_FILE).write_text(json.dumps(body), "utf-8")
key = {"box": list(BOX), "epsg": EPSG, "count": 1, "failed": 0, "failed_tiles": []}
(old / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
res = world["get"]("D", "cadastral")
assert res.status_code == 200 and res.json() == body and world["wfs"].calls == 0
assert (world["D"] / source.LAYER_DIR / layers.CADASTRAL_KEY).is_file()
def test_plan_slope_same_as_before(world, tmp_path) -> None:
"""D 급경사 = 옛 길(`slope_layer(root, models_plan, epsg)`) 값 그대로."""
res = world["get"]("D", "raster", kind="slope")
meta = _raster(res)
old = layers.slope_layer(world["D"], world["D"] / "B04_PreProcess" / "models_plan", EPSG)
png, old_meta = layers.raster_bytes(old)
assert res.content == png and meta == {"kind": "slope", "source": "sheet", **old_meta}
def test_lidar_zero_order_whole_grid(world) -> None:
"""라이다 0차곡 = 격자 통째(1 m → 2 m 솎음) · 저장본 · 수치지형도 = 창고 길."""
n, cell = 200, 1.0
x, y = X0 + np.arange(n) * cell, Y0 + np.arange(n) * cell
valley = np.abs(x - x.mean())[None, :] * 0.3 + (y - Y0)[:, None] * 0.2
npz = world["B"] / "B04_PreProcess" / "models" / "dtm_csf_smooth.npz"
np.savez(npz, x=x, y=y, z=valley, valid_mask=np.ones(valley.shape, bool), resolution=[cell])
res = world["get"]("B", "zero-order", terrain="lidar")
got = res.json()
assert res.status_code == 200 and got["source"] == "lidar" and got["cell_m"] == 2.0
assert got["count"] >= 1 and got["length_m"] > 0
again = zero_order.dem_zero_order_layer(world["B"], npz, EPSG, compute=False)
assert again is not None and again["count"] == got["count"]
frame = source.plan_frame(world["D"], EPSG, BOX, None)
work, light, _ = source.zero_order_calls(frame, 1.0, 2.0)
assert work is zero_order.zero_order_layer and light[-1] == frame.dem