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
+273
View File
@@ -0,0 +1,273 @@
"""E04 3D 입력 격자 — 합성 DEM · 지층 경계 격자 · 노면 하중 격자.
합성 DEM(원지형 + B06 깎기 · 쌓기):
- 측점 i · i+1 횡단선을 t 로 잇는 선직면에 셀을 앉힘
— P = C(t) + o·L(t) 를 t 의 2차식으로 풂 · 측점 위(t=0 · 1)는 횡단선 그대로.
- δ(o) = 설계선 z − 원지형 z(같은 offset) · 측점 사이는 t 선형 · 합성 = 원지형 + δ.
→ 측점 위 합성 단면 = B06 설계선 · δ=0 인 곳과 띠 밖은 원지형 그대로.
- 띠 = 두 측점 설계선 offset 범위의 합(B06 ±20 m · 사면이 안 닫히면 더 넓음)
· 한쪽 설계선 밖은 δ=0 으로 이음 · 두 구간에 걸린 셀은 |o| 작은 쪽.
지층 경계(정함 · 보고):
- 흙 두께 T = 암 측점 |암 경계 offset|(B06 평행선) · 토사 측점 = 기본 3 m
(국내 산지 풍화토 · 붕적토 1~3 m 의 큰 쪽).
- 띠 안 = 두 측점 T 를 t 선형 · 띠 밖 · 노선 끝 밖 = 가장 가까운 측점 값.
- 암 윗면 = 원지형 − T(깎기로 드러나면 지표) · 암 종류 = 가까운 측점 라벨
(토사 측점 아래 = 리핑암).
- 층(위→아래) = 성토 · 풍화토 · 리핑암(풍화암 · 뚫림) · 발파암(연암 · 못 뚫음).
노면 하중 = 두 측점 노면 끝(road_edges) 사이(t 보간) 띠 안 셀에 q(kPa).
"""
from __future__ import annotations
import numpy as np
from scipy.interpolate import RegularGridInterpolator
from scipy.spatial import cKDTree
from E03_StabSection.E03_StabSection_Types import DEFAULT_MATERIALS, DEFAULT_ROAD_SURCHARGE_KPA
from E04_Stab3D.E04_Stab3D_Engine_Column import Strata
DEFAULT_SOIL_THICKNESS_M = 3.0
ROCK_KINDS = ("ripping_rock", "blasting_rock")
#: 층 → 물성 id(E03 기본표) · 못 뚫는 층
LAYER_MATERIALS = ("fill", "weathered_soil", "weathered_rock", "soft_rock")
LAYER_HARD = (False, False, False, True)
def station_from_b06(cross: dict, design: dict) -> dict:
"""B06 횡단 파일(`cross_NNNNNm.json`) + 설계(`cross_sections.data.design`) → 측점 한 줄."""
ground = [[s["offset_m"], s["z"]] for s in cross["samples"] if s.get("valid", True)]
edges = design.get("road_edges") or {}
rb = design.get("rock_boundary_offset_m")
return {
"chainage_m": float(cross["chainage_m"]),
"center": [float(cross["center_x"]), float(cross["center_y"])],
"left": [float(v) for v in cross["frame"]["left_xy"]],
"ground": ground,
"design": [[p["offset_m"], p["elevation_m"]] for p in design["design_line"]],
"ground_type": design.get("ground_type", "soil"),
"rock_depth_m": abs(float(rb))
if rb is not None and design.get("ground_type") in ROCK_KINDS
else None,
"road": sorted(float(e["offset_m"]) for e in edges.values()) if len(edges) == 2 else None,
}
def _delta(
st: dict, center: np.ndarray, left: np.ndarray, ground_at
) -> tuple[np.ndarray, np.ndarray]:
"""설계선 offset 위 δ = 설계 − 원지형(쌍선형) — B06 이 반폭 밖으로 늘린 설계선도 받음."""
d = np.asarray(st["design"], float)
d = d[np.argsort(d[:, 0], kind="stable")]
pts = center[None] + d[:, :1] * left[None]
zg = ground_at(pts)
delta = d[:, 1] - zg
ok = np.isfinite(delta)
return d[ok, 0], delta[ok]
def _segment_map(xx, yy, c0, c1, l0, l1):
"""선직면 P = C0 + tD + o(L0 + tM) 에서 (t, o[m]) — t∈[0,1] 아닌 곳은 nan."""
d = c1 - c0
m = l1 - l0
qx, qy = xx - c0[0], yy - c0[1]
def cross(ax, ay, bx, by):
return ax * by - ay * bx
a = -cross(d[0], d[1], m[0], m[1])
b = cross(qx, qy, m[0], m[1]) - cross(d[0], d[1], l0[0], l0[1])
c = cross(qx, qy, l0[0], l0[1])
if abs(a) < 1e-12:
t = np.where(np.abs(b) > 1e-12, -c / np.where(b == 0, 1, b), np.nan)
else:
disc = np.sqrt(np.maximum(b * b - 4 * a * c, 0.0))
t1 = (-b + disc) / (2 * a)
t2 = (-b - disc) / (2 * a)
ok1 = (t1 >= -1e-9) & (t1 <= 1 + 1e-9)
t = np.where(ok1, t1, t2)
t = np.where((t >= -1e-9) & (t <= 1 + 1e-9), np.clip(t, 0, 1), np.nan)
lx, ly = l0[0] + t * m[0], l0[1] + t * m[1]
norm = np.hypot(lx, ly)
o = ((qx - t * d[0]) * lx + (qy - t * d[1]) * ly) / norm
return t, o
def build_grid(
dem: dict,
stations: list[dict],
*,
bbox: tuple[float, float, float, float] | None = None,
default_soil_thickness_m: float = DEFAULT_SOIL_THICKNESS_M,
road_q_kpa: float = DEFAULT_ROAD_SURCHARGE_KPA,
cell_m: float | None = 1.0,
) -> dict:
"""원지형 DEM(x · y · z · valid_mask) + 측점 → 합성 · 지층 격자. bbox=(x0, x1, y0, y1) 로 자름.
cell_m — 정사각 격자로 다시 뜸(쌍선형 · B04 축은 float32 라 간격이 흔들림)
· None = DEM 축 그대로.
"""
x = np.asarray(dem["x"], float)
y = np.asarray(dem["y"], float)
z = np.asarray(dem["z"], float)
valid = np.asarray(dem.get("valid_mask", np.isfinite(z)), bool)
if x[0] > x[-1]:
x, z, valid = x[::-1], z[:, ::-1], valid[:, ::-1]
if y[0] > y[-1]:
y, z, valid = y[::-1], z[::-1], valid[::-1]
z_orig = np.where(valid, z, np.nan)
if cell_m:
x0, x1, y0, y1 = bbox or (x[0], x[-1], y[0], y[-1])
x0, y0 = max(x0, x[0]), max(y0, y[0])
xs = x0 + np.arange(int((min(x1, x[-1]) - x0) // cell_m) + 1) * cell_m
ys = y0 + np.arange(int((min(y1, y[-1]) - y0) // cell_m) + 1) * cell_m
f = RegularGridInterpolator((y, x), z_orig, bounds_error=False, fill_value=np.nan)
gy_, gx_ = np.meshgrid(ys, xs, indexing="ij")
z_orig = f(np.column_stack([gy_.ravel(), gx_.ravel()])).reshape(gy_.shape)
x, y = xs, ys
elif bbox is not None:
sx = (x >= bbox[0]) & (x <= bbox[1])
sy = (y >= bbox[2]) & (y <= bbox[3])
x, y, z_orig = x[sx], y[sy], z_orig[np.ix_(sy, sx)]
z = z_orig
xx, yy = np.meshgrid(x, y)
sts = sorted(stations, key=lambda s: s["chainage_m"])
centers = np.array([s["center"] for s in sts], float)
lefts = np.array([s["left"] for s in sts], float)
lefts /= np.hypot(lefts[:, 0], lefts[:, 1])[:, None]
interp = RegularGridInterpolator((y, x), z_orig, bounds_error=False, fill_value=np.nan)
def ground_at(pts: np.ndarray) -> np.ndarray:
return interp(np.column_stack([pts[:, 1], pts[:, 0]]))
profiles = [_delta(s, centers[i], lefts[i], ground_at) for i, s in enumerate(sts)]
thick = np.array(
[
s["rock_depth_m"] if s.get("rock_depth_m") is not None else default_soil_thickness_m
for s in sts
]
)
kind = np.array([1 if s.get("ground_type") == "blasting_rock" else 0 for s in sts])
seg = np.full(z.shape, -1)
t_map = np.full(z.shape, np.nan)
o_map = np.full(z.shape, np.nan)
dz = np.zeros(z.shape)
road = np.zeros(z.shape, bool)
for i in range(len(sts) - 1):
o0, d0 = profiles[i]
o1, d1 = profiles[i + 1]
lo, hi = min(o0[0], o1[0]), max(o0[-1], o1[-1])
reach = max(abs(lo), abs(hi))
corners = np.array(
[centers[i] + s * reach * lefts[i] for s in (-1, 1)]
+ [centers[i + 1] + s * reach * lefts[i + 1] for s in (-1, 1)]
)
jx = np.flatnonzero((x >= corners[:, 0].min() - 1) & (x <= corners[:, 0].max() + 1))
jy = np.flatnonzero((y >= corners[:, 1].min() - 1) & (y <= corners[:, 1].max() + 1))
if not len(jx) or not len(jy):
continue
sub = np.ix_(jy, jx)
t, o = _segment_map(xx[sub], yy[sub], centers[i], centers[i + 1], lefts[i], lefts[i + 1])
inside = np.isfinite(t) & (o >= lo) & (o <= hi)
better = inside & (
~np.isfinite(o_map[sub]) | (np.abs(o) < np.abs(np.nan_to_num(o_map[sub], nan=np.inf)))
)
if not better.any():
continue
tb, ob = t[better], o[better]
val = (1 - tb) * np.interp(ob, o0, d0, 0.0, 0.0) + tb * np.interp(ob, o1, d1, 0.0, 0.0)
for arr, v in ((seg, i), (t_map, tb), (o_map, ob), (dz, val)):
blk = arr[sub]
blk[better] = v
arr[sub] = blk
r0, r1 = sts[i].get("road"), sts[i + 1].get("road")
if r0 and r1:
a = (1 - tb) * r0[0] + tb * r1[0]
b = (1 - tb) * r0[1] + tb * r1[1]
blk = road[sub]
blk[better] = (ob >= a) & (ob <= b)
road[sub] = blk
in_band = seg >= 0
dz = np.where(np.isfinite(z_orig), dz, 0.0)
z_syn = z_orig + dz
# 흙 두께 · 암 종류 — 띠 안 = t 보간 · 밖 = 가까운 측점
near = cKDTree(centers).query(np.column_stack([xx.ravel(), yy.ravel()]))[1].reshape(z.shape)
t_safe = np.nan_to_num(t_map)
s_safe = np.maximum(seg, 0)
t_band = (1 - t_safe) * thick[s_safe] + t_safe * thick[np.minimum(s_safe + 1, len(sts) - 1)]
soil_t = np.where(in_band, t_band, thick[near])
k_band = np.where(t_safe < 0.5, kind[s_safe], kind[np.minimum(s_safe + 1, len(sts) - 1)])
rock_kind = np.where(in_band, k_band, kind[near])
return {
"x": x,
"y": y,
"z_orig": z_orig,
"z": z_syn,
"dz": dz,
"in_band": in_band,
"segment": seg,
"t": t_map,
"offset": o_map,
"rock_top": z_orig - soil_t,
"soil_thickness": soil_t,
"rock_kind": rock_kind, # 0 리핑암 · 1 발파암
"q": np.where(road & in_band, road_q_kpa, 0.0),
"stations": sts,
}
def strata_from_grid(
grid: dict,
materials: list[dict] | None = None,
*,
water_depth_m: float | None = None,
with_surcharge: bool = True,
) -> Strata:
"""격자 → 기둥 솔버 지층(층 차례 = 머리 주석) · 물성은 id 로 고름(E03 꼴 · 빠지면 기본표).
water_depth_m — 지하수위면 = 합성 지표 − 깊이(0 = 지표까지 포화 · KDS 우기 규칙)
· None = 지하수 없음.
"""
by_id = {m["id"]: m for m in DEFAULT_MATERIALS}
by_id.update({m["id"]: m for m in materials or []})
z = grid["z"]
fill_bot = np.minimum(z, grid["z_orig"])
soil_bot = np.minimum(grid["rock_top"], fill_bot)
blasting = grid["rock_kind"] == 1
rip_bot = np.where(blasting, soil_bot, -1e6)
return Strata(
grid["x"],
grid["y"],
z,
np.stack([fill_bot, soil_bot, rip_bot]),
[by_id[k] for k in LAYER_MATERIALS],
hard=list(LAYER_HARD),
z_water=None if water_depth_m is None else z - water_depth_m,
q=grid["q"] if with_surcharge else None,
)
def section_check(grid: dict, dem_sampler, station: dict) -> dict:
"""측점 횡단선 위 합성 DEM(쌍선형) − B06 설계선 · 원지형 − B06 지반선 — 오차 기록용."""
d = np.asarray(station["design"], float)
c = np.asarray(station["center"], float)
left = np.asarray(station["left"], float)
left = left / np.hypot(*left)
pts = c[None] + d[:, :1] * left[None]
f = RegularGridInterpolator(
(grid["y"], grid["x"]), grid["z"], bounds_error=False, fill_value=np.nan
)
z_syn = f(np.column_stack([pts[:, 1], pts[:, 0]]))
err = z_syn - d[:, 1]
g = np.asarray(station["ground"], float)
gp = c[None] + g[:, :1] * left[None]
z0, _ = dem_sampler(gp)
gerr = z0 - g[:, 1]
return {
"max_abs": float(np.nanmax(np.abs(err))),
"rms": float(np.sqrt(np.nanmean(err**2))),
"ground_max_abs": float(np.nanmax(np.abs(gerr))),
}