feat(536-3): E 보강 검토 앞부분 — 벽 외적 안정(활동 · 전도 · 지지력 · KDS 11 80 05) · 비탈 완화 · 소단 가정 재해석 · 최소 완화 찾기 · E03 「보강 검토」 탭 · E05 보강검토 시트(있을 때만)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)}
|
||||
@@ -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<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
|
||||
const wallRecs = new Map<string, WallRecord>();
|
||||
let repCache: Record<string, boolean | null> = 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<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
done: (value: T) => void,
|
||||
): Promise<void> {
|
||||
busy = true;
|
||||
paintSection();
|
||||
try {
|
||||
done(await api<T>(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<void> =>
|
||||
reinforce<WhatIfRecord>(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) =>
|
||||
slopeRecs.set(key, rec),
|
||||
);
|
||||
const runWall = (key: string, ask: WallAsk): Promise<void> =>
|
||||
reinforce<WallRecord>(`${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"),
|
||||
|
||||
@@ -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<Record<Condition, SideCase>>;
|
||||
after: Partial<Record<Condition, SideCase>>;
|
||||
elapsed_s: number;
|
||||
view: SectionView;
|
||||
}
|
||||
|
||||
type Check = "sliding" | "overturning" | "bearing";
|
||||
interface WallCase {
|
||||
case: "normal" | "seismic";
|
||||
fs: Record<Check, number>;
|
||||
required: Record<Check, number>;
|
||||
ok: Record<Check, boolean>;
|
||||
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;
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user