- 새 `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>
182 lines
6.9 KiB
Python
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
|