Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-26 00:16:08 +09:00
14 changed files with 2044 additions and 9 deletions
@@ -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)}
+73 -1
View File
@@ -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,33 @@ 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);
side = rec.assume.side; // 전 그림 = 가정한 비탈
});
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},
}
+199
View File
@@ -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}
+10 -2
View File
@@ -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
+140
View File
@@ -0,0 +1,140 @@
"""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:
goal = CONDITION_NAMES.get(search.get("condition"), "전 조건")
text += (
f" · 최소 완화({goal} 목표)"
if search.get("found")
else f" · 최소 완화 못 찾음({goal} · 끝값)"
)
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}", "벽 검토 없음")
+9 -2
View File
@@ -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)
+2
View File
@@ -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": {
"폭": 15
},
"B": {
"폭": 28
},
"C": {
"폭": 30
},
"D": {
"폭": 7
},
"E": {
"폭": 8
},
"F": {
"폭": 8
},
"G": {
"폭": 8
},
"H": {
"폭": 8
},
"I": {
"폭": 8
}
},
"행": {
"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["시트"]} # 없으면 시트 뺌
+44
View File
@@ -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>;