Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
228 lines
9.8 KiB
Python
228 lines
9.8 KiB
Python
"""351-2 추천 노선 곡선 MC → 일반측점 — `B05_Profile_Engine_RouteInitial_Station`.
|
|
|
|
./venv/Scripts/python.exe -m pytest resources/tester/test_351_2_curve_stations.py -q
|
|
|
|
모든 곡선 MC % 20 ≈ 0 · 이웃 MC 20 m 이상 · 곡선 수 ≤ 내부 일반측점 수 · 맞추기 전 선에서
|
|
`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과의
|
|
기울기 넘은 곳이 맞추기 전보다 늘지 않음 · 검증_sub1 예상노선 8벌 수치.
|
|
"""
|
|
|
|
import math
|
|
import random
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from shapely.geometry import LineString
|
|
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
|
|
MC_TOL_M,
|
|
STATION_M,
|
|
curve_mc_chainages,
|
|
off_station_count,
|
|
snap_curves_to_stations,
|
|
station_hits,
|
|
)
|
|
from common_util.common_util_route_geometry import densify_route, read_planned_route_csv
|
|
from common_util.common_util_route_polyline import build_planned_polyline
|
|
from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M
|
|
|
|
PROJECT = Path(__file__).resolve().parents[2] / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391"
|
|
|
|
|
|
def _ls(vertices: list) -> LineString:
|
|
"""겹친 정점 뺀 선(길이 0 조각은 GEOS 경고)."""
|
|
return LineString([xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]])
|
|
|
|
|
|
def _zigzag(seed: int) -> list[tuple[float, float]]:
|
|
rng = random.Random(seed)
|
|
points, heading = [(0.0, 0.0)], 0.0
|
|
for _ in range(12):
|
|
heading += rng.uniform(-0.8, 0.8)
|
|
step = rng.uniform(30.0, 90.0)
|
|
x, y = points[-1]
|
|
points.append((x + step * math.cos(heading), y + step * math.sin(heading)))
|
|
return points
|
|
|
|
|
|
def _check(outline, before) -> None:
|
|
mcs = curve_mc_chainages(outline)
|
|
assert outline.curves and off_station_count(outline) == 0
|
|
for mc in mcs:
|
|
assert abs(mc - STATION_M * round(mc / STATION_M)) <= MC_TOL_M and mc >= STATION_M - 1
|
|
assert all(b - a >= STATION_M - 2 * MC_TOL_M for a, b in zip(mcs, mcs[1:]))
|
|
length = _ls(outline.vertices).length
|
|
assert len(outline.curves) <= int((length - 1e-6) // STATION_M) # 내부 일반측점 수
|
|
gap = _ls(outline.vertices).hausdorff_distance(_ls(before.vertices))
|
|
assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05
|
|
# 시점 · 종점 그대로
|
|
assert outline.vertices[0] == pytest.approx(before.vertices[0], abs=1e-3)
|
|
assert outline.vertices[-1] == pytest.approx(before.vertices[-1], abs=1e-3)
|
|
|
|
|
|
def _reflattened(outline, min_radius: float):
|
|
"""화면 `flattenServerPlan` → [확인] 다시 짜기와 같은 꼴(저장 반올림: IP 0.1 mm · R 1 mm)."""
|
|
points, flags, radii = _flat(outline)
|
|
return build_planned_polyline(
|
|
[(round(x, 4), round(y, 4)) for x, y in points],
|
|
min_radius_m=min_radius,
|
|
simplify=False,
|
|
curve_flags=flags,
|
|
radii=[None if r is None else round(r, 3) for r in radii],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("seed", range(8))
|
|
def test_zigzag_mc_on_stations(seed: int) -> None:
|
|
before = build_planned_polyline(
|
|
_zigzag(seed), min_radius_m=15.0, simplify=False, curve_flags=[True] * 13
|
|
)
|
|
after = snap_curves_to_stations(before, 15.0)
|
|
assert after is not None
|
|
_check(after, before)
|
|
for curve in after.curves:
|
|
assert curve.radius_m >= 15.0 - 1e-6 or curve.violations
|
|
# 저장 뒤 다시 짜도 MC 그대로
|
|
again = _reflattened(after, 15.0)
|
|
assert curve_mc_chainages(again) == pytest.approx(curve_mc_chainages(after), abs=MC_TOL_M)
|
|
|
|
|
|
def test_no_curves_unchanged() -> None:
|
|
straight = build_planned_polyline([(0.0, 0.0), (100.0, 0.0)], min_radius_m=12.0)
|
|
assert snap_curves_to_stations(straight, 12.0) is straight
|
|
|
|
|
|
def test_too_close_curves_merge_or_drop() -> None:
|
|
"""20 m 안에 꺾임 셋 — 곡선 수 ≤ 내부 일반측점 수로 합쳐짐."""
|
|
points = [(0.0, 0.0), (40.0, 0.0), (48.0, 4.0), (54.0, 10.0), (60.0, 20.0), (60.0, 80.0)]
|
|
before = build_planned_polyline(
|
|
points, min_radius_m=12.0, simplify=False, curve_flags=[True] * 6
|
|
)
|
|
after = snap_curves_to_stations(before, 12.0)
|
|
assert after is not None and station_hits(after) == len(after.curves)
|
|
assert len(after.curves) < len(before.curves)
|
|
|
|
|
|
def _terrain() -> Terrain:
|
|
x = np.arange(0.0, 802.0, 2.0)
|
|
y = np.arange(0.0, 402.0, 2.0)
|
|
xx, yy = np.meshgrid(x, y)
|
|
z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
|
|
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def hill_runs() -> dict:
|
|
expected = [(float(x), 200.0 + 40.0 * math.sin(x / 60.0)) for x in np.arange(50.0, 752.0, 4.0)]
|
|
return {
|
|
mode: generate_initial_route(
|
|
expected, _terrain(), [], mode=mode, weights=None, max_grade=0.14, min_radius_m=12.0
|
|
)
|
|
for mode in ("follow", "gentle", "earthwork", "weighted")
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["follow", "gentle", "earthwork", "weighted"])
|
|
def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
|
|
result = hill_runs[mode]
|
|
assert result["off_station_curves"] == 0
|
|
outline = build_planned_polyline(
|
|
[tuple(n[k] for k in ("x", "y")) for n in result["nodes"]],
|
|
min_radius_m=12.0,
|
|
simplify=False,
|
|
curve_flags=[n["radius_m"] is not None for n in result["nodes"]],
|
|
radii=[n["radius_m"] for n in result["nodes"]],
|
|
)
|
|
# 화면이 펴서 다시 짠 선 = 서버 선
|
|
assert np.allclose(outline.vertices, result["planned"], atol=1e-3)
|
|
assert station_hits(outline) == len(outline.curves) > 0
|
|
if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음)
|
|
assert result["metrics"]["over_limit"]["total"] == 0
|
|
assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6
|
|
# 측점 맞춤 끔 = 맞추기 전 선(같으면 None) — 비교값 따로
|
|
raw = result["unsnapped"]
|
|
if raw is not None:
|
|
assert raw["planned"] != result["planned"] and raw["metrics"]["length_m"] > 0
|
|
assert set(raw) == {"planned", "nodes", "curves", "off_station_curves", "metrics"}
|
|
|
|
|
|
def _project_terrain():
|
|
from pyproj import Transformer
|
|
|
|
from B04_PreProcess.B04_PreProcess_Router_Watershed import (
|
|
STREAM_FILE,
|
|
_load_features,
|
|
_reproject_features,
|
|
)
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial import stream_lines
|
|
from common_util.common_util_crs import resolve_project_crs
|
|
|
|
cached = np.load(
|
|
PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz",
|
|
allow_pickle=False,
|
|
)
|
|
terrain = Terrain(cached["x"], cached["y"], cached["z"], cached["valid_mask"])
|
|
to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(PROJECT), always_xy=True)
|
|
features = _load_features(PROJECT / "B04_PreProcess" / "processed", STREAM_FILE)
|
|
return terrain, stream_lines(_reproject_features(features, to_metric))
|
|
|
|
|
|
def test_project_modes_before_after() -> None:
|
|
"""검증_sub1 실지형(14 % · R12) — 갈래별 측점 맞춤 전후 넘은 곳 · 최대 기울기 · 측점 밖 곡선."""
|
|
if not (PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz").is_file():
|
|
pytest.skip("검증_sub1 storage 없음")
|
|
terrain, streams = _project_terrain()
|
|
path = PROJECT / "B05_Profile/route/expected_route.csv"
|
|
expected = [(v.x, v.y) for v in read_planned_route_csv(path).vertices]
|
|
on_total, curves_total = 0, 0
|
|
for mode in ("follow", "gradeline", "earthwork", "gentle", "weighted"):
|
|
result = generate_initial_route(
|
|
expected,
|
|
terrain,
|
|
streams,
|
|
mode=mode,
|
|
weights=None,
|
|
max_grade=0.14,
|
|
min_radius_m=12.0,
|
|
with_profile=False,
|
|
)
|
|
raw = result["unsnapped"] or result
|
|
after, before = result["metrics"], raw["metrics"]
|
|
curves = len(result["curves"])
|
|
on_total += curves - result["off_station_curves"]
|
|
curves_total += curves
|
|
print(
|
|
f"{mode}: 넘은 곳 {before['over_limit']['total']} → {after['over_limit']['total']}"
|
|
f" · 최대 기울기 {before['max_grade_pct']:.1f} → {after['max_grade_pct']:.1f} %"
|
|
f" · 곡선 {len(raw['curves'])} → {curves}(측점 밖 {result['off_station_curves']})"
|
|
)
|
|
assert raw["off_station_curves"] >= result["off_station_curves"]
|
|
print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%")
|
|
assert on_total / curves_total >= 0.75
|
|
|
|
|
|
def test_project_eight_outlines() -> None:
|
|
"""검증_sub1 예상노선 — R 12 · 15 · 20 · 30 × 추적 · 큰 R 8벌 수치(부분 맞춤 허용 · 브레인)."""
|
|
path = PROJECT / "B05_Profile/route/expected_route.csv"
|
|
if not path.is_file():
|
|
pytest.skip("검증_sub1 storage 없음")
|
|
points = [(v.x, v.y) for v in read_planned_route_csv(path).vertices]
|
|
full, hits, total = 0, 0, 0
|
|
for radius in (12.0, 15.0, 20.0, 30.0):
|
|
follow = build_planned_polyline(points, min_radius_m=radius)
|
|
for before in (follow, widen(follow, densify_route(points, 2.0), radius)):
|
|
after = snap_curves_to_stations(before, radius)
|
|
assert after is not None
|
|
on = station_hits(after)
|
|
full += on == len(after.curves)
|
|
hits, total = hits + on, total + len(after.curves)
|
|
gap = _ls(after.vertices).hausdorff_distance(_ls(before.vertices))
|
|
assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05
|
|
print(
|
|
f"검증_sub1 8벌 중 {full}벌 전부 · 곡선 {hits}/{total} = {100 * hits / total:.0f}% 측점 위"
|
|
)
|
|
assert full >= 7 and hits / total >= 0.95
|