feat(지형표본): 344-1 횡단 지반선 생성 짝 — B05 logic sectionsGround(종단 1 m · 측점 중심 · 횡단 ±20 m 0.5 m · 상단측) · groundProfile · groundAt(측점 지반고) · extendUnclosed(미교차 덧붙임) · 두 쪽 머리 짝 표시 · 거울 시험(서버 생성 · 저장된 종 · 횡단과 같음 · 노선 1,078 m 3.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:14:46 +09:00
co-authored by Claude Opus 5.5
parent e8c6cfd1ea
commit 51cebefd40
4 changed files with 589 additions and 0 deletions
@@ -9,6 +9,7 @@
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
`resources/tester/test_224_2_station_snap.py`.
⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`.
"""
import math
@@ -0,0 +1,311 @@
/* =============================================================================
* B05_Profile_Sections_Ground.ts
* 노선 한 벌의 **지반선** — 종단 1 m 표본 · 측점 지반고 · 횡단 ±20 m 0.5 m · 미교차 덧붙임.
* 노선을 고치는 동안 브라우저가 지형 격자(`common_util_surface_sampler.ts`)로 바로 셈한다(PLAN 344-1).
*
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `generate_sections` 의 지반 부분
* (종단 표본 · 측점 중심 · 횡단 표본 · `uphill_side`) ↔ `sectionsGround`.
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Grade.py` `ground_profile` + 측점 `np.interp` ↔
* `groundProfile` · `groundAt`.
* ⚠ 파이썬·TS 짝 — `B06_Section_Engine_SampleExtend.py` `extend_unclosed` ↔ `extendUnclosed`.
* 한쪽을 고치면 다른 쪽도 고치고 거울 시험 `resources/tester/test_344_1_sections_ground_mirror.py`.
*
* 측점 자리(규칙 · 스냅 · 구조물 측점 병합)는 부르는 쪽이 정해 넘김 — 틀은
* `B05_Profile_UI_RouteEdit_Frame.ts`(이미 짝). 자릿수는 서버 출력과 같게 소수 6자리.
* ========================================================================== */
import { type DtmGrid, heightAt } from "../../common_util/common_util_surface_sampler";
import type { CrossDesignEdge } from "../../common_util/common_util_cross_design";
import { stationFrameAt, stationsAlong } from "../B05_Profile_UI_RouteEdit_Frame";
type Vertex = [number, number];
/** 짝: `config_system_design.py` `SECTION_CROSS_HALF_WIDTH_M` · `_SAMPLE_INTERVAL_M` · `SECTION_LONG_…`. */
export const SECTION_CROSS_HALF_WIDTH_M = 20.0;
export const SECTION_CROSS_SAMPLE_INTERVAL_M = 0.5;
export const SECTION_LONG_SAMPLE_INTERVAL_M = 1.0;
/** 짝: `B06_Section_Engine_SampleExtend.EXTEND_STEP_M`. */
export const EXTEND_STEP_M = 5.0;
/** 짝: `B06_Section_Engine_Design._SLOPE_CLOSE_TOLERANCE_M`. */
const SLOPE_CLOSE_TOLERANCE_M = 0.01;
export interface GroundSample {
x: number;
y: number;
z: number | null;
elevation_m: number | null;
valid: boolean;
}
export interface LongGroundSample extends GroundSample {
chainage_m: number;
}
export interface CrossGroundPoint extends GroundSample {
offset_m: number;
}
export interface StationGround {
chainage_m: number;
center_x: number;
center_y: number;
center_z: number | null;
tangent_xy: Vertex;
/** 좌측(+offset) 단위 벡터. */
left_xy: Vertex;
/** 횡단 기준 등고가 높은 쪽 — 좌우 유효 표본 평균 비교 · 모르면 null. */
uphill_side: "left" | "right" | null;
samples: CrossGroundPoint[];
}
export interface SectionsGround {
length_m: number;
/** 종단 지반 표본(1 m · 끝점 포함). */
samples: LongGroundSample[];
stations: StationGround[];
}
export interface GroundOptions {
crossHalfWidthM?: number;
crossSampleIntervalM?: number;
longSampleIntervalM?: number;
}
/** 파이썬 `round(x, 6)` 자리. */
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 {
const z = heightAt(grid, x, y);
const valid = !Number.isNaN(z);
const value = valid ? round6(z) : null;
return { x: round6(x), y: round6(y), z: value, elevation_m: value, valid };
}
/** `_clean_polyline` — 앞 점과 길이 0(≤ 1e-8)인 점을 버리고 누가거리. */
function cleanLine(line: ReadonlyArray<Vertex>): {
xs: number[];
ys: number[];
chainage: number[];
} {
const kept = line.filter(
(vertex, i) =>
i === 0 || Math.hypot(vertex[0] - line[i - 1][0], vertex[1] - line[i - 1][1]) > 1e-8,
);
const chainage = [0];
for (let i = 1; i < kept.length; i += 1) {
chainage.push(
chainage[i - 1] + Math.hypot(kept[i][0] - kept[i - 1][0], kept[i][1] - kept[i - 1][1]),
);
}
return { xs: kept.map((v) => v[0]), ys: kept.map((v) => v[1]), chainage };
}
/** 횡단 offset 열 — `np.arange(-hw, hw + si/2, si)` · hw 넘는 것 버림 · 0 없으면 끼움. */
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) {
const value = -halfWidth + i * interval;
if (value <= halfWidth + 1e-9) offsets.push(value);
}
if (!offsets.some((value) => Math.abs(value) <= 1e-9)) {
offsets.push(0);
offsets.sort((a, b) => a - b);
}
return offsets;
}
/** 노선 지반선 — 종단 표본 · 측점마다 중심 · 좌측 축 · 횡단 표본. `stationsM` = 측점 누가거리. */
export function sectionsGround(
line: ReadonlyArray<Vertex>,
stationsM: ReadonlyArray<number>,
grid: DtmGrid,
options: GroundOptions = {},
): 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 offsets = crossOffsets(halfWidth, interval);
let center = 0;
offsets.forEach((value, index) => {
if (Math.abs(value) < Math.abs(offsets[center])) center = index;
});
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;
const cross: CrossGroundPoint[] = [];
const raw: number[] = [];
for (const offset of offsets) {
const x = ox + lx * offset;
const y = oy + ly * offset;
raw.push(heightAt(grid, x, y));
cross.push({ offset_m: round6(offset), ...sample(grid, x, y) });
}
const mean = (pick: (offset: number) => boolean): number | null => {
const values = raw.filter((z, i) => pick(offsets[i]) && !Number.isNaN(z));
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null;
};
const leftMean = mean((offset) => offset > 1e-9);
const rightMean = mean((offset) => offset < -1e-9);
let uphill: StationGround["uphill_side"] = null;
if (leftMean !== null && rightMean !== null && Math.abs(leftMean - rightMean) > 1e-6) {
uphill = leftMean > rightMean ? "left" : "right";
}
const centerZ = raw[center];
stations.push({
chainage_m: round6(chainage),
center_x: round6(ox),
center_y: round6(oy),
center_z: Number.isNaN(centerZ) ? null : round6(centerZ),
tangent_xy: [round9(ly), round9(-lx)],
left_xy: [round9(lx), round9(ly)],
uphill_side: uphill,
samples: cross,
});
}
return { length_m: round6(along[along.length - 1] ?? 0), samples, stations };
}
/** `np.interp(x, xp, fp)` — xp 오름차순 · 범위 밖은 끝값 · 식 차례도 numpy 와 같음. */
function npInterp(x: number, xp: ReadonlyArray<number>, fp: ReadonlyArray<number>): number {
const last = xp.length - 1;
if (x <= xp[0]) return fp[0];
if (x >= xp[last]) return fp[last];
let low = 0;
let high = last;
while (high - low > 1) {
const mid = (low + high) >> 1;
if (xp[mid] <= x) low = mid;
else high = mid;
}
const slope = (fp[low + 1] - fp[low]) / (xp[low + 1] - xp[low]);
return slope * (x - xp[low]) + fp[low];
}
/** 종단 표본 → (누가거리, 지반고) · 결측은 이웃 유효 값으로 선형 보간. 유효 둘 미만이면 throw. */
export function groundProfile(samples: ReadonlyArray<Partial<LongGroundSample>>): {
chainage: number[];
ground: number[];
} {
const rows = samples
.map((s) => ({
chainage: Number(s.chainage_m ?? 0),
z: s.valid !== false && s.elevation_m != null ? Number(s.elevation_m) : NaN,
}))
.sort((a, b) => a.chainage - b.chainage);
const known = rows.filter((row) => Number.isFinite(row.z));
if (known.length < 2 || rows.length < 2) {
throw new Error("계획선을 만들기에 유효한 종단 표고 샘플이 부족합니다.");
}
const xp = known.map((row) => row.chainage);
const fp = known.map((row) => row.z);
return {
chainage: rows.map((row) => row.chainage),
ground: rows.map((row) => npInterp(row.chainage, xp, fp)),
};
}
/** 측점 지반고 — `groundProfile` 위 선형 보간(파이썬 `station_ground`). */
export function groundAt(profile: { chainage: number[]; ground: number[] }, chainageM: number) {
return npInterp(chainageM, profile.chainage, profile.ground);
}
/** 횡단 설계가 덧붙임에 돌려줘야 할 칸. */
export interface UnclosedDesign {
slope_unclosed: boolean;
design_line?: ReadonlyArray<CrossDesignEdge> | null;
}
export interface ExtendInfo {
left_m: number;
right_m: number;
surface_end: boolean;
}
function validSorted(samples: ReadonlyArray<Partial<CrossGroundPoint>>): Array<[number, number]> {
return samples
.filter((s) => s.valid !== false && s.elevation_m != null)
.map((s): [number, number] => [Number(s.offset_m), Number(s.elevation_m)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
}
/** 미교차 측점 지반 표본을 **열린 쪽만** +`stepM` 씩 지표면 끝까지 넓힘 — 닫혔으면 null.
* 새 걸음에 무효 표본이 섞이면 그 쪽은 안 붙이고 `surface_end`. 상한 없음(파이썬과 같음). */
export function extendUnclosed<T extends Partial<CrossGroundPoint>>(
samples: ReadonlyArray<T>,
origin: Vertex,
leftAxis: Vertex,
compute: (samples: Array<T | CrossGroundPoint>) => UnclosedDesign,
grid: DtmGrid,
stepM: number = EXTEND_STEP_M,
): { samples: Array<T | CrossGroundPoint>; info: ExtendInfo } | null {
let current: Array<T | CrossGroundPoint> = [...samples];
let design = compute(current);
if (!design.slope_unclosed) return null;
let offsets = validSorted(current).map(([offset]) => offset);
let spacing = Infinity;
offsets.slice(1).forEach((value, index) => {
const gap = value - offsets[index];
if (gap > 1e-9 && gap < spacing) spacing = gap;
});
if (!Number.isFinite(spacing)) throw new Error("넓힐 표본 간격을 잴 수 없습니다.");
const count = Math.max(Math.round(stepM / spacing), 1);
const info: ExtendInfo = { left_m: 0, right_m: 0, surface_end: false };
while (design.slope_unclosed) {
const ground = validSorted(current);
const lineEnds = (design.design_line ?? [])
.map((p): [number, number] => [Number(p.offset_m), Number(p.elevation_m)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const open =
ground.length && lineEnds.length
? {
right: Math.abs(ground[0][1] - lineEnds[0][1]) > SLOPE_CLOSE_TOLERANCE_M,
left:
Math.abs(ground[ground.length - 1][1] - lineEnds[lineEnds.length - 1][1]) >
SLOPE_CLOSE_TOLERANCE_M,
}
: { right: false, left: false };
const added: CrossGroundPoint[] = [];
for (const [side, sign] of [
["right", -1],
["left", 1],
] as const) {
if (!open[side]) continue;
const edge = sign < 0 ? Math.min(...offsets) : Math.max(...offsets);
const step: CrossGroundPoint[] = [];
for (let k = 1; k <= count; k += 1) {
const offset = edge + sign * spacing * k;
const point = sample(
grid,
origin[0] + leftAxis[0] * offset,
origin[1] + leftAxis[1] * offset,
);
step.push({ offset_m: round6(offset), ...point });
}
if (!step.every((point) => point.valid)) {
info.surface_end = true;
continue;
}
added.push(...step);
info[`${side}_m`] += stepM;
}
if (!added.length) break;
current = [...current, ...added].sort((a, b) => Number(a.offset_m) - Number(b.offset_m));
offsets = validSorted(current).map(([offset]) => offset);
design = compute(current);
}
return { samples: current, info };
}
@@ -8,6 +8,7 @@
⚠ 716 판정 「미교차 측점만 반폭 +5m 한 번」을 갈음(그것으론 4곳만 닫혔음).
⚠ 계산식은 안 바뀐다 — 샘플(입력)만 넓어진다. 넓힌 샘플은 횡단 파일에 남겨 화면(TS)·Node·B08 이
같은 지반을 본다(한 벌).
⚠ 파이썬·TS 짝 — `extend_unclosed` ↔ `B05_Profile_Sections_Ground.ts`(B05 logic) `extendUnclosed`.
"""
from __future__ import annotations
@@ -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 "")