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

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