From 314ce5351bff39e52e94fa2a3c44de1a51f07240 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 17:59:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EC=A2=85=EB=8B=A8):=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=A2=85=EB=8B=A8=20R=20500=20=EB=84=98=EB=8A=94?= =?UTF-8?q?=20=EA=BC=AD=EC=A7=93=EC=A0=90=20=EB=AC=B6=EC=9D=8C=20=EC=A7=81?= =?UTF-8?q?=EC=84=A0=ED=99=94=20=C2=B7=20432-1=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C=20=EA=B1=B7=EC=9D=8C=20(438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 계획선 · 균형 종단 맞춤 뒤 기본 L 20 기준 R > 500 m 꼭짓점 묶음을 앞뒤 꺾임(시종점)을 잇는 직선 위로 · 경계가 새로 R > 500 이면 묶음 늘려 다시(`straight_pass_through` ↔ TS `passThrough`) - 정책 `no_curve_keys` · 열쇠 이어받기 걷음 — 옛 저장분 열쇠는 무시 · 꺾인 꼭짓점마다 곡선(모서리 없음) - 균형 종단 꼭짓점 높이 소수 6자리 — 4자리면 직선 판정(426)이 꺾임으로 읽음 - 관 옮김 새 꼭짓점: 옮기기 전 계획선이 지반을 안 지나면(균형 종단) 그 계획선 높이(427 · 파이썬 ↔ TS) - 시험 test_438_1 + 헬퍼 · test_432_1 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- .../B05_Profile_Engine_Grade_Alignment.py | 14 +- B05_Profile/B05_Profile_Engine_Grade_Carry.py | 40 +-- .../B05_Profile_Engine_Grade_CurveLaw.py | 46 ++-- .../B05_Profile_Engine_Grade_Profile.py | 55 +++- ...B05_Profile_Engine_RouteInitial_Profile.py | 15 +- B05_Profile/B05_Profile_Router_Confirm.py | 23 +- .../B05_Profile_Router_RouteInitial.py | 11 +- B05_Profile/logic/B05_Profile_Grade_Carry.ts | 24 -- B05_Profile/logic/B05_Profile_Grade_Ground.ts | 57 ++-- B05_Profile/logic/B05_Profile_Grade_Seat.ts | 25 +- .../logic/B05_Profile_Pipeline_Grade.ts | 38 +-- .../logic/B05_Profile_Profile_CurveLaw.ts | 71 ++--- .../logic/B05_Profile_UI_Profile_Alignment.ts | 6 +- config/config_system_design.py | 5 +- resources/tester/helper_432_1_curve_law.cjs | 17 +- resources/tester/helper_438_1_straighten.cjs | 30 +++ resources/tester/test_432_1_curve_law.py | 111 ++------ .../tester/test_438_1_initial_straighten.py | 244 ++++++++++++++++++ 18 files changed, 516 insertions(+), 316 deletions(-) create mode 100644 resources/tester/helper_438_1_straighten.cjs create mode 100644 resources/tester/test_438_1_initial_straighten.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 63b83395d..863e075b7 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -73,9 +73,6 @@ class AlignmentPolicy: #: 설계속도별 종단곡선 최소 R · L(m) — 미달 경고만(432 · 별표2 마.). 0이면 판정 안 함. min_vertical_radius_m: float = 0.0 min_curve_length_m: float = 0.0 - #: 초기값에서 곡선을 두지 않은 꼭짓점 열쇠(432 · `initial_no_curve_keys`) — 손 편집 · R 이면 - #: 곡선. - no_curve_keys: tuple[str, ...] = () @classmethod def from_config( @@ -147,7 +144,6 @@ class AlignmentPolicy: paved=bool(payload.get("paved", False)), min_vertical_radius_m=float(payload.get("min_vertical_radius_m") or 0.0), min_curve_length_m=float(payload.get("min_curve_length_m") or 0.0), - no_curve_keys=tuple(str(key) for key in payload.get("no_curve_keys") or ()), ) @property @@ -180,7 +176,6 @@ class AlignmentPolicy: "hairpin_min_radius_m": self.hairpin_min_radius_m, "min_vertical_radius_m": self.min_vertical_radius_m, "min_curve_length_m": self.min_curve_length_m, - "no_curve_keys": list(self.no_curve_keys), } @@ -310,7 +305,6 @@ def build_curves( curve_radii: dict[str, float] | None = None, *, only_explicit: bool = False, - edited: frozenset[str] = frozenset(), ) -> tuple[list[dict[str, Any]], list[str]]: """각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다. @@ -339,9 +333,6 @@ def build_curves( continue # 직선 판정 한 벌(426) if only_explicit and overrides.get(key) is None: continue - # 초기값 직선 꼭짓점(432) — 사용자가 고치거나(`edited`) R 을 넣기 전까지 곡선 없음 - if key in policy.no_curve_keys and key not in edited and overrides.get(key) is None: - continue # 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다. # 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을 # 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능). @@ -539,11 +530,8 @@ def build_alignment( station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"] pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) - edited = frozenset( - chainage_key(s) for s, source in zip(pvi_s.tolist(), sources) if source == "user" - ) curves, warnings = build_curves( - pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves, edited=edited + pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves ) warnings += vertical_curve_shortfalls( curves, policy.min_vertical_radius_m, policy.min_curve_length_m diff --git a/B05_Profile/B05_Profile_Engine_Grade_Carry.py b/B05_Profile/B05_Profile_Engine_Grade_Carry.py index 713faf628..56c5d8784 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Carry.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Carry.py @@ -207,50 +207,16 @@ def carry_edits( return out -def carry_no_curve_keys( - old_xy: list[tuple[float, float]], - new_xy: list[tuple[float, float]], - old_keys: list[str], - ground_keys: list[str], - ground_pvi: list[dict[str, float]], - tol_m: float = SAME_LINE_TOL_M, -) -> list[str]: - """새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) · - 바뀐 자리면 새 지반 첫 계획선 열쇠. 초기값만 · 그 뒤는 사용자 몫(요구 262).""" - match = line_matcher(old_xy, new_xy, tol_m) - old = [float(key) for key in old_keys] - ground = set(ground_keys) - out = [] - for node in ground_pvi: - key = chainage_key(float(node["chainage_m"])) - s = match(float(node["chainage_m"])) - if (s is None and key in ground) or ( - s is not None and any(abs(s - value) < _ON_NODE_M for value in old) - ): - out.append(key) - return out - - def carry_policy( old_xy: list[tuple[float, float]], new_xy: list[tuple[float, float]], old_alignment: dict[str, Any], new_alignment: dict[str, Any], ) -> dict[str, Any]: - """노선 [저장] 정책(432) — 새 정책에 옛 기본 L · 옮긴 직선 열쇠 · 옛 정책 없으면 새 정책.""" + """노선 [저장] 정책(432) — 새 정책에 옛 기본 L · 옛 정책 없으면 새 정책. 바뀐 자리 새 꼭짓점은 + 새 지반 첫 계획선(직선화 438 · `carry_base`).""" new_policy = dict(new_alignment.get("policy") or {}) old_policy = old_alignment.get("policy") or {} if not old_policy: return new_policy - keys = carry_no_curve_keys( - old_xy, - new_xy, - list(old_policy.get("no_curve_keys") or []), - list(new_policy.get("no_curve_keys") or []), - list(new_alignment.get("base_pvi") or []), - ) - return { - **new_policy, - "default_curve_length_m": old_policy.get("default_curve_length_m"), - "no_curve_keys": keys, - } + return {**new_policy, "default_curve_length_m": old_policy.get("default_curve_length_m")} diff --git a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py index ce984cb18..e9d454ffd 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py +++ b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py @@ -1,9 +1,9 @@ """B05 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262). -- `initial_no_curve_keys` : 초기값(자동설계 체인 · 추천 [적용]으로 처음 만드는 종단)에서만 — 기본 L - 기준 R 이 `initial_straight_radius_m`(500 m)보다 큰(대수차 L/R 안쪽) 꼭짓점 열쇠. 정책 - `no_curve_keys` 로 저장돼 그 꼭짓점엔 종단곡선을 두지 않음 · 사용자가 그 꼭짓점을 고치거나 R 을 - 넣으면 다시 곡선(`build_curves`). 꼭짓점 높이는 그대로. +- `flat_bends` · `straighten_held` : 초기값(자동설계 체인 · 추천 [적용] · 노선 저장 때 새로 생긴 + 구간)에서만 — 기본 L 기준 R 이 `initial_straight_radius_m`(500 m)보다 큰(대수차 L/R 안쪽) 꼭짓점 + 묶음을 앞뒤 경계(진짜 꺾임 · 시종점)를 잇는 직선 위로 옮김(438 · 종단 직선화 단추와 같은 규칙). + 직선 위 꼭짓점은 직선 판정(426)이라 곡선 없음 · 되풀이는 `straight_pass_through`. - `vertical_curve_shortfalls` : 설계속도별 종단곡선 최소 R · L 미달(지식DB 별표2 마.) — 막지 않고 경고만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌 · 45 % 규칙으로 줄어든 곡선도 같은 잣대. @@ -20,20 +20,32 @@ from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"]) -def initial_no_curve_keys( - base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None -) -> tuple[str, ...]: - """안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것의 열쇠(소수 3자리).""" +def flat_bends(base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None) -> np.ndarray: + """안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 시종점은 늘 False.""" + flat = np.zeros(len(base_s), dtype=bool) if not curve_length_m or len(base_s) < 3: - return () - limit = float(curve_length_m) / INITIAL_STRAIGHT_RADIUS_M - spans = base_s[1:] - base_s[:-1] - grades = (base_z[1:] - base_z[:-1]) / np.where(spans > 0, spans, 1.0) - keys = [] - for index in range(1, len(base_s) - 1): - if abs(float(grades[index] - grades[index - 1])) < limit: - keys.append(f"{float(base_s[index]):.3f}") - return tuple(keys) + return flat + spans = np.diff(base_s) + grades = np.diff(base_z) / np.where(spans > 0, spans, 1.0) + flat[1:-1] = np.abs(np.diff(grades)) < float(curve_length_m) / INITIAL_STRAIGHT_RADIUS_M + return flat + + +def straighten_held(base_s: np.ndarray, base_z: np.ndarray, held: np.ndarray) -> np.ndarray: + """`held` 꼭짓점 묶음마다 앞뒤 경계(held 아닌 꼭짓점)를 잇는 직선 위로 옮긴 표고.""" + z = np.asarray(base_z, dtype=np.float64).copy() + index = 1 + while index < len(z) - 1: + if not held[index]: + index += 1 + continue + end = index + while end + 1 < len(z) - 1 and held[end + 1]: + end += 1 + a, b = index - 1, end + 1 + z[index:b] = np.interp(base_s[index:b], [base_s[a], base_s[b]], [z[a], z[b]]) + index = b + return z def vertical_curve_shortfalls( diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index e303aaf6a..3ce1036ec 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -27,7 +27,7 @@ """ from collections import Counter -from dataclasses import replace +from collections.abc import Callable from typing import Any import numpy as np @@ -46,7 +46,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( chainage_key, evaluate, ) -from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys +from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends, straighten_held from B05_Profile.B05_Profile_Engine_Grade_Solver import ( grade_limits, integration_weights, @@ -152,23 +152,57 @@ def _clearance_at( return best -def pass_through(base_s: np.ndarray, target: np.ndarray, policy: AlignmentPolicy) -> np.ndarray: +def pass_through( + base_s: np.ndarray, + target: np.ndarray, + policy: AlignmentPolicy, + held: np.ndarray | None = None, + hold: Callable[[np.ndarray], np.ndarray] | None = None, +) -> np.ndarray: """종단곡선을 넣어도 계획선이 측점마다 `target` 을 지나게 하는 꼭짓점 표고. 대칭 종단곡선은 꼭짓점을 지나지 않는다(중앙종거 |A|·L/8) — 보정 없이 곡선을 넣으면 계획고가 목표에서 뜬다. 호가 측점 세로선과 만나는 점이 목표가 되도록 꼭짓점을 밀어내며, 표고가 움직이면 대수차도 변하므로 수렴할 때까지 되풀이한다. 1차 종단(목표 = 지반고)과 노선 추천 균형 종단 반영(목표 = 균형 종단, PLAN 74-4)이 함께 쓴다. + `held` 꼭짓점은 목표 대신 앞뒤 경계를 잇는 직선 위(438) · `hold` = 맞출 때마다 거는 제약(419-3). """ - base_z = np.asarray(target, dtype=np.float64).copy() + held = np.zeros(len(base_s), dtype=bool) if held is None else held + + def fit(z: np.ndarray) -> np.ndarray: + return straighten_held(base_s, z if hold is None else hold(z), held) + + base_z = fit(np.asarray(target, dtype=np.float64)) for _ in range(12): curves, _curve_warnings = build_curves(base_s, base_z, policy) error = target - evaluate(base_s, base_z, curves, base_s) error[0] = 0.0 error[-1] = 0.0 + error[held] = 0.0 if float(np.max(np.abs(error))) < 1e-4: break - base_z = base_z + error + base_z = fit(base_z + error) + return base_z + + +def straight_pass_through( + base_s: np.ndarray, + target: np.ndarray, + policy: AlignmentPolicy, + hold: Callable[[np.ndarray], np.ndarray] | None = None, +) -> np.ndarray: + """초기값 맞춤(438 · 요구 265) — `pass_through` 뒤 기본 L 기준 R > 500 m 꼭짓점 묶음을 앞뒤 + 경계(진짜 꺾임 · 시종점)를 잇는 직선 위로 · 경계가 새로 R > 500 m 가 되면 묶음을 늘려 다시. + 묶음은 커지기만 해 꼭짓점 수 안에 끝남. 짝 = TS `passThrough` + (`logic/B05_Profile_Grade_Ground.ts`). + """ + held = np.zeros(len(base_s), dtype=bool) + for _ in range(len(base_s)): + base_z = pass_through(base_s, target, policy, held, hold) + flat = flat_bends(base_s, base_z, policy.default_curve_length_m) & ~held + if not flat.any(): + break + held = held | flat return base_z @@ -183,8 +217,8 @@ def design_ground_following_profile( 규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원): - **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다. - - **변화점마다 종단곡선을 넣는다**(432: 기본 L 기준 R > 500 m 꼭짓점은 빼고 · 정책 - `no_curve_keys`). 직선과 직선 사이에는 반드시 호가 있고, 호는 + - **변화점마다 종단곡선을 넣는다**(438: 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 + 잇는 직선 위로 · 직선이라 곡선 없음). 직선과 직선 사이에는 반드시 호가 있고, 호는 변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다. - 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가 되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다 @@ -240,11 +274,8 @@ def design_ground_following_profile( if note: warnings.append(note) - # 초기값 직선(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 = 지반고 그대로) - policy = replace( - policy, no_curve_keys=initial_no_curve_keys(base_s, base_z, policy.default_curve_length_m) - ) - base_z = pass_through(base_s, base_z, policy) + # 초기값 직선화(438) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위 + base_z = straight_pass_through(base_s, base_z, policy) alignment = build_alignment( base_s=base_s, diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 8b58087b3..738d32384 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -277,8 +277,9 @@ def held_pass_through( 계획고를 지나게 꼭짓점을 미는 `pass_through` 와 같은 되풀이에 직선 기울기 상한을 함께 둠(419-3). 밀기만 하면 꼭짓점 사이 직선이 균형 종단 기울기보다 가팔라짐(검증_sub4 14 % → 14.47 %). - 상한 = 균형 종단 측점 사이 가장 센 기울기(이미 상한 안 · 화면 겹침 상한도 그대로).""" - from B05_Profile.B05_Profile_Engine_Grade_Alignment import build_curves, evaluate + 상한 = 균형 종단 측점 사이 가장 센 기울기(이미 상한 안 · 화면 겹침 상한도 그대로). + 초기값 직선화(438) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위.""" + from B05_Profile.B05_Profile_Engine_Grade_Profile import straight_pass_through 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()) @@ -286,15 +287,7 @@ def held_pass_through( 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 + return straight_pass_through(base_s, target, policy, lambda z: _hold(base_s, z, limit)) def _dp( diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 752271427..f03524cf5 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -211,6 +211,8 @@ async def sync_uphill_overrides_into_designs( #: 반올림만 다른 같은 측점(m) — 서버가 관 측점을 0.01m 로 반올림. #: 화면 `applyIrregularStations` 0.006 과 짝. _SAME_STATION_M = 0.006 +#: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438). +_SEAT_GROUND_M = 0.01 def _seat_born_nodes( @@ -219,8 +221,10 @@ def _seat_born_nodes( """새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427). 다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장 - 선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). TS 짝 = `seatBornNodes` - (B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = `test_427_1_follow_seat.py`. + 선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점 + 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 = + `seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = + `test_427_1_follow_seat.py`. """ samples = stored.get("samples") or [] if not base or len(samples) < 2: @@ -236,15 +240,26 @@ def _seat_born_nodes( return base ground_s = np.array([float(row["chainage_m"]) for row in samples], dtype=np.float64) ground_z = np.array([float(row["ground_elevation_m"]) for row in samples], dtype=np.float64) + policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) + pre_s = np.array([float(node["chainage_m"]) for node in stored["base_pvi"]], dtype=np.float64) + pre_z = np.array([float(node["elevation_m"]) for node in stored["base_pvi"]], dtype=np.float64) + pre_curves, _warnings = build_curves(pre_s, pre_z, policy) + off = evaluate(pre_s, pre_z, pre_curves, pre_s) - np.interp(pre_s, ground_s, ground_z) + on_ground = float(np.median(np.abs(off))) < _SEAT_GROUND_M + + def seat_z(value: float) -> float: + if on_ground: + return float(np.interp(value, ground_s, ground_z)) + return float(evaluate(pre_s, pre_z, pre_curves, np.array([value]))[0]) + rows = sorted( [(float(node["chainage_m"]), float(node["elevation_m"]), node) for node in base] - + [(value, float(np.interp(value, ground_s, ground_z)), None) for value in fresh], + + [(value, seat_z(value), None) for value in fresh], key=lambda row: row[0], ) base_s = np.array([row[0] for row in rows], dtype=np.float64) target = np.array([row[1] for row in rows], dtype=np.float64) free = np.array([row[2] is None for row in rows]) - policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) base_z = target.copy() for _ in range(20): curves, _warnings = build_curves(base_s, base_z, policy) diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 55a0f53e3..c2936fbd2 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -360,7 +360,6 @@ 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_CurveLaw import initial_no_curve_keys from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through from common_util.common_util_json import atomic_write_json @@ -368,19 +367,13 @@ def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: alignment = longitudinal["profile_alignment"] base = alignment["base_pvi"] base_s = np.array([float(item["chainage_m"]) for item in base]) - # 초기값 직선(432) — 균형 종단에서 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이 - policy = AlignmentPolicy.from_dict(alignment["policy"]) - keys = initial_no_curve_keys( - base_s, np.interp(base_s, stations, plan), policy.default_curve_length_m - ) - alignment["policy"]["no_curve_keys"] = list(keys) # 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게 · 꼭짓점 사이 - # 직선은 균형 종단 상한 안(419-3) + # 직선은 균형 종단 상한 안(419-3) · R > 500 m 꼭짓점 묶음은 직선화(438) 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) + item["elevation_m"] = round(float(value), 6) # 직선 판정(426) 1e-5 m 안 atomic_write_json(path, longitudinal) return len(base) diff --git a/B05_Profile/logic/B05_Profile_Grade_Carry.ts b/B05_Profile/logic/B05_Profile_Grade_Carry.ts index 7a01df662..cb8f6a18b 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Carry.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Carry.ts @@ -238,27 +238,3 @@ export function carryEdits( }); return out; } - -/** - * 새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) · 바뀐 자리면 새 - * 지반 첫 계획선 열쇠(파이썬 `carry_no_curve_keys` 짝). 초기값만 · 그 뒤는 사용자 몫(요구 262). - */ -export function carryNoCurveKeys( - oldXy: ReadonlyArray, - newXy: ReadonlyArray, - oldKeys: ReadonlyArray, - groundKeys: ReadonlyArray, - groundPvi: ReadonlyArray, - tolM: number = SAME_LINE_TOL_M, -): string[] { - const match = lineMatcher(oldXy, newXy, tolM); - const old = oldKeys.map(Number); - const ground = new Set(groundKeys); - return groundPvi - .filter((node) => { - const s = match(node.chainage_m); - if (s === null) return ground.has(node.chainage_m.toFixed(3)); - return old.some((value) => Math.abs(s - value) < ON_NODE_M); - }) - .map((node) => node.chainage_m.toFixed(3)); -} diff --git a/B05_Profile/logic/B05_Profile_Grade_Ground.ts b/B05_Profile/logic/B05_Profile_Grade_Ground.ts index 866fc11d7..01e724e0a 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Ground.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Ground.ts @@ -14,7 +14,7 @@ import { type AlignmentNode, type AlignmentPolicy, } from "./B05_Profile_UI_Profile_Alignment"; -import { initialNoCurveKeys } from "./B05_Profile_Profile_CurveLaw"; +import { flatBends, straightenHeld } from "./B05_Profile_Profile_CurveLaw"; /** 종단 지반 표본 한 점 — 저장 종단 `samples[]` 꼴. */ export interface GroundSample { @@ -67,33 +67,51 @@ export function groundProfile( return { chainage: rows.map((row) => row.s), ground: rows.map((row) => interp(fs, fz, row.s)) }; } -/** 종단곡선을 넣어도 측점마다 `target` 을 지나게 하는 꼭짓점 표고 — 파이썬 `pass_through` 짝. */ +/** + * 종단곡선을 넣어도 측점마다 `target` 을 지나게 하는 꼭짓점 표고 — 파이썬 `straight_pass_through` 짝. + * 초기값 직선화(438): 맞춘 뒤 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 경계(진짜 꺾임 · 시종점)를 + * 잇는 직선 위 · 경계가 새로 R > 500 m 가 되면 묶음을 늘려 다시(묶음은 커지기만). + */ export function passThrough(baseS: number[], target: number[], policy: AlignmentPolicy): number[] { - let baseZ = [...target]; + let held = baseS.map(() => false); + let baseZ = fitThrough(baseS, target, policy, held); + for (let round = 1; round < baseS.length; round += 1) { + const flat = flatBends(baseS, baseZ, policy.default_curve_length_m); + if (!flat.some((value, index) => value && !held[index])) break; + held = held.map((value, index) => value || flat[index]); + baseZ = fitThrough(baseS, target, policy, held); + } + return baseZ; +} + +/** 파이썬 `pass_through` 짝 — `held` 꼭짓점은 목표 대신 앞뒤 경계를 잇는 직선 위. */ +function fitThrough( + baseS: number[], + target: number[], + policy: AlignmentPolicy, + held: boolean[], +): number[] { + let baseZ = straightenHeld(baseS, target, held); for (let round = 0; round < 12; round += 1) { const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] })); const curves = buildCurves(pvi, policy, {}, []); - const error = baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s)); + const error = baseS.map((s, index) => + held[index] ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, s), + ); error[0] = 0; error[error.length - 1] = 0; if (Math.max(...error.map(Math.abs)) < 1e-4) break; - baseZ = baseZ.map((z, index) => z + error[index]); + baseZ = straightenHeld( + baseS, + baseZ.map((z, index) => z + error[index]), + held, + ); } return baseZ; } /** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null { - return groundFollowingPlan(input)?.pvi ?? null; -} - -/** - * 첫 계획선 꼭짓점 + 초기값 직선 열쇠(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 = - * 지반고 그대로) · 파이썬 `design_ground_following_profile` 의 `no_curve_keys` 짝. - */ -export function groundFollowingPlan( - input: GroundFollowingInput, -): { pvi: AlignmentNode[]; noCurveKeys: string[] } | null { const profile = groundProfile(input.samples); if (!profile) return null; const { chainage, ground } = profile; @@ -114,14 +132,9 @@ export function groundFollowingPlan( const target = nodeS.map((s) => interp(chainage, ground, s)); target[0] += input.startOffsetM ?? 0; target[target.length - 1] += input.endOffsetM ?? 0; - const noCurveKeys = initialNoCurveKeys( - nodeS.map((s, index) => ({ chainage_m: s, elevation_m: target[index] })), - input.policy.default_curve_length_m, - ); - const baseZ = passThrough(nodeS, target, { ...input.policy, no_curve_keys: noCurveKeys }); - const pvi = nodeS.map((s, index) => ({ + const baseZ = passThrough(nodeS, target, input.policy); + return nodeS.map((s, index) => ({ chainage_m: roundTo(s, 6), elevation_m: roundTo(baseZ[index], 6), })); - return { pvi, noCurveKeys }; } diff --git a/B05_Profile/logic/B05_Profile_Grade_Seat.ts b/B05_Profile/logic/B05_Profile_Grade_Seat.ts index 7108f3dee..7d42b50c2 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Seat.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Seat.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_Grade_Seat.ts * 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 다른 꼭짓점 그대로. + * 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). * * 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes` * (common_util_structure_station.ts)가 고름 · 거울 시험 = resources/tester/test_427_1_follow_seat.py. @@ -13,9 +14,19 @@ import { type AlignmentNode, } from "./B05_Profile_UI_Profile_Alignment"; +/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */ +const SEAT_GROUND_M = 0.01; + +/** `np.median` 짝. */ +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + /** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */ export function seatBornNodes( - base: Pick, + base: Pick, nodes: AlignmentNode[], born: ReadonlyArray, ): AlignmentNode[] { @@ -29,13 +40,23 @@ export function seatBornNodes( nodes.every((node) => Math.abs(value - node.chainage_m) >= 0.006), ); if (!fresh.length) return nodes; + const groundAt = (s: number): number => evaluateAt(base.chainage, base.ground, [], s); + // 옮기기 전 계획선(편집 없이) — 꼭짓점 대부분에서 지반을 지나면 지반 종단 + const preS = base.basePvi.map((node) => node.chainage_m); + const preZ = base.basePvi.map((node) => node.elevation_m); + const preCurves = buildCurves(base.basePvi, base.policy, {}, []); + const onGround = + median(preS.map((s) => Math.abs(evaluateAt(preS, preZ, preCurves, s) - groundAt(s)))) < + SEAT_GROUND_M; + const seatZ = (s: number): number => + onGround ? groundAt(s) : evaluateAt(preS, preZ, preCurves, s); const rows = [ ...nodes.map((node) => ({ s: node.chainage_m, z: node.elevation_m, node: node as AlignmentNode | null, })), - ...fresh.map((s) => ({ s, z: evaluateAt(base.chainage, base.ground, [], s), node: null })), + ...fresh.map((s) => ({ s, z: seatZ(s), node: null })), ].sort((a, b) => a.s - b.s); const baseS = rows.map((row) => row.s); const target = rows.map((row) => row.z); diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index f1fbef778..07b28bec4 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -32,15 +32,9 @@ import { type AlignmentBase, type AlignmentEdits, } from "./B05_Profile_UI_Profile_Alignment"; -import { - carryBase, - carryEdits, - carryNoCurveKeys, - changedSpan, - type XY, -} from "./B05_Profile_Grade_Carry"; +import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry"; import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations"; -import { groundFollowingPlan, groundProfile } from "./B05_Profile_Grade_Ground"; +import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground"; import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw"; const xy = (line: ReadonlyArray): Array<{ x: number; y: number }> => @@ -172,30 +166,18 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { })); const profile = groundProfile(samples); if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈) - // 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선 열쇠 · 432) - const plan = groundFollowingPlan({ - samples, - stations: ground.stations.map((station) => station.chainageM), - policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M }, - }); - const groundPvi = plan?.pvi ?? []; + // 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438) + const groundPvi = + groundFollowingBasePvi({ + samples, + stations: ground.stations.map((station) => station.chainageM), + policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M }, + }) ?? []; const savedLine = deps.savedLine(); const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null; const savedPvi = deps.storedPvi?.() ?? saved.basePvi; return { - // 이어받은 구간 = 옛 정책(L · 직선 열쇠) · 바뀐 자리 새 꼭짓점만 새 열쇠(서버 `carry_policy` 짝) - policy: span - ? { - ...saved.policy, - no_curve_keys: carryNoCurveKeys( - xy(savedLine!), - xy(route.line), - saved.policy.no_curve_keys ?? [], - plan?.noCurveKeys ?? [], - groundPvi, - ), - } - : saved.policy, + policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝) onlyExplicitCurves: saved.onlyExplicitCurves, basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi, chainage: profile.chainage, diff --git a/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts b/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts index 9e1cfdb59..c09e28a0e 100644 --- a/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts +++ b/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts @@ -2,53 +2,64 @@ * B05_Profile_Profile_CurveLaw.ts * 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262). * - * 짝: 파이썬 `B05_Profile_Engine_Grade_CurveLaw.py`(`initial_no_curve_keys` · + * 짝: 파이썬 `B05_Profile_Engine_Grade_CurveLaw.py`(`flat_bends` · `straighten_held` · * `vertical_curve_shortfalls`) · config `FOREST_ROAD_PROFILE_ALIGNMENT` 의 `default_curve_length_m` · * `initial_straight_radius_m` — 거울 시험 `test_432_1_curve_law.py`. * - * - 초기값에서만 기본 L 기준 R > 500 m(|대수차| < L/500) 꼭짓점은 곡선 없음 — 정책 `no_curve_keys` - * 로 얼려 두고, 사용자가 그 꼭짓점을 고치거나(source "user") R 을 넣으면 다시 기본 곡선. + * - 초기값에서만 기본 L 기준 R > 500 m(|대수차| < L/500) 꼭짓점 묶음을 앞뒤 경계(진짜 꺾임 · 시종점)를 + * 잇는 직선 위로(438 · 종단 직선화 단추와 같은 규칙) — 직선 판정(426)이라 곡선 없음 · 되풀이는 + * `passThrough`(`B05_Profile_Grade_Ground.ts`). * - 법정 최소 R · L 미달은 막지 않고 경고 글만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌. * ========================================================================== */ -import type { - AlignmentCurve, - AlignmentNode, - AlignmentPolicy, -} from "./B05_Profile_UI_Profile_Alignment"; +import type { AlignmentCurve, AlignmentPolicy } from "./B05_Profile_UI_Profile_Alignment"; /** 기본 종단곡선 길이 L(m) — config `default_curve_length_m` 짝(새 정책에 담겨 옴 · 옛 저장분은 저장값). */ export const DEFAULT_CURVE_LENGTH_M = 20; /** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */ export const INITIAL_STRAIGHT_RADIUS_M = 500; -/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m 인 것의 열쇠(소수 3자리) — 파이썬 `initial_no_curve_keys`. */ -export function initialNoCurveKeys( - nodes: ReadonlyArray, +/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 파이썬 `flat_bends` · 시종점 false. */ +export function flatBends( + baseS: ReadonlyArray, + baseZ: ReadonlyArray, curveLengthM: number | null | undefined, -): string[] { - if (!curveLengthM || nodes.length < 3) return []; +): boolean[] { + const flat = baseS.map(() => false); + if (!curveLengthM || baseS.length < 3) return flat; const limit = curveLengthM / INITIAL_STRAIGHT_RADIUS_M; - const keys: string[] = []; - for (let index = 1; index < nodes.length - 1; index += 1) { - const [a, b, c] = [nodes[index - 1], nodes[index], nodes[index + 1]]; - const spanIn = b.chainage_m - a.chainage_m; - const spanOut = c.chainage_m - b.chainage_m; - const gradeIn = (b.elevation_m - a.elevation_m) / (spanIn > 0 ? spanIn : 1); - const gradeOut = (c.elevation_m - b.elevation_m) / (spanOut > 0 ? spanOut : 1); - if (Math.abs(gradeOut - gradeIn) < limit) keys.push(b.chainage_m.toFixed(3)); + const grade = (index: number): number => { + const span = baseS[index + 1] - baseS[index]; + return (baseZ[index + 1] - baseZ[index]) / (span > 0 ? span : 1); + }; + for (let index = 1; index < baseS.length - 1; index += 1) { + flat[index] = Math.abs(grade(index) - grade(index - 1)) < limit; } - return keys; + return flat; } -/** 초기값 직선 꼭짓점이라 곡선을 안 넣는가 — 사용자가 고친 꼭짓점 · R 지정이면 넣음. */ -export function skipsInitialCurve( - policy: AlignmentPolicy, - key: string, - node: AlignmentNode & { source?: string }, - hasRequested: boolean, -): boolean { - return !hasRequested && node.source !== "user" && (policy.no_curve_keys ?? []).includes(key); +/** `held` 꼭짓점 묶음마다 앞뒤 경계(held 아닌 꼭짓점)를 잇는 직선 위로 — 파이썬 `straighten_held`. */ +export function straightenHeld( + baseS: ReadonlyArray, + baseZ: ReadonlyArray, + held: ReadonlyArray, +): number[] { + const z = [...baseZ]; + let index = 1; + while (index < z.length - 1) { + if (!held[index]) { + index += 1; + continue; + } + let end = index; + while (end + 1 < z.length - 1 && held[end + 1]) end += 1; + const [a, b] = [index - 1, end + 1]; + for (let k = index; k < b; k += 1) { + z[k] = z[a] + ((z[b] - z[a]) * (baseS[k] - baseS[a])) / (baseS[b] - baseS[a]); + } + index = b; + } + return z; } /** 법정 최소 R · L 을 못 채운 종단곡선 경고 글 — 파이썬 `vertical_curve_shortfalls` 와 같은 글. */ diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index a026c3f2a..a68c8b485 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -15,7 +15,7 @@ import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot"; import { isStraightBend } from "./B05_Profile_Profile_Straight"; -import { skipsInitialCurve, verticalCurveShortfalls } from "./B05_Profile_Profile_CurveLaw"; +import { verticalCurveShortfalls } from "./B05_Profile_Profile_CurveLaw"; export interface AlignmentPolicy { station_interval_m: number; @@ -36,10 +36,9 @@ export interface AlignmentPolicy { min_plan_radius_m?: number; /** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */ hairpin_min_radius_m?: number; - /** 종단곡선 법정 최소 R · L(m) · 초기값 곡선 없는 꼭짓점 열쇠(432 · `_Profile_CurveLaw`). */ + /** 종단곡선 법정 최소 R · L(m) — 미달 경고만(432 · `_Profile_CurveLaw`). */ min_vertical_radius_m?: number; min_curve_length_m?: number; - no_curve_keys?: string[]; } export interface AlignmentNode { @@ -321,7 +320,6 @@ export function buildCurves( const hasRequested = Number.isFinite(requested) && requested > 0; // 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다. if (onlyExplicit && !hasRequested) continue; - if (skipsInitialCurve(policy, key, pvi[index], hasRequested)) continue; // 초기값 직선(432) const fallbackLength = Number.isFinite(policy.default_curve_length_m) ? (policy.default_curve_length_m as number) : policy.default_curve_radius_m * Math.abs(delta); diff --git a/config/config_system_design.py b/config/config_system_design.py index 092d8aed5..c973520a7 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -620,8 +620,9 @@ FOREST_ROAD_PROFILE_ALIGNMENT = { # 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시). # 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다. "default_curve_length_m": 20.0, - # 초기값(자동설계 체인 · 추천 [적용]으로 처음 만드는 종단)에서만 — 기본 L 기준 R 이 이 값(m)보다 - # 크면(대수차 L/R 안쪽) 그 꼭짓점에 종단곡선을 두지 않음(직선) · 그 뒤 손 편집은 기본 곡선. + # 초기값(자동설계 체인 · 추천 [적용] · 노선 저장 때 새로 생긴 구간)에서만 — 기본 L 기준 R 이 + # 이 값(m)보다 큰(대수차 L/R 안쪽) 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위로(438) · 그 뒤는 + # 사용자 몫. # TS 짝 = `B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts` `INITIAL_STRAIGHT_RADIUS_M`. "initial_straight_radius_m": 500.0, # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. diff --git a/resources/tester/helper_432_1_curve_law.cjs b/resources/tester/helper_432_1_curve_law.cjs index a8a451c75..36b1c3350 100644 --- a/resources/tester/helper_432_1_curve_law.cjs +++ b/resources/tester/helper_432_1_curve_law.cjs @@ -1,5 +1,5 @@ /* 432-1 종단곡선 초기값 직선 · 법정 미달 거울 헬퍼 — `logic/B05_Profile_Grade_Ground.ts` - * `groundFollowingPlan` · `_UI_Profile_Alignment.ts` `buildAlignment` · `_Profile_CurveLaw.ts` 를 + * `groundFollowingBasePvi` · `_UI_Profile_Alignment.ts` `buildAlignment` · `_Profile_CurveLaw.ts` 를 * 그 자리에서 트랜스파일해 돌림. 입력 argv[2] JSON 파일 · 표준출력 JSON 한 벌. */ const fs = require("fs"); @@ -28,10 +28,9 @@ const LOGIC = path.join(ROOT, "B05_Profile", "logic"); const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); const ground = loadTs(path.join(LOGIC, "B05_Profile_Grade_Ground.ts")); const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts")); -const carry = loadTs(path.join(LOGIC, "B05_Profile_Grade_Carry.ts")); const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const plan = ground.groundFollowingPlan(input.ground); +const pvi = ground.groundFollowingBasePvi(input.ground); const base = { policy: input.policy, onlyExplicitCurves: false, @@ -50,18 +49,8 @@ const cases = input.edits.map((edits) => { }); console.log( JSON.stringify({ - pvi: plan ? plan.pvi : null, - keys: plan ? plan.noCurveKeys : null, + pvi, cases, consts: [law.DEFAULT_CURVE_LENGTH_M, law.INITIAL_STRAIGHT_RADIUS_M], - carried: input.carry - ? carry.carryNoCurveKeys( - input.carry.old_xy, - input.carry.new_xy, - input.carry.old_keys, - input.carry.ground_keys, - input.carry.ground_pvi, - ) - : null, }), ); diff --git a/resources/tester/helper_438_1_straighten.cjs b/resources/tester/helper_438_1_straighten.cjs new file mode 100644 index 000000000..88d6355f9 --- /dev/null +++ b/resources/tester/helper_438_1_straighten.cjs @@ -0,0 +1,30 @@ +/* 438-1 초기 종단 직선화 거울 헬퍼 — `logic/B05_Profile_Grade_Ground.ts` `groundFollowingBasePvi` + * (`passThrough` 직선화)를 그 자리에서 트랜스파일해 돌림. 입력 argv[2] JSON [GroundFollowingInput, …] · + * 표준출력 = 꼭짓점 목록 한 벌씩. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const ground = loadTs( + path.join(__dirname, "..", "..", "B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"), +); +const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +process.stdout.write(JSON.stringify(cases.map((item) => ground.groundFollowingBasePvi(item)))); diff --git a/resources/tester/test_432_1_curve_law.py b/resources/tester/test_432_1_curve_law.py index 28efdd490..f6b127f8c 100644 --- a/resources/tester/test_432_1_curve_law.py +++ b/resources/tester/test_432_1_curve_law.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -"""432-1 종단곡선 기본 L 20 m · 법정 미달 경고 · 초기값 R > 500 m 꼭짓점 = 곡선 없음(요구 262). +"""432-1 종단곡선 기본 L 20 m · 법정 미달 경고(요구 262). 초기값 R > 500 m 꼭짓점 「곡선 없음」 +(정책 `no_curve_keys`)은 438 직선화로 바뀜 — `test_438_1_initial_straighten.py`. 파이썬 `B05_Profile_Engine_Grade_CurveLaw.py` · `design_ground_following_profile` · -`build_alignment` -(정책 `no_curve_keys`) ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` · `_Grade_Ground.ts` -`groundFollowingPlan` · `_UI_Profile_Alignment.ts` 거울(`helper_432_1_curve_law.cjs`). +`build_alignment` ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` · `_Grade_Ground.ts` +`groundFollowingBasePvi` · `_UI_Profile_Alignment.ts` 거울(`helper_432_1_curve_law.cjs`). ./venv/Scripts/python.exe -m pytest resources/tester/test_432_1_curve_law.py -q """ @@ -26,12 +26,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 AlignmentPolicy, build_alignment, ) -from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 - carry_base, - carry_no_curve_keys, - carry_policy, -) -from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_policy # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 design_ground_following_profile, ) @@ -40,8 +35,8 @@ from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT # noqa: E402 # 20 m 마디 기울기(%) — 꼭짓점 대수차 1 · 6 · 0.5 · 7.5 · 0.5 · 7.5 · 5.5 · 0.5 · 3 % GRADES = [2.0, 3.0, 9.0, 9.5, 2.0, 2.5, 10.0, 4.5, 4.0, 1.0] STATIONS = [20.0 * i for i in range(len(GRADES) + 1)] +# 432-1 때 곡선 없던 꼭짓점 열쇠 — 옛 저장분 정책에 남은 채 와도 무시(438) NO_CURVE = ["20.000", "60.000", "100.000", "160.000", "180.000"] -CURVED = [40.0, 80.0, 120.0, 140.0] def _vertex_z() -> list[float]: @@ -83,24 +78,19 @@ def _rebuild(alignment: dict, edits: dict, policy: dict | None = None) -> dict: EDITS = [ {}, - {"station_offsets": {"60.000": 0.1}}, # 그 꼭짓점 손 편집 → 기본 곡선 - {"station_offsets": {"80.000": 0.2}}, # 이웃 손 편집 → 60 · 100 은 얼린 채 - {"curve_radii": {"100.000": 800.0}}, # R 넣음 → 곡선 + {"station_offsets": {"60.000": 0.1}}, # 직선 안 꼭짓점 손 편집 → 기본 곡선 + {"station_offsets": {"80.000": 0.2}}, + {"curve_radii": {"100.000": 800.0}}, # 직선 위 R → 직선 판정(426)이라 곡선 없음 ] -# 노선 [저장] — 끝 노드만 옮김(100 m 까지 같은 자리) · 옛 열쇠(추천) · 새 지반 열쇠 +# 노선 [저장] — 끝 노드만 옮김(100 m 까지 같은 자리) NEW_TOTAL = 100.0 + float(np.hypot(100.0, 30.0)) NEW_STATIONS = [20.0 * i for i in range(11)] + [round(NEW_TOTAL, 3)] CARRY = { "old_xy": [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], "new_xy": [(0.0, 0.0), (100.0, 0.0), (200.0, 30.0)], - "old_keys": NO_CURVE, - "ground_keys": ["140.000", "180.000"], - "ground_pvi": [{"chainage_m": s, "elevation_m": 0.0} for s in NEW_STATIONS], } -# 같은 자리 옛 꼭짓점 열쇠(20 · 60 · 100) + 바뀐 자리 새 지반 열쇠(140 · 180) · 옛 160 은 버림 -CARRIED = ["20.000", "60.000", "100.000", "140.000", "180.000"] def _curve_at(alignment: dict) -> list[float]: @@ -114,48 +104,27 @@ def test_기본_L_20_문턱_500_config_TS_짝(): assert "DEFAULT_CURVE_LENGTH_M = 20;" in law and "INITIAL_STRAIGHT_RADIUS_M = 500;" in law -def test_초기값_대수차_4퍼센트_안쪽은_곡선_없음(initial): - _long, _options, alignment, _profile = initial - assert alignment["policy"]["default_curve_length_m"] == 20.0 - assert alignment["policy"]["no_curve_keys"] == NO_CURVE - assert _curve_at(alignment) == CURVED - # 꼭짓점 높이 그대로 — 곡선 없는 측점 계획고 = 지반고(원지반 지남) - rows = {round(r["chainage_m"], 3): r for r in alignment["stations"]} - for key in NO_CURVE: - row = rows[float(key)] - assert row["plan_elevation_m"] == pytest.approx(row["ground_elevation_m"], abs=1e-4) - - def test_법정_미달_경고_45퍼센트로_줄어든_곳도(initial): _long, options, alignment, _profile = initial assert options.min_vertical_radius_m == 100.0 and options.min_curve_length_m == 20.0 assert alignment["policy"]["min_curve_length_m"] == 20.0 # 20 m 마디 = 반쪽 9 m 한도 → L 18 m(줄어든 경고 글 그대로) + 법정 L 20 미달 경고 lengths = [round(c["l_m"], 3) for c in alignment["curves"]] - assert lengths == [18.0] * len(CURVED) + assert lengths and lengths == [18.0] * len(lengths) reduced = [w for w in alignment["warnings"] if "줄였습니다" in w] short = [w for w in alignment["warnings"] if "미달" in w] - assert len(reduced) == len(CURVED) and len(short) == len(CURVED) + assert len(reduced) == len(lengths) and len(short) == len(lengths) assert short[0].startswith("40.0m: 종단곡선 R") and "법정 최소 R 100m · L 20m 미달" in short[0] -def test_손_편집은_기본_곡선_이웃은_얼린_채(initial): +def test_옛_저장분_열쇠는_무시_꺾인_꼭짓점마다_곡선(initial): alignment = initial[2] - own = _rebuild(alignment, EDITS[1]) - assert 60.0 in _curve_at(own) - neighbor = _rebuild(alignment, EDITS[2]) - assert 60.0 not in _curve_at(neighbor) and 100.0 not in _curve_at(neighbor) - assert 80.0 in _curve_at(neighbor) - radius = _rebuild(alignment, EDITS[3]) - assert 100.0 in _curve_at(radius) - - -def test_옛_저장분은_그대로(initial): - alignment = initial[2] - old = {**alignment["policy"], "default_curve_length_m": 15.0} - old.pop("no_curve_keys") - got = _rebuild(alignment, {}, old) - assert len(got["curves"]) == len(STATIONS) - 2 # 꼭짓점마다 곡선(옛 규칙) + old = {**alignment["policy"], "default_curve_length_m": 15.0, "no_curve_keys": NO_CURVE} + # 432-1 때 저장된 꼭짓점(지반 그대로 · 직선화 전) + nodes = [{"chainage_m": s, "elevation_m": z} for s, z in zip(STATIONS, _vertex_z())] + got = _rebuild({**alignment, "base_pvi": nodes}, {}, old) + assert len(got["curves"]) == len(STATIONS) - 2 # 꼭짓점마다 곡선 · 모서리 없음 + assert "no_curve_keys" not in got["policy"] def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path): @@ -164,7 +133,7 @@ def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path): "ground": { "samples": longitudinal["samples"], "stations": STATIONS, - "policy": {**alignment["policy"], "no_curve_keys": []}, + "policy": alignment["policy"], "startOffsetM": options.start_elevation_offset_m, "endOffsetM": options.end_elevation_offset_m, }, @@ -174,11 +143,6 @@ def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path): "groundZ": [row["elevation_m"] for row in longitudinal["samples"]], "stations": STATIONS, "edits": [{"station_offsets": {}, "curve_radii": {}, **e} for e in EDITS], - "carry": { - **CARRY, - "old_xy": [{"x": x, "y": y} for x, y in CARRY["old_xy"]], - "new_xy": [{"x": x, "y": y} for x, y in CARRY["new_xy"]], - }, } source = tmp_path / "in.json" source.write_text(json.dumps(payload), encoding="utf-8") @@ -192,12 +156,10 @@ def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path): assert proc.returncode == 0, proc.stderr ts = json.loads(proc.stdout) assert ts["consts"] == [20, 500] - assert ts["keys"] == NO_CURVE assert [n["chainage_m"] for n in ts["pvi"]] == [n["chainage_m"] for n in alignment["base_pvi"]] assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx( [n["elevation_m"] for n in alignment["base_pvi"]], abs=1e-6 ) - assert ts["carried"] == CARRIED for edits, got in zip(EDITS, ts["cases"], strict=True): py = _rebuild(alignment, edits) assert [c[0] for c in got["curves"]] == pytest.approx( @@ -212,42 +174,20 @@ def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path): assert [w for w in got["warnings"] if "미달" in w] == py_short -def test_추천_적용_균형_종단에서_열쇠(initial, tmp_path): - from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi - - longitudinal, _options, alignment, _profile = initial - path = tmp_path / "long.json" - path.write_text(json.dumps({**longitudinal, "profile_alignment": alignment}), encoding="utf-8") - # 균형 종단 — 100 m 를 2 m 낮춤(그 둘레 대수차가 커짐 · 나머지는 지반 그대로) - plan = [z - (2.0 if s == 100.0 else 0.0) for s, z in zip(STATIONS, _vertex_z())] - _set_base_pvi(path, STATIONS, plan) - stored = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]["policy"] - expected = initial_no_curve_keys(np.array(STATIONS), np.array(plan), 20.0) - assert stored["no_curve_keys"] == list(expected) - assert "100.000" not in stored["no_curve_keys"] and "20.000" in stored["no_curve_keys"] - - def test_부르는_곳(): def read(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") - assert "skipsInitialCurve(policy, key, pvi[index], hasRequested)" in read( - "B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts" - ) + assert "skipsInitialCurve" not in read("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts") assert "verticalCurveShortfalls(alignment.curves, policy)" in read( "B05_Profile/B05_Profile_UI_Profile_Balance.ts" ) - assert "carryNoCurveKeys(" in read("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts") + assert "carryNoCurveKeys" not in read("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts") assert "carry_policy(old_xy, new_xy, old_alignment, alignment)" in read( "B05_Profile/B05_Profile_Route_Save.py" ) -def test_노선_저장_열쇠_옮김(): - args = [CARRY[k] for k in ("old_xy", "new_xy", "old_keys", "ground_keys", "ground_pvi")] - assert carry_no_curve_keys(*args) == CARRIED - - def test_추천_적용_종단_노선_고쳐_저장_안_바뀐_구간_그대로(initial, tmp_path): from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi @@ -258,7 +198,7 @@ def test_추천_적용_종단_노선_고쳐_저장_안_바뀐_구간_그대로(i _set_base_pvi(path, STATIONS, plan) old = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"] old_rebuilt = _rebuild(old, {}) - # 새 노선 종단 = 서버가 새로 냄(정책 config · L 20 · 새 지반 열쇠) → 옛 정책 · 옛 꼭짓점 이어받음 + # 새 노선 종단 = 서버가 새로 냄(정책 config · L 20 · 직선화) → 옛 L · 옛 꼭짓점 이어받음 zs = _vertex_z() samples = [ {"chainage_m": float(s), "elevation_m": float(np.interp(s, STATIONS, zs))} @@ -283,7 +223,4 @@ def test_추천_적용_종단_노선_고쳐_저장_안_바뀐_구간_그대로(i after = [(c["chainage_m"], round(c["r_m"], 3)) for c in carried["curves"]] # 안 바뀐 구간(< 80 m · 끝 노드 영향 밖) R · 직선 그대로 assert [c for c in after if c[0] < 80] == [c for c in before if c[0] < 80] - old_keys = set(old["policy"]["no_curve_keys"]) - assert {k for k in policy["no_curve_keys"] if float(k) <= 100} == { - k for k in old_keys if float(k) <= 100 - } + assert "no_curve_keys" not in policy diff --git a/resources/tester/test_438_1_initial_straighten.py b/resources/tester/test_438_1_initial_straighten.py new file mode 100644 index 000000000..c142b4cfa --- /dev/null +++ b/resources/tester/test_438_1_initial_straighten.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +"""438-1 초기 종단 — R 500 넘는 꼭짓점 묶음 직선화(요구 262 · 265). + +초기값(1차 지반 종단 · 추천 [적용] 균형 종단)만 — 기본 L 20 기준 R > 500 m(|대수차| < 4 %) +꼭짓점 묶음을 앞뒤 경계(진짜 꺾임 · 시종점)를 잇는 직선 위로 · 경계가 새로 R > 500 m 가 되면 +묶음을 늘려 다시. 432-1 「곡선 없음 · 높이 그대로」(`no_curve_keys`)는 걷음 — 모서리 원인. +관 옮김(427) 새 꼭짓점은 지반 종단이 아니면 옮기기 전 계획선 높이. + +파이썬 `straight_pass_through` · `held_pass_through` · `_set_base_pvi` · `_seat_born_nodes` ↔ TS +`groundFollowingBasePvi`(`helper_438_1_straighten.cjs`) · `seatBornNodes`(427 헬퍼). +./venv/Scripts/python.exe -m pytest resources/tester/test_438_1_initial_straighten.py -q +""" + +import copy +import json +import math +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(HERE)) + +from test_427_1_follow_seat import ( # noqa: E402 + MOVED, + PIPE, + _move, + _ts, + _write, +) +from test_427_1_follow_seat import _longitudinal as _long_427 # noqa: E402 + +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_alignment, + build_curves, + evaluate, + 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_ground_following_profile, + pass_through, + rebuild_alignment_profile, +) + +L_M = 20.0 + + +def _wave(s: float) -> float: + return 100.0 + 0.05 * s + 0.6 * math.sin(s / 45.0) + 0.08 * math.sin(s / 9.0) + + +def _from_grades(grades: list[float]) -> tuple[list[float], list[float]]: + stations = [20.0 * i for i in range(len(grades) + 1)] + z = [100.0] + for grade in grades: + z.append(z[-1] + grade / 100.0 * 20.0) + return stations, z + + +def _longitudinal(stations: list[float], height) -> dict: + samples = [ + {"chainage_m": float(s), "elevation_m": round(float(height(s)), 6)} + for s in np.arange(0.0, stations[-1] + 0.5, 1.0) + ] + return {"samples": samples, "stations": [{"chainage_m": s} for s in stations]} + + +WAVE_STATIONS = [20.0 * i for i in range(31)] +WAVE = _longitudinal(WAVE_STATIONS, _wave) +# 경계가 새로 R > 500 이 됨 — 20 m(대수차 −2 %) 직선화 뒤 40 m 대수차 3.5 → 2.5 %(맞춤 뒤 ×1.2 쯤) +GROW_STATIONS, GROW_Z = _from_grades([2.0, 0.0, 3.5, 8.0, 4.0]) +GROW = _longitudinal(GROW_STATIONS, lambda s: np.interp(s, GROW_STATIONS, GROW_Z)) +OPTIONS = resolve_grade_options("trunk", terrain_type="normal") + + +def _design(longitudinal: dict) -> dict: + return design_ground_following_profile(longitudinal, OPTIONS)[0] + + +def _arrays(nodes: list[dict]) -> tuple[np.ndarray, np.ndarray]: + return ( + np.array([float(n["chainage_m"]) for n in nodes]), + np.array([float(n["elevation_m"]) for n in nodes]), + ) + + +def _straight_mask(s: np.ndarray, z: np.ndarray) -> np.ndarray: + grades = np.diff(z) / np.diff(s) + mask = np.zeros(len(s), dtype=bool) + for i in range(1, len(s) - 1): + mask[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i]) + return mask + + +def _check_initial(alignment: dict) -> int: + """모서리 0 · R > 500 꼭짓점 0 · 직선 안쪽 R 없음 — 직선 위 꼭짓점 수를 돌려줌.""" + s, z = _arrays(alignment["base_pvi"]) + straight = _straight_mask(s, z) + curved = {round(float(c["chainage_m"]), 3) for c in alignment["curves"]} + for i in range(1, len(s) - 1): + key = round(float(s[i]), 3) + assert straight[i] != (key in curved), key # 꺾이면 곡선 · 직선이면 곡선 없음 + assert not (flat_bends(s, z, L_M) & ~straight).any() # R > 500 꺾임 없음 + assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"]) + return int(straight.sum()) + + +def test_지반_종단_직선화_모서리_없음(): + alignment = _design(WAVE) + assert alignment["policy"]["default_curve_length_m"] == L_M + assert "no_curve_keys" not in alignment["policy"] + assert _check_initial(alignment) >= 3 + # 기준이 바뀜을 잼 — 직선화 없이 맞추면 R > 500 꺾임이 남음 + s, _z = _arrays(alignment["base_pvi"]) + target = np.array([_wave(v) for v in s]) + plain = pass_through(s, target, AlignmentPolicy.from_dict(alignment["policy"])) + assert flat_bends(s, plain, L_M).sum() >= 3 + + +def test_경계가_새로_R_500_넘으면_묶음을_늘림(): + alignment = _design(GROW) + _check_initial(alignment) + curved = {round(float(c["chainage_m"]), 3) for c in alignment["curves"]} + assert 20.0 not in curved and 40.0 not in curved + assert {60.0, 80.0} <= curved + + +def test_손_편집엔_안_걺(): + alignment = _design(WAVE) + s, z = _arrays(alignment["base_pvi"]) + key = f"{s[np.nonzero(_straight_mask(s, z))[0][0]]:.3f}" + edited = build_alignment( + base_s=s, + base_z=z, + chainage=np.array([r["chainage_m"] for r in WAVE["samples"]]), + ground=np.array([r["elevation_m"] for r in WAVE["samples"]]), + stations=WAVE["stations"], + policy=AlignmentPolicy.from_dict(alignment["policy"]), + edits={"station_offsets": {key: 0.05}}, + ) + curve = next(c for c in edited["curves"] if f"{c['chainage_m']:.3f}" == key) + assert curve["r_m"] > 500.0 # 작은 꺾임도 곡선 · 다시 펴지 않음 + + +def test_추천_적용_균형_종단도_직선화(tmp_path): + from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi + + path = tmp_path / "long.json" + path.write_text(json.dumps({**WAVE, "profile_alignment": _design(WAVE)}), encoding="utf-8") + plan = [_wave(s) - 0.3 + 0.002 * s for s in WAVE_STATIONS] + _set_base_pvi(path, WAVE_STATIONS, plan) + stored = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"] + s, z = _arrays(stored["base_pvi"]) + curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(stored["policy"])) + _check_initial({**stored, "curves": curves}) + # 꼭짓점 사이 직선은 균형 종단 상한 안(419-3) + limit = np.abs(np.diff(plan) / np.diff(WAVE_STATIONS)).max() + assert np.abs(np.diff(z) / np.diff(s)).max() <= limit + 1e-3 + + +def test_TS_거울_첫_계획선(tmp_path): + cases, expected = [], [] + for longitudinal in (WAVE, GROW): + alignment = _design(longitudinal) + cases.append( + { + "samples": longitudinal["samples"], + "stations": [r["chainage_m"] for r in longitudinal["stations"]], + "policy": alignment["policy"], + "startOffsetM": OPTIONS.start_elevation_offset_m, + "endOffsetM": OPTIONS.end_elevation_offset_m, + } + ) + expected.append(alignment["base_pvi"]) + source = tmp_path / "in438.json" + source.write_text(json.dumps(cases), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_438_1_straighten.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + assert proc.returncode == 0, proc.stderr + for got, py in zip(json.loads(proc.stdout), expected, strict=True): + assert [n["chainage_m"] for n in got] == [n["chainage_m"] for n in py] + assert [n["elevation_m"] for n in got] == pytest.approx( + [n["elevation_m"] for n in py], abs=1e-6 + ) + + +def _balanced_427() -> dict: + """427 지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴).""" + data = _long_427() + for node in data["profile_alignment"]["base_pvi"]: + node["elevation_m"] -= 1.0 + alignment, profile = rebuild_alignment_profile(data, {}) + data["profile_alignment"], data["design_profiles"] = alignment, [profile] + return data + + +def test_관_옮김_균형_종단은_지금_계획선_높이(tmp_path): + first = _balanced_427() + stored = copy.deepcopy(first["profile_alignment"]) + s, z = _arrays(stored["base_pvi"]) + curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(stored["policy"])) + before = float(evaluate(s, z, curves, np.array([MOVED]))[0]) + _write(tmp_path, first) + server = _move(tmp_path, MOVED)["profile_alignment"] + row = next(r for r in server["stations"] if r["chainage_m"] == MOVED) + assert row["plan_elevation_m"] == pytest.approx(before, abs=1e-4) + assert abs(row["plan_elevation_m"] - row["ground_elevation_m"]) > 0.5 # 지반에 안 세움 + # 화면 짝(`seatBornNodes`) 같은 꼭짓점 + base = { + "basePvi": stored["base_pvi"], + "chainage": [r["chainage_m"] for r in stored["samples"]], + "ground": [r["ground_elevation_m"] for r in stored["samples"]], + "policy": stored["policy"], + } + [nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [MOVED], "edited": []}]) + assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]] + for mine, theirs in zip(nodes, server["base_pvi"], strict=True): + assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6) + + +def test_부르는_곳(): + def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + profile = read("B05_Profile/B05_Profile_Engine_Grade_Profile.py") + assert "base_z = straight_pass_through(base_s, base_z, policy)" in profile + assert "straight_pass_through(base_s, target, policy, lambda z: _hold(" in read( + "B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py" + ) + assert "no_curve_keys" not in read("B05_Profile/B05_Profile_Engine_Grade_Alignment.py") + assert "no_curve_keys" not in read("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts")