diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 5bd1d5a71..3744807b0 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -16,6 +16,8 @@ scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구 직선 판정 짝: `is_straight_bend` · `STRAIGHT_HEIGHT_M` ↔ TS `logic/B05_Profile_Profile_Straight.ts` (거울 시험 `test_426_profile_straight.py`). +경고 글 짝: `build_alignment` warnings ↔ TS `buildAlignment` warnings — 같은 목록 +(거울 시험 `test_472_save_warnings.py`). """ from dataclasses import dataclass @@ -369,7 +371,7 @@ def build_curves( continue if length / 2.0 - half > 1e-6: warnings.append( - f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 " + f"{chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 " f"{half * 2.0:.1f}m(반경 {half * 2.0 / abs(delta):.0f}m)로 줄였습니다." ) length = half * 2.0 @@ -592,18 +594,9 @@ def build_alignment( for segment in segments if abs(segment["grade_percent"]) > policy.max_grade_pct + 1e-6 ] - if violations: - worst = max(abs(item["value"]) for item in violations) - warnings.append( - f"종단기울기 {worst:.2f}%가 기준 {policy.max_grade_pct:.2f}%를 초과하는 구간이 " - f"{len(violations)}개 있습니다." - ) + # 경고 글 = TS `buildAlignment` 와 한 벌(472 덧 · 거울 `test_472_save_warnings`) — 기울기 초과 · + # 불균형은 글로 안 남김(`violations` · `balance` 칸이 정본 · 화면 칩이 그 칸으로 셈) within_tolerance = imbalance <= policy.balance_tolerance_percent + 1e-9 - if not within_tolerance: - warnings.append( - f"절·성토 불균형 {imbalance:.1f}%가 허용치 " - f"{policy.balance_tolerance_percent:.1f}%를 초과합니다." - ) return { "schema_version": ALIGNMENT_SCHEMA_VERSION, diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index fae86cfec..0b8d11742 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -229,6 +229,42 @@ def _enforce_stored_designs( enforce_ford_surface_drops(longitudinal, sections, project_root, standard) +def refresh_alignment(longitudinal: dict[str, Any]) -> dict[str, Any] | None: + """종단 경고를 저장 입력(base_pvi · 편집 · 정책 · 기준)으로 지금 코드가 다시 셈 — 옛 코드가 + 쓴 경고가 남지 않게(472 덧 · 요구 272). 기하(계획고 · 곡선 · 표본 · 위반)는 저장값 그대로 — + 저장값이 6 자리 반올림이라 통째로 다시 쓰면 끝자리가 흔들림(계획고 1e-6 m → 면적 1e-4 ㎡). + 반환 = 요약(`grade_summary`) · 선형이 없는 옛 종단이면 None(그대로).""" + from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile + + stored = longitudinal.get("profile_alignment") + if not isinstance(stored, dict) or not stored.get("base_pvi"): + return None + fresh, profile = rebuild_alignment_profile(longitudinal, stored.get("edits")) + stored["warnings"] = fresh["warnings"] + profiles = longitudinal.get("design_profiles") + if isinstance(profiles, list) and profiles and isinstance(profiles[0], dict): + profile = profiles[0] + profile.setdefault("summary", {})["warnings"] = fresh["warnings"] + else: + longitudinal["design_profiles"] = [profile] + return {"id": profile.get("id"), **profile["summary"]} + + +def _refresh_alignment_file(project_root: Path, relative: str) -> dict[str, Any] | None: + """종단 정본 파일의 선형을 다시 셈 · 바뀐 때만 씀. 파일 · 선형이 없으면 None.""" + from common_util.common_util_json import atomic_write_json + + path = project_root / relative + if not path.is_file(): + return None + longitudinal = json.loads(path.read_text(encoding="utf-8")) + before = json.dumps(longitudinal, sort_keys=True, default=str) + summary = refresh_alignment(longitudinal) + if summary is not None and json.dumps(longitudinal, sort_keys=True, default=str) != before: + atomic_write_json(path, longitudinal) + return summary + + async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: """포장 구간·세월교 노면 하강 보정에 더해 구조물 면적·유토곡선까지 Node 로 만들어 저장한다. @@ -240,19 +276,40 @@ async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: async def _recompute(project_id: UUID | str, route_id: int) -> int: + from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary from B06_Section.B06_Section_Router import get_section_detail project_uuid = UUID(str(project_id)) marks = [("시작", time.perf_counter())] - # 상세 만들기(파일 읽기 위주)와 DB 두 건은 서로 기다릴 이유가 없다 — 같이 보낸다. - # 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측). + # 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측) — 같이 보냄. pool = get_db_pool() - # 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면 - # 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다). - response, stored_path, longitudinal_row, haul_inputs, surface = await asyncio.gather( - get_section_detail(project_uuid, route_id), + stored_path, longitudinal_row = await asyncio.gather( run_with_connection(get_project_storage_relative_path, project_uuid), run_with_connection(get_longitudinal_section, project_uuid, route_id), + ) + project_root = Path(resolve_stored_project_path(stored_path)) + # 종단 경고 먼저 — 저장 입력으로 다시 셈(472 덧) · 아래 상세가 새 경고를 읽음 + summary = await asyncio.to_thread( + _refresh_alignment_file, + project_root, + str((longitudinal_row or {}).get("longitudinal_file_path") or ""), + ) + if summary is not None: + async with pool.acquire() as connection: + await connection.begin() + try: + await update_longitudinal_grade_summary( + connection, route_id=route_id, grade_summary=summary + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + marks.append(("종단 선형 다시 셈", time.perf_counter())) + # 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면 + # 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다). + response, haul_inputs, surface = await asyncio.gather( + get_section_detail(project_uuid, route_id), haul_inputs_for(project_uuid), run_with_connection(get_surface_confirmation_params, str(project_uuid)), ) @@ -262,7 +319,6 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: raise RuntimeError(f"서버 재계산: 종횡단 상세를 못 받음 (route_id={route_id})") detail = payload(mode="json") sections = detail.get("cross_sections") or [] - project_root = Path(resolve_stored_project_path(stored_path)) from B06_Section.B06_Section_Router_Design import stored_standard_cross_section standard = stored_standard_cross_section(longitudinal_row) diff --git a/docs/wiki/concepts/design_page/calculations.md b/docs/wiki/concepts/design_page/calculations.md index 435b5a144..2fbf731b3 100644 --- a/docs/wiki/concepts/design_page/calculations.md +++ b/docs/wiki/concepts/design_page/calculations.md @@ -33,7 +33,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md | P5 | 노선 바뀜 계획고 옮김 | `logic/B05_Profile_Grade_Carry.ts:188` | `B05_Profile_Engine_Grade_Carry.py:149` | — | 짝 · `test_400_profile_carry` · `test_349_0v_route_save_span` | | P6 | 옮긴 시설 꼭짓점 세움 | `logic/B05_Profile_Grade_Seat.ts:32` · `common_util_structure_station.ts:356` | `B05_Profile_Router_Confirm.py:220 · 306` | — | 짝 · `test_427_1_follow_seat` · `test_459_route_seat_mirror` | | P7 | 직선화 · 자유지정 · 쉬프트 · 시종점 | `logic/B05_Profile_UI_Profile_Straighten.ts` · `logic/design_profile_free.ts` · `logic/B05_Profile_UI_Profile_Pivot.ts` · `logic/B05_Profile_EndOffsets.ts` | — | — | 브라우저(편집값만 저장 → P2) | -| P8 | 종단 경고(기울기 · 법정 L · 최소 R · 피복) | `B05_Profile_UI_Panel_Fields.ts` · `logic/B05_Profile_UI_Profile_Guard.ts` · `B05_Profile_UI_Profile_MinCover.ts` | `B05_Profile_Engine_Grade.py` · `B05_Profile_PageSettings.py` | — | 짝 · `test_442_2_grade_limit_mirror` | +| P8 | 종단 경고(기울기 · 법정 L · 최소 R · 피복) | `B05_Profile_UI_Panel_Fields.ts` · `logic/B05_Profile_UI_Profile_Guard.ts` · `B05_Profile_UI_Profile_MinCover.ts` · 경고 글 `logic/B05_Profile_UI_Profile_Alignment.ts` buildAlignment | `B05_Profile_Engine_Grade.py` · `B05_Profile_PageSettings.py` · 경고 글 `B05_Profile_Engine_Grade_Alignment.py` build_alignment | — | 짝 · [저장] · [확정] = `B06_Section_Server_Calc_Prebuild.py` refresh_alignment 가 경고만 다시 씀(기하 그대로) · `test_442_2_grade_limit_mirror` · `test_472_save_warnings` | | C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195` | ex | 짝 · `test_b06_cross_design_mirror` | | C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | sc · ex | 짝 · 정본 = 표준 측점 파이썬 · 구조물 측점 Node | | C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design.py` · `B06_Section_Engine_Tilt.py` | — | 짝 · `test_349_0n_cross_enforce_mirror` | diff --git a/docs/wiki/concepts/design_page/operations_common.md b/docs/wiki/concepts/design_page/operations_common.md index 53f0dce06..7517c1ae5 100644 --- a/docs/wiki/concepts/design_page/operations_common.md +++ b/docs/wiki/concepts/design_page/operations_common.md @@ -35,7 +35,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | alignment | `flushAlignmentDraft` | `PUT /route/profile-alignment` | 초안 노선 0 편집을 새 노선에 | `design_save.ts:185-186` | | culvertOptions | `flushCulvertOptions`(`culvertopt` · 옛 노선 열쇠) | `pipe_points.json` 한 번 더 PUT | 옛 노선 열쇠로 읽음 | `design_save.ts:187-188` · `B06_Section_Api_Culvert_Options.ts:67-95` | | uphill | `flushRouteParts` | 상단측 | 건너뜀 — 횡단 차례가 옮긴 뒤 보냄 | `design_save.ts:189-190 · 285` · `B05_Profile_Api_Fetch.ts:321-342` | -| cross | `ctx.cross(confirm)` = 횡단 패치 한 요청 | `/sections/{노선}/save` · `confirm` | `carryAfterRouteSave`(새 측점으로 옮김) | `design_save.ts:275-283` | +| cross | `ctx.cross(confirm)` = 횡단 패치 한 요청 | `/sections/{노선}/save` · `confirm` · 재계산 맨 앞 종단 경고 다시 씀(`B06_Section_Server_Calc_Prebuild.py` refresh_alignment) | `carryAfterRouteSave`(새 측점으로 옮김) | `design_save.ts:275-283` | - 차례마다 따로 커밋 — 실패 = 그 차례에서 멈춤 · 이름째 알림 · 앞 차례는 저장된 채(`design_save.ts:297-299`). - 저장 중 = `saving` 표시 · 묶음 맞춤은 저장 뒤로 미룸(`design_pipeline_page.ts:567`). diff --git a/resources/tester/fixtures/golden_472/golden_472_values.json b/resources/tester/fixtures/golden_472/golden_472_values.json index 0d7d7117b..e2cd782ae 100644 --- a/resources/tester/fixtures/golden_472/golden_472_values.json +++ b/resources/tester/fixtures/golden_472/golden_472_values.json @@ -1850,11 +1850,56 @@ "pipe:p35100": 7.9 }, "warnings": [ - "chainage 1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.", - "chainage 340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.", - "chainage 351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.", - "chainage 360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.", - "종단기울기 56.60%가 기준 14.00%를 초과하는 구간이 34개 있습니다." + "100.0m: 종단곡선 R 57m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "1000.0m: 종단곡선 R 73m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "1020.0m: 종단곡선 R 31m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "1060.0m: 종단곡선 R 25m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.", + "1080.0m: 종단곡선 R 1m · L 0.1m — 법정 최소 R 100m · L 20m 미달", + "140.0m: 종단곡선 R 53m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "20.0m: 종단곡선 R 205m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "200.0m: 종단곡선 R 154m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "220.0m: 종단곡선 R 285m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "240.0m: 종단곡선 R 61m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "260.0m: 종단곡선 R 24m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "280.0m: 종단곡선 R 21m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "300.0m: 종단곡선 R 196m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "320.0m: 종단곡선 R 118m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.", + "340.0m: 종단곡선 R 26m · L 9.9m — 법정 최소 R 100m · L 20m 미달", + "351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.", + "351.0m: 종단곡선 R 25m · L 8.1m — 법정 최소 R 100m · L 20m 미달", + "360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.", + "40.0m: 종단곡선 R 55m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "400.0m: 종단곡선 R 105m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "420.0m: 종단곡선 R 100m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "440.0m: 종단곡선 R 117m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "460.0m: 종단곡선 R 48m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "480.0m: 종단곡선 R 46m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "520.0m: 종단곡선 R 24m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "540.0m: 종단곡선 R 41m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "560.0m: 종단곡선 R 53m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "580.0m: 종단곡선 R 123m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "600.0m: 종단곡선 R 40m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "620.0m: 종단곡선 R 88m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "640.0m: 종단곡선 R 147m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "660.0m: 종단곡선 R 28m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "680.0m: 종단곡선 R 14m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "700.0m: 종단곡선 R 49m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "720.0m: 종단곡선 R 38m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "740.0m: 종단곡선 R 55m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "760.0m: 종단곡선 R 36m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "780.0m: 종단곡선 R 54m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "80.0m: 종단곡선 R 58m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "800.0m: 종단곡선 R 13m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "820.0m: 종단곡선 R 45m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "840.0m: 종단곡선 R 29m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "880.0m: 종단곡선 R 29m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "900.0m: 종단곡선 R 115m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "920.0m: 종단곡선 R 44m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "940.0m: 종단곡선 R 66m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "960.0m: 종단곡선 R 23m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "980.0m: 종단곡선 R 42m · L 15.0m — 법정 최소 R 100m · L 20m 미달" ], "violations": 34 }, @@ -2783,6 +2828,7 @@ "1000.0m: 종단곡선 R 73m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "1020.0m: 종단곡선 R 31m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "1060.0m: 종단곡선 R 25m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.", "1080.0m: 종단곡선 R 1m · L 0.1m — 법정 최소 R 100m · L 20m 미달", "140.0m: 종단곡선 R 53m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "20.0m: 종단곡선 R 205m · L 15.0m — 법정 최소 R 100m · L 20m 미달", @@ -2793,8 +2839,11 @@ "280.0m: 종단곡선 R 21m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "300.0m: 종단곡선 R 196m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "320.0m: 종단곡선 R 118m · L 15.0m — 법정 최소 R 100m · L 20m 미달", + "340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.", "340.0m: 종단곡선 R 26m · L 9.9m — 법정 최소 R 100m · L 20m 미달", + "351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.", "351.0m: 종단곡선 R 25m · L 8.1m — 법정 최소 R 100m · L 20m 미달", + "360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.", "40.0m: 종단곡선 R 55m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "400.0m: 종단곡선 R 105m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "420.0m: 종단곡선 R 100m · L 15.0m — 법정 최소 R 100m · L 20m 미달", @@ -2824,12 +2873,7 @@ "920.0m: 종단곡선 R 44m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "940.0m: 종단곡선 R 66m · L 15.0m — 법정 최소 R 100m · L 20m 미달", "960.0m: 종단곡선 R 23m · L 15.0m — 법정 최소 R 100m · L 20m 미달", - "980.0m: 종단곡선 R 42m · L 15.0m — 법정 최소 R 100m · L 20m 미달", - "chainage 1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.", - "chainage 340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.", - "chainage 351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.", - "chainage 360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.", - "종단기울기 56.60%가 기준 14.00%를 초과하는 구간이 34개 있습니다." + "980.0m: 종단곡선 R 42m · L 15.0m — 법정 최소 R 100m · L 20m 미달" ], "violations": 34 }, diff --git a/resources/tester/helper_472_1_paths.py b/resources/tester/helper_472_1_paths.py index 3c0c33035..587b9b9d7 100644 --- a/resources/tester/helper_472_1_paths.py +++ b/resources/tester/helper_472_1_paths.py @@ -1,8 +1,8 @@ """472-1 기준값 — 고정 자료(`fixtures/golden_472`) 하나로 길 넷의 값을 뜸(시험 · 값 뜨기 함께 씀). stored GET 상세(정본 그대로) → 누가토량(서버 Node 묶음 `computeMassHaul`) - server_save [저장] 서버 재계산 — 정본 상세 → `enforce_design_lines` → - `_enforce_stored_designs` → server-calc + server_save [저장] 서버 재계산 — 정본 상세 → 종단 경고 다시 셈(`refresh_alignment` · 472 덧) → + `enforce_design_lines` → `_enforce_stored_designs` → server-calc server_route 노선 저장 체인(같은 노선 · DB 없이) — `run_section_generation`(고정 geojson · npz) → `_carry_profile` 같은 노선 갈래(옛 base_pvi · 편집) → `_apply_alignment_edits` → 측점 열쇠 이월(`carry_cross_inputs`) → `compute_default_designs` → 상세 읽기 → @@ -125,19 +125,21 @@ def _context() -> dict[str, Any]: def _save_tail(detail: dict[str, Any], root: Path) -> tuple[list, dict | None]: - """[저장] 재계산 본문(`_recompute`) — 설계선 · 넓힘 · 보정 → Node 면적 · 누가토량 얹음.""" + """[저장] 재계산(`_recompute`) — 종단 경고 · 설계선 · 넓힘 · 보정 → Node 면적 · 누가토량.""" from B06_Section.B06_Section_Router_Design import enforce_design_lines from B06_Section.B06_Section_Server_Calc_Prebuild import ( _AREA_KEYS, _NPM_SCRIPT, BUNDLE, _enforce_stored_designs, + refresh_alignment, ) from common_util.common_util_node_bundle import run_bundle_json from common_util.common_util_surface_sampler import build_surface_sampler longitudinal = detail["longitudinal"] sections = detail["cross_sections"] + refresh_alignment(longitudinal) enforce_design_lines(longitudinal, sections, root, None) unclosed = any((s.get("design") or {}).get("slope_unclosed") for s in sections) sampler = ( @@ -163,9 +165,9 @@ def _save_tail(detail: dict[str, Any], root: Path) -> tuple[list, dict | None]: def server_save(root: Path) -> dict[str, Any]: detail = copy.deepcopy(read("detail.json")) - alignment = detail["longitudinal"].get("profile_alignment") or {} keys = _keys(detail["longitudinal"], detail["cross_sections"], root) sections, haul = _save_tail(detail, root) + alignment = detail["longitudinal"].get("profile_alignment") or {} return values_of( sections, keys, haul, alignment.get("warnings") or [], alignment.get("violations") or [] ) diff --git a/resources/tester/helper_472_save_warnings.cjs b/resources/tester/helper_472_save_warnings.cjs new file mode 100644 index 000000000..e1c90c6d0 --- /dev/null +++ b/resources/tester/helper_472_save_warnings.cjs @@ -0,0 +1,36 @@ +/* 472 덧 종단 경고 거울 헬퍼 — `logic/B05_Profile_UI_Profile_Alignment.ts` `buildAlignment` 를 그 자리에서 + * 트랜스파일해 돌림. 입력 argv[2] JSON { base, cases: [edits] } · 표준출력 JSON [{ warnings, violations }]. + */ +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) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const align = loadTs( + path.join(__dirname, "..", "..", "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"), +); +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +console.log( + JSON.stringify( + input.cases.map((edits) => { + const built = align.buildAlignment(input.base, edits); + return { warnings: built.warnings, violations: built.violations.length }; + }), + ), +); diff --git a/resources/tester/test_472_1_golden.py b/resources/tester/test_472_1_golden.py index abd7e47bd..aff996468 100644 --- a/resources/tester/test_472_1_golden.py +++ b/resources/tester/test_472_1_golden.py @@ -44,10 +44,15 @@ class Known: KNOWN: dict[tuple[str, str], list[Known]] = { - ( - "server_save", - "stored", - ): [], # [저장] 재계산 = 정본 그대로(사용자 원칙 — 입력 안 바뀌면 값 안 바뀜) + # [저장] 재계산 = 정본 그대로(사용자 원칙 — 입력 안 바뀌면 값 안 바뀜) · 경고만 지금 코드 + ("server_save", "stored"): [ + Known( + "W0", + {"warnings": None}, + "정본 종단 경고 = 10-17 저장 때 코드(법정 R · L 미달 46 줄 전) · " + "[저장] 재계산이 지금 코드로 다시 씀(472 덧 · 기하 그대로)", + ), + ], ("server_route", "stored"): [ Known( "R7", @@ -58,7 +63,7 @@ KNOWN: dict[tuple[str, str], list[Known]] = { "W0", {"warnings": None}, "정본 종단 경고 = 10-17 저장 때 코드(법정 R · L 미달 46 줄 전) · " - "[저장] 재계산은 종단 경고를 안 씀", + "노선 저장 체인은 지금 코드", ), ], ("browser", "server_route"): [ @@ -76,12 +81,6 @@ KNOWN: dict[tuple[str, str], list[Known]] = { "측점 자리 ≤ 1 mm → 표본 · 면적 ≤ 0.07 ㎡ · 층따기 밑수 ≤ 0.52 m · " "누가토량 끝값 0.69 ㎥(−17,570.01 ↔ −17,570.70)", ), - Known( - "W1", - {"warnings": None}, - "종단 경고 글 두 벌(X5 · P8) — TS 「340.0m: …」 · 파이썬 「chainage 340.0m: …」 · " - "최대 기울기 총괄 줄은 파이썬만", - ), ], } diff --git a/resources/tester/test_472_save_warnings.py b/resources/tester/test_472_save_warnings.py new file mode 100644 index 000000000..f50f79307 --- /dev/null +++ b/resources/tester/test_472_save_warnings.py @@ -0,0 +1,130 @@ +"""PLAN 472 덧 (요구 272) — [저장] · [확정] 재계산이 종단 경고를 저장 입력으로 다시 씀. + +증상 — 검증_랩탑메인 정본 종단 경고 5 줄(10-17 옛 코드) · 지금 코드로 셈하면 50 줄 · +[저장] · [확정] 재계산(`B06_Section_Server_Calc_Prebuild._recompute`)이 경고를 다시 안 셈. +고침 — `_recompute` 맨 앞 `_refresh_alignment_file` → `refresh_alignment`(저장 base_pvi · 편집 · +정책 · 기준 → `rebuild_alignment_profile` 의 경고만) · 기하는 저장값 그대로 · 같으면 안 씀. +경고 글 = TS `buildAlignment` 와 한 벌(거울). 고정 자료 = `fixtures/golden_472/detail.json`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_save_warnings.py -q +""" + +from __future__ import annotations + +import copy +import inspect +import json +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade import ground_profile # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402 +from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # noqa: E402 + +FIXTURE = HERE / "fixtures" / "golden_472" +DETAIL = json.loads((FIXTURE / "detail.json").read_text(encoding="utf-8")) + + +def _longitudinal() -> dict: + return copy.deepcopy(DETAIL["longitudinal"]) + + +def test_정본_경고는_옛_코드_5줄() -> None: + assert len(DETAIL["longitudinal"]["profile_alignment"]["warnings"]) == 5 + + +def test_다시_셈_경고만_새로_기하는_그대로() -> None: + longitudinal = _longitudinal() + before = copy.deepcopy(longitudinal) + summary = prebuild.refresh_alignment(longitudinal) + warnings = longitudinal["profile_alignment"]["warnings"] + assert len(warnings) == 50 + assert sum("법정 최소 R" in w for w in warnings) == 46 # 432 법정 R · L 미달 + assert not [w for w in warnings if w.startswith("chainage ")] + assert summary is not None and summary["warnings"] == warnings + assert summary["id"] == before["design_profiles"][0]["id"] + assert longitudinal["design_profiles"][0]["summary"]["warnings"] == warnings + # 경고 밖 = 저장값 그대로(계획고 · 곡선 · 표본 · 위반 · 정책 — 6 자리 반올림 끝자리도) + longitudinal["profile_alignment"]["warnings"] = before["profile_alignment"]["warnings"] + longitudinal["design_profiles"][0]["summary"]["warnings"] = before["design_profiles"][0][ + "summary" + ]["warnings"] + assert longitudinal == before + + +def test_선형_없는_옛_종단은_그대로() -> None: + longitudinal = _longitudinal() + longitudinal["profile_alignment"].pop("base_pvi") + keep = copy.deepcopy(longitudinal) + assert prebuild.refresh_alignment(longitudinal) is None + assert longitudinal == keep + + +def test_파일_다시_씀_같으면_안_씀(tmp_path) -> None: + path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(_longitudinal()), encoding="utf-8") + relative = "B06_Section/longitudinal/longitudinal.json" + first = prebuild._refresh_alignment_file(tmp_path, relative) + stored = json.loads(path.read_text(encoding="utf-8")) + assert first is not None and len(stored["profile_alignment"]["warnings"]) == 50 + written = path.stat().st_mtime_ns + assert prebuild._refresh_alignment_file(tmp_path, relative) == first + assert path.stat().st_mtime_ns == written # 같은 값 — 다시 안 씀 + assert prebuild._refresh_alignment_file(tmp_path, "없음/longitudinal.json") is None + + +def test_재계산_맨_앞에서_선형_다시_셈() -> None: + source = inspect.getsource(prebuild._recompute) + assert "_refresh_alignment_file" in source and "update_longitudinal_grade_summary" in source + assert source.index("_refresh_alignment_file") < source.index("get_section_detail(") + + +def test_TS_거울_같은_경고_목록(tmp_path) -> None: + longitudinal = _longitudinal() + alignment = longitudinal["profile_alignment"] + # 옛 정책엔 법정 R · L 이 없음 — 두 쪽 다 기준으로 채운 정책(433-13 · TS `readAlignment`) + policy = rebuild_alignment_profile(longitudinal, alignment["edits"])[0]["policy"] + assert ( + policy["min_vertical_radius_m"] == 100.0 + and "min_vertical_radius_m" not in alignment["policy"] + ) + chainage, ground = ground_profile(longitudinal) + curve = alignment["curves"][1] + station = longitudinal["stations"][5] + cases = [ + alignment["edits"], + {"station_offsets": {}, "curve_radii": {chainage_key(curve["chainage_m"]): 500.0}}, + {"station_offsets": {chainage_key(station["chainage_m"]): 3.0}, "curve_radii": {}}, + ] + base = { + "policy": policy, + "onlyExplicitCurves": bool(alignment.get("only_explicit_curves")), + "basePvi": alignment["base_pvi"], + "chainage": chainage.tolist(), + "ground": ground.tolist(), + "stations": [ + {"station_id": s.get("station_id"), "chainage_m": s["chainage_m"]} + for s in longitudinal["stations"] + ], + } + source = tmp_path / "in.json" + source.write_text(json.dumps({"base": base, "cases": cases}), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_472_save_warnings.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + for edits, got in zip(cases, json.loads(proc.stdout), strict=True): + py, _profile = rebuild_alignment_profile(copy.deepcopy(longitudinal), edits) + assert got["warnings"] == py["warnings"] + assert got["violations"] == len(py["violations"])