Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -347,12 +347,6 @@ async def run_auto_design_chain(
|
||||
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
|
||||
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
|
||||
|
||||
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
|
||||
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
schedule_precompute(project_id)
|
||||
|
||||
return {
|
||||
"route_id": route_id,
|
||||
"length_m": float(solve_result.total_length_m or 0.0),
|
||||
@@ -367,6 +361,10 @@ async def run_auto_design_chain(
|
||||
# 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다.
|
||||
if project_root is not None:
|
||||
clear_designing(project_root)
|
||||
# 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
schedule_precompute(project_id)
|
||||
|
||||
|
||||
async def _ensure_initial_polyline(
|
||||
@@ -670,10 +668,6 @@ async def run_redesign_chain(
|
||||
return f"종횡단 확정 실패({sections_result.status_code})"
|
||||
marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter()))
|
||||
_log_steps("재확정 체인", marks)
|
||||
# 지형이 바뀌면 추천 키도 바뀜 — 첫 누름이 저장본이게 미리 셈(351-1)
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
schedule_precompute(project_id)
|
||||
logger.info(
|
||||
"재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)",
|
||||
project_id,
|
||||
@@ -688,3 +682,7 @@ async def run_redesign_chain(
|
||||
finally:
|
||||
if project_root is not None:
|
||||
clear_designing(project_root)
|
||||
# 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
schedule_precompute(project_id)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""서피스가 바뀐 뒤 전처리 체인 다시(763-3 · 요구 605) — 업로드와 같은 길 하나.
|
||||
|
||||
부르는 곳 = B04 다른 서피스 [확정]. B05~B09 초기화 = 같은 지형 재업로드 길
|
||||
(`purge_project_outputs(keep_preprocess=True)` · 단계 2 이후 처음 · 초기값 스냅샷 버림) →
|
||||
`run_auto_design_chain`(끝에 추천 미리 셈 · 실패 길에도). 전처리 결과(B04) · 확정은 그대로.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_surface_change_chain(project_id: UUID, surface_model_id: int | None) -> None:
|
||||
"""B05~B09 를 지우고 자동 설계 체인을 새 확정 서피스로 다시 탐 — 실패는 로그만."""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
from common_util.common_util_initial_snapshot import discard_initial_snapshot
|
||||
from common_util.common_util_project_reset import purge_project_outputs
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import reset_stages_after_input_change
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
try:
|
||||
async with get_db_pool().acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored_path:
|
||||
return
|
||||
root = Path(resolve_stored_project_path(stored_path))
|
||||
await asyncio.to_thread(discard_initial_snapshot, root)
|
||||
await connection.begin()
|
||||
try:
|
||||
await purge_project_outputs(connection, str(project_id), root, keep_preprocess=True)
|
||||
async with connection.cursor() as cursor:
|
||||
await reset_stages_after_input_change(cursor, str(project_id), 2)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — 체인은 응답 뒤 · 추천은 새 서피스로 셈
|
||||
logger.exception("서피스 바꿈 체인 — B05~B09 초기화 실패: project_id=%s", project_id)
|
||||
schedule_precompute(project_id)
|
||||
return
|
||||
logger.info("서피스 바꿈 체인 — B05~B09 초기화 뒤 자동 설계: project_id=%s", project_id)
|
||||
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
|
||||
@@ -14,6 +14,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import (
|
||||
base_filter,
|
||||
build_ground_masks,
|
||||
resolve_auto_source_filters,
|
||||
run_ground_filter,
|
||||
@@ -282,24 +283,29 @@ def run_surface_analysis(
|
||||
source_filters = resolve_auto_source_filters(data, processed_dir)
|
||||
logger.info("B04 자동 전처리 기본 필터: %s", ", ".join(source_filters))
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for filter_key in source_filters:
|
||||
|
||||
def _mask_for(filter_key: str) -> np.ndarray:
|
||||
mask_path = processed_dir / f"mask_{filter_key}.npy"
|
||||
mask: np.ndarray | None = None
|
||||
if not rebuild and mask_path.is_file():
|
||||
stored_mask = np.load(mask_path)
|
||||
if len(stored_mask) == total_points:
|
||||
mask = np.asarray(stored_mask, dtype=bool)
|
||||
logger.info("B04 지면 필터 캐시 재사용: %s", mask_path.name)
|
||||
if mask is None:
|
||||
step_started = time.monotonic()
|
||||
mask = np.asarray(run_ground_filter(filter_key, data), dtype=bool)
|
||||
np.save(mask_path, mask)
|
||||
logger.info(
|
||||
"B04 지면 필터 계산 완료: %s (%.1fs)",
|
||||
filter_key,
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
masks[filter_key] = mask
|
||||
return np.asarray(stored_mask, dtype=bool)
|
||||
# 다듬기 판(762)은 바탕 필터 마스크(저장본)를 다듬음 — 점진 TIN 을 두 번 돌리지 않음
|
||||
base = base_filter(filter_key)
|
||||
base_mask = (masks.get(base) if base in masks else _mask_for(base)) if base else None
|
||||
step_started = time.monotonic()
|
||||
mask = np.asarray(run_ground_filter(filter_key, data, base_mask), dtype=bool)
|
||||
np.save(mask_path, mask)
|
||||
logger.info(
|
||||
"B04 지면 필터 계산 완료: %s (%.1fs)",
|
||||
filter_key,
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
return mask
|
||||
|
||||
for filter_key in source_filters:
|
||||
masks[filter_key] = _mask_for(filter_key)
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
# 지면점 비율은 필터가 조용히 실패해도 유일하게 드러나는 신호다 — 반드시 남긴다.
|
||||
for filter_key, entry in ground_summary.items():
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""B04 지면점 다듬기 — 필터가 고른 지면점에서 덤불 · 풀 혹과 낮은 잡점을 한 번 더 거름(762-1).
|
||||
|
||||
SMRF 꼴(Pingel 2013 · PDAL filters.smrf) — 입력 = 필터 지면 마스크 · 출력 = 줄어든 마스크.
|
||||
① 1 m 칸 최저면 → 뒤집어 열림(낮은 잡점 칸)
|
||||
② 창 반지름 1 칸부터 최대까지 키우며 열림 · 문턱 = 기울기 × 칸 × 반지름(물체 칸)
|
||||
③ 걸린 칸 빼고 남은 칸으로 임시 지면 다시 그림(줄여 메우고 늘려 붓는 다층 보간)
|
||||
④ 점마다 |z − 임시 지면| ≤ 문턱 + 배수 × 그 자리 기울기 이면 지면.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
|
||||
from config.config_system import (
|
||||
SURFACE_REFINE_CELL_M,
|
||||
SURFACE_REFINE_LOW_SLOPE,
|
||||
SURFACE_REFINE_SCALAR,
|
||||
SURFACE_REFINE_SLOPE,
|
||||
SURFACE_REFINE_THRESHOLD_M,
|
||||
SURFACE_REFINE_WINDOW_M,
|
||||
)
|
||||
|
||||
_RELAX_STEPS = 30
|
||||
|
||||
|
||||
def _fill_nearest(grid: np.ndarray, missing: np.ndarray) -> np.ndarray:
|
||||
if not missing.any():
|
||||
return grid
|
||||
index = ndimage.distance_transform_edt(missing, return_distances=False, return_indices=True)
|
||||
return grid[tuple(index)]
|
||||
|
||||
|
||||
def _progressive(surface: np.ndarray, cell: float, slope: float, max_radius: int) -> np.ndarray:
|
||||
"""창을 한 칸씩 키운 열림이 한 단계에 문턱(기울기 × 칸 × 반지름)보다 깎은 칸."""
|
||||
flagged = np.zeros(surface.shape, dtype=bool)
|
||||
last = surface
|
||||
for radius in range(1, max_radius + 1):
|
||||
# ponytail: 정사각 창(분리형 최소 · 최대 거름) — 원판보다 빠름 · 모서리 방향만 조금 넓음
|
||||
# (네 방향 선분 열림은 능선을 더 지키나 덤불 판을 3 배 남겨 버림 — 762 훑기)
|
||||
current = ndimage.grey_opening(last, size=(2 * radius + 1, 2 * radius + 1))
|
||||
flagged |= (last - current) > slope * cell * radius
|
||||
last = current
|
||||
return flagged
|
||||
|
||||
|
||||
def _inpaint(values: np.ndarray, known: np.ndarray) -> np.ndarray:
|
||||
"""빈 칸을 아는 칸으로 메움 — 2 배씩 줄여 메운 뒤 늘려 붓고 이웃 평균으로 폄."""
|
||||
if known.all():
|
||||
return values
|
||||
if not known.any():
|
||||
return np.zeros_like(values)
|
||||
rows, cols = values.shape
|
||||
pad = ((0, rows % 2), (0, cols % 2))
|
||||
weight = np.pad(known.astype(np.float64), pad)
|
||||
total = np.pad(np.where(known, values, 0.0), pad)
|
||||
shape = (weight.shape[0] // 2, 2, weight.shape[1] // 2, 2)
|
||||
coarse_weight = weight.reshape(shape).sum(axis=(1, 3))
|
||||
coarse_known = coarse_weight > 0
|
||||
coarse = total.reshape(shape).sum(axis=(1, 3)) / np.maximum(coarse_weight, 1.0)
|
||||
coarse = _inpaint(coarse, coarse_known)
|
||||
up = ndimage.zoom(coarse, 2, order=1, mode="nearest", grid_mode=True)[:rows, :cols]
|
||||
result = np.where(known, values, up)
|
||||
missing = ~known
|
||||
for _ in range(_RELAX_STEPS):
|
||||
padded = np.pad(result, 1, mode="edge")
|
||||
mean = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:]) / 4
|
||||
result[missing] = mean[missing]
|
||||
return result
|
||||
|
||||
|
||||
def refine_ground_mask(
|
||||
xyz: Any,
|
||||
mask: np.ndarray,
|
||||
*,
|
||||
cell: float = SURFACE_REFINE_CELL_M,
|
||||
slope: float = SURFACE_REFINE_SLOPE,
|
||||
window_m: float = SURFACE_REFINE_WINDOW_M,
|
||||
threshold: float = SURFACE_REFINE_THRESHOLD_M,
|
||||
scalar: float = SURFACE_REFINE_SCALAR,
|
||||
low_slope: float = SURFACE_REFINE_LOW_SLOPE,
|
||||
) -> np.ndarray:
|
||||
"""필터 지면 마스크를 다듬어 돌려줌 — 지면이던 점만 지면으로 남음(늘지 않음)."""
|
||||
mask = np.asarray(mask, dtype=bool)
|
||||
index = np.flatnonzero(mask)
|
||||
refined = np.zeros(mask.shape, dtype=bool)
|
||||
if index.size < 3:
|
||||
refined[index] = True
|
||||
return refined
|
||||
points = np.asarray(xyz[index], dtype=np.float64)
|
||||
fx = (points[:, 0] - points[:, 0].min()) / cell
|
||||
fy = (points[:, 1] - points[:, 1].min()) / cell
|
||||
gx = fx.astype(np.int64)
|
||||
gy = fy.astype(np.int64)
|
||||
shape = (int(gy.max()) + 1, int(gx.max()) + 1)
|
||||
zmin = np.full(shape, np.inf)
|
||||
np.minimum.at(zmin, (gy, gx), points[:, 2])
|
||||
empty = ~np.isfinite(zmin)
|
||||
filled = _fill_nearest(np.where(empty, 0.0, zmin), empty)
|
||||
|
||||
# ① 낮은 잡점 — 뒤집은 면에서 한 칸 창 열림이 문턱 넘게 깎는 칸(PDAL 기본 기울기 5)
|
||||
low = _progressive(-filled, cell, low_slope, 1) & ~empty
|
||||
surface = _fill_nearest(filled, empty | low)
|
||||
# ② 물체 — 창을 최대 반지름까지 키우며
|
||||
objects = _progressive(surface, cell, slope, max(1, int(round(window_m / cell))))
|
||||
# ③ 임시 지면 · 그 자리 기울기(rise / run)
|
||||
provisional = _inpaint(filled, ~empty & ~low & ~objects)
|
||||
grad_y, grad_x = np.gradient(provisional, cell)
|
||||
gradient = np.hypot(grad_x, grad_y)
|
||||
# ④ 점마다 판정 — 칸 가운데 기준 쌍선형
|
||||
coords = [fy - 0.5, fx - 0.5]
|
||||
ground_z = ndimage.map_coordinates(provisional, coords, order=1, mode="nearest")
|
||||
local_slope = ndimage.map_coordinates(gradient, coords, order=1, mode="nearest")
|
||||
keep = np.abs(points[:, 2] - ground_z) <= threshold + scalar * local_slope
|
||||
refined[index[keep]] = True
|
||||
return refined
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B04 지면 필터 오케스트레이션.
|
||||
|
||||
구조화된 포인트클라우드(structured.npz)에 대해 grid_min_z/csf/pmf/ransac
|
||||
구조화된 포인트클라우드(structured.npz)에 대해 grid_min_z/csf/pmf/ransac/ptd/classification
|
||||
(+ `_refine` 다듬기 판)
|
||||
필터를 실행하여 지면 마스크 딕셔너리를 만든다. 필터 선택은 config의
|
||||
source_filters를 따른다.
|
||||
"""
|
||||
@@ -20,7 +21,12 @@ from B04_PreProcess.B04_PreProcess_Engine_Filter_Grid import filter_grid_min_z
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_PMF import filter_pmf
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_PTD import filter_ptd
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_RANSAC import filter_ransac
|
||||
from config.config_system import SURFACE_AUTO_FALLBACK_FILTER
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Refine import refine_ground_mask
|
||||
from config.config_system import SURFACE_AUTO_FALLBACK_FILTER, SURFACE_REFINE_FLOW
|
||||
|
||||
# 다듬기 판(762) — `{기본 필터}_refine` = 기본 필터 지면점을 SMRF 꼴로 한 번 더 거름
|
||||
REFINE_SUFFIX = "_refine"
|
||||
_REFINABLE = ("classification", "ptd")
|
||||
|
||||
# 필터 키 → 함수 매핑
|
||||
_FILTERS = {
|
||||
@@ -30,10 +36,17 @@ _FILTERS = {
|
||||
"ransac": filter_ransac,
|
||||
"ptd": filter_ptd,
|
||||
"classification": filter_classification,
|
||||
"classification_refine": lambda data: refine_ground_mask(
|
||||
data["xyz"], filter_classification(data)
|
||||
),
|
||||
"ptd_refine": lambda data: refine_ground_mask(data["xyz"], filter_ptd(data)),
|
||||
}
|
||||
|
||||
# LAS에 지면분류가 실제로 들어 있을 때만 쓰는 필터 (미분류 LAS에서는 마스크가 빈다)
|
||||
_CONDITIONAL_FILTERS = {"classification": has_classified_ground}
|
||||
_CONDITIONAL_FILTERS = {
|
||||
"classification": has_classified_ground,
|
||||
"classification_refine": has_classified_ground,
|
||||
}
|
||||
|
||||
|
||||
def available_filters() -> tuple[str, ...]:
|
||||
@@ -61,15 +74,29 @@ def resolve_auto_source_filters(
|
||||
usable = usable_conditional_filters(structured_data)
|
||||
if usable and not has_classified_coverage(structured_data, processed_dir):
|
||||
usable = []
|
||||
return [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
|
||||
filters = [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
|
||||
# 762 제안 흐름(스위치 켬) — 같은 기본 필터에 다듬기를 붙인 판을 지음
|
||||
if SURFACE_REFINE_FLOW:
|
||||
filters = [f"{key}{REFINE_SUFFIX}" if key in _REFINABLE else key for key in filters]
|
||||
return filters
|
||||
|
||||
|
||||
def base_filter(filter_key: str) -> str | None:
|
||||
"""다듬기 판이면 바탕 필터 이름 — 엔진이 바탕 마스크 저장본을 다시 쓰게."""
|
||||
base = filter_key.removesuffix(REFINE_SUFFIX)
|
||||
return base if base != filter_key and base in _REFINABLE else None
|
||||
|
||||
|
||||
def run_ground_filter(
|
||||
filter_key: str, structured_data: dict[str, Any] | np.lib.npyio.NpzFile
|
||||
filter_key: str,
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
base_mask: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다."""
|
||||
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다 — 다듬기 판은 `base_mask` 를 다듬음."""
|
||||
if filter_key not in _FILTERS:
|
||||
raise ValueError(f"알 수 없는 지면 필터입니다: {filter_key}")
|
||||
if base_mask is not None and base_filter(filter_key):
|
||||
return refine_ground_mask(structured_data["xyz"], base_mask)
|
||||
return np.asarray(_FILTERS[filter_key](structured_data), dtype=bool)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
|
||||
bounds_dict,
|
||||
config_signature,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Quality import quality_after_build
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Smooth import (
|
||||
compute_smoothing_signature,
|
||||
run_smoothing,
|
||||
@@ -262,6 +263,7 @@ def _build_all_terrain_models(
|
||||
manifest.get("bounds", {}),
|
||||
entry,
|
||||
)
|
||||
quality_after_build(output_dir, filter_key, method, context, fresh=False)
|
||||
done_units += 1
|
||||
_report(f"{filter_key}-{method} 캐시 재사용")
|
||||
continue
|
||||
@@ -320,6 +322,7 @@ def _build_all_terrain_models(
|
||||
manifest.get("bounds", {}),
|
||||
metadata,
|
||||
)
|
||||
quality_after_build(output_dir, filter_key, method, context, fresh=True)
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
filter_entry["methods"][method] = {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""B04 서피스 품질 지표 넷 — 기준 없이 재는 값(762-2 · _분석_599 정의 · _분석_601-1 3-3).
|
||||
|
||||
① 튐 넓이 % — 5 × 5 칸 중간값 대비 0.5 m 넘는 칸(8 방향 묶음 = 곳 수)
|
||||
② 1 m 등고선 꺾임 /km — 2 m 마다 다시 찍어 30° 넘게 꺾인 곳
|
||||
③ 땅 점 빈 곳 % — 2 m 안 지면점 없는 칸 · 그중 기울기 > 15° 칸
|
||||
④ 필터 차 % — 같은 표현 · 짝 필터 모델과 |차| > 0.5 m 넓이
|
||||
저장 = `models/quality_{필터}_{표현}.json`(파생 저장본 — 모델 · 짝 모델 지문이 다를 때만 다시 셈).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
from skimage.measure import find_contours
|
||||
|
||||
from common_util.common_util_derived_store import file_stamp
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUALITY_VERSION = 1
|
||||
SPIKE_M = 0.5
|
||||
SPIKE_WINDOW = 5
|
||||
CONTOUR_INTERVAL_M = 1.0
|
||||
KINK_STEP_M = 2.0
|
||||
KINK_DEG = 30.0
|
||||
GAP_M = 2.0
|
||||
GAP_STEEP_DEG = 15.0
|
||||
DIFF_M = 0.5
|
||||
GRID_METHODS = ("dtm", "implicit")
|
||||
# 필터 차 짝 — 업체 분류 ↔ 점진 TIN(다듬기 판끼리) · 없으면 다듬기 전 ↔ 뒤
|
||||
_PAIRS = {
|
||||
"classification": ("ptd", "classification_refine"),
|
||||
"ptd": ("classification", "ptd_refine"),
|
||||
"classification_refine": ("ptd_refine", "classification"),
|
||||
"ptd_refine": ("classification_refine", "ptd"),
|
||||
}
|
||||
|
||||
|
||||
def _grid_path(models_dir: Path, filter_key: str, method: str) -> Path:
|
||||
suffix = "_grid" if method == "implicit" else ""
|
||||
return models_dir / f"{method}_{filter_key}{suffix}.npz"
|
||||
|
||||
|
||||
def _load_grid(path: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
with np.load(path) as data:
|
||||
z = np.asarray(data["z"], dtype=np.float64)
|
||||
valid = np.asarray(data["valid_mask"], dtype=bool) & np.isfinite(z)
|
||||
cell = float(np.asarray(data["resolution"]).ravel()[0]) if "resolution" in data else 1.0
|
||||
return np.where(valid, z, 0.0), valid, np.asarray(cell)
|
||||
|
||||
|
||||
def spike_stats(z: np.ndarray, valid: np.ndarray) -> dict[str, Any]:
|
||||
residual = z - ndimage.median_filter(z, size=SPIKE_WINDOW, mode="nearest")
|
||||
inner = ndimage.binary_erosion(valid, iterations=SPIKE_WINDOW // 2)
|
||||
spike = (np.abs(residual) > SPIKE_M) & inner
|
||||
_, count = ndimage.label(spike, structure=np.ones((3, 3), dtype=bool))
|
||||
area = int(inner.sum())
|
||||
return {
|
||||
"spike_pct": round(100.0 * int(spike.sum()) / area, 2) if area else None,
|
||||
"spike_count": int(count),
|
||||
}
|
||||
|
||||
|
||||
def contour_kinks(z: np.ndarray, valid: np.ndarray, cell: float) -> dict[str, Any]:
|
||||
if not valid.any():
|
||||
return {"contour_km": 0.0, "kinks_per_km": None, "bubbles": 0}
|
||||
low, high = float(z[valid].min()), float(z[valid].max())
|
||||
total, kinks, bubbles = 0.0, 0, 0
|
||||
step = KINK_STEP_M / cell
|
||||
for level in np.arange(np.ceil(low / CONTOUR_INTERVAL_M) * CONTOUR_INTERVAL_M, high, 1.0):
|
||||
for line in find_contours(z, level, mask=valid):
|
||||
seg = np.hypot(*np.diff(line, axis=0).T)
|
||||
length = float(seg.sum())
|
||||
if length < step * 2:
|
||||
continue
|
||||
total += length
|
||||
if np.allclose(line[0], line[-1]) and length * cell < 30.0:
|
||||
bubbles += 1
|
||||
along = np.r_[0.0, np.cumsum(seg)]
|
||||
t = np.arange(0.0, length, step)
|
||||
rows, cols = np.interp(t, along, line[:, 0]), np.interp(t, along, line[:, 1])
|
||||
angle = np.arctan2(np.diff(rows), np.diff(cols))
|
||||
turn = np.abs((np.diff(angle) + np.pi) % (2 * np.pi) - np.pi)
|
||||
kinks += int((np.degrees(turn) > KINK_DEG).sum())
|
||||
km = total * cell / 1000.0
|
||||
return {
|
||||
"contour_km": round(km, 2),
|
||||
"kinks_per_km": round(kinks / km, 1) if km else None,
|
||||
"bubbles": bubbles,
|
||||
}
|
||||
|
||||
|
||||
def gap_stats(
|
||||
z: np.ndarray, valid: np.ndarray, occupied: np.ndarray, cell: float
|
||||
) -> dict[str, Any]:
|
||||
gap = (ndimage.distance_transform_edt(~occupied) * cell > GAP_M) & valid
|
||||
grad_y, grad_x = np.gradient(z, cell)
|
||||
steep = np.degrees(np.arctan(np.hypot(grad_x, grad_y))) > GAP_STEEP_DEG
|
||||
area = int(valid.sum())
|
||||
return {
|
||||
"gap_pct": round(100.0 * int(gap.sum()) / area, 2) if area else None,
|
||||
"gap_steep_pct": round(100.0 * int((gap & steep).sum()) / area, 2) if area else None,
|
||||
}
|
||||
|
||||
|
||||
def ground_occupancy(xyz: Any, indices: np.ndarray, bounds: np.ndarray, shape: tuple) -> np.ndarray:
|
||||
"""지면점이 하나라도 든 칸 — dtm 격자와 같은 칸 나눔(`TerrainContext.grid`)."""
|
||||
rows, cols = shape
|
||||
cell_x = (bounds[0, 1] - bounds[0, 0]) / max(cols - 1, 1)
|
||||
cell_y = (bounds[1, 1] - bounds[1, 0]) / max(rows - 1, 1)
|
||||
occupied = np.zeros(shape, dtype=bool)
|
||||
for start in range(0, len(indices), 2_000_000):
|
||||
points = np.asarray(xyz[indices[start : start + 2_000_000]], dtype=np.float64)
|
||||
gx = np.clip(((points[:, 0] - bounds[0, 0]) / cell_x).astype(np.int64), 0, cols - 1)
|
||||
gy = np.clip(((points[:, 1] - bounds[1, 0]) / cell_y).astype(np.int64), 0, rows - 1)
|
||||
occupied[gy, gx] = True
|
||||
return occupied
|
||||
|
||||
|
||||
def diff_pct(z: np.ndarray, valid: np.ndarray, other_path: Path) -> float | None:
|
||||
other_z, other_valid, _ = _load_grid(other_path)
|
||||
if other_z.shape != z.shape:
|
||||
return None
|
||||
both = valid & other_valid
|
||||
area = int(both.sum())
|
||||
if not area:
|
||||
return None
|
||||
return round(100.0 * int(((np.abs(z - other_z) > DIFF_M) & both).sum()) / area, 2)
|
||||
|
||||
|
||||
def _pair(models_dir: Path, filter_key: str, method: str) -> Path | None:
|
||||
for other in _PAIRS.get(filter_key, ()):
|
||||
path = _grid_path(models_dir, other, method)
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def ensure_quality(
|
||||
models_dir: Path,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
occupied: np.ndarray | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""지표 넷을 돌려줌 — 저장본 지문이 맞으면 그대로 · ③ 은 지을 때 넘긴 지면점 칸만."""
|
||||
if method not in GRID_METHODS:
|
||||
return None
|
||||
grid_path = _grid_path(models_dir, filter_key, method)
|
||||
if not grid_path.is_file():
|
||||
return None
|
||||
pair = _pair(models_dir, filter_key, method)
|
||||
key = {
|
||||
"version": QUALITY_VERSION,
|
||||
"model": file_stamp(grid_path),
|
||||
"pair": [pair.name, file_stamp(pair)] if pair else None,
|
||||
}
|
||||
path = models_dir / f"quality_{filter_key}_{method}.json"
|
||||
try:
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
stored = {}
|
||||
if stored.get("key") == key and occupied is None:
|
||||
return stored
|
||||
z, valid, cell = _load_grid(grid_path)
|
||||
cell = float(cell)
|
||||
same_model = (stored.get("key") or {}).get("model") == key["model"]
|
||||
result: dict[str, Any] = {"key": key, "filter": filter_key, "method": method}
|
||||
if same_model and occupied is None:
|
||||
result.update({k: stored.get(k) for k in ("spike_pct", "spike_count", "contour_km")})
|
||||
result.update({k: stored.get(k) for k in ("kinks_per_km", "bubbles")})
|
||||
else:
|
||||
result.update(spike_stats(z, valid))
|
||||
result.update(contour_kinks(z, valid, cell))
|
||||
if occupied is not None and occupied.shape == z.shape:
|
||||
result.update(gap_stats(z, valid, occupied, cell))
|
||||
elif same_model:
|
||||
result.update({k: stored.get(k) for k in ("gap_pct", "gap_steep_pct")})
|
||||
result["diff_against"] = pair.name if pair else None
|
||||
result["diff_pct"] = diff_pct(z, valid, pair) if pair else None
|
||||
atomic_write_json(path, result)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_quality_safely(models_dir: Path, filter_key: str, method: str, **kwargs: Any) -> None:
|
||||
"""지을 때 부름 — 실패가 모델 짓기를 막지 않음."""
|
||||
try:
|
||||
ensure_quality(models_dir, filter_key, method, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("서피스 품질 지표 실패: %s-%s %s", filter_key, method, exc)
|
||||
|
||||
|
||||
def quality_after_build(
|
||||
models_dir: Path, filter_key: str, method: str, context: Any, fresh: bool
|
||||
) -> None:
|
||||
"""파이프라인이 표현 하나를 짓거나 다시 쓴 뒤 부름 — ③ 은 지면점이 손에 있을 때만 셈."""
|
||||
if method not in GRID_METHODS:
|
||||
return
|
||||
occupied = None
|
||||
if fresh or not (models_dir / f"quality_{filter_key}_{method}.json").is_file():
|
||||
try:
|
||||
resolution = float(context.config.get("dtm_grid_resolution_meters", 1.0))
|
||||
shape = context.grid(resolution)[2].shape
|
||||
occupied = ground_occupancy(context.xyz, context.indices(), context.bounds, shape)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("서피스 품질 지면점 칸 실패: %s %s", filter_key, exc)
|
||||
ensure_quality_safely(models_dir, filter_key, method, occupied=occupied)
|
||||
@@ -287,19 +287,6 @@ async def list_surface_models(
|
||||
return models
|
||||
|
||||
|
||||
async def clear_confirmed_surface_models(connection: aiomysql.Connection, project_id: UUID) -> None:
|
||||
"""재분석 시 기존 확정을 해제하여 새 분석 결과의 재확정을 요구한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE surface_models
|
||||
SET status = 'COMPLETE'
|
||||
WHERE project_id = %s AND status = 'CONFIRMED'
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
|
||||
|
||||
async def confirm_surface_model(
|
||||
connection: aiomysql.Connection, project_id: UUID, model_id: int
|
||||
) -> None:
|
||||
@@ -384,7 +371,7 @@ async def _upsert_surface_model(
|
||||
processed_cloud_id: int | None,
|
||||
crs_epsg: int | None,
|
||||
) -> int:
|
||||
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결이 그대로.
|
||||
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결 · 확정이 그대로(763-3).
|
||||
|
||||
지문이 다르거나 없으면 옛 행을 지우고 새로 넣는다(새 지표면 = 노선 연결도 끊김).
|
||||
"""
|
||||
@@ -397,7 +384,8 @@ async def _upsert_surface_model(
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE surface_models
|
||||
SET source_file_id = %s, processed_cloud_id = %s, status = 'COMPLETE',
|
||||
SET source_file_id = %s, processed_cloud_id = %s,
|
||||
status = IF(status = 'CONFIRMED', 'CONFIRMED', 'COMPLETE'),
|
||||
crs_epsg = %s, resolution_m = %s, generation_params = %s,
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
|
||||
@@ -24,7 +24,6 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
|
||||
from B04_PreProcess.B04_PreProcess_Repository import (
|
||||
clear_confirmed_surface_models,
|
||||
get_input_file,
|
||||
list_project_point_cloud_inputs,
|
||||
list_project_point_cloud_paths,
|
||||
@@ -67,13 +66,11 @@ from common_util.common_util_auth import require_system_admin
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
confirmed_surface_params,
|
||||
get_surface_confirmation_params,
|
||||
surface_confirmation_defaults,
|
||||
)
|
||||
from common_util.common_util_workflow_state import (
|
||||
fail_stage,
|
||||
start_stage,
|
||||
update_stage_progress,
|
||||
terrain_changes,
|
||||
update_contour_interval_param,
|
||||
)
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -89,7 +86,10 @@ router.include_router(status_router)
|
||||
async def analyze_surface(
|
||||
project_id: UUID, request: SurfaceAnalyzeRequest
|
||||
) -> SurfaceAnalyzeResponse | JSONResponse:
|
||||
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다."""
|
||||
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다 — 보기만(763-3 · 요구 605).
|
||||
|
||||
확정 · 단계 상태 · 1 단계 값(`las_selection` 포함)을 안 바꿈 · 미리 셈도 없음. 서피스를 바꾸는
|
||||
길 = 다른 모델 [확정](전처리 체인 다시)."""
|
||||
try:
|
||||
source_filters = request.resolved_filters()
|
||||
methods = request.resolved_methods()
|
||||
@@ -97,19 +97,9 @@ async def analyze_surface(
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
|
||||
pool = get_db_pool()
|
||||
project_root: Path | None = None
|
||||
try:
|
||||
params = {
|
||||
"input_file_id": request.input_file_id,
|
||||
"source_filters": source_filters,
|
||||
"methods": methods,
|
||||
"force": request.force,
|
||||
}
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 1, params)
|
||||
await clear_confirmed_surface_models(connection, project_id)
|
||||
await connection.commit()
|
||||
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
input_file = await get_input_file(connection, project_id, request.input_file_id)
|
||||
@@ -151,19 +141,23 @@ async def analyze_surface(
|
||||
analysis_result=result,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await update_stage_progress(cursor, str(project_id), 1, 100)
|
||||
await connection.commit()
|
||||
confirmed = await confirmed_surface_params(connection, str(project_id))
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
if confirmed is not None:
|
||||
write_surface_progress(
|
||||
project_root, 100, "completed", "조합을 지었습니다 — 확정 그대로."
|
||||
)
|
||||
else:
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
|
||||
return SurfaceAnalyzeResponse(
|
||||
project_id=str(project_id),
|
||||
@@ -173,16 +167,14 @@ async def analyze_surface(
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||
await connection.commit()
|
||||
except (OSError, ValueError) as exc: # 보기만 — 단계 상태 그대로 · 진행 파일만 끝(763-3)
|
||||
if project_root is not None:
|
||||
write_surface_progress(project_root, 100, "failed", str(exc))
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||
await connection.commit()
|
||||
if project_root is not None:
|
||||
write_surface_progress(project_root, 100, "failed", str(exc))
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
|
||||
@@ -214,7 +206,10 @@ async def confirm_surface(
|
||||
request: SurfaceConfirmRequest,
|
||||
_session: dict[str, Any] = Depends(require_system_admin),
|
||||
) -> SurfaceConfirmResponse | JSONResponse:
|
||||
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
|
||||
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다.
|
||||
|
||||
같은 서피스(필터 · 표현 · 평활) 다시 [확정] = 등고선 간격만 · 체인 없음. 다른 서피스 = 업로드와
|
||||
같은 전처리 체인(B05~B09 초기화 → 자동 설계 · 추천 미리 셈 · 763-3 · 요구 605)."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
@@ -247,24 +242,32 @@ async def confirm_surface(
|
||||
),
|
||||
}
|
||||
)
|
||||
await confirm_surface_selection(
|
||||
connection,
|
||||
project_id,
|
||||
request.model_id,
|
||||
selection,
|
||||
)
|
||||
current = await confirmed_surface_params(connection, str(project_id))
|
||||
same = current is not None and not terrain_changes(current, selection)
|
||||
if same: # 같은 서피스 — 아무것도 안 다시 셈(등고선 간격만 · 763-3)
|
||||
await update_contour_interval_param(
|
||||
connection, str(project_id), float(selection["contour_interval_m"])
|
||||
)
|
||||
else:
|
||||
await confirm_surface_selection(
|
||||
connection,
|
||||
project_id,
|
||||
request.model_id,
|
||||
selection,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
# 재확정 체인(2026-08-04 사용자 확정) — 새 지표면 기준으로 B05·B06을 백그라운드
|
||||
# 재계산·저장한다. 사용자 저장 입력(제어점·경사 옵션·측점별 설계)은 유지·이월된다.
|
||||
if same:
|
||||
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
|
||||
# 서피스가 바뀜 — 업로드와 같은 전처리 체인 하나(B05~B09 초기화 · 763-3 · 요구 605).
|
||||
# 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다.
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import run_redesign_chain
|
||||
from B03_FileInput.B03_FileInput_Service_SurfaceChange import run_surface_change_chain
|
||||
|
||||
redesign_task = asyncio.create_task(
|
||||
run_redesign_chain(project_id, request.model_id, dict(selection)),
|
||||
name=f"redesign-chain-{project_id}",
|
||||
run_surface_change_chain(project_id, request.model_id),
|
||||
name=f"surface-change-chain-{project_id}",
|
||||
)
|
||||
redesign_task.add_done_callback(
|
||||
lambda task: task.exception() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지
|
||||
|
||||
@@ -190,3 +190,34 @@ async def get_surface_model_contour(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/quality")
|
||||
async def get_surface_model_quality(project_id: UUID, model_id: int) -> JSONResponse:
|
||||
"""서피스 품질 지표 넷(762-2) — 저장본 지문이 다르면 다시 셈 · 격자 표현 밖이면 null."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Quality import ensure_quality
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_file_path FROM surface_models WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row[0]:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_path = Path(resolve_stored_project_path(stored_path)) / row[0]
|
||||
method, _, filter_key = model_path.stem.partition("_")
|
||||
try:
|
||||
quality = await asyncio.to_thread(ensure_quality, model_path.parent, filter_key, method)
|
||||
except Exception:
|
||||
logger.exception("서피스 품질 지표 실패: project_id=%s, model_id=%s", project_id, model_id)
|
||||
quality = None
|
||||
if quality:
|
||||
quality = {key: value for key, value in quality.items() if key != "key"}
|
||||
return JSONResponse(content={"status": "success", "quality": quality})
|
||||
|
||||
@@ -11,7 +11,12 @@ from config.config_system import (
|
||||
|
||||
# classification은 LAS가 지면분류를 싣고 있을 때 엔진이 자동으로 붙이지만,
|
||||
# 재계산 요청에서 이름으로 지정하는 것도 허용한다.
|
||||
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac", "classification"}
|
||||
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {
|
||||
"ransac",
|
||||
"classification",
|
||||
"classification_refine",
|
||||
"ptd_refine",
|
||||
}
|
||||
_ALLOWED_METHODS = set(SURFACE_MODEL_PRECOMPUTE)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* =============================================================================
|
||||
* B04_PreProcess_UI_Filter.ts
|
||||
* B04 지면 필터 드롭다운(고를 갈래 · 이름) + 고른 모델의 서피스 품질 지표 넷(762-2).
|
||||
* 지표 = GET /api/projects/{id}/surface/models/{model}/quality — 서버가 지을 때 셈해 둔 값
|
||||
* (튐 넓이 % · 1 m 등고선 꺾임 /km · 땅 점 빈 곳 % · 필터 차 %) · 문턱 판정은 안 함(사용자 협의).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createSelectField } from "@ui/ui_template_elements";
|
||||
import { createKv } from "@ui/ui_template_kv";
|
||||
import type { SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
|
||||
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
|
||||
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
|
||||
// `_refine` = 다듬기 판(762) — 바탕 필터 바로 뒤에 둠(이름 찾기는 가장 긴 것이 이김).
|
||||
export const SOURCE_FILTERS = [
|
||||
"classification",
|
||||
"classification_refine",
|
||||
"ptd",
|
||||
"ptd_refine",
|
||||
"grid_min_z",
|
||||
"csf",
|
||||
"pmf",
|
||||
] as const;
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
ptd: "점진 TIN",
|
||||
classification_refine: "업체 분류 + 다듬기",
|
||||
ptd_refine: "점진 TIN + 다듬기",
|
||||
};
|
||||
|
||||
function optionLabel(value: string): string {
|
||||
return FILTER_LABELS[value] ?? value.replaceAll("_", " ").toUpperCase();
|
||||
}
|
||||
|
||||
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
|
||||
export function buildSelectField(
|
||||
label: string,
|
||||
values: readonly string[],
|
||||
defaultValue: string,
|
||||
): { root: HTMLElement; select: HTMLSelectElement } {
|
||||
const { root, select } = createSelectField({
|
||||
label,
|
||||
options: values.map((value) => ({ value, text: optionLabel(value) })),
|
||||
value: defaultValue,
|
||||
size: 36,
|
||||
});
|
||||
root.classList.add("b04-surface__field");
|
||||
return { root, select };
|
||||
}
|
||||
|
||||
interface SurfaceQuality {
|
||||
spike_pct?: number | null;
|
||||
spike_count?: number | null;
|
||||
kinks_per_km?: number | null;
|
||||
gap_pct?: number | null;
|
||||
gap_steep_pct?: number | null;
|
||||
diff_pct?: number | null;
|
||||
diff_against?: string | null;
|
||||
}
|
||||
|
||||
function fmt(value: number | null | undefined, unit: string): string | null {
|
||||
return value == null ? null : `${value.toLocaleString()} ${unit}`;
|
||||
}
|
||||
|
||||
function rows(quality: SurfaceQuality | null): ReturnType<typeof createKv> {
|
||||
const against = quality?.diff_against
|
||||
?.replace(/^(dtm|implicit)_/, "")
|
||||
.replace(/(_grid)?\.npz$/, "");
|
||||
const list = createKv([
|
||||
{
|
||||
label: "튐 넓이",
|
||||
value:
|
||||
quality?.spike_pct == null
|
||||
? null
|
||||
: `${quality.spike_pct} % · ${quality.spike_count ?? 0} 곳`,
|
||||
},
|
||||
{ label: "등고선 꺾임", value: fmt(quality?.kinks_per_km, "/km") },
|
||||
{ label: "땅 점 빈 곳", value: fmt(quality?.gap_pct, "%") },
|
||||
{ label: "그중 급경사", value: fmt(quality?.gap_steep_pct, "%") },
|
||||
// 대상 이름은 길어 줄을 깨뜨림 — 값은 % 만 · 대상은 마우스 올림 글
|
||||
{ label: "필터 차", value: fmt(quality?.diff_pct, "%") },
|
||||
]);
|
||||
if (against) list.title = `필터 차 = ${optionLabel(against)} 모델과 0.5 m 넘게 다른 넓이`;
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 고른 모델의 품질 지표 넷 — 늦게 온 옛 응답은 버림. */
|
||||
export function createQualityPanel(): {
|
||||
root: HTMLElement;
|
||||
show: (projectId: string, model: SurfaceModelSummary | undefined) => void;
|
||||
} {
|
||||
const root = document.createElement("div");
|
||||
root.append(rows(null));
|
||||
let ticket = 0;
|
||||
function show(projectId: string, model: SurfaceModelSummary | undefined): void {
|
||||
const mine = ++ticket;
|
||||
root.replaceChildren(rows(null));
|
||||
if (!model) return;
|
||||
void fetch(`${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/quality`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload: { quality?: SurfaceQuality | null } | null) => {
|
||||
if (mine === ticket) root.replaceChildren(rows(payload?.quality ?? null));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return { root, show };
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type SurfacePointCloudSampleResponse,
|
||||
type SurfaceStatusResponse,
|
||||
} from "./B04_PreProcess_Api_Fetch";
|
||||
import { buildSelectField, createQualityPanel, SOURCE_FILTERS } from "./B04_PreProcess_UI_Filter";
|
||||
import { buildSurfaceGroup } from "./B04_PreProcess_UI_Group";
|
||||
import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer";
|
||||
import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer";
|
||||
@@ -49,9 +50,6 @@ import "./B04_PreProcess_UI_Style.css";
|
||||
import "./B04_PreProcess_UI_Style_Map.css";
|
||||
import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch";
|
||||
|
||||
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
|
||||
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
|
||||
const SOURCE_FILTERS = ["classification", "ptd", "grid_min_z", "csf", "pmf"] as const;
|
||||
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
|
||||
const DEFAULT_FILTER = "ptd";
|
||||
const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저
|
||||
@@ -67,30 +65,11 @@ function infoRow(label: string, value: unknown): KvRow {
|
||||
return { label, value: value == null || value === "" ? "-" : String(value) };
|
||||
}
|
||||
|
||||
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
|
||||
function buildSelectField(
|
||||
label: string,
|
||||
values: readonly string[],
|
||||
defaultValue: string,
|
||||
): { root: HTMLElement; select: HTMLSelectElement } {
|
||||
const { root, select } = createSelectField({
|
||||
label,
|
||||
options: values.map((value) => ({
|
||||
value,
|
||||
text: value === "ptd" ? "점진 TIN" : value.replaceAll("_", " ").toUpperCase(),
|
||||
})),
|
||||
value: defaultValue,
|
||||
size: 36,
|
||||
});
|
||||
root.classList.add("b04-surface__field");
|
||||
return { root, select };
|
||||
}
|
||||
|
||||
function getModelFilter(model: SurfaceModelSummary): string {
|
||||
const configured = model.generation_params?.source_filter;
|
||||
if (typeof configured === "string") return configured.toLowerCase();
|
||||
const path = model.model_file_path?.toLowerCase() ?? "";
|
||||
return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? "";
|
||||
return SOURCE_FILTERS.filter((filter) => path.includes(filter)).pop() ?? "";
|
||||
}
|
||||
|
||||
export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
@@ -138,6 +117,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
let appliedFilter = DEFAULT_FILTER;
|
||||
let appliedMethod = DEFAULT_METHOD;
|
||||
const viewer = createSurfacePointCloudViewer();
|
||||
const quality = createQualityPanel(); // 762-2 고른 모델의 서피스 품질 지표 넷
|
||||
const terrainViewer = createSurfaceTerrainViewer();
|
||||
const mapViewer = createSurfaceMapViewer();
|
||||
// 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천,
|
||||
@@ -268,6 +248,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
filterGroup.root,
|
||||
methodGroup.root,
|
||||
terrainViewer.smoothingField,
|
||||
quality.root,
|
||||
]);
|
||||
// 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래).
|
||||
const displayGroup = buildSurfaceGroup(L("B04_Surface_Group_Display"), [
|
||||
@@ -460,6 +441,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
if (!projectId) return;
|
||||
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
|
||||
terrainViewer.render(projectId, models);
|
||||
quality.show(projectId, findSelectedModel());
|
||||
confirmButton.disabled = !findSelectedModel();
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface PrecomputeProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
mode: RouteInitialLogic | null;
|
||||
/** 기록은 「도는 중」인데 서버가 다시 켜져 끊김(763-4) — 추천 요청 409 가 이어 셈을 예약. */
|
||||
interrupted?: boolean;
|
||||
}
|
||||
|
||||
/** 미리 셈 진행 — 못 읽으면 null(진행 표시만 빠짐). */
|
||||
|
||||
@@ -221,6 +221,9 @@ async def upload_route_lidar(project_id: UUID, files: list[UploadFile] = File(..
|
||||
await connection.begin()
|
||||
await stash_las_selection(connection, str(project_id), got["meta"]["selection"])
|
||||
await connection.commit()
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
schedule_precompute(project_id) # 라이다 지형이 바뀜 — 추천 다시 셈(763-3)
|
||||
report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개")
|
||||
|
||||
return lidar_upload.start_job(_JOBS, _TASKS, project_id, KIND, work, root / JOB, "B05")
|
||||
|
||||
@@ -578,7 +578,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
|
||||
if request.balanced_profile and as_initial
|
||||
else None
|
||||
),
|
||||
request.terrain if as_initial else None, # 손 [저장] = 지형 그대로 · 바꿈 = 지형 칩(626-1)
|
||||
None, # 설계 지형 그대로 — 추천 [적용]도 선만(763-3 · 요구 605-2) · 바꿈 = 지형 칩(626-1)
|
||||
(old_line, await asyncio.to_thread(_vertices_of, working_path)),
|
||||
as_initial,
|
||||
)
|
||||
|
||||
@@ -26,8 +26,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜)
|
||||
_RUNNING: dict[str, dict[str, Any]] = {}
|
||||
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음
|
||||
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다."
|
||||
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음 · 화면은 머리말로 가름
|
||||
# (`B05_Profile_UI_RouteEdit_RecPreview.ts` NOT_PREPARED_HEAD · 셈 중이면 진행 · 아니면 [지금 셈])
|
||||
NOT_PREPARED = "추천 저장본 없음 — 지표면이 바뀌어 다시 셈해야 함"
|
||||
# 미리 셈 진행 기록(763-4) — 재시동 뒤 「끊김」을 알게 · 저장본 폴더 밖(저장본 glob 에 안 섞임)
|
||||
JOB_FILE = Path("B05_Profile") / "route_recommend_job.json"
|
||||
# ponytail: 다른 서버(같은 storage)가 도는 중인지 = 기록 나이 · 갈래 하나 최대 ~2 분 · 넘으면 끊김
|
||||
JOB_STALE_S = 600.0
|
||||
|
||||
|
||||
@replan.router.post("/{project_id}/route/initial", response_model=None)
|
||||
@@ -172,6 +177,7 @@ async def compute_initial(
|
||||
)[0],
|
||||
)
|
||||
if found is None:
|
||||
resume_if_interrupted(project_id, project_root) # 끊긴 미리 셈 이어 셈(763-4)
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "error", "message": NOT_PREPARED}
|
||||
)
|
||||
@@ -296,6 +302,9 @@ async def precompute_defaults(project_id: UUID) -> None:
|
||||
progress = _RUNNING.get(str(project_id))
|
||||
if progress is not None:
|
||||
progress.update(total=len(terrains) * len(variants) * len(modes))
|
||||
if paths is not None: # 진행 기록 자리 — 끝 기록은 `run_precompute`(763-4)
|
||||
progress["root"] = paths[0]
|
||||
_note(paths, progress)
|
||||
for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes):
|
||||
if progress is not None:
|
||||
progress["mode"] = mode
|
||||
@@ -308,17 +317,17 @@ async def precompute_defaults(project_id: UUID) -> None:
|
||||
terrain=terrain,
|
||||
)
|
||||
outcome = await compute_initial(project_id, request, precompute=True)
|
||||
if isinstance(outcome, JSONResponse):
|
||||
if isinstance(outcome, JSONResponse): # 그 갈래만 건너뜀(763-4)
|
||||
logger.warning(
|
||||
"추천 미리 셈 멈춤(%s %s %s): project_id=%s",
|
||||
"추천 미리 셈 갈래 건너뜀(%s %s %s): project_id=%s",
|
||||
terrain,
|
||||
mode,
|
||||
outcome.status_code,
|
||||
project_id,
|
||||
)
|
||||
return
|
||||
if progress is not None:
|
||||
progress["done"] += 1
|
||||
_note(paths, progress)
|
||||
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
|
||||
|
||||
|
||||
@@ -343,8 +352,23 @@ async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any]
|
||||
return [design, *[source for source in sources if source != design]]
|
||||
|
||||
|
||||
def _note(paths: Any, progress: dict[str, Any], running: bool = True) -> None:
|
||||
"""진행 기록 쓰기(763-4) — 실패해도 셈은 계속."""
|
||||
from common_util.common_util_lidar_upload import note_job
|
||||
|
||||
if paths is not None:
|
||||
note_job(
|
||||
Path(paths[0]) / JOB_FILE,
|
||||
{
|
||||
"running": running,
|
||||
**{k: progress.get(k) for k in ("done", "total", "mode")},
|
||||
"updated": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def run_precompute(project_id: UUID) -> None:
|
||||
"""미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음)."""
|
||||
"""미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음) · 기록(763-4)."""
|
||||
key = str(project_id)
|
||||
if key in _RUNNING:
|
||||
_RUNNING[key]["again"] = True
|
||||
@@ -356,20 +380,62 @@ async def run_precompute(project_id: UUID) -> None:
|
||||
if not _RUNNING[key]["again"]:
|
||||
break
|
||||
finally:
|
||||
_RUNNING.pop(key, None)
|
||||
progress = _RUNNING.pop(key, {})
|
||||
if progress.get("root"):
|
||||
_note((progress["root"],), progress, running=False)
|
||||
|
||||
|
||||
def precompute_progress(project_id: UUID) -> dict[str, Any]:
|
||||
"""모달 진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래."""
|
||||
def precompute_progress(project_id: UUID, project_root: Path | None = None) -> dict[str, Any]:
|
||||
"""진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래 · 끊겼나(763-4).
|
||||
|
||||
이 서버 메모리가 먼저 · 없으면 기록: 「도는 중」이 `JOB_STALE_S` 안 = 다른 서버가 셈 ·
|
||||
넘음 = 끊김."""
|
||||
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")}}
|
||||
if progress is not None:
|
||||
return {
|
||||
"running": True,
|
||||
"interrupted": False,
|
||||
**{k: progress[k] for k in ("done", "total", "mode")},
|
||||
}
|
||||
idle = {"running": False, "interrupted": False, "done": 0, "total": 0, "mode": None}
|
||||
if project_root is None:
|
||||
return idle
|
||||
try:
|
||||
saved = json.loads((Path(project_root) / JOB_FILE).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return idle
|
||||
if not isinstance(saved, dict) or not saved.get("running"):
|
||||
return idle
|
||||
fresh = time.time() - float(saved.get("updated") or 0) < JOB_STALE_S
|
||||
return {
|
||||
**idle,
|
||||
**{k: saved.get(k) for k in ("done", "total", "mode")},
|
||||
"running": fresh,
|
||||
"interrupted": not fresh,
|
||||
}
|
||||
|
||||
|
||||
def resume_if_interrupted(project_id: UUID, project_root: Path) -> bool:
|
||||
"""추천 요청이 409 인데 기록이 「끊김」 — 이 서버가 이어 셈 예약 한 번(돌면 안 겹침 · 763-4).
|
||||
|
||||
이어 셈 = 미리 셈 다시(있는 갈래는 저장본 꺼내기만 · 0.2 s 안팎)."""
|
||||
state = precompute_progress(project_id, project_root)
|
||||
if not state["interrupted"]:
|
||||
return False
|
||||
_note((project_root,), state) # 곧바로 「도는 중」 — 뒤 요청이 또 예약하지 않게
|
||||
schedule_precompute(project_id)
|
||||
logger.info("추천 미리 셈 이어 셈 예약(끊김 기록): project_id=%s", project_id)
|
||||
return True
|
||||
|
||||
|
||||
@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)}
|
||||
try:
|
||||
paths = await replan._project_paths(project_id)
|
||||
except LookupError:
|
||||
paths = None
|
||||
root = paths[0] if paths is not None else None
|
||||
return {"status": "success", **precompute_progress(project_id, root)}
|
||||
|
||||
|
||||
def schedule_precompute(project_id: UUID) -> None:
|
||||
|
||||
@@ -4,20 +4,32 @@
|
||||
* (영선 · 토공 최소 · 기울기 최소 · 비중 계산) · 여럿 고름(589-12 · 줄마다 색 · 하나도 안 고르면 꺼짐) ·
|
||||
* 선만(위 캔버스 · 노선 아래 그림 · 집기 없음).
|
||||
* 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 ·
|
||||
* 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김).
|
||||
* 저장본 없으면(409) — 미리 셈 도는 중 = 진행 n/m(줄 마우스 글 · 알림 한 번) · 끝나면 다시 그림 ·
|
||||
* 아니면 확인창 「지표면이 바뀌어 다시 셈해야 함」 [지금 셈](그 갈래만 `recompute` · 763-4) ·
|
||||
* 지형이 바뀌면 받아 둔 선을 비움. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김).
|
||||
* 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞).
|
||||
* 메뉴 · 선 그리기 = 공용 `A00_Common/map/map_route_preview`(636-2 · 예정노선 가이드 선과 같이 씀).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview";
|
||||
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
|
||||
import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
|
||||
import {
|
||||
fetchPrecomputeProgress,
|
||||
fetchRouteInitial,
|
||||
type RouteInitialLogic,
|
||||
} from "./B05_Profile_Api_RouteInitial";
|
||||
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 서버 `NOT_PREPARED` 머리말(`B05_Profile_Router_RouteInitial.py`) — 저장본 없음 가름(763-4). */
|
||||
const NOT_PREPARED_HEAD = "추천 저장본 없음";
|
||||
/** 미리 셈 진행 묻기 간격(ms). */
|
||||
const POLL_MS = 3000;
|
||||
|
||||
/** 드롭다운 넷(요구 530 · 「예상노선 추적」 = 초기값이라 뺌). */
|
||||
const MODES = [
|
||||
"gradeline",
|
||||
@@ -54,6 +66,8 @@ export function createRecPreview(opts: {
|
||||
const lines = new Map<string, Vertex[]>();
|
||||
const shown = new Map<RouteInitialLogic, Vertex[]>();
|
||||
const turns = new Map<RouteInitialLogic, number>();
|
||||
const waiting = new Set<RouteInitialLogic>();
|
||||
let seenTerrain = opts.terrain();
|
||||
const menu = createRoutePreviewMenu({
|
||||
items: MODES.map((mode, index) => ({
|
||||
value: mode,
|
||||
@@ -63,32 +77,67 @@ export function createRecPreview(opts: {
|
||||
onPick: (mode) => void pick(mode as RouteInitialLogic),
|
||||
});
|
||||
|
||||
async function pick(mode: RouteInitialLogic): Promise<void> {
|
||||
/** 미리 셈이 도는 중이면 진행 n/m 을 보이고 끝난 뒤 다시 고름 — 돌면 true(763-4). */
|
||||
async function waitForPrecompute(mode: RouteInitialLogic, mine: number): Promise<boolean> {
|
||||
const progress = await fetchPrecomputeProgress(opts.projectId);
|
||||
if (!progress?.running || mine !== turns.get(mode)) return false;
|
||||
const note = `추천 셈 중 ${progress.done}/${progress.total}`;
|
||||
menu.setItem(mode, { title: note });
|
||||
if (!waiting.has(mode)) showToast(`${note} — 끝나면 그림`, "info");
|
||||
waiting.add(mode);
|
||||
window.setTimeout(() => {
|
||||
if (mine === turns.get(mode) && menu.values().includes(mode)) void pick(mode);
|
||||
}, POLL_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pick(mode: RouteInitialLogic, recompute = false): Promise<void> {
|
||||
const mine = (turns.get(mode) ?? 0) + 1;
|
||||
turns.set(mode, mine);
|
||||
shown.delete(mode);
|
||||
opts.redraw();
|
||||
if (!menu.values().includes(mode)) return;
|
||||
const key = `${mode}|${opts.terrain()}`;
|
||||
const terrain = opts.terrain();
|
||||
if (terrain !== seenTerrain) lines.clear(); // 지형 바뀜 = 옛 선 버림(763-4)
|
||||
seenTerrain = terrain;
|
||||
const key = `${mode}|${terrain}`;
|
||||
try {
|
||||
if (!lines.has(key)) {
|
||||
if (!lines.has(key) || recompute) {
|
||||
const body = (await fetchRouteInitial(opts.projectId, {
|
||||
mode,
|
||||
with_profile: true,
|
||||
terrain: opts.terrain(),
|
||||
terrain,
|
||||
...(recompute ? { recompute: true } : {}),
|
||||
})) as { planned?: number[][] };
|
||||
const planned = (body.planned ?? []).map(([x, y]) => [x, y] as Vertex);
|
||||
if (planned.length < 2) throw new Error("추천 선이 비었음");
|
||||
lines.set(key, planned);
|
||||
}
|
||||
if (mine !== turns.get(mode)) return;
|
||||
waiting.delete(mode);
|
||||
menu.setItem(mode, { title: "" });
|
||||
shown.set(mode, lines.get(key)!);
|
||||
opts.redraw();
|
||||
} catch (error) {
|
||||
if (mine !== turns.get(mode)) return;
|
||||
menu.setItem(mode, { checked: false });
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info");
|
||||
if (!recompute && (await waitForPrecompute(mode, mine))) return;
|
||||
waiting.delete(mode);
|
||||
menu.setItem(mode, { checked: false, title: "" });
|
||||
const now =
|
||||
text.startsWith(NOT_PREPARED_HEAD) &&
|
||||
(await askConfirm({
|
||||
title: "추천 노선",
|
||||
message: `「${LOGIC_LABEL[mode]}」 ${text}. 지금 셀까요? 몇 분 걸릴 수 있습니다.`,
|
||||
okLabel: "지금 셈",
|
||||
}));
|
||||
if (now) {
|
||||
menu.setItem(mode, { checked: true });
|
||||
showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 셈 중 — 끝나면 그림`, "info");
|
||||
return void pick(mode, true);
|
||||
}
|
||||
if (!text.startsWith(NOT_PREPARED_HEAD))
|
||||
showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ SurfaceFilter = Literal[
|
||||
"pmf",
|
||||
"ptd",
|
||||
"ransac",
|
||||
"classification_refine",
|
||||
"ptd_refine",
|
||||
"sheet_tin_sheet",
|
||||
"sheet_tin",
|
||||
"sheet_biharmonic",
|
||||
|
||||
@@ -527,11 +527,14 @@ async def _surface_run_settled(cursor: aiomysql.DictCursor, project_id: str) ->
|
||||
return stage in _SETTLED_SURFACE_STAGES
|
||||
|
||||
|
||||
async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_id: str) -> None:
|
||||
async def reset_stages_after_input_change(
|
||||
cursor: aiomysql.DictCursor, project_id: str, first_stage: int = 1
|
||||
) -> None:
|
||||
"""입력 자료가 갈렸을 때 1단계(전처리) 이후를 처음 상태로 되돌린다.
|
||||
|
||||
새 자료로 다시 계산해 덮어쓰므로, 옛 자료 기준의 진행 표시가 남아 있으면 사용자가 이미
|
||||
끝난 단계로 착각한다. 파일 입력(0단계)은 방금 끝났으니 건드리지 않는다.
|
||||
`first_stage` = 2 — 서피스 바꿈 체인(전처리 결과 · 확정은 그대로 · 763-3).
|
||||
"""
|
||||
await cursor.execute(
|
||||
"""
|
||||
@@ -542,7 +545,7 @@ async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_i
|
||||
message = NULL,
|
||||
started_at = NULL,
|
||||
completed_at = NULL
|
||||
WHERE project_id = %s AND stage_no >= 1
|
||||
WHERE project_id = %s AND stage_no >= %s
|
||||
""",
|
||||
(project_id,),
|
||||
(project_id, first_stage),
|
||||
)
|
||||
|
||||
@@ -94,6 +94,16 @@ SURFACE_MODEL_SOURCE_FILTERS = tuple(
|
||||
SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(","))
|
||||
# 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값.
|
||||
SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "ptd")
|
||||
# 762 제안 흐름 스위치 — 켜면 자동 전처리 · 확정 기본 = 기본 필터 + 다듬기(`_refine`) · 기본 끔.
|
||||
SURFACE_REFINE_FLOW = os.getenv("SURFACE_REFINE_FLOW", "False").lower() == "true"
|
||||
# 다듬기(SMRF 꼴) — 칸 · 물체 기울기(rise/run · PDAL 0.15 는 급경사 능선을 깎아 0.3) · 최대 창 ·
|
||||
# 판정 문턱 · 기울기 배수 · 낮은 잡점 기울기 — 나머지는 PDAL 기본.
|
||||
SURFACE_REFINE_CELL_M = float(os.getenv("SURFACE_REFINE_CELL_M", "1.0"))
|
||||
SURFACE_REFINE_SLOPE = float(os.getenv("SURFACE_REFINE_SLOPE", "0.3"))
|
||||
SURFACE_REFINE_WINDOW_M = float(os.getenv("SURFACE_REFINE_WINDOW_M", "18.0"))
|
||||
SURFACE_REFINE_THRESHOLD_M = float(os.getenv("SURFACE_REFINE_THRESHOLD_M", "0.5"))
|
||||
SURFACE_REFINE_SCALAR = float(os.getenv("SURFACE_REFINE_SCALAR", "1.25"))
|
||||
SURFACE_REFINE_LOW_SLOPE = float(os.getenv("SURFACE_REFINE_LOW_SLOPE", "5.0"))
|
||||
# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6)
|
||||
SURFACE_MODEL_PRECOMPUTE = tuple(
|
||||
os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",")
|
||||
@@ -177,7 +187,9 @@ SHEET_SURFACE_AUTO_METHODS = [
|
||||
]
|
||||
|
||||
# 일반 사용자 WF1 자동 확정 기본값
|
||||
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "ptd")
|
||||
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv(
|
||||
"SURFACE_CONFIRM_DEFAULT_FILTER", "ptd_refine" if SURFACE_REFINE_FLOW else "ptd"
|
||||
)
|
||||
# 확정 표현 = implicit · 평활 끔 — WF1 자동 짓기도 이 표현을 기본 필터에 지음(요구 558).
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "implicit")
|
||||
SURFACE_CONFIRM_DEFAULT_SMOOTH = (
|
||||
|
||||
@@ -66,6 +66,8 @@ def test_미리_셈_부르는_곳은_체인만() -> None:
|
||||
"B03_FileInput_Service_Chain.py", # 업로드 체인 · B04 재확정 체인
|
||||
"B01_Dashboard_Router.py", # 사용 범위 바꿈 = 예상노선 다시 씀
|
||||
"B05_Profile_Router_RouteInitial.py", # 정의
|
||||
"B03_FileInput_Service_SurfaceChange.py", # 서피스 바꿈 체인 초기화 실패 길(763-3)
|
||||
"B05_Profile_Router_Lidar.py", # LAS 나중 올리기 끝(763-3)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +75,7 @@ def test_화면_기본_갈래_저장본_없으면_409_셈_0(wired) -> None: # n
|
||||
for mode in DEFAULT_MODES:
|
||||
response = _ask(False, mode=mode)
|
||||
assert response.status_code == 409
|
||||
assert "전처리를 다시 하면 생깁니다" in json.loads(response.body)["message"]
|
||||
assert json.loads(response.body)["message"] == router.NOT_PREPARED
|
||||
assert wired.count == 0 and not list(store_dir(wired.root).glob("*.json"))
|
||||
|
||||
|
||||
|
||||
@@ -92,10 +92,9 @@ def test_두_체인_끝에서_미리_셈_예약() -> None:
|
||||
source = Path("B03_FileInput/B03_FileInput_Service_Chain.py").read_text(encoding="utf-8")
|
||||
redesign = source[source.index("async def run_redesign_chain(") :]
|
||||
upload = source[: source.index("async def run_redesign_chain(")]
|
||||
assert "schedule_precompute(project_id)" in upload
|
||||
# 재확정 체인 성공 끝(완료 로그 앞)
|
||||
done = redesign.index("재확정 체인 완료")
|
||||
assert "schedule_precompute(project_id)" in redesign[:done]
|
||||
# 두 체인 모두 finally — 실패 · 건너뜀 길에도 예약(763-3 · 실행 시험 = test_763_3)
|
||||
for body in (upload, redesign):
|
||||
assert "schedule_precompute(project_id)" in body.split(" finally:", 1)[1]
|
||||
b01 = Path("B01_Dashboard/B01_Dashboard_Router.py").read_text(encoding="utf-8")
|
||||
assert "refresh_expected_route(" in b01
|
||||
|
||||
|
||||
@@ -40,9 +40,16 @@ def test_겹쳐_셈_막음_끝난_뒤_한_번_더(monkeypatch) -> None:
|
||||
|
||||
asyncio.run(main())
|
||||
assert calls == [str(pid), str(pid)]
|
||||
assert seen[0] == {"running": True, "done": 2, "total": 5, "mode": "earthwork"}
|
||||
assert seen[0] == {
|
||||
"running": True,
|
||||
"interrupted": False, # 끊김 기록(763-4)
|
||||
"done": 2,
|
||||
"total": 5,
|
||||
"mode": "earthwork",
|
||||
}
|
||||
assert router.precompute_progress(pid) == {
|
||||
"running": False,
|
||||
"interrupted": False,
|
||||
"done": 0,
|
||||
"total": 0,
|
||||
"mode": None,
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_미리_셈_없는_지형은_안내_재계산은_셈(two) -> None:
|
||||
missing = _ask(False, mode="gentle", terrain="sheet")
|
||||
assert missing.status_code == 409
|
||||
message = json.loads(missing.body)["message"]
|
||||
assert "전처리를 다시 하면" in message and "[재계산]" in message
|
||||
assert message == router.NOT_PREPARED # 화면 = 진행 또는 [지금 셈](763-4)
|
||||
assert two.count == 0
|
||||
assert _ask(False, mode="gentle", terrain="sheet", recompute=True)["stored"] is False
|
||||
assert two.count == 1
|
||||
@@ -116,7 +116,7 @@ def test_고를_지형은_DB_모델_있을_때만(tmp_path, monkeypatch) -> None
|
||||
def test_지형_바꿈은_추천_적용만_초기값_다시_세움() -> None:
|
||||
replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8")
|
||||
save = replan.split("async def _save_route(", 1)[1]
|
||||
assert "request.terrain if as_initial else None," in save # 손 [저장]은 지형 안 바꿈
|
||||
assert "request.terrain" not in save # 추천 [적용]도 지형 안 바꿈(763-3 · 요구 605-2)
|
||||
assert save.index("await _recompute(") < save.index("refresh_initial_snapshot(project_id")
|
||||
recompute = replan.split("async def _recompute(", 1)[1].split("\nasync def ")[0]
|
||||
assert "switch_design_terrain(connection, project_id, terrain)" in recompute
|
||||
|
||||
@@ -42,10 +42,11 @@ def test_미리보기_넷_하나만_저장본만() -> None:
|
||||
src = read(B05 / "B05_Profile_UI_RouteEdit_RecPreview.ts")
|
||||
modes = re.search(r"const MODES = \[(.*?)\]", src, re.S).group(1)
|
||||
assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"]
|
||||
assert "recompute" not in src and "createRoutePreviewMenu({" in src
|
||||
# 저장본만 · 다시 셈은 저장본 없을 때 확인창 [지금 셈]만(763-4)
|
||||
assert src.count("recompute: true") == 1 and "createRoutePreviewMenu({" in src
|
||||
shared = read(ROOT / "A00_Common" / "map" / "map_route_preview.ts") # 636-2 공용 메뉴 · 그리기
|
||||
assert "createPickMenu({" in shared and "multi: true" in shared and "offLabel" not in shared
|
||||
assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7)
|
||||
assert "const terrain = opts.terrain();" in src # 지금 설계 지형 저장본(D7)
|
||||
chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome
|
||||
menu = read(ROOT / "ui_template" / "ui_template_context_menu.ts")
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""762-1 지면점 다듬기(SMRF 꼴) · 제안 흐름 스위치 · 762-2 품질 지표 지킴 시험.
|
||||
|
||||
합성 비탈(17°) + 능선(높이 6 m · 폭 약 16 m) + 덤불 혹(3 × 3 m · 1.5 m 뜸) — 필터가
|
||||
덤불까지 지면으로 고른 판 → 다듬기가 혹은 지우고 능선 · 비탈은 남김.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Engine_Ground as ground
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Refine import refine_ground_mask
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Quality import spike_stats
|
||||
|
||||
RNG = np.random.default_rng(7621)
|
||||
|
||||
|
||||
def _terrain(x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
||||
return 0.3 * x + 6.0 * np.exp(-(((x - 60.0) / 8.0) ** 2)) + 0.02 * y
|
||||
|
||||
|
||||
def _scene() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
count = 120 * 120 * 4
|
||||
x = RNG.uniform(0, 120, count)
|
||||
y = RNG.uniform(0, 120, count)
|
||||
z = _terrain(x, y) + RNG.normal(0, 0.03, count)
|
||||
bush = np.zeros(count, dtype=bool)
|
||||
for cx, cy in RNG.uniform(10, 110, (15, 2)):
|
||||
if abs(cx - 60) < 12: # 능선 위 덤불은 따로 가름 — 여기선 비탈 · 골 덤불
|
||||
cx += 25
|
||||
inside = (np.abs(x - cx) < 1.5) & (np.abs(y - cy) < 1.5)
|
||||
z[inside] += 1.5
|
||||
bush |= inside
|
||||
ridge = (np.abs(x - 60) < 4) & ~bush
|
||||
return np.column_stack((x, y, z)), bush, ridge
|
||||
|
||||
|
||||
def test_refine_drops_bushes_keeps_ridge() -> None:
|
||||
xyz, bush, ridge = _scene()
|
||||
kept = refine_ground_mask(xyz, np.ones(len(xyz), dtype=bool))
|
||||
print(kept[bush].mean(), kept[ridge].mean(), kept[~bush].mean())
|
||||
assert kept[bush].mean() < 0.1 # 덤불 혹 90 % 넘게 지움
|
||||
assert kept[ridge].mean() > 0.95 # 능선 안 깎음
|
||||
assert kept[~bush].mean() > 0.97 # 땅 점 거의 그대로
|
||||
|
||||
|
||||
def test_refine_never_adds_points() -> None:
|
||||
xyz, _, _ = _scene()
|
||||
mask = RNG.random(len(xyz)) < 0.5
|
||||
kept = refine_ground_mask(xyz, mask)
|
||||
assert not (kept & ~mask).any()
|
||||
assert refine_ground_mask(xyz, np.zeros(len(xyz), dtype=bool)).sum() == 0
|
||||
|
||||
|
||||
def test_refine_flow_switch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
xyz, _, _ = _scene()
|
||||
bounds = np.array([[0, 120], [0, 120], [0, 50]], dtype=np.float64)
|
||||
data = {"xyz": xyz, "bounds": bounds, "classification": np.full(len(xyz), 2, np.uint8)}
|
||||
# 기본 끔 = 지금 길 그대로
|
||||
assert ground.resolve_auto_source_filters(data) == ["classification", "ptd"]
|
||||
monkeypatch.setattr(ground, "SURFACE_REFINE_FLOW", True)
|
||||
assert ground.resolve_auto_source_filters(data) == ["classification_refine", "ptd_refine"]
|
||||
data["classification"][:] = 0
|
||||
assert ground.resolve_auto_source_filters(data) == ["ptd_refine"]
|
||||
assert ground.base_filter("ptd_refine") == "ptd"
|
||||
assert ground.base_filter("ptd") is None
|
||||
assert {"classification_refine", "ptd_refine"} <= set(ground.available_filters())
|
||||
# 바탕 마스크를 넘기면 바탕 필터를 다시 돌리지 않고 다듬기만
|
||||
base = np.ones(len(xyz), dtype=bool)
|
||||
refined = ground.run_ground_filter("ptd_refine", data, base)
|
||||
assert refined.sum() < base.sum()
|
||||
|
||||
|
||||
def test_spike_stat_sees_bumps() -> None:
|
||||
yy, xx = np.mgrid[0:100, 0:100].astype(float)
|
||||
z = 0.3 * xx
|
||||
valid = np.ones(z.shape, dtype=bool)
|
||||
assert spike_stats(z, valid)["spike_pct"] == 0.0
|
||||
z[40:42, 40:42] += 1.5
|
||||
stats = spike_stats(z, valid)
|
||||
assert stats["spike_count"] == 1 and stats["spike_pct"] > 0
|
||||
@@ -0,0 +1,266 @@
|
||||
"""763-3 (요구 605) B04 분석 · 조합 짓기 = 보기만 · 다른 서피스 [확정] = 업로드와 같은 전처리 체인 ·
|
||||
같은 서피스 다시 [확정] = 다시 셈 없음 · 체인 실패에도 추천 미리 셈 · B05~B09 는 서피스를 안 바꿈.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_763_3_surface_change.py -q
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from B03_FileInput import B03_FileInput_Service_SurfaceChange as change
|
||||
from B04_PreProcess import B04_PreProcess_Router as b04
|
||||
from B04_PreProcess.B04_PreProcess_Schema import SurfaceAnalyzeRequest, SurfaceConfirmRequest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONFIRMED = {
|
||||
"source_filter": "classification",
|
||||
"method": "implicit",
|
||||
"smooth": False,
|
||||
"contour_interval_m": 1.0,
|
||||
}
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_a):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, *_a):
|
||||
self.log.append(" ".join(str(sql).split()))
|
||||
|
||||
async def fetchone(self):
|
||||
return None
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
def cursor(self, *_a):
|
||||
return _Cursor(self.log)
|
||||
|
||||
async def begin(self):
|
||||
self.log.append("BEGIN")
|
||||
|
||||
async def commit(self):
|
||||
self.log.append("COMMIT")
|
||||
|
||||
async def rollback(self):
|
||||
self.log.append("ROLLBACK")
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield _Connection(self.log)
|
||||
|
||||
|
||||
def _async(value):
|
||||
async def inner(*_a, **_k):
|
||||
return value
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def b04_world(tmp_path, monkeypatch):
|
||||
log: list[str] = []
|
||||
calls: dict[str, list] = {"progress": [], "chain": [], "confirm": [], "contour": []}
|
||||
las = tmp_path / "raw.las"
|
||||
las.write_bytes(b"")
|
||||
monkeypatch.setattr(b04, "get_db_pool", lambda: _Pool(log))
|
||||
monkeypatch.setattr(b04, "get_project_storage_relative_path", _async("x"))
|
||||
monkeypatch.setattr(b04, "resolve_stored_project_path", lambda _p: str(tmp_path))
|
||||
monkeypatch.setattr(b04, "get_input_file", _async({"raw_file_path": "raw.las"}))
|
||||
monkeypatch.setattr(b04, "list_project_point_cloud_paths", _async([las]))
|
||||
monkeypatch.setattr(
|
||||
b04,
|
||||
"run_surface_analysis",
|
||||
lambda *_a, **_k: {"ground_summary": {}, "manifest": {"status": "ok"}},
|
||||
)
|
||||
monkeypatch.setattr(b04, "save_surface_analysis_to_db", _async([1, 2]))
|
||||
monkeypatch.setattr(b04, "confirmed_surface_params", _async(dict(CONFIRMED)))
|
||||
monkeypatch.setattr(
|
||||
b04, "write_surface_progress", lambda _r, p, stage, _m: calls["progress"].append(stage)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
b04,
|
||||
"list_surface_models",
|
||||
_async(
|
||||
[
|
||||
{
|
||||
"id": 7,
|
||||
"model_type": "implicit",
|
||||
"generation_params": {"source_filter": "classification"},
|
||||
},
|
||||
{"id": 8, "model_type": "implicit", "generation_params": {"source_filter": "csf"}},
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
async def confirm(_c, _pid, model_id, selection):
|
||||
calls["confirm"].append((model_id, selection["source_filter"]))
|
||||
|
||||
async def contour(_c, _pid, value):
|
||||
calls["contour"].append(value)
|
||||
|
||||
async def chain(pid, model_id):
|
||||
calls["chain"].append(model_id)
|
||||
|
||||
monkeypatch.setattr(b04, "confirm_surface_selection", confirm)
|
||||
monkeypatch.setattr(b04, "update_contour_interval_param", contour)
|
||||
monkeypatch.setattr(change, "run_surface_change_chain", chain)
|
||||
calls["log"] = log
|
||||
return calls
|
||||
|
||||
|
||||
def test_분석은_보기만_단계_확정_그대로(b04_world) -> None:
|
||||
request = SurfaceAnalyzeRequest(input_file_id=3, source_filters=["csf"], methods=["implicit"])
|
||||
answer = asyncio.run(b04.analyze_surface(uuid4(), request))
|
||||
assert answer.surface_model_ids == [1, 2]
|
||||
sql = " | ".join(b04_world["log"])
|
||||
assert "project_workflow_stages" not in sql and "surface_models" not in sql
|
||||
assert b04_world["progress"][-1] == "completed" # 확정 그대로 — 「확정하세요」 아님
|
||||
source = (ROOT / "B04_PreProcess/B04_PreProcess_Router.py").read_text(encoding="utf-8")
|
||||
body = source.split("async def analyze_surface(", 1)[1].split("\n@router.", 1)[0]
|
||||
for name in ("start_stage", "fail_stage", "update_stage_progress", "schedule_precompute"):
|
||||
assert name not in body, name
|
||||
|
||||
|
||||
def test_조합_다시_적어도_확정_행_그대로() -> None:
|
||||
source = (ROOT / "B04_PreProcess/B04_PreProcess_Repository.py").read_text(encoding="utf-8")
|
||||
assert "status = IF(status = 'CONFIRMED', 'CONFIRMED', 'COMPLETE')" in source
|
||||
assert "clear_confirmed_surface_models" not in source
|
||||
|
||||
|
||||
def _confirm(model_id: int, contour: float | None = None):
|
||||
request = SurfaceConfirmRequest(model_id=model_id, smooth=False, contour_interval_m=contour)
|
||||
|
||||
async def run():
|
||||
answer = await b04.confirm_surface(uuid4(), request, {})
|
||||
await asyncio.sleep(0) # 백그라운드 체인 한 번 돌림
|
||||
return answer
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_같은_서피스_다시_확정은_다시_셈_없음(b04_world) -> None:
|
||||
_confirm(7, 2.0)
|
||||
assert b04_world["confirm"] == [] and b04_world["chain"] == []
|
||||
assert b04_world["contour"] == [2.0]
|
||||
|
||||
|
||||
def test_다른_서피스_확정은_전처리_체인(b04_world) -> None:
|
||||
_confirm(8)
|
||||
assert b04_world["confirm"] == [(8, "csf")]
|
||||
assert b04_world["chain"] == [8]
|
||||
|
||||
|
||||
def test_서피스_바꿈_체인_차례(tmp_path, monkeypatch) -> None:
|
||||
import B03_FileInput.B03_FileInput_Repository as repo
|
||||
import B03_FileInput.B03_FileInput_Service_Chain as chain
|
||||
import B05_Profile.B05_Profile_Router_RouteInitial as router
|
||||
import common_util.common_util_initial_snapshot as snapshot
|
||||
import common_util.common_util_project_reset as reset
|
||||
import common_util.common_util_storage as storage
|
||||
import common_util.common_util_workflow_state as state
|
||||
import config.config_db as db
|
||||
|
||||
log: list = []
|
||||
|
||||
async def purge(_c, _pid, _root, keep_preprocess=False):
|
||||
log.append(("purge", keep_preprocess))
|
||||
|
||||
async def stages(_cursor, _pid, first_stage=1):
|
||||
log.append(("stages", first_stage))
|
||||
|
||||
async def auto(_pid, surface_model_id=None):
|
||||
log.append(("auto", surface_model_id))
|
||||
|
||||
monkeypatch.setattr(db, "get_db_pool", lambda: _Pool([]))
|
||||
monkeypatch.setattr(repo, "get_project_storage_relative_path", _async("x"))
|
||||
monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path))
|
||||
monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda _r: log.append("snapshot"))
|
||||
monkeypatch.setattr(reset, "purge_project_outputs", purge)
|
||||
monkeypatch.setattr(state, "reset_stages_after_input_change", stages)
|
||||
monkeypatch.setattr(chain, "run_auto_design_chain", auto)
|
||||
monkeypatch.setattr(router, "schedule_precompute", lambda pid: log.append("precompute"))
|
||||
asyncio.run(change.run_surface_change_chain(uuid4(), 8))
|
||||
assert log == ["snapshot", ("purge", True), ("stages", 2), ("auto", 8)]
|
||||
|
||||
async def broken(*_a, **_k):
|
||||
raise RuntimeError("db")
|
||||
|
||||
log.clear()
|
||||
monkeypatch.setattr(reset, "purge_project_outputs", broken)
|
||||
asyncio.run(change.run_surface_change_chain(uuid4(), 8))
|
||||
assert log == ["snapshot", "precompute"] # 초기화 실패 = 체인 없음 · 추천만 새 서피스로
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["run_auto_design_chain", "run_redesign_chain"])
|
||||
def test_체인_실패_길에도_미리_셈(monkeypatch, name) -> None:
|
||||
import B03_FileInput.B03_FileInput_Service_Chain as chain
|
||||
import B05_Profile.B05_Profile_Router_RouteInitial as router
|
||||
import config.config_db as db
|
||||
|
||||
class _Broken:
|
||||
def acquire(self):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
scheduled: list = []
|
||||
monkeypatch.setattr(db, "get_db_pool", lambda: _Broken())
|
||||
monkeypatch.setattr(router, "schedule_precompute", scheduled.append)
|
||||
project = uuid4()
|
||||
args = (project,) if name == "run_auto_design_chain" else (project, 1, {})
|
||||
asyncio.run(getattr(chain, name)(*args))
|
||||
assert scheduled == [project]
|
||||
|
||||
|
||||
def test_LAS_나중_올리기_끝에_미리_셈() -> None:
|
||||
source = (ROOT / "B05_Profile/B05_Profile_Router_Lidar.py").read_text(encoding="utf-8")
|
||||
work = source.split("async def work(", 1)[1].split("return lidar_upload.start_job", 1)[0]
|
||||
assert "schedule_precompute(project_id)" in work
|
||||
|
||||
|
||||
#: B05~B09 에서 확정 서피스를 바꿀 수 있는 부름 — 남은 곳은 까닭 있는 둘만(요구 605-2)
|
||||
_SURFACE_WRITES = re.compile(
|
||||
r"\b(confirm_surface_selection|confirm_surface_model|merge_surface_confirmation_params|"
|
||||
r"switch_design_terrain|stash_las_selection|run_surface_change_chain)\("
|
||||
)
|
||||
_ALLOWED = {
|
||||
# 지형 칩(626-1) — 사용자 답 기다림(763 ①) · 추천 [적용]은 지형을 안 넘김(아래 시험)
|
||||
"B05_Profile/B05_Profile_Router_Replan.py",
|
||||
# 라이다 확정값만 적어 둠 · 설계 지형 그대로(637-2)
|
||||
"B05_Profile/B05_Profile_Router_Lidar.py",
|
||||
}
|
||||
|
||||
|
||||
def test_B05_B09_확정은_서피스를_안_바꿈() -> None:
|
||||
found = set()
|
||||
for folder in (
|
||||
"B05_Profile",
|
||||
"B06_Section",
|
||||
"B07_DesignDetail",
|
||||
"B08_Quantity",
|
||||
"B09_Estimation",
|
||||
):
|
||||
for path in (ROOT / folder).glob("*.py"):
|
||||
if _SURFACE_WRITES.search(path.read_text(encoding="utf-8")):
|
||||
found.add(path.relative_to(ROOT).as_posix())
|
||||
assert found <= _ALLOWED, found - _ALLOWED
|
||||
replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8")
|
||||
save = replan.split("async def _save_route(", 1)[1].split("\nasync def ", 1)[0]
|
||||
assert "request.terrain" not in save # 추천 [적용] = 선만 · 설계 지형 그대로(605-2)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""763-4 추천 미리 셈이 끊기면 이어 셈 — 진행 기록 · 한 갈래 409 는 그 갈래만 건너뜀 · 화면 글 짝.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_763_4_precompute_resume.py -q
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처
|
||||
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial as router
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _record(root: Path, running: bool, age_s: float) -> None:
|
||||
path = root / router.JOB_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = {"running": running, "done": 3, "total": 10, "mode": "gentle"}
|
||||
path.write_text(json.dumps({**body, "updated": time.time() - age_s}), encoding="utf-8")
|
||||
|
||||
|
||||
def test_진행_기록_읽기(tmp_path) -> None:
|
||||
pid = uuid4()
|
||||
assert router.precompute_progress(pid, tmp_path)["running"] is False
|
||||
_record(tmp_path, True, 5) # 다른 서버가 도는 중
|
||||
state = router.precompute_progress(pid, tmp_path)
|
||||
assert state["running"] and not state["interrupted"] and state["done"] == 3
|
||||
_record(tmp_path, True, router.JOB_STALE_S + 1) # 재시동으로 끊김
|
||||
state = router.precompute_progress(pid, tmp_path)
|
||||
assert not state["running"] and state["interrupted"]
|
||||
_record(tmp_path, False, 5)
|
||||
assert router.precompute_progress(pid, tmp_path)["interrupted"] is False
|
||||
|
||||
|
||||
def test_끊김_기록_409_이어_셈_한_번(wired, monkeypatch) -> None: # noqa: F811
|
||||
scheduled: list = []
|
||||
monkeypatch.setattr(router, "schedule_precompute", scheduled.append)
|
||||
_record(wired.root, True, router.JOB_STALE_S + 1)
|
||||
for _ in range(3): # 화면이 여러 번 물어도 예약 한 번(기록이 곧바로 「도는 중」)
|
||||
answer = _ask(False, mode="gentle")
|
||||
assert answer.status_code == 409
|
||||
assert json.loads(answer.body)["message"] == router.NOT_PREPARED
|
||||
assert len(scheduled) == 1
|
||||
assert router.precompute_progress(uuid4(), wired.root)["running"] is True
|
||||
|
||||
|
||||
def test_끊김_아니면_예약_없음(wired, monkeypatch) -> None: # noqa: F811
|
||||
scheduled: list = []
|
||||
monkeypatch.setattr(router, "schedule_precompute", scheduled.append)
|
||||
_ask(False, mode="gentle") # 기록 없음
|
||||
_record(wired.root, True, 5) # 다른 서버가 셈 중 — 안 겹침
|
||||
_ask(False, mode="gentle")
|
||||
assert scheduled == []
|
||||
|
||||
|
||||
def test_미리_셈_기록_남기고_끝(wired) -> None: # noqa: F811
|
||||
asyncio.run(router.run_precompute(uuid4()))
|
||||
saved = json.loads((wired.root / router.JOB_FILE).read_text(encoding="utf-8"))
|
||||
assert saved["running"] is False and saved["done"] == saved["total"] == len(DEFAULT_MODES)
|
||||
|
||||
|
||||
def test_한_갈래_409_는_그_갈래만_건너뜀(wired, monkeypatch) -> None: # noqa: F811
|
||||
real = router.compute_initial
|
||||
asked: list[str] = []
|
||||
|
||||
async def flaky(project_id, request, precompute=False):
|
||||
asked.append(request.mode)
|
||||
if request.mode == "gentle":
|
||||
return JSONResponse(status_code=409, content={"message": "x"})
|
||||
return await real(project_id, request, precompute)
|
||||
|
||||
monkeypatch.setattr(router, "compute_initial", flaky)
|
||||
asyncio.run(router.precompute_defaults(uuid4()))
|
||||
assert sorted(asked) == sorted(DEFAULT_MODES)
|
||||
assert wired.count == len(DEFAULT_MODES) - 1
|
||||
|
||||
|
||||
def test_화면_머리말_서버_글과_같음() -> None:
|
||||
preview = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
head = preview.split('const NOT_PREPARED_HEAD = "', 1)[1].split('"', 1)[0]
|
||||
assert router.NOT_PREPARED.startswith(head)
|
||||
assert "fetchPrecomputeProgress(" in preview and "recompute: true" in preview
|
||||
assert 'okLabel: "지금 셈"' in preview and "lines.clear()" in preview
|
||||
assert "<button" not in preview # 부품 상자만(확인창 = ui_template_modal)
|
||||
Reference in New Issue
Block a user