Files
Aislo/resources/tester/test_427_1_follow_seat.py
T
eomsangdonandClaude Opus 5.5 91a9a3ece3 fix(B05 종단 계획선): 관 · 구조물 옮김 때 새 측점에 꼭짓점 세움 — 원지반 지남 · 높이 고친 꼭짓점은 높이째 옮김 · 왕복하면 처음 값 (427-1)
- 서버 `_follow_moved_stations` · 새 `_seat_born_nodes` ↔ TS `followMovedNodes` 짝
- 짝 못 찾은 옛 측점 ↔ 새 측점 같은 수면 차례대로 짝 · 다른 꼭짓점 높이 그대로
- 거울 시험 test_427_1_follow_seat · 272-1 헬퍼 새 서명

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-18 15:42:59 +09:00

198 lines
7.8 KiB
Python

"""427-1 관 · 구조물 옮김 때 종단 꼭짓점 따라감(요구 255-2 · 분석 424 2장 · 10장).
까닭: 자동 계획선은 모든 측점이 꼭짓점(원지반 지남) · 관을 옮기면 옛 측점 꼭짓점만 지우고
새 측점엔 안 세움 → 351 → 348 → 351 왕복 뒤 351 m 가 원지반보다 깎인 채 굳음.
고침: 새 측점에 자동 계획선 규칙으로 꼭짓점을 세움 · 높이 고친 꼭짓점은 높이째 옮김
→ 되돌리면 처음 값. 서버 `_follow_moved_stations` · `_seat_born_nodes` ↔ TS `followMovedNodes`
(helper_427_1_follow_seat.cjs 가 진짜 TS 를 Node 로 돌림).
"""
from __future__ import annotations
import copy
import json
import math
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_curves,
evaluate,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_ground_following_profile,
rebuild_alignment_profile,
)
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
REL = "B06_Section/longitudinal/longitudinal.json"
PIPE = 351.0
MOVED = 348.0
def _ground(s: float) -> float:
return 100.0 + 0.08 * s + 1.5 * math.sin(s / 7.0)
def _longitudinal(offsets: dict[str, float] | None = None) -> dict:
samples = [
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
for s in range(0, 401)
]
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
stations.append({"chainage_m": PIPE, "kind": "irregular", "structure": "파형강관 D800"})
stations.sort(key=lambda row: row["chainage_m"])
data = {"samples": samples, "stations": stations}
options = GradeDesignOptions(
max_grade_pct=14.0,
max_reverse_grade_pct=5.0,
min_vertical_radius_m=100.0,
min_curve_length_m=20.0,
min_tangent_length_m=20.0,
vertical_curve_skip_delta_pct=0.5,
design_speed_kph=20,
)
alignment, profile = design_ground_following_profile(data, options, station_interval_m=20.0)
data["profile_alignment"] = alignment
data["design_profiles"] = [profile]
if offsets:
alignment, profile = rebuild_alignment_profile(data, {"station_offsets": offsets})
data["profile_alignment"] = alignment
data["design_profiles"] = [profile]
return data
def _write(tmp_path: Path, data: dict) -> None:
path = tmp_path / REL
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data), encoding="utf-8")
def _move(tmp_path: Path, *to: float) -> dict:
marks = [{"chainage_m": at, "kind": "irregular", "structure": "파형강관 D800"} for at in to]
_merge_irregular_into_longitudinal(tmp_path, REL, marks)
return json.loads((tmp_path / REL).read_text(encoding="utf-8"))
def _rows(data: dict, low: float = 340, high: float = 360) -> dict[float, float]:
"""측점 계획고(표 한 줄) — 기본 340~360 m."""
return {
row["chainage_m"]: row["plan_elevation_m"]
for row in data["profile_alignment"]["stations"]
if low <= row["chainage_m"] <= high
}
def test_옛_판은_옮긴_자리에_꼭짓점이_없어_깎임() -> None:
"""까닭 근거 — 꼭짓점을 지우기만 하면 351 m 계획선이 원지반을 안 지남."""
data = _longitudinal()
base = [n for n in data["profile_alignment"]["base_pvi"] if n["chainage_m"] != PIPE]
s = np.array([n["chainage_m"] for n in base])
z = np.array([n["elevation_m"] for n in base])
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(data["profile_alignment"]["policy"]))
assert abs(float(evaluate(s, z, curves, np.array([PIPE]))[0]) - _ground(PIPE)) > 0.2
def test_옮긴_새_측점에_원지반_지나는_꼭짓점(tmp_path: Path) -> None:
_write(tmp_path, _longitudinal())
after = _move(tmp_path, MOVED)
base = [n["chainage_m"] for n in after["profile_alignment"]["base_pvi"]]
assert MOVED in base and PIPE not in base
rows = _rows(after)
assert MOVED in rows and PIPE not in rows
assert rows[MOVED] == pytest.approx(_ground(MOVED), abs=1e-4)
def test_왕복하면_처음_계획고(tmp_path: Path) -> None:
first = _longitudinal()
_write(tmp_path, first)
_move(tmp_path, MOVED)
back = _move(tmp_path, PIPE)
before, after = _rows(first), _rows(back)
assert list(after) == list(before)
for at, value in before.items():
assert after[at] == pytest.approx(value, abs=5e-4), at
assert after[PIPE] == pytest.approx(_ground(PIPE), abs=1e-4)
assert _rows(back, 0, 400).keys() == _rows(first, 0, 400).keys()
def test_높이_고친_꼭짓점은_높이째_옮김(tmp_path: Path) -> None:
first = _longitudinal({"351.000": 0.4})
_write(tmp_path, first)
moved = _move(tmp_path, MOVED)
assert moved["profile_alignment"]["edits"]["station_offsets"] == {"348.000": 0.4}
# 꼭짓점 높이(자동 높이 + 편집) 그대로 — 측점 계획고는 이웃 곡선 따라 조금 바뀜
def vertex(data: dict, at: float) -> float:
return next(
p["elevation_m"] for p in data["profile_alignment"]["pvi"] if p["chainage_m"] == at
)
assert vertex(moved, MOVED) == pytest.approx(vertex(first, PIPE), abs=1e-6)
back = _move(tmp_path, PIPE)
assert back["profile_alignment"]["edits"]["station_offsets"] == {"351.000": 0.4}
before, after = _rows(first), _rows(back)
for at, value in before.items():
assert after[at] == pytest.approx(value, abs=1e-6), at
def test_짝_없는_새_측점도_세우고_옛_꼭짓점은_지움(tmp_path: Path) -> None:
"""관 하나 지우고 둘 놓음(수가 다름) — 새 측점 둘 다 원지반 지남 · 옛 편집 지움."""
_write(tmp_path, _longitudinal({"351.000": 0.4}))
after = _move(tmp_path, 125.0, 287.0)
rows = _rows(after, 0, 400)
assert PIPE not in rows
assert after["profile_alignment"]["edits"]["station_offsets"] == {}
for at in (125.0, 287.0):
assert rows[at] == pytest.approx(_ground(at), abs=1e-4)
def _ts(tmp_path: Path, cases: list[dict]) -> list[list[dict]]:
source = tmp_path / "in427.json"
source.write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_427_1_follow_seat.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize(
("offsets", "to"),
[(None, [MOVED]), ({"351.000": 0.4}, [MOVED]), (None, [125.0, 287.0])],
)
def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None:
first = _longitudinal(offsets)
stored = copy.deepcopy(first["profile_alignment"])
_write(tmp_path, first)
server = _move(tmp_path, *to)["profile_alignment"]
base = {
"basePvi": stored["base_pvi"],
"chainage": [s["chainage_m"] for s in stored["samples"]],
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
"policy": stored["policy"],
}
[nodes] = _ts(
tmp_path,
[{"base": base, "gone": [PIPE], "born": to, "edited": list(offsets or {})}],
)
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
for mine, theirs in zip(nodes, server["base_pvi"]):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)