From 83968bf6ed3f9c5da00db2f9813c8c16b79f1703 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:37:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(455=20433-13=E2=91=A1=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?=20=EC=B2=AB=20=EC=A2=85=EB=8B=A8):=20=EA=B4=80=20=EC=A0=95?= =?UTF-8?q?=EC=B0=A9=20=C2=B7=20=EC=A7=81=EC=84=A0=20=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=EC=84=A0=ED=98=95=EB=8F=84=20=EC=B4=88=EA=B8=B0=EA=B0=92=20?= =?UTF-8?q?=EC=A7=81=EC=84=A0=ED=99=94=20=E2=80=94=20R=20500=20=EB=84=98?= =?UTF-8?q?=EB=8A=94=20=EA=BC=AD=EC=A7=93=EC=A0=90=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=EC=A7=81=EC=84=A0=20=EC=9C=84=20=C2=B7=20=EC=97=AC=EC=9C=A0?= =?UTF-8?q?=EB=A1=9C=20=EB=93=A4=EB=A6=B0=20=EA=B4=80=20=EA=BC=AD=EC=A7=93?= =?UTF-8?q?=EC=A0=90=EC=9D=80=20=EA=B2=BD=EA=B3=84=EB=A1=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Engine_Grade_Profile.py | 50 +++++-- .../tester/test_455_fallback_straight.py | 122 ++++++++++++++++++ 2 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_455_fallback_straight.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 3ce1036ec..11aeab2cc 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -367,11 +367,13 @@ def design_pipe_anchored_profile( # 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다. # 시·종점만 오프셋을 얹는다. target = np.interp(base_s, chainage, ground) + lifted = np.zeros(len(base_s), dtype=bool) if pipe_clearances: for index, value in enumerate(base_s): clearance = _clearance_at(pipe_clearances, float(value)) if clearance > 0: target[index] += clearance + lifted[index] = True target[0] = fixed[0] target[-1] = fixed[1] @@ -380,12 +382,15 @@ def design_pipe_anchored_profile( edits = dict(edits or {}) user_radii = dict(edits.get("curve_radii") or {}) - def radii_for(nodes_z: np.ndarray) -> dict[str, float]: - """현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다).""" + def radii_for(nodes_z: np.ndarray, held: np.ndarray) -> dict[str, float]: + """현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다) · + 직선 위로 옮긴(`held`) 꼭짓점은 R 없음.""" spans = base_s[1:] - base_s[:-1] grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0) resolved: dict[str, float] = {} for index in range(1, len(base_s) - 1): + if held[index]: + continue delta = abs(float(grades[index] - grades[index - 1])) radius = target_length / delta if delta > 1e-9 else min_radius resolved[chainage_key(base_s[index])] = max(radius, min_radius) @@ -394,19 +399,28 @@ def design_pipe_anchored_profile( # 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록 # 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다). # 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다. - base_z = target.copy() - radii = radii_for(base_z) - for _ in range(12): - radii = radii_for(base_z) - curves, _curve_warnings = build_curves(base_s, base_z, policy, radii) - plan_at = evaluate(base_s, base_z, curves, base_s) - error = target - plan_at - error[0] = 0.0 - error[-1] = 0.0 - if float(np.max(np.abs(error))) < 1e-4: + # 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 관 꼭짓점 묶음은 앞뒤 꺾임을 잇는 + # 직선 위(높이 한도 없음 · 요구 269-2) · 경계가 새로 R > 500 m 가 되면 묶음을 늘려 다시 · + # 시설 여유로 들린 관 꼭짓점은 그대로(직선화 경계). + held = np.zeros(len(base_s), dtype=bool) + for _ in range(len(base_s)): + base_z = straighten_held(base_s, target, held) + for _ in range(12): + radii = radii_for(base_z, held) + curves, _curve_warnings = build_curves(base_s, base_z, policy, radii) + plan_at = evaluate(base_s, base_z, curves, base_s) + error = target - plan_at + error[0] = 0.0 + error[-1] = 0.0 + error[held] = 0.0 + if float(np.max(np.abs(error))) < 1e-4: + break + base_z = straighten_held(base_s, base_z + error, held) + flat = flat_bends(base_s, base_z, policy.default_curve_length_m) & ~held & ~lifted + if not flat.any(): break - base_z = base_z + error - edits["curve_radii"] = radii + held = held | flat + edits["curve_radii"] = radii_for(base_z, held) alignment = build_alignment( base_s=base_s, @@ -498,6 +512,14 @@ def design_alignment_profile( down_limit, balance_tolerance_percent=policy.balance_tolerance_percent, ) + # 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위 + held = np.zeros(len(base_s), dtype=bool) + flat = flat_bends(base_s, base_z, policy.default_curve_length_m) + while flat.any(): + held = held | flat + straight = straighten_held(base_s, base_z, held) + flat = flat_bends(base_s, straight, policy.default_curve_length_m) & ~held + base_z = straighten_held(base_s, base_z, held) alignment = build_alignment( base_s=base_s, base_z=base_z, diff --git a/resources/tester/test_455_fallback_straight.py b/resources/tester/test_455_fallback_straight.py new file mode 100644 index 000000000..8b2306feb --- /dev/null +++ b/resources/tester/test_455_fallback_straight.py @@ -0,0 +1,122 @@ +"""455 (433-13 ②) 폴백 첫 종단 직선화 — 관 정착 · 직선 분할도 R > 500 m 꼭짓점 없음. + +까닭: 초기값 직선화(438)가 1순위 지반 추종 선형에만 걸려, 1순위가 실패해 2 · 3순위(관 정착 · +직선 분할)로 내려가면 R > 500 m 꼭짓점이 남음. 사용자 확정(269-2) — 직선화 높이 한도 없음. +""" + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_curves, + is_straight_bend, +) +from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_alignment_profile, + design_pipe_anchored_profile, +) + +PIPES = [60.0, 125.0, 190.0, 260.0, 330.0] + + +def _ground(s: float) -> float: + return 100.0 + 0.06 * s + 2.5 * math.sin(s / 45.0) + 0.05 * math.sin(s / 6.0) + + +def _longitudinal() -> dict: + samples = [ + {"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True} + for s in range(0, 401) + ] + stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)] + return {"samples": samples, "stations": stations} + + +OPTIONS = GradeDesignOptions( + max_grade_pct=14.0, + max_reverse_grade_pct=5.0, + min_vertical_radius_m=100.0, + min_curve_length_m=20.0, + min_tangent_length_m=20.0, + vertical_curve_skip_delta_pct=0.5, + design_speed_kph=20, +) + + +def _flat(alignment: dict) -> tuple[list[float], int]: + """(R > 500 m 꺾임 측점 · 직선 위로 옮긴 꼭짓점 수) — 438 시험과 같은 판정.""" + s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64) + z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64) + policy = AlignmentPolicy.from_dict(alignment["policy"]) + grades = np.diff(z) / np.diff(s) + straight = np.zeros(len(s), dtype=bool) + for i in range(1, len(s) - 1): + straight[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i]) + flat = flat_bends(s, z, policy.default_curve_length_m) & ~straight + assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"]) + return s[flat].tolist(), int(straight.sum()) + + +def test_관_정착_선형_R500_넘는_꼭짓점_없음() -> None: + alignment, _profile = design_pipe_anchored_profile(_longitudinal(), OPTIONS, PIPES) + flat, straight = _flat(alignment) + assert flat == [] and 0 < straight < len(PIPES) + # 직선 위로 옮긴 관 꼭짓점은 자동 R 을 안 남김 · 남은 꺾임은 곡선이 관 자리를 지남 + radii = alignment["edits"]["curve_radii"] + kinks = {n["chainage_m"] for n in alignment["base_pvi"][1:-1]} + assert set(map(float, radii)) <= kinks + s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64) + z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64) + curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(alignment["policy"]), radii) + assert all(float(c["chainage_m"]) in kinks for c in curves) + + +def test_직선_분할_선형_R500_넘는_꼭짓점_없음(monkeypatch) -> None: + alignment, _profile = design_alignment_profile(_longitudinal(), OPTIONS) + assert _flat(alignment)[0] == [] + # 풀이가 R > 500 m 꺾임(100 m · 대수차 0.5 %)을 낸 꼴 — 직선 위로 · 200 m 꺾임은 그대로 + import B05_Profile.B05_Profile_Engine_Grade_Profile as engine + + s = np.array([0.0, 100.0, 200.0, 400.0]) + z = np.array([100.0, 103.0, 106.5, 124.5]) + monkeypatch.setattr(engine, "station_breakpoints", lambda *_a, **_k: s) + monkeypatch.setattr(engine, "solve_alignment_elevations", lambda *_a, **_k: (z.copy(), True)) + alignment, _profile = engine.design_alignment_profile(_longitudinal(), OPTIONS) + flat, straight = _flat(alignment) + assert flat == [] and straight == 1 + nodes = {n["chainage_m"]: n["elevation_m"] for n in alignment["base_pvi"]} + assert nodes[100.0] == pytest.approx(103.25) + assert nodes[200.0] == pytest.approx(106.5) + + +def test_부르는_곳() -> None: + src = (ROOT / "B05_Profile" / "B05_Profile_Engine_Grade_Profile.py").read_text(encoding="utf-8") + assert src.count("flat_bends(base_s, ") >= 3 # 지반 추종(straight_pass_through 안) 밖 둘 + + +def test_여유로_들린_관_꼭짓점은_그대로() -> None: + """관 여유 강제(+1.5 m)로 들린 관 꼭짓점 = 직선화 경계 · 여유 없는 관 꼭짓점만 직선 위.""" + clearances = {125.0: 1.5} + alignment, _profile = design_pipe_anchored_profile( + _longitudinal(), OPTIONS, PIPES, pipe_clearances=clearances + ) + flat, straight = _flat(alignment) + assert straight > 0 + assert flat in ([], [125.0]) # 들린 꼭짓점만 R > 500 이 남을 수 있음 + rows = {r["chainage_m"]: r["plan_elevation_m"] for r in alignment["stations"]} + plan = np.interp(125.0, list(rows), list(rows.values())) + assert plan > _ground(125.0) + 1.0