Files
Aislo/B05_Profile/B05_Profile_Engine_RouteInitial_Follow.py
T
eomsangdonandClaude Opus 5.5 c187f30e8e feat(758-1): 예정노선 따라가기에도 토공 최소 곡선 최적 — 큰 R · 같은 쪽 곡선 합치기 · 직접 잼 측점 맞춤
- 새 `B05_Profile_Engine_RouteInitial_Follow.merged_follow` — 예정노선 폴리라인 → 큰 R(widen) → 꺾임만 곡선 → 같은 쪽 합치기(merge_same_side · 노선 횡단 토공 +10 % 멈춤) → 곡선 MC 일반측점 맞춤 · 잼 = 토공 최소와 같은 Guard(지형 · 계류 격자) · 같은 입력 기억
- 한 함수 — 체인 처음 · /route/plan · /route/replan(`initial_land` · 초기선 낡을 때만 지형 읽음) · /route/follow(`follow_land`) · 추천 「예상노선 추적」(follow_line · 묶음 격자 재사용)
- 지형 못 읽으면 옛 길(순수 기하) · 기존 확정 노선 · 초기선 · 저장본은 안 바꿈(다음 체인 · 끌 때부터)
- 화면 — 따라가기 뒤 지금 추천 = 예상노선 추적(「토공 최소」 꼬리표 남던 흠) · 걸음 사진에 실려 되돌리기 짝
- 엔진 합치기 잣대 셈(cross_total · cross_delta)은 새 파일 한 벌로 옮김(엔진 줄 줄어듦)
- 시험: test_758_1 새 지킴 · test_383_1 은 체인 초기선도 같은 지형을 넘기게 고침(추천 추적 = 곡선 최적이라 순수 기하와 달라짐) · test_626_3 은 onFollow 새 꼴로 글 대조 고침
- 검증_sub1: 곡선 25 → 16 · 측점 16/16 · 예정노선과 최대 10.6 m · 끌 때 1.4~1.9 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:38:17 +09:00

182 lines
6.9 KiB
Python

"""예정노선 따라가기 곡선 최적(758-1 · 요구 598-1) — 따라간 선에 「토공 최소」와 같은 둘째 단계.
`B05_Profile_Route_Polyline_Write.followed_table`(체인 처음 · `/route/follow` · 받은 노선으로) ·
추천 「예상노선 추적」(`..._RouteInitial.generate_initial_route` `follow_line`)이 같이 부름.
차례(`merged_follow`) = ① 예정노선 폴리라인(꺾임점 · 묶기 · R 맞춤 · 기본 규칙) → 큰 R
(`..._Radius.widen`) → 꺾임만 곡선으로(`curve_every_kink`) → 같은 쪽 이웃 곡선 합치기
(`..._Merge.merge_same_side`) → ② 곡선 MC 일반측점 맞춤. 큰 R · 합치기 · 측점 맞춤 모두 직접 잼
(`Guard` · 지형 · 계류 격자 · 토공 최소 갈래와 같은 판 — 이격도 판 `keeping_offset` 은 합치기 0 ·
측점 맞춤이 더 벗어나 안 씀) · 합치기 멈춤 = 못 지킨 곳 수 · 노선 횡단 토공 +10 %.
지형이 없으면(`FollowLand` None) 부르는 쪽이 옛 길(순수 기하 `snapped_initial`)을 씀.
"""
from __future__ import annotations
import math
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import (
cross_earthwork,
cross_earthwork_delta,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import STATION_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CORRIDOR_M,
ROAD_WIDTH_M,
SearchGrid,
Terrain,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snap_curves_to_stations
from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline
Xy = tuple[float, float]
# 같은 입력 다시 셈 막기(끌 때 · 체인 · 추천이 같은 선을 거듭 부름) — 최근 몇 벌만
MEMO_SIZE = 8
_MEMO: OrderedDict[tuple, PlannedPolyline] = OrderedDict()
@dataclass(frozen=True)
class FollowLand:
"""따라가기 곡선 최적의 지형 · 제약 — 추천 엔진과 같은 풀이 값(`resolve_inputs` 뒤)."""
terrain: Terrain
streams: list[list[Xy]]
max_grade: float
stream_offset_m: float
grade_smooth_m: float
target_grade: float
standard: dict[str, Any] | None = None
corridor_m: float = CORRIDOR_M
def cross_total(line: PlannedPolyline, terrain: Terrain, standard: dict[str, Any] | None) -> float:
"""합치기 멈춤 잣대 — 계획고 지반고 횡단 토공(1차 종단 규칙 · 종단 끔과 같음) · 못 재면 inf."""
try:
found = cross_earthwork(
line.vertices,
None,
None,
terrain,
standard,
design_curves=[curve.as_dict() for curve in line.curves],
)
except ValueError:
return math.inf
return found["cut"] + found["fill"]
def cross_delta(
before: PlannedPolyline,
after: PlannedPolyline,
window: tuple[float, float],
terrain: Terrain,
standard: dict[str, Any] | None,
) -> float:
"""바뀐 자리 측점만 횡단 토공 차 — 뒤 측점은 같은 선 따라 밀림(옛 끝 s1 → 새 s1 + 연장 차)."""
shift = LineString(after.vertices).length - LineString(before.vertices).length
first = math.ceil(window[0] / STATION_M) * STATION_M
try:
return cross_earthwork_delta(
before.vertices,
after.vertices,
np.arange(first, window[1] + shift + 1e-9, STATION_M),
terrain,
standard,
before_chainages=np.arange(first, window[1] + 1e-9, STATION_M),
design_curves_before=[curve.as_dict() for curve in before.curves],
design_curves_after=[curve.as_dict() for curve in after.curves],
)["earth"]
except ValueError:
return math.inf
def merged_follow(
points: list[Xy], min_radius_m: float, land: FollowLand, grid: SearchGrid | None = None
) -> PlannedPolyline:
"""예정노선 점 묶음 → 곡선 최적 · 측점 맞춘 폴리라인(못 맞추면 맞추기 전 선). `grid` = 이미 짠
탐색 격자(같은 예정노선 · 추천 묶음) · 없으면 짬."""
key = (
tuple((float(x), float(y)) for x, y in points),
round(min_radius_m, 6),
land.terrain.signature or id(land.terrain),
land.max_grade,
land.stream_offset_m,
land.grade_smooth_m,
land.target_grade,
land.corridor_m,
repr(land.standard),
len(land.streams),
)
if key in _MEMO:
_MEMO.move_to_end(key)
return _MEMO[key]
def measure(line: PlannedPolyline) -> dict[str, Any]:
# 추천 「예상노선 추적」 잣대(follow · 못 지킨 곳 = R · 계류 · 측점 · 원지반 기울기 참고)
return hard_limits(
route_metrics(
line.vertices,
points,
land.terrain,
land.streams,
line.curves,
max_grade=land.max_grade,
min_radius_m=min_radius_m,
stream_offset_m=land.stream_offset_m,
road_width_m=ROAD_WIDTH_M,
bound_pct=None,
grade_smooth_m=land.grade_smooth_m,
follow=True,
target_grade=land.target_grade,
)
)
if grid is None:
grid = SearchGrid(
points,
land.terrain,
land.streams,
stream_offset_m=land.stream_offset_m,
grade_smooth_m=land.grade_smooth_m,
target_grade=land.target_grade,
corridor_m=land.corridor_m,
)
guard = Guard(grid, land.max_grade)
outline = build_planned_polyline(points, min_radius_m=min_radius_m)
route = (outline, measure(outline))
larger = widen(outline, points, min_radius_m, guard=guard)
if larger is not outline:
trial = (larger, measure(larger))
if trial[1]["over_limit"]["total"] <= route[1]["over_limit"]["total"]:
route = trial
kinked = curve_every_kink(route[0], min_radius_m)
if kinked is not route[0]:
route = (kinked, measure(kinked))
route = merge_same_side(
route,
guard,
min_radius_m,
measure,
RouteEarth(
lambda line: cross_total(line, land.terrain, land.standard),
lambda before, after, window: cross_delta(
before, after, window, land.terrain, land.standard
),
),
)
snapped = snap_curves_to_stations(route[0], min_radius_m, guard=guard)
found = route[0] if snapped is None else snapped
_MEMO[key] = found
while len(_MEMO) > MEMO_SIZE:
_MEMO.popitem(last=False)
return found