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

This commit is contained in:
2026-10-18 13:27:40 +09:00
6 changed files with 127 additions and 13 deletions
@@ -471,9 +471,11 @@ def generate_initial_route(
snapped = (outline, metrics)
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
line, values = route
# 정밀 종단은 낼 사본에만 — 묶음 캐시 후보는 빠른 판 그대로 둬 다음 고르기가 같은 잣대끼리
# 견줌(앞서 보인 후보만 정밀이면 캐시 순서 따라 고르는 선이 바뀜 · 419-2)
line, values = route[0], dict(route[1])
if with_profile:
profiled(route)
profiled((line, values))
if pipes and "error" not in values["profile"]:
values["profile"].update(cover_shortfall(values["profile"], line.vertices, pipes))
else:
@@ -63,6 +63,8 @@ LAMBDA_STEPS = 14
EXACT_STEPS = 6 # 고른 계획선의 실제 누가토량으로 λ 를 더 좁히는 횟수
PIPE_SNAP_M = 5.0 # 관 자리가 노선에서 이 안이면 그 노선의 관으로 봄
GRADE_EPS = 1e-6
# [적용] 종단 꼭짓점 표고 반올림(0.1 mm) 두 끝 몫 — 직선 기울기 누를 때 남기는 여유(m · 419-3)
ROUND_SLACK_M = 1e-4
def _sample(
@@ -249,6 +251,52 @@ def _reference(ground: np.ndarray, spans: np.ndarray, max_grade: float) -> np.nd
return 0.5 * (forward + backward)
def _hold(s: np.ndarray, z: np.ndarray, limit: float) -> np.ndarray:
"""변화점 사이 직선 기울기를 `limit` 안으로 — 시종점은 그대로 두고 앞으로 · 뒤로 누름(시종점
사이가 상한 안이면 몇 번 안에 둘 다 맞음). 마디마다 `ROUND_SLACK_M` 남김 — 종단 정본이 표고를
0.1 mm 로 반올림해도 상한 안(검증_sub4 14.001 %)."""
z = z.copy()
start, end = z[0], z[-1]
rise = np.maximum(limit * np.diff(s) - ROUND_SLACK_M, 0.0)
for _ in range(4):
z[0] = start
for i in range(1, len(z)):
z[i] = min(max(z[i], z[i - 1] - rise[i - 1]), z[i - 1] + rise[i - 1])
z[-1] = end
for i in range(len(z) - 2, -1, -1):
z[i] = min(max(z[i], z[i + 1] - rise[i]), z[i + 1] + rise[i])
if abs(z[0] - start) < 1e-9:
break
return z
def held_pass_through(
base_s: np.ndarray, stations: list[float], plan: list[float], policy: Any
) -> np.ndarray:
"""균형 종단(측점 · 계획고)을 1차 변화점에 옮긴 꼭짓점 표고 — 종단곡선을 넣어도 측점마다
계획고를 지나게 꼭짓점을 미는 `pass_through` 와 같은 되풀이에 직선 기울기 상한을 함께 둠(419-3).
밀기만 하면 꼭짓점 사이 직선이 균형 종단 기울기보다 가팔라짐(검증_sub4 14 % → 14.47 %).
상한 = 균형 종단 측점 사이 가장 센 기울기(이미 상한 안 · 화면 겹침 상한도 그대로)."""
from B05_Profile.B05_Profile_Engine_Grade_Alignment import build_curves, evaluate
stations_a, plan_a = np.asarray(stations, float), np.asarray(plan, float)
limit = float(np.abs(np.diff(plan_a) / np.diff(stations_a)).max())
# 계획고 1 mm 반올림(`plan_m`)으로 상한을 살짝 넘은 몫은 상한으로
legal = policy.max_grade_pct / 100.0
limit = legal if legal <= limit <= legal + 1e-4 else limit + GRADE_EPS
target = np.interp(base_s, stations_a, plan_a)
base_z = _hold(base_s, target, limit)
for _ in range(12):
curves, _warnings = build_curves(base_s, base_z, policy)
error = target - evaluate(base_s, base_z, curves, base_s)
error[0] = error[-1] = 0.0
if float(np.max(np.abs(error))) < 1e-4:
break
base_z = _hold(base_s, base_z + error, limit)
return base_z
def _dp(
levels: np.ndarray, cost: np.ndarray, spans: np.ndarray, max_grade: float, delta_max: float
) -> np.ndarray | None:
@@ -360,16 +360,17 @@ def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
import numpy as np
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
from B05_Profile.B05_Profile_Engine_Grade_Profile import pass_through
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
from common_util.common_util_json import atomic_write_json
longitudinal = json.loads(path.read_text(encoding="utf-8"))
alignment = longitudinal["profile_alignment"]
base = alignment["base_pvi"]
base_s = np.array([float(item["chainage_m"]) for item in base])
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게.
base_z = pass_through(
base_s, np.interp(base_s, stations, plan), AlignmentPolicy.from_dict(alignment["policy"])
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게 · 꼭짓점 사이
# 직선은 균형 종단 상한 안(419-3)
base_z = held_pass_through(
base_s, stations, plan, AlignmentPolicy.from_dict(alignment["policy"])
)
for item, value in zip(base, base_z, strict=True):
item["elevation_m"] = round(float(value), 4)
@@ -150,8 +150,8 @@ def _route(name: str, earth: float, station: int = 0):
def test_choose_profiles_only_shortlist() -> None:
"""종단 반영 켬 토공 = 노선 횡단 토공(줄 세움) 위 `SHORTLIST` 개만 균형 종단 · 그 사이에서 고름 ·
줄 세움 = `earth`(켬 = 상한 누른 계획선 · 끔 = 지반고) · 없으면 띠 가늠."""
"""종단 반영 켬 토공 = 노선 횡단 토공(줄 세움) 위 `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 = []
@@ -212,3 +212,67 @@ def test_hill_branches_rules(hill_runs: dict, mode: str) -> None:
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
def _hill_expected():
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 hill, expected
def test_419_2_pick_independent_of_cache_order() -> None:
"""같은 입력 — 앞서 다른 갈래를 보였든(캐시 후보에 정밀 종단) 처음이든 같은 선(419-2)."""
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
hill, expected = _hill_expected()
def run(mode):
return generate_initial_route(
expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS
)
_POOLS.clear()
fresh = {mode: run(mode)["planned"] for mode in ("earthwork", "cost")}
_POOLS.clear()
for mode in ("gentle", "gradeline", "weighted", "cost", "earthwork", "gentle"):
run(mode)
again = {mode: run(mode)["planned"] for mode in ("earthwork", "cost")}
# 캐시 후보에 정밀 종단이 안 남음 — 고르기는 늘 빠른 판끼리(정밀은 낼 사본에만)
pool = next(iter(_POOLS.values()))
routes = [*pool.candidates, *pool.searched.values()]
assert not any((m.get("profile") or {}).get("exact") for _, m in routes)
_POOLS.clear()
assert again == fresh
def test_419_3_applied_profile_holds_grade() -> None:
"""[적용] 종단 — 종단곡선을 넣으려 꼭짓점을 밀어도 직선 · 곡선 어디서나 균형 종단 상한 안."""
from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
AlignmentPolicy,
build_curves,
evaluate,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import pass_through
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
policy = AlignmentPolicy.from_config(
station_interval_m=20.0, max_grade_pct=14.0, curve_skip_delta_pct=5.0, paved=False
)
s = np.arange(0.0, 401.0, 20.0)
grades = [0.14, 0.14, -0.02, 0.14, 0.14, 0.03, -0.14, -0.14, 0.0, 0.14]
grades += [0.10, 0.14, -0.05, -0.14, -0.14, 0.02, 0.14, 0.14, 0.0, -0.14]
plan = np.concatenate([[100.0], 100.0 + np.cumsum(np.asarray(grades) * 20.0)])
fine = np.linspace(0.0, 400.0, 4001)
def steepest(z):
curves, _ = build_curves(s, z, policy)
return float(np.abs(np.diff(evaluate(s, z, curves, fine)) / 0.1).max())
assert steepest(pass_through(s, plan, policy)) > 0.15 # 옛 맞춤 = 상한 넘음(재현)
held = held_pass_through(s, list(s), list(plan), policy)
assert steepest(held) <= 0.14 + 1e-5
assert steepest(np.round(held, 4)) <= 0.14 # 정본 0.1 mm 반올림 뒤도 상한 안
assert held[0] == pytest.approx(plan[0]) and held[-1] == pytest.approx(plan[-1])
@@ -6,6 +6,8 @@
import json
from pathlib import Path
import pytest
from B05_Profile.B05_Profile_Router_Replan import RouteReplanRequest
from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS
@@ -47,7 +49,8 @@ def test_변화점_계획고를_균형_종단으로(tmp_path: Path) -> None:
p["elevation_m"]
for p in json.loads(path.read_text("utf-8"))["profile_alignment"]["base_pvi"]
]
assert got == [10.0, 12.0, 13.0, 14.0] # 구조물 측점(30 m)은 직선 보간
# 구조물 측점(30 m)은 직선 보간 · 상한 누를 때 반올림 여유 0.1 mm 몫(419-3)
assert got == pytest.approx([10.0, 12.0, 13.0, 14.0], abs=5e-4)
def test_확인_요청_기본은_끔() -> None:
-4
View File
@@ -209,10 +209,6 @@ def _check_weight_monotone(run) -> None:
assert tops[0] <= min(tops) + 1e-6, tops # 끝 0/0/100 = 기울기 순한
@pytest.mark.xfail(
reason="엔진 pick 이 정밀 · 빠른 판 종단을 섞어 견줌 → 캐시 순서 의존 · PLAN 419 에서 풂",
strict=False,
)
def test_weight_ends_match_branches(hill: Terrain) -> None:
_check_weight_ends(lambda mode, weights=None: _run(hill, mode, weights=weights))