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,179 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Pipeline_RouteGround.ts
|
||||
* 실시간 계산 묶음(PLAN 346-1)의 **route · ground 단계** — 계약 = `design_pipeline_types.ts`.
|
||||
*
|
||||
* route — 노선 초안(RouteDraft) → 그린 노선(원호 포함) · 누가거리. 선은 이미 있는 짝
|
||||
* `buildEditedPolyline`(↔ `common_util_route_polyline.py`) 그대로.
|
||||
* ground — 측점(지반 null) → 지반 채운 측점 · 종단 지반선(1 m). 표본 = 지형 격자 짝
|
||||
* (`common_util_surface_sampler.ts` · `B05_Profile_Sections_Ground.ts`) · 격자가 없거나
|
||||
* (아직 못 받음 · DTM 아님) 분석 지형 ≠ 설계 지형이면 주입받은 서버 표본(`remote`).
|
||||
* 바뀐 범위 밖 측점 · 종단은 앞 출력 재사용(국부 연산) — 자리 · 축 · 지형이 같을 때만.
|
||||
*
|
||||
* 화면 · fetch · 세션 안 씀 — 격자 · 서버 길은 부르는 쪽이 넣음(`B05_Profile_Api_TerrainGrid.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
GroundOutput,
|
||||
GroundSample,
|
||||
PipelineStation,
|
||||
RouteDraft,
|
||||
RouteLine,
|
||||
StageFn,
|
||||
StationGround,
|
||||
Vertex,
|
||||
} from "../../A00_Common/design/design_pipeline_types";
|
||||
import { type DtmGrid, heightAt } from "../../common_util/common_util_surface_sampler";
|
||||
import { buildEditedPolyline } from "../B05_Profile_UI_RouteEdit_Curve";
|
||||
import { applyArcLocks } from "../B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
crossOffsets,
|
||||
longitudinalPoints,
|
||||
round6,
|
||||
SECTION_CROSS_HALF_WIDTH_M,
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M,
|
||||
} from "./B05_Profile_Sections_Ground";
|
||||
|
||||
/* ── route ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface RouteStageDeps {
|
||||
/** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */
|
||||
minRadiusM(): number;
|
||||
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 — 없으면 null. */
|
||||
savedLine(): Vertex[] | null;
|
||||
}
|
||||
|
||||
/** 정점마다 누가거리 — 첫 점 0. */
|
||||
export function routeLineOf(line: Vertex[]): RouteLine {
|
||||
const chainage = [0];
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
chainage.push(
|
||||
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
|
||||
);
|
||||
}
|
||||
return { line, chainage, lengthM: chainage[chainage.length - 1] };
|
||||
}
|
||||
|
||||
/** 초안 → 그린 노선. 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 `markEdited` 와 같은 차례). */
|
||||
export function draftLine(draft: RouteDraft, minRadiusM: number): Vertex[] {
|
||||
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
|
||||
const radius = [...draft.curveRadius];
|
||||
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
|
||||
return buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM).vertices;
|
||||
}
|
||||
|
||||
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
|
||||
return (ctx) => {
|
||||
const draft = ctx.inputs.route;
|
||||
const line = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
|
||||
if (!line || line.length < 2) throw new Error("그릴 노선이 없습니다.");
|
||||
return routeLineOf(line);
|
||||
};
|
||||
}
|
||||
|
||||
/* ── ground ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface GroundStageDeps {
|
||||
/** 지금 쓸 수 있는 설계 지형 격자 — 아직 못 받았거나 없으면 null(그동안은 서버 길). */
|
||||
grid(): { grid: DtmGrid; design: "las" | "sheet" } | null;
|
||||
/** 서버 표본(`/route/elevations`) — 점 차례 그대로 · 무효 null · `signal` 로 끊김. */
|
||||
remote(
|
||||
points: Vertex[],
|
||||
terrain: string | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<number | null>>;
|
||||
/** 횡단 반폭(m) — 비면 서버 기본(20 m). */
|
||||
crossHalfWidthM?(): number | null;
|
||||
}
|
||||
|
||||
const samePlace = (a: PipelineStation, b: PipelineStation): boolean =>
|
||||
a.x === b.x && a.y === b.y && a.left[0] === b.left[0] && a.left[1] === b.left[1];
|
||||
|
||||
const outside = (chainageM: number, range: { fromM: number; toM: number } | null): boolean =>
|
||||
range !== null && (chainageM < range.fromM || chainageM > range.toM);
|
||||
|
||||
export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> {
|
||||
/** 앞 출력이 쓴 지형 · 노선 · 반폭 — 다르면 재사용 안 함. */
|
||||
let last: { terrain: string; line: Vertex[] | null; halfWidth: number } | null = null;
|
||||
|
||||
return async (ctx) => {
|
||||
const stations = ctx.out.stations ?? [];
|
||||
const line = ctx.out.route?.line ?? null;
|
||||
const terrain = ctx.inputs.route?.terrain ?? null;
|
||||
const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M;
|
||||
const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_M);
|
||||
const local = deps.grid();
|
||||
const useGrid = local !== null && (terrain === null || terrain === local.design);
|
||||
|
||||
const tag = terrain ?? "design";
|
||||
const reusable = ctx.prev !== null && last?.terrain === tag && last.halfWidth === halfWidth;
|
||||
const range = ctx.range;
|
||||
const prevByKey = new Map((reusable ? ctx.prev!.stations : []).map((s) => [s.key, s]));
|
||||
|
||||
// 다시 셀 측점 — 범위 안 · 앞 출력에 없음 · 자리나 축이 바뀜.
|
||||
const kept = stations.map((station) => {
|
||||
const before = prevByKey.get(station.key);
|
||||
return before?.ground && outside(station.chainageM, range) && samePlace(before, station)
|
||||
? before.ground
|
||||
: null;
|
||||
});
|
||||
|
||||
const long = line ? longitudinalPoints(line) : { chainage: [], xy: [] as Vertex[], lengthM: 0 };
|
||||
// 종단 — 노선이 그대로면 통째 · 범위 앞쪽은 같은 노선 위라 그대로.
|
||||
const prevLong = reusable ? ctx.prev!.longitudinal : null;
|
||||
const longKept: Array<number | null | undefined> = long.chainage.map((c, i) => {
|
||||
if (!prevLong || prevLong.chainageM[i] !== round6(c)) return undefined;
|
||||
if (last?.line === line || (range !== null && c < range.fromM)) return prevLong.z[i];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const points: Vertex[] = [];
|
||||
stations.forEach((station, i) => {
|
||||
if (kept[i]) return;
|
||||
for (const offset of offsets) {
|
||||
points.push([station.x + station.left[0] * offset, station.y + station.left[1] * offset]);
|
||||
}
|
||||
});
|
||||
long.xy.forEach((xy, i) => {
|
||||
if (longKept[i] === undefined) points.push(xy);
|
||||
});
|
||||
|
||||
let heights: Array<number | null>;
|
||||
if (useGrid) {
|
||||
heights = points.map(([x, y]) => {
|
||||
const z = heightAt(local!.grid, x, y);
|
||||
return Number.isNaN(z) ? null : round6(z);
|
||||
});
|
||||
} else {
|
||||
heights = points.length ? await deps.remote(points, terrain, ctx.signal) : [];
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const filled: PipelineStation[] = stations.map((station, i) => {
|
||||
if (kept[i]) return { ...station, ground: kept[i] };
|
||||
const zs = offsets.map(() => heights[cursor++] ?? null);
|
||||
return { ...station, ground: stationGround(offsets, zs) };
|
||||
});
|
||||
const z = long.chainage.map((_, i) =>
|
||||
longKept[i] === undefined ? (heights[cursor++] ?? null) : (longKept[i] as number | null),
|
||||
);
|
||||
last = { terrain: tag, line, halfWidth };
|
||||
const output: GroundOutput = {
|
||||
stations: filled,
|
||||
longitudinal: { chainageM: long.chainage.map(round6), z },
|
||||
};
|
||||
return output;
|
||||
};
|
||||
}
|
||||
|
||||
/** offset 열 표고 → 계약 꼴(중심 · 좌 · 우 · 중심에서 바깥 차례 · offsetM 양수). */
|
||||
function stationGround(offsets: number[], zs: Array<number | null>): StationGround {
|
||||
const left: GroundSample[] = [];
|
||||
const right: GroundSample[] = [];
|
||||
let centerZ: number | null = null;
|
||||
offsets.forEach((offset, i) => {
|
||||
if (Math.abs(offset) <= 1e-9) centerZ = zs[i];
|
||||
else if (offset > 0) left.push({ offsetM: round6(offset), z: zs[i] });
|
||||
else right.unshift({ offsetM: round6(-offset), z: zs[i] });
|
||||
});
|
||||
return { centerZ, left, right };
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export interface GroundOptions {
|
||||
}
|
||||
|
||||
/** 파이썬 `round(x, 6)` 자리. */
|
||||
const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
|
||||
export const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
|
||||
const round9 = (value: number): number => Math.round(value * 1e9) / 1e9;
|
||||
|
||||
function sample(grid: DtmGrid, x: number, y: number): GroundSample {
|
||||
@@ -100,7 +100,7 @@ function cleanLine(line: ReadonlyArray<Vertex>): {
|
||||
}
|
||||
|
||||
/** 횡단 offset 열 — `np.arange(-hw, hw + si/2, si)` · hw 넘는 것 버림 · 0 없으면 끼움. */
|
||||
function crossOffsets(halfWidth: number, interval: number): number[] {
|
||||
export function crossOffsets(halfWidth: number, interval: number): number[] {
|
||||
const count = Math.ceil((halfWidth + interval * 0.5 + halfWidth) / interval);
|
||||
const offsets: number[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
@@ -114,6 +114,33 @@ function crossOffsets(halfWidth: number, interval: number): number[] {
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/** 종단 표본 자리 — 누가거리(간격 · 끝점 포함) · 자리 = `_interpolate_xy`(축마다 `np.interp`). */
|
||||
export function longitudinalPoints(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
intervalM: number = SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
): { chainage: number[]; xy: Vertex[]; lengthM: number } {
|
||||
const { xs, ys, chainage: along } = cleanLine(line);
|
||||
const chainage = stationsAlong(line, intervalM, true);
|
||||
const xy = chainage.map((c): Vertex => [npInterp(c, along, xs), npInterp(c, along, ys)]);
|
||||
return { chainage, xy, lengthM: along[along.length - 1] ?? 0 };
|
||||
}
|
||||
|
||||
/** 측점 자리 · 좌측 축 — 자리 = `_interpolate_xy` · 축 = 측점 틀 짝(`stationFrameAt`). 못 재면 뺌. */
|
||||
export function placeStations(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
stationsM: ReadonlyArray<number>,
|
||||
longIntervalM: number = SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
): Array<{ chainage: number; origin: Vertex; left: Vertex }> {
|
||||
const { xs, ys, chainage: along } = cleanLine(line);
|
||||
const probe = Math.max(longIntervalM, 0.5);
|
||||
return stationsM.flatMap((chainage) => {
|
||||
const frame = stationFrameAt(line, chainage, probe);
|
||||
if (!frame) return [];
|
||||
const origin: Vertex = [npInterp(chainage, along, xs), npInterp(chainage, along, ys)];
|
||||
return [{ chainage, origin, left: frame.left }];
|
||||
});
|
||||
}
|
||||
|
||||
/** 노선 지반선 — 종단 표본 · 측점마다 중심 · 좌측 축 · 횡단 표본. `stationsM` = 측점 누가거리. */
|
||||
export function sectionsGround(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
@@ -124,15 +151,12 @@ export function sectionsGround(
|
||||
const halfWidth = options.crossHalfWidthM ?? SECTION_CROSS_HALF_WIDTH_M;
|
||||
const interval = options.crossSampleIntervalM ?? SECTION_CROSS_SAMPLE_INTERVAL_M;
|
||||
const longInterval = options.longSampleIntervalM ?? SECTION_LONG_SAMPLE_INTERVAL_M;
|
||||
const probe = Math.max(longInterval, 0.5);
|
||||
|
||||
// 자리 = `_interpolate_xy`(축마다 `np.interp`) · 좌측 축만 측점 틀 짝(`stationFrameAt`).
|
||||
const { xs, ys, chainage: along } = cleanLine(line);
|
||||
const at = (c: number): Vertex => [npInterp(c, along, xs), npInterp(c, along, ys)];
|
||||
const samples: LongGroundSample[] = stationsAlong(line, longInterval, true).map((chainage) => {
|
||||
const [x, y] = at(chainage);
|
||||
return { chainage_m: round6(chainage), ...sample(grid, x, y) };
|
||||
});
|
||||
const long = longitudinalPoints(line, longInterval);
|
||||
const samples: LongGroundSample[] = long.chainage.map((chainage, i) => ({
|
||||
chainage_m: round6(chainage),
|
||||
...sample(grid, long.xy[i][0], long.xy[i][1]),
|
||||
}));
|
||||
|
||||
const offsets = crossOffsets(halfWidth, interval);
|
||||
let center = 0;
|
||||
@@ -141,11 +165,9 @@ export function sectionsGround(
|
||||
});
|
||||
|
||||
const stations: StationGround[] = [];
|
||||
for (const chainage of stationsM) {
|
||||
const frame = stationFrameAt(line, chainage, probe);
|
||||
if (!frame) continue;
|
||||
const [ox, oy] = at(chainage);
|
||||
const [lx, ly] = frame.left;
|
||||
for (const { chainage, origin, left } of placeStations(line, stationsM, longInterval)) {
|
||||
const [ox, oy] = origin;
|
||||
const [lx, ly] = left;
|
||||
const cross: CrossGroundPoint[] = [];
|
||||
const raw: number[] = [];
|
||||
for (const offset of offsets) {
|
||||
@@ -176,7 +198,7 @@ export function sectionsGround(
|
||||
samples: cross,
|
||||
});
|
||||
}
|
||||
return { length_m: round6(along[along.length - 1] ?? 0), samples, stations };
|
||||
return { length_m: round6(long.lengthM), samples, stations };
|
||||
}
|
||||
|
||||
/** `np.interp(x, xp, fp)` — xp 오름차순 · 범위 밖은 끝값 · 식 차례도 numpy 와 같음. */
|
||||
|
||||
@@ -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