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:
2026-11-03 14:51:29 +09:00
29 changed files with 1260 additions and 149 deletions
+8 -10
View File
@@ -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)
+19 -13
View File
@@ -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
+33 -6
View File
@@ -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)
+3 -15
View File
@@ -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
+48 -45
View File
@@ -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})
+6 -1
View File
@@ -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)
+110
View File
@@ -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 };
}
+5 -23
View File
@@ -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(진행 표시만 빠짐). */
+3
View File
@@ -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")
+1 -1
View File
@@ -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,
)
+79 -13
View File
@@ -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",
+6 -3
View File
@@ -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),
)
+13 -1
View File
@@ -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")
+80
View File
@@ -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)