Files
Aislo/resources/tester/test_419_1_recommend_engine.py
T
eomsangdonandClaude Opus 5.5 3082007dba fix(B05 노선 추천 [적용] 종단): 직선 기울기 누를 때 표고 반올림 여유 0.1 mm · 상한과 같은 균형 종단은 상한으로 (419-3)
- 검증_sub4 [적용] 뒤 종단 최대 14.47 % → 14.001 %(정본 0.1 mm 반올림 몫) → 14.0 %(넘음 표시 없음)
- test_74 변화점 계획고 비교를 0.5 mm 허용차로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-18 13:26:18 +09:00

279 lines
12 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
def _hill_expected():
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 hill, expected
def test_419_2_pick_independent_of_cache_order() -> None:
"""같은 입력 — 앞서 다른 갈래를 보였든(캐시 후보에 정밀 종단) 처음이든 같은 선(419-2)."""
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
hill, expected = _hill_expected()
def run(mode):
return generate_initial_route(
expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS
)
_POOLS.clear()
fresh = {mode: run(mode)["planned"] for mode in ("earthwork", "cost")}
_POOLS.clear()
for mode in ("gentle", "gradeline", "weighted", "cost", "earthwork", "gentle"):
run(mode)
again = {mode: run(mode)["planned"] for mode in ("earthwork", "cost")}
# 캐시 후보에 정밀 종단이 안 남음 — 고르기는 늘 빠른 판끼리(정밀은 낼 사본에만)
pool = next(iter(_POOLS.values()))
routes = [*pool.candidates, *pool.searched.values()]
assert not any((m.get("profile") or {}).get("exact") for _, m in routes)
_POOLS.clear()
assert again == fresh
def test_419_3_applied_profile_holds_grade() -> None:
"""[적용] 종단 — 종단곡선을 넣으려 꼭짓점을 밀어도 직선 · 곡선 어디서나 균형 종단 상한 안."""
from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
AlignmentPolicy,
build_curves,
evaluate,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import pass_through
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
policy = AlignmentPolicy.from_config(
station_interval_m=20.0, max_grade_pct=14.0, curve_skip_delta_pct=5.0, paved=False
)
s = np.arange(0.0, 401.0, 20.0)
grades = [0.14, 0.14, -0.02, 0.14, 0.14, 0.03, -0.14, -0.14, 0.0, 0.14]
grades += [0.10, 0.14, -0.05, -0.14, -0.14, 0.02, 0.14, 0.14, 0.0, -0.14]
plan = np.concatenate([[100.0], 100.0 + np.cumsum(np.asarray(grades) * 20.0)])
fine = np.linspace(0.0, 400.0, 4001)
def steepest(z):
curves, _ = build_curves(s, z, policy)
return float(np.abs(np.diff(evaluate(s, z, curves, fine)) / 0.1).max())
assert steepest(pass_through(s, plan, policy)) > 0.15 # 옛 맞춤 = 상한 넘음(재현)
held = held_pass_through(s, list(s), list(plan), policy)
assert steepest(held) <= 0.14 + 1e-5
assert steepest(np.round(held, 4)) <= 0.14 # 정본 0.1 mm 반올림 뒤도 상한 안
assert held[0] == pytest.approx(plan[0]) and held[-1] == pytest.approx(plan[-1])