"""공용 지형 창고 · 흠 고친 지형 계산(PLAN 607-1~3) — 굽기 방향 무관 · 고리 둘레 채움 · 조각 = 통째 · 처음만 셈 · 같이 두 번 = 한 번 · 원천 바뀌면 다시 · 빠진 곳 = 도엽 zip · 받기 실패는 저장 안 함. ./venv/Scripts/python.exe -m pytest resources/tester/test_607_terrain_store.py -q """ import threading import numpy as np import pytest from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import _resolve_enclosed_interiors from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from common_util import common_util_terrain_store as store from common_util import common_util_terrain_surface as surf EPSG = 5187 WEST, EAST = "36901074", "36901075" def _circle(cx, cy, r, n=48): t = np.linspace(0.0, 2 * np.pi, n + 1) return np.column_stack([cx + r * np.cos(t), cy + r * np.sin(t)]) def _field_lines(x0, y0, hill): """기운 면(대각 등고선 · 50 m 마다 5 m) + 봉우리 고리 둘 — (꼭짓점, 높이) 목록.""" normal, along = np.array([0.8, 0.6]), np.array([-0.6, 0.8]) t = np.array([[-8000.0], [8000.0]]) lines = [ (np.array([x0, y0]) + normal * (k * 50.0) + t * along, 500.0 + 5.0 * k) for k in range(-40, 160) ] hx, hy = hill lines.append((_circle(hx, hy, 60.0), 1000.0)) lines.append((_circle(hx, hy, 30.0), 1005.0)) return lines def test_burn_ignores_direction_and_split(): spec = GridSpec(x_min=0.0, y_max=200.0, cell_m=2.0, n_rows=100, n_cols=100) rng = np.random.default_rng(7) pts = np.cumsum(rng.normal(0, 3.3, (300, 2)), axis=0) + 100.0 a, b, z = surf.segments_from_lines([(pts, 10.0), (_circle(80, 90, 41.3), 15.0)]) one = surf.burn_segments(spec, a, b, z) flipped = surf.burn_segments(spec, b[::-1], a[::-1], z[::-1]) assert np.array_equal(np.isfinite(one), np.isfinite(flipped)) assert np.array_equal(one[np.isfinite(one)], flipped[np.isfinite(flipped)]) # 선을 두 조각으로 나눠도 같음 a2, b2, z2 = surf.segments_from_lines([(pts[:150], 10.0), (pts[149:], 10.0)]) c = surf.segments_from_lines([(_circle(80, 90, 41.3), 15.0)]) split = surf.burn_segments( spec, np.vstack([a2, c[0]]), np.vstack([b2, c[1]]), np.concatenate([z2, c[2]]) ) assert np.array_equal(np.nan_to_num(one, nan=-1), np.nan_to_num(split, nan=-1)) # 굵기 1 칸 — 가로 선은 한 줄 flat = surf.burn_segments(spec, np.array([[10.0, 51.0]]), np.array([[150.0, 51.0]]), np.ones(1)) assert int(np.isfinite(flat).sum()) == 71 and np.isfinite(flat).any(axis=1).sum() == 1 def test_fill_direction_follows_own_ring(): """같은 높이 고리 둘 — 하나는 봉우리(바깥 낮음) · 하나는 웅덩이(바깥 높음).""" spec = GridSpec(x_min=0.0, y_max=400.0, cell_m=2.0, n_rows=200, n_cols=300) lines = [ (_circle(100, 200, 80), 95.0), (_circle(100, 200, 40), 100.0), # 봉우리: 95 안에 100 (_circle(300, 200, 80), 105.0), (_circle(300, 200, 40), 100.0), # 웅덩이: 105 안에 100 ] a, b, z = surf.segments_from_lines(lines) out = surf.build_contour_surface(spec, a, b, z) row = 100 hill, pit = out[row, 50], out[row, 150] assert hill > 100.5 and pit < 99.5 # 옛 B 판은 같은 높이 선 전체 둘레로 재서 뒤집힘(이 꼴은 둘 다) from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import build_laplace burned = surf.burn_segments(spec, a, b, z) present = sorted(np.unique(burned[np.isfinite(burned)]).tolist()) old = build_laplace(spec, burned, None, 2.0) _resolve_enclosed_interiors(burned, present, old, 2.0, 5.0) assert not (old[row, 50] > 100.5 and old[row, 150] < 99.5) def _tm(lon, lat): return Transformer.from_crs("EPSG:4326", f"EPSG:{EPSG}", always_xy=True).transform(lon, lat) @pytest.fixture def fake(tmp_path, monkeypatch): lon0, lat0, lon1, lat1 = sheet5k_to_bounds(WEST) x0, y0 = _tm(lon0 - 0.01, lat0 - 0.01) edge_x, mid_y = _tm(lon1, (lat0 + lat1) / 2) lines = _field_lines(x0, y0, (edge_x + 10.0, mid_y)) allseg = surf.segments_from_lines(lines) gpkg = tmp_path / "national.gpkg" gpkg.write_bytes(b"v1") calls = {"build": 0, "fetch": 0} real_build = store.build_contour_surface def segments(epsg, box): a, b, z = allseg hit = (np.maximum(a[:, 0], b[:, 0]) >= box[0]) & (np.minimum(a[:, 0], b[:, 0]) <= box[2]) hit &= (np.maximum(a[:, 1], b[:, 1]) >= box[1]) & (np.minimum(a[:, 1], b[:, 1]) <= box[3]) return a[hit], b[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) return {"gpkg": gpkg, "calls": calls, "allseg": allseg} def test_store_tiles_equal_whole_and_compute_once(fake): cell = 10.0 seen = [] got = store.load_terrain( EPSG, sheets=[WEST, EAST], cell_m=cell, on_progress=lambda p, s: seen.append(p) ) assert got["computed"] == [WEST, EAST] and not got["failed"] assert seen[-1] == 100 and seen == sorted(seen) # 통째 셈(같은 계산 · 넉넉한 상자)과 칸마다 같음 ix0, iy0 = int(round(got["x"][0] / cell - 0.5)), int(round(got["y"][0] / cell - 0.5)) ny, nx = got["z"].shape pad = 40 spec = GridSpec( x_min=(ix0 - pad) * cell, y_max=(iy0 + ny + pad) * cell, cell_m=cell, n_rows=ny + 2 * pad, n_cols=nx + 2 * pad, ) whole = surf.surface_grid(spec, surf.build_contour_surface(spec, *fake["allseg"]))["z"] whole = whole[pad:-pad, pad:-pad] both = got["valid_mask"] assert both.mean() > 0.9 assert float(np.max(np.abs(got["z"][both] - whole[both]))) < 1e-3 # 두 번째 읽기 = 셈 없음 builds = fake["calls"]["build"] again = store.load_terrain(EPSG, sheets=[WEST, EAST], cell_m=cell) assert again["computed"] == [] and fake["calls"]["build"] == builds assert np.array_equal(np.nan_to_num(again["z"], nan=-1), np.nan_to_num(got["z"], nan=-1)) # 상자로 읽기 = 그 상자 칸만 box = (got["x"][10] - 5, got["y"][20] - 5, got["x"][60] + 5, got["y"][90] + 5) part = store.load_terrain(EPSG, box=box, cell_m=cell) assert part["z"].shape == (71, 51) assert np.array_equal( np.nan_to_num(part["z"], nan=-1), np.nan_to_num(got["z"][20:91, 10:61], nan=-1) ) # 원천(전국 파일) 바뀜 = 다시 셈 fake["gpkg"].write_bytes(b"v2-longer") store.load_terrain(EPSG, sheets=[WEST], cell_m=cell) assert fake["calls"]["build"] == builds + 1 def test_same_sheet_twice_at_once_computes_once(fake): results = [] threads = [ threading.Thread( target=lambda: results.append(store.ensure_sheet_terrain(WEST, EPSG, 10.0)) ) for _ in range(3) ] for t in threads: t.start() for t in threads: t.join() assert fake["calls"]["build"] == 1 and len(set(results)) == 1 and results[0] is not None assert not list((store.TERRAIN_ROOT / WEST).glob(".*.lock")) def test_missing_sheet_uses_zip_and_failure_not_stored(fake, monkeypatch): a, b, z = fake["allseg"] zlines = [(np.vstack([a[i], b[i]]), z[i]) for i in range(len(z))] monkeypatch.setattr(store, "_gpkg_segments", lambda epsg, box: store._NO_SEGMENTS) zip_path = store.TERRAIN_ROOT / WEST / f"{WEST}.zip" def fetch_fail(sheet_no, report): return {}, "PermissionError: 로그인" monkeypatch.setattr(store, "_fetch_zips", fetch_fail) got = store.load_terrain(EPSG, sheets=[WEST], cell_m=20.0) assert ( WEST in got["failed"] and not (store.TERRAIN_ROOT / WEST / "dem_5187_20m.key.json").exists() ) stages = [] def fetch_ok(sheet_no, report): report(10, f"도엽 받기 1/9 · {sheet_no}") zip_path.parent.mkdir(parents=True, exist_ok=True) zip_path.write_bytes(b"zip") return {WEST: store._sha256(zip_path)}, None monkeypatch.setattr(store, "_fetch_zips", fetch_ok) monkeypatch.setattr(store, "_zip_lines", lambda nos, epsg: zlines) got = store.load_terrain( EPSG, sheets=[WEST], cell_m=20.0, on_progress=lambda p, s: stages.append(s) ) assert got["computed"] == [WEST] and got["valid_mask"].mean() > 0.9 assert any("도엽 받기" in s for s in stages) builds = fake["calls"]["build"] assert store.load_terrain(EPSG, sheets=[WEST], cell_m=20.0)["computed"] == [] zip_path.write_bytes(b"zip-new") # 도엽 zip 바뀜 = 다시 셈 store.load_terrain(EPSG, sheets=[WEST], cell_m=20.0) assert fake["calls"]["build"] == builds + 1 def test_empty_sheet_marked_and_skipped(fake, monkeypatch): monkeypatch.setattr(store, "_gpkg_segments", lambda epsg, box: store._NO_SEGMENTS) monkeypatch.setattr(store, "_fetch_zips", lambda sheet_no, report: ({}, None)) got = store.load_terrain(EPSG, sheets=[WEST], cell_m=20.0) assert got["z"].size == 0 and not got["failed"] assert store.ensure_sheet_terrain(WEST, EPSG, 20.0) is None assert fake["calls"]["build"] == 0