Files
Aislo/resources/tester/test_438_1_initial_straighten.py
T
eomsangdonandClaude Opus 5.5 314ce5351b feat(B05 종단): 초기 종단 R 500 넘는 꼭짓점 묶음 직선화 · 432-1 곡선 없음 걷음 (438)
- 첫 계획선 · 균형 종단 맞춤 뒤 기본 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
2026-10-18 17:59:23 +09:00

245 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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")