Files
Aislo/resources/tester/test_432_1_curve_law.py
T
eomsangdonandClaude Opus 5.5 0714e9653e feat(B05 종단): 종단곡선 기본 L 20 m · 초기값 R 500 m 넘는 꼭짓점 곡선 없음 · 법정 R · L 미달 경고 (432-1)
- config 기본 L 20 · 초기값 직선 문턱 500 m ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` 상수 짝
- 초기값(1차 지반 종단 · 추천 [적용])만 정책 `no_curve_keys` 로 얼림 · 손 편집 · R 이면 기본 곡선
- 법정 종단곡선 최소 R · L 미달 = 경고 글(파이썬 · TS 같은 글) + 종단 요약줄 「종단곡선 미달」
- 노선 [저장]은 옛 정책(L · 열쇠) 이어받음 · 바뀐 자리 새 꼭짓점만 새 지반 열쇠(`carry_policy` ↔ `carryNoCurveKeys`)
- 시험 test_432_1 + 헬퍼 스텁 넷

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-18 16:20:03 +09:00

290 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""432-1 종단곡선 기본 L 20 m · 법정 미달 경고 · 초기값 R > 500 m 꼭짓점 = 곡선 없음(요구 262).
파이썬 `B05_Profile_Engine_Grade_CurveLaw.py` · `design_ground_following_profile` ·
`build_alignment`
(정책 `no_curve_keys`) ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` · `_Grade_Ground.ts`
`groundFollowingPlan` · `_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 ( # noqa: E402
carry_base,
carry_no_curve_keys,
carry_policy,
)
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys # 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)]
NO_CURVE = ["20.000", "60.000", "100.000", "160.000", "180.000"]
CURVED = [40.0, 80.0, 120.0, 140.0]
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}}, # 이웃 손 편집 → 60 · 100 은 얼린 채
{"curve_radii": {"100.000": 800.0}}, # R 넣음 → 곡선
]
# 노선 [저장] — 끝 노드만 옮김(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)],
"old_keys": NO_CURVE,
"ground_keys": ["140.000", "180.000"],
"ground_pvi": [{"chainage_m": s, "elevation_m": 0.0} for s in NEW_STATIONS],
}
# 같은 자리 옛 꼭짓점 열쇠(20 · 60 · 100) + 바뀐 자리 새 지반 열쇠(140 · 180) · 옛 160 은 버림
CARRIED = ["20.000", "60.000", "100.000", "140.000", "180.000"]
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_초기값_대수차_4퍼센트_안쪽은_곡선_없음(initial):
_long, _options, alignment, _profile = initial
assert alignment["policy"]["default_curve_length_m"] == 20.0
assert alignment["policy"]["no_curve_keys"] == NO_CURVE
assert _curve_at(alignment) == CURVED
# 꼭짓점 높이 그대로 — 곡선 없는 측점 계획고 = 지반고(원지반 지남)
rows = {round(r["chainage_m"], 3): r for r in alignment["stations"]}
for key in NO_CURVE:
row = rows[float(key)]
assert row["plan_elevation_m"] == pytest.approx(row["ground_elevation_m"], abs=1e-4)
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 == [18.0] * len(CURVED)
reduced = [w for w in alignment["warnings"] if "줄였습니다" in w]
short = [w for w in alignment["warnings"] if "미달" in w]
assert len(reduced) == len(CURVED) and len(short) == len(CURVED)
assert short[0].startswith("40.0m: 종단곡선 R") and "법정 최소 R 100m · L 20m 미달" in short[0]
def test_손_편집은_기본_곡선_이웃은_얼린_채(initial):
alignment = initial[2]
own = _rebuild(alignment, EDITS[1])
assert 60.0 in _curve_at(own)
neighbor = _rebuild(alignment, EDITS[2])
assert 60.0 not in _curve_at(neighbor) and 100.0 not in _curve_at(neighbor)
assert 80.0 in _curve_at(neighbor)
radius = _rebuild(alignment, EDITS[3])
assert 100.0 in _curve_at(radius)
def test_옛_저장분은_그대로(initial):
alignment = initial[2]
old = {**alignment["policy"], "default_curve_length_m": 15.0}
old.pop("no_curve_keys")
got = _rebuild(alignment, {}, old)
assert len(got["curves"]) == len(STATIONS) - 2 # 꼭짓점마다 곡선(옛 규칙)
def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path):
longitudinal, options, alignment, _profile = initial
payload = {
"ground": {
"samples": longitudinal["samples"],
"stations": STATIONS,
"policy": {**alignment["policy"], "no_curve_keys": []},
"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],
"carry": {
**CARRY,
"old_xy": [{"x": x, "y": y} for x, y in CARRY["old_xy"]],
"new_xy": [{"x": x, "y": y} for x, y in CARRY["new_xy"]],
},
}
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 ts["keys"] == NO_CURVE
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
)
assert ts["carried"] == CARRIED
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_추천_적용_균형_종단에서_열쇠(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")
# 균형 종단 — 100 m 를 2 m 낮춤(그 둘레 대수차가 커짐 · 나머지는 지반 그대로)
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)
stored = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]["policy"]
expected = initial_no_curve_keys(np.array(STATIONS), np.array(plan), 20.0)
assert stored["no_curve_keys"] == list(expected)
assert "100.000" not in stored["no_curve_keys"] and "20.000" in stored["no_curve_keys"]
def test_부르는_곳():
def read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
assert "skipsInitialCurve(policy, key, pvi[index], hasRequested)" 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(" 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_노선_저장_열쇠_옮김():
args = [CARRY[k] for k in ("old_xy", "new_xy", "old_keys", "ground_keys", "ground_pvi")]
assert carry_no_curve_keys(*args) == CARRIED
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 · 새 지반 열쇠) → 옛 정책 · 옛 꼭짓점 이어받음
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]
old_keys = set(old["policy"]["no_curve_keys"])
assert {k for k in policy["no_curve_keys"] if float(k) <= 100} == {
k for k in old_keys if float(k) <= 100
}