From ab3eee034d18cd0a91b23d830f9f26ad0d79d89a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:59:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(536-3):=20E=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EC=95=9E=EB=B6=80=EB=B6=84=20=E2=80=94=20?= =?UTF-8?q?=EB=B2=BD=20=EC=99=B8=EC=A0=81=20=EC=95=88=EC=A0=95(=ED=99=9C?= =?UTF-8?q?=EB=8F=99=20=C2=B7=20=EC=A0=84=EB=8F=84=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EC=A7=80=EB=A0=A5=20=C2=B7=20KDS=2011=2080=2005)=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=ED=83=88=20=EC=99=84=ED=99=94=20=C2=B7=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=20=EA=B0=80=EC=A0=95=20=EC=9E=AC=ED=95=B4=EC=84=9D=20?= =?UTF-8?q?=C2=B7=20=EC=B5=9C=EC=86=8C=20=EC=99=84=ED=99=94=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20E03=20=E3=80=8C=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=E3=80=8D=20=ED=83=AD=20=C2=B7=20E05=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=EA=B2=80=ED=86=A0=20=EC=8B=9C=ED=8A=B8(?= =?UTF-8?q?=EC=9E=88=EC=9D=84=20=EB=95=8C=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E03_StabSection_WallCheck: Coulomb · Rankine · M-O 토압 · 상재하중 · 수압 · 양압력 · Meyerhof 지지력 · 평상시 / 지진 시 판정 - E03_StabSection_WhatIf: 한 쪽 비탈 1:n(+ 소단) 다시 그림 · 원지반 만나는 가장 완만한 n · 성긴 걸음 + 이분 최소 완화(완만할수록 늘 안전하지 않음) - E03_StabSection_Router_WhatIf: POST whatif · wallcheck · GET whatif · 저장본 _derived/stab2d_whatif(+ _view) · 원본 측점 · 요약 그대로 - E05: s8 보강검토(양식 · 자리표) · 저장본 없으면 시트 뺌 · 갱신 때 사라진 프로그램 몫 뺌 - 시험 test_536_3 12 · 534-3 양식 시험 시트 8(보강검토 빈 판 1쪽) Co-Authored-By: Claude Opus 5.5 --- .../E03_StabSection_Router_WhatIf.py | 345 ++++++++++++++++++ E03_StabSection/E03_StabSection_UI_Page.ts | 73 +++- .../E03_StabSection_UI_Reinforce.ts | 314 ++++++++++++++++ E03_StabSection/E03_StabSection_WallCheck.py | 220 +++++++++++ E03_StabSection/E03_StabSection_WhatIf.py | 199 ++++++++++ E05_StabReport/E05_StabReport_Build.py | 12 +- E05_StabReport/E05_StabReport_Reinforce.py | 135 +++++++ E05_StabReport/E05_StabReport_Store.py | 11 +- main.py | 2 + .../master_template/report/stab_report.json | 345 ++++++++++++++++++ .../report/stab_report_slots.md | 10 + .../tester/test_534_3_stab_report_form.py | 9 +- resources/tester/test_536_3_e03_reinforce.py | 328 +++++++++++++++++ ui_template/ui_template_locale_e03.ts | 44 +++ 14 files changed, 2038 insertions(+), 9 deletions(-) create mode 100644 E03_StabSection/E03_StabSection_Router_WhatIf.py create mode 100644 E03_StabSection/E03_StabSection_UI_Reinforce.ts create mode 100644 E03_StabSection/E03_StabSection_WallCheck.py create mode 100644 E03_StabSection/E03_StabSection_WhatIf.py create mode 100644 E05_StabReport/E05_StabReport_Reinforce.py create mode 100644 resources/tester/test_536_3_e03_reinforce.py diff --git a/E03_StabSection/E03_StabSection_Router_WhatIf.py b/E03_StabSection/E03_StabSection_Router_WhatIf.py new file mode 100644 index 000000000..85ae4723f --- /dev/null +++ b/E03_StabSection/E03_StabSection_Router_WhatIf.py @@ -0,0 +1,345 @@ +"""E03 보강 검토 API — 비탈 완화 · 소단 가정 재해석 · 벽 외적 안정 (요구 345 · 351 · PLAN 536-3). + +- `POST /api/projects/{id}/stab/whatif/{station_key}` — 가정(비탈 · 1:n · 소단 폭 · 높이 · 최소 완화 찾기) + → 형상만 바꾼 모형으로 `analyze_station` 다시 · 전후 안전율 · 판정 · 바뀐 단면(화면 꼴) +- `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 math +import time +from pathlib import Path +from typing import Any, Literal +from functools import lru_cache +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +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 + + +@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 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) + + +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 + 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 + key = derived_key(code_hash(), row["fp"], assume, goal) + 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[float, tuple[dict, dict] | None] = {} + + def run(n: float) -> tuple[dict, dict] | None: + n = round(n, 4) + if n not in memo: + variant = whatif.design_variant(design, ground, {**assume, "ratio": n}) + # None = 해석 폭 안에서 원지반을 못 만남(자연 비탈보다 완만) + memo[n] = variant and (analyze_station(ctx["cross"], variant, **ctx["kw"]), variant) + return memo[n] + + reached: list[float] = [] # 원지반을 만난 기울기 + + def passes(n: float) -> bool: + done = run(n) # 원본과 같은 방법 · 조건(방법을 줄이면 탐색 씨앗이 달라 값이 바뀜) + if done is None: + return False + reached.append(n) + 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() + ) + + search = None + if 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"]) + 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) + record = { + "type": "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), + "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)} diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index c84fc4af9..f60d17cb3 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -5,6 +5,7 @@ * 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프 * 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) · * 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음). + * 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본). * ========================================================================== */ import "./E03_StabSection_UI_Style.css"; @@ -31,6 +32,13 @@ import { type SectionToggles, } from "./E03_StabSection_UI_Section"; import { openView3d } from "./E03_StabSection_UI_View3D"; +import { + buildReinforce, + type SlopeAsk, + type WallAsk, + type WallRecord, + type WhatIfRecord, +} from "./E03_StabSection_UI_Reinforce"; import type { Analysis, Condition, @@ -101,6 +109,10 @@ export function renderE03StabSection(root: HTMLElement): void { let side: string | null = null; let picked: number | null = null; const toggles: SectionToggles = { map: true, centers: false, candidates: true }; + let mode: "result" | "reinforce" = "result"; + let busy = false; + const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 + const wallRecs = new Map(); let repCache: Record = JSON.parse( sessionStorage.getItem(repKey(pid)) ?? "{}", ); @@ -292,7 +304,21 @@ export function renderE03StabSection(root: HTMLElement): void { (v) => ((toggles.candidates = v), paintSection()), ), ); - toolbar.append(title, sideField.root, flags); + const modeField = createSelectField({ + options: [ + { value: "result", text: t("E03_Tab_Result") }, + { value: "reinforce", text: t("E03_Tab_Reinforce") }, + ], + value: mode, + compact: true, + ariaLabel: t("E03_Tab"), + onChange: (v) => { + mode = v as typeof mode; + paintSection(); + }, + }); + modeField.select.dataset.role = "mode"; + toolbar.append(title, sideField.root, modeField.root, flags); const draw = { view, analysis, toggles, picked }; canvas.render(draw); legendHost.append(buildLegends(draw)); @@ -307,6 +333,25 @@ export function renderE03StabSection(root: HTMLElement): void { ); if (view.regions.some((r) => r.material_id === WALL_ID)) legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); + const row = currentRow(); + if (mode === "reinforce" && row) { + if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable) + queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 })); + resultHost.append( + buildReinforce({ + row, + condition, + side, + editable: !!result?.editable, + busy, + slope: slopeRecs.get(row.key), + wall: wallRecs.get(row.key), + runSlope: (ask) => void runSlope(row.key, ask), + runWall: (ask) => void runWall(row.key, ask), + }), + ); + return; + } resultHost.append( buildResult(view, analysis, picked, (i) => { picked = i; @@ -315,6 +360,32 @@ export function renderE03StabSection(root: HTMLElement): void { ); }; + /** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */ + async function reinforce( + path: string, + body: unknown, + done: (value: T) => void, + ): Promise { + busy = true; + paintSection(); + try { + done(await api(path, { method: "POST", body: JSON.stringify(body) })); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + busy = false; + paintSection(); + } + } + const runSlope = (key: string, ask: SlopeAsk): Promise => + reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => + slopeRecs.set(key, rec), + ); + const runWall = (key: string, ask: WallAsk): Promise => + reinforce(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) => + wallRecs.set(key, rec), + ); + const paintPlan = (): void => { planHost.replaceChildren( result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"), diff --git a/E03_StabSection/E03_StabSection_UI_Reinforce.ts b/E03_StabSection/E03_StabSection_UI_Reinforce.ts new file mode 100644 index 000000000..0b30092bd --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Reinforce.ts @@ -0,0 +1,314 @@ +/* ============================================================================= + * E03_StabSection_UI_Reinforce.ts + * E03 「보강 검토」 탭(PLAN 536-3) — 고른 측점 비탈 가정(1:n 완화 · 소단 폭 · 높이 · 최소 완화 찾기) + * → 서버 재해석(POST …/stab/whatif/{측점}) · 전후 안전율 · 판정 표 · 후 단면 그림 · + * 벽 있는 측점 = 벽 외적 안정 표(POST …/stab/wallcheck/{측점} · 활동 · 전도 · 지지력). + * 가정은 형상만 바꾼 따로 저장본(원본 결과 그대로) · 계산은 서버. + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSectionCanvas } from "./E03_StabSection_UI_Section"; +import { verdictChip } from "./E03_StabSection_UI_Result"; +import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic"; + +export interface WhatIfRecord { + type: "slope"; + station_key: string; + assume: { + side: string; + role: "cut" | "fill"; + ratio: number; + ratio_before: number; + bench_w: number; + bench_h: number; + }; + search: { + found: number | null; + gentlest: number; + tried: [number, boolean][]; + condition: Condition | null; + } | null; + before: Partial>; + after: Partial>; + elapsed_s: number; + view: SectionView; +} + +type Check = "sliding" | "overturning" | "bearing"; +interface WallCase { + case: "normal" | "seismic"; + fs: Record; + required: Record; + ok: Record; + coefficients: { ka: number; kae: number | null }; + base: { e: number; q_max: number; q_ult: number }; + geometry: { H: number; B: number }; +} +export interface WallRecord { + type: "wall"; + params: { method: string; water_ratio: number; kh: number }; + walls: { name: string | null; side: string; role: string; cases: WallCase[] }[]; +} + +export interface SlopeAsk { + side: string; + ratio: number | null; + bench_w: number; + bench_h: number; + search: boolean; + /** 최소 완화 찾기 목표 조건(그 조건 기준안전율) */ + condition: Condition | null; +} +export interface WallAsk { + method: string; + water_ratio: number; +} + +export interface ReinforceOptions { + row: StationRow; + condition: Condition; + side: string | null; + editable: boolean; + busy: boolean; + slope: WhatIfRecord | undefined; + wall: WallRecord | undefined; + runSlope: (ask: SlopeAsk) => void; + runWall: (ask: WallAsk) => void; +} + +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); +const CONDS: Condition[] = ["dry", "wet", "seismic"]; +const CHECKS: Check[] = ["sliding", "overturning", "bearing"]; + +function num(input: HTMLInputElement): number | null { + const v = Number(input.value); + return input.value.trim() === "" || !Number.isFinite(v) ? null : v; +} + +function slopeTable(rec: WhatIfRecord): HTMLElement { + const table = el("table", { className: "e03-table", attrs: { "data-role": "whatif-table" } }); + const head = ["E03_Condition", "E03_Rf_Before", "E03_Rf_After", "E03_Required"] as LocaleKey[]; + table.append( + el("thead", { + children: [ + el("tr", { + children: [ + ...head.map((k, i) => el("th", { className: i ? "is-num" : "", text: t(k) })), + el("th", { text: t("E03_Rf_VerdictBefore") }), + el("th", { text: t("E03_Rf_VerdictAfter") }), + ], + }), + ], + }), + ); + const body = el("tbody"); + for (const cond of CONDS) { + const a = rec.after[cond]; + if (!a) continue; + const b = rec.before[cond]; + const tr = el("tr", { attrs: { "data-cond": cond } }); + tr.append( + el("td", { text: t(`E03_Cond_${cond}` as LocaleKey) }), + el("td", { className: "is-num", text: fmt(b?.fs, 3) }), + el("td", { className: "is-num", text: fmt(a.fs, 3), attrs: { "data-role": "fs-after" } }), + el("td", { className: "is-num", text: fmt(a.required) }), + el("td", { children: [verdictChip(b?.ok ?? null)] }), + el("td", { children: [verdictChip(a.ok)] }), + ); + body.append(tr); + } + table.append(body); + return table; +} + +function assumeText(rec: WhatIfRecord): string { + const a = rec.assume; + let text = `1:${a.ratio_before} → 1:${a.ratio}`; + if (a.bench_w && a.bench_h) text += ` · ${t("E03_Rf_Bench")} ${a.bench_w} m / ${a.bench_h} m`; + if (rec.search) + text += rec.search.found + ? ` · ${t("E03_Rf_Found")} (${rec.search.tried.length}${t("E03_Iter")})` + : ` · ${t("E03_Rf_NotFound").replace("{n}", String(rec.search.gentlest))}`; + return `${text} · ${rec.elapsed_s} s`; +} + +function wallTable(rec: WallRecord): HTMLElement { + const box = el("div", { attrs: { "data-role": "wall-table" } }); + for (const wall of rec.walls) { + for (const c of wall.cases) { + box.append( + el("p", { + className: "e03-muted", + text: + `${wall.name ?? t("E03_Rf_Wall")} · ${t(`E03_Rf_Case_${c.case}` as LocaleKey)} · ` + + `H ${fmt(c.geometry.H)} m · B ${fmt(c.geometry.B)} m · Ka ${fmt(c.coefficients.ka, 3)}` + + (c.coefficients.kae ? ` · KAE ${fmt(c.coefficients.kae, 3)}` : "") + + ` · e ${fmt(c.base.e)} m · q_max ${fmt(c.base.q_max, 1)} kPa`, + }), + ); + const table = el("table", { className: "e03-table" }); + table.append( + el("thead", { + children: [ + el("tr", { + children: [ + el("th", { text: t("E03_Rf_Check") }), + el("th", { className: "is-num", text: t("E03_Rf_Fs") }), + el("th", { className: "is-num", text: t("E03_Required") }), + el("th", { text: t("E03_Verdict") }), + ], + }), + ], + }), + ); + const body = el("tbody"); + for (const k of CHECKS) + body.append( + el("tr", { + attrs: { "data-check": k, "data-case": c.case }, + children: [ + el("td", { text: t(`E03_Rf_${k}` as LocaleKey) }), + el("td", { className: "is-num", text: fmt(c.fs[k]) }), + el("td", { className: "is-num", text: fmt(c.required[k], 1) }), + el("td", { children: [verdictChip(c.ok[k])] }), + ], + }), + ); + table.append(body); + box.append(table); + } + } + return box; +} + +export function buildReinforce(o: ReinforceOptions): HTMLElement { + const host = el("div", { className: "e03-card", attrs: { "data-role": "reinforce" } }); + const sides = o.row.sides; + const sideField = createSelectField({ + label: t("E03_Side"), + options: sides.map((s) => ({ + value: s.side, + text: `${t(s.side === "left" ? "E03_Left" : "E03_Right")} · ${t(s.role === "cut" ? "E03_Cut" : "E03_Fill")}`, + })), + value: o.slope?.assume.side ?? o.side ?? undefined, + compact: true, + }); + const ratio = createInputField({ + label: t("E03_Rf_Ratio"), + type: "number", + min: 0.1, + max: 4, + compact: true, + value: o.slope ? String(o.slope.assume.ratio) : "", + placeholder: t("E03_Rf_RatioNow"), + }); + const benchW = createInputField({ + label: t("E03_Rf_BenchW"), + type: "number", + min: 0, + compact: true, + value: o.slope?.assume.bench_w ? String(o.slope.assume.bench_w) : "", + }); + const benchH = createInputField({ + label: t("E03_Rf_BenchH"), + type: "number", + min: 0, + compact: true, + value: o.slope?.assume.bench_h ? String(o.slope.assume.bench_h) : "", + }); + for (const f of [ratio, benchW, benchH]) f.input.step = "0.05"; + const ask = (search: boolean): SlopeAsk => ({ + side: sideField.select.value, + ratio: search ? null : num(ratio.input), + bench_w: num(benchW.input) ?? 0, + bench_h: num(benchH.input) ?? 0, + search, + condition: search ? o.condition : null, + }); + const run = createButton({ + label: o.busy ? t("E03_Rf_Busy") : t("E03_Rf_Run"), + variant: "filled", + onClick: () => o.runSlope(ask(false)), + }); + const find = createButton({ + label: t("E03_Rf_Search"), + variant: "ghost", + onClick: () => o.runSlope(ask(true)), + }); + run.disabled = find.disabled = o.busy || !o.editable; + run.dataset.role = "whatif-run"; + find.dataset.role = "whatif-search"; + const fields = el("div", { className: "e03-panel__row" }); + fields.append(sideField.root, ratio.root, benchW.root, benchH.root); + const buttons = el("div", { className: "e03-panel__row" }); + buttons.append(find, run); + host.append( + el("h3", { className: "e03-card__title", text: t("E03_Rf_Title") }), + el("p", { className: "e03-muted", text: t("E03_Rf_Hint") }), + el("p", { + className: "e03-muted", + text: t("E03_Rf_Goal").replace("{c}", t(`E03_Cond_${o.condition}` as LocaleKey)), + }), + fields, + buttons, + ); + if (o.slope) { + host.append( + el("p", { + className: "e03-muted", + text: assumeText(o.slope), + attrs: { "data-role": "assume" }, + }), + slopeTable(o.slope), + el("h4", { className: "e03-card__title", text: t("E03_Rf_AfterSection") }), + ); + const canvas = createSectionCanvas(() => undefined); + canvas.root.style.height = "260px"; + canvas.root.style.flex = "none"; + canvas.root.dataset.role = "whatif-section"; + host.append(canvas.root); + const analyses = o.slope.view.analyses.filter((a) => a.condition === o.condition); + const analysis = analyses.find((a) => a.side === o.slope!.assume.side) ?? analyses[0] ?? null; + const draw = { + view: o.slope.view, + analysis, + toggles: { map: false, centers: false, candidates: false }, + picked: null, + }; + requestAnimationFrame(() => canvas.render(draw)); + } + if (o.row.walls) { + const method = createSelectField({ + label: t("E03_Rf_Method"), + options: [ + { value: "coulomb", text: "Coulomb" }, + { value: "rankine", text: "Rankine" }, + ], + value: o.wall?.params.method, + compact: true, + }); + const water = createInputField({ + label: t("E03_Rf_Water"), + type: "number", + min: 0, + max: 1, + compact: true, + value: String(o.wall?.params.water_ratio ?? 0), + }); + water.input.step = "0.1"; + const check = createButton({ + label: t("E03_Rf_WallRun"), + variant: "ghost", + onClick: () => o.runWall({ method: method.select.value, water_ratio: num(water.input) ?? 0 }), + }); + check.disabled = o.busy || !o.editable; + check.dataset.role = "wall-run"; + const row = el("div", { className: "e03-panel__row" }); + row.append(method.root, water.root, check); + host.append(el("h3", { className: "e03-card__title", text: t("E03_Rf_WallTitle") }), row); + if (o.wall) host.append(wallTable(o.wall)); + } + return host; +} diff --git a/E03_StabSection/E03_StabSection_WallCheck.py b/E03_StabSection/E03_StabSection_WallCheck.py new file mode 100644 index 000000000..e02dc0ce9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_WallCheck.py @@ -0,0 +1,220 @@ +"""E03 벽(옹벽 · 석축) 외적 안정 — 활동 · 전도 · 지지력 (요구 345 · 351 · PLAN 536-3). + +storage 를 모르는 순수 함수. 벽 폴리곤(2D 모형 `walls` · x = B06 offset · z = 표고) + 배면 · 기초 물성 +→ 하중 · 안전율 · 판정. 기준 = KDS 11 80 05(지식DB `구조물_안정계산.md` §2 · `original/안정성평가/04`). + +- 토압 = Coulomb(벽 배면 = 뒷굽 바닥 ~ 배면 꼭대기 가상면 · 벽마찰 δ) 또는 Rankine(뒷굽 연직 가상면 · + δ = β) · 지진 시 = Mononobe-Okabe(정적 H/3 + 증분 0.6H · Seed-Whitman) + 벽 관성 kh·W. +- 가상면과 벽 사이 흙 = 벽 무게에 더함 · 배면 점착 = 0(보수 쪽). +- 상재하중 q = 배면 등분포(노면 하중) · 수압 = 배면 수위 hw(바닥부터) 정수압 + 바닥 삼각 양압력 · + 수위 아래 토압 = 수중 단위중량. +- 활동 = (V·μ + c_b·B) / ΣH(수동토압 빼고) · 전도 = 앞굽 기준 저항 / 전도 모멘트 · + 지지력 = Meyerhof 극한(유효폭 B' · 경사 계수) / 최대 지반반력(사다리꼴 · 삼각). +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from shapely.geometry import LineString, Polygon, box +from shapely.ops import unary_union + +GAMMA_W = 9.81 +#: KDS 11 80 05 기준안전율 — 평상시 · 지진 시(활동은 수동토압 뺀 값) +REQUIRED = { + "normal": {"sliding": 1.5, "overturning": 2.0, "bearing": 3.0}, + "seismic": {"sliding": 1.2, "overturning": 1.5, "bearing": 2.0}, +} +#: 벽 단위중량(kN/m³) — 콘크리트 · 찰쌓기 23 · 메쌓기(공극) 20 +WALL_GAMMA = {"default": 23.0, "dry_masonry": 20.0} +_N = 400 # 토압 적분 칸 + + +def ka_coulomb(phi: float, delta: float, theta: float, beta: float, psi: float = 0.0) -> float: + """Coulomb 주동토압계수(ψ > 0 = Mononobe-Okabe KAE · kv = 0). 각 = 도. + + θ = 배면이 연직에서 앞쪽(벽 쪽)으로 누운 각(쐐기가 커지는 쪽 +) · β = 배면 흙 경사 · δ = 벽마찰.""" + f, d, t, b, p = (math.radians(v) for v in (phi, delta, theta, beta, psi)) + under = math.sin(f + d) * math.sin(f - b - p) / (math.cos(d + t + p) * math.cos(b - t)) + root = math.sqrt(max(under, 0.0)) + return math.cos(f - t - p) ** 2 / ( + math.cos(p) * math.cos(t) ** 2 * math.cos(d + t + p) * (1.0 + root) ** 2 + ) + + +def ka_rankine(phi: float, beta: float = 0.0) -> float: + """Rankine 주동토압계수(경사 배면 β · 합력은 β 로 기욺).""" + f, b = math.radians(phi), math.radians(beta) + root = math.sqrt(max(math.cos(b) ** 2 - math.cos(f) ** 2, 0.0)) + return math.cos(b) * (math.cos(b) - root) / (math.cos(b) + root) + + +def bearing_factors(phi: float) -> tuple[float, float, float]: + """Meyerhof 지지력 계수(Nc · Nq · Nγ) — φ 도.""" + f = math.radians(phi) + if f < 1e-9: + return 5.14, 1.0, 0.0 + nq = math.exp(math.pi * math.tan(f)) * math.tan(math.pi / 4 + f / 2) ** 2 + return (nq - 1.0) / math.tan(f), nq, (nq - 1.0) * math.tan(1.4 * f) + + +def _pressure( + ka: float, H: float, gamma: float, gamma_sat: float, q: float, hw: float +) -> tuple[float, float]: + """연직 높이 H 가상면 토압 합력 · 바닥에서 작용 높이 — σ' = 수위 위 γ · 아래 γ' 누적 + q.""" + z = np.linspace(0.0, H, _N + 1) # 꼭대기에서 아래로 + dry = np.minimum(z, H - hw) + wet = np.maximum(z - (H - hw), 0.0) + p = ka * (gamma * dry + (gamma_sat - GAMMA_W) * wet + q) + total = float(np.trapz(p, z)) + arm = float(np.trapz(p * (H - z), z)) / total if total > 0 else H / 3 + return total, arm + + +def check_wall( + polygon: list, + back: int, + *, + backfill: dict[str, float], + foundation: dict[str, float], + method: str = "coulomb", + delta_ratio: float = 2.0 / 3.0, + beta_deg: float = 0.0, + surcharge_kpa: float = 10.0, + water_ratio: float = 0.0, + kh: float = 0.0, + wall_gamma: float = WALL_GAMMA["default"], + base_friction: float | None = None, + base_adhesion: float = 0.0, + embed_m: float = 0.0, +) -> dict[str, Any]: + """벽 하나 외적 안정. back = 배면(흙) 쪽 x 방향(+1 · −1). 물성 = {gamma_t, gamma_sat, c, phi}. + + kh > 0 이면 지진 시(기준 `REQUIRED["seismic"]`) · 아니면 평상시. 돌려줌 = 하중 · 안전율 · 판정.""" + wall = Polygon(polygon) + xs = np.array([p[0] for p in polygon], dtype=float) + zs = np.array([p[1] for p in polygon], dtype=float) + zb, zt = float(zs.min()), float(zs.max()) + H = zt - zb + base = xs[zs <= zb + 1e-3] + heel = float(base.max() if back > 0 else base.min()) + toe = float(base.min() if back > 0 else base.max()) + B = abs(heel - toe) + phi, gamma = float(backfill["phi"]), float(backfill["gamma_t"]) + gamma_sat = float(backfill.get("gamma_sat") or gamma) + hw = min(max(water_ratio, 0.0), 1.0) * H + + if method == "rankine": + x_top, theta, delta = heel, 0.0, beta_deg + else: + top = xs[zs >= zt - 1e-3] + x_top = float(top.max() if back > 0 else top.min()) # 배면 꼭대기 모서리 + theta = math.degrees(math.atan2(-(x_top - heel) * back, H)) + delta = delta_ratio * phi + # 가상면과 벽 사이 흙 — 벽 쪽 반평면 ∩ 벽 상자 − 벽 · 가상면에 붙은 조각만(앞면 앞 흙 빼고) + reach = 10.0 * (H + B + 1.0) + side = [(heel, zb), (x_top, zt), (x_top - back * reach, zt), (heel - back * reach, zb)] + plane = LineString([(heel, zb), (x_top, zt)]) + rest = Polygon(side).intersection(box(*wall.bounds)).difference(wall) + parts = [g for g in getattr(rest, "geoms", [rest]) if isinstance(g, Polygon)] + soil = unary_union([g for g in parts if g.area > 1e-9 and g.distance(plane) < 1e-6]) + soil_w = soil.area * gamma + soil_x = soil.centroid.x if soil_w > 0 else heel + soil_z = soil.centroid.y if soil_w > 0 else zb + + ka = ( + ka_rankine(phi, beta_deg) + if method == "rankine" + else ka_coulomb(phi, delta, theta, beta_deg) + ) + q_eff = surcharge_kpa * ( + 1.0 + if method == "rankine" + else math.cos(math.radians(theta)) / math.cos(math.radians(theta - beta_deg)) + ) + Pa, ya = _pressure(ka, H, gamma, gamma_sat, q_eff, hw) + loads = [("earth", Pa, ya)] + kae = None + if kh > 0: + psi = math.degrees(math.atan(kh)) + kae = ka_coulomb(phi, delta, theta, beta_deg, psi) + Pae, _ = _pressure(kae, H, gamma, gamma_sat, q_eff, hw) + loads.append(("seismic_earth", max(Pae - Pa, 0.0), 0.6 * H)) + incline = math.radians(delta + theta) + + def plane_x(y: float) -> float: # 가상면 위 높이 y 자리 x + return heel + (x_top - heel) * (y / H if H > 0 else 0.0) + + def arm(x: float) -> float: # 앞굽에서 배면 쪽 수평 팔 길이 + return (x - toe) * back + + W = wall.area * wall_gamma + xw, zw = wall.centroid.x, wall.centroid.y + Ph = sum(P for _, P, _ in loads) * math.cos(incline) + Pv = sum(P for _, P, _ in loads) * math.sin(incline) + m_res = W * arm(xw) + soil_w * arm(soil_x) + m_over = 0.0 + for _, P, y in loads: + m_res += P * math.sin(incline) * arm(plane_x(y)) + m_over += P * math.cos(incline) * y + Pw = 0.5 * GAMMA_W * hw**2 + U = 0.5 * GAMMA_W * hw * B + m_over += Pw * hw / 3.0 + U * (2.0 * B / 3.0) + inertia = kh * (W + soil_w) + m_over += kh * W * (zw - zb) + kh * soil_w * (soil_z - zb) + H_total = Ph + Pw + inertia + V = W + soil_w + Pv - U + + phi_f = float(foundation["phi"]) + mu = base_friction if base_friction is not None else math.tan(math.radians(2.0 / 3.0 * phi_f)) + sliding = (V * mu + base_adhesion * B) / H_total if H_total > 0 else math.inf + overturning = m_res / m_over if m_over > 0 else math.inf + xbar = (m_res - m_over) / V if V > 0 else -math.inf + e = B / 2.0 - xbar + if xbar <= 0: + q_max, b_eff = math.inf, 0.0 + elif abs(e) <= B / 6.0: + q_max, b_eff = V / B * (1.0 + 6.0 * abs(e) / B), B - 2.0 * abs(e) + else: + q_max, b_eff = 2.0 * V / (3.0 * min(xbar, B - xbar)), B - 2.0 * abs(e) + nc, nq, ng = bearing_factors(phi_f) + alpha = math.degrees(math.atan2(H_total, V)) if V > 0 else 90.0 + i_cq = (1.0 - alpha / 90.0) ** 2 + i_g = (1.0 - alpha / phi_f) ** 2 if phi_f > 0 and alpha < phi_f else 0.0 + gf = float(foundation["gamma_t"]) + q_ult = ( + float(foundation.get("c") or 0.0) * nc * i_cq + + gf * max(embed_m, 0.0) * nq * i_cq + + 0.5 * gf * max(b_eff, 0.0) * ng * i_g + ) + bearing = q_ult / q_max if q_max > 0 and math.isfinite(q_max) else 0.0 + case = "seismic" if kh > 0 else "normal" + fs = {"sliding": sliding, "overturning": overturning, "bearing": bearing} + need = REQUIRED[case] + return { + "case": case, + "method": method, + "geometry": {"H": H, "B": B, "toe_x": toe, "heel_x": heel, "theta_deg": theta}, + "coefficients": {"ka": ka, "kae": kae, "delta_deg": delta, "beta_deg": beta_deg}, + "loads": { + "W": W, + "W_soil": soil_w, + "Pa": Pa, + "Pa_arm": ya, + "Ph": Ph, + "Pv": Pv, + "Pw": Pw, + "U": U, + "inertia": inertia, + "V": V, + "H": H_total, + "M_resist": m_res, + "M_overturn": m_over, + }, + "base": {"e": e, "q_max": q_max, "q_ult": q_ult, "B_eff": b_eff, "mu": mu, "alpha": alpha}, + "fs": fs, + "required": dict(need), + "ok": {k: bool(fs[k] >= need[k]) for k in fs}, + } diff --git a/E03_StabSection/E03_StabSection_WhatIf.py b/E03_StabSection/E03_StabSection_WhatIf.py new file mode 100644 index 000000000..9593dbf87 --- /dev/null +++ b/E03_StabSection/E03_StabSection_WhatIf.py @@ -0,0 +1,199 @@ +"""E03 보강 가정 — 비탈 기울기 완화 · 소단 덧붙임 → 설계선 다시 그림 · 최소 완화 찾기 (PLAN 536-3). + +storage 를 모르는 순수 함수. 가정 = 모형 형상만 바꿈(설계 · 임도 쪽으로 안 돌려 씀 · 요구 345 E). +- `reshape_side` = 한 쪽 비탈을 비탈 시작점에서 1:n(+ 높이 h 마다 폭 w 소단)으로 원지반까지 다시 그림 · + 안쪽(노면 · 측구)은 그대로 · 2단(암 · 토사) 경사는 한 경사로 갈음. +- `slope_start` = 비탈 시작점 — 깎기 = 측구 바깥(`cut_slope_segments` 시작) · 쌓기 = 노면 끝. +- `search_ratio` = 목표 안전율을 맞추는 가장 급한 1:n(이분 · `step` 단위 올림) — 해석은 부르는 쪽 함수. +좌표 = B06 offset(좌 +) · 표고. +""" + +from __future__ import annotations + +import math +from typing import Any, Callable + +import numpy as np + +#: 최소 완화 찾기 — 가장 완만한 기울기 · 찾는 단위 +MAX_RATIO = 4.0 +RATIO_STEP = 0.05 + + +def current_ratio(design: dict[str, Any], side: str, role: str) -> float: + """지금 설계 기울기 1:n 의 n — 깎기 = 그 쪽 첫 구간 · 쌓기 = 쌓기 기울기.""" + if role == "fill": + return float(design.get("fill_slope_ratio") or 1.5) + segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] + if segs: + return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["ratio"]) + return float(design.get("cut_slope_ratio") or 1.0) + + +def slope_start(design: dict[str, Any], side: str, role: str) -> float: + """비탈 시작 offset.""" + edge = float(((design.get("road_edges") or {}).get(side) or {}).get("offset_m", 0.0)) + if role == "cut": + segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] + if segs: + return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["start_offset_m"]) + return edge + + +def _profile( + x0: float, z0: float, sign: float, up: float, ratio: float, bench_w: float, bench_h: float +) -> list[tuple[float, float]]: + """비탈 꼭짓점(시작 → 바깥) — 원지반을 넉넉히 지나도록 길게(뒤에서 자름).""" + pts = [(x0, z0)] + x, z = x0, z0 + while abs(x - x0) < 200.0: + rise = bench_h if bench_w > 0 and bench_h > 0 else 200.0 + x, z = x + sign * rise * ratio, z + up * rise + pts.append((x, z)) + if bench_w > 0 and bench_h > 0: + x += sign * bench_w + pts.append((x, z)) + return pts + + +def _daylight( + pts: list[tuple[float, float]], ground: list, sign: float, up: float +) -> list[tuple[float, float]] | None: + """비탈이 원지반과 처음 만나는 점까지 — 못 만나면 None(지형 밖). 시작부터 원지반 밖이면 시작점만.""" + gx = np.array([p[0] for p in ground], dtype=float) + gz = np.array([p[1] for p in ground], dtype=float) + out = [pts[0]] + for (xa, za), (xb, zb) in zip(pts, pts[1:]): + inside = gx[(gx > min(xa, xb)) & (gx < max(xa, xb))] + xs = np.unique(np.r_[xa, xb, inside]) + xs = xs[np.argsort(sign * xs)] + zl = za + (xs - xa) / (xb - xa) * (zb - za) if xb != xa else np.full(len(xs), zb) + f = up * (zl - np.interp(xs, gx, gz)) # ≥ 0 = 원지반 밖으로 나옴 + if len(out) == 1 and f[0] >= 0: + return out + for k in range(1, len(xs)): + if not gx[0] <= xs[k] <= gx[-1]: + return None + if f[k] >= 0: + s = f[k - 1] / (f[k - 1] - f[k]) + x = float(xs[k - 1] + s * (xs[k] - xs[k - 1])) + return out + [(x, float(zl[k - 1] + s * (zl[k] - zl[k - 1])))] + out.append((xb, zb)) + return None + + +def reshape_side( + design_line: list, + ground: list, + *, + side: str, + role: str, + start_x: float, + ratio: float, + bench_w: float = 0.0, + bench_h: float = 0.0, +) -> list[list[float]] | None: + """한 쪽 비탈만 다시 그린 설계선([[offset, z] …]) — 원지반을 못 만나면 None.""" + line = sorted((float(x), float(z)) for x, z in design_line) + sign = 1.0 if side == "left" else -1.0 + up = 1.0 if role == "cut" else -1.0 + lx = np.array([p[0] for p in line]) + lz = np.array([p[1] for p in line]) + z0 = float(np.interp(start_x, lx, lz)) + inner = [p for p in line if sign * p[0] < sign * start_x - 1e-9] + slope = _daylight(_profile(start_x, z0, sign, up, ratio, bench_w, bench_h), ground, sign, up) + if slope is None: + return None + return [list(p) for p in sorted(inner + slope)] + + +def design_variant( + design: dict[str, Any], ground: list, assume: dict[str, Any] +) -> dict[str, Any] | None: + """가정 하나(side · role · ratio · bench_w · bench_h) → 설계선만 바꾼 설계 사본(None = 원지반 못 만남).""" + side, role = assume["side"], assume["role"] + pts = [(p["offset_m"], p["elevation_m"]) for p in design.get("design_line") or []] + line = reshape_side( + pts, + ground, + side=side, + role=role, + start_x=slope_start(design, side, role), + ratio=float(assume["ratio"]), + bench_w=float(assume.get("bench_w") or 0.0), + bench_h=float(assume.get("bench_h") or 0.0), + ) + if line is None: + return None + return {**design, "design_line": [{"offset_m": x, "elevation_m": z} for x, z in line]} + + +def gentlest_ratio( + design: dict[str, Any], + ground: list, + assume: dict[str, Any], + start: float, + step: float = RATIO_STEP, +) -> float | None: + """원지반을 만나는 가장 완만한 n(start ~ MAX_RATIO · step 단위 · 형상만 · 이분) — 없으면 None.""" + + def meets(n: float) -> bool: + return design_variant(design, ground, {**assume, "ratio": n}) is not None + + lo = math.ceil(start / step - 1e-9) * step + hi = math.floor(MAX_RATIO / step + 1e-9) * step + if not meets(lo): + return None + if meets(hi): + return round(hi, 4) + while hi - lo > step + 1e-9: # lo = 만남 · hi = 못 만남 + mid = round(round((lo + hi) / 2 / step) * step, 6) + if mid in (lo, hi): + break + lo, hi = (mid, hi) if meets(mid) else (lo, mid) + return round(lo, 4) + + +def search_ratio( + passes: Callable[[float], bool], + start: float, + *, + max_ratio: float = MAX_RATIO, + step: float = RATIO_STEP, + coarse: int = 5, +) -> dict[str, Any]: + """목표를 맞추는 가장 급한 n(start ≤ n ≤ max_ratio · step 단위). passes(n) = 해석 · 판정. + + 완만할수록 늘 안전하지 않음(급한 자연 비탈 = 완화가 깎기 높이를 키움) — 성긴 걸음(최대 `coarse` 칸)으로 + 처음 맞는 칸을 찾고 그 앞 칸 사이를 이분으로 좁힘 · 끝까지 못 맞추면 found None.""" + tried: list[tuple[float, bool]] = [] + + def test(n: float) -> bool: + ok = passes(n) + tried.append((round(n, 4), ok)) + return ok + + def snap(n: float) -> float: + return round(round(n / step) * step, 6) + + lo = snap(math.ceil(start / step - 1e-9) * step) + top = snap(math.floor(max_ratio / step + 1e-9) * step) + if test(lo): + return {"found": lo, "tried": tried} + gap = max(2 * step, snap((top - lo) / coarse)) + hi = None + n = lo + while n < top - 1e-9: + n = min(snap(n + gap), top) + if test(n): + hi = n + break + lo = n + if hi is None: + return {"found": None, "tried": tried} + while hi - lo > step + 1e-9: # lo = 못 맞춤 · hi = 맞춤 + mid = snap((lo + hi) / 2) + if mid in (lo, hi): + break + lo, hi = (lo, mid) if test(mid) else (mid, hi) + return {"found": round(hi, 4), "tried": tried} diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index d0be14542..9529db131 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -2,12 +2,13 @@ 입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별 `stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) · E07 광역 저장본(있으면) · +E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) · E01 복사본(측점 · 횡단 · 노선). 양식 = `resources/master_template/report/stab_report.json` 칸 자리 = 같은 폴더 `stab_report_slots.md`. 몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 · -프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7) +프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임 (인쇄 HTML · xlsx 는 조건부 서식을 안 먹음). @@ -36,13 +37,14 @@ from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary from E04_Stab3D import E04_Stab3D_Run as run3d from E05_StabReport import E05_StabReport_Area as area from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport import E05_StabReport_Reinforce as reinforce ROOT = Path(__file__).resolve().parents[1] FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.json" STAGE = "E05_StabReport" REPORT, META = "report.json", "report_meta.json" USER_SHEETS = ("s1", "s2") -PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7") +PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7", "s8") BLOCK = 28 # 대표단면 한 쪽 = 28 행 REPRESENTATIVE = Path("E03_StabSection") / "representative.json" BAND_KEY = "fig/3d_band.png" @@ -171,6 +173,7 @@ def analysis_fingerprint(project_root: Path) -> str: if folder.is_dir() else [], "area": area.stamp(project_root), + "reinforce": reinforce.stamp(project_root), "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(), } text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) @@ -220,6 +223,7 @@ def gather(project_root: Path) -> dict[str, Any]: "ground": ground, "sections3d": sorted(sections3d, key=lambda s: s["start_m"]), "area": area.read(project_root), + "reinforce": reinforce.read(project_root), "fingerprint": analysis_fingerprint(project_root), } @@ -620,6 +624,10 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st jobs = fill_sections(by["s5"], inputs) jobs.append(fill_3d(by["s6"], inputs)) area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"])) + if inputs.get("reinforce"): + reinforce.fill_reinforce(by["s8"], inputs["reinforce"], _labels(inputs["stations"])) + else: # 보강 검토가 없으면 시트를 뺌 + book["시트"] = [s for s in book["시트"] if s["id"] != "s8"] return jobs diff --git a/E05_StabReport/E05_StabReport_Reinforce.py b/E05_StabReport/E05_StabReport_Reinforce.py new file mode 100644 index 000000000..93f58008d --- /dev/null +++ b/E05_StabReport/E05_StabReport_Reinforce.py @@ -0,0 +1,135 @@ +"""E05 보고서 s8 보강검토 — E03 보강 검토 저장본(비탈 완화 · 소단 가정 · 벽 외적 안정) → 시트 (PLAN 536-3). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s8. +저장본이 없으면 시트를 뺌(있을 때만) — `build_program` 이 책에서 지움. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +from E03_StabSection.E03_StabSection_Router_WhatIf import records + +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SIDE_NAMES = {"left": "좌", "right": "우"} +ROLE_NAMES = {"cut": "깎기", "fill": "쌓기"} +CASE_NAMES = {"normal": "평상시", "seismic": "지진 시"} +CHECKS = ("sliding", "overturning", "bearing") +ST_NG, ST_OK = 10, 11 + + +def read(project_root: Path) -> list[dict[str, Any]]: + return records(Path(project_root)) + + +def stamp(project_root: Path) -> str: + """해석 지문용 — 저장본 목록 내용.""" + body = json.dumps(read(project_root), ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha1(body.encode("utf-8")).hexdigest() + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + cell = dict(sheet["칸"].get(a1) or {}) + cell.pop("식", None) + if value is None: + cell.pop("값", None) + else: + cell["값"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["서식"] = style + sheet["칸"][a1] = cell + + +def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None: + if ok is None: + _set(sheet, a1, "-") + else: + _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG) + + +def _assume_text(rec: dict[str, Any]) -> str: + a = rec["assume"] + text = f"1:{a['ratio_before']:g} → 1:{a['ratio']:g}" + if a.get("bench_w") and a.get("bench_h"): + text += f" · 소단 폭 {a['bench_w']:g} m / 높이 {a['bench_h']:g} m 마다" + search = rec.get("search") + if search: + text += " · 최소 완화" if search.get("found") else " · 최소 완화 못 찾음(끝값)" + return text + + +def fill_reinforce(sheet: dict[str, Any], recs: list[dict[str, Any]], labels: dict) -> None: + """s8 — 1. 가정 줄(조건마다 한 줄 · 6 행 서식) · 2. 벽 줄(경우마다 한 줄 · 10 행 서식).""" + tpl = { + r: {c: copy.deepcopy(sheet["칸"].get(f"{c}{r}") or {}) for c in "ABCDEFGHI"} + for r in (8, 9, 10) + } + row6 = {c: copy.deepcopy(sheet["칸"].get(f"{c}6") or {}) for c in "ABCDEFGHI"} + for a1 in [k for k in sheet["칸"] if int("".join(ch for ch in k if ch.isdigit())) >= 6]: + del sheet["칸"][a1] + sheet["병합"] = [ + m for m in sheet["병합"] if int("".join(ch for ch in m.split(":")[0] if ch.isdigit())) < 6 + ] + sheet["행"] = {k: v for k, v in sheet["행"].items() if int(k) < 6} + + def put_row(r: int, styles: dict[str, dict]) -> None: + for c, cell in styles.items(): + sheet["칸"][f"{c}{r}"] = copy.deepcopy(cell) + + def name(rec: dict[str, Any]) -> str: + ch = float(rec["chainage_m"]) + return rec.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}" + + r = 6 + slopes = [x for x in recs if x["type"] == "slope"] + for rec in slopes: + a = rec["assume"] + for cond, after in rec["after"].items(): + before = rec["before"].get(cond) or {} + put_row(r, row6) + _set(sheet, f"A{r}", name(rec)) + _set(sheet, f"B{r}", f"{SIDE_NAMES[a['side']]} · {ROLE_NAMES[a['role']]}") + _set(sheet, f"C{r}", _assume_text(rec)) + _set(sheet, f"D{r}", CONDITION_NAMES.get(cond, cond)) + _set(sheet, f"E{r}", before.get("fs")) + _set(sheet, f"F{r}", after.get("fs")) + _set(sheet, f"G{r}", after.get("required")) + _verdict(sheet, f"H{r}", before.get("ok")) + _verdict(sheet, f"I{r}", after.get("ok")) + r += 1 + if not slopes: + put_row(r, row6) + _set(sheet, f"A{r}", "가정 없음") + r += 1 + r += 1 + put_row(r, tpl[8]) + sheet["병합"].append(f"A{r}:I{r}") + sheet["행"][str(r)] = sheet["행"][str(r + 1)] = {"높이": 22} + put_row(r + 1, tpl[9]) + r += 2 + walls = [x for x in recs if x["type"] == "wall"] + for rec in walls: + p = rec["params"] + for wall in rec["walls"]: + for case in wall["cases"]: + put_row(r, tpl[10]) + _set(sheet, f"A{r}", name(rec)) + _set( + sheet, + f"B{r}", + f"{wall.get('name') or '벽'} · {CASE_NAMES[case['case']]} · " + f"{'Coulomb' if p['method'] == 'coulomb' else 'Rankine'} · 수위 {p['water_ratio']:g}H", + ) + for c, need, check in zip("CEG", "DFH", CHECKS): + fs = case["fs"][check] + _set(sheet, f"{c}{r}", fs if fs == fs and abs(fs) < 1e6 else None) + _set(sheet, f"{need}{r}", case["required"][check]) + _verdict(sheet, f"I{r}", all(case["ok"].values())) + r += 1 + if not walls: + put_row(r, tpl[10]) + _set(sheet, f"A{r}", "벽 검토 없음") diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py index ac96b8429..8579210ed 100644 --- a/E05_StabReport/E05_StabReport_Store.py +++ b/E05_StabReport/E05_StabReport_Store.py @@ -117,8 +117,15 @@ def refresh(project_root: Path) -> dict[str, Any]: stamp = datetime.now().strftime("%Y%m%d_%H%M%S") shutil.copy2(folder / REPORT, old / f"{stamp}.json") have = {s["id"] for s in book["시트"]} - sheets = [built.get(s["id"], s) for s in book["시트"]] - sheets += [built[sid] for sid in PROGRAM_SHEETS if sid not in have] # 지운 시트는 끝에 다시 + # 새 판에 없는 프로그램 몫(보강검토 — 저장본 없음)은 뺌 + sheets = [ + built.get(s["id"], s) + for s in book["시트"] + if s["id"] in built or s["id"] not in PROGRAM_SHEETS + ] + sheets += [ + built[sid] for sid in PROGRAM_SHEETS if sid in built and sid not in have + ] # 지운 시트는 끝에 다시 book = with_server_values({**book, "시트": sheets}) figures = _write_figures(folder, jobs) new_meta = _meta(book, inputs, figures) diff --git a/main.py b/main.py index f36ca04af..a1ce11a71 100644 --- a/main.py +++ b/main.py @@ -85,6 +85,7 @@ from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router from E07_StabArea.E07_StabArea_Router import router as e07_stab_area_router from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router +from E03_StabSection.E03_StabSection_Router_WhatIf import router as e03_stab_whatif_router from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_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 @@ -497,6 +498,7 @@ app.include_router(e04_stab_3d_router, dependencies=protected_with_company) app.include_router(e05_stab_report_router, dependencies=protected_with_company) app.include_router(e07_stab_area_router, dependencies=protected_with_company) app.include_router(e03_stab_section_router, dependencies=protected_with_company) +app.include_router(e03_stab_whatif_router, dependencies=protected_with_company) app.include_router(e06_stab_rock_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) diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 469361b41..78c2f3835 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -3078,6 +3078,351 @@ 21 ] } + }, + { + "id": "s8", + "이름": "보강검토", + "칸": { + "A1": { + "값": "보강 검토 (가정)", + "서식": 1 + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "G1": { + "서식": 1 + }, + "H1": { + "서식": 1 + }, + "I1": { + "서식": 1 + }, + "A2": { + "값": "E 안 가정 검토 — 비탈 완화 · 소단은 형상만 바꿔 다시 해석 · 설계(임도)에 안 돌려 씀 · 문헌 추정치", + "서식": 2 + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "G2": { + "서식": 2 + }, + "H2": { + "서식": 2 + }, + "I2": { + "서식": 2 + }, + "A4": { + "값": "1. 비탈 완화 · 소단 가정 — 전후 안전율 (Bishop)", + "서식": 3 + }, + "B4": { + "서식": 3 + }, + "C4": { + "서식": 3 + }, + "D4": { + "서식": 3 + }, + "E4": { + "서식": 3 + }, + "F4": { + "서식": 3 + }, + "G4": { + "서식": 3 + }, + "H4": { + "서식": 3 + }, + "I4": { + "서식": 3 + }, + "A5": { + "값": "측점", + "서식": 6 + }, + "B5": { + "값": "비탈", + "서식": 6 + }, + "C5": { + "값": "가정", + "서식": 6 + }, + "D5": { + "값": "조건", + "서식": 6 + }, + "E5": { + "값": "전 Fs", + "서식": 6 + }, + "F5": { + "값": "후 Fs", + "서식": 6 + }, + "G5": { + "값": "기준", + "서식": 6 + }, + "H5": { + "값": "전 판정", + "서식": 6 + }, + "I5": { + "값": "후 판정", + "서식": 6 + }, + "A6": { + "서식": 7 + }, + "B6": { + "서식": 7 + }, + "C6": { + "서식": 5 + }, + "D6": { + "서식": 7 + }, + "E6": { + "서식": 8 + }, + "F6": { + "서식": 8 + }, + "G6": { + "서식": 8 + }, + "H6": { + "서식": 7 + }, + "I6": { + "서식": 7 + }, + "A8": { + "값": "2. 벽 외적 안정 — 활동 · 전도 · 지지력 (KDS 11 80 05)", + "서식": 3 + }, + "B8": { + "서식": 3 + }, + "C8": { + "서식": 3 + }, + "D8": { + "서식": 3 + }, + "E8": { + "서식": 3 + }, + "F8": { + "서식": 3 + }, + "G8": { + "서식": 3 + }, + "H8": { + "서식": 3 + }, + "I8": { + "서식": 3 + }, + "A9": { + "값": "측점", + "서식": 6 + }, + "B9": { + "값": "벽 · 경우", + "서식": 6 + }, + "C9": { + "값": "활동 Fs", + "서식": 6 + }, + "D9": { + "값": "기준", + "서식": 6 + }, + "E9": { + "값": "전도 Fs", + "서식": 6 + }, + "F9": { + "값": "기준", + "서식": 6 + }, + "G9": { + "값": "지지력 Fs", + "서식": 6 + }, + "H9": { + "값": "기준", + "서식": 6 + }, + "I9": { + "값": "판정", + "서식": 6 + }, + "A10": { + "서식": 7 + }, + "B10": { + "서식": 5 + }, + "C10": { + "서식": 8 + }, + "D10": { + "서식": 8 + }, + "E10": { + "서식": 8 + }, + "F10": { + "서식": 8 + }, + "G10": { + "서식": 8 + }, + "H10": { + "서식": 8 + }, + "I10": { + "서식": 7 + }, + "A12": { + "값": "가정 · 벽 검토 없음 — E03 [보강 검토] 뒤 [보고서 갱신]", + "서식": 5 + }, + "B12": { + "서식": 5 + }, + "C12": { + "서식": 5 + }, + "D12": { + "서식": 5 + }, + "E12": { + "서식": 5 + }, + "F12": { + "서식": 5 + }, + "G12": { + "서식": 5 + }, + "H12": { + "서식": 5 + }, + "I12": { + "서식": 5 + } + }, + "병합": [ + "A1:I1", + "A2:I2", + "A4:I4", + "A8:I8", + "A12:I12" + ], + "열": { + "A": { + "폭": 11 + }, + "B": { + "폭": 14 + }, + "C": { + "폭": 20 + }, + "D": { + "폭": 8 + }, + "E": { + "폭": 9 + }, + "F": { + "폭": 9 + }, + "G": { + "폭": 9 + }, + "H": { + "폭": 9 + }, + "I": { + "폭": 9 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 30 + }, + "4": { + "높이": 22 + }, + "5": { + "높이": 22 + }, + "8": { + "높이": 22 + }, + "9": { + "높이": 22 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + } + } } ], "활성": "s1" diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index 52bb6e56a..b2a94f85f 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -90,3 +90,13 @@ | 22 행부터 보통 · 높음 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 측점 등급 + SI<1 비율 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | - 22 행 서식을 줄마다 복사. 측점 등급 = 영향 구역(측점 앞뒤 ½ 간격 × 띠 폭) 안 SI < 1(등급 4 이상) 셀 비율 — 낮음 < 5 % · 보통 5~20 % · 높음 ≥ 20 %(문턱 = E07 설정). + +## s8 보강검토 (프로그램 몫 · E03 보강 검토 저장본 · 없으면 시트를 뺌) + +| 칸 | 내용 | +| --- | --- | +| 6 행부터 가정 하나 × 조건 하나 = 한 줄 | A 측점 · B 비탈(좌 · 우 · 깎기 · 쌓기)(서식 7) · C 가정 글(1:n 전 → 후 · 소단 폭 / 높이 · 최소 완화)(5) · D 조건(7) · E 전 Fs · F 후 Fs · G 기준(8) · H 전 판정 · I 후 판정(7 → N.G 10 · O.K 11) · 없으면 「가정 없음」 | +| 가정 줄 다음 한 줄 띄고 `8` · `9` 행 꼴(제목 3 · 머리 6) | 2. 벽 외적 안정 | +| 그 아래 벽 하나 × 경우 하나 = 한 줄 | A 측점(7) · B 벽 이름 · 평상시 / 지진 시 · 토압법 · 배면 수위(5) · C 활동 · E 전도 · G 지지력 Fs(8) · D · F · H 기준(8 · KDS 11 80 05) · I 판정(7 → 10 · 11) · 없으면 「벽 검토 없음」 | + +- 6 행 · 10 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나. diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py index 8eee5c646..0a6681409 100644 --- a/resources/tester/test_534_3_stab_report_form.py +++ b/resources/tester/test_534_3_stab_report_form.py @@ -36,7 +36,7 @@ STYLE_KEYS = { } # fmt: skip PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"} A1 = re.compile(r"^([A-Z]{1,3})(\d+)$") -NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] +NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토"] needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -148,7 +148,7 @@ def test_양식_계약(): book = _form() _contract(book) assert [s["이름"] for s in book["시트"]] == NAMES - assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7"] + assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"] def test_양식_인쇄_쪽나눔_머리행_그림(): @@ -167,6 +167,7 @@ def test_양식_인쇄_쪽나눔_머리행_그림(): "대표단면": 1, "3D요약": 1, "광역요약": 0, + "보강검토": 0, } # N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀) assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9 @@ -230,8 +231,8 @@ def test_견본_pdf_쪽수_머리행(png): ) sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t] assert len(sections) == 2 # 단면마다 한 쪽 - # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 - assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1(양식 빈 판) + assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab) html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0] assert html.count(" bool: + return abs(a - b) <= tol * abs(b) + + +# ── 벽 외적 안정 ───────────────────────────────────────────────────────────── +def test_토압_지지력_계수_교과서표(): + """Das(기초공학) 표 — Coulomb δ = 2/3φ · Rankine 경사 배면 · Meyerhof 지지력 계수.""" + assert close(wc.ka_coulomb(30, 20, 0, 0), 0.2973) + assert close(wc.ka_coulomb(30, 0, 0, 0), 1 / 3) + assert close(wc.ka_rankine(30, 0), 1 / 3) + assert close(wc.ka_rankine(30, 10), 0.3495) + nc, nq, ng = wc.bearing_factors(30) + assert close(nc, 30.14) and close(nq, 18.40) and close(ng, 15.67) + assert wc.bearing_factors(0) == (5.14, 1.0, 0.0) + + +def test_MO_닫힌꼴(): + """δ = θ = β = 0 · kv = 0 — KAE = cos²(φ−ψ) / (cos²ψ [1 + √(sinφ sin(φ−ψ) / cosψ)]²) · ψ = 0 이면 Coulomb.""" + for phi, kh in ((30, 0.2), (35, 0.15)): + psi = math.atan(kh) + f = math.radians(phi) + want = math.cos(f - psi) ** 2 / ( + math.cos(psi) ** 2 + * (1 + math.sqrt(math.sin(f) * math.sin(f - psi) / math.cos(psi))) ** 2 + ) + assert close(wc.ka_coulomb(phi, 0, 0, 0, math.degrees(psi)), want, 1e-9) + assert close(wc.ka_coulomb(30, 0, 0, 0, math.degrees(math.atan(0.2))), 0.4733) + assert wc.ka_coulomb(32, 21, 5, 10, 0.0) == pytest.approx(wc.ka_coulomb(32, 21, 5, 10)) + + +RECT = [[0, 0], [2, 0], [2, 4], [0, 4]] # B 2 · H 4 · 배면 = +x(뒷굽 x = 2) + + +def test_직사각_벽_손계산(): + """Rankine · q 0 · 물 없음 · μ 0.5 — W 184 · Pa 48(H/3) · 활동 1.917 · 전도 2.875 · + e = 1 − 0.6522 · q_max · Meyerhof q_ult(유효폭 · 경사 계수).""" + r = wc.check_wall( + RECT, +1, backfill=SOIL, foundation=SOIL, method="rankine", + surcharge_kpa=0.0, base_friction=0.5, + ) # fmt: skip + W, Pa = 2 * 4 * 23.0, 0.5 * 18 * 16 / 3 + assert close(r["loads"]["W"], W) and close(r["loads"]["Pa"], Pa) and r["loads"]["W_soil"] == 0 + assert close(r["fs"]["sliding"], W * 0.5 / Pa) + m_res, m_over = W * 1.0, Pa * 4 / 3 + assert close(r["fs"]["overturning"], m_res / m_over) + xbar = (m_res - m_over) / W + e = 1.0 - xbar + q_max = W / 2 * (1 + 6 * e / 2) if e <= 2 / 6 else 2 * W / (3 * xbar) + alpha = math.degrees(math.atan(Pa / W)) + q_ult = 0.5 * 18 * (2 - 2 * e) * 15.668 * (1 - alpha / 30) ** 2 + assert close(r["base"]["q_max"], q_max) and close(r["fs"]["bearing"], q_ult / q_max) + assert r["case"] == "normal" and r["required"] == wc.REQUIRED["normal"] + assert r["ok"] == {k: r["fs"][k] >= r["required"][k] for k in r["fs"]} + + +def test_상재하중_수압_양압력(): + """q 10 · 배면 수위 = 벽 높이 — Pa = Ka(½γ'H² + qH) · Pw = ½γwH² · U = ½γwHB · ΣH · V.""" + r = wc.check_wall( + RECT, +1, backfill=SOIL, foundation=SOIL, method="rankine", + surcharge_kpa=10.0, water_ratio=1.0, base_friction=0.5, + ) # fmt: skip + g = SOIL["gamma_sat"] - wc.GAMMA_W + Pa = (0.5 * g * 16 + 10 * 4) / 3 + Pw, U = 0.5 * wc.GAMMA_W * 16, 0.5 * wc.GAMMA_W * 4 * 2 + assert close(r["loads"]["Pa"], Pa) and close(r["loads"]["Pw"], Pw) and close(r["loads"]["U"], U) + assert close(r["loads"]["H"], Pa + Pw) and close(r["loads"]["V"], 184 - U) + arm = (0.5 * g * 16 * 4 / 3 + 40 * 2) / (0.5 * g * 16 + 40) # 삼각 + 사각 합력 높이 + assert close(r["loads"]["Pa_arm"], arm) + m_over = Pa * arm + Pw * 4 / 3 + U * 4 / 3 + assert close(r["loads"]["M_overturn"], m_over) + + +def test_L형_벽_뒷굽_위_흙_지진(): + """역 T 형(앞굽 0 · 뒷굽 3 · 벽체 0.6~1.0) — Rankine 가상면 위 흙 = (3 − 1.0) × 4.5 × γ · + Coulomb 가상면 θ = atan(2 / 5) · 지진 시 = M-O 증분 0.6H + 관성 · 기준 = 지진 시 표.""" + L = [[0, 0], [3, 0], [3, 0.5], [1.0, 0.5], [1.0, 5], [0.6, 5], [0.6, 0.5], [0, 0.5]] + r = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL, method="rankine") + assert close(r["loads"]["W_soil"], 2.0 * 4.5 * 18) + c = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL) + assert close(c["geometry"]["theta_deg"], math.degrees(math.atan(2 / 5))) + # 가상면(x = 3 − 0.4z)과 벽체(x = 1.0) 사이 · 0.5 ≤ z ≤ 5 → ∫(2 − 0.4z)dz = 4.05 m² + assert close(c["loads"]["W_soil"], 4.05 * 18) + s = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL, kh=0.15) + assert s["case"] == "seismic" and s["required"] == wc.REQUIRED["seismic"] + assert s["coefficients"]["kae"] > c["coefficients"]["ka"] + assert s["loads"]["H"] > c["loads"]["H"] and s["fs"]["sliding"] < c["fs"]["sliding"] + + +def test_배면이_반대쪽이면_거울(): + a = wc.check_wall(RECT, +1, backfill=SOIL, foundation=SOIL) + b = wc.check_wall([[-x, z] for x, z in RECT], -1, backfill=SOIL, foundation=SOIL) + for k in a["fs"]: + assert a["fs"][k] == pytest.approx(b["fs"][k]) + + +# ── 가정 형상 · 최소 완화 찾기 ──────────────────────────────────────────────── +GROUND = [(-40.0, 120.0), (0.0, 100.0), (40.0, 80.0)] # 1:2 비탈(좌 = +x 낮음) +LINE = [(-3.0, 101.5), (-2.0, 100.0), (2.0, 100.0), (3.0, 99.5)] + + +def test_비탈_다시_그림_해석해(): + """지반 z = 100 − x/2 · 우(−x) 깎기 시작 (−2, 100) · 1:n — u = −x 에서 (u − 2)/n = u/2 → + u = 2 / (1 − n/2) · z = 100 + u/2 · n ≥ 2 = 못 만남. 좌 쌓기 (2, 100) 1:1.5 → (8, 96).""" + for n in (0.5, 1.0, 1.5): + line = whatif.reshape_side(LINE, GROUND, side="right", role="cut", start_x=-2.0, ratio=n) + u = 2.0 / (1.0 - n / 2) + x, z = min(line) + assert x == pytest.approx(-u) and z == pytest.approx(100.0 + u / 2) + assert [p for p in line if p[0] > -2.0 + 1e-9] == [list(p) for p in LINE[2:]] + gentle = dict(side="right", role="cut", start_x=-2.0, ratio=2.5) + assert whatif.reshape_side(LINE, GROUND, **gentle) is None + line = whatif.reshape_side( + LINE, GROUND, side="right", role="cut", start_x=-2.0, ratio=0.5, bench_w=1.0, bench_h=1.0 + ) + corners = [p for p in line if -3.6 < p[0] < -2.4] + assert [[round(x, 6), round(z, 6)] for x, z in corners] == [[-3.5, 101.0], [-2.5, 101.0]] + fill = whatif.reshape_side(LINE, GROUND, side="left", role="fill", start_x=2.0, ratio=1.5) + assert max(fill) == pytest.approx([8.0, 96.0]) + assert [p for p in fill if p[0] < 2.0 - 1e-9] == [list(p) for p in LINE[:2]] + assert ( + whatif.reshape_side(LINE, GROUND, side="left", role="fill", start_x=2.0, ratio=3.0) is None + ) + + +def test_최소_완화_찾기(): + found = whatif.search_ratio(lambda n: n >= 1.23, 0.4) + assert found["found"] == pytest.approx(1.25) + window = whatif.search_ratio(lambda n: 1.0 <= n <= 1.3, 0.4) # 완만할수록 늘 안전하지 않음 + assert 1.0 <= window["found"] <= 1.3 + assert whatif.search_ratio(lambda n: False, 0.4, max_ratio=1.1)["found"] is None + assert whatif.search_ratio(lambda n: True, 0.4) == {"found": 0.4, "tried": [(0.4, True)]} + assert len(whatif.search_ratio(lambda n: n >= 3.9, 0.4)["tried"]) <= 10 + + +# ── 길(검증_sub2 동결 복사) ────────────────────────────────────────────────── +async def _copy(target: Path) -> None: + import aiomysql + + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + connection = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True + ) + try: + await cp.copy_project( + connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target + ) + finally: + connection.close() + + +@pytest.fixture(scope="module") +def frozen(): + folder = Path(tempfile.mkdtemp(prefix="aislo_e03_rf_")) + try: + asyncio.run(_copy(folder / "copy")) + except Exception as error: # noqa: BLE001 + shutil.rmtree(folder, ignore_errors=True) + pytest.skip(f"검증 원본 복사 실패: {error}") + yield folder / "copy" + shutil.rmtree(folder, ignore_errors=True) + + +def _fast(cross, design, **kw): + """진짜 엔진 — Bishop 원호만(빠르게).""" + return analyze_station(cross, design, **{**kw, "methods": ("bishop",), "noncircular": False}) + + +@pytest.fixture(scope="module") +def project(frozen): + """벽 측점 + 깎기 비탈 있는 벽 없는 측점 하나 · 진짜 엔진(빠르게) · [해석] 한 번.""" + mp = pytest.MonkeyPatch() + original = src.stations + + def pick(root): + rows = original(root) + wall = [r for r in rows if chainage_key(r["chainage_m"]) == WALL_KEY] + rest = [r for r in rows if chainage_key(r["chainage_m"]) != WALL_KEY] + return wall + rest[10:11] + + mp.setattr(router.src, "stations", pick) + + async def fake_project(_pid): + return frozen, {"company_id": 1, "project_kind": "STABILITY"} + + mp.setattr(e02, "_project", fake_project) + mp.setattr(worker, "analyze_station", _fast) + mp.setattr(wf, "analyze_station", _fast) + mp.setattr(router, "WORKERS", 1) + router._JOBS.clear() + + async def go(): + await router.analyze2d(UUID(PID)) + while router._TASKS: + await asyncio.gather(*list(router._TASKS)) + + asyncio.run(go()) + yield frozen + mp.undo() + + +def _stamps(root: Path) -> dict[str, bytes]: + out = {} + for kind in (router.KIND_STATION, router.KIND_SUMMARY): + for p in (root / "_derived" / kind).glob("*.json"): + out[str(p)] = p.read_bytes() # 읽기가 고친 때를 건드림(최근 표시) — 내용만 견줌 + return out + + +def _plain_station(root: Path) -> tuple[str, str, float, dict]: + summary, _ = router._latest_summary(root, "") + row = next(r for r in summary["stations"] if r["key"] != WALL_KEY) + side = next(s for s in row["sides"] if s["role"] == "cut")["side"] + return row["key"], side, row["cases"]["dry"][side]["fs"], row + + +def test_지금_기울기_그대로면_원본_안전율(project): + key, side, before, _ = _plain_station(project) + rec = wf.run_whatif(project, key, wf.WhatIfRequest(side=side)) + assert rec["assume"]["ratio"] == rec["assume"]["ratio_before"] + assert close(rec["after"]["dry"]["fs"], before, 0.03) + assert rec["view"]["analyses"] and rec["before"]["dry"]["fs"] == before + + +def test_최소_완화가_목표를_맞춤_원본_불변(project): + key, side, before, _ = _plain_station(project) + stamps = _stamps(project) + target = round(before + 0.02, 3) + rec = wf.run_whatif( + project, + key, + wf.WhatIfRequest(side=side, search=True, condition="dry", target_fs=target), + ) + search = rec["search"] + if search["found"] is None: # 완화가 깎기 높이를 키워 오히려 낮아지는 지형 — 끝값까지 봄 + assert all(not ok for _, ok in search["tried"]) + else: + assert rec["after"]["dry"]["fs"] >= target - 1e-9 + assert rec["assume"]["ratio"] == search["found"] > rec["assume"]["ratio_before"] + steeper = [n for n, ok in search["tried"] if n < search["found"]] + assert all(not ok for n, ok in search["tried"] if n in steeper) + assert _stamps(project) == stamps # 원본 측점 · 요약 저장본 그대로 + again = wf.run_whatif( + project, + key, + wf.WhatIfRequest(side=side, search=True, condition="dry", target_fs=target), + ) + assert again["computed_at"] == rec["computed_at"] # 같은 가정 = 저장본 + + +def test_벽_측점_외적_안정_벽쪽_가정_거절(project): + rec = wf.run_wallcheck(project, WALL_KEY, wf.WallRequest(kh=0.15)) + assert len(rec["walls"]) == 1 + wall = rec["walls"][0] + assert [c["case"] for c in wall["cases"]] == ["normal", "seismic"] + for c in wall["cases"]: + assert c["required"] == wc.REQUIRED[c["case"]] + assert all(v > 0 for v in c["fs"].values()) + assert c["geometry"]["H"] == pytest.approx(1.5, abs=0.05) + with pytest.raises(HTTPException) as error: + wf.run_whatif(project, WALL_KEY, wf.WhatIfRequest(side=wall["side"])) + assert error.value.status_code == 422 + + +def test_E05_보강검토_시트(project, monkeypatch): + from E05_StabReport import E05_StabReport_Build as build + + recs = wf.records(project) + assert {r["type"] for r in recs} == {"slope", "wall"} + book = build.form() + build.build_program(book, build.gather(project)) + s8 = next(s for s in book["시트"] if s["id"] == "s8") + cells = s8["칸"] + slopes = [r for r in recs if r["type"] == "slope"] + rows = sum(len(r["after"]) for r in slopes) + assert all(cells[f"D{6 + i}"]["값"] in ("건기", "우기", "지진") for i in range(rows)) + wall_rows = [a1 for a1, c in cells.items() if a1[0] == "B" and "평상시" in str(c.get("값"))] + assert len(wall_rows) == 1 and cells[f"I{wall_rows[0][1:]}"]["값"] in ("O.K", "N.G") + monkeypatch.setattr(build.reinforce, "read", lambda root: []) + empty = build.form() + build.build_program(empty, build.gather(project)) + assert "s8" not in {s["id"] for s in empty["시트"]} # 없으면 시트 뺌 diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index f51cdf387..3fa49b93b 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -123,4 +123,48 @@ export const ui_locales_e03 = { E03_Graph_Sigma: ["유효 수직응력 σ'", "Effective normal σ'"], E03_Graph_U: ["간극수압 u", "Pore pressure u"], E03_Graph_Tau: ["동원 전단응력 τ", "Mobilized shear τ"], + + /* --- 보강 검토 탭 --- */ + E03_Tab: ["보기", "View"], + E03_Tab_Result: ["해석 결과", "Analysis Result"], + E03_Tab_Reinforce: ["보강 검토", "Remediation Check"], + E03_Rf_Title: ["비탈 완화 · 소단 가정", "Slope Flattening · Bench Assumption"], + E03_Rf_Hint: [ + "형상만 바꿔 다시 해석합니다(가정 검토 · 설계 · 원본 결과는 그대로). 비탈 전체를 한 기울기로 갈음합니다.", + "Re-analyzes with the changed shape only (assumption · design and original results unchanged). The whole slope uses one gradient.", + ], + E03_Rf_Ratio: ["기울기 1:n", "Slope 1:n"], + E03_Rf_RatioNow: ["지금 값", "Current"], + E03_Rf_BenchW: ["소단 폭 m", "Bench width m"], + E03_Rf_BenchH: ["소단 높이 m 마다", "Bench every m"], + E03_Rf_Bench: ["소단", "Bench"], + E03_Rf_Run: ["가정 해석", "Analyze Assumption"], + E03_Rf_Search: ["최소 완화 찾기", "Find Minimum Flattening"], + E03_Rf_Busy: ["계산 중…", "Computing…"], + E03_Rf_Goal: [ + "[최소 완화 찾기] 목표 = 지금 조건({c}) 기준안전율 · 한 번에 수십 초~1분", + "[Find] target = required FS of the current condition ({c}) · takes up to a minute", + ], + E03_Rf_Found: ["기준안전율을 맞추는 가장 급한 기울기", "Steepest gradient meeting required FS"], + E03_Rf_NotFound: [ + "못 찾음 — 원지반을 만나는 가장 완만한 1:{n} 까지 봄", + "Not found — checked up to 1:{n} (gentlest that meets the ground)", + ], + E03_Rf_Before: ["전 Fs", "FS before"], + E03_Rf_After: ["후 Fs", "FS after"], + E03_Rf_VerdictBefore: ["전 판정", "Before"], + E03_Rf_VerdictAfter: ["후 판정", "After"], + E03_Rf_AfterSection: ["가정 단면", "Assumed Section"], + E03_Rf_WallTitle: ["벽 외적 안정 (KDS 11 80 05)", "Wall External Stability (KDS 11 80 05)"], + E03_Rf_Method: ["토압", "Earth pressure"], + E03_Rf_Water: ["배면 수위 ÷ 벽 높이", "Water level ÷ wall height"], + E03_Rf_WallRun: ["외적 안정 검토", "Check Wall"], + E03_Rf_Wall: ["벽", "Wall"], + E03_Rf_Case_normal: ["평상시", "Normal"], + E03_Rf_Case_seismic: ["지진 시", "Seismic"], + E03_Rf_Check: ["검토", "Check"], + E03_Rf_Fs: ["안전율", "FS"], + E03_Rf_sliding: ["활동", "Sliding"], + E03_Rf_overturning: ["전도", "Overturning"], + E03_Rf_bearing: ["지지력", "Bearing"], } as const satisfies Record; From 29ab719ff6a0f33fc6b23ecff4005c060c63aac8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 00:15:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(536-3):=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8B=A4=EB=93=AC=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=80=EC=A0=95=20=EB=92=A4=20=EC=A0=84=20=EA=B7=B8=EB=A6=BC?= =?UTF-8?q?=20=3D=20=EA=B0=80=EC=A0=95=ED=95=9C=20=EB=B9=84=ED=83=88=20?= =?UTF-8?q?=C2=B7=20E05=20=EB=B3=B4=EA=B0=95=EA=B2=80=ED=86=A0=20=EC=97=B4?= =?UTF-8?q?=20=ED=8F=AD=20=C2=B7=20=EA=B0=80=EC=A0=95=20=EA=B8=80=EC=97=90?= =?UTF-8?q?=20=EC=B5=9C=EC=86=8C=20=EC=99=84=ED=99=94=20=EB=AA=A9=ED=91=9C?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_UI_Page.ts | 7 ++++--- E05_StabReport/E05_StabReport_Reinforce.py | 7 ++++++- .../master_template/report/stab_report.json | 18 +++++++++--------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index f60d17cb3..0d0846373 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -378,9 +378,10 @@ export function renderE03StabSection(root: HTMLElement): void { } } const runSlope = (key: string, ask: SlopeAsk): Promise => - reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => - slopeRecs.set(key, rec), - ); + reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => { + slopeRecs.set(key, rec); + side = rec.assume.side; // 전 그림 = 가정한 비탈 + }); const runWall = (key: string, ask: WallAsk): Promise => reinforce(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) => wallRecs.set(key, rec), diff --git a/E05_StabReport/E05_StabReport_Reinforce.py b/E05_StabReport/E05_StabReport_Reinforce.py index 93f58008d..bd41246a7 100644 --- a/E05_StabReport/E05_StabReport_Reinforce.py +++ b/E05_StabReport/E05_StabReport_Reinforce.py @@ -58,7 +58,12 @@ def _assume_text(rec: dict[str, Any]) -> str: text += f" · 소단 폭 {a['bench_w']:g} m / 높이 {a['bench_h']:g} m 마다" search = rec.get("search") if search: - text += " · 최소 완화" if search.get("found") else " · 최소 완화 못 찾음(끝값)" + goal = CONDITION_NAMES.get(search.get("condition"), "전 조건") + text += ( + f" · 최소 완화({goal} 목표)" + if search.get("found") + else f" · 최소 완화 못 찾음({goal} · 끝값)" + ) return text diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 78c2f3835..585ab1a29 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -3359,31 +3359,31 @@ ], "열": { "A": { - "폭": 11 + "폭": 15 }, "B": { - "폭": 14 + "폭": 28 }, "C": { - "폭": 20 + "폭": 30 }, "D": { - "폭": 8 + "폭": 7 }, "E": { - "폭": 9 + "폭": 8 }, "F": { - "폭": 9 + "폭": 8 }, "G": { - "폭": 9 + "폭": 8 }, "H": { - "폭": 9 + "폭": 8 }, "I": { - "폭": 9 + "폭": 8 } }, "행": {