Files
Aislo/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py
T
eomsangdonandClaude Opus 5.5 4ae4fa49ea feat(B05 노선 추천 엔진): 합치기 판정 = 바뀐 측점 횡단 차이 · 후보 줄 세움 = 노선 횡단 토공 · 균형 종단 위 5 개 (419-1)
- 420 cross_earthwork · cross_earthwork_delta 이음 — 합치기 멈춤 = 계획고 지반고 노선 횡단 +10 %
- 토공 · 공사비 줄 세움 = 상한 누른 계획선 횡단(종단 켬) · 지반고 횡단(종단 끔)
- 위 후보 수 3 → 5(검증_sub4 균형 종단 토공 최소 = 줄 세움 5 위)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-18 12:56:34 +09:00

315 lines
15 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) 이상.
· 옮긴 조각 토공 가늠(띠 · 빠른 거름)이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선
이격도(`keeping_offset`). 원지반 기울기는 참고만(419 · 막지 않음 · 자리 잣대에도 안 씀).
· 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함
(`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음).
· 합치기 멈춤 = 노선 전체 잣대 — 못 지킨 곳 수(R · 계류) 안 늘어남 · 노선 토공이 합치기 전 선보다
`TOTAL_EARTH_TOL` 넘게 늘지 않음(엔진 상수 · 사용자 옵션 아님 · 되풀이해도 누적 안 됨). 토공 =
바뀐 측점 횡단 차이(`cross_earthwork_delta` · 420) · 없으면 노선 잣대 토공(띠 가늠).
큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신).
"""
from __future__ import annotations
import copy
import math
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import numpy as np
import shapely
from shapely.geometry import LineString, Point
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
# 합치기 멈춤 — 노선 토공(횡단)이 합치기 전 선보다 늘어도 되는 몫(곡선 수 ↔ 토공 · 요구 254)
TOTAL_EARTH_TOL = 0.10
# 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]]:
"""(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 토공 `EARTH_TOL` 안(`offset`
이면 예상노선 이격도 같은 폭) · 원지반 기울기는 안 봄(419). 잣대(작을수록 좋음) = 복도 밖 ·
늘어난 계류 나란히(m) · 늘어난 토공 가늠(㎥)."""
_, earth, along, inside, offset = self._look(local)
_, old_earth, old_along, _, old_offset = self._look(reference)
passed = (
inside
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), 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"]
@dataclass
class RouteEarth:
"""합치기 멈춤 잣대 — 노선 토공(횡단 ㎥ · `..._CrossEarth`)과 바뀐 측점 횡단 차이."""
total: Callable[[PlannedPolyline], float]
# (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥)
delta: Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float]
def merge_same_side(
route: Route,
guard: Guard,
min_radius_m: float,
measure: Callable[[PlannedPolyline], dict[str, Any]],
route_earth: RouteEarth | None = None,
) -> Route:
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로.
멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의
1 + `TOTAL_EARTH_TOL` 넘음. `route_earth` 주면 토공 = 횡단(합치기 전 선 전체 + 바뀐 측점 차이
누적) · 없으면 노선 잣대 토공(`measure` 절 + 성 · 띠 가늠)."""
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
earth = (
_earth(base) if route_earth is None else route_earth.total(outline)
) # 지금 선 토공 — 차이를 누적
limit = earth * (1 + TOTAL_EARTH_TOL)
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, local))
merged = None
for _, k, cross, radius, local in sorted(found, key=lambda item: item[:2]):
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 route_earth is None:
total = _earth(metrics)
else:
window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1]))
total = earth + route_earth.delta(current[0], line, (window[0], window[1]))
if metrics["over_limit"]["total"] > base["over_limit"]["total"] or total > limit:
blocked.add(tuple(points[k - 1 : k + 3]))
continue
merged = (trial_points, trial_flags, trial_radii, (line, metrics), total)
break
if merged is None:
return current
points, flags, radii, current, earth = merged