feat(B05 노선 추천 엔진): 합치기 판정 = 바뀐 측점 횡단 차이 · 후보 줄 세움 = 노선 횡단 토공 · 균형 종단 위 5 개 (419-1)

- 420 cross_earthwork · cross_earthwork_delta 이음 — 합치기 멈춤 = 계획고 지반고 노선 횡단 +10 %
- 토공 · 공사비 줄 세움 = 상한 누른 계획선 횡단(종단 켬) · 지반고 횡단(종단 끔)
- 위 후보 수 3 → 5(검증_sub4 균형 종단 토공 최소 = 줄 세움 5 위)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-18 12:56:34 +09:00
co-authored by Claude Opus 5.5
parent 44f77df1c8
commit 4ae4fa49ea
5 changed files with 159 additions and 56 deletions
@@ -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 EarthDelta, Guard, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
@@ -67,8 +67,9 @@ TARGETS: dict[str, dict[str, float]] = {
"crossings": {"crossing": 100.0},
"cost": {},
}
# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419)
SHORTLIST = 3
# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419 · 검증_sub4 = 상한 누른
# 계획선 횡단 5 위가 균형 종단 토공 최소 → 5)
SHORTLIST = 5
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
SOFT_GRADE_COST = 500.0
@@ -112,8 +113,10 @@ class Pool:
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
# 합치기 멈춤 토공 = 바뀐 측점 횡단 차이(420 · 없으면 노선 잣대 토공 · `merge_same_side`)
earth_delta: EarthDelta | None = None
# 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`)
cross: RouteEarth | None = None
# 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419)
scores: dict[tuple[int, bool], float] = field(default_factory=dict)
def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]:
@@ -174,7 +177,7 @@ def _trace(
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
chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.cross
)
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw)
return traced[key]
@@ -250,14 +253,12 @@ def build_pool(
min_radius_m: float,
measure: Measure,
snap: Snap,
earth_delta: EarthDelta | None = None,
cross: RouteEarth | None = None,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.
`measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`)."""
pool = Pool(
graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), earth_delta=earth_delta
)
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross)
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]]
@@ -284,15 +285,16 @@ def similar(
outline: Any,
with_profile: bool = True,
profile: Callable[[Route], None] | None = None,
earth: CrossScore | None = None,
) -> list[dict[str, Any]]:
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.
다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` = 줄 세운 위 후보에 균형 종단)."""
다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` · `earth`)."""
here = LineString(outline.vertices)
others = {
"follow": pool.follow,
**{name: choose(name, pool.candidates, with_profile, profile) for name in GOALS},
**{name: choose(name, pool.candidates, with_profile, profile, earth) for name in GOALS},
}
found = []
for name, (other, _) in others.items():
@@ -359,27 +361,38 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
}
# (후보, 종단 반영) → 노선 횡단 토공 ㎥ — 켬 = 상한 누른 계획선 · 끔 = 계획고 지반고(420)
CrossScore = Callable[[Route, bool], float]
def choose(
mode: str,
candidates: list[Route],
with_profile: bool,
profile: Callable[[Route], None] | None,
earth: CrossScore | 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)
"""갈래 고르기 — 토공 · 공사비는 노선 횡단 토공(`earth` · 없으면 띠 가늠)으로 줄 세움(419):
종단 반영 켬 = 상한 누른 계획선 횡단 위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여
그 사이에서 고름(후보마다 종단을 세우지 않음) · 끔 = 계획고 지반고 횡단으로 바로 고름.
나머지 갈래는 `pick` 그대로."""
if mode not in ("earthwork", "cost"):
return pick(mode, candidates, with_profile)
unique = list({id(item[1]): item for item in candidates}.values()) # 같은 노선 겹침 뺌
def score(item: Route, profiled: bool) -> tuple:
plain = plain_metrics(item[1])
value = _earth(plain) if earth is None else earth(item, profiled)
if mode == "cost": # 공사비 어림의 토공 몫을 횡단 값으로 갈음
value += plain["cost_index"] - _earth(plain)
return (item[1]["over_limit"].get("station", 0) > 0, value)
if not (with_profile and profile is not None):
return min(unique, key=lambda item: score(item, False))
shortlist = sorted(unique, key=lambda item: score(item, True))[:SHORTLIST]
for route in shortlist:
profile(route)
return pick(mode, shortlist, with_profile)
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route: