"""초기 계획노선 갈래의 후보 묶음 · 비중 탐색(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])), ), )