feat(계산묶음): 346-1 route · ground 단계 — 노선 초안 → 그린 노선(buildEditedPolyline 짝 그대로 · 길이 붙든 곡선 R 다시 잡음) · 측점 → 지반 채움 · 종단 지반선(계약 꼴) · 범위 밖 측점 · 종단 재사용(자리 · 축 · 지형 같을 때만) · 격자 없음 · 분석 지형 다름 = 주입 서버 길 · 시험(서버 generate_sections 와 같음 · 노선 1,078 m 1.4~6.9 ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
"""PLAN 346-1 — 계산 묶음 route · ground 단계(`logic/B05_Profile_Pipeline_RouteGround.ts`).
|
||||
|
||||
route = 노선 초안 → 그린 노선(서버 `build_planned_polyline` 편집 갈래와 같은 선).
|
||||
ground = 측점 → 지반 채움 · 종단 지반선(서버 `generate_sections` 와 같은 표본).
|
||||
격자 없음 · 분석 지형 다름 = 서버 길 · 바뀐 범위 밖 재사용 · 노선 1,078 m 한 번 시간.
|
||||
"""
|
||||
|
||||
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 common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
|
||||
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_Pipeline_RouteGround.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"
|
||||
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_initial_nodes.csv"
|
||||
_TOL = 1.5e-6
|
||||
MIN_RADIUS_M = 12.0
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { parseDtmGrid, heightAt } from "./common_util/common_util_surface_sampler.js";
|
||||
import { placeStations } from "./B05_Profile/logic/B05_Profile_Sections_Ground.js";
|
||||
import {
|
||||
createRouteStage, createGroundStage,
|
||||
} from "./B05_Profile/logic/B05_Profile_Pipeline_RouteGround.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 n = job.nodes.length;
|
||||
const draft = {
|
||||
rev: 1, nodes: job.nodes, curveOn: Array(n).fill(true), curveRadius: Array(n).fill(null),
|
||||
curveLock: Array(n).fill(null), curveArc: Array(n).fill(null), stationSnap: false, terrain: null,
|
||||
};
|
||||
const routeStage = createRouteStage({ minRadiusM: () => job.minRadius, savedLine: () => job.line });
|
||||
const ctxOf = (extra) => ({
|
||||
generation: 1, range: null, signal: new AbortController().signal,
|
||||
inputs: { route: null, alignment: null, cross: null, pipes: null, structures: null },
|
||||
out: {}, before: {}, prev: null, ...extra,
|
||||
});
|
||||
const route = routeStage(ctxOf({ inputs: { route: draft } }));
|
||||
const saved = routeStage(ctxOf({}));
|
||||
let noRoute = null;
|
||||
try { createRouteStage({ minRadiusM: () => 12, savedLine: () => null })(ctxOf({})); }
|
||||
catch (error) { noRoute = String(error.message); }
|
||||
|
||||
const line = job.line;
|
||||
const stations = placeStations(line, job.stations).map((s, i) => ({
|
||||
key: `k${i}`, chainageM: s.chainage, x: s.origin[0], y: s.origin[1], azimuthDeg: 0,
|
||||
left: s.left, kind: "grid", id: null, ground: null,
|
||||
planRadiusM: null, curveOuterSide: null, curveWideningM: null,
|
||||
}));
|
||||
const lineOut = { line, chainage: [], lengthM: 0 };
|
||||
let remoteCalls = [];
|
||||
const remote = async (points, terrain) => {
|
||||
remoteCalls.push({ count: points.length, terrain });
|
||||
return points.map(([x, y]) => {
|
||||
const z = heightAt(grid, x, y);
|
||||
return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000;
|
||||
});
|
||||
};
|
||||
let ready = null;
|
||||
const ground = createGroundStage({ grid: () => ready, remote });
|
||||
const run = (extra) => ground(ctxOf({ out: { route: lineOut, stations }, ...extra }));
|
||||
|
||||
const out = { route, saved, noRoute };
|
||||
out.serverPath = await run({}); // 격자 받기 전 = 서버 길
|
||||
out.serverCalls = remoteCalls; remoteCalls = [];
|
||||
ready = { grid, design: "las" };
|
||||
out.gridPath = await run({}); // 받은 뒤 = 브라우저 길
|
||||
out.gridCalls = remoteCalls.length;
|
||||
out.sheetPath = await run({ inputs: { route: { ...draft, terrain: "sheet" } } });
|
||||
out.sheetCalls = remoteCalls; remoteCalls = [];
|
||||
|
||||
// 범위 밖 재사용 — 서버 길로 앞 출력을 받고 범위 [fromM, ∞) 만 다시
|
||||
ready = null;
|
||||
const reuseStage = createGroundStage({ grid: () => ready, remote });
|
||||
const first = await reuseStage(ctxOf({ out: { route: lineOut, stations } }));
|
||||
remoteCalls = [];
|
||||
const moved = stations.map((s, i) => (i === 1 ? { ...s, x: s.x + 0.5 } : s));
|
||||
const second = await reuseStage(ctxOf({
|
||||
out: { route: lineOut, stations: moved }, prev: first,
|
||||
range: { sources: ["grade"], fromM: job.fromM, toM: Infinity, keys: [] },
|
||||
}));
|
||||
out.reuseCalls = remoteCalls;
|
||||
out.reuse = second;
|
||||
ready = { grid, design: "las" };
|
||||
out.movedFull = await createGroundStage({ grid: () => ready, remote })(
|
||||
ctxOf({ out: { route: lineOut, stations: moved } }));
|
||||
|
||||
const timing = createGroundStage({ grid: () => ready, remote });
|
||||
for (let i = 0; i < 5; i += 1) await timing(ctxOf({ out: { route: lineOut, stations } }));
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < 20; i += 1) await timing(ctxOf({ out: { route: lineOut, stations } }));
|
||||
out.ms = (performance.now() - t0) / 20;
|
||||
writeFileSync(process.argv[4], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
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 _read_xy(path: Path) -> list[list[float]]:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
return [[float(row["x"]), float(row["y"])] for row in csv.DictReader(handle)]
|
||||
|
||||
|
||||
def _contract_rows(station: dict) -> list[tuple[float, float | None]]:
|
||||
"""계약 꼴(중심 · 좌 · 우) → 서버 꼴 (offset, z) 오름차순."""
|
||||
ground = station["ground"]
|
||||
rows = [(-s["offsetM"], s["z"]) for s in ground["right"]]
|
||||
rows += [(0.0, ground["centerZ"])] + [(s["offsetM"], s["z"]) for s in ground["left"]]
|
||||
return sorted(rows)
|
||||
|
||||
|
||||
def _close(a, b, tol: float) -> bool:
|
||||
return (a is None and b is None) or (
|
||||
a is not None and b is not None and abs(float(a) - float(b)) <= tol
|
||||
)
|
||||
|
||||
|
||||
def _fake_case(tmp_path: Path) -> tuple[Path, list, list, float]:
|
||||
"""가짜 격자 — 칸 폭 고르지 않음 · 빈칸 띠 · 노선이 빈칸 · 격자 끝을 지나감."""
|
||||
rng = np.random.default_rng(3461)
|
||||
x = (np.cumsum(rng.uniform(0.95, 1.1, 260)) + 1000.0).astype(np.float32)
|
||||
y = (np.cumsum(rng.uniform(0.95, 1.1, 140)) + 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)
|
||||
mask = np.ones(z.shape, dtype=bool)
|
||||
mask[60:70, 100:115] = False
|
||||
npz = tmp_path / "dtm_fake.npz"
|
||||
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=mask)
|
||||
nodes = [[1010.0, 2020.0], [1090.0, 2040.0], [1150.0, 2110.0], [1250.0, 2135.0]]
|
||||
built = build_planned_polyline(
|
||||
[tuple(n) for n in nodes],
|
||||
min_radius_m=MIN_RADIUS_M,
|
||||
simplify=False,
|
||||
curve_flags=[True] * 4,
|
||||
radii=[None] * 4,
|
||||
)
|
||||
return npz, [list(v) for v in built.vertices], nodes, 120.0
|
||||
|
||||
|
||||
def _real_case(_tmp_path: Path) -> tuple[Path, list, list, float]:
|
||||
if not (REAL_GRID.is_file() and REAL_ROUTE.is_file() and REAL_NODES.is_file()):
|
||||
pytest.skip("검증_sub8 없음")
|
||||
return REAL_GRID, _read_xy(REAL_ROUTE), _read_xy(REAL_NODES), 600.0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
@pytest.mark.parametrize("case", [_fake_case, _real_case], ids=["가짜", "검증_sub8"])
|
||||
def test_route_ground_단계가_서버와_같고_범위_밖을_재사용한다(tmp_path: Path, case) -> None:
|
||||
npz, line, nodes, from_m = case(tmp_path)
|
||||
expected = core.generate_sections(
|
||||
np.asarray(line, dtype=np.float64), DtmGridSampler.from_npz(npz)
|
||||
)
|
||||
stations = [s["chainage_m"] for s in expected["longitudinal"]["stations"]]
|
||||
job = {
|
||||
"line": line,
|
||||
"nodes": nodes,
|
||||
"minRadius": MIN_RADIUS_M,
|
||||
"stations": stations,
|
||||
"fromM": from_m,
|
||||
}
|
||||
out = _run_ts(tmp_path, npz, job)
|
||||
|
||||
# route — 서버 편집 갈래와 같은 선 · 누가거리 · 저장분 대체 · 노선 없음
|
||||
built = build_planned_polyline(
|
||||
[tuple(node) for node in nodes],
|
||||
min_radius_m=MIN_RADIUS_M,
|
||||
simplify=False,
|
||||
curve_flags=[True] * len(nodes),
|
||||
radii=[None] * len(nodes),
|
||||
)
|
||||
route = out["route"]
|
||||
assert len(route["line"]) == len(built.vertices)
|
||||
assert (
|
||||
max(
|
||||
abs(a - b)
|
||||
for p, q in zip(route["line"], built.vertices, strict=True)
|
||||
for a, b in zip(p, q)
|
||||
)
|
||||
< 1e-9
|
||||
)
|
||||
steps = np.hypot(*np.diff(np.asarray(built.vertices), axis=0).T)
|
||||
assert abs(route["lengthM"] - float(steps.sum())) < 1e-6
|
||||
assert route["chainage"][0] == 0 and len(route["chainage"]) == len(route["line"])
|
||||
assert out["saved"]["line"] == line
|
||||
assert out["noRoute"]
|
||||
|
||||
# ground 브라우저 길 = 서버 generate_sections(측점 · 횡단 · 종단)
|
||||
grid_path = out["gridPath"]
|
||||
assert out["gridCalls"] == 0
|
||||
assert len(grid_path["stations"]) == len(expected["cross_sections"])
|
||||
problems: list[str] = []
|
||||
for ts, py in zip(grid_path["stations"], expected["cross_sections"], strict=True):
|
||||
rows = _contract_rows(ts)
|
||||
assert len(rows) == len(py["samples"])
|
||||
for (offset, z), sample in zip(rows, py["samples"], strict=True):
|
||||
if not (
|
||||
_close(offset, sample["offset_m"], _TOL)
|
||||
and _close(z, sample["z"] if sample["valid"] else None, _TOL)
|
||||
):
|
||||
problems.append(f"측점 {py['chainage_m']} offset {sample['offset_m']}: {z}")
|
||||
if not _close(ts["ground"]["centerZ"], py["center_z"], _TOL):
|
||||
problems.append(f"측점 {py['chainage_m']} 중심 {ts['ground']['centerZ']}")
|
||||
long_py = expected["longitudinal"]["samples"]
|
||||
long_ts = grid_path["longitudinal"]
|
||||
assert len(long_ts["z"]) == len(long_py)
|
||||
for c, z, sample in zip(long_ts["chainageM"], long_ts["z"], long_py, strict=True):
|
||||
if not (_close(c, sample["chainage_m"], _TOL) and _close(z, sample["z"], _TOL)):
|
||||
problems.append(f"종단 {sample['chainage_m']}: {z}")
|
||||
assert not problems, "\n".join(problems[:30])
|
||||
|
||||
# 서버 길 — 격자 받기 전 · 분석 지형 다름 · 값은 mm 자리로 같음
|
||||
count = len(stations) * 81 + len(long_py)
|
||||
assert out["serverCalls"] == [{"count": count, "terrain": None}]
|
||||
assert out["sheetCalls"] == [{"count": count, "terrain": "sheet"}]
|
||||
for ts, gs in zip(out["serverPath"]["stations"], grid_path["stations"], strict=True):
|
||||
for (_o1, a), (_o2, b) in zip(_contract_rows(ts), _contract_rows(gs), strict=True):
|
||||
assert _close(a, b, 5e-4)
|
||||
|
||||
# 범위 밖 재사용 — 다시 묻는 것 = 범위 안 측점 + 범위 밖이어도 자리가 바뀐 측점 하나
|
||||
again = sum(1 for c in stations if c >= from_m) + 1
|
||||
assert out["reuseCalls"] == [{"count": again * 81, "terrain": None}]
|
||||
for ts, full in zip(out["reuse"]["stations"], out["movedFull"]["stations"], strict=True):
|
||||
for (_o1, a), (_o2, b) in zip(_contract_rows(ts), _contract_rows(full), strict=True):
|
||||
assert _close(a, b, 5e-4), f"재사용 값 다름 {ts['chainageM']}"
|
||||
assert out["reuse"]["longitudinal"]["z"] == out["serverPath"]["longitudinal"]["z"]
|
||||
|
||||
# 한 번 전체 ground(검증_sub8 = 노선 1,078 m · 측점 55) — 0.3 s 예산 안 넉넉히
|
||||
assert out["ms"] < 50, f"ground 단계 {out['ms']:.1f} ms"
|
||||
print(f"ground 단계 {out['ms']:.2f} ms · 측점 {len(stations)} · 종단 {len(long_py)}")
|
||||
Reference in New Issue
Block a user