Files
Aislo/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py
T
eomsangdonandClaude Opus 5.5 4ae4fa49ea 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
2026-10-18 12:56:34 +09:00

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])),
),
)