feat(E04): 3D 해석 = 실제 엔진(533-1 합성 지형 · 533-2 구 · 타원체 탐색) 잇기 · 탐색 진행률 · 끊기 고리 · 건기/우기 · 기준안전율 판정 · 방법별 F · 못 뚫는 층 잘린 활동면 + 엔진 잇기 시험 (533-3)
- _Search 에 on_progress · should_stop(기본 None · 결과 불변 시험) — 533-2 시험 14 그대로 통과 - 탐색 노드 = 중심선 ± 탐색 폭 · 구간 누가 안 · 격자 = 그 밖 깊이 + 10 m 여유 - 미리보기(가짜) 엔진 지움 · 방법 고르기 → 조건(건기 · 우기) · Hovland 는 같은 임계면 비교 값 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""E04 엔진 잇기(PLAN 533-3) — 복사본 측점 → 533-1 합성 지형 → 533-2 탐색 → 저장본 꼴 ·
|
||||
탐색 노드 = 구간 · 탐색 폭 안 · 진행률 · 끊기 · 고리를 달아도 결과 불변.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_533_3_e04_engine.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from E04_Stab3D import E04_Stab3D_Run as run
|
||||
from E04_Stab3D.E04_Stab3D_Engine_Column import Strata
|
||||
from E04_Stab3D.E04_Stab3D_Engine_Search import SearchParams, search
|
||||
|
||||
# 노선 = x 축(동쪽) · 왼쪽(북) = 오르막 0.6 · 측점 20 m
|
||||
CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 100.0]
|
||||
SLOPE = 0.6
|
||||
|
||||
|
||||
def _ground(y):
|
||||
return 100.0 + SLOPE * y
|
||||
|
||||
|
||||
def _cross(ch: float) -> dict:
|
||||
offsets = np.arange(-25.0, 25.01, 0.5)
|
||||
samples = [{"offset_m": o, "z": _ground(o), "valid": True} for o in offsets]
|
||||
# 노면 |o| ≤ 3 평평(100) · 왼쪽 1:1 깎기 · 오른쪽 1:1.5 쌓기 · 바깥 = 지반
|
||||
line = []
|
||||
for o in offsets:
|
||||
if abs(o) <= 3:
|
||||
z = 100.0
|
||||
elif o > 0:
|
||||
z = min(100.0 + (o - 3), _ground(o))
|
||||
else:
|
||||
z = max(100.0 - (-o - 3) / 1.5, _ground(o))
|
||||
line.append({"offset_m": o, "elevation_m": z})
|
||||
return {
|
||||
"chainage_m": ch,
|
||||
"frame": {"left_xy": [0.0, 1.0]},
|
||||
"samples": samples,
|
||||
"design": {
|
||||
"design_line": line,
|
||||
"ground_type": "soil",
|
||||
"road_edges": {"left": {"offset_m": 3.0}, "right": {"offset_m": -3.0}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
STATIONS = [
|
||||
{"chainage_m": ch, "label": f"No.{i}", "center_x": ch, "center_y": 0.0}
|
||||
for i, ch in enumerate(CHAINAGES)
|
||||
]
|
||||
|
||||
SETTINGS = {
|
||||
"start_m": 20.0,
|
||||
"end_m": 60.0,
|
||||
"condition": "dry",
|
||||
"shape": "both",
|
||||
"ellipsoid_ratio": 2.0,
|
||||
"box_half_width_m": 12.0,
|
||||
"cell_m": 1.0,
|
||||
"spacing_m": 3.0,
|
||||
"h_min_m": 2.0,
|
||||
"h_max_m": 10.0,
|
||||
"h_step_m": 2.0,
|
||||
"dr_m": 1.0,
|
||||
"max_depth_m": 6.0,
|
||||
"v_min_m3": 5.0,
|
||||
"v_max_m3": 3000.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path, monkeypatch):
|
||||
models = tmp_path / "B04_PreProcess" / "models"
|
||||
models.mkdir(parents=True)
|
||||
xs = np.arange(-60.0, 160.0, 1.0)
|
||||
ys = np.arange(-60.0, 60.0, 1.0)
|
||||
xx, yy = np.meshgrid(xs, ys)
|
||||
z = _ground(yy)
|
||||
np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool))
|
||||
monkeypatch.setattr(run.source, "cross_section", lambda r, ch: _cross(float(ch)))
|
||||
monkeypatch.setattr(
|
||||
run.source,
|
||||
"db_snapshot",
|
||||
lambda r: {
|
||||
"route_points": [
|
||||
{"model_x": ch, "model_y": 0.0, "chainage_m": ch, "sequence_num": i}
|
||||
for i, ch in enumerate(CHAINAGES)
|
||||
]
|
||||
},
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_engine_raster_and_best(root):
|
||||
seen: list[tuple[int, str]] = []
|
||||
raw = run.run_engine(
|
||||
root, STATIONS[0:5], None, SETTINGS, lambda p, m: seen.append((p, m)), lambda: False
|
||||
)
|
||||
grid = raw["grid"]
|
||||
f = raw["f"]
|
||||
assert f.shape == (grid["ny"], grid["nx"]) == raw["z"].shape
|
||||
assert grid["dx"] == 1.0
|
||||
# 격자 = 구간 측점 ± (탐색 폭 + 깊이 + 10)
|
||||
assert grid["x0"] == pytest.approx(0.0 - 28.0, abs=1.0)
|
||||
finite = np.isfinite(f)
|
||||
assert finite.sum() > 50
|
||||
best = raw["best"]
|
||||
iy = round((best["y"] - grid["y0"]) / grid["dx"])
|
||||
ix = round((best["x"] - grid["x0"]) / grid["dx"])
|
||||
# 전체 최소 = 래스터 최소(그 활동체가 덮는 셀 중 하나) · 중심 = 탐색 띠 안(x 20~60 · |y| ≤ 12)
|
||||
assert np.nanmin(f) == pytest.approx(best["f"], rel=1e-6)
|
||||
assert 20.0 - 1e-6 <= best["x"] <= 60.0 + 1e-6 and abs(best["y"]) <= 12.0 + 1e-6
|
||||
assert 0 <= ix < grid["nx"] and 0 <= iy < grid["ny"]
|
||||
assert best["r"] > 0 and best["ratio"] in (1.0, 2.0) and 0 <= best["azimuth_deg"] < 360
|
||||
assert best["f"] == best["bishop"] or best["method"] == 1 # 다스림 = Bishop(잘림 없음)
|
||||
assert raw["reference_fs"] == 1.5 # 마스터 기본 건기
|
||||
# 합성 지표 — 노면 위(측점 40 · o=0) = 설계 100 · 깎기 끝 밖(o=+20 · 원지형) = 지반
|
||||
z = raw["z"]
|
||||
jy0 = round((0.0 - grid["y0"]) / grid["dx"])
|
||||
jx40 = round((40.0 - grid["x0"]) / grid["dx"])
|
||||
assert z[jy0, jx40] == pytest.approx(100.0, abs=0.05)
|
||||
jyw = round((20.0 - grid["y0"]) / grid["dx"])
|
||||
assert z[jyw, jx40] == pytest.approx(_ground(20.0), abs=0.05)
|
||||
# 진행률 — 0~100 · 단계 이름 · 마지막이 탐색 끝 무렵
|
||||
assert seen and all(0 <= p <= 100 for p, _ in seen) and seen[-1][0] >= 80
|
||||
|
||||
|
||||
def test_engine_stop(root):
|
||||
calls = {"n": 0}
|
||||
|
||||
def stop():
|
||||
calls["n"] += 1
|
||||
return calls["n"] > 3
|
||||
|
||||
with pytest.raises(InterruptedError):
|
||||
run.run_engine(root, STATIONS[0:5], None, SETTINGS, lambda p, m: None, stop)
|
||||
|
||||
|
||||
def test_wet_lower_than_dry(root):
|
||||
dry = run.run_engine(root, STATIONS[0:5], None, SETTINGS, lambda p, m: None, lambda: False)
|
||||
wet = run.run_engine(
|
||||
root,
|
||||
STATIONS[0:5],
|
||||
None,
|
||||
{**SETTINGS, "condition": "wet"},
|
||||
lambda p, m: None,
|
||||
lambda: False,
|
||||
)
|
||||
assert wet["best"]["f"] < dry["best"]["f"]
|
||||
assert wet["reference_fs"] == 1.3 # 우기 깎기 1.2 · 쌓기 1.3 중 큰 값
|
||||
|
||||
|
||||
def test_hooks_do_not_change_search():
|
||||
"""533-2 탐색에 단 진행률 · 끊기 고리 — 결과 불변(sub5 시험 꼴 작은 비탈)."""
|
||||
x = np.arange(-15.0, 35.0, 1.0)
|
||||
y = np.arange(-15.0, 15.0, 1.0)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
zz = np.clip(xx * 0.5, 0, 10)
|
||||
mat = [{"id": "m", "name": "m", "gamma_t": 19.0, "gamma_sat": 19.0, "c": 10.0, "phi": 25.0}]
|
||||
strata = Strata(x, y, zz, np.zeros((0,) + zz.shape), mat)
|
||||
base = dict(box=(-5, 22, -1, 1), spacing=2.0, h_min=2, h_max=14, h_step=2, dr=1.0,
|
||||
max_depth=10, v_min=20, v_max=2e4, ratios=(1.0,)) # fmt: skip
|
||||
plain = search(strata, SearchParams(**base))
|
||||
ticks: list[int] = []
|
||||
hooked = search(
|
||||
strata,
|
||||
SearchParams(**base, on_progress=lambda p, m: ticks.append(p), should_stop=lambda: False),
|
||||
)
|
||||
assert hooked.best["governing"] == plain.best["governing"]
|
||||
assert np.array_equal(np.isnan(hooked.f_min), np.isnan(plain.f_min))
|
||||
assert np.nanmax(np.abs(hooked.f_min - plain.f_min)) == 0
|
||||
assert ticks and min(ticks) >= 0 and max(ticks) <= 100
|
||||
with pytest.raises(InterruptedError):
|
||||
search(strata, SearchParams(**base, should_stop=lambda: True))
|
||||
assert math.isfinite(plain.best["governing"])
|
||||
@@ -60,6 +60,8 @@ writeFileSync(process.argv[3], JSON.stringify({
|
||||
sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)),
|
||||
ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)),
|
||||
cut: Array.from(M.cutCells(c.grid, c.z, c.sphere).keys()),
|
||||
floored: Object.fromEntries(M.cutCells(c.grid, c.z,
|
||||
{ ...c.sphere, floor: [[7 * 21 + 10, 53.0], [7 * 21 + 11, 60.0]] }).entries()),
|
||||
holed: mesh(M.terrainWithout(c.grid, c.z, M.cutCells(c.grid, c.z, c.sphere))),
|
||||
hCenter: M.slipHeight(c.sphere, 110, 207),
|
||||
hEdge: M.slipHeight(c.sphere, 116.5, 207),
|
||||
@@ -178,6 +180,14 @@ def test_slip_mesh_vertices(ts, name):
|
||||
assert all(z < Z[i] for i, z in cut.items())
|
||||
|
||||
|
||||
def test_floor_truncates_slip(ts):
|
||||
"""못 뚫는 층 — 활동면이 층 윗면 아래로 안 내려감 · 층이 지표보다 높으면 잘린 셀 아님."""
|
||||
center = 7 * 21 + 10
|
||||
assert ts["floored"][str(center)] == pytest.approx(53.0) # 활동면 52 → 층 53
|
||||
assert str(center + 1) not in ts["floored"] # 층 60 ≥ 지표 53.5
|
||||
assert ts["floored"][str(center - 1)] == pytest.approx(dict(_py_cut(SPHERE))[center - 1])
|
||||
|
||||
|
||||
def test_terrain_hole_matches_cut(ts):
|
||||
"""활동체를 걷어낸 지표 = 지표 꼭짓점 − 잘린 셀 · 구멍 테두리는 활동면 메쉬와 같은 꼭짓점."""
|
||||
cut = [i for i, _ in _py_cut(SPHERE)]
|
||||
|
||||
@@ -24,6 +24,27 @@ STATIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _fake_engine(root, stations, ground, settings, on_progress, should_stop):
|
||||
"""엔진 자리 — 길 시험은 셈 내용이 아니라 흐름(지문 · 끊기 · 저장)을 봄(엔진 = test_533_3_e04_engine)."""
|
||||
on_progress(50, "가짜")
|
||||
f = np.array([[1.4, 1.2, np.nan], [1.1, 0.9, 1.6]])
|
||||
return {
|
||||
"grid": {"x0": 100.0, "y0": 200.0, "dx": 1.0, "nx": 3, "ny": 2},
|
||||
"z": np.full(f.shape, 500.0),
|
||||
"f": f,
|
||||
"v": np.where(np.isfinite(f), 120.0, np.nan),
|
||||
"m": np.where(np.isfinite(f), 0.0, np.nan),
|
||||
"best": {
|
||||
"x": 101.0, "y": 201.0, "z": 505.0, "r": 6.0, "ratio": 1.0, "azimuth_deg": 0.0,
|
||||
"f": 0.9, "volume": 120.0, "method": 0,
|
||||
"hovland": 0.85, "bishop": 0.9, "janbu_corrected": 0.95, "truncated_ratio": 0.0,
|
||||
"floor": [],
|
||||
},
|
||||
"trials": 7,
|
||||
"reference_fs": 1.5,
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path, monkeypatch):
|
||||
src = tmp_path / "E01_StabRegister" / "source"
|
||||
@@ -55,6 +76,8 @@ def project(tmp_path, monkeypatch):
|
||||
async def fake_root(project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(run, "run_engine", _fake_engine)
|
||||
|
||||
monkeypatch.setattr(router_mod, "_project_root", fake_root)
|
||||
router_mod._JOBS.clear()
|
||||
return tmp_path
|
||||
@@ -102,7 +125,8 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch):
|
||||
assert first == {"status": "accepted", "key": "0.00_100.00"}
|
||||
done = _wait(client)
|
||||
assert done["error"] is None and done["percent"] == 100
|
||||
assert done["summary"]["cells"] > 0
|
||||
assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng"
|
||||
assert done["summary"]["by_method"] == {"hovland": 0.85, "bishop": 0.9, "janbu_corrected": 0.95}
|
||||
# 같은 입력 = 셈하지 않음
|
||||
again = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()
|
||||
assert again["status"] == "cached" and len(calls) == 1
|
||||
@@ -174,13 +198,7 @@ def test_cancel_and_restart(client, monkeypatch):
|
||||
== 404
|
||||
)
|
||||
# 다시 시작
|
||||
monkeypatch.setattr(
|
||||
run,
|
||||
"run_engine",
|
||||
run._preview_engine.__wrapped__
|
||||
if hasattr(run._preview_engine, "__wrapped__")
|
||||
else lambda *a: run._preview_engine(a[0], a[1], a[3], a[4], a[5]),
|
||||
)
|
||||
monkeypatch.setattr(run, "run_engine", _fake_engine)
|
||||
assert (
|
||||
client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()["status"] == "accepted"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user