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:
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user