feat(533-1 · 533-2): E04 3D 기둥 솔버 · 구 · 타원체 탐색 · 합성 DEM · 지층 격자

- Column: Hovland · Hungr 3D Bishop · Hungr 3D Janbu 간편 + 보정(f0) · 활동 방향 자동(지표 낙하) · r_u · 지하수위면 · kh · 노면 하중 · 못 뚫는 층에서 잘림 · 부분 기둥
- Search: Scoops3D 방식 구 격자(노드 = 지표 + h · 반지름 증분 · 부피/면적 제약 · 거친→고운) + 타원체(축 방향 ratio 배) · 덩이 나눔 · 셀별 최소 F · 임계 체적 래스터
- 잘린 면 ≥ 10 % → Janbu 보정 전환 · Bishop 이 낮으면 Bishop
- Grid: 측점 사이 선직면 보간 δ(설계 − 원지형) · 띠 밖 원지형 · 흙 두께(암 경계 · 토사 3 m) · 리핑암 뚫림 · 발파암 못 뚫음 · 노면 하중 · 1 m 정사각 재표본
- 공용 꼴 = E03_StabSection_Types import(칸 이름 그대로) · 새 의존 없음(NumPy · SciPy)
- 시험: Gens 구 1.402 · Zhang 1988 경우 1 · 2(Hungr 2.167 · 1.620) · Scoops3D 제방 1.32 · D&G 1a 1.04 · 합성 DEM 단면 · 띠 밖 · 지층

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:13:33 +09:00
co-authored by Claude Opus 5.5
parent 4730edba31
commit b97ddf4783
6 changed files with 1340 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
"""E04 3D 기둥 솔버 — Hovland · Hungr 3D Bishop 간편 · Hungr 3D Janbu 간편 + 보정.
근거(수식만 · 코드는 자체):
- Reid 외(2015) USGS TM 14-A1 Scoops3D 2.1~2.5 — 기둥 기하 · 모멘트 평형 · 간극수압 · 특수 경우.
- Hungr(1987) Géotechnique 37(1) · Hungr·Salgado·Byrne(1989) CGJ 26(4) — 3D Bishop · 3D Janbu.
- Hovland(1977) ASCE GT9 — 기둥 사이 힘 0 · N = W cosψ.
- Janbu(1973) 보정 계수 f0 = 1 + b1 (d/L − 1.4 (d/L)²).
- 지식DB `original/안정성평가/05_3D_사면안정해석_논문_기술정보.md` 1장.
꼴: 기둥은 납작한 배열 + `group`(활동체 번호) — 여러 활동체를 한 번에 셈(np.bincount).
좌표: 활동 방향 d(수평 단위) · 회전축 = 중심을 지나는 수평선 ⊥ d.
모멘트(내리미는 쪽 +) — 자중 −p·W · 지진 e·kh·W · 수직력 N·m_n · 저항 전단 −S·arm_s.
p = (P − C)·d · e = 축 높이 − 기둥 무게중심 높이 · g = 바닥면의 d 방향 기울기
arm_s = (p·g − z_b')/√(1+g²) · m_n = cosψ (p + z_b'·g)
구 · 원형 단면 타원체는 수직력이 축을 지나 m_n = 0 → Scoops3D 식 2.22 그대로.
Bishop 수직력(식 2.31): N = (W+Q − (cA − uA tanφ) sinα / F) / mα · mα = cosψ + sinα tanφ / F
→ 저항 S_res = [c A_h + (W+Q − u A_h) tanφ] / mα (식 2.33 · A_h = 수평 면적).
Janbu 힘 평형(d 방향): F0 = Σ S_res / cosα ÷ Σ((W+Q) tanα + kh W) · 보정 F = f0 F0.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from scipy import ndimage
from E03_StabSection.E03_StabSection_Types import GAMMA_W, Groundwater, Material, groundwater
MA_MIN = 0.2 # mα 하한 경고(Duncan & Wright) — 밑이면 결과에 표시
F_CAP = 100.0 # Scoops3D 2.5 — 너무 큰 F 는 100
TOL = 1e-4
MAX_ITER = 50
@dataclass
class Strata:
"""지층 격자 — 셀 중심 축 x(nx) · y(ny) · 지표 z_top(ny, nx) · 층 바닥(K−1, ny, nx).
층 k 는 위(층 k−1 바닥 · 0 층은 지표)부터 bottoms[k] 까지 · 맨 아래 층은 끝없음.
hard[k] = 활동면이 못 뚫는 층(암반) — 그 윗면에서 잘림(Search 머리 주석 규칙).
z_water = 지하수위면(mode "line" 의 3D 꼴) · q = 상재하중(kPa) 격자.
"""
x: np.ndarray
y: np.ndarray
z_top: np.ndarray
bottoms: np.ndarray
materials: list[Material]
hard: list[bool] = field(default_factory=list)
z_water: np.ndarray | None = None
q: np.ndarray | None = None
def __post_init__(self) -> None:
self.x = np.asarray(self.x, float)
self.y = np.asarray(self.y, float)
self.z_top = np.asarray(self.z_top, float)
self.bottoms = np.asarray(self.bottoms, float).reshape(-1, *self.z_top.shape)
if len(self.materials) != len(self.bottoms) + 1:
raise ValueError("물성 수 = 층 바닥 수 + 1 이어야 함")
self.hard = list(self.hard) or [False] * len(self.materials)
self.dx = float(self.x[1] - self.x[0])
if abs(float(self.y[1] - self.y[0]) - self.dx) > 1e-4 * self.dx:
raise ValueError("격자 간격은 x · y 같아야 함")
# 층 바닥은 지표 아래 · 위에서 아래로 내려감
tops = np.concatenate([self.z_top[None], self.bottoms])
self.bottoms = np.minimum.accumulate(tops, axis=0)[1:]
self.gamma_t = np.array([m["gamma_t"] for m in self.materials])
self.gamma_sat = np.array([m["gamma_sat"] for m in self.materials])
self.c = np.array([m["c"] for m in self.materials])
self.tan_phi = np.tan(np.radians([m["phi"] for m in self.materials]))
# 못 뚫는 층 윗면(없으면 −inf) · 그 기울기
tops = np.concatenate([self.z_top[None], self.bottoms])
first = next((k for k, h in enumerate(self.hard) if h), None)
hard_top = np.full(self.z_top.shape, -np.inf) if first is None else tops[first].copy()
self.hard_top = hard_top
finite = np.where(np.isfinite(hard_top), hard_top, 0.0)
self.hard_gy, self.hard_gx = np.gradient(finite, self.dx)
self.gy, self.gx = np.gradient(self.z_top, self.dx)
# 가장자리 · 빈칸 이웃 — 여기 닿은 활동체는 질량이 잘려 버림(Search)
edge = ndimage.binary_dilation(~np.isfinite(self.z_top))
edge[[0, -1], :] = True
edge[:, [0, -1]] = True
self.edge = edge
self._z_pad = np.pad(self.z_top, 1, mode="edge")
self._shifted: dict = {}
def ground_at(self, iy: np.ndarray, ix: np.ndarray, fy: float, fx: float) -> np.ndarray:
"""셀 (iy, ix) 중심에서 (fy, fx) 칸만큼 옮긴 점의 지표(쌍선형 · |f| ≤ 0.5)."""
if fx == 0 and fy == 0:
return self.z_top[iy, ix]
key = (fy, fx)
if key not in self._shifted: # 옮긴 점 지표 격자를 한 번 만들어 둠
sx, sy = int(np.sign(fx)), int(np.sign(fy))
ax, ay = abs(fx), abs(fy)
zp = self._z_pad
ny, nx = self.z_top.shape
z00 = zp[1 : ny + 1, 1 : nx + 1]
z01 = zp[1 : ny + 1, 1 + sx : nx + 1 + sx]
z10 = zp[1 + sy : ny + 1 + sy, 1 : nx + 1]
z11 = zp[1 + sy : ny + 1 + sy, 1 + sx : nx + 1 + sx]
self._shifted[key] = (1 - ay) * ((1 - ax) * z00 + ax * z01) + ay * (
(1 - ax) * z10 + ax * z11
)
return self._shifted[key][iy, ix]
@dataclass
class Columns:
"""활동체 묶음의 기둥 — 납작한 배열(길이 n) · group 은 0..G−1."""
iy: np.ndarray
ix: np.ndarray
group: np.ndarray
z_slip: np.ndarray # 시험 활동면 높이(잘리기 전)
gx: np.ndarray # 활동면 기울기 ∂z/∂x
gy: np.ndarray # ∂z/∂y
center: np.ndarray # (G, 3) 회전축이 지나는 점
direction: np.ndarray | None = None # (G, 2) 활동 방향 · None 이면 지표 낙하 방향(Scoops3D)
# 부분 기둥(셀 안 표본) — 대표 위치 · 걸친 비율 · 걸친 부분 평균 깊이
# None = 셀 중심 · 한 칸 · 지표 − 바닥
px: np.ndarray | None = None
py: np.ndarray | None = None
frac: np.ndarray | None = None
depth: np.ndarray | None = None
def _gsum(group: np.ndarray, values: np.ndarray, n: int) -> np.ndarray:
return np.bincount(group, weights=values, minlength=n)
def fall_direction(strata: Strata, cols: Columns, n: int) -> np.ndarray:
"""활동체 지표의 평균 낙하 방향(Scoops3D 2.1) — (n, 2) 단위 벡터."""
sx = _gsum(cols.group, -strata.gx[cols.iy, cols.ix], n)
sy = _gsum(cols.group, -strata.gy[cols.iy, cols.ix], n)
norm = np.hypot(sx, sy)
norm[norm == 0] = 1.0
return np.column_stack([sx / norm, sy / norm])
def _column_state(strata: Strata, cols: Columns, gw: Groundwater, kh: float) -> dict:
"""기둥마다 자중 · 바닥 물성 · 간극수압 · 기하 — 활동면을 못 뚫는 층에서 자름."""
iy, ix, grp = cols.iy, cols.ix, cols.group
hard_top = strata.hard_top[iy, ix]
truncated = cols.z_slip < hard_top
z_b = np.where(truncated, hard_top, cols.z_slip)
gx = np.where(truncated, strata.hard_gx[iy, ix], cols.gx)
gy = np.where(truncated, strata.hard_gy[iy, ix], cols.gy)
z_top = strata.z_top[iy, ix] if cols.depth is None else z_b + cols.depth
depth = np.maximum(z_top - z_b, 0.0)
x = strata.x[ix] if cols.px is None else cols.px
y = strata.y[iy] if cols.py is None else cols.py
# 층별 두께 → 자중(지하수위 아래는 γsat) · 부분 기둥은 걸친 비율만큼 면적
a_h = strata.dx**2 * (np.ones(len(iy)) if cols.frac is None else cols.frac)
k_n = len(strata.materials)
tops = np.concatenate([z_top[None], strata.bottoms[:, iy, ix]])
bots = np.concatenate([strata.bottoms[:, iy, ix], np.full((1, len(iy)), -np.inf)])
upper = np.minimum(tops, z_top)
lower = np.maximum(bots, z_b)
thick = np.clip(upper - lower, 0.0, None)
if gw.get("mode") == "line" and strata.z_water is not None:
zw = strata.z_water[iy, ix]
wet = np.clip(np.minimum(upper, zw) - lower, 0.0, None)
weight = a_h * (strata.gamma_t[:, None] * (thick - wet) + strata.gamma_sat[:, None] * wet)
else:
weight = a_h * strata.gamma_t[:, None] * thick
w = weight.sum(0)
# 바닥이 놓인 층 — 경계 위면 위층(잘린 면 = 암반 위 흙의 강도)
layer = (strata.bottoms[:, iy, ix] >= z_b + 1e-9).sum(0) if k_n > 1 else np.zeros(len(iy), int)
c = strata.c[layer]
tan_phi = strata.tan_phi[layer]
q = strata.q[iy, ix] * a_h if strata.q is not None else np.zeros_like(w)
mode = gw.get("mode", "none")
if mode == "ru":
u_ah = gw["ru"] * w # Scoops3D 식 2.37 — u A_h = ru W
elif mode == "line" and strata.z_water is not None:
u_ah = GAMMA_W * np.maximum(strata.z_water[iy, ix] - z_b, 0.0) * a_h
else:
u_ah = np.zeros_like(w)
n_g = len(cols.center)
d = cols.direction if cols.direction is not None else fall_direction(strata, cols, n_g)
dxg, dyg = d[grp, 0], d[grp, 1]
xc, yc, zc = cols.center[grp, 0], cols.center[grp, 1], cols.center[grp, 2]
p = (x - xc) * dxg + (y - yc) * dyg
g = gx * dxg + gy * dyg
sec_g = np.sqrt(1.0 + g * g)
cos_psi = 1.0 / np.sqrt(1.0 + gx * gx + gy * gy)
zr = z_b - zc
return {
"w": w,
"q": q,
"c": c,
"tan_phi": tan_phi,
"u_ah": u_ah,
"a_h": a_h,
"cos_psi": cos_psi,
"sin_a": -g / sec_g,
"cos_a": 1.0 / sec_g,
"tan_a": -g,
"p": p,
"arm_s": (p * g - zr) / sec_g,
"m_n": cos_psi * (p + zr * g),
"e": zc - 0.5 * (z_top + z_b),
"kh": kh,
"depth": depth,
"z_b": z_b,
"z_top": z_top,
"truncated": truncated,
"direction": d,
"group": grp,
"n": n_g,
"layer": layer,
}
def _s_res(st: dict, f: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Bishop 수직력에서 바닥 저항력 S_res(F) · mα (Scoops3D 식 2.33 · 2.43)."""
fg = np.maximum(f[st["group"]], 1e-3)
m_a = st["cos_psi"] + st["sin_a"] * st["tan_phi"] / fg
fric = np.maximum(st["w"] + st["q"] - st["u_ah"], 0.0) * st["tan_phi"]
return (st["c"] * st["a_h"] + fric) / m_a, m_a
def _hovland(st: dict) -> np.ndarray:
"""N = (W+Q) cosψ · 기둥 사이 힘 0 — 반복 없음(Hovland 1977 · 지식DB 05 식 ①)."""
grp, n = st["group"], st["n"]
a = st["a_h"] / st["cos_psi"]
wq = st["w"] + st["q"]
nn = wq * st["cos_psi"]
fric = np.maximum(nn - st["u_ah"] / st["cos_psi"], 0.0) * st["tan_phi"]
res = _gsum(grp, st["arm_s"] * (st["c"] * a + fric), n)
drive = _gsum(grp, -st["p"] * wq + st["e"] * st["kh"] * st["w"] + nn * st["m_n"], n)
return _finish(res, drive)
def _finish(res: np.ndarray, drive: np.ndarray) -> np.ndarray:
out = np.full(res.shape, F_CAP)
ok = drive > 0
out[ok] = np.minimum(np.maximum(res[ok], 0.0) / drive[ok], F_CAP)
return out
def _bishop(st: dict, f0: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
grp, n = st["group"], st["n"]
wq = st["w"] + st["q"]
# 거꾸로 선 바닥(α<0)이 있으면 처음 값을 올림 — Scoops3D 식 2.34 · 2.35
neg = np.where(st["sin_a"] < 0, -st["sin_a"] * st["tan_phi"] / st["cos_psi"], 0.0)
lift = np.zeros(n)
np.maximum.at(lift, grp, neg)
f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0) + lift
conv = np.zeros(n, bool)
for _ in range(MAX_ITER):
s_res, _ = _s_res(st, f)
s = s_res / np.maximum(f[grp], 1e-3)
nn = (wq - s * st["sin_a"]) / st["cos_psi"] # 연직 평형에서 N
res = _gsum(grp, st["arm_s"] * s_res, n)
drive = _gsum(grp, -st["p"] * wq + st["e"] * st["kh"] * st["w"] + nn * st["m_n"], n)
f_new = _finish(res, drive)
done = np.abs(f_new - f) < TOL
f = np.where(conv, f, f_new)
conv |= done
if conv.all():
break
_, m_a = _s_res(st, f)
ma_min = _gmin(grp, m_a, n)
# 못 쓰는 면 — 수렴 안 함 · mα ≤ 0(바닥 수직력이 음) · F ≤ 0 → nan(후보에서 빠짐)
f[~conv | (ma_min <= 0) | (f <= 0)] = np.nan
return f, ma_min
def _gmin(grp: np.ndarray, values: np.ndarray, n: int) -> np.ndarray:
out = np.full(n, np.inf)
np.minimum.at(out, grp, np.nan_to_num(values, nan=-np.inf))
return out
def _janbu(st: dict, f0: np.ndarray) -> np.ndarray:
grp, n = st["group"], st["n"]
drive = _gsum(grp, (st["w"] + st["q"]) * st["tan_a"] + st["kh"] * st["w"], n)
f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0)
conv = np.zeros(n, bool)
for _ in range(MAX_ITER):
s_res, _ = _s_res(st, f)
f_new = _finish(_gsum(grp, s_res / st["cos_a"], n), drive)
done = np.abs(f_new - f) < TOL
f = np.where(conv, f, f_new)
conv |= done
if conv.all():
break
f[~conv | (f <= 0)] = np.nan
return f
def janbu_f0(st: dict) -> np.ndarray:
"""Janbu(1973) 보정 계수 — d/L 은 활동 방향 단면(현 = 윗끝 · 아랫끝 지표를 잇는 선)에서.
b1 = 0.69(c=0) · 0.31(φ=0) · 0.50(c−φ). L = 활동 방향 길이 · d = 바닥에서 현까지 가장 먼 거리.
"""
grp, n = st["group"], st["n"]
s = st["p"] # 축 기준 d 방향 위치(같은 묶음 안 비교만 씀)
lo = np.full(n, np.inf)
hi = np.full(n, -np.inf)
np.minimum.at(lo, grp, s)
np.maximum.at(hi, grp, s)
# 양 끝 지표 높이 — 끝 기둥들 가운데 가장 얕은 바닥 위 지표
at_lo = s <= lo[grp] + 1e-9
at_hi = s >= hi[grp] - 1e-9
z_lo = np.full(n, -np.inf)
z_hi = np.full(n, -np.inf)
np.maximum.at(z_lo, grp[at_lo], st["z_top"][at_lo])
np.maximum.at(z_hi, grp[at_hi], st["z_top"][at_hi])
length = np.maximum(hi - lo, 1e-9)
# 현: (lo, z_lo) → (hi, z_hi) · 바닥점과의 수직 거리
dz = z_hi - z_lo
norm = np.hypot(length, dz)
dist = np.abs((s - lo[grp]) * dz[grp] - (st["z_b"] - z_lo[grp]) * length[grp]) / norm[grp]
depth = np.zeros(n)
np.maximum.at(depth, grp, dist)
ratio = depth / norm
has_c = _gsum(grp, (st["c"] > 0).astype(float), n) > 0
has_phi = _gsum(grp, (st["tan_phi"] > 0).astype(float), n) > 0
b1 = np.where(has_c & has_phi, 0.50, np.where(has_phi, 0.69, 0.31))
return 1.0 + b1 * (ratio - 1.4 * ratio**2)
def solve(
strata: Strata,
cols: Columns,
groundwater_raw: dict | None = None,
seismic_kh: float = 0.0,
) -> dict:
"""활동체마다 안전율 — hovland · bishop · janbu(간편) · janbu_corrected · 부피 · 잘림 비율.
group 은 0..G−1 · 활동면이 지표 아래인 기둥만 셈(밖은 버림).
"""
raw = groundwater_raw or {}
# 3D 의 "line" = 지하수위면 격자(Strata.z_water) — 2D 선(line 칸)은 없어도 됨
gw = {"mode": "line"} if raw.get("mode") == "line" and "line" not in raw else groundwater(raw)
if gw["mode"] == "line" and strata.z_water is None:
raise ValueError("지하수 mode line — Strata.z_water(지하수위면 격자)가 없음")
st = _column_state(strata, cols, gw, float(seismic_kh))
keep = st["depth"] > 0
n = st["n"]
st = {
k: (v[keep] if isinstance(v, np.ndarray) and v.shape == keep.shape else v)
for k, v in st.items()
}
grp = st["group"]
count = np.bincount(grp, minlength=n)
f_hov = _hovland(st)
f_bis, ma_min = _bishop(st, f_hov)
f_jan = _janbu(st, f_bis)
f0 = janbu_f0(st)
area = _gsum(grp, st["a_h"], n)
trunc_area = _gsum(grp, st["truncated"] * st["a_h"], n)
empty = count == 0
out = {
"hovland": f_hov,
"bishop": f_bis,
"janbu": f_jan,
"janbu_corrected": f_jan * f0,
"janbu_f0": f0,
"volume": _gsum(grp, st["depth"] * st["a_h"], n),
"weight": _gsum(grp, st["w"], n),
"area": area,
"truncated_ratio": np.divide(trunc_area, area, out=np.zeros(n), where=area > 0),
"ma_min": ma_min,
"columns": count,
"direction": st["direction"],
}
for key in ("hovland", "bishop", "janbu", "janbu_corrected"):
out[key] = np.where(empty, np.nan, out[key])
return out