# -*- coding: utf-8 -*- """511-B B-3 종단 짝 모으기 — 종단 다시 셈 · 첫 계획선 · 계획고 옮김 = 서버 Node(TS 한 벌). 옛 파이썬 길(`build_alignment` · `straight_pass_through` — 2~4 순위 폴백 몫으로 남음)로 지은 정본과 새 Node 길(`rebuild_alignment_profile` · `design_ground_following_profile` 겉 → `profile.alignment` · `profile.base` → `alignment_record`)이 낸 정본의 **JSON 글이 같음** (칸 · 차례 · 실수형 · −0.0 · 반올림). 사용자 R(측점 밖 곡선 시종점) · 측점 0.006 m 안 편집 키 · 법정 R 미달 경고 · 시종점 오프셋 포함. ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b3_profile_node.py -q """ from __future__ import annotations import copy import json import math import sys from dataclasses import replace from pathlib import Path import numpy as np import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B05_Profile.B05_Profile_Engine_Grade import ( # noqa: E402 ground_profile, legal_plan_radius_min_m, # noqa: E402 resolve_grade_options, ) from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 AlignmentPolicy, build_alignment, ) from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_edits # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 design_ground_following_profile, infer_station_interval, rebuild_alignment_profile, straight_pass_through, ) pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치", ) def _ground(s: float, phase: float) -> float: return 300.0 + 0.06 * s + 1.4 * math.sin(s / 37.0 + phase) + 0.2 * math.sin(s / 7.3) def _longitudinal(phase: float, length: float = 400.0) -> dict: samples = [ {"chainage_m": float(s), "elevation_m": _ground(float(s), phase), "valid": True} for s in np.arange(0.0, length + 1e-9, 1.0) ] marks = sorted({*np.arange(0.0, length + 1e-9, 20.0).tolist(), 133.37, 251.2, 318.06}) stations = [ {"station_id": f"s{index}", "chainage_m": float(value)} for index, value in enumerate(marks) ] return {"samples": samples, "stations": stations, "length_m": length, "design_profiles": []} def _options(): return resolve_grade_options("trunk", terrain_type="special", design_speed_kph=30) def _old_ground_following(longitudinal: dict, options, edits: dict | None) -> dict: """옛 1순위 몸통(B-3 전 `design_ground_following_profile`) — 파이썬 꼭짓점 · 파이썬 선형.""" chainage, ground = ground_profile(longitudinal) total = float(chainage[-1]) stations = list(longitudinal["stations"]) policy = AlignmentPolicy.from_config( station_interval_m=infer_station_interval(stations), max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), min_vertical_radius_m=options.min_vertical_radius_m, min_curve_length_m=options.min_curve_length_m, ) node_s = sorted( {round(float(row["chainage_m"]), 3) for row in stations if 0 <= row["chainage_m"] <= total} ) if node_s[-1] < total: node_s.append(round(total, 3)) base_s = np.array(node_s, dtype=np.float64) base_z = np.interp(base_s, chainage, ground) base_z[0] += options.start_elevation_offset_m base_z[-1] += options.end_elevation_offset_m base_z = straight_pass_through(base_s, base_z, policy) return build_alignment( base_s=base_s, base_z=base_z, chainage=chainage, ground=ground, stations=stations, policy=policy, edits=dict(edits or {}), ) def _old_rebuild(longitudinal: dict, edits: dict | None) -> dict: """옛 `rebuild_alignment_profile` 몸통 — 저장 꼭짓점 · 파이썬 선형.""" stored = longitudinal["profile_alignment"] chainage, ground = ground_profile(longitudinal) base = stored["base_pvi"] criteria = (longitudinal.get("design_profiles") or [{}])[0].get("criteria") or {} policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) policy = replace( policy, min_vertical_radius_m=policy.min_vertical_radius_m or float(criteria.get("min_vertical_radius_m") or 0.0), min_curve_length_m=policy.min_curve_length_m or float(criteria.get("min_curve_length_m") or 0.0), ) return build_alignment( base_s=np.array([float(n["chainage_m"]) for n in base], dtype=np.float64), base_z=np.array([float(n["elevation_m"]) for n in base], dtype=np.float64), chainage=chainage, ground=ground, stations=list(longitudinal["stations"]), policy=policy, edits=edits, ) def _text(value) -> str: return json.dumps(value, ensure_ascii=False) EDITS = { "station_offsets": {"60.000": 0.8, "133.370": -0.45, "251.204": 0.3, "300.000": None}, "curve_radii": {"100.000": 350.0, "251.200": 77.7, "60.000": 1234.5}, } @pytest.mark.parametrize("phase", [0.0, 0.7, 2.1]) def test_첫_계획선_옛_파이썬과_정본_글_같음(phase: float) -> None: longitudinal = _longitudinal(phase) options = _options() old = _old_ground_following(longitudinal, options, None) new, profile = design_ground_following_profile(longitudinal, options) assert _text(new) == _text(old) assert profile["samples"] == new["samples"] @pytest.mark.parametrize("phase", [0.0, 0.7, 2.1]) def test_다시_셈_옛_파이썬과_정본_글_같음(phase: float) -> None: longitudinal = _longitudinal(phase) alignment, profile = design_ground_following_profile(longitudinal, _options()) longitudinal["profile_alignment"] = alignment longitudinal["design_profiles"] = [profile] for edits in (None, {}, copy.deepcopy(EDITS)): old = _old_rebuild(longitudinal, copy.deepcopy(edits)) new, entry = rebuild_alignment_profile(longitudinal, copy.deepcopy(edits)) assert _text(new) == _text(old) assert entry["summary"]["warnings"] == old["warnings"] # 사용자 R 77.7 m = 측점 밖 곡선 시종점 · 법정 R 미달 경고(432) assert any(abs(s["chainage_m"] * 1000 % 1) > 1e-6 for s in new["samples"]) assert any("종단곡선" in text for text in new["warnings"]) def test_시종점_오프셋_첫_계획선_같음() -> None: longitudinal = _longitudinal(1.3) options = replace(_options(), start_elevation_offset_m=0.6, end_elevation_offset_m=-0.4) old = _old_ground_following(longitudinal, options, EDITS) new, _profile = design_ground_following_profile(longitudinal, options, edits=EDITS) assert _text(new) == _text(old) def test_저장_선형_없으면_ValueError() -> None: with pytest.raises(ValueError): rebuild_alignment_profile(_longitudinal(0.0), {}) def test_계획고_옮김_꼭짓점_세움_한_부름() -> None: """노선 [저장] 몫 — 편집 옮김(`carryEdits`)과 꼭짓점 세움(`seatRouteMoved`)이 Node 한 부름 · 편집만 부르는 겉(`carry_edits`)과 같은 편집.""" old_xy = [[0.0, 0.0], [200.0, 0.0], [400.0, 0.0]] new_xy = [[0.0, 0.0], [200.0, 0.0], [300.0, 60.0], [420.0, 60.0]] longitudinal = _longitudinal(0.4) alignment, _profile = design_ground_following_profile(longitudinal, _options()) base = alignment["base_pvi"] edits = {"station_offsets": {"60.000": 0.5, "120.000": -0.3}, "curve_radii": {"120.000": 400}} stations = [row["chainage_m"] for row in longitudinal["stations"]] seat = { "ground_s": [row["chainage_m"] for row in alignment["samples"]], "ground_z": [row["ground_elevation_m"] for row in alignment["samples"]], "policy": AlignmentPolicy.from_dict(alignment["policy"]).as_dict(), "base_pvi": base, "born": [251.2], } moved, seated = carry_seat(old_xy, new_xy, base, edits, stations, 10.0, seat) assert moved == carry_edits(old_xy, new_xy, base, edits, stations, 10.0) assert set(moved["station_offsets"]) == {"60.000", "120.000"} assert moved["curve_radii"] == {"120.000": 400.0} assert all(isinstance(v, float) for v in moved["station_offsets"].values()) assert any(abs(node["chainage_m"] - 251.2) < 1e-6 for node in seated) assert carry_edits(old_xy, new_xy, [], edits, stations, 10.0) == { "station_offsets": {}, "curve_radii": {}, }