feat(E03): 2D 지층 모델 빌더 · 한계평형 솔버(Fellenius · Bishop · Janbu 보정 · Spencer) · 원호 · 비원호 탐색 (532-2)

- 모델: B06 횡단(지반선 · 설계선 · 암 경계 · 성토체 · 벽) → 지층 폴리곤 · 지하수 · 노면 하중 · 비탈별 방향 · 탐색 한계
- 솔버: 한 벌 절편 식(λ = 0 Bishop · Janbu / Spencer 모멘트 · 힘 동시) · 간극수압 none · r_u · 지하수위선 · kh · 상재하중 · 인장균열
- 탐색: Entry & Exit · Grid & Radius · 차분진화 다듬기 · 블록 씨앗 + 꼭짓점 국부 최적화 + Nelder-Mead
- 출력: 방법별 최소 안전율 · 임계면 좌표 · 절편표 · 등안전율 격자 · 수렴 정보
- 시험: Slide2 검증 예제 12 문제(ACADS 1a~4 · F&K · Loukidis · Arai & Tagyo · Yamagami & Ueta) ±3 % · 면적 = B06

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:07:14 +09:00
co-authored by Claude Opus 5.5
parent eb29e753fd
commit bfd87f3e3e
6 changed files with 2013 additions and 0 deletions
@@ -0,0 +1,293 @@
"""E03 2D 지층 모델 빌더 — 측점 횡단(지반선 · 설계선 · 소단 · 측구 · 암 경계 · 성토체 · 벽) → 지층 폴리곤.
`build_section_model` = storage 를 모르는 순수 함수(좌표 · 값만) · `section_model_from_b06` = B06 횡단 자료 꼴
(분석 345-D ②)을 읽는 얇은 어댑터. 좌표 x = B06 offset(좌 +) · z = 표고.
설계선(design_line)이 소단 · 측구 · 노면을 이미 담음 — 여기서 다시 그리지 않음. 설계선 밖은 원지반.
암 경계 = 원지반을 연직으로 |offset| 내린 선(B06 면적 가름과 같은 정의 · `Engine_Design.py` 363-377).
"""
import numpy as np
from shapely.geometry import LineString, MultiPolygon, Polygon, box
from shapely.ops import unary_union
from shapely.validation import make_valid
from E03_StabSection.E03_StabSection_Types import (
DEFAULT_MATERIALS,
DEFAULT_ROAD_SURCHARGE_KPA,
)
from E03_StabSection.E03_StabSection_Types import (
groundwater as norm_groundwater,
)
from E03_StabSection.E03_StabSection_Types import (
surcharges as norm_surcharges,
)
#: B06 지반 라벨 → 암 경계 아래 지층(요구서 345 6장 · 분석 D 5-2). 경계 위는 풍화토.
ROCK_LAYER_OF = {"ripping_rock": "weathered_rock", "blasting_rock": "soft_rock"}
SOIL_ID = "weathered_soil"
FILL_ID = "fill"
#: 벽(C군) — 활동면이 못 지나게 강한 물체로(콘크리트 · 석축 대표값). 물성 표에 없으면 이 값을 덧붙임.
WALL_MATERIAL = {
"id": "wall",
"name": "벽체",
"gamma_t": 23.0,
"gamma_sat": 23.0,
"c": 1000.0,
"phi": 45.0,
}
DEFAULT_HALF_WIDTH_M = 40.0
def _polys(geom) -> list[Polygon]:
geom = make_valid(geom)
if isinstance(geom, Polygon):
parts = [geom]
elif isinstance(geom, MultiPolygon):
parts = list(geom.geoms)
else:
parts = [g for g in getattr(geom, "geoms", []) if isinstance(g, Polygon)]
return [p for p in parts if p.area > 1e-6]
def _region(material_id: str, poly: Polygon) -> dict:
out = {"material_id": material_id, "polygon": [list(c) for c in poly.exterior.coords]}
if poly.interiors:
out["holes"] = [[list(c) for c in ring.coords] for ring in poly.interiors]
return out
def final_surface(ground: list, design_line: list) -> list[list[float]]:
"""공사 뒤 지표 = 설계선 범위는 설계선 · 밖은 원지반."""
line = sorted((float(x), float(z)) for x, z in design_line)
if not line:
return [list(p) for p in ground]
x0, x1 = line[0][0], line[-1][0]
left = [p for p in ground if p[0] < x0 - 1e-9]
right = [p for p in ground if p[0] > x1 + 1e-9]
return [list(map(float, p)) for p in left + line + right]
def build_section_model(
ground: list,
design_line: list,
*,
layers: list,
materials: list[dict] | None = None,
fill_material: str = FILL_ID,
walls: list[dict] | None = None,
groundwater: dict | None = None,
seismic_kh: float = 0.0,
surcharge: list[dict] | None = None,
bottom_z: float | None = None,
area_range: tuple[float, float] | None = None,
) -> dict:
"""원지반 · 설계선 · 지층(위부터 [(material_id, 원지반 아래 바닥 깊이 m | None=모형 바닥)]) → 해석 모형.
돌려주는 값 = LEM 모형(materials · regions · surface · groundwater · seismic_kh · surcharge)
+ `areas`(area_range 안 절토 · 성토 · 절토 토사 · 절토 암 ㎡ — B06 면적 대조용).
"""
ground = sorted((float(x), float(z)) for x, z in ground)
surface = final_surface(ground, design_line)
xs = [p[0] for p in ground]
zs = [p[1] for p in ground] + [p[1] for p in surface]
relief = max(zs) - min(zs)
zb = bottom_z if bottom_z is not None else min(zs) - max(5.0, 0.5 * relief)
x0, x1 = xs[0], xs[-1]
bottom = [(x1, zb), (x0, zb)]
below_final = make_valid(Polygon(surface + bottom))
ground_body = make_valid(Polygon(ground + bottom))
natural = below_final.intersection(ground_body)
fill = below_final.difference(ground_body)
cut = ground_body.difference(below_final)
def band(d_top: float, d_bot: float | None) -> Polygon:
top = [(x, z - d_top) for x, z in ground]
if d_bot is None:
low = [(x1, zb - 1.0), (x0, zb - 1.0)]
else:
low = [(x, z - d_bot) for x, z in reversed(ground)]
return make_valid(Polygon(top + low))
wall_geom = unary_union([Polygon(w["polygon"]) for w in walls or []]) if walls else None
regions: list[dict] = []
bands: list = []
depth = 0.0
for material_id, d_bot in layers:
b = band(depth, None if d_bot is None else float(d_bot))
bands.append(b)
part = natural.intersection(b)
if wall_geom is not None:
part = part.difference(wall_geom)
regions += [_region(material_id, p) for p in _polys(part)]
if d_bot is None:
break
depth = float(d_bot)
fill_part = fill.difference(wall_geom) if wall_geom is not None else fill
regions += [_region(fill_material, p) for p in _polys(fill_part)]
mats = [dict(m) for m in (materials or DEFAULT_MATERIALS)]
if wall_geom is not None:
for w in walls:
regions += [
_region(w.get("material_id", "wall"), p) for p in _polys(Polygon(w["polygon"]))
]
if not any(m["id"] == "wall" for m in mats):
mats.append(dict(WALL_MATERIAL))
a0, a1 = area_range or (x0, x1)
clip = box(a0, zb - 1.0, a1, max(zs) + 100.0)
cut_in = cut.intersection(clip)
cut_soil = cut_in.intersection(bands[0]).area if len(bands) > 1 else cut_in.area
return {
"materials": mats,
"regions": regions,
"surface": surface,
"groundwater": norm_groundwater(groundwater),
"seismic_kh": float(seismic_kh or 0.0),
"surcharge": norm_surcharges(surcharge),
"bottom_z": zb,
"areas": {
"cut_m2": cut_in.area,
"fill_m2": fill.intersection(clip).area,
"cut_soil_m2": cut_soil,
"cut_rock_m2": cut_in.area - cut_soil,
},
}
# ---- B06 어댑터 -----------------------------------------------------------------
def ground_from_cross(cross: dict) -> list[tuple[float, float]]:
return sorted(
(float(s["offset_m"]), float(s["elevation_m"]))
for s in cross.get("samples") or []
if s.get("valid") is not False and s.get("elevation_m") is not None
)
def widen_ground(cross: dict, sampler, half_width: float = DEFAULT_HALF_WIDTH_M, step: float = 0.5):
"""해석 폭 — 횡단 표본 바깥을 지형에서 다시 떠 ±half_width 까지 넓힘(지형 밖이 나오면 거기서 멈춤)."""
ground = ground_from_cross(cross)
frame = cross["frame"]
origin = np.array([float(frame["origin"]["x"]), float(frame["origin"]["y"])])
axis = np.array([float(v) for v in frame["left_xy"]])
added = []
for sign, edge in ((-1.0, ground[0][0]), (1.0, ground[-1][0])):
n = int(max(0.0, half_width - abs(edge)) / step)
if n <= 0:
continue
offs = edge + sign * step * np.arange(1, n + 1)
z, valid = sampler.sample_xy(origin[None, :] + axis[None, :] * offs[:, None])
stop = int(np.argmin(valid)) if not valid.all() else len(valid)
added += [(float(o), float(e)) for o, e in zip(offs[:stop], z[:stop])]
return sorted(ground + added)
def side_cases(design: dict, ground: list, line: list) -> list[dict]:
"""좌 · 우 비탈마다 역할(절토 · 성토) · 활동 방향 · 탐색 한계. 좌 = +offset.
탐색 한계 = 노면 반대쪽 끝 ~ 비탈 끝(설계선이 원지반과 만나는 점) 바깥 max(5 m, 2H) — 설계 비탈을
지나는 면만 봄(바깥 자연 비탈 전체 붕괴는 E 광역 · 2차 몫). H = 노면 끝 ~ 비탈 끝 높이차.
"""
mode = design.get("section_mode") or "right_cut"
roles = {
"left_cut": ("cut", "fill"),
"right_cut": ("fill", "cut"),
"both_cut": ("cut", "cut"),
"both_fill": ("fill", "fill"),
}[mode]
edges = design.get("road_edges") or {}
left_edge = float((edges.get("left") or {}).get("offset_m", 2.0))
right_edge = float((edges.get("right") or {}).get("offset_m", -2.0))
gx = np.array([p[0] for p in ground])
gz = np.array([p[1] for p in ground])
lx = np.array([p[0] for p in line])
lz = np.array([p[1] for p in line])
off = np.abs(lz - np.interp(lx, gx, gz)) > 0.005
out = []
for side, role in zip(("left", "right"), roles):
inward = role == "cut" # 절토 = 산 쪽에서 노면으로 · 성토 = 노면에서 바깥으로
sign = 1.0 if side == "left" else -1.0
edge = left_edge if side == "left" else right_edge
# 비탈 끝 = 원지반에서 떨어진 가장 바깥 꼭짓점의 바로 바깥 꼭짓점(다시 원지반에 붙는 점)
idx = [k for k, (x, o) in enumerate(zip(lx, off)) if o and sign * x >= sign * edge - 1e-9]
if idx:
k = max(idx) if sign > 0 else min(idx)
k = min(max(k + int(sign), 0), len(lx) - 1)
day = float(lx[k])
else:
day = edge
H = abs(float(np.interp(day, lx, lz)) - float(np.interp(edge, lx, lz)))
reach = day + sign * max(5.0, 2.0 * H)
reach = min(max(reach, gx[0]), gx[-1])
inner = right_edge if side == "left" else left_edge
limits = tuple(sorted((inner, reach)))
direction = int(-sign) if inward else int(sign)
out.append(
{"side": side, "role": role, "direction": direction, "limits": limits, "height_m": H}
)
return out
def section_model_from_b06(
cross: dict,
design: dict,
*,
materials: list[dict] | None = None,
layers: list | None = None,
ground: list | None = None,
wet: bool = False,
seismic_kh: float = 0.0,
road_surcharge_kpa: float = DEFAULT_ROAD_SURCHARGE_KPA,
walls: list[dict] | None = None,
) -> dict:
"""B06 횡단 파일(samples · frame) + 설계(design) → 해석 모형 + 비탈 목록(`sides`).
ground = 넓혀 다시 뜬 지반선(`widen_ground`) · 없으면 횡단 표본 그대로.
layers 기본 = 토사 측점 풍화토 한 층 · 암 측점 풍화토(암 경계까지) + 풍화암 / 연암.
wet = 우기 — 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3 · 토사 · 풍화암 지표 포화).
노면 하중 = 노면 끝 사이 등분포. 벽 도형(walls)은 B06 화면 산식(TS)이 그리므로 부르는 쪽이 넘김.
"""
ground = ground or ground_from_cross(cross)
line = [
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
]
if layers is None:
rock = ROCK_LAYER_OF.get(design.get("ground_type"))
offset = design.get("rock_boundary_offset_m")
if rock and offset is not None:
layers = [(SOIL_ID, abs(float(offset))), (rock, None)]
elif rock:
layers = [(rock, None)]
else:
layers = [(SOIL_ID, None)]
edges = design.get("road_edges") or {}
loads = []
if road_surcharge_kpa and edges.get("left") and edges.get("right"):
loads = [
{
"x0": edges["right"]["offset_m"],
"x1": edges["left"]["offset_m"],
"q": road_surcharge_kpa,
}
]
surface = final_surface(ground, line)
gw = {"mode": "line", "line": surface} if wet else {"mode": "none"}
model = build_section_model(
ground,
line,
layers=layers,
materials=materials,
walls=walls,
groundwater=gw,
seismic_kh=seismic_kh,
surcharge=loads,
area_range=(line[0][0], line[-1][0]) if line else None,
)
model["sides"] = side_cases(design, ground, line or ground)
model["station"] = {"chainage_m": cross.get("chainage_m"), "label": cross.get("label")}
return model
def polygon_closed(region: dict) -> bool:
"""폴리곤 닫힘 · 자기 교차 없음 · 넓이 > 0 (시험용)."""
ring = region["polygon"]
poly = Polygon(ring, region.get("holes"))
return bool(poly.is_valid and poly.area > 0 and LineString(ring).is_closed)