Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-16 11:22:41 +09:00
8 changed files with 334 additions and 31 deletions
@@ -5,7 +5,7 @@
일반 사용자가 쓰는 B05는 그 결과를 읽어 쓰기만 한다(2026-07-31 사용자 지시).
① 도로 교차 세류망 상류측 추출 → 반경 버퍼 = 1차 배수유역
② 도로 시작점 기준 격자 생성 (1차 영역에 걸치는 셀만)
② 좌표 정수 m 기준 격자 생성 (1차 영역에 걸치는 셀만)
③ 등고선 하강 방향 — 높은 등고 라인에서 낮은 등고 라인으로. 보간면을 쓰지 않으므로
가짜 웅덩이·평탄면이 원리적으로 생기지 않는다
④ 세류망 흐름 새김 → 최외곽부터 사슬 추적 → 도로 도달 여부(적/청) 판정
@@ -24,6 +24,7 @@ import logging
import math
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
@@ -152,6 +153,7 @@ def preview_stages(
vertices: list[RouteVertex],
contour_features: list[dict[str, Any]],
stream_features: list[dict[str, Any]],
descent_store: tuple[Path, str] | None = None,
) -> StagePreview | None:
"""지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다.
@@ -184,6 +186,7 @@ def preview_stages(
route_line,
region.split.upstream,
floor,
descent_store=descent_store,
)
if expansion is None:
logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.")
@@ -22,10 +22,14 @@
from __future__ import annotations
import hashlib
import json
import logging
import time
import math
import time
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
@@ -349,6 +353,99 @@ def build_contour_descent(
)
# ── 흐름 방향 저장본(347-1) ─────────────────────────────────────────────
#
# 방향장은 등고선 · 남긴 등고 단 · 격자(원점 · 셀 · 범위)만으로 정해진다. 원점이 좌표 정수 m 라
# 노선이 저장 범위 안이면 읽어서 잘라 쓴다. 파일 이름 = descent_<입력 지문>_<범위 지문>.npz ·
# 종류마다 최근 2벌만 남긴다. 계산 규칙이 바뀌면 `DESCENT_FILE_VERSION` 을 올린다.
DESCENT_FILE_VERSION = 1
_DESCENT_KEEP = 2
def descent_input_key(
contour_key: str,
contour_features: list[dict[str, Any]],
elevation_floor_m: float | None,
cell_m: float,
) -> str:
"""방향장 입력 지문 — 도엽 등고선 지문 + 남긴 등고 단 + 셀 + 계산 규칙 값.
남긴 단은 속성 표고만 본다 — 같은 도엽 · 같은 단 집합이면 남는 선도 같다(길이 거름은 결정적).
"""
levels = sorted(
{
elevation
for feature in contour_features
if feature.get("geometry")
and (elevation := _feature_elevation(feature.get("properties") or {})) is not None
and (elevation_floor_m is None or elevation >= elevation_floor_m)
}
)
text = json.dumps(
[
DESCENT_FILE_VERSION,
contour_key,
levels,
cell_m,
DRAINAGE_CONTOUR_MIN_LENGTH_M,
DESCENT_WINDOW_MARGIN_CELLS,
DESCENT_TILE_CELLS,
]
)
return hashlib.sha1(text.encode(), usedforsecurity=False).hexdigest()[:16]
def iter_saved_descents(directory: Path, input_key: str) -> Iterator[ContourDescent]:
"""같은 입력 지문으로 저장한 방향장 — 최근 것부터. 못 읽는 파일은 건너뜀."""
paths = sorted(
directory.glob(f"descent_{input_key}_*.npz"),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
for path in paths:
try:
with np.load(path, allow_pickle=False) as data:
x_min, y_max, cell_m, n_rows, n_cols = data["spec"].tolist()
yield ContourDescent(
spec=GridSpec(
float(x_min), float(y_max), float(cell_m), int(n_rows), int(n_cols)
),
band_elevation=data["band_elevation"],
valid=data["valid"],
receiver=data["receiver"],
step_length=data["step_length"],
azimuth=data["azimuth"],
levels=data["levels"].tolist(),
)
except (OSError, KeyError, ValueError):
logger.warning("배수유역: 흐름 방향 저장본을 읽지 못해 건너뜁니다 (%s).", path)
def save_descent(directory: Path, input_key: str, descent: ContourDescent) -> None:
"""방향장을 저장하고 최근 2벌만 남긴다. 실패는 로그만(계산은 이어감)."""
spec = descent.spec
extent = f"{spec.x_min:.3f},{spec.y_max:.3f},{spec.n_rows},{spec.n_cols}"
extent_key = hashlib.sha1(extent.encode(), usedforsecurity=False).hexdigest()[:8]
path = directory / f"descent_{input_key}_{extent_key}.npz"
try:
directory.mkdir(parents=True, exist_ok=True)
np.savez(
path,
spec=np.array([spec.x_min, spec.y_max, spec.cell_m, spec.n_rows, spec.n_cols]),
band_elevation=descent.band_elevation,
valid=descent.valid,
receiver=descent.receiver,
step_length=descent.step_length,
azimuth=descent.azimuth,
levels=np.array(descent.levels, dtype=np.float64),
)
olds = sorted(directory.glob("descent_*.npz"), key=lambda item: item.stat().st_mtime)
for old in olds[:-_DESCENT_KEEP]:
old.unlink(missing_ok=True)
except OSError:
logger.warning("배수유역: 흐름 방향 저장본을 남기지 못했습니다 (%s).", path)
def _route_by_potential(
spec: GridSpec,
potential: np.ndarray,
@@ -9,10 +9,12 @@
③ 넓힌 영역으로 흐름 방향·색을 다시 분석한다
④ **새로 추가된 셀에 적색이 하나도 없으면 종료** (2026-07-31 사용자 지시)
격자 bbox에 닿으면 격자 자체도 셀 정수배로 넓힌다 — 도로 시작점 기준 격자점은 유지된다.
격자 bbox에 닿으면 격자 자체도 셀 정수배로 넓힌다 — 좌표 정수 m 기준 격자점은 유지된다.
하강 방향장은 처음에 **넉넉한 큰 격자**로 한 번 짓고, 회차마다 그 격자에서 잘라 쓴다
(`DESCENT_SPARE_M` · 342-3). 격자가 커질 때마다 다시 짓던 것(8회 · 30 s)을 한 번으로 줄인다.
저장소(`descent_store` = 폴더 · 도엽 등고선 지문)를 주면 지은 것을 남기고, 덮는 저장본이 있으면
읽기만 한다(347-1).
"""
from __future__ import annotations
@@ -20,6 +22,7 @@ from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
@@ -28,6 +31,9 @@ from shapely.geometry import LineString
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import (
ContourDescent,
build_contour_descent,
descent_input_key,
iter_saved_descents,
save_descent,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import (
FlowClassification,
@@ -135,6 +141,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,
) -> RedExpansion | None:
"""최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다."""
_expand_started = time.perf_counter()
@@ -144,9 +151,33 @@ def expand_by_red_boundary(
spec, domain = _pad_spec(spec, domain, band_cells * 2)
started_cells = int(domain.sum())
spare_cells = max(0, int(round(DESCENT_SPARE_M / spec.cell_m)))
source = build_contour_descent(
_pad_spec(spec, domain, spare_cells)[0], contour_features, None, elevation_floor_m
)
store = None
if descent_store is not None:
folder, contour_key = descent_store
store = (
folder,
descent_input_key(contour_key, contour_features, elevation_floor_m, spec.cell_m),
)
def source_for(grid: GridSpec, mask: np.ndarray) -> ContourDescent:
"""`grid` 를 덮는 큰 방향장 — 저장본이 덮으면 읽기만 · 아니면 둘레 여유째 짓고 남김."""
if store is not None:
for saved in iter_saved_descents(*store):
if crop_descent(saved, grid) is not None:
logger.info(
"배수유역: 흐름 방향 저장본을 읽어 씁니다(%d×%d).",
saved.spec.n_rows,
saved.spec.n_cols,
)
return saved
built = build_contour_descent(
_pad_spec(grid, mask, spare_cells)[0], contour_features, None, elevation_floor_m
)
if store is not None:
save_descent(*store, built)
return built
source = source_for(spec, domain)
analysis = analyze_domain(
spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m, source
)
@@ -181,12 +212,7 @@ def expand_by_red_boundary(
break
if crop_descent(source, grown_spec) is None:
# 넉넉히 둔 방향장 밖으로 넓어짐 — 넓힌 격자 둘레로 다시 짓는다.
source = build_contour_descent(
_pad_spec(grown_spec, grown_domain, spare_cells)[0],
contour_features,
None,
elevation_floor_m,
)
source = source_for(grown_spec, grown_domain)
logger.info(
"배수유역: %d회차 확장 — 최외곽 적색 %d셀 주변 %.0fm, 셀 %d개 추가",
@@ -329,7 +355,7 @@ def _grow_for_rim(
) -> tuple[GridSpec, np.ndarray, np.ndarray]:
"""적색 최외곽이 격자 bbox에 닿았으면 그 방향으로 격자를 넓히고 마스크를 옮겨 담는다.
격자는 셀 정수배로만 넓히므로 도로 시작점 기준 격자점이 그대로 유지된다.
격자는 셀 정수배로만 넓히므로 좌표 정수 m 기준 격자점이 그대로 유지된다.
"""
north = band_cells if rim[0, :].any() else 0
south = band_cells if rim[-1, :].any() else 0
@@ -216,6 +216,10 @@ def _outside_bounds(
return bounds[2] < clip[0] or bounds[0] > clip[2] or bounds[3] < clip[1] or bounds[1] > clip[3]
# 배수유역 격자 원점 — 셀 모서리가 좌표 정수 m 에 놓인다(347-1 · 프로젝트 공통).
GRID_ORIGIN_XY = (0.0, 0.0)
def grid_spec_from_bounds(
x_min: float,
y_min: float,
@@ -226,9 +230,10 @@ def grid_spec_from_bounds(
) -> GridSpec:
"""범위를 덮는 격자를 만든다. `anchor_xy`를 주면 그 점에 셀 모서리를 맞춘다.
격자 원점은 **도로 시작점**에 고정한다(2026-07-31 사용자 지시). bbox 좌상단에 맞추면
1차 영역이 조금만 달라져도 격자가 통째로 밀려 이전 결과와 셀이 대응되지 않는다.
도로 시작점에 맞추면 반경·영역을 바꿔도 같은 자리의 셀은 같은 자리에 남는다.
격자 원점은 **좌표 정수 m(`GRID_ORIGIN_XY`)**에 고정한다(347-1 사용자 확정 · 옛 「도로
시작점」 지시를 바꿈). bbox 좌상단에 맞추면 1차 영역이 조금만 달라져도 격자가 통째로 밀려
이전 결과와 셀이 대응되지 않는다. 좌표 정수 m 에 맞추면 노선 · 반경이 바뀌어도 같은 자리의
셀은 같은 자리에 남아 흐름 방향 저장본(`drainage/descent_*.npz`)을 그대로 잘라 쓴다.
격자 크기는 절대 자동으로 바꾸지 않는다 — config 값이 그대로 쓰인다(사용자 지시).
셀 수가 많으면 경고만 남기고 그대로 진행한다.
@@ -258,7 +263,7 @@ def grid_spec_from_bounds(
def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> GridSpec:
"""활성 셀이 닿은 방향으로만 격자를 넓힌다.
셀 정수배로만 넓혀 격자 격자점(도로 시작점 기준)이 그대로 유지되게 한다.
셀 정수배로만 넓혀 격자점(좌표 정수 m 기준)이 그대로 유지되게 한다.
"""
steps = max(1, int(math.ceil(step_m / spec.cell_m)))
west = steps if sides.get("west") else 0
@@ -21,6 +21,7 @@ from shapely.geometry import LineString, MultiPolygon, Polygon, shape
from shapely.ops import substring, unary_union
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
GRID_ORIGIN_XY,
ContourCloud,
GridSpec,
build_cell_mask,
@@ -344,7 +345,7 @@ def build_primary_region(
노선 버퍼는 세류 교차가 없는 구간의 도로도 격자 안에 들어오게 한다 — 그래야 그 구간
사면이 유역으로 잡힌다. 상류 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시).
격자는 bbox를 통째로 채우지 않는다. **도로 시작점에 셀 모서리를 맞춘 뒤, 1차 영역에
격자는 bbox를 통째로 채우지 않는다. **좌표 정수 m(원점 0,0)에 셀 모서리를 맞춘 뒤, 1차 영역에
조금이라도 걸치는 셀만** 생성한다(2026-07-31 사용자 지시). bbox 전체를 쓰면 영역 밖
빈 셀이 대부분이라 의미가 없고, 원점을 bbox 좌상단에 두면 영역이 조금만 변해도 격자가
통째로 밀려 이전 결과와 셀이 대응되지 않는다.
@@ -359,16 +360,13 @@ def build_primary_region(
logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼만으로 1차 영역을 잡습니다.")
area = unary_union(geometries)
x_min, y_min, x_max, y_max = area.bounds
road_start = route_line.coords[0]
spec = grid_spec_from_bounds(
x_min, y_min, x_max, y_max, cell_m, anchor_xy=(float(road_start[0]), float(road_start[1]))
)
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, cell_m, anchor_xy=GRID_ORIGIN_XY)
cell_mask = build_cell_mask(spec, area)
outside = route_line.difference(area)
road_outside_m = float(outside.length) if not outside.is_empty else 0.0
active = int(cell_mask.sum())
logger.info(
"배수유역: 1차 영역 %.0f㎡ → 격자 %d×%d (%.2fm, 도로 시점 기준) 중 %d셀 생성 "
"배수유역: 1차 영역 %.0f㎡ → 격자 %d×%d (%.2fm, 좌표 정수 m 기준) 중 %d셀 생성 "
"(bbox %d셀의 %.0f%%), 노선 이탈 %.0fm/%.0fm",
area.area,
spec.n_rows,
@@ -8,9 +8,11 @@
"""
import asyncio
import hashlib
import json
import logging
import math
import pickle
import time
from pathlib import Path
from typing import Any
@@ -149,6 +151,42 @@ def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any:
return [item for item in mapped if item is not None]
# 도엽을 사업지 좌표로 바꾼 저장본(347-1) — 원본 파일 자국 · 좌표계가 같으면 읽기만.
SHEET_METRIC_FILENAME = "00_sheet_metric.pkl"
def _metric_sheets(
directory: Path, cache_dir: Path, source_crs: str, transformer: Transformer
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]:
"""(하천, 등고선, 도엽 등고선 지문). 피처 4,200개 재투영(3.8 s)을 한 번만 한다."""
marks = []
for name in (STREAM_FILE, CONTOUR_FILE):
try:
info = (directory / name).stat()
marks.append(f"{name}:{info.st_mtime_ns}:{info.st_size}")
except OSError:
marks.append(f"{name}:-")
text = "|".join([*marks, str(source_crs)])
key = hashlib.sha1(text.encode(), usedforsecurity=False).hexdigest()[:16]
path = cache_dir / SHEET_METRIC_FILENAME
try:
with path.open("rb") as file:
saved = pickle.load(file)
if saved.get("key") == key:
return saved["streams"], saved["contours"], key
except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError):
pass
streams = _reproject_features(_load_features(directory, STREAM_FILE), transformer)
contours = _reproject_features(_load_features(directory, CONTOUR_FILE), transformer)
try:
cache_dir.mkdir(parents=True, exist_ok=True)
with path.open("wb") as file:
pickle.dump({"key": key, "streams": streams, "contours": contours}, file)
except OSError:
logger.warning("배수유역: 도엽 좌표 저장본을 남기지 못했습니다 (%s).", path)
return streams, contours, key
def _candidate_payload(
candidate: StructureCandidate,
to_lonlat: Any,
@@ -204,10 +242,8 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
directory = _sheet_dir(stored_path)
streams = _reproject_features(_load_features(directory, STREAM_FILE), to_metric_transformer)
contour_features = _reproject_features(
_load_features(directory, CONTOUR_FILE), to_metric_transformer
streams, contour_features, contour_key = _metric_sheets(
_sheet_dir(stored_path), drainage_dir(stored_path), source_crs, to_metric_transformer
)
return {
"route_source": route_file.name,
@@ -215,6 +251,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
"route_line": planned.line,
"streams": streams,
"contours": contour_features,
"contour_key": contour_key,
"stored_path": stored_path,
"to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y),
}
@@ -403,6 +440,8 @@ async def get_primary_region(
prepared["vertices"],
prepared["contours"],
prepared["streams"],
# 흐름 방향 저장본(347-1) — 노선이 저장 범위 안이면 읽기만
(drainage_dir(prepared["stored_path"]), prepared["contour_key"]),
)
if preview is None:
return JSONResponse(
+11 -4
View File
@@ -3,9 +3,10 @@
./venv/Scripts/python.exe resources/tester/bench_342_watershed.py base # 기준 저장
./venv/Scripts/python.exe resources/tester/bench_342_watershed.py new base # 새로 돌려 대조
./venv/Scripts/python.exe resources/tester/bench_342_watershed.py s1 base store # 흐름 방향 저장본
pytest 대상 아님(DB · 프로젝트 파일이 있어야 돎). 저장 안 함 — 프로젝트 파일은 읽기만
(흐름 방향 저장본 drainage/descent_*.npz 는 347-1 부터 씀).
pytest 대상 아님(DB · 프로젝트 파일이 있어야 돎). 분석 결과는 저장 안 함 — 도엽 좌표 저장본
(drainage/00_sheet_metric.pkl)만 프로젝트에 남고 · 흐름 방향 저장본은 tmp/342/descent 에 둠.
"""
import asyncio
@@ -40,7 +41,8 @@ async def _prepare():
def main() -> None:
name = sys.argv[1]
base = sys.argv[2] if len(sys.argv) > 2 else None
base = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] != "-" else None
store = len(sys.argv) > 3 and sys.argv[3] == "store"
OUT_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
@@ -51,7 +53,12 @@ def main() -> None:
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages
started = time.perf_counter()
pv = preview_stages(prepared["vertices"], prepared["contours"], prepared["streams"])
pv = preview_stages(
prepared["vertices"],
prepared["contours"],
prepared["streams"],
(OUT_DIR / "descent", prepared["contour_key"]) if store else None,
)
preview_s = time.perf_counter() - started
spec = pv.spec
red = pv.flow.reaches_road & pv.flow.analyzed
@@ -0,0 +1,128 @@
"""347-1 흐름 방향 · 도엽 좌표 저장 — 저장본을 읽어도 결과 같음 · 두 번째는 짓지 않음.
- 격자 원점 = 좌표 정수 m(셀 모서리 0,0) — 노선이 바뀌어도 같은 자리 셀은 같은 자리.
- 방향장 저장본: 입력 지문(도엽 지문 · 남긴 등고 단 · 셀) 같고 범위가 덮으면 읽기만 · 최근 2벌.
- 도엽 좌표 저장본: 원본 자국 · 좌표계가 같으면 다시 안 바꿈.
실측(검증_sub3 · 차이 0칸 · 108 → 10.8 s 붐빔 · 340 기준 약 6 s)은 bench_342_watershed.py.
"""
import math
import numpy as np
from shapely.geometry import LineString
from B04_PreProcess import B04_PreProcess_Engine_Watershed_Expand as expand
from B04_PreProcess import B04_PreProcess_Router_Watershed as router
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import (
descent_input_key,
iter_saved_descents,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
GRID_ORIGIN_XY,
GridSpec,
grid_spec_from_bounds,
)
def _contours():
features = []
for radius in range(10, 200, 10):
coords = [
[150.0 + radius * math.cos(t), -150.0 + radius * math.sin(t)]
for t in np.linspace(0, 2 * math.pi, 241)
]
features.append(
{
"geometry": {"type": "LineString", "coordinates": coords},
"properties": {"elevation": 300.0 - radius},
}
)
return features
def test_grid_origin_is_integer_metre():
assert GRID_ORIGIN_XY == (0.0, 0.0)
spec = grid_spec_from_bounds(10.37, -80.6, 90.2, -3.4, 1.0, anchor_xy=GRID_ORIGIN_XY)
assert spec.x_min == 10.0 and spec.y_max == -3.0
def test_input_key_follows_contours_and_floor():
contours = _contours()
base = descent_input_key("도엽-1", contours, None, 1.0)
assert base == descent_input_key("도엽-1", contours, None, 1.0)
assert base != descent_input_key("도엽-2", contours, None, 1.0) # 도엽 · 좌표계 바뀜
assert base != descent_input_key("도엽-1", contours, 200.0, 1.0) # 남긴 등고 단 바뀜
assert base == descent_input_key("도엽-1", contours, 100.5, 1.0) # 단 집합 그대로(전부 남음)
def _run(store, monkeypatch, counter):
real = expand.build_contour_descent
def counting(*args, **kwargs):
counter.append(1)
return real(*args, **kwargs)
monkeypatch.setattr(expand, "build_contour_descent", counting)
spec = GridSpec(x_min=100.0, y_max=-100.0, cell_m=1.0, n_rows=100, n_cols=100)
domain = np.zeros((100, 100), bool)
domain[20:80, 20:80] = True
route = LineString([(110.0, -150.0), (190.0, -150.0)])
result = expand.expand_by_red_boundary(
spec, domain, _contours(), route, [], None, descent_store=store
)
return result.analysis
def test_store_gives_same_result_and_reads_second_time(tmp_path, monkeypatch):
monkeypatch.setattr(expand, "DESCENT_SPARE_M", 40.0)
built: list[int] = []
plain = _run(None, monkeypatch, built)
store = (tmp_path, "도엽-1")
first = _run(store, monkeypatch, built)
count_after_first = len(built)
second = _run(store, monkeypatch, built)
assert len(built) == count_after_first # 두 번째는 짓지 않음
for analysis in (first, second):
assert analysis.spec == plain.spec
assert np.array_equal(analysis.flow.reaches_road, plain.flow.reaches_road)
assert np.array_equal(analysis.descent.receiver, plain.descent.receiver)
saved = list(tmp_path.glob("descent_*.npz"))
assert 1 <= len(saved) <= 2
key = descent_input_key("도엽-1", _contours(), None, 1.0)
assert next(iter_saved_descents(tmp_path, key)).spec.n_rows >= plain.spec.n_rows
def test_store_keeps_two_newest(tmp_path, monkeypatch):
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import save_descent
monkeypatch.setattr(expand, "DESCENT_SPARE_M", 0.0)
analysis = _run(None, monkeypatch, [])
for index in range(3):
moved = GridSpec(100.0 + index, -100.0, 1.0, analysis.spec.n_rows, analysis.spec.n_cols)
descent = analysis.descent
save_descent(tmp_path, "key", type(descent)(**{**descent.__dict__, "spec": moved}))
assert len(list(tmp_path.glob("descent_*.npz"))) == 2
def test_metric_sheets_cached_until_source_changes(tmp_path, monkeypatch):
sheets = tmp_path / "sheets"
sheets.mkdir()
feature = {
"type": "Feature",
"properties": {"elevation": 10},
"geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]},
}
import json
for name in (router.STREAM_FILE, router.CONTOUR_FILE):
(sheets / name).write_text(json.dumps({"features": [feature]}), "utf-8")
calls = []
real = router._reproject_features
monkeypatch.setattr(
router, "_reproject_features", lambda f, t: calls.append(1) or real(f, None)
)
first = router._metric_sheets(sheets, tmp_path / "drainage", "EPSG:5186", None)
again = router._metric_sheets(sheets, tmp_path / "drainage", "EPSG:5186", None)
assert len(calls) == 2 and first == again # 두 번째는 저장본
router._metric_sheets(sheets, tmp_path / "drainage", "EPSG:5179", None) # 좌표계 바뀜
assert len(calls) == 4