From 6836bcef1f183e4b3f9d39e70c87727efb06edfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:43:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(455=20433-13=E2=91=A2=20=EC=98=9B=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B2=BD=EA=B3=A0):=20=EC=98=9B=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=B9=88=20=EB=B2=95=EC=A0=95=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20R=20=C2=B7=20L=20=3D=20=EC=B2=AB=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=A0=20=EC=84=A4=EA=B3=84=20=EA=B8=B0=EC=A4=80(cr?= =?UTF-8?q?iteria)=20=E2=80=94=20=ED=99=94=EB=A9=B4=20readAlignment=20?= =?UTF-8?q?=E2=86=94=20=EC=84=9C=EB=B2=84=20rebuild=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=C2=B7=20=EC=B4=88=EC=95=88=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=3D=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Engine_Grade_Profile.py | 12 +- .../logic/B05_Profile_UI_Profile_Data.ts | 10 ++ B06_Section/B06_Section_Api_Types.ts | 2 + resources/tester/helper_455_old_policy.cjs | 38 +++++++ .../tester/test_455_old_policy_warning.py | 105 ++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 resources/tester/helper_455_old_policy.cjs create mode 100644 resources/tester/test_455_old_policy_warning.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 11aeab2cc..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 @@ -553,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, @@ -562,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/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/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_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"] == []