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:
2026-10-16 11:28:18 +09:00
co-authored by Claude Opus 5.5
parent e26568e997
commit f8d44cd8e9
3 changed files with 507 additions and 16 deletions
@@ -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)}")