fix(B05): 노선 추천 균형 종단을 본 설계 횡단과 맞춤 — 곡선표 확폭 · 비탈 안 닫힘 넓힘 · 암반 경계 · 누가토량 ±500㎥(78)

- 확폭은 노선 곡선표로(후보 · 편집 띠 · [확인]) · 없으면 종전 실측 반경
- 반폭에서 비탈이 안 닫히면 지표면 끝까지 넓힌 샘플로 다시 셈(본 설계 extend_unclosed 와 같은 뜻)
- 암반 경계 기본 오프셋(본 설계 기본 설계와 같음)
- 균형 목표 = 누가토량 끝값(절토 다짐 − 성토) ±500㎥ · 고른 선은 보간 말고 바로 셈해 λ 를 더 좁힘
- 후보 줄 세우기는 보간값 그대로(빠름) · 화면에 낼 선만 바로 셈
- 추천 창 한 줄: 불균형 % → 누가토량 ±㎥

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMfGERKUg5nMMNQsyVZ2j
This commit is contained in:
2026-10-07 23:08:43 +09:00
co-authored by Claude Opus 5.5
parent 1c30986a73
commit 0deeb52e9a
9 changed files with 291 additions and 78 deletions
@@ -490,7 +490,8 @@ const withProfile = model.normalizeRouteInitial(
borrow_m3: 0,
spoil_m3: 39.9,
imbalance_pct: -28.6,
tolerance_pct: 10,
mass_end_m3: -39.9,
mass_tolerance_m3: 500,
max_grade_pct: 7.5,
grade_limit_pct: 14,
curve_over: 1,
+7 -2
View File
@@ -3,7 +3,7 @@
./venv/Scripts/python.exe -m pytest resources/tester/test_63_4_route_profile.py -q
합성 지형(노선 따라 ±20 % 물결 · 옆경사 50 %)에서 균형 계획선이 종단기울기 상한을 지키고,
절(다짐 환산) · 성토가 허용(10 %) 안에서 맞고, 시 · 종점은 지반고에 붙는다. 토공 최소 갈래는
누가토량 끝값(절 다짐 − 성)이 ±500㎥ 안에서 맞고, 시 · 종점은 지반고에 붙는다. 토공 최소 갈래는
균형 종단 토공(절 + 성 + 반입 + 사토)으로 고른다. 균형 종단은 화면 캐시에 안 적힌다.
"""
@@ -56,7 +56,11 @@ def test_상한을_지키고_지반_기울기는_넘는다(profile: dict) -> Non
def test_절성토_균형은_허용_안(profile: dict) -> None:
assert profile["balanced"] is True
assert abs(profile["imbalance_pct"]) <= profile["tolerance_pct"]
# 누가토량 끝값(절토 다짐 − 성토)이 ±500㎥ 안(PLAN 78)
assert profile["mass_tolerance_m3"] == 500.0
assert abs(profile["mass_end_m3"]) <= profile["mass_tolerance_m3"]
net = profile["cut_compacted_m3"] - profile["fill_m3"]
assert profile["mass_end_m3"] == pytest.approx(net, abs=0.2)
# 반입 − 사토 = 성토 − 절토(다짐 환산)
net = profile["fill_m3"] - profile["cut_compacted_m3"]
assert abs((profile["borrow_m3"] - profile["spoil_m3"]) - net) <= 0.2
@@ -114,5 +118,6 @@ def test_균형_종단은_캐시에_안_남는다() -> None:
assert result["cached"]["metrics"]["cutM3"] is not None # 비교값은 그대로 캐시
assert result["missing"] is None and result["broken"] is None
assert "반입 0㎥" in result["line"] and "덮개 모자란 관 1/2" in result["line"]
assert "누가토량 -40㎥(허용 ±500㎥)" in result["line"]
assert result["svg"].count("<path") == 2
assert '<th scope="row">토공 최소</th>' in result["table"]
+29 -2
View File
@@ -24,8 +24,8 @@ def _strip_points(monkeypatch, tmp_path: Path, criteria=None) -> tuple[list, obj
async def plan_criteria(_pid):
return RADIUS_M, 0.0, 0.0
async def profile_of(_pid, _root, points, crit):
seen["points"], seen["criteria"] = points, crit
async def profile_of(_pid, _root, points, crit, curves=None):
seen["points"], seen["criteria"], seen["curves"] = points, crit, curves
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
monkeypatch.setattr(replan, "_project_paths", paths)
@@ -60,6 +60,33 @@ def test_띠_노선점은_확인이_읽는_CSV와_같다(monkeypatch, tmp_path:
assert len(points) > len(NODES) # 원호가 펴져 있다
def test_띠는_그_노선의_곡선표로_확폭을_맨다(monkeypatch, tmp_path: Path) -> None:
seen: dict = {}
async def paths(_pid):
return tmp_path, "x"
async def plan_criteria(_pid):
return RADIUS_M, 0.0, 0.0
async def profile_of(_pid, _root, _points, _crit, curves=None):
seen["curves"] = curves
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_plan_criteria", plan_criteria)
monkeypatch.setattr(initial, "balanced_profile_of", profile_of)
request = initial.ProfilePreviewRequest(
vertices=[
replan.RouteVertexInput(x=x, y=y, curve=i != 2, radius_m=40.0 if i == 1 else None)
for i, (x, y) in enumerate(NODES)
]
)
asyncio.run(initial.profile_preview(uuid4(), request))
assert seen["curves"] and 40.0 in [c["radius_m"] for c in seen["curves"]]
assert all({"start", "apex", "end", "radius_m"} <= set(c) for c in seen["curves"])
def test_띠는_지금_추천_제약을_싣는다(monkeypatch, tmp_path: Path) -> None:
criteria = replan.RouteInitialCriteria(max_grade_pct=12.0)
_, got = _strip_points(monkeypatch, tmp_path, criteria)
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""노선 추천 균형 종단 — 본 설계와 같은 횡단 · 누가토량 ±500㎥(PLAN 78).
랩탑 메인 노선 271: 추천은 곡선표 확폭 · 비탈 안 닫힘 넓힘을 빼 성토를 4,200㎥ 적게 봄.
"""
import numpy as np
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
CROSS_HALF_M,
_station_areas,
_widenings,
route_profile,
route_sections,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain
from config.config_system_design import curve_widening_m
def _steep() -> Terrain:
# 노선(y = 200) 왼쪽(+y)으로 70 % 내리막 — 성토 비탈(1:1.5)이 ±20 m 안에서 안 닫힘
x = np.arange(0.0, 202.0, 2.0)
y = np.arange(120.0, 282.0, 2.0)
xx, yy = np.meshgrid(x, y)
z = 100.0 - 0.7 * (yy - 200.0) + 0.02 * xx
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
def test_곡선표가_있으면_그_반경으로_확폭():
xy = np.array([(0.0, 0.0), (40.0, 0.0), (80.0, 0.0)])
chain = np.array([0.0, 40.0, 80.0])
at = np.array([0.0, 20.0, 40.0, 60.0, 80.0])
table = [{"start": [30.0, 0.0], "apex": [40.0, 1.0], "end": [50.0, 0.0], "radius_m": 12.0}]
rows = _widenings(xy, chain, at, table)
assert rows[2]["curve_widening_m"] == curve_widening_m(12.0)
assert rows[2]["plan_radius_m"] == 12.0
# 곡선표 없으면 직선(반경 실측 없음) — 확폭 0
assert all(row["curve_widening_m"] == 0 for row in _widenings(xy, chain, at))
def test_비탈_안_닫히면_넓힌_샘플로_셈():
at, ground, sections, modes, curves = route_sections([(20.0, 200.0), (180.0, 200.0)], _steep())
i = len(at) // 2
samples = sections[i]
assert max(s["offset_m"] for s in samples) > CROSS_HALF_M # 반폭 밖까지 떠 둠
narrow = [s for s in samples if abs(s["offset_m"]) <= CROSS_HALF_M]
high = float(ground[i] + 3.0) # 쌓기 — 내리막 쪽 성토 비탈이 길어짐
_, _, wide_fill = _station_areas(samples, modes[i], high, curves[i])
_, _, narrow_fill = _station_areas(narrow, modes[i], high, curves[i])
assert wide_fill > narrow_fill + 1.0
def test_누가토량_끝값은_허용_안():
profile = route_profile(
[(20.0, 200.0), (180.0, 200.0)], _steep(), max_grade=0.14, design_speed=20
)
assert profile["exact"] is True
assert abs(profile["mass_end_m3"]) <= profile["mass_tolerance_m3"] == 500.0
assert profile["balanced"] is True