- 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
409 lines
20 KiB
Python
409 lines
20 KiB
Python
"""초기 계획노선 갈래의 후보 묶음 · 비중 탐색(PLAN 23-1 · 23-4).
|
|
|
|
`B05_Profile_Engine_RouteInitial` 이 부름.
|
|
|
|
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
|
|
· 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 ·
|
|
계류 횡단 · 공사비) × 탐색 링크 상한(지킬 수 있을 때 · 한 번) 또는 벌점 크기(지형상 불가능할
|
|
때 · 막은 기울기 안) — 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):
|
|
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
|
|
기울기 순한) 사이 범위로 나눠(`scale`) 비중 1 이 비슷한 크기로 움직이게.
|
|
· 제약은 막지 않는다 — 벌점 = (1 − 예상 몫) × 센 벌점(기울기 · 계류). 예상 100 이면 벌점 0
|
|
(추적 노선도 넘는 지형) · 예상 0 이면 갈래와 같은 세기. 넘은 곳은 `violations` 에 뜬다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
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_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 curve_every_kink, widen
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
|
CROSSING_BAND,
|
|
OBJECTIVES,
|
|
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
|
|
|
|
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
|
|
# 갈래 후보를 찾는 탐색 목표(PLAN 23-1 · 23-5) — 공사비는 상대 단가 그대로(`_solve`)
|
|
TARGETS: dict[str, dict[str, float]] = {
|
|
"earthwork": {**ZERO, "earthwork": 100.0},
|
|
"gentle": {**ZERO, "grade": 100.0},
|
|
"follow": {**ZERO, "expected": 100.0},
|
|
"gradeline": {"gradeline": 100.0},
|
|
"safe": {"risk": 100.0},
|
|
"crossings": {"crossing": 100.0},
|
|
"cost": {},
|
|
}
|
|
# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419 · 검증_sub4 = 상한 누른
|
|
# 계획선 횡단 5 위가 균형 종단 토공 최소 → 5)
|
|
SHORTLIST = 5
|
|
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
|
|
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
|
|
SOFT_GRADE_COST = 500.0
|
|
RELAXED_GRADE_COSTS = (50.0, 5.0)
|
|
# 토공 100 → 기울기 100 쓸기 벌 수(양 끝 포함 · 10 % 걸음)
|
|
SWEEP_STEPS = 11
|
|
# 끝 노선 사이 범위가 링크 평균의 이 몫보다 작으면(끝 노선끼리 같음) 링크 평균으로 크기를 맞춤
|
|
MIN_RANGE_SHARE = 0.05
|
|
# 두 노선 사이 가장 먼 거리가 이보다 작으면 「같은 노선」으로 알림(`similar` · PLAN 23-4)
|
|
SIMILAR_M = 10.0
|
|
# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기
|
|
CONSTRAINT_SHARE = 1.0
|
|
# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼
|
|
TRACE_DENSIFY_M = 2.0
|
|
|
|
Measure = Callable[..., dict[str, Any]]
|
|
Route = tuple[Any, dict[str, Any]]
|
|
# (선, 직접 잼) → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
|
|
Snap = Callable[[Any, Any], Any]
|
|
|
|
|
|
@dataclass
|
|
class Pool:
|
|
"""같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다."""
|
|
|
|
graph: RouteGraph
|
|
snap: Snap
|
|
candidates: list[Route] = field(default_factory=list)
|
|
bound: float | None = None
|
|
limit: int = 0 # 후보로 남길 못 지킨 곳 수 상한
|
|
# 비중 몫 크기(1 m 당) — 이격 · 토공 · 기울기 · 넘은 기울기 · 계류 나란히
|
|
scale: np.ndarray = field(default_factory=lambda: np.ones(5))
|
|
# 비중 탐색 상한 — 예상 0 이면 갈래 상한(`base_cap`) · 예상 100 이면 추적 노선이 지나는
|
|
# 가장 센 링크 기울기(`follow_cap`) · 사이는 예상 몫만큼(추적 쪽으로 갈수록 덜 막음)
|
|
base_cap: float = np.inf
|
|
follow_cap: float = np.inf
|
|
searched: dict[tuple[float, ...], Route] = field(default_factory=dict)
|
|
traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`)
|
|
follow: Route | None = None
|
|
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
|
|
band_width_m: float = 0.0
|
|
# 큰 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)
|
|
|
|
|
|
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:
|
|
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:
|
|
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
|
|
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
|
|
# 경로를 예정노선 규칙으로 단순화 · 묶음 · 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"):
|
|
guard = guard.keeping_offset()
|
|
key = (tuple(nodes), tuple(sorted(kw.items())))
|
|
if key not in traced:
|
|
floor = build_planned_polyline(
|
|
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes)
|
|
)
|
|
least = (floor, measure(floor, **kw))
|
|
path = densify_route(nodes, TRACE_DENSIFY_M)
|
|
fitted = build_planned_polyline(path, min_radius_m=min_radius_m)
|
|
larger = (
|
|
widen(fitted, path, min_radius_m, guard=guard),
|
|
widen(floor, path, min_radius_m, guard=guard),
|
|
fitted,
|
|
)
|
|
chosen = next(
|
|
(
|
|
route
|
|
for route in ((outline, measure(outline, **kw)) for outline in larger)
|
|
if route[1]["over_limit"]["total"] <= least[1]["over_limit"]["total"]
|
|
),
|
|
least,
|
|
)
|
|
if guard is not None:
|
|
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)
|
|
return traced[key]
|
|
|
|
|
|
def _solve(graph: RouteGraph, target: str, **constraint: Any) -> list[tuple[float, float]]:
|
|
"""갈래 후보 탐색 한 번. 공사비 = 토공 가늠 · 계류 횡단 · 연장을 상대 단가 그대로 더한 비용
|
|
(링크 평균이 1 쯤 되게 한 값으로 나눔 — 제약 벌점과 겨루는 크기를 다른 목표와 맞춤)."""
|
|
if target != "cost":
|
|
return graph.solve(TARGETS[target], **constraint)
|
|
crossing = COST_CROSSING / (2.0 * CROSSING_BAND * graph.grid.cell) # 1 m 당(횡단 한 곳 몫)
|
|
per_m = COST_EARTH_M3 * float(np.average(graph.terms[1], weights=graph.length)) + COST_LENGTH_M
|
|
weights = {"earthwork": COST_EARTH_M3 / per_m, "crossing": crossing / per_m}
|
|
total = sum(weights.values())
|
|
return graph.solve(
|
|
weights,
|
|
scale=np.full(len(OBJECTIVES), 1.0 / total),
|
|
length_cost=COST_LENGTH_M / per_m,
|
|
**constraint,
|
|
)
|
|
|
|
|
|
def _candidates(
|
|
pool: Pool, max_grade: float, min_radius_m: float, measure: Measure
|
|
) -> tuple[list[Route], float | None, int]:
|
|
"""제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한.
|
|
|
|
제약을 지키는 길이 있으면 목표마다 탐색 링크 상한으로 찾은 노선들(곡선 맞춤 뒤 원지반
|
|
기울기는 참고만 — 상한 낮춰 다시 찾지 않음 · 419) · 없으면 막은 기울기 안에서 벌점 크기 ·
|
|
목표를 바꿔 찾은 노선들.
|
|
"""
|
|
graph = pool.graph
|
|
found: list[Route] = []
|
|
for target in TARGETS:
|
|
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
|
|
|
|
# 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를
|
|
# 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다.
|
|
bound = graph.terrain_bound()
|
|
cap = bound + 1e-9
|
|
seed = _trace(
|
|
pool,
|
|
graph.solve(
|
|
{"expected": 50.0, "earthwork": 25.0, "grade": 25.0},
|
|
cap=cap,
|
|
strict_stream=False,
|
|
grade_cost=SOFT_GRADE_COST,
|
|
),
|
|
min_radius_m,
|
|
measure,
|
|
bound=bound,
|
|
)
|
|
found = [seed]
|
|
for target in TARGETS:
|
|
for cost in RELAXED_GRADE_COSTS:
|
|
nodes = _solve(graph, target, cap=cap, strict_stream=False, grade_cost=cost)
|
|
found.append(_trace(pool, nodes, min_radius_m, measure, bound=bound))
|
|
most = seed[1]["over_limit"]["total"]
|
|
return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most
|
|
|
|
|
|
def build_pool(
|
|
graph: RouteGraph,
|
|
follow: Route,
|
|
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.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]]
|
|
pool.scale, steepest = _scale(graph, [outline.vertices for outline in ends], max_grade)
|
|
pool.base_cap = (max_grade if bound is None else bound) + 1e-9
|
|
pool.follow_cap = max(steepest + 1e-9, pool.base_cap)
|
|
for k in np.linspace(0.0, 1.0, SWEEP_STEPS):
|
|
weights = {"expected": 0.0, "earthwork": 100.0 * k, "grade": 100.0 * (1.0 - k)}
|
|
route = weighted(pool, weights, min_radius_m, measure)
|
|
if route[1]["over_limit"]["total"] <= limit:
|
|
pool.candidates.append(route)
|
|
lines = list(
|
|
{id(outline): LineString(outline.vertices) for outline, _ in pool.candidates}.values()
|
|
)
|
|
pool.band_width_m = max(
|
|
(a.hausdorff_distance(b) for i, a in enumerate(lines) for b in lines[i + 1 :]), default=0.0
|
|
)
|
|
return pool
|
|
|
|
|
|
def similar(
|
|
pool: Pool,
|
|
mode: str,
|
|
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` · `earth`)."""
|
|
here = LineString(outline.vertices)
|
|
others = {
|
|
"follow": pool.follow,
|
|
**{name: choose(name, pool.candidates, with_profile, profile, earth) for name in GOALS},
|
|
}
|
|
found = []
|
|
for name, (other, _) in others.items():
|
|
if name == mode or other is None:
|
|
continue
|
|
gap = here.hausdorff_distance(LineString(other.vertices))
|
|
if gap < SIMILAR_M:
|
|
found.append({"logic": name, "max_gap_m": round(gap, 1)})
|
|
return sorted(found, key=lambda item: item["max_gap_m"])
|
|
|
|
|
|
def _scale(
|
|
graph: RouteGraph, lines: list[list[tuple[float, float]]], max_grade: float
|
|
) -> tuple[np.ndarray, float]:
|
|
"""비중 몫의 크기(1 m 당) — 끝 노선 셋 사이 몫 적분 범위 / 추적 연장 · 추적의 가장 센 기울기.
|
|
|
|
비중 1 을 옮기면 몫마다 「끝 노선 사이 차이의 1 %」만큼 무게가 옮겨 가 몫끼리 영향이 비슷하다.
|
|
끝 노선끼리 그 몫이 거의 같으면(평지 등) 링크 평균으로 맞춤.
|
|
"""
|
|
measured = [graph.grid.objectives(line, limit=max_grade) for line in lines]
|
|
values = np.array([value for value, _ in measured])
|
|
reference = max(float(np.hypot(*np.diff(np.asarray(lines[0]), axis=0).T).sum()), 1.0)
|
|
spread = (values.max(axis=0) - values.min(axis=0)) / reference
|
|
per_link = np.vstack([graph.terms[:3], graph.over / graph.limit, graph.along])
|
|
mean = (per_link * graph.length).sum(axis=1) / max(float(graph.length.sum()), 1e-9)
|
|
return np.where(spread > MIN_RANGE_SHARE * mean, spread, mean), measured[0][1]
|
|
|
|
|
|
def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure: Measure) -> Route:
|
|
"""비중 계산 — 탐색 비용의 가중합 한 번(같은 비중은 다시 안 찾음). 제약은 벌점만."""
|
|
key = tuple(round(float(weights[name]), 6) for name in ZERO)
|
|
if key not in pool.searched:
|
|
share = weights["expected"] / max(sum(weights.values()), 1e-9)
|
|
soft = CONSTRAINT_SHARE * (1.0 - share)
|
|
nodes = pool.graph.solve(
|
|
weights,
|
|
cap=pool.base_cap + share * (pool.follow_cap - pool.base_cap),
|
|
strict_stream=False,
|
|
grade_cost=soft / max(pool.scale[3], 1e-12),
|
|
stream_cost=soft / max(pool.scale[4], 1e-12),
|
|
scale=np.concatenate([pool.scale[:3], np.ones(len(OBJECTIVES) - 3)]),
|
|
)
|
|
pool.searched[key] = _trace(
|
|
pool, nodes, min_radius_m, measure, bound=pool.bound, weighted=True
|
|
)
|
|
return pool.searched[key]
|
|
|
|
|
|
def _earth(metrics: dict[str, Any]) -> float:
|
|
# 균형 종단을 붙인 후보(PLAN 63-4)는 절 + 성 + 반입 + 사토 · 못 붙였으면 가늠값
|
|
profile = metrics.get("profile")
|
|
if profile and "error" not in profile:
|
|
return profile_earth(profile)
|
|
return metrics["cut_m3"] + metrics["fill_m3"]
|
|
|
|
|
|
GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
|
|
"earthwork": lambda m: (_earth(m),),
|
|
"gentle": lambda m: (m["max_grade_pct"], m["avg_grade_pct"]),
|
|
"gradeline": lambda m: (m["grade_dev_pct"], m["max_grade_pct"]),
|
|
"safe": lambda m: (m["hazard_m"], m["side_slope_avg_pct"]),
|
|
"crossings": lambda m: (m["stream_crossings"], -(m["stream_offset_avg_m"] or 0.0)),
|
|
"cost": lambda m: (m["cost_index"],),
|
|
}
|
|
|
|
|
|
# (후보, 종단 반영) → 노선 횡단 토공 ㎥ — 켬 = 상한 누른 계획선 · 끔 = 계획고 지반고(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:
|
|
"""갈래 고르기 — 토공 · 공사비는 노선 횡단 토공(`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:
|
|
"""갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보.
|
|
`with_profile` 끔 = 원지반 가늠 점수."""
|
|
goal = GOALS[mode]
|
|
return min(
|
|
candidates,
|
|
key=lambda item: (
|
|
item[1]["over_limit"].get("station", 0) > 0,
|
|
*goal(item[1] if with_profile else plain_metrics(item[1])),
|
|
),
|
|
)
|