fix(E03): Spencer · M-P 해 없음 = 「수렴 안 됨」 — 넓은 λ 다시 찾기 · 수렴 정보 칸 (532-2 덧 3 · 요구 351)

- 좁은 λ 에 해 없으면 넓은 λ(Spencer −2 ~ 2 · M-P ±π · 41 점 거친 훑기 → 뉴턴) 다시 찾음
- Bishop 임계 원에서도 해 없음 + 찾은 최소 > Bishop = 그 방법 원호 「해 없음」(F None · status · reason) · 최소 F 후보에서 빠짐
- 수렴 정보 칸 residual · lambda_range · status(converged · no_solution · rejected) 덧붙임 · 해 없음 때도 F_m · F_f–λ 곡선(넓은 범위)
- E03 방법 표 빈 값 = 「수렴 안 됨」(View no_solution 칸 · 옛 저장본은 없음)
- 시험 test_532_2_stab_nosolution 새로 · 검증_sub4 59 측점 Bishop · Fellenius · Janbu 값 차 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 04:00:59 +09:00
co-authored by Claude Opus 5.5
parent 3e4e50f371
commit f3cc133297
9 changed files with 232 additions and 27 deletions
@@ -19,15 +19,17 @@ SAFETY_MAP_TOP = 50
_MAP_POINTS = 25
def _curve(sl, center, method, f=None) -> dict:
"""F_m · F_f 대 λ (화면 그래프) — Spencer = 상수 f · M-P = 반정현."""
def _curve(sl, center, method, f=None, wide=False) -> dict:
"""F_m · F_f 대 λ (화면 그래프) — Spencer = 상수 f · M-P = 반정현 · wide = 넓은 범위(해 없음이 보이게)."""
if method == "spencer":
grid, fm, ff = lem.lambda_curve(
sl, center, np.linspace(*lem.LAMBDA_RANGE, gle.CURVE_POINTS)
rng, n = (
(lem.LAMBDA_WIDE, lem.WIDE_POINTS) if wide else (lem.LAMBDA_RANGE, gle.CURVE_POINTS)
)
grid, fm, ff = lem.lambda_curve(sl, center, np.linspace(*rng, n))
fm, ff = fm[:, 0], ff[:, 0]
else:
grid, fm, ff = gle.lambda_curves(sl, center, f)
grid = np.linspace(*gle.LAMBDA_WIDE, lem.WIDE_POINTS) if wide else None
grid, fm, ff = gle.lambda_curves(sl, center, f, grid)
fm, ff = fm[0], ff[0]
num = lambda v: [float(x) if np.isfinite(x) else None for x in v] # noqa: E731
return {"lambda": num(grid), "Fm": num(fm), "Ff": num(ff)}
@@ -75,10 +77,29 @@ def describe(prep, kind, geom, method, n, min_depth, seed_F=None) -> dict:
else:
out["moment_center"] = [float(s * center[0, 0]), float(center[0, 1])]
if method in ("spencer", "morgenstern_price"):
out["lambda"] = lam
out["theta_deg"] = float(np.degrees(np.arctan(lam)))
if np.isfinite(F):
out["lambda_curve"] = _curve(sl, center, method, f)
# 수렴 정보 — λ · 반복 수 · 잔차(max |F_m − F|, |F_f − F|) · 상태 · 해 없음 까닭
solved = np.isfinite(F)
wide = bool(res["wide"][0])
rng = (
(lem.LAMBDA_WIDE if wide else lem.LAMBDA_RANGE)
if method == "spencer"
else (gle.LAMBDA_WIDE if wide else gle.LAMBDA_RANGE)
)
out["lambda"] = lam if solved else None
out["theta_deg"] = float(np.degrees(np.arctan(lam))) if solved else None
r = float(res["residual"][0])
out["residual"] = r if np.isfinite(r) else None
out["lambda_range"] = [float(rng[0]), float(rng[1])]
crossed = bool(res["converged"][0])
out["status"] = "converged" if solved else ("rejected" if crossed else "no_solution")
out["reason"] = (
None
if solved
else "해는 있으나 버림(m_α < 0.2 · 저항 없음 · F 하한)"
if crossed
else f"F_m · F_f 가 λ {rng[0]:.2f} ~ {rng[1]:.2f} 안에서 안 만남(해 없음)"
)
out["lambda_curve"] = _curve(sl, center, method, f, wide or not solved)
if method == "morgenstern_price":
out["interslice_function"] = "half_sine"
if method == "janbu":
+28 -6
View File
@@ -17,6 +17,8 @@ _TINY = 1e-9
CURVE_POINTS = 21
#: M-P λ 범위 — 반정현 평균 2/π 라 평균 X/E 가 Spencer 범위(LEM.LAMBDA_RANGE)와 같게 π/2 배
LAMBDA_RANGE = tuple(v * np.pi / 2 for v in lem.LAMBDA_RANGE)
#: 위 범위에 해가 없을 때만 다시 찾는 넓은 범위(같은 π/2 배 · 거친 훑기 → 뉴턴) — 그래도 없으면 「해 없음」
LAMBDA_WIDE = tuple(v * np.pi / 2 for v in lem.LAMBDA_WIDE)
def interslice_function(xe: np.ndarray, kind: str = "half_sine") -> np.ndarray:
@@ -167,21 +169,39 @@ def _newton2(sl, center, f, F, lam, iters=40):
return F, lam, it
def _solve(sl, center, f, grid, fm, ff, F0, rng):
"""곡선 교점 → 뉴턴 → (F, λ, 반복, 수렴, 잔차) — 수렴 = 두 잔차 < 1e-4 · λ 범위 안 · 참고값 0.5~2 배."""
F, lam = _crossing(grid, fm, ff)
F, lam, it = _newton2(sl, center, f, F, lam)
ok = np.isfinite(F)
safe = np.where(ok & (F > 0), F, 1.0)
rm, rff, _ = _fm_ff(sl, center, safe, np.where(ok, lam, 0.0), f)
res = np.where(ok, np.maximum(np.abs(rm - F), np.abs(rff - F)), np.nan)
conv = ok & (res < 1e-4) & (lam >= rng[0]) & (lam <= rng[1])
if F0 is not None:
conv &= (F > 0.5 * F0) & (F < 2.0 * F0)
return F, lam, it, conv, res
def morgenstern_price(sl: dict, center: np.ndarray, kind="half_sine", F0=None, curve=True):
"""M-P (F, λ) · 수렴 · (curve) F_m · F_f–λ 곡선. F0 = 출발 참고값(Spencer · Bishop) — 엉뚱한 근 거름."""
f = interslice_function(sl["xe"], kind)
grid, fm, ff = lambda_curves(sl, center, f, F0=F0)
F, lam = _crossing(grid, fm, ff)
F, lam, it = _newton2(sl, center, f, F, lam)
F, lam, it, conv, res = _solve(sl, center, f, grid, fm, ff, F0, LAMBDA_RANGE)
wide = ~conv & sl["valid"]
if wide.any():
# 좁은 범위에 해 없음 → 넓은 범위 거친 훑기 → 뉴턴(그 면만)
sub, cw, fw = lem._subset(sl, wide), center[wide], f[wide]
g2 = np.linspace(*LAMBDA_WIDE, lem.WIDE_POINTS)
F0w = None if F0 is None else np.asarray(F0, float)[wide]
_, fm2, ff2 = lambda_curves(sub, cw, fw, g2, F0w)
F2, l2, it2, c2, r2 = _solve(sub, cw, fw, g2, fm2, ff2, F0w, LAMBDA_WIDE)
F[wide], lam[wide], it[wide], conv[wide], res[wide] = F2, l2, it[wide] + it2, c2, r2
ok = np.isfinite(F)
safe = np.where(ok & (F > 0), F, 1.0)
rm, rff, den = _fm_ff(sl, center, safe, np.where(ok, lam, 0.0), f)
p = lem._pre(sl)
N = forces(sl, safe, np.where(ok, lam, 0.0), f)[0]
conv = ok & (np.abs(rm - F) < 1e-4) & (np.abs(rff - F) < 1e-4)
conv &= (lam >= LAMBDA_RANGE[0]) & (lam <= LAMBDA_RANGE[1])
if F0 is not None:
conv &= (F > 0.5 * F0) & (F < 2.0 * F0)
malpha_ok = np.all(den >= lem.MALPHA_MIN, axis=1)
R = p["cu"] + N * sl["tanphi"]
good = sl["valid"] & conv & (F > lem.F_MIN) & malpha_ok & (R.sum(1) > 0)
@@ -191,6 +211,8 @@ def morgenstern_price(sl: dict, center: np.ndarray, kind="half_sine", F0=None, c
"iterations": it,
"converged": conv,
"malpha_ok": malpha_ok,
"residual": res,
"wide": wide,
"function": kind,
}
if curve:
+24 -11
View File
@@ -22,6 +22,9 @@ MALPHA_MIN = 0.2
#: 윗 덩이가 아랫 덩이를 내리누르면 λ < 0. 아래 한도 −1.25 = SLOPE/W GLE λ 범위 · 위는 작은 + 만 허용
#: (아랫 덩이를 들어 올리는 해 = 비현실 근 — Yamagami & Ueta 대조에서 λ ≈ +1 근이 F 를 9 % 낮췄음).
LAMBDA_RANGE = (-1.25, 0.25)
#: 위 범위에 해가 없을 때만 다시 찾는 넓은 범위(거친 훑기 → 뉴턴) — 그래도 없으면 「해 없음」
LAMBDA_WIDE = (-2.0, 2.0)
WIDE_POINTS = 41
#: 이보다 작은 F = 1/F 항이 터진 엉뚱한 근(실제 비탈 F 는 이 위) — 버림.
F_MIN = 0.05
TOL = 1e-6
@@ -467,20 +470,30 @@ def spencer(sl: dict, center: np.ndarray, F0=None, scan: bool = True) -> dict:
lam = np.zeros(S)
# 엉뚱한 근(F → 0 쪽) 거르기 — 엄밀해는 출발값(Bishop)의 절반~두 배 안에 있음
near = lambda f, ref: (f > 0.5 * ref) & (f < 2.0 * ref) # noqa: E731
F, lam, it, conv = _spencer_newton(sl, center, F, lam)
F, lam, it, conv, res = _spencer_newton(sl, center, F, lam)
conv &= near(F, start)
bad = ~conv & sl["valid"]
if scan and bad.any():
wide = np.zeros(S, bool)
for grid, rng in (
(None, LAMBDA_RANGE),
(np.linspace(*LAMBDA_WIDE, WIDE_POINTS), LAMBDA_WIDE),
):
bad = ~conv & sl["valid"]
if not (scan and bad.any()):
break
sub = _subset(sl, bad)
Fs, ls = _spencer_scan(sub, center[bad])
F2, l2, it2, c2 = _spencer_newton(sub, center[bad], Fs, ls)
F[bad], lam[bad], it[bad], conv[bad] = F2, l2, it[bad] + it2, c2 & near(F2, start[bad])
Fs, ls = _spencer_scan(sub, center[bad], grid)
F2, l2, it2, c2, r2 = _spencer_newton(sub, center[bad], Fs, ls, rng=rng)
F[bad], lam[bad], it[bad], res[bad] = F2, l2, it[bad] + it2, r2
conv[bad] = c2 & near(F2, start[bad])
wide[bad] = rng is LAMBDA_WIDE
out = _finish(sl, F, it, conv, lam)
out["lambda"] = np.where(np.isfinite(out["F"]), lam, np.nan)
out["residual"] = res
out["wide"] = wide
return out
def _spencer_newton(sl, center, F, lam, iters=25):
def _spencer_newton(sl, center, F, lam, iters=25, rng=LAMBDA_RANGE):
"""(F_m − F, F_f − F) = 0 뉴턴 — 걸음 크기 제한(F 절반 · λ 0.3) · 수렴 = 두 잔차 < 1e-4 · λ 범위 안.
끝난 면은 빼고(남은 면이 절반 아래면 묶음을 줄임) · F < F_MIN 으로 빠진 면은 버림(어차피 `_finish` 가 버림).
@@ -520,8 +533,8 @@ def _spencer_newton(sl, center, F, lam, iters=25):
rm = _fm(sl, center, F, lam, nd=nd)[0] - F
rf = _ff(sl, F, lam, nd=nd)[0] - F
conv = done & np.isfinite(F) & (F > 0) & (np.abs(rm) < 1e-4) & (np.abs(rf) < 1e-4)
conv &= (lam >= LAMBDA_RANGE[0]) & (lam <= LAMBDA_RANGE[1])
return F, lam, it, conv
conv &= (lam >= rng[0]) & (lam <= rng[1])
return F, lam, it, conv, np.maximum(np.abs(rm), np.abs(rf))
def lambda_curve(sl, center, grid=None):
@@ -538,9 +551,9 @@ def lambda_curve(sl, center, grid=None):
return grid, fm_all, ff_all
def _spencer_scan(sl, center):
def _spencer_scan(sl, center, grid=None):
"""λ 를 훑어 F_m(λ) − F_f(λ) 부호가 바뀌는 곳(λ=0 가까운 쪽)을 출발점으로."""
grid, fm_all, ff_all = lambda_curve(sl, center)
grid, fm_all, ff_all = lambda_curve(sl, center, grid)
S = len(sl["W"])
diff = fm_all - ff_all
best_F, best_l = np.full(S, 1.5), np.zeros(S)
@@ -487,6 +487,17 @@ def analyze(
if g is not None:
best_circles[m] = g
results[m]["circular"] = _describe(prep, "circle", g, m, n_slices, min_depth)
# 엄밀해(Spencer · M-P)가 Bishop 임계 원에서 해 없음 · 찾은 최소가 Bishop 보다 높음 = 임계 둘레에서
# 풀리는 원이 없어 먼 원을 고른 것 → 그 방법 원호 = 「해 없음」(Bishop 임계 원 · F None · 까닭 · F–λ 곡선)
ref = best_circles.get("bishop") if "bishop" in results else None
fb = (results.get("bishop", {}).get("circular") or {}).get("F")
for m in [k for k in ("spencer", "morgenstern_price") if k in methods and ref and fb]:
cur = results[m].get("circular")
if cur and cur["F"] is not None and cur["F"] <= fb:
continue
at = _describe(prep, "circle", ref, m, n_slices, min_depth)
if at["F"] is None:
results[m]["circular"] = at
if noncircular:
mv = polyline_vertices
seeds = []
+5 -1
View File
@@ -69,7 +69,11 @@ function methodTable(analysis: Analysis): HTMLElement {
const body = el("tr");
for (const m of METHODS) {
head.append(el("th", { className: "is-num", text: t(`E03_Method_${m}` as LocaleKey) }));
body.append(el("td", { className: "is-num", text: fmt(analysis.min_by_method[m], 3) }));
const v = analysis.min_by_method[m];
const unsolved = (v === null || v === undefined) && analysis.no_solution?.includes(m);
body.append(
el("td", { className: "is-num", text: unsolved ? t("E03_NoSolution") : fmt(v, 3) }),
);
}
table.append(el("thead", { children: [head] }), el("tbody", { children: [body] }));
return table;
+9
View File
@@ -251,6 +251,15 @@ def section_view(
"required": side_row.get("required"),
"ok": side_row.get("ok"),
"min_by_method": {m: _r(v) for m, v in (case.get("min_F") or {}).items()},
# 엄밀해(Spencer · M-P) 「해 없음」 면이 있는 방법 — 화면이 빈 값을 「수렴 안 됨」으로
"no_solution": sorted(
m
for m, found in methods.items()
if any(
(found.get(k) or {}).get("status") == "no_solution"
for k in ("circular", "noncircular")
)
),
"critical": _surface(best, PRIMARY),
"candidates": candidates,
"slices": _slices(best.get("slices") or []) if best else [],
@@ -83,6 +83,8 @@ export interface Analysis {
required: number | null;
ok: boolean | null;
min_by_method: Record<string, number | null>;
/** 「해 없음」 면이 있는 방법(Spencer · M-P) — 옛 저장본은 없음 */
no_solution?: string[];
critical: Surface | null;
candidates: Surface[];
slices: SliceRow[];
@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""532-2 덧 3 엄밀해(Spencer · M-P) 해 없음 — 넓은 λ 다시 찾기 · 「수렴 안 됨」 · 수렴 정보 칸.
- 해 없는 견본 = 검증_sub4 STA.0+340 우기 깎기 비탈을 줄인 꼴(풍화토 위 · 풍화암 · 지표 수위 · 노면 하중):
Bishop 임계 원에서 F_m · F_f 가 λ −2 ~ 2(M-P ±π)에서 안 만남 → 옛 엔진은 먼 원(F 1.82 · Bishop 1.34 의
136 %)을 Spencer 값으로 냈음 → 이제 Spencer · M-P 원호 = F None · status no_solution · 까닭 · 넓은 곡선.
- 해 있는 면 = 상태 converged · 잔차 < 1e-4 · λ 좁은 범위 안 · 곡선 21 점(옛 꼴 그대로).
./venv/Scripts/python.exe -m pytest resources/tester/test_532_2_stab_nosolution.py -q
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_532_2_stab_lem_benchmarks import ACADS_EXT, mats # noqa: E402
from E03_StabSection import E03_StabSection_Engine_GLE as gle # noqa: E402
from E03_StabSection import E03_StabSection_Engine_LEM as lem # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Search import ( # noqa: E402
analyze,
evaluate_specified,
)
MP = "morgenstern_price"
SURFACE = [
[-25.0, 876.6], [-17.0, 871.7], [-13.0, 867.7], [-11.5, 867.1],
[-3.9, 859.7], [-2.5, 856.6], [2.0, 857.0], [10.0, 850.3],
] # fmt: skip
NO_SOLUTION_MODEL = {
"materials": [
{"id": "ws", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 15.0, "phi": 30.0},
{"id": "wr", "gamma_t": 20.0, "gamma_sat": 21.0, "c": 30.0, "phi": 33.0},
],
"regions": [
{"material_id": "ws", "polygon": [
[-24.5, 876.5], [-16.5, 871.4], [-13.0, 867.7], [-11.5, 867.1], [-8.0, 863.0],
[-5.5, 861.4], [-3.5, 858.9], [-8.0, 862.5], [-11.5, 866.6], [-14.5, 868.5],
[-16.5, 870.9], [-25.0, 876.1],
]},
{"material_id": "ws", "polygon": [
[-0.2, 856.9], [3.5, 855.3], [10.0, 849.2], [3.5, 854.8], [1.0, 855.4], [-0.8, 856.9],
]},
{"material_id": "wr", "polygon": [
[-3.0, 857.6], [-2.5, 856.6], [-0.8, 856.9], [1.0, 855.4], [3.5, 854.8],
[10.0, 849.2], [10.0, 801.5], [-25.0, 801.5], [-25.0, 876.1], [-16.5, 870.9],
[-5.0, 860.5],
]},
],
"surface": SURFACE,
"groundwater": {"mode": "line", "line": SURFACE},
"surcharge": [{"x0": -2.0162, "x1": 2.0, "q": 10.0}],
"bottom_z": 801.5,
} # fmt: skip
@pytest.fixture(scope="module")
def unsolved():
return analyze(
NO_SOLUTION_MODEL, direction=1, limits=[-9.561, 2.0], min_depth=0.3, noncircular=False
)
def test_no_solution_reported_not_a_far_circle(unsolved):
ms = unsolved["methods"]
bishop = ms["bishop"]["circular"]
assert 1.30 < bishop["F"] < 1.40
for key, rng in (("spencer", lem.LAMBDA_WIDE), (MP, gle.LAMBDA_WIDE)):
c = ms[key]["circular"]
assert c["F"] is None and c["status"] == "no_solution" and c["converged"] is False
assert c["lambda"] is None and "해 없음" in c["reason"]
assert c["lambda_range"] == pytest.approx(list(rng))
# 「해 없음」 면 = Bishop 임계 원 · 최소 F 후보에서 빠짐
assert c["radius"] == pytest.approx(bishop["radius"]) and c["center"] == bishop["center"]
assert ms[key]["min"] is None
assert isinstance(c["iterations"], int)
assert c["residual"] is None or c["residual"] > 1e-4
# F_m · F_f – λ 곡선은 넓은 범위 그대로(화면 그래프로 해 없음이 보이게)
curve = c["lambda_curve"]
assert len(curve["lambda"]) == lem.WIDE_POINTS
assert curve["lambda"][0] == pytest.approx(rng[0]) and curve["lambda"][-1] == pytest.approx(
rng[1]
)
assert sum(v is not None for v in curve["Fm"]) > 10
# 판정 쪽(Bishop · Fellenius · Janbu)은 그대로
assert all(ms[k]["min"]["F"] is not None for k in ("ordinary", "bishop", "janbu"))
def test_spencer_curve_has_no_crossing_in_wide_range(unsolved):
curve = unsolved["methods"]["spencer"]["circular"]["lambda_curve"]
lam = np.array(curve["lambda"], float)
d = np.array(curve["Fm"], float) - np.array(curve["Ff"], float)
near0 = np.isfinite(d) & (lam <= 0.25)
assert near0.sum() > 20 and np.all(d[near0] < 0)
def test_solved_surface_status_and_shape_unchanged():
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", MP)
)
for key, rng in (("spencer", lem.LAMBDA_RANGE), (MP, gle.LAMBDA_RANGE)):
out = r[key]
assert out["status"] == "converged" and out["reason"] is None and out["converged"] is True
assert out["residual"] < 1e-4 and rng[0] <= out["lambda"] <= rng[1]
assert out["lambda_range"] == pytest.approx(list(rng))
assert len(out["lambda_curve"]["lambda"]) == gle.CURVE_POINTS
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))
+1
View File
@@ -98,6 +98,7 @@ export const ui_locales_e03 = {
E03_Converged: ["수렴", "Converged"],
E03_Yes: ["예", "Yes"],
E03_No: ["아니오", "No"],
E03_NoSolution: ["수렴 안 됨", "Not converged"],
E03_Iter: ["회", " iter."],
E03_Height: ["비탈 높이", "Slope height"],
E03_Center: ["원호 중심", "Center"],