diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 55570d39..6bdd8fa2 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -1,4 +1,6 @@ -"""초기 계획노선 갈래 — 예상노선 추종 · 토공 최소 · 기울기 순한 노선 · 비중(PLAN 20 · 23장). +"""초기 계획노선 갈래(PLAN 20 · 23장). + +예상노선 추종 · 토공 최소 · 기울기 순한 · 비중 · 짧은 노선 · 흙 균형. 계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 받은 노드로 기존 `POST /route/replan`([확인])을 부른다. @@ -8,13 +10,17 @@ 제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다. · earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소. · gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소). - · weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대, 같으면 평균) 세 값을 후보 - 범위로 0~1 정규화해 비중(합 100)으로 더한 값 최소. 끝값은 그 갈래와 같은 선 — - 토공 100 = 토공 최소 · 기울기 100 = 기울기 순한 · 예상 100 = 추적 선 그대로(23-3e). + · short — 후보 중 연장 최소(PLAN 23-4c). + · balance — 후보 중 흙 남음 |절토 − 성토| 최소 · 같으면 절성토 합 최소(PLAN 23-4c). + · weighted — 탐색 비용의 가중합 한 번(후보 고르기 아님 · PLAN 23-4a · `..._Pool.weighted`). + 끝값은 그 갈래와 같은 선 — 예상 100 = 추적 · 토공 100 = 토공 최소 · 기울기 100 = + 기울기 순한. 제약은 (1 − 예상 몫)만큼 벌점으로만 따지고 넘은 곳은 목록에 뜸. -후보 = 목표 비중(토공 · 기울기 · 이격 · 기본 비중)을 바꿔 가며 격자 탐색(`..._Search`)한 노선들. -갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 비교값 -(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1). +후보 묶음(`..._Pool.build_pool`) = 목표를 바꿔 가며 격자 탐색(`..._Search`)한 노선들 + 토공 ↔ 기울기 +비중 쓸기. 갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 +비교값(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1). +가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수 +있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4). 공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이 @@ -46,6 +52,13 @@ from typing import Any import numpy as np from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( + Pool, + build_pool, + pick, + similar, + weighted, +) from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CORRIDOR_M, ROAD_WIDTH_M, @@ -70,24 +83,19 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천") PENDING: tuple[str, ...] = () # ── 탐색 · 측정 값 ─────────────────────────────────────────────────────────────── -MODES = ("follow", "earthwork", "gentle", "weighted") +MODES = ("follow", "earthwork", "gentle", "weighted", "short", "balance") DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} MODE_WEIGHTS = { "follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0}, "earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0}, "gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0}, + # 비중 몫이 없는 갈래 — 연장 · 흙 균형은 비교값으로만 고름 + "short": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}, + "balance": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}, } -# 후보를 만들 탐색 비중 — 목표 하나씩 -CANDIDATE_WEIGHTS = (MODE_WEIGHTS["earthwork"], MODE_WEIGHTS["gentle"], MODE_WEIGHTS["follow"]) -# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 -GRADE_CAP_STEPS = (1.0, 0.93, 0.85) -# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 -# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) -SOFT_GRADE_COST = 500.0 -RELAXED_GRADE_COSTS = (50.0, 5.0) # 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리) POOL_CACHE_SIZE = 4 -_POOLS: dict[bytes, tuple[list[tuple[Any, dict[str, Any]]], float | None]] = {} +_POOLS: dict[bytes, Pool] = {} def load_terrain_and_streams( @@ -203,9 +211,9 @@ def generate_initial_route( ) -> dict[str, Any]: """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14). - follow 밖 갈래는 후보(`_candidates`) 중 제 목표 비교값이 가장 좋은 것을 고른다. - `stream_offset_m`·`grade_smooth_m` 을 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 - 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정). + follow 는 전처리 초기값 규칙 그대로 · weighted 는 비중 탐색 한 번(끝값은 그 갈래) · 나머지는 + 후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을 + 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정). """ if mode not in MODES: raise ValueError(f"모르는 갈래: {mode}") @@ -215,7 +223,9 @@ def generate_initial_route( stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) - def measure(outline: Any, bound: float | None, follow: bool = False) -> dict[str, Any]: + def measure( + outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False + ) -> dict[str, Any]: return route_metrics( outline.vertices, expected, @@ -229,22 +239,25 @@ def generate_initial_route( bound_pct=None if bound is None else bound * 100.0, grade_smooth_m=grade_smooth, follow=follow, + weighted=weighted, ) def follow_line() -> tuple[Any, dict[str, Any]]: # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 → # 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸. outline = build_planned_polyline(expected, min_radius_m=min_radius_m) - return outline, measure(outline, None, follow=True) + return outline, measure(outline, bound=None, follow=True) bound = None - # 비중 예상 100 = 예상노선 추적 그 선(PLAN 23-3e) — 사이 비중은 제약 지킨 후보에서만 고름 - if mode == "follow" or (mode == "weighted" and used["earthwork"] == used["grade"] == 0): + pool: Pool | None = None + target = _weighted_end(used) if mode == "weighted" else mode + key = _pool_key( + expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth + ) + if target == "follow": # 비중 예상 100 = 추적 그 선(PLAN 23-3e) · 묶음은 짜지 않음(빠른 길) outline, metrics = follow_line() + pool = _POOLS.get(key) else: - key = _pool_key( - expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth - ) if key not in _POOLS: grid = SearchGrid( expected, @@ -256,9 +269,13 @@ 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))) - _POOLS[key] = _candidates(graph, max_grade, min_radius_m, measure) - candidates, bound = _POOLS[key] - outline, metrics = _pick(mode, used, candidates) + _POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure) + pool = _POOLS[key] + bound = pool.bound + if target == "weighted": + outline, metrics = weighted(pool, used, min_radius_m, measure) + else: + outline, metrics = pick(target, pool.candidates) return { "mode": mode, "logic": mode, @@ -270,6 +287,9 @@ def generate_initial_route( "nodes": [node.as_dict() for node in outline.nodes], "curves": [curve.as_dict() for curve in outline.curves], "metrics": metrics, + # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 + "similar": None if pool is None else similar(pool, target, outline), + "band_width_m": None if pool is None else round(pool.band_width_m, 1), "criteria": { "max_grade_pct": round(max_grade * 100.0, 2), "min_radius_m": round(min_radius_m, 2), @@ -283,57 +303,12 @@ def generate_initial_route( } -def _candidates( - graph: RouteGraph, - max_grade: float, - min_radius_m: float, - measure: Any, -) -> tuple[list[tuple[Any, dict[str, Any]]], float | None]: - """갈래가 고를 후보 노선들(폴리라인 · 비교값)과 지형상 불가능 때 막은 기울기(아니면 None). - - 제약을 지키는 길이 있으면 비중마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · - 없으면 막은 기울기 안에서 벌점 크기 · 비중을 바꿔 찾은 노선들. 못 지킨 곳 수가 기준 - (엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. - """ - - def trace(nodes: list[tuple[float, float]], bound: float | None) -> tuple[Any, dict]: - # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선 - outline = build_planned_polyline( - nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) - ) - return outline, measure(outline, bound) - - found: list[tuple[Any, dict[str, Any]]] = [] - for weights in CANDIDATE_WEIGHTS: - for step in GRADE_CAP_STEPS: - try: - nodes = graph.solve(weights, cap=max_grade * step, strict_stream=True) - except NoRoute: - break - found.append(trace(nodes, None)) - if found[-1][1]["over_limit"]["total"] == 0: - break - if not found: # 제약을 지키는 길 없음 — 비중과 무관하니 더 볼 것 없음 - break - 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 - - # 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를 - # 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다. - bound = graph.terrain_bound() - cap = bound + 1e-9 - seed = trace( - graph.solve(DEFAULT_WEIGHTS, cap=cap, strict_stream=False, grade_cost=SOFT_GRADE_COST), - bound, - ) - found = [seed] - for weights in CANDIDATE_WEIGHTS: - for cost in RELAXED_GRADE_COSTS: - nodes = graph.solve(weights, cap=cap, strict_stream=False, grade_cost=cost) - found.append(trace(nodes, bound)) - most = seed[1]["over_limit"]["total"] - return [item for item in found if item[1]["over_limit"]["total"] <= most], bound +def _weighted_end(weights: dict[str, float]) -> str: + """비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색.""" + for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")): + if all(value == 0 for key, value in weights.items() if key != name): + return mode + return "weighted" def _pool_key( @@ -348,34 +323,3 @@ def _pool_key( digest.update(np.ascontiguousarray(array).tobytes()) digest.update(repr((streams, numbers)).encode()) return digest.digest() - - -def _pick( - mode: str, weights: dict[str, float], candidates: list[tuple[Any, dict[str, Any]]] -) -> tuple[Any, dict[str, Any]]: - """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" - - def earth(metrics: dict[str, Any]) -> float: - return metrics["cut_m3"] + metrics["fill_m3"] - - def grade(metrics: dict[str, Any]) -> float: - # 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서 — 값은 0.01 단위라 평균 몫 < 0.01) - return metrics["max_grade_pct"] + 1e-4 * metrics["avg_grade_pct"] - - def offset(metrics: dict[str, Any]) -> float: - return metrics["offset_avg_m"] - - if mode == "earthwork": - return min(candidates, key=lambda item: earth(item[1])) - if mode == "gentle": - return min( - candidates, key=lambda item: (item[1]["max_grade_pct"], item[1]["avg_grade_pct"]) - ) - # 비중 — 세 값을 후보 범위로 0~1 로 맞춘 뒤 비중으로 더함(단위가 다른 값을 겨루게) - parts = [] - for key, value in (("expected", offset), ("earthwork", earth), ("grade", grade)): - values = [value(item[1]) for item in candidates] - low, span = min(values), max(values) - min(values) - parts.append([weights[key] * ((v - low) / span if span > 1e-9 else 0.0) for v in values]) - scores = [sum(column) for column in zip(*parts)] - return candidates[scores.index(min(scores))] diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py index bc5070b0..0b3196fe 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py @@ -33,6 +33,8 @@ REASON_STREAM = "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 REASON_FOLLOW_GRADE = "예상노선 추적 — 예상노선이 지나는 지반이 상한을 넘음(제약 탐색 안 함)" REASON_FOLLOW_STREAM = "예상노선 추적 — 예상노선이 계류 버퍼 안을 지남(제약 탐색 안 함)" REASON_STREAM_TERRAIN = "지형상 불가능 — 기울기 · 반지름을 지키며 계류 버퍼를 비켜 잇는 길 없음" +REASON_WEIGHTED_GRADE = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)" +REASON_WEIGHTED_STREAM = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(계류 버퍼 안을 지남)" def smoothed_ground(terrain: Terrain, grade_smooth_m: float = GRADE_SMOOTH_M) -> np.ndarray: @@ -113,11 +115,13 @@ def route_metrics( bound_pct: float | None, grade_smooth_m: float = GRADE_SMOOTH_M, follow: bool = False, + weighted: bool = False, ) -> dict[str, Any]: """비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기. `bound_pct` 는 제약을 다 지키는 길이 없을 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%). - `follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) — 사유가 그 뜻으로 뜬다. + `follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) · `weighted` 는 비중 계산(제약을 + 벌점으로만 따짐) — 사유가 그 뜻으로 뜬다. """ grid = (terrain.y, terrain.x) sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None) @@ -149,7 +153,13 @@ def route_metrics( float(ends[last]), float(grades[first : last + 1].max()) * 100.0, max_grade * 100.0, - REASON_FOLLOW_GRADE if follow else terrain_reason if relaxed else REASON_GRADE_FIT, + REASON_FOLLOW_GRADE + if follow + else REASON_WEIGHTED_GRADE + if weighted + else terrain_reason + if relaxed + else REASON_GRADE_FIT, ) ) @@ -187,6 +197,8 @@ def route_metrics( stream_offset_m, REASON_FOLLOW_STREAM if follow + else REASON_WEIGHTED_STREAM + if weighted else REASON_STREAM_TERRAIN if relaxed else REASON_STREAM, @@ -228,6 +240,8 @@ def route_metrics( "raw_max_grade_pct": round(float(raw_grades.max()) * 100.0 if raw_grades.size else 0.0, 2), "cut_m3": round(cut, 1), "fill_m3": round(fill, 1), + # 흙 남음(절토 − 성토 · + = 남아 버릴 흙 · − = 모자라 들여올 흙) — 흙 균형 갈래의 목표 + "surplus_m3": round(cut - fill, 1), "over_limit": {**counts, "total": len(violations)}, "offset_avg_m": round(float(offsets.mean()), 2), "offset_max_m": round(float(offsets.max()), 2), diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py new file mode 100644 index 00000000..874fbad7 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -0,0 +1,267 @@ +"""초기 계획노선 갈래의 후보 묶음 · 비중 탐색(PLAN 23-1 · 23-4). + +`B05_Profile_Engine_RouteInitial` 이 부름. + +후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다: + · 제약 탐색 후보 — 목표 하나씩(토공 · 기울기 · 이격 · 연장) × 탐색 상한 단계(지킬 수 있을 + 때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로. + · 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌. + 갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤ + 쓸기 최대 기울기). + · 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. + +갈래는 **비교값으로** 고른다(`pick`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면 평균) · +짧은 노선 = 연장 · 흙 균형 = |절토 − 성토|(같으면 절성토 합). + +비중 계산(`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_Search import LENGTH_COST, NoRoute, RouteGraph +from common_util.common_util_route_polyline import build_planned_polyline + +ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} +TARGETS = { + "earthwork": {**ZERO, "earthwork": 100.0}, + "gentle": {**ZERO, "grade": 100.0}, + "follow": {**ZERO, "expected": 100.0}, + "short": dict(ZERO), # 목표 몫 없음 — 거리 비용만(`SHORT_LENGTH_COST`) +} +# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 +GRADE_CAP_STEPS = (1.0, 0.93, 0.85) +# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 +# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) +SOFT_GRADE_COST = 500.0 +RELAXED_GRADE_COSTS = (50.0, 5.0) +# 짧은 노선 탐색의 1 m 당 거리 비용 — 꺾기 벌점(원호 길이)과 같은 단위라 「연장 최소」가 됨 +SHORT_LENGTH_COST = 1.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 + +Measure = Callable[..., dict[str, Any]] +Route = tuple[Any, dict[str, Any]] + + +@dataclass +class Pool: + """같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다.""" + + graph: RouteGraph + candidates: list[Route] + bound: float | None + limit: int # 후보로 남길 못 지킨 곳 수 상한 + # 비중 몫 크기(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 + + +def _trace( + nodes: list[tuple[float, float]], + min_radius_m: float, + measure: Measure, + traced: dict[tuple, Route], + **kw: Any, +) -> Route: + # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. + # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). + key = (tuple(nodes), tuple(sorted(kw.items()))) + if key not in traced: + outline = build_planned_polyline( + nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) + ) + traced[key] = (outline, measure(outline, **kw)) + return traced[key] + + +def _length_cost(weights: dict[str, float]) -> float: + return LENGTH_COST if any(weights.values()) else SHORT_LENGTH_COST + + +def _candidates( + graph: RouteGraph, + max_grade: float, + min_radius_m: float, + measure: Measure, + traced: dict[tuple, Route], +) -> tuple[list[Route], float | None, int]: + """제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한. + + 제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · + 없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들. + """ + found: list[Route] = [] + for weights in TARGETS.values(): + for step in GRADE_CAP_STEPS: + try: + nodes = graph.solve( + weights, + cap=max_grade * step, + strict_stream=True, + length_cost=_length_cost(weights), + ) + except NoRoute: + break + found.append(_trace(nodes, min_radius_m, measure, traced, bound=None)) + if found[-1][1]["over_limit"]["total"] == 0: + break + if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음 + break + 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( + 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, + traced, + bound=bound, + ) + found = [seed] + for weights in TARGETS.values(): + for cost in RELAXED_GRADE_COSTS: + nodes = graph.solve( + weights, + cap=cap, + strict_stream=False, + grade_cost=cost, + length_cost=_length_cost(weights), + ) + found.append(_trace(nodes, min_radius_m, measure, traced, 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, +) -> Pool: + """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.""" + traced: dict[tuple, Route] = {} + candidates, bound, limit = _candidates(graph, max_grade, min_radius_m, measure, traced) + pool = Pool(graph, candidates, bound, limit, traced=traced, follow=follow) + 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) -> list[dict[str, Any]]: + """이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m). + + 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.""" + here = LineString(outline.vertices) + others = {"follow": pool.follow, **{name: pick(name, pool.candidates) 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, 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=pool.scale[:3], + ) + pool.searched[key] = _trace( + nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True + ) + return pool.searched[key] + + +def _earth(metrics: dict[str, Any]) -> float: + 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"]), + "short": lambda m: (m["length_m"], _earth(m)), + "balance": lambda m: (abs(m["cut_m3"] - m["fill_m3"]), _earth(m)), +} + + +def pick(mode: str, candidates: list[Route]) -> Route: + """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" + goal = GOALS[mode] + return min(candidates, key=lambda item: goal(item[1])) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py index 53e3d625..7680ed37 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py @@ -15,6 +15,7 @@ from dataclasses import dataclass import numpy as np from scipy import ndimage +from scipy.interpolate import RegularGridInterpolator from scipy.sparse import csr_matrix from scipy.sparse.csgraph import breadth_first_order, connected_components, dijkstra @@ -156,6 +157,51 @@ class SearchGrid: terms = np.stack([offset, earth, (grade / limit) ** 2]) return a[ok], b[ok], self.cell * norm, terms, over, along, grade + def objectives( + self, vertices: list[tuple[float, float]], *, limit: float + ) -> tuple[np.ndarray, float]: + """폴리라인 하나의 몫 적분(`links` 의 1 m 당 값 × 길이)과 지나는 가장 센 링크 기울기. + + 몫 = 이격 · 토공 가늠 · 기울기 · 넘은 기울기(/ 상한) · 계류 버퍼 안 나란히(m) — 칸 길이 + 간격으로 잰다. 비중 계산의 크기 맞춤(끝 노선 셋 사이 범위)에 쓴다 — 탐색 경로가 아닌 + 선(추적)도 잰다. + """ + xy = np.asarray(vertices, dtype=np.float64) + chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))]) + at = np.linspace(0.0, chain[-1], max(2, int(chain[-1] / self.cell) + 1)) + points = np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]) + mid = 0.5 * (points[1:] + points[:-1]) + step = np.diff(points, axis=0) + length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9) + + def sample(field: np.ndarray, where: np.ndarray) -> np.ndarray: + field = np.where(np.isfinite(field), field, 1e9) + return RegularGridInterpolator( + (self.ys, self.xs), field, bounds_error=False, fill_value=None + )(where[:, ::-1]) + + grade = np.abs(np.diff(sample(self.z, points))) / length + over = np.maximum(0.0, grade - limit) + normal = np.column_stack([-step[:, 1], step[:, 0]]) / length[:, None] + cross = np.abs( + sample(self.grad_x, mid) * normal[:, 0] + sample(self.grad_y, mid) * normal[:, 1] + ) + earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0 + away = np.column_stack([sample(self.away_x, mid), sample(self.away_y, mid)]) + size = np.hypot(away[:, 0], away[:, 1]) + across = np.abs((away * step).sum(axis=1)) >= STREAM_CROSS_COS * length * size + along = (sample(self.d_stream, mid) < self.stream_offset_m) & ~across & (size > 1e-6) + terms = np.stack( + [ + np.minimum(sample(self.d_expected, mid), CORRIDOR_M), + earth, + (grade / limit) ** 2, + over / limit, + along.astype(np.float64), + ] + ) + return (terms * length).sum(axis=1), float(grade.max(initial=0.0)) + def _turn_steps(min_radius_m: float, cell: float) -> int: """꺾은 뒤 곧게 가야 할 칸 수 — 앞뒤 원호 접선이 한 직선에 들어가게. @@ -226,6 +272,7 @@ class RouteGraph: shape=(self.size, self.size), ) self.indices, self.indptr = order.indices, order.indptr + self._frame: tuple | None = None # 마지막으로 쓴 간선 틀(`_graph`) edge = (order.data - 1.0).astype(np.int64) self.edge_link = link_of[edge] self.edge_turn = np.concatenate(turn)[edge] @@ -243,15 +290,22 @@ class RouteGraph: return usable & ~self.along if strict_stream else usable def _graph(self, usable: np.ndarray, link_cost: np.ndarray | None) -> csr_matrix: - """쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`).""" - keep = usable[self.edge_link] - indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr] - indptr = np.append(indptr, indptr[-1] + len(self.starts)) - indices = np.concatenate([self.indices[keep], self.starts]) + """쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`). + + 틀(남길 간선 · 행 포인터)은 같은 `usable` 이면 다시 쓴다 — 후보 묶음은 같은 상한으로 + 여러 번 찾는다(비용만 다름).""" + signature = usable.tobytes() + if self._frame is None or self._frame[0] != signature: + keep = usable[self.edge_link] + indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr] + indptr = np.append(indptr, indptr[-1] + len(self.starts)) + indices = np.concatenate([self.indices[keep], self.starts]) + self._frame = (signature, keep, indptr, indices, self.edge_link[keep]) + _, keep, indptr, indices, links = self._frame if link_cost is None: data = np.ones(indices.size) else: - cost = link_cost[self.edge_link[keep]] + self.edge_turn[keep] + cost = link_cost[links] + self.edge_turn[keep] data = np.concatenate([cost, np.full(len(self.starts), 1e-9)]) return csr_matrix((data, indices, indptr), shape=(self.size + 1, self.size + 1)) @@ -268,24 +322,29 @@ class RouteGraph: cap: float, strict_stream: bool, grade_cost: float = 0.0, + stream_cost: float = SOFT_STREAM_COST, + scale: np.ndarray | None = None, + length_cost: float = LENGTH_COST, ) -> list[tuple[float, float]]: """노드(꺾임점) 목록 — 첫 · 끝은 예상노선 양 끝 그대로. - 세 목표는 쓸 수 있는 링크 평균이 1 이 되게 정규화(길이 가중)한 뒤 비중을 곱한다. - `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 비율 / 상한) — `cap` 이 상한보다 - 클 때(지형상 불가능)만 뜻이 있다. + 세 목표는 `scale`(1 m 당 크기)로 나눈 뒤 비중을 곱한다 — 안 주면 쓸 수 있는 링크 평균이 + 1 이 되게(길이 가중). `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 비율 / 상한) · + `stream_cost` = 계류 버퍼 안 나란히 벌점(엄격하지 않을 때만) · + `length_cost` = 1 m 당 거리 비용. """ usable = self._usable(cap, strict_stream) if not usable.any(): raise NoRoute("예상노선 둘레에 제약을 지키며 지날 수 있는 링크가 없습니다.") - length = self.length[usable] - scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9) + if scale is None: + length = self.length[usable] + scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9) share = np.array([weights[key] for key in OBJECTIVES]) / max(sum(weights.values()), 1e-9) factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12) penalty = grade_cost * self.over / self.limit if not strict_stream: - penalty = penalty + SOFT_STREAM_COST * self.along - link_cost = self.length * (LENGTH_COST + factor @ self.terms + penalty) + penalty = penalty + stream_cost * self.along + link_cost = self.length * (length_cost + factor @ self.terms + penalty) distance, predecessors = dijkstra( self._graph(usable, link_cost), indices=self.size, return_predecessors=True ) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index d9a7a24d..a3b76d94 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -108,9 +108,10 @@ class RouteInitialCriteria(BaseModel): class RouteInitialRequest(BaseModel): - """초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted.""" + """초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted · + short(연장 최소) · balance(흙 균형 · PLAN 23-4c).""" - mode: Literal["follow", "earthwork", "gentle", "weighted"] = Field( + mode: Literal["follow", "earthwork", "gentle", "weighted", "short", "balance"] = Field( "follow", validation_alias=AliasChoices("logic", "mode") ) weights: RouteInitialWeights | None = None diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index 6d5300a4..2f684022 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -29,6 +29,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, stat from common_util.common_util_route_polyline import build_planned_polyline SEARCHED = [mode for mode in MODES if mode != "follow"] +# 제약을 지키는 갈래(후보 묶음에서 고름) — 비중은 제약을 벌점으로만(PLAN 23-4a) +BRANCHES = ["earthwork", "gentle", "short", "balance"] GRADE = 0.14 RADIUS = 12.0 @@ -76,7 +78,7 @@ def hill() -> Terrain: ) -@pytest.mark.parametrize("mode", SEARCHED) +@pytest.mark.parametrize("mode", BRANCHES) def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None: result = _run(hill, mode) _kept(result) @@ -105,12 +107,24 @@ def _earth(result: dict) -> float: return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"] -def test_each_branch_best_at_own_goal(hill: Terrain) -> None: - """토공 최소 = 절성토 가장 적음 · 기울기 순한 = 최대 기울기 가장 낮음(같은 후보 묶음).""" - runs = {mode: _run(hill, mode) for mode in SEARCHED} - assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in runs.values()) +def _check_branch_goals(runs: dict) -> None: + """토공 최소 = 절성토 · 기울기 순한 = 최대 기울기 · 짧은 노선 = 연장 · 흙 균형 = |절토 − 성토| + 가 갈래(같은 후보 묶음) 가운데 가장 좋음 · 흙 남음 = 절토 − 성토.""" + branches = [runs[mode] for mode in BRANCHES] + assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in branches) top = runs["gentle"]["metrics"]["max_grade_pct"] - assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values()) + assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in branches) + shortest = runs["short"]["metrics"]["length_m"] + assert all(shortest <= r["metrics"]["length_m"] + 1e-6 for r in branches) + surplus = abs(runs["balance"]["metrics"]["surplus_m3"]) + assert all(surplus <= abs(r["metrics"]["surplus_m3"]) + 0.1 for r in branches) + for result in runs.values(): + metrics = result["metrics"] + assert abs(metrics["surplus_m3"] - (metrics["cut_m3"] - metrics["fill_m3"])) <= 0.11 + + +def test_each_branch_best_at_own_goal(hill: Terrain) -> None: + _check_branch_goals({mode: _run(hill, mode) for mode in MODES}) # 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선 @@ -144,7 +158,8 @@ def _check_weight_ends(run) -> None: def _check_weight_monotone(run) -> None: - """비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질).""" + """예상 몫을 줄이면 예상노선 이격이 늘기만 함(비중 탐색 = 가중합 최소의 성질) · 토공 ↔ 기울기 + 끝값(갈래)은 그 사이 어느 비중보다 제 목표에서 좋음(쓸기가 후보 묶음에 들어 있음).""" def metrics(steps): keys = ("expected", "earthwork", "grade") @@ -155,8 +170,8 @@ def _check_weight_monotone(run) -> None: earth = metrics(TOWARD_EARTH) cuts = [m["cut_m3"] + m["fill_m3"] for m in earth] tops = [m["max_grade_pct"] for m in earth] - assert cuts == sorted(cuts, reverse=True), cuts - assert tops == sorted(tops), tops + assert cuts[-1] <= min(cuts) + 1e-6, cuts # 끝 0/100/0 = 토공 최소 + assert tops[0] <= min(tops) + 1e-6, tops # 끝 0/0/100 = 기울기 순한 def test_weight_ends_match_branches(hill: Terrain) -> None: @@ -185,7 +200,7 @@ def test_stream_offset_is_hard() -> None: """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감.""" flat = _terrain(lambda xx, yy: np.zeros_like(xx)) stream = [[(0.0, 220.0), (800.0, 220.0)]] - for mode in SEARCHED: + for mode in BRANCHES: result = _run(flat, mode, streams=stream) _kept(result) assert result["metrics"]["over_limit"]["stream"] == 0 @@ -203,7 +218,7 @@ def test_stream_crossing_is_allowed() -> None: def test_terrain_impossible_is_listed() -> None: """복도 전체를 가로지르는 20 m 벽 — 어떤 길도 상한을 넘음 → 목록 · 사유 · 막은 기울기.""" wall = _terrain(lambda xx, yy: np.where(xx > 400.0, 20.0, 0.0)) - result = _run(wall, "weighted") + result = _run(wall, "earthwork") _kept(result) assert result["relaxed"] is True assert result["terrain_bound_pct"] > GRADE * 100.0 @@ -222,7 +237,8 @@ def test_grade_line_stays_within_limit() -> None: def test_weights_and_stream_filter() -> None: assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0 - assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"} + assert set(MODES) == {"follow", "earthwork", "gentle", "weighted", "short", "balance"} + assert resolve_weights("short", {"expected": 99})["expected"] == 0.0 line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]} features = [ {"properties": {"구분": "세류"}, "geometry": line}, @@ -302,6 +318,8 @@ def test_request_accepts_logic_name() -> None: assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle" assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork" + assert RouteInitialRequest.model_validate({"logic": "short"}).mode == "short" + assert RouteInitialRequest.model_validate({"logic": "balance"}).mode == "balance" replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}}) assert replan.initial is not None and replan.initial.mode == "weighted" @@ -385,20 +403,39 @@ def test_project_follow_is_preprocess_initial(project_runs: dict) -> None: def test_project_branches_differ_by_goal(project_runs: dict) -> None: - """갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음.""" - searched = {mode: project_runs[mode] for mode in SEARCHED} # 비중은 50/25/25 - for first in SEARCHED: - for second in SEARCHED: - if first < second: - gap = LineString(searched[first]["planned"]).hausdorff_distance( - LineString(searched[second]["planned"]) - ) - assert gap >= 3.0, (first, second, gap) - assert all(_earth(searched["earthwork"]) <= _earth(r) for r in searched.values()) - top = searched["gentle"]["metrics"]["max_grade_pct"] - assert all(top <= r["metrics"]["max_grade_pct"] for r in searched.values()) - for result in searched.values(): - assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"]) + """각 갈래가 제 목표 비교값에서 가장 좋음 · 토공 최소와 기울기 순한은 선이 갈림(6.1 m).""" + _check_branch_goals({mode: project_runs[mode] for mode in MODES}) + assert _gap(project_runs["earthwork"], project_runs["gentle"]) >= 3.0 + for mode in MODES: + metrics = project_runs[mode]["metrics"] + assert metrics["over_limit"]["total"] == len(metrics["violations"]) + + +def test_project_similar_and_band(project_runs: dict) -> None: + """가파른 지형 — 제약 안 길이 한 띠(띠 폭 < 20 m) · 10 m 안인 다른 갈래를 `similar` 로 알림.""" + earthwork = project_runs["earthwork"] + assert earthwork["band_width_m"] < 20.0 + near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]} + assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1) + assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near + assert "follow" not in near # 추적은 40 m 넘게 떨어짐 + + +def test_project_expected_axis_moves_little_by_little(project_runs: dict) -> None: + """예상 100 → 0(토공 : 기울기 1 : 1) — 99 는 추적에서 몇 m 안 · 이웃 단계마다 노선이 움직임 · + 추적에서 점점 멀어짐(이격 늘기만) · 비중 탐색은 넘은 곳을 「비중 계산」 사유로 알림.""" + run = project_runs["run"] + near = run("weighted", {"expected": 99, "earthwork": 0.5, "grade": 0.5}) + assert 0.0 < _gap(near, project_runs["follow"]) < 10.0 + steps = [ + run("weighted", {"expected": e, "earthwork": (100 - e) / 2, "grade": (100 - e) / 2}) + for e in range(100, -1, -10) + ] + assert all(_gap(a, b) > 0.0 for a, b in zip(steps, steps[1:])) + offsets = [step["metrics"]["offset_avg_m"] for step in steps] + assert offsets == sorted(offsets), offsets + reasons = [v["reason"] for v in steps[5]["metrics"]["violations"] if v["kind"] == "grade"] + assert reasons and all(reason.startswith("비중 계산") for reason in reasons) def test_project_weight_ends_match_branches(project_runs: dict) -> None: