- 서버 `_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
198 lines
7.8 KiB
Python
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)
|