⚠ **뿌리** — `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>
217 lines
8.9 KiB
Python
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
|