diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index ce8acc6f7..995236acc 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -280,6 +280,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { base: () => base, alignment: () => alignment, edits: () => store.edits(), + savedRadii: () => store.savedRadii(), applyEdits: (next) => applyEdits(next), selectedStationId: () => selectedStationId, irregularStations: () => irregularStations, diff --git a/A00_Common/design/design_profile_base.ts b/A00_Common/design/design_profile_base.ts index e7410f0c6..ddad9ab42 100644 --- a/A00_Common/design/design_profile_base.ts +++ b/A00_Common/design/design_profile_base.ts @@ -44,7 +44,7 @@ export function listedBase( ); return { ...storedBase, - basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat), + basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held), stations: list.map((station) => ({ station_id: station.station_id, chainage_m: station.chainage_m, diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 3ce1036ec..0c3ff91fe 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -28,6 +28,7 @@ from collections import Counter from collections.abc import Callable +from dataclasses import replace from typing import Any import numpy as np @@ -367,11 +368,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 +383,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 +400,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 +513,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, @@ -531,6 +554,16 @@ def rebuild_alignment_profile( policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) previous = (longitudinal.get("design_profiles") or [{}])[0] + criteria = previous.get("criteria") or {} + # 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로 + # 채움(433-13 · TS `readAlignment` 짝) + policy = replace( + policy, + min_vertical_radius_m=policy.min_vertical_radius_m + or float(criteria.get("min_vertical_radius_m") or 0.0), + min_curve_length_m=policy.min_curve_length_m + or float(criteria.get("min_curve_length_m") or 0.0), + ) alignment = build_alignment( base_s=base_s, base_z=base_z, @@ -540,7 +573,6 @@ def rebuild_alignment_profile( policy=policy, edits=edits, ) - criteria = previous.get("criteria") or {} options = GradeDesignOptions( max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct), max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0), diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index f03524cf5..d9a1c43cc 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -213,14 +213,21 @@ async def sync_uphill_overrides_into_designs( _SAME_STATION_M = 0.006 #: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438). _SEAT_GROUND_M = 0.01 +#: 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5). +_SEAT_KEEP_M = 0.001 def _seat_born_nodes( - stored: dict[str, Any], base: list[dict[str, Any]], born: list[float] + stored: dict[str, Any], + base: list[dict[str, Any]], + born: list[float], + held: list[float] = (), ) -> list[dict[str, Any]]: """새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427). - 다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장 + 이웃 꼭짓점은 곡선 모양이 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · + 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점(`held` = 옮긴 뒤 `station_offsets` 측점)과 그 + 밖 꼭짓점은 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장 선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 = `seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = @@ -258,20 +265,41 @@ def _seat_born_nodes( 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]) - base_z = target.copy() - for _ in range(20): + born_rows = np.array([row[2] is None for row in rows]) + # 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것 + target = np.where( + born_rows, + np.array([row[1] for row in rows], dtype=np.float64), + evaluate(pre_s, pre_z, pre_curves, base_s), + ) + can = np.array( + [ + row[2] is not None and all(abs(row[0] - value) >= _SAME_STATION_M for value in held) + for row in rows + ] + ) + free = born_rows.copy() + base_z = np.array([row[1] for row in rows], dtype=np.float64) + for _ in range(len(rows)): + for _ in range(20): + curves, _warnings = build_curves(base_s, base_z, policy) + error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0) + if float(np.max(np.abs(error))) < 1e-6: + break + base_z = base_z + error + # 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼) curves, _warnings = build_curves(base_s, base_z, policy) - error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0) - if float(np.max(np.abs(error))) < 1e-6: + drift = ( + can & ~free & (np.abs(target - evaluate(base_s, base_z, curves, base_s)) > _SEAT_KEEP_M) + ) + if not drift.any(): break - base_z = base_z + error + free |= drift return [ node - if node is not None - else {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} - for (s, _target, node), z in zip(rows, base_z.tolist()) + if node is not None and not keep + else {**(node or {}), "chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} + for (s, _z, node), z, keep in zip(rows, base_z.tolist(), free.tolist()) ] @@ -367,7 +395,8 @@ def _follow_moved_stations( changed = True if new_key is not None and new_key not in reseat: base.append({**node, "chainage_m": float(new_key)}) - seated = _seat_born_nodes(stored, base, seat) + held = [float(key) for key in next_edits["station_offsets"]] + seated = _seat_born_nodes(stored, base, seat, held) changed = changed or len(seated) != len(base) base = seated # 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) · diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index a686aa18c..c943b4944 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -70,6 +70,8 @@ function createHoldRepeater(node: Node): { export interface ProfileEditStore { edits(): AlignmentEdits; + /** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */ + savedRadii(): Readonly>; replace(next: AlignmentEdits): void; /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. * 이때의 값이 [초기화]의 기준점이 된다. */ @@ -209,6 +211,7 @@ function handleOf( return { edits: () => state.current, + savedRadii: () => state.saved.curve_radii ?? {}, replace: commit, markSaved() { state.unsaved = false; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 132e87ed9..54e24ff6d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -47,6 +47,8 @@ export interface PanelToolsContext { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; + /** 서버 저장분 곡선반경 — ▲▼ 자동 R 기준(433-13). */ + savedRadii: () => Readonly>; /** false = 막힘(기울기 · 흙덮기). */ applyEdits: (next: AlignmentEdits) => boolean; /** 고른 측점 id(없으면 null). */ @@ -219,7 +221,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (tools.mode() === "shift") return tools.nudge(delta); const chainage = selectedChainage(); if (chainage === null) return false; - ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())); return true; } // 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1) diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 36095813d..08d7b87d1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -79,6 +79,7 @@ export interface ProfileRenderContext { }; store: { edits: () => AlignmentEdits; + savedRadii: () => Readonly>; resetStation: (chainageM: number) => void; }; detail: () => SectionDetailResponse | null; @@ -203,7 +204,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. onAdjustStation: (chainage, delta) => - base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)), + base && + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())), }) : null; @@ -411,7 +413,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02). onStation: (chainage, delta) => { if (!base) return; - ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())); }, }), ); diff --git a/B05_Profile/logic/B05_Profile_Grade_Seat.ts b/B05_Profile/logic/B05_Profile_Grade_Seat.ts index 7d42b50c2..d9fcd7330 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` 규칙) · 다른 꼭짓점 그대로. + * 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 이웃 꼭짓점은 곡선 모양이 + * 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점은 그대로. * 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). * * 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes` @@ -16,6 +17,8 @@ import { /** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */ const SEAT_GROUND_M = 0.01; +/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5) — 파이썬 `_SEAT_KEEP_M`. */ +const SEAT_KEEP_M = 0.001; /** `np.median` 짝. */ function median(values: number[]): number { @@ -24,11 +27,13 @@ function median(values: number[]): number { return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } -/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */ +/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀 · + * `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(`followMovedNodes` 의 `held`). */ export function seatBornNodes( base: Pick, nodes: AlignmentNode[], born: ReadonlyArray, + held: ReadonlyArray = [], ): AlignmentNode[] { if (!nodes.length || base.chainage.length < 2) return nodes; const first = nodes[0].chainage_m; @@ -59,22 +64,40 @@ export function seatBornNodes( ...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); - let baseZ = [...target]; - for (let round = 0; round < 20; round += 1) { + // 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것 + const target = rows.map((row) => (row.node ? evaluateAt(preS, preZ, preCurves, row.s) : row.z)); + const can = rows.map( + (row) => row.node !== null && held.every((value) => Math.abs(row.s - value) >= 0.006), + ); + const free = rows.map((row) => row.node === null); + let baseZ = rows.map((row) => row.z); + const errorOf = (): number[] => { const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] })); const curves = buildCurves(pvi, base.policy, {}, []); - const error = rows.map((row, index) => - row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]), + return baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s)); + }; + for (let grow = 0; grow < rows.length; grow += 1) { + for (let round = 0; round < 20; round += 1) { + const error = errorOf().map((value, index) => (free[index] ? value : 0)); + if (Math.max(...error.map(Math.abs)) < 1e-6) break; + baseZ = baseZ.map((z, index) => z + error[index]); + } + // 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼) + const drift = errorOf().map( + (value, index) => can[index] && !free[index] && Math.abs(value) > SEAT_KEEP_M, ); - if (Math.max(...error.map(Math.abs)) < 1e-6) break; - baseZ = baseZ.map((z, index) => z + error[index]); + if (!drift.includes(true)) break; + drift.forEach((hit, index) => { + if (hit) free[index] = true; + }); } - return rows.map( - (row, index) => - row.node ?? { - chainage_m: Number(row.s.toFixed(6)), - elevation_m: Number(baseZ[index].toFixed(6)), - }, + return rows.map((row, index) => + row.node && !free[index] + ? row.node + : { + ...row.node, + chainage_m: Number(row.s.toFixed(6)), + elevation_m: Number(baseZ[index].toFixed(6)), + }, ); } diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index a68c8b485..c26217af1 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -561,12 +561,14 @@ const FEEDBACK_TOLERANCE_M = 1e-4; * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. * 누른 점 좌우 다음 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1) · 직선 안 점도 그 점이 새 꺾임(368-1). + * 누른 점 R 이 자동 R(`autoRadii` = 저장분 · 관 정착 등 엔진이 역산한 값)과 같으면 지워 기본 L(433-13) · 사용자 R 은 둠. */ export function adjustStation( base: AlignmentBase, edits: AlignmentEdits, chainageM: number, delta: number, + autoRadii: Readonly> = {}, ): AlignmentEdits { const key = chainageKey(chainageM); const current = buildAlignment(base, edits); @@ -580,6 +582,7 @@ export function adjustStation( const withOffset = (value: number): AlignmentEdits => { const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }; const radii = { ...edits.curve_radii }; + if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key]; axes?.inner.forEach((chainage) => { const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ]; const z = diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts index 2968725fe..e76df599f 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts @@ -17,6 +17,16 @@ export function readAlignment(data: LongitudinalSection): ProfileAlignment | nul station_offsets: candidate.edits?.station_offsets ?? {}, curve_radii: candidate.edits?.curve_radii ?? {}, }; + // 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로 채움 + // (433-13 · 서버 `rebuild_alignment_profile` 짝) + const criteria = data.design_profiles?.[0]?.criteria; + const policy = candidate.policy; + if (criteria && (!policy.min_vertical_radius_m || !policy.min_curve_length_m)) + candidate.policy = { + ...policy, + min_vertical_radius_m: policy.min_vertical_radius_m || criteria.min_vertical_radius_m || 0, + min_curve_length_m: policy.min_curve_length_m || criteria.min_curve_length_m || 0, + }; return candidate; } diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index b310af6c1..092918d85 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -180,6 +180,8 @@ export interface DesignProfile { samples: DesignProfileSample[]; balance_segments: DesignProfileSegment[]; summary: DesignProfileSummary; + /** 첫 계획선 설계 기준(서버 `GradeDesignOptions.as_dict`) — 옛 정책 빈 법정 최소 R · L 채움(433-13). */ + criteria?: { min_vertical_radius_m?: number | null; min_curve_length_m?: number | null }; } export interface LongitudinalSection { diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index b6eda5730..09b644cbc 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -344,14 +344,15 @@ export function oneStationList( nodes: ReadonlyArray, gone: ReadonlyArray, born: ReadonlyArray, edited: ReadonlyArray = [], -): { nodes: N[]; seat: number[] } { +): { nodes: N[]; seat: number[]; held: number[] } { const target = (old: number): number | null => { const near = born.find((value) => Math.abs(value - old) < 0.006); if (near !== undefined) return near; @@ -379,5 +380,10 @@ export function followMovedNodes( const to = moves.get(old) ?? null; return to === null || seat.includes(to) ? [] : [{ ...node, chainage_m: to }]; }); - return { nodes: kept, seat }; + const held = edited.flatMap((key) => { + const old = gone.find((value) => Math.abs(value - Number(key)) < 0.006); + const to = old === undefined ? Number(key) : (moves.get(old) ?? null); + return to === null ? [] : [to]; + }); + return { nodes: kept, seat, held }; } diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index a4a3c0054..fc88620ae 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -89,6 +89,7 @@ tools.createPanelTools({ base: () => ({}), alignment: () => null, edits: () => ({}), + savedRadii: () => ({}), applyEdits: () => undefined, selectedStationId: () => "irregular:p1", irregularStations: () => [pipe], diff --git a/resources/tester/helper_427_1_follow_seat.cjs b/resources/tester/helper_427_1_follow_seat.cjs index 0d915ff69..9cedb64f9 100644 --- a/resources/tester/helper_427_1_follow_seat.cjs +++ b/resources/tester/helper_427_1_follow_seat.cjs @@ -28,6 +28,6 @@ const station = loadTs("common_util/common_util_structure_station.ts"); const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts"); const out = cases.map((item) => { const moved = station.followMovedNodes(item.base.basePvi, item.gone, item.born, item.edited); - return seat.seatBornNodes(item.base, moved.nodes, moved.seat); + return seat.seatBornNodes(item.base, moved.nodes, moved.seat, moved.held); }); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_455_arrow_auto_radius.cjs b/resources/tester/helper_455_arrow_auto_radius.cjs new file mode 100644 index 000000000..1210f49fe --- /dev/null +++ b/resources/tester/helper_455_arrow_auto_radius.cjs @@ -0,0 +1,77 @@ +/* 455 (433-13 ①) 종단 ▲▼ 자동 R — `B05_Profile_UI_Profile_Alignment.ts` adjustStation 을 Node 로 돌림. + * ① 누른 꼭짓점 R = 저장 자동 R → 지움 · 곡선 L = 기본 L ② 사용자 R(저장분과 다름) → 그대로 + * ③ 자동 R 기준을 안 넘기면(옛 부름) → 그대로. 표준출력 = JSON. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +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); + cache.set(full, box.exports); + return box.exports; +} + +const align = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"), +); + +// 20m 측점 · 100m 에 꺾임 하나(대수차 0.5 %) +const stations = Array.from({ length: 11 }, (_, i) => i * 20); +const ground = stations.map((s) => 100 + 0.02 * s + (s > 100 ? 0.005 * (s - 100) : 0)); +const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 15, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, + }, + onlyExplicitCurves: false, + basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })), + chainage: stations, + ground, + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}; +const KEY = "100.000"; +const AUTO = 15 / 0.005; // 관 정착이 역산한 꼴(R = L / A) + +function press(radius, autoRadii) { + const edits = { station_offsets: {}, curve_radii: { [KEY]: radius } }; + const next = + autoRadii === undefined + ? align.adjustStation(base, edits, 100, 0.1) + : align.adjustStation(base, edits, 100, 0.1, autoRadii); + const curve = align + .buildAlignment(base, next) + .curves.find((c) => Math.abs(c.chainage_m - 100) < 1e-6); + return { radius: next.curve_radii[KEY] ?? null, length: curve ? curve.l_m : null }; +} + +process.stdout.write( + JSON.stringify({ + auto: press(AUTO, { [KEY]: AUTO }), + user: press(777, { [KEY]: AUTO }), + legacy: press(AUTO), + }), +); diff --git a/resources/tester/helper_455_old_policy.cjs b/resources/tester/helper_455_old_policy.cjs new file mode 100644 index 000000000..b4b2519b2 --- /dev/null +++ b/resources/tester/helper_455_old_policy.cjs @@ -0,0 +1,38 @@ +/* 455 (433-13 ③) 옛 정책 경고 — 진짜 `B05_Profile_UI_Profile_Data.ts` readAlignment + + * `B05_Profile_UI_Profile_Alignment.ts` buildAlignment 를 Node 로 돌림. + * 입력 argv[2] = 종단 정본 JSON · 표준출력 = { warnings: [법정 미달 글], policy }. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +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 LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const data = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Data.ts")); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const longitudinal = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const stored = data.readAlignment(longitudinal); +const built = align.buildAlignment(align.toAlignmentBase(stored), align.emptyEdits()); +process.stdout.write( + JSON.stringify({ + warnings: built.warnings.filter((text) => text.includes("법정 최소")), + policy: stored.policy, + }), +); diff --git a/resources/tester/test_193_1_line_axis.py b/resources/tester/test_193_1_line_axis.py index ba53cb1fa..9a6e59951 100644 --- a/resources/tester/test_193_1_line_axis.py +++ b/resources/tester/test_193_1_line_axis.py @@ -80,7 +80,9 @@ def test_종단도는_막힌_방향키를_건너뜀() -> None: encoding="utf-8" ) assert "if (event.defaultPrevented) return;" in src - assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src + assert ( + "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()))" in src + ) def _ground(level: float) -> list[dict]: diff --git a/resources/tester/test_355_1_pivot_tilt.py b/resources/tester/test_355_1_pivot_tilt.py index 84fb63679..2c600b80a 100644 --- a/resources/tester/test_355_1_pivot_tilt.py +++ b/resources/tester/test_355_1_pivot_tilt.py @@ -69,10 +69,11 @@ def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case): def test_부르는_곳_셋이_같은_함수(): - assert "adjustStation(base, store.edits(), chainage, delta)" in _read( + # 455 — 자동 R 기준(저장 곡선반경)을 함께 넘김 + assert "adjustStation(base, store.edits(), chainage, delta, store.savedRadii())" in _read( "B05_Profile/B05_Profile_UI_Profile_Render.ts" ) - assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read( + assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in _read( "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts" ) assert "pivotAxes(current, chainageM)" in _read( diff --git a/resources/tester/test_427_1_follow_seat.py b/resources/tester/test_427_1_follow_seat.py index 6b5408ca1..23a316edd 100644 --- a/resources/tester/test_427_1_follow_seat.py +++ b/resources/tester/test_427_1_follow_seat.py @@ -195,3 +195,63 @@ def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None: 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"]): assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6) + + +def _balanced() -> dict: + """지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴).""" + data = _longitudinal() + 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 + + +@pytest.mark.parametrize("make", [_longitudinal, _balanced]) +def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None: + """관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고.""" + first = make() + before = _rows(first, 0, 400) + _write(tmp_path, first) + after = _rows(_move(tmp_path, 356.0), 0, 400) + for at, value in before.items(): + if at != PIPE: + assert abs(after[at] - value) < 0.01, at + if make is _longitudinal: + assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4) + # 화면 짝 같은 꼭짓점 + stored = first["profile_alignment"] + base = { + "basePvi": stored["base_pvi"], + "chainage": [s["chainage_m"] for s in stored["samples"]], + "ground": [s["ground_elevation_m"] for s in stored["samples"]], + "policy": stored["policy"], + } + server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"] + [nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "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_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None: + """340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝.""" + first = _longitudinal({"340.000": 0.3}) + stored = copy.deepcopy(first["profile_alignment"]) + _write(tmp_path, first) + server = _move(tmp_path, 356.0)["profile_alignment"] + old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]} + new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]} + assert new[340.0] == old[340.0] + assert new[360.0] != old[360.0] + base = { + "basePvi": stored["base_pvi"], + "chainage": [s["chainage_m"] for s in stored["samples"]], + "ground": [s["ground_elevation_m"] for s in stored["samples"]], + "policy": stored["policy"], + } + [nodes] = _ts( + tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": ["340.000"]}] + ) + for mine, theirs in zip(nodes, server["base_pvi"], strict=True): + assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6) diff --git a/resources/tester/test_455_arrow_auto_radius.py b/resources/tester/test_455_arrow_auto_radius.py new file mode 100644 index 000000000..0d4142297 --- /dev/null +++ b/resources/tester/test_455_arrow_auto_radius.py @@ -0,0 +1,52 @@ +"""455 (433-13 ①) 종단 ▲▼ — 누른 꼭짓점의 자동 R 을 지워 기본 L. + +까닭: ▲▼ 가 누른 꼭짓점 자신의 R 을 남겨 거의 직선이 된 꼭짓점에 옛 자동 R(관 정착 역산값)로 +짧은 곡선이 생김. 자동 R = 서버 저장분 곡선반경과 같은 값 · 사용자가 넣은 R 은 둠. +계산은 `helper_455_arrow_auto_radius.cjs` 가 진짜 TS 를 Node 로 돌림 · 부르는 곳 셋은 소스로 봄. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_455_arrow_auto_radius.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_자동_R_은_지워_기본_L(out) -> None: + assert out["auto"]["radius"] is None + assert out["auto"]["length"] == pytest.approx(15.0, abs=1e-6) + # 옛 판(지우지 않음)은 같은 R 로 짧은 곡선 — 까닭 근거 + assert out["legacy"]["radius"] == pytest.approx(3000.0) + assert out["legacy"]["length"] < 14.0 + + +def test_사용자_R_은_그대로(out) -> None: + assert out["user"]["radius"] == 777 + + +def test_부르는_곳_셋이_저장_R_을_넘김() -> None: + def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + render = read("B05_Profile/B05_Profile_UI_Profile_Render.ts") + assert render.count("chainage, delta, store.savedRadii())") == 2 + tools = read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in tools + assert "savedRadii: () => store.savedRadii()," in read("A00_Common/design/design_profile.ts") 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 diff --git a/resources/tester/test_455_old_policy_warning.py b/resources/tester/test_455_old_policy_warning.py new file mode 100644 index 000000000..ec3e59e8c --- /dev/null +++ b/resources/tester/test_455_old_policy_warning.py @@ -0,0 +1,105 @@ +"""455 (433-13 ③) 옛 정책 종단 — 법정 최소 R · L 미달 경고가 초안 · 저장 뒤 같음. + +까닭: 옛 저장 정책엔 `min_vertical_radius_m` · `min_curve_length_m` 가 없어 화면 초안(저장 +정책 그대로) 경고 0 · 노선 [저장](새 정책)에서야 뜸. 고침: 빈 값은 첫 계획선 설계 기준(criteria = +설계속도 법정값) +— TS `readAlignment` ↔ 서버 `rebuild_alignment_profile` 같은 규칙(`helper_455_old_policy.cjs`). +""" + +from __future__ import annotations + +import copy +import json +import math +import subprocess +import sys +from pathlib import Path + +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_Profile import ( # noqa: E402 + design_ground_following_profile, + rebuild_alignment_profile, +) + +# 설계속도 40 — 법정 최소 R 450 · L 40 > 기본 L 20 → 곡선마다 미달 +OPTIONS = GradeDesignOptions( + max_grade_pct=14.0, + max_reverse_grade_pct=5.0, + min_vertical_radius_m=450.0, + min_curve_length_m=40.0, + min_tangent_length_m=20.0, + vertical_curve_skip_delta_pct=0.5, + design_speed_kph=40, +) + + +def _ground(s: float) -> float: + return 100.0 + 0.05 * s + 2.0 * math.sin(s / 30.0) + + +def _fresh() -> 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)] + data = {"length_m": 400.0, "samples": samples, "stations": stations} + alignment, profile = design_ground_following_profile(data, OPTIONS, station_interval_m=20.0) + data["profile_alignment"], data["design_profiles"] = alignment, [profile] + return data + + +def _old() -> dict: + data = _fresh() + for key in ("min_vertical_radius_m", "min_curve_length_m"): + data["profile_alignment"]["policy"].pop(key) + return data + + +def _law(warnings: list[str]) -> list[str]: + return [text for text in warnings if "법정 최소" in text] + + +def _ts(tmp_path: Path, data: dict) -> dict: + source = tmp_path / "in455.json" + source.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_455_old_policy.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_새_정책_경고_기준(tmp_path: Path) -> None: + fresh = _fresh() + law = _law(fresh["profile_alignment"]["warnings"]) + assert law + assert _ts(tmp_path, fresh)["warnings"] == law + + +def test_옛_정책_초안_경고_저장_뒤와_같음(tmp_path: Path) -> None: + expected = _law(_fresh()["profile_alignment"]["warnings"]) + old = _old() + ts = _ts(tmp_path, copy.deepcopy(old)) + assert ts["policy"]["min_vertical_radius_m"] == 450.0 + assert ts["policy"]["min_curve_length_m"] == 40.0 + assert ts["warnings"] == expected + alignment, _profile = rebuild_alignment_profile(old, {}) + assert _law(alignment["warnings"]) == expected + assert alignment["policy"]["min_curve_length_m"] == 40.0 + + +def test_criteria_없는_판은_그대로(tmp_path: Path) -> None: + old = _old() + old["design_profiles"][0].pop("criteria") + assert _ts(tmp_path, copy.deepcopy(old))["warnings"] == []