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,348 @@
# -*- coding: utf-8 -*-
"""532-2 E03 2D 한계평형 솔버 — 공개 검증 예제 대조(±3 %).
출처(기대값 = 원문 그대로):
- S2VM = Rocscience Slide2 Slope Stability Verification Manual (2022) — 지식DB
`original/안정성평가/참고문헌/Rocscience_Slide2_SlopeStabilityVerification.pdf` · 문제 번호 = 그 책 번호.
- ACADS = Giam & Donald (1989) ACADS 사면 시험문제(S2VM #1~#9 에 실림).
- FK = Fredlund & Krahn (1977) Can. Geotech. J. 14(3) (S2VM #21).
- AT = Arai & Tagyo (1985) · YU = Yamagami & Ueta (1988) · LOUK = Loukidis et al. (2003) (S2VM #14~#17 · #62).
- 지정 활동면(원 · 꺾은선)은 방법 대조 · 탐색 문제는 상용 SW 탐색 결과 대조.
- Ordinary(Fellenius) 간극수압 = F&K 식 N = W·cosα − u·l (지식DB 02 §2.1) — Slide 는 (W − u·b)·cosα 라 r_u 문제 값이 다름.
./venv/Scripts/python.exe -m pytest resources/tester/test_532_2_stab_lem_benchmarks.py -q
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from shapely.geometry import Polygon, box
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from E03_StabSection.E03_StabSection_Engine_Search import analyze, evaluate_specified # noqa: E402
TOL = 0.03
def mats(*rows):
return [{"id": i, "name": i, "gamma_t": g, "c": c, "phi": p} for i, g, c, p in rows]
def near(got, expected, tol=TOL):
assert got is not None, "안전율 없음"
assert abs(got / expected - 1) <= tol, (
f"{got:.4f} vs {expected} ({(got / expected - 1) * 100:+.2f} %)"
)
def circ(res, method):
return (res["methods"][method].get("circular") or {}).get("F")
def nonc(res, method):
return (res["methods"][method].get("noncircular") or {}).get("F")
ACADS_EXT = [[20, 20], [70, 20], [70, 35], [50, 35], [30, 25], [20, 25]]
ACADS1C_REGIONS = [
{
"material_id": "soil1",
"polygon": [[30, 25], [50, 35], [70, 35], [70, 31], [54, 31], [50, 29], [40, 27]],
},
{
"material_id": "soil2",
"polygon": [[40, 27], [50, 29], [54, 31], [70, 31], [70, 24], [52, 24]],
},
{
"material_id": "soil3",
"polygon": [[20, 20], [70, 20], [70, 24], [52, 24], [40, 27], [30, 25], [20, 25]],
},
]
ACADS1C_MATS = mats(("soil1", 19.5, 0, 38), ("soil2", 19.5, 5.3, 23), ("soil3", 19.5, 7.2, 20))
def test_acads_1a_homogeneous_circular_search():
# S2VM #1 (ACADS 1a) — Bishop 0.987 · Spencer 0.986 · Janbu 보정 0.990 · 심판값 1.00
m = {
"materials": mats(("s", 20, 3, 19.6)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 0.987)
near(circ(r, "spencer"), 0.986)
near(circ(r, "janbu"), 0.990)
def test_acads_1b_tension_crack_full_of_water():
# S2VM #2 (ACADS 1b) — 균열 깊이 4 m · 물 가득 — Bishop 1.596 · Spencer 1.592 · Janbu 보정 1.489
m = {
"materials": mats(("s", 20, 32, 10)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
"tension_crack": {"depth": 4.0, "water": 1.0},
}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 1.596)
near(circ(r, "spencer"), 1.592)
near(circ(r, "janbu"), 1.489)
def test_acads_1c_three_layers():
# S2VM #3 (ACADS 1c) — Bishop 1.405 · Spencer 1.375 · Janbu 보정 1.357
r = analyze(
{"materials": ACADS1C_MATS, "regions": ACADS1C_REGIONS}, direction=-1, noncircular=False
)
near(circ(r, "bishop"), 1.405)
near(circ(r, "spencer"), 1.375)
near(circ(r, "janbu"), 1.357)
def test_acads_1d_three_layers_seismic():
# S2VM #4 (ACADS 1d) — kh 0.15 — Bishop 1.016 · Spencer 0.991 · Janbu 보정 0.965
m = {"materials": ACADS1C_MATS, "regions": ACADS1C_REGIONS, "seismic_kh": 0.15}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 1.016)
near(circ(r, "spencer"), 0.991)
near(circ(r, "janbu"), 0.965)
def test_acads_2b_embankment_dam_specified_circle():
# S2VM #6 (ACADS 2b Talbingo 댐 · 성토) — 지정 원 (100.3, 291.0, R 278.8)
# Bishop 2.208 · Spencer 2.292 · Janbu 보정 2.073 · 심판값 2.29
ext = [
[0, 0], [315.5, 162], [319.5, 162], [321.6, 162], [327.6, 162], [386.9, 130.6], [394.1, 130.6],
[453.4, 97.9], [460.6, 97.9], [515, 65.3], [521.1, 65.3], [577.9, 31.4], [585.1, 31.4], [648, 0],
] # fmt: skip
core = [
[200.7, 0],
[311.9, 130.6],
[319.5, 162],
[321.6, 162],
[328.8, 146.1],
[331.3, 130.6],
[307.1, 0],
]
rest = Polygon(ext).difference(Polygon(core))
regions = [{"material_id": "core", "polygon": core}] + [
{"material_id": "rockfill", "polygon": list(p.exterior.coords)}
for p in getattr(rest, "geoms", [rest])
]
m = {"materials": mats(("rockfill", 20.4, 0, 45), ("core", 18.1, 85, 23)), "regions": regions}
r = evaluate_specified(m, circle=[100.3, 291.0, 278.8], direction=-1)
near(r["bishop"]["F"], 2.208)
near(r["spencer"]["F"], 2.292)
near(r["janbu"]["F"], 2.073)
def _acads3_model():
ext = Polygon([[20, 20], [84, 20], [84, 40], [67.5, 40], [43, 27.75], [20, 27.75]])
regions = [
{
"material_id": "soil",
"polygon": list(ext.intersection(box(0, 27, 100, 100)).exterior.coords),
},
{
"material_id": "seam",
"polygon": list(ext.intersection(box(0, 26.5, 100, 27)).exterior.coords),
},
{
"material_id": "soil",
"polygon": list(ext.intersection(box(0, 0, 100, 26.5)).exterior.coords),
},
]
return {
"materials": mats(("soil", 18.84, 28.5, 20), ("seam", 18.84, 0, 10)),
"regions": regions,
"groundwater": {"mode": "line", "line": [[20, 26.5], [84, 26.5]]},
}
def test_acads_3b_weak_seam_specified_polyline():
# S2VM #8 (ACADS 3b) — 약층 + 지하수위 · 지정 비원호 — Spencer 1.277 · Janbu 보정 1.294
r = evaluate_specified(
_acads3_model(),
polyline=[[41.85, 27.75], [44.0, 26.5], [63.5, 27.0], [73.31, 40.0]],
direction=-1,
methods=("janbu", "spencer"),
)
near(r["spencer"]["F"], 1.277)
near(r["janbu"]["F"], 1.294)
def test_acads_3a_weak_seam_noncircular_search():
# S2VM #7 (ACADS 3a) — 블록 탐색 Janbu 보정 1.275 · Spencer 1.258(심판값 1.24~1.27)
r = analyze(_acads3_model(), direction=-1, methods=("janbu", "spencer"))
near(nonc(r, "janbu"), 1.275)
near(nonc(r, "spencer"), 1.258)
def test_acads_4_inclined_seam_water_surcharge():
# S2VM #9 (ACADS 4) — 기운 약층 · 지하수위선 · 등분포 20 kPa + 선형 20→40 kPa
# 최적화 비원호 Spencer 0.707 · Janbu 보정 0.699 (약층 좌표 = 저해상 그림 판독 — 출처 ±5 % 권함)
ext = Polygon([[20, 15], [84, 15], [84, 40], [67.5, 40], [43, 27.75], [20, 27.75]])
seam = Polygon([[20, 18.26], [84, 36.2], [84, 36.8], [20, 18.88]])
regions = [{"material_id": "seam", "polygon": list(ext.intersection(seam).exterior.coords)}] + [
{"material_id": "soil", "polygon": list(p.exterior.coords)}
for p in ext.difference(seam).geoms
]
linear = [{"x0": 70 + i, "x1": 71 + i, "q": 20 + 20 * (i + 0.5) / 10} for i in range(10)]
m = {
"materials": mats(("soil", 18.84, 28.5, 20), ("seam", 18.84, 0, 10)),
"regions": regions,
"surcharge": [{"x0": 23, "x1": 43, "q": 20}] + linear,
"groundwater": {
"mode": "line",
"line": [
[20, 27.75],
[43, 27.75],
[49, 29.8],
[60, 34],
[66, 35.8],
[74, 37.6],
[80, 38.4],
[84, 38.4],
],
},
}
r = analyze(m, direction=-1, methods=("janbu", "spencer"))
near(nonc(r, "spencer"), 0.707)
near(nonc(r, "janbu"), 0.699)
FK_EXT = [[0, 0], [180, 0], [180, 20], [140, 20], [60, 60], [0, 60]]
def test_fredlund_krahn_specified_circle_dry_and_ru():
# S2VM #21 (F&K 1977, ft · lbf) — 지정 원 (120, 90, R 80)
# 건기 Ordinary 1.928 · Bishop 2.080 · Spencer 2.073 / r_u 0.25 Ordinary 1.607 · Bishop 1.766 · Spencer 1.761
m = {
"materials": mats(("s", 120, 600, 20)),
"regions": [{"material_id": "s", "polygon": FK_EXT}],
}
r = evaluate_specified(
m, circle=[120, 90, 80], direction=1, methods=("ordinary", "bishop", "spencer")
)
near(r["ordinary"]["F"], 1.928)
near(r["bishop"]["F"], 2.080)
near(r["spencer"]["F"], 2.073)
m["groundwater"] = {"mode": "ru", "ru": 0.25}
r = evaluate_specified(
m, circle=[120, 90, 80], direction=1, methods=("ordinary", "bishop", "spencer")
)
near(r["ordinary"]["F"], 1.607)
near(r["bishop"]["F"], 1.766)
near(r["spencer"]["F"], 1.761)
def test_loukidis_seismic_with_ru():
# S2VM #62 — 건기 kh 0.432: Bishop 0.991 · Spencer 1.001 / r_u 0.5 + kh 0.132: Bishop 0.987 · Spencer 1.001
ext = [[-50, -25], [150, -25], [150, 25], [75, 25], [0, 0], [-50, 0]]
m = {
"materials": mats(("c", 20, 25, 30)),
"regions": [{"material_id": "c", "polygon": ext}],
"seismic_kh": 0.432,
}
r = analyze(m, direction=-1, noncircular=False, methods=("bishop", "spencer"))
near(circ(r, "bishop"), 0.991)
near(circ(r, "spencer"), 1.001)
m.update(seismic_kh=0.132, groundwater={"mode": "ru", "ru": 0.5})
r = analyze(m, direction=-1, noncircular=False, methods=("bishop", "spencer"))
near(circ(r, "bishop"), 0.987)
near(circ(r, "spencer"), 1.001)
AT_EXT = [[0, 0], [66, 0], [66, 35], [48, 35], [18, 15], [0, 15]]
def test_arai_tagyo_3_water_table():
# S2VM #16 — 지하수위선 · 원호 Bishop 1.118 · Spencer 1.118 · Janbu 보정 1.131 /
# 비원호 Janbu 보정 1.050 · Spencer 1.094
m = {
"materials": mats(("s", 18.82, 41.65, 15)),
"regions": [{"material_id": "s", "polygon": AT_EXT}],
"groundwater": {"mode": "line", "line": [[0, 15], [18, 15], [30, 23], [48, 29], [66, 32]]},
}
r = analyze(m, direction=-1)
near(circ(r, "bishop"), 1.118)
near(circ(r, "spencer"), 1.118)
near(circ(r, "janbu"), 1.131)
near(nonc(r, "janbu"), 1.050)
near(nonc(r, "spencer"), 1.094)
def test_arai_tagyo_2_weak_middle_layer():
# S2VM #15 — 약한 가운데 층 · Bishop 0.420 · Spencer 0.409 · Janbu 보정 0.423
regions = [
{"material_id": "upper", "polygon": [[24, 19], [48, 35], [72, 35]]},
{
"material_id": "middle",
"polygon": [[0, 3], [96, 35], [72, 35], [24, 19], [18, 15], [0, 15]],
},
{"material_id": "lower", "polygon": [[0, 3], [96, 3], [96, 35]]},
]
m = {
"materials": mats(
("upper", 18.82, 29.4, 12), ("middle", 18.82, 9.8, 5), ("lower", 18.82, 294, 40)
),
"regions": regions,
}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 0.420)
near(circ(r, "spencer"), 0.409)
near(circ(r, "janbu"), 0.423)
def test_yamagami_ueta_ordinary_vs_bishop():
# S2VM #17 — Bishop 1.344 · Ordinary 1.278 / 비원호 Spencer 1.325
ext = [[0, 0], [25, 0], [25, 10], [15, 10], [5, 5], [0, 5]]
m = {
"materials": mats(("s", 17.64, 9.8, 10)),
"regions": [{"material_id": "s", "polygon": ext}],
}
r = analyze(m, direction=-1, methods=("ordinary", "bishop", "spencer"))
near(circ(r, "bishop"), 1.344)
near(circ(r, "ordinary"), 1.278)
near(nonc(r, "spencer"), 1.325)
def test_mirror_direction_same_factor():
# 좌우 뒤집은 같은 비탈 = 같은 안전율 · 임계면 좌표도 거울
m = {
"materials": mats(("s", 20, 3, 19.6)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
}
mirrored = {**m, "regions": [{"material_id": "s", "polygon": [[-x, z] for x, z in ACADS_EXT]}]}
a = evaluate_specified(m, circle=[29.104, 55.527, 30.527], direction=-1)
b = evaluate_specified(mirrored, circle=[-29.104, 55.527, 30.527], direction=1)
for k in ("ordinary", "bishop", "janbu", "spencer"):
assert abs(a[k]["F"] - b[k]["F"]) < 1e-9
assert abs(a["bishop"]["entry"][0] + b["bishop"]["entry"][0]) < 1e-9
near(a["bishop"]["F"], 0.987) # S2VM #1 Bishop 임계 원 그대로
def test_slice_table_and_convergence_info():
m = {
"materials": mats(("s", 20, 3, 19.6)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
}
r = evaluate_specified(m, circle=[29.104, 55.527, 30.527], direction=-1, methods=("spencer",))[
"spencer"
]
assert r["converged"] and r["iterations"] > 0 and -1.25 <= r["lambda"] <= 0.25
rows = r["slices"]
assert len(rows) == 50 and all(row["width"] > 0 for row in rows)
# 절편간 힘 E — 머리(0) 에서 시작해 발끝에서 다시 0 근처(힘 평형이 닫힘)
weights = sum(row["weight"] for row in rows)
assert abs(rows[0]["E_left"]) < 1e-9
assert {"alpha_deg", "pore_pressure", "normal_eff", "shear_mobilized", "material"} <= rows[
0
].keys()
assert weights > 0
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))
@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
"""532-2 E03 2D 지층 모델 빌더 — 폴리곤 닫힘 · 지층 · 성토체 · 절성토 면적 = B06 · 비탈 목록 · 측점 해석 꼴.
- 순수 함수(`build_section_model`)는 만든 단면으로 · B06 대조는 검증_sub4 초기값 스냅샷(읽기만 · 없으면 건너뜀).
./venv/Scripts/python.exe -m pytest resources/tester/test_532_2_stab_section_model.py -q
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from shapely.geometry import Polygon
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from E03_StabSection.E03_StabSection_Engine_Model import ( # noqa: E402
build_section_model,
polygon_closed,
section_model_from_b06,
side_cases,
)
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
SUB4 = ROOT / "storage/1/3/c725d747-50ae-46b0-87ce-4d4e56a53cf3/initial_snapshot"
# 오른쪽(−) 산 · 왼쪽(+) 골 · 편절편성: 노면 −2~2 · 우 절토 1:0.5 · 좌 성토 1:1.5
GROUND = [(x / 2, 10.0 - 0.6 * (x / 2)) for x in range(-40, 41)] # 10 − 0.6x (좌로 내려감)
DESIGN = [
(-20.0, 22.0),
(-6.0, 13.6),
(-2.6, 10.0),
(-2.0, 10.0),
(2.0, 10.0),
(14.0, 2.0),
(20.0, -2.0),
]
def _trapezoid(ground, line, sign):
"""설계선 꼭짓점 · 지반 표본 합집합 위 사다리꼴(B06 `_trapezoid_areas` 와 같은 정의) — 절토(+) / 성토(−)."""
import numpy as np
gx, gz = zip(*ground)
lx, lz = zip(*line)
xs = sorted({*gx, *lx})
xs = [x for x in xs if lx[0] <= x <= lx[-1]]
d = np.interp(xs, gx, gz) - np.interp(xs, lx, lz)
area = 0.0
for x0, x1, d0, d1 in zip(xs, xs[1:], d, d[1:]):
if d0 * d1 < 0:
xm = x0 + (x1 - x0) * d0 / (d0 - d1)
area += 0.5 * (xm - x0) * max(sign * d0, 0) + 0.5 * (x1 - xm) * max(sign * d1, 0)
else:
area += 0.5 * (x1 - x0) * (max(sign * d0, 0) + max(sign * d1, 0))
return area
def test_build_model_polygons_layers_fill_and_areas():
m = build_section_model(
GROUND,
DESIGN,
layers=[("weathered_soil", 0.5), ("weathered_rock", None)],
area_range=(-20.0, 20.0),
)
assert m["regions"] and all(polygon_closed(r) for r in m["regions"])
ids = {r["material_id"] for r in m["regions"]}
assert ids == {"weathered_soil", "weathered_rock", "fill"}
# 지층 사이 겹침 없음 · 합 = 공사 뒤 지표 아래 전체
polys = [Polygon(r["polygon"], r.get("holes")) for r in m["regions"]]
total = sum(p.area for p in polys)
union = polys[0]
for p in polys[1:]:
union = union.union(p)
assert abs(union.area - total) < 1e-6
a = m["areas"]
assert abs(a["cut_m2"] - _trapezoid(GROUND, DESIGN, 1)) < 1e-6
assert abs(a["fill_m2"] - _trapezoid(GROUND, DESIGN, -1)) < 1e-6
assert (
0 < a["cut_soil_m2"] < a["cut_m2"]
and abs(a["cut_soil_m2"] + a["cut_rock_m2"] - a["cut_m2"]) < 1e-9
)
def test_side_cases_roles_directions_limits():
design = {
"section_mode": "right_cut",
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
}
left, right = side_cases(design, GROUND, DESIGN)
assert (left["role"], left["direction"]) == ("fill", 1) # 좌 성토 → 바깥(+x)으로
assert (right["role"], right["direction"]) == ("cut", 1) # 우 절토 → 노면(+x)으로
assert left["limits"][0] == -2.0 and right["limits"][1] == 2.0
assert left["height_m"] > 0 and right["height_m"] > 0
def test_analyze_station_shape():
cross = {
"chainage_m": 0.0,
"label": "STA.0+000",
"samples": [{"offset_m": x, "elevation_m": z} for x, z in GROUND],
}
design = {
"section_mode": "right_cut",
"ground_type": "ripping_rock",
"rock_boundary_offset_m": -0.5,
"design_line": [{"offset_m": x, "elevation_m": z} for x, z in DESIGN],
"road_edges": {
"left": {"offset_m": 2.0, "elevation_m": 10.0},
"right": {"offset_m": -2.0, "elevation_m": 10.0},
},
}
res = analyze_station(
cross, design, conditions=("dry",), methods=("bishop",), noncircular=False
)
assert len(res["cases"]) == 2
for case in res["cases"]:
F = case["min_F"]["bishop"]
assert F is not None and 0.3 < F < 20
best = case["result"]["methods"]["bishop"]["circular"]
assert best["slices"] and best["center"] and best["radius"] > 0
lo, hi = case["limits"]
assert (
lo - 1e-6 <= min(best["entry"][0], best["exit"][0])
and max(best["entry"][0], best["exit"][0]) <= hi + 1e-6
)
assert res["cases"][0]["result"]["contour"]["F"]
@pytest.mark.skipif(not (SUB4 / "db.json").exists(), reason="검증_sub4 스냅샷 없음")
def test_b06_areas_match_all_stations():
"""검증_sub4 전 측점 — 모델 절토 · 성토 · 토사 · 암 면적 = B06 저장값(반올림 4 자리 안)."""
rows = json.loads((SUB4 / "db.json").read_text(encoding="utf-8"))["cross_sections"]
assert rows
for row in rows:
design = json.loads(row["data"])["design"]
name = Path(row["cross_section_file_path"]).name
cross = json.loads(
(SUB4 / "B06_Section__cross_sections" / name).read_text(encoding="utf-8")
)
m = section_model_from_b06(cross, design)
assert all(polygon_closed(r) for r in m["regions"]), name
a = m["areas"]
assert abs(a["cut_m2"] - design["cut_area_m2"]) < 2e-3, name
assert abs(a["fill_m2"] - design["fill_area_m2"]) < 2e-3, name
assert abs(a["cut_soil_m2"] - design["cut_soil_area_m2"]) < 2e-3, name
assert abs(a["cut_rock_m2"] - design["cut_rock_area_m2"]) < 2e-3, name
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))