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

This commit is contained in:
2026-10-16 11:16:26 +09:00
13 changed files with 1216 additions and 5 deletions
@@ -76,6 +76,11 @@ def _patch_db(monkeypatch, project_root: Path, params: dict) -> None:
monkeypatch.setattr(terrain_router, "get_surface_confirmation_params", confirmed)
monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: project_root)
async def no_model(_connection, _project_id): # 격자 받을 자리(344-1)는 따로 시험
return None
monkeypatch.setattr(terrain_router, "_confirmed_model", no_model)
PID = "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
@@ -0,0 +1,276 @@
"""횡단 지반선 생성 거울 — 파이썬 ↔ TS(`B05_Profile/logic/B05_Profile_Sections_Ground.ts`).
PLAN 344-1 — 노선을 고치는 동안 브라우저가 지은 지반선이 서버 정본과 같아야 함.
짝: `generate_sections` 지반 부분 ↔ `sectionsGround` · `ground_profile` + 측점 `np.interp` ↔
`groundProfile` · `groundAt` · `extend_unclosed` ↔ `extendUnclosed`. 저장된 종 · 횡단 파일과도 대조.
"""
from __future__ import annotations
import csv
import json
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade import ground_profile # noqa: E402
from B06_Section import B06_Section_Engine_SampleExtend as extend_py # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
from config import config_system # noqa: E402
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_Sections_Ground.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
PROJECT = ROOT / "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
REAL_ROUTE = PROJECT / "B05_Profile/route/planned_route_initial.csv"
_TOL = 1.5e-6 # 두 쪽 다 소수 6자리 반올림
#: 저장값 대조 — 저장된 x · y 가 6자리로 잘려 그 자리 표고가 기울기만큼(수 μm) 움직임.
_STORED_TOL = 1e-5
#: 덧붙임 시험용 설계 — 쪽마다 닫히는 반폭(우 27 · 좌 31 m)에 닿기 전엔 끝이 1 m 떠 있음.
_COMPUTE_PY_RADII = {"right": 27.0, "left": 31.0}
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { parseDtmGrid, heightAt } from "./common_util/common_util_surface_sampler.js";
import {
sectionsGround, groundProfile, groundAt, extendUnclosed,
SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, SECTION_LONG_SAMPLE_INTERVAL_M,
EXTEND_STEP_M,
} from "./B05_Profile/logic/B05_Profile_Sections_Ground.js";
const bytes = readFileSync(process.argv[2]);
const grid = parseDtmGrid(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length));
const job = JSON.parse(readFileSync(process.argv[3], "utf8"));
const compute = (samples) => {
const ground = samples.filter((s) => s.valid !== false && s.elevation_m != null)
.sort((a, b) => a.offset_m - b.offset_m);
const right = ground[0], left = ground[ground.length - 1];
const rightOpen = Math.abs(right.offset_m) < job.radii.right;
const leftOpen = Math.abs(left.offset_m) < job.radii.left;
return {
slope_unclosed: rightOpen || leftOpen,
design_line: [
{ offset_m: right.offset_m, elevation_m: right.elevation_m + (rightOpen ? 1 : 0) },
{ offset_m: left.offset_m, elevation_m: left.elevation_m + (leftOpen ? 1 : 0) },
],
};
};
const ground = sectionsGround(job.line, job.stations, grid);
const profile = groundProfile(ground.samples);
const out = {
constants: [SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M,
SECTION_LONG_SAMPLE_INTERVAL_M, EXTEND_STEP_M],
ground,
stationGround: job.stations.map((c) => groundAt(profile, c)),
extended: ground.stations.map((s) =>
extendUnclosed(s.samples, [s.center_x, s.center_y], s.left_xy, compute, grid)),
stored: job.stored.map(([x, y]) => {
const z = heightAt(grid, x, y);
return Number.isNaN(z) ? null : z;
}),
};
writeFileSync(process.argv[4], JSON.stringify(out));
"""
def _compute_py(samples: list[dict]) -> dict:
ground = sorted(
(s for s in samples if s.get("valid") is not False and s.get("elevation_m") is not None),
key=lambda s: s["offset_m"],
)
right, left = ground[0], ground[-1]
right_open = abs(right["offset_m"]) < _COMPUTE_PY_RADII["right"]
left_open = abs(left["offset_m"]) < _COMPUTE_PY_RADII["left"]
return {
"slope_unclosed": right_open or left_open,
"design_line": [
{"offset_m": right["offset_m"], "elevation_m": right["elevation_m"] + right_open},
{"offset_m": left["offset_m"], "elevation_m": left["elevation_m"] + left_open},
],
}
def _run_ts(tmp_path: Path, npz: Path, job: dict) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
grid = tmp_path / "grid.bin"
grid.write_bytes(dtm_grid_bytes(npz))
payload = tmp_path / "job.json"
result = tmp_path / "out.json"
payload.write_text(json.dumps(job), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(grid), str(payload), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _same(a, b, where: str, problems: list[str]) -> None:
if a is None or b is None:
if a is not b:
problems.append(f"{where}: {a!r} vs {b!r}")
elif abs(float(a) - float(b)) > _TOL:
problems.append(f"{where}: {a!r} vs {b!r}")
def _check(npz: Path, line: list, tmp_path: Path, stored: list | None = None) -> dict:
sampler = DtmGridSampler.from_npz(npz)
expected = core.generate_sections(np.asarray(line, dtype=np.float64), sampler)
stations = [s["chainage_m"] for s in expected["longitudinal"]["stations"]]
job = {"line": line, "stations": stations, "radii": _COMPUTE_PY_RADII, "stored": stored or []}
actual = _run_ts(tmp_path, npz, job)
problems: list[str] = []
ground = actual["ground"]
_same(ground["length_m"], expected["longitudinal"]["length_m"], "길이", problems)
long_py = expected["longitudinal"]["samples"]
assert len(ground["samples"]) == len(long_py)
for i, (ts, py) in enumerate(zip(ground["samples"], long_py, strict=True)):
assert ts["valid"] == py["valid"], f"종단 {i} 유효 어긋남"
for key in ("chainage_m", "x", "y", "z", "elevation_m"):
_same(ts[key], py[key], f"종단 {i} {key}", problems)
cross_py = expected["cross_sections"]
assert len(ground["stations"]) == len(cross_py)
for ts, py in zip(ground["stations"], cross_py, strict=True):
where = f"측점 {py['chainage_m']}"
assert ts["uphill_side"] == py["uphill_side"], f"{where} 상단측"
for key in ("chainage_m", "center_x", "center_y", "center_z"):
_same(ts[key], py[key], f"{where} {key}", problems)
for key in ("left_xy", "tangent_xy"):
for axis in (0, 1):
_same(ts[key][axis], py["frame"][key][axis], f"{where} {key}", problems)
assert len(ts["samples"]) == len(py["samples"])
for a, b in zip(ts["samples"], py["samples"], strict=True):
assert a["valid"] == b["valid"], f"{where} offset {b['offset_m']} 유효 어긋남"
for key in ("offset_m", "x", "y", "z"):
_same(a[key], b[key], f"{where} offset {b['offset_m']} {key}", problems)
profile_c, profile_z = ground_profile(expected["longitudinal"])
for c, z in zip(stations, actual["stationGround"], strict=True):
_same(z, float(np.interp(c, profile_c, profile_z)), f"측점 지반고 {c}", problems)
extended_count = 0
for ts, py in zip(actual["extended"], cross_py, strict=True):
want = extend_py.extend_unclosed(py["samples"], py["frame"], _compute_py, sampler)
if want is None:
assert ts is None
continue
extended_count += 1
assert ts["info"] == want[1], f"측점 {py['chainage_m']} 넓힘 {ts['info']} vs {want[1]}"
assert len(ts["samples"]) == len(want[0])
for a, b in zip(ts["samples"], want[0], strict=True):
for key in ("offset_m", "x", "y", "z"):
_same(a[key], b[key], f"넓힘 {py['chainage_m']} {b['offset_m']} {key}", problems)
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
return {"actual": actual, "extended": extended_count, "stations": len(stations)}
@pytest.fixture
def holed_npz(tmp_path: Path) -> Path:
"""칸 폭 고르지 않음 · 빈칸 띠 · 격자 끝 — 노선이 빈칸 · 끝을 지나감."""
rng = np.random.default_rng(3441)
x = (np.cumsum(rng.uniform(0.95, 1.1, 160)) + 1000.0).astype(np.float32)
y = (np.cumsum(rng.uniform(0.95, 1.1, 90)) + 2000.0).astype(np.float32)
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
z = (500 + 0.08 * (xx - 1000) + 0.3 * (yy - 2000) + rng.normal(0, 0.2, xx.shape)).astype(
np.float32
)
mask = np.ones(z.shape, dtype=bool)
mask[40:46, 60:70] = False
path = tmp_path / "dtm_holed.npz"
np.savez(path, x=x, y=y, z=z, valid_mask=mask)
return path
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_가짜_지형_빈칸_끝_넓힘이_서버와_같다(tmp_path: Path, holed_npz: Path) -> None:
arc = [[1080 + 30 * np.cos(a), 2040 + 30 * np.sin(a)] for a in np.linspace(-1.2, 0.9, 25)]
line = [[1003.0, 2010.0], [1040.0, 2016.0], *arc, [1150.0, 2083.0]]
result = _check(holed_npz, [[float(a), float(b)] for a, b in line], tmp_path)
assert result["actual"]["constants"] == [
config_system.SECTION_CROSS_HALF_WIDTH_M,
config_system.SECTION_CROSS_SAMPLE_INTERVAL_M,
config_system.SECTION_LONG_SAMPLE_INTERVAL_M,
extend_py.EXTEND_STEP_M,
]
assert result["extended"] >= 3
infos = [e["info"] for e in result["actual"]["extended"] if e]
assert any(info["surface_end"] for info in infos), "격자 끝 멈춤 사례 없음"
assert any(not info["surface_end"] for info in infos), "끝까지 닫힌 사례 없음"
def _stored_points() -> list[list[float]]:
longitudinal = json.loads(
(PROJECT / "B06_Section/longitudinal/longitudinal.json").read_text(encoding="utf-8")
)
points = [[s["x"], s["y"], s["z"]] for s in longitudinal["samples"]]
for path in sorted((PROJECT / "B06_Section/cross_sections").glob("cross_*.json")):
section = json.loads(path.read_text(encoding="utf-8"))
points += [[s["x"], s["y"], s.get("z")] for s in section.get("samples") or []]
return points
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
@pytest.mark.skipif(not (REAL_GRID.is_file() and REAL_ROUTE.is_file()), reason="검증_sub8 없음")
def test_검증_프로젝트_노선과_저장된_종횡단이_같다(tmp_path: Path) -> None:
with REAL_ROUTE.open(encoding="utf-8") as handle:
line = [[float(row["x"]), float(row["y"])] for row in csv.DictReader(handle)]
stored = _stored_points()
result = _check(REAL_GRID, line, tmp_path, [[x, y] for x, y, _z in stored])
assert result["stations"] > 50
# 저장된 종단 · 횡단(넓힌 표본 포함)의 자리에서 브라우저 표본 = 저장값
wrong = [
(x, y, z, got)
for (x, y, z), got in zip(stored, result["actual"]["stored"], strict=True)
if (z is None) != (got is None) or (z is not None and abs(z - got) > _STORED_TOL)
]
assert len(stored) > 5000
assert not wrong, f"저장값과 다름 {len(wrong)}점 — 처음 {wrong[:3]}"
def test_짝_머리글이_서로를_가리킨다() -> None:
text = TS_FILE.read_text(encoding="utf-8")
for name in (
"B05_Profile_Engine_Sections_Core.py",
"B05_Profile_Engine_Grade.py",
"B06_Section_Engine_SampleExtend.py",
):
assert name in text
assert "B05_Profile_Sections_Ground.ts" in (core.__doc__ or "")
assert "B05_Profile_Sections_Ground.ts" in (extend_py.__doc__ or "")
@@ -0,0 +1,206 @@
"""지형 격자 표본기 거울 — 파이썬 `DtmGridSampler.sample_xy` ↔ TS `common_util_surface_sampler.ts`.
PLAN 344-1 — 노선 편집이 브라우저에서 지반고를 읽어도 서버 값과 같아야 함.
가장자리 · 빈칸 · 마지막 선 · 칸 폭 고르지 않음 · 내림차순 축 · 무작위 2만 점.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from common_util import common_util_surface_sampler as sampler_py # noqa: E402
TS_FILE = ROOT / "common_util" / "common_util_surface_sampler.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
#: 검증 프로젝트(검증_sub8) 설계 지형 — 없으면 그 시험만 건너뜀.
REAL_GRID = (
ROOT
/ "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a/B04_PreProcess/models"
/ "dtm_classification_smooth.npz"
)
REAL_LONGITUDINAL = REAL_GRID.parents[2] / "B06_Section" / "longitudinal" / "longitudinal.json"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { parseDtmGrid, heightAt } from "./common_util_surface_sampler.js";
const bytes = readFileSync(process.argv[2]);
const grid = parseDtmGrid(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length));
const points = JSON.parse(readFileSync(process.argv[3], "utf8"));
const z = points.map(([x, y]) => {
const value = heightAt(grid, x, y);
return Number.isNaN(value) ? null : value;
});
writeFileSync(process.argv[4], JSON.stringify(z));
"""
def _compile(tmp_path: Path) -> Path:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.glob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
return out
def _ts_heights(tmp_path: Path, npz: Path, points: np.ndarray) -> list[float | None]:
out = _compile(tmp_path)
grid = tmp_path / "grid.bin"
grid.write_bytes(sampler_py.dtm_grid_bytes(npz))
payload = tmp_path / "points.json"
result = tmp_path / "z.json"
payload.write_text(json.dumps(points.tolist()), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(grid), str(payload), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _compare(npz: Path, points: np.ndarray, actual: list[float | None]) -> float:
"""유효 어긋남 0 을 묶고 최대 표고 차를 돌려줌."""
expected, valid = sampler_py.DtmGridSampler.from_npz(npz).sample_xy(points)
mismatched = [i for i, ok in enumerate(valid) if bool(ok) != (actual[i] is not None)]
assert not mismatched, (
f"유효 어긋남 {len(mismatched)}점 — 처음 {points[mismatched[:5]].tolist()}"
)
diffs = [abs(actual[i] - float(expected[i])) for i in np.flatnonzero(valid)]
return max(diffs) if diffs else 0.0
def _edge_points(x: np.ndarray, y: np.ndarray) -> np.ndarray:
"""격자 꼭짓점 · 첫 · 마지막 선 · 그 바로 안팎 · 칸 가운데."""
xs = np.r_[x, (x[:-1] + x[1:]) / 2, x[0] - 1e-9, x[-1] - 1e-9, x[-1] + 1e-9]
ys = np.r_[y[[0, 1, -2, -1]], y[-1] - 1e-9, y[0] + 1e-9, (y[0] + y[1]) / 2]
edge = np.array([[a, b] for a in xs for b in ys])
edge2 = np.array([[a, b] for a in x[[0, 1, -2, -1]] for b in y])
return np.vstack([edge, edge2])
@pytest.fixture
def synthetic_npz(tmp_path: Path) -> Path:
"""칸 폭 고르지 않음(float32) · y 내림차순 · 빈칸 · 유효인데 z NaN 한 칸."""
rng = np.random.default_rng(344)
x = np.cumsum(rng.uniform(0.95, 1.1, 60)).astype(np.float32) + np.float32(208300.0)
y = (np.cumsum(rng.uniform(0.95, 1.1, 40)) + 367400.0).astype(np.float32)[::-1]
z = (800 + rng.normal(0, 5, (40, 60))).astype(np.float32)
mask = np.ones((40, 60), dtype=bool)
mask[10:14, 20:25] = False
mask[0, :] = False
mask[:, -1] = False
z[30, 30] = np.nan
path = tmp_path / "dtm_test.npz"
np.savez(path, x=x, y=y, z=z, valid_mask=mask)
return path
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_가짜_격자_가장자리_빈칸_무작위가_서버와_같다(tmp_path: Path, synthetic_npz: Path) -> None:
data = np.load(synthetic_npz)
x, y = np.sort(data["x"].astype(np.float64)), np.sort(data["y"].astype(np.float64))
rng = np.random.default_rng(1)
random = np.column_stack(
[rng.uniform(x[0] - 5, x[-1] + 5, 20000), rng.uniform(y[0] - 5, y[-1] + 5, 20000)]
)
points = np.vstack([_edge_points(x, y), random])
worst = _compare(synthetic_npz, points, _ts_heights(tmp_path, synthetic_npz, points))
assert worst <= 1e-9, f"표고 차 {worst}"
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
@pytest.mark.skipif(not REAL_GRID.is_file(), reason="검증_sub8 지형 없음")
def test_검증_프로젝트_격자와_저장된_종단이_서버와_같다(tmp_path: Path) -> None:
grid = sampler_py.DtmGridSampler.from_npz(REAL_GRID)
rng = np.random.default_rng(2)
random = np.column_stack(
[
rng.uniform(grid.x[0] - 5, grid.x[-1] + 5, 20000),
rng.uniform(grid.y[0] - 5, grid.y[-1] + 5, 20000),
]
)
stored = []
if REAL_LONGITUDINAL.is_file():
samples = json.loads(REAL_LONGITUDINAL.read_text(encoding="utf-8"))["samples"]
stored = [[s["x"], s["y"]] for s in samples]
points = np.vstack([_edge_points(grid.x, grid.y), random, np.asarray(stored).reshape(-1, 2)])
actual = _ts_heights(tmp_path, REAL_GRID, points)
worst = _compare(REAL_GRID, points, actual)
assert worst <= 1e-9, f"표고 차 {worst}"
def test_격자_꼴이_축_칸_비트를_그대로_싣는다(synthetic_npz: Path) -> None:
blob = sampler_py.dtm_grid_bytes(synthetic_npz)
assert blob[:4] == b"AGRD"
version, nx, ny, z_bytes, _ = np.frombuffer(blob, "<u4", 5, 4)
assert (version, nx, ny, z_bytes) == (1, 60, 40, 4)
grid = sampler_py.DtmGridSampler.from_npz(synthetic_npz)
x = np.frombuffer(blob, "<f8", nx, 24)
assert np.array_equal(x, grid.x) and np.all(np.diff(x) > 0)
z = np.frombuffer(blob, "<f4", nx * ny, 24 + 8 * (nx + ny)).reshape(ny, nx)
assert np.array_equal(z, grid.z.astype(np.float32), equal_nan=True)
bits = np.frombuffer(blob, np.uint8, offset=24 + 8 * (nx + ny) + 4 * nx * ny)
mask = np.unpackbits(bits, bitorder="little")[: nx * ny].reshape(ny, nx).astype(bool)
assert np.array_equal(mask, grid.valid_mask)
def test_표본기_기억은_파일이_바뀌면_새로_읽는다(synthetic_npz: Path) -> None:
first = sampler_py.DtmGridSampler.open_cached(synthetic_npz)
assert sampler_py.DtmGridSampler.open_cached(synthetic_npz) is first
data = dict(np.load(synthetic_npz))
data["z"] = data["z"] + np.float32(1.0)
np.savez(synthetic_npz, **data)
later = time.time() + 5
os.utime(synthetic_npz, (later, later))
assert sampler_py.DtmGridSampler.open_cached(synthetic_npz) is not first
def test_격자_저장본은_npz_보다_옛것이면_다시_쓴다(synthetic_npz: Path) -> None:
target = sampler_py.dtm_grid_file(synthetic_npz)
assert target.name == "dtm_test_grid.bin"
assert target.read_bytes() == sampler_py.dtm_grid_bytes(synthetic_npz)
stamp = target.stat().st_mtime_ns
assert sampler_py.dtm_grid_file(synthetic_npz).stat().st_mtime_ns == stamp # 그대로 씀
later = time.time() + 10
os.utime(synthetic_npz, (later, later))
assert sampler_py.dtm_grid_file(synthetic_npz).stat().st_mtime_ns != stamp
def test_짝_머리글이_서로를_가리킨다() -> None:
assert "common_util_surface_sampler.py" in TS_FILE.read_text(encoding="utf-8")
assert "common_util_surface_sampler.ts" in (sampler_py.__doc__ or "")
@@ -0,0 +1,116 @@
"""PLAN 344-1 — 격자 통로(`/surface/models/{mid}/grid`) · 받을 자리(`terrain-sources` 의 `grid`).
DTM 만 격자 · 평활 고르기 = 서버 표본기 · 첫 요청 때 `.bin` 저장본 · ETag 304 · DTM 아닌 표현 404 ·
주소 서명 = manifest 구성 · 평활 서명.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
import numpy as np
from starlette.requests import Request
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
from common_util.common_util_surface_sampler import dtm_grid_bytes
PID = "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
PARAMS = {"source_filter": "csf", "method": "dtm", "smooth": True, "contour_interval_m": 1.0}
def _dtm(path: Path, z: float) -> None:
x = np.arange(0.0, 11.0, dtype=np.float32)
np.savez(
path,
x=x,
y=x,
z=np.full((11, 11), z, dtype=np.float32),
valid_mask=np.ones((11, 11), dtype=bool),
)
def _project(tmp_path: Path) -> Path:
models = tmp_path / "B04_PreProcess" / "models"
models.mkdir(parents=True)
_dtm(models / "dtm_csf.npz", 100.0)
_dtm(models / "dtm_csf_smooth.npz", 101.0)
manifest = {
"config_signature": "cfg1",
"source_filters": {"csf": {"methods": {"dtm": {"smooth": {"smoothing_signature": "sm1"}}}}},
}
(models / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
return tmp_path
def _patch(monkeypatch, root: Path, row: tuple | None) -> None:
class _Cursor:
async def execute(self, *_args):
return None
async def fetchone(self):
return row
class _Connection:
@asynccontextmanager
async def cursor(self):
yield _Cursor()
class _Pool:
@asynccontextmanager
async def acquire(self):
yield _Connection()
async def stored(_connection, _project_id):
return "x"
monkeypatch.setattr(terrain_router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(terrain_router, "get_project_storage_relative_path", stored)
monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: root)
def _request(etag: str | None = None) -> Request:
headers = [(b"if-none-match", etag.encode())] if etag else []
return Request({"type": "http", "method": "GET", "path": "/", "headers": headers})
def _grid(smooth: bool, etag: str | None = None):
return asyncio.run(terrain_router.read_surface_grid(_request(etag), PID, 7, smooth))
def test_평활_격자를_저장본으로_내고_같은_ETag_면_304(tmp_path, monkeypatch):
root = _project(tmp_path)
models = root / "B04_PreProcess" / "models"
_patch(monkeypatch, root, ("dtm", "B04_PreProcess/models/dtm_csf.npz"))
response = _grid(True)
assert response.status_code == 200
saved = models / "dtm_csf_smooth_grid.bin"
assert Path(response.path) == saved
assert saved.read_bytes() == dtm_grid_bytes(models / "dtm_csf_smooth.npz")
assert _grid(True, response.headers["etag"]).status_code == 304
assert Path(_grid(False).path).name == "dtm_csf_grid.bin"
def test_DTM_아닌_표현은_404(tmp_path, monkeypatch):
root = _project(tmp_path)
_patch(monkeypatch, root, ("tin", "B04_PreProcess/models/tin_csf.npz"))
assert _grid(True).status_code == 404
_patch(monkeypatch, root, None)
assert _grid(True).status_code == 404
def test_받을_자리_서명은_manifest_구성과_평활(tmp_path):
models = _project(tmp_path) / "B04_PreProcess" / "models"
confirmed = (7, "dtm", "B04_PreProcess/models/dtm_csf.npz")
grid = terrain_router._design_grid(PARAMS, models, confirmed)
assert grid == {"model_id": 7, "smooth": True, "signature": "cfg1.sm1"}
raw = terrain_router._design_grid({**PARAMS, "smooth": False}, models, confirmed)
assert raw["signature"] == "cfg1."
# 서버 표본기와 다른 파일 · DTM 아닌 표현 · 확정 없음 = 격자 안 냄(서버 표본 길)
assert (
terrain_router._design_grid({**PARAMS, "source_filter": "pmf"}, models, confirmed) is None
)
assert terrain_router._design_grid({**PARAMS, "method": "tin"}, models, confirmed) is None
assert terrain_router._design_grid(PARAMS, models, None) is None