Merge origin/sub_laptop_7 — E04 3D 길 · main.py 라우터 충돌 풂(E02 · E04 모두 등록)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:17:18 +09:00
co-authored by Claude Opus 5.5
4 changed files with 762 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
"""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, HTTPException
from pydantic import BaseModel, Field
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", "ellipsoid", "both"] = "both"
ellipsoid_ratio: float = Field(2.0, ge=1.0, 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()
method: Literal["bishop", "hovland"] = "bishop" # bishop = Hungr 3D Bishop(+Janbu 보정)
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),
"method": payload.method,
**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
points = source.db_snapshot(root).get("route_points") or []
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
],
"line": [
[float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"])]
for p in sorted(points, key=lambda p: p["sequence_num"])
],
"saved": saved,
"defaults": SearchSettings().model_dump(),
"max_section_m": run.MAX_SECTION_M,
}
@router.get("/{project_id}/stab/result3d", response_model=None)
async def result3d(project_id: UUID, key: str | None = None) -> dict[str, Any]:
"""key 없음 = 구간 · 노선 · 저장본 요약 / key = 그 구간 래스터 · 임계 활동면."""
project_root = await _project_root(project_id)
root = _source_root(project_root)
if key is None:
return {"status": "success", **await asyncio.to_thread(_overview, project_root, root)}
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}
+275
View File
@@ -0,0 +1,275 @@
"""E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3).
계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만.
저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 ·
요청 · 엔진 판)이 같으면 다시 셈하지 않음.
래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠):
grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열
z = 합성 지표(원지형 + 노선면) · f = 셀별 최소 안전율 · v = 그 임계 체적 m³ · m = 방법(0 Bishop
· 1 Janbu 보정) · 닿지 않은 셀 = null
best = 전체 최소 활동면 {x, y, z(중심), r, ratio, azimuth_deg, f, volume, method, ix, iy}
"""
from __future__ import annotations
import hashlib
import json
import math
import time
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
import numpy as np
from common_util.common_util_surface_sampler import DtmGridSampler
from E01_StabRegister import E01_StabRegister_Source as source
ENGINE_VERSION = "e04-preview-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
STAGE_DIR = "E04_Stab3D"
GROUND_FILE = Path("E02_StabGround") / "ground.json"
SECTION_LENGTH_M = 100.0 # 자동 구간 길이
MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리)
METHOD_NAMES = ("bishop", "janbu_corrected")
Progress = Callable[[int, str], None]
StopFlag = Callable[[], bool]
def results_dir(project_root: Path) -> Path:
return Path(project_root) / STAGE_DIR / "results"
def section_key(start_m: float, end_m: float) -> str:
return f"{start_m:.2f}_{end_m:.2f}"
def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]:
"""측점을 앞에서부터 SECTION_LENGTH_M 이상 묶음 — 경계 = 측점 · 꼬리가 짧으면 앞 구간에 붙임."""
chainages = sorted({float(s["chainage_m"]) for s in stations})
out: list[dict[str, float]] = []
start = chainages[0] if chainages else 0.0
for ch in chainages[1:]:
if ch - start >= SECTION_LENGTH_M - 1e-6:
out.append({"start_m": start, "end_m": ch})
start = ch
if chainages and start < chainages[-1]:
if out and chainages[-1] - start < SECTION_LENGTH_M / 2:
out[-1]["end_m"] = chainages[-1]
else:
out.append({"start_m": start, "end_m": chainages[-1]})
return out
def read_ground(project_root: Path) -> dict[str, Any] | None:
"""E02 작업본(없으면 None = 기본값)."""
path = Path(project_root) / GROUND_FILE
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def fingerprint(
source_fingerprint: str, ground: dict[str, Any] | None, settings: dict[str, Any]
) -> str:
body = json.dumps(
{
"source": source_fingerprint,
"ground": ground,
"settings": settings,
"engine": ENGINE_VERSION,
},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.sha1(body.encode("utf-8")).hexdigest()
def section_stations(stations: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]:
return [s for s in stations if start_m - 1e-6 <= float(s["chainage_m"]) <= end_m + 1e-6]
def bbox_of(
stations: list[dict[str, Any]], half_width_m: float
) -> tuple[float, float, float, float]:
xs = [float(s["center_x"]) for s in stations]
ys = [float(s["center_y"]) for s in stations]
return (
min(xs) - half_width_m,
max(xs) + half_width_m,
min(ys) - half_width_m,
max(ys) + half_width_m,
)
# ---------------------------------------------------------------------------
# 엔진 — 533-1 · 2 결과 꼴이 오기 전 = 미리보기(지형 경사로 만든 가짜 래스터 · 틀 · 배치 확인용)
# ---------------------------------------------------------------------------
def _preview_engine(
root: Path,
stations: list[dict[str, Any]],
settings: dict[str, Any],
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
dem = DtmGridSampler.open_cached(source.terrain_grid_path(root))
x0, x1, y0, y1 = bbox_of(stations, float(settings["box_half_width_m"]))
dx = float(settings["cell_m"])
xs = np.arange(x0, x1 + dx / 2, dx)
ys = np.arange(y0, y1 + dx / 2, dx)
xx, yy = np.meshgrid(xs, ys)
z, valid = dem.sample_xy(np.column_stack([xx.ravel(), yy.ravel()]))
z = np.where(valid, z, np.nan).reshape(xx.shape)
gy, gx = np.gradient(z, dx)
slope = np.hypot(gx, gy)
f = np.full(z.shape, np.nan)
rows = z.shape[0]
for iy in range(rows):
if should_stop():
raise InterruptedError
f[iy] = 0.8 + 2.4 / (1.0 + 2.5 * slope[iy] ** 2)
if iy % max(1, rows // 20) == 0:
on_progress(int(100 * iy / rows), f"미리보기 {iy}/{rows} 줄")
volume = np.where(np.isfinite(f), 50.0 + 400.0 * slope**2, np.nan)
iy, ix = np.unravel_index(int(np.nanargmin(f)), f.shape)
r = 10.0
best = {
"x": float(xs[ix]),
"y": float(ys[iy]),
"z": float(z[iy, ix] + 0.6 * r),
"r": r,
"ratio": 1.0,
"azimuth_deg": 0.0,
"f": float(f[iy, ix]),
"volume": float(volume[iy, ix]),
"method": 0,
}
return {
"grid": {"x0": float(xs[0]), "y0": float(ys[0]), "dx": dx, "nx": len(xs), "ny": len(ys)},
"z": z,
"f": f,
"v": volume,
"m": np.where(np.isfinite(f), 0.0, np.nan),
"best": best,
"trials": int(np.isfinite(f).sum()),
"preview": True,
}
def run_engine(
root: Path,
stations: list[dict[str, Any]],
ground: dict[str, Any] | None,
settings: dict[str, Any],
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
"""엔진 한 번 → {grid, z, f, v, m(넘파이 (ny, nx)), best, trials}."""
return _preview_engine(root, stations, settings, on_progress, should_stop)
# ---------------------------------------------------------------------------
# 저장본
# ---------------------------------------------------------------------------
def _flat(values: np.ndarray, digits: int) -> list[float | None]:
return [None if not math.isfinite(v) else round(float(v), digits) for v in values.ravel()]
def _nearest_chainage(stations: list[dict[str, Any]], x: float, y: float) -> float:
return min(
stations, key=lambda s: (float(s["center_x"]) - x) ** 2 + (float(s["center_y"]) - y) ** 2
)["chainage_m"]
def pack(
raw: dict[str, Any],
stations: list[dict[str, Any]],
settings: dict[str, Any],
finger: str,
elapsed_s: float,
) -> dict[str, Any]:
"""엔진 결과 → 저장본(JSON) — 요약은 best 에서."""
grid = raw["grid"]
best = dict(raw["best"])
best["ix"] = int(round((best["x"] - grid["x0"]) / grid["dx"]))
best["iy"] = int(round((best["y"] - grid["y0"]) / grid["dx"]))
f = np.asarray(raw["f"], dtype=float)
return {
"fingerprint": finger,
"engine": ENGINE_VERSION,
"preview": bool(raw.get("preview")),
"settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_s": round(elapsed_s, 2),
"grid": grid,
"z": _flat(np.asarray(raw["z"], dtype=float), 2),
"f": _flat(f, 3),
"v": _flat(np.asarray(raw["v"], dtype=float), 1),
"m": _flat(np.asarray(raw["m"], dtype=float), 0),
"best": best,
"summary": {
"start_m": settings["start_m"],
"end_m": settings["end_m"],
"f_min": round(float(best["f"]), 3),
"volume": round(float(best["volume"]), 1),
"method": METHOD_NAMES[int(best.get("method") or 0)],
"chainage_m": float(_nearest_chainage(stations, best["x"], best["y"])),
"cells": int(np.isfinite(f).sum()),
"trials": int(raw.get("trials") or 0),
},
}
def save(project_root: Path, key: str, result: dict[str, Any]) -> None:
folder = results_dir(project_root)
folder.mkdir(parents=True, exist_ok=True)
tmp = folder / f"{key}.json.tmp"
tmp.write_text(json.dumps(result, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
tmp.replace(folder / f"{key}.json")
def load(project_root: Path, key: str) -> dict[str, Any] | None:
path = results_dir(project_root) / f"{key}.json"
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def saved_summaries(project_root: Path) -> list[dict[str, Any]]:
"""저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용."""
out = []
for path in sorted(results_dir(project_root).glob("*.json")):
doc = json.loads(path.read_text(encoding="utf-8"))
out.append(
{
"key": path.stem,
"fingerprint": doc["fingerprint"],
"settings": doc["settings"],
"preview": doc.get("preview", False),
**doc["summary"],
}
)
return out
def analyze(
project_root: Path,
root: Path,
settings: dict[str, Any],
finger: str,
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
"""한 구간 3D 해석 → 저장본 쓰고 요약 돌려줌."""
started = time.perf_counter()
stations = source.stations(root)
picked = section_stations(stations, settings["start_m"], settings["end_m"])
if len(picked) < 2:
raise ValueError("구간 안 측점이 둘 미만입니다.")
ground = read_ground(project_root)
raw = run_engine(root, picked, ground, settings, on_progress, should_stop)
result = pack(raw, picked, settings, finger, time.perf_counter() - started)
save(project_root, section_key(settings["start_m"], settings["end_m"]), result)
return result["summary"]
+2
View File
@@ -81,6 +81,7 @@ from D04_PlanConfirm.D04_PlanConfirm_Link import router as d04_plan_link_router
from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_router
from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router
from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
@@ -488,6 +489,7 @@ app.include_router(d03_plan_analysis_router, dependencies=protected_with_company
app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
app.include_router(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
app.include_router(e04_stab_3d_router, dependencies=protected_with_company)
app.include_router(b05_corridor_router, dependencies=protected_with_company)
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
+264
View File
@@ -0,0 +1,264 @@
"""E04 3D 해석 길(PLAN 533-3) — 지문 같으면 안 셈 · force 면 다시 · 끊기 · 진행률 · 저장본 · 공유 403.
./venv/Scripts/python.exe -m pytest resources/tester/test_533_3_e04_router.py -q
"""
import asyncio
import json
import threading
import time
import numpy as np
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from common_util import common_util_auth as auth
from E04_Stab3D import E04_Stab3D_Router as router_mod
from E04_Stab3D import E04_Stab3D_Run as run
PID = "4b1f6f0c-0c52-4a49-9d2b-6c0c3f3e0a11"
STATIONS = [
{"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 100.0 + 20.0 * i, "center_y": 200.0}
for i in range(11)
]
@pytest.fixture
def project(tmp_path, monkeypatch):
src = tmp_path / "E01_StabRegister" / "source"
models = src / "B04_PreProcess" / "models"
models.mkdir(parents=True)
(src / "copy_manifest.json").write_text(json.dumps({"source_fingerprint": "a" * 40}))
xs = np.arange(0.0, 400.0, 2.0)
ys = np.arange(100.0, 300.0, 2.0)
xx, yy = np.meshgrid(xs, ys)
z = 500.0 + 0.4 * (yy - 200.0) + 0.002 * (xx - 200.0) ** 2 # 노선 옆 비탈
np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool))
monkeypatch.setattr(run.source, "stations", lambda root: STATIONS)
monkeypatch.setattr(
run.source,
"db_snapshot",
lambda root: {
"route_points": [
{
"model_x": s["center_x"],
"model_y": 200.0,
"chainage_m": s["chainage_m"],
"sequence_num": i,
}
for i, s in enumerate(STATIONS)
]
},
)
async def fake_root(project_id):
return tmp_path
monkeypatch.setattr(router_mod, "_project_root", fake_root)
router_mod._JOBS.clear()
return tmp_path
@pytest.fixture
def client(project):
app = FastAPI()
app.include_router(router_mod.router)
with TestClient(app) as c:
yield c
def _wait(client, done=lambda p: not p["running"], seconds=10.0):
end = time.time() + seconds
while time.time() < end:
body = client.get(f"/api/projects/{PID}/stab/progress3d").json()
if done(body):
return body
time.sleep(0.05)
raise AssertionError("진행 기다림 초과")
BODY = {"start_m": 0, "end_m": 100, "search": {"box_half_width_m": 20, "cell_m": 2}}
def test_auto_sections():
sections = run.auto_sections(STATIONS)
assert sections == [{"start_m": 0.0, "end_m": 100.0}, {"start_m": 100.0, "end_m": 200.0}]
short = run.auto_sections(STATIONS[:8]) # 0~140 — 꼬리 40 m 는 앞에 붙임
assert short == [{"start_m": 0.0, "end_m": 140.0}]
def test_analyze_cached_force_and_result(client, project, monkeypatch):
calls = []
real = run.run_engine
def counted(*args, **kwargs):
calls.append(1)
return real(*args, **kwargs)
monkeypatch.setattr(run, "run_engine", counted)
first = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()
assert first == {"status": "accepted", "key": "0.00_100.00"}
done = _wait(client)
assert done["error"] is None and done["percent"] == 100
assert done["summary"]["cells"] > 0
# 같은 입력 = 셈하지 않음
again = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()
assert again["status"] == "cached" and len(calls) == 1
# force = 다시 셈
assert (
client.post(f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "force": True}).json()[
"status"
]
== "accepted"
)
_wait(client)
assert len(calls) == 2
# 탐색 조건이 바뀌면 지문이 달라 셈
changed = {**BODY, "search": {**BODY["search"], "dr_m": 0.5}}
assert (
client.post(f"/api/projects/{PID}/stab/analyze3d", json=changed).json()["status"]
== "accepted"
)
_wait(client)
assert len(calls) == 3
doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).json()
grid = doc["grid"]
assert len(doc["f"]) == len(doc["z"]) == len(doc["v"]) == grid["nx"] * grid["ny"]
f = np.array([np.nan if v is None else v for v in doc["f"]])
best = doc["best"]
assert f[best["iy"] * grid["nx"] + best["ix"]] == pytest.approx(np.nanmin(f), abs=1e-3)
assert doc["summary"]["f_min"] == pytest.approx(np.nanmin(f), abs=1e-3)
assert doc["stale"] is False
over = client.get(f"/api/projects/{PID}/stab/result3d").json()
assert [s["key"] for s in over["saved"]] == ["0.00_100.00"]
assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0
# E02 지반을 바꾸면 저장본이 「옛 것」
(project / "E02_StabGround").mkdir()
(project / "E02_StabGround" / "ground.json").write_text(json.dumps({"road_surcharge_kpa": 12}))
assert client.get(f"/api/projects/{PID}/stab/result3d").json()["saved"][0]["stale"] is True
assert (
client.post(f"/api/projects/{PID}/stab/analyze3d", json=changed).json()["status"]
== "accepted"
)
_wait(client)
def test_cancel_and_restart(client, monkeypatch):
started = threading.Event()
def slow(root, stations, ground, settings, on_progress, should_stop):
started.set()
for i in range(500):
if should_stop():
raise InterruptedError
on_progress(i // 5, f"{i}")
time.sleep(0.01)
raise AssertionError("끊기지 않음")
monkeypatch.setattr(run, "run_engine", slow)
assert (
client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()["status"] == "accepted"
)
assert started.wait(5)
# 도는 중 또 누르면 409
assert client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).status_code == 409
assert client.post(f"/api/projects/{PID}/stab/analyze3d/cancel").json()["stopping"] is True
body = _wait(client)
assert body["cancelled"] is True and body["error"] is None
assert (
client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).status_code
== 404
)
# 다시 시작
monkeypatch.setattr(
run,
"run_engine",
run._preview_engine.__wrapped__
if hasattr(run._preview_engine, "__wrapped__")
else lambda *a: run._preview_engine(a[0], a[1], a[3], a[4], a[5]),
)
assert (
client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()["status"] == "accepted"
)
body = _wait(client)
assert body["cancelled"] is False and body["percent"] == 100
def test_bad_requests(client):
url = f"/api/projects/{PID}/stab/analyze3d"
assert client.post(url, json={"start_m": 100, "end_m": 50}).status_code == 400
assert client.post(url, json={"start_m": 0, "end_m": 900}).status_code == 400
bad = {**BODY, "search": {"h_min_m": 10, "h_max_m": 5}}
assert client.post(url, json=bad).status_code == 400
assert (
client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "../x"}).status_code == 400
)
class _Cur:
def __init__(self, rows):
self.rows, self.sql = rows, ""
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, sql, args=None):
self.sql = sql
async def fetchone(self):
return next((v for k, v in self.rows.items() if k in self.sql), None)
class _Pool:
def __init__(self, rows):
self.rows = rows
def acquire(self):
pool = self
class _Conn:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def cursor(self, *args):
return _Cur(pool.rows)
return _Conn()
class _Req:
def __init__(self, method):
self.method = method
self.path_params = {"project_id": PID}
def test_shared_company_get_only(monkeypatch):
"""main.py 가 E04 길을 프로젝트 권한 검사에 걸었는지 + 지정받은 회사 = GET 만(POST 403)."""
import main
paths = {
"/api/projects/{project_id}/stab/analyze3d",
"/api/projects/{project_id}/stab/result3d",
}
hits = [r for r in main.app.routes if getattr(r, "path", None) in paths]
assert len(hits) == 2
for route in hits:
calls = {d.call for d in route.dependant.dependencies}
assert auth.require_project_access in calls
user = {"role": "USER", "company_id": 1}
pool = _Pool({"FROM projects": (2,), "plan_route_assignees": (1,)})
monkeypatch.setattr(auth, "get_db_pool", lambda: pool)
assert asyncio.run(auth.require_project_access(_Req("GET"), user)) is user
with pytest.raises(HTTPException) as e:
asyncio.run(auth.require_project_access(_Req("POST"), user))
assert e.value.status_code == 403