Files
Aislo/resources/tester/test_351_2_curve_stations.py
eomsangdonandClaude Opus 5.5 f7bd8dbf06 fix(769-1): 추천 4 갈래 = 계산 로직 결과 그대로 — 같은 쪽 곡선 합치기 · 측점 맞춤 뺌
- 요구 611: 추천노선 = 평면계획선을 긋는 가이드 선 · 합치기 · 스냅은 예정노선 수정 뒤 평면계획선에만
- _trace = 경로 탐색 → 곡선 넣기(curve_every_kink) → 큰 R(widen) 까지 · merge_same_side · settle 뺌
- 예상노선 추적(merged_follow) · 체인 · /route/plan · replan · follow 는 합치기 · 스냅 그대로
- Pool.snap · Pool.cross · build_pool snap · cross 인자 · 엔진 fit · route_earth · merge_delta 걷음(쓰는 곳 없음)
- ALGO_VERSION 9 → 10
- 낡은 시험 고침: test_419_1 · test_351_2 추천 갈래 측점 위 기대 뺌(추적만 측점 위 · 갈래는 안 맞춤 확인 추가) ·
  test_route_initial 영선 · test_351_2 비중 기울기 상한 기대 뺌(원지반 기울기 참고만 · 비중은 벌점만) ·
  test_224_4 build_pool 엿봄 인자

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:30:29 +09:00

181 lines
7.8 KiB
Python
Raw Permalink 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.
"""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) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과는
측점에 맞춘 선만(맞추기 전 선 안 냄 · 419) · 합성 예상노선 8벌 수치.
"""
import math
import random
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
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_polyline import build_planned_polyline
from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M
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:
"""추적(평면계획선 길)만 측점 맞춤 · 추천 갈래는 계산 로직 결과 그대로(769-1 · 요구 611)."""
result = hill_runs[mode]
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)
if mode == "follow":
assert result["off_station_curves"] == 0
assert station_hits(outline) == len(outline.curves) > 0
# 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음) · 비중은 제약을 벌점으로만(넘으면 목록)
if mode in ("gentle", "earthwork"):
assert result["metrics"]["over_limit"]["total"] == 0
assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6
assert "unsnapped" not in result
def test_합성_지형_modes_before_after(hill_runs: dict) -> None:
"""합성 언덕(14 % · R12) — 추적만 측점 위 · 추천 갈래는 측점 맞춤 없음(769-1)."""
on_total, curves_total = 0, 0
for mode, result in hill_runs.items():
after = result["metrics"]
curves = len(result["curves"])
on_total += curves - result["off_station_curves"]
curves_total += curves
print(
f"{mode}: 넘은 곳 {after['over_limit']['total']}"
f" · 최대 기울기 {after['max_grade_pct']:.1f} %"
f" · 곡선 {curves}(측점 밖 {result['off_station_curves']})"
)
print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%")
assert curves_total and hill_runs["follow"]["off_station_curves"] == 0
assert on_total < curves_total # 추천 갈래는 맞추지 않음
def test_합성_예상노선_eight_outlines() -> None:
"""합성 예상노선 — R 12 · 15 · 20 · 30 × 두 선 = 8벌 측점 맞춤."""
points = [(float(x), 200.0 + 40.0 * math.sin(x / 60.0)) for x in np.arange(50.0, 752.0, 20.0)]
shifted = [(x, y + 3.0 * math.sin(x / 35.0)) for x, y in points]
full, hits, total = 0, 0, 0
for radius in (12.0, 15.0, 20.0, 30.0):
for line in (points, shifted):
before = build_planned_polyline(line, min_radius_m=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"합성 8벌 중 {full}벌 전부 · 곡선 {hits}/{total} = {100 * hits / total:.0f}% 측점 위")
assert full >= 7 and hits / total >= 0.95