"""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=" 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))