fix(B05 노선 추천 엔진): 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · 합치기 멈춤 노선 토공 +10 % · 토공 고르기 위 3 개만 균형 종단 (419-1)

- 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · grade 수에 참고) · 후보 거름 · 큰 R 판 · 합치기 · 측점 맞춤의 기울기 막기 걷음 · 탐색 링크 상한 남김
- 꺾임만 → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤 곡선 끔 · 늦춤 단계 걷음
- 맞추기 전 선(unsnapped) 걷음 · 시종점 기울기 ends_grade_pct · ends_over_limit
- 검증_sub4 첫 갈래 122.6 s → 23.8 s · 곡선 33 → 16~18 · 세 갈래 다른 선

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-18 12:45:38 +09:00
co-authored by Claude Opus 5.5
parent 71da77f105
commit ad855b35d7
10 changed files with 472 additions and 165 deletions
+52 -31
View File
@@ -28,9 +28,11 @@
가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수
있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이
지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선
맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) —
못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면
`ends_over_limit`(종단으로도 못 풂 · 화면 알림).
· 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼
곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감.
· 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기).
@@ -61,7 +63,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M,
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
Pool,
build_pool,
pick,
choose,
hard_limits,
settle,
similar,
weighted,
@@ -303,21 +306,24 @@ def generate_initial_route(
def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
) -> dict[str, Any]:
return route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
# 못 지킨 곳 수 = R · 계류 · 측점 — 원지반 기울기는 참고만(목록엔 남음 · 419)
return hard_limits(
route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
)
)
def fit(outline: Any, guard: Any = None) -> Any:
@@ -326,14 +332,12 @@ def generate_initial_route(
moved = snap_curves_to_stations(outline, min_radius_m, guard=guard)
return outline if moved is None else moved
unsnapped: dict[int, Any] = {}
def follow_line() -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은
# 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
route = (outline, measure(outline, bound=None, follow=True))
return settle(route, fit, measure, unsnapped, bound=None, follow=True)
return settle(route, fit, measure, bound=None, follow=True)
def profiled(route: Any, exact: bool = True) -> None:
if route is not None:
@@ -347,6 +351,9 @@ def generate_initial_route(
exact=exact,
)
def quick(route: Any) -> None:
profiled(route, exact=False)
bound = None
pool: Pool | None = None
target = _weighted_end(used) if mode == "weighted" else mode
@@ -369,19 +376,19 @@ def generate_initial_route(
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
while len(_POOLS) >= POOL_CACHE_SIZE:
_POOLS.pop(next(iter(_POOLS)))
# 합치기 멈춤 토공 — 420 `cross_earthwork_delta`(바뀐 측점 횡단 차이)가 오면 `earth_delta`
# 로 이음 · 그 전엔 노선 잣대 토공(띠 가늠 · `..._Merge.merge_same_side`)
_POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit)
pool = _POOLS[key]
bound = pool.bound
if target == "weighted":
outline, metrics = weighted(pool, used, min_radius_m, measure)
else:
if with_profile:
for route in [*pool.candidates, pool.follow]:
profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈
outline, metrics = pick(target, pool.candidates, with_profile)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle`) — 모달 「측점 맞춤」 끔 = 맞추기 전 선
# 종단 반영 켬 토공 · 공사비 = 줄 세운 위 후보만 균형 종단(빠른 판) · 고른 하나만
# 아래에서 바로 셈(419)
outline, metrics = choose(target, pool.candidates, with_profile, quick)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419)
snapped = (outline, metrics)
raw = unsnapped.get(id(outline)) or (pool.raw.get(id(outline)) if pool else None)
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
line, values = route
@@ -401,6 +408,7 @@ def generate_initial_route(
}
shown = finish(snapped)
ends = ends_grade(expected, terrain, shown["metrics"]["length_m"])
return {
"mode": mode,
"logic": mode,
@@ -409,10 +417,11 @@ def generate_initial_route(
# 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음
"terrain_bound_pct": None if bound is None else round(bound * 100.0, 2),
**shown,
# 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음)
"unsnapped": None if raw is None else finish(raw),
# 시종점 높이 차 ÷ 노선 연장(%) — 상한 넘으면 종단으로도 못 풂(노선을 늘려야 함 · 화면 알림)
"ends_grade_pct": ends,
"ends_over_limit": ends > max_grade * 100.0 + 1e-6,
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
"similar": None if pool is None else similar(pool, target, outline, with_profile),
"similar": None if pool is None else similar(pool, target, outline, with_profile, quick),
"with_profile": with_profile,
"band_width_m": None if pool is None else round(pool.band_width_m, 1),
"criteria": {
@@ -429,6 +438,18 @@ def generate_initial_route(
}
def ends_grade(expected: list[tuple[float, float]], terrain: Terrain, length_m: float) -> float:
"""시종점 지반 높이 차 ÷ 노선 연장(%) — 균형 종단이 끝점을 지반고에 두므로 이 값이 상한을 넘으면
어떤 계획선도 상한 안에 못 듦(419 · 원지반 기울기를 참고로 돌린 뒤 남는 알림)."""
from scipy.interpolate import RegularGridInterpolator
sample = RegularGridInterpolator(
(terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None
)
z = sample([expected[0][::-1], expected[-1][::-1]])
return round(abs(float(z[1] - z[0])) / max(length_m, 1e-9) * 100.0, 2)
def _weighted_end(weights: dict[str, float]) -> str:
"""비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색."""
for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")):
@@ -9,12 +9,13 @@
거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`):
· 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상.
· 옮긴 조각이 같은 자리 옛 조각보다 지반 기울기 상한을 더 넘지 않음(`GRADE_NOISE`) · 토공
가늠이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 이격도(`keeping_offset`).
· 옮긴 조각 토공 가늠(띠 · 빠른 거름)이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선
이격도(`keeping_offset`). 원지반 기울기는 참고만(419 · 막지 않음 · 자리 잣대에도 안 씀).
· 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함
(`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음).
· 합치기는 노선 전체 잣대(`route_metrics`)도 — 넘은 곳 수 · 기울기 넘은 곳 안 늘어남 · 최대
기울기 `GRADE_TOL_PCT` · 절성토 `TOTAL_EARTH_TOL` 안(합치기 전 선 기준 · 되풀이해도 누적 안 됨).
· 합치기 멈춤 = 노선 전체 잣대 — 못 지킨 곳 수(R · 계류) 안 늘어남 · 노선 토공이 합치기 전 선보다
`TOTAL_EARTH_TOL` 넘게 늘지 않음(엔진 상수 · 사용자 옵션 아님 · 되풀이해도 누적 안 됨). 토공 =
바뀐 측점 횡단 차이(`cross_earthwork_delta` · 420) · 없으면 노선 잣대 토공(띠 가늠).
큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신).
"""
@@ -27,7 +28,7 @@ from typing import Any
import numpy as np
import shapely
from shapely.geometry import LineString
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
@@ -52,11 +53,8 @@ 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
# 합치기 멈춤 — 노선 토공(횡단)이 합치기 전 선보다 늘어도 되는 몫(곡선 수 ↔ 토공 · 요구 254)
TOTAL_EARTH_TOL = 0.10
# R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터)
RADIUS_STEPS = 12
# 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t)
@@ -150,27 +148,19 @@ class Guard:
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)
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 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,
)
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:
@@ -243,15 +233,8 @@ 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)
)
# (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥ · 횡단)
EarthDelta = Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float]
def merge_same_side(
@@ -259,14 +242,21 @@ def merge_same_side(
guard: Guard,
min_radius_m: float,
measure: Callable[[PlannedPolyline], dict[str, Any]],
cross_earthwork_delta: EarthDelta | None = None,
) -> Route:
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로."""
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로.
멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의
1 + `TOTAL_EARTH_TOL` 넘음. `cross_earthwork_delta` 주면 토공 = 바뀐 측점 횡단 차이 누적 ·
없으면 노선 잣대 토공(`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
limit = _earth(base) * (1 + TOTAL_EARTH_TOL)
earth = _earth(base) # 지금 선 토공 — 차이를 누적
blocked: set[tuple[Xy, ...]] = set()
while True:
reference = _Reference(current[0].vertices)
@@ -287,9 +277,9 @@ def merge_same_side(
LineString(local), LineString(reference.window(local)), densify=0.1
)
)
found.append((gap, k, cross, radius))
found.append((gap, k, cross, radius, local))
merged = None
for _, k, cross, radius in sorted(found):
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 :]
@@ -301,11 +291,16 @@ def merge_same_side(
radii=trial_radii,
)
metrics = measure(line)
if _worse(metrics, base):
if cross_earthwork_delta is None:
total = _earth(metrics)
else:
window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1]))
total = earth + cross_earthwork_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))
merged = (trial_points, trial_flags, trial_radii, (line, metrics), total)
break
if merged is None:
return current
points, flags, radii, current = merged
points, flags, radii, current, earth = merged
@@ -4,18 +4,23 @@
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
· 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 ·
계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을
때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로.
계류 횡단 · 공사비) × 탐색 링크 상한(지킬 수 있을 때 · 한 번) 또는 벌점 크기(지형상 불가능할
때 · 막은 기울기 안) — PLAN 23-1.
· 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌.
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
쓸기 최대 기울기).
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
· 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보
최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은
남김.
· R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고
고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움.
꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419).
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5).
종단 반영 켬 토공 · 공사비(`choose`) = 상한 누른 계획선 토공으로 줄 세운 위 `SHORTLIST` 개만 균형
종단(빠른 판)을 붙여 고름(419).
비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a):
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
@@ -33,14 +38,14 @@ 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_Merge import EarthDelta, Guard, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
COST_LENGTH_M,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND,
OBJECTIVES,
@@ -62,8 +67,8 @@ TARGETS: dict[str, dict[str, float]] = {
"crossings": {"crossing": 100.0},
"cost": {},
}
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419)
SHORTLIST = 3
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
SOFT_GRADE_COST = 500.0
@@ -102,20 +107,28 @@ class Pool:
follow_cap: float = np.inf
searched: dict[tuple[float, ...], Route] = field(default_factory=dict)
traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`)
raw: dict[int, Route] = field(default_factory=dict) # 맞춘 선 id → 맞추기 전(「측점 맞춤」 끔)
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
# 합치기 멈춤 토공 = 바뀐 측점 횡단 차이(420 · 없으면 노선 잣대 토공 · `merge_same_side`)
earth_delta: EarthDelta | None = None
def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1) · 맞추기 전 선은 `raw` 에."""
def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]:
"""원지반 기울기 = 참고만(419) — 못 지킨 곳 수(`over_limit.total`)에서 뺀 잰 값.
기울기 넘은 곳은 목록(`violations` · kind grade)과 `over_limit.grade` 에 참고로 남는다."""
over = metrics["over_limit"]
return {**metrics, "over_limit": {**over, "total": over["total"] - over.get("grade", 0)}}
def settle(route: Route, snap: Snap, measure: Measure, **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1)."""
outline, metrics = route
moved = snap(outline)
if moved is not outline:
raw[id(moved)] = (outline, with_station_misses(metrics, outline))
outline, metrics = moved, measure(moved, **kw)
return outline, with_station_misses(metrics, outline)
@@ -125,12 +138,12 @@ def _trace(
) -> Route:
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
# 고른 판의 같은 쪽 이웃 곡선을 합치고(406 · `..._Merge`) 측점에 맞춤(`settle` · 383-1).
# 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은 예상노선
# 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이
# 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시
# 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) →
# 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접
# 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
traced = pool.traced
guard = pool.guard
if guard is not None and kw.get("weighted"):
@@ -157,8 +170,13 @@ def _trace(
least,
)
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)
line = curve_every_kink(chosen[0], min_radius_m)
if line is not chosen[0]:
chosen = (line, measure(line, **kw))
chosen = merge_same_side(
chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.earth_delta
)
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw)
return traced[key]
@@ -184,22 +202,18 @@ def _candidates(
) -> tuple[list[Route], float | None, int]:
"""제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한.
제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 ·
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
제약을 지키는 길이 있으면 목표마다 탐색 링크 상한으로 찾은 노선들(곡선 맞춤 뒤 원지반
기울기는 참고만 — 상한 낮춰 다시 찾지 않음 · 419) · 없으면 막은 기울기 안에서 벌점 크기 ·
목표를 바꿔 찾은 노선들.
"""
graph = pool.graph
found: list[Route] = []
for target in TARGETS:
for step in GRADE_CAP_STEPS:
try:
nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True)
except NoRoute:
break
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found[-1][1]["over_limit"]["total"] == 0:
break
if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
break
try:
nodes = _solve(graph, target, cap=max_grade, strict_stream=True)
except NoRoute:
break # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found:
least = min(item[1]["over_limit"]["total"] for item in found)
return [item for item in found if item[1]["over_limit"]["total"] == least], None, least
@@ -236,9 +250,14 @@ def build_pool(
min_radius_m: float,
measure: Measure,
snap: Snap,
earth_delta: EarthDelta | None = None,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade))
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.
`measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`)."""
pool = Pool(
graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), earth_delta=earth_delta
)
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]]
@@ -259,14 +278,21 @@ def build_pool(
return pool
def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]:
def similar(
pool: Pool,
mode: str,
outline: Any,
with_profile: bool = True,
profile: Callable[[Route], None] | None = None,
) -> list[dict[str, Any]]:
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다."""
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.
다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` = 줄 세운 위 후보에 균형 종단)."""
here = LineString(outline.vertices)
others = {
"follow": pool.follow,
**{name: pick(name, pool.candidates, with_profile) for name in GOALS},
**{name: choose(name, pool.candidates, with_profile, profile) for name in GOALS},
}
found = []
for name, (other, _) in others.items():
@@ -333,6 +359,29 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
}
def choose(
mode: str,
candidates: list[Route],
with_profile: bool,
profile: Callable[[Route], None] | None,
) -> Route:
"""갈래 고르기 — 종단 반영 켬 토공 · 공사비는 상한 누른 계획선 토공(종단 전 비교값)으로 줄 세운
위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여 그 사이에서 고름(419 · 후보마다 종단을
세우지 않음). 나머지 갈래 · 종단 끔은 `pick` 그대로."""
if with_profile and profile is not None and mode in ("earthwork", "cost"):
goal = GOALS[mode]
candidates = sorted(
{id(item[1]): item for item in candidates}.values(), # 같은 노선 겹침 뺌
key=lambda item: (
item[1]["over_limit"].get("station", 0) > 0,
*goal(plain_metrics(item[1])),
),
)[:SHORTLIST]
for route in candidates:
profile(route)
return pick(mode, candidates, with_profile)
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route:
"""갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보.
`with_profile` 끔 = 원지반 가늠 점수."""
@@ -10,6 +10,8 @@
복도 밖 · 기울기 · 계류 · 토공이 나빠지지 않는 가장 큰 R(`..._Merge.Guard` · 자리 R ~ 맞춘 R 사이
`GUARD_STEPS` 벌 · 1 m 내림).
꺾임만(`curve_every_kink` · 419) — 추천 후보에 R 없는 교점을 안 남김(곡선 또는 교점 지움).
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
"""
@@ -51,6 +53,30 @@ def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float |
return points, flags, radii
def curve_every_kink(outline: PlannedPolyline, min_radius_m: float) -> PlannedPolyline:
"""꺾임만(R 없는 교점 · 삼각) 없앤 선(419) — 최소 R 원호가 들어가면 곡선(내각 155° 이상도) ·
아니면 교점 지움(곧게). 꺾임만이 없으면 받은 그대로."""
points, flags, radii = _flat(outline)
if all(flags[1:-1]):
return outline
index = 1
while index < len(points) - 1:
if flags[index]:
index += 1
continue
before, apex, after = points[index - 1 : index + 2]
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
room = min(math.dist(before, apex), math.dist(apex, after)) / 2
if half_tan > 1e-9 and room / half_tan >= min_radius_m:
flags[index] = True
index += 1
else:
del points[index], flags[index], radii[index]
return build_planned_polyline(
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
)
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
@@ -21,11 +21,13 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
추천 후보(`guard` 줌 · PLAN 406 · 요구 251-2)는 이탈 한도 없음 — 옮긴 조각을 직접 잼
(`..._Merge.Guard` · 복도 · 기울기 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐)으로 거름:
(`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는
참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419):
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안 · 더 넘는 기울기 `SOFT_SLACKS` 단계까지) —
MC 를 다 측점에 놓는 첫 단계 판.
· 그래도 못 놓으면 더 안 넘기는 놓기만 쓰고 그 곡선은 그 자리(측점 밖 · 기울기 먼저).
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에.
· 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못
끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌.
· 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수).
· 합친 꼴 먼저 판에 측점 밖이 생기면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖이 적은 쪽(406-4).
예상노선 추적 · 새 초기 노선은 안 줌(이탈 한도 그대로).
@@ -78,9 +80,6 @@ RADIUS_STEPS = 8
BACKTRACK_LIMIT = 30
# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로
PER_VARIANT = 3
# 직접 잼(406) — 잼을 못 지나는 놓기가 더 넘겨도 되는 기울기(비율) 단계 · 앞 단계로 다 놓으면
# 그 판 · 다 못 놓으면 더 안 넘기는 놓기만 쓰고 못 놓는 곡선은 그 자리(부분 맞춤)
SOFT_SLACKS = (0.0, 0.01, 0.03)
@dataclass
@@ -317,7 +316,6 @@ class _Snapper:
self.excess = excess
self.guard = guard
self.keep = False
self.slack = math.inf # 못 지나는 놓기가 더 넘겨도 되는 기울기(`SOFT_SLACKS`)
self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406)
def _steeper(self, local: list[Xy]) -> bool:
@@ -362,7 +360,7 @@ class _Snapper:
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]] = []
found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = []
for station in stations:
for radius in radii:
solved = _solve(state, plan, k, station, radius)
@@ -376,12 +374,12 @@ class _Snapper:
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(
passed, (out, along, earth) = self.guard.judge(
local, self.reference.window(local)
)
if out or passed == soft or steeper > self.slack:
if out or passed == soft:
continue
rank = (along, steeper, tight, rank[1], earth, rank[2])
rank = (along, tight, rank[1], earth, rank[2])
found.append((rank, local, new_at, new_corner, new_mc, station))
given = 0
for _, local, new_at, new_corner, new_mc, station in sorted(
@@ -408,7 +406,19 @@ class _Snapper:
"""꺾임점 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:
if self.guard is not None:
# 추천 후보(419) — 꺾임만 없음: 곡선 못 끼우는 자리(나란함)는 교점 지움 · 꺾임만으로
# 들어온 자리도 곡선으로 놓아 봄
if _corner(before, at, after, plan.radii[k] or 1.0) is None:
gone = plan.copy()
del gone.points[k], gone.flags[k], gone.radii[k]
yield replace(state, plan=gone)
return
if not plan.flags[k]:
plan = plan.copy()
plan.flags[k] = True
state = replace(state, plan=plan)
elif 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)
@@ -433,10 +443,13 @@ class _Snapper:
for soft in (False, True) if self.guard is not None else (False,):
for trial in variants:
yield from self._placements(state, trial, soft)
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는
# 안 함 · 155° 이상도 곡선 · 419)
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG and not self._far(
[state.end_xy, at, middle]
if (
self.guard is None
and _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
@@ -486,7 +499,8 @@ class _Snapper:
at,
((at[0] + b[0]) / 2, (at[1] + b[1]) / 2),
]
if self._far(piece) or self._steeper(piece):
# 꺾임만 남김 — 추천 후보는 안 함(삼각 없음 · 419)
if self.guard is not None or self._far(piece) or self._steeper(piece):
continue
off = plan.copy()
off.flags[small], off.radii[small] = False, None
@@ -584,13 +598,8 @@ def snap_curves_to_stations(
def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None:
"""다 놓는 길(직접 잼이면 `SOFT_SLACKS` 단계) → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = None
for slack in SOFT_SLACKS if snapper.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)
"""다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = snapper.run(keep=False) or snapper.run(keep=True)
if state is None:
return None
snapped = build_planned_polyline(
+8 -13
View File
@@ -3,8 +3,8 @@
./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벌 수치.
`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과는
측점에 맞춘 선만(맞추기 전 선 안 냄 · 419) · 검증_sub1 예상노선 8벌 수치.
"""
import math
@@ -142,11 +142,8 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
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"}
# 측점 맞춤 필수 — 맞추기 전 선은 안 냄(419)
assert "unsnapped" not in result
def _project_terrain():
@@ -189,17 +186,15 @@ def test_project_modes_before_after() -> None:
min_radius_m=12.0,
with_profile=False,
)
raw = result["unsnapped"] or result
after, before = result["metrics"], raw["metrics"]
after = result["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']})"
f"{mode}: 넘은 곳 {after['over_limit']['total']}"
f" · 최대 기울기 {after['max_grade_pct']:.1f} %"
f" · 곡선 {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
@@ -100,7 +100,7 @@ def test_follow_nodes_equal_initial(tmp_path: Path) -> None:
assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved))
misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"]
assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"]
assert result["unsnapped"] is not None # 「측점 맞춤」 끔 = 맞추기 전 선
assert "unsnapped" not in result # 측점 맞춤 필수 — 맞추기 전 선 안 냄(419)
def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
+13 -12
View File
@@ -12,11 +12,7 @@ import numpy as np
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
GRADE_NOISE,
Guard,
merge_same_side,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import 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
@@ -73,16 +69,18 @@ def test_s_curve_keeps_straight() -> None:
assert _flat(merged)[0] == _flat(_outline(S_CURVE))[0]
def test_valley_grade_blocks() -> None:
"""옛 선 = 골짜기 바닥(평평) · 합친 곡선은 옆 비탈을 올라 기울기 상한을 넘음."""
def test_valley_earth_blocks() -> None:
"""옛 선 = 골짜기 바닥 · 합친 곡선은 옆 비탈을 올라 토공 가늠이 늘어 막힘(419 — 원지반 기울기는
참고만이라 기울기로는 안 막음 · 넘는 만큼은 잼에 남음)."""
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
passed, (out, along, earth) = guard.judge(piece, window)
assert not passed and not out and along == 0.0 and earth > 0.0
assert guard._look(piece)[0] > guard._look(window)[0] # 기울기는 참고로 잼
def test_outside_corridor_blocks() -> None:
@@ -111,9 +109,12 @@ def test_stream_buffer_blocks() -> None:
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
"""노선 전체 잣대(못 지킨 곳 · 절성토 +10 %)가 합치기 전보다 나빠지면 안 합침 · 최대 기울기는
참고만(419)."""
steeper = {**FLAT, "max_grade_pct": 5.0}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: steeper).curves) == 1
over = {**FLAT, "over_limit": {"total": 1, "grade": 0}}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: over).curves) == 2
def test_widen_guard_larger_than_gap_limit() -> None:
@@ -0,0 +1,203 @@
"""419 노선 추천 엔진 — 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · 합치기 멈춤 = 노선 토공.
./venv/Scripts/python.exe -m pytest resources/tester/test_419_1_recommend_engine.py -q
· 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · `grade` 수에 참고로만).
· 꺾임만(R 없는 교점) → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤(직접 잼)도 꺾임만을 안 냄.
· 합치기 멈춤 = 노선 토공 +10 % — 바뀐 측점 횡단 차이(`cross_earthwork_delta`)를 주면 그 값으로.
· 종단 반영 켬 토공 고르기 = 줄 세운 위 3 개만 균형 종단 · 시종점 기울기 알림 · 맞추기 전 선 안 냄.
"""
import math
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_RouteInitial import ends_grade, generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
TOTAL_EARTH_TOL,
Guard,
merge_same_side,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import SHORTLIST, choose, hard_limits
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, curve_every_kink
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
RADIUS = 12.0
SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)]
def _kinks(outline) -> int:
return sum(1 for flag in _flat(outline)[1][1:-1] if not flag)
def _flat_terrain(width=802.0, height=402.0) -> Terrain:
x = np.arange(0.0, width, 2.0)
y = np.arange(0.0, height, 2.0)
xx, _ = np.meshgrid(x, y)
return Terrain(x, y, np.zeros(xx.shape), np.ones(xx.shape, dtype=bool))
def test_hard_limits_leave_grade_as_reference() -> None:
metrics = {
"over_limit": {"grade": 3, "radius": 1, "stream": 1, "total": 5},
"violations": [{"kind": "grade"}] * 3 + [{"kind": "radius"}, {"kind": "stream"}],
}
hard = hard_limits(metrics)
assert hard["over_limit"] == {"grade": 3, "radius": 1, "stream": 1, "total": 2}
assert hard["violations"] == metrics["violations"] # 목록엔 참고로 남음
def test_kink_becomes_curve_even_gentle() -> None:
"""내각 160° 꺾임만(곡선 끔) → 곡선(최소 R) · 155° 이상도 곡선 둠."""
points = [(0.0, 0.0), (100.0, 0.0), (200.0, 100.0 * math.tan(math.radians(20.0)))]
kinked = build_planned_polyline(
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, False, True]
)
assert _kinks(kinked) == 1
fixed = curve_every_kink(kinked, RADIUS)
assert _kinks(fixed) == 0 and len(fixed.curves) == 1
assert fixed.curves[0].radius_m >= RADIUS - 1e-6 and not fixed.curves[0].violations
def test_kink_without_room_is_removed() -> None:
"""앞뒤 직선이 짧아 최소 R 이 안 드는 꺾임만 → 교점 지움(곧게)."""
points = [(0.0, 0.0), (100.0, 0.0), (103.0, 2.0), (200.0, 2.0)]
kinked = build_planned_polyline(
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, True, False, True]
)
fixed = curve_every_kink(kinked, RADIUS)
assert _kinks(fixed) == 0
assert len(_flat(fixed)[0]) == 3 # 짧은 쪽 교점 하나 빠짐
def _zigzag(seed: int) -> list[tuple[float, float]]:
rng = np.random.default_rng(seed)
points, heading = [(60.0, 200.0)], 0.0
for _ in range(10):
heading += float(rng.uniform(-0.6, 0.6))
step = float(rng.uniform(40.0, 80.0))
x, y = points[-1]
points.append((x + step * math.cos(heading), y + step * math.sin(heading)))
return points
@pytest.mark.parametrize("seed", range(6))
def test_guard_snap_no_kinks_all_on_station(seed: int) -> None:
"""직접 잼 측점 맞춤(추천 후보) — 평지 · 꺾임만 섞인 선 → 꺾임만 0 · 측점 밖 0."""
points = _zigzag(seed)
flags = [True] + [i % 3 != 1 for i in range(1, len(points) - 1)] + [True]
before = build_planned_polyline(points, min_radius_m=RADIUS, simplify=False, curve_flags=flags)
lo = np.min(points, axis=0) - 150.0
hi = np.max(points, axis=0) + 150.0
x = np.arange(lo[0], hi[0], 2.0)
y = np.arange(lo[1], hi[1], 2.0)
terrain = Terrain(x, y, np.zeros((len(y), len(x))), np.ones((len(y), len(x)), dtype=bool))
guard = Guard(SearchGrid(points, terrain, [], stream_offset_m=100.0), 0.14)
after = snap_curves_to_stations(before, RADIUS, guard=guard)
assert after is not None
assert _kinks(after) == 0
assert off_station_count(after) == 0
FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0}
def _merge(earth_base: float, delta: float):
"""평지 같은 쪽 두 꺾임 — 노선 토공 `earth_base` · 합친 선 차이 `delta`(가짜 횡단 함수)."""
outline = build_planned_polyline(
SAME, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(SAME)
)
base = {**FLAT, "cut_m3": earth_base, "fill_m3": 0.0}
calls = []
def cross(before, after, window):
calls.append(window)
return delta
guard = Guard(SearchGrid(SAME, _flat_terrain(402.0, 262.0), [], stream_offset_m=100.0), 0.14)
merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, cross)[0]
return merged, calls
def test_merge_stops_at_route_earth_tolerance() -> None:
"""합치기 판정 = 바뀐 측점 횡단 차이 — +10 % 안이면 합치고 넘으면 안 합침(엔진 상수)."""
assert TOTAL_EARTH_TOL == pytest.approx(0.10)
merged, calls = _merge(1000.0, 90.0)
assert len(merged.curves) == 1 and calls
s0, s1 = calls[0]
assert 0.0 <= s0 < s1 # 바뀐 자리(앞 선 누가거리)만 넘김
blocked, calls = _merge(1000.0, 110.0)
assert len(blocked.curves) == 2 and calls
def _route(name: str, earth: float, station: int = 0):
metrics = {
"name": name,
"cut_m3": earth,
"fill_m3": 0.0,
"cost_index": earth,
"over_limit": {"total": station, "station": station},
}
return (object(), metrics)
def test_choose_profiles_only_shortlist() -> None:
"""종단 반영 켬 토공 = 상한 누른 계획선 토공 위 3 개만 균형 종단 · 그 사이에서 고름."""
routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(8)] + [_route("off", 1.0, 1)]
profiled = []
def profile(route):
profiled.append(route[1]["name"])
# 균형 종단 토공은 순서가 바뀜 — 셋째가 가장 작음
earth = {"r0": 900.0, "r1": 800.0, "r2": 300.0}.get(route[1]["name"], 50.0)
route[1]["profile"] = {"cut_m3": earth, "fill_m3": 0, "borrow_m3": 0, "spoil_m3": 0}
chosen = choose("earthwork", routes, True, profile)
assert profiled == ["r0", "r1", "r2"] and len(profiled) == SHORTLIST
assert chosen[1]["name"] == "r2"
# 종단 끔 = 줄 세우기 값 그대로 · 종단 안 붙임
profiled.clear()
assert choose("earthwork", routes, False, profile)[1]["name"] == "r0" and not profiled
def test_ends_grade() -> None:
x = np.arange(0.0, 402.0, 2.0)
y = np.arange(0.0, 102.0, 2.0)
xx, _ = np.meshgrid(x, y)
slope = Terrain(x, y, 0.3 * xx, np.ones(xx.shape, dtype=bool))
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 200.0) == pytest.approx(30.0)
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 600.0) == pytest.approx(10.0)
@pytest.fixture(scope="module")
def hill_runs() -> dict:
x = np.arange(0.0, 802.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
xx, yy = np.meshgrid(x, y)
hill = Terrain(x, y, 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2), xx > -1)
expected = [(float(v), 200.0 + 40.0 * math.sin(v / 60.0)) for v in np.arange(50.0, 752.0, 4.0)]
return {
mode: generate_initial_route(
expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS
)
for mode in ("gradeline", "earthwork", "gentle", "weighted")
}
@pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"])
def test_hill_branches_rules(hill_runs: dict, mode: str) -> None:
"""언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음."""
result = hill_runs[mode]
interior = result["nodes"][1:-1]
assert interior and all(node["radius_m"] is not None for node in interior) # 삼각 0
assert result["off_station_curves"] == 0
over = result["metrics"]["over_limit"]
assert over["total"] == over["radius"] + over["stream"] + over.get("station", 0)
assert "unsnapped" not in result
assert result["ends_over_limit"] is False and result["ends_grade_pct"] < 14.0
+16 -8
View File
@@ -2,8 +2,9 @@
./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q
공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만
`metrics.violations` 에 측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다.
공통 제약(최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만 `metrics.violations` 에
측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다. 원지반 기울기(탐색 링크 상한은 막음)는
참고만 — 목록엔 뜨되 `total` 에 안 듦(419).
예상노선 추적(follow)은 전처리 초기값과 같은 선 — 제약 탐색 없이 넘은 곳만 목록(PLAN 23-1).
갈래는 제 목표 비교값이 가장 좋은 후보 — 실프로젝트(랩탑_보조 · 읽기만)에서 갈래가 갈림.
"""
@@ -32,8 +33,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
grade_line,
station_label,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS, SHORTLIST
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from common_util.common_util_route_polyline import build_planned_polyline
SEARCHED = [mode for mode in MODES if mode != "follow"]
@@ -68,9 +69,10 @@ def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict:
def _kept(result: dict) -> None:
"""제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음."""
"""제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음(원지반 기울기는 참고 · 수에 안 듦)."""
metrics = result["metrics"]
assert metrics["over_limit"]["total"] == len(metrics["violations"])
hard = [v for v in metrics["violations"] if v["kind"] != "grade"]
assert metrics["over_limit"]["total"] == len(hard)
if not metrics["violations"]:
assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
assert result["relaxed"] is False
@@ -109,7 +111,7 @@ def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None:
assert result["relaxed"] is False and result["terrain_bound_pct"] is None
grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"]
assert grades and all("예상노선 추적" in v["reason"] for v in grades)
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])
_kept(result)
def _earth(result: dict) -> float:
@@ -123,13 +125,19 @@ def _earth(result: dict) -> float:
def _check_branch_goals(runs: dict) -> None:
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1)."""
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1). 토공 · 공사비는 상한 누른 계획선
토공(종단 전 값) 위 `SHORTLIST` 안에서 균형 종단으로 고름(419) — 종단 전 값으로 위 3 안."""
branches = [runs[mode]["metrics"] for mode in BRANCHES]
def rank(goal, m: dict) -> tuple:
return (m["over_limit"].get("station", 0) > 0, *goal(m))
for mode, goal in GOALS.items():
if mode in ("earthwork", "cost"):
plain = sorted(rank(goal, plain_metrics(m)) for m in branches)
own = rank(goal, plain_metrics(runs[mode]["metrics"]))
assert own <= plain[min(SHORTLIST, len(plain)) - 1], mode
continue
assert all(rank(goal, runs[mode]["metrics"]) <= rank(goal, m) for m in branches), mode
for result in runs.values():
m = result["metrics"]