From 98a15432b8b96a787a4e1f34458a1863464c9a5c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 7 Oct 2026 18:32:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=C2=B7B06):=20=EB=85=B8=EC=84=A0=20[?= =?UTF-8?q?=ED=99=95=EC=9D=B8]=20=EC=9D=B4=EC=9B=94=EC=9D=80=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9E=85=EB=A0=A5=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B7=A0=ED=98=95=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?1=EC=B0=A8=20=EC=A2=85=EB=8B=A8=20=C2=B7=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=3D=20=EC=83=88=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92(74-3=C2=B774-4=C2=B774-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [확인] 체인 — 옛 설계 통째 이월 대신 사용자 입력 칸(CARRY_INPUT_KEYS)만 누가거리로 얹고 전 측점을 새 계획고로 다시 셈 · 새 노선에 없는 누가거리 행 안 만듦 - 「종단 반영」 켬으로 [적용]한 추천 그대로 [확인] = 서버가 같은 코드(route_profile)로 균형 종단을 다시 셈해 base_pvi 로 · 요청엔 켬/끔 표시만 - [확인] 성공 뒤 initial_snapshot 다시 뜸(save_initial_snapshot 재사용) - [확인] 안내 글(title) · 「종단 반영」 체크박스를 갈래 알약 줄 오른쪽 토글 단추로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B03_FileInput/B03_FileInput_Service_Chain.py | 49 +++++-- B05_Profile/B05_Profile_Router_Replan.py | 33 ++++- .../B05_Profile_Router_RouteInitial.py | 129 ++++++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 11 +- .../B05_Profile_UI_RouteEdit_Chrome.ts | 3 +- .../B05_Profile_UI_RouteEdit_Initial.ts | 10 ++ .../B05_Profile_UI_RouteEdit_Recommend.ts | 34 ++--- .../B05_Profile_UI_Style_RouteRecommend.css | 38 ++++-- B06_Section/B06_Section_Router_Design.py | 35 ++++- .../tester/test_74_route_confirm_initial.py | 53 +++++++ 10 files changed, 346 insertions(+), 49 deletions(-) create mode 100644 resources/tester/test_74_route_confirm_initial.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 6e8dfec71..703c27d4e 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -15,6 +15,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어 의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다. """ +import asyncio import logging import time from pathlib import Path @@ -393,6 +394,7 @@ async def run_redesign_chain( project_id: UUID, surface_model_id: int, selection: dict[str, Any], + profile_target: tuple[list[float], list[float]] | None = None, ) -> str | None: """B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장. @@ -406,6 +408,9 @@ async def run_redesign_chain( 측점은 확정 시 기본값으로 채워진다. 경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다. + + `profile_target`(누가거리 · 계획고)이 오면 1차 종단을 그 계획고로 세운다 — 노선 추천 + 「종단 반영」 켬으로 [적용]한 노선의 균형 종단(PLAN 74-4). 없으면 계획고 = 지반고. """ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Repository import get_latest_route @@ -416,7 +421,8 @@ async def run_redesign_chain( get_longitudinal_section, ) from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs - from B06_Section.B06_Section_Router_Confirm import confirm_sections + from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections + from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs from B06_Section.B06_Section_Schema import SectionConfirmRequest from common_util.common_util_initial_snapshot import ( clear_designing, @@ -557,14 +563,40 @@ async def run_redesign_chain( marks.append(("배수·관·측점 재생성", time.perf_counter())) - # 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 설계를 **누가거리로** 얹는다. - # 묶어 쓰므로 행 수와 무관하게 왕복 두 번이다(`merge_cross_section_designs`). + if profile_target is not None: + from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target + + await apply_profile_target(project_id, new_route_id, profile_target) + marks.append(("균형 종단 반영", time.perf_counter())) + + old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {} + standard = old_options.get("standard_cross_section") or None + + # 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 **입력**만 누가거리로 얹고 전 측점을 + # 새 계획고로 다시 셈한다(PLAN 74-3). 결과 칸(계획고 · 면적 · design_line …)을 통째 + # 옮기면 옛 노선 단면이 새 노선에 남아 누가토량이 두 배가 됐다(74-1). 새 노선에 없는 + # 누가거리는 행을 만들지 않는다. try: - entries = [ - (float(record["chainage_m"]), record["design"]) + inputs = { + round(float(record["chainage_m"]), 3): { + key: record["design"][key] + for key in CARRY_INPUT_KEYS + if record["design"].get(key) is not None + } for record in old_designs if isinstance(record.get("design"), dict) - ] + } + async with pool.acquire() as connection: + new_longitudinal = await get_longitudinal_section( + connection, project_id, new_route_id + ) + long_path = str((new_longitudinal or {}).get("longitudinal_file_path") or "") + chainages = await asyncio.to_thread( + _rowless_station_chainages, project_root, long_path, [] + ) + entries = await asyncio.to_thread( + compute_default_designs, project_root, long_path, chainages, standard, inputs + ) async with pool.acquire() as connection: await connection.begin() try: @@ -587,11 +619,8 @@ async def run_redesign_chain( marks.append(("설계 이월", time.perf_counter())) - old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {} section_request = ( - SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"]) - if old_options.get("standard_cross_section") - else None + SectionConfirmRequest(standard_cross_section=standard) if standard else None ) sections_result = await confirm_sections( project_id, new_route_id, section_request, mark_stage_complete=False diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 902f5390c..dcdf6faf8 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -128,6 +128,9 @@ class RouteReplanRequest(BaseModel): vertices: list[RouteVertexInput] = Field(default_factory=list) initial: RouteInitialRequest | None = None """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" + balanced_profile: bool = False + """「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해 + 1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만.""" async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: @@ -375,8 +378,16 @@ def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int return len(dense) -async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> dict[str, Any]: - """노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다.""" +async def _recompute( + project_id: UUID, + project_root: Path, + stored_path: str, + balanced: RouteInitialRequest | None = None, +) -> dict[str, Any]: + """노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다. + + `balanced`(추천 갈래 · 제약)가 오면 그 제약으로 균형 종단을 셈해 1차 종단으로 쓴다(PLAN 74-4). + """ import aiomysql from B03_FileInput.B03_FileInput_Service_Chain import ( @@ -454,7 +465,12 @@ async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter())) - failure = await run_redesign_chain(project_id, int(surface_model_id), selection) + target = None + if balanced is not None: + from B05_Profile.B05_Profile_Router_RouteInitial import balanced_target + + target = await balanced_target(project_id, project_root, points, balanced.criteria) + failure = await run_redesign_chain(project_id, int(surface_model_id), selection, target) marks.append(("재확정 체인", time.perf_counter())) _log_steps("노선 [확인] 재계산", marks) if failure: @@ -590,7 +606,12 @@ async def replan_route( summary["violations"], radius_m, ) - result = await _recompute(project_id, project_root, stored_path) + result = await _recompute( + project_id, + project_root, + stored_path, + (request.initial or RouteInitialRequest()) if request.balanced_profile else None, + ) if "error" in result: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. if previous is None: @@ -617,6 +638,10 @@ async def replan_route( if choice else await asyncio.to_thread(read_initial_choice, project_root) ) + # 확정 노선 = 새 초기값(PLAN 74-4) — [저장] 없이 이 결과를 [초기화]가 돌아갈 자리로 뜬다. + from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot + + await refresh_initial_snapshot(project_id, project_root, result.get("route_id")) return { "status": "success", "project_id": str(project_id), diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index e748f7c34..c94f9e657 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -8,6 +8,7 @@ import asyncio import functools import logging import time +from pathlib import Path from typing import Any from uuid import UUID @@ -118,3 +119,131 @@ async def initial_route( **result, "current_initial": current, } + + +def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: + """종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다.""" + import json + + import numpy as np + + from common_util.common_util_json import atomic_write_json + + longitudinal = json.loads(path.read_text(encoding="utf-8")) + base = longitudinal["profile_alignment"]["base_pvi"] + for item in base: + item["elevation_m"] = round(float(np.interp(float(item["chainage_m"]), stations, plan)), 4) + atomic_write_json(path, longitudinal) + return len(base) + + +async def apply_profile_target( + project_id: UUID, route_id: int, target: tuple[list[float], list[float]] +) -> None: + """[확인] 체인의 1차 종단을 추천 균형 종단(누가거리 · 계획고)으로 갈아 끼운다(PLAN 74-4). + + 1차 종단(`design_ground_following_profile`)은 측점마다 계획고 = 지반고로 맞춘다 — 그 맞춤 대신 + 변화점(`base_pvi`)을 균형 종단에 놓고 `rebuild_alignment_profile` 로 계획선을 다시 낸다. + 구조물 · 관 측점은 앞뒤 20 m 측점 사이 직선 보간. 실패는 비치명적 — 지반고 종단이 남는다. + """ + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary + from B05_Profile.B05_Profile_Router import _apply_alignment_edits + from B06_Section.B06_Section_Repository import get_longitudinal_section + from common_util.common_util_storage import resolve_stored_project_path + + pool = get_db_pool() + try: + async with pool.acquire() as connection: + row = await get_longitudinal_section(connection, project_id, route_id) + stored_path = await get_project_storage_relative_path(connection, project_id) + root = Path(resolve_stored_project_path(stored_path)) + relative = str(row["longitudinal_file_path"]) + count = await asyncio.to_thread(_set_base_pvi, root / relative, *target) + _alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {}) + async with pool.acquire() as connection: + await connection.begin() + await update_longitudinal_grade_summary( + connection, route_id=route_id, grade_summary=summary + ) + await connection.commit() + logger.info( + "균형 종단 반영: project_id=%s route_id=%s 변화점 %d", project_id, route_id, count + ) + except Exception: # noqa: BLE001 — 지반고 종단이 남는다 + logger.exception("균형 종단 반영 실패(지반고 종단 유지): project_id=%s", project_id) + + +async def balanced_target( + project_id: UUID, + project_root: Path, + points: list[dict[str, float]], + criteria: Any, +) -> tuple[list[float], list[float]] | None: + """확정할 노선의 균형 종단(누가거리 · 계획고) — 추천 창과 같은 코드 · 같은 상한(PLAN 74-4). + + 화면 값을 받지 않고 노선 정점으로 다시 셈한다(CLAUDE.md 5). 못 세우면 None(지반고 종단). + """ + from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct + from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams + from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile + from common_util.common_util_surface_confirmation import get_surface_confirmation_params + + grade_class, design_speed, terrain_type = await replan._road_settings(project_id) + max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0 + if criteria is not None and criteria.max_grade_pct is not None: + max_grade = criteria.max_grade_pct / 100.0 + pool = get_db_pool() + async with pool.acquire() as connection: + selection = await get_surface_confirmation_params(connection, str(project_id)) + try: + terrain, _streams = await asyncio.to_thread( + load_terrain_and_streams, project_root, selection + ) + profile = await asyncio.to_thread( + functools.partial( + route_profile, + [(p["x"], p["y"]) for p in points], + terrain, + max_grade=max_grade, + design_speed=design_speed or 20, + ) + ) + except (FileNotFoundError, ValueError): + logger.exception("균형 종단 셈 실패(지반고 종단으로): project_id=%s", project_id) + return None + logger.info( + "균형 종단 셈: project_id=%s 측점 %d 절 %.0f 성 %.0f %.1fs", + project_id, + len(profile["stations_m"]), + profile["cut_m3"], + profile["fill_m3"], + profile["seconds"], + ) + return profile["stations_m"], profile["plan_m"] + + +async def refresh_initial_snapshot( + project_id: UUID, project_root: Path, route_id: int | None +) -> None: + """노선 [확인] 결과로 초기값(initial_snapshot)을 다시 뜬다(PLAN 74-4) — 실패는 비치명적.""" + from common_util.common_util_initial_snapshot import ( + discard_initial_snapshot, + save_initial_snapshot, + ) + + if route_id is None: + return + try: + # 스냅샷 안 `planned_route.csv` 는 예상노선(원본) 자리 — 확정 노선 아닌 예상노선을 둔다. + expected = await asyncio.to_thread( + replan._vertices_of, expected_route_csv_path(project_root) + ) + points = [{"x": x, "y": y} for x, y in expected] or None + await asyncio.to_thread(discard_initial_snapshot, project_root) + pool = get_db_pool() + async with pool.acquire() as connection: + await save_initial_snapshot(connection, project_root, int(route_id), points) + logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id) + except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다 + logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 63c1e3fe4..0be3ed8a5 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -26,7 +26,11 @@ export interface RouteApplyParams { /** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ changed: () => boolean; /** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ - initial: { current: () => unknown; keep: () => void }; + initial: { + current: () => unknown; + keep: () => void; + profiled: (nodes: ReturnType) => boolean; + }; /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ close: () => void; onApplied: () => void | Promise; @@ -85,7 +89,10 @@ export function bindRouteApply(params: RouteApplyParams): void { curve: curveOn[index] !== false, radius_m: curveRadius[index] ?? null, })), - { initial: params.initial.current() }, + { + initial: params.initial.current(), + balanced_profile: params.initial.profiled({ planned, curveOn, curveRadius }), + }, ), ); }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 57946413f..9e458f358 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -58,7 +58,8 @@ export function createRouteEditChrome(): RouteEditChrome { - + diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index d12e1c55f..b48dd4f4f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -58,6 +58,8 @@ export interface InitialPanel { weights: RouteInitialWeights | null; criteria: RouteInitialCriteria | null; }; + /** 「종단 반영」 켬으로 [적용]한 추천 노선 그대로인가 — [확인]이 균형 종단을 1차 종단으로 쓸지(PLAN 74-4). */ + profiled: (nodes: Pick) => boolean; /** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */ tag: () => InitialChoice | null; restoreTag: (tag: unknown) => void; @@ -214,6 +216,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { const criteria = choice ? choice.criteriaOverrides : base.criteria; return { mode: now.logic, weights: now.weights, criteria }; }, + profiled: (nodes) => { + if (!choice?.balance) return false; // 끔 · [초기화] · 손 편집만 한 노선 + const { planned, curveOn, curveRadius } = choice.snapshot; + return ( + JSON.stringify([planned, curveOn, curveRadius]) === + JSON.stringify([nodes.planned, nodes.curveOn, nodes.curveRadius]) + ); + }, tag: () => choice, restoreTag(tag) { if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index c84a90e9e..cb06d171b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -14,7 +14,6 @@ * ========================================================================== */ import { openModal } from "@ui/ui_template_modal"; -import { createCheckbox } from "@ui/ui_template_checkbox"; import { showToast } from "@ui/ui_template_elements"; import type { RouteInitialCriteria, @@ -185,11 +184,16 @@ export function openRecommendModal(params: RecommendParams): void { bodyEl = body; body.classList.add("b05-routerec__body"); body.innerHTML = ` -
- ${PILLS.map( - (key) => ``, - ).join("")} +
+
+ ${PILLS.map( + (key) => ``, + ).join("")} +
+
@@ -219,10 +223,6 @@ export function openRecommendModal(params: RecommendParams): void {
-
- 끄면 원지반만 · 이 창 안에서만 -
${CRITERIA_KEYS.map( (key) => `