Files
Aislo/B05_Profile/B05_Profile_Engine_RouteInitial_Follow.py
T
eomsangdonandClaude Opus 5.5 c7d63c1b78 feat(775-1·775-2): 평면계획선 예정노선 맞춤 — 곡선 수 그대로 교각점 · R 최소제곱 + 측점 · 최소 R 벌점 · 수정 끝 = 수정 전 곡선 수 틀
- 새 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>
2026-11-03 20:42:48 +09:00

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