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:
2026-10-30 21:45:11 +09:00
co-authored by Claude Opus 5.5
parent 20ed22709c
commit 7ce1841e2e
3 changed files with 819 additions and 0 deletions
+222
View File
@@ -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