- 420 cross_earthwork · cross_earthwork_delta 이음 — 합치기 멈춤 = 계획고 지반고 노선 횡단 +10 % - 토공 · 공사비 줄 세움 = 상한 누른 계획선 횡단(종단 켬) · 지반고 횡단(종단 끔) - 위 후보 수 3 → 5(검증_sub4 균형 종단 토공 최소 = 줄 세움 5 위) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
215 lines
9.2 KiB
Python
215 lines
9.2 KiB
Python
"""419 노선 추천 엔진 — 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · 합치기 멈춤 = 노선 토공.
|
|
|
|
./venv/Scripts/python.exe -m pytest resources/tester/test_419_1_recommend_engine.py -q
|
|
|
|
· 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · `grade` 수에 참고로만).
|
|
· 꺾임만(R 없는 교점) → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤(직접 잼)도 꺾임만을 안 냄.
|
|
· 합치기 멈춤 = 노선 토공 +10 % — 바뀐 측점 횡단 차이(`cross_earthwork_delta`)를 주면 그 값으로.
|
|
· 종단 반영 켬 토공 고르기 = 줄 세운 위 3 개만 균형 종단 · 시종점 기울기 알림 · 맞추기 전 선 안 냄.
|
|
"""
|
|
|
|
import math
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial import ends_grade, generate_initial_route
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
|
|
TOTAL_EARTH_TOL,
|
|
Guard,
|
|
RouteEarth,
|
|
merge_same_side,
|
|
)
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import SHORTLIST, choose, hard_limits
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, curve_every_kink
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
|
|
off_station_count,
|
|
snap_curves_to_stations,
|
|
)
|
|
from common_util.common_util_route_polyline import build_planned_polyline
|
|
|
|
RADIUS = 12.0
|
|
SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)]
|
|
|
|
|
|
def _kinks(outline) -> int:
|
|
return sum(1 for flag in _flat(outline)[1][1:-1] if not flag)
|
|
|
|
|
|
def _flat_terrain(width=802.0, height=402.0) -> Terrain:
|
|
x = np.arange(0.0, width, 2.0)
|
|
y = np.arange(0.0, height, 2.0)
|
|
xx, _ = np.meshgrid(x, y)
|
|
return Terrain(x, y, np.zeros(xx.shape), np.ones(xx.shape, dtype=bool))
|
|
|
|
|
|
def test_hard_limits_leave_grade_as_reference() -> None:
|
|
metrics = {
|
|
"over_limit": {"grade": 3, "radius": 1, "stream": 1, "total": 5},
|
|
"violations": [{"kind": "grade"}] * 3 + [{"kind": "radius"}, {"kind": "stream"}],
|
|
}
|
|
hard = hard_limits(metrics)
|
|
assert hard["over_limit"] == {"grade": 3, "radius": 1, "stream": 1, "total": 2}
|
|
assert hard["violations"] == metrics["violations"] # 목록엔 참고로 남음
|
|
|
|
|
|
def test_kink_becomes_curve_even_gentle() -> None:
|
|
"""내각 160° 꺾임만(곡선 끔) → 곡선(최소 R) · 155° 이상도 곡선 둠."""
|
|
points = [(0.0, 0.0), (100.0, 0.0), (200.0, 100.0 * math.tan(math.radians(20.0)))]
|
|
kinked = build_planned_polyline(
|
|
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, False, True]
|
|
)
|
|
assert _kinks(kinked) == 1
|
|
fixed = curve_every_kink(kinked, RADIUS)
|
|
assert _kinks(fixed) == 0 and len(fixed.curves) == 1
|
|
assert fixed.curves[0].radius_m >= RADIUS - 1e-6 and not fixed.curves[0].violations
|
|
|
|
|
|
def test_kink_without_room_is_removed() -> None:
|
|
"""앞뒤 직선이 짧아 최소 R 이 안 드는 꺾임만 → 교점 지움(곧게)."""
|
|
points = [(0.0, 0.0), (100.0, 0.0), (103.0, 2.0), (200.0, 2.0)]
|
|
kinked = build_planned_polyline(
|
|
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, True, False, True]
|
|
)
|
|
fixed = curve_every_kink(kinked, RADIUS)
|
|
assert _kinks(fixed) == 0
|
|
assert len(_flat(fixed)[0]) == 3 # 짧은 쪽 교점 하나 빠짐
|
|
|
|
|
|
def _zigzag(seed: int) -> list[tuple[float, float]]:
|
|
rng = np.random.default_rng(seed)
|
|
points, heading = [(60.0, 200.0)], 0.0
|
|
for _ in range(10):
|
|
heading += float(rng.uniform(-0.6, 0.6))
|
|
step = float(rng.uniform(40.0, 80.0))
|
|
x, y = points[-1]
|
|
points.append((x + step * math.cos(heading), y + step * math.sin(heading)))
|
|
return points
|
|
|
|
|
|
@pytest.mark.parametrize("seed", range(6))
|
|
def test_guard_snap_no_kinks_all_on_station(seed: int) -> None:
|
|
"""직접 잼 측점 맞춤(추천 후보) — 평지 · 꺾임만 섞인 선 → 꺾임만 0 · 측점 밖 0."""
|
|
points = _zigzag(seed)
|
|
flags = [True] + [i % 3 != 1 for i in range(1, len(points) - 1)] + [True]
|
|
before = build_planned_polyline(points, min_radius_m=RADIUS, simplify=False, curve_flags=flags)
|
|
lo = np.min(points, axis=0) - 150.0
|
|
hi = np.max(points, axis=0) + 150.0
|
|
x = np.arange(lo[0], hi[0], 2.0)
|
|
y = np.arange(lo[1], hi[1], 2.0)
|
|
terrain = Terrain(x, y, np.zeros((len(y), len(x))), np.ones((len(y), len(x)), dtype=bool))
|
|
guard = Guard(SearchGrid(points, terrain, [], stream_offset_m=100.0), 0.14)
|
|
after = snap_curves_to_stations(before, RADIUS, guard=guard)
|
|
assert after is not None
|
|
assert _kinks(after) == 0
|
|
assert off_station_count(after) == 0
|
|
|
|
|
|
FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0}
|
|
|
|
|
|
def _merge(earth_base: float, delta: float):
|
|
"""평지 같은 쪽 두 꺾임 — 노선 토공 `earth_base` · 합친 선 차이 `delta`(가짜 횡단 함수)."""
|
|
outline = build_planned_polyline(
|
|
SAME, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(SAME)
|
|
)
|
|
base = {**FLAT, "cut_m3": 0.0, "fill_m3": 0.0} # 띠 가늠은 안 씀
|
|
calls = []
|
|
|
|
def cross(before, after, window):
|
|
calls.append(window)
|
|
return delta
|
|
|
|
guard = Guard(SearchGrid(SAME, _flat_terrain(402.0, 262.0), [], stream_offset_m=100.0), 0.14)
|
|
earth = RouteEarth(lambda line: earth_base, cross)
|
|
merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, earth)[0]
|
|
return merged, calls
|
|
|
|
|
|
def test_merge_stops_at_route_earth_tolerance() -> None:
|
|
"""합치기 판정 = 바뀐 측점 횡단 차이 — +10 % 안이면 합치고 넘으면 안 합침(엔진 상수)."""
|
|
assert TOTAL_EARTH_TOL == pytest.approx(0.10)
|
|
merged, calls = _merge(1000.0, 90.0)
|
|
assert len(merged.curves) == 1 and calls
|
|
s0, s1 = calls[0]
|
|
assert 0.0 <= s0 < s1 # 바뀐 자리(앞 선 누가거리)만 넘김
|
|
blocked, calls = _merge(1000.0, 110.0)
|
|
assert len(blocked.curves) == 2 and calls
|
|
|
|
|
|
def _route(name: str, earth: float, station: int = 0):
|
|
metrics = {
|
|
"name": name,
|
|
"cut_m3": earth,
|
|
"fill_m3": 0.0,
|
|
"cost_index": earth,
|
|
"over_limit": {"total": station, "station": station},
|
|
}
|
|
return (object(), metrics)
|
|
|
|
|
|
def test_choose_profiles_only_shortlist() -> None:
|
|
"""종단 반영 켬 토공 = 노선 횡단 토공(줄 세움) 위 `SHORTLIST` 개만 균형 종단 · 그 사이에서 고름 ·
|
|
줄 세움 = `earth`(켬 = 상한 누른 계획선 · 끔 = 지반고) · 없으면 띠 가늠."""
|
|
routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(12)] + [_route("off", 1.0, 1)]
|
|
last = f"r{SHORTLIST - 1}"
|
|
profiled = []
|
|
|
|
def profile(route):
|
|
profiled.append(route[1]["name"])
|
|
# 균형 종단 토공은 순서가 바뀜 — 줄 세운 마지막이 가장 작음 · 밖은 더 작아도 안 봄
|
|
earth = 300.0 if route[1]["name"] == last else 900.0
|
|
route[1]["profile"] = {"cut_m3": earth, "fill_m3": 0, "borrow_m3": 0, "spoil_m3": 0}
|
|
|
|
chosen = choose("earthwork", routes, True, profile)
|
|
assert profiled == [f"r{i}" for i in range(SHORTLIST)]
|
|
assert chosen[1]["name"] == last
|
|
# 종단 끔 = 줄 세우기 값 그대로 · 종단 안 붙임
|
|
profiled.clear()
|
|
assert choose("earthwork", routes, False, profile)[1]["name"] == "r0" and not profiled
|
|
# 횡단 줄 세움이 띠 가늠과 다르면 그것을 따름(끔 = 지반고 횡단 하나로 고름)
|
|
cross = {f"r{i}": 1000.0 - i for i in range(12)}
|
|
|
|
def earth(route, capped):
|
|
return cross.get(route[1]["name"], 0.0) + (0.0 if capped else 5.0)
|
|
|
|
assert choose("earthwork", routes, False, None, earth)[1]["name"] == "r11"
|
|
|
|
|
|
def test_ends_grade() -> None:
|
|
x = np.arange(0.0, 402.0, 2.0)
|
|
y = np.arange(0.0, 102.0, 2.0)
|
|
xx, _ = np.meshgrid(x, y)
|
|
slope = Terrain(x, y, 0.3 * xx, np.ones(xx.shape, dtype=bool))
|
|
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 200.0) == pytest.approx(30.0)
|
|
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 600.0) == pytest.approx(10.0)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def hill_runs() -> dict:
|
|
x = np.arange(0.0, 802.0, 2.0)
|
|
y = np.arange(0.0, 402.0, 2.0)
|
|
xx, yy = np.meshgrid(x, y)
|
|
hill = Terrain(x, y, 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2), xx > -1)
|
|
expected = [(float(v), 200.0 + 40.0 * math.sin(v / 60.0)) for v in np.arange(50.0, 752.0, 4.0)]
|
|
return {
|
|
mode: generate_initial_route(
|
|
expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS
|
|
)
|
|
for mode in ("gradeline", "earthwork", "gentle", "weighted")
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"])
|
|
def test_hill_branches_rules(hill_runs: dict, mode: str) -> None:
|
|
"""언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음."""
|
|
result = hill_runs[mode]
|
|
interior = result["nodes"][1:-1]
|
|
assert interior and all(node["radius_m"] is not None for node in interior) # 삼각 0
|
|
assert result["off_station_curves"] == 0
|
|
over = result["metrics"]["over_limit"]
|
|
assert over["total"] == over["radius"] + over["stream"] + over.get("station", 0)
|
|
assert "unsnapped" not in result
|
|
assert result["ends_over_limit"] is False and result["ends_grade_pct"] < 14.0
|