- 서버: 15 · 35 · 40° 단계 PNG 길을 걷고 기울기 % 값 그림 한 장(칸 = % 내림 0~250 · 빈 칸 255) + 1 % 칸 수 · 파생 저장본 판 2(열쇠 = 격자 지문) - 화면: 고른 범위대로 값 그림을 칠함(서버 왕복 없음) · 범례 비율 = 칸 수로 바로 셈 · 범례 급경사 줄 ⚙ → 모달(아래 % · 위 % · 색 · ° 환산 · 범위 추가 · 지움 · 기본값 · 빈 값 · 겹침 막음) - 기본 = 지식DB 15 · 35 · 40° 의 % 26.8 · 70.0 · 83.9 · 기억 = 화면 취향 aislo:pref:d03.slope-bands(계정) - 시험: 569_1 급경사 시험을 값 그림 · 칸 수로 고침, 581_1 은 bands 대신 counts(옛 단계 응답이 없어짐) · 새 603 시험(칸 수 합 = 유효 칸 · 기본 비율 = 옛 15 · 35 · 40° 비율) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
8.6 KiB
Python
216 lines
8.6 KiB
Python
"""D 노선분석 GIS 겹 「지금」 묶음(PLAN 581-1) — 계류 · 도로 띠 합침 · 넓이 · 보호구역 잘라 넓이 ·
|
||
종류 하나 실패 = 그 종류만 빠짐 · 모두 실패 = 오류 · 덮으면 다시 안 받음 · 음영기복 저장본 따로 ·
|
||
도엽 건물 · 물 면 코드.
|
||
|
||
./venv/Scripts/python.exe -m pytest resources/tester/test_581_1_plan_gis_layers.py -q
|
||
"""
|
||
|
||
import base64
|
||
import io
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pytest
|
||
from PIL import Image
|
||
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["counts"] and shade["box_m"] == slope["box_m"] # 603 = 값 그림 + 칸 수
|
||
assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade
|
||
|
||
|
||
def test_hillshade_hole_transparent() -> None:
|
||
z = np.tile(np.arange(20) * 1.0, (20, 1))
|
||
valid = np.ones(z.shape, bool)
|
||
valid[:5, :5] = False # 남서 귀퉁이 = 그림 왼쪽 아래
|
||
value = layers.hillshade_layer_png(z, valid, 2.0)
|
||
alpha = np.asarray(Image.open(io.BytesIO(base64.b64decode(value["image"].split(",", 1)[1]))))[
|
||
..., 1
|
||
]
|
||
assert (alpha[-5:, :5] == 0).all() and (alpha[:10, 10:] == 255).all()
|