- 첫 계획선 · 균형 종단 맞춤 뒤 기본 L 20 기준 R > 500 m 꼭짓점 묶음을 앞뒤 꺾임(시종점)을 잇는 직선 위로 · 경계가 새로 R > 500 이면 묶음 늘려 다시(`straight_pass_through` ↔ TS `passThrough`) - 정책 `no_curve_keys` · 열쇠 이어받기 걷음 — 옛 저장분 열쇠는 무시 · 꺾인 꼭짓점마다 곡선(모서리 없음) - 균형 종단 꼭짓점 높이 소수 6자리 — 4자리면 직선 판정(426)이 꺾임으로 읽음 - 관 옮김 새 꼭짓점: 옮기기 전 계획선이 지반을 안 지나면(균형 종단) 그 계획선 높이(427 · 파이썬 ↔ TS) - 시험 test_438_1 + 헬퍼 · test_432_1 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
245 lines
9.8 KiB
Python
245 lines
9.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""438-1 초기 종단 — R 500 넘는 꼭짓점 묶음 직선화(요구 262 · 265).
|
||
|
||
초기값(1차 지반 종단 · 추천 [적용] 균형 종단)만 — 기본 L 20 기준 R > 500 m(|대수차| < 4 %)
|
||
꼭짓점 묶음을 앞뒤 경계(진짜 꺾임 · 시종점)를 잇는 직선 위로 · 경계가 새로 R > 500 m 가 되면
|
||
묶음을 늘려 다시. 432-1 「곡선 없음 · 높이 그대로」(`no_curve_keys`)는 걷음 — 모서리 원인.
|
||
관 옮김(427) 새 꼭짓점은 지반 종단이 아니면 옮기기 전 계획선 높이.
|
||
|
||
파이썬 `straight_pass_through` · `held_pass_through` · `_set_base_pvi` · `_seat_born_nodes` ↔ TS
|
||
`groundFollowingBasePvi`(`helper_438_1_straighten.cjs`) · `seatBornNodes`(427 헬퍼).
|
||
./venv/Scripts/python.exe -m pytest resources/tester/test_438_1_initial_straighten.py -q
|
||
"""
|
||
|
||
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.parent.parent
|
||
sys.path.insert(0, str(ROOT))
|
||
sys.path.insert(0, str(HERE))
|
||
|
||
from test_427_1_follow_seat import ( # noqa: E402
|
||
MOVED,
|
||
PIPE,
|
||
_move,
|
||
_ts,
|
||
_write,
|
||
)
|
||
from test_427_1_follow_seat import _longitudinal as _long_427 # noqa: E402
|
||
|
||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
|
||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||
AlignmentPolicy,
|
||
build_alignment,
|
||
build_curves,
|
||
evaluate,
|
||
is_straight_bend,
|
||
)
|
||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends # noqa: E402
|
||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||
design_ground_following_profile,
|
||
pass_through,
|
||
rebuild_alignment_profile,
|
||
)
|
||
|
||
L_M = 20.0
|
||
|
||
|
||
def _wave(s: float) -> float:
|
||
return 100.0 + 0.05 * s + 0.6 * math.sin(s / 45.0) + 0.08 * math.sin(s / 9.0)
|
||
|
||
|
||
def _from_grades(grades: list[float]) -> tuple[list[float], list[float]]:
|
||
stations = [20.0 * i for i in range(len(grades) + 1)]
|
||
z = [100.0]
|
||
for grade in grades:
|
||
z.append(z[-1] + grade / 100.0 * 20.0)
|
||
return stations, z
|
||
|
||
|
||
def _longitudinal(stations: list[float], height) -> dict:
|
||
samples = [
|
||
{"chainage_m": float(s), "elevation_m": round(float(height(s)), 6)}
|
||
for s in np.arange(0.0, stations[-1] + 0.5, 1.0)
|
||
]
|
||
return {"samples": samples, "stations": [{"chainage_m": s} for s in stations]}
|
||
|
||
|
||
WAVE_STATIONS = [20.0 * i for i in range(31)]
|
||
WAVE = _longitudinal(WAVE_STATIONS, _wave)
|
||
# 경계가 새로 R > 500 이 됨 — 20 m(대수차 −2 %) 직선화 뒤 40 m 대수차 3.5 → 2.5 %(맞춤 뒤 ×1.2 쯤)
|
||
GROW_STATIONS, GROW_Z = _from_grades([2.0, 0.0, 3.5, 8.0, 4.0])
|
||
GROW = _longitudinal(GROW_STATIONS, lambda s: np.interp(s, GROW_STATIONS, GROW_Z))
|
||
OPTIONS = resolve_grade_options("trunk", terrain_type="normal")
|
||
|
||
|
||
def _design(longitudinal: dict) -> dict:
|
||
return design_ground_following_profile(longitudinal, OPTIONS)[0]
|
||
|
||
|
||
def _arrays(nodes: list[dict]) -> tuple[np.ndarray, np.ndarray]:
|
||
return (
|
||
np.array([float(n["chainage_m"]) for n in nodes]),
|
||
np.array([float(n["elevation_m"]) for n in nodes]),
|
||
)
|
||
|
||
|
||
def _straight_mask(s: np.ndarray, z: np.ndarray) -> np.ndarray:
|
||
grades = np.diff(z) / np.diff(s)
|
||
mask = np.zeros(len(s), dtype=bool)
|
||
for i in range(1, len(s) - 1):
|
||
mask[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i])
|
||
return mask
|
||
|
||
|
||
def _check_initial(alignment: dict) -> int:
|
||
"""모서리 0 · R > 500 꼭짓점 0 · 직선 안쪽 R 없음 — 직선 위 꼭짓점 수를 돌려줌."""
|
||
s, z = _arrays(alignment["base_pvi"])
|
||
straight = _straight_mask(s, z)
|
||
curved = {round(float(c["chainage_m"]), 3) for c in alignment["curves"]}
|
||
for i in range(1, len(s) - 1):
|
||
key = round(float(s[i]), 3)
|
||
assert straight[i] != (key in curved), key # 꺾이면 곡선 · 직선이면 곡선 없음
|
||
assert not (flat_bends(s, z, L_M) & ~straight).any() # R > 500 꺾임 없음
|
||
assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"])
|
||
return int(straight.sum())
|
||
|
||
|
||
def test_지반_종단_직선화_모서리_없음():
|
||
alignment = _design(WAVE)
|
||
assert alignment["policy"]["default_curve_length_m"] == L_M
|
||
assert "no_curve_keys" not in alignment["policy"]
|
||
assert _check_initial(alignment) >= 3
|
||
# 기준이 바뀜을 잼 — 직선화 없이 맞추면 R > 500 꺾임이 남음
|
||
s, _z = _arrays(alignment["base_pvi"])
|
||
target = np.array([_wave(v) for v in s])
|
||
plain = pass_through(s, target, AlignmentPolicy.from_dict(alignment["policy"]))
|
||
assert flat_bends(s, plain, L_M).sum() >= 3
|
||
|
||
|
||
def test_경계가_새로_R_500_넘으면_묶음을_늘림():
|
||
alignment = _design(GROW)
|
||
_check_initial(alignment)
|
||
curved = {round(float(c["chainage_m"]), 3) for c in alignment["curves"]}
|
||
assert 20.0 not in curved and 40.0 not in curved
|
||
assert {60.0, 80.0} <= curved
|
||
|
||
|
||
def test_손_편집엔_안_걺():
|
||
alignment = _design(WAVE)
|
||
s, z = _arrays(alignment["base_pvi"])
|
||
key = f"{s[np.nonzero(_straight_mask(s, z))[0][0]]:.3f}"
|
||
edited = build_alignment(
|
||
base_s=s,
|
||
base_z=z,
|
||
chainage=np.array([r["chainage_m"] for r in WAVE["samples"]]),
|
||
ground=np.array([r["elevation_m"] for r in WAVE["samples"]]),
|
||
stations=WAVE["stations"],
|
||
policy=AlignmentPolicy.from_dict(alignment["policy"]),
|
||
edits={"station_offsets": {key: 0.05}},
|
||
)
|
||
curve = next(c for c in edited["curves"] if f"{c['chainage_m']:.3f}" == key)
|
||
assert curve["r_m"] > 500.0 # 작은 꺾임도 곡선 · 다시 펴지 않음
|
||
|
||
|
||
def test_추천_적용_균형_종단도_직선화(tmp_path):
|
||
from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi
|
||
|
||
path = tmp_path / "long.json"
|
||
path.write_text(json.dumps({**WAVE, "profile_alignment": _design(WAVE)}), encoding="utf-8")
|
||
plan = [_wave(s) - 0.3 + 0.002 * s for s in WAVE_STATIONS]
|
||
_set_base_pvi(path, WAVE_STATIONS, plan)
|
||
stored = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
|
||
s, z = _arrays(stored["base_pvi"])
|
||
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(stored["policy"]))
|
||
_check_initial({**stored, "curves": curves})
|
||
# 꼭짓점 사이 직선은 균형 종단 상한 안(419-3)
|
||
limit = np.abs(np.diff(plan) / np.diff(WAVE_STATIONS)).max()
|
||
assert np.abs(np.diff(z) / np.diff(s)).max() <= limit + 1e-3
|
||
|
||
|
||
def test_TS_거울_첫_계획선(tmp_path):
|
||
cases, expected = [], []
|
||
for longitudinal in (WAVE, GROW):
|
||
alignment = _design(longitudinal)
|
||
cases.append(
|
||
{
|
||
"samples": longitudinal["samples"],
|
||
"stations": [r["chainage_m"] for r in longitudinal["stations"]],
|
||
"policy": alignment["policy"],
|
||
"startOffsetM": OPTIONS.start_elevation_offset_m,
|
||
"endOffsetM": OPTIONS.end_elevation_offset_m,
|
||
}
|
||
)
|
||
expected.append(alignment["base_pvi"])
|
||
source = tmp_path / "in438.json"
|
||
source.write_text(json.dumps(cases), encoding="utf-8")
|
||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||
["node", str(HERE / "helper_438_1_straighten.cjs"), str(source)],
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
check=False,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
for got, py in zip(json.loads(proc.stdout), expected, strict=True):
|
||
assert [n["chainage_m"] for n in got] == [n["chainage_m"] for n in py]
|
||
assert [n["elevation_m"] for n in got] == pytest.approx(
|
||
[n["elevation_m"] for n in py], abs=1e-6
|
||
)
|
||
|
||
|
||
def _balanced_427() -> dict:
|
||
"""427 지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴)."""
|
||
data = _long_427()
|
||
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
|
||
|
||
|
||
def test_관_옮김_균형_종단은_지금_계획선_높이(tmp_path):
|
||
first = _balanced_427()
|
||
stored = copy.deepcopy(first["profile_alignment"])
|
||
s, z = _arrays(stored["base_pvi"])
|
||
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(stored["policy"]))
|
||
before = float(evaluate(s, z, curves, np.array([MOVED]))[0])
|
||
_write(tmp_path, first)
|
||
server = _move(tmp_path, MOVED)["profile_alignment"]
|
||
row = next(r for r in server["stations"] if r["chainage_m"] == MOVED)
|
||
assert row["plan_elevation_m"] == pytest.approx(before, abs=1e-4)
|
||
assert abs(row["plan_elevation_m"] - row["ground_elevation_m"]) > 0.5 # 지반에 안 세움
|
||
# 화면 짝(`seatBornNodes`) 같은 꼭짓점
|
||
base = {
|
||
"basePvi": stored["base_pvi"],
|
||
"chainage": [r["chainage_m"] for r in stored["samples"]],
|
||
"ground": [r["ground_elevation_m"] for r in stored["samples"]],
|
||
"policy": stored["policy"],
|
||
}
|
||
[nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [MOVED], "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_부르는_곳():
|
||
def read(rel: str) -> str:
|
||
return (ROOT / rel).read_text(encoding="utf-8")
|
||
|
||
profile = read("B05_Profile/B05_Profile_Engine_Grade_Profile.py")
|
||
assert "base_z = straight_pass_through(base_s, base_z, policy)" in profile
|
||
assert "straight_pass_through(base_s, target, policy, lambda z: _hold(" in read(
|
||
"B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py"
|
||
)
|
||
assert "no_curve_keys" not in read("B05_Profile/B05_Profile_Engine_Grade_Alignment.py")
|
||
assert "no_curve_keys" not in read("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts")
|