# -*- 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") # 1순위 첫 계획선 = 서버 Node(511-B B-3 · TS `groundFollowingNodes` → `passThrough`) assert "ground_following={" in profile assert "const baseZ = passThrough(nodeS, target, input.policy);" in read( "B05_Profile/logic/B05_Profile_Grade_Ground.ts" ) 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")