Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]))
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,8 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
|
||||
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<Record<string, unknown>>) {
|
||||
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(" · ");
|
||||
|
||||
@@ -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<RouteInitialLogic, string> = {
|
||||
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
|
||||
earthwork: "절토 · 성토가 가장 적은 노선",
|
||||
gentle: "종단기울기가 가장 순한 노선",
|
||||
short: "연장이 가장 짧은 노선(제약 지킴)",
|
||||
balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선",
|
||||
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
|
||||
};
|
||||
|
||||
@@ -196,6 +207,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<div data-line="metrics"></div>
|
||||
<div data-line="violations" hidden></div>
|
||||
<div class="is-warn" data-line="relaxed" hidden></div>
|
||||
<div data-line="similar" hidden></div>
|
||||
<div class="is-dim" data-line="similar-hint" hidden></div>
|
||||
<div data-line="criteria"></div>
|
||||
</div>
|
||||
<div class="b05-routerec__foot">
|
||||
@@ -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)
|
||||
: "";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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%(비중 계산)"
|
||||
|
||||
@@ -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}),
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user