From 4220eb6e0ac8c42f53b75cdc2fa0c0c1e87f4054 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 22:58:17 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=ED=98=95):=20=EC=A0=84=EA=B5=AD=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=9D=BC=EA=B4=84=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=A7=80=ED=98=95=20=C2=B7=200=EC=B0=A8=EA=B3=A1?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=C2=B7=20E07=20=EB=8F=84=EC=97=BD?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20(PLAN=20612-1=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/precompute_terrain.py — 도엽 목록(전국 등고선 꼭짓점이 닿는 도엽 · 길이 · 높이 차 · 지문 같으면 저장본) · 산지 먼저 · 단계 dem → zero → e07 · 이어 하기(진행 기록) · 일꾼 수 · 낮은 우선순위 · 범위 고름(--sheets 앞자리 · --box · --limit · --min-relief) · 죽은 잠금 치움 - 셈은 공용 창고 · D 0차곡 · E07 함수만 부름(원자 쓰기 · 열쇠 건너뜀) - 시험 test_612_precompute 6 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_612_precompute.py | 124 ++++++++ tools/precompute_terrain.py | 407 ++++++++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 resources/tester/test_612_precompute.py create mode 100644 tools/precompute_terrain.py diff --git a/resources/tester/test_612_precompute.py b/resources/tester/test_612_precompute.py new file mode 100644 index 000000000..32b06e9d0 --- /dev/null +++ b/resources/tester/test_612_precompute.py @@ -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() diff --git a/tools/precompute_terrain.py b/tools/precompute_terrain.py new file mode 100644 index 000000000..5dbfc3e6b --- /dev/null +++ b/tools/precompute_terrain.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +"""전국 미리 계산 일괄 도구(PLAN 612-1 · 요구 493) — 공용 지형 창고에 도엽마다 미리 셈. + +실행: ./venv/Scripts/python.exe tools/precompute_terrain.py --sheets 36901 --workers 4 + --steps dem,zero,e07 단계(차례 고정 = 지형 → 0차곡 누적 → E07 · 뒤에 tiles) + --sheets 36901,36712089 도엽 번호 또는 앞자리(1:50,000 묶음) · 주면 높이 차 거름 안 씀 + --box lon0,lat0,lon1,lat1 도엽 가운데가 이 경위도 상자 안 + --min-relief 150 도엽 안 높이 차(m) 이상만(산지) · --limit N 앞 N 장만 + --workers 4 일꾼 수 · --status 진행 기록 요약만 · --rebuild-index 도엽 목록 다시 + +- 도엽 목록 = 전국 등고선 파일 꼭짓점이 닿는 도엽(0.025°) — `_precompute_sheets.json`(전국 파일 + 지문이 같으면 다시 안 셈) · 차례 = 등고선 길이 큰 것(산지)부터 · 같으면 높이 차. +- 좌표계 = 도엽 가운데 경도의 TM 띠(`tm_epsg_for_lon` · 5185~5187) · 격자 2 m. +- 셈 = 공용 함수만 부름(창고 지형 · D 0차곡 누적 · E07 도엽) — 저장 열쇠가 같으면 함수가 건너뜀 · + 쓰기 = 함수 쪽 원자 쓰기(끊겨도 반쯤 쓴 파일 없음). +- 이어 하기 = `_precompute_progress.json`(단계 · 도엽마다 성공 · 시간) — 성공한 도엽은 + 다시 안 부름 · 실패는 다음에 다시 · 전국 파일이 바뀌면 기록을 새로 시작. +- 시작 때 죽은 셈의 잠금(주인 프로세스 없음) · 오래된 임시 파일을 치움 · + 낮은 우선순위(BELOW_NORMAL). +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sqlite3 +import sys +import time +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np # noqa: E402 + +from B04_PreProcess.B04_PreProcess_Engine_MapSheet import ( # noqa: E402 + SHEET5K_SIZE_DEG, + latlon_to_sheet5k, + neighbors_3x3, + sheet5k_center, +) +from common_util import common_util_terrain_store as store # noqa: E402 +from common_util.common_util_crs import tm_epsg_for_lon # noqa: E402 +from common_util.common_util_json import atomic_write_json # noqa: E402 + +STEPS = ("dem", "zero", "e07") +CELL_M = 2.0 +PROGRESS_NAME = "_precompute_progress.json" +INDEX_NAME = "_precompute_sheets.json" +INDEX_CHUNK = 50_000 # 가닥 묶음(약 0.5 GB 읽기) +INDEX_STRIDE = 8 # 꼭짓점 솎음(도엽 2 km 에 비해 넉넉) +SAVE_EVERY_S = 10.0 +# 단계별 창고 파일 앞머리(크기 셈) +STEP_FILES = {"dem": ("dem_",), "zero": ("zero_acc_",), "e07": ("e07_", "e07si_")} +_ENVELOPE = {0: 0, 1: 32, 2: 48, 3: 48, 4: 64} + + +def below_normal() -> None: + """이 프로세스를 낮은 우선순위로(Windows) — 다른 일 · 서버를 막지 않게.""" + import psutil + + if os.name == "nt": + psutil.Process().nice(psutil.BELOW_NORMAL_PRIORITY_CLASS) + + +def epsg_of(sheet_no: str) -> int: + return tm_epsg_for_lon(sheet5k_center(sheet_no)[1]) + + +# ── 도엽 목록 ──────────────────────────────────────────────────────────────── + + +def aggregate(wkbs: list[bytes], heights: np.ndarray) -> dict[int, list[float]]: + """EPSG:5179 선(WKB) · 높이 → {칸 열쇠: [길이 m, 최저, 최고]} · 칸 = 0.025°(위도 · 경도).""" + import shapely + from pyproj import Transformer + + geoms = shapely.from_wkb(wkbs) + coords, owner = shapely.get_coordinates(geoms, return_index=True) + coords, owner = coords[::INDEX_STRIDE], owner[::INDEX_STRIDE] + if not len(coords): + return {} + lon, lat = Transformer.from_crs("EPSG:5179", "EPSG:4326", always_xy=True).transform( + coords[:, 0], coords[:, 1] + ) + keys = np.floor(lat / SHEET5K_SIZE_DEG).astype(np.int64) * 100_000 + np.floor( + lon / SHEET5K_SIZE_DEG + ).astype(np.int64) + seg = np.zeros(len(coords)) + same = owner[1:] == owner[:-1] + seg[:-1] = np.where(same, np.hypot(*(coords[1:] - coords[:-1]).T), 0.0) + z = np.asarray(heights, dtype=np.float64)[owner] + uniq, inv = np.unique(keys, return_inverse=True) + length = np.bincount(inv, weights=seg, minlength=len(uniq)) + lo = np.full(len(uniq), np.inf) + hi = np.full(len(uniq), -np.inf) + np.minimum.at(lo, inv, z) + np.maximum.at(hi, inv, z) + return {int(k): [float(a), float(b), float(c)] for k, a, b, c in zip(uniq, length, lo, hi)} + + +def _merge(total: dict[int, list[float]], part: dict[int, list[float]]) -> None: + for k, (length, lo, hi) in part.items(): + if k in total: + t = total[k] + t[0] += length + t[1] = min(t[1], lo) + t[2] = max(t[2], hi) + else: + total[k] = [length, lo, hi] + + +def _gpkg_rows(gpkg: Path): + """전국 등고선 가닥 묶음 — (WKB 목록 · 높이) · 읽기 전용.""" + con = sqlite3.connect(f"file:{gpkg.resolve().as_posix()}?mode=ro", uri=True) + try: + last = -1 + while True: + rows = con.execute( + "SELECT fid, geom, CTRLN_HG FROM contours WHERE fid > ? AND geom IS NOT NULL" + " ORDER BY fid LIMIT ?", + (last, INDEX_CHUNK), + ).fetchall() + if not rows: + return + last = rows[-1][0] + wkbs = [g[8 + _ENVELOPE[(g[3] >> 1) & 7] :] for _, g, _ in rows] + yield wkbs, np.array([h if h is not None else np.nan for _, _, h in rows]) + finally: + con.close() + + +def sheet_index(rebuild: bool = False) -> dict[str, list[float]]: + """{도엽: [등고선 길이 km, 최저 m, 최고 m]} — 전국 파일 지문이 같으면 저장본.""" + path = store.TERRAIN_ROOT / INDEX_NAME + stamp = store._gpkg_stamp() + if not rebuild: + try: + saved = json.loads(path.read_text(encoding="utf-8")) + if saved.get("gpkg") == stamp or stamp is None: + return saved["sheets"] + except (OSError, ValueError, KeyError): + pass + if stamp is None: + raise SystemExit(f"전국 등고선 파일 없음: {store.NATIONAL_GPKG}") + started = time.monotonic() + cells: dict[int, list[float]] = {} + for n, (wkbs, heights) in enumerate(_gpkg_rows(store.NATIONAL_GPKG), 1): + _merge(cells, aggregate(wkbs, heights)) + elapsed = time.monotonic() - started + print(f"도엽 목록 {n * INDEX_CHUNK:,} 가닥 · {elapsed:.0f} s", flush=True) + step = SHEET5K_SIZE_DEG + sheets = { + latlon_to_sheet5k((k // 100_000 + 0.5) * step, (k % 100_000 + 0.5) * step): [ + round(v[0] / 1000, 2), + v[1], + v[2], + ] + for k, v in cells.items() + if math.isfinite(v[1]) + } + atomic_write_json(path, {"gpkg": stamp, "sheets": sheets}) + return sheets + + +def pick_sheets( + index: dict[str, list[float]], + *, + sheets: list[str] | None = None, + box: tuple[float, float, float, float] | None = None, + min_relief: float = 150.0, + limit: int | None = None, +) -> list[str]: + """고른 도엽 — 등고선 길이 큰 것부터(같으면 높이 차) · 전국 파일이 안 덮는 도엽은 뺌.""" + if sheets: + chosen = [no for no in index if any(no.startswith(p) for p in sheets)] + else: + chosen = [no for no in index if index[no][2] - index[no][1] >= min_relief] + if box is not None: + lon0, lat0, lon1, lat1 = box + inside = [] + for no in chosen: + lat, lon = sheet5k_center(no) + if lon0 <= lon <= lon1 and lat0 <= lat <= lat1: + inside.append(no) + chosen = inside + chosen.sort(key=lambda no: (-index[no][0], -(index[no][2] - index[no][1]), no)) + return chosen[:limit] if limit else chosen + + +# ── 셈(일꾼) ───────────────────────────────────────────────────────────────── + + +def _mtime(path: Path) -> int | None: + try: + return path.stat().st_mtime_ns + except OSError: + return None + + +def run_step(step: str, sheet_no: str) -> tuple[str, str, dict[str, Any]]: + """도엽 하나 · 단계 하나 — 공용 함수만 부름 · fresh = 이번에 새로 셈.""" + epsg = epsg_of(sheet_no) + started = time.monotonic() + try: + if step == "dem": + fresh = store._stored_ok(sheet_no, epsg, CELL_M) is None + empty = store.ensure_sheet_terrain(sheet_no, epsg, CELL_M) is None + elif step == "zero": + from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero + + for no in neighbors_3x3(sheet_no): # 여유 200 m 이웃 지형(누적 열쇠가 나중에 안 바뀌게) + store.ensure_sheet_terrain(no, epsg, CELL_M) + target = zero._acc_names(sheet_no, epsg, CELL_M)[0] + before = _mtime(target) + empty = zero.sheet_accumulation(sheet_no, epsg, CELL_M) is None + fresh = _mtime(target) != before + elif step == "e07": + from E07_StabArea import E07_StabArea_Router as e07_router + from E07_StabArea import E07_StabArea_Sheet as e07 + + paths = e07._paths(sheet_no, epsg, CELL_M) + before = (_mtime(paths["flow"]), _mtime(paths["si"])) + empty = e07.ensure_sheet(sheet_no, epsg, CELL_M, e07_router.default_params()) is None + fresh = (_mtime(paths["flow"]), _mtime(paths["si"])) != before + else: + raise ValueError(f"모르는 단계: {step}") + except Exception as exc: # noqa: BLE001 — 한 장이 죽어도 나머지는 감 · 기록에 까닭 + return step, sheet_no, {"ok": False, "error": f"{type(exc).__name__}: {exc}"[:300]} + seconds = round(time.monotonic() - started, 2) + return step, sheet_no, {"ok": True, "s": seconds, "fresh": fresh, "empty": empty} + + +# ── 진행 기록 · 정리 ───────────────────────────────────────────────────────── + + +def load_progress() -> dict[str, Any]: + path = store.TERRAIN_ROOT / PROGRESS_NAME + stamp = store._gpkg_stamp() + try: + saved = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + saved = None + if not saved or saved.get("gpkg") != stamp or saved.get("cell_m") != CELL_M: + saved = {"gpkg": stamp, "cell_m": CELL_M, "steps": {}} + for step in STEPS: + saved["steps"].setdefault(step, {}) + return saved + + +def save_progress(progress: dict[str, Any]) -> None: + atomic_write_json(store.TERRAIN_ROOT / PROGRESS_NAME, progress) + + +def _alive(pid: int) -> bool: + import psutil + + return psutil.pid_exists(pid) + + +def clean_dead(root: Path | None = None) -> int: + """주인이 죽은 잠금 · 오래된 임시 파일을 지움(끊긴 일괄 셈이 남긴 것) → 지운 수.""" + root = root or store.TERRAIN_ROOT + removed = 0 + if not root.is_dir(): + return 0 + now = time.time() + for folder in root.iterdir(): + if not folder.is_dir(): + continue + for path in folder.glob(".*"): + try: + if path.suffix == ".lock": + pid = int(path.read_text(encoding="utf-8").split()[0]) + if _alive(pid): + continue + elif path.suffix != ".tmp" or now - path.stat().st_mtime < store.LOCK_STALE_S: + continue + path.unlink(missing_ok=True) + removed += 1 + except (OSError, ValueError, IndexError): + continue + return removed + + +def run( + sheets: list[str], + steps: list[str], + workers: int, + progress: dict[str, Any], + say=print, +) -> dict[str, Any]: + """단계 차례대로 · 단계 안은 일꾼 여럿 · 성공 기록된 도엽은 건너뜀 → 단계별 벽시계 초.""" + walls: dict[str, float] = {} + for step in [s for s in STEPS if s in steps]: + done = progress["steps"][step] + todo = [no for no in sheets if not done.get(no, {}).get("ok")] + say(f"[{step}] {len(todo)} 장 (건너뜀 {len(sheets) - len(todo)})") + started = time.monotonic() + saved_at = started + count = 0 + + def record(result: tuple[str, str, dict[str, Any]]) -> None: + nonlocal count, saved_at + _, no, info = result + done[no] = info + count += 1 + state = f"{info['s']} s" if info["ok"] else f"실패 {info['error']}" + say(f"[{step}] {count}/{len(todo)} {no} {state}") + if time.monotonic() - saved_at > SAVE_EVERY_S: + save_progress(progress) + saved_at = time.monotonic() + + try: + if workers <= 1: + for no in todo: + record(run_step(step, no)) + else: + with ProcessPoolExecutor(workers, initializer=below_normal) as pool: + futures = [pool.submit(run_step, step, no) for no in todo] + try: + for future in as_completed(futures): + record(future.result()) + except BaseException: + pool.shutdown(wait=False, cancel_futures=True) + raise + finally: + save_progress(progress) + walls[step] = round(time.monotonic() - started, 1) + return walls + + +def summary(sheets: list[str], progress: dict[str, Any]) -> dict[str, Any]: + """단계별 — 성공 · 새로 셈 · 실패 · 새로 셈 장당 초(가운데 · 평균 · 최대) · 장당 크기 MB.""" + out: dict[str, Any] = {} + for step in STEPS: + done = progress["steps"][step] + infos = [done[no] for no in sheets if no in done] + failed = {no: done[no]["error"] for no in sheets if done.get(no, {}).get("ok") is False} + fresh = [i["s"] for i in infos if i.get("ok") and i.get("fresh")] + sizes = [] + for no in sheets: + if not done.get(no, {}).get("ok"): + continue + stem = f"{epsg_of(no)}_{CELL_M:g}m" + folder = store.TERRAIN_ROOT / no + total = 0 + for head in STEP_FILES[step]: + total += sum(p.stat().st_size for p in folder.glob(f"{head}{stem}*")) + if total: + sizes.append(total / 1e6) + out[step] = { + "ok": sum(1 for i in infos if i.get("ok")), + "fresh": len(fresh), + "failed": failed, + "s_median": round(float(np.median(fresh)), 1) if fresh else None, + "s_mean": round(float(np.mean(fresh)), 1) if fresh else None, + "s_max": round(float(np.max(fresh)), 1) if fresh else None, + "mb_mean": round(float(np.mean(sizes)), 2) if sizes else None, + "mb_total": round(float(np.sum(sizes)), 1) if sizes else 0.0, + } + return out + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="공용 지형 창고 전국 미리 계산(PLAN 612)") + parser.add_argument("--steps", default="dem,zero,e07") + parser.add_argument("--sheets", default="") + parser.add_argument("--box", default="") + parser.add_argument("--min-relief", type=float, default=150.0) + parser.add_argument("--limit", type=int, default=0) + parser.add_argument("--workers", type=int, default=4) + parser.add_argument("--status", action="store_true") + parser.add_argument("--rebuild-index", action="store_true") + args = parser.parse_args(argv) + steps = [s.strip() for s in args.steps.split(",") if s.strip()] + unknown = [s for s in steps if s not in STEPS] + if unknown: + parser.error(f"모르는 단계 {unknown} — 지금 되는 단계 {STEPS}(tiles 는 다음)") + box = tuple(float(v) for v in args.box.split(",")) if args.box else None + if box is not None and len(box) != 4: + parser.error("--box = lon0,lat0,lon1,lat1") + + below_normal() + index = sheet_index(args.rebuild_index) + wanted = [s.strip() for s in args.sheets.split(",") if s.strip()] + sheets = pick_sheets( + index, sheets=wanted or None, box=box, min_relief=args.min_relief, limit=args.limit or None + ) + print(f"도엽 {len(sheets)} 장 (목록 {len(index)} 장) · 단계 {steps} · 일꾼 {args.workers}") + progress = load_progress() + if not args.status: + print(f"치운 잠금 · 임시 파일 {clean_dead()}") + started = time.monotonic() + walls = run(sheets, steps, args.workers, progress) + print(f"벽시계 {walls} · 전체 {time.monotonic() - started:.1f} s") + print(json.dumps(summary(sheets, progress), ensure_ascii=False, indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 59ae735eda3cc3d77726913016d74c78578ac874 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 23:09:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(B04=20=EB=B0=B0=EC=88=98):=20=ED=95=98?= =?UTF-8?q?=EA=B0=95=20=EB=B0=A9=ED=96=A5=EC=9E=A5=20=EB=8F=84=EC=97=BD?= =?UTF-8?q?=EB=B3=84=20=EA=B3=B5=EC=9A=A9=20=EC=B0=BD=EA=B3=A0=20=E2=80=94?= =?UTF-8?q?=20=EB=8F=84=EC=97=BD=20+=20=EC=97=AC=EC=9C=A0=20100=20m=20?= =?UTF-8?q?=EB=A1=9C=20=ED=95=9C=20=EB=B2=88=20=EC=A7=93=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EB=B6=99=EC=97=AC=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20(PLAN=20609-1=20=C2=B7=20=EC=9A=94=EA=B5=AC=20486?= =?UTF-8?q?=20=C2=B7=20493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 B04_PreProcess_Engine_Watershed_DescentShared.py — storage/_shared/terrain/<도엽>/descent_<좌표계>_1m.npz(칸 코드 int8 · 밴드 표고) + 열쇠(판 · 여유 · 규칙 · 전국 파일 지문 · zip 해시) · 짓는 법은 build_contour_descent 그대로 · 원천 = 전국 등고선 · 빠진 곳 = 607 도엽 zip - 배수 확장 source_for — 저장소 셋째 값(좌표계)이 있으면 공용 길 · 못 지으면 옛 프로젝트 길 · 2 값 저장소(시험)는 그대로 - 라우터 두 곳 — _prepare 가 좌표계를 함께 넘김 - 공용 지형 창고 — 좌표 변환 6 곳이 번호 없는 좌표계 글(PRJ WKT)도 받음 · 정수 입력 그대로 - 시험 test_609_1 — 도엽 넷 이어 붙임 = 통째 짓기 · 두 번째 짓기 0 · 원천 바뀌면 다시 · WKT · 확장 공용 길 = 프로젝트 길 · 실패 시 프로젝트 길 Co-Authored-By: Claude Opus 5.5 --- ...eProcess_Engine_Watershed_DescentShared.py | 321 ++++++++++++++++++ .../B04_PreProcess_Engine_Watershed_Expand.py | 18 +- .../B04_PreProcess_Router_Watershed.py | 10 +- .../B04_PreProcess_Router_Watershed_Live.py | 2 +- common_util/common_util_terrain_store.py | 27 +- resources/tester/test_609_1_descent_shared.py | 143 ++++++++ 6 files changed, 501 insertions(+), 20 deletions(-) create mode 100644 B04_PreProcess/B04_PreProcess_Engine_Watershed_DescentShared.py create mode 100644 resources/tester/test_609_1_descent_shared.py diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_DescentShared.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_DescentShared.py new file mode 100644 index 000000000..1057b796d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_DescentShared.py @@ -0,0 +1,321 @@ +"""도엽별 공용 하강장(609-1) — 배수 하강 방향장을 도엽마다 한 번 짓고 공용 창고에 영구 저장. + +자리 = `storage/_shared/terrain/<도엽>/descent_<좌표계>_<셀>m.npz` + `.key.json`(607 지형 창고 옆). +원천 = 전국 등고선 · 그 도엽에 없으면 607 길로 받은 도엽 zip. 도엽 + 여유 100 m 로 짓고(짓는 법은 +`build_contour_descent` 그대로 — 1 m · 같은 굽기 · 같은 EDT) 도엽 안쪽 칸(칸 중심 위경도)만 남김 — +칸마다 방향 코드(0~7 = `_NEIGHBOURS` · 8 = 제자리 · -1 = 무효) + 밴드 표고. 프로젝트는 격자를 덮는 +도엽을 이어 붙여 읽기만 — 코드라 도엽 경계를 넘는 물길도 그대로 이어짐. 짓는 규칙이 바뀌면 판을 +올림. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import math +import time +from pathlib import Path +from typing import Any + +import numpy as np +from pyproj import CRS, Transformer +from pyproj.exceptions import CRSError +from shapely.geometry import LineString, mapping, shape + +from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import ( + _NEIGHBOURS, + DESCENT_TILE_CELLS, + DESCENT_WINDOW_MARGIN_CELLS, + ContourDescent, + build_contour_descent, +) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, + GridSpec, +) +from common_util.common_util_atomic import atomic_write_npz +from common_util.common_util_crs import tm_epsg_for_lon +from common_util.common_util_json import atomic_write_json +from common_util.common_util_terrain_store import ( + NATIONAL_GPKG, + TERRAIN_ROOT, + _crs, + _gpkg_stamp, + _Lock, + _names, + _owned, + _sha256, + _zip_lines, + ensure_sheet_terrain, + sheets_for_box, +) +from config.config_system import DRAINAGE_CONTOUR_MIN_LENGTH_M + +logger = logging.getLogger(__name__) + +SHARED_DESCENT_VERSION = 1 +SHARED_DESCENT_MARGIN_M = 100.0 +_CODE_SINK = 8 +# 3×3 자리 (dr+1)*3 + (dc+1) → 코드 · 코드 → (dr, dc) · 방위 +_CODE_OF_SHIFT = np.array([0, 1, 2, 3, _CODE_SINK, 4, 5, 6, 7], dtype=np.int8) +_SHIFT_ROW = np.array([r for r, _ in _NEIGHBOURS] + [0]) +_SHIFT_COL = np.array([c for _, c in _NEIGHBOURS] + [0]) +_CODE_AZIMUTH = np.append( + np.rint(np.arctan2(_SHIFT_ROW[:8], _SHIFT_COL[:8]) / (2.0 * math.pi / AZIMUTH_STEPS)).astype( + np.int16 + ) + % AZIMUTH_STEPS, + AZIMUTH_SINK, +).astype(np.int16) + + +Crs = int | str # EPSG 번호 또는 번호 없는 좌표계 글(PRJ WKT — 용화 등 Korean 1985 + TOWGS84) + + +def descent_crs(crs: str) -> Crs | None: + """프로젝트 좌표계 → EPSG 번호(있으면) 또는 그 글 · 못 읽으면 None(프로젝트 저장본 길).""" + try: + return CRS.from_user_input(crs).to_epsg() or crs + except CRSError: + return None + + +def _crs_tag(crs: Crs) -> str: + """파일 이름 · 열쇠의 좌표계 — 번호 또는 WKT 지문(같은 PRJ 를 쓰는 프로젝트끼리 같음).""" + if isinstance(crs, int) or str(crs).isdigit(): + return str(int(crs)) + wkt = CRS.from_user_input(crs).to_wkt() + return "wkt" + hashlib.sha1(wkt.encode(), usedforsecurity=False).hexdigest()[:10] + + +def _shared_names(sheet_no: str, crs: Crs, cell_m: float) -> tuple[Path, Path, Path]: + stem = f"descent_{_crs_tag(crs)}_{cell_m:g}m" + folder = TERRAIN_ROOT / str(sheet_no) + return folder / f"{stem}.npz", folder / f"{stem}.key.json", folder / f".{stem}.lock" + + +def _shared_key(crs: Crs, cell_m: float, zips: dict[str, str | None]) -> dict[str, Any]: + return { + "version": SHARED_DESCENT_VERSION, + "margin_m": SHARED_DESCENT_MARGIN_M, + "rule": [DRAINAGE_CONTOUR_MIN_LENGTH_M, DESCENT_WINDOW_MARGIN_CELLS, DESCENT_TILE_CELLS], + "crs": _crs_tag(crs), + "cell_m": float(cell_m), + "gpkg": _gpkg_stamp(), + "zips": zips, + } + + +def _shared_ok(sheet_no: str, crs: Crs, cell_m: float) -> dict[str, Any] | None: + """저장 열쇠가 지금 원천 · 규칙과 같으면 그 열쇠 — 아니면 None(다시 지음).""" + npz, key_path, _ = _shared_names(sheet_no, crs, cell_m) + try: + stored = json.loads(key_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not stored.get("empty") and not npz.is_file(): + return None + zips = {s: _sha256(TERRAIN_ROOT / s / f"{s}.zip") for s in stored.get("zips") or {}} + now = _shared_key(crs, cell_m, zips) + if now["gpkg"] is None: # 전국 파일 없는 PC — 있는 저장본을 믿음(607 과 같음) + now["gpkg"] = stored.get("gpkg") + return stored if {k: stored.get(k) for k in now} == now else None + + +def _feature(line: Any, level: float) -> dict[str, Any]: + return {"geometry": mapping(line), "properties": {"elevation": float(level)}} + + +def _gpkg_features(crs: Crs, region: tuple[float, float, float, float]) -> list[dict[str, Any]]: + """전국 등고선에서 TM 상자에 닿는 선 — 통째 선(길이 거름이 프로젝트 길과 같게) · 사업 좌표.""" + if not NATIONAL_GPKG.is_file(): + return [] + import pyogrio + import shapely + + x0, y0, x1, y1 = region + t = np.linspace(0.0, 1.0, 21) + bx, by = Transformer.from_crs(_crs(crs), "EPSG:5179", always_xy=True).transform( + np.concatenate([x0 + (x1 - x0) * t, x0 + (x1 - x0) * t, np.full(21, x0), np.full(21, x1)]), + np.concatenate([np.full(21, y0), np.full(21, y1), y0 + (y1 - y0) * t, y0 + (y1 - y0) * t]), + ) + frame = pyogrio.read_dataframe( + NATIONAL_GPKG, + bbox=(bx.min() - 20.0, by.min() - 20.0, bx.max() + 20.0, by.max() + 20.0), + columns=["CTRLN_HG"], + ) + if frame.empty: + return [] + to_tm = Transformer.from_crs("EPSG:5179", _crs(crs), always_xy=True) + lines = shapely.transform( + frame.geometry.values, lambda xy: np.column_stack(to_tm.transform(xy[:, 0], xy[:, 1])) + ) + levels = frame["CTRLN_HG"].to_numpy(dtype=np.float64) + return [ + _feature(line, level) + for line, level in zip(lines, levels) + if line is not None and np.isfinite(level) + ] + + +def _touches(features: list[dict[str, Any]], box: tuple[float, float, float, float]) -> bool: + x0, y0, x1, y1 = box + for feature in features: + bx0, by0, bx1, by1 = shape(feature["geometry"]).bounds + if bx1 >= x0 and bx0 <= x1 and by1 >= y0 and by0 <= y1: + return True + return False + + +def _codes(descent: ContourDescent) -> np.ndarray: + """방향장 → 칸 코드 (R, C) int8.""" + spec = descent.spec + index = np.arange(spec.size, dtype=np.int64) + receiver = descent.receiver.astype(np.int64) + shift = (receiver // spec.n_cols - index // spec.n_cols + 1) * 3 + ( + receiver % spec.n_cols - index % spec.n_cols + 1 + ) + code = _CODE_OF_SHIFT[shift] + code[~descent.valid.reshape(-1)] = -1 + return code.reshape(spec.n_rows, spec.n_cols) + + +def ensure_sheet_descent(sheet_no: str, crs: Crs, cell_m: float = 1.0) -> Path | None: + """도엽 하나의 공용 하강장 — 있으면 그대로 · 없거나 원천이 바뀌었으면 지음. 빈 도엽이면 None. + + 빠진 곳 받기 실패(로그인 · 망)는 RuntimeError — 저장 안 함(다음에 다시). + """ + sheet_no = str(sheet_no) + npz, key_path, lock_path = _shared_names(sheet_no, crs, cell_m) + if (stored := _shared_ok(sheet_no, crs, cell_m)) is not None: + return None if stored.get("empty") else npz + with _Lock(lock_path): + if (stored := _shared_ok(sheet_no, crs, cell_m)) is not None: + return None if stored.get("empty") else npz + started = time.monotonic() + ix0, iy0, own = _owned(sheet_no, crs, cell_m) + pad = math.ceil(SHARED_DESCENT_MARGIN_M / cell_m) + rows, cols = own.shape + spec = GridSpec( + (ix0 - pad) * cell_m, + (iy0 + rows + pad) * cell_m, + cell_m, + rows + 2 * pad, + cols + 2 * pad, + ) + x_max, y_min = spec.x_min + spec.n_cols * cell_m, spec.y_max - spec.n_rows * cell_m + features = _gpkg_features(crs, (spec.x_min, y_min, x_max, spec.y_max)) + zips: dict[str, str | None] = {} + own_box = (ix0 * cell_m, iy0 * cell_m, (ix0 + cols) * cell_m, (iy0 + rows) * cell_m) + if not _touches(features, own_box): + # 빠진 곳(섬 등) — 607 길로 도엽 zip 받기 · 그 열쇠의 zip 을 원천으로 + lon0, _, lon1, _ = sheet5k_to_bounds(sheet_no) + tm = crs if isinstance(crs, int) else tm_epsg_for_lon((lon0 + lon1) / 2) + ensure_sheet_terrain(sheet_no, tm) + terrain_key = json.loads(_names(sheet_no, tm, 2.0)[1].read_text(encoding="utf-8")) + zips = terrain_key.get("zips") or {} + features += [_feature(LineString(xy), z) for xy, z in _zip_lines(list(zips), crs)] + descent = build_contour_descent(spec, features) if features else None + key = _shared_key(crs, cell_m, zips) + key_path.unlink(missing_ok=True) # 쓰는 도중 끊기면 옛 열쇠가 새 파일을 덮지 않게 + if descent is None or not descent.valid.any(): + npz.unlink(missing_ok=True) + atomic_write_json(key_path, {**key, "empty": True}) + return None + inner = (slice(pad, pad + rows), slice(pad, pad + cols)) + code = np.where(own[::-1, :], _codes(descent)[inner], -1).astype(np.int8) + band = np.where(code >= 0, descent.band_elevation[inner], np.nan).astype(np.float32) + atomic_write_npz( + npz, + code=code, + band=band, + origin=np.array([ix0 * cell_m, (iy0 + rows) * cell_m, cell_m]), + levels=np.array(descent.levels, dtype=np.float64), + ) + seconds = round(time.monotonic() - started, 1) + atomic_write_json( + key_path, {**key, "source": "zip" if zips else "gpkg", "seconds": seconds} + ) + logger.info("공용 하강장 %s (%s · %gm) %.1fs", sheet_no, _crs_tag(crs), cell_m, seconds) + return npz + + +def shared_descent(crs: Crs, spec: GridSpec) -> ContourDescent | None: + """`spec` 격자를 덮는 도엽 하강장을 이어 붙임(없는 도엽은 지음). + + 못 지은 도엽 · 칸 어긋남 = None(프로젝트 길로). + """ + + started = time.perf_counter() + rows, cols, cell_m = spec.n_rows, spec.n_cols, spec.cell_m + code = np.full((rows, cols), -1, dtype=np.int8) + band = np.full((rows, cols), np.nan, dtype=np.float32) + levels: set[float] = set() + box = (spec.x_min, spec.y_max - rows * cell_m, spec.x_min + cols * cell_m, spec.y_max) + for sheet_no in sheets_for_box(box, crs): + try: + path = ensure_sheet_descent(sheet_no, crs, cell_m) + except Exception: # noqa: BLE001 — 한 장이라도 못 지으면 프로젝트 길로 + logger.exception( + "배수유역: 공용 하강장 %s 을 짓지 못해 프로젝트 길로 갑니다.", sheet_no + ) + return None + if path is None: + continue + with np.load(path) as data: + x_min, y_max, _ = data["origin"].tolist() + row_shift = (spec.y_max - y_max) / cell_m + col_shift = (x_min - spec.x_min) / cell_m + row0, col0 = int(round(row_shift)), int(round(col_shift)) + if abs(row_shift - row0) > 1e-6 or abs(col_shift - col0) > 1e-6: + return None + piece, piece_band = data["code"], data["band"] + levels.update(data["levels"].tolist()) + r0, c0 = max(row0, 0), max(col0, 0) + r1, c1 = min(row0 + piece.shape[0], rows), min(col0 + piece.shape[1], cols) + if r0 >= r1 or c0 >= c1: + continue + src = (slice(r0 - row0, r1 - row0), slice(c0 - col0, c1 - col0)) + mine = piece[src] >= 0 + np.copyto(code[r0:r1, c0:c1], piece[src], where=mine) + np.copyto(band[r0:r1, c0:c1], piece_band[src], where=mine) + descent = _from_codes(spec, code, band, sorted(levels, reverse=True)) + logger.info( + "[계측] 공용 하강장 이어 붙임 %.2fs · %d×%d", time.perf_counter() - started, rows, cols + ) + return descent + + +def _from_codes( + spec: GridSpec, code: np.ndarray, band: np.ndarray, levels: list[float] +) -> ContourDescent: + """칸 코드 → 방향장. 격자 밖을 가리키는 칸은 제자리(`crop_descent` 와 같은 판정).""" + rows, cols = spec.n_rows, spec.n_cols + valid = code >= 0 + safe = np.where(valid, code, _CODE_SINK) + target_row = np.arange(rows)[:, None] + _SHIFT_ROW[safe] + target_col = np.arange(cols)[None, :] + _SHIFT_COL[safe] + moves = ( + valid + & (safe != _CODE_SINK) + & (target_row >= 0) + & (target_row < rows) + & (target_col >= 0) + & (target_col < cols) + ) + flat = np.arange(spec.size, dtype=np.int64).reshape(rows, cols) + receiver = np.where(moves, target_row * cols + target_col, flat) + length = np.hypot(_SHIFT_ROW[safe], _SHIFT_COL[safe]) * spec.cell_m + return ContourDescent( + spec=spec, + band_elevation=band, + valid=valid, + receiver=receiver.reshape(-1).astype(np.int32), + step_length=np.where(moves, length, 0.0).astype(np.float32).reshape(-1), + azimuth=np.where(valid, _CODE_AZIMUTH[safe], AZIMUTH_INVALID).astype(np.int16).reshape(-1), + levels=levels, + ) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py index 8865fb76f..c9ea61078 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py @@ -15,6 +15,8 @@ (`DESCENT_SPARE_M` · 342-3). 격자가 커질 때마다 다시 짓던 것(8회 · 30 s)을 한 번으로 줄인다. 저장소(`descent_store` = 폴더 · 도엽 등고선 지문)를 주면 지은 것을 남기고, 덮는 저장본이 있으면 읽기만 한다(347-1). 방향장은 노선 최저점 하한 없이 짓는다 — 노선이 바뀌어도 같은 저장본(520-6). +저장소에 좌표계(셋째 값)가 있으면 도엽별 공용 하강장을 이어 붙여 읽기만 한다(609-1) — 못 지으면 +위 프로젝트 길로. 확장은 **큰 방향장 위 한 번 셈**으로 낸다(520-6) — 큰 격자 전체에서 도로 도달을 한 번 판정하고 「도로로 오는 셀 + 확장 폭 띠 ∪ 1차 영역」으로 잘라 돌려준다. 도로로 오는 셀이 큰 격자 @@ -41,6 +43,10 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import ( iter_saved_descents, save_descent, ) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_DescentShared import ( + descent_crs, + shared_descent, +) from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import ( FlowClassification, RoadRaster, @@ -150,7 +156,7 @@ def expand_by_red_boundary( elevation_floor_m: float | None = None, band_m: float = DRAINAGE_RED_EXPAND_BAND_M, max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS, - descent_store: tuple[Path, str] | None = None, + descent_store: tuple[Path, str] | tuple[Path, str, str] | None = None, should_stop: Callable[[], bool] | None = None, ) -> RedExpansion | None: """큰 방향장 위 한 번 셈 — 가장자리에 닿으면 최외곽 적색 셀 주변으로 넓히며 반복한다. @@ -165,15 +171,21 @@ def expand_by_red_boundary( started_cells = int(domain.sum()) spare_cells = max(0, int(round(DESCENT_SPARE_M / spec.cell_m))) store = None + shared_crs = None if descent_store is not None: - folder, contour_key = descent_store + folder, contour_key, *crs = descent_store store = ( folder, descent_input_key(contour_key, contour_features, spec.cell_m), ) + shared_crs = descent_crs(crs[0]) if crs else None def source_for(grid: GridSpec, mask: np.ndarray) -> ContourDescent: - """`grid` 를 덮는 큰 방향장 — 저장본이 덮으면 읽기만 · 아니면 둘레 여유째 짓고 남김.""" + """`grid` 를 덮는 큰 방향장 — 공용 도엽 하강장(609-1) · 프로젝트 저장본 · 짓고 남김 차례.""" + if shared_crs is not None: + shared = shared_descent(shared_crs, _pad_spec(grid, mask, spare_cells)[0]) + if shared is not None: + return shared if store is not None: for saved in iter_saved_descents(*store): if crop_descent(saved, grid) is not None: diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index 992b055ec..8fde1465c 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -215,12 +215,13 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: ) # 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 이 좌표계로 간다. - def sheets() -> tuple[Transformer, list[dict[str, Any]], list[dict[str, Any]], str]: + def sheets() -> tuple[str, Transformer, list[dict[str, Any]], list[dict[str, Any]], str]: source_crs = resolve_project_crs( project_root, route_crs_input=planned.crs_input, db_epsg=epsg ) to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) return ( + source_crs, Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True), *_metric_sheets( _sheet_dir(stored_path), @@ -231,7 +232,9 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: ) # 재투영(저장본 없으면 3.8 s) · 피클 읽기는 스레드로 — 이벤트 루프를 안 막음(505-1) - to_lonlat_transformer, streams, contour_features, contour_key = await asyncio.to_thread(sheets) + crs, to_lonlat_transformer, streams, contour_features, contour_key = await asyncio.to_thread( + sheets + ) return { "route_source": route_file.name, "vertices": planned.vertices, @@ -239,6 +242,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: "streams": streams, "contours": contour_features, "contour_key": contour_key, + "crs": crs, # 공용 하강장(609-1) "stored_path": stored_path, "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), } @@ -413,7 +417,7 @@ async def get_primary_region( prepared["contours"], prepared["streams"], # 흐름 방향 저장본(347-1) — 노선이 저장 범위 안이면 읽기만 - (drainage_dir(prepared["stored_path"]), prepared["contour_key"]), + (drainage_dir(prepared["stored_path"]), prepared["contour_key"], prepared["crs"]), ) if preview is None: return JSONResponse( diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py index 336f6fb46..10df2a35b 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py @@ -134,7 +134,7 @@ async def post_drainage_live( vertices, prepared["contours"], prepared["streams"], - (drainage_dir(context.stored_path), prepared["contour_key"]), + (drainage_dir(context.stored_path), prepared["contour_key"], prepared["crs"]), superseded, ) except AnalysisStopped: diff --git a/common_util/common_util_terrain_store.py b/common_util/common_util_terrain_store.py index dad1b3263..62850abf8 100644 --- a/common_util/common_util_terrain_store.py +++ b/common_util/common_util_terrain_store.py @@ -54,6 +54,11 @@ Segments = tuple[np.ndarray, np.ndarray, np.ndarray] _NO_SEGMENTS: Segments = (np.zeros((0, 2)), np.zeros((0, 2)), np.zeros(0)) +def _crs(epsg: int | str) -> str: + """EPSG 번호 또는 좌표계 글(번호 없는 WKT · 배수 하강장 609-1) → pyproj 입력.""" + return f"EPSG:{int(epsg)}" if isinstance(epsg, int) or str(epsg).isdigit() else str(epsg) + + def _names(sheet_no: str, epsg: int, cell_m: float) -> tuple[Path, Path, Path]: folder = TERRAIN_ROOT / str(sheet_no) stem = f"dem_{int(epsg)}_{cell_m:g}m" @@ -130,15 +135,13 @@ class _Lock: self.path.unlink(missing_ok=True) -def sheets_for_box(box: Box, epsg: int) -> list[str]: +def sheets_for_box(box: Box, epsg: int | str) -> list[str]: """TM 상자(x0, y0, x1, y1)에 닿는 도엽 번호(북서부터).""" x0, y0, x1, y1 = box t = np.linspace(0.0, 1.0, 21) xs = np.concatenate([x0 + (x1 - x0) * t, np.full(21, x1), x1 - (x1 - x0) * t, np.full(21, x0)]) ys = np.concatenate([np.full(21, y0), y0 + (y1 - y0) * t, np.full(21, y1), y1 - (y1 - y0) * t]) - lon, lat = Transformer.from_crs(f"EPSG:{int(epsg)}", "EPSG:4326", always_xy=True).transform( - xs, ys - ) + lon, lat = Transformer.from_crs(_crs(epsg), "EPSG:4326", always_xy=True).transform(xs, ys) step = SHEET5K_SIZE_DEG sheets: list[str] = [] for i in range(math.floor(lat.max() / step), math.floor(lat.min() / step) - 1, -1): @@ -149,10 +152,10 @@ def sheets_for_box(box: Box, epsg: int) -> list[str]: return sheets -def _owned(sheet_no: str, epsg: int, cell_m: float) -> tuple[int, int, np.ndarray]: +def _owned(sheet_no: str, epsg: int | str, cell_m: float) -> tuple[int, int, np.ndarray]: """도엽 안쪽 칸 — (첫 칸 열 번호 ix0 · 첫 칸 행 번호 iy0 · 소유 표 (ny, nx) y 오름차순).""" lon0, lat0, lon1, lat1 = sheet5k_to_bounds(sheet_no) - to_tm = Transformer.from_crs("EPSG:4326", f"EPSG:{int(epsg)}", always_xy=True) + to_tm = Transformer.from_crs("EPSG:4326", _crs(epsg), always_xy=True) t = np.linspace(0.0, 1.0, 21) lon = np.concatenate([lon0 + (lon1 - lon0) * t, np.full(21, lon1), lon0 + (lon1 - lon0) * t]) lat = np.concatenate([np.full(21, lat0), lat0 + (lat1 - lat0) * t, np.full(21, lat1)]) @@ -164,9 +167,7 @@ def _owned(sheet_no: str, epsg: int, cell_m: float) -> tuple[int, int, np.ndarra cx = (np.arange(ix0, ix1) + 0.5) * cell_m cy = (np.arange(iy0, iy1) + 0.5) * cell_m gx, gy = np.meshgrid(cx, cy) - clon, clat = Transformer.from_crs(f"EPSG:{int(epsg)}", "EPSG:4326", always_xy=True).transform( - gx, gy - ) + clon, clat = Transformer.from_crs(_crs(epsg), "EPSG:4326", always_xy=True).transform(gx, gy) own = (clon >= lon0) & (clon < lon1) & (clat >= lat0) & (clat < lat1) return ix0, iy0, own @@ -179,7 +180,7 @@ def _gpkg_segments(epsg: int, box: Box) -> Segments: import shapely x0, y0, x1, y1 = box - to_5179 = Transformer.from_crs(f"EPSG:{int(epsg)}", "EPSG:5179", always_xy=True) + to_5179 = Transformer.from_crs(_crs(epsg), "EPSG:5179", always_xy=True) t = np.linspace(0.0, 1.0, 21) bx, by = to_5179.transform( np.concatenate([x0 + (x1 - x0) * t, x0 + (x1 - x0) * t, np.full(21, x0), np.full(21, x1)]), @@ -208,13 +209,13 @@ def _gpkg_segments(epsg: int, box: Box) -> Segments: seg = np.flatnonzero(hit) if not len(seg): return _NO_SEGMENTS - to_tm = Transformer.from_crs("EPSG:5179", f"EPSG:{int(epsg)}", always_xy=True) + to_tm = Transformer.from_crs("EPSG:5179", _crs(epsg), always_xy=True) ax, ay = to_tm.transform(a[seg, 0], a[seg, 1]) bx2, by2 = to_tm.transform(b[seg, 0], b[seg, 1]) return np.column_stack([ax, ay]), np.column_stack([bx2, by2]), levels[part[seg]] -def _zip_lines(sheet_nos: list[str], epsg: int) -> list[tuple[np.ndarray, float]]: +def _zip_lines(sheet_nos: list[str], epsg: int | str) -> list[tuple[np.ndarray, float]]: """창고 폴더의 도엽 zip 등고선 → (꼭짓점 · 높이) 목록.""" from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS, _read_layer_from_zip from B04_PreProcess.B04_PreProcess_Engine_SheetStore import _load_index @@ -230,7 +231,7 @@ def _zip_lines(sheet_nos: list[str], epsg: int) -> list[tuple[np.ndarray, float] frame = _read_layer_from_zip(folder / entry["file"], prefix, code, entry["crs"]) if frame is None: continue - frame = frame.to_crs(f"EPSG:{int(epsg)}") + frame = frame.to_crs(_crs(epsg)) column = next((k for k in ELEVATION_KEYS if k in frame.columns), None) if column is None: continue diff --git a/resources/tester/test_609_1_descent_shared.py b/resources/tester/test_609_1_descent_shared.py new file mode 100644 index 000000000..76181c4a6 --- /dev/null +++ b/resources/tester/test_609_1_descent_shared.py @@ -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"} From db5a2f040810ae3f62c0d7eaf59095840823cc2e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 23:11:41 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=B0=9B=EA=B8=B0=20=EC=83=81=ED=95=9C=20=3D=20?= =?UTF-8?q?=EC=B9=B8=20500=20=EB=A7=8C=20=E2=86=92=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EC=B0=BD=EA=B3=A0=20=EB=8F=84=EC=97=BD=2036=20=EC=9E=A5=20(PLA?= =?UTF-8?q?N=20613-1=20=C2=B7=20=EC=9A=94=EA=B5=AC=20493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한 번에 이어 붙이는 도엽 수 36 장(6 × 6) 넘으면 413 + 알림 글(몇 장 · 줄이는 법) - 범위 보기 응답에 store_sheets · max_sheets - 시험 530_1 · 530_2 갱신 Co-Authored-By: Claude Sonnet 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 7 +++--- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 15 +++++------- D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py | 24 ++++++++++++++++---- resources/tester/test_530_1_plan_terrain.py | 24 ++++++++++++++++++-- resources/tester/test_530_2_plan_router.py | 15 +++++++----- 5 files changed, 60 insertions(+), 25 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 2228fe371..6ebd7774b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -63,8 +63,9 @@ export interface TerrainExtent { covered: boolean; sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>; cells: number; - /** 받기 칸 상한(넘으면 받기 413). */ - max_cells: number; + /** 공용 창고 이어 붙일 도엽 수 · 한 번에 상한(넘으면 받기 413). */ + store_sheets: number; + max_sheets: number; } /** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */ @@ -130,7 +131,7 @@ export const fetchTerrainExtent = ( return request(`${base(projectId)}/terrain/extent?${query}`); }; -/** 받기 — 칸 상한 넘음 = 오류(413 글). */ +/** 받기 — 도엽 상한 넘음 = 오류(413 글). */ export const startTerrain = ( projectId: string, marginM: number | null, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 8d7386481..f303d8f13 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -14,7 +14,7 @@ (PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐). 위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 · 받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고 -center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어 +center 둘레만 · 창고 도엽 `MAX_SHEETS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어 서버가 다시 켜지면 「끊김」(interrupted). 노선 정본(손 그은 선)은 `D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음). @@ -221,7 +221,7 @@ async def plan_terrain_extent( @router.post("/{project_id}/plan/terrain", response_model=None) async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]: """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress` · - 위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413.""" + 위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 도엽 상한 넘음 = 413.""" body = body or TerrainBody() center = (body.center.lon, body.center.lat) if body.center else None root, epsg, ends, band = await _plan_ends(project_id, center, body.reset) @@ -243,13 +243,10 @@ async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dic "옛 받은 범위를 버리고 새로 받을까요?", } points = [*ends, *body.extra] - cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have)) - if cells > terrain.MAX_CELLS: - raise HTTPException( - status_code=413, - detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · " - f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)", - ) + want = terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have) + count = terrain.store_sheet_count(want, epsg) + if count > terrain.MAX_SHEETS: + raise HTTPException(status_code=413, detail=terrain.sheet_limit_message(count)) async def work(report: Any) -> None: got = await asyncio.to_thread( diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index b0b78904c..1221d9bc7 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -5,7 +5,7 @@ - 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) · 새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 · - 칸 상한 `MAX_CELLS`(넘으면 라우터가 거절) + 칸 상한 도엽 `MAX_SHEETS`(공용 창고 · 넘으면 라우터가 거절) - 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음) - 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀) - 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) @@ -65,9 +65,9 @@ DEFAULT_MARGIN_M = 350.0 MAX_MARGIN_M = 5000.0 # 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2) SITE_RADIUS_M = 1000.0 -# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 DEM 칸 상한(요구서_463 3장 4) +# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 도엽 상한(요구서_463 3장 4 · 613-1) FAR_M = 5000.0 -MAX_CELLS = 5_000_000 +MAX_SHEETS = 36 # 한 번에 이어 붙이는 공용 창고 도엽 수 상한(6 × 6) # ponytail: 새 도엽 함께 셀 수 — 장당 메모리 1 GB 안팎 · 넘으면 차례로 WARM_WORKERS = 4 STAGE = Path("B04_PreProcess") @@ -125,10 +125,23 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box: def cells_of(box: Box) -> int: - """DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정.""" + """DEM 칸 수 어림 — 받기 시간 · 메모리 가늠.""" return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2) +def store_sheet_count(box: Box, epsg: int) -> int: + """공용 창고에서 이어 붙일 도엽 수 — 상한 판정.""" + return len(terrain_store.sheets_for_box(box, epsg)) + + +def sheet_limit_message(count: int) -> str: + """도엽 상한 넘음 알림 글 — 몇 장 · 줄이는 법.""" + return ( + f"받을 범위가 너무 넓습니다 — 도엽 {count}장 · 한 번에 {MAX_SHEETS}장까지" + "(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)" + ) + + def distance_to_box(xy: XY, box: Box) -> float: """점 ~ 상자 거리(m · 안이면 0).""" dx = max(box[0] - xy[0], 0.0, xy[0] - box[2]) @@ -195,7 +208,8 @@ def plan_extent( "covered": covers(have, box), "sheets": sheets, "cells": cells_of(box), - "max_cells": MAX_CELLS, + "store_sheets": store_sheet_count(box, epsg), + "max_sheets": MAX_SHEETS, } diff --git a/resources/tester/test_530_1_plan_terrain.py b/resources/tester/test_530_1_plan_terrain.py index aa877ba7b..44593681b 100644 --- a/resources/tester/test_530_1_plan_terrain.py +++ b/resources/tester/test_530_1_plan_terrain.py @@ -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: diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 10bfe6970..522ce8364 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -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: From eb97ddc73cffaf6a91e4c0840bd4c3d52bda6513 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 23:11:42 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EA=B3=A0=EB=A6=84=20=EA=B0=95=EC=A1=B0=20?= =?UTF-8?q?=EA=B2=80=EC=9D=80=20=ED=85=8C=20=ED=95=9C=EC=AA=BD=204=20px=20?= =?UTF-8?q?(PLAN=20613-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위성 켬 밝은 바탕에서도 윤곽 대비 하위 10 % 4.7 : 1(옛 줄만 기준 2.96) - 시험 551_2 에 바탕 밝기 전 구간 ≥ 3 : 1 지킴 추가 Co-Authored-By: Claude Sonnet 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts | 10 +++++++++- resources/tester/test_551_2_draw_tokens.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 259eb0f29..4feb44586 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -61,6 +61,9 @@ export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select"; const DRAG_PX = 4; /** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */ const MARK_OFF_PX = 3 * SNAP_PX; +/** 고름 강조 — 노랑 줄 굵기 · 검은 테 한쪽 두께(px). */ +export const PICK_LINE_PX = 8; +export const PICK_HALO_PX = 4; export interface PlanEditParams { network: () => PlanNetwork; @@ -319,7 +322,12 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); - strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1) + // 밝은 위성 위에서도 윤곽이 읽히게 검은 테를 한쪽 4 px 로 두껍게(613-2) + context.setLineDash([]); + context.lineWidth = PICK_LINE_PX + 2 * PICK_HALO_PX; + context.strokeStyle = colors.casing; + context.stroke(); + strokeCased(context, colors.active, PICK_LINE_PX); } // 꺾임점 손잡이(고른 것이 든 선) const lines = pickedLines(network); diff --git a/resources/tester/test_551_2_draw_tokens.py b/resources/tester/test_551_2_draw_tokens.py index 2be72965d..74130a9a1 100644 --- a/resources/tester/test_551_2_draw_tokens.py +++ b/resources/tester/test_551_2_draw_tokens.py @@ -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