feat(노선추천): 364-4 B05 열 때 추천 다섯 갈래 미리 셈 예약 · 프로젝트마다 한 줄(도는 중 또 오면 끝난 뒤 한 번 더) · 진행 GET route/initial/progress · 모달 상태줄 「서버가 다섯 갈래를 미리 셈하는 중 n/N」 · 모달이 여는 갈래 먼저
검증_sub1(8001) 판 바뀐 저장본 — B05 열기 뒤 86.8 s 에 다섯 갈래 끝 · 그 뒤 모달 누름 0.12~0.16 s(저장본) · 다 있으면 미리 셈 1 s 안팎 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -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<PrecomputeProgress | null> {
|
||||
try {
|
||||
return await requestJson<PrecomputeProgress>(
|
||||
`/projects/${projectId}/route/initial/progress`,
|
||||
{ method: "GET" },
|
||||
5000,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<InitialChoice>;
|
||||
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
|
||||
onApply: (choice: InitialChoice) => void;
|
||||
/** 서버 미리 셈 진행(364-4) — 셈 기다리는 동안 상태줄에 붙인다. */
|
||||
progress?: () => Promise<PrecomputeProgress | null>;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user