Files
Aislo/resources/tester/test_364_5_crowded_curves.py
T

155 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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