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}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}", "벽 검토 없음")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나.
|
||||
|
||||
@@ -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("<img ") == 3 # 단면 2 + 3D 띠 1
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""536-3 E03 보강 검토 — 벽 외적 안정 · 비탈 완화 · 소단 가정 재해석 · E05 보강검토 시트.
|
||||
|
||||
- 벽 외적 안정(순수): 교과서 계수표(Coulomb · Rankine · Meyerhof) · M-O 닫힌 꼴 · 손계산 예제
|
||||
(직사각 벽 · 상재하중 · 수압 · 뒷굽 위 흙) ±2 %
|
||||
- 가정 형상(순수): 1:n 비탈이 원지반과 만나는 점 = 해석해 · 소단 꼭짓점 · 최소 완화 찾기(단조 · 창)
|
||||
- 길(검증_sub2 동결 복사 · 벽 측점 + 하나): 지금 기울기 그대로 = 원본 안전율(±3 %) · 최소 완화가
|
||||
목표 안전율을 맞춤 · 원본 측점 · 요약 저장본 그대로 · 벽 측점 외적 안정 · 벽 쪽 비탈 가정 거절 ·
|
||||
E05 보강검토 시트(있을 때만)
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_536_3_e03_reinforce.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from common_util import common_util_project_copy as cp # noqa: E402
|
||||
from common_util.common_util_station_key import chainage_key # noqa: E402
|
||||
from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402
|
||||
from E02_StabGround import E02_StabGround_Router as e02 # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_Router as router # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_Router_WhatIf as wf # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_WallCheck as wc # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_WhatIf as whatif # noqa: E402
|
||||
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
|
||||
|
||||
SOURCE_ID = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(읽기만)
|
||||
PID = "e" * 8 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 12
|
||||
WALL_KEY = "351.00" # 검증_sub2 기슭막이 측점
|
||||
SOIL = {"gamma_t": 18.0, "gamma_sat": 19.0, "c": 0.0, "phi": 30.0}
|
||||
|
||||
|
||||
def close(a: float, b: float, tol: float = 0.02) -> 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["시트"]} # 없으면 시트 뺌
|
||||
@@ -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<string, LocaleEntry>;
|
||||
|
||||
Reference in New Issue
Block a user