Files
Aislo/resources/tester/test_533_3_e04_engine.py
T
eomsangdonandClaude Opus 5.5 9d67c3eba6 feat(567-2): E04 임계 활동체 기둥 수 · 100 미만 경고 · 기본 설정 Scoops3D 재현 시험 · E07 집수면적 D∞
- 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 <noreply@anthropic.com>
2026-10-27 20:25:43 +09:00

255 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)))
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 # 마스터 기본 건기
# 활동체 셀 = 엔진 덩이 — 모두 그 셀 최소(= 전체 최소)
cells = np.asarray(best["cells"])
assert len(cells) and np.allclose(f.ravel()[cells], best["f"])
# 합성 지표 — 노면 위(측점 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"])
def test_node_mask_section_and_route_ends(root):
"""노드 자리 = 구간 누가 안 · 탐색 폭 안 · 노선 시점 앞(x < 0) · 종점 뒤(x > 100)는 뺌."""
x = np.arange(-30.0, 131.0, 1.0)
y = np.arange(-20.0, 21.0, 1.0)
grid = {"x": x, "y": y}
mask = run._node_mask(STATIONS, grid, 0.0, 100.0, 12.0)
xx, yy = np.meshgrid(x, y)
assert not mask[xx < -0.5].any() and not mask[xx > 100.5].any()
assert not mask[np.abs(yy) > 12.0].any()
assert mask[(np.abs(yy) <= 12.0) & (xx >= 1) & (xx <= 99)].all()
part = run._node_mask(STATIONS, grid, 20.0, 60.0, 12.0)
assert not part[(xx < 19.5) | (xx > 60.5)].any() and part[(yy == 0) & (xx == 40)].all()
def test_ground_order_and_label_map(tmp_path):
"""물성 = E02 작업본 → 초기값 → 마스터 기본값(성토 φ 28) · 층 물성 = label_map 짝."""
import json
from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine
assert {m["id"]: m["phi"] for m in run.read_ground(tmp_path)["materials"]}["fill"] == 28.0
init = tmp_path / "initial_snapshot" / "E02_StabGround" / "ground.json"
init.parent.mkdir(parents=True)
init.write_text(json.dumps({"tag": "initial"}), encoding="utf-8")
assert run.read_ground(tmp_path) == {"tag": "initial"}
work = tmp_path / "E02_StabGround" / "ground.json"
work.parent.mkdir(parents=True)
work.write_text(json.dumps({"tag": "work"}), encoding="utf-8")
assert run.read_ground(tmp_path) == {"tag": "work"}
z = np.full((2, 2), 10.0)
grid = {
"x": np.arange(2.0),
"y": np.arange(2.0),
"z": z,
"z_orig": z,
"rock_top": z - 3,
"rock_kind": np.ones((2, 2), int),
"q": np.zeros((2, 2)),
}
ids = [m["id"] for m in grid_engine.strata_from_grid(grid).materials]
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