Files
Aislo/resources/tester/test_533_3_e04_logic.py
T

228 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) — `E04_Stab3D/logic/` 색 단계 · 범례 · 래스터 그림 · 최소 셀 · 3D/2D 비 ·
지표 메쉬 · 임계 활동면(잘린 구 · 타원체) 꼭짓점 — 손계산 · 파이썬 전수 대조.
./venv/Scripts/python.exe -m pytest resources/tester/test_533_3_e04_logic.py -q
"""
from __future__ import annotations
import json
import math
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
LOGIC = ROOT / "E04_Stab3D" / "logic"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
GRID = {"x0": 100.0, "y0": 200.0, "dx": 1.0, "nx": 21, "ny": 15}
# 북쪽(+y)으로 오르는 비탈 · 한 셀 빔
Z = [
None if (ix, iy) == (3, 4) else 50.0 + 0.5 * iy
for iy in range(GRID["ny"])
for ix in range(GRID["nx"])
]
SPHERE = {"x": 110.0, "y": 207.0, "z": 58.0, "r": 6.0, "ratio": 1.0, "azimuth_deg": 0.0}
ELLIPSOID = {**SPHERE, "ratio": 2.0, "azimuth_deg": 180.0} # 남쪽으로 미끄러짐 · 동서로 긴 면
F = [
None if z is None else round(1.0 + abs(i % 21 - 10) * 0.1 + i // 21 * 0.05, 3)
for i, z in enumerate(Z)
]
F[7 * 21 + 10] = 0.95 # 최소
DRIVER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as R from "./E04_Stab3D_Raster.js";
import * as M from "./E04_Stab3D_Mesh.js";
const c = JSON.parse(readFileSync(process.argv[2], "utf-8"));
const mesh = (m) => ({ n: m.positions.length / 3, tri: m.indices.length / 3,
cells: Array.from(m.cells), zs: Array.from(m.positions.filter((_, i) => i % 3 === 2)) });
const rgba = R.rasterRgba(c.grid, c.f, R.FS_STEPS);
writeFileSync(process.argv[3], JSON.stringify({
steps: [0.5, 1.0, 1.25, 1.3, 1.49, 2.9, 3.0, 99, null].map((v) => R.stepIndex(R.FS_STEPS, v)),
colorNull: R.stepColor(R.FS_STEPS, null),
legend: R.legendRows(R.FS_STEPS, 1).map((r) => r.label),
legendV: R.legendRows(R.VOLUME_STEPS, 0).map((r) => r.label),
rgb: R.hexToRgb("#a50026"),
min: R.rasterMin(c.grid, c.f),
count: R.countCells(c.f),
cellAt: [R.cellAt(c.grid, 110.4, 206.6), R.cellAt(c.grid, 99, 200)],
// 남서 셀(0,0) = 그림 맨 아래 줄 · 최소 셀 픽셀 = 가장 붉은 색
pixel00: Array.from(rgba.slice(((c.grid.ny - 1) * c.grid.nx) * 4, ((c.grid.ny - 1) * c.grid.nx) * 4 + 4)),
pixelMin: Array.from(rgba.slice(((c.grid.ny - 1 - 7) * c.grid.nx + 10) * 4, ((c.grid.ny - 1 - 7) * c.grid.nx + 10) * 4 + 4)),
pixelEmpty: rgba[((c.grid.ny - 1 - 4) * c.grid.nx + 3) * 4 + 3],
min2d: R.min2d([{ chainage_m: 0, fs_min: 1.1 }, { chainage_m: 20, fs_min: 1.4 },
{ chainage_m: 40, fs_min: null }, { chainage_m: 60, fs_min: 0.9 }], 10, 50),
ratio: [R.ratio3d2d(1.68, 1.4), R.ratio3d2d(1.5, null), R.ratio3d2d(1.5, 0)],
fs2d: [R.fs2dOf({ fs_by_condition: { wet: 1.2 }, min: { wet: 1.1 } }, "wet"),
R.fs2dOf({ fs_by_condition: { dry: 1.6 }, min: { wet: 1.1 } }, "wet"),
R.fs2dOf({ fs_by_condition: { wet: null }, min: {} }, "wet"), R.fs2dOf({}, "dry")],
terrain: mesh(M.terrainMesh(c.grid, c.z)),
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()),
massCut: Array.from(M.cutCells(c.grid, c.z, { ...c.sphere, cells: [7 * 21 + 10, 7 * 21 + 11, 0] }).keys()),
massBase0: M.cutCells(c.grid, c.z, { ...c.sphere, cells: [0] }).get(0),
center: M.footprintCenter(c.grid, M.cutCells(c.grid, c.z, c.sphere), c.sphere),
centerEmpty: M.footprintCenter(c.grid, new Map(), c.sphere),
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),
hEll: [M.slipHeight(c.ellipsoid, 121, 207), M.slipHeight(c.ellipsoid, 110, 212)],
height: [M.heightAt(c.grid, c.z, 110.5, 207.5), M.heightAt(c.grid, c.z, 103.5, 204.5),
M.heightAt(c.grid, c.z, 500, 0)],
}));
"""
@pytest.fixture(scope="module")
def ts(tmp_path_factory: pytest.TempPathFactory) -> dict:
out = tmp_path_factory.mktemp("e04_logic")
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(LOGIC / "E04_Stab3D_Raster.ts"), str(LOGIC / "E04_Stab3D_Mesh.ts"),
"--outDir", str(out), "--module", "esnext", "--target", "es2022",
"--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", "--noResolve"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
payload = {"grid": GRID, "z": Z, "f": F, "sphere": SPHERE, "ellipsoid": ELLIPSOID}
(out / "in.json").write_text(json.dumps(payload), encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")],
capture_output=True, text=True, cwd=str(out),
) # fmt: skip
assert run.returncode == 0, run.stderr
return json.loads((out / "out.json").read_text(encoding="utf-8"))
def test_color_steps_and_legend(ts):
# 경계값은 위 단계(1.0 → 1.0~1.2) · null = -1
assert ts["steps"] == [0, 1, 2, 3, 3, 5, 6, 6, -1]
assert ts["colorNull"] == "#9aa3ad"
assert ts["legend"] == [
"< 1.0",
"1.0 – 1.2",
"1.2 – 1.3",
"1.3 – 1.5",
"1.5 – 2.0",
"2.0 – 3.0",
"≥ 3.0",
]
assert ts["legendV"] == ["< 10", "10 – 100", "100 – 1,000", "1,000 – 10,000", "≥ 10,000"]
assert ts["rgb"] == pytest.approx([165 / 255, 0, 38 / 255])
def test_raster_min_pixels_ratio(ts):
assert ts["min"] == {"ix": 10, "iy": 7, "value": 0.95}
assert ts["count"] == GRID["nx"] * GRID["ny"] - 1
assert ts["cellAt"] == [{"ix": 10, "iy": 7}, None]
assert ts["pixelMin"] == [165, 0, 38, 255] # 최소 셀 = 가장 붉은 단계
assert ts["pixel00"][3] == 255 and ts["pixel00"][:3] != [165, 0, 38]
assert ts["pixelEmpty"] == 0 # 빈 셀 = 투명
assert ts["min2d"] == 1.4 # 10~50 m 안 = 20 m(1.4) · null 뺌
assert ts["ratio"][0] == pytest.approx(1.2) and ts["ratio"][1:] == [None, None]
assert ts["fs2d"] == [1.2, 1.1, None, None] # 지배 비탈 → 조건별 최소 → 없음
def _py_slip(best, x, y):
az = math.radians(best["azimuth_deg"])
dx, dy = x - best["x"], y - best["y"]
along = dx * math.sin(az) + dy * math.cos(az)
axis = (dx * math.cos(az) - dy * math.sin(az)) / best["ratio"]
rest = best["r"] ** 2 - along**2 - axis**2
return None if rest < 0 else best["z"] - math.sqrt(rest)
def _py_cut(best):
out = []
for index, top in enumerate(Z):
if top is None:
continue
x = GRID["x0"] + index % GRID["nx"]
y = GRID["y0"] + index // GRID["nx"]
slip = _py_slip(best, x, y)
if slip is not None and slip < top:
out.append((index, slip))
return out
def test_terrain_mesh(ts):
nx, ny = GRID["nx"], GRID["ny"]
assert ts["terrain"]["n"] == nx * ny - 1
# 빈 셀 하나 = 네 칸이 삼각형 하나씩 잃음 → 칸 수 × 2 − 4
assert ts["terrain"]["tri"] == (nx - 1) * (ny - 1) * 2 - 4
assert 4 * nx + 3 not in ts["terrain"]["cells"]
def _py_rim(cut: set[int]) -> set[int]:
nx, ny = GRID["nx"], GRID["ny"]
rim = set()
for index in cut:
ix, iy = index % nx, index // nx
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
jx, jy = ix + dx, iy + dy
nxt = jy * nx + jx
if 0 <= jx < nx and 0 <= jy < ny and nxt not in cut and Z[nxt] is not None:
rim.add(nxt)
return rim
@pytest.mark.parametrize("name", ["sphere", "ellipsoid"])
def test_slip_mesh_vertices(ts, name):
"""활동면 꼭짓점 = 잘린 셀(활동면 높이 · 지표 아래) + 테두리 셀(지표 높이)."""
best = SPHERE if name == "sphere" else ELLIPSOID
cut = dict(_py_cut(best))
rim = _py_rim(set(cut))
want = sorted(set(cut) | rim)
got = ts[name]
assert len(cut) > 20 and rim
assert got["n"] == len(want)
assert got["cells"] == want
assert got["zs"] == pytest.approx([cut.get(i, Z[i]) for i in want], abs=1e-3) # Float32
assert got["tri"] > 0
assert all(z < Z[i] for i, z in cut.items())
def test_footprint_center(ts):
"""지도 최소 표시 = 잘린 셀 중심 평균 · 잘린 셀 없으면 구 중심."""
cut = [i for i, _ in _py_cut(SPHERE)]
x = sum(GRID["x0"] + i % GRID["nx"] for i in cut) / len(cut)
y = sum(GRID["y0"] + i // GRID["nx"] for i in cut) / len(cut)
assert ts["center"] == pytest.approx({"x": x, "y": y})
assert ts["centerEmpty"] == {"x": SPHERE["x"], "y": SPHERE["y"]}
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)]
assert ts["cut"] == cut
assert ts["massCut"] == [0, 7 * 21 + 10, 7 * 21 + 11] # 엔진 덩이 셀 그대로(0 = 안 잘림 · 두께 0)
assert ts["massBase0"] == Z[0]
assert ts["holed"]["n"] == ts["terrain"]["n"] - len(cut)
assert not set(ts["holed"]["cells"]) & set(cut)
assert _py_rim(set(cut)) <= set(ts["holed"]["cells"])
def test_slip_height_and_terrain_height(ts):
assert ts["hCenter"] == pytest.approx(52.0) # 중심 바로 아래 = z − r
assert ts["hEdge"] is None # 반지름 밖
# 타원체: 회전축(동서) 방향 11 m 는 반축 12 m 안 · 활동 방향(남북) 5 m
assert ts["hEll"][0] == pytest.approx(58.0 - math.sqrt(36 - (11 / 2) ** 2))
assert ts["hEll"][1] == pytest.approx(58.0 - math.sqrt(36 - 25))
assert ts["height"][0] == pytest.approx(50.0 + 0.5 * 7.5)
assert ts["height"][1] is None # 빈 셀 이웃
assert ts["height"][2] is None