fix(769-1): 추천 4 갈래 = 계산 로직 결과 그대로 — 같은 쪽 곡선 합치기 · 측점 맞춤 뺌

- 요구 611: 추천노선 = 평면계획선을 긋는 가이드 선 · 합치기 · 스냅은 예정노선 수정 뒤 평면계획선에만
- _trace = 경로 탐색 → 곡선 넣기(curve_every_kink) → 큰 R(widen) 까지 · merge_same_side · settle 뺌
- 예상노선 추적(merged_follow) · 체인 · /route/plan · replan · follow 는 합치기 · 스냅 그대로
- Pool.snap · Pool.cross · build_pool snap · cross 인자 · 엔진 fit · route_earth · merge_delta 걷음(쓰는 곳 없음)
- ALGO_VERSION 9 → 10
- 낡은 시험 고침: test_419_1 · test_351_2 추천 갈래 측점 위 기대 뺌(추적만 측점 위 · 갈래는 안 맞춤 확인 추가) ·
  test_route_initial 영선 · test_351_2 비중 기울기 상한 기대 뺌(원지반 기울기 참고만 · 비중은 벌점만) ·
  test_224_4 build_pool 엿봄 인자

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 16:30:29 +09:00
co-authored by Claude Opus 5.5
parent 2937ba6208
commit f7bd8dbf06
7 changed files with 35 additions and 75 deletions
+5 -31
View File
@@ -28,7 +28,8 @@
가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수
있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점:
공통 제약(follow 밖 모든 갈래 · **넘지 못함** · 측점 맞춤 · 합치기 없음 = 가이드 선 · 요구 611) —
최소 곡선반지름 · 계류 이격:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선
맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) —
못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면
@@ -64,11 +65,8 @@ import numpy as np
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import cross_earthwork
from B05_Profile.B05_Profile_Engine_RouteInitial_Follow import (
FollowLand,
cross_delta,
cross_total,
merged_follow,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import RouteEarth
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
Pool,
@@ -94,7 +92,6 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
off_station_count,
snap_curves_to_stations,
with_station_misses,
)
@@ -352,12 +349,6 @@ def generate_initial_route(
)
)
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
def follow_line(grid: Any = None) -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 함수(곡선 최적 · 측점
# 맞춤 · 758-1 `..._Follow.merged_follow`) · 같은 입력 → 같은 선. 넘은 곳만 목록에 뜸.
@@ -392,13 +383,6 @@ def generate_initial_route(
design_curves=[curve.as_dict() for curve in line.curves],
)
def route_earth(line: Any) -> float:
# 합치기 멈춤 잣대 = 계획고 지반고 횡단(1차 종단 규칙 · 종단 끔과 같음)
return cross_total(line, terrain, standard)
def merge_delta(before: Any, after: Any, window: tuple[float, float]) -> float:
return cross_delta(before, after, window, terrain, standard)
def cross_score(route: Any, capped: bool) -> float:
# 후보 줄 세움(419) — 켬 = 상한 누른 계획선(`_reference`) 횡단 · 끔 = 계획고 지반고 횡단
held = pool.scores if pool is not None else {}
@@ -446,16 +430,7 @@ 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 `..._CrossEarth` · 419)
_POOLS[key] = build_pool(
graph,
follow_line(grid),
max_grade,
min_radius_m,
measure,
fit,
cross=RouteEarth(route_earth, merge_delta),
)
_POOLS[key] = build_pool(graph, follow_line(grid), max_grade, min_radius_m, measure)
pool = _POOLS[key]
bound = pool.bound
if target == "weighted":
@@ -464,8 +439,7 @@ def generate_initial_route(
# 토공 · 공사비 = 횡단 토공으로 줄 세움 · 종단 반영 켬이면 위 후보만 균형 종단(빠른
# 판) · 고른 하나만 아래에서 바로 셈(419)
outline, metrics = choose(target, pool.candidates, with_profile, quick, cross_score)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419)
snapped = (outline, metrics)
# 추천 = 계산 로직 결과 그대로(합치기 · 측점 맞춤 없음 · 요구 611) · 추적만 평면계획선 길
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
# 정밀 종단은 낼 사본에만 — 묶음 캐시 후보는 빠른 판 그대로 둬 다음 고르기가 같은 잣대끼리
@@ -486,7 +460,7 @@ def generate_initial_route(
"metrics": values,
}
shown = finish(snapped)
shown = finish((outline, metrics))
ends = ends_grade(expected, terrain, shown["metrics"]["length_m"])
return {
"mode": mode,
@@ -12,9 +12,9 @@
· 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보
최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은
남김.
· R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고
고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움.
꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419).
· 추천 = 평면계획선을 긋는 가이드 선(요구 611 · 769-1) — 계산 로직 결과 그대로(경로 탐색 → 곡선
넣기 → 큰 R) · 같은 쪽 곡선 합치기 · 측점 맞춤은 안 함(평면계획선 길 `..._Follow.merged_follow`
몫). 꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 419).
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
@@ -38,7 +38,7 @@ from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
@@ -52,7 +52,6 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
NoRoute,
RouteGraph,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses
from common_util.common_util_route_geometry import densify_route
from common_util.common_util_route_polyline import build_planned_polyline
@@ -87,8 +86,6 @@ TRACE_DENSIFY_M = 2.0
Measure = Callable[..., dict[str, Any]]
Route = tuple[Any, dict[str, Any]]
# (선, 직접 잼) → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
Snap = Callable[[Any, Any], Any]
@dataclass
@@ -96,7 +93,6 @@ class Pool:
"""같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다."""
graph: RouteGraph
snap: Snap
candidates: list[Route] = field(default_factory=list)
bound: float | None = None
limit: int = 0 # 후보로 남길 못 지킨 곳 수 상한
@@ -111,10 +107,8 @@ class Pool:
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
# 큰 R 이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
# 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`)
cross: RouteEarth | None = None
# 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419)
scores: dict[tuple[int, bool], float] = field(default_factory=dict)
@@ -127,15 +121,6 @@ def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]:
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:
outline, metrics = moved, measure(moved, **kw)
return outline, with_station_misses(metrics, outline)
def _trace(
pool: Pool, nodes: list[tuple[float, float]], min_radius_m: float, measure: Measure, **kw: Any
) -> Route:
@@ -144,9 +129,9 @@ def _trace(
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이
# 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시
# 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) →
# 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접
# 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) — 끝. 같은 쪽 곡선 합치기 · 측점 맞춤은 안 함
# (추천 = 가이드 선 · 요구 611). 큰 R 은 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은
# 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
traced = pool.traced
guard = pool.guard
if guard is not None and kw.get("weighted"):
@@ -176,10 +161,7 @@ def _trace(
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.cross
)
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw)
traced[key] = chosen
return traced[key]
@@ -252,13 +234,11 @@ def build_pool(
max_grade: float,
min_radius_m: float,
measure: Measure,
snap: Snap,
cross: RouteEarth | None = None,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.
`measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`)."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross)
pool = Pool(graph, 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]]
@@ -41,8 +41,9 @@ from common_util.common_util_time import utc_now_text
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) ·
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420) ·
# 9 = 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리(요구 606-2)
ALGO_VERSION = 9
# 9 = 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리(요구 606-2) ·
# 10 = 추천 4 갈래 합치기 · 측점 맞춤 뺌(가이드 선 · 요구 611)
ALGO_VERSION = 10
# 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드
SOURCE_GLOBS = (
"B05_Profile/B05_Profile_Engine_RouteInitial*.py",
@@ -40,9 +40,9 @@ def runs() -> dict:
caught: dict = {}
real = engine.build_pool
def spy(graph, follow, max_grade, min_radius_m, measure, snap, **kw):
def spy(graph, follow, max_grade, min_radius_m, measure, **kw):
caught["measure"] = measure
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap, **kw)
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, **kw)
return caught["pool"]
engine._POOLS.clear()
@@ -122,8 +122,8 @@ def hill_runs() -> dict:
@pytest.mark.parametrize("mode", ["follow", "gentle", "earthwork", "weighted"])
def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
"""추적(평면계획선 길)만 측점 맞춤 · 추천 갈래는 계산 로직 결과 그대로(769-1 · 요구 611)."""
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,
@@ -133,16 +133,18 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
)
# 화면이 펴서 다시 짠 선 = 서버 선
assert np.allclose(outline.vertices, result["planned"], atol=1e-3)
assert station_hits(outline) == len(outline.curves) > 0
if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음)
if mode == "follow":
assert result["off_station_curves"] == 0
assert station_hits(outline) == len(outline.curves) > 0
# 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음) · 비중은 제약을 벌점으로만(넘으면 목록)
if mode in ("gentle", "earthwork"):
assert result["metrics"]["over_limit"]["total"] == 0
assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6
# 측점 맞춤 필수 — 맞추기 전 선은 안 냄(419)
assert "unsnapped" not in result
def test_합성_지형_modes_before_after(hill_runs: dict) -> None:
"""합성 언덕(14 % · R12) — 갈래별 최대 기울기와 측점 밖 곡선을 함께 지킨다."""
"""합성 언덕(14 % · R12) — 추적만 측점 위 · 추천 갈래는 측점 맞춤 없음(769-1)."""
on_total, curves_total = 0, 0
for mode, result in hill_runs.items():
after = result["metrics"]
@@ -155,7 +157,8 @@ def test_합성_지형_modes_before_after(hill_runs: dict) -> None:
f" · 곡선 {curves}(측점 밖 {result['off_station_curves']})"
)
print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%")
assert curves_total and on_total == curves_total
assert curves_total and hill_runs["follow"]["off_station_curves"] == 0
assert on_total < curves_total # 추천 갈래는 맞추지 않음
def test_합성_예상노선_eight_outlines() -> None:
@@ -203,13 +203,12 @@ def hill_runs() -> dict:
@pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"])
def test_hill_branches_rules(hill_runs: dict, mode: str) -> None:
"""언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음."""
"""언덕 — 꺾임만 0 · 못 지킨 곳 수 = R · 계류 · 맞추기 전 선 없음(측점 맞춤 없음 · 769-1)."""
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 over["total"] == over["radius"] + over["stream"]
assert "unsnapped" not in result
assert result["ends_over_limit"] is False and result["ends_grade_pct"] < 14.0
+4 -1
View File
@@ -104,7 +104,10 @@ def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None:
_kept(result)
metrics = result["metrics"]
assert metrics["over_limit"]["total"] == 0
assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
# 영선 = 상한 기울기를 걷는 선 — 곡선 넣기로 조금 넘을 수 있음(원지반 기울기 참고만 · 419 ·
# 측점 맞춤 없는 가이드 선 · 769-1)
if mode != "gradeline":
assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
# 기점 · 종점은 옮기지 않고 복도(100 m) 밖으로 안 나감
assert result["planned"][0] == [50.0, 200.0]
assert result["planned"][-1] == [750.0, 200.0]