From 15ce7cdd0b6cfaf8ee1eb316bde119f1bd7b78cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:01:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(E04):=203D=20=ED=95=B4=EC=84=9D=20=3D=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=97=94=EC=A7=84(533-1=20=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=A7=80=ED=98=95=20=C2=B7=20533-2=20=EA=B5=AC=20?= =?UTF-8?q?=C2=B7=20=ED=83=80=EC=9B=90=EC=B2=B4=20=ED=83=90=EC=83=89)=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0=20=C2=B7=20=ED=83=90=EC=83=89=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=A5=A0=20=C2=B7=20=EB=81=8A=EA=B8=B0=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EA=B1=B4=EA=B8=B0/=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=EC=95=88=EC=A0=84=EC=9C=A8=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=C2=B7=20=EB=B0=A9=EB=B2=95=EB=B3=84=20F?= =?UTF-8?q?=20=C2=B7=20=EB=AA=BB=20=EB=9A=AB=EB=8A=94=20=EC=B8=B5=20?= =?UTF-8?q?=EC=9E=98=EB=A6=B0=20=ED=99=9C=EB=8F=99=EB=A9=B4=20+=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EC=9E=87=EA=B8=B0=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?(533-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _Search 에 on_progress · should_stop(기본 None · 결과 불변 시험) — 533-2 시험 14 그대로 통과 - 탐색 노드 = 중심선 ± 탐색 폭 · 구간 누가 안 · 격자 = 그 밖 깊이 + 10 m 여유 - 미리보기(가짜) 엔진 지움 · 방법 고르기 → 조건(건기 · 우기) · Hovland 는 같은 임계면 비교 값 Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Engine_Search.py | 22 ++- E04_Stab3D/E04_Stab3D_Router.py | 8 +- E04_Stab3D/E04_Stab3D_Run.py | 205 ++++++++++++++++------ E04_Stab3D/E04_Stab3D_UI_Page.ts | 20 ++- E04_Stab3D/E04_Stab3D_UI_Settings.ts | 24 +-- E04_Stab3D/E04_Stab3D_UI_Style.css | 8 + E04_Stab3D/logic/E04_Stab3D_Mesh.ts | 7 +- E04_Stab3D/logic/E04_Stab3D_Raster.ts | 8 +- resources/tester/test_533_3_e04_engine.py | 180 +++++++++++++++++++ resources/tester/test_533_3_e04_logic.py | 10 ++ resources/tester/test_533_3_e04_router.py | 34 +++- ui_template/ui_template_locale_e04.ts | 19 +- 12 files changed, 444 insertions(+), 101 deletions(-) create mode 100644 resources/tester/test_533_3_e04_engine.py diff --git a/E04_Stab3D/E04_Stab3D_Engine_Search.py b/E04_Stab3D/E04_Stab3D_Engine_Search.py index 8a2c1426d..1716245f1 100644 --- a/E04_Stab3D/E04_Stab3D_Engine_Search.py +++ b/E04_Stab3D/E04_Stab3D_Engine_Search.py @@ -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) diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index 56a21b515..a52acc53d 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -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(), } diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 2a0e1a003..8ffc5fbb7 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -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"] diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index 8cad3b057..858502ba9 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -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 { 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); diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts index 69377dded..215201303 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Settings.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -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; +type NumberKey = Exclude; 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 = {}; 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 { diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css index 74fc63e89..7b0c9b7ca 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Style.css +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -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); diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts index 7ff8d3c66..b191f4e79 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -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 { + const floor = new Map(best.floor ?? []); const out = new Map(); 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; } diff --git a/E04_Stab3D/logic/E04_Stab3D_Raster.ts b/E04_Stab3D/logic/E04_Stab3D_Raster.ts index dd73bdaac..a98e95df8 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Raster.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Raster.ts @@ -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; diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py new file mode 100644 index 000000000..9e3497618 --- /dev/null +++ b/resources/tester/test_533_3_e04_engine.py @@ -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"]) diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py index dc95b46d5..380ba706a 100644 --- a/resources/tester/test_533_3_e04_logic.py +++ b/resources/tester/test_533_3_e04_logic.py @@ -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)] diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index 31a69f097..aac24b02b 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -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" ) diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts index b541c2c9f..49c898651 100644 --- a/ui_template/ui_template_locale_e04.ts +++ b/ui_template/ui_template_locale_e04.ts @@ -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"],