Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-17 00:23:24 +09:00
10 changed files with 1691 additions and 9 deletions
@@ -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;
}
}
@@ -14,6 +14,7 @@
· 못 놓으면 다음 꺾임과 합침(같은 쪽으로 돌면 두 접선 교점 · 아니면 다음 IP 를 뺌) → 그래도
안 되면 그 곡선을 끔(꺾임만) → 그래도 이탈 한도를 넘으면 실패.
· 모두 놓는 길이 없으면 못 놓는 곡선만 그 자리에 두고 나머지는 맞춤(부분 · 「측점 밖 곡선 N개」).
그 전에 이웃 곡선과 한 측점을 다투면 덜 꺾인 쪽을 끔 — 꺾임점째 빼거나 꺾임만(364-5).
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점
@@ -25,7 +26,7 @@ from __future__ import annotations
import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from dataclasses import dataclass, replace
import numpy as np
import shapely
@@ -362,11 +363,63 @@ class _Snapper:
kink.flags[k], kink.radii[k] = False, None
yield self._kink(state, kink)
if self.keep:
yield from self._crowded(state)
yield from self._kept(state)
def _kept(self, state: _State) -> Iterator[_State]:
"""못 놓는 곡선은 그 자리 그대로(브레인 확정 · 측점 밖 곡선으로 셈)."""
def _crowded(self, state: _State) -> Iterator[_State]:
"""이웃 곡선과 한 측점을 다툼(꺾임점 사이 20 m 안 · 364-5) — 덜 꺾인 쪽 곡선을 끔.
끈 꺾임이 원래 선에서 한도 안일 때만. 이 곡선을 끄면 꺾임만 · 뒤 곡선을 끄면 이 곡선을
다시 놓아 봄(못 놓으면 그 자리). 앞 곡선은 이미 정해져 이 곡선만 끔.
"""
plan, k = state.plan, state.k
p = plan.points
def close(i: int) -> bool:
return 1 <= i < len(p) - 1 and plan.flags[i] and math.dist(p[i], p[k]) < STATION_M
pair = [k, k + 1] if close(k + 1) else []
# 덜 꺾인 쪽부터 · 그걸 꺼도 못 놓으면 다른 쪽
pair.sort(key=lambda i: -_inner_angle_deg(*p[i - 1 : i + 2]))
found = _try(state, plan, k, p[k], plan.radii[k] or self.min_radius)
# 앞 곡선 MC 측점에서 20 m 안(남는 측점 없음)도 다툼
crowded = (
found is not None
and 0 < state.last_station
and (found[1] - state.last_station < STATION_M)
)
if (close(k - 1) or crowded) and k not in pair:
pair.append(k)
for small in pair:
a, at, b = p[small - 1 : small + 2]
chord = [
(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) for t in (0.25, 0.5, 0.75)
]
# 꺾임점째 뺌(이웃 곡선 접선 자리가 넓어짐) → 안 되면 꺾임만 남김. 이 꺾임점은 앞
# 곡선이 꺾임만일 때만 뺌(앞 곡선 EC 가 이 꺾임점 쪽 직선 위)
movable = small > k or not plan.flags[k - 1]
if movable and self.reference.gap(chord) <= self.max_gap and not self._steeper(chord):
gone = plan.copy()
del gone.points[small], gone.flags[small], gone.radii[small]
yield replace(state, plan=gone)
piece = [
((a[0] + at[0]) / 2, (a[1] + at[1]) / 2),
at,
((at[0] + b[0]) / 2, (at[1] + b[1]) / 2),
]
if self.reference.gap(piece) > self.max_gap or self._steeper(piece):
continue
off = plan.copy()
off.flags[small], off.radii[small] = False, None
if small == k:
yield self._kink(state, off)
else:
yield from self._placements(state, off)
yield from self._kept(state, off)
def _kept(self, state: _State, plan: _Plan | None = None) -> Iterator[_State]:
"""못 놓는 곡선은 그 자리 그대로(브레인 확정 · 측점 밖 곡선으로 셈)."""
plan, k = plan or state.plan, state.k
found = _try(state, plan, k, plan.points[k], plan.radii[k] or self.min_radius)
if found is None:
return
@@ -32,7 +32,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
)
from common_util.common_util_json import atomic_write_json
ALGO_VERSION = 3 # 3 = 곡선 MC 일반측점(351-2)
ALGO_VERSION = 4 # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5)
STORE_DIR = "B05_Profile/route_recommend"
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
KEEP_PER_KIND = 2
+4
View File
@@ -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:
+47 -4
View File
@@ -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();
File diff suppressed because it is too large Load Diff
@@ -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)
@@ -0,0 +1,150 @@
"""364-5 한 측점을 다투는 가까운 두 곡선 — `B05_Profile_Engine_RouteInitial_Station._crowded`.
./venv/Scripts/python.exe -m pytest resources/tester/test_364_5_crowded_curves.py -q -s
자료 = 브레인 검증 판(요구 239) 다섯 갈래의 맞추기 전 선(`fixtures/b05_364_5_unsnapped_req239.json`)
+ 검증_sub1 지형. 측점 밖 곡선 수 · 넘은 곳 · 최대 기울기를 맞추기 전 · 옛 맞춤 · 새 맞춤으로 찍고
이웃 MC 20 m 안 측점 밖 쌍 0 · 측점 밖 합계가 옛 맞춤의 1/4 이하 · 이탈 한도 안을 봄.
"""
import json
from pathlib import Path
import numpy as np
import pytest
from scipy.interpolate import RegularGridInterpolator
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
SAMPLE_M,
_resample,
route_metrics,
smoothed_ground,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
MC_TOL_M,
STATION_M,
curve_mc_chainages,
off_station_count,
snap_curves_to_stations,
)
from common_util.common_util_route_geometry import read_planned_route_csv
from common_util.common_util_route_polyline import build_planned_polyline
from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M
HERE = Path(__file__).resolve().parent
PROJECT = HERE.parents[1] / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391"
SURFACE = PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz"
CASES = json.loads((HERE / "fixtures/b05_364_5_unsnapped_req239.json").read_text(encoding="utf-8"))
def _ls(vertices: list) -> LineString:
return LineString([xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]])
def _outline(case: dict):
nodes = case["nodes"]
return build_planned_polyline(
[(n["x"], n["y"]) for n in nodes],
min_radius_m=case["criteria"]["min_radius_m"],
simplify=False,
curve_flags=[n["radius_m"] is not None for n in nodes],
radii=[n["radius_m"] for n in nodes],
)
def _excess(terrain: Terrain, max_grade: float, smooth: float):
"""`generate_initial_route.grade_excess` 와 같은 잣대(20 m 고른 지반 기울기 − 상한)."""
surface = RegularGridInterpolator(
(terrain.y, terrain.x),
smoothed_ground(terrain, smooth),
bounds_error=False,
fill_value=None,
)
def excess(points):
xy, chain = _resample(points, SAMPLE_M)
z = surface(xy[:, ::-1])
span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1)
if span < 1:
return 0.0
run = np.maximum(chain[span:] - chain[:-span], 1e-9)
return max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - max_grade)
return excess
def _off_mcs(outline) -> list[float]:
return [
mc
for mc in curve_mc_chainages(outline)
if abs(mc - STATION_M * round(mc / STATION_M)) > MC_TOL_M
]
def test_req239_five_modes_before_after() -> None:
if not SURFACE.is_file():
pytest.skip("검증_sub1 storage 없음")
cached = np.load(SURFACE, allow_pickle=False)
terrain = Terrain(cached["x"], cached["y"], cached["z"], cached["valid_mask"])
expected = [
(v.x, v.y)
for v in read_planned_route_csv(PROJECT / "B05_Profile/route/expected_route.csv").vertices
]
old_total = new_total = 0
for case in CASES:
cr = case["criteria"]
before = _outline(case)
follow = case["mode"] == "follow"
excess = (
None if follow else _excess(terrain, cr["max_grade_pct"] / 100, cr["grade_smooth_m"])
)
after = snap_curves_to_stations(before, cr["min_radius_m"], excess=excess)
assert after is not None
def grade(outline):
m = route_metrics(
outline.vertices,
expected,
terrain,
[],
outline.curves,
max_grade=cr["max_grade_pct"] / 100,
min_radius_m=cr["min_radius_m"],
stream_offset_m=cr["stream_offset_m"],
road_width_m=cr["road_width_m"],
bound_pct=None,
grade_smooth_m=cr["grade_smooth_m"],
)
return m["over_limit"]["total"], m["max_grade_pct"]
(o0, g0), (o1, g1) = grade(before), grade(after)
off = off_station_count(after)
print(
f"{case['mode']}: 측점 밖 {case['old']['off']}/{case['old']['curves']}(옛)"
f" → {off}/{len(after.curves)} · 넘은 곳 {o0} → {o1}"
f" · 최대 기울기 {g0:.1f} → {g1:.1f} %(맞추기 전 → 새)"
)
old_total += case["old"]["off"]
new_total += off
# 다투는 쌍 없음 — 이웃 측점 밖 곡선 MC 가 20 m 안이면 한 측점을 다툼
mcs = _off_mcs(after)
assert all(b - a >= STATION_M for a, b in zip(mcs, mcs[1:])), mcs
gap = _ls(after.vertices).hausdorff_distance(_ls(before.vertices))
assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05
assert after.vertices[0] == pytest.approx(before.vertices[0], abs=1e-3)
assert after.vertices[-1] == pytest.approx(before.vertices[-1], abs=1e-3)
print(f"측점 밖 곡선 합 {old_total} → {new_total}")
assert new_total * 4 <= old_total
def test_crowded_pair_drops_one() -> None:
"""11 m 사이 두 곡선 · 이탈 한도 1 m(측점 옮김 막힘) — 옛 맞춤은 둘 다 그 자리(44.8 · 55.8)."""
points = [(0.0, 0.0), (45.0, 0.0), (54.0, 7.0), (58.0, 70.0)]
before = build_planned_polyline(
points, min_radius_m=12.0, simplify=False, curve_flags=[True] * 4
)
after = snap_curves_to_stations(before, 12.0, max_gap_m=1.0)
assert after is not None and len(after.curves) == 1
assert _ls(after.vertices).hausdorff_distance(_ls(before.vertices)) <= 1.0 + 0.05