Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

시험 둘 충돌(351_2 · 383_1 unsnapped 단언) = sub4 쪽 살림(측점 맞춤 필수 · 맞추기 전 선 안 냄 · 419)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
2026-10-18 13:07:28 +09:00
co-authored by Claude Opus 5.5
11 changed files with 577 additions and 159 deletions
+127 -35
View File
@@ -28,9 +28,11 @@
가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수
있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이
지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선
맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) —
못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면
`ends_over_limit`(종단으로도 못 풂 · 화면 알림).
· 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼
곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감.
· 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기).
@@ -52,21 +54,31 @@ from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import (
cross_earthwork,
cross_earthwork_delta,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import RouteEarth
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,
choose,
hard_limits,
settle,
similar,
weighted,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
STATION_M,
_reference,
attach_profile,
cover_shortfall,
plain_metrics,
@@ -297,9 +309,10 @@ def generate_initial_route(
후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을
안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창
추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
`with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다.
토공 · 공사비 갈래는 후보를 노선 횡단 토공(`..._CrossEarth` · 상한 누른 계획선)으로 줄 세워 위
몇 개에만 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 고른다(419) — 편집 창 추천 점수 · 미리
보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
`with_profile` 끄면(종단 반영 끔) 종단 없이 계획고 지반고 횡단 토공으로 고른다.
"""
if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}")
@@ -313,21 +326,24 @@ def generate_initial_route(
def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
) -> dict[str, Any]:
return route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
# 못 지킨 곳 수 = R · 계류 · 측점 — 원지반 기울기는 참고만(목록엔 남음 · 419)
return hard_limits(
route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
)
)
def fit(outline: Any, guard: Any = None) -> Any:
@@ -336,14 +352,12 @@ def generate_initial_route(
moved = snap_curves_to_stations(outline, min_radius_m, guard=guard)
return outline if moved is None else moved
unsnapped: dict[int, Any] = {}
def follow_line() -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은
# 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
route = (outline, measure(outline, bound=None, follow=True))
return settle(route, fit, measure, unsnapped, bound=None, follow=True)
return settle(route, fit, measure, bound=None, follow=True)
def profiled(route: Any, exact: bool = True) -> None:
if route is not None:
@@ -358,6 +372,61 @@ def generate_initial_route(
standard=standard,
)
def quick(route: Any) -> None:
profiled(route, exact=False)
def cross(line: Any, at: Any = None, design_z: Any = None) -> dict[str, Any]:
return cross_earthwork(
line.vertices,
at,
design_z,
terrain,
standard,
design_curves=[curve.as_dict() for curve in line.curves],
)
def route_earth(line: Any) -> float:
# 합치기 멈춤 잣대 = 계획고 지반고 횡단(1차 종단 규칙 · 종단 끔과 같음)
try:
found = cross(line)
except ValueError:
return math.inf
return found["cut"] + found["fill"]
def merge_delta(before: Any, after: Any, window: tuple[float, float]) -> float:
# 바뀐 자리 측점만 — 뒤 측점은 같은 선을 따라 밀린 것뿐(옛 선 끝 s1 → 새 선 s1 + 연장 차)
shift = LineString(after.vertices).length - LineString(before.vertices).length
first = math.ceil(window[0] / STATION_M) * STATION_M
try:
return cross_earthwork_delta(
before.vertices,
after.vertices,
np.arange(first, window[1] + shift + 1e-9, STATION_M),
terrain,
standard,
before_chainages=np.arange(first, window[1] + 1e-9, STATION_M),
design_curves_before=[curve.as_dict() for curve in before.curves],
design_curves_after=[curve.as_dict() for curve in after.curves],
)["earth"]
except ValueError:
return math.inf
def cross_score(route: Any, capped: bool) -> float:
# 후보 줄 세움(419) — 켬 = 상한 누른 계획선(`_reference`) 횡단 · 끔 = 계획고 지반고 횡단
held = pool.scores if pool is not None else {}
key = (id(route[1]), capped)
if key not in held:
try:
found = cross(route[0])
if capped:
at = np.asarray([row["chainage_m"] for row in found["per_station"]])
ground = np.asarray([row["ground_m"] for row in found["per_station"]])
found = cross(route[0], at, _reference(ground, np.diff(at), max_grade))
held[key] = found["cut"] + found["fill"]
except ValueError:
held[key] = math.inf
return held[key]
bound = None
pool: Pool | None = None
target = _weighted_end(used) if mode == "weighted" else mode
@@ -380,19 +449,26 @@ 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] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit)
# 합치기 멈춤 토공 = 노선 횡단 + 바뀐 측점 횡단 차이(420 `..._CrossEarth` · 419)
_POOLS[key] = build_pool(
graph,
follow_line(),
max_grade,
min_radius_m,
measure,
fit,
cross=RouteEarth(route_earth, merge_delta),
)
pool = _POOLS[key]
bound = pool.bound
if target == "weighted":
outline, metrics = weighted(pool, used, min_radius_m, measure)
else:
if with_profile:
for route in [*pool.candidates, pool.follow]:
profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈
outline, metrics = pick(target, pool.candidates, with_profile)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle`) — 모달 「측점 맞춤」 끔 = 맞추기 전 선
# 토공 · 공사비 = 횡단 토공으로 줄 세움 · 종단 반영 켬이면 위 후보만 균형 종단(빠른
# 판) · 고른 하나만 아래에서 바로 셈(419)
outline, metrics = choose(target, pool.candidates, with_profile, quick, cross_score)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419)
snapped = (outline, metrics)
raw = unsnapped.get(id(outline)) or (pool.raw.get(id(outline)) if pool else None)
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
line, values = route
@@ -412,6 +488,7 @@ def generate_initial_route(
}
shown = finish(snapped)
ends = ends_grade(expected, terrain, shown["metrics"]["length_m"])
return {
"mode": mode,
"logic": mode,
@@ -420,10 +497,13 @@ def generate_initial_route(
# 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음
"terrain_bound_pct": None if bound is None else round(bound * 100.0, 2),
**shown,
# 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음)
"unsnapped": None if raw is None else finish(raw),
# 시종점 높이 차 ÷ 노선 연장(%) — 상한 넘으면 종단으로도 못 풂(노선을 늘려야 함 · 화면 알림)
"ends_grade_pct": ends,
"ends_over_limit": ends > max_grade * 100.0 + 1e-6,
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
"similar": None if pool is None else similar(pool, target, outline, with_profile),
"similar": None
if pool is None
else similar(pool, target, outline, with_profile, quick, cross_score),
"with_profile": with_profile,
"band_width_m": None if pool is None else round(pool.band_width_m, 1),
"criteria": {
@@ -440,6 +520,18 @@ def generate_initial_route(
}
def ends_grade(expected: list[tuple[float, float]], terrain: Terrain, length_m: float) -> float:
"""시종점 지반 높이 차 ÷ 노선 연장(%) — 균형 종단이 끝점을 지반고에 두므로 이 값이 상한을 넘으면
어떤 계획선도 상한 안에 못 듦(419 · 원지반 기울기를 참고로 돌린 뒤 남는 알림)."""
from scipy.interpolate import RegularGridInterpolator
sample = RegularGridInterpolator(
(terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None
)
z = sample([expected[0][::-1], expected[-1][::-1]])
return round(abs(float(z[1] - z[0])) / max(length_m, 1e-9) * 100.0, 2)
def _weighted_end(weights: dict[str, float]) -> str:
"""비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색."""
for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")):
@@ -9,12 +9,13 @@
거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`):
· 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상.
· 옮긴 조각이 같은 자리 옛 조각보다 지반 기울기 상한을 더 넘지 않음(`GRADE_NOISE`) · 토공
가늠이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 이격도(`keeping_offset`).
· 옮긴 조각 토공 가늠(띠 · 빠른 거름)이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선
이격도(`keeping_offset`). 원지반 기울기는 참고만(419 · 막지 않음 · 자리 잣대에도 안 씀).
· 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함
(`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음).
· 합치기는 노선 전체 잣대(`route_metrics`)도 — 넘은 곳 수 · 기울기 넘은 곳 안 늘어남 · 최대
기울기 `GRADE_TOL_PCT` · 절성토 `TOTAL_EARTH_TOL` 안(합치기 전 선 기준 · 되풀이해도 누적 안 됨).
· 합치기 멈춤 = 노선 전체 잣대 — 못 지킨 곳 수(R · 계류) 안 늘어남 · 노선 토공이 합치기 전 선보다
`TOTAL_EARTH_TOL` 넘게 늘지 않음(엔진 상수 · 사용자 옵션 아님 · 되풀이해도 누적 안 됨). 토공 =
바뀐 측점 횡단 차이(`cross_earthwork_delta` · 420) · 없으면 노선 잣대 토공(띠 가늠).
큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신).
"""
@@ -23,11 +24,12 @@ from __future__ import annotations
import copy
import math
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import numpy as np
import shapely
from shapely.geometry import LineString
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import SAMPLE_M, STATION_M, _resample
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
@@ -52,11 +54,8 @@ Route = tuple[Any, dict[str, Any]]
# 옮긴 조각 토공 가늠(탐색 몫 · 옆경사 단면)이 옛 조각보다 늘어도 되는 몫 · 바닥(㎥ · 짧은 조각)
EARTH_TOL = 0.05
EARTH_FLOOR_M3 = 5.0
# 옮긴 조각 지반 기울기가 상한을 더 넘어도 「안 늘어남」으로 볼 잴 잡음(비율 · 0.2 %p)
GRADE_NOISE = 0.002
# 합치기 노선 전체 잣대 — 최대 기울기(%p) · 절성토 늘어도 되는 몫
GRADE_TOL_PCT = 0.5
TOTAL_EARTH_TOL = 0.02
# 합치기 멈춤 — 노선 토공(횡단)이 합치기 전 선보다 늘어도 되는 몫(곡선 수 ↔ 토공 · 요구 254)
TOTAL_EARTH_TOL = 0.10
# R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터)
RADIUS_STEPS = 12
# 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t)
@@ -150,27 +149,19 @@ class Guard:
self.seen[key] = found
return found
def judge(
self, local: list[Xy], reference: list[Xy]
) -> tuple[bool, tuple[bool, float, float, float]]:
"""(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 기울기 넘음 안 늘어남 · 토공
`EARTH_TOL` 안(`offset` 이면 예상노선 이격도 같은 폭). 잣대(작을수록 좋음) = 복도 밖 ·
늘어난 계류 나란히(m) · 더 넘은 기울기 · 늘어난 토공 가늠(㎥)."""
excess, earth, along, inside, offset = self._look(local)
old_excess, old_earth, old_along, _, old_offset = self._look(reference)
def judge(self, local: list[Xy], reference: list[Xy]) -> tuple[bool, tuple[bool, float, float]]:
"""(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 토공 `EARTH_TOL` 안(`offset`
이면 예상노선 이격도 같은 폭) · 원지반 기울기는 안 봄(419). 잣대(작을수록 좋음) = 복도 밖 ·
늘어난 계류 나란히(m) · 늘어난 토공 가늠(㎥)."""
_, earth, along, inside, offset = self._look(local)
_, old_earth, old_along, _, old_offset = self._look(reference)
passed = (
inside
and excess <= old_excess + GRADE_NOISE
and along <= old_along + 1e-6
and earth <= old_earth * (1 + EARTH_TOL) + EARTH_FLOOR_M3
and (not self.offset or offset <= old_offset * (1 + EARTH_TOL) + EARTH_FLOOR_M3)
)
cost = (
not inside,
round(max(0.0, along - old_along), 3),
round(max(0.0, excess - old_excess), 4),
earth - old_earth,
)
cost = (not inside, round(max(0.0, along - old_along), 3), earth - old_earth)
return passed, cost
def ok(self, local: list[Xy], reference: list[Xy]) -> bool:
@@ -243,15 +234,13 @@ def _earth(metrics: dict[str, Any]) -> float:
return metrics["cut_m3"] + metrics["fill_m3"]
def _worse(new: dict[str, Any], base: dict[str, Any]) -> bool:
"""노선 전체 잣대가 합치기 전보다 나빠짐(허용 폭 밖)."""
over, was = new["over_limit"], base["over_limit"]
return (
over["total"] > was["total"]
or over["grade"] > was["grade"]
or new["max_grade_pct"] > base["max_grade_pct"] + GRADE_TOL_PCT
or _earth(new) > _earth(base) * (1 + TOTAL_EARTH_TOL)
)
@dataclass
class RouteEarth:
"""합치기 멈춤 잣대 — 노선 토공(횡단 ㎥ · `..._CrossEarth`)과 바뀐 측점 횡단 차이."""
total: Callable[[PlannedPolyline], float]
# (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥)
delta: Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float]
def merge_same_side(
@@ -259,14 +248,23 @@ def merge_same_side(
guard: Guard,
min_radius_m: float,
measure: Callable[[PlannedPolyline], dict[str, Any]],
route_earth: RouteEarth | None = None,
) -> Route:
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로."""
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로.
멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의
1 + `TOTAL_EARTH_TOL` 넘음. `route_earth` 주면 토공 = 횡단(합치기 전 선 전체 + 바뀐 측점 차이
누적) · 없으면 노선 잣대 토공(`measure` 절 + 성 · 띠 가늠)."""
outline, base = route
if len(outline.curves) < 2:
return route
points, flags, radii = _flat(outline)
points = [(round(x, 4), round(y, 4)) for x, y in points]
current = route
earth = (
_earth(base) if route_earth is None else route_earth.total(outline)
) # 지금 선 토공 — 차이를 누적
limit = earth * (1 + TOTAL_EARTH_TOL)
blocked: set[tuple[Xy, ...]] = set()
while True:
reference = _Reference(current[0].vertices)
@@ -287,9 +285,9 @@ def merge_same_side(
LineString(local), LineString(reference.window(local)), densify=0.1
)
)
found.append((gap, k, cross, radius))
found.append((gap, k, cross, radius, local))
merged = None
for _, k, cross, radius in sorted(found):
for _, k, cross, radius, local in sorted(found, key=lambda item: item[:2]):
trial_points = points[:k] + [cross] + points[k + 2 :]
trial_flags = flags[:k] + [True] + flags[k + 2 :]
trial_radii = radii[:k] + [radius] + radii[k + 2 :]
@@ -301,11 +299,16 @@ def merge_same_side(
radii=trial_radii,
)
metrics = measure(line)
if _worse(metrics, base):
if route_earth is None:
total = _earth(metrics)
else:
window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1]))
total = earth + route_earth.delta(current[0], line, (window[0], window[1]))
if metrics["over_limit"]["total"] > base["over_limit"]["total"] or total > limit:
blocked.add(tuple(points[k - 1 : k + 3]))
continue
merged = (trial_points, trial_flags, trial_radii, (line, metrics))
merged = (trial_points, trial_flags, trial_radii, (line, metrics), total)
break
if merged is None:
return current
points, flags, radii, current = merged
points, flags, radii, current, earth = merged
@@ -4,18 +4,23 @@
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
· 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 ·
계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을
때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로.
계류 횡단 · 공사비) × 탐색 링크 상한(지킬 수 있을 때 · 한 번) 또는 벌점 크기(지형상 불가능할
때 · 막은 기울기 안) — PLAN 23-1.
· 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌.
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
쓸기 최대 기울기).
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
· 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보
최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은
남김.
· R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고
고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움.
꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419).
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5).
종단 반영 켬 토공 · 공사비(`choose`) = 상한 누른 계획선 토공으로 줄 세운 위 `SHORTLIST` 개만 균형
종단(빠른 판)을 붙여 고름(419).
비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a):
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
@@ -33,14 +38,14 @@ from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
COST_LENGTH_M,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND,
OBJECTIVES,
@@ -62,8 +67,9 @@ TARGETS: dict[str, dict[str, float]] = {
"crossings": {"crossing": 100.0},
"cost": {},
}
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419 · 검증_sub4 = 상한 누른
# 계획선 횡단 5 위가 균형 종단 토공 최소 → 5)
SHORTLIST = 5
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
SOFT_GRADE_COST = 500.0
@@ -102,20 +108,30 @@ class Pool:
follow_cap: float = np.inf
searched: dict[tuple[float, ...], Route] = field(default_factory=dict)
traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`)
raw: dict[int, Route] = field(default_factory=dict) # 맞춘 선 id → 맞추기 전(「측점 맞춤」 끔)
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
# 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`)
cross: RouteEarth | None = None
# 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419)
scores: dict[tuple[int, bool], float] = field(default_factory=dict)
def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1) · 맞추기 전 선은 `raw` 에."""
def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]:
"""원지반 기울기 = 참고만(419) — 못 지킨 곳 수(`over_limit.total`)에서 뺀 잰 값.
기울기 넘은 곳은 목록(`violations` · kind grade)과 `over_limit.grade` 에 참고로 남는다."""
over = metrics["over_limit"]
return {**metrics, "over_limit": {**over, "total": over["total"] - over.get("grade", 0)}}
def settle(route: Route, snap: Snap, measure: Measure, **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1)."""
outline, metrics = route
moved = snap(outline)
if moved is not outline:
raw[id(moved)] = (outline, with_station_misses(metrics, outline))
outline, metrics = moved, measure(moved, **kw)
return outline, with_station_misses(metrics, outline)
@@ -125,12 +141,12 @@ def _trace(
) -> Route:
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
# 고른 판의 같은 쪽 이웃 곡선을 합치고(406 · `..._Merge`) 측점에 맞춤(`settle` · 383-1).
# 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은 예상노선
# 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이
# 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시
# 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) →
# 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접
# 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
traced = pool.traced
guard = pool.guard
if guard is not None and kw.get("weighted"):
@@ -157,8 +173,13 @@ def _trace(
least,
)
if guard is not None:
chosen = merge_same_side(chosen, guard, min_radius_m, lambda line: measure(line, **kw))
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, pool.raw, **kw)
line = curve_every_kink(chosen[0], min_radius_m)
if line is not chosen[0]:
chosen = (line, measure(line, **kw))
chosen = merge_same_side(
chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.cross
)
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw)
return traced[key]
@@ -184,22 +205,18 @@ def _candidates(
) -> tuple[list[Route], float | None, int]:
"""제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한.
제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 ·
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
제약을 지키는 길이 있으면 목표마다 탐색 링크 상한으로 찾은 노선들(곡선 맞춤 뒤 원지반
기울기는 참고만 — 상한 낮춰 다시 찾지 않음 · 419) · 없으면 막은 기울기 안에서 벌점 크기 ·
목표를 바꿔 찾은 노선들.
"""
graph = pool.graph
found: list[Route] = []
for target in TARGETS:
for step in GRADE_CAP_STEPS:
try:
nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True)
except NoRoute:
break
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found[-1][1]["over_limit"]["total"] == 0:
break
if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
break
try:
nodes = _solve(graph, target, cap=max_grade, strict_stream=True)
except NoRoute:
break # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found:
least = min(item[1]["over_limit"]["total"] for item in found)
return [item for item in found if item[1]["over_limit"]["total"] == least], None, least
@@ -236,9 +253,12 @@ def build_pool(
min_radius_m: float,
measure: Measure,
snap: Snap,
cross: RouteEarth | None = None,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade))
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.
`measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`)."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross)
pool.candidates, pool.bound, pool.limit = _candidates(pool, max_grade, min_radius_m, measure)
candidates, bound, limit = pool.candidates, pool.bound, pool.limit
ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]]
@@ -259,14 +279,22 @@ def build_pool(
return pool
def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]:
def similar(
pool: Pool,
mode: str,
outline: Any,
with_profile: bool = True,
profile: Callable[[Route], None] | None = None,
earth: CrossScore | None = None,
) -> list[dict[str, Any]]:
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다."""
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.
다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` · `earth`)."""
here = LineString(outline.vertices)
others = {
"follow": pool.follow,
**{name: pick(name, pool.candidates, with_profile) for name in GOALS},
**{name: choose(name, pool.candidates, with_profile, profile, earth) for name in GOALS},
}
found = []
for name, (other, _) in others.items():
@@ -333,6 +361,40 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
}
# (후보, 종단 반영) → 노선 횡단 토공 ㎥ — 켬 = 상한 누른 계획선 · 끔 = 계획고 지반고(420)
CrossScore = Callable[[Route, bool], float]
def choose(
mode: str,
candidates: list[Route],
with_profile: bool,
profile: Callable[[Route], None] | None,
earth: CrossScore | None = None,
) -> Route:
"""갈래 고르기 — 토공 · 공사비는 노선 횡단 토공(`earth` · 없으면 띠 가늠)으로 줄 세움(419):
종단 반영 켬 = 상한 누른 계획선 횡단 위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여
그 사이에서 고름(후보마다 종단을 세우지 않음) · 끔 = 계획고 지반고 횡단으로 바로 고름.
나머지 갈래는 `pick` 그대로."""
if mode not in ("earthwork", "cost"):
return pick(mode, candidates, with_profile)
unique = list({id(item[1]): item for item in candidates}.values()) # 같은 노선 겹침 뺌
def score(item: Route, profiled: bool) -> tuple:
plain = plain_metrics(item[1])
value = _earth(plain) if earth is None else earth(item, profiled)
if mode == "cost": # 공사비 어림의 토공 몫을 횡단 값으로 갈음
value += plain["cost_index"] - _earth(plain)
return (item[1]["over_limit"].get("station", 0) > 0, value)
if not (with_profile and profile is not None):
return min(unique, key=lambda item: score(item, False))
shortlist = sorted(unique, key=lambda item: score(item, True))[:SHORTLIST]
for route in shortlist:
profile(route)
return pick(mode, shortlist, with_profile)
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route:
"""갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보.
`with_profile` 끔 = 원지반 가늠 점수."""
@@ -10,6 +10,8 @@
복도 밖 · 기울기 · 계류 · 토공이 나빠지지 않는 가장 큰 R(`..._Merge.Guard` · 자리 R ~ 맞춘 R 사이
`GUARD_STEPS` 벌 · 1 m 내림).
꺾임만(`curve_every_kink` · 419) — 추천 후보에 R 없는 교점을 안 남김(곡선 또는 교점 지움).
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
"""
@@ -51,6 +53,30 @@ def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float |
return points, flags, radii
def curve_every_kink(outline: PlannedPolyline, min_radius_m: float) -> PlannedPolyline:
"""꺾임만(R 없는 교점 · 삼각) 없앤 선(419) — 최소 R 원호가 들어가면 곡선(내각 155° 이상도) ·
아니면 교점 지움(곧게). 꺾임만이 없으면 받은 그대로."""
points, flags, radii = _flat(outline)
if all(flags[1:-1]):
return outline
index = 1
while index < len(points) - 1:
if flags[index]:
index += 1
continue
before, apex, after = points[index - 1 : index + 2]
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
room = min(math.dist(before, apex), math.dist(apex, after)) / 2
if half_tan > 1e-9 and room / half_tan >= min_radius_m:
flags[index] = True
index += 1
else:
del points[index], flags[index], radii[index]
return build_planned_polyline(
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
)
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
@@ -21,11 +21,13 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
추천 후보(`guard` 줌 · PLAN 406 · 요구 251-2)는 이탈 한도 없음 — 옮긴 조각을 직접 잼
(`..._Merge.Guard` · 복도 · 기울기 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐)으로 거름:
(`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는
참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419):
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안 · 더 넘는 기울기 `SOFT_SLACKS` 단계까지) —
MC 를 다 측점에 놓는 첫 단계 판.
· 그래도 못 놓으면 더 안 넘기는 놓기만 쓰고 그 곡선은 그 자리(측점 밖 · 기울기 먼저).
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에.
· 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못
끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌.
· 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수).
· 합친 꼴 먼저 판에 측점 밖이 생기면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖이 적은 쪽(406-4).
예상노선 추적 · 새 초기 노선은 안 줌(이탈 한도 그대로).
@@ -78,9 +80,6 @@ RADIUS_STEPS = 8
BACKTRACK_LIMIT = 30
# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로
PER_VARIANT = 3
# 직접 잼(406) — 잼을 못 지나는 놓기가 더 넘겨도 되는 기울기(비율) 단계 · 앞 단계로 다 놓으면
# 그 판 · 다 못 놓으면 더 안 넘기는 놓기만 쓰고 못 놓는 곡선은 그 자리(부분 맞춤)
SOFT_SLACKS = (0.0, 0.01, 0.03)
@dataclass
@@ -317,7 +316,6 @@ class _Snapper:
self.excess = excess
self.guard = guard
self.keep = False
self.slack = math.inf # 못 지나는 놓기가 더 넘겨도 되는 기울기(`SOFT_SLACKS`)
self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406)
def _steeper(self, local: list[Xy]) -> bool:
@@ -362,7 +360,7 @@ class _Snapper:
max(lowest, STATION_M * math.floor(mc / STATION_M)),
max(lowest, STATION_M * math.ceil(mc / STATION_M)),
}
found: list[tuple[tuple[bool, float, float], Xy, _Corner, float, float]] = []
found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = []
for station in stations:
for radius in radii:
solved = _solve(state, plan, k, station, radius)
@@ -376,12 +374,12 @@ class _Snapper:
rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius))
if self.guard is not None:
# 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림
passed, (out, along, steeper, earth) = self.guard.judge(
passed, (out, along, earth) = self.guard.judge(
local, self.reference.window(local)
)
if out or passed == soft or steeper > self.slack:
if out or passed == soft:
continue
rank = (along, steeper, tight, rank[1], earth, rank[2])
rank = (along, tight, rank[1], earth, rank[2])
found.append((rank, local, new_at, new_corner, new_mc, station))
given = 0
for _, local, new_at, new_corner, new_mc, station in sorted(
@@ -408,7 +406,19 @@ class _Snapper:
"""꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔."""
plan, k = state.plan, state.k
before, at, after = plan.points[k - 1 : k + 2]
if not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None:
if self.guard is not None:
# 추천 후보(419) — 꺾임만 없음: 곡선 못 끼우는 자리(나란함)는 교점 지움 · 꺾임만으로
# 들어온 자리도 곡선으로 놓아 봄
if _corner(before, at, after, plan.radii[k] or 1.0) is None:
gone = plan.copy()
del gone.points[k], gone.flags[k], gone.radii[k]
yield replace(state, plan=gone)
return
if not plan.flags[k]:
plan = plan.copy()
plan.flags[k] = True
state = replace(state, plan=plan)
elif not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None:
kink = plan.copy()
kink.flags[k] = False # 꺾임만(나란한 직선 등 곡선 못 끼움 포함)
yield self._kink(state, kink)
@@ -433,10 +443,13 @@ class _Snapper:
for soft in (False, True) if self.guard is not None else (False,):
for trial in variants:
yield from self._placements(state, trial, soft)
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는
# 안 함 · 155° 이상도 곡선 · 419)
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG and not self._far(
[state.end_xy, at, middle]
if (
self.guard is None
and _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG
and not self._far([state.end_xy, at, middle])
):
kink = plan.copy()
kink.flags[k], kink.radii[k] = False, None
@@ -486,7 +499,8 @@ class _Snapper:
at,
((at[0] + b[0]) / 2, (at[1] + b[1]) / 2),
]
if self._far(piece) or self._steeper(piece):
# 꺾임만 남김 — 추천 후보는 안 함(삼각 없음 · 419)
if self.guard is not None or self._far(piece) or self._steeper(piece):
continue
off = plan.copy()
off.flags[small], off.radii[small] = False, None
@@ -584,13 +598,8 @@ def snap_curves_to_stations(
def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None:
"""다 놓는 길(직접 잼이면 `SOFT_SLACKS` 단계) → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = None
for slack in SOFT_SLACKS if snapper.guard is not None else (math.inf,):
snapper.slack = slack
state = state or snapper.run(keep=False)
snapper.slack = 0.0
state = state or snapper.run(keep=True)
"""다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = snapper.run(keep=False) or snapper.run(keep=True)
if state is None:
return None
snapped = build_planned_polyline(
@@ -40,9 +40,9 @@ def runs() -> dict:
caught: dict = {}
real = engine.build_pool
def spy(graph, follow, max_grade, min_radius_m, measure, snap):
def spy(graph, follow, max_grade, min_radius_m, measure, snap, **kw):
caught["measure"] = measure
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap)
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap, **kw)
return caught["pool"]
engine._POOLS.clear()
@@ -3,8 +3,8 @@
./venv/Scripts/python.exe -m pytest resources/tester/test_351_2_curve_stations.py -q
모든 곡선 MC % 20 ≈ 0 · 이웃 MC 20 m 이상 · 곡선 수 ≤ 내부 일반측점 수 · 맞추기 전 선에서
`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과의
기울기 넘은 곳이 맞추기 전보다 늘지 않음 · 검증_sub1 예상노선 8벌 수치.
`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과는
측점에 맞춘 선만(맞추기 전 선 안 냄 · 419) · 검증_sub1 예상노선 8벌 수치.
"""
import math
@@ -142,6 +142,8 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음)
assert result["metrics"]["over_limit"]["total"] == 0
assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6
# 측점 맞춤 필수 — 맞추기 전 선은 안 냄(419)
assert "unsnapped" not in result
def _project_terrain():
@@ -100,6 +100,7 @@ def test_follow_nodes_equal_initial(tmp_path: Path) -> None:
assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved))
misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"]
assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"]
assert "unsnapped" not in result # 측점 맞춤 필수 — 맞추기 전 선 안 냄(419)
def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
+13 -12
View File
@@ -12,11 +12,7 @@ import numpy as np
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
GRADE_NOISE,
Guard,
merge_same_side,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _Reference
@@ -73,16 +69,18 @@ def test_s_curve_keeps_straight() -> None:
assert _flat(merged)[0] == _flat(_outline(S_CURVE))[0]
def test_valley_grade_blocks() -> None:
"""옛 선 = 골짜기 바닥(평평) · 합친 곡선은 옆 비탈을 올라 기울기 상한을 넘음."""
def test_valley_earth_blocks() -> None:
"""옛 선 = 골짜기 바닥 · 합친 곡선은 옆 비탈을 올라 토공 가늠이 늘어 막힘(419 — 원지반 기울기는
참고만이라 기울기로는 안 막음 · 넘는 만큼은 잼에 남음)."""
distance = _distance(_outline(SAME))
guard = _guard(SAME, z=lambda xx, yy: 3.0 * distance(xx, yy))
assert len(_merge(SAME, guard).curves) == 2
# 막은 까닭 = 기울기 — 합친 꼴 조각이 옛 자리보다 상한을 더 넘음
cross = _line_intersection(*SAME)
piece = guard.piece(SAME[0], cross, SAME[-1], 40.0)
window = _Reference(_outline(SAME).vertices).window(piece)
assert guard._look(piece)[0] > guard._look(window)[0] + GRADE_NOISE
passed, (out, along, earth) = guard.judge(piece, window)
assert not passed and not out and along == 0.0 and earth > 0.0
assert guard._look(piece)[0] > guard._look(window)[0] # 기울기는 참고로 잼
def test_outside_corridor_blocks() -> None:
@@ -111,9 +109,12 @@ def test_stream_buffer_blocks() -> None:
def test_route_yardstick_blocks() -> None:
"""노선 전체 잣대(넘은 곳 · 최대 기울기 · 절성토)가 합치기 전보다 나빠지면 안 합침."""
worse = {**FLAT, "max_grade_pct": 5.0}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: worse).curves) == 2
"""노선 전체 잣대(못 지킨 곳 · 절성토 +10 %)가 합치기 전보다 나빠지면 안 합침 · 최대 기울기는
참고만(419)."""
steeper = {**FLAT, "max_grade_pct": 5.0}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: steeper).curves) == 1
over = {**FLAT, "over_limit": {"total": 1, "grade": 0}}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: over).curves) == 2
def test_widen_guard_larger_than_gap_limit() -> None:
@@ -0,0 +1,214 @@
"""419 노선 추천 엔진 — 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · 합치기 멈춤 = 노선 토공.
./venv/Scripts/python.exe -m pytest resources/tester/test_419_1_recommend_engine.py -q
· 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · `grade` 수에 참고로만).
· 꺾임만(R 없는 교점) → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤(직접 잼)도 꺾임만을 안 냄.
· 합치기 멈춤 = 노선 토공 +10 % — 바뀐 측점 횡단 차이(`cross_earthwork_delta`)를 주면 그 값으로.
· 종단 반영 켬 토공 고르기 = 줄 세운 위 3 개만 균형 종단 · 시종점 기울기 알림 · 맞추기 전 선 안 냄.
"""
import math
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_RouteInitial import ends_grade, generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
TOTAL_EARTH_TOL,
Guard,
RouteEarth,
merge_same_side,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import SHORTLIST, choose, hard_limits
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, curve_every_kink
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
off_station_count,
snap_curves_to_stations,
)
from common_util.common_util_route_polyline import build_planned_polyline
RADIUS = 12.0
SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)]
def _kinks(outline) -> int:
return sum(1 for flag in _flat(outline)[1][1:-1] if not flag)
def _flat_terrain(width=802.0, height=402.0) -> Terrain:
x = np.arange(0.0, width, 2.0)
y = np.arange(0.0, height, 2.0)
xx, _ = np.meshgrid(x, y)
return Terrain(x, y, np.zeros(xx.shape), np.ones(xx.shape, dtype=bool))
def test_hard_limits_leave_grade_as_reference() -> None:
metrics = {
"over_limit": {"grade": 3, "radius": 1, "stream": 1, "total": 5},
"violations": [{"kind": "grade"}] * 3 + [{"kind": "radius"}, {"kind": "stream"}],
}
hard = hard_limits(metrics)
assert hard["over_limit"] == {"grade": 3, "radius": 1, "stream": 1, "total": 2}
assert hard["violations"] == metrics["violations"] # 목록엔 참고로 남음
def test_kink_becomes_curve_even_gentle() -> None:
"""내각 160° 꺾임만(곡선 끔) → 곡선(최소 R) · 155° 이상도 곡선 둠."""
points = [(0.0, 0.0), (100.0, 0.0), (200.0, 100.0 * math.tan(math.radians(20.0)))]
kinked = build_planned_polyline(
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, False, True]
)
assert _kinks(kinked) == 1
fixed = curve_every_kink(kinked, RADIUS)
assert _kinks(fixed) == 0 and len(fixed.curves) == 1
assert fixed.curves[0].radius_m >= RADIUS - 1e-6 and not fixed.curves[0].violations
def test_kink_without_room_is_removed() -> None:
"""앞뒤 직선이 짧아 최소 R 이 안 드는 꺾임만 → 교점 지움(곧게)."""
points = [(0.0, 0.0), (100.0, 0.0), (103.0, 2.0), (200.0, 2.0)]
kinked = build_planned_polyline(
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, True, False, True]
)
fixed = curve_every_kink(kinked, RADIUS)
assert _kinks(fixed) == 0
assert len(_flat(fixed)[0]) == 3 # 짧은 쪽 교점 하나 빠짐
def _zigzag(seed: int) -> list[tuple[float, float]]:
rng = np.random.default_rng(seed)
points, heading = [(60.0, 200.0)], 0.0
for _ in range(10):
heading += float(rng.uniform(-0.6, 0.6))
step = float(rng.uniform(40.0, 80.0))
x, y = points[-1]
points.append((x + step * math.cos(heading), y + step * math.sin(heading)))
return points
@pytest.mark.parametrize("seed", range(6))
def test_guard_snap_no_kinks_all_on_station(seed: int) -> None:
"""직접 잼 측점 맞춤(추천 후보) — 평지 · 꺾임만 섞인 선 → 꺾임만 0 · 측점 밖 0."""
points = _zigzag(seed)
flags = [True] + [i % 3 != 1 for i in range(1, len(points) - 1)] + [True]
before = build_planned_polyline(points, min_radius_m=RADIUS, simplify=False, curve_flags=flags)
lo = np.min(points, axis=0) - 150.0
hi = np.max(points, axis=0) + 150.0
x = np.arange(lo[0], hi[0], 2.0)
y = np.arange(lo[1], hi[1], 2.0)
terrain = Terrain(x, y, np.zeros((len(y), len(x))), np.ones((len(y), len(x)), dtype=bool))
guard = Guard(SearchGrid(points, terrain, [], stream_offset_m=100.0), 0.14)
after = snap_curves_to_stations(before, RADIUS, guard=guard)
assert after is not None
assert _kinks(after) == 0
assert off_station_count(after) == 0
FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0}
def _merge(earth_base: float, delta: float):
"""평지 같은 쪽 두 꺾임 — 노선 토공 `earth_base` · 합친 선 차이 `delta`(가짜 횡단 함수)."""
outline = build_planned_polyline(
SAME, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(SAME)
)
base = {**FLAT, "cut_m3": 0.0, "fill_m3": 0.0} # 띠 가늠은 안 씀
calls = []
def cross(before, after, window):
calls.append(window)
return delta
guard = Guard(SearchGrid(SAME, _flat_terrain(402.0, 262.0), [], stream_offset_m=100.0), 0.14)
earth = RouteEarth(lambda line: earth_base, cross)
merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, earth)[0]
return merged, calls
def test_merge_stops_at_route_earth_tolerance() -> None:
"""합치기 판정 = 바뀐 측점 횡단 차이 — +10 % 안이면 합치고 넘으면 안 합침(엔진 상수)."""
assert TOTAL_EARTH_TOL == pytest.approx(0.10)
merged, calls = _merge(1000.0, 90.0)
assert len(merged.curves) == 1 and calls
s0, s1 = calls[0]
assert 0.0 <= s0 < s1 # 바뀐 자리(앞 선 누가거리)만 넘김
blocked, calls = _merge(1000.0, 110.0)
assert len(blocked.curves) == 2 and calls
def _route(name: str, earth: float, station: int = 0):
metrics = {
"name": name,
"cut_m3": earth,
"fill_m3": 0.0,
"cost_index": earth,
"over_limit": {"total": station, "station": station},
}
return (object(), metrics)
def test_choose_profiles_only_shortlist() -> None:
"""종단 반영 켬 토공 = 노선 횡단 토공(줄 세움) 위 `SHORTLIST` 개만 균형 종단 · 그 사이에서 고름 ·
줄 세움 = `earth`(켬 = 상한 누른 계획선 · 끔 = 지반고) · 없으면 띠 가늠."""
routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(12)] + [_route("off", 1.0, 1)]
last = f"r{SHORTLIST - 1}"
profiled = []
def profile(route):
profiled.append(route[1]["name"])
# 균형 종단 토공은 순서가 바뀜 — 줄 세운 마지막이 가장 작음 · 밖은 더 작아도 안 봄
earth = 300.0 if route[1]["name"] == last else 900.0
route[1]["profile"] = {"cut_m3": earth, "fill_m3": 0, "borrow_m3": 0, "spoil_m3": 0}
chosen = choose("earthwork", routes, True, profile)
assert profiled == [f"r{i}" for i in range(SHORTLIST)]
assert chosen[1]["name"] == last
# 종단 끔 = 줄 세우기 값 그대로 · 종단 안 붙임
profiled.clear()
assert choose("earthwork", routes, False, profile)[1]["name"] == "r0" and not profiled
# 횡단 줄 세움이 띠 가늠과 다르면 그것을 따름(끔 = 지반고 횡단 하나로 고름)
cross = {f"r{i}": 1000.0 - i for i in range(12)}
def earth(route, capped):
return cross.get(route[1]["name"], 0.0) + (0.0 if capped else 5.0)
assert choose("earthwork", routes, False, None, earth)[1]["name"] == "r11"
def test_ends_grade() -> None:
x = np.arange(0.0, 402.0, 2.0)
y = np.arange(0.0, 102.0, 2.0)
xx, _ = np.meshgrid(x, y)
slope = Terrain(x, y, 0.3 * xx, np.ones(xx.shape, dtype=bool))
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 200.0) == pytest.approx(30.0)
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 600.0) == pytest.approx(10.0)
@pytest.fixture(scope="module")
def hill_runs() -> dict:
x = np.arange(0.0, 802.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
xx, yy = np.meshgrid(x, y)
hill = Terrain(x, y, 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2), xx > -1)
expected = [(float(v), 200.0 + 40.0 * math.sin(v / 60.0)) for v in np.arange(50.0, 752.0, 4.0)]
return {
mode: generate_initial_route(
expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS
)
for mode in ("gradeline", "earthwork", "gentle", "weighted")
}
@pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"])
def test_hill_branches_rules(hill_runs: dict, mode: str) -> None:
"""언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음."""
result = hill_runs[mode]
interior = result["nodes"][1:-1]
assert interior and all(node["radius_m"] is not None for node in interior) # 삼각 0
assert result["off_station_curves"] == 0
over = result["metrics"]["over_limit"]
assert over["total"] == over["radius"] + over["stream"] + over.get("station", 0)
assert "unsnapped" not in result
assert result["ends_over_limit"] is False and result["ends_grade_pct"] < 14.0
+16 -8
View File
@@ -2,8 +2,9 @@
./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q
공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만
`metrics.violations` 에 측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다.
공통 제약(최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만 `metrics.violations` 에
측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다. 원지반 기울기(탐색 링크 상한은 막음)는
참고만 — 목록엔 뜨되 `total` 에 안 듦(419).
예상노선 추적(follow)은 전처리 초기값과 같은 선 — 제약 탐색 없이 넘은 곳만 목록(PLAN 23-1).
갈래는 제 목표 비교값이 가장 좋은 후보 — 실프로젝트(랩탑_보조 · 읽기만)에서 갈래가 갈림.
"""
@@ -32,8 +33,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
grade_line,
station_label,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS, SHORTLIST
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from common_util.common_util_route_polyline import build_planned_polyline
SEARCHED = [mode for mode in MODES if mode != "follow"]
@@ -68,9 +69,10 @@ def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict:
def _kept(result: dict) -> None:
"""제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음."""
"""제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음(원지반 기울기는 참고 · 수에 안 듦)."""
metrics = result["metrics"]
assert metrics["over_limit"]["total"] == len(metrics["violations"])
hard = [v for v in metrics["violations"] if v["kind"] != "grade"]
assert metrics["over_limit"]["total"] == len(hard)
if not metrics["violations"]:
assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
assert result["relaxed"] is False
@@ -109,7 +111,7 @@ def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None:
assert result["relaxed"] is False and result["terrain_bound_pct"] is None
grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"]
assert grades and all("예상노선 추적" in v["reason"] for v in grades)
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])
_kept(result)
def _earth(result: dict) -> float:
@@ -123,13 +125,19 @@ def _earth(result: dict) -> float:
def _check_branch_goals(runs: dict) -> None:
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1)."""
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1). 토공 · 공사비는 상한 누른 계획선
토공(종단 전 값) 위 `SHORTLIST` 안에서 균형 종단으로 고름(419) — 종단 전 값으로 위 3 안."""
branches = [runs[mode]["metrics"] for mode in BRANCHES]
def rank(goal, m: dict) -> tuple:
return (m["over_limit"].get("station", 0) > 0, *goal(m))
for mode, goal in GOALS.items():
if mode in ("earthwork", "cost"):
plain = sorted(rank(goal, plain_metrics(m)) for m in branches)
own = rank(goal, plain_metrics(runs[mode]["metrics"]))
assert own <= plain[min(SHORTLIST, len(plain)) - 1], mode
continue
assert all(rank(goal, runs[mode]["metrics"]) <= rank(goal, m) for m in branches), mode
for result in runs.values():
m = result["metrics"]