From a843aa3ad34e0e14f56379b71cf10e2e24360e55 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:25:21 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(567-1):=20E03=202D=20=EB=B9=84?= =?UTF-8?q?=EC=9B=90=ED=98=B8=20=EC=B6=9C=EB=B0=9C=20=EC=85=8B=20=EC=B5=9C?= =?UTF-8?q?=EC=86=9F=EA=B0=92=20=C2=B7=20=ED=8C=90=EC=A0=95=20=3D=20?= =?UTF-8?q?=EC=9B=90=ED=98=B8=20Bishop=20=C2=B7=20=EB=B9=84=EC=9B=90?= =?UTF-8?q?=ED=98=B8=20Spencer=20=C2=B7=20M-P=20=EC=A4=91=20=EC=9E=91?= =?UTF-8?q?=EC=9D=80=20=EA=B0=92=20=C2=B7=20=EB=A9=B4=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=C2=B7=20=EB=AA=A8=ED=98=95=20=EB=81=9D=20=EB=A9=B4=20=C2=B7?= =?UTF-8?q?=20=ED=83=90=EC=83=89=20=ED=95=9C=EA=B3=84=20=EB=AA=A8=ED=98=95?= =?UTF-8?q?=20=EB=81=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비원호 꼭짓점 최적화를 난수 씨앗 셋으로 돌려 찾은 면 모두에서 방법별 최소(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 --- .../E03_StabSection_Engine_Model.py | 23 ++- .../E03_StabSection_Engine_Search.py | 22 ++- E03_StabSection/E03_StabSection_View.py | 69 ++++++- .../tester/test_532_2_stab_lem_benchmarks.py | 7 +- .../tester/test_532_2_stab_mp_outputs.py | 4 +- resources/tester/test_567_1_stab_search.py | 170 ++++++++++++++++++ 6 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 resources/tester/test_567_1_stab_search.py diff --git a/E03_StabSection/E03_StabSection_Engine_Model.py b/E03_StabSection/E03_StabSection_Engine_Model.py index c706dca2c..ae2bf5dd7 100644 --- a/E03_StabSection/E03_StabSection_Engine_Model.py +++ b/E03_StabSection/E03_StabSection_Engine_Model.py @@ -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 diff --git a/E03_StabSection/E03_StabSection_Engine_Search.py b/E03_StabSection/E03_StabSection_Engine_Search.py index a5ba221c4..b62f8c287 100644 --- a/E03_StabSection/E03_StabSection_Engine_Search.py +++ b/E03_StabSection/E03_StabSection_Engine_Search.py @@ -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, diff --git a/E03_StabSection/E03_StabSection_View.py b/E03_StabSection/E03_StabSection_View.py index 5cf3e5296..26ee18526 100644 --- a/E03_StabSection/E03_StabSection_View.py +++ b/E03_StabSection/E03_StabSection_View.py @@ -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": ( diff --git a/resources/tester/test_532_2_stab_lem_benchmarks.py b/resources/tester/test_532_2_stab_lem_benchmarks.py index 676d0a48f..7312ecedb 100644 --- a/resources/tester/test_532_2_stab_lem_benchmarks.py +++ b/resources/tester/test_532_2_stab_lem_benchmarks.py @@ -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]] diff --git a/resources/tester/test_532_2_stab_mp_outputs.py b/resources/tester/test_532_2_stab_mp_outputs.py index 71d592357..51d7d5434 100644 --- a/resources/tester/test_532_2_stab_mp_outputs.py +++ b/resources/tester/test_532_2_stab_mp_outputs.py @@ -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): diff --git a/resources/tester/test_567_1_stab_search.py b/resources/tester/test_567_1_stab_search.py new file mode 100644 index 000000000..ae684089f --- /dev/null +++ b/resources/tester/test_567_1_stab_search.py @@ -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 From 9d67c3eba6fed5dc8d646f4cb0892d290802dd74 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:25:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(567-2):=20E04=20=EC=9E=84=EA=B3=84=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=EC=B2=B4=20=EA=B8=B0=EB=91=A5=20=EC=88=98=20?= =?UTF-8?q?=C2=B7=20100=20=EB=AF=B8=EB=A7=8C=20=EA=B2=BD=EA=B3=A0=20=C2=B7?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95=20Scoops3D=20?= =?UTF-8?q?=EC=9E=AC=ED=98=84=20=EC=8B=9C=ED=97=98=20=C2=B7=20E07=20?= =?UTF-8?q?=EC=A7=91=EC=88=98=EB=A9=B4=EC=A0=81=20D=E2=88=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E04 best · stats 에 columns · few_columns(< 100 · Scoops3D limcol) - 시험 533_3: 기본 설정 그대로 · 상자 없이 제방 30° 1.3203 · D&G 1a 1.0385 · 제방 60° 0.7488(±2 %) · 기둥 경고 - E07 집수면적 = D∞(Tarboton 1997 · SINMAP 원본) · 내리막 없는 채운 평지만 D8 - 시험 536_2: D∞ 면 몫(22.5° = 반씩) · 30° 돌린 평면 줄무늬 표준편차 0.05(D8 0.16) Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Engine_Search.py | 12 +++- E07_StabArea/E07_StabArea_Engine.py | 68 ++++++++++++++++++++--- resources/tester/test_533_3_e04_engine.py | 35 ++++++++++++ resources/tester/test_536_2_e07_area.py | 18 +++++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_Engine_Search.py b/E04_Stab3D/E04_Stab3D_Engine_Search.py index c62c0fd7f..46d9863d6 100644 --- a/E04_Stab3D/E04_Stab3D_Engine_Search.py +++ b/E04_Stab3D/E04_Stab3D_Engine_Search.py @@ -40,6 +40,8 @@ from scipy import ndimage from E04_Stab3D.E04_Stab3D_Engine_Column import Columns, Strata, solve TRUNCATION_JANBU = 0.10 +#: 임계 활동체 기둥(셀) 수 경고 문턱 — Scoops3D limcol 기본(≥ 200 개면 F 1 % 안 · Reid 외 2015 §5.3.4) +MIN_COLUMNS = 100 _STRUCT = np.zeros((3, 3, 3), bool) _STRUCT[1] = True # 같은 후보 안에서만 8 이웃 · 후보끼리는 안 이음 BATCH_CELLS = 3_000_000 # 한 번에 만드는 (후보 × 창) 칸 수 — 메모리 약 0.5 GB 아래 @@ -541,6 +543,9 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult: if pool: # 끊기(InterruptedError) · 오류에도 일꾼을 닫음 pool.shutdown(cancel_futures=True) f_out = np.where(np.isfinite(f_min), f_min, np.nan) + if "cells" in best: # 기둥 수 · 경고(셀이 굵으면 F 오차가 큼 — 셀 줄임 권함) + best["columns"] = len(best["cells"]) + best["few_columns"] = best["columns"] < MIN_COLUMNS return SearchResult( f_min=f_out, volume=volume, @@ -549,7 +554,12 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult: trials=counters["trials"], evaluated=counters["evaluated"], seconds=time.perf_counter() - t0, - stats={"nodes": len(seen), "cells": int(np.isfinite(f_out).sum())}, + stats={ + "nodes": len(seen), + "cells": int(np.isfinite(f_out).sum()), + "columns": best.get("columns"), + "few_columns": best.get("few_columns"), + }, ) diff --git a/E07_StabArea/E07_StabArea_Engine.py b/E07_StabArea/E07_StabArea_Engine.py index 1ff42172b..e6ce7ee40 100644 --- a/E07_StabArea/E07_StabArea_Engine.py +++ b/E07_StabArea/E07_StabArea_Engine.py @@ -3,8 +3,9 @@ 순수 계산(storage 모름) — D 예정노선 재해 안전성도 같은 엔진을 씀(한 벌). 근거 = 지식DB `original/예정노선/02` §1.1 · §2 ① · `original/안정성평가/04`(붕적토 범위). - 지형 DEM → 고른 격자(cell_m) · 경사 β(Horn 3×3) · D8 흐름(웅덩이 채움 · 수신 셀 = B04 배수유역 - 함수 재사용) · 단위 집수면적 a = 상류 셀 수 × cell²/cell(등고선 폭 = cell) + 지형 DEM → 고른 격자(cell_m) · 경사 β(Horn 3×3) · D∞ 흐름(Tarboton 1997 · SINMAP 원본 · 웅덩이 채움 + = B04 배수유역 함수 재사용 · 내리막 없는 평지만 D8) · 단위 집수면적 a = 상류 셀 몫 합 × cell²/cell + (등고선 폭 = cell) 포화비 m 시나리오 둘 sinmap(기본 · SINMAP 표준) = 습윤지수 W = min(x·a / sinβ, 1) · x = R/T ∈ [1/(T/R)_hi, 1/(T/R)_lo] saturated(최악 · 강우 극한) = 지표 포화 m = 1 @@ -98,8 +99,54 @@ def slope_rad(z: np.ndarray, cell_m: float) -> np.ndarray: return np.arctan(np.hypot(gx, gy)) +#: D∞ 삼각 면 여덟(Tarboton 1997 표 1) — (e1 = 곁 이웃 행·열, e2 = 대각 이웃 행·열) +_FACETS = ( + ((0, 1), (-1, 1)), + ((-1, 0), (-1, 1)), + ((-1, 0), (-1, -1)), + ((0, -1), (-1, -1)), + ((0, -1), (1, -1)), + ((1, 0), (1, -1)), + ((1, 0), (1, 1)), + ((0, 1), (1, 1)), +) + + +def dinf_receivers(z: np.ndarray, cell_m: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """D∞ — 셀마다 가장 가파른 면의 받는 셀 둘(평면 번호 · 없으면 −1) · e1 몫 = 1 − r/(π/4). + + 면 기울기 s1 = (e0 − e1)/d · s2 = (e1 − e2)/d · r = atan(s2/s1) 를 [0, π/4] 로 자름(끝이면 한 셀로). + NaN(밖) 이웃이 낀 면은 안 봄.""" + ny, nx = z.shape + p = np.pad(z.astype(float), 1, constant_values=np.nan) + iy, ix = np.indices((ny, nx)) + best = np.zeros((ny, nx)) + r1 = np.full((ny, nx), -1, np.int64) + r2 = np.full((ny, nx), -1, np.int64) + f1 = np.ones((ny, nx)) + quarter = math.pi / 4 + with np.errstate(invalid="ignore"): + for (a, b), (c, d) in _FACETS: + e1 = p[1 + a : 1 + a + ny, 1 + b : 1 + b + nx] + e2 = p[1 + c : 1 + c + ny, 1 + d : 1 + d + nx] + s1 = (z - e1) / cell_m + s2 = (e1 - e2) / cell_m + r = np.arctan2(s2, s1) + s = np.where( + r < 0, + s1, + np.where(r > quarter, (z - e2) / (cell_m * math.sqrt(2)), np.hypot(s1, s2)), + ) + up = np.isfinite(s) & (s > best) + best[up] = s[up] + r1[up] = ((iy + a) * nx + ix + b)[up] + r2[up] = ((iy + c) * nx + ix + d)[up] + f1[up] = 1.0 - np.clip(r, 0.0, quarter)[up] / quarter + return r1, r2, f1 + + def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray: - """D8 단위 집수면적 a(m) = 자기 포함 상류 셀 수 × cell. 밖 = NaN. + """D∞ 단위 집수면적 a(m) = 자기 포함 상류 셀 몫 합 × cell. 밖 = NaN · 내리막 없는 셀(채운 평지) = D8. ponytail: 지형 격자 끝 밖 상류는 모름(복사 DTM = 노선 띠) — 띠 끝 셀은 a 가 작게 나옴 · 넓은 DEM(D 예정노선 등)이면 그대로 맞음. @@ -107,15 +154,22 @@ def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray: filled, valid = condition_surface(z.astype(np.float32)) spec = SimpleNamespace(n_rows=z.shape[0], n_cols=z.shape[1], cell_m=cell_m) receiver, _ = compute_receivers(spec, filled, valid) + r1, r2, f1 = dinf_receivers(np.where(valid, filled, np.nan), cell_m) + d8 = r1 < 0 + r1[d8] = receiver.reshape(z.shape)[d8] + r2[d8] = -1 + f1[d8] = 1.0 flat = filled.ravel() order = np.flatnonzero(valid.ravel()) order = order[np.argsort(-flat[order], kind="stable")].tolist() # 높은 데서 낮은 데로 acc = valid.ravel().astype(float).tolist() - recv = receiver.tolist() + one, two, share = r1.ravel().tolist(), r2.ravel().tolist(), f1.ravel().tolist() for i in order: - r = recv[i] - if r != i: - acc[r] += acc[i] + j, f = one[i], share[i] + if j != i: + acc[j] += acc[i] * f + if f < 1.0: + acc[two[i]] += acc[i] * (1.0 - f) out = np.asarray(acc).reshape(z.shape) * cell_m return np.where(valid, out, np.nan) diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py index 25d77abdc..0321582d6 100644 --- a/resources/tester/test_533_3_e04_engine.py +++ b/resources/tester/test_533_3_e04_engine.py @@ -217,3 +217,38 @@ def test_ground_order_and_label_map(tmp_path): assert ids == ["fill", "weathered_soil", "weathered_rock", "soft_rock"] mapped = grid_engine.strata_from_grid(grid, label_map={"soil": "colluvium", "fill": "fill"}) assert [m["id"] for m in mapped.materials][:2] == ["fill", "colluvium"] + + +def _embankment(run_m, gamma, phi, c, half=20.0): + """Scoops3D 검증 제방(H 10 m) — 탐색 상자 없이 격자 전체 · y ±20 m.""" + x = np.arange(-25, run_m + 35 + 1e-9, 1.0) + y = np.arange(-half, half + 1e-9, 1.0) + xx, _ = np.meshgrid(x, y) + z = np.clip(xx / run_m * 10.0, 0, 10.0) + mat = {"id": "m", "name": "m", "gamma_t": gamma, "gamma_sat": gamma, "c": c, "phi": phi} + return Strata(x, y, z, np.zeros((0,) + z.shape), [mat]) + + +@pytest.mark.parametrize( + ("run_m", "gamma", "phi", "c", "expected"), + [ + (10 / math.tan(math.radians(30)), 19, 20, 8.64, 1.32), # 표 6.8 제방 30° + (20.0, 20, 19.6, 3.0, 1.04), # Donald & Giam 1a + (10 / math.tan(math.radians(60)), 19, 20, 8.64, 0.75), # 표 6.11 제방 60° + ], +) +def test_default_settings_reproduce_scoops3d(run_m, gamma, phi, c, expected): + """567-2 — 실사용 기본 설정 그대로(노드 2 m · 거친 4 배 · 높이 2~30 m · dr 1 m · 부피 10~10,000 m³ · + 구 + 타원체) · 좁힌 상자 없이 → 문헌값 ±2 %(실측 +0.0 · −0.1 · −0.2 %).""" + best = search(_embankment(run_m, gamma, phi, c), SearchParams(workers=8)).best + assert best["governing"] == pytest.approx(expected, rel=0.02) + assert best["columns"] == len(best["cells"]) >= 100 and best["few_columns"] is False + + +def test_few_columns_warning(): + """567-2 — 임계 활동체 기둥 < 100(Scoops3D limcol) → few_columns 경고.""" + st = _embankment(10 / math.tan(math.radians(60)), 19, 20, 8.64, half=8.0) + p = SearchParams(box=(-9, -3, -1, 1), h_min=4, h_max=10, v_min=10, v_max=60, ratios=(1.0,)) + r = search(st, p) + assert r.best["columns"] < 100 and r.best["few_columns"] is True + assert r.stats["columns"] == r.best["columns"] and r.stats["few_columns"] is True diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index 6bfe08965..a19e9a59e 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -1,7 +1,7 @@ """536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`. 무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) · -D8 집수면적 견본(기운 평면 · V 골짜기) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · +D∞ 집수면적 견본(기운 평면 · V 골짜기 · 면 몫 · 돌린 평면) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · 가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. """ @@ -84,6 +84,22 @@ def test_D8_집수면적_기운평면_V골짜기(): assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 +def test_Dinf_면_몫_돌린평면(): + """567-2 D∞(Tarboton 1997 · SINMAP 원본) — 면 안 22.5° 흐름 = 곁 · 대각 반씩 · 30° 돌린 평면 + 집수면적 = 흐름길 길이(D8 줄무늬 표준편차 0.16 → D∞ 0.05).""" + cell = 2.0 + ii, jj = np.indices((5, 5)) + r1, r2, f1 = engine.dinf_receivers(-(jj - math.tan(math.pi / 8) * ii) * cell, cell) + assert (r1[2, 2], r2[2, 2]) == (2 * 5 + 3, 1 * 5 + 3) and f1[2, 2] == pytest.approx(0.5) + n, th = 60, math.radians(30) + i, j = np.indices((n, n)) + z = 500 - (j * math.cos(th) + i * math.sin(th)) * cell * 0.5 + length = np.minimum((j + 0.5) / math.cos(th), (i + 0.5) / math.sin(th)) * cell + inner = (i > 5) & (j > 5) & (i < n - 5) & (j < n - 5) + ratio = engine.catchment_area(z, cell)[inner] / length[inner] + assert np.std(ratio) < 0.08 and np.median(np.abs(ratio - 1)) < 0.2 + + def test_습윤지수(): beta = np.radians(np.array([30.0, 30.0, 0.0])) a = np.array([100.0, 1e6, 50.0]) From fb48bd74fbab156f9fab35915a1f13603b3f464e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:26:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(E03):=20=EC=9C=84=ED=97=98=20=EB=9D=A0?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B8=20=EC=B8=A1=EC=A0=90=20=EA=B3=81=20?= =?UTF-8?q?=EC=86=8E=EC=9D=80=20=EA=B8=80=20=EA=B2=B9=EC=B9=A8=20=EB=BA=8C?= =?UTF-8?q?=20(PLAN=20566-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - planLabelKeys — 고른 측점 반 걸음 안 글은 뺌(원배율에서 24+0 · 25+0 겹침) - 시험 532_3 logic TS 에 고른 측점 곁 뺌 Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/logic/E03_StabSection_Logic.ts | 8 ++++++-- resources/tester/test_532_3_e03_section.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index 969d97871..ea80a9bc6 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -197,7 +197,8 @@ export function labelStride(intervalPx: number, minPx: number): number { return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100; } -/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 */ +/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 + * (고른 측점 곁 반 걸음 안 글은 뺌 — 겹침) */ export function planLabelKeys( rows: StationRow[], interval: number, @@ -205,10 +206,13 @@ export function planLabelKeys( active: string | null, ): string[] { const step = interval > 0 ? interval : 1; + const at = rows.find((r) => r.key === active)?.chainage_m; return rows .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) .filter((r) => { - if (r.key === active || stride === 1) return true; + if (r.key === active) return true; + if (at !== undefined && Math.abs(r.chainage_m - at) < (stride * step) / 2) return false; + if (stride === 1) return true; const n = Math.round(r.chainage_m / step); return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0; }) diff --git a/resources/tester/test_532_3_e03_section.py b/resources/tester/test_532_3_e03_section.py index 4e051d996..7cf96914f 100644 --- a/resources/tester/test_532_3_e03_section.py +++ b/resources/tester/test_532_3_e03_section.py @@ -446,7 +446,7 @@ process.stdout.write(JSON.stringify({ zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)], stride: [m.labelStride(100, 64), m.labelStride(40, 64), m.labelStride(10, 64), m.labelStride(0.1, 64)], labels: [1, 2, 5].map((k) => m.planLabelKeys(input.rows, 20, k, null)), - labelsActive: m.planLabelKeys(input.rows, 20, 5, "e"), + labelsActive: [m.planLabelKeys(input.rows, 20, 5, "e"), m.planLabelKeys(input.rows, 20, 5, "b")], })); """ @@ -580,7 +580,7 @@ def test_logic_TS(): # 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘 assert out["stride"] == [1, 2, 10, 100] assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]] - assert out["labelsActive"] == ["a", "e"] + assert out["labelsActive"] == [["a", "e"], ["b"]] # 고른 측점 반 걸음(50 m) 안 「a」 뺌 def test_엔진_지문은_코드_내용():