Files
Aislo/resources/tester/test_584_1_plan_geo_lines.py
T
eomsangdonandClaude Opus 5.5 8793b4f6b2 feat(D03): 노선분석 바탕 MapLibre 전체 적용 — 겹 스물 · 등고 글 · 지명 GL · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 586-2 ①②③⑤ · 요구 448)
- GL 겹 스물: 위성 조각 · 음영 · 급경사 · 산사태위험(image) · 산림입지 · 보호구역 · 띠 · 물 면 · 건물(fill + 테두리)
  · 지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지(line · 점선 px 그대로)
  · 산사태취약 점 · 지명 점(circle) — 색 · 굵기 · 투명도 · 차례 = 옛 캔버스 그대로
- 음영 = 위성 위 「곱하기 0.75」와 같은 셈의 검은 반투명 그림(밝기 g → 투명도 0.75·(1−g)) · 위성 끔 = 그대로
- 등고 수치 · 지명 = symbol(겹치면 뺌 · 등고 글은 선 따라) · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림
  (꼴에 글꼴 주소 없음 = 글꼴 조각 파일 없음 · 받기 전에 그 글자 글꼴 미리 받음)
- 카메라 주인 = D03 화면 view · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 · 맞춤 · 단축키 모두 draw() → glCamera
- 처음 = GL(aislo:pref:d03.canvas = "1" 이면 옛 캔버스) · 겹 단추줄 끝 단추로 바꿈 · WebGL 못 열거나 잃음 = 저절로 옛 캔버스 + 알림
- GL 이면 캔버스 바탕 겹은 쉼(등고 주곡선 간격 셈만) · GL 이면 위성 한 장 그림 안 받음
- 산사태위험 그림도 경위도 귀퉁이(lonlat_corners 한 곳) · 묶음 꼴 표시 없는 합친 자료도 GL 이 받게 다시 쌈
- 캔버스 그리기 목록 = 새 D03_PlanAnalysis_UI_LayerPaint.ts(700줄 규칙) · 검증 창구 layers().gl = GL 겹별 그린 지물 수
- 잼(headless Edge 실 GPU · 검증_sub2_D · 겹 18 켬 = 자료 있는 겹 전부): 줌아웃 화면 간격 중앙 58.4 → 4.2 ms · 끊김 27 → 0/30
  · 끌기 62.5 → 4.2 ms · 끊김 37 → 0/40 · 마우스 4.2 ms 끊김 0 그대로 · 그은 선 대 GL 0.019 px(돌림 30° 0.019 · 확대 0.077)
- 시험: 캔버스 그리기를 옮긴 자리로 지킴 시험 넷 고침(581-2 · 582 · 584-1 · 584-3 — 그리기 차례 · 색 값이 LayerPaint 로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:28:13 +09:00

237 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""D 노선분석 GIS 겹 더함 2(PLAN 584-1) — 단층 · 지질구조선 잘라 가닥 · 길이 · 실패 = 오류 ·
덮으면 다시 안 받음 · 산사태위험지도(키 없음 · 그림 아님 = 오류 · 옮겨 그림 · 덮으면 저장본) ·
0차곡 흐름누적(웅덩이 · 평지 넘김) · 임계 · 선 뽑기 · 저장본 · 화면 겹 정의.
./venv/Scripts/python.exe -m pytest resources/tester/test_584_1_plan_geo_lines.py -q
"""
import base64
import io
import json
import math
import re
from pathlib import Path
import numpy as np
import pytest
from common_util.common_util_crs import tm_to_lonlat
from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zo
EPSG = 5186
BOX = (230000.0, 450000.0, 232000.0, 451000.0) # 2 × 1 km
ROOT = Path(__file__).resolve().parents[2]
def _line(gid: str, points: list[tuple[float, float]]) -> dict:
coords = [list(tm_to_lonlat(x, y, EPSG)) for x, y in points]
return {"id": gid, "geometry": {"type": "LineString", "coordinates": coords}, "properties": {}}
# ── 선 겹 ──
def test_clip_lines_length() -> None:
found = [
_line("a", [(229000.0, 450500.0), (231000.0, 450500.0)]), # 반만 안 = 1,000 m
_line("a", [(229000.0, 450500.0), (231000.0, 450500.0)]), # 겹침
_line("b", [(231500.0, 450100.0), (231500.0, 450400.0)]), # 300 m
_line("c", [(240000.0, 460000.0), (240100.0, 460000.0)]), # 밖
]
features, summary = layers.clip_lines(found, BOX, EPSG)
assert summary["count"] == 2 == len(features)
assert summary["length_m"] == pytest.approx(1300.0, rel=0.005)
assert features[0]["properties"]["length_m"] == pytest.approx(1000.0, rel=0.005)
class FakeWfs:
def __init__(self, fail: bool = False) -> None:
self.fail, self.calls = fail, []
def __call__(self, typename, filename, bounds, output_dir) -> None:
self.calls.append(typename)
if self.fail:
return
feats = [_line("a", [(230100.0, 450100.0), (230100.0, 450600.0)])]
body = {"type": "FeatureCollection", "features": feats}
(Path(output_dir) / filename).write_text(json.dumps(body), "utf-8")
@pytest.mark.parametrize("kind", list(layers.LINE_KINDS))
def test_lines_fetch_once(tmp_path, monkeypatch, kind) -> None:
fake = FakeWfs()
monkeypatch.setattr(layers, "request_vworld_wfs", fake)
label, typename = layers.LINE_KINDS[kind]
assert layers.ensure_lines(tmp_path, BOX, EPSG, kind)["fetched"] and fake.calls == [typename]
saved = json.loads((tmp_path / f"{label}_bounds.geojson").read_text("utf-8"))
assert saved["summary"]["count"] == 1
assert saved["summary"]["length_m"] == pytest.approx(500.0, rel=0.005)
inner = (230050.0, 450050.0, 231000.0, 450900.0)
assert not layers.ensure_lines(tmp_path, inner, EPSG, kind)["fetched"] # 덮으면 다시 안 받음
assert layers.ensure_lines(tmp_path, (229000.0, *BOX[1:]), EPSG, kind)["fetched"]
def test_lines_fail_raises(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=True))
with pytest.raises(RuntimeError):
layers.ensure_lines(tmp_path, BOX, EPSG, "faults")
assert not list(tmp_path.iterdir())
def test_line_kinds_vworld_names() -> None:
assert layers.LINE_KINDS["faults"][1] == "lt_l_gimsfault"
assert layers.LINE_KINDS["lineaments"][1] == "lt_l_gimslinea"
# ── 산사태위험지도 ──
def _png(width: int, height: int, rgba: tuple[int, int, int, int]) -> bytes:
from PIL import Image
buffer = io.BytesIO()
Image.new("RGBA", (width, height), rgba).save(buffer, format="PNG")
return buffer.getvalue()
def test_landslide_no_key(tmp_path, monkeypatch) -> None:
monkeypatch.setenv(landslide.LANDSLIDE_KEY_ENV, " ")
with pytest.raises(landslide.MissingKeyError, match="키 없음"):
landslide.ensure_landslide(tmp_path, BOX, EPSG)
assert not list(tmp_path.iterdir())
def test_landslide_not_image(tmp_path, monkeypatch) -> None:
class Reply(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, *exc) -> None:
pass
monkeypatch.setenv(landslide.LANDSLIDE_KEY_ENV, "k")
monkeypatch.setattr(landslide.urllib.request, "urlopen", lambda *a, **k: Reply(b"<xml/>"))
with pytest.raises(RuntimeError, match="그림이 아닌"):
landslide.ensure_landslide(tmp_path, BOX, EPSG)
assert not list(tmp_path.iterdir())
def test_landslide_warp_and_saved(tmp_path, monkeypatch) -> None:
from PIL import Image
calls = []
def fake(bounds, width, height):
calls.append((width, height))
return _png(width, height, (220, 38, 38, 200))
monkeypatch.setattr(landslide, "request_wms", fake)
value = landslide.ensure_landslide(tmp_path, BOX, EPSG)
assert calls == [(1000, 500)] # 2 m/px
assert value["box_m"] == list(BOX)
raw = base64.b64decode(value["image"].split(",", 1)[1])
image = np.asarray(Image.open(io.BytesIO(raw)))
assert image.shape == (500, 1000, 4)
assert tuple(image[250, 500]) == (220, 38, 38, 200) # 가운데 = 받은 색
inner = (230100.0, 450100.0, 231000.0, 450900.0)
assert landslide.ensure_landslide(tmp_path, inner, EPSG) == value and len(calls) == 1
# ── 0차곡 ──
def _valley(n: int = 60) -> np.ndarray:
"""가운데(열 n/2)로 모이고 북(행 끝)에서 남으로 내려가는 V 골짜기 — 행 = 남 → 북."""
r, c = np.mgrid[0:n, 0:n].astype(float)
return 0.5 * r + 0.8 * np.abs(c - n // 2)
def test_accumulation_valley_floor() -> None:
z = _valley()
acc = zo.d8_accumulation(z, np.ones(z.shape, bool))
assert acc[0, 30] == acc.max() # 골 바닥 남쪽 끝이 가장 큼
assert acc[0, 30] > 0.4 * z.size # 반쯤 넘게 모임
def test_accumulation_crosses_pit_and_flat() -> None:
z = _valley()
z[30, 30] -= 5.0 # 웅덩이
z[20:24, 20:41] = z[22, 30] # 평지 띠(등고선 칸)
acc = zo.d8_accumulation(z, np.ones(z.shape, bool))
assert acc[0, 30] > 0.4 * z.size # 웅덩이 · 평지에서 안 끊김
def test_zero_order_mask_threshold() -> None:
acc = np.array([[10.0, 249.0, 250.0, 2499.0, 2500.0]])
mask = zo.zero_order_mask(acc, np.ones(acc.shape, bool), 2.0, 1000.0, 10000.0) # 칸 4 m²
assert mask.tolist() == [[False, False, True, True, False]]
def test_zero_order_lines_head_only() -> None:
n = 60
z = _valley(n)
x = y = np.arange(n) * 2.0
lines = zo.zero_order_lines(z, np.ones(z.shape, bool), x, y, 2.0, min_m2=400.0, max_m2=2000.0)
assert lines
# 골 바닥(x = 60 m) 위 · 집수 400 ~ 2,000 m² 사이만(골 위쪽 머리)
floor = sorted(p[1] for line in lines for p in line if abs(p[0] - 60.0) < 1e-6)
assert floor and floor[0] > 0.0 and floor[-1] < y[-1] # 남 끝(1차 계류) · 북 끝 빠짐
for line in lines:
assert sum(math.dist(a, b) for a, b in zip(line, line[1:])) >= zo.ZERO_ORDER_MIN_LEN_M
def test_zero_order_layer_saved_and_threshold_key(tmp_path, monkeypatch) -> None:
n = 60
models = tmp_path / "models"
models.mkdir()
np.savez(
models / f"dtm_sheet_{zo.SHEET_SURFACE_DEFAULT_METHOD}.npz",
x=230000.0 + np.arange(n) * 2.0,
y=450000.0 + np.arange(n) * 2.0,
z=_valley(n),
valid_mask=np.ones((n, n), bool),
resolution=np.array([2.0, 2.0]),
)
calls = []
real = zo.zero_order_lines
monkeypatch.setattr(zo, "zero_order_lines", lambda *a, **k: calls.append(1) or real(*a, **k))
first = zo.zero_order_layer(tmp_path, models, EPSG, 400.0, 2000.0)
assert first["count"] >= 1 and first["length_m"] > 0 and first["cell_m"] == 2.0
assert first["collection"]["features"][0]["geometry"]["type"] == "LineString"
again = zo.zero_order_layer(tmp_path, models, EPSG, 400.0, 2000.0)
assert (again["count"], again["length_m"]) == (first["count"], first["length_m"])
assert len(calls) == 1 # 두 번째 = 저장본
zo.zero_order_layer(tmp_path, models, EPSG, 400.0, 3000.0) # 임계 바뀜 = 다시 셈
assert len(calls) == 2
def test_zero_order_no_dem(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
zo.zero_order_layer(tmp_path, tmp_path, EPSG)
# ── 화면 ──
def test_screen_defs_icons_colors() -> None:
defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8")
icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8")
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
for key, color in (
("faults", "fault"),
("lineaments", "lineament"),
("zeroOrder", "zero-order"),
):
entry = re.search(rf'key: "{key}",\s*icon: "(\w+)",[^}}]*on: (\w+)', defs)
assert entry and entry.group(2) == "false", key # 처음 값 끔
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in defs
for kind in layers.LINE_KINDS:
assert f'kind: "{kind}"' in defs # 서버 열쇠와 같음
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
"utf-8"
) # 586-2
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582)