- 새 파일 Engine_RouteInitial_Merge — 같은 쪽 이웃 꺾임 둘 → 바깥 두 직선 교점 하나 · 되풀이 · S자 안 합침 - 직접 잼(Guard) — 탐색 복도 안 · 계류 나란히 · 기울기 넘음 · 토공 가늠 안 나빠짐 · 비중 계산은 예상노선 이격도 - 합친 곡선은 MC 가 측점 사이 어디여도 IP 밀어 측점에 놓은 꼴까지 잼 통과 · 노선 전체 잣대(넘은 곳 · 최대 기울기 +0.5 %p · 절성토 +2 %) 안 나빠짐 - 시험 test_406_1 — 같은 쪽 합침 · S자 · 기울기 · 복도 · 계류 · 전체 잣대 막힘 · 큰 R 직접 잼 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼(PLAN 406 · 요구 251-2).
|
|
|
|
`B05_Profile_Engine_RouteInitial_Pool._trace` 가 큰 R 판 뒤 · 측점 맞춤 앞에서 부름. 노선 추천
|
|
후보만 — 손 편집 · 옛 노선 · 예상노선 추적(전처리 초기값) · 공용 선 짜기 짝은 안 거침.
|
|
|
|
합치기(`merge_same_side`) = 같은 쪽으로 도는 이웃 꺾임점 둘 → 바깥 두 직선 교점 하나(큰 단곡선 ·
|
|
사이 직선 없어짐) · 되풀이. 반대 방향(S자) 이웃은 안 합침(사이 직선 남김). 접선 규칙은 그대로
|
|
(앞뒤 직선 절반 · 398-4 안 함). 고르는 차례 = 지금 선에서 덜 벗어나는 합치기부터.
|
|
|
|
거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`):
|
|
· 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상.
|
|
· 옮긴 조각이 같은 자리 옛 조각보다 지반 기울기 상한을 더 넘지 않음(`GRADE_NOISE`) · 토공
|
|
가늠이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 이격도(`keeping_offset`).
|
|
· 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함
|
|
(`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음).
|
|
· 합치기는 노선 전체 잣대(`route_metrics`)도 — 넘은 곳 수 · 기울기 넘은 곳 안 늘어남 · 최대
|
|
기울기 `GRADE_TOL_PCT` · 절성토 `TOTAL_EARTH_TOL` 안(합치기 전 선 기준 · 되풀이해도 누적 안 됨).
|
|
큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import math
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import shapely
|
|
from shapely.geometry import LineString
|
|
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import SAMPLE_M, STATION_M, _resample
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
|
CORRIDOR_M,
|
|
ROAD_WIDTH_M,
|
|
STREAM_CROSS_COS,
|
|
SearchGrid,
|
|
)
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _corner, _Reference
|
|
from common_util.common_util_route_polyline import (
|
|
PlannedPolyline,
|
|
_inner_angle_deg,
|
|
_line_intersection,
|
|
_turn_sign,
|
|
build_planned_polyline,
|
|
)
|
|
|
|
Xy = tuple[float, float]
|
|
Route = tuple[Any, dict[str, Any]]
|
|
|
|
# 옮긴 조각 토공 가늠(탐색 몫 · 옆경사 단면)이 옛 조각보다 늘어도 되는 몫 · 바닥(㎥ · 짧은 조각)
|
|
EARTH_TOL = 0.05
|
|
EARTH_FLOOR_M3 = 5.0
|
|
# 옮긴 조각 지반 기울기가 상한을 더 넘어도 「안 늘어남」으로 볼 잴 잡음(비율 · 0.2 %p)
|
|
GRADE_NOISE = 0.002
|
|
# 합치기 노선 전체 잣대 — 최대 기울기(%p) · 절성토 늘어도 되는 몫
|
|
GRADE_TOL_PCT = 0.5
|
|
TOTAL_EARTH_TOL = 0.02
|
|
# R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터)
|
|
RADIUS_STEPS = 12
|
|
# 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t)
|
|
# 놓기 하나는 직접 잼을 지나야 함(측점 맞춤이 IP 를 들어오는 접선 따라 밂 · 합치기 풀기 없음)
|
|
PLACE_OFFSETS_M = (0.0, 5.0, 10.0, 15.0)
|
|
# 잰 조각 기억 수 — 측점 맞춤 단계 · 되돌림이 같은 조각을 자주 다시 잼
|
|
SEEN_LIMIT = 200_000
|
|
|
|
|
|
def _mid(a: Xy, b: Xy) -> Xy:
|
|
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
|
|
|
|
|
|
class Guard:
|
|
"""거리 한도 대신 직접 잼 — 옮긴 조각이 같은 자리 옛 조각보다 나빠지지 않나(탐색 격자 잣대)."""
|
|
|
|
def __init__(self, grid: SearchGrid, limit: float) -> None:
|
|
self.grid, self.limit = grid, limit
|
|
stream = np.where(np.isfinite(grid.d_stream), grid.d_stream, 1e9)
|
|
# 겹쳐 놓은 칸 값(잼마다 보간기를 새로 안 짬)
|
|
expected = np.minimum(grid.d_expected, CORRIDOR_M)
|
|
self.fields = np.stack(
|
|
[grid.z, grid.grad_x, grid.grad_y, grid.away_x, grid.away_y, stream, expected]
|
|
)
|
|
# 예상노선 이격도 잼(비중 계산 · 예상 몫 있을 때 — 그 갈래 잣대 · `keeping_offset`)
|
|
self.offset = False
|
|
self.seen: dict[bytes, tuple[float, float, float, bool]] = {} # 같은 조각 다시 안 잼
|
|
|
|
def _sample(self, xy: np.ndarray) -> np.ndarray:
|
|
"""칸 값 [7, n] — 쌍선형(격자 밖은 가장자리 칸)."""
|
|
grid, fields = self.grid, self.fields
|
|
rows_n, cols_n = fields.shape[1:]
|
|
r = np.clip((xy[:, 1] - grid.ys[0]) / grid.cell, 0.0, rows_n - 1.0)
|
|
c = np.clip((xy[:, 0] - grid.xs[0]) / grid.cell, 0.0, cols_n - 1.0)
|
|
r0 = np.minimum(r.astype(int), rows_n - 2)
|
|
c0 = np.minimum(c.astype(int), cols_n - 2)
|
|
fr, fc = r - r0, c - c0
|
|
return (
|
|
fields[:, r0, c0] * (1 - fr) * (1 - fc)
|
|
+ fields[:, r0 + 1, c0] * fr * (1 - fc)
|
|
+ fields[:, r0, c0 + 1] * (1 - fr) * fc
|
|
+ fields[:, r0 + 1, c0 + 1] * fr * fc
|
|
)
|
|
|
|
def keeping_offset(self) -> Guard:
|
|
"""예상노선 이격도 안 늘게 재는 판(잰 조각 기억은 함께 씀)."""
|
|
twin = copy.copy(self)
|
|
twin.offset = True
|
|
return twin
|
|
|
|
def _look(self, points: list[Xy]) -> tuple[float, float, float, bool, float]:
|
|
"""(기울기 넘은 만큼 · 토공 가늠 ㎥ · 계류 버퍼 안 나란히 m · 복도 안 · 예상노선 이격 ㎡) —
|
|
탐색 몫과 같은 셈(`SearchGrid.objectives` 이격 · 토공 · 계류 몫) · 기울기는 20 m 사이 고른
|
|
지반(비교값 잣대)."""
|
|
key = np.round(np.asarray(points, dtype=float), 3).tobytes()
|
|
if key in self.seen:
|
|
return self.seen[key]
|
|
if len(self.seen) >= SEEN_LIMIT:
|
|
self.seen.clear()
|
|
grid = self.grid
|
|
xy, chain = _resample(points, SAMPLE_M)
|
|
if len(xy) < 2:
|
|
return 0.0, 0.0, 0.0, True, 0.0
|
|
z = self._sample(xy)[0]
|
|
span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1)
|
|
run = np.maximum(chain[span:] - chain[:-span], 1e-9)
|
|
excess = max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - self.limit)
|
|
cols = np.rint((xy[:, 0] - grid.xs[0]) / grid.cell).astype(int)
|
|
rows = np.rint((xy[:, 1] - grid.ys[0]) / grid.cell).astype(int)
|
|
rows_n, cols_n = grid.corridor.shape
|
|
inside = bool(
|
|
((cols >= 0) & (cols < cols_n) & (rows >= 0) & (rows < rows_n)).all()
|
|
and grid.corridor[rows, cols].all()
|
|
)
|
|
step = np.diff(xy, axis=0)
|
|
length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9)
|
|
_, gx, gy, ax, ay, stream, offset = self._sample(0.5 * (xy[1:] + xy[:-1]))
|
|
over = np.maximum(0.0, np.abs(np.diff(z)) / length - self.limit)
|
|
cross = np.abs(-gx * step[:, 1] + gy * step[:, 0]) / length
|
|
earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0
|
|
size = np.hypot(ax, ay)
|
|
across = np.abs(ax * step[:, 0] + ay * step[:, 1]) >= STREAM_CROSS_COS * length * size
|
|
along = (stream < grid.stream_offset_m) & ~across & (size > 1e-6)
|
|
found = (
|
|
excess,
|
|
float((earth * length).sum()),
|
|
float(length[along].sum()),
|
|
inside,
|
|
float((offset * length).sum()),
|
|
)
|
|
self.seen[key] = found
|
|
return found
|
|
|
|
def judge(
|
|
self, local: list[Xy], reference: list[Xy]
|
|
) -> tuple[bool, tuple[bool, float, float, float]]:
|
|
"""(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 기울기 넘음 안 늘어남 · 토공
|
|
`EARTH_TOL` 안(`offset` 이면 예상노선 이격도 같은 폭). 잣대(작을수록 좋음) = 복도 밖 ·
|
|
늘어난 계류 나란히(m) · 더 넘은 기울기 · 늘어난 토공 가늠(㎥)."""
|
|
excess, earth, along, inside, offset = self._look(local)
|
|
old_excess, old_earth, old_along, _, old_offset = self._look(reference)
|
|
passed = (
|
|
inside
|
|
and excess <= old_excess + GRADE_NOISE
|
|
and along <= old_along + 1e-6
|
|
and earth <= old_earth * (1 + EARTH_TOL) + EARTH_FLOOR_M3
|
|
and (not self.offset or offset <= old_offset * (1 + EARTH_TOL) + EARTH_FLOOR_M3)
|
|
)
|
|
cost = (
|
|
not inside,
|
|
round(max(0.0, along - old_along), 3),
|
|
round(max(0.0, excess - old_excess), 4),
|
|
earth - old_earth,
|
|
)
|
|
return passed, cost
|
|
|
|
def ok(self, local: list[Xy], reference: list[Xy]) -> bool:
|
|
return self.judge(local, reference)[0]
|
|
|
|
@staticmethod
|
|
def piece(before: Xy, apex: Xy, after: Xy, radius: float) -> list[Xy] | None:
|
|
"""곡선 하나가 쓰는 조각 — 앞 직선 가운데 → 원호 → 뒤 직선 가운데(직선 절반 규칙)."""
|
|
corner = _corner(before, apex, after, radius)
|
|
if corner is None:
|
|
return None
|
|
return [_mid(before, apex), *corner.arc, _mid(apex, after)]
|
|
|
|
def curve_ok(self, before: Xy, apex: Xy, after: Xy, radius: float, reference: list[Xy]) -> bool:
|
|
local = self.piece(before, apex, after, radius)
|
|
return local is not None and self.ok(local, reference)
|
|
|
|
|
|
def _join(a: Xy, p: Xy, q: Xy, b: Xy) -> Xy | None:
|
|
"""같은 쪽으로 도는 이웃 꺾임 p · q → 바깥 두 직선(a-p · q-b) 교점 · S자 · 뒤집힘이면 None."""
|
|
side = _turn_sign(a, p, q)
|
|
if side == 0 or side != _turn_sign(p, q, b):
|
|
return None
|
|
cross = _line_intersection(a, p, q, b)
|
|
if cross is None:
|
|
return None
|
|
ahead = (cross[0] - a[0]) * (p[0] - a[0]) + (cross[1] - a[1]) * (p[1] - a[1]) > 0 and (
|
|
b[0] - cross[0]
|
|
) * (b[0] - q[0]) + (b[1] - cross[1]) * (b[1] - q[1]) > 0
|
|
return (round(cross[0], 4), round(cross[1], 4)) if ahead else None
|
|
|
|
|
|
def _radius(
|
|
guard: Guard, before: Xy, apex: Xy, after: Xy, min_radius_m: float, reference: _Reference
|
|
) -> tuple[float, list[Xy]] | None:
|
|
"""접선 자리 안 · 직접 잼을 지나는 가장 큰 R(최소 R 이상)과 그 조각 · 없으면 None.
|
|
|
|
MC 가 측점 사이 어디에 떨어져도 교점을 들어오는 접선 따라 밀어 놓을 수 있어야 함
|
|
(`PLACE_OFFSETS_M` · 측점 맞춤이 MC 를 늘 측점에)."""
|
|
u = (
|
|
(apex[0] - before[0]) / math.dist(before, apex),
|
|
(apex[1] - before[1]) / math.dist(before, apex),
|
|
)
|
|
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
|
|
if half_tan <= 1e-9:
|
|
return None
|
|
room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan
|
|
if room < min_radius_m:
|
|
return None
|
|
steps = np.geomspace(room, min_radius_m, RADIUS_STEPS)
|
|
for radius in sorted({max(float(math.floor(r)), min_radius_m) for r in steps}, reverse=True):
|
|
local = guard.piece(before, apex, after, radius)
|
|
if local is None or not guard.ok(local, reference.window(local)):
|
|
continue
|
|
seen: dict[float, bool] = {0.0: True}
|
|
|
|
def placed(d: float) -> bool:
|
|
if d not in seen:
|
|
at = (apex[0] + u[0] * d, apex[1] + u[1] * d)
|
|
moved = guard.piece(before, at, after, radius)
|
|
seen[d] = moved is not None and guard.ok(moved, reference.window(moved))
|
|
return seen[d]
|
|
|
|
if all(placed(-t) or placed(STATION_M - t) for t in PLACE_OFFSETS_M):
|
|
return radius, local
|
|
return None
|
|
|
|
|
|
def _earth(metrics: dict[str, Any]) -> float:
|
|
return metrics["cut_m3"] + metrics["fill_m3"]
|
|
|
|
|
|
def _worse(new: dict[str, Any], base: dict[str, Any]) -> bool:
|
|
"""노선 전체 잣대가 합치기 전보다 나빠짐(허용 폭 밖)."""
|
|
over, was = new["over_limit"], base["over_limit"]
|
|
return (
|
|
over["total"] > was["total"]
|
|
or over["grade"] > was["grade"]
|
|
or new["max_grade_pct"] > base["max_grade_pct"] + GRADE_TOL_PCT
|
|
or _earth(new) > _earth(base) * (1 + TOTAL_EARTH_TOL)
|
|
)
|
|
|
|
|
|
def merge_same_side(
|
|
route: Route,
|
|
guard: Guard,
|
|
min_radius_m: float,
|
|
measure: Callable[[PlannedPolyline], dict[str, Any]],
|
|
) -> Route:
|
|
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로."""
|
|
outline, base = route
|
|
if len(outline.curves) < 2:
|
|
return route
|
|
points, flags, radii = _flat(outline)
|
|
points = [(round(x, 4), round(y, 4)) for x, y in points]
|
|
current = route
|
|
blocked: set[tuple[Xy, ...]] = set()
|
|
while True:
|
|
reference = _Reference(current[0].vertices)
|
|
found: list[tuple[float, int, Xy, float]] = []
|
|
for k in range(1, len(points) - 2):
|
|
key = tuple(points[k - 1 : k + 3])
|
|
if key in blocked:
|
|
continue
|
|
a, p, q, b = key
|
|
cross = _join(a, p, q, b)
|
|
fitted = None if cross is None else _radius(guard, a, cross, b, min_radius_m, reference)
|
|
if fitted is None:
|
|
blocked.add(key)
|
|
continue
|
|
radius, local = fitted
|
|
gap = float(
|
|
shapely.hausdorff_distance(
|
|
LineString(local), LineString(reference.window(local)), densify=0.1
|
|
)
|
|
)
|
|
found.append((gap, k, cross, radius))
|
|
merged = None
|
|
for _, k, cross, radius in sorted(found):
|
|
trial_points = points[:k] + [cross] + points[k + 2 :]
|
|
trial_flags = flags[:k] + [True] + flags[k + 2 :]
|
|
trial_radii = radii[:k] + [radius] + radii[k + 2 :]
|
|
line = build_planned_polyline(
|
|
trial_points,
|
|
min_radius_m=min_radius_m,
|
|
simplify=False,
|
|
curve_flags=trial_flags,
|
|
radii=trial_radii,
|
|
)
|
|
metrics = measure(line)
|
|
if _worse(metrics, base):
|
|
blocked.add(tuple(points[k - 1 : k + 3]))
|
|
continue
|
|
merged = (trial_points, trial_flags, trial_radii, (line, metrics))
|
|
break
|
|
if merged is None:
|
|
return current
|
|
points, flags, radii, current = merged
|