Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-30 23:12:18 +09:00
15 changed files with 1121 additions and 46 deletions
+22 -2
View File
@@ -157,8 +157,28 @@ def test_cells_distance_and_limit(fake) -> None:
assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자
assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0
assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5
assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000
assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS
assert terrain.MAX_SHEETS == 36 and terrain.FAR_M == 5000
view = terrain.plan_extent(root, [A, B], EPSG)
assert view["max_sheets"] == terrain.MAX_SHEETS and "max_cells" not in view
assert view["store_sheets"] == terrain.store_sheet_count(tuple(view["box_m"]), EPSG)
def test_sheet_limit_counts_store_sheets(fake) -> None:
"""613-1 — 상한 = 공용 창고 도엽 수(칸 수 아님) · 36 장까지 · 넘으면 알림 글에 장 수."""
root, _ = fake
small = terrain.plan_extent(root, [A, B], EPSG)
assert 1 <= small["store_sheets"] <= 4 and small["cells"] < 2_000_000
# 남쪽 1.5 km 늘림 — 옛 칸 상한(500 만 칸)은 넘지만 도엽은 상한 안
south = (A[0], A[1] - 6000.0)
wide = terrain.plan_extent(root, [A, B, south], EPSG)
assert wide["store_sheets"] <= terrain.MAX_SHEETS
# 37 장 이상 — 약 6 도엽 폭 넘게 벌림
huge = terrain.plan_extent(root, [A, (A[0] + 20000.0, A[1] + 22000.0)], EPSG)
assert huge["store_sheets"] > terrain.MAX_SHEETS
text = terrain.sheet_limit_message(huge["store_sheets"])
assert (
f"도엽 {huge['store_sheets']}장" in text and "36장까지" in text and "새로 받으세요" in text
)
def test_extent_no_download_and_covered(fake) -> None:
+9 -6
View File
@@ -227,13 +227,16 @@ def test_center_other_band_asks_then_moves(posting) -> None:
assert got["epsg"] == 5187 and got["covered"] is True
def test_cells_limit_413(posting) -> None:
posting.plan["points"].append(
{"x": OX + 6000.0, "y": OY + 6000.0}
) # 6.7 × 6.7 km = 1,100 만 칸
def test_sheet_limit_413(posting) -> None:
"""613-1 — 6.7 × 6.7 km(옛 칸 상한 넘음)는 받아짐 · 도엽 37 장 이상은 413 + 장 수 알림."""
posting.plan["points"].append({"x": OX + 6000.0, "y": OY + 6000.0})
assert posting.run_post({}).status_code == 200
assert len(posting.calls) == 1
posting.plan["points"].append({"x": OX + 22000.0, "y": OY + 24000.0})
res = posting.run_post({})
assert res.status_code == 413 and "상한 500만" in res.json()["detail"]
assert posting.calls == []
detail = res.json()["detail"]
assert res.status_code == 413 and "한 번에 36장까지" in detail and "도엽 " in detail
assert len(posting.calls) == 1
def test_progress_interrupted_after_restart(client, monkeypatch) -> None:
@@ -190,3 +190,23 @@ def test_paint_network_uses_cased_route_lines():
body = src[src.index("export function paintNetwork") : src.index("export function pointLayer")]
assert "strokeCased(context, color, width)" in body
assert "routeColor(index)" in body
def test_pick_highlight_readable_on_any_map_background():
"""613-2 고름 강조 = 노랑 줄(`--draw-map-sketch`) + 검은 테 — 바탕(위성 · 어두운 지도)이 어떤 밝기여도
줄 · 검은 테 중 큰 쪽이 ≥ 3 : 1 · 테 한쪽 4 px 이상 · 노랑 줄은 검은 테와 함께 그림."""
blocks = _blocks(THEME.read_text(encoding="utf-8"))
yellow = _lum(blocks["light"]["--draw-map-sketch"])
for step in range(201):
ground = step / 200
line = (
(yellow + 0.05) / (ground + 0.05)
if yellow > ground
else (ground + 0.05) / (yellow + 0.05)
)
casing = (ground + 0.05) / 0.05
assert max(line, casing) >= 3, ground
src = (ROOT / "D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts").read_text(encoding="utf-8")
assert int(re.search(r"PICK_HALO_PX = (\d+)", src).group(1)) >= 4
assert "strokeCased(context, colors.active, PICK_LINE_PX)" in src
assert "context.strokeStyle = colors.casing;" in src
@@ -0,0 +1,143 @@
"""609-1 도엽별 공용 하강장 — 도엽 넷 이어 붙임 = 통째 짓기 · 두 번째는 짓지 않음 ·
원천 바뀌면 다시 · 번호 없는 좌표계 · 배수 확장이 공용 길 = 프로젝트 길 · 못 지으면 프로젝트 길.
./venv/Scripts/python.exe -m pytest resources/tester/test_609_1_descent_shared.py -q
"""
import numpy as np
import pytest
from pyproj import Transformer
from shapely.geometry import LineString, box, mapping
from B04_PreProcess import B04_PreProcess_Engine_Watershed_DescentShared as shared
from B04_PreProcess import B04_PreProcess_Engine_Watershed_Expand as expand
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import build_contour_descent
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from common_util import common_util_terrain_store as store
EPSG = 5187
CELL = 10.0 # 시험만 10 m(실제 1 m) — 도엽 넷을 빨리 지음
def _corner():
"""도엽 36901074 남동 모서리(도엽 넷이 만나는 점) · 사업 좌표 10 m 눈금."""
_, lat0, lon1, _ = sheet5k_to_bounds("36901074")
x, y = Transformer.from_crs("EPSG:4326", f"EPSG:{EPSG}", always_xy=True).transform(lon1, lat0)
return round(x / CELL) * CELL, round(y / CELL) * CELL
def _features():
"""모서리 곁 봉우리 — 30 m 마다 5 m 내려가는 고리(중심을 비켜 도엽 경계를 비스듬히 지남)."""
cx, cy = _corner()
out = []
for i, radius in enumerate(range(30, 4500, 30)):
t = np.linspace(0.0, 2 * np.pi, max(64, radius // 5) + 1)
line = LineString(
np.column_stack([cx + 137 + radius * np.cos(t), cy - 91 + radius * np.sin(t)])
)
out.append({"geometry": mapping(line), "properties": {"elevation": 1000.0 - 5.0 * i}})
return out
@pytest.fixture
def world(tmp_path, monkeypatch):
monkeypatch.setattr(store, "TERRAIN_ROOT", tmp_path)
monkeypatch.setattr(shared, "TERRAIN_ROOT", tmp_path)
monkeypatch.setattr(shared, "_gpkg_stamp", lambda: "gpkg-1")
features = _features()
def from_gpkg(epsg, region):
area = box(*region)
return [f for f in features if LineString(f["geometry"]["coordinates"]).intersects(area)]
monkeypatch.setattr(shared, "_gpkg_features", from_gpkg)
built = []
real = shared.build_contour_descent
monkeypatch.setattr(shared, "build_contour_descent", lambda *a: built.append(1) or real(*a))
return features, built
def _spec(half=1500.0):
cx, cy = _corner()
n = int(2 * half / CELL)
return GridSpec(cx - half, cy + half, CELL, n, n)
def test_stitched_equals_whole(world):
features, built = world
spec = _spec()
stitched = shared.shared_descent(EPSG, spec)
assert len(built) == 4 # 도엽 넷
pad = 20
big = GridSpec(
spec.x_min - pad * CELL,
spec.y_max + pad * CELL,
CELL,
spec.n_rows + 2 * pad,
spec.n_cols + 2 * pad,
)
whole = expand.crop_descent(build_contour_descent(big, features), spec)
assert stitched.valid.sum() > 0.99 * spec.size
assert np.array_equal(stitched.valid, whole.valid)
assert np.array_equal(stitched.receiver, whole.receiver)
assert np.array_equal(stitched.azimuth, whole.azimuth)
assert np.array_equal(stitched.step_length, whole.step_length)
assert np.array_equal(stitched.band_elevation, whole.band_elevation, equal_nan=True)
def test_second_read_builds_nothing_and_source_change_rebuilds(world, monkeypatch):
_, built = world
shared.shared_descent(EPSG, _spec(500.0))
first = len(built)
shared.shared_descent(EPSG, _spec(800.0)) # 다른 프로젝트 · 같은 도엽
assert first == 4 and len(built) == first
monkeypatch.setattr(shared, "_gpkg_stamp", lambda: "gpkg-2") # 전국 파일 바뀜
shared.shared_descent(EPSG, _spec(500.0))
assert len(built) == first + 4
def _expand(store_tuple):
cx, cy = _corner()
spec = GridSpec(cx - 400, cy + 400, CELL, 80, 80)
domain = np.zeros((80, 80), bool)
domain[20:60, 20:60] = True
route = LineString([(cx - 300, cy - 250), (cx + 300, cy - 250)])
return expand.expand_by_red_boundary(
spec, domain, _features(), route, [], None, descent_store=store_tuple
)
def test_expand_shared_equals_project_path(world, tmp_path):
_, built = world
project = _expand((tmp_path / "p1", "도엽-1"))
via_shared = _expand((tmp_path / "p2", "도엽-1", f"EPSG:{EPSG}"))
assert built # 공용 길로 지음
assert not list((tmp_path / "p2").glob("descent_*.npz")) # 프로젝트엔 안 남김
assert via_shared.analysis.spec == project.analysis.spec
assert np.array_equal(via_shared.analysis.flow.reaches_road, project.analysis.flow.reaches_road)
assert np.array_equal(via_shared.analysis.descent.receiver, project.analysis.descent.receiver)
def test_shared_failure_falls_back_to_project(world, tmp_path, monkeypatch):
def fail(*_):
raise RuntimeError("받기 실패")
monkeypatch.setattr(shared, "ensure_sheet_descent", fail)
result = _expand((tmp_path / "p3", "도엽-1", f"EPSG:{EPSG}"))
assert result is not None and list((tmp_path / "p3").glob("descent_*.npz"))
def test_crs_without_epsg_number(world, tmp_path):
"""PRJ WKT(번호 없음 · 용화 등)도 공용 길 — 좌표계 지문으로 따로 저장."""
text = (
"+proj=tmerc +lat_0=38 +lon_0=129 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80"
" +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs"
) # 5187 과 같은 격자 · 번호 없음
crs = shared.descent_crs(text)
assert crs == text and shared.descent_crs("EPSG:5187") == EPSG
spec = _spec(500.0)
by_text = shared.shared_descent(crs, spec)
by_number = shared.shared_descent(EPSG, spec)
assert np.array_equal(by_text.receiver, by_number.receiver)
assert {p.name.split("_")[1][:3] for p in tmp_path.glob("*/descent_*.npz")} == {"wkt", "518"}
+124
View File
@@ -0,0 +1,124 @@
"""PLAN 612-1 — 전국 미리 계산 일괄 도구(`tools/precompute_terrain.py`) 지킴 시험.
도엽 목록 칸 모음 · 고름 · 산지 차례 · 좌표계 띠 · 이어 하기(성공 건너뜀 · 실패 다시) ·
죽은 잠금 치움 — 셈 함수는 가짜로 바꿔 서버 · 전국 파일 없이 돎.
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
import numpy as np
import pytest
import shapely
from pyproj import Transformer
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import latlon_to_sheet5k # noqa: E402
from tools import precompute_terrain as tool # noqa: E402
def _line_5179(lon_lat: list[tuple[float, float]]) -> bytes:
to = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True)
xs, ys = to.transform(*zip(*lon_lat))
return shapely.to_wkb(shapely.LineString(list(zip(xs, ys))))
def test_aggregate_cells_length_and_relief() -> None:
# 도엽 하나 안 두 줄(높이 100 · 300) + 다른 도엽 한 줄
a = [(129.0010 + 0.0001 * i, 36.9901) for i in range(200)] # 약 1.8 km
b = [(129.0010 + 0.0001 * i, 36.9951) for i in range(100)]
c = [(129.1001 + 0.0001 * i, 36.9901) for i in range(50)]
cells = tool.aggregate([_line_5179(a), _line_5179(b), _line_5179(c)], np.array([100, 300, 50]))
assert len(cells) == 2
big = max(cells.values(), key=lambda v: v[0])
assert big[1:] == [100.0, 300.0]
assert 2_300 < big[0] < 2_800 # 1.78 + 0.89 km — 솎음 현이라 조금 짧음
def test_pick_sheets_prefix_box_relief_order() -> None:
index = {
"36901001": [10.0, 100.0, 400.0],
"36901002": [30.0, 100.0, 200.0],
"36901003": [30.0, 100.0, 500.0],
"36712089": [50.0, 0.0, 20.0],
}
assert tool.pick_sheets(index) == ["36901003", "36901001"] # 높이 차 150 m 이상 · 길이 순
# 앞자리 고름은 높이 차 거름 안 씀 · 길이 같으면 높이 차 큰 것 먼저
assert tool.pick_sheets(index, sheets=["36901"]) == ["36901003", "36901002", "36901001"]
assert tool.pick_sheets(index, sheets=["36901"], limit=1) == ["36901003"]
assert tool.pick_sheets(index, sheets=["99999999"]) == [] # 전국 파일 밖 = 안 고름
assert tool.pick_sheets(index, min_relief=0, box=(127.5, 36.0, 128.0, 36.5)) == ["36712089"]
def test_epsg_band_follows_longitude() -> None:
assert tool.epsg_of(latlon_to_sheet5k(37.5, 125.5)) == 5185
assert tool.epsg_of(latlon_to_sheet5k(37.5, 127.5)) == 5186
assert tool.epsg_of("36901074") == 5187
@pytest.fixture()
def fake_store(tmp_path, monkeypatch):
monkeypatch.setattr(tool.store, "TERRAIN_ROOT", tmp_path)
monkeypatch.setattr(tool.store, "_gpkg_stamp", lambda: "stamp-1")
calls: list[tuple[str, str]] = []
fail = {"36901002"}
def fake_run(step: str, no: str):
calls.append((step, no))
if no in fail:
return step, no, {"ok": False, "error": "RuntimeError: 받기 실패"}
return step, no, {"ok": True, "s": 0.01, "fresh": True, "empty": False}
monkeypatch.setattr(tool, "run_step", fake_run)
return tmp_path, calls, fail
def test_resume_skips_done_and_retries_failed(fake_store) -> None:
root, calls, fail = fake_store
sheets = ["36901001", "36901002", "36901003"]
progress = tool.load_progress()
tool.run(sheets, ["e07", "dem"], 1, progress, say=lambda _: None)
assert [s for s, _ in calls] == ["dem"] * 3 + ["e07"] * 3 # 차례 고정
saved = json.loads((root / tool.PROGRESS_NAME).read_text(encoding="utf-8"))
assert saved["steps"]["dem"]["36901001"]["ok"] is True
assert saved["steps"]["dem"]["36901002"]["ok"] is False
calls.clear()
fail.clear()
tool.run(sheets, ["dem", "e07"], 1, tool.load_progress(), say=lambda _: None)
assert calls == [("dem", "36901002"), ("e07", "36901002")] # 성공은 건너뜀 · 실패만 다시
assert tool.summary(sheets, tool.load_progress())["dem"]["ok"] == 3
def test_progress_restarts_when_source_changes(fake_store, monkeypatch) -> None:
root, calls, _ = fake_store
tool.run(["36901001"], ["dem"], 1, tool.load_progress(), say=lambda _: None)
monkeypatch.setattr(tool.store, "_gpkg_stamp", lambda: "stamp-2")
assert tool.load_progress()["steps"]["dem"] == {}
def test_clean_dead_locks_and_old_tmp(tmp_path) -> None:
folder = tmp_path / "36901001"
folder.mkdir()
dead = folder / ".dem_5187_2m.lock"
dead.write_text("999999999 0", encoding="utf-8")
live = folder / ".zero_acc_5187_2m.lock"
live.write_text(f"{os.getpid()} 0", encoding="utf-8")
old_tmp = folder / ".dem_5187_2m.npz.abc.tmp"
old_tmp.write_bytes(b"x")
stale = time.time() - tool.store.LOCK_STALE_S - 10
os.utime(old_tmp, (stale, stale))
new_tmp = folder / ".e07_5187_2m.npz.def.tmp"
new_tmp.write_bytes(b"x")
kept = folder / "dem_5187_2m.npz"
kept.write_bytes(b"x")
assert tool.clean_dead(tmp_path) == 2
assert not dead.exists() and not old_tmp.exists()
assert live.exists() and new_tmp.exists() and kept.exists()