Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-29 14:47:03 +09:00
4 changed files with 462 additions and 19 deletions
@@ -0,0 +1,201 @@
"""D 노선분석 GIS 겹 「지금」 묶음(PLAN 581-1) — 계류 · 도로 띠 합침 · 넓이 · 보호구역 잘라 넓이 ·
종류 하나 실패 = 그 종류만 빠짐 · 모두 실패 = 오류 · 덮으면 다시 안 받음 · 음영기복 저장본 따로 ·
도엽 건물 · 물 면 코드.
./venv/Scripts/python.exe -m pytest resources/tester/test_581_1_plan_gis_layers.py -q
"""
import json
import math
from pathlib import Path
import numpy as np
import pytest
from shapely.geometry import LineString
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS
from common_util.common_util_crs import tm_to_lonlat
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
EPSG = 5186
BOX = (230000.0, 450000.0, 232000.0, 451000.0) # 2 × 1 km
def test_sheet_codes() -> None:
assert SHEET_LAYERS["건물"] == ("N3A", "B0010000")
assert SHEET_LAYERS["실폭하천"] == ("N3A", "E0010001")
assert {SHEET_LAYERS["수계면E003"][1], SHEET_LAYERS["수계면E005"][1]} == {
"E0032111",
"E0052114",
}
# ── 띠 ──
def test_band_one_line_area() -> None:
line = LineString([(230500.0, 450500.0), (231500.0, 450500.0)])
band = layers.band_union([line], 100.0, BOX)
assert band.area == pytest.approx(2 * 100 * 1000 + math.pi * 100**2, rel=0.01)
def test_band_overlap_merged_and_clipped() -> None:
a = LineString([(230500.0, 450500.0), (231500.0, 450500.0)])
twin = layers.band_union([a, a], 100.0, BOX)
assert twin.area == pytest.approx(layers.band_union([a], 100.0, BOX).area) # 겹친 곳 한 번
edge = LineString([(230000.0, 450500.0), (232000.0, 450500.0)])
assert layers.band_union([edge], 100.0, BOX).area == pytest.approx(2000 * 200, rel=1e-6)
assert layers.band_union([], 100.0, BOX) is None
far = LineString([(240000.0, 460000.0), (240100.0, 460000.0)])
assert layers.band_union([far], 100.0, BOX) is None
def _line_feature(points: list[tuple[float, float]], **props) -> dict:
coords = [list(tm_to_lonlat(x, y, EPSG)) for x, y in points]
return {
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords},
"properties": props,
}
def test_band_layer_filters_and_caches(tmp_path, monkeypatch) -> None:
processed = tmp_path / "B04_PreProcess" / "processed"
processed.mkdir(parents=True)
row = [(230500.0, 450500.0), (231500.0, 450500.0)]
streams = [
_line_feature(row, 구분="지방하천"),
_line_feature([(230200.0, 450100.0), (230200.0, 450900.0)], 구분="세류"),
]
roads = [_line_feature(row, 도로구분="지방도"), _line_feature(row, 도로구분="소로")]
for key, feats in (("하천중심선", streams), ("도로중심선", roads)):
body = {"type": "FeatureCollection", "features": feats}
(processed / f"도엽_{key}.geojson").write_text(
json.dumps(body, ensure_ascii=False), "utf-8"
)
real = layers.band_union
counted = []
monkeypatch.setattr(layers, "band_union", lambda *a: counted.append(1) or real(*a))
value = layers.band_layer(tmp_path, EPSG, BOX)
assert value["stream"]["lines"] == 1 and value["road"]["lines"] == 1 # 세류 · 소로 뺌
assert value["stream"]["area_m2"] == pytest.approx(2e5 + math.pi * 1e4, rel=0.02)
road = 2 * 300 * 1000 + math.pi * 300**2 # 둥근 끝 · 범위 안
assert value["road"]["area_m2"] == pytest.approx(road, rel=0.01)
assert value["road"]["ratio"] == pytest.approx(road / 2e6, abs=0.005)
assert len(value["stream"]["collection"]["features"]) == 1
again = layers.band_layer(tmp_path, EPSG, BOX)
assert again == json.loads(json.dumps(value)) and len(counted) == 2 # 저장본
# ── 보호 · 규제 구역 ──
def _square(x0: float, y0: float, side: float) -> dict:
ring = [(x0, y0), (x0 + side, y0), (x0 + side, y0 + side), (x0, y0 + side), (x0, y0)]
return {"type": "Polygon", "coordinates": [[list(tm_to_lonlat(x, y, EPSG)) for x, y in ring]]}
def test_clip_zones_area_and_names() -> None:
found = {
"forest": [
{
"id": "a",
"geometry": _square(231800.0, 450800.0, 400.0),
"properties": {"alias": "보안림"},
},
{
"id": "a",
"geometry": _square(231800.0, 450800.0, 400.0),
"properties": {},
}, # 조각 겹침
{
"id": "b",
"geometry": _square(240000.0, 460000.0, 100.0),
"properties": {},
}, # 범위 밖
],
"park": [
{
"id": "c",
"geometry": _square(230100.0, 450100.0, 100.0),
"properties": {"park_name": "속리산"},
}
],
}
features, summary = layers.clip_zones(found, BOX, EPSG)
assert summary["forest"]["count"] == 1
assert summary["forest"]["area_m2"] == pytest.approx(200 * 200, rel=0.01) # 귀퉁이만 남음
assert summary["park"]["area_m2"] == pytest.approx(100 * 100, rel=0.01)
assert summary["water"] == {"label": "상수원보호구역", "count": 0, "area_m2": 0.0}
assert [f["properties"]["name"] for f in features] == ["보안림", "속리산"]
assert {f["properties"]["kind"] for f in features} == {"forest", "park"}
class FakeZoneWfs:
"""겹 이름마다 정사각형 하나 · `fail` 에 든 겹 이름은 실패(파일 없음)."""
def __init__(self, fail: frozenset[str] = frozenset()) -> None:
self.fail, self.calls = fail, []
def __call__(self, typename, filename, bounds, output_dir) -> None:
self.calls.append(typename)
if typename in self.fail:
return
feats = [{"id": typename, "geometry": _square(230500.0, 450200.0, 300.0), "properties": {}}]
body = {"type": "FeatureCollection", "features": feats}
(Path(output_dir) / filename).write_text(json.dumps(body), "utf-8")
ALL_NAMES = [name for _, _, names in layers.ZONE_KINDS for name in names]
def test_zones_fetch_once_partial_failure(tmp_path, monkeypatch) -> None:
assert len(layers.ZONE_KINDS) == 8 and len(ALL_NAMES) == 10
fake = FakeZoneWfs(fail=frozenset({"lt_c_uf151"}))
monkeypatch.setattr(layers, "request_vworld_wfs", fake)
got = layers.ensure_zones(tmp_path, BOX, EPSG)
assert (
got["fetched"] and got["failed"] == ["forest"] and sorted(fake.calls) == sorted(ALL_NAMES)
)
saved = json.loads((tmp_path / layers.ZONE_FILE).read_text("utf-8"))
assert saved["summary"]["forest"].get("failed") is True
assert saved["summary"]["park"]["count"] == 3 # 자연공원 겹 셋
assert len(saved["features"]) == 9
ok = FakeZoneWfs()
monkeypatch.setattr(layers, "request_vworld_wfs", ok)
assert layers.ensure_zones(tmp_path, BOX, EPSG)["fetched"] # 실패 종류 있으면 다시
assert not layers.ensure_zones(tmp_path, (230500.0, 450100.0, 231000.0, 450900.0), EPSG)[
"fetched"
]
assert len(ok.calls) == 10 # 덮으면 다시 안 받음
def test_zones_all_fail(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(layers, "request_vworld_wfs", FakeZoneWfs(fail=frozenset(ALL_NAMES)))
with pytest.raises(RuntimeError):
layers.ensure_zones(tmp_path, BOX, EPSG)
assert not (tmp_path / layers.ZONE_FILE).exists()
# ── 음영기복 ──
def test_hillshade_saved_apart_from_slope(tmp_path) -> None:
models = tmp_path / "B04_PreProcess" / "models_plan"
models.mkdir(parents=True)
n = 30
z = np.tile(np.arange(n) * 1.0, (n, 1))
x = 200000.0 + np.arange(n) * 2.0
np.savez(
models / "dtm_sheet_laplace.npz",
x=x,
y=x + 250000.0,
z=z,
valid_mask=np.ones(z.shape, bool),
resolution=[2.0],
)
shade = layers.slope_layer(tmp_path, models, EPSG, "hillshade")
slope = layers.slope_layer(tmp_path, models, EPSG, "slope")
assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == []
assert slope["bands"] and shade["box_m"] == slope["box_m"]
assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade