"""띠 밖 좌표계 = 띠 지형 옮겨 담기(PLAN 614-1) — 띠로 먼저 셈 · 옮긴 것도 그 좌표계 열쇠로 저장 · 둘째 읽기 셈 없음 · 참값(등고선 위 점) 대비 옮김 오차 ≤ 직접 + 0.10 m · 이미 직접 센 저장본 그대로 · 끄면 직접 셈 · 빈 띠 = 빈 도엽 · 가장자리 직선 연장. ./venv/Scripts/python.exe -m pytest resources/tester/test_614_terrain_reproject.py -q """ import json import numpy as np import pytest from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds from common_util import common_util_terrain_store as store from common_util import common_util_terrain_surface as surf BAND, OTHER = 5187, 5186 SHEET = "36901074" CELL = 10.0 @pytest.fixture def fake(tmp_path, monkeypatch): """띠(5187) 좌표의 굽은 비탈(남서 먼 곳 중심 동심 호 · 간격 점점 넓음) — 요청 좌표계로 옮겨 상자 안 선분만 돌려줌.""" lon0, lat0, _, _ = sheet5k_to_bounds(SHEET) to_band = Transformer.from_crs("EPSG:4326", f"EPSG:{BAND}", always_xy=True) cx, cy = to_band.transform(lon0 - 0.04, lat0 - 0.04) arc = np.linspace(0.0, np.pi / 2, 401)[:, None] lines = [] for k in range(250): r = 3000.0 + 30.0 * k + 0.1 * k * k lines.append((np.hstack([cx + r * np.cos(arc), cy + r * np.sin(arc)]), 500.0 + 5.0 * k)) a, b, z = surf.segments_from_lines(lines) gpkg = tmp_path / "national.gpkg" gpkg.write_bytes(b"v1") calls = {"build": 0} real_build = store.build_contour_surface def segments(epsg, box): tr = Transformer.from_crs(f"EPSG:{BAND}", store._crs(epsg), always_xy=True) pa, pb = np.column_stack(tr.transform(*a.T)), np.column_stack(tr.transform(*b.T)) hit = (np.maximum(pa[:, 0], pb[:, 0]) >= box[0]) & ( np.minimum(pa[:, 0], pb[:, 0]) <= box[2] ) hit &= (np.maximum(pa[:, 1], pb[:, 1]) >= box[1]) & ( np.minimum(pa[:, 1], pb[:, 1]) <= box[3] ) return pa[hit], pb[hit], z[hit] def counted(*args): calls["build"] += 1 return real_build(*args) monkeypatch.setattr(store, "TERRAIN_ROOT", tmp_path / "terrain") monkeypatch.setattr(store, "NATIONAL_GPKG", gpkg) monkeypatch.setattr(store, "_gpkg_segments", segments) monkeypatch.setattr(store, "build_contour_surface", counted) monkeypatch.setattr(store, "REPROJECT_FROM_BAND", True) return calls def _key(epsg): path = store._names(SHEET, epsg, CELL)[1] return json.loads(path.read_text(encoding="utf-8")) def test_other_crs_reprojects_band_and_stores(fake): assert store._via(SHEET, OTHER) == BAND and store._via(SHEET, BAND) is None got = store.load_terrain(OTHER, sheets=[SHEET], cell_m=CELL) assert got["computed"] == [SHEET] and not got["failed"] assert fake["build"] == 1 # 띠 한 번만 셈 · 옮기기는 셈 아님 assert _key(BAND).get("via") is None and _key(OTHER)["via"] == BAND assert _key(OTHER)["source"] == "reproject" again = store.load_terrain(OTHER, sheets=[SHEET], cell_m=CELL) assert again["computed"] == [] and fake["build"] == 1 assert np.array_equal(np.nan_to_num(again["z"], nan=-1), np.nan_to_num(got["z"], nan=-1)) # 직접 셈(끔)과 견줌 — 같은 칸 거의 다 · 높이 차 작음 store.REPROJECT_FROM_BAND = False assert store._stored_ok(SHEET, OTHER, CELL) is None # 끄면 옮긴 저장본 버림 direct = store.load_terrain(OTHER, sheets=[SHEET], cell_m=CELL) assert direct["computed"] == [SHEET] and fake["build"] == 2 assert _key(OTHER).get("via") is None assert direct["z"].shape == got["z"].shape both = direct["valid_mask"] & got["valid_mask"] assert both.sum() > 0.98 * direct["valid_mask"].sum() diff = np.abs(direct["z"][both] - got["z"][both]) assert float(np.percentile(diff, 95)) < 0.5 and float(diff.max()) < 2.0 # 10 m 칸 견줌 # 문턱(브레인) = 참값(등고선 위 점) 대비 오차 — 옮김이 더하는 95 % ≤ 0.10 m · 최대 ≤ 직접 최대 gx, gy = direct["x"], direct["y"] a, b, z = store._gpkg_segments(OTHER, (gx[2], gy[2], gx[-3], gy[-3])) px, py = (a[:, 0] + b[:, 0]) / 2, (a[:, 1] + b[:, 1]) / 2 inside = (px > gx[2]) & (px < gx[-3]) & (py > gy[2]) & (py < gy[-3]) err_direct = np.abs(store._bilinear(direct, px[inside], py[inside]) - z[inside]) err_moved = np.abs(store._bilinear(got, px[inside], py[inside]) - z[inside]) ok = np.isfinite(err_direct) & np.isfinite(err_moved) assert ok.sum() > 1000 added = np.percentile(err_moved[ok], 95) - np.percentile(err_direct[ok], 95) assert added <= 0.10 and err_moved[ok].max() <= err_direct[ok].max() # 다시 켜도 직접 센 저장본은 그대로 씀 store.REPROJECT_FROM_BAND = True assert store._stored_ok(SHEET, OTHER, CELL) is not None store.load_terrain(OTHER, sheets=[SHEET], cell_m=CELL) assert fake["build"] == 2 def test_empty_band_is_empty(fake, monkeypatch): monkeypatch.setattr(store, "_gpkg_segments", lambda epsg, box: store._NO_SEGMENTS) monkeypatch.setattr(store, "_fetch_zips", lambda sheet_no, report: ({}, None)) assert store.ensure_sheet_terrain(SHEET, OTHER, CELL) is None assert _key(BAND)["empty"] and _key(OTHER)["empty"] and _key(OTHER)["via"] == BAND assert store.ensure_sheet_terrain(SHEET, OTHER, CELL) is None def test_extend_edges_and_bilinear_exact_on_plane(): y, x = np.mgrid[0:12, 0:15].astype(float) plane = 3.0 * x - 2.0 * y + 7.0 z = np.full_like(plane, np.nan) z[1:-1, 1:-1] = plane[1:-1, 1:-1] assert np.allclose(store._extend_edges(z), plane) grid = {"z": z, "x": np.arange(15) + 0.5, "y": np.arange(12) + 0.5, "cell_m": 1.0} px, py = np.array([0.5, 7.3, 14.4]), np.array([0.6, 5.9, 11.5]) assert np.allclose(store._bilinear(grid, px, py), 3.0 * (px - 0.5) - 2.0 * (py - 0.5) + 7.0)