fix(노선추천): 364-5 한 측점을 다투는 이웃 곡선 — 덜 꺾인 쪽 끔(꺾임점째 뺌 · 안 되면 꺾임만) · 이웃 MC 20 m 안 측점 밖 쌍 없앰 · ALGO_VERSION 4

브레인 판 다섯 갈래 측점 밖 곡선 합 16 → 3(토공 8/39 → 2/34 · 영선 5/34 → 0/30 · 비중 3/35 → 1/33) · 이탈 한도 · 기울기 잣대 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-17 00:16:01 +09:00
co-authored by Claude Opus 5.5
parent 734c5e6c61
commit 63032d6cce
4 changed files with 1494 additions and 4 deletions
@@ -14,6 +14,7 @@
· 못 놓으면 다음 꺾임과 합침(같은 쪽으로 돌면 두 접선 교점 · 아니면 다음 IP 를 뺌) → 그래도 · 못 놓으면 다음 꺾임과 합침(같은 쪽으로 돌면 두 접선 교점 · 아니면 다음 IP 를 뺌) → 그래도
안 되면 그 곡선을 끔(꺾임만) → 그래도 이탈 한도를 넘으면 실패. 안 되면 그 곡선을 끔(꺾임만) → 그래도 이탈 한도를 넘으면 실패.
· 모두 놓는 길이 없으면 못 놓는 곡선만 그 자리에 두고 나머지는 맞춤(부분 · 「측점 밖 곡선 N개」). · 모두 놓는 길이 없으면 못 놓는 곡선만 그 자리에 두고 나머지는 맞춤(부분 · 「측점 밖 곡선 N개」).
그 전에 이웃 곡선과 한 측점을 다투면 덜 꺾인 쪽을 끔 — 꺾임점째 빼거나 꺾임만(364-5).
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀. · 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점 고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점
@@ -25,7 +26,7 @@ from __future__ import annotations
import math import math
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from dataclasses import dataclass from dataclasses import dataclass, replace
import numpy as np import numpy as np
import shapely import shapely
@@ -362,11 +363,63 @@ class _Snapper:
kink.flags[k], kink.radii[k] = False, None kink.flags[k], kink.radii[k] = False, None
yield self._kink(state, kink) yield self._kink(state, kink)
if self.keep: if self.keep:
yield from self._crowded(state)
yield from self._kept(state) yield from self._kept(state)
def _kept(self, state: _State) -> Iterator[_State]: def _crowded(self, state: _State) -> Iterator[_State]:
"""못 놓는 곡선은 그 자리 그대로(브레인 확정 · 측점 밖 곡선으로 셈).""" """이웃 곡선과 한 측점을 다툼(꺾임점 사이 20 m 안 · 364-5) — 덜 꺾인 쪽 곡선을 끔.
끈 꺾임이 원래 선에서 한도 안일 때만. 이 곡선을 끄면 꺾임만 · 뒤 곡선을 끄면 이 곡선을
다시 놓아 봄(못 놓으면 그 자리). 앞 곡선은 이미 정해져 이 곡선만 끔.
"""
plan, k = state.plan, state.k plan, k = state.plan, state.k
p = plan.points
def close(i: int) -> bool:
return 1 <= i < len(p) - 1 and plan.flags[i] and math.dist(p[i], p[k]) < STATION_M
pair = [k, k + 1] if close(k + 1) else []
# 덜 꺾인 쪽부터 · 그걸 꺼도 못 놓으면 다른 쪽
pair.sort(key=lambda i: -_inner_angle_deg(*p[i - 1 : i + 2]))
found = _try(state, plan, k, p[k], plan.radii[k] or self.min_radius)
# 앞 곡선 MC 측점에서 20 m 안(남는 측점 없음)도 다툼
crowded = (
found is not None
and 0 < state.last_station
and (found[1] - state.last_station < STATION_M)
)
if (close(k - 1) or crowded) and k not in pair:
pair.append(k)
for small in pair:
a, at, b = p[small - 1 : small + 2]
chord = [
(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) for t in (0.25, 0.5, 0.75)
]
# 꺾임점째 뺌(이웃 곡선 접선 자리가 넓어짐) → 안 되면 꺾임만 남김. 이 꺾임점은 앞
# 곡선이 꺾임만일 때만 뺌(앞 곡선 EC 가 이 꺾임점 쪽 직선 위)
movable = small > k or not plan.flags[k - 1]
if movable and self.reference.gap(chord) <= self.max_gap and not self._steeper(chord):
gone = plan.copy()
del gone.points[small], gone.flags[small], gone.radii[small]
yield replace(state, plan=gone)
piece = [
((a[0] + at[0]) / 2, (a[1] + at[1]) / 2),
at,
((at[0] + b[0]) / 2, (at[1] + b[1]) / 2),
]
if self.reference.gap(piece) > self.max_gap or self._steeper(piece):
continue
off = plan.copy()
off.flags[small], off.radii[small] = False, None
if small == k:
yield self._kink(state, off)
else:
yield from self._placements(state, off)
yield from self._kept(state, off)
def _kept(self, state: _State, plan: _Plan | None = None) -> Iterator[_State]:
"""못 놓는 곡선은 그 자리 그대로(브레인 확정 · 측점 밖 곡선으로 셈)."""
plan, k = plan or state.plan, state.k
found = _try(state, plan, k, plan.points[k], plan.radii[k] or self.min_radius) found = _try(state, plan, k, plan.points[k], plan.radii[k] or self.min_radius)
if found is None: if found is None:
return return
@@ -32,7 +32,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
) )
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
ALGO_VERSION = 3 # 3 = 곡선 MC 일반측점(351-2) ALGO_VERSION = 4 # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5)
STORE_DIR = "B05_Profile/route_recommend" STORE_DIR = "B05_Profile/route_recommend"
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음 # 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
KEEP_PER_KIND = 2 KEEP_PER_KIND = 2
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
"""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`)
+ 검증_sub1 지형. 측점 밖 곡선 수 · 넘은 곳 · 최대 기울기를 맞추기 전 · 옛 맞춤 · 새 맞춤으로 찍고
이웃 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_geometry import 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
HERE = Path(__file__).resolve().parent
PROJECT = HERE.parents[1] / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391"
SURFACE = PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz"
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
]
def test_req239_five_modes_before_after() -> None:
if not SURFACE.is_file():
pytest.skip("검증_sub1 storage 없음")
cached = np.load(SURFACE, allow_pickle=False)
terrain = Terrain(cached["x"], cached["y"], cached["z"], cached["valid_mask"])
expected = [
(v.x, v.y)
for v in read_planned_route_csv(PROJECT / "B05_Profile/route/expected_route.csv").vertices
]
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