Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-18 10:07:08 +09:00
7 changed files with 568 additions and 68 deletions
+6 -36
View File
@@ -56,16 +56,8 @@ from pathlib import Path
from typing import Any
import numpy as np
from scipy.interpolate import RegularGridInterpolator
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
GRADE_SMOOTH_M,
SAMPLE_M,
STATION_M,
_resample,
route_metrics,
smoothed_ground,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
Pool,
build_pool,
@@ -328,30 +320,10 @@ def generate_initial_route(
target_grade=goal_grade,
)
smooth_z = RegularGridInterpolator(
(terrain.y, terrain.x),
smoothed_ground(terrain, grade_smooth),
bounds_error=False,
fill_value=None,
)
def grade_excess(points: list[tuple[float, float]]) -> float:
# 측점 맞춤(351-2)이 옮긴 조각 — 20 m 사이 고른 지반 기울기(비교값과 같은 잣대)가 상한을
# 넘은 만큼. 20 m 보다 짧으면 양 끝 사이.
xy, chain = _resample(points, SAMPLE_M)
z = smooth_z(xy[:, ::-1])
span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1)
if span < 1:
return 0.0
run = np.maximum(chain[span:] - chain[:-span], 1e-9)
return max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - max_grade)
def fit(outline: Any, follow: bool = False) -> Any:
# R = 일반측점(383-1) — 갈래는 옮긴 조각이 원래보다 더 가파르면 그 곡선만 그 자리(추적은
# 제약을 안 따짐 · 새 초기 노선 `snapped_initial` 과 같은 잣대).
moved = snap_curves_to_stations(
outline, min_radius_m, excess=None if follow else grade_excess
)
def fit(outline: Any, guard: Any = None) -> Any:
# R = 일반측점(383-1) — 갈래 후보는 직접 잼(`guard` · 406 · 거리 한도 없음) · 추적은 제약을
# 안 따짐(새 초기 노선 `snapped_initial` 과 같은 잣대 · 거리 한도 그대로).
moved = snap_curves_to_stations(outline, min_radius_m, guard=guard)
return outline if moved is None else moved
unsnapped: dict[int, Any] = {}
@@ -361,9 +333,7 @@ def generate_initial_route(
# 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
route = (outline, measure(outline, bound=None, follow=True))
return settle(
route, lambda line: fit(line, True), measure, unsnapped, bound=None, follow=True
)
return settle(route, fit, measure, unsnapped, bound=None, follow=True)
def profiled(route: Any, exact: bool = True) -> None:
if route is not None:
@@ -0,0 +1,311 @@
"""추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼(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
@@ -33,6 +33,7 @@ from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
@@ -80,8 +81,8 @@ TRACE_DENSIFY_M = 2.0
Measure = Callable[..., dict[str, Any]]
Route = tuple[Any, dict[str, Any]]
# 선 → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
Snap = Callable[[Any], Any]
# (선, 직접 잼) → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
Snap = Callable[[Any, Any], Any]
@dataclass
@@ -105,6 +106,8 @@ class Pool:
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route:
@@ -125,8 +128,13 @@ def _trace(
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
# 고른 판을 측점에 맞춤(`settle` · 383-1).
# 고른 판의 같은 쪽 이웃 곡선을 합치고(406 · `..._Merge`) 측점에 맞춤(`settle` · 383-1).
# 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은 예상노선
# 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
traced = pool.traced
guard = pool.guard
if guard is not None and kw.get("weighted"):
guard = guard.keeping_offset()
key = (tuple(nodes), tuple(sorted(kw.items())))
if key not in traced:
floor = build_planned_polyline(
@@ -135,7 +143,11 @@ def _trace(
least = (floor, measure(floor, **kw))
path = densify_route(nodes, TRACE_DENSIFY_M)
fitted = build_planned_polyline(path, min_radius_m=min_radius_m)
larger = (widen(fitted, path, min_radius_m), widen(floor, path, min_radius_m), fitted)
larger = (
widen(fitted, path, min_radius_m, guard=guard),
widen(floor, path, min_radius_m, guard=guard),
fitted,
)
chosen = next(
(
route
@@ -144,7 +156,9 @@ def _trace(
),
least,
)
traced[key] = settle(chosen, pool.snap, measure, pool.raw, **kw)
if guard is not None:
chosen = merge_same_side(chosen, guard, min_radius_m, lambda line: measure(line, **kw))
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, pool.raw, **kw)
return traced[key]
@@ -224,7 +238,7 @@ def build_pool(
snap: Snap,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함."""
pool = Pool(graph, snap, follow=follow)
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade))
pool.candidates, pool.bound, pool.limit = _candidates(pool, max_grade, min_radius_m, measure)
candidates, bound, limit = pool.candidates, pool.bound, pool.limit
ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]]
@@ -6,6 +6,10 @@
[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M`
인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로.
추천 후보(`guard` 줌 · PLAN 406)는 거리 한도 대신 직접 잼 — 곡선 조각이 같은 자리 탐색 경로보다
복도 밖 · 기울기 · 계류 · 토공이 나빠지지 않는 가장 큰 R(`..._Merge.Guard` · 자리 R ~ 맞춘 R 사이
`GUARD_STEPS` 벌 · 1 m 내림).
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
"""
@@ -13,6 +17,7 @@
from __future__ import annotations
import math
from typing import Any
import numpy as np
from shapely.geometry import LineString, Point
@@ -25,6 +30,8 @@ from common_util.common_util_route_polyline import (
from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M
Xy = tuple[float, float]
# 직접 잼 R 후보 수(자리 R ~ 맞춘 R · 큰 것부터)
GUARD_STEPS = 12
def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]:
@@ -77,8 +84,12 @@ def widen(
path: list[Xy],
min_radius_m: float,
max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M,
*,
guard: Any = None,
) -> PlannedPolyline:
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로."""
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로.
`guard` 를 주면 이탈 한도 대신 직접 잼(`..._Merge.Guard.curve_ok`)을 지나는 가장 큰 R."""
if not outline.curves:
return outline
points, flags, radii = _flat(outline)
@@ -102,6 +113,20 @@ def widen(
samples = coords[(along >= min(ends)) & (along <= max(ends))]
if not candidates.size or not len(samples):
continue
if guard is not None:
if len(samples) < 2:
continue
steps = np.geomspace(room, float(radii[index]), GUARD_STEPS)
reference = [tuple(p) for p in samples]
radii[index] = next(
(
r
for r in sorted({float(math.floor(r)) for r in steps}, reverse=True)
if r > radii[index] and guard.curve_ok(before, apex, after, r, reference)
),
radii[index],
)
continue
ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m]
if ok.size:
radii[index] = float(ok[0])
@@ -20,6 +20,14 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마
그 전에 이웃 곡선과 한 측점을 다투면 덜 꺾인 쪽을 끔 — 꺾임점째 빼거나 꺾임만(364-5).
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
추천 후보(`guard` 줌 · PLAN 406 · 요구 251-2)는 이탈 한도 없음 — 옮긴 조각을 직접 잼
(`..._Merge.Guard` · 복도 · 기울기 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐)으로 거름:
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안 · 더 넘는 기울기 `SOFT_SLACKS` 단계까지) —
MC 를 다 측점에 놓는 첫 단계 판.
· 그래도 못 놓으면 더 안 넘기는 놓기만 쓰고 그 곡선은 그 자리(측점 밖 · 기울기 먼저).
예상노선 추적 · 새 초기 노선은 안 줌(이탈 한도 그대로).
고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점
하나로 펴서(`flattenServerPlan`) [확인] 때 다시 짜는 입력과 같아 저장 뒤에도 MC 가 측점 위(TS 짝
불필요). 누가거리는 짜인 선의 정점(원호 5° 현)을 따라 잼 — 측점 생성과 같은 잣대.
@@ -30,6 +38,7 @@ from __future__ import annotations
import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass, replace
from typing import Any
import numpy as np
import shapely
@@ -68,6 +77,9 @@ RADIUS_STEPS = 8
BACKTRACK_LIMIT = 30
# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로
PER_VARIANT = 3
# 직접 잼(406) — 잼을 못 지나는 놓기가 더 넘겨도 되는 기울기(비율) 단계 · 앞 단계로 다 놓으면
# 그 판 · 다 못 놓으면 더 안 넘기는 놓기만 쓰고 못 놓는 곡선은 그 자리(부분 맞춤)
SOFT_SLACKS = (0.0, 0.01, 0.03)
@dataclass
@@ -293,30 +305,43 @@ class _Snapper:
min_radius_m: float,
max_gap_m: float,
excess: Excess | None,
guard: Any = None,
) -> None:
points, flags, radii = _flat(outline)
points = [(round(x, 4), round(y, 4)) for x, y in points] # 화면이 펴는 자릿수
self.start = _State(_Plan(points, flags, radii), 1, points[0], 0.0, 0.0, 0.0)
self.reference = _Reference(outline.vertices)
self.min_radius = min_radius_m
self.max_gap = max_gap_m
self.max_gap = math.inf if guard is not None else max_gap_m
self.excess = excess
self.guard = guard
self.keep = False
self.slack = math.inf # 못 지나는 놓기가 더 넘겨도 되는 기울기(`SOFT_SLACKS`)
def _steeper(self, local: list[Xy]) -> bool:
"""맞춘 조각이 원래 선 같은 자리보다 상한을 더 넘나."""
"""맞춘 조각이 원래 선 같은 자리보다 상한을 더 넘나(직접 잼이면 그 잼 전부)."""
if self.guard is not None:
return not self.guard.ok(local, self.reference.window(local))
if self.excess is None:
return False
return self.excess(local) > self.excess(self.reference.window(local)) + 1e-6
def _far(self, local: list[Xy]) -> bool:
"""원래 선에서 이탈 한도 밖(직접 잼이면 그 잼)."""
if self.guard is not None:
return self._steeper(local)
return self.reference.gap(local) > self.max_gap
@staticmethod
def _kink(state: _State, plan: _Plan) -> _State:
at = plan.points[state.k]
end_s = state.end_s + math.dist(state.end_xy, at)
return _State(plan, state.k + 1, at, end_s, 0.0, state.last_station, state.placed)
def _placements(self, state: _State, plan: _Plan) -> Iterator[_State]:
"""곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터."""
def _placements(self, state: _State, plan: _Plan, soft: bool = False) -> Iterator[_State]:
"""곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터.
직접 잼(406)이면 잼을 지나는 자리만 · `soft` 면 못 지나는 자리(복도 안)만 — 잣대 나은 순."""
k = state.k
before, at, after = plan.points[k - 1], plan.points[k], plan.points[k + 1]
start = _try(state, plan, k, at, plan.radii[k] or self.min_radius)
@@ -344,13 +369,23 @@ class _Snapper:
new_at, new_corner, new_mc = solved
# 뒤 직선 절반에 여유를 남긴 것 먼저 — 다음 IP 가 뒤로도 밀리게
tight = math.dist(new_at, after) / 2 - new_corner.tangent < SLACK_M
rank = (tight, abs(station - mc), abs(radius - corner.radius))
found.append((rank, new_at, new_corner, new_mc, station))
middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2)
local = [state.end_xy, *new_corner.arc, middle]
rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius))
if self.guard is not None:
# 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림
passed, (out, along, steeper, earth) = self.guard.judge(
local, self.reference.window(local)
)
if out or passed == soft or steeper > self.slack:
continue
rank = (along, steeper, tight, rank[1], earth, rank[2])
found.append((rank, local, new_at, new_corner, new_mc, station))
given = 0
for _, new_at, new_corner, new_mc, station in sorted(found, key=lambda item: item[0]):
middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2)
local = [state.end_xy, *new_corner.arc, middle]
if self.reference.gap(local) > self.max_gap or self._steeper(local):
for _, local, new_at, new_corner, new_mc, station in sorted(
found, key=lambda item: item[0]
):
if self.guard is None and (self._far(local) or self._steeper(local)):
continue
placed = plan.copy()
placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3)
@@ -376,17 +411,29 @@ class _Snapper:
kink.flags[k] = False # 꺾임만(나란한 직선 등 곡선 못 끼움 포함)
yield self._kink(state, kink)
return
trial = plan.copy()
for _ in range(MERGE_TRIES + 1):
yield from self._placements(state, trial)
trial = trial.copy()
variants = [plan.copy()]
for _ in range(MERGE_TRIES):
trial = variants[-1].copy()
if not _absorb_next(trial, k):
break
variants.append(trial)
if self.guard is not None:
# 직접 잼(406) — 같은 쪽으로 가장 많이 합친 꼴부터(S자 뺌 · 남은 꼴은 옛 차례) · 잼
# 지나는 놓기를 모두 본 뒤 덜 나쁜 놓기(MC 는 늘 측점에)
same = 1
while same < len(variants):
p = variants[same - 1].points
if _turn_sign(*p[k - 1 : k + 2]) != _turn_sign(*p[k : k + 3]):
break
same += 1
variants = variants[:same][::-1] + variants[same:]
for soft in (False, True) if self.guard is not None else (False,):
for trial in variants:
yield from self._placements(state, trial, soft)
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if (
_inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG
and self.reference.gap([state.end_xy, at, middle]) <= self.max_gap
if _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG and not self._far(
[state.end_xy, at, middle]
):
kink = plan.copy()
kink.flags[k], kink.radii[k] = False, None
@@ -427,7 +474,7 @@ class _Snapper:
# 꺾임점째 뺌(이웃 곡선 접선 자리가 넓어짐) → 안 되면 꺾임만 남김. 이 꺾임점은 앞
# 곡선이 꺾임만일 때만 뺌(앞 곡선 EC 가 이 꺾임점 쪽 직선 위)
movable = small > k or not plan.flags[k - 1]
if movable and self.reference.gap(chord) <= self.max_gap and not self._steeper(chord):
if movable and not self._far(chord) and not self._steeper(chord):
gone = plan.copy()
del gone.points[small], gone.flags[small], gone.radii[small]
yield replace(state, plan=gone)
@@ -436,7 +483,7 @@ class _Snapper:
at,
((at[0] + b[0]) / 2, (at[1] + b[1]) / 2),
]
if self.reference.gap(piece) > self.max_gap or self._steeper(piece):
if self._far(piece) or self._steeper(piece):
continue
off = plan.copy()
off.flags[small], off.radii[small] = False, None
@@ -455,7 +502,8 @@ class _Snapper:
corner, mc = found
at, after = plan.points[k], plan.points[k + 1]
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if self.reference.gap([state.end_xy, *corner.arc, middle]) > self.max_gap:
# 직접 잼(406)은 안 거름 — 옮기지 않은 곡선(앞 곡선이 민 만큼만 달라짐)
if self.guard is None and self._far([state.end_xy, *corner.arc, middle]):
return
kept = plan.copy()
kept.radii[k] = round(corner.radius, 3)
@@ -508,17 +556,24 @@ def snap_curves_to_stations(
max_gap_m: float = ROUTE_STATION_SNAP_MAX_GAP_M,
*,
excess: Excess | None = None,
guard: Any = None,
) -> PlannedPolyline | None:
"""곡선 MC 를 일반측점에 놓아 다시 짠 선 · 못 하면 None(원래 선을 씀). 곡선 없으면 그대로.
모두 놓는 길을 먼저 찾고, 없으면 못 놓는 곡선만 그 자리에 둔 부분 맞춤(`off_station_count`).
`excess`(점 묶음 → 기울기가 상한을 넘은 만큼)를 주면 옮긴 조각이 원래 자리보다 더 넘을 때 그
놓기를 버림 — 급한 산지에서 측점 맞춤이 기울기를 망치지 않게.
놓기를 버림 — 급한 산지에서 측점 맞춤이 기울기를 망치지 않게. `guard`(추천 후보 · 406)를 주면
이탈 한도 · `excess` 대신 그 직접 잼으로 거르고 자리를 고름.
"""
if not outline.curves:
return outline
snapper = _Snapper(outline, min_radius_m, max_gap_m, excess)
state = snapper.run(keep=False) or snapper.run(keep=True)
snapper = _Snapper(outline, min_radius_m, max_gap_m, excess, guard)
state = None
for slack in SOFT_SLACKS if guard is not None else (math.inf,):
snapper.slack = slack
state = state or snapper.run(keep=False)
snapper.slack = 0.0
state = state or snapper.run(keep=True)
if state is None:
return None
snapped = build_planned_polyline(
@@ -32,8 +32,9 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
)
from common_util.common_util_json import atomic_write_json
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1)
ALGO_VERSION = 5
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406)
ALGO_VERSION = 6
STORE_DIR = "B05_Profile/route_recommend"
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
KEEP_PER_KIND = 2
+124
View File
@@ -0,0 +1,124 @@
"""406 추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼 — `B05_Profile_Engine_RouteInitial_Merge`.
./venv/Scripts/python.exe -m pytest resources/tester/test_406_1_route_merge.py -q
· 같은 쪽으로 도는 이웃 곡선 둘 → 바깥 두 직선 교점 하나(곡선 하나) · S자는 안 합침.
· 막힘 — 지반 기울기(골짜기) · 복도 밖(지형 없음) · 계류 버퍼 안 나란히 · 노선 전체 잣대.
· 큰 R(`widen`) 직접 잼 = 평지면 이탈 한도(4 m) 판보다 큼.
"""
import numpy as np
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
GRADE_NOISE,
Guard,
merge_same_side,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _Reference
from common_util.common_util_route_polyline import _line_intersection, build_planned_polyline
RADIUS = 12.0
GRADE = 0.14
SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)] # 둘 다 왼쪽으로 돎
S_CURVE = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (360.0, 80.0)] # 왼쪽 → 오른쪽
FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0, "cut_m3": 0.0, "fill_m3": 0.0}
def _outline(points):
return build_planned_polyline(
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(points)
)
def _guard(points, z=None, valid=None, streams=(), stream_offset_m=100.0):
x = np.arange(0.0, 402.0, 2.0)
y = np.arange(0.0, 262.0, 2.0)
xx, yy = np.meshgrid(x, y)
terrain = Terrain(
x,
y,
np.zeros(xx.shape) if z is None else z(xx, yy),
np.ones(xx.shape, dtype=bool) if valid is None else valid(xx, yy),
)
grid = SearchGrid(points, terrain, list(streams), stream_offset_m=stream_offset_m)
return Guard(grid, GRADE)
def _distance(outline):
line = LineString(outline.vertices)
return np.vectorize(lambda px, py: line.distance(Point(px, py)))
def _merge(points, guard, measure=lambda line: FLAT):
outline = _outline(points)
return merge_same_side((outline, FLAT), guard, RADIUS, measure)[0]
def test_same_side_pair_merges_to_outer_intersection() -> None:
merged = _merge(SAME, _guard(SAME))
assert len(merged.curves) == 1
cross = _line_intersection(*SAME)
assert merged.curves[0].apex == (round(cross[0], 4), round(cross[1], 4))
assert merged.curves[0].radius_m > RADIUS
def test_s_curve_keeps_straight() -> None:
merged = _merge(S_CURVE, _guard(S_CURVE))
assert len(merged.curves) == 2
assert _flat(merged)[0] == _flat(_outline(S_CURVE))[0]
def test_valley_grade_blocks() -> None:
"""옛 선 = 골짜기 바닥(평평) · 합친 곡선은 옆 비탈을 올라 기울기 상한을 넘음."""
distance = _distance(_outline(SAME))
guard = _guard(SAME, z=lambda xx, yy: 3.0 * distance(xx, yy))
assert len(_merge(SAME, guard).curves) == 2
# 막은 까닭 = 기울기 — 합친 꼴 조각이 옛 자리보다 상한을 더 넘음
cross = _line_intersection(*SAME)
piece = guard.piece(SAME[0], cross, SAME[-1], 40.0)
window = _Reference(_outline(SAME).vertices).window(piece)
assert guard._look(piece)[0] > guard._look(window)[0] + GRADE_NOISE
def test_outside_corridor_blocks() -> None:
distance = _distance(_outline(SAME))
guard = _guard(SAME, valid=lambda xx, yy: distance(xx, yy) <= 4.0)
assert len(_merge(SAME, guard).curves) == 2
def test_stream_buffer_blocks() -> None:
"""평지에서 합친 큰 곡선 가운데에 나란한 계류 — 그 곡선만 버퍼 안을 나란히 지남."""
merged = _merge(SAME, _guard(SAME))
arc = LineString(merged.vertices)
middle = arc.interpolate(arc.project(Point(_line_intersection(*SAME))))
ahead = arc.interpolate(arc.project(middle) + 1.0)
unit = np.subtract(ahead.coords[0], middle.coords[0])
stream = [tuple(np.add(middle.coords[0], unit * s)) for s in (-12.0, 12.0)]
gap = LineString(_outline(SAME).vertices).distance(middle)
assert gap > 5.0
guard = _guard(SAME, streams=[stream], stream_offset_m=0.5 * gap)
original = _outline(SAME).vertices
assert guard._look(original)[2] == 0.0 # 옛 선은 버퍼 밖
# 버퍼 안을 나란히 지나는 큰 R 은 막힘 — 버퍼를 비키는 작은 R 로 합치거나 안 합침
kept = _merge(SAME, guard)
assert len(kept.curves) == 2 or kept.curves[0].radius_m < merged.curves[0].radius_m
assert guard._look(kept.vertices)[2] == 0.0
def test_route_yardstick_blocks() -> None:
"""노선 전체 잣대(넘은 곳 · 최대 기울기 · 절성토)가 합치기 전보다 나빠지면 안 합침."""
worse = {**FLAT, "max_grade_pct": 5.0}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: worse).curves) == 2
def test_widen_guard_larger_than_gap_limit() -> None:
"""곧은 두 직선이 직각으로 만나는 탐색 경로 — 이탈 4 m 면 R 이 못 커짐 · 평지 직접 잼은 큼."""
path = [(float(x), 20.0) for x in np.arange(20.0, 220.0, 2.0)]
path += [(220.0, float(y)) for y in np.arange(20.0, 222.0, 2.0)]
fitted = build_planned_polyline(path, min_radius_m=RADIUS)
gap = widen(fitted, path, RADIUS)
direct = widen(fitted, path, RADIUS, guard=_guard(path))
assert direct.curves[0].radius_m > gap.curves[0].radius_m + 20.0