fix(764-1): 추천 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리 따짐
- 요구 606-2 · 분석 761-6 — 설계 지형에서 측점 맞춤이 후보 7/8 을 비탈로 옮겨 추천 셋이 같은 선 - 덜 나쁜 자리 순위 = 계류 나란히 → 기울기 늘어남 → 옮긴 거리 · 꼴(합친 꼴)을 가로질러 견줌 - 크게 망침(기울기 넘친 만큼 +5 %p 초과 · 원래 선에서 10 m 초과)은 그것 없이 다 놓는 길이 없을 때만 · 그때 가장 덜 망치는 자리(곡선 가운데 = 측점 유지) - 놓을 자리 풀이 기억(층 · 다시 셈 공통) · ALGO_VERSION 9(옛 저장본 안 꺼냄) - 지킴 시험 test_764_1_snap_grade.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,11 @@ class Guard:
|
||||
def ok(self, local: list[Xy], reference: list[Xy]) -> bool:
|
||||
return self.judge(local, reference)[0]
|
||||
|
||||
def rise(self, local: list[Xy], reference: list[Xy]) -> float:
|
||||
"""원지반 기울기가 상한을 넘은 만큼의 늘어남(옛 조각 대비) — 측점 맞춤 덜 나쁜 자리 고르기만
|
||||
씀(요구 606-2 · 그 밖 잼은 419 그대로)."""
|
||||
return self._look(local)[0] - self._look(reference)[0]
|
||||
|
||||
@staticmethod
|
||||
def piece(before: Xy, apex: Xy, after: Xy, radius: float) -> list[Xy] | None:
|
||||
"""곡선 하나가 쓰는 조각 — 앞 직선 가운데 → 원호 → 뒤 직선 가운데(직선 절반 규칙)."""
|
||||
|
||||
@@ -24,7 +24,9 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마
|
||||
(`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는
|
||||
참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419):
|
||||
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
|
||||
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에.
|
||||
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에. 덜 나쁜 놓기는 원지반
|
||||
기울기 늘어남 · 옮긴 거리를 따져 고르고 크게 망치는 놓기는 맨 뒤(다 막힐 때 그중 가장 덜
|
||||
망치는 것 · 요구 606-2).
|
||||
· 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못
|
||||
끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌.
|
||||
· 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수).
|
||||
@@ -80,6 +82,10 @@ RADIUS_STEPS = 8
|
||||
BACKTRACK_LIMIT = 30
|
||||
# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로
|
||||
PER_VARIANT = 3
|
||||
# 덜 나쁜 놓기(직접 잼 못 지남)가 「크게 망침」 — 원지반 기울기 상한 넘은 만큼이 옛 자리보다 이만큼
|
||||
# 넘게 늘거나 원래 선에서 이만큼(m) 넘게 옮김 · 다른 놓기가 다 막힐 때만 씀(요구 606-2)
|
||||
RUIN_RISE = 0.05
|
||||
RUIN_GAP_M = STATION_M / 2
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -316,6 +322,9 @@ class _Snapper:
|
||||
self.excess = excess
|
||||
self.guard = guard
|
||||
self.keep = False
|
||||
# 놓기 층 — 0 잼 지남 · 1 덜 나쁨 · 2 크게 망침(2 는 없이 다 놓는 길이 없을 때만 · 606-2)
|
||||
self.tiers: tuple[int, ...] = (0, 1) if guard is not None else (0,)
|
||||
self.memo: dict[tuple, list[tuple]] = {} # 놓을 자리(`_found`)
|
||||
self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406)
|
||||
|
||||
def _steeper(self, local: list[Xy]) -> bool:
|
||||
@@ -338,15 +347,54 @@ class _Snapper:
|
||||
end_s = state.end_s + math.dist(state.end_xy, at)
|
||||
return _State(plan, state.k + 1, at, end_s, 0.0, state.last_station, state.placed)
|
||||
|
||||
def _placements(self, state: _State, plan: _Plan, soft: bool = False) -> Iterator[_State]:
|
||||
def _placements(
|
||||
self, state: _State, plan: _Plan, tier: int = 0
|
||||
) -> Iterator[tuple[tuple, _State]]:
|
||||
"""곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터.
|
||||
|
||||
직접 잼(406)이면 잼을 지나는 자리만 · `soft` 면 못 지나는 자리(복도 안)만 — 잣대 나은 순."""
|
||||
직접 잼(406)이면 `tier` 층 자리만 — 0 잼 지남 · 1 못 지남(복도 안 · 덜 나쁨) · 2 그중 크게
|
||||
망침(`RUIN_RISE` · `RUIN_GAP_M`) — 잣대 나은 순 · 1 · 2 층은 원지반 기울기 늘어남 · 옮긴
|
||||
거리도 따짐(요구 606-2 · 419 「기울기 참고만」을 이 자리만 풂)."""
|
||||
k = state.k
|
||||
given = 0
|
||||
for _, rank, local, new_at, new_corner, new_mc, station in sorted(
|
||||
(item for item in self._found(state, plan) if item[0] == tier),
|
||||
key=lambda item: item[1],
|
||||
):
|
||||
if self.guard is None and (self._far(local) or self._steeper(local)):
|
||||
continue
|
||||
placed = plan.copy()
|
||||
placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3)
|
||||
yield (
|
||||
rank,
|
||||
_State(
|
||||
placed,
|
||||
k + 1,
|
||||
new_corner.arc[-1],
|
||||
new_mc + new_corner.length / 2,
|
||||
new_corner.tangent,
|
||||
station,
|
||||
state.placed + 1,
|
||||
),
|
||||
)
|
||||
given += 1
|
||||
if given >= PER_VARIANT:
|
||||
return
|
||||
|
||||
def _found(self, state: _State, plan: _Plan) -> list[tuple]:
|
||||
"""곡선 k 를 놓을 수 있는 자리 모두 — (층, 순위, 조각, IP, 곡선, MC, 측점) · 층 · 판(망침
|
||||
다시 셈)마다 다시 풀지 않게 기억."""
|
||||
k = state.k
|
||||
memo = (k, *state.end_xy, state.end_s, state.prev_tangent, state.last_station)
|
||||
memo += (*plan.points[k - 1 : k + 2], plan.radii[k])
|
||||
if memo in self.memo:
|
||||
return self.memo[memo]
|
||||
found: list[tuple] = []
|
||||
self.memo[memo] = found
|
||||
before, at, after = plan.points[k - 1], plan.points[k], plan.points[k + 1]
|
||||
start = _try(state, plan, k, at, plan.radii[k] or self.min_radius)
|
||||
if start is None:
|
||||
return
|
||||
return found
|
||||
corner, mc = start
|
||||
floor_r = min(self.min_radius, corner.radius)
|
||||
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, at, after)) / 2)
|
||||
@@ -360,7 +408,6 @@ class _Snapper:
|
||||
max(lowest, STATION_M * math.floor(mc / STATION_M)),
|
||||
max(lowest, STATION_M * math.ceil(mc / STATION_M)),
|
||||
}
|
||||
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)
|
||||
@@ -372,35 +419,25 @@ class _Snapper:
|
||||
middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2)
|
||||
local = [state.end_xy, *new_corner.arc, middle]
|
||||
rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius))
|
||||
tier = 0
|
||||
if self.guard is not None:
|
||||
# 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림
|
||||
passed, (out, along, earth) = self.guard.judge(
|
||||
local, self.reference.window(local)
|
||||
)
|
||||
if out or passed == soft:
|
||||
window = self.reference.window(local)
|
||||
passed, (out, along, earth) = self.guard.judge(local, window)
|
||||
if out:
|
||||
continue
|
||||
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(
|
||||
found, key=lambda item: item[0]
|
||||
):
|
||||
if self.guard is None and (self._far(local) or self._steeper(local)):
|
||||
continue
|
||||
placed = plan.copy()
|
||||
placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3)
|
||||
yield _State(
|
||||
placed,
|
||||
k + 1,
|
||||
new_corner.arc[-1],
|
||||
new_mc + new_corner.length / 2,
|
||||
new_corner.tangent,
|
||||
station,
|
||||
state.placed + 1,
|
||||
)
|
||||
given += 1
|
||||
if given >= PER_VARIANT:
|
||||
return
|
||||
if not passed:
|
||||
# 덜 나쁜 자리 — 기울기 늘어남 · 옮긴 거리를 순위 · 문턱에(요구 606-2)
|
||||
rise = self.guard.rise(local, window)
|
||||
gap = self.reference.gap(local)
|
||||
ruin = max(rise / RUIN_RISE, gap / RUIN_GAP_M)
|
||||
tier = 2 if ruin > 1 else 1
|
||||
rank = (along, round(rise, 2), tight, round(gap), earth, rank[2])
|
||||
if tier == 2: # 크게 망침 — 가장 덜 망치는 것부터
|
||||
rank = (round(ruin, 1), *rank)
|
||||
found.append((tier, rank, local, new_at, new_corner, new_mc, station))
|
||||
return found
|
||||
|
||||
def _options(self, state: _State) -> Iterator[_State]:
|
||||
"""꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔."""
|
||||
@@ -440,9 +477,14 @@ class _Snapper:
|
||||
same += 1
|
||||
if self.merged_first:
|
||||
variants = variants[:same][::-1] + variants[same:]
|
||||
for soft in (False, True) if self.guard is not None else (False,):
|
||||
for trial in variants:
|
||||
yield from self._placements(state, trial, soft)
|
||||
for tier in self.tiers:
|
||||
if not tier:
|
||||
for trial in variants:
|
||||
yield from (placed for _, placed in self._placements(state, trial))
|
||||
continue
|
||||
# 덜 나쁨 · 크게 망침 — 꼴(합친 꼴 먼저)을 가로질러 순위대로(606-2)
|
||||
found = [item for trial in variants for item in self._placements(state, trial, tier)]
|
||||
yield from (placed for _, placed in sorted(found, key=lambda item: item[0]))
|
||||
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는
|
||||
# 안 함 · 155° 이상도 곡선 · 419)
|
||||
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
|
||||
@@ -507,7 +549,7 @@ class _Snapper:
|
||||
if small == k:
|
||||
yield self._kink(state, off)
|
||||
else:
|
||||
yield from self._placements(state, off)
|
||||
yield from (placed for _, placed in self._placements(state, off))
|
||||
yield from self._kept(state, off)
|
||||
|
||||
def _kept(self, state: _State, plan: _Plan | None = None) -> Iterator[_State]:
|
||||
@@ -599,7 +641,12 @@ def snap_curves_to_stations(
|
||||
|
||||
def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None:
|
||||
"""다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
|
||||
state = snapper.run(keep=False) or snapper.run(keep=True)
|
||||
state = snapper.run(keep=False)
|
||||
if state is None and snapper.guard is not None:
|
||||
# 크게 망치는 놓기는 그것 없이 다 놓는 길(이웃 곡선 다른 배치 포함)이 없을 때만(606-2)
|
||||
snapper.tiers = (0, 1, 2)
|
||||
state = snapper.run(keep=False)
|
||||
state = state or snapper.run(keep=True)
|
||||
if state is None:
|
||||
return None
|
||||
snapped = build_planned_polyline(
|
||||
|
||||
@@ -40,8 +40,9 @@ from common_util.common_util_time import utc_now_text
|
||||
|
||||
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
|
||||
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) ·
|
||||
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420)
|
||||
ALGO_VERSION = 8
|
||||
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420) ·
|
||||
# 9 = 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리(요구 606-2)
|
||||
ALGO_VERSION = 9
|
||||
# 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드
|
||||
SOURCE_GLOBS = (
|
||||
"B05_Profile/B05_Profile_Engine_RouteInitial*.py",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""764-1 추천 측점 맞춤 — 덜 나쁜 자리 고를 때 원지반 기울기 늘어남 · 옮긴 거리를 따짐(요구 606-2).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_764_1_snap_grade.py -q
|
||||
|
||||
· 가까운 측점 자리가 비탈(기울기 늘어남)이면 다음 측점 자리로 — 옛 순위는 가까운 측점 · 토공을
|
||||
앞세워 비탈 자리를 골랐음.
|
||||
· 모든 자리가 크게 망치면 그중 가장 덜 망치는 자리(곡선 가운데 = 측점은 지킴).
|
||||
· 판(`ALGO_VERSION`)이 다른 저장본은 꺼내지 않음.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
|
||||
STATION_M,
|
||||
curve_mc_chainages,
|
||||
off_station_count,
|
||||
snap_curves_to_stations,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
|
||||
ALGO_VERSION,
|
||||
read_stored_any,
|
||||
store_dir,
|
||||
)
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
RADIUS = 12.0
|
||||
# MC ≈ 109.5 m — 가까운 측점 100 · 다음 120
|
||||
POINTS = [(0.0, 0.0), (110.0, 0.0), (230.0, 90.0)]
|
||||
|
||||
|
||||
class _Slope:
|
||||
"""직접 잼 흉내 — 늘 못 지남(덜 나쁜 자리만 남음) · 곡선 가운데가 x < 112 면 비탈 쪽.
|
||||
|
||||
토공은 비탈 쪽이 더 작게(옛 순위가 비탈을 고르도록 유혹)."""
|
||||
|
||||
def __init__(self, steep: float, flat: float) -> None:
|
||||
self.steep, self.flat = steep, flat
|
||||
|
||||
def rise(self, local, reference) -> float:
|
||||
arc = local[1:-1]
|
||||
return self.steep if sum(x for x, _ in arc) / len(arc) < 112.0 else self.flat
|
||||
|
||||
def judge(self, local, reference):
|
||||
return False, (False, 0.0, -1000.0 * self.rise(local, reference))
|
||||
|
||||
def ok(self, local, reference) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _snap(guard) -> float:
|
||||
outline = build_planned_polyline(
|
||||
POINTS,
|
||||
min_radius_m=RADIUS,
|
||||
simplify=False,
|
||||
curve_flags=[True] * 3,
|
||||
radii=[None, 40.0, None],
|
||||
)
|
||||
(mc,) = curve_mc_chainages(outline)
|
||||
assert abs(mc - 100.0) < abs(mc - 120.0) # 가까운 측점 = 비탈 쪽
|
||||
snapped = snap_curves_to_stations(outline, RADIUS, guard=guard)
|
||||
assert snapped is not None and off_station_count(snapped) == 0
|
||||
(placed,) = curve_mc_chainages(snapped)
|
||||
return round(placed / STATION_M) * STATION_M
|
||||
|
||||
|
||||
def test_less_bad_spot_avoids_slope() -> None:
|
||||
"""가까운 측점 자리가 기울기를 크게 늘리면 다음 측점 자리."""
|
||||
assert _snap(_Slope(steep=0.30, flat=0.0)) == 120.0
|
||||
|
||||
|
||||
def test_all_ruined_takes_least_ruin() -> None:
|
||||
"""모든 자리가 크게 망치면 그중 가장 덜 망치는 자리 · MC 는 측점에."""
|
||||
assert _snap(_Slope(steep=0.20, flat=0.40)) == 100.0
|
||||
assert _snap(_Slope(steep=0.40, flat=0.20)) == 120.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version, found", [(ALGO_VERSION - 1, False), (ALGO_VERSION, True)])
|
||||
def test_other_version_not_read(tmp_path, version: int, found: bool) -> None:
|
||||
folder = store_dir(tmp_path)
|
||||
folder.mkdir(parents=True)
|
||||
data = {"version": version, "source": "s", "pool": "p", "request": {}, "result": {}}
|
||||
(folder / "k.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
got = read_stored_any(tmp_path, "p", lambda source: "k")
|
||||
assert (got is not None) is found
|
||||
|
||||
|
||||
def test_ruin_threshold_constants() -> None:
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import RUIN_GAP_M, RUIN_RISE
|
||||
|
||||
assert RUIN_RISE == pytest.approx(0.05) and math.isclose(RUIN_GAP_M, STATION_M / 2)
|
||||
Reference in New Issue
Block a user