155 lines
6.0 KiB
Python
155 lines
6.0 KiB
Python
"""364-5 한 측점을 다투는 가까운 두 곡선 — `B05_Profile_Engine_RouteInitial_Station._crowded`.
|
||
|
||
./venv/Scripts/python.exe -m pytest resources/tester/test_364_5_crowded_curves.py -q -s
|
||
|
||
자료 = 브레인 검증 판(요구 239) 다섯 갈래의 맞추기 전 선(`fixtures/b05_364_5_unsnapped_req239.json`)
|
||
+ 작은 합성 NPZ 지형. 측점 밖 곡선 수 · 넘은 곳 · 최대 기울기를 맞추기 전 · 새 맞춤으로 찍고
|
||
이웃 MC 20 m 안 측점 밖 쌍 0 · 측점 밖 합계가 옛 맞춤의 1/4 이하 · 이탈 한도 안을 봄.
|
||
"""
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pytest
|
||
from scipy.interpolate import RegularGridInterpolator
|
||
from shapely.geometry import LineString
|
||
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||
SAMPLE_M,
|
||
_resample,
|
||
route_metrics,
|
||
smoothed_ground,
|
||
)
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
|
||
MC_TOL_M,
|
||
STATION_M,
|
||
curve_mc_chainages,
|
||
off_station_count,
|
||
snap_curves_to_stations,
|
||
)
|
||
from common_util.common_util_route_polyline import build_planned_polyline
|
||
from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
CASES = json.loads((HERE / "fixtures/b05_364_5_unsnapped_req239.json").read_text(encoding="utf-8"))
|
||
|
||
|
||
def _ls(vertices: list) -> LineString:
|
||
return LineString([xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]])
|
||
|
||
|
||
def _outline(case: dict):
|
||
nodes = case["nodes"]
|
||
return build_planned_polyline(
|
||
[(n["x"], n["y"]) for n in nodes],
|
||
min_radius_m=case["criteria"]["min_radius_m"],
|
||
simplify=False,
|
||
curve_flags=[n["radius_m"] is not None for n in nodes],
|
||
radii=[n["radius_m"] for n in nodes],
|
||
)
|
||
|
||
|
||
def _excess(terrain: Terrain, max_grade: float, smooth: float):
|
||
"""`generate_initial_route.grade_excess` 와 같은 잣대(20 m 고른 지반 기울기 − 상한)."""
|
||
surface = RegularGridInterpolator(
|
||
(terrain.y, terrain.x),
|
||
smoothed_ground(terrain, smooth),
|
||
bounds_error=False,
|
||
fill_value=None,
|
||
)
|
||
|
||
def excess(points):
|
||
xy, chain = _resample(points, SAMPLE_M)
|
||
z = surface(xy[:, ::-1])
|
||
span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1)
|
||
if span < 1:
|
||
return 0.0
|
||
run = np.maximum(chain[span:] - chain[:-span], 1e-9)
|
||
return max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - max_grade)
|
||
|
||
return excess
|
||
|
||
|
||
def _off_mcs(outline) -> list[float]:
|
||
return [
|
||
mc
|
||
for mc in curve_mc_chainages(outline)
|
||
if abs(mc - STATION_M * round(mc / STATION_M)) > MC_TOL_M
|
||
]
|
||
|
||
|
||
@pytest.fixture
|
||
def synthetic_surface(tmp_path: Path) -> Path:
|
||
nodes = [node for case in CASES for node in case["nodes"]]
|
||
x = np.arange(min(n["x"] for n in nodes) - 20, max(n["x"] for n in nodes) + 22, 2.0)
|
||
y = np.arange(min(n["y"] for n in nodes) - 20, max(n["y"] for n in nodes) + 22, 2.0)
|
||
xx, yy = np.meshgrid(x, y)
|
||
z = 0.04 * (xx - x[0]) + 2.0 * np.sin((yy - y[0]) / 45.0)
|
||
path = tmp_path / "crowded_surface.npz"
|
||
np.savez(path, x=x, y=y, z=z, valid_mask=np.ones(z.shape, dtype=bool))
|
||
return path
|
||
|
||
|
||
def test_req239_five_modes_before_after(synthetic_surface: Path) -> None:
|
||
cached = np.load(synthetic_surface, allow_pickle=False)
|
||
terrain = Terrain(cached["x"], cached["y"], cached["z"], cached["valid_mask"])
|
||
expected = [(node["x"], node["y"]) for node in CASES[0]["nodes"]]
|
||
old_total = new_total = 0
|
||
for case in CASES:
|
||
cr = case["criteria"]
|
||
before = _outline(case)
|
||
follow = case["mode"] == "follow"
|
||
excess = (
|
||
None if follow else _excess(terrain, cr["max_grade_pct"] / 100, cr["grade_smooth_m"])
|
||
)
|
||
after = snap_curves_to_stations(before, cr["min_radius_m"], excess=excess)
|
||
assert after is not None
|
||
|
||
def grade(outline):
|
||
m = route_metrics(
|
||
outline.vertices,
|
||
expected,
|
||
terrain,
|
||
[],
|
||
outline.curves,
|
||
max_grade=cr["max_grade_pct"] / 100,
|
||
min_radius_m=cr["min_radius_m"],
|
||
stream_offset_m=cr["stream_offset_m"],
|
||
road_width_m=cr["road_width_m"],
|
||
bound_pct=None,
|
||
grade_smooth_m=cr["grade_smooth_m"],
|
||
)
|
||
return m["over_limit"]["total"], m["max_grade_pct"]
|
||
|
||
(o0, g0), (o1, g1) = grade(before), grade(after)
|
||
off = off_station_count(after)
|
||
print(
|
||
f"{case['mode']}: 측점 밖 {case['old']['off']}/{case['old']['curves']}(옛)"
|
||
f" → {off}/{len(after.curves)} · 넘은 곳 {o0} → {o1}"
|
||
f" · 최대 기울기 {g0:.1f} → {g1:.1f} %(맞추기 전 → 새)"
|
||
)
|
||
old_total += case["old"]["off"]
|
||
new_total += off
|
||
# 다투는 쌍 없음 — 이웃 측점 밖 곡선 MC 가 20 m 안이면 한 측점을 다툼
|
||
mcs = _off_mcs(after)
|
||
assert all(b - a >= STATION_M for a, b in zip(mcs, mcs[1:])), mcs
|
||
gap = _ls(after.vertices).hausdorff_distance(_ls(before.vertices))
|
||
assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05
|
||
assert after.vertices[0] == pytest.approx(before.vertices[0], abs=1e-3)
|
||
assert after.vertices[-1] == pytest.approx(before.vertices[-1], abs=1e-3)
|
||
print(f"측점 밖 곡선 합 {old_total} → {new_total}")
|
||
assert new_total * 4 <= old_total
|
||
|
||
|
||
def test_crowded_pair_drops_one() -> None:
|
||
"""11 m 사이 두 곡선 · 이탈 한도 1 m(측점 옮김 막힘) — 옛 맞춤은 둘 다 그 자리(44.8 · 55.8)."""
|
||
points = [(0.0, 0.0), (45.0, 0.0), (54.0, 7.0), (58.0, 70.0)]
|
||
before = build_planned_polyline(
|
||
points, min_radius_m=12.0, simplify=False, curve_flags=[True] * 4
|
||
)
|
||
after = snap_curves_to_stations(before, 12.0, max_gap_m=1.0)
|
||
assert after is not None and len(after.curves) == 1
|
||
assert _ls(after.vertices).hausdorff_distance(_ls(before.vertices)) <= 1.0 + 0.05
|