fix(B05·B06): 한 자리 측점 둘 숨음 — 측점 0.006 m 안 꼭짓점 · 편집 키를 그 측점으로 모음(213-1)

- 까닭: 자동설계 관 측점 263.206 → 화면 저장 0.01 m 반올림 263.21 로 측점이 다시 섬 · 계획선 꼭짓점 · 편집 키는 옛 글자 그대로 → 새 측점 편집과 0.004 m 사이 두 꼭짓점(−15003% 계단 · 엄상준_검증 No.13+3)
- 계획선 공용 함수 짝(파이썬 snap_to_stations ↔ TS snapToStations) — 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이(측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로 · 읽을 때 · [저장] 때 모두 정리
- 192 병합(_follow_moved_stations)도 옛 측점 0.006 m 안 글자를 같은 측점으로 봄
- 시험 test_213_1(합성 · 거울 · 엄상준 사본 재현)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 15:22:52 +09:00
co-authored by Claude Opus 5.5
parent 9e435d29e8
commit 002165f040
4 changed files with 373 additions and 12 deletions
@@ -0,0 +1,214 @@
"""213-1 — 한 자리에 측점 둘이 숨는 흠(엄상준_검증 d2245e3b · No.13+3).
까닭: 자동설계 관 측점 263.206(0.001m) → 화면 저장이 관을 0.01m 로 반올림(263.21) → 측점은
263.21 로 다시 섰는데 계획선 꼭짓점(base_pvi) · 편집 키는 옛 글자 「263.206」 그대로 → 새 측점
편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단).
고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의
꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도
옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path).
"""
from __future__ import annotations
import copy
import json
import shutil
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_alignment,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
SOURCE = (
PROJECT_ROOT
/ "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json"
)
REL = "B06_Section/longitudinal/longitudinal.json"
needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음")
# 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998)
STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998]
BASE = [
(0.0, 100.0),
(20.0, 99.0),
(40.0, 98.5),
(263.206, 90.0),
(280.0, 89.0),
(300.0, 88.0),
(1000.0, 60.0),
]
EDITS = {
"station_offsets": {"263.206": 1.5, "263.210": 0.9, "40.000": 0.2, "500.000": 0.3},
"curve_radii": {"263.206": 300.0},
}
def _synthetic() -> dict:
chainage = np.arange(0.0, 1000.0, 1.0)
return {
"base_s": np.array([s for s, _ in BASE]),
"base_z": np.array([z for _, z in BASE]),
"chainage": np.r_[chainage, 1000.0],
"ground": np.r_[100.0 - chainage * 0.04, 60.0],
"stations": [
{"station_id": f"st{index}", "chainage_m": value}
for index, value in enumerate(STATIONS)
],
}
def _python(edits: dict) -> dict:
data = _synthetic()
return build_alignment(**data, policy=AlignmentPolicy.from_dict({}), edits=copy.deepcopy(edits))
def _close_pairs(alignment: dict, limit: float = 0.006) -> list[tuple[float, float]]:
pvi = [node["chainage_m"] for node in alignment["pvi"]]
return [(a, b) for a, b in zip(pvi, pvi[1:], strict=False) if b - a < limit]
def test_rounded_station_keys_collapse_onto_station():
alignment = _python(EDITS)
pvi = {round(node["chainage_m"], 3): node for node in alignment["pvi"]}
assert _close_pairs(alignment) == []
assert 263.206 not in pvi and 263.21 in pvi
# 측점 위 편집(263.210 = 0.9)이 이김 · 반경도 측점으로 옮김 · 측점에서 먼 키(500)는 그대로
assert alignment["edits"]["station_offsets"] == {"263.210": 0.9, "40.000": 0.2, "500.000": 0.3}
assert alignment["edits"]["curve_radii"] == {"263.210": 300.0}
# 양 끝 꼭짓점은 그대로(끝 1000.0 을 측점 999.998 로 당기지 않음)
assert [node["chainage_m"] for node in alignment["base_pvi"]][-1] == 1000.0
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
def test_snapped_base_keeps_station_elevation():
"""편집 없는 측점 — 꼭짓점을 측점으로 옮겨도 그 측점 계획고(탄젠트)는 그대로."""
alignment = _python({"station_offsets": {}, "curve_radii": {}})
node = next(n for n in alignment["base_pvi"] if abs(n["chainage_m"] - 263.21) < 1e-9)
expected = float(np.interp(263.21, [s for s, _ in BASE], [z for _, z in BASE]))
assert node["elevation_m"] == pytest.approx(expected, abs=1e-6)
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { buildAlignment } from "./B05_Profile_UI_Profile_Alignment.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const result = buildAlignment(input.base, input.edits);
writeFileSync(process.argv[3], JSON.stringify(result));
"""
@pytest.fixture(scope="module")
def ts_alignment(tmp_path_factory):
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
tmp = tmp_path_factory.mktemp("align213")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
data = _synthetic()
base = {
"policy": AlignmentPolicy.from_dict({}).as_dict(),
"onlyExplicitCurves": False,
"basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in BASE],
"chainage": data["chainage"].tolist(),
"ground": data["ground"].tolist(),
"stations": data["stations"],
}
src, dst = tmp / "in.json", tmp / "out.json"
src.write_text(json.dumps({"base": base, "edits": EDITS}), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
)
return json.loads(dst.read_text(encoding="utf-8"))
def test_mirror_python_ts(ts_alignment):
py = _python(EDITS)
ts = ts_alignment
assert ts["edits"] == py["edits"]
assert len(ts["base_pvi"]) == len(py["base_pvi"])
for a, b in zip(ts["base_pvi"], py["base_pvi"], strict=True):
assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-6)
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6)
assert [n["chainage_m"] for n in ts["pvi"]] == pytest.approx(
[n["chainage_m"] for n in py["pvi"]], abs=1e-6
)
assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx(
[n["elevation_m"] for n in py["pvi"]], abs=1e-6
)
for a, b in zip(ts["stations"], py["stations"], strict=True):
assert a["plan_elevation_m"] == pytest.approx(b["plan_elevation_m"], abs=1e-6)
def _copy(tmp_path: Path) -> Path:
target = tmp_path / REL
target.parent.mkdir(parents=True)
shutil.copyfile(SOURCE, target)
return target
@needs_copy
def test_d2245e3b_no13_3_spike_gone(tmp_path):
data = json.loads(_copy(tmp_path).read_text(encoding="utf-8"))
edits = data["profile_alignment"]["edits"]
assert {"263.206", "263.210"} <= set(edits["station_offsets"]) # 재현 조건
alignment, _ = rebuild_alignment_profile(data, edits)
assert _close_pairs(alignment) == []
assert "263.206" not in alignment["edits"]["station_offsets"]
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
@needs_copy
def test_d2245e3b_station_regen_drops_rounded_key(tmp_path):
"""[측점 만들기] 로 관 측점 263.21 → 263.31 — 옛 글자 263.206 꼭짓점 · 편집도 함께 지워짐."""
path = _copy(tmp_path)
data = json.loads(path.read_text(encoding="utf-8"))
irregular = []
for station in data["stations"]:
if station.get("kind") != "irregular":
continue
item = copy.deepcopy(station)
if abs(item["chainage_m"] - 263.21) < 1e-6:
item["chainage_m"] = 263.31
irregular.append(item)
_merge_irregular_into_longitudinal(tmp_path, REL, irregular)
saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
leftovers = [
value
for value in [n["chainage_m"] for n in saved["base_pvi"]]
+ [float(key) for key in saved["edits"]["station_offsets"]]
if 263.2 < value < 263.22
]
assert leftovers == []
assert _close_pairs(saved, 0.05) == []