Files
Aislo/E04_Stab3D/E04_Stab3D_Router.py
T

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}