Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -56,8 +56,16 @@ 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, route_metrics
|
||||
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_Pool import (
|
||||
Pool,
|
||||
build_pool,
|
||||
@@ -78,6 +86,10 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||
SearchGrid,
|
||||
Terrain,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
|
||||
off_station_count,
|
||||
snap_curves_to_stations,
|
||||
)
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
__all__ = ["NoRoute", "Terrain", "generate_initial_route"]
|
||||
@@ -315,6 +327,24 @@ 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 follow_line() -> tuple[Any, dict[str, Any]]:
|
||||
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 →
|
||||
# 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸.
|
||||
@@ -365,12 +395,45 @@ def generate_initial_route(
|
||||
for route in [*pool.candidates, pool.follow]:
|
||||
profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈
|
||||
outline, metrics = pick(target, pool.candidates, with_profile)
|
||||
if with_profile:
|
||||
profiled((outline, metrics))
|
||||
if pipes and "error" not in metrics["profile"]:
|
||||
metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes))
|
||||
else:
|
||||
metrics = plain_metrics(metrics)
|
||||
# 측점 맞춤(351-2 · 브레인 확정) — 고른 선의 곡선 MC 를 일반측점에 놓은 선을 늘 함께 냄(모달
|
||||
# 「측점 맞춤」 켬 = 이 선 · 끔 = 고른 선 그대로). 갈래는 옮긴 조각이 원래보다 더 가파르면 그
|
||||
# 곡선만 그 자리(추적은 제약을 안 따짐). 비교값은 맞춘 선으로 다시 셈.
|
||||
raw = (outline, metrics)
|
||||
moved = snap_curves_to_stations(
|
||||
outline, min_radius_m, excess=None if target == "follow" else grade_excess
|
||||
)
|
||||
snapped = (
|
||||
raw
|
||||
if moved is None or moved is outline
|
||||
else (
|
||||
moved,
|
||||
measure(
|
||||
moved,
|
||||
bound=bound,
|
||||
follow=target == "follow",
|
||||
weighted=target == "weighted",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
|
||||
line, values = route
|
||||
if with_profile:
|
||||
profiled(route)
|
||||
if pipes and "error" not in values["profile"]:
|
||||
values["profile"].update(cover_shortfall(values["profile"], line.vertices, pipes))
|
||||
else:
|
||||
values = plain_metrics(values)
|
||||
return {
|
||||
"planned": [[round(x, 4), round(y, 4)] for x, y in line.vertices],
|
||||
"nodes": [node.as_dict() for node in line.nodes],
|
||||
"curves": [curve.as_dict() for curve in line.curves],
|
||||
# MC 가 일반측점 밖인 곡선 수(못 맞춘 곡선은 그 자리) — 모달 상태줄
|
||||
"off_station_curves": off_station_count(line),
|
||||
"metrics": values,
|
||||
}
|
||||
|
||||
shown = finish(snapped)
|
||||
return {
|
||||
"mode": mode,
|
||||
"logic": mode,
|
||||
@@ -378,10 +441,9 @@ def generate_initial_route(
|
||||
"relaxed": bound is not None,
|
||||
# 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음
|
||||
"terrain_bound_pct": None if bound is None else round(bound * 100.0, 2),
|
||||
"planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices],
|
||||
"nodes": [node.as_dict() for node in outline.nodes],
|
||||
"curves": [curve.as_dict() for curve in outline.curves],
|
||||
"metrics": metrics,
|
||||
**shown,
|
||||
# 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음)
|
||||
"unsnapped": None if snapped is raw else finish(raw),
|
||||
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
|
||||
"similar": None if pool is None else similar(pool, target, outline, with_profile),
|
||||
"with_profile": with_profile,
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""추천 노선 곡선 MC → 일반측점(PLAN 351-2 · 요구 235-3).
|
||||
|
||||
`B05_Profile_Engine_RouteInitial.generate_initial_route` 끝이 고른 선(다섯 갈래 · 큰 R 뒤)에
|
||||
부름 — 맞춘 선 · 맞추기 전 선 두 벌을 냄(모달 「측점 맞춤」 켬끔 · 브레인 확정). 전처리 초기
|
||||
노선 · 손 편집 · [측점 스냅]은 안 거침.
|
||||
|
||||
일반측점 = 누가거리 20 m 격자(No.N+0). 곡선 가운데 MC(BC · EC 누가거리 가운데 = 곡선 중심과
|
||||
마주치는 법선 자리)를 일반측점 위에 둔다 → 곡선 수 ≤ 내부 일반측점 수. 앞 곡선이 뒤 누가거리를
|
||||
바꾸므로 시점부터 차례로:
|
||||
|
||||
· 곡선마다 교각점(IP)을 들어오는 접선 따라 밀고(앞 곡선 · 앞 직선은 그대로) R 을 맞춰 MC 를
|
||||
가까운 일반측점에 놓음 — 앞 MC 보다 20 m 이상 뒤 · 원래 선에서 `ROUTE_STATION_SNAP_MAX_GAP_M`
|
||||
안 · 최소 R 미달을 새로 만들지 않음.
|
||||
· 못 놓으면 다음 꺾임과 합침(같은 쪽으로 돌면 두 접선 교점 · 아니면 다음 IP 를 뺌) → 그래도
|
||||
안 되면 그 곡선을 끔(꺾임만) → 그래도 이탈 한도를 넘으면 실패.
|
||||
· 모두 놓는 길이 없으면 못 놓는 곡선만 그 자리에 두고 나머지는 맞춤(부분 · 「측점 밖 곡선 N개」).
|
||||
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
|
||||
|
||||
고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점
|
||||
하나로 펴서(`flattenServerPlan`) [확인] 때 다시 짜는 입력과 같아 저장 뒤에도 MC 가 측점 위(TS 짝
|
||||
불필요). 누가거리는 짜인 선의 정점(원호 5° 현)을 따라 잼 — 측점 생성과 같은 잣대.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import shapely
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
|
||||
from common_util.common_util_route_polyline import (
|
||||
STRAIGHT_INNER_ANGLE_DEG,
|
||||
PlannedPolyline,
|
||||
_arc_geometry,
|
||||
_arc_points,
|
||||
_inner_angle_deg,
|
||||
_line_intersection,
|
||||
_turn_sign,
|
||||
build_planned_polyline,
|
||||
)
|
||||
from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M
|
||||
|
||||
Xy = tuple[float, float]
|
||||
# 점 묶음 → 지반 기울기가 상한을 넘은 만큼(0 = 상한 안)
|
||||
Excess = Callable[[list[Xy]], float]
|
||||
|
||||
STATION_M = 20.0
|
||||
# MC 가 측점에서 이만큼 안이면 「위」(m) — 저장 반올림(교각점 0.1 mm · R 1 mm)을 넉넉히 덮음
|
||||
MC_TOL_M = 0.05
|
||||
# IP 를 밀 수 있는 거리(m) — 다음 측점까지 한 칸(실제 한도는 원래 선에서 벗어남)
|
||||
SHIFT_MAX_M = STATION_M
|
||||
# 곡선 접선이 뒤 직선 절반에서 남길 여유(m) — 다음 IP 를 뒤로 이만큼 두 배 밀 수 있음
|
||||
SLACK_M = 5.0
|
||||
# 한 곡선이 뒤 꺾임을 몇 번까지 합쳐 볼지
|
||||
MERGE_TRIES = 3
|
||||
# R 맞춤 후보 수(최소 R ~ 자리 R 사이 · 지금 R 에 가까운 것부터)
|
||||
RADIUS_STEPS = 8
|
||||
# 막혀 앞 곡선의 다음 갈래로 되돌아가는 횟수 한도 — 넘으면 부분 맞춤으로
|
||||
BACKTRACK_LIMIT = 30
|
||||
# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로
|
||||
PER_VARIANT = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Corner:
|
||||
arc: list[Xy] # BC · 원호 점 · EC — 짜인 선 정점 그대로
|
||||
radius: float
|
||||
tangent: float
|
||||
length: float # 정점을 따라 잰 BC → EC
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Plan:
|
||||
points: list[Xy]
|
||||
flags: list[bool]
|
||||
radii: list[float | None]
|
||||
|
||||
def copy(self) -> _Plan:
|
||||
return _Plan(list(self.points), list(self.flags), list(self.radii))
|
||||
|
||||
|
||||
def _corner(before: Xy, at: Xy, after: Xy, radius: float) -> _Corner | None:
|
||||
"""`build_planned_polyline` 편집 갈래 곡선 하나와 같은 셈(접선 자리 모자라면 R 줄임)."""
|
||||
inner = _inner_angle_deg(before, at, after)
|
||||
half_tan = math.tan(math.radians(180.0 - inner) / 2)
|
||||
available = min(math.dist(before, at), math.dist(at, after)) / 2
|
||||
if half_tan <= 1e-9 or available <= 0 or radius <= 0:
|
||||
return None
|
||||
tangent = radius * half_tan
|
||||
if tangent > available:
|
||||
radius, tangent = available / half_tan, available
|
||||
geometry = _arc_geometry(before, at, after, inner, radius, half_tan)
|
||||
if geometry is None:
|
||||
return None
|
||||
start, end, center = geometry
|
||||
clockwise = _turn_sign(before, at, after) < 0
|
||||
arc = [start, *_arc_points(center, start, end, clockwise=clockwise), end]
|
||||
length = sum(math.dist(a, b) for a, b in zip(arc, arc[1:]))
|
||||
return _Corner(arc, radius, tangent, length)
|
||||
|
||||
|
||||
def curve_mc_chainages(outline: PlannedPolyline) -> list[float]:
|
||||
"""곡선마다 MC 누가거리 — 짜인 선 정점을 따라 잰 (BC + EC) / 2(측점 스냅과 같은 정의)."""
|
||||
vertices = outline.vertices
|
||||
coords = np.asarray(vertices, dtype=float)
|
||||
along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))])
|
||||
found: list[float] = []
|
||||
cursor = 0
|
||||
for curve in outline.curves:
|
||||
first = next(i for i in range(cursor, len(vertices)) if vertices[i] == curve.start)
|
||||
last = next(i for i in range(first, len(vertices)) if vertices[i] == curve.end)
|
||||
found.append(float(along[first] + along[last]) / 2)
|
||||
cursor = last
|
||||
return found
|
||||
|
||||
|
||||
def station_hits(outline: PlannedPolyline, tol: float = MC_TOL_M) -> int:
|
||||
"""MC 가 일반측점(No.1 이상) 위인 곡선 수 — 같은 측점 둘은 하나만 셈."""
|
||||
stations = {
|
||||
n
|
||||
for mc in curve_mc_chainages(outline)
|
||||
if (n := round(mc / STATION_M)) >= 1 and abs(mc - n * STATION_M) <= tol
|
||||
}
|
||||
return len(stations)
|
||||
|
||||
|
||||
def off_station_count(outline: PlannedPolyline) -> int:
|
||||
"""측점 밖 곡선 수 — 모달 상태줄 「측점 밖 곡선 N개」."""
|
||||
return len(outline.curves) - station_hits(outline)
|
||||
|
||||
|
||||
def _unit(a: Xy, b: Xy) -> Xy:
|
||||
length = math.dist(a, b)
|
||||
return ((b[0] - a[0]) / length, (b[1] - a[1]) / length)
|
||||
|
||||
|
||||
class _Reference:
|
||||
"""원래 선 — 맞춘 조각과 같은 자리만 잘라 잼(정점 배열 · 누가거리)."""
|
||||
|
||||
def __init__(self, vertices: list[Xy]) -> None:
|
||||
# 겹친 정점(길이 0 조각)은 뺌 — 내려 잴 때 NaN
|
||||
vertices = [xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]]
|
||||
self.line = LineString(vertices)
|
||||
self.coords = np.asarray(vertices, dtype=float)
|
||||
self.along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(self.coords, axis=0).T))])
|
||||
|
||||
def window(self, local: list[Xy]) -> list[Xy]:
|
||||
"""맞춘 조각과 같은 자리의 원래 선 — 조각 양 끝을 내린 사이."""
|
||||
s0, s1 = sorted(self.line.project(Point(xy)) for xy in (local[0], local[-1]))
|
||||
inside = self.coords[(self.along > s0) & (self.along < s1)]
|
||||
ends = [
|
||||
(
|
||||
np.interp(s, self.along, self.coords[:, 0]),
|
||||
np.interp(s, self.along, self.coords[:, 1]),
|
||||
)
|
||||
for s in (s0, s1)
|
||||
]
|
||||
return [ends[0], *map(tuple, inside), ends[1]]
|
||||
|
||||
def gap(self, local: list[Xy]) -> float:
|
||||
"""맞춘 조각과 원래 선의 같은 자리 사이 가장 먼 거리(m)."""
|
||||
piece = LineString([xy for i, xy in enumerate(local) if not i or xy != local[i - 1]])
|
||||
window = LineString(self.window(local))
|
||||
gap = float(shapely.hausdorff_distance(piece, window, densify=0.1))
|
||||
return gap if math.isfinite(gap) else math.inf
|
||||
|
||||
|
||||
@dataclass
|
||||
class _State:
|
||||
"""차례 셈의 한 걸음 — 곡선 k 앞까지 정해진 선."""
|
||||
|
||||
plan: _Plan
|
||||
k: int # 다음에 볼 꺾임점
|
||||
end_xy: Xy # 앞 요소 끝(앞 곡선 EC 또는 앞 꺾임점)
|
||||
end_s: float
|
||||
prev_tangent: float # 앞 곡선 접선 길이 — 그 곡선 R 이 줄지 않게
|
||||
last_station: float # 앞 MC 측점(없으면 0 = 시점)
|
||||
placed: int = 0 # 측점에 놓은 곡선 수
|
||||
|
||||
|
||||
def _try(state: _State, plan: _Plan, k: int, at: Xy, radius: float) -> tuple[_Corner, float] | None:
|
||||
"""IP 를 `at` 에 두고 R 을 준 곡선과 그 MC — 앞 곡선이 줄거나 BC 가 앞 요소 앞이면 None."""
|
||||
before, after = plan.points[k - 1], plan.points[k + 1]
|
||||
corner = _corner(before, at, after, radius)
|
||||
if corner is None or state.prev_tangent > math.dist(before, at) / 2 + 1e-6:
|
||||
return None
|
||||
u = _unit(before, at)
|
||||
bc = corner.arc[0]
|
||||
if (bc[0] - state.end_xy[0]) * u[0] + (bc[1] - state.end_xy[1]) * u[1] < -1e-6:
|
||||
return None
|
||||
return corner, state.end_s + math.dist(state.end_xy, bc) + corner.length / 2
|
||||
|
||||
|
||||
def _solve(
|
||||
state: _State, plan: _Plan, k: int, target: float, radius: float
|
||||
) -> tuple[Xy, _Corner, float] | None:
|
||||
"""IP 를 들어오는 접선 따라 d 만큼 밀어 MC = target — 할선법(기울기 ≈ 1)."""
|
||||
base = plan.points[k]
|
||||
u = _unit(plan.points[k - 1], base)
|
||||
|
||||
def at_of(d: float) -> Xy:
|
||||
return (round(base[0] + u[0] * d, 4), round(base[1] + u[1] * d, 4))
|
||||
|
||||
def miss(d: float) -> float | None:
|
||||
found = _try(state, plan, k, at_of(d), radius)
|
||||
return None if found is None else found[1] - target
|
||||
|
||||
# 앞 곡선 접선이 앞 직선 절반을 다 쓰면 뒤로는 못 감 — 앞으로 조금 민 데서 시작
|
||||
d0 = next((d for d in (0.0, 1.0, 3.0, -1.0) if miss(d) is not None), None)
|
||||
if d0 is None:
|
||||
return None
|
||||
f0 = miss(d0)
|
||||
assert f0 is not None
|
||||
d1 = max(-SHIFT_MAX_M, min(SHIFT_MAX_M, d0 - f0))
|
||||
for _ in range(10):
|
||||
if abs(f0) <= MC_TOL_M / 5:
|
||||
done = _try(state, plan, k, at_of(d0), radius)
|
||||
return None if done is None else (at_of(d0), done[0], done[1])
|
||||
f1 = miss(d1)
|
||||
if f1 is None or d1 == d0:
|
||||
return None
|
||||
slope = (f1 - f0) / (d1 - d0)
|
||||
if abs(slope) < 0.2:
|
||||
return None
|
||||
d0, f0 = d1, f1
|
||||
d1 = max(-SHIFT_MAX_M, min(SHIFT_MAX_M, d1 - f1 / slope))
|
||||
return None
|
||||
|
||||
|
||||
def _absorb_next(plan: _Plan, k: int) -> bool:
|
||||
"""곡선 k 에 뒤 꺾임 하나를 합침 — 같은 쪽 = 두 접선 교점 · 아니면 다음 IP 뺌."""
|
||||
if k + 2 >= len(plan.points):
|
||||
return False
|
||||
before, at, nxt, beyond = (plan.points[i] for i in range(k - 1, k + 3))
|
||||
same = _turn_sign(before, at, nxt) == _turn_sign(at, nxt, beyond) != 0
|
||||
cross = _line_intersection(before, at, nxt, beyond) if same else None
|
||||
ahead = cross is not None and (
|
||||
(cross[0] - before[0]) * (at[0] - before[0]) + (cross[1] - before[1]) * (at[1] - before[1])
|
||||
> 0
|
||||
and (beyond[0] - cross[0]) * (beyond[0] - nxt[0])
|
||||
+ (beyond[1] - cross[1]) * (beyond[1] - nxt[1])
|
||||
> 0
|
||||
)
|
||||
radius = max(r for r in (plan.radii[k], plan.radii[k + 1], 0.0) if r is not None)
|
||||
if ahead:
|
||||
assert cross is not None
|
||||
plan.points[k : k + 2] = [(round(cross[0], 4), round(cross[1], 4))]
|
||||
else:
|
||||
del plan.points[k + 1]
|
||||
plan.flags[k : k + 2] = [True]
|
||||
plan.radii[k : k + 2] = [radius or None]
|
||||
return True
|
||||
|
||||
|
||||
class _Snapper:
|
||||
def __init__(
|
||||
self,
|
||||
outline: PlannedPolyline,
|
||||
min_radius_m: float,
|
||||
max_gap_m: float,
|
||||
excess: Excess | 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.excess = excess
|
||||
self.keep = False
|
||||
|
||||
def _steeper(self, local: list[Xy]) -> bool:
|
||||
"""맞춘 조각이 원래 선 같은 자리보다 상한을 더 넘나."""
|
||||
if self.excess is None:
|
||||
return False
|
||||
return self.excess(local) > self.excess(self.reference.window(local)) + 1e-6
|
||||
|
||||
@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 에 가까운 것부터."""
|
||||
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)
|
||||
if start is None:
|
||||
return
|
||||
corner, mc = start
|
||||
floor_r = min(self.min_radius, corner.radius)
|
||||
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, at, after)) / 2)
|
||||
room = min(math.dist(before, at), math.dist(at, after)) / 2 / half_tan
|
||||
steps = np.geomspace(max(floor_r, 1e-3), room, RADIUS_STEPS) if room > floor_r else []
|
||||
radii = [round(corner.radius, 3)] + sorted(
|
||||
{round(float(r), 2) for r in steps}, key=lambda r: abs(r - corner.radius)
|
||||
)
|
||||
lowest = state.last_station + STATION_M
|
||||
stations = {
|
||||
max(lowest, STATION_M * math.floor(mc / STATION_M)),
|
||||
max(lowest, STATION_M * math.ceil(mc / STATION_M)),
|
||||
}
|
||||
found: list[tuple[tuple[bool, float, float], Xy, _Corner, float, float]] = []
|
||||
for station in stations:
|
||||
for radius in radii:
|
||||
solved = _solve(state, plan, k, station, radius)
|
||||
if solved is None or solved[1].radius < floor_r - 1e-6:
|
||||
continue
|
||||
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))
|
||||
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):
|
||||
continue
|
||||
placed = plan.copy()
|
||||
placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3)
|
||||
yield _State(
|
||||
placed,
|
||||
k + 1,
|
||||
new_corner.arc[-1],
|
||||
new_mc + new_corner.length / 2,
|
||||
new_corner.tangent,
|
||||
station,
|
||||
state.placed + 1,
|
||||
)
|
||||
given += 1
|
||||
if given >= PER_VARIANT:
|
||||
return
|
||||
|
||||
def _options(self, state: _State) -> Iterator[_State]:
|
||||
"""꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔."""
|
||||
plan, k = state.plan, state.k
|
||||
before, at, after = plan.points[k - 1 : k + 2]
|
||||
if not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None:
|
||||
kink = plan.copy()
|
||||
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()
|
||||
if not _absorb_next(trial, k):
|
||||
break
|
||||
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만
|
||||
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
|
||||
):
|
||||
kink = plan.copy()
|
||||
kink.flags[k], kink.radii[k] = False, None
|
||||
yield self._kink(state, kink)
|
||||
if self.keep:
|
||||
yield from self._kept(state)
|
||||
|
||||
def _kept(self, state: _State) -> Iterator[_State]:
|
||||
"""못 놓는 곡선은 그 자리 그대로(브레인 확정 · 측점 밖 곡선으로 셈)."""
|
||||
plan, k = state.plan, state.k
|
||||
found = _try(state, plan, k, plan.points[k], plan.radii[k] or self.min_radius)
|
||||
if found is None:
|
||||
return
|
||||
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:
|
||||
return
|
||||
kept = plan.copy()
|
||||
kept.radii[k] = round(corner.radius, 3)
|
||||
yield _State(
|
||||
kept,
|
||||
k + 1,
|
||||
corner.arc[-1],
|
||||
mc + corner.length / 2,
|
||||
corner.tangent,
|
||||
max(state.last_station, STATION_M * math.floor(mc / STATION_M)),
|
||||
state.placed,
|
||||
)
|
||||
|
||||
def run(self, keep: bool) -> _State | None:
|
||||
"""시점부터 깊이 우선 — 막히면 앞 곡선의 다음 갈래로(되돌림 `BACKTRACK_LIMIT` 번까지).
|
||||
|
||||
`keep` = 못 놓는 곡선을 그 자리에 두는 갈래를 끝에 더함(부분 맞춤).
|
||||
"""
|
||||
self.keep = keep
|
||||
stack = [self._options(self.start)]
|
||||
backtracks = 0
|
||||
while stack:
|
||||
state = next(stack[-1], None)
|
||||
if state is None:
|
||||
stack.pop()
|
||||
backtracks += 1
|
||||
if backtracks > BACKTRACK_LIMIT:
|
||||
return None
|
||||
continue
|
||||
if state.k >= len(state.plan.points) - 1:
|
||||
return state
|
||||
stack.append(self._options(state))
|
||||
return None
|
||||
|
||||
|
||||
def snap_curves_to_stations(
|
||||
outline: PlannedPolyline,
|
||||
min_radius_m: float,
|
||||
max_gap_m: float = ROUTE_STATION_SNAP_MAX_GAP_M,
|
||||
*,
|
||||
excess: Excess | None = None,
|
||||
) -> PlannedPolyline | None:
|
||||
"""곡선 MC 를 일반측점에 놓아 다시 짠 선 · 못 하면 None(원래 선을 씀). 곡선 없으면 그대로.
|
||||
|
||||
모두 놓는 길을 먼저 찾고, 없으면 못 놓는 곡선만 그 자리에 둔 부분 맞춤(`off_station_count`).
|
||||
`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)
|
||||
if state is None:
|
||||
return None
|
||||
snapped = build_planned_polyline(
|
||||
state.plan.points,
|
||||
min_radius_m=min_radius_m,
|
||||
simplify=False,
|
||||
curve_flags=state.plan.flags,
|
||||
radii=state.plan.radii,
|
||||
)
|
||||
return snapped if station_hits(snapped) >= state.placed else None
|
||||
@@ -32,7 +32,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
ALGO_VERSION = 2
|
||||
ALGO_VERSION = 3 # 3 = 곡선 MC 일반측점(351-2)
|
||||
STORE_DIR = "B05_Profile/route_recommend"
|
||||
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
|
||||
KEEP_PER_KIND = 2
|
||||
|
||||
@@ -148,11 +148,15 @@ async def compute_initial(
|
||||
current,
|
||||
),
|
||||
)
|
||||
profile = result["metrics"].get("profile")
|
||||
if request.with_profile and profile and "error" not in profile:
|
||||
# 측점 맞춘 선 · 맞추기 전 선(351-2) 둘 다 덮개 수를 지금 관으로
|
||||
shown = [result, *([result["unsnapped"]] if result.get("unsnapped") else [])]
|
||||
profiles = [(item["metrics"].get("profile"), item["planned"]) for item in shown]
|
||||
profiles = [(p, line) for p, line in profiles if p and "error" not in p]
|
||||
if request.with_profile and profiles:
|
||||
pipes = await asyncio.to_thread(project_pipes, project_root)
|
||||
if pipes:
|
||||
profile.update(cover_shortfall(profile, result["planned"], pipes))
|
||||
for profile, line in profiles:
|
||||
profile.update(cover_shortfall(profile, line, pipes))
|
||||
except (FileNotFoundError, ValueError) as error:
|
||||
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
|
||||
logger.info(
|
||||
|
||||
@@ -193,6 +193,12 @@ export interface InitialChoice {
|
||||
stored?: boolean;
|
||||
/** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */
|
||||
balance?: BalanceProfile | null;
|
||||
/** 서버가 짠 선(원호 정점 · 계획서 351-2) — 모달 미리 보기에만. 캐시에는 안 적는다. */
|
||||
line?: Vertex[] | null;
|
||||
/** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */
|
||||
offStationCurves?: number | null;
|
||||
/** 측점 맞춤 끔 = 맞추기 전 선(계획서 351-2) — 모달에만 · 캐시에는 안 적는다. 같으면 null. */
|
||||
unsnapped?: InitialChoice | null;
|
||||
}
|
||||
|
||||
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
|
||||
@@ -339,7 +345,20 @@ export function normalizeRouteInitial(
|
||||
similar: parseSimilar(body.similar),
|
||||
bandWidthM: num(body.band_width_m),
|
||||
stored: body.stored === true,
|
||||
offStationCurves: num(body.off_station_curves),
|
||||
unsnapped:
|
||||
body.unsnapped && typeof body.unsnapped === "object"
|
||||
? normalizeRouteInitial(
|
||||
{ ...body, ...(body.unsnapped as Record<string, unknown>), unsnapped: null },
|
||||
logic,
|
||||
weights,
|
||||
criteriaOverrides,
|
||||
)
|
||||
: null,
|
||||
balance: parseBalance(m.profile),
|
||||
line: Array.isArray(body.planned)
|
||||
? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])])
|
||||
: null,
|
||||
criteria: body.criteria
|
||||
? {
|
||||
maxGradePct: pick(c, "max_grade_pct"),
|
||||
@@ -562,6 +581,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null {
|
||||
}
|
||||
|
||||
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
|
||||
// 균형 종단은 편집 창 안에서만(계획서 63-4) — 캐시에 싣지 않는다.
|
||||
writeState("routeedit-initial", choice && { ...choice, balance: undefined }, projectId);
|
||||
// 균형 종단 · 서버 선 · 맞추기 전 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
|
||||
writeState(
|
||||
"routeedit-initial",
|
||||
choice && { ...choice, balance: undefined, line: undefined, unsnapped: undefined },
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
|
||||
const invalid = new Set<WeightKey>();
|
||||
let preview: InitialChoice | null = null;
|
||||
/** 측점 맞춤(계획서 351-2) — 켬 = 곡선 MC 를 일반측점에 맞춘 선 · 끔 = 맞추기 전 선. */
|
||||
let snapOn = true;
|
||||
const shownChoice = (): InitialChoice | null =>
|
||||
preview && !snapOn ? (preview.unsnapped ?? preview) : preview;
|
||||
let busy = false;
|
||||
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
|
||||
let dirty = false;
|
||||
@@ -191,7 +195,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<label class="b05-routerec__target" data-target-row hidden
|
||||
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
|
||||
<span data-slot="target-input"></span>%</label>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>다시 셈</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>재계산</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routerec__legend">
|
||||
@@ -212,6 +216,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
</div>
|
||||
<div data-line="violations" hidden></div>
|
||||
<div class="is-warn" data-line="relaxed" hidden></div>
|
||||
<div class="is-warn" data-line="stations" hidden></div>
|
||||
<div data-line="similar" hidden></div>
|
||||
<div class="is-dim" data-line="similar-hint" hidden></div>
|
||||
<div data-line="criteria"></div>
|
||||
@@ -299,6 +304,13 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
|
||||
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
|
||||
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
|
||||
const snapToggle = toggleButton("측점 맞춤", snapOn, (active) => {
|
||||
snapOn = active;
|
||||
render();
|
||||
});
|
||||
snapToggle.dataset.act = "snap";
|
||||
snapToggle.title = "곡선 가운데(MC)를 일반측점(20 m) 위에 — 끄면 맞추기 전 선";
|
||||
rerun.before(snapToggle);
|
||||
const stateEl = q<HTMLElement>("[data-state]");
|
||||
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
|
||||
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
|
||||
@@ -330,7 +342,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
{ kind: "expected", points: params.expected },
|
||||
{ kind: "current", points: params.current },
|
||||
];
|
||||
if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned });
|
||||
// 서버가 짠 원호 그대로(351-2 · 측점 맞춤 켬끔) — 옛 답이면 꺾임점
|
||||
const view = shownChoice();
|
||||
if (view) layers.push({ kind: "preview", points: view.line ?? view.snapshot.planned });
|
||||
map?.setLayers(layers);
|
||||
}
|
||||
|
||||
@@ -382,40 +396,45 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
|
||||
rerun.hidden = !dirty;
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
|
||||
const shown = metricsLines(preview?.metrics ?? null);
|
||||
line("metrics").textContent = preview
|
||||
? preview.metrics
|
||||
const view = shownChoice();
|
||||
applyButton.disabled = busy || dirty || !view || invalid.size > 0;
|
||||
const shown = metricsLines(view?.metrics ?? null);
|
||||
line("metrics").textContent = view
|
||||
? view.metrics
|
||||
? shown.route
|
||||
: "비교값 — 서버 답에 없습니다."
|
||||
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
|
||||
line("metrics-work").textContent = preview ? shown.work : "";
|
||||
line("metrics-work").textContent = view ? shown.work : "";
|
||||
line("metrics-work").hidden = !line("metrics-work").textContent;
|
||||
const balance = preview?.balance ?? null;
|
||||
const balance = view?.balance ?? null;
|
||||
line("balance").textContent = balanceLine(balance);
|
||||
line("balance").hidden = !balance;
|
||||
line("profile").innerHTML = profileSvg(balance);
|
||||
line("profile").hidden = !balance;
|
||||
line("candidates").innerHTML = candidatesTable([...seen]);
|
||||
line("candidates").hidden = seen.size === 0;
|
||||
const violations = violationsLine(preview?.metrics ?? null);
|
||||
const violations = violationsLine(view?.metrics ?? null);
|
||||
line("violations").textContent = violations;
|
||||
line("violations").title = (preview?.metrics?.violations ?? [])
|
||||
line("violations").title = (view?.metrics?.violations ?? [])
|
||||
.map((item) => `${violationText(item)} — ${item.reason}`)
|
||||
.join("\n");
|
||||
line("violations").hidden = !violations;
|
||||
const relaxed = relaxedLine(preview);
|
||||
const relaxed = relaxedLine(view);
|
||||
line("relaxed").textContent = relaxed;
|
||||
line("relaxed").hidden = !relaxed;
|
||||
const similar = similarLine(preview);
|
||||
// 곡선 MC 를 일반측점에 못 놓은 곡선(351-2 · 그 자리에 둠)
|
||||
const offStation = view?.offStationCurves ?? 0;
|
||||
line("stations").textContent = offStation > 0 ? `측점 밖 곡선 ${offStation}개` : "";
|
||||
line("stations").hidden = offStation <= 0;
|
||||
const similar = similarLine(view);
|
||||
line("similar").textContent = similar;
|
||||
line("similar").hidden = !similar;
|
||||
line("similar-hint").textContent = similar ? SIMILAR_HINT : "";
|
||||
line("similar-hint").hidden = !similar;
|
||||
line("criteria").textContent = preview
|
||||
? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline")
|
||||
line("criteria").textContent = view
|
||||
? criteriaLine(view.criteria, params.planMinRadiusM, view.logic === "gradeline")
|
||||
: "";
|
||||
line("criteria").hidden = !preview;
|
||||
line("criteria").hidden = !view;
|
||||
syncRecommendHelp(body, logic);
|
||||
drawMap();
|
||||
}
|
||||
@@ -541,8 +560,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
});
|
||||
rerun.addEventListener("click", () => void run());
|
||||
applyButton.addEventListener("click", () => {
|
||||
if (!preview) return;
|
||||
params.onApply(preview);
|
||||
const view = shownChoice(); // 보이는 쪽(측점 맞춤 켬끔)
|
||||
if (!view) return;
|
||||
params.onApply(view);
|
||||
closed = true;
|
||||
close();
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR",
|
||||
ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9"))
|
||||
# 추천 노선 큰 R — 곡선을 키울 때 탐색 경로에서 벗어나도 되는 가장 먼 거리(m) · 탐색 칸 하나
|
||||
ROUTE_RECOMMEND_MAX_GAP_M = float(os.getenv("ROUTE_RECOMMEND_MAX_GAP_M", "4.0"))
|
||||
# 추천 노선 곡선 MC → 일반측점 — IP 를 밀어 맞춘 선이 맞추기 전 선에서 벗어나도 되는 가장 먼 거리(m)
|
||||
ROUTE_STATION_SNAP_MAX_GAP_M = float(os.getenv("ROUTE_STATION_SNAP_MAX_GAP_M", "6.0"))
|
||||
# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존
|
||||
# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19).
|
||||
ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch")
|
||||
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처
|
||||
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial as router
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import write_initial_choice
|
||||
@@ -28,7 +29,6 @@ from common_util.common_util_route_geometry import (
|
||||
planned_route_working_path,
|
||||
write_route_csv,
|
||||
)
|
||||
from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처
|
||||
|
||||
|
||||
def _saved(root: Path) -> list[dict]:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""351-2 추천 노선 곡선 MC → 일반측점 — `B05_Profile_Engine_RouteInitial_Station`.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_351_2_curve_stations.py -q
|
||||
|
||||
모든 곡선 MC % 20 ≈ 0 · 이웃 MC 20 m 이상 · 곡선 수 ≤ 내부 일반측점 수 · 맞추기 전 선에서
|
||||
`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과의
|
||||
기울기 넘은 곳이 맞추기 전보다 늘지 않음 · 검증_sub1 예상노선 8벌 수치.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
|
||||
MC_TOL_M,
|
||||
STATION_M,
|
||||
curve_mc_chainages,
|
||||
off_station_count,
|
||||
snap_curves_to_stations,
|
||||
station_hits,
|
||||
)
|
||||
from common_util.common_util_route_geometry import densify_route, read_planned_route_csv
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M
|
||||
|
||||
PROJECT = Path(__file__).resolve().parents[2] / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391"
|
||||
|
||||
|
||||
def _ls(vertices: list) -> LineString:
|
||||
"""겹친 정점 뺀 선(길이 0 조각은 GEOS 경고)."""
|
||||
return LineString([xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]])
|
||||
|
||||
|
||||
def _zigzag(seed: int) -> list[tuple[float, float]]:
|
||||
rng = random.Random(seed)
|
||||
points, heading = [(0.0, 0.0)], 0.0
|
||||
for _ in range(12):
|
||||
heading += rng.uniform(-0.8, 0.8)
|
||||
step = rng.uniform(30.0, 90.0)
|
||||
x, y = points[-1]
|
||||
points.append((x + step * math.cos(heading), y + step * math.sin(heading)))
|
||||
return points
|
||||
|
||||
|
||||
def _check(outline, before) -> None:
|
||||
mcs = curve_mc_chainages(outline)
|
||||
assert outline.curves and off_station_count(outline) == 0
|
||||
for mc in mcs:
|
||||
assert abs(mc - STATION_M * round(mc / STATION_M)) <= MC_TOL_M and mc >= STATION_M - 1
|
||||
assert all(b - a >= STATION_M - 2 * MC_TOL_M for a, b in zip(mcs, mcs[1:]))
|
||||
length = _ls(outline.vertices).length
|
||||
assert len(outline.curves) <= int((length - 1e-6) // STATION_M) # 내부 일반측점 수
|
||||
gap = _ls(outline.vertices).hausdorff_distance(_ls(before.vertices))
|
||||
assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05
|
||||
# 시점 · 종점 그대로
|
||||
assert outline.vertices[0] == pytest.approx(before.vertices[0], abs=1e-3)
|
||||
assert outline.vertices[-1] == pytest.approx(before.vertices[-1], abs=1e-3)
|
||||
|
||||
|
||||
def _reflattened(outline, min_radius: float):
|
||||
"""화면 `flattenServerPlan` → [확인] 다시 짜기와 같은 꼴(저장 반올림: IP 0.1 mm · R 1 mm)."""
|
||||
points, flags, radii = _flat(outline)
|
||||
return build_planned_polyline(
|
||||
[(round(x, 4), round(y, 4)) for x, y in points],
|
||||
min_radius_m=min_radius,
|
||||
simplify=False,
|
||||
curve_flags=flags,
|
||||
radii=[None if r is None else round(r, 3) for r in radii],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", range(8))
|
||||
def test_zigzag_mc_on_stations(seed: int) -> None:
|
||||
before = build_planned_polyline(
|
||||
_zigzag(seed), min_radius_m=15.0, simplify=False, curve_flags=[True] * 13
|
||||
)
|
||||
after = snap_curves_to_stations(before, 15.0)
|
||||
assert after is not None
|
||||
_check(after, before)
|
||||
for curve in after.curves:
|
||||
assert curve.radius_m >= 15.0 - 1e-6 or curve.violations
|
||||
# 저장 뒤 다시 짜도 MC 그대로
|
||||
again = _reflattened(after, 15.0)
|
||||
assert curve_mc_chainages(again) == pytest.approx(curve_mc_chainages(after), abs=MC_TOL_M)
|
||||
|
||||
|
||||
def test_no_curves_unchanged() -> None:
|
||||
straight = build_planned_polyline([(0.0, 0.0), (100.0, 0.0)], min_radius_m=12.0)
|
||||
assert snap_curves_to_stations(straight, 12.0) is straight
|
||||
|
||||
|
||||
def test_too_close_curves_merge_or_drop() -> None:
|
||||
"""20 m 안에 꺾임 셋 — 곡선 수 ≤ 내부 일반측점 수로 합쳐짐."""
|
||||
points = [(0.0, 0.0), (40.0, 0.0), (48.0, 4.0), (54.0, 10.0), (60.0, 20.0), (60.0, 80.0)]
|
||||
before = build_planned_polyline(
|
||||
points, min_radius_m=12.0, simplify=False, curve_flags=[True] * 6
|
||||
)
|
||||
after = snap_curves_to_stations(before, 12.0)
|
||||
assert after is not None and station_hits(after) == len(after.curves)
|
||||
assert len(after.curves) < len(before.curves)
|
||||
|
||||
|
||||
def _terrain() -> Terrain:
|
||||
x = np.arange(0.0, 802.0, 2.0)
|
||||
y = np.arange(0.0, 402.0, 2.0)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
|
||||
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hill_runs() -> dict:
|
||||
expected = [(float(x), 200.0 + 40.0 * math.sin(x / 60.0)) for x in np.arange(50.0, 752.0, 4.0)]
|
||||
return {
|
||||
mode: generate_initial_route(
|
||||
expected, _terrain(), [], mode=mode, weights=None, max_grade=0.14, min_radius_m=12.0
|
||||
)
|
||||
for mode in ("follow", "gentle", "earthwork", "weighted")
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["follow", "gentle", "earthwork", "weighted"])
|
||||
def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
|
||||
result = hill_runs[mode]
|
||||
assert result["off_station_curves"] == 0
|
||||
outline = build_planned_polyline(
|
||||
[tuple(n[k] for k in ("x", "y")) for n in result["nodes"]],
|
||||
min_radius_m=12.0,
|
||||
simplify=False,
|
||||
curve_flags=[n["radius_m"] is not None for n in result["nodes"]],
|
||||
radii=[n["radius_m"] for n in result["nodes"]],
|
||||
)
|
||||
# 화면이 펴서 다시 짠 선 = 서버 선
|
||||
assert np.allclose(outline.vertices, result["planned"], atol=1e-3)
|
||||
assert station_hits(outline) == len(outline.curves) > 0
|
||||
if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음)
|
||||
assert result["metrics"]["over_limit"]["total"] == 0
|
||||
assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6
|
||||
# 측점 맞춤 끔 = 맞추기 전 선(같으면 None) — 비교값 따로
|
||||
raw = result["unsnapped"]
|
||||
if raw is not None:
|
||||
assert raw["planned"] != result["planned"] and raw["metrics"]["length_m"] > 0
|
||||
assert set(raw) == {"planned", "nodes", "curves", "off_station_curves", "metrics"}
|
||||
|
||||
|
||||
def _project_terrain():
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import (
|
||||
STREAM_FILE,
|
||||
_load_features,
|
||||
_reproject_features,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import stream_lines
|
||||
from common_util.common_util_crs import resolve_project_crs
|
||||
|
||||
cached = np.load(
|
||||
PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz",
|
||||
allow_pickle=False,
|
||||
)
|
||||
terrain = Terrain(cached["x"], cached["y"], cached["z"], cached["valid_mask"])
|
||||
to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(PROJECT), always_xy=True)
|
||||
features = _load_features(PROJECT / "B04_PreProcess" / "processed", STREAM_FILE)
|
||||
return terrain, stream_lines(_reproject_features(features, to_metric))
|
||||
|
||||
|
||||
def test_project_modes_before_after() -> None:
|
||||
"""검증_sub1 실지형(14 % · R12) — 갈래별 측점 맞춤 전후 넘은 곳 · 최대 기울기 · 측점 밖 곡선."""
|
||||
if not (PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz").is_file():
|
||||
pytest.skip("검증_sub1 storage 없음")
|
||||
terrain, streams = _project_terrain()
|
||||
path = PROJECT / "B05_Profile/route/expected_route.csv"
|
||||
expected = [(v.x, v.y) for v in read_planned_route_csv(path).vertices]
|
||||
on_total, curves_total = 0, 0
|
||||
for mode in ("follow", "gradeline", "earthwork", "gentle", "weighted"):
|
||||
result = generate_initial_route(
|
||||
expected,
|
||||
terrain,
|
||||
streams,
|
||||
mode=mode,
|
||||
weights=None,
|
||||
max_grade=0.14,
|
||||
min_radius_m=12.0,
|
||||
with_profile=False,
|
||||
)
|
||||
raw = result["unsnapped"] or result
|
||||
after, before = result["metrics"], raw["metrics"]
|
||||
curves = len(result["curves"])
|
||||
on_total += curves - result["off_station_curves"]
|
||||
curves_total += curves
|
||||
print(
|
||||
f"{mode}: 넘은 곳 {before['over_limit']['total']} → {after['over_limit']['total']}"
|
||||
f" · 최대 기울기 {before['max_grade_pct']:.1f} → {after['max_grade_pct']:.1f} %"
|
||||
f" · 곡선 {len(raw['curves'])} → {curves}(측점 밖 {result['off_station_curves']})"
|
||||
)
|
||||
assert raw["off_station_curves"] >= result["off_station_curves"]
|
||||
print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%")
|
||||
assert on_total / curves_total >= 0.75
|
||||
|
||||
|
||||
def test_project_eight_outlines() -> None:
|
||||
"""검증_sub1 예상노선 — R 12 · 15 · 20 · 30 × 추적 · 큰 R 8벌 수치(부분 맞춤 허용 · 브레인)."""
|
||||
path = PROJECT / "B05_Profile/route/expected_route.csv"
|
||||
if not path.is_file():
|
||||
pytest.skip("검증_sub1 storage 없음")
|
||||
points = [(v.x, v.y) for v in read_planned_route_csv(path).vertices]
|
||||
full, hits, total = 0, 0, 0
|
||||
for radius in (12.0, 15.0, 20.0, 30.0):
|
||||
follow = build_planned_polyline(points, min_radius_m=radius)
|
||||
for before in (follow, widen(follow, densify_route(points, 2.0), radius)):
|
||||
after = snap_curves_to_stations(before, radius)
|
||||
assert after is not None
|
||||
on = station_hits(after)
|
||||
full += on == len(after.curves)
|
||||
hits, total = hits + on, total + len(after.curves)
|
||||
gap = _ls(after.vertices).hausdorff_distance(_ls(before.vertices))
|
||||
assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05
|
||||
print(
|
||||
f"검증_sub1 8벌 중 {full}벌 전부 · 곡선 {hits}/{total} = {100 * hits / total:.0f}% 측점 위"
|
||||
)
|
||||
assert full >= 7 and hits / total >= 0.95
|
||||
@@ -100,7 +100,8 @@ def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None:
|
||||
|
||||
|
||||
def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None:
|
||||
"""추종 = 전처리 초기값 규칙(`build_planned_polyline` 기본값) — 언덕을 넘는 곳은 목록에."""
|
||||
"""추종 = 전처리 초기값 규칙(`build_planned_polyline` 기본값 · 곧은 선이라 곡선 MC 맞춤 없음)
|
||||
— 언덕을 넘는 곳은 목록에."""
|
||||
result = _run(hill, "follow")
|
||||
initial = build_planned_polyline(_line(200.0), min_radius_m=RADIUS)
|
||||
assert result["planned"] == [[round(x, 4), round(y, 4)] for x, y in initial.vertices]
|
||||
@@ -119,9 +120,16 @@ def _earth(result: dict) -> float:
|
||||
return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"]
|
||||
|
||||
|
||||
def _raw(result: dict) -> dict:
|
||||
"""측점 맞추기 전 선(351-2) — 갈래 고르기 · `similar` 는 이 선으로 함."""
|
||||
return {**result, **(result.get("unsnapped") or {})}
|
||||
|
||||
|
||||
def _check_branch_goals(runs: dict) -> None:
|
||||
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
|
||||
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5)."""
|
||||
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).
|
||||
고르기는 측점 맞추기 전 선으로 함(351-2)."""
|
||||
runs = {mode: _raw(result) for mode, result in runs.items()}
|
||||
branches = [runs[mode]["metrics"] for mode in BRANCHES]
|
||||
for mode, goal in GOALS.items():
|
||||
assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode
|
||||
@@ -418,17 +426,18 @@ def project_runs() -> dict:
|
||||
|
||||
|
||||
def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
|
||||
"""추종 = 전처리 초기값 `planned_route_initial.csv`(1078 m) 그대로."""
|
||||
"""추종 = 전처리 초기값(1078 m)의 곡선 MC 를 일반측점에 맞춘 선(351-2)."""
|
||||
from common_util.common_util_route_geometry import (
|
||||
planned_route_initial_path,
|
||||
read_planned_route_csv,
|
||||
)
|
||||
from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M
|
||||
|
||||
initial = read_planned_route_csv(planned_route_initial_path(PROJECT))
|
||||
stored = LineString([(v.x, v.y) for v in initial.vertices])
|
||||
follow = LineString(project_runs["follow"]["planned"])
|
||||
assert follow.hausdorff_distance(stored) < 0.01
|
||||
assert abs(follow.length - stored.length) < 0.01
|
||||
assert follow.hausdorff_distance(stored) <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05
|
||||
assert project_runs["follow"]["off_station_curves"] <= 2
|
||||
|
||||
|
||||
def test_project_branches_differ_by_goal(project_runs: dict) -> None:
|
||||
@@ -445,7 +454,7 @@ def test_project_similar_and_band(project_runs: dict) -> None:
|
||||
earthwork = project_runs["earthwork"]
|
||||
assert earthwork["band_width_m"] < 20.0
|
||||
near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]}
|
||||
assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1)
|
||||
assert near["gentle"] == round(_gap(_raw(earthwork), _raw(project_runs["gentle"])), 1)
|
||||
assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near
|
||||
assert "follow" not in near # 추적은 40 m 넘게 떨어짐
|
||||
|
||||
|
||||
Reference in New Issue
Block a user