"""427-1 관 · 구조물 옮김 때 종단 꼭짓점 따라감(요구 255-2 · 분석 424 2장 · 10장). 까닭: 자동 계획선은 모든 측점이 꼭짓점(원지반 지남) · 관을 옮기면 옛 측점 꼭짓점만 지우고 새 측점엔 안 세움 → 351 → 348 → 351 왕복 뒤 351 m 가 원지반보다 깎인 채 굳음. 고침: 새 측점에 자동 계획선 규칙으로 꼭짓점을 세움 · 높이 고친 꼭짓점은 높이째 옮김 → 되돌리면 처음 값. 서버 `_follow_moved_stations` · `_seat_born_nodes` ↔ TS `followMovedNodes` (helper_427_1_follow_seat.cjs 가 진짜 TS 를 Node 로 돌림). """ from __future__ import annotations 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.parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 AlignmentPolicy, build_curves, evaluate, ) from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 design_ground_following_profile, rebuild_alignment_profile, ) from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 from common_util.common_util_route_profile import design_elevation_from_longitudinal # noqa: E402 REL = "B06_Section/longitudinal/longitudinal.json" PIPE = 351.0 MOVED = 348.0 def _ground(s: float) -> float: return 100.0 + 0.08 * s + 1.5 * math.sin(s / 7.0) def _longitudinal(offsets: dict[str, float] | None = None) -> dict: samples = [ {"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True} for s in range(0, 401) ] stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)] stations.append({"chainage_m": PIPE, "kind": "irregular", "structure": "파형강관 D800"}) stations.sort(key=lambda row: row["chainage_m"]) data = {"samples": samples, "stations": stations} options = GradeDesignOptions( max_grade_pct=14.0, max_reverse_grade_pct=5.0, min_vertical_radius_m=100.0, min_curve_length_m=20.0, min_tangent_length_m=20.0, vertical_curve_skip_delta_pct=0.5, design_speed_kph=20, ) alignment, profile = design_ground_following_profile(data, options, station_interval_m=20.0) data["profile_alignment"] = alignment data["design_profiles"] = [profile] if offsets: alignment, profile = rebuild_alignment_profile(data, {"station_offsets": offsets}) data["profile_alignment"] = alignment data["design_profiles"] = [profile] return data def _write(tmp_path: Path, data: dict) -> None: path = tmp_path / REL path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data), encoding="utf-8") def _move(tmp_path: Path, *to: float) -> dict: marks = [{"chainage_m": at, "kind": "irregular", "structure": "파형강관 D800"} for at in to] _merge_irregular_into_longitudinal(tmp_path, REL, marks) return json.loads((tmp_path / REL).read_text(encoding="utf-8")) def _rows(data: dict, low: float = 340, high: float = 360) -> dict[float, float]: """측점 계획고(표 한 줄) — 기본 340~360 m.""" return { row["chainage_m"]: row["plan_elevation_m"] for row in data["profile_alignment"]["stations"] if low <= row["chainage_m"] <= high } def test_옛_판은_옮긴_자리에_꼭짓점이_없어_깎임() -> None: """까닭 근거 — 꼭짓점을 지우기만 하면 351 m 계획선이 원지반을 안 지남.""" data = _longitudinal() base = [n for n in data["profile_alignment"]["base_pvi"] if n["chainage_m"] != PIPE] s = np.array([n["chainage_m"] for n in base]) z = np.array([n["elevation_m"] for n in base]) curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(data["profile_alignment"]["policy"])) assert abs(float(evaluate(s, z, curves, np.array([PIPE]))[0]) - _ground(PIPE)) > 0.2 def test_옮긴_새_측점에_원지반_지나는_꼭짓점(tmp_path: Path) -> None: _write(tmp_path, _longitudinal()) after = _move(tmp_path, MOVED) base = [n["chainage_m"] for n in after["profile_alignment"]["base_pvi"]] assert MOVED in base and PIPE not in base rows = _rows(after) assert MOVED in rows and PIPE not in rows assert rows[MOVED] == pytest.approx(_ground(MOVED), abs=1e-4) def test_왕복하면_처음_계획고(tmp_path: Path) -> None: first = _longitudinal() _write(tmp_path, first) _move(tmp_path, MOVED) back = _move(tmp_path, PIPE) before, after = _rows(first), _rows(back) assert list(after) == list(before) for at, value in before.items(): assert after[at] == pytest.approx(value, abs=5e-4), at assert after[PIPE] == pytest.approx(_ground(PIPE), abs=1e-4) assert _rows(back, 0, 400).keys() == _rows(first, 0, 400).keys() def test_높이_고친_꼭짓점은_높이째_옮김(tmp_path: Path) -> None: first = _longitudinal({"351.000": 0.4}) _write(tmp_path, first) moved = _move(tmp_path, MOVED) assert moved["profile_alignment"]["edits"]["station_offsets"] == {"348.000": 0.4} # 꼭짓점 높이(자동 높이 + 편집) 그대로 — 측점 계획고는 이웃 곡선 따라 조금 바뀜 def vertex(data: dict, at: float) -> float: return next( p["elevation_m"] for p in data["profile_alignment"]["pvi"] if p["chainage_m"] == at ) assert vertex(moved, MOVED) == pytest.approx(vertex(first, PIPE), abs=1e-6) back = _move(tmp_path, PIPE) assert back["profile_alignment"]["edits"]["station_offsets"] == {"351.000": 0.4} before, after = _rows(first), _rows(back) for at, value in before.items(): assert after[at] == pytest.approx(value, abs=1e-6), at def test_짝_없는_새_측점은_꼭짓점_안_세우고_옛_꼭짓점은_지움(tmp_path: Path) -> None: """관 하나 지우고 둘 놓음(수가 다름) — 새 측점 둘 = 꼭짓점 안 세움 · 계획고 = 놓기 전 계획선 높이 (브라우저가 보인 값 · [저장]으로 안 바뀜 · 467 · 요구 272) · 옛 편집 지움.""" before = _longitudinal({"351.000": 0.4}) _write(tmp_path, before) after = _move(tmp_path, 125.0, 287.0) rows = _rows(after, 0, 400) assert PIPE not in rows assert after["profile_alignment"]["edits"]["station_offsets"] == {} nodes = [float(n["chainage_m"]) for n in after["profile_alignment"]["base_pvi"]] for at in (125.0, 287.0): assert all(abs(at - s) > 0.006 for s in nodes) # 꼭짓점 없음 assert rows[at] == pytest.approx(design_elevation_from_longitudinal(before, at), abs=1e-3) assert abs(rows[at] - _ground(at)) > 1e-3 # 원지반으로 안 내려앉음 def _ts(tmp_path: Path, cases: list[dict]) -> list[list[dict]]: source = tmp_path / "in427.json" source.write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8") proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_427_1_follow_seat.cjs"), str(source)], capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), check=False, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @pytest.mark.parametrize( ("offsets", "to"), [(None, [MOVED]), ({"351.000": 0.4}, [MOVED]), (None, [125.0, 287.0])], ) def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None: first = _longitudinal(offsets) stored = copy.deepcopy(first["profile_alignment"]) _write(tmp_path, first) server = _move(tmp_path, *to)["profile_alignment"] base = { "basePvi": stored["base_pvi"], "chainage": [s["chainage_m"] for s in stored["samples"]], "ground": [s["ground_elevation_m"] for s in stored["samples"]], "policy": stored["policy"], } [nodes] = _ts( tmp_path, [{"base": base, "gone": [PIPE], "born": to, "edited": list(offsets or {})}], ) 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"]): assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6) def _balanced() -> dict: """지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴).""" data = _longitudinal() 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 @pytest.mark.parametrize("make", [_longitudinal, _balanced]) def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None: """관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고.""" first = make() before = _rows(first, 0, 400) _write(tmp_path, first) after = _rows(_move(tmp_path, 356.0), 0, 400) for at, value in before.items(): if at != PIPE: assert abs(after[at] - value) < 0.01, at if make is _longitudinal: assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4) # 화면 짝 같은 꼭짓점 stored = first["profile_alignment"] base = { "basePvi": stored["base_pvi"], "chainage": [s["chainage_m"] for s in stored["samples"]], "ground": [s["ground_elevation_m"] for s in stored["samples"]], "policy": stored["policy"], } server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"] [nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "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_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None: """340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝.""" first = _longitudinal({"340.000": 0.3}) stored = copy.deepcopy(first["profile_alignment"]) _write(tmp_path, first) server = _move(tmp_path, 356.0)["profile_alignment"] old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]} new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]} assert new[340.0] == old[340.0] assert new[360.0] != old[360.0] base = { "basePvi": stored["base_pvi"], "chainage": [s["chainage_m"] for s in stored["samples"]], "ground": [s["ground_elevation_m"] for s in stored["samples"]], "policy": stored["policy"], } [nodes] = _ts( tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": ["340.000"]}] ) for mine, theirs in zip(nodes, server["base_pvi"], strict=True): assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)