Files
Aislo/resources/tester/test_sheet_surface.py
eomsangdonandClaude Opus 5 0ef32b5279 chore(tester): 시험을 resources/tester/ 로 옮김 — 창끼리 건너가게
⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt`
가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는
시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음.

- `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침**
- `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시)
- 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q`
  옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0

⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:12:30 +09:00

217 lines
8.9 KiB
Python

"""도엽등고선 3D 서피스(B04_PreProcess_Engine_SheetSurface) 단위 검증.
합성 등고선(정사각 링 3단, EPSG:5186 → WGS84 역투영)으로 dtm_sheet.npz를 만들고,
종·횡단이 쓰는 build_surface_sampler("sheet", "dtm")로 왕복 조회가 되는지 본다.
"""
import json
import sys
from pathlib import Path
import numpy as np
import pytest
from pyproj import Transformer
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import ( # noqa: E402
build_sheet_surface_model,
)
from common_util.common_util_surface_sampler import build_surface_sampler # noqa: E402
EPSG = 5186
# 사업지 부근 임의 원점 (EPSG:5186 중부원점 좌표계, m)
X0, Y0 = 200000.0, 500000.0
def _square_ring(cx: float, cy: float, half: float) -> list[list[float]]:
"""정사각 폐합 링 (반시계)."""
return [
[cx - half, cy - half],
[cx + half, cy - half],
[cx + half, cy + half],
[cx - half, cy + half],
[cx - half, cy - half],
]
@pytest.fixture()
def sheet_project(tmp_path: Path) -> dict:
"""processed/도엽_등고선.geojson(WGS84)을 갖춘 가짜 프로젝트 저장소."""
processed_dir = tmp_path / "B04_PreProcess" / "processed"
models_dir = tmp_path / "B04_PreProcess" / "models"
processed_dir.mkdir(parents=True)
models_dir.mkdir(parents=True)
to_wgs84 = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True)
features = []
# 바깥 100m → 안쪽 120m: 중심으로 갈수록 높은 언덕
for half, elev in ((450.0, 100.0), (300.0, 110.0), (150.0, 120.0)):
ring = [list(to_wgs84.transform(x, y)) for x, y in _square_ring(X0, Y0, half)]
features.append(
{
"type": "Feature",
"properties": {"등고수치": elev},
"geometry": {"type": "LineString", "coordinates": ring},
}
)
(processed_dir / "도엽_등고선.geojson").write_text(
json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False),
encoding="utf-8",
)
return {"root": tmp_path, "processed": processed_dir, "models": models_dir}
def test_sheet_surface_npz_and_sampler_roundtrip(sheet_project: dict) -> None:
# 노선: 중심을 지나는 100m 직선
route_xy = np.array([[X0 - 50.0, Y0], [X0 + 50.0, Y0]], dtype=np.float64)
models = build_sheet_surface_model(
sheet_project["root"],
sheet_project["processed"],
sheet_project["models"],
route_xy,
EPSG,
methods=["tin_sheet"],
)
assert len(models) == 1
model = models[0]
assert model["model_type"] == "dtm"
assert model["generation_params"]["source_filter"] == "sheet_tin_sheet"
npz_path = sheet_project["models"] / "dtm_sheet_tin_sheet.npz"
assert npz_path.is_file()
with np.load(npz_path) as data:
for key in ("x", "y", "z", "valid_mask"):
assert key in data, key
x, y = data["x"], data["y"]
# DtmGridSampler 규약: 오름차순 축, z=(len(y), len(x))
assert x[0] < x[-1] and y[0] < y[-1]
assert data["z"].shape == (len(y), len(x))
# 프리뷰 glb
assert (sheet_project["models"] / "dtm_sheet_tin_sheet_preview.glb").stat().st_size > 0
# 종·횡단 공용 sampler로 왕복 — 등고선 안쪽은 유효 표고.
# 최고 등고선(120) 안쪽은 마루 캡(경사 연장)으로 최대 +간격-0.5m까지 올라갈 수 있다.
sampler = build_surface_sampler(sheet_project["models"], "sheet_tin_sheet", "dtm", smooth=False)
inner = np.array([[X0, Y0 + 200.0], [X0 + 100.0, Y0 - 100.0]], dtype=np.float64)
z, valid = sampler.sample_xy(inner)
assert valid.all()
assert np.all((z >= 100.0 - 1e-6) & (z <= 124.5 + 1e-3))
# TIN 바깥(등고선 밖 + margin 밖)은 무효
outer = np.array([[X0 + 10000.0, Y0]], dtype=np.float64)
_, outer_valid = sampler.sample_xy(outer)
assert not outer_valid.any()
def test_sheet_surface_without_contours_returns_none(tmp_path: Path) -> None:
processed_dir = tmp_path / "B04_PreProcess" / "processed"
models_dir = tmp_path / "B04_PreProcess" / "models"
processed_dir.mkdir(parents=True)
models_dir.mkdir(parents=True)
route_xy = np.array([[0.0, 0.0], [10.0, 0.0]])
assert build_sheet_surface_model(tmp_path, processed_dir, models_dir, route_xy, EPSG) == []
def test_interpolate_between_contours_is_continuous() -> None:
"""등고선 사이 z가 거리 비례로 연속 변화 — 계단(같은 표고 평탄면)이 없다."""
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import build_distance
burned = np.full((10, 101), np.nan, dtype=np.float32)
burned[:, 0] = 100.0 # 좌측 벽 100m
burned[:, 100] = 105.0 # 우측 벽 105m
z = build_distance(None, burned, None, 1.0)
middle = z[5]
assert abs(float(middle[0]) - 100.0) < 1e-3
assert abs(float(middle[100]) - 105.0) < 1e-3
assert abs(float(middle[50]) - 102.5) < 0.05 # 중앙 = 정확히 절반
# 단조 증가이고 계단(연속 동일값)이 없다
steps = np.diff(middle)
assert np.all(steps > 0)
def test_resolve_enclosed_interiors_raises_summit_lowers_pit() -> None:
"""폐합 등고선 안쪽 — 바깥이 낮으면 마루로 올리고, 높으면 웅덩이로 내린다."""
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import _resolve_enclosed_interiors
burned = np.full((60, 60), np.nan, dtype=np.float32)
burned[10:50, 10] = 100.0
burned[10:50, 49] = 100.0
burned[10, 10:50] = 100.0
burned[49, 10:50] = 100.0 # 100m 폐합 사각 링
surface = np.full((60, 60), 95.0, dtype=np.float32) # 바깥이 낮은 지형
surface[11:49, 11:49] = 97.0 # 보간이 만든 분화구
handled = _resolve_enclosed_interiors(burned, [100.0], surface, 1.0, 5.0)
assert handled.any()
center = float(surface[29:31, 29:31].max())
assert 100.0 < center <= 100.0 + 4.5 + 1e-3 # 상한 +간격-0.5m
# 바깥이 높으면 웅덩이 — 안쪽이 내려간다
surface2 = np.full((60, 60), 110.0, dtype=np.float32)
surface2[11:49, 11:49] = 103.0
assert _resolve_enclosed_interiors(burned, [100.0], surface2, 1.0, 5.0).any()
assert float(surface2[30, 30]) < 100.0
def test_cone_reproduction_beats_over_relaxation() -> None:
"""원뿔(동심원 등고선) 재현 — 거리 보간이 정답에 가깝고, 과도한 완화는 악화된다.
z=r 형상은 harmonic이 아니라 biharmonic이라, 라플라스 완화를 수렴시키면 마루가
눌린다. ANUDEM이 thin plate spline을 쓰는 이유(Hutchinson 1988/89)를 지킨다.
"""
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import build_distance, relax_laplace
n = 301
centre = n // 2
rows, cols = np.indices((n, n))
radius = np.hypot(rows - centre, cols - centre)
truth = 140.0 - radius * 0.1 # 반경 1m마다 0.1m 하강 = 1m 등고선 간격 10m
burned = np.full((n, n), np.nan, dtype=np.float32)
for level in (110.0, 115.0, 120.0, 125.0, 130.0, 135.0):
burned[np.abs(radius - (140.0 - level) / 0.1) < 0.5] = level
band = (radius > 55) & (radius < 295)
base = build_distance(None, burned, None, 1.0)
base_error = float(np.abs(base - truth)[band].mean())
assert base_error < 0.5 # 거리 보간은 원뿔을 그대로 재현한다
light = base.copy()
relax_laplace(light, np.isfinite(burned), 5)
assert float(np.abs(light - truth)[band].mean()) <= base_error + 0.02
heavy = base.copy()
relax_laplace(heavy, np.isfinite(burned), 200)
# 수렴시키면 harmonic 해로 끌려가 오차가 눈에 띄게 커진다
assert float(np.abs(heavy - truth)[band].mean()) > base_error
def test_tin_sheet_follows_source_contour_lines(sheet_project: dict) -> None:
"""TIN(도엽선)은 원본 등고선 자리에서 그 표고를 그대로 낸다 — 격자 계단을 타지 않는다.
링을 셀 격자와 어긋난 위치(+0.37m)에 두어, 격자에 구운 라인 셀이 아니라 벡터
정점을 쓰는지 가른다.
"""
route_xy = np.array([[X0 - 200.0, Y0 - 200.0], [X0 + 200.0, Y0 + 200.0]], dtype=np.float64)
models = build_sheet_surface_model(
sheet_project["root"],
sheet_project["processed"],
sheet_project["models"],
route_xy,
EPSG,
methods=["tin_sheet"],
)
assert len(models) == 1
sampler = build_surface_sampler(sheet_project["models"], "sheet_tin_sheet", "dtm", smooth=False)
# 각 링 변 위의 점 — 표고는 그 링의 값이어야 한다
probes, expected = [], []
for half, elev in ((300.0, 110.0), (150.0, 120.0)):
for offset in (-100.0, 0.0, 100.0):
probes.append([X0 + offset + 0.37, Y0 - half + 0.37])
expected.append(elev)
z, valid = sampler.sample_xy(np.asarray(probes))
assert valid.all()
assert np.abs(z - np.asarray(expected)).max() < 1.0