Files
Aislo/resources/tester/test_427_1_follow_seat.py
T
eomsangdonandClaude Opus 5.5 2db6946365 fix(467 덧 새 측점 계획고): 짝 없는 새 측점(구조물 · 추가 측점 · 새 관)엔 계획선 꼭짓점을 안 세움 — [저장]으로 계획고가 지반고로 바뀌던 흠
- 파이썬 _follow_moved_stations · TS followMovedNodes(짝) — seat 기본 = 빈 목록 · 짝 있는 옮긴 관 · 구조물은 427 그대로
- 검증_sub5(계획고 = 지반고 상태) 소단 22+0 저장 = −17,595.6 → −17,499.3 이던 것 · 435 · 445 m 계획고가 지반고로 바뀜
- 시험 test_427_1(짝 없는 새 측점 = 꼭짓점 없음 · 놓기 전 계획선 높이 · TS 거울 125 · 287 그대로 통과) · test_384_3(심은 측점에 꼭짓점 없음) 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:01:03 +09:00

264 lines
11 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
from common_util.common_util_route_profile import design_elevation_from_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:
"""관 하나 지우고 둘 놓음(수가 다름) — 새 측점 둘 = 꼭짓점 안 세움 · 계획고 = 놓기 전 계획선 높이
(브라우저가 보인 값 · [저장]으로 안 바뀜 · 467 · 요구 272) · 옛 편집 지움."""
before = _longitudinal({"351.000": 0.4})
_write(tmp_path, before)
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"] == {}
nodes = [float(n["chainage_m"]) for n in after["profile_alignment"]["base_pvi"]]
for at in (125.0, 287.0):
assert all(abs(at - s) > 0.006 for s in nodes) # 꼭짓점 없음
assert rows[at] == pytest.approx(design_elevation_from_longitudinal(before, at), abs=1e-3)
assert abs(rows[at] - _ground(at)) > 1e-3 # 원지반으로 안 내려앉음
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)
def _balanced() -> dict:
"""지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴)."""
data = _longitudinal()
for node in data["profile_alignment"]["base_pvi"]:
node["elevation_m"] -= 1.0
alignment, profile = rebuild_alignment_profile(data, {})
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
return data
@pytest.mark.parametrize("make", [_longitudinal, _balanced])
def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None:
"""관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고."""
first = make()
before = _rows(first, 0, 400)
_write(tmp_path, first)
after = _rows(_move(tmp_path, 356.0), 0, 400)
for at, value in before.items():
if at != PIPE:
assert abs(after[at] - value) < 0.01, at
if make is _longitudinal:
assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4)
# 화면 짝 같은 꼭짓점
stored = first["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"],
}
server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"]
[nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": []}])
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"], strict=True):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
def test_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None:
"""340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝."""
first = _longitudinal({"340.000": 0.3})
stored = copy.deepcopy(first["profile_alignment"])
_write(tmp_path, first)
server = _move(tmp_path, 356.0)["profile_alignment"]
old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]}
new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]}
assert new[340.0] == old[340.0]
assert new[360.0] != old[360.0]
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": [356.0], "edited": ["340.000"]}]
)
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)