From bb0046b52410901e95a58e689a1ae754551c26d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 13:18:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(B05=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C):=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EB=AC=B4=EA=B4=80=20=C2=B7=20[?= =?UTF-8?q?=EC=A0=81=EC=9A=A9]=20=EC=A2=85=EB=8B=A8=20=EC=A7=81=EC=84=A0?= =?UTF-8?q?=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EA=B7=A0=ED=98=95=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=EC=83=81=ED=95=9C=20=EC=95=88=20(419-2=20=C2=B7=20?= =?UTF-8?q?419-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정밀 종단은 낼 사본에만 — 묶음 캐시 후보는 빠른 판끼리 견줌 · test_weight_ends_match_branches xfail 풂 - [적용] 종단 꼭짓점 맞춤(held_pass_through) = 종단곡선 넣으려 꼭짓점 밀 때 직선 기울기를 균형 종단 상한 안으로 누름(14.47 % → 14 %) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_Engine_RouteInitial.py | 6 +- ...B05_Profile_Engine_RouteInitial_Profile.py | 42 ++++++++++++ .../B05_Profile_Router_RouteInitial.py | 9 +-- .../tester/test_419_1_recommend_engine.py | 67 ++++++++++++++++++- resources/tester/test_route_initial.py | 4 -- 5 files changed, 116 insertions(+), 12 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 538fbd3f1..f2ae4b30d 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -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: diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index b1f639bd5..aa0d43615 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -249,6 +249,48 @@ 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` 안으로 — 시종점은 그대로 두고 앞으로 · 뒤로 누름(시종점 + 사이가 상한 안이면 몇 번 안에 둘 다 맞음).""" + z = z.copy() + start, end = z[0], z[-1] + rise = limit * np.diff(s) + 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()) + 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: diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 9676bfeec..c1cd2f766 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -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) diff --git a/resources/tester/test_419_1_recommend_engine.py b/resources/tester/test_419_1_recommend_engine.py index 613b22309..dbaa0e4a4 100644 --- a/resources/tester/test_419_1_recommend_engine.py +++ b/resources/tester/test_419_1_recommend_engine.py @@ -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,66 @@ 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 held[0] == pytest.approx(plan[0]) and held[-1] == pytest.approx(plan[-1]) diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index b213c01b4..316e6c80a 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -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)) From 3082007dba365255e437a9dab13d28866d82e889 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 13:26:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(B05=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20[=EC=A0=81=EC=9A=A9]=20=EC=A2=85=EB=8B=A8):=20?= =?UTF-8?q?=EC=A7=81=EC=84=A0=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EB=88=84?= =?UTF-8?q?=EB=A5=BC=20=EB=95=8C=20=ED=91=9C=EA=B3=A0=20=EB=B0=98=EC=98=AC?= =?UTF-8?q?=EB=A6=BC=20=EC=97=AC=EC=9C=A0=200.1=20mm=20=C2=B7=20=EC=83=81?= =?UTF-8?q?=ED=95=9C=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=B7=A0=ED=98=95=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=EC=9D=80=20=EC=83=81=ED=95=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20(419-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검증_sub4 [적용] 뒤 종단 최대 14.47 % → 14.001 %(정본 0.1 mm 반올림 몫) → 14.0 %(넘음 표시 없음) - test_74 변화점 계획고 비교를 0.5 mm 허용차로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_Engine_RouteInitial_Profile.py | 12 +++++++++--- resources/tester/test_419_1_recommend_engine.py | 1 + resources/tester/test_74_route_confirm_initial.py | 5 ++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index aa0d43615..8b58087b3 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -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( @@ -251,10 +253,11 @@ def _reference(ground: np.ndarray, spans: np.ndarray, max_grade: float) -> np.nd 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 = limit * np.diff(s) + 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)): @@ -278,7 +281,10 @@ def held_pass_through( 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()) + GRADE_EPS + 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): diff --git a/resources/tester/test_419_1_recommend_engine.py b/resources/tester/test_419_1_recommend_engine.py index dbaa0e4a4..89ca5bceb 100644 --- a/resources/tester/test_419_1_recommend_engine.py +++ b/resources/tester/test_419_1_recommend_engine.py @@ -274,4 +274,5 @@ def test_419_3_applied_profile_holds_grade() -> None: 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]) diff --git a/resources/tester/test_74_route_confirm_initial.py b/resources/tester/test_74_route_confirm_initial.py index 1c79e715b..089e1dbfa 100644 --- a/resources/tester/test_74_route_confirm_initial.py +++ b/resources/tester/test_74_route_confirm_initial.py @@ -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: