- 파이썬 _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>
264 lines
11 KiB
Python
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)
|