feat(B05): 추천 갈래 다시 — 영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소(PLAN 23-5a)
- 갈래 short · balance 뺌(요청 422) · gradeline · safe · crossings · cost 더함 — 각 목표로 제약 탐색한 후보를 묶음에 넣고 제 비교값으로 고름 - 탐색 목표 셋 더함: 목표 기울기 편차(영선 · 등경사선) · 위험(옆경사 35° 이상 · 계류 버퍼 안 통과 길이) · 계류 횡단 링크 · 공사비는 상대 단가 그대로 - 비교값 더함: side_slope_avg_pct · side_slope_max_pct · hazard_m · stream_crossings · stream_offset_avg_m · grade_dev_pct · cost_index - 공사비 상대 단가(절성토 1 ㎥ = 1 · 연장 1 m = 2 · 계류 횡단 1 곳 = 400)는 임시 상대값 · 브레인 임의 판정 - 요청 criteria.target_grade_pct(영선 목표 기울기 · 없으면 상한) · 응답 criteria.target_grade_pct - 시험 test_route_initial 31 · 왕복 시험은 화면이 아는 갈래만(새 넷은 23-5b 뒤) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"""초기 계획노선 갈래(PLAN 20 · 23장).
|
"""초기 계획노선 갈래(PLAN 20 · 23장).
|
||||||
|
|
||||||
예상노선 추종 · 토공 최소 · 기울기 순한 · 비중 · 짧은 노선 · 흙 균형.
|
예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 ·
|
||||||
|
공사비 어림 최소 · 비중.
|
||||||
|
|
||||||
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
|
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
|
||||||
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
|
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
|
||||||
@@ -10,8 +11,12 @@
|
|||||||
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
|
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
|
||||||
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
|
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
|
||||||
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
|
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
|
||||||
· short — 후보 중 연장 최소(PLAN 23-4c).
|
· gradeline — 영선 노선: 목표 기울기(`target_grade` · 없으면 상한)로 등고선을 건너가는
|
||||||
· balance — 후보 중 흙 남음 |절토 − 성토| 최소 · 같으면 절성토 합 최소(PLAN 23-4c).
|
등경사선(PEGGER 식) · 후보 중 목표 기울기 편차(`grade_dev_pct`) 최소(PLAN 23-5).
|
||||||
|
· safe — 안전 우선: 후보 중 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) 최소.
|
||||||
|
· crossings — 계류 횡단 최소: 후보 중 계류 횡단 수 최소 · 같으면 계류에서 먼 쪽.
|
||||||
|
· cost — 공사비 어림 최소: 후보 중 상대 공사비(토공 + 계류 횡단 구조물 + 연장 · 임시 상대
|
||||||
|
단가 `..._Metrics.COST_*`) 최소.
|
||||||
· weighted — 탐색 비용의 가중합 한 번(후보 고르기 아님 · PLAN 23-4a · `..._Pool.weighted`).
|
· weighted — 탐색 비용의 가중합 한 번(후보 고르기 아님 · PLAN 23-4a · `..._Pool.weighted`).
|
||||||
끝값은 그 갈래와 같은 선 — 예상 100 = 추적 · 토공 100 = 토공 최소 · 기울기 100 =
|
끝값은 그 갈래와 같은 선 — 예상 100 = 추적 · 토공 100 = 토공 최소 · 기울기 100 =
|
||||||
기울기 순한. 제약은 (1 − 예상 몫)만큼 벌점으로만 따지고 넘은 곳은 목록에 뜸.
|
기울기 순한. 제약은 (1 − 예상 몫)만큼 벌점으로만 따지고 넘은 곳은 목록에 뜸.
|
||||||
@@ -83,15 +88,17 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
|
|||||||
PENDING: tuple[str, ...] = ()
|
PENDING: tuple[str, ...] = ()
|
||||||
|
|
||||||
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
|
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
|
||||||
MODES = ("follow", "earthwork", "gentle", "weighted", "short", "balance")
|
MODES = ("follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted")
|
||||||
DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
|
DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
|
||||||
MODE_WEIGHTS = {
|
MODE_WEIGHTS = {
|
||||||
"follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
|
"follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
|
||||||
"earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
|
"earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
|
||||||
"gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0},
|
"gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0},
|
||||||
# 비중 몫이 없는 갈래 — 연장 · 흙 균형은 비교값으로만 고름
|
# 비중 몫이 없는 갈래(PLAN 23-5) — 제 목표로 찾은 후보에서 비교값으로 고름
|
||||||
"short": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0},
|
**{
|
||||||
"balance": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0},
|
mode: {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
|
||||||
|
for mode in ("gradeline", "safe", "crossings", "cost")
|
||||||
|
},
|
||||||
}
|
}
|
||||||
# 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리)
|
# 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리)
|
||||||
POOL_CACHE_SIZE = 4
|
POOL_CACHE_SIZE = 4
|
||||||
@@ -208,6 +215,7 @@ def generate_initial_route(
|
|||||||
min_radius_m: float,
|
min_radius_m: float,
|
||||||
stream_offset_m: float | None = None,
|
stream_offset_m: float | None = None,
|
||||||
grade_smooth_m: float | None = None,
|
grade_smooth_m: float | None = None,
|
||||||
|
target_grade: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
|
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
|
||||||
|
|
||||||
@@ -222,6 +230,8 @@ def generate_initial_route(
|
|||||||
used = resolve_weights(mode, weights)
|
used = resolve_weights(mode, weights)
|
||||||
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
|
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)
|
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
|
||||||
|
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
|
||||||
|
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
|
||||||
|
|
||||||
def measure(
|
def measure(
|
||||||
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
|
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
|
||||||
@@ -240,6 +250,7 @@ def generate_initial_route(
|
|||||||
grade_smooth_m=grade_smooth,
|
grade_smooth_m=grade_smooth,
|
||||||
follow=follow,
|
follow=follow,
|
||||||
weighted=weighted,
|
weighted=weighted,
|
||||||
|
target_grade=goal_grade,
|
||||||
)
|
)
|
||||||
|
|
||||||
def follow_line() -> tuple[Any, dict[str, Any]]:
|
def follow_line() -> tuple[Any, dict[str, Any]]:
|
||||||
@@ -252,7 +263,7 @@ def generate_initial_route(
|
|||||||
pool: Pool | None = None
|
pool: Pool | None = None
|
||||||
target = _weighted_end(used) if mode == "weighted" else mode
|
target = _weighted_end(used) if mode == "weighted" else mode
|
||||||
key = _pool_key(
|
key = _pool_key(
|
||||||
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth
|
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth, goal_grade
|
||||||
)
|
)
|
||||||
if target == "follow": # 비중 예상 100 = 추적 그 선(PLAN 23-3e) · 묶음은 짜지 않음(빠른 길)
|
if target == "follow": # 비중 예상 100 = 추적 그 선(PLAN 23-3e) · 묶음은 짜지 않음(빠른 길)
|
||||||
outline, metrics = follow_line()
|
outline, metrics = follow_line()
|
||||||
@@ -265,6 +276,7 @@ def generate_initial_route(
|
|||||||
streams,
|
streams,
|
||||||
stream_offset_m=stream_offset,
|
stream_offset_m=stream_offset,
|
||||||
grade_smooth_m=grade_smooth,
|
grade_smooth_m=grade_smooth,
|
||||||
|
target_grade=goal_grade,
|
||||||
)
|
)
|
||||||
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
|
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
|
||||||
while len(_POOLS) >= POOL_CACHE_SIZE:
|
while len(_POOLS) >= POOL_CACHE_SIZE:
|
||||||
@@ -297,6 +309,7 @@ def generate_initial_route(
|
|||||||
"road_width_m": ROAD_WIDTH_M,
|
"road_width_m": ROAD_WIDTH_M,
|
||||||
"corridor_m": CORRIDOR_M,
|
"corridor_m": CORRIDOR_M,
|
||||||
"grade_smooth_m": round(grade_smooth, 2),
|
"grade_smooth_m": round(grade_smooth, 2),
|
||||||
|
"target_grade_pct": round(goal_grade * 100.0, 2),
|
||||||
# 기준값 확정(PLAN 20장, 2026-09-29) — 더는 후보가 아니다. 항상 빈 목록.
|
# 기준값 확정(PLAN 20장, 2026-09-29) — 더는 후보가 아니다. 항상 빈 목록.
|
||||||
"pending": list(PENDING),
|
"pending": list(PENDING),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ SAMPLE_M = 2.0 # 측정 간격(m)
|
|||||||
GRADE_SMOOTH_M = 5.0
|
GRADE_SMOOTH_M = 5.0
|
||||||
STATION_M = 20.0 # 종단기울기 측정 간격(m) — 측점 체계 · 타당성평가 20 m 간격
|
STATION_M = 20.0 # 종단기울기 측정 간격(m) — 측점 체계 · 타당성평가 20 m 간격
|
||||||
GRADE_EPS = 1e-6 # 상한과 같은 기울기는 지킨 것으로 봄
|
GRADE_EPS = 1e-6 # 상한과 같은 기울기는 지킨 것으로 봄
|
||||||
|
# 안전 우선 — 옆경사가 이보다 급한 곳을 위험으로 봄(35° = 70 % · 지식DB 「35° 이상 전폭 절토」
|
||||||
|
# `산림과임업기술(임도)/2. 임도/4. 노선측량.md`)
|
||||||
|
SAFE_SIDE_SLOPE = math.tan(math.radians(35.0))
|
||||||
|
# 공사비 어림 상대 단가(절성토 1 ㎥ = 1) — ⚠ 임시 상대값 · 브레인 임의 판정(PLAN 23-5 · 지식DB
|
||||||
|
# 근거가 붙으면 바꿈). 연장 1 m = 벌개 · 노반 · 측구 등 절성토 밖 공종을 흙 2 ㎥ 로 어림(이 현장
|
||||||
|
# 절성토가 1 m 당 3~4 ㎥ 라 두 몫이 같은 자릿수) · 계류 횡단 1 곳 = 횡단 배수 구조물(관 ·
|
||||||
|
# 기슭막이) 400 ㎥.
|
||||||
|
COST_EARTH_M3 = 1.0
|
||||||
|
COST_LENGTH_M = 2.0
|
||||||
|
COST_CROSSING = 400.0
|
||||||
|
CROSSING_MERGE_M = 10.0 # 이 거리 안의 계류 교점은 한 횡단(갈라진 도엽 선 이음매)
|
||||||
|
|
||||||
REASON_TERRAIN = "지형상 불가능 — 복도 안 어떤 길도 {bound:.1f}% 이상 기울기를 지나야 이어짐"
|
REASON_TERRAIN = "지형상 불가능 — 복도 안 어떤 길도 {bound:.1f}% 이상 기울기를 지나야 이어짐"
|
||||||
REASON_GRADE_FIT = "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음"
|
REASON_GRADE_FIT = "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음"
|
||||||
@@ -116,18 +127,19 @@ def route_metrics(
|
|||||||
grade_smooth_m: float = GRADE_SMOOTH_M,
|
grade_smooth_m: float = GRADE_SMOOTH_M,
|
||||||
follow: bool = False,
|
follow: bool = False,
|
||||||
weighted: bool = False,
|
weighted: bool = False,
|
||||||
|
target_grade: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기.
|
"""비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기.
|
||||||
|
|
||||||
`bound_pct` 는 제약을 다 지키는 길이 없을 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%).
|
`bound_pct` 는 제약을 다 지키는 길이 없을 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%).
|
||||||
`follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) · `weighted` 는 비중 계산(제약을
|
`follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) · `weighted` 는 비중 계산(제약을
|
||||||
벌점으로만 따짐) — 사유가 그 뜻으로 뜬다.
|
벌점으로만 따짐) — 사유가 그 뜻으로 뜬다. `target_grade` = 영선 노선 목표 기울기(비율 · 없으면
|
||||||
|
상한) — 기울기 편차(`grade_dev_pct`)의 기준.
|
||||||
"""
|
"""
|
||||||
grid = (terrain.y, terrain.x)
|
grid = (terrain.y, terrain.x)
|
||||||
sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None)
|
sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None)
|
||||||
smooth = RegularGridInterpolator(
|
smoothed = smoothed_ground(terrain, grade_smooth_m)
|
||||||
grid, smoothed_ground(terrain, grade_smooth_m), bounds_error=False, fill_value=None
|
smooth = RegularGridInterpolator(grid, smoothed, bounds_error=False, fill_value=None)
|
||||||
)
|
|
||||||
points, chain = _resample(vertices, SAMPLE_M)
|
points, chain = _resample(vertices, SAMPLE_M)
|
||||||
length = float(chain[-1])
|
length = float(chain[-1])
|
||||||
lookup = points[:, ::-1]
|
lookup = points[:, ::-1]
|
||||||
@@ -181,8 +193,12 @@ def route_metrics(
|
|||||||
# 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의
|
# 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의
|
||||||
# 버퍼 안 길이(45° 로 가로지름) + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이
|
# 버퍼 안 길이(45° 로 가로지름) + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이
|
||||||
route_points = shapely.points(points)
|
route_points = shapely.points(points)
|
||||||
|
near = np.full(len(points), np.inf)
|
||||||
|
crossings = 0
|
||||||
if streams:
|
if streams:
|
||||||
near = shapely.distance(route_points, MultiLineString(streams))
|
network = MultiLineString(streams)
|
||||||
|
near = shapely.distance(route_points, network)
|
||||||
|
crossings = _crossings(LineString(vertices), network)
|
||||||
for first, last in _runs(near < stream_offset_m):
|
for first, last in _runs(near < stream_offset_m):
|
||||||
ends_in = int(first == 0) + int(last == len(near) - 1)
|
ends_in = int(first == 0) + int(last == len(near) - 1)
|
||||||
crosses = float(near[first : last + 1].min()) <= SAMPLE_M
|
crosses = float(near[first : last + 1].min()) <= SAMPLE_M
|
||||||
@@ -223,6 +239,17 @@ def route_metrics(
|
|||||||
cut = float((np.maximum(depth, 0.0).mean(axis=1) * road_width_m * weight).sum())
|
cut = float((np.maximum(depth, 0.0).mean(axis=1) * road_width_m * weight).sum())
|
||||||
fill = float((np.maximum(-depth, 0.0).mean(axis=1) * road_width_m * weight).sum())
|
fill = float((np.maximum(-depth, 0.0).mean(axis=1) * road_width_m * weight).sum())
|
||||||
|
|
||||||
|
# 옆경사(고른 지반 · 노선에 수직) · 위험 통과 길이(옆경사 35° 이상 또는 계류 버퍼 안)
|
||||||
|
sy, sx = np.gradient(smoothed, _spacing(terrain.y), _spacing(terrain.x))
|
||||||
|
side = np.abs(
|
||||||
|
RegularGridInterpolator(grid, sx, bounds_error=False, fill_value=None)(lookup)
|
||||||
|
* -tangent[:, 1]
|
||||||
|
+ RegularGridInterpolator(grid, sy, bounds_error=False, fill_value=None)(lookup)
|
||||||
|
* tangent[:, 0]
|
||||||
|
)
|
||||||
|
hazard = float(weight[(side >= SAFE_SIDE_SLOPE) | (near < stream_offset_m)].sum())
|
||||||
|
target = max_grade if target_grade is None else target_grade
|
||||||
|
deviation = np.abs(grades - target)
|
||||||
offsets = shapely.distance(route_points, LineString(expected))
|
offsets = shapely.distance(route_points, LineString(expected))
|
||||||
counts = {
|
counts = {
|
||||||
kind: sum(1 for v in violations if v["kind"] == kind)
|
kind: sum(1 for v in violations if v["kind"] == kind)
|
||||||
@@ -245,9 +272,34 @@ def route_metrics(
|
|||||||
"over_limit": {**counts, "total": len(violations)},
|
"over_limit": {**counts, "total": len(violations)},
|
||||||
"offset_avg_m": round(float(offsets.mean()), 2),
|
"offset_avg_m": round(float(offsets.mean()), 2),
|
||||||
"offset_max_m": round(float(offsets.max()), 2),
|
"offset_max_m": round(float(offsets.max()), 2),
|
||||||
|
# PLAN 23-5 — 옆경사 · 위험 통과 길이(안전 우선) · 계류 횡단(계류 횡단 최소) · 계류 평균
|
||||||
|
# 거리(계류 없으면 None) · 목표 기울기 편차(영선 노선 · 측점 기울기와 목표의 차 평균) ·
|
||||||
|
# 공사비 어림
|
||||||
|
"side_slope_avg_pct": round(float((side * weight).sum() / max(length, 1e-9)) * 100.0, 2),
|
||||||
|
"side_slope_max_pct": round(float(side.max()) * 100.0, 2),
|
||||||
|
"hazard_m": round(hazard, 1),
|
||||||
|
"stream_crossings": crossings,
|
||||||
|
"stream_offset_avg_m": round(float(near.mean()), 2) if streams else None,
|
||||||
|
"grade_dev_pct": round(
|
||||||
|
float((deviation * spans[keep]).sum() / spans[keep].sum()) * 100.0
|
||||||
|
if grades.size
|
||||||
|
else 0.0,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"cost_index": round(
|
||||||
|
COST_EARTH_M3 * (cut + fill) + COST_LENGTH_M * length + COST_CROSSING * crossings
|
||||||
|
),
|
||||||
"violations": violations,
|
"violations": violations,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _crossings(route: LineString, network: MultiLineString) -> int:
|
||||||
|
"""노선이 계류를 건너는 횟수 — 교점을 노선 따라 세고 `CROSSING_MERGE_M` 안은 한 번."""
|
||||||
|
hit = route.intersection(network)
|
||||||
|
points = [geom for geom in getattr(hit, "geoms", [hit]) if not geom.is_empty]
|
||||||
|
at = sorted(route.project(point.centroid) for point in points)
|
||||||
|
return sum(1 for i, value in enumerate(at) if i == 0 or value - at[i - 1] > CROSSING_MERGE_M)
|
||||||
|
|
||||||
|
|
||||||
def _spacing(coords: np.ndarray) -> float:
|
def _spacing(coords: np.ndarray) -> float:
|
||||||
return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0
|
return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
`B05_Profile_Engine_RouteInitial` 이 부름.
|
`B05_Profile_Engine_RouteInitial` 이 부름.
|
||||||
|
|
||||||
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
|
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
|
||||||
· 제약 탐색 후보 — 목표 하나씩(토공 · 기울기 · 이격 · 연장) × 탐색 상한 단계(지킬 수 있을
|
· 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 ·
|
||||||
|
계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을
|
||||||
때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로.
|
때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로.
|
||||||
· 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌.
|
· 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌.
|
||||||
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
|
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
|
||||||
쓸기 최대 기울기).
|
쓸기 최대 기울기).
|
||||||
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
|
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
|
||||||
|
|
||||||
갈래는 **비교값으로** 고른다(`pick`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면 평균) ·
|
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
|
||||||
짧은 노선 = 연장 · 흙 균형 = |절토 − 성토|(같으면 절성토 합).
|
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
|
||||||
|
계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5).
|
||||||
|
|
||||||
비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a):
|
비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a):
|
||||||
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
|
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
|
||||||
@@ -29,15 +31,29 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from shapely.geometry import LineString
|
from shapely.geometry import LineString
|
||||||
|
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import LENGTH_COST, NoRoute, RouteGraph
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||||||
|
COST_CROSSING,
|
||||||
|
COST_EARTH_M3,
|
||||||
|
COST_LENGTH_M,
|
||||||
|
)
|
||||||
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||||
|
CROSSING_BAND,
|
||||||
|
OBJECTIVES,
|
||||||
|
NoRoute,
|
||||||
|
RouteGraph,
|
||||||
|
)
|
||||||
from common_util.common_util_route_polyline import build_planned_polyline
|
from common_util.common_util_route_polyline import build_planned_polyline
|
||||||
|
|
||||||
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
|
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
|
||||||
TARGETS = {
|
# 갈래 후보를 찾는 탐색 목표(PLAN 23-1 · 23-5) — 공사비는 상대 단가 그대로(`_solve`)
|
||||||
|
TARGETS: dict[str, dict[str, float]] = {
|
||||||
"earthwork": {**ZERO, "earthwork": 100.0},
|
"earthwork": {**ZERO, "earthwork": 100.0},
|
||||||
"gentle": {**ZERO, "grade": 100.0},
|
"gentle": {**ZERO, "grade": 100.0},
|
||||||
"follow": {**ZERO, "expected": 100.0},
|
"follow": {**ZERO, "expected": 100.0},
|
||||||
"short": dict(ZERO), # 목표 몫 없음 — 거리 비용만(`SHORT_LENGTH_COST`)
|
"gradeline": {"gradeline": 100.0},
|
||||||
|
"safe": {"risk": 100.0},
|
||||||
|
"crossings": {"crossing": 100.0},
|
||||||
|
"cost": {},
|
||||||
}
|
}
|
||||||
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
|
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
|
||||||
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
|
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
|
||||||
@@ -45,8 +61,6 @@ GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
|
|||||||
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
|
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
|
||||||
SOFT_GRADE_COST = 500.0
|
SOFT_GRADE_COST = 500.0
|
||||||
RELAXED_GRADE_COSTS = (50.0, 5.0)
|
RELAXED_GRADE_COSTS = (50.0, 5.0)
|
||||||
# 짧은 노선 탐색의 1 m 당 거리 비용 — 꺾기 벌점(원호 길이)과 같은 단위라 「연장 최소」가 됨
|
|
||||||
SHORT_LENGTH_COST = 1.0
|
|
||||||
# 토공 100 → 기울기 100 쓸기 벌 수(양 끝 포함 · 10 % 걸음)
|
# 토공 100 → 기울기 100 쓸기 벌 수(양 끝 포함 · 10 % 걸음)
|
||||||
SWEEP_STEPS = 11
|
SWEEP_STEPS = 11
|
||||||
# 끝 노선 사이 범위가 링크 평균의 이 몫보다 작으면(끝 노선끼리 같음) 링크 평균으로 크기를 맞춤
|
# 끝 노선 사이 범위가 링크 평균의 이 몫보다 작으면(끝 노선끼리 같음) 링크 평균으로 크기를 맞춤
|
||||||
@@ -99,8 +113,21 @@ def _trace(
|
|||||||
return traced[key]
|
return traced[key]
|
||||||
|
|
||||||
|
|
||||||
def _length_cost(weights: dict[str, float]) -> float:
|
def _solve(graph: RouteGraph, target: str, **constraint: Any) -> list[tuple[float, float]]:
|
||||||
return LENGTH_COST if any(weights.values()) else SHORT_LENGTH_COST
|
"""갈래 후보 탐색 한 번. 공사비 = 토공 가늠 · 계류 횡단 · 연장을 상대 단가 그대로 더한 비용
|
||||||
|
(링크 평균이 1 쯤 되게 한 값으로 나눔 — 제약 벌점과 겨루는 크기를 다른 목표와 맞춤)."""
|
||||||
|
if target != "cost":
|
||||||
|
return graph.solve(TARGETS[target], **constraint)
|
||||||
|
crossing = COST_CROSSING / (2.0 * CROSSING_BAND * graph.grid.cell) # 1 m 당(횡단 한 곳 몫)
|
||||||
|
per_m = COST_EARTH_M3 * float(np.average(graph.terms[1], weights=graph.length)) + COST_LENGTH_M
|
||||||
|
weights = {"earthwork": COST_EARTH_M3 / per_m, "crossing": crossing / per_m}
|
||||||
|
total = sum(weights.values())
|
||||||
|
return graph.solve(
|
||||||
|
weights,
|
||||||
|
scale=np.full(len(OBJECTIVES), 1.0 / total),
|
||||||
|
length_cost=COST_LENGTH_M / per_m,
|
||||||
|
**constraint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _candidates(
|
def _candidates(
|
||||||
@@ -116,15 +143,10 @@ def _candidates(
|
|||||||
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
|
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
|
||||||
"""
|
"""
|
||||||
found: list[Route] = []
|
found: list[Route] = []
|
||||||
for weights in TARGETS.values():
|
for target in TARGETS:
|
||||||
for step in GRADE_CAP_STEPS:
|
for step in GRADE_CAP_STEPS:
|
||||||
try:
|
try:
|
||||||
nodes = graph.solve(
|
nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True)
|
||||||
weights,
|
|
||||||
cap=max_grade * step,
|
|
||||||
strict_stream=True,
|
|
||||||
length_cost=_length_cost(weights),
|
|
||||||
)
|
|
||||||
except NoRoute:
|
except NoRoute:
|
||||||
break
|
break
|
||||||
found.append(_trace(nodes, min_radius_m, measure, traced, bound=None))
|
found.append(_trace(nodes, min_radius_m, measure, traced, bound=None))
|
||||||
@@ -153,15 +175,9 @@ def _candidates(
|
|||||||
bound=bound,
|
bound=bound,
|
||||||
)
|
)
|
||||||
found = [seed]
|
found = [seed]
|
||||||
for weights in TARGETS.values():
|
for target in TARGETS:
|
||||||
for cost in RELAXED_GRADE_COSTS:
|
for cost in RELAXED_GRADE_COSTS:
|
||||||
nodes = graph.solve(
|
nodes = _solve(graph, target, cap=cap, strict_stream=False, grade_cost=cost)
|
||||||
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))
|
found.append(_trace(nodes, min_radius_m, measure, traced, bound=bound))
|
||||||
most = seed[1]["over_limit"]["total"]
|
most = seed[1]["over_limit"]["total"]
|
||||||
return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most
|
return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most
|
||||||
@@ -224,7 +240,7 @@ def _scale(
|
|||||||
values = np.array([value for value, _ in measured])
|
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)
|
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
|
spread = (values.max(axis=0) - values.min(axis=0)) / reference
|
||||||
per_link = np.vstack([graph.terms, graph.over / graph.limit, graph.along])
|
per_link = np.vstack([graph.terms[:3], graph.over / graph.limit, graph.along])
|
||||||
mean = (per_link * graph.length).sum(axis=1) / max(float(graph.length.sum()), 1e-9)
|
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]
|
return np.where(spread > MIN_RANGE_SHARE * mean, spread, mean), measured[0][1]
|
||||||
|
|
||||||
@@ -241,7 +257,7 @@ def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure
|
|||||||
strict_stream=False,
|
strict_stream=False,
|
||||||
grade_cost=soft / max(pool.scale[3], 1e-12),
|
grade_cost=soft / max(pool.scale[3], 1e-12),
|
||||||
stream_cost=soft / max(pool.scale[4], 1e-12),
|
stream_cost=soft / max(pool.scale[4], 1e-12),
|
||||||
scale=pool.scale[:3],
|
scale=np.concatenate([pool.scale[:3], np.ones(len(OBJECTIVES) - 3)]),
|
||||||
)
|
)
|
||||||
pool.searched[key] = _trace(
|
pool.searched[key] = _trace(
|
||||||
nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True
|
nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True
|
||||||
@@ -256,8 +272,10 @@ def _earth(metrics: dict[str, Any]) -> float:
|
|||||||
GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
|
GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
|
||||||
"earthwork": lambda m: (_earth(m),),
|
"earthwork": lambda m: (_earth(m),),
|
||||||
"gentle": lambda m: (m["max_grade_pct"], m["avg_grade_pct"]),
|
"gentle": lambda m: (m["max_grade_pct"], m["avg_grade_pct"]),
|
||||||
"short": lambda m: (m["length_m"], _earth(m)),
|
"gradeline": lambda m: (m["grade_dev_pct"], m["max_grade_pct"]),
|
||||||
"balance": lambda m: (abs(m["cut_m3"] - m["fill_m3"]), _earth(m)),
|
"safe": lambda m: (m["hazard_m"], m["side_slope_avg_pct"]),
|
||||||
|
"crossings": lambda m: (m["stream_crossings"], -(m["stream_offset_avg_m"] or 0.0)),
|
||||||
|
"cost": lambda m: (m["cost_index"],),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ from scipy.interpolate import RegularGridInterpolator
|
|||||||
from scipy.sparse import csr_matrix
|
from scipy.sparse import csr_matrix
|
||||||
from scipy.sparse.csgraph import breadth_first_order, connected_components, dijkstra
|
from scipy.sparse.csgraph import breadth_first_order, connected_components, dijkstra
|
||||||
|
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, smoothed_ground
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||||||
|
GRADE_SMOOTH_M,
|
||||||
|
SAFE_SIDE_SLOPE,
|
||||||
|
smoothed_ground,
|
||||||
|
)
|
||||||
|
|
||||||
# 토공 가늠 폭(m) = 유효너비 3 + 길어깨 0.5 × 2(별표2 간선).
|
# 토공 가늠 폭(m) = 유효너비 3 + 길어깨 0.5 × 2(별표2 간선).
|
||||||
ROAD_WIDTH_M = 4.0
|
ROAD_WIDTH_M = 4.0
|
||||||
@@ -31,7 +35,10 @@ TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·
|
|||||||
SOFT_STREAM_COST = 20.0 # 계류 버퍼 안 나란히 달리기(지형상 불가능할 때만 · 1 m 당)
|
SOFT_STREAM_COST = 20.0 # 계류 버퍼 안 나란히 달리기(지형상 불가능할 때만 · 1 m 당)
|
||||||
BOUND_BISECT = 7 # 지형상 불가능할 때 막는 기울기를 찾는 이분 탐색 횟수(구간 1/128 까지)
|
BOUND_BISECT = 7 # 지형상 불가능할 때 막는 기울기를 찾는 이분 탐색 횟수(구간 1/128 까지)
|
||||||
STREAM_CROSS_COS = math.cos(math.radians(45.0)) # 버퍼 안 이동 방향 한도(계류 쪽 · 반대쪽 ±45°)
|
STREAM_CROSS_COS = math.cos(math.radians(45.0)) # 버퍼 안 이동 방향 한도(계류 쪽 · 반대쪽 ±45°)
|
||||||
OBJECTIVES = ("expected", "earthwork", "grade")
|
# 탐색 목표(1 m 당) — 이격 · 토공 가늠 · 기울기 · 목표 기울기 편차(영선) · 위험(급한 옆경사 · 계류
|
||||||
|
# 가까움) · 계류 횡단(계류 선 위 링크) — PLAN 23-5
|
||||||
|
OBJECTIVES = ("expected", "earthwork", "grade", "gradeline", "risk", "crossing")
|
||||||
|
CROSSING_BAND = 0.75 # 계류 횡단 링크 = 링크 가운데가 계류에서 칸 × 이 값 안
|
||||||
# 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림.
|
# 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림.
|
||||||
DIRECTIONS = (
|
DIRECTIONS = (
|
||||||
(0, 1),
|
(0, 1),
|
||||||
@@ -101,8 +108,10 @@ class SearchGrid:
|
|||||||
*,
|
*,
|
||||||
stream_offset_m: float,
|
stream_offset_m: float,
|
||||||
grade_smooth_m: float = GRADE_SMOOTH_M,
|
grade_smooth_m: float = GRADE_SMOOTH_M,
|
||||||
|
target_grade: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x))))
|
step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x))))
|
||||||
|
self.target_grade = target_grade # 영선 노선 목표 기울기(비율) · 없으면 링크 상한
|
||||||
self.xs, self.ys = terrain.x[::step], terrain.y[::step]
|
self.xs, self.ys = terrain.x[::step], terrain.y[::step]
|
||||||
# 기울기 · 옆경사는 비교값과 같은 고른 지반에서 잰다(`smoothed_ground`)
|
# 기울기 · 옆경사는 비교값과 같은 고른 지반에서 잰다(`smoothed_ground`)
|
||||||
self.z = smoothed_ground(terrain, grade_smooth_m)[::step, ::step]
|
self.z = smoothed_ground(terrain, grade_smooth_m)[::step, ::step]
|
||||||
@@ -154,7 +163,17 @@ class SearchGrid:
|
|||||||
# 반 간격(10 m) 동안 쌓는 높이 차 단면(지형상 불가능할 때만 0 이 아님)
|
# 반 간격(10 m) 동안 쌓는 높이 차 단면(지형상 불가능할 때만 0 이 아님)
|
||||||
earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0
|
earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0
|
||||||
offset = 0.5 * (self.d_expected[first][ok] + self.d_expected[second][ok])
|
offset = 0.5 * (self.d_expected[first][ok] + self.d_expected[second][ok])
|
||||||
terms = np.stack([offset, earth, (grade / limit) ** 2])
|
target = limit if self.target_grade is None else self.target_grade
|
||||||
|
stream_mid = 0.5 * (self.d_stream[first][ok] + self.d_stream[second][ok])
|
||||||
|
# 위험 = 비교값(`hazard_m`)과 같은 뜻 — 옆경사 35° 이상 · 계류 버퍼 안을 지나는 길이(칸
|
||||||
|
# 두 끝 가운데 급한 쪽) · 옆경사 몫을 조금 더해 같은 길이면 덜 급한 쪽
|
||||||
|
risk = ((cross >= SAFE_SIDE_SLOPE) | (stream_mid < self.stream_offset_m)).astype(
|
||||||
|
np.float64
|
||||||
|
) + 0.1 * (cross / SAFE_SIDE_SLOPE) ** 2
|
||||||
|
crossing = (stream_mid < CROSSING_BAND * self.cell).astype(np.float64)
|
||||||
|
terms = np.stack(
|
||||||
|
[offset, earth, (grade / limit) ** 2, ((grade - target) / limit) ** 2, risk, crossing]
|
||||||
|
)
|
||||||
return a[ok], b[ok], self.cell * norm, terms, over, along, grade
|
return a[ok], b[ok], self.cell * norm, terms, over, along, grade
|
||||||
|
|
||||||
def objectives(
|
def objectives(
|
||||||
@@ -328,8 +347,9 @@ class RouteGraph:
|
|||||||
) -> list[tuple[float, float]]:
|
) -> list[tuple[float, float]]:
|
||||||
"""노드(꺾임점) 목록 — 첫 · 끝은 예상노선 양 끝 그대로.
|
"""노드(꺾임점) 목록 — 첫 · 끝은 예상노선 양 끝 그대로.
|
||||||
|
|
||||||
세 목표는 `scale`(1 m 당 크기)로 나눈 뒤 비중을 곱한다 — 안 주면 쓸 수 있는 링크 평균이
|
목표(`OBJECTIVES`)는 `scale`(1 m 당 크기)로 나눈 뒤 비중을 곱한다 — 안 주면 쓸 수 있는
|
||||||
1 이 되게(길이 가중). `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 비율 / 상한) ·
|
링크 평균이 1 이 되게(길이 가중). `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은
|
||||||
|
비율 / 상한) ·
|
||||||
`stream_cost` = 계류 버퍼 안 나란히 벌점(엄격하지 않을 때만) ·
|
`stream_cost` = 계류 버퍼 안 나란히 벌점(엄격하지 않을 때만) ·
|
||||||
`length_cost` = 1 m 당 거리 비용.
|
`length_cost` = 1 m 당 거리 비용.
|
||||||
"""
|
"""
|
||||||
@@ -339,7 +359,8 @@ class RouteGraph:
|
|||||||
if scale is None:
|
if scale is None:
|
||||||
length = self.length[usable]
|
length = self.length[usable]
|
||||||
scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9)
|
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)
|
share = np.array([weights.get(key, 0.0) for key in OBJECTIVES])
|
||||||
|
share = share / max(sum(weights.values()), 1e-9)
|
||||||
factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12)
|
factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12)
|
||||||
penalty = grade_cost * self.over / self.limit
|
penalty = grade_cost * self.over / self.limit
|
||||||
if not strict_stream:
|
if not strict_stream:
|
||||||
|
|||||||
@@ -105,15 +105,17 @@ class RouteInitialCriteria(BaseModel):
|
|||||||
min_radius_m: float | None = Field(None, ge=0)
|
min_radius_m: float | None = Field(None, ge=0)
|
||||||
stream_offset_m: float | None = Field(None, ge=0)
|
stream_offset_m: float | None = Field(None, ge=0)
|
||||||
grade_smooth_m: float | None = Field(None, ge=0)
|
grade_smooth_m: float | None = Field(None, ge=0)
|
||||||
|
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
|
||||||
|
target_grade_pct: float | None = Field(None, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
class RouteInitialRequest(BaseModel):
|
class RouteInitialRequest(BaseModel):
|
||||||
"""초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted ·
|
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
|
||||||
short(연장 최소) · balance(흙 균형 · PLAN 23-4c)."""
|
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
|
||||||
|
|
||||||
mode: Literal["follow", "earthwork", "gentle", "weighted", "short", "balance"] = Field(
|
mode: Literal[
|
||||||
"follow", validation_alias=AliasChoices("logic", "mode")
|
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
|
||||||
)
|
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
|
||||||
weights: RouteInitialWeights | None = None
|
weights: RouteInitialWeights | None = None
|
||||||
criteria: RouteInitialCriteria | None = None
|
criteria: RouteInitialCriteria | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ async def initial_route(
|
|||||||
radius_m = criteria.min_radius_m
|
radius_m = criteria.min_radius_m
|
||||||
stream_offset_m = criteria.stream_offset_m if criteria else None
|
stream_offset_m = criteria.stream_offset_m if criteria else None
|
||||||
grade_smooth_m = criteria.grade_smooth_m if criteria else None
|
grade_smooth_m = criteria.grade_smooth_m if criteria else None
|
||||||
|
target_pct = criteria.target_grade_pct if criteria else None
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||||
@@ -90,6 +91,7 @@ async def initial_route(
|
|||||||
min_radius_m=radius_m,
|
min_radius_m=radius_m,
|
||||||
stream_offset_m=stream_offset_m,
|
stream_offset_m=stream_offset_m,
|
||||||
grade_smooth_m=grade_smooth_m,
|
grade_smooth_m=grade_smooth_m,
|
||||||
|
target_grade=None if target_pct is None else target_pct / 100.0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, ValueError) as error:
|
except (FileNotFoundError, ValueError) as error:
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [
|
|||||||
("follow", None),
|
("follow", None),
|
||||||
("earthwork", None),
|
("earthwork", None),
|
||||||
("gentle", None),
|
("gentle", None),
|
||||||
("short", None),
|
# PLAN 23-5 — 서버는 짧은 노선 · 흙 균형을 빼고 넷(gradeline · safe · crossings · cost)을
|
||||||
("balance", None),
|
# 더함. 새 넷은 화면(23-5b)이 알약으로 알게 되면 여기 더함 — 지금 화면은 다시 열 때 모르는
|
||||||
|
# 갈래를 추적으로 읽음. 서버 쪽 저장 · 다시 읽기는 `test_route_initial` 이 지킴.
|
||||||
("weighted", {"expected": 50, "earthwork": 25, "grade": 25}),
|
("weighted", {"expected": 50, "earthwork": 25, "grade": 25}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,19 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
|||||||
stream_lines,
|
stream_lines,
|
||||||
write_initial_choice,
|
write_initial_choice,
|
||||||
)
|
)
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, station_label
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||||||
|
COST_CROSSING,
|
||||||
|
COST_EARTH_M3,
|
||||||
|
COST_LENGTH_M,
|
||||||
|
grade_line,
|
||||||
|
station_label,
|
||||||
|
)
|
||||||
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
|
||||||
from common_util.common_util_route_polyline import build_planned_polyline
|
from common_util.common_util_route_polyline import build_planned_polyline
|
||||||
|
|
||||||
SEARCHED = [mode for mode in MODES if mode != "follow"]
|
SEARCHED = [mode for mode in MODES if mode != "follow"]
|
||||||
# 제약을 지키는 갈래(후보 묶음에서 고름) — 비중은 제약을 벌점으로만(PLAN 23-4a)
|
# 제약을 지키는 갈래(후보 묶음에서 고름) — 비중은 제약을 벌점으로만(PLAN 23-4a)
|
||||||
BRANCHES = ["earthwork", "gentle", "short", "balance"]
|
BRANCHES = ["gradeline", "earthwork", "gentle", "safe", "crossings", "cost"]
|
||||||
|
|
||||||
GRADE = 0.14
|
GRADE = 0.14
|
||||||
RADIUS = 12.0
|
RADIUS = 12.0
|
||||||
@@ -108,19 +115,18 @@ def _earth(result: dict) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _check_branch_goals(runs: dict) -> None:
|
def _check_branch_goals(runs: dict) -> None:
|
||||||
"""토공 최소 = 절성토 · 기울기 순한 = 최대 기울기 · 짧은 노선 = 연장 · 흙 균형 = |절토 − 성토|
|
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
|
||||||
가 갈래(같은 후보 묶음) 가운데 가장 좋음 · 흙 남음 = 절토 − 성토."""
|
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5)."""
|
||||||
branches = [runs[mode] for mode in BRANCHES]
|
branches = [runs[mode]["metrics"] for mode in BRANCHES]
|
||||||
assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in branches)
|
for mode, goal in GOALS.items():
|
||||||
top = runs["gentle"]["metrics"]["max_grade_pct"]
|
assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode
|
||||||
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():
|
for result in runs.values():
|
||||||
metrics = result["metrics"]
|
m = result["metrics"]
|
||||||
assert abs(metrics["surplus_m3"] - (metrics["cut_m3"] - metrics["fill_m3"])) <= 0.11
|
assert abs(m["surplus_m3"] - (m["cut_m3"] - m["fill_m3"])) <= 0.11
|
||||||
|
assert 0.0 <= m["hazard_m"] <= m["length_m"] + 1e-6
|
||||||
|
assert m["side_slope_avg_pct"] <= m["side_slope_max_pct"] + 1e-6
|
||||||
|
cost = COST_EARTH_M3 * _earth(result) + COST_LENGTH_M * m["length_m"]
|
||||||
|
assert abs(m["cost_index"] - cost - COST_CROSSING * m["stream_crossings"]) <= 1.0
|
||||||
|
|
||||||
|
|
||||||
def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
|
def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
|
||||||
@@ -237,8 +243,17 @@ def test_grade_line_stays_within_limit() -> None:
|
|||||||
def test_weights_and_stream_filter() -> 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("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
|
||||||
assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0
|
assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0
|
||||||
assert set(MODES) == {"follow", "earthwork", "gentle", "weighted", "short", "balance"}
|
assert set(MODES) == {
|
||||||
assert resolve_weights("short", {"expected": 99})["expected"] == 0.0
|
"follow",
|
||||||
|
"gradeline",
|
||||||
|
"earthwork",
|
||||||
|
"gentle",
|
||||||
|
"safe",
|
||||||
|
"crossings",
|
||||||
|
"cost",
|
||||||
|
"weighted",
|
||||||
|
}
|
||||||
|
assert resolve_weights("cost", {"expected": 99})["expected"] == 0.0
|
||||||
line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}
|
line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}
|
||||||
features = [
|
features = [
|
||||||
{"properties": {"구분": "세류"}, "geometry": line},
|
{"properties": {"구분": "세류"}, "geometry": line},
|
||||||
@@ -293,6 +308,10 @@ def test_initial_choice_roundtrip(tmp_path) -> None:
|
|||||||
"weights": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
|
"weights": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
|
||||||
"criteria": None,
|
"criteria": None,
|
||||||
}
|
}
|
||||||
|
for logic in ("gradeline", "safe", "crossings", "cost"): # 새 갈래도 적고 다시 읽음(23-5)
|
||||||
|
write_initial_choice(tmp_path, logic, None, {"target_grade_pct": 10.0})
|
||||||
|
assert read_initial_choice(tmp_path)["logic"] == logic
|
||||||
|
assert read_initial_choice(tmp_path)["criteria"] == {"target_grade_pct": 10.0}
|
||||||
write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20})
|
write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20})
|
||||||
assert read_initial_choice(tmp_path) == {
|
assert read_initial_choice(tmp_path) == {
|
||||||
"logic": "weighted",
|
"logic": "weighted",
|
||||||
@@ -318,8 +337,13 @@ def test_request_accepts_logic_name() -> None:
|
|||||||
|
|
||||||
assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle"
|
assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle"
|
||||||
assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork"
|
assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork"
|
||||||
assert RouteInitialRequest.model_validate({"logic": "short"}).mode == "short"
|
for logic in ("gradeline", "safe", "crossings", "cost"):
|
||||||
assert RouteInitialRequest.model_validate({"logic": "balance"}).mode == "balance"
|
assert RouteInitialRequest.model_validate({"logic": logic}).mode == logic
|
||||||
|
for gone in ("short", "balance"): # 23-5 에서 뺀 갈래 — 422
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
RouteInitialRequest.model_validate({"logic": gone})
|
||||||
|
criteria = RouteInitialRequest.model_validate({"criteria": {"target_grade_pct": 10}}).criteria
|
||||||
|
assert criteria is not None and criteria.target_grade_pct == 10
|
||||||
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
|
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
|
||||||
assert replan.initial is not None and replan.initial.mode == "weighted"
|
assert replan.initial is not None and replan.initial.mode == "weighted"
|
||||||
|
|
||||||
@@ -446,3 +470,43 @@ def test_project_weight_ends_match_branches(project_runs: dict) -> None:
|
|||||||
|
|
||||||
def test_project_weight_moves_monotone(project_runs: dict) -> None:
|
def test_project_weight_moves_monotone(project_runs: dict) -> None:
|
||||||
_check_weight_monotone(project_runs["run"])
|
_check_weight_monotone(project_runs["run"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_crossing_counted_once() -> None:
|
||||||
|
"""평지 · 복도를 가로지르는 소하천 한 줄 — 어느 노선이든 한 번 건넘 · 계류 횡단 최소도 한 번."""
|
||||||
|
flat = _terrain(lambda xx, yy: np.zeros_like(xx))
|
||||||
|
crossing = [[(400.0, 0.0), (400.0, 400.0)]]
|
||||||
|
for mode in ("follow", "crossings", "cost"):
|
||||||
|
metrics = _run(flat, mode, streams=crossing)["metrics"]
|
||||||
|
assert metrics["stream_crossings"] == 1, mode
|
||||||
|
assert metrics["stream_offset_avg_m"] is not None
|
||||||
|
assert _run(flat, "follow")["metrics"]["stream_crossings"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_gradeline_target_grade(hill: Terrain) -> None:
|
||||||
|
"""영선 노선 — 목표 기울기(없으면 상한 · 상한보다 크면 상한)."""
|
||||||
|
default = _run(hill, "gradeline")
|
||||||
|
assert default["criteria"]["target_grade_pct"] == 14.0
|
||||||
|
capped = generate_initial_route(
|
||||||
|
_line(200.0),
|
||||||
|
hill,
|
||||||
|
[],
|
||||||
|
mode="gradeline",
|
||||||
|
weights=None,
|
||||||
|
max_grade=GRADE,
|
||||||
|
min_radius_m=RADIUS,
|
||||||
|
target_grade=0.5,
|
||||||
|
)
|
||||||
|
assert capped["criteria"]["target_grade_pct"] == 14.0
|
||||||
|
low = generate_initial_route(
|
||||||
|
_line(200.0),
|
||||||
|
hill,
|
||||||
|
[],
|
||||||
|
mode="gradeline",
|
||||||
|
weights=None,
|
||||||
|
max_grade=GRADE,
|
||||||
|
min_radius_m=RADIUS,
|
||||||
|
target_grade=0.02,
|
||||||
|
)
|
||||||
|
assert low["criteria"]["target_grade_pct"] == 2.0
|
||||||
|
_kept(low)
|
||||||
|
|||||||
Reference in New Issue
Block a user