- 새 B05_Profile_Engine_RouteInitial_Fit: 성긴 최소제곱 맞춤 · 측점 벌점 1 → 1000 · 끝 확인(곡선 수 · MC 측점 어김 0 · R ≥ 최소 R) · 수정 끝 틀 고르기(예정노선 거리 덜 늘어나는 합침) - merged_follow: 합치기 뒤 측점 맞춤 대신 맞춤 · 끝 확인 못 지나면 지금 측점 맞춤으로 물러남 · keep_curves - /route/follow: 수정 전 곡선 수 = 본문 keep_curves 또는 저장 노선 곡선표 · 응답 keep_curves · curve_note - 773 여덟 꼴 평균 최대 26.5 → 6.9 m · 평균 3.80 → 1.24 m · N 유지 8/8 · 측점 어김 0 · R 미달 0 · 물러남 0 - 지킴 시험 test_775_route_fit 새로 · test_758_1 그대로 통과(입력 덧점 정리로 체인 · 추적 같은 선) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
7.7 KiB
Python
196 lines
7.7 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`) → ② 예정노선 맞춤(775 · `..._Fit` — 곡선 수 그대로 교각점 · R 를
|
|
예정노선에 맞추며 MC = 일반측점 · 끝 확인 못 지나면 지금 측점 맞춤 · 수정 끝 `keep_curves` = 수정 전
|
|
곡선 수 틀). 큰 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_Fit import fit_to_expected, frame_for_count
|
|
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,
|
|
keep_curves: int | None = None,
|
|
) -> PlannedPolyline:
|
|
"""예정노선 점 묶음 → 곡선 최적 · 측점 맞춘 폴리라인(못 맞추면 맞추기 전 선). `grid` = 이미 짠
|
|
탐색 격자(같은 예정노선 · 추천 묶음) · 없으면 짬. `keep_curves` = 지킬 곡선 수(수정 끝 = 수정 전
|
|
평면계획선 곡선 수) · 없으면 합친 뒤 곡선 수."""
|
|
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),
|
|
keep_curves,
|
|
)
|
|
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
|
|
),
|
|
),
|
|
)
|
|
frame = route[0]
|
|
if keep_curves is not None and keep_curves != len(frame.curves):
|
|
frame = frame_for_count(kinked, points, min_radius_m, keep_curves)
|
|
found = fit_to_expected(frame, points, min_radius_m)
|
|
if found is None: # 끝 확인 못 지남 — 지금 측점 맞춤(스냅 절대)
|
|
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
|