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:
2026-10-25 21:01:30 +09:00
co-authored by Claude Opus 5.5
parent fccd316a06
commit 15ce7cdd0b
12 changed files with 444 additions and 101 deletions
+19 -3
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import math
import time
from collections.abc import Callable
from dataclasses import dataclass, field
import numpy as np
@@ -61,6 +62,9 @@ class SearchParams:
refine_below: float = 2.0
groundwater: dict | None = None
seismic_kh: float = 0.0
# 화면 고리(533-3) — 결과 불변 · on_progress(percent, message) · should_stop() 참이면 InterruptedError
on_progress: Callable[[int, str], None] | None = None
should_stop: Callable[[], bool] | None = None
@dataclass
@@ -264,6 +268,13 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
best = {"governing": np.inf}
seen: set[int] = set()
counters = {"trials": 0, "evaluated": 0}
stage = [0, 40, "거친 탐색"] # 진행률 — 시작 % · 폭 % · 이름
def tick(done: float) -> None:
if p.should_stop and p.should_stop():
raise InterruptedError
if p.on_progress:
p.on_progress(int(stage[0] + stage[1] * done), str(stage[2]))
def node_xyz(ids: np.ndarray) -> np.ndarray:
ix = ids % nxs
@@ -290,9 +301,11 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
dirs = np.column_stack([fall_x[cy, cx], fall_y[cy, cx]])
r_lo = np.maximum(p.dr, np.floor(0.5 * h / p.dr) * p.dr)
r_hi = h + p.max_depth
for ratio in ratios:
for k_ratio, ratio in enumerate(ratios):
done = np.zeros(len(ids), bool)
for r in np.arange(r_lo.min(), r_hi.max() + 1e-9, p.dr):
radii = np.arange(r_lo.min(), r_hi.max() + 1e-9, p.dr)
for k_r, r in enumerate(radii):
tick((k_ratio + k_r / len(radii)) / len(ratios))
live = np.flatnonzero(~done & (r_lo <= r + 1e-9) & (r <= r_hi + 1e-9))
if not len(live):
continue
@@ -365,11 +378,13 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
ids = (grid[:, 0] * nys + grid[:, 1]) * nxs + grid[:, 2]
seen.update(ids.tolist())
seeds = run(ids)
rounds = 0
rounds = passes = 0
while seeds and (step > 1 or rounds < p.refine_rounds):
if step == 1:
rounds += 1
step = max(1, step // 2)
passes += 1
stage[:] = [min(40 + 8 * (passes - 1), 77), 8, f"고운 탐색 {passes}"]
cand = set()
for nid in seeds:
ih, rem = divmod(nid, nys * nxs)
@@ -394,6 +409,7 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
limit = max(p.refine_below, 1.2 * best["governing"])
crit = np.unique(node_map[(node_map >= 0) & (f_min < limit)])
if ell and len(crit):
stage[:] = [85, 14, "타원체"]
run(crit, ell)
f_out = np.where(np.isfinite(f_min), f_min, np.nan)
+4 -4
View File
@@ -49,8 +49,8 @@ _IDLE = {
class SearchSettings(BaseModel):
"""탐색 조건 — 길이 m · 부피 m³ (Scoops3D 방식 · 요구서 4-2)."""
shape: Literal["sphere", "ellipsoid", "both"] = "both"
ellipsoid_ratio: float = Field(2.0, ge=1.0, le=5.0) # 타원체 = 회전축 방향 반축 배수
shape: Literal["sphere", "both"] = "both" # both = 구 + 구 임계 노드에서 타원체
ellipsoid_ratio: float = Field(2.0, ge=1.1, le=5.0) # 타원체 = 회전축 방향 반축 배수
box_half_width_m: float = Field(30.0, ge=5.0, le=100.0) # 탐색 상자 = 중심선 ± 이 폭
cell_m: float = Field(1.0, ge=0.5, le=5.0) # 기둥(셀) 크기
spacing_m: float = Field(2.0, ge=0.5, le=10.0) # 구 중심 수평 간격(가장 고운)
@@ -67,7 +67,7 @@ class Analyze3dRequest(BaseModel):
start_m: float = Field(..., ge=0.0)
end_m: float = Field(..., ge=0.0)
search: SearchSettings = SearchSettings()
method: Literal["bishop", "hovland"] = "bishop" # bishop = Hungr 3D Bishop(+Janbu 보정)
condition: Literal["dry", "wet"] = "dry" # 우기 = 지표까지 포화(KDS 우기 규칙)
force: bool = False
@@ -108,7 +108,7 @@ def _settings(payload: Analyze3dRequest) -> dict[str, Any]:
return {
"start_m": round(payload.start_m, 2),
"end_m": round(payload.end_m, 2),
"method": payload.method,
"condition": payload.condition,
**search.model_dump(),
}
+147 -58
View File
@@ -8,7 +8,9 @@
grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열
z = 합성 지표(원지형 + 노선면) · f = 셀별 최소 안전율 · v = 그 임계 체적 m³ · m = 방법(0 Bishop
· 1 Janbu 보정) · 닿지 않은 셀 = null
best = 전체 최소 활동면 {x, y, z(중심), r, ratio, azimuth_deg, f, volume, method, ix, iy}
best = 전체 최소 활동면 {x, y, z(중심), r, ratio, azimuth_deg(활동 방향), f, volume, method, ix, iy,
hovland · bishop · janbu_corrected(같은 면 방법별 F) · truncated_ratio · floor([셀, 못 뚫는 층 윗면])}
타원체 = 회전축(활동 방향에 수직) 반축만 ratio·r — 533-2 `trial_columns` 와 같은 꼴.
"""
from __future__ import annotations
@@ -23,11 +25,15 @@ from pathlib import Path
from typing import Any
import numpy as np
from scipy.spatial import cKDTree
from common_util.common_util_surface_sampler import DtmGridSampler
from E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround.E02_StabGround_Model import load_defaults as load_ground_defaults
from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA
from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine
from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine
ENGINE_VERSION = "e04-preview-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
ENGINE_VERSION = "e04-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
STAGE_DIR = "E04_Stab3D"
GROUND_FILE = Path("E02_StabGround") / "ground.json"
SECTION_LENGTH_M = 100.0 # 자동 구간 길이
@@ -103,59 +109,64 @@ def bbox_of(
# ---------------------------------------------------------------------------
# 엔진 — 533-1 · 2 결과 꼴이 오기 전 = 미리보기(지형 경사로 만든 가짜 래스터 · 틀 · 배치 확인용)
# 엔진 — 합성 DEM · 지층(533-1 Grid) → 구 · 타원체 탐색 + 기둥 솔버(533-2 Search · Column)
# ---------------------------------------------------------------------------
def _preview_engine(
root: Path,
stations: list[dict[str, Any]],
settings: dict[str, Any],
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
dem = DtmGridSampler.open_cached(source.terrain_grid_path(root))
x0, x1, y0, y1 = bbox_of(stations, float(settings["box_half_width_m"]))
dx = float(settings["cell_m"])
xs = np.arange(x0, x1 + dx / 2, dx)
ys = np.arange(y0, y1 + dx / 2, dx)
xx, yy = np.meshgrid(xs, ys)
z, valid = dem.sample_xy(np.column_stack([xx.ravel(), yy.ravel()]))
z = np.where(valid, z, np.nan).reshape(xx.shape)
gy, gx = np.gradient(z, dx)
slope = np.hypot(gx, gy)
f = np.full(z.shape, np.nan)
rows = z.shape[0]
for iy in range(rows):
if should_stop():
raise InterruptedError
f[iy] = 0.8 + 2.4 / (1.0 + 2.5 * slope[iy] ** 2)
if iy % max(1, rows // 20) == 0:
on_progress(int(100 * iy / rows), f"미리보기 {iy}/{rows} 줄")
volume = np.where(np.isfinite(f), 50.0 + 400.0 * slope**2, np.nan)
iy, ix = np.unravel_index(int(np.nanargmin(f)), f.shape)
r = 10.0
best = {
"x": float(xs[ix]),
"y": float(ys[iy]),
"z": float(z[iy, ix] + 0.6 * r),
"r": r,
"ratio": 1.0,
"azimuth_deg": 0.0,
"f": float(f[iy, ix]),
"volume": float(volume[iy, ix]),
"method": 0,
}
return {
"grid": {"x0": float(xs[0]), "y0": float(ys[0]), "dx": dx, "nx": len(xs), "ny": len(ys)},
"z": z,
"f": f,
"v": volume,
"m": np.where(np.isfinite(f), 0.0, np.nan),
"best": best,
"trials": int(np.isfinite(f).sum()),
"preview": True,
def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]:
"""복사본 측점 → 533-1 측점 한 줄(중심 · 좌표틀 · 지반선 · B06 설계선 · 암 깊이 · 노면)."""
cross = source.cross_section(root, station["chainage_m"])
row = {
"chainage_m": station["chainage_m"],
"center_x": station["center_x"],
"center_y": station["center_y"],
"frame": cross["frame"],
"samples": cross["samples"],
}
return grid_engine.station_from_b06(row, cross["design"])
def _node_mask(
root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float
) -> np.ndarray:
"""탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안."""
points = sorted(
source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"]
)
line = np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float)
# 0.5 m 로 촘촘히 — 꺾인 점 사이 셀도 바른 누가
dense = [line[:1]]
for a, b in zip(line[:-1], line[1:]):
n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / 0.5)))
t = np.linspace(0, 1, n + 1)[1:, None]
dense.append(a + (b - a) * t)
line = np.vstack(dense)
xx, yy = np.meshgrid(grid["x"], grid["y"])
dist, at = cKDTree(line[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()]))
chain = line[at, 2]
keep = (dist <= half_width_m) & (chain >= start_m - 1e-6) & (chain <= end_m + 1e-6)
return keep.reshape(xx.shape)
def _floor(strata: Any, best: dict[str, Any]) -> list[list[float]]:
"""임계면이 못 뚫는 층에 잘린 셀 — [셀 번호, 층 윗면] (화면 활동면 메쉬가 그 높이로 자름)."""
xx, yy = np.meshgrid(strata.x, strata.y)
cx, cy, cz = best["center"]
d = np.asarray(best.get("direction") or [0.0, 1.0], float)
pp = (xx - cx) * d[0] + (yy - cy) * d[1]
qq = (-(xx - cx) * d[1] + (yy - cy) * d[0]) / max(float(best["ratio"]), 1e-9)
rest = float(best["radius"]) ** 2 - pp**2 - qq**2
slip = np.where(rest >= 0, cz - np.sqrt(np.maximum(rest, 0)), np.nan)
hard = strata.hard_top
hit = np.isfinite(slip) & (slip < strata.z_top) & (hard > slip) & (hard < strata.z_top)
return [[int(i), round(float(hard.ravel()[i]), 2)] for i in np.flatnonzero(hit.ravel())]
def reference_fs(ground: dict[str, Any] | None, condition: str) -> float:
"""기준안전율(E02 · 없으면 마스터 기본) — 깎기 · 쌓기 중 큰 값(보수)."""
factors = (ground or load_ground_defaults()).get("safety_factors") or {}
row = factors.get(condition) or {}
return float(max(row.get("cut", 1.5), row.get("fill", 1.5)))
def run_engine(
@@ -166,8 +177,80 @@ def run_engine(
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
"""엔진 한 번 → {grid, z, f, v, m(넘파이 (ny, nx)), best, trials}."""
return _preview_engine(root, stations, settings, on_progress, should_stop)
"""엔진 한 번 → {grid, z, f, v, m(넘파이 (ny, nx)), best, trials}.
stations = 구간 측점 + 앞뒤 이웃 하나씩(구간 끝 합성 지형이 이어지게).
"""
on_progress(1, "합성 지형")
half = float(settings["box_half_width_m"])
margin = half + float(settings["max_depth_m"]) + 10.0 # 활동체가 격자 끝에 닿지 않게
with np.load(source.terrain_grid_path(root)) as npz:
dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")}
grid = grid_engine.build_grid(
dem,
[_engine_station(root, s) for s in stations],
bbox=bbox_of(stations, margin),
road_q_kpa=float((ground or {}).get("road_surcharge_kpa", DEFAULT_ROAD_SURCHARGE_KPA)),
cell_m=float(settings["cell_m"]),
)
wet = settings["condition"] == "wet"
strata = grid_engine.strata_from_grid(
grid, (ground or {}).get("materials"), water_depth_m=0.0 if wet else None
)
mask = _node_mask(root, grid, settings["start_m"], settings["end_m"], half)
ratios = (1.0,) if settings["shape"] == "sphere" else (1.0, float(settings["ellipsoid_ratio"]))
params = search_engine.SearchParams(
node_mask=mask,
spacing=float(settings["spacing_m"]),
h_min=float(settings["h_min_m"]),
h_max=float(settings["h_max_m"]),
h_step=float(settings["h_step_m"]),
dr=float(settings["dr_m"]),
max_depth=float(settings["max_depth_m"]),
v_min=float(settings["v_min_m3"]),
v_max=float(settings["v_max_m3"]),
ratios=ratios,
groundwater={"mode": "line"} if wet else None, # 지하수위면 = strata.z_water
on_progress=lambda pct, msg: on_progress(2 + int(pct * 0.97), msg),
should_stop=should_stop,
)
res = search_engine.search(strata, params)
best = res.best
if not math.isfinite(float(best.get("governing", math.inf))):
raise ValueError("조건에 맞는 활동면이 없습니다 — 부피 · 높이 범위를 넓히세요.")
d = best.get("direction") or [0.0, 1.0]
cx, cy, cz = best["center"]
return {
"grid": {
"x0": float(strata.x[0]),
"y0": float(strata.y[0]),
"dx": float(strata.dx),
"nx": len(strata.x),
"ny": len(strata.y),
},
"z": strata.z_top,
"f": res.f_min,
"v": res.volume,
"m": np.where(res.method >= 0, res.method, np.nan),
"best": {
"x": float(cx),
"y": float(cy),
"z": float(cz),
"r": float(best["radius"]),
"ratio": float(best["ratio"]),
"azimuth_deg": math.degrees(math.atan2(float(d[0]), float(d[1]))) % 360.0,
"f": float(best["governing"]),
"volume": float(best["volume"]),
"method": int(best["method"]),
"hovland": float(best["hovland"]),
"bishop": float(best["bishop"]),
"janbu_corrected": float(best["janbu_corrected"]),
"truncated_ratio": float(best["truncated_ratio"]),
"floor": _floor(strata, best),
},
"trials": int(res.trials),
"reference_fs": reference_fs(ground, settings["condition"]),
}
# ---------------------------------------------------------------------------
@@ -201,7 +284,6 @@ def pack(
return {
"fingerprint": finger,
"engine": ENGINE_VERSION,
"preview": bool(raw.get("preview")),
"settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_s": round(elapsed_s, 2),
@@ -217,6 +299,12 @@ def pack(
"f_min": round(float(best["f"]), 3),
"volume": round(float(best["volume"]), 1),
"method": METHOD_NAMES[int(best.get("method") or 0)],
"condition": settings["condition"],
"reference_fs": raw["reference_fs"],
"verdict": "ok" if best["f"] >= raw["reference_fs"] else "ng",
"by_method": {
k: round(float(best[k]), 3) for k in ("hovland", "bishop", "janbu_corrected")
},
"chainage_m": float(_nearest_chainage(stations, best["x"], best["y"])),
"cells": int(np.isfinite(f).sum()),
"trials": int(raw.get("trials") or 0),
@@ -247,7 +335,6 @@ def saved_summaries(project_root: Path) -> list[dict[str, Any]]:
"key": path.stem,
"fingerprint": doc["fingerprint"],
"settings": doc["settings"],
"preview": doc.get("preview", False),
**doc["summary"],
}
)
@@ -264,12 +351,14 @@ def analyze(
) -> dict[str, Any]:
"""한 구간 3D 해석 → 저장본 쓰고 요약 돌려줌."""
started = time.perf_counter()
stations = source.stations(root)
stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"]))
picked = section_stations(stations, settings["start_m"], settings["end_m"])
if len(picked) < 2:
raise ValueError("구간 안 측점이 둘 미만입니다.")
first, last = stations.index(picked[0]), stations.index(picked[-1])
around = stations[max(first - 1, 0) : last + 2] # 앞뒤 이웃 — 구간 끝 합성 지형 이음
ground = read_ground(project_root)
raw = run_engine(root, picked, ground, settings, on_progress, should_stop)
raw = run_engine(root, around, ground, settings, on_progress, should_stop)
result = pack(raw, picked, settings, finger, time.perf_counter() - started)
save(project_root, section_key(settings["start_m"], settings["end_m"]), result)
return result["summary"]
+14 -6
View File
@@ -35,7 +35,6 @@ interface SavedSummary {
volume: number;
method: string;
stale: boolean;
preview: boolean;
}
interface Overview {
@@ -302,10 +301,19 @@ export function renderE04Stab3D(root: HTMLElement): void {
);
const rows: [LocaleKey, string, string?][] = [
["E04_Result_F3d", s.f_min.toFixed(3), stepColor(FS_STEPS, s.f_min)],
[
"E04_Result_Verdict",
`${t(s.verdict === "ok" ? "E04_Verdict_ok" : "E04_Verdict_ng")} (${t(
`E04_Condition_${s.condition}` as LocaleKey,
)} ≥ ${s.reference_fs.toFixed(2)})`,
],
["E04_Result_F2d", f2d === null ? t("E04_No2D") : f2d.toFixed(3)],
["E04_Result_Ratio", ratio === null ? "-" : ratio.toFixed(3)],
["E04_Result_Volume", `${Math.round(s.volume).toLocaleString()} m³`],
["E04_Result_Method", t(`E04_MethodName_${s.method}` as LocaleKey)],
["E04_Result_Bishop", s.by_method.bishop.toFixed(3)],
["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)],
["E04_Result_Hovland", s.by_method.hovland.toFixed(3)],
["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`],
["E04_Result_Cells", s.cells.toLocaleString()],
["E04_Result_Trials", s.trials.toLocaleString()],
@@ -314,6 +322,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
const list = el("dl", { className: "e04-result__list" });
for (const [key, value, color] of rows) {
const dd = el("dd", { text: value, attrs: { "data-field": key } });
if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`);
if (color) {
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = color;
@@ -323,8 +332,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
}
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") }));
if (result.preview)
notes.push(el("p", { className: "e04-note", text: t("E04_Result_Preview") }));
notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") }));
resultBody.replaceChildren(list, ...notes);
}
@@ -371,11 +379,11 @@ export function renderE04Stab3D(root: HTMLElement): void {
async function analyze(force: boolean): Promise<void> {
const values = settings.read();
const { start_m, end_m, method, ...search } = values;
const { start_m, end_m, condition, ...search } = values;
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, {
method: "POST",
body: JSON.stringify({ start_m, end_m, method, search, force }),
body: JSON.stringify({ start_m, end_m, condition, search, force }),
});
if (body.status === "cached") {
showToast(t("E04_Cached"), "info");
@@ -415,7 +423,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
...ov.defaults,
start_m: first?.start_m ?? 0,
end_m: first?.end_m ?? 100,
method: "bishop",
condition: "dry",
},
);
map.setRoute(ov.line, ov.stations);
+12 -12
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* E04_Stab3D_UI_Settings.ts
* E04 좌측 탐색 설정 칸(PLAN 533-3) — 구간 시작 · 끝 · 기본(모양 · 탐색 폭 · 방법) · 고급(접힘 ·
* E04 좌측 탐색 설정 칸(PLAN 533-3) — 구간 시작 · 끝 · 기본(조건 · 모양 · 탐색 폭) · 고급(접힘 ·
* 기둥 · 구 중심 격자 · 반지름 증분 · 깊이 · 부피 제약) · [기본값]. 기본값 = 서버 SearchSettings.
* ========================================================================== */
@@ -17,7 +17,7 @@ import { t, type LocaleKey } from "@ui/ui_template_locale";
export interface Settings {
start_m: number;
end_m: number;
method: string;
condition: string;
shape: string;
ellipsoid_ratio: number;
box_half_width_m: number;
@@ -32,7 +32,7 @@ export interface Settings {
v_max_m3: number;
}
type NumberKey = Exclude<keyof Settings, "method" | "shape">;
type NumberKey = Exclude<keyof Settings, "condition" | "shape">;
const BASIC: [NumberKey, LocaleKey][] = [
["ellipsoid_ratio", "E04_Ratio"],
@@ -69,23 +69,23 @@ export function buildSettingsCards() {
const shape = createSelectField({
label: t("E04_Shape"),
compact: true,
options: (["both", "sphere", "ellipsoid"] as const).map((v) => ({
options: (["both", "sphere"] as const).map((v) => ({
value: v,
text: t(`E04_Shape_${v}` as LocaleKey),
})),
onChange: changed,
});
const method = createSelectField({
label: t("E04_Method"),
const condition = createSelectField({
label: t("E04_Condition"),
compact: true,
options: (["bishop", "hovland"] as const).map((v) => ({
options: (["dry", "wet"] as const).map((v) => ({
value: v,
text: t(`E04_Method_${v}` as LocaleKey),
text: t(`E04_Condition_${v}` as LocaleKey),
})),
onChange: changed,
});
const basicCard = createPanelCard(t("E04_Card_Search"), false);
basicCard.append(shape.root, ...BASIC.map(([k, l]) => number(k, l)), method.root);
basicCard.append(condition.root, shape.root, ...BASIC.map(([k, l]) => number(k, l)));
let defaults: Partial<Settings> = {};
const advancedCard = createPanelCard(t("E04_Card_Advanced"), true);
@@ -99,14 +99,14 @@ export function buildSettingsCards() {
size: "sm",
onClick: () => {
const range = { start_m: read().start_m, end_m: read().end_m };
write({ ...read(), ...defaults, ...range, method: "bishop" });
write({ ...read(), ...defaults, ...range, condition: "dry" });
changed();
},
}),
);
function read(): Settings {
const out = { shape: shape.select.value, method: method.select.value } as Settings;
const out = { shape: shape.select.value, condition: condition.select.value } as Settings;
for (const [key, field] of fields) out[key] = Number(field.input.value);
return out;
}
@@ -117,7 +117,7 @@ export function buildSettingsCards() {
if (typeof value === "number" && Number.isFinite(value)) field.input.value = String(value);
}
if (values.shape) shape.select.value = values.shape;
if (values.method) method.select.value = values.method;
if (values.condition) condition.select.value = values.condition;
}
return {
+8
View File
@@ -266,6 +266,14 @@
justify-content: flex-end;
}
.e04-result__list dd.is-ok {
color: var(--color-success);
}
.e04-result__list dd.is-ng {
color: var(--color-danger);
}
.e04-warn {
margin: var(--spacing-12) 0 0;
color: var(--color-danger);
+5 -2
View File
@@ -70,19 +70,22 @@ export function slipHeight(best: SlipBest, x: number, y: number): number | null
return rest < 0 ? null : best.z - Math.sqrt(rest);
}
/** 잘린 셀 → 활동면 높이(활동면이 지표보다 낮은 셀만) */
/** 잘린 셀 → 활동면 높이(활동면이 지표보다 낮은 셀만 · 못 뚫는 층 윗면 아래로는 안 내려감) */
export function cutCells(
grid: Grid,
z: readonly (number | null)[],
best: SlipBest,
): Map<number, number> {
const floor = new Map(best.floor ?? []);
const out = new Map<number, number>();
z.forEach((top, index) => {
if (top === null) return;
const x = grid.x0 + (index % grid.nx) * grid.dx;
const y = grid.y0 + Math.floor(index / grid.nx) * grid.dx;
const slip = slipHeight(best, x, y);
if (slip !== null && slip < top) out.set(index, slip);
if (slip === null) return;
const base = Math.max(slip, floor.get(index) ?? -Infinity);
if (base < top) out.set(index, base);
});
return out;
}
+7 -1
View File
@@ -26,6 +26,8 @@ export interface SlipBest {
method: number;
ix: number;
iy: number;
/** 못 뚫는 층에 잘린 셀 — [셀 번호, 층 윗면] · 활동면을 그 높이에서 자름 */
floor?: [number, number][];
}
export interface Summary3d {
@@ -37,12 +39,16 @@ export interface Summary3d {
chainage_m: number;
cells: number;
trials: number;
condition: string;
reference_fs: number;
verdict: "ok" | "ng";
/** 같은 임계면의 방법별 안전율 */
by_method: { hovland: number; bishop: number; janbu_corrected: number };
}
export interface Result3d {
key: string;
stale: boolean;
preview: boolean;
computed_at: string;
elapsed_s: number;
settings: Record<string, unknown>;
+180
View File
@@ -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"])
+10
View File
@@ -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)]
+26 -8
View File
@@ -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"
)
+12 -7
View File
@@ -22,7 +22,6 @@ export const ui_locales_e04 = {
E04_End: ["끝 (m)", "End (m)"],
E04_Shape: ["활동면 모양", "Slip shape"],
E04_Shape_sphere: ["구", "Sphere"],
E04_Shape_ellipsoid: ["타원체", "Ellipsoid"],
E04_Shape_both: ["구 + 타원체", "Sphere + ellipsoid"],
E04_Ratio: ["타원체 길이비", "Ellipsoid ratio"],
E04_BoxWidth: ["탐색 폭 (중심선 ±m)", "Search half-width (m)"],
@@ -35,9 +34,9 @@ export const ui_locales_e04 = {
E04_MaxDepth: ["최대 깊이 (m)", "Max depth (m)"],
E04_VMin: ["최소 부피 (m³)", "Min volume (m³)"],
E04_VMax: ["최대 부피 (m³)", "Max volume (m³)"],
E04_Method: ["해석 방법", "Method"],
E04_Method_bishop: ["3D Bishop (Hungr) + Janbu 보정", "3D Bishop (Hungr) + corrected Janbu"],
E04_Method_hovland: ["Hovland (비교)", "Hovland (comparison)"],
E04_Condition: ["조건", "Condition"],
E04_Condition_dry: ["건기", "Dry"],
E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"],
E04_Defaults: ["기본값", "Defaults"],
E04_Analyze: ["해석", "Analyze"],
E04_Reanalyze: ["다시 해석", "Re-analyze"],
@@ -78,10 +77,16 @@ export const ui_locales_e04 = {
"입력(지반 조건 · 탐색 설정 · 엔진)이 바뀌었습니다 — 다시 해석하세요.",
"Inputs changed — re-analyze.",
],
E04_Result_Preview: [
"미리보기 엔진 결과(3D 엔진 연결 전 · 지형 경사로 만든 값)",
"Preview engine (slope-based placeholder)",
E04_Result_Note: [
"3D/2D 비 = 같은 구간 3D 최소 ÷ E03 측점 최소 · 방법별 = 같은 임계면 · 물성 = 문헌 추정치(현장 시험 없음).",
"3D/2D = 3D min ÷ E03 station min · per-method values on the same surface · literature properties.",
],
E04_Result_Verdict: ["판정", "Verdict"],
E04_Verdict_ok: ["만족", "OK"],
E04_Verdict_ng: ["불만족", "NG"],
E04_Result_Bishop: [" 3D Bishop", " 3D Bishop"],
E04_Result_Janbu: [" 3D Janbu 보정", " 3D corrected Janbu"],
E04_Result_Hovland: [" Hovland (비교)", " Hovland (comparison)"],
E04_No2D: ["2D 결과 없음", "No 2D result"],
E04_MethodName_bishop: ["3D Bishop", "3D Bishop"],
E04_MethodName_janbu_corrected: ["3D Janbu 보정", "3D corrected Janbu"],