239 lines
10 KiB
Python
239 lines
10 KiB
Python
"""E04 안정성평가 3D 해석 API (요구 347-1 · 351 · 요구서 4-2 · 4-3 · PLAN 533-3).
|
|
|
|
- `POST /api/projects/{id}/stab/analyze3d` — 한 구간 3D 해석(뒤에서) · 입력 지문이 저장본과 같으면
|
|
셈하지 않고 `cached` · `force` 면 다시 셈
|
|
- `POST …/stab/analyze3d/cancel` — 도는 해석 끊기(다시 시작 = analyze3d 다시)
|
|
- `GET …/stab/progress3d` — 진행률
|
|
- `GET …/stab/result3d` — 자동 구간 · 노선 · 측점 · 저장본 요약(+ 지문이 지금과 다른지)
|
|
- `GET …/stab/result3d?key=` — 한 구간 저장본(래스터 · 임계 활동면)
|
|
|
|
계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`(지정받은 회사 = GET 만 · 그 밖 403).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from common_util.common_util_auth import require_company
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import get_db_pool
|
|
from E01_StabRegister import E01_StabRegister_Source as source
|
|
from E04_Stab3D import E04_Stab3D_Run as run
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["E04 Stab 3D"])
|
|
|
|
# 진행 — 프로젝트 → {running, percent, message, error, cancelled, key, summary} · 프로세스 메모리
|
|
_JOBS: dict[str, dict[str, Any]] = {}
|
|
_STOPS: dict[str, bool] = {}
|
|
_TASKS: set[asyncio.Task] = set()
|
|
_IDLE = {
|
|
"running": False,
|
|
"percent": 0,
|
|
"message": None,
|
|
"error": None,
|
|
"cancelled": False,
|
|
"key": None,
|
|
"summary": None,
|
|
}
|
|
|
|
|
|
class SearchSettings(BaseModel):
|
|
"""탐색 조건 — 길이 m · 부피 m³ (Scoops3D 방식 · 요구서 4-2)."""
|
|
|
|
shape: Literal["sphere", "both"] = "both" # both = 구 + 구 임계 노드에서 타원체
|
|
ellipsoid_ratio: float = Field(2.0, ge=1.1, le=5.0) # 타원체 = 회전축 방향 반축 배수
|
|
box_half_width_m: float = Field(30.0, ge=5.0, le=100.0) # 탐색 상자 = 중심선 ± 이 폭
|
|
cell_m: float = Field(1.0, ge=0.5, le=5.0) # 기둥(셀) 크기
|
|
spacing_m: float = Field(2.0, ge=0.5, le=10.0) # 구 중심 수평 간격(가장 고운)
|
|
h_min_m: float = Field(2.0, ge=0.0, le=100.0) # 구 중심 높이(지표 위) 범위 · 간격
|
|
h_max_m: float = Field(30.0, ge=1.0, le=200.0)
|
|
h_step_m: float = Field(2.0, ge=0.5, le=20.0)
|
|
dr_m: float = Field(1.0, ge=0.2, le=5.0) # 반지름 증분
|
|
max_depth_m: float = Field(15.0, ge=1.0, le=60.0)
|
|
v_min_m3: float = Field(10.0, ge=0.0)
|
|
v_max_m3: float = Field(10_000.0, gt=0.0)
|
|
|
|
|
|
class Analyze3dRequest(BaseModel):
|
|
start_m: float = Field(..., ge=0.0)
|
|
end_m: float = Field(..., ge=0.0)
|
|
search: SearchSettings = SearchSettings()
|
|
condition: Literal["dry", "wet"] = "dry" # 우기 = 지표까지 포화(KDS 우기 규칙)
|
|
force: bool = False
|
|
|
|
|
|
async def _project_root(project_id: UUID) -> Path:
|
|
async with (
|
|
get_db_pool().acquire() as connection,
|
|
connection.cursor(aiomysql.DictCursor) as cursor,
|
|
):
|
|
await cursor.execute(
|
|
"SELECT storage_path FROM projects"
|
|
" WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL",
|
|
(str(project_id),),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row or not row["storage_path"]:
|
|
raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.")
|
|
return Path(resolve_stored_project_path(row["storage_path"]))
|
|
|
|
|
|
def _source_root(project_root: Path) -> Path:
|
|
root = source.source_dir(project_root)
|
|
if not (root / source.MANIFEST_NAME).is_file():
|
|
raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.")
|
|
return root
|
|
|
|
|
|
def _settings(payload: Analyze3dRequest) -> dict[str, Any]:
|
|
"""지문 · 저장본에 남는 요청 — 소수 둘째 자리로 맞춤(같은 구간 = 같은 열쇠)."""
|
|
if payload.end_m <= payload.start_m:
|
|
raise HTTPException(status_code=400, detail="구간 끝이 시작보다 앞입니다.")
|
|
if payload.end_m - payload.start_m > run.MAX_SECTION_M:
|
|
raise HTTPException(
|
|
status_code=400, detail=f"구간은 {run.MAX_SECTION_M:.0f} m 이하로 고르세요."
|
|
)
|
|
search = payload.search
|
|
if search.h_max_m < search.h_min_m or search.v_max_m3 <= search.v_min_m3:
|
|
raise HTTPException(status_code=400, detail="탐색 범위의 최소가 최대보다 큽니다.")
|
|
return {
|
|
"start_m": round(payload.start_m, 2),
|
|
"end_m": round(payload.end_m, 2),
|
|
"condition": payload.condition,
|
|
**search.model_dump(),
|
|
}
|
|
|
|
|
|
def _current_fingerprint(project_root: Path, root: Path, settings: dict[str, Any]) -> str:
|
|
return run.fingerprint(
|
|
source.manifest(root)["source_fingerprint"], run.read_ground(project_root), settings
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/stab/analyze3d", response_model=None)
|
|
async def analyze3d(project_id: UUID, payload: Analyze3dRequest) -> dict[str, Any]:
|
|
"""[해석] — 지문이 같으면 `cached`(안 셈) · 다르거나 force 면 뒤에서 셈."""
|
|
pid = str(project_id)
|
|
project_root = await _project_root(project_id)
|
|
root = _source_root(project_root)
|
|
settings = _settings(payload)
|
|
key = run.section_key(settings["start_m"], settings["end_m"])
|
|
finger = await asyncio.to_thread(_current_fingerprint, project_root, root, settings)
|
|
saved = await asyncio.to_thread(run.load, project_root, key)
|
|
if not payload.force and saved and saved["fingerprint"] == finger:
|
|
return {"status": "cached", "key": key, "summary": saved["summary"]}
|
|
if (_JOBS.get(pid) or {}).get("running"):
|
|
raise HTTPException(status_code=409, detail="이미 3D 해석이 돌고 있습니다.")
|
|
|
|
job = {**_IDLE, "running": True, "message": "시작", "key": key}
|
|
_JOBS[pid] = job
|
|
_STOPS[pid] = False
|
|
|
|
def report(percent: int, message: str) -> None:
|
|
job.update(percent=max(0, min(99, int(percent))), message=message)
|
|
|
|
async def work() -> None:
|
|
try:
|
|
job["summary"] = await asyncio.to_thread(
|
|
run.analyze, project_root, root, settings, finger, report, lambda: _STOPS[pid]
|
|
)
|
|
job.update(percent=100, message="끝")
|
|
except InterruptedError:
|
|
job.update(cancelled=True, message="끊음")
|
|
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
|
|
logger.exception("E04 3D 해석 실패: project_id=%s", pid)
|
|
job["error"] = str(getattr(error, "detail", None) or error)
|
|
finally:
|
|
job["running"] = False
|
|
|
|
task = asyncio.create_task(work(), name=f"E04 3D {pid}")
|
|
_TASKS.add(task)
|
|
task.add_done_callback(_TASKS.discard)
|
|
return {"status": "accepted", "key": key}
|
|
|
|
|
|
@router.post("/{project_id}/stab/analyze3d/cancel", response_model=None)
|
|
async def cancel3d(project_id: UUID) -> dict[str, Any]:
|
|
"""[끊기] — 엔진이 다음 확인 때 멈춤 · 저장본은 그대로."""
|
|
pid = str(project_id)
|
|
running = bool((_JOBS.get(pid) or {}).get("running"))
|
|
if running:
|
|
_STOPS[pid] = True
|
|
return {"status": "success", "stopping": running}
|
|
|
|
|
|
@router.get("/{project_id}/stab/progress3d", response_model=None)
|
|
async def progress3d(project_id: UUID) -> dict[str, Any]:
|
|
return {"status": "success", **dict(_JOBS.get(str(project_id)) or _IDLE)}
|
|
|
|
|
|
def _overview(project_root: Path, root: Path) -> dict[str, Any]:
|
|
stations = source.stations(root)
|
|
finger_source = source.manifest(root)["source_fingerprint"]
|
|
ground = run.read_ground(project_root)
|
|
saved = run.saved_summaries(project_root)
|
|
for item in saved:
|
|
current = run.fingerprint(finger_source, ground, item.pop("settings"))
|
|
item["stale"] = item.pop("fingerprint") != current
|
|
return {
|
|
"sections": run.auto_sections(stations),
|
|
"stations": [
|
|
{
|
|
"chainage_m": s["chainage_m"],
|
|
"label": s["label"],
|
|
"x": s["center_x"],
|
|
"y": s["center_y"],
|
|
}
|
|
for s in stations
|
|
],
|
|
# 중심선 = 측점 중심(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름)
|
|
"line": [
|
|
[float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])]
|
|
for s in sorted(stations, key=lambda s: float(s["chainage_m"]))
|
|
],
|
|
"saved": saved,
|
|
"defaults": SearchSettings().model_dump(),
|
|
"max_section_m": run.MAX_SECTION_M,
|
|
}
|
|
|
|
|
|
async def _editable(project_id: UUID, session: dict[str, Any]) -> bool:
|
|
"""[해석] 가능 = 시스템 관리자 · 같은 회사(지정받은 회사는 보기만)."""
|
|
if session.get("role") == "SYSTEM_ADMIN":
|
|
return True
|
|
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),))
|
|
row = await cursor.fetchone()
|
|
return bool(row) and row[0] == session.get("company_id")
|
|
|
|
|
|
@router.get("/{project_id}/stab/result3d", response_model=None)
|
|
async def result3d(
|
|
project_id: UUID,
|
|
key: str | None = None,
|
|
session: dict[str, Any] = Depends(require_company),
|
|
) -> dict[str, Any]:
|
|
"""key 없음 = 구간 · 노선 · 저장본 요약 / key = 그 구간 래스터 · 임계 활동면."""
|
|
project_root = await _project_root(project_id)
|
|
root = _source_root(project_root)
|
|
if key is None:
|
|
overview = await asyncio.to_thread(_overview, project_root, root)
|
|
editable = await _editable(project_id, session)
|
|
return {"status": "success", "editable": editable, **overview}
|
|
if not all(ch.isdigit() or ch in "._" for ch in key):
|
|
raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.")
|
|
doc = await asyncio.to_thread(run.load, project_root, key)
|
|
if doc is None:
|
|
raise HTTPException(status_code=404, detail="이 구간 3D 해석 결과가 없습니다.")
|
|
current = await asyncio.to_thread(_current_fingerprint, project_root, root, doc["settings"])
|
|
return {"status": "success", "key": key, "stale": doc["fingerprint"] != current, **doc}
|