feat(공용 지형): 공용 지형 창고 · 흠 고친 지형 계산 새 모듈 둘 (PLAN 607-1~3 · 요구 486~491)
- common_util_terrain_surface — 방향 무관 굽기(선분 끝 정렬 · 큰 축 칸 중심) · 폐합 안쪽 채움을 그 고리 둘레로 · 보간은 B04 build_laplace 그대로 부름 - common_util_terrain_store — storage/_shared/terrain/<도엽>/dem_<epsg>_<격자>m.npz · 전국 등고선 + 여유 200 m · 도엽 안쪽 칸만 · 열쇠 = 방식 판 + 전국 파일 크기·수정 시각 + 도엽 zip 해시 · 잠금 파일 · 빠진 곳만 도엽 zip(B04 ensure_sheets · prune 안 부름) · load_terrain 진행 % · 단계 글 - 시험 test_607_terrain_store — 뒤집기·조각 굽기 같음 · 봉우리·웅덩이 방향 · 조각 = 통째 · 한 번만 셈 · 원천 바뀜 · zip 길 · 실패 저장 안 함 - B04 · B05 파일 고침 없음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""공용 지형 창고 · 흠 고친 지형 계산(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
|
||||
Reference in New Issue
Block a user