199 lines
8.4 KiB
Python
199 lines
8.4 KiB
Python
"""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)],
|
||
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()),
|
||
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]
|
||
|
||
|
||
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_terrain_hole_matches_cut(ts):
|
||
"""활동체를 걷어낸 지표 = 지표 꼭짓점 − 잘린 셀 · 구멍 테두리는 활동면 메쉬와 같은 꼭짓점."""
|
||
cut = [i for i, _ in _py_cut(SPHERE)]
|
||
assert ts["cut"] == cut
|
||
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
|