# -*- coding: utf-8 -*- """432-1 종단곡선 기본 L 20 m · 법정 미달 경고(요구 262). 초기값 R > 500 m 꼭짓점 「곡선 없음」 (정책 `no_curve_keys`)은 438 직선화로 바뀜 — `test_438_1_initial_straighten.py`. 파이썬 `B05_Profile_Engine_Grade_CurveLaw.py` · `design_ground_following_profile` · `build_alignment` ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` · `_Grade_Ground.ts` `groundFollowingBasePvi` · `_UI_Profile_Alignment.ts` 거울(`helper_432_1_curve_law.cjs`). ./venv/Scripts/python.exe -m pytest resources/tester/test_432_1_curve_law.py -q """ import json import os 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)) 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, ) from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_policy # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 design_ground_following_profile, ) from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT # noqa: E402 # 20 m 마디 기울기(%) — 꼭짓점 대수차 1 · 6 · 0.5 · 7.5 · 0.5 · 7.5 · 5.5 · 0.5 · 3 % GRADES = [2.0, 3.0, 9.0, 9.5, 2.0, 2.5, 10.0, 4.5, 4.0, 1.0] STATIONS = [20.0 * i for i in range(len(GRADES) + 1)] # 432-1 때 곡선 없던 꼭짓점 열쇠 — 옛 저장분 정책에 남은 채 와도 무시(438) NO_CURVE = ["20.000", "60.000", "100.000", "160.000", "180.000"] def _vertex_z() -> list[float]: z = [100.0] for grade in GRADES: z.append(z[-1] + grade / 100.0 * 20.0) return z def _longitudinal() -> dict: zs = _vertex_z() samples = [ {"chainage_m": float(s), "elevation_m": float(np.interp(s, STATIONS, zs))} for s in range(0, int(STATIONS[-1]) + 1) ] return {"samples": samples, "stations": [{"chainage_m": s} for s in STATIONS]} @pytest.fixture(scope="module") def initial(): longitudinal = _longitudinal() options = resolve_grade_options("trunk", terrain_type="normal") alignment, profile = design_ground_following_profile(longitudinal, options) return longitudinal, options, alignment, profile def _rebuild(alignment: dict, edits: dict, policy: dict | None = None) -> dict: longitudinal = _longitudinal() return build_alignment( base_s=np.array([n["chainage_m"] for n in alignment["base_pvi"]]), base_z=np.array([n["elevation_m"] for n in alignment["base_pvi"]]), chainage=np.array([row["chainage_m"] for row in longitudinal["samples"]]), ground=np.array([row["elevation_m"] for row in longitudinal["samples"]]), stations=longitudinal["stations"], policy=AlignmentPolicy.from_dict(policy or alignment["policy"]), edits=edits, ) EDITS = [ {}, {"station_offsets": {"60.000": 0.1}}, # 직선 안 꼭짓점 손 편집 → 기본 곡선 {"station_offsets": {"80.000": 0.2}}, {"curve_radii": {"100.000": 800.0}}, # 직선 위 R → 직선 판정(426)이라 곡선 없음 ] # 노선 [저장] — 끝 노드만 옮김(100 m 까지 같은 자리) NEW_TOTAL = 100.0 + float(np.hypot(100.0, 30.0)) NEW_STATIONS = [20.0 * i for i in range(11)] + [round(NEW_TOTAL, 3)] CARRY = { "old_xy": [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], "new_xy": [(0.0, 0.0), (100.0, 0.0), (200.0, 30.0)], } def _curve_at(alignment: dict) -> list[float]: return [round(c["chainage_m"], 3) for c in alignment["curves"]] def test_기본_L_20_문턱_500_config_TS_짝(): assert FOREST_ROAD_PROFILE_ALIGNMENT["default_curve_length_m"] == 20.0 assert FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"] == 500.0 law = (ROOT / "B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts").read_text(encoding="utf-8") assert "DEFAULT_CURVE_LENGTH_M = 20;" in law and "INITIAL_STRAIGHT_RADIUS_M = 500;" in law def test_법정_미달_경고_45퍼센트로_줄어든_곳도(initial): _long, options, alignment, _profile = initial assert options.min_vertical_radius_m == 100.0 and options.min_curve_length_m == 20.0 assert alignment["policy"]["min_curve_length_m"] == 20.0 # 20 m 마디 = 반쪽 9 m 한도 → L 18 m(줄어든 경고 글 그대로) + 법정 L 20 미달 경고 lengths = [round(c["l_m"], 3) for c in alignment["curves"]] assert lengths and lengths == [18.0] * len(lengths) reduced = [w for w in alignment["warnings"] if "줄였습니다" in w] short = [w for w in alignment["warnings"] if "미달" in w] assert len(reduced) == len(lengths) and len(short) == len(lengths) assert short[0].startswith("40.0m: 종단곡선 R") and "법정 최소 R 100m · L 20m 미달" in short[0] def test_옛_저장분_열쇠는_무시_꺾인_꼭짓점마다_곡선(initial): alignment = initial[2] old = {**alignment["policy"], "default_curve_length_m": 15.0, "no_curve_keys": NO_CURVE} # 432-1 때 저장된 꼭짓점(지반 그대로 · 직선화 전) nodes = [{"chainage_m": s, "elevation_m": z} for s, z in zip(STATIONS, _vertex_z())] got = _rebuild({**alignment, "base_pvi": nodes}, {}, old) assert len(got["curves"]) == len(STATIONS) - 2 # 꼭짓점마다 곡선 · 모서리 없음 assert "no_curve_keys" not in got["policy"] def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path): longitudinal, options, alignment, _profile = initial payload = { "ground": { "samples": longitudinal["samples"], "stations": STATIONS, "policy": alignment["policy"], "startOffsetM": options.start_elevation_offset_m, "endOffsetM": options.end_elevation_offset_m, }, "policy": alignment["policy"], "basePvi": alignment["base_pvi"], "chainage": [row["chainage_m"] for row in longitudinal["samples"]], "groundZ": [row["elevation_m"] for row in longitudinal["samples"]], "stations": STATIONS, "edits": [{"station_offsets": {}, "curve_radii": {}, **e} for e in EDITS], } source = tmp_path / "in.json" source.write_text(json.dumps(payload), encoding="utf-8") proc = subprocess.run( ["node", os.path.join(HERE, "helper_432_1_curve_law.cjs"), str(source)], capture_output=True, text=True, encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr ts = json.loads(proc.stdout) assert ts["consts"] == [20, 500] assert [n["chainage_m"] for n in ts["pvi"]] == [n["chainage_m"] for n in alignment["base_pvi"]] assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx( [n["elevation_m"] for n in alignment["base_pvi"]], abs=1e-6 ) for edits, got in zip(EDITS, ts["cases"], strict=True): py = _rebuild(alignment, edits) assert [c[0] for c in got["curves"]] == pytest.approx( [c["chainage_m"] for c in py["curves"]], abs=1e-6 ) assert [c[1] for c in got["curves"]] == pytest.approx( [c["r_m"] for c in py["curves"]], rel=1e-6 ) # 미달 경고 글은 두 쪽 같은 글(옛 경고 글은 앞머리가 원래 다름) py_short = [w for w in py["warnings"] if "미달" in w] assert got["shortfalls"] == py_short assert [w for w in got["warnings"] if "미달" in w] == py_short def test_부르는_곳(): def read(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") assert "skipsInitialCurve" not in read("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts") assert "verticalCurveShortfalls(alignment.curves, policy)" in read( "B05_Profile/B05_Profile_UI_Profile_Balance.ts" ) assert "carryNoCurveKeys" not in read("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts") assert "carry_policy(old_xy, new_xy, old_alignment, alignment)" in read( "B05_Profile/B05_Profile_Route_Save.py" ) def test_추천_적용_종단_노선_고쳐_저장_안_바뀐_구간_그대로(initial, tmp_path): from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi longitudinal, options, alignment, _profile = initial path = tmp_path / "long.json" path.write_text(json.dumps({**longitudinal, "profile_alignment": alignment}), encoding="utf-8") plan = [z - (2.0 if s == 100.0 else 0.0) for s, z in zip(STATIONS, _vertex_z())] _set_base_pvi(path, STATIONS, plan) old = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"] old_rebuilt = _rebuild(old, {}) # 새 노선 종단 = 서버가 새로 냄(정책 config · L 20 · 직선화) → 옛 L · 옛 꼭짓점 이어받음 zs = _vertex_z() samples = [ {"chainage_m": float(s), "elevation_m": float(np.interp(s, STATIONS, zs))} for s in range(0, int(NEW_TOTAL) + 1) ] + [{"chainage_m": NEW_TOTAL, "elevation_m": float(zs[-1])}] new_long = {"samples": samples, "stations": [{"chainage_m": s} for s in NEW_STATIONS]} fresh, _ = design_ground_following_profile(new_long, options) old_policy = {**old["policy"], "default_curve_length_m": 15.0} # 옛 L 이 이어지는지 policy = carry_policy(CARRY["old_xy"], CARRY["new_xy"], {**old, "policy": old_policy}, fresh) assert policy["default_curve_length_m"] == 15.0 base = carry_base(CARRY["old_xy"], CARRY["new_xy"], old["base_pvi"], fresh["base_pvi"]) carried = build_alignment( base_s=np.array([n["chainage_m"] for n in base]), base_z=np.array([n["elevation_m"] for n in base]), chainage=np.array([row["chainage_m"] for row in samples]), ground=np.array([row["elevation_m"] for row in samples]), stations=new_long["stations"], policy=AlignmentPolicy.from_dict({**policy, "default_curve_length_m": 20.0}), edits={}, ) before = [(c["chainage_m"], round(c["r_m"], 3)) for c in old_rebuilt["curves"]] after = [(c["chainage_m"], round(c["r_m"], 3)) for c in carried["curves"]] # 안 바뀐 구간(< 80 m · 끝 노드 영향 밖) R · 직선 그대로 assert [c for c in after if c[0] < 80] == [c for c in before if c[0] < 80] assert "no_curve_keys" not in policy