feat(567-1): E03 2D 비원호 출발 셋 최솟값 · 판정 = 원호 Bishop · 비원호 Spencer · M-P 중 작은 값 · 면 확인 · 모형 끝 면 · 탐색 한계 모형 끝

- 비원호 꼭짓점 최적화를 난수 씨앗 셋으로 돌려 찾은 면 모두에서 방법별 최소(NONCIRC_STARTS 3)
- 판정 면 = 원호 Bishop · 비원호 Spencer · M-P 중 작은 값 · 비원호 < 원호 Bishop × 0.85 = surface_check · 판정 면 진입 · 진출이 모형 끝 1 m 안 = edge
- 탐색 한계 바깥 끝 = 모형 끝(요구 392 · Slide2 기본) · 설정 search_reach(model_end · 2h · m)
- 새 지킴 시험 test_567_1(원호 기본 대 촘촘 ≤ 0.5 % · 비원호 ≤ 3 % · 다출발 최소 · 한계 · 판정 칸)
- 낡은 시험 고침: ACADS 3a Spencer · ACADS 4 · M-P ACADS 4 비원호 탐색 허용 ±5 % — 다출발이 원문보다 낮은 면(약층 바닥 따라감 · 3a Spencer 1.217)을 찾음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:25:21 +09:00
co-authored by Claude Opus 5.5
parent 6d4964ae55
commit a843aa3ad3
6 changed files with 266 additions and 29 deletions
@@ -36,6 +36,8 @@ WALL_MATERIAL = {
"phi": 45.0,
}
DEFAULT_HALF_WIDTH_M = 40.0
#: 탐색 한계 바깥 끝 — "model_end"(모형 끝 · Slide2 기본) · "2h"(비탈 끝 + max(5 m, 2H)) · 수(비탈 끝 + m)
SEARCH_REACH = "model_end"
def _polys(geom) -> list[Polygon]:
@@ -180,11 +182,13 @@ def widen_ground(cross: dict, sampler, half_width: float = DEFAULT_HALF_WIDTH_M,
return sorted(ground + added)
def side_cases(design: dict, ground: list, line: list) -> list[dict]:
def side_cases(
design: dict, ground: list, line: list, reach: str | float = SEARCH_REACH
) -> list[dict]:
"""좌 · 우 비탈마다 역할(절토 · 성토) · 활동 방향 · 탐색 한계. 좌 = +offset.
탐색 한계 = 노면 반대쪽 끝 ~ 비탈 끝(설계선이 원지반과 만나는 점) 바깥 max(5 m, 2H) — 설계 비탈을
지나는 면만 봄(바깥 자연 비탈 전체 붕괴는 E 광역 · 2차 몫). H = 노면 끝 ~ 비탈 끝 높이차.
탐색 한계 = 노면 반대쪽 끝 ~ reach(`SEARCH_REACH` · 기본 모형 끝 = 깎기 위 · 쌓기 아래 자연 비탈 큰 면까지).
H = 노면 끝 ~ 비탈 끝(설계선이 원지반과 만나는 점) 높이차.
"""
mode = design.get("section_mode") or "right_cut"
roles = {
@@ -215,10 +219,13 @@ def side_cases(design: dict, ground: list, line: list) -> list[dict]:
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])
if reach == "model_end":
far = float(gx[-1] if sign > 0 else gx[0])
else:
far = day + sign * (max(5.0, 2.0 * H) if reach == "2h" else float(reach))
far = min(max(far, gx[0]), gx[-1])
inner = right_edge if side == "left" else left_edge
limits = tuple(sorted((inner, reach)))
limits = tuple(sorted((inner, far)))
direction = int(-sign) if inward else int(sign)
out.append(
{"side": side, "role": role, "direction": direction, "limits": limits, "height_m": H}
@@ -237,6 +244,7 @@ def section_model_from_b06(
seismic_kh: float = 0.0,
road_surcharge_kpa: float = DEFAULT_ROAD_SURCHARGE_KPA,
walls: list[dict] | None = None,
search_reach: str | float = SEARCH_REACH,
) -> dict:
"""B06 횡단 파일(samples · frame) + 설계(design) → 해석 모형 + 비탈 목록(`sides`).
@@ -244,6 +252,7 @@ def section_model_from_b06(
layers 기본 = 토사 측점 풍화토 한 층 · 암 측점 풍화토(암 경계까지) + 풍화암 / 연암.
wet = 우기 — 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3 · 토사 · 풍화암 지표 포화).
노면 하중 = 노면 끝 사이 등분포. 벽 도형(walls)은 B06 화면 산식(TS)이 그리므로 부르는 쪽이 넘김.
search_reach = 탐색 한계 바깥 끝(`side_cases` reach).
"""
ground = ground or ground_from_cross(cross)
line = [
@@ -281,7 +290,7 @@ def section_model_from_b06(
surcharge=loads,
area_range=(line[0][0], line[-1][0]) if line else None,
)
model["sides"] = side_cases(design, ground, line or ground)
model["sides"] = side_cases(design, ground, line or ground, search_reach)
model["station"] = {"chainage_m": cross.get("chainage_m"), "label": cross.get("label")}
return model
@@ -40,6 +40,8 @@ SPENCER_SEED_POOL = 300
CROSS_TOP = 10
#: Spencer 원호 씨앗 — Bishop 임계 둘레 구름 크기
CLOUD_SIZE = 200
#: 비원호 출발 횟수 — 난수 씨앗 · 출발점을 바꿔 여러 번 찾아 최소(한 번은 국부 최소에 갇혀 ±2~4 % 흔들림)
NONCIRC_STARTS = 3
#: 엄밀해 「해 없음」 허용 — Bishop 임계 원에서 해 없어도 찾은 최소가 Bishop × (1 + 이 값) 안이면 값을 살림
FAR_TOL = 0.02
@@ -526,14 +528,17 @@ def analyze(
# Bishop)은 비원호 중심이 임의라 따로 최적화하지 않고 찾은 면에서 값만 셈(참고 칸 · 상용 SW 도
# 비원호 최적화 = Janbu · Spencer · GLE) · M-P 는 찾은 면에서 값만
found = []
for m in [k for k in ("janbu", "spencer") if k in methods]:
iters = LS_ITER_SPENCER if m == "spencer" else LS_ITER
fmin, _x, geom, top = optimize_polyline(
prep, lim, m, seeds, n_slices, min_depth, mv, seed, iters
)
seeds = [*top, *seeds]
if fmin < _BIG:
found.append(geom)
for k in range(NONCIRC_STARTS):
# 출발마다 난수 씨앗 + 출발점 묶음(가장 좋은 씨앗 + 다음 차례 7 개)을 바꿈
run = list(seeds)
for m in [x for x in ("janbu", "spencer") if x in methods]:
iters = LS_ITER_SPENCER if m == "spencer" else LS_ITER
fmin, _x, geom, top = optimize_polyline(
prep, lim, m, run, n_slices, min_depth, mv, seed + k, iters
)
run = [*top, *run]
if fmin < _BIG:
found.append(geom)
for m in methods:
g = _cross(prep, "polyline", found, m, n_slices, min_depth)
if g is not None:
@@ -551,6 +556,7 @@ def analyze(
out = {
"direction": s,
"limits": sorted([s * lim[0], s * lim[1]]),
"model_x": sorted([s * float(prep.surface_x[0]), s * float(prep.surface_x[-1])]),
"methods": results,
"contour": contour,
"safety_map": smap,
+60 -9
View File
@@ -1,7 +1,9 @@
"""E03 2D 해석 결과 → 화면 꼴 (요구 345 · 347 · PLAN 532-3) — storage 를 모르는 순수 함수.
엔진 출력 = `E03_StabSection_Engine_Station.analyze_station` 그대로(cases × methods).
판정 = 대표 방법(Bishop) 최소 안전율 ≥ 기준안전율(E02 `safety_factors[조건][깎기 · 쌓기]`).
판정 = 원호 Bishop · 비원호 Spencer · M-P 중 작은 값 ≥ 기준안전율(E02 `safety_factors[조건][깎기 · 쌓기]` ·
지식DB 06 해석법 조합) · 비원호가 원호 Bishop 보다 15 % 넘게 낮으면 「면 확인」(SLOPE/W 최적화 기준) ·
판정 면 진입 · 진출이 모형 끝 1 m 안이면 「모형 끝 면」(edge · 값이 모형 폭에 따라 바뀜).
대표 단면(보고서 몫) = 자동(쌓기 높이 ≥ 10 m · 깎기 ≥ 15 m · 최소 안전율 하위 5) + 사용자 켬/끔.
좌표 = 엔진 그대로(x = B06 offset · 좌 + · z = 표고) — 화면이 같은 좌표로 그림.
"""
@@ -12,6 +14,16 @@ import math
from typing import Any
PRIMARY = "bishop"
#: 판정 후보(방법 · 면 꼴)
GOVERNING = (
("bishop", "circular"),
("spencer", "noncircular"),
("morgenstern_price", "noncircular"),
)
#: 비원호 F < 원호 Bishop F × 이 값 = 「면 확인」
SURFACE_CHECK_RATIO = 0.85
#: 판정 면 진입 · 진출이 모형 끝에서 이 거리(m) 안 = 「모형 끝 면」
EDGE_M = 1.0
CONDITIONS = ("dry", "wet", "seismic")
REPRESENTATIVE_FILL_M = 10.0
REPRESENTATIVE_CUT_M = 15.0
@@ -34,9 +46,31 @@ def required_factor(factors: dict[str, Any], condition: str, role: str) -> float
return float(value) if isinstance(value, int | float) else None
def _governing(case: dict[str, Any]) -> dict[str, Any] | None:
"""한 비탈 · 조건의 대표 방법 최소 면(원호 · 비원호 중 작은 쪽)."""
return ((case.get("result") or {}).get("methods") or {}).get(PRIMARY, {}).get("min")
def _governing(case: dict[str, Any]) -> tuple[str, dict[str, Any] | None, float | None, bool]:
"""한 비탈 · 조건의 판정 면 — (방법, 면, 원호 Bishop F, 면 확인).
후보가 없으면(옛 결과 · 원호만 셈) Bishop 최소."""
methods = (case.get("result") or {}).get("methods") or {}
circ = ((methods.get(PRIMARY) or {}).get("circular") or {}).get("F")
cands = [
(m, found)
for m, kind in GOVERNING
if (found := (methods.get(m) or {}).get(kind)) and found.get("F") is not None
]
if not cands:
return PRIMARY, (methods.get(PRIMARY) or {}).get("min"), circ, False
method, best = min(cands, key=lambda c: c[1]["F"])
check = method != PRIMARY and circ is not None and best["F"] < SURFACE_CHECK_RATIO * circ
return method, best, circ, check
def _edge(case: dict[str, Any], best: dict[str, Any] | None) -> bool | None:
"""판정 면이 모형 끝에 닿음 — 옛 결과(모형 끝 모름) · 면 없음 = None."""
ends = (case.get("result") or {}).get("model_x")
if not ends or not best or not best.get("entry") or not best.get("exit"):
return None
xs = (best["entry"][0], best["exit"][0])
return bool(min(xs) <= ends[0] + EDGE_M or max(xs) >= ends[1] - EDGE_M)
def station_row(
@@ -51,7 +85,7 @@ def station_row(
for case in (out or {}).get("cases", []):
cond, side, role = case["condition"], case["side"], case["role"]
sides.setdefault(side, {"side": side, "role": role, "height_m": _r(case.get("height_m"))})
best = _governing(case)
method, best, circ, check = _governing(case)
fs = best.get("F") if best else None
need = required_factor(factors, cond, role)
cases.setdefault(cond, {})[side] = {
@@ -60,6 +94,10 @@ def station_row(
"ok": None if fs is None or need is None else bool(fs >= need),
"kind": best.get("kind") if best else None,
"converged": best.get("converged") if best else None,
"method": method if best else None,
"fs_circular": _r(circ),
"surface_check": check,
"edge": _edge(case, best),
}
minimum: dict[str, float | None] = {}
verdict: dict[str, str] = {}
@@ -81,6 +119,9 @@ def station_row(
"cases": cases,
"min": minimum,
"verdict": verdict,
"surface_check": any(
v["surface_check"] for sides in cases.values() for v in sides.values()
),
"error": error,
"elapsed_s": (out or {}).get("elapsed_s"),
}
@@ -137,7 +178,7 @@ def governing_row(row: dict[str, Any]) -> dict[str, Any]:
cases = row.get("cases") or {}
worst = min(
(
(c["fs"] / c["required"], side, c["fs"])
(c["fs"] / c["required"], side, c["fs"], c.get("method") or PRIMARY)
for sides in cases.values()
for side, c in sides.items()
if c.get("fs") is not None and c.get("required")
@@ -155,7 +196,7 @@ def governing_row(row: dict[str, Any]) -> dict[str, Any]:
"fs_by_condition": {
cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides
},
"method": PRIMARY,
"method": worst[3] if worst else PRIMARY,
"verdict_overall": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None,
}
@@ -168,6 +209,14 @@ def summarize(rows: list[dict[str, Any]], conditions: list[str]) -> dict[str, An
out[cond] = {
"min_fs": min(values) if values else None,
"ng": sum(1 for r in rows if r["verdict"].get(cond) == "ng"),
"surface_check": sum(
1
for r in rows
if any(v.get("surface_check") for v in (r["cases"].get(cond) or {}).values())
),
"edge": sum(
1 for r in rows if any(v.get("edge") for v in (r["cases"].get(cond) or {}).values())
),
}
return out
@@ -231,7 +280,7 @@ def section_view(
for case in out.get("cases", []):
result = case.get("result") or {}
methods = result.get("methods") or {}
best = (methods.get(PRIMARY) or {}).get("min")
method, best, _circ, _check = _governing(case)
candidates = []
for method, found in methods.items():
for kind in ("circular", "noncircular"):
@@ -250,6 +299,8 @@ def section_view(
"direction": case.get("direction"),
"required": side_row.get("required"),
"ok": side_row.get("ok"),
"surface_check": side_row.get("surface_check"),
"edge": side_row.get("edge"),
"min_by_method": {m: _r(v) for m, v in (case.get("min_F") or {}).items()},
# 엄밀해(Spencer · M-P) 「해 없음」 면이 있는 방법 — 화면이 빈 값을 「수렴 안 됨」으로
"no_solution": sorted(
@@ -260,7 +311,7 @@ def section_view(
for k in ("circular", "noncircular")
)
),
"critical": _surface(best, PRIMARY),
"critical": _surface(best, method),
"candidates": candidates,
"slices": _slices(best.get("slices") or []) if best else [],
"grid": (
@@ -175,9 +175,10 @@ def test_acads_3b_weak_seam_specified_polyline():
def test_acads_3a_weak_seam_noncircular_search():
# S2VM #7 (ACADS 3a) — 블록 탐색 Janbu 보정 1.275 · Spencer 1.258(심판값 1.24~1.27)
# 비원호 다출발(567-1)이 약층 바닥을 따르는 더 낮은 면을 찾음(Spencer 1.217 · −3.3 %) → ±5 %
r = analyze(_acads3_model(), direction=-1, methods=("janbu", "spencer"))
near(nonc(r, "janbu"), 1.275)
near(nonc(r, "spencer"), 1.258)
near(nonc(r, "spencer"), 1.258, 0.05)
def _acads4_model():
@@ -213,8 +214,8 @@ def _acads4_model():
def test_acads_4_inclined_seam_water_surcharge():
# S2VM #9 — 최적화 비원호 Spencer 0.707 · Janbu 보정 0.699 (약층 좌표 = 저해상 그림 판독 — 출처 ±5 % 권함)
r = analyze(_acads4_model(), direction=-1, methods=("janbu", "spencer"))
near(nonc(r, "spencer"), 0.707)
near(nonc(r, "janbu"), 0.699)
near(nonc(r, "spencer"), 0.707, 0.05)
near(nonc(r, "janbu"), 0.699, 0.05)
FK_EXT = [[0, 0], [180, 0], [180, 20], [140, 20], [60, 60], [0, 60]]
@@ -81,9 +81,9 @@ def test_mp_search_slide2():
def test_mp_acads4_noncircular():
# #9 블록 + 최적화 GLE 0.683 (약층 좌표 = 그림 판독 → ±2 %)
# #9 블록 + 최적화 GLE 0.683 (약층 좌표 = 그림 판독 · 비원호 다출발이 더 낮은 면을 찾음 → ±5 %)
r = analyze(_acads4_model(), direction=-1, methods=("janbu", "spencer", MP))
near(nonc(r, MP), 0.683, 0.02)
near(nonc(r, MP), 0.683, 0.05)
def _closure(rows, lam, f_last=1.0):
+170
View File
@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
"""567-1 E03 2D 탐색 지킴 — 수렴(기본 대 촘촘) · 비원호 다출발 · 탐색 한계 = 모형 끝 · 판정 = 원호 Bishop ·
비원호 Spencer · M-P 중 작은 값 · 「면 확인」(비원호 < 원호 Bishop × 0.85).
./venv/Scripts/python.exe -m pytest resources/tester/test_567_1_stab_search.py -q -s
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from E03_StabSection import E03_StabSection_Engine_Search as search # noqa: E402
from E03_StabSection import E03_StabSection_View as view # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Model import side_cases # noqa: E402
from test_532_2_stab_lem_benchmarks import ( # noqa: E402
ACADS1C_MATS,
ACADS1C_REGIONS,
ACADS_EXT,
_acads3_model,
_acads4_model,
mats,
)
from test_532_2_stab_section_model import DESIGN, GROUND # noqa: E402
DENSE_CIRCLE = {"grid": (30, 30, 20), "entry_exit_points": 50}
CIRCLE_TOL = 0.005
NONCIRC_TOL = 0.03
def _f(res, method, kind):
return (res["methods"][method].get(kind) or {}).get("F")
def _diff(a, b):
return abs(b / a - 1)
def test_circular_default_vs_dense():
# 원호 = 판정 값 · 격자 4 배 · 진입진출 2 배에도 차 ≤ 0.5 %
cases = {
"1a": {
"materials": mats(("s", 20, 3, 19.6)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
},
"1c": {"materials": ACADS1C_MATS, "regions": ACADS1C_REGIONS},
}
for name, model in cases.items():
base = search.analyze(model, direction=-1, noncircular=False)
dense = search.analyze(model, direction=-1, noncircular=False, **DENSE_CIRCLE)
for m in search.METHODS:
a, b = _f(base, m, "circular"), _f(dense, m, "circular")
print(f"ACADS {name} {m} 원호 기본 {a:.4f} 촘촘 {b:.4f} 차 {(b / a - 1) * 100:+.2f} %")
assert _diff(a, b) <= CIRCLE_TOL
def test_noncircular_default_vs_dense(monkeypatch):
# 비원호 = 출발 셋 기본 대 출발 여섯 · 걸음 두 배 — 차 ≤ 3 %(기록 · 3a +0.2 % · 4 −1.8 %)
for name, make in (("3a", _acads3_model), ("4", _acads4_model)):
base = search.analyze(make(), direction=-1, methods=("janbu", "spencer"))
with monkeypatch.context() as mp:
mp.setattr(search, "NONCIRC_STARTS", 2 * search.NONCIRC_STARTS)
mp.setattr(search, "LS_ITER", 2 * search.LS_ITER)
mp.setattr(search, "LS_ITER_SPENCER", 2 * search.LS_ITER_SPENCER)
dense = search.analyze(make(), direction=-1, methods=("janbu", "spencer"))
for m in ("janbu", "spencer"):
a, b = _f(base, m, "noncircular"), _f(dense, m, "noncircular")
print(
f"ACADS {name} {m} 비원호 기본 {a:.4f} 촘촘 {b:.4f} 차 {(b / a - 1) * 100:+.2f} %"
)
assert _diff(a, b) <= NONCIRC_TOL
def test_multistart_takes_minimum(monkeypatch):
# 출발 셋 = 출발 하나(같은 씨앗)를 안에 품음 → 같거나 낮음
model = _acads4_model()
with monkeypatch.context() as mp:
mp.setattr(search, "NONCIRC_STARTS", 1)
one = search.analyze(model, direction=-1, methods=("janbu", "spencer"))
three = search.analyze(model, direction=-1, methods=("janbu", "spencer"))
assert search.NONCIRC_STARTS == 3
for m in ("janbu", "spencer"):
assert _f(three, m, "noncircular") <= _f(one, m, "noncircular") + 1e-12
def test_limits_reach_model_end():
# 탐색 한계 = 노면 반대쪽 끝 ~ 모형 끝(요구 392 · Slide2 기본)
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["limits"] == (-2.0, GROUND[-1][0])
assert right["limits"] == (GROUND[0][0], 2.0)
# 설정 — 우 깎기 비탈 끝 −6 · H 3.6 → 옛 정책 −6 − max(5, 2H) · 정한 6 m → −12
assert side_cases(design, GROUND, DESIGN, "2h")[1]["limits"] == (-13.2, 2.0)
assert side_cases(design, GROUND, DESIGN, 6.0)[1]["limits"] == (-12.0, 2.0)
def _case(bishop_c, spencer_n=None, mp_n=None, cond="wet", side="right", role="cut", ends=None):
def found(F, kind):
if F is None:
return None
return {"F": F, "kind": kind, "converged": True, "entry": [-30.0, 20.0], "exit": [2.0, 9.0]}
methods = {
"bishop": {"circular": found(bishop_c, "circle"), "min": found(bishop_c, "circle")},
"spencer": {"circular": found(9.0, "circle"), "noncircular": found(spencer_n, "polyline")},
"morgenstern_price": {"noncircular": found(mp_n, "polyline")},
}
result = {"methods": methods, **({"model_x": ends} if ends else {})}
return {"condition": cond, "side": side, "role": role, "result": result}
def _row(*cases):
meta = {"key": "k", "chainage_m": 0.0}
return view.station_row(meta, {"cases": list(cases)}, {"wet": {"cut": 1.2}})
def test_governing_minimum_and_surface_check():
# 비원호 Spencer 1.0 < Bishop 1.3 × 0.85 → 판정 = Spencer 1.0 N.G · 면 확인
c = _row(_case(1.3, 1.0, 1.05))["cases"]["wet"]["right"]
assert (c["method"], c["kind"], c["fs"], c["ok"]) == ("spencer", "polyline", 1.0, False)
assert c["fs_circular"] == 1.3 and c["surface_check"] is True
# 비원호 M-P 1.15 · 15 % 안 → 판정 = M-P N.G · 면 확인 아님
c = _row(_case(1.3, 1.2, 1.15))["cases"]["wet"]["right"]
assert (c["method"], c["fs"], c["ok"], c["surface_check"]) == (
"morgenstern_price",
1.15,
False,
False,
)
# 비원호가 더 높음 → Bishop 원호 그대로(Spencer 원호 9.0 은 후보 아님)
row = _row(_case(1.3, 1.4, None))
c = row["cases"]["wet"]["right"]
assert (c["method"], c["kind"], c["fs"], c["ok"]) == ("bishop", "circle", 1.3, True)
assert row["surface_check"] is False and row["verdict"]["wet"] == "ok"
# 지배 줄 · 요약도 판정 방법 · 면 확인 수를 따름
row = _row(_case(1.3, 1.0))
assert view.governing_row(row)["method"] == "spencer"
assert view.summarize([row], ["wet"])["wet"] == {
"min_fs": 1.0,
"ng": 1,
"surface_check": 1,
"edge": 0,
}
def test_old_result_without_noncircular_keeps_bishop():
# 옛 결과(원호 Bishop min 만) → 판정 그대로
case = {
"condition": "wet",
"side": "right",
"role": "cut",
"result": {"methods": {"bishop": {"min": {"F": 1.25, "kind": "circle"}}}},
}
c = _row(case)["cases"]["wet"]["right"]
assert (c["method"], c["fs"], c["ok"], c["surface_check"]) == ("bishop", 1.25, True, False)
def test_edge_flag():
# 판정 면 진입 −30 · 진출 2 — 모형 끝 −30.5 면 1 m 안 = edge · −40 이면 아님 · 모형 끝 모름 = None
for ends, edge in (([-30.5, 40.0], True), ([-40.0, 40.0], False), (None, None)):
row = _row(_case(1.3, ends=ends))
assert row["cases"]["wet"]["right"]["edge"] is edge
assert view.summarize([_row(_case(1.3, ends=[-30.5, 40.0]))], ["wet"])["wet"]["edge"] == 1