Files
Aislo/E03_StabSection/E03_StabSection_Router.py
T
eomsangdonandClaude Opus 5.5 0ac8bc0d9f fix(532-5): E03 → E05 이음매 하나로 — 사본 쓰기 걷음 · result2d 지배 비탈 덧칸(fs / 기준)
- E03 export_report · report_row · report_representatives · REPORT_REASONS 걷음 — E05 는 E03 정본(stab2d_summary · stab2d_station)만 읽음 · representative.json = overrides 만
- GET …/stab/result2d 측점 줄에 station_key · side · fs_min · fs_by_condition · method · verdict_overall 덧붙임(E04 2D 최소) — 지배 = 조건 × 비탈 중 fs / 기준 가장 낮은 것 · 조건별 verdict 칸은 E03 화면 몫이라 그대로
- 시험 test_532_5 — 옛 사본 꼴 시험을 E05 = E03 정본(측점 수 · N.G 수 · 지배 비탈 같음)으로 바꿈 · 지배 비탈 규칙(깎기 1.22/1.2 · 쌓기 1.25/1.3 → 쌓기 N.G · E05 _row2d 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:22:25 +09:00

477 lines
20 KiB
Python

"""E03 2D 측점 해석 API (요구 345 · 347 · 351 · PLAN 532-3).
- `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈
- `GET …/stab/progress` — 진행률
- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수 · 지배 비탈) + 횡단 없는 측점
- `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자
- `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json`
- `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용)
입력 = E01 복사본(측점 · 횡단 · DTM) + E02 지반 조건(작업본 · 없으면 초기값)
계산 자리 = ③ 서버 단독.
결과 = 파생 저장본 `_derived/stab2d_station/<측점 지문>` · `_derived/stab2d_summary/<전체 지문>` —
측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 지하수 갈래 · 그 측점 지층 · 벽.
공유 회사(보기만)는 `require_project_access` 가 POST · PUT 를 막음.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import os
import subprocess
import sys
import tempfile
import time
from functools import lru_cache
from pathlib import Path
from typing import Any
from uuid import UUID
import numpy as np
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_derived_store import (
derived_dir,
derived_key,
read_derived,
write_derived,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_station_key import chainage_key
from common_util.common_util_surface_sampler import DtmGridSampler
from E01_StabRegister import E01_StabRegister_Source as src
from E02_StabGround import E02_StabGround_Model as ground_model
from E02_StabGround import E02_StabGround_Router as e02
from E03_StabSection import E03_StabSection_View as view
from E03_StabSection import E03_StabSection_Walls as walls_mod
from E03_StabSection import E03_StabSection_Worker as worker
from E03_StabSection.E03_StabSection_Engine_Model import (
ground_from_cross,
section_model_from_b06,
widen_ground,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["E03 Stab Section"])
PROJECT_ROOT = Path(__file__).resolve().parents[1]
KIND_STATION = worker.KIND_STATION
KIND_SUMMARY = "stab2d_summary"
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
ENGINE_FILES = (
"E03_StabSection_Types.py",
"E03_StabSection_Engine_Model.py",
"E03_StabSection_Engine_LEM.py",
"E03_StabSection_Engine_Search.py",
"E03_StabSection_Engine_Station.py",
)
#: 3D 보기 노선면 솎음 간격(m)
CORRIDOR_STEP_M = 1.0
#: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험)
WORKERS: int | None = None
# 진행 — 프로젝트 → {running, percent, message, error, done, total} · 프로세스 메모리
_JOBS: dict[str, dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
class RepresentativeRequest(BaseModel):
overrides: dict[str, bool | None]
@lru_cache(maxsize=1)
def engine_hash() -> str:
"""엔진 코드 지문 — 코드가 바뀌면 모든 측점을 다시 셈."""
digest = hashlib.sha1()
for name in ENGINE_FILES:
digest.update((Path(__file__).parent / name).read_bytes())
return digest.hexdigest()[:12]
def _ground(root: Path) -> dict[str, Any]:
"""E02 지반 조건 — 작업본 · 없으면 초기값(옛 정본 = 빠진 지하수 칸 기본값 · E02 화면과 같음)."""
ground = e02._read_working(root) or e02.ensure_initial(root)
return ground_model.upgrade(ground, ground_model.load_defaults())
def _layers(resolved: dict[str, Any] | None) -> list | None:
"""E02 측점 적용 지층 → 엔진 지층(위부터 [물성 id, 원지반 아래 바닥 깊이 | None]).
없으면 None(엔진 기본 = B06 라벨)."""
if not resolved or not resolved.get("upper"):
return None
if resolved.get("lower"):
return [[resolved["upper"], float(resolved["soil_thickness_m"])], [resolved["lower"], None]]
return [[resolved["upper"], None]]
@lru_cache(maxsize=4)
def _walls(source: str, _copy_fp: str) -> dict[str, dict[str, Any]]:
"""측점별 벽(532-4 · Node 한 번 약 2 s) — 복사본은 동결이라 복사 지문으로 기억."""
return walls_mod.walls_by_station(source)
def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]:
"""측점마다 입력 · 지문 + 전체 지문(판정 기준 포함) — 복사본이 없으면 400."""
try:
source = src.source_dir(root)
copy_fp = src.manifest(source)["source_fingerprint"]
stations = src.stations(source)
except (OSError, KeyError) as error:
raise HTTPException(status_code=400, detail="원본 복사본이 없습니다.") from error
seismic = ground.get("seismic") or {}
conditions = ["dry", "wet"] + (["seismic"] if seismic.get("enabled") else [])
shared = {
"materials": ground.get("materials"),
"conditions": conditions,
"seismic_kh": float(seismic.get("kh") or 0.0) if seismic.get("enabled") else 0.0,
"road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0),
"groundwater": ground.get("groundwater"),
}
resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)}
try:
found = _walls(str(source), copy_fp)
except RuntimeError as error:
raise HTTPException(status_code=500, detail=str(error)) from error
items = []
for station in stations:
key = chainage_key(station["chainage_m"])
layers = _layers(resolved.get(key))
entry = found.get(key)
parts = [engine_hash(), copy_fp, shared, key, layers] + ([entry] if entry else [])
items.append(
{
"key": key,
"chainage_m": station["chainage_m"],
"label": station.get("label"),
"ground_type": station.get("ground_type"),
"center": [station.get("center_x"), station.get("center_y")],
"layers": layers,
"walls": entry,
"fp": derived_key(*parts), # 벽 없는 측점 = 종전 지문 꼴
}
)
factors = ground.get("safety_factors") or {}
overall = derived_key([item["fp"] for item in items], factors)
return {
"source": source,
"ground": ground,
"shared": shared,
"items": items,
"factors": factors,
"fp": overall,
}
def _station_input(source: Path, item: dict[str, Any], sampler: Any) -> dict[str, Any]:
"""측점 하나 엔진 입력 — 횡단 · 설계 · 넓힌 지반선(DTM 밖은 표본 그대로)."""
cross = src.cross_section(source, item["chainage_m"])
try:
ground = widen_ground(cross, sampler) if sampler else ground_from_cross(cross)
except (KeyError, TypeError, ValueError):
ground = ground_from_cross(cross)
return {
"cross": cross,
"design": walls_mod.design_with_walls(cross["design"], item.get("walls")),
"walls": walls_mod.walls_of(item.get("walls")),
"ground": [list(p) for p in ground],
}
def _sampler(source: Path) -> Any:
try:
return DtmGridSampler.from_npz(src.terrain_grid_path(source))
except (OSError, FileNotFoundError, ValueError):
logger.warning("E03 지형 격자 없음 — 횡단 표본 폭으로 해석: %s", source)
return None
def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]:
keys = ("key", "chainage_m", "label", "ground_type", "center", "fp")
walls = len(walls_mod.walls_of(item.get("walls")) or [])
return {
**{k: item[k] for k in keys},
"section_mode": design.get("section_mode"),
"walls": walls,
}
def _run_worker(job: dict[str, Any], emit: Any) -> None:
"""일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError.
stderr 는 파일로(파이프로 두면 경고가 쌓여 일꾼이 멈춤)."""
with tempfile.TemporaryDirectory(prefix="aislo_e03_") as folder:
path = Path(folder) / "job.json"
path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8")
log = Path(folder) / "stderr.txt"
with log.open("w", encoding="utf-8") as errors:
process = subprocess.Popen(
[sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)],
cwd=PROJECT_ROOT,
stdout=subprocess.PIPE,
stderr=errors,
text=True,
encoding="utf-8",
)
for line in process.stdout:
if line.startswith("{"):
emit(json.loads(line).get("done"))
code = process.wait()
if code != 0:
tail = log.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:]
raise RuntimeError(f"해석 일꾼 실패: {tail}")
def run_all(root: Path, plan: dict[str, Any], report: Any) -> int:
"""지문이 없는 측점만 셈(일꾼 프로세스 병렬) → 측점 저장본 · 요약 저장본. 센 측점 수."""
source, shared, items = plan["source"], plan["shared"], plan["items"]
todo = [it for it in items if read_derived(root, KIND_STATION, it["fp"]) is None]
if todo:
sampler = _sampler(source)
job_items = []
for it in todo:
data = _station_input(source, it, sampler)
job_items.append({"fp": it["fp"], "meta": _meta(it, data["design"]), **data})
job_items[-1]["layers"] = it["layers"]
job = {
"root": str(root),
"keep": max(3 * len(items), 6),
"workers": WORKERS or max(1, (os.cpu_count() or 2) - 2),
"conditions_ground": plan["ground"],
"kwargs": {
"materials": shared["materials"],
"conditions": list(shared["conditions"]),
"seismic_kh": shared["seismic_kh"],
"road_surcharge_kpa": shared["road_surcharge_kpa"],
},
"items": job_items,
}
report(2, f"측점 {len(todo)} 개 해석 시작")
done = [0]
def emit(_key: str | None) -> None:
done[0] += 1
report(2 + 95 * done[0] // len(todo), f"측점 {done[0]} / {len(todo)}")
if WORKERS == 1:
worker.run_job(job, emit)
else:
_run_worker(job, emit)
rows = []
for it in items:
saved = read_derived(root, KIND_STATION, it["fp"])
value = saved["value"] if saved else {"meta": _meta(it, {}), "out": None, "error": "없음"}
rows.append(view.station_row(value["meta"], value["out"], plan["factors"], value["error"]))
summary = {
"fingerprint": plan["fp"],
"computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"conditions": shared["conditions"],
"method": view.PRIMARY,
"factors": plan["factors"],
"summary": view.summarize(rows, shared["conditions"]),
"stations": rows,
}
write_derived(root, KIND_SUMMARY, plan["fp"], summary)
return len(todo)
def _latest_summary(root: Path, fp: str) -> tuple[dict[str, Any] | None, bool]:
"""(요약, 지금 입력과 같은지) — 같은 지문이 없으면 가장 최근 것(옛 결과)."""
saved = read_derived(root, KIND_SUMMARY, fp)
if saved:
return saved["value"], True
files = sorted(
derived_dir(root, KIND_SUMMARY).glob("*.json"),
key=lambda p: p.stat().st_mtime_ns,
reverse=True,
)
for path in files:
old = read_derived(root, KIND_SUMMARY, path.stem)
if old:
return old["value"], False
return None, False
def _overrides(root: Path) -> dict[str, bool]:
path = root / REPRESENTATIVE
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return {k: bool(v) for k, v in (data.get("overrides") or {}).items() if v is not None}
@router.post("/{project_id}/stab/analyze2d", response_model=None)
async def analyze2d(project_id: UUID) -> dict[str, Any]:
"""전 측점 해석(뒤에서) — 지문이 같으면 다시 안 셈(`fresh`) · 진행은 `GET …/stab/progress`."""
root, _ = await e02._project(project_id)
plan = plan_run(root, _ground(root))
if read_derived(root, KIND_SUMMARY, plan["fp"]):
return {"status": "fresh", "fingerprint": plan["fp"]}
key = str(project_id)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 해석 중입니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None, "computed": 0}
_JOBS[key] = job
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
async def work() -> None:
try:
job["computed"] = await asyncio.to_thread(run_all, root, plan, report)
job.update(percent=100, message="끝")
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("E03 2D 해석 실패: project_id=%s", project_id)
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
task = asyncio.create_task(work(), name=f"E03 analyze2d {project_id}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted", "fingerprint": plan["fp"], **job}
@router.get("/{project_id}/stab/progress", response_model=None)
async def progress(project_id: UUID) -> dict[str, Any]:
await e02._project(project_id)
job = _JOBS.get(str(project_id))
return dict(job) if job else {"running": False, "percent": 0, "message": "", "error": None}
@router.get("/{project_id}/stab/result2d", response_model=None)
async def result2d(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
"""요약 + 측점 목록 — 결과가 없으면 복사본 측점만(status none)."""
root, project = await e02._project(project_id)
ground = _ground(root)
plan = plan_run(root, ground)
summary, fresh = _latest_summary(root, plan["fp"])
editable = e02._editable(session, project)
running = bool((_JOBS.get(str(project_id)) or {}).get("running"))
missing = src.missing_stations(plan["source"]) # 횡단 없어 해석에서 빠진 측점(화면 띠)
if not summary:
rows = [view.station_row(_meta(item, {}), None, plan["factors"]) for item in plan["items"]]
view.mark_representative(rows, _overrides(root))
rows = [{**r, **view.governing_row(r)} for r in rows]
return {
"status": "none",
"fresh": False,
"editable": editable,
"running": running,
"conditions": plan["shared"]["conditions"],
"factors": plan["factors"],
"stations": rows,
"missing_stations": missing,
}
rows = summary["stations"]
view.mark_representative(rows, _overrides(root))
rows = [{**r, **view.governing_row(r)} for r in rows]
return {
"status": "ok",
"fresh": fresh,
"editable": editable,
"running": running,
**{k: v for k, v in summary.items() if k != "stations"},
"stations": rows,
"missing_stations": missing,
}
@router.get("/{project_id}/stab/section/{station_key}", response_model=None)
async def section(project_id: UUID, station_key: str) -> dict[str, Any]:
"""측점 하나 — 해석에 쓴 지반선 · 지층으로 모형을 다시 세워(빠름) 결과와 함께."""
root, _ = await e02._project(project_id)
ground = _ground(root)
plan = plan_run(root, ground)
summary, fresh = _latest_summary(root, plan["fp"])
row = next((r for r in (summary or {}).get("stations", []) if r["key"] == station_key), None)
saved = read_derived(root, KIND_STATION, row["fp"]) if row else None
if not saved or not saved["value"]["out"]:
raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.")
value = saved["value"]
cross = src.cross_section(plan["source"], row["chainage_m"])
entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None)
design = walls_mod.design_with_walls(cross["design"], entry)
kw = {
"materials": plan["shared"]["materials"],
"layers": value["inputs"]["layers"],
"ground": [tuple(p) for p in value["inputs"]["ground"]],
"road_surcharge_kpa": plan["shared"]["road_surcharge_kpa"],
"walls": walls_mod.walls_of(entry),
}
dry = section_model_from_b06(cross, design, **kw)
water, rules = {}, {}
for cond in summary["conditions"]:
# 수위선 = E02 고른 갈래(r_u · 없음 = 선 없음)
model = section_model_from_b06(cross, design, wet=cond == "wet", **kw)
gw = ground_model.station_groundwater(ground, cond, model)
water[cond] = gw.get("line") if gw.get("mode") == "line" else None
rules[cond] = {"mode": gw.get("mode"), "ru": gw.get("ru")}
out = view.section_view(row, value["out"], dry, water)
out["water_rule"] = rules
out["fresh"] = fresh
return out
@router.put("/{project_id}/stab/representative", response_model=None)
async def put_representative(project_id: UUID, payload: RepresentativeRequest) -> dict[str, Any]:
"""대표 단면 켬/끔(None = 자동) — 아는 측점 열쇠만."""
root, _ = await e02._project(project_id)
known = {item["key"] for item in plan_run(root, _ground(root))["items"]}
unknown = sorted(set(payload.overrides) - known)
if unknown:
raise HTTPException(status_code=422, detail=f"모르는 측점: {unknown[:5]}")
overrides = {k: v for k, v in sorted(payload.overrides.items()) if v is not None}
atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides})
return {"overrides": overrides}
@lru_cache(maxsize=2)
def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
ribbons = []
origin = None
for ribbon in data.get("ribbons") or []:
cols = int(ribbon["colCount"])
chain = np.asarray(ribbon["chainages"], dtype=float)
pos = np.frombuffer(base64.b64decode(ribbon["positionsBase64"]), dtype="<f8")
pos = pos.reshape(len(chain), cols, 3)
if origin is None and len(pos):
origin = pos[0, 0].tolist()
# 솎음 — 1 m 마다 첫 줄 + 끝 줄
bins = np.floor(np.round(chain, 6) / CORRIDOR_STEP_M)
keep = np.flatnonzero(np.r_[True, np.diff(bins) > 0])
if keep[-1] != len(chain) - 1:
keep = np.r_[keep, len(chain) - 1]
local = pos[keep] - np.asarray(origin)
ribbons.append(
{
"kind": ribbon["kind"],
"side": ribbon["side"],
"colCount": cols,
"chainages": np.round(chain[keep], 3).tolist(),
"positions": np.round(local, 3).reshape(-1).tolist(),
}
)
return {"origin": origin, "ribbons": ribbons}
@router.get("/{project_id}/stab/corridor3d", response_model=None)
async def corridor3d(project_id: UUID) -> dict[str, Any]:
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z)."""
root, _ = await e02._project(project_id)
path = src.corridor_path(src.source_dir(root))
if not path:
raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.")
stat = path.stat()
return await asyncio.to_thread(_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size))