diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index b846d9e9..4a658d9c 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -15,7 +15,8 @@ import { requestJson } from "./B05_Profile_Api_Replan"; const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; /** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */ -export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted"; +export type RouteInitialLogic = + "follow" | "earthwork" | "gentle" | "short" | "balance" | "weighted"; /** 비중 계산의 세 비중(%) — 합 100. */ export interface RouteInitialWeights { 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/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 4b5ea6c6..d0578760 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -19,6 +19,8 @@ export const LOGIC_LABEL: Record = { follow: "예상노선 추적", earthwork: "토공 최소", gentle: "기울기 순한", + short: "짧은 노선", + balance: "흙 균형", weighted: "비중 계산", }; @@ -120,6 +122,8 @@ export interface InitialMetrics { meanGradePct: number | null; cutM3: number | null; fillM3: number | null; + /** 남는 흙(㎥) = 절토 − 성토 — + 남음 / − 모자람. 서버 `surplus_m3`. */ + surplusM3: number | null; overLimitCount: number | null; offsetM: number | null; /** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */ @@ -155,6 +159,16 @@ export interface InitialChoice { criteriaOverrides: RouteInitialCriteria | null; /** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */ terrainBoundPct?: number | null; + /** 이 노선과 거의 같은(10 m 안) 다른 갈래 — 서버 `similar`. 없으면 null(옛 답 · 묶음 없음). */ + similar?: SimilarRoute[] | null; + /** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */ + bandWidthM?: number | null; +} + +/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */ +export interface SimilarRoute { + logic: RouteInitialLogic; + gapM: number; } const num = (value: unknown): number | null => @@ -229,6 +243,19 @@ export function mapPaths( })); } +/** 서버 `similar` — 갈래 이름을 아는 것만 담는다. 목록이 아니면 null · 빈 목록이면 빈 배열. */ +function parseSimilar(raw: unknown): SimilarRoute[] | null { + if (!Array.isArray(raw)) return null; + const out: SimilarRoute[] = []; + for (const item of raw as Array>) { + const logic = typeof item?.logic === "string" ? item.logic : ""; + const gap = num(item?.max_gap_m); + if (logic in LOGIC_LABEL && gap !== null) + out.push({ logic: logic as RouteInitialLogic, gapM: gap }); + } + return out; +} + /** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). * `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */ export function normalizeRouteInitial( @@ -257,6 +284,7 @@ export function normalizeRouteInitial( meanGradePct: pick(m, "avg_grade_pct"), cutM3: pick(m, "cut_m3"), fillM3: pick(m, "fill_m3"), + surplusM3: pick(m, "surplus_m3"), overLimitCount: pick(over, "total"), offsetM: pick(m, "offset_avg_m"), violations: Array.isArray(m.violations) @@ -271,6 +299,8 @@ export function normalizeRouteInitial( } : null, terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null, + similar: parseSimilar(body.similar), + bandWidthM: num(body.band_width_m), criteria: body.criteria ? { maxGradePct: pick(c, "max_grade_pct"), @@ -351,6 +381,39 @@ const fixed = (value: number | null, digits: number, unit: string): string => ? "—" : `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`; +/** 남는 흙(절토 − 성토) — 부호를 늘 붙여 + 남음 / − 모자람으로 읽힌다. */ +export function surplusText(surplusM3: number): string { + const rounded = Math.round(surplusM3); + if (rounded === 0) return "0㎥(균형)"; + const sign = rounded > 0 ? "+" : "−"; + return `${sign}${Math.abs(rounded).toLocaleString("ko-KR")}㎥(${rounded > 0 ? "남음" : "모자람"})`; +} + +/** 조사 「과 / 와」 — 받침이 있으면 과. */ +const withParticle = (name: string): string => { + const code = name.charCodeAt(name.length - 1); + const hasFinal = code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0; + return `${name}${hasFinal ? "과" : "와"}`; +}; + +/** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다). + * 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */ +export function similarLine(choice: InitialChoice | null): string { + const similar = choice?.similar; + if (!choice || !similar || similar.length === 0) return ""; + const names = similar.map((item) => LOGIC_LABEL[item.logic]).join(" · "); + const gap = Math.max(...similar.map((item) => item.gapM)); + const band = + choice.bandWidthM === null || choice.bandWidthM === undefined + ? "" + : `폭 ${fixed(choice.bandWidthM, 1, "m")} `; + return `이 지형은 제약 안에서 갈 길이 ${band}한 띠뿐 — ${withParticle(names)} 거의 같은 노선(가장 먼 거리 ${fixed(gap, 1, "m")})`; +} + +/** 같은 노선 알림 아래 흐린 글 — 제약을 바꾸면 갈래가 벌어질 수 있다. */ +export const SIMILAR_HINT = + "기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈)."; + /** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */ export function metricsLine(metrics: InitialMetrics | null): string { if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다."; @@ -358,6 +421,7 @@ export function metricsLine(metrics: InitialMetrics | null): string { `연장 ${fixed(metrics.lengthM, 1, "m")}`, `기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`, `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, + ...(metrics.surplusM3 === null ? [] : [`남는 흙 ${surplusText(metrics.surplusM3)}`]), `기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`, `이격 ${fixed(metrics.offsetM, 1, "m")}`, ].join(" · "); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index f86fe93d..e78d004b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -29,6 +29,8 @@ import { inputWeight, moveWeight, relaxedLine, + SIMILAR_HINT, + similarLine, violationsLine, violationText, WEIGHT_KEYS, @@ -44,12 +46,21 @@ import "./B05_Profile_UI_Style_RouteRecommend.css"; type Vertex = [number, number]; -const PILLS: RouteInitialLogic[] = ["follow", "earthwork", "gentle", "weighted"]; +const PILLS: RouteInitialLogic[] = [ + "follow", + "earthwork", + "gentle", + "short", + "balance", + "weighted", +]; const PILL_TITLE: Record = { follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다", earthwork: "절토 · 성토가 가장 적은 노선", gentle: "종단기울기가 가장 순한 노선", + short: "연장이 가장 짧은 노선(제약 지킴)", + balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선", weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선", }; @@ -196,6 +207,8 @@ export function openRecommendModal(params: RecommendParams): void {
+ +
@@ -299,6 +312,11 @@ export function openRecommendModal(params: RecommendParams): void { const relaxed = relaxedLine(preview); line("relaxed").textContent = relaxed; line("relaxed").hidden = !relaxed; + const similar = similarLine(preview); + line("similar").textContent = similar; + line("similar").hidden = !similar; + line("similar-hint").textContent = similar ? SIMILAR_HINT : ""; + line("similar-hint").hidden = !similar; line("criteria").textContent = preview ? criteriaLine(preview.criteria, params.planMinRadiusM) : ""; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 8d00da23..60b2de8d 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -281,6 +281,13 @@ line-height: 1.5; } +/* 같은 노선 알림 아래 흐린 글. */ +.b05-routerec__lines .is-dim { + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.8; +} + /* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */ .b05-routerec__lines .is-warn { color: var(--color-warning); diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index 16573519..bada657b 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -298,6 +298,63 @@ const weightLocks = { }, }; +// ⑨ 추천 갈래 여섯 · 남는 흙 · 같은 노선 알림(계획서 23-4c 화면). +const withExtras = (extra, metricExtra = {}) => + model.normalizeRouteInitial( + { ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } }, + "short", + null, + ); +const sixth = { + labels: Object.fromEntries( + ["follow", "earthwork", "gentle", "short", "balance", "weighted"].map((k) => [k, model.LOGIC_LABEL[k]]), + ), + surplus: [122.4, -578.2, 0, 0.4].map((v) => model.surplusText(v)), + metricsPlus: model.metricsLine(withExtras({}, { surplus_m3: 122.4 }).metrics), + metricsMinus: model.metricsLine(withExtras({}, { surplus_m3: -578 }).metrics), + metricsNone: model.metricsLine(withExtras({}).metrics), + similarTwo: model.similarLine( + withExtras({ + band_width_m: 13.5, + similar: [ + { logic: "earthwork", max_gap_m: 0.0 }, + { logic: "gentle", max_gap_m: 6.1 }, + ], + }), + ), + similarOne: model.similarLine( + withExtras({ band_width_m: 13.54, similar: [{ logic: "balance", max_gap_m: 6.1 }] }), + ), + similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "weighted", max_gap_m: 9.1 }] })), + similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })), + similarMissing: model.similarLine(withExtras({})), + similarNull: model.similarLine(null), + similarUnknown: withExtras({ similar: [{ logic: "nope", max_gap_m: 1 }, { logic: "short", max_gap_m: "x" }] }).similar, + bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM, + hint: model.SIMILAR_HINT, + weightedViolation: model.violationsLine({ + ...plain.metrics, + violations: [ + { + kind: "grade", + station: "No.9+0.0", + value: 29.1, + limit: 14, + reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)", + }, + ], + }), + weightedViolationReason: model.violationText({ + kind: "grade", + station: "No.9+0.0", + value: 29.1, + limit: 14, + reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음", + }), + shortLabel: model.choiceLabel("short", { expected: 0, earthwork: 0, grade: 0 }), + balanceLabel: model.choiceLabel("balance", null), +}; + // ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. model.writeInitialChoice("p-1", plain); const readBack = model.readInitialChoice("p-1"); @@ -341,5 +398,6 @@ process.stdout.write( historyRecs, sweep, weightLocks, + sixth, }), ); diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index be5cb5e7..ece139ab 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -246,3 +246,59 @@ def test_비중_값_칸_직접_입력(results): "ok": False, "weights": {"expected": 70, "earthwork": 20, "grade": 10}, } + + +def test_추천_갈래_여섯_이름과_가중치_없음(results): + six = results["sixth"] + assert six["labels"] == { + "follow": "예상노선 추적", + "earthwork": "토공 최소", + "gentle": "기울기 순한", + "short": "짧은 노선", + "balance": "흙 균형", + "weighted": "비중 계산", + } + # 짧은 노선 · 흙 균형은 비중이 없다(weights 0/0/0 이 와도 이름만) + assert six["shortLabel"] == "짧은 노선" and six["balanceLabel"] == "흙 균형" + page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts") + with open(page, encoding="utf-8") as handle: + text = handle.read() + order = [ + text.index(f'"{name}"') for name in ("follow", "earthwork", "gentle", "short", "balance") + ] + assert order == sorted(order) # 알약 순서: 추적 · 토공 · 기울기 · 짧은 · 균형 · 비중 + + +def test_비교값_남는_흙(results): + six = results["sixth"] + assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"] + assert six["metricsPlus"].endswith( + "성토 900㎥ · 남는 흙 +122㎥(남음) · 기준 초과 2구간 · 이격 3.3m" + ) + assert "남는 흙 −578㎥(모자람)" in six["metricsMinus"] + # 서버가 안 주면 칸이 없다(옛 답) — 기존 비교값 글 그대로 + assert "남는 흙" not in six["metricsNone"] + + +def test_같은_노선_알림_줄(results): + six = results["sixth"] + assert six["similarTwo"] == ( + "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — " + "토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)" + ) + assert six["similarOne"] == ( + "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 흙 균형과 거의 같은 노선(가장 먼 거리 6.1m)" + ) + # 띠 폭이 없어도 줄은 선다 · 받침 없는 이름은 「와」 + assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"] + # 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음 + assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == "" + assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목은 버림 + assert six["bandParsed"] == 13.5 + assert "제약 값을 바꾸면" in six["hint"] + + +def test_비중_계산_넘은_구간_사유_그대로(results): + six = results["sixth"] + assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)" + assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)" diff --git a/resources/tester/test_b05_routeedit_initial_roundtrip.py b/resources/tester/test_b05_routeedit_initial_roundtrip.py index a3e9d833..14d7d9a0 100644 --- a/resources/tester/test_b05_routeedit_initial_roundtrip.py +++ b/resources/tester/test_b05_routeedit_initial_roundtrip.py @@ -43,6 +43,8 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [ ("follow", None), ("earthwork", None), ("gentle", None), + ("short", None), + ("balance", None), ("weighted", {"expected": 50, "earthwork": 25, "grade": 25}), ] 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: