"""E03 보강 검토 API — 비탈 완화 · 소단 가정 재해석 · 벽 외적 안정 (요구 345 · 351 · PLAN 536-3). - `POST /api/projects/{id}/stab/whatif/{station_key}` — 가정(비탈 · 1:n · 소단 폭 · 높이 · 최소 완화 찾기 · 네일 · 앵커 `supports` · 최소 길이 찾기) → 형상만 바꾼 모형으로 `analyze_station` 다시 · 전후 안전율 · 판정 · 바뀐 단면(화면 꼴) · 네일마다 동원력 · mode(엔진 `cases[i].result.support_forces`) - `POST …/stab/wallcheck/{station_key}` — 그 측점 벽마다 활동 · 전도 · 지지력(평상시 · 지진 시) - `GET …/stab/whatif` — 지금 2D 결과에 붙은 가정 · 벽 검토 목록(단면 꼴 뺌 · E05 「보강검토」 시트) 가정 결과 = 파생 저장본 `_derived/stab2d_whatif/<지문>` + 단면 꼴 `stab2d_whatif_view/` (원본 결과 그대로) · 지문 = 원본 측점 지문 + 가정. 엔진 = 부르기만(`analyze_station` · `section_model_from_b06`). 계산 자리 = ③ 서버 단독. """ from __future__ import annotations import asyncio import hashlib import inspect import math import time from pathlib import Path from typing import Any, Literal from functools import lru_cache, partial from uuid import UUID from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field, model_validator from shapely.geometry import Point, Polygon from common_util.common_util_derived_store import ( derived_dir, derived_key, read_derived, write_derived, ) 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_Router as e03 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_WallCheck as wallcheck from E03_StabSection import E03_StabSection_WhatIf as whatif from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL, section_model_from_b06 from E03_StabSection.E03_StabSection_Engine_Station import analyze_station router = APIRouter(prefix="/api/projects", tags=["E03 Stab What-if"]) KIND = "stab2d_whatif" KIND_VIEW = "stab2d_whatif_view" # 바뀐 단면 화면 꼴(무거움 · 목록은 안 읽음) KEEP = 300 #: 최소 길이 찾기 — 가장 긴 길이 · 찾는 단위(m) SUPPORT_MAX_LEN = 25.0 SUPPORT_LEN_STEP = 0.5 @lru_cache(maxsize=1) def code_hash() -> str: """가정 · 벽 검토 코드 지문 — 바뀌면 저장본을 다시 셈(엔진 지문은 원본 측점 지문에 있음).""" digest = hashlib.sha1() for name in ("E03_StabSection_WhatIf.py", "E03_StabSection_WallCheck.py", Path(__file__).name): digest.update((Path(__file__).parent / name).read_bytes()) return digest.hexdigest()[:12] class SupportIn(BaseModel): """네일 · 앵커 하나 — 브레인 정함 꼴(sub4 엔진 같은 이름) · side 없으면 요청 비탈.""" type: Literal["nail", "anchor"] side: Literal["left", "right"] | None = None x: float z: float angle_deg: float = Field(ge=-10.0, le=80.0) length_m: float = Field(gt=0.0, le=50.0) spacing_m: float = Field(gt=0.1, le=10.0) tensile_kN: float = Field(gt=0.0) bond_kPa: float = Field(gt=0.0) bond_length_m: float = Field(0.0, ge=0.0, le=50.0) hole_dia_mm: float = Field(gt=0.0, le=500.0) prestress_kN: float = Field(0.0, ge=0.0) @model_validator(mode="after") def _anchor_bond(self) -> "SupportIn": if self.type == "anchor" and not 0 < self.bond_length_m < self.length_m: raise ValueError("앵커 정착장은 0 초과 · 길이 미만") return self class WhatIfRequest(BaseModel): side: Literal["left", "right"] ratio: float | None = Field(None, gt=0.05, le=whatif.MAX_RATIO) bench_w: float = Field(0.0, ge=0.0, le=10.0) bench_h: float = Field(0.0, ge=0.0, le=30.0) search: bool = False #: 최소 완화 찾기 목표 — 조건 하나(None = 모든 조건) · 목표 안전율(None = 그 조건 기준안전율) condition: Literal["dry", "wet", "seismic"] | None = None target_fs: float | None = Field(None, gt=0.5, le=5.0) #: 네일 · 앵커 — 있으면 search = 최소 길이 찾기(모두 같은 길이 · 앵커 정착장 그대로) supports: list[SupportIn] = Field(default_factory=list, max_length=40) def engine_takes_supports() -> bool: """sub4 엔진에 supports 인자가 들어왔는지 — 없으면 네일 · 앵커는 그림만(결과 = 보강 없음).""" return "supports" in inspect.signature(analyze_station).parameters def _sized(supports: list[dict], length: float | None) -> list[dict]: if length is None: return supports return [ {**s, "length_m": max(length, s["bond_length_m"] + 0.5 * (s["type"] == "anchor"))} for s in supports ] def _forces(out: dict, side: str) -> dict[str, list]: """조건마다 그 비탈 임계면 기준 네일 힘(엔진 `cases[i].result.support_forces`).""" return { c["condition"]: c["result"]["support_forces"] for c in out.get("cases") or [] if c.get("side") == side and (c.get("result") or {}).get("support_forces") is not None } class WallRequest(BaseModel): method: Literal["coulomb", "rankine"] = "coulomb" water_ratio: float = Field(0.0, ge=0.0, le=1.0) kh: float | None = Field(None, ge=0.0, le=0.5) def _context(root: Path, station_key: str) -> dict[str, Any]: """측점 하나 — 지금 입력과 같은 2D 결과 · 원본 저장본 · 엔진 입력(일꾼과 같은 값).""" ground = e03._ground(root) plan = e03.plan_run(root, ground) summary, fresh = e03._latest_summary(root, plan["fp"]) if not summary or not fresh: raise HTTPException(status_code=409, detail="2D 결과가 지금 입력과 다릅니다 — 먼저 [해석].") row = next((r for r in summary["stations"] if r["key"] == station_key), None) saved = read_derived(root, e03.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) shared = plan["shared"] kw = { "materials": shared["materials"], "conditions": list(shared["conditions"]), "seismic_kh": shared["seismic_kh"], "road_surcharge_kpa": shared["road_surcharge_kpa"], "layers": value["inputs"]["layers"], "ground": [tuple(p) for p in value["inputs"]["ground"]], } walls = walls_mod.walls_of(entry) if walls: kw["walls"] = walls kw["groundwater_for"] = partial(ground_model.station_groundwater, ground) return { "ground": ground, "plan": plan, "row": row, "value": value, "cross": cross, "design": walls_mod.design_with_walls(cross["design"], entry), "walls": walls or [], "kw": kw, } def _wall_side(wall: dict[str, Any]) -> str: return "left" if Polygon(wall["polygon"]).centroid.x > 0 else "right" def _section(ctx: dict[str, Any], design: dict[str, Any], row: dict, out: dict) -> dict: """바뀐 단면 화면 꼴 — `GET …/section` 과 같은 꼴(지하수선 = E02 고른 갈래).""" kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa")} kw["walls"] = ctx["kw"].get("walls") dry = section_model_from_b06(ctx["cross"], design, **kw) water = {} for cond in ctx["kw"]["conditions"]: model = section_model_from_b06(ctx["cross"], design, wet=cond == "wet", **kw) gw = ground_model.station_groundwater(ctx["ground"], cond, model) water[cond] = gw.get("line") if gw.get("mode") == "line" else None return view.section_view(row, out, dry, water) def _side_cases(row: dict[str, Any], side: str) -> dict[str, Any]: return {cond: by[side] for cond, by in (row.get("cases") or {}).items() if side in by} def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, Any]: ctx = _context(root, station_key) row, design, ground = ctx["row"], ctx["design"], ctx["kw"]["ground"] role = {s["side"]: s["role"] for s in row["sides"]}.get(req.side) if role is None: raise HTTPException(status_code=422, detail="그 쪽 비탈이 없습니다.") if any(_wall_side(w) == req.side for w in ctx["walls"]): raise HTTPException(status_code=422, detail="벽이 선 비탈 — 벽 외적 안정으로 검토하세요.") before_ratio = whatif.current_ratio(design, req.side, role) assume = { "side": req.side, "role": role, "ratio": float(req.ratio or before_ratio), "bench_w": req.bench_w if req.bench_h > 0 else 0.0, "bench_h": req.bench_h if req.bench_w > 0 else 0.0, } goal = {"condition": req.condition, "target_fs": req.target_fs} if req.search else None supports = [{**s.model_dump(), "side": s.side or req.side} for s in req.supports] engine = engine_takes_supports() key = derived_key(code_hash(), row["fp"], assume, goal, supports, engine) cached, shown = read_derived(root, KIND, key), read_derived(root, KIND_VIEW, key) if cached and shown: return {**cached["value"], "view": shown["value"]} t0 = time.perf_counter() meta, factors = ctx["value"]["meta"], ctx["plan"]["factors"] memo: dict[tuple, tuple[dict, dict] | None] = {} same = not assume["bench_w"] # 지금 기울기 그대로 = 원본 설계선(다시 그리지 않음) def run(n: float, length: float | None = None) -> tuple[dict, dict] | None: n = round(n, 4) if (n, length) not in memo: if same and abs(n - before_ratio) < 1e-9: variant = design else: # None = 해석 폭 안에서 원지반을 못 만남(자연 비탈보다 완만) variant = whatif.design_variant(design, ground, {**assume, "ratio": n}) kw = dict(ctx["kw"]) if supports and engine: kw["supports"] = _sized(supports, length) memo[(n, length)] = variant and (analyze_station(ctx["cross"], variant, **kw), variant) return memo[(n, length)] reached: list[float] = [] # 원지반을 만난 기울기 def meets(done: tuple[dict, dict] | None) -> bool: if done is None: return False cases = _side_cases(view.station_row(meta, done[0], factors), req.side) if req.condition: cases = {k: v for k, v in cases.items() if k == req.condition} need = (lambda c: c["required"]) if req.target_fs is None else (lambda c: req.target_fs) return bool(cases) and all( c["fs"] is not None and need(c) is not None and c["fs"] >= need(c) for c in cases.values() ) def passes(n: float) -> bool: done = run(n) # 원본과 같은 방법 · 조건(방법을 줄이면 탐색 씨앗이 달라 값이 바뀜) if done is not None: reached.append(n) return meets(done) search = None length = None if req.search and supports: start = max([2.0] + [s["bond_length_m"] + 0.5 for s in supports if s["type"] == "anchor"]) if engine: found = whatif.search_ratio( lambda L: meets(run(assume["ratio"], L)), start, max_ratio=SUPPORT_MAX_LEN, step=SUPPORT_LEN_STEP, # 길이 대 Fs 가 늘 단조롭지 않음(활동면이 깊어짐) — 1 m 성긴 걸음 뒤 이분 coarse=max(1, round(SUPPORT_MAX_LEN - start)), ) else: found = {"found": None, "tried": []} search = {**found, "by": "length", "longest": SUPPORT_MAX_LEN} length = found["found"] or SUPPORT_MAX_LEN elif req.search: top = whatif.gentlest_ratio(design, ground, assume, before_ratio) or before_ratio search = {**whatif.search_ratio(passes, before_ratio, max_ratio=top), "gentlest": top} # 못 찾으면 원지반을 만난 가장 완만한 기울기로 보여 줌 assume["ratio"] = search["found"] or max(reached, default=before_ratio) assume["ratio"] = round(assume["ratio"], 4) done = run(assume["ratio"], length) if done is None: raise HTTPException( status_code=422, detail=f"가정 비탈(1:{assume['ratio']:g} · 소단 포함)이 해석 폭 안에서 원지반을 못 만납니다" " — 자연 비탈보다 완만.", ) out, variant = done after = view.station_row(meta, out, factors) if supports: assume["supports"] = _sized(supports, length) record = { "type": "support" if supports else "slope", "station_key": station_key, "chainage_m": row["chainage_m"], "label": row.get("label"), "base_fp": row["fp"], "assume": {**assume, "ratio_before": before_ratio}, "search": search and {**search, **goal}, "before": _side_cases(row, req.side), "after": _side_cases(after, req.side), # 엔진 고리 전 = 네일 · 앵커 힘 없음(결과 = 보강 없는 재해석 · 화면은 그림만) "support_forces": _forces(out, req.side) if supports else None, "supports_pending": bool(supports) and not engine, "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "elapsed_s": round(time.perf_counter() - t0, 2), } shown = _section(ctx, variant, after, out) write_derived(root, KIND_VIEW, key, shown, keep=KEEP) write_derived(root, KIND, key, record, keep=KEEP) return {**record, "view": shown} def _material_at(model: dict[str, Any], x: float, z: float, skip: str = "wall") -> str | None: pt = Point(x, z) for region in model["regions"]: if region["material_id"] != skip and Polygon(region["polygon"]).buffer(1e-6).contains(pt): return region["material_id"] return None def _surface_z(surface: list, x: float) -> float: pts = sorted(surface) for (xa, za), (xb, zb) in zip(pts, pts[1:]): if xa <= x <= xb: return za + (zb - za) * ((x - xa) / (xb - xa) if xb > xa else 0.0) return pts[0][1] if x < pts[0][0] else pts[-1][1] def run_wallcheck(root: Path, station_key: str, req: WallRequest) -> dict[str, Any]: ctx = _context(root, station_key) if not ctx["walls"]: raise HTTPException(status_code=404, detail="그 측점에 벽이 없습니다.") row = ctx["row"] seismic = ctx["ground"].get("seismic") or {} kh = ( req.kh if req.kh is not None else float(seismic.get("kh") or 0.0) * bool(seismic.get("enabled")) ) params = {"method": req.method, "water_ratio": req.water_ratio, "kh": kh} key = derived_key(code_hash(), row["fp"], "wall", params) cached = read_derived(root, KIND, key) if cached: return cached["value"] kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa", "walls")} model = section_model_from_b06(ctx["cross"], ctx["design"], **kw) mats = {m["id"]: m for m in model["materials"]} roles = {s["side"]: s["role"] for s in row["sides"]} walls = [] for wall in ctx["walls"]: poly = Polygon(wall["polygon"]) side = _wall_side(wall) role = roles.get(side, "fill") outward = 1 if side == "left" else -1 back = -outward if role == "fill" else outward # 쌓기 = 노면 쪽 흙 · 깎기 = 산 쪽 흙 x0, z0, x1, z1 = poly.bounds H = z1 - z0 heel = x1 if back > 0 else x0 toe = x0 if back > 0 else x1 fill_id = _material_at(model, heel + back * 0.3, z0 + 0.5 * H) or "fill" found_id = _material_at(model, 0.5 * (x0 + x1), z0 - 0.2) or fill_id backfill, foundation = mats.get(fill_id, mats.get("fill")), mats.get(found_id) surface = model["surface"] dz = _surface_z(surface, heel + back * max(H, 1.0)) - _surface_z(surface, heel) beta = math.degrees(math.atan2(dz, max(H, 1.0))) beta = min(max(beta, 0.0), float(backfill["phi"]) - 1.0) common = { "backfill": backfill, "foundation": foundation, "method": req.method, "beta_deg": beta, "surcharge_kpa": ctx["kw"]["road_surcharge_kpa"] if role == "fill" else 0.0, "water_ratio": req.water_ratio, "wall_gamma": float(WALL_MATERIAL["gamma_t"]), "embed_m": max(_surface_z(surface, toe - back * 0.05) - z0, 0.0), } cases = [wallcheck.check_wall(wall["polygon"], back, **common)] if kh > 0: cases.append(wallcheck.check_wall(wall["polygon"], back, kh=kh, **common)) walls.append( { "name": wall.get("name"), "kind": wall.get("kind"), "side": side, "role": role, "backfill": fill_id, "foundation": found_id, "cases": cases, } ) record = { "type": "wall", "station_key": station_key, "chainage_m": row["chainage_m"], "label": row.get("label"), "base_fp": row["fp"], "params": params, "walls": walls, "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), } write_derived(root, KIND, key, record, keep=KEEP) return record def records(root: Path) -> list[dict[str, Any]]: """가장 최근 2D 요약 측점에 붙은 가정 · 벽 검토 — (측점 · 종류 · 비탈)마다 가장 최근 하나 · 누가거리 순.""" summary, _ = e03._latest_summary(root, "") fps = {r["fp"] for r in (summary or {}).get("stations") or []} latest: dict[tuple, dict[str, Any]] = {} for path in derived_dir(root, KIND).glob("*.json"): saved = read_derived(root, KIND, path.stem) value = (saved or {}).get("value") if not value or value.get("base_fp") not in fps: continue slot = (value["station_key"], value["type"], (value.get("assume") or {}).get("side")) if slot not in latest or value["computed_at"] > latest[slot]["computed_at"]: latest[slot] = value return sorted(latest.values(), key=lambda v: (float(v["chainage_m"]), v["type"])) @router.post("/{project_id}/stab/whatif/{station_key}", response_model=None) async def post_whatif(project_id: UUID, station_key: str, payload: WhatIfRequest) -> dict: root, _ = await e02._project(project_id) return await asyncio.to_thread(run_whatif, root, station_key, payload) @router.post("/{project_id}/stab/wallcheck/{station_key}", response_model=None) async def post_wallcheck(project_id: UUID, station_key: str, payload: WallRequest) -> dict: root, _ = await e02._project(project_id) return await asyncio.to_thread(run_wallcheck, root, station_key, payload) @router.get("/{project_id}/stab/whatif", response_model=None) async def get_whatif(project_id: UUID) -> dict: root, _ = await e02._project(project_id) return {"records": await asyncio.to_thread(records, root)}