Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user