diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index e2fbfa402..2ddf78b19 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -70,3 +70,26 @@ export async function fetchRouteInitial( throw error; } } + +/** 서버가 뒤에서 미리 셈하는 다섯 갈래(364-4) — 끝낸 수 / 셀 수 · 지금 갈래. */ +export interface PrecomputeProgress { + running: boolean; + done: number; + total: number; + mode: RouteInitialLogic | null; +} + +/** 미리 셈 진행 — 못 읽으면 null(진행 표시만 빠짐). */ +export async function fetchPrecomputeProgress( + projectId: string, +): Promise { + try { + return await requestJson( + `/projects/${projectId}/route/initial/progress`, + { method: "GET" }, + 5000, + ); + } catch { + return null; + } +} diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index b7ea83c8c..85c4fe5ba 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -475,6 +475,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: if paths is None: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) project_root, _ = paths + # B05 열 때 추천 저장본이 없거나 판이 다르면 뒤에서 미리 셈(364-4 · 있으면 꺼내기만) + from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute + + schedule_precompute(project_id) # 예상노선 정본이 먼저, 없으면 옛 자리(초기값 스냅샷)를 본다. expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root)) if not expected: diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index cdbae3d7e..962a83d30 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -22,6 +22,9 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) +# 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜) +_RUNNING: dict[str, dict[str, Any]] = {} + @replan.router.post("/{project_id}/route/initial", response_model=None) async def initial_route( @@ -187,6 +190,7 @@ async def precompute_defaults(project_id: UUID) -> None: started = time.perf_counter() variants: list[tuple[Any, Any]] = [(None, None)] + modes = list(DEFAULT_MODES) try: paths = await replan._project_paths(project_id) except LookupError: @@ -196,8 +200,15 @@ async def precompute_defaults(project_id: UUID) -> None: weights = saved["weights"] if saved["logic"] == "weighted" else None if saved["criteria"] or (weights and weights != DEFAULT_WEIGHTS): variants.append((saved["criteria"], weights)) + # 모달이 여는 갈래 먼저(추적은 묶음이 선 뒤라 끝에) + modes.sort(key=lambda mode: mode != saved["logic"] or mode == "follow") + progress = _RUNNING.get(str(project_id)) + if progress is not None: + progress.update(total=len(variants) * len(modes)) for criteria, weights in variants: - for mode in DEFAULT_MODES: + for mode in modes: + if progress is not None: + progress["mode"] = mode # 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 추적만 종단 끔(`withProfileOf`) request = replan.RouteInitialRequest( mode=mode, @@ -211,16 +222,48 @@ async def precompute_defaults(project_id: UUID) -> None: "추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id ) return + if progress is not None: + progress["done"] += 1 logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) +async def run_precompute(project_id: UUID) -> None: + """미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음).""" + key = str(project_id) + if key in _RUNNING: + _RUNNING[key]["again"] = True + return + try: + while True: + _RUNNING[key] = {"done": 0, "total": 0, "mode": None, "again": False} + await precompute_defaults(project_id) + if not _RUNNING[key]["again"]: + break + finally: + _RUNNING.pop(key, None) + + +def precompute_progress(project_id: UUID) -> dict[str, Any]: + """모달 진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래.""" + progress = _RUNNING.get(str(project_id)) + if progress is None: + return {"running": False, "done": 0, "total": 0, "mode": None} + return {"running": True, **{k: progress[k] for k in ("done", "total", "mode")}} + + +@replan.router.get("/{project_id}/route/initial/progress", response_model=None) +async def initial_progress(project_id: UUID) -> dict[str, Any]: + return {"status": "success", **precompute_progress(project_id)} + + def schedule_precompute(project_id: UUID) -> None: - """추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1). 기다리지 않음 · 실패는 로그만.""" + """추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1) · B05 열 때(364-4). 기다리지 않음 · + 이미 돌면 끝난 뒤 한 번 더 · 실패는 로그만. 저장본이 다 있으면 꺼내기만(갈래마다 0.2 s 안팎).""" try: from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task _schedule_background_task( - precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}" + run_precompute(project_id), task_name=f"추천 노선 미리 셈 {project_id}" ) except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다 logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id) @@ -268,7 +311,7 @@ async def refresh_expected_route(project_id: UUID) -> None: points = [{"x": v.x, "y": v.y} for v in planned.vertices] await asyncio.to_thread(write_route_csv, path, points) logger.info("예상노선 다시 씀(사용 범위): project_id=%s 점 %d", project_id, len(points)) - await precompute_defaults(project_id) + await run_precompute(project_id) def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index c6101bc9f..db204402d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -20,6 +20,7 @@ import { showToast } from "@ui/ui_template_elements"; import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { + fetchPrecomputeProgress, fetchRouteInitial, type RouteInitialCriteria, type RouteInitialLogic, @@ -156,6 +157,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: params.planned().map(([x, y]) => [x, y]), request, + progress: () => fetchPrecomputeProgress(projectId), onApply: (picked) => { choice = picked; writeInitialChoice(projectId, choice); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 06ef95d1d..46dcd9320 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -18,6 +18,7 @@ import { openModal } from "@ui/ui_template_modal"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { toggleButton } from "./B05_Profile_UI_Panel_Fields"; import type { + PrecomputeProgress, RouteInitialCriteria, RouteInitialLogic, RouteInitialWeights, @@ -133,6 +134,8 @@ export interface RecommendParams { ) => Promise; /** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */ onApply: (choice: InitialChoice) => void; + /** 서버 미리 셈 진행(364-4) — 셈 기다리는 동안 상태줄에 붙인다. */ + progress?: () => Promise; } export function openRecommendModal(params: RecommendParams): void { @@ -454,11 +457,24 @@ export function openRecommendModal(params: RecommendParams): void { // 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4). const started = Date.now(); const label = choiceLabel(logic, sentWeights); + /** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */ + let behind = ""; const tick = (): void => { const seconds = Math.round((Date.now() - started) / 1000); + if (seconds % 2 === 1 && params.progress) { + void params.progress().then((p) => { + if (mine !== seq) return; + behind = + p?.running && p.total > 0 + ? ` · 서버가 다섯 갈래를 미리 셈하는 중 ${p.done}/${p.total}${ + p.mode ? `(지금 ${choiceLabel(p.mode, null)})` : "" + } — 끝나면 다음부터 바로 보입니다` + : ""; + }); + } stateEl.textContent = `${label} 셈하는 중… ${seconds}초${ withProfile ? " (후보마다 측점 횡단으로 종단을 셈합니다)" : " (종단 없음 — 원지반만)" - }`; + }${behind}`; }; window.clearInterval(ticker); tick(); diff --git a/resources/tester/test_364_4_precompute_on_open.py b/resources/tester/test_364_4_precompute_on_open.py new file mode 100644 index 000000000..a13910ea5 --- /dev/null +++ b/resources/tester/test_364_4_precompute_on_open.py @@ -0,0 +1,104 @@ +"""364-4 노선 추천 미리 셈 — B05 열 때 예약 · 겹쳐 셈 막음 · 모달 진행 표시. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_364_4_precompute_on_open.py -q + +옛 프로젝트 · 판이 바뀐 저장본은 사용자가 처음 누를 때 기다렸음(요구 239 · 토공 62.9 s). +`GET /route/plan`(B05 열기)이 미리 셈을 예약 · 도는 동안 또 오면 끝난 뒤 한 번 더 · 진행은 +`GET /route/initial/progress` · 모달 상태줄이 그것을 붙임. +""" + +import asyncio +from pathlib import Path +from uuid import uuid4 + +from B05_Profile import B05_Profile_Router_RouteInitial as router + +ROOT = Path(__file__).resolve().parents[2] + + +def test_겹쳐_셈_막음_끝난_뒤_한_번_더(monkeypatch) -> None: + calls: list[str] = [] + seen: list[dict] = [] + pid = uuid4() + + async def precompute(project_id): + calls.append(str(project_id)) + progress = router._RUNNING[str(project_id)] + progress.update(total=5, done=2, mode="earthwork") + seen.append(router.precompute_progress(project_id)) + await asyncio.sleep(0.05) + + monkeypatch.setattr(router, "precompute_defaults", precompute) + + async def main() -> None: + first = asyncio.create_task(router.run_precompute(pid)) + await asyncio.sleep(0.01) + # 도는 중 두 번 더 와도 셈은 한 번 더만 + await router.run_precompute(pid) + await router.run_precompute(pid) + await first + + asyncio.run(main()) + assert calls == [str(pid), str(pid)] + assert seen[0] == {"running": True, "done": 2, "total": 5, "mode": "earthwork"} + assert router.precompute_progress(pid) == { + "running": False, + "done": 0, + "total": 0, + "mode": None, + } + + +def test_실패해도_진행_비움(monkeypatch) -> None: + async def broken(_pid): + raise RuntimeError("셈 실패") + + monkeypatch.setattr(router, "precompute_defaults", broken) + pid = uuid4() + try: + asyncio.run(router.run_precompute(pid)) + except RuntimeError: + pass + assert not router.precompute_progress(pid)["running"] + + +def test_b05_열기_예약_모달_진행_배선() -> None: + replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8") + plan = replan[replan.index("async def read_route_plan") :] + assert "schedule_precompute(project_id)" in plan[: plan.index("\n@router")] + source = (ROOT / "B05_Profile/B05_Profile_Router_RouteInitial.py").read_text(encoding="utf-8") + assert '"/{project_id}/route/initial/progress"' in source + # 체인 · B01 범위도 같은 한 줄(run_precompute)로 + assert "run_precompute(project_id), task_name" in source + assert "await run_precompute(project_id)" in source + api = (ROOT / "B05_Profile/B05_Profile_Api_RouteInitial.ts").read_text(encoding="utf-8") + assert "/route/initial/progress" in api + initial = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts").read_text(encoding="utf-8") + assert "progress: () => fetchPrecomputeProgress(projectId)" in initial + modal = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8") + assert "미리 셈하는 중 ${p.done}/${p.total}" in modal + + +def test_모달이_여는_갈래_먼저(monkeypatch) -> None: + """저장된 갈래(추적 아님)를 먼저 셈 — 모달이 열자마자 그 갈래를 물음.""" + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES + + asked: list[str] = [] + + async def paths(_pid): + return (ROOT, None) + + async def compute(_pid, request): + asked.append(request.mode) + return {} + + monkeypatch.setattr(router.replan, "_project_paths", paths) + monkeypatch.setattr( + router, + "read_initial_choice", + lambda _root: {"logic": "gentle", "weights": None, "criteria": None}, + ) + monkeypatch.setattr(router, "compute_initial", compute) + asyncio.run(router.precompute_defaults(uuid4())) + assert asked[0] == "gentle" and asked[-1] == "follow" + assert sorted(asked) == sorted(DEFAULT_MODES)