diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py index ad1dce02b..337223f36 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py @@ -18,21 +18,31 @@ from __future__ import annotations import hashlib import json import logging +import math +import multiprocessing +from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path from typing import Any, Callable +import numpy as np +import shapely +from shapely.geometry import LineString, MultiLineString + from B05_Profile.B05_Profile_Engine_Grade import ( legal_grade_limit_pct, legal_plan_radius_min_m, resolve_design_speed, ) from B05_Profile.B05_Profile_Engine_RouteInitial import ( + _POOLS, + _TERRAINS, generate_initial_route, load_terrain_and_streams, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Store import request_fingerprint from common_util.common_util_json import atomic_write_json from common_util.common_util_surface_confirmation import SHEET_ANALYSIS_FILTER +from config.config_system_terrain import PLAN_ANALYZE_WORKERS from D03_PlanAnalysis.D03_PlanAnalysis_Terrain import PLAN_CORRIDOR_M, models_dir_of logger = logging.getLogger(__name__) @@ -43,6 +53,9 @@ SELECTION = {"source_filter": SHEET_ANALYSIS_FILTER, "method": "dtm", "smooth": # D 등록에 지형 구분 칸 없음 — B `_road_settings` 못 읽을 때 폴백 그대로(가장 완화) TERRAIN_TYPE = "special" ALGO = 1 +# 계류 거르기 칸 · 지형 상자 여유(m) — `near_streams` +STREAM_TILE_M = 200.0 +STREAM_PAD_M = 100.0 Progress = Callable[[int, str], None] Loader = Callable[..., tuple[Any, list]] @@ -90,6 +103,38 @@ def _route_view(result: dict[str, Any]) -> dict[str, Any]: return {key: result.get(key) for key in keep} +def _engine_inputs(criteria: dict[str, Any]) -> dict[str, Any]: + return { + "weights": None, + "max_grade": criteria["max_grade"], + "min_radius_m": criteria["min_radius_m"], + "stream_offset_m": None, + "grade_smooth_m": None, + "target_grade": None, + "with_profile": False, + "design_speed": criteria["design_speed"], + } + + +def segment_fingerprint( + seg: dict[str, Any], + terrain: Any, + streams: list, + criteria: dict[str, Any], + corridor_m: float = PLAN_CORRIDOR_M, +) -> str: + """구간 입력 지문 — 갈래 넷 B 저장 키 · 복도 · 판.""" + expected = [_point(seg["start"]), _point(seg["end"])] + inputs = _engine_inputs(criteria) + keys = [ + request_fingerprint(expected, terrain, streams, mode=mode, **inputs)[0] + for mode in PLAN_MODES + ] + return hashlib.blake2b( + repr((keys, float(corridor_m), ALGO)).encode(), digest_size=16 + ).hexdigest() + + def analyze_segment( project_root: Path, seg: dict[str, Any], @@ -100,23 +145,8 @@ def analyze_segment( ) -> dict[str, Any]: """연도 구간 하나 · 갈래 넷 — 입력 지문이 저장본과 같으면 그대로(`stored` True).""" expected = [_point(seg["start"]), _point(seg["end"])] - inputs = { - "weights": None, - "max_grade": criteria["max_grade"], - "min_radius_m": criteria["min_radius_m"], - "stream_offset_m": None, - "grade_smooth_m": None, - "target_grade": None, - "with_profile": False, - "design_speed": criteria["design_speed"], - } - keys = [ - request_fingerprint(expected, terrain, streams, mode=mode, **inputs)[0] - for mode in PLAN_MODES - ] - fingerprint = hashlib.blake2b( - repr((keys, float(corridor_m), ALGO)).encode(), digest_size=16 - ).hexdigest() + inputs = _engine_inputs(criteria) + fingerprint = segment_fingerprint(seg, terrain, streams, criteria, corridor_m) stored = read_segment(project_root, seg) if stored is not None and stored.get("fingerprint") == fingerprint: return {**stored, "stored": True} @@ -148,6 +178,60 @@ def analyze_segment( return {**result, "stored": False} +def near_streams(terrain: Any, streams: list) -> list: + """지형 상자 안 어느 점에서든 가장 가까운 계류일 수 있는 선만 — 나머지는 엔진 값에 안 닿음. + + 칸마다 닿는 거리 = 칸 가운데 계류 거리 + 칸 반대각(거리는 1-립시츠) — 그 안에 드는 선만 남기면 + 상자 안 점의 계류 거리 · 건너기 · 격자 거리가 통째와 같음(통째 거리 = 추천 시간의 큰 몫). + """ + if not streams: + return streams + x0, x1 = float(np.min(terrain.x)) - STREAM_PAD_M, float(np.max(terrain.x)) + STREAM_PAD_M + y0, y1 = float(np.min(terrain.y)) - STREAM_PAD_M, float(np.max(terrain.y)) + STREAM_PAD_M + xs = np.linspace(x0, x1, max(1, math.ceil((x1 - x0) / STREAM_TILE_M)) + 1) + ys = np.linspace(y0, y1, max(1, math.ceil((y1 - y0) / STREAM_TILE_M)) + 1) + lo_x, lo_y = np.meshgrid(xs[:-1], ys[:-1]) + hi_x, hi_y = np.meshgrid(xs[1:], ys[1:]) + tiles = shapely.box(lo_x.ravel(), lo_y.ravel(), hi_x.ravel(), hi_y.ravel()) + reach = shapely.distance(shapely.centroid(tiles), MultiLineString(streams)) + 0.5 * math.hypot( + xs[1] - xs[0], ys[1] - ys[0] + ) + lines = np.array([LineString(line) for line in streams], dtype=object) + keep = (shapely.distance(lines[:, None], tiles[None, :]) <= reach[None, :]).any(axis=1) + return [line for line, kept in zip(streams, keep) if kept] + + +def _load(project_root: Path, year: int, loader: Loader) -> tuple[Any, list]: + terrain, streams = loader( + project_root, + SELECTION, + models_dir_of(project_root, year), + derived_dir(project_root) / f"cost_{year}", + ) + return terrain, near_streams(terrain, streams) + + +def _segment_job( + project_root: Path, + seg: dict[str, Any], + criteria: dict[str, Any], + corridor_m: float, + loader: Loader, +) -> dict[str, Any]: + """연도 구간 하나 — 지형 읽기부터(제 프로세스면 끝날 때 비용면 · 묶음 메모리를 놓음).""" + terrain, streams = _load(project_root, int(seg["year"]), loader) + return analyze_segment(project_root, seg, terrain, streams, criteria, corridor_m) + + +def _worker_job(*args: Any) -> dict[str, Any]: + """나란히 셈 프로세스 — 구간 하나 뒤 B 기억(비용면 · 묶음)을 비워 다음 구간 메모리를 놓음.""" + try: + return _segment_job(*args) + finally: + _POOLS.clear() + _TERRAINS.clear() + + def analyze_plan( project_root: Path, segments: list[dict[str, Any]], @@ -156,23 +240,52 @@ def analyze_plan( loader: Loader = load_terrain_and_streams, corridor_m: float = PLAN_CORRIDOR_M, on_progress: Progress | None = None, + workers: int = PLAN_ANALYZE_WORKERS, ) -> list[dict[str, Any]]: - """연도 구간마다 지형(연도 DEM · 연도 비용면 캐시)을 읽어 `analyze_segment`.""" - results = [] - for i, seg in enumerate(segments): - year = int(seg["year"]) + """연도 구간마다(연도 DEM · 연도 비용면 캐시) `analyze_segment`. + + 다시 셀 구간이 둘 이상이면 프로세스 `workers` 개로 나란히 — 구간끼리 입력 · 저장본이 따로라 + 결과 같음. `workers` 1 = 이 프로세스(가짜 지형 시험).""" + + def report(percent: int, message: str) -> None: if on_progress is not None: - on_progress(100 * i // len(segments), f"{year}년 구간 노선 추천 중") - terrain, streams = loader( - project_root, - SELECTION, - models_dir_of(project_root, year), - derived_dir(project_root) / f"cost_{year}", - ) - results.append(analyze_segment(project_root, seg, terrain, streams, criteria, corridor_m)) - if on_progress is not None: - on_progress(100, "분석 끝") - return results + on_progress(percent, message) + + results: list[dict[str, Any] | None] = [None] * len(segments) + todo = [] + for i, seg in enumerate(segments): + stored = read_segment(project_root, seg) + if stored is not None: # 저장본이 있을 때만 이 프로세스에서 지형을 읽어 지문을 봄 + terrain, streams = _load(project_root, int(seg["year"]), loader) + if stored.get("fingerprint") == segment_fingerprint( + seg, terrain, streams, criteria, corridor_m + ): + results[i] = {**stored, "stored": True} + continue + todo.append(i) + + count = min(workers, len(todo)) + if count <= 1: + for n, i in enumerate(todo): + report(100 * n // len(segments), f"{int(segments[i]['year'])}년 구간 노선 추천 중") + results[i] = _segment_job(project_root, segments[i], criteria, corridor_m, loader) + else: + years = "·".join(str(int(segments[i]["year"])) for i in todo) + report(0, f"{years}년 구간 노선 추천 중(나란히 {count})") + context = multiprocessing.get_context("spawn") + with ProcessPoolExecutor(count, mp_context=context) as pool: + futures = { + pool.submit(_worker_job, project_root, segments[i], criteria, corridor_m, loader): i + for i in todo + } + for n, future in enumerate(as_completed(futures), 1): + i = futures[future] + results[i] = future.result() + report( + 100 * n // len(todo), f"{int(segments[i]['year'])}년 구간 끝 ({n}/{len(todo)})" + ) + report(100, "분석 끝") + return results # type: ignore[return-value] def pick_mode(result: dict[str, Any], wanted: str | None) -> str | None: diff --git a/E03_StabSection/E03_StabSection_Engine_Prob.py b/E03_StabSection/E03_StabSection_Engine_Prob.py new file mode 100644 index 000000000..fc6e913c0 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Prob.py @@ -0,0 +1,441 @@ +"""E03 2D 확률 해석 · 민감도 (요구 351 · PLAN 537-1) — Slide2 · SLOPE/W Probabilistic 과 같은 생각. + +확률 변수 = 물성(c · φ · γ) · 지하수위(수위선 위아래 이동 m) · r_u — 정규 · 대수정규 · 절단(평균 아래 · 위 +거리 = Slide2 Relative Min · Max · 물리 한계 c ≥ 0 · 0 ≤ φ < 89 · γ > 0 · 0 ≤ r_u < 1 도 절단) · 변수 사이 상관 +(Slide2 c–φ Correlation · 표준정규에서 촐레스키) → 몬테카를로 · 라틴 하이퍼큐브 N 회 +→ 안전율 표본 → 평균 · 표준편차 · Pf = P(F < 1) · β 정규 (μ − 1)/σ · β 대수정규 +ln(μ/√(1+V²))/√ln(1+V²) · 히스토그램 · 누적분포 · P(F < 기준안전율). +갈래: fixed = 결정론 임계면 고정(절편 기하 한 번 · 물성만 바꿔 묶음 평가 · 빠름 · Slide2 Global Minimum) · +search = 표본마다 임계면 다시 찾음(`Engine_Search.analyze` · 느림 · 프로세스 병렬 · Slide2 Overall Slope). +민감도 = 변수 하나씩 범위(절단 끝 · 없으면 ±3σ) 50 등분 훑기(나머지 평균 · 임계면 고정 · Slide2 Sensitivity 와 같음) +· 토네이도 = 끝값 F 폭 순. +γ 변수 = γt 표본 · γsat 는 같은 차만큼 옮김(γsat − γt 그대로). +search 일꾼 = `python -m E03_StabSection.E03_StabSection_Engine_Prob `(서버 프로세스에서 풀을 열지 않음 · +`Worker` 머리글) · 한 줄마다 {"done": n} · 끝에 job["out"] 파일. +NumPy · SciPy(stats) 만 · 계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import copy +import json +import math +import sys +import time +from pathlib import Path + +import numpy as np +from scipy.stats import norm + +from E03_StabSection import E03_StabSection_Engine_LEM as lem +from E03_StabSection.E03_StabSection_Engine_Search import analyze +from E03_StabSection.E03_StabSection_Engine_Station import MIN_DEPTH_M +from E03_StabSection.E03_StabSection_Engine_Surface import ( + circle_slices, + evaluate, + poly_slices, + three_point_center, +) + +PROPS = ("c", "phi", "gamma", "water", "ru") +#: 물리 한계(절단) — 지하수 이동은 한계 없음 +BOUNDS = {"c": (0.0, math.inf), "phi": (0.0, 89.0), "gamma": (1e-3, math.inf), "ru": (0.0, 0.99)} +#: 절단 없을 때 민감도 훑기 범위(±σ 배) +SWEEP_SIGMA = 3.0 +SWEEP_POINTS = 51 +HIST_BINS = 30 +CDF_POINTS = 101 +N_SLICES = 50 + + +# ---- 분포 · 표본 -------------------------------------------------------------- +def _bounds(v: dict) -> tuple[float, float]: + """절단 구간 [a, b] — 평균 ∓ 아래 · 위 거리 ∩ 물리 한계.""" + lo_phys, hi_phys = BOUNDS.get(v["prop"], (-math.inf, math.inf)) + mean = float(v["mean"]) + a = mean - float(v["lo"]) if v.get("lo") is not None else -math.inf + b = mean + float(v["hi"]) if v.get("hi") is not None else math.inf + return max(a, lo_phys), min(b, hi_phys) + + +def _lognormal(v: dict) -> tuple[float, float]: + """대수정규 (μ_ln, σ_ln) — 평균 · 표준편차 그대로 맞춤.""" + mean, std = float(v["mean"]), float(v["std"]) + s2 = math.log1p((std / mean) ** 2) + return math.log(mean) - 0.5 * s2, math.sqrt(s2) + + +def ppf(v: dict, u: np.ndarray) -> np.ndarray: + """균등 u(0~1) → 변수 값(절단 분포 역함수).""" + u = np.asarray(u, float) + mean, std = float(v["mean"]), float(v.get("std") or 0.0) + if std <= 0: + return np.full(u.shape, mean) + a, b = _bounds(v) + if v.get("dist") == "lognormal" and v["prop"] != "water": + if mean <= 0: + raise ValueError(f"대수정규는 평균 > 0: {v}") + mu, s = _lognormal(v) + za = (math.log(a) - mu) / s if a > 0 else -math.inf + zb = (math.log(b) - mu) / s if b < math.inf else math.inf + pa, pb = norm.cdf(za), norm.cdf(zb) + return np.exp(mu + s * norm.ppf(pa + u * (pb - pa))) + pa, pb = norm.cdf((a - mean) / std), norm.cdf((b - mean) / std) + return mean + std * norm.ppf(pa + u * (pb - pa)) + + +def sample( + variables: list[dict], + n: int, + *, + sampling: str = "lhs", + seed: int = 0, + correlations: list | None = None, +) -> np.ndarray: + """표본 (n, 변수 수) — lhs = 라틴 하이퍼큐브(변수마다 n 칸 하나씩 · 칸 차례 섞음) · mc = 단순 무작위. + + correlations = [(i, j, ρ), …] — 표준정규 칸에서 촐레스키로 섞은 뒤 분포로(LHS 층은 섞인 변수만 흐려짐).""" + rng = np.random.default_rng(int(seed)) + k = len(variables) + if sampling == "lhs": + order = np.argsort(rng.random((n, k)), axis=0) + u = (order + rng.random((n, k))) / n + else: + u = rng.random((n, k)) + u = np.clip(u, 1e-12, 1 - 1e-12) + if correlations: + corr = np.eye(k) + for i, j, rho in correlations: + corr[i, j] = corr[j, i] = float(rho) + u = norm.cdf(norm.ppf(u) @ np.linalg.cholesky(corr).T) + u = np.clip(u, 1e-12, 1 - 1e-12) + return np.column_stack([ppf(v, u[:, j]) for j, v in enumerate(variables)]) if k else u + + +def sweep_range(v: dict) -> tuple[float, float]: + """민감도 훑기 범위 — 절단 끝 · 없으면 평균 ±3σ(물리 한계 안).""" + mean, std = float(v["mean"]), float(v.get("std") or 0.0) + a, b = _bounds(v) + lo = a if math.isfinite(a) and v.get("lo") is not None else mean - SWEEP_SIGMA * std + hi = b if math.isfinite(b) and v.get("hi") is not None else mean + SWEEP_SIGMA * std + lo_phys, hi_phys = BOUNDS.get(v["prop"], (-math.inf, math.inf)) + return max(lo, lo_phys), min(hi, hi_phys) + + +# ---- 통계 ------------------------------------------------------------------- +def statistics(F: np.ndarray, required: float | None = None, bins: int = HIST_BINS) -> dict: + """안전율 표본 → 평균 · 표준편차 · Pf · β 정규 · 대수정규 · 히스토그램 · 누적분포(해 없는 표본 = 셈 밖).""" + F = np.asarray(F, float) + ok = F[np.isfinite(F)] + out = {"n": int(len(F)), "n_valid": int(len(ok))} + if len(ok) < 2: + return {**out, "mean": None, "std": None, "pf": None, "beta_normal": None} + mean, std = float(ok.mean()), float(ok.std(ddof=1)) + V = std / mean if mean > 0 else math.nan + beta_ln = ( + math.log(mean / math.sqrt(1 + V * V)) / math.sqrt(math.log1p(V * V)) + if mean > 0 and V > 0 + else None + ) + counts, edges = np.histogram(ok, bins=bins) + p = np.linspace(0.0, 1.0, CDF_POINTS) + out.update( + mean=mean, + std=std, + cov=V if math.isfinite(V) else None, + min=float(ok.min()), + max=float(ok.max()), + pf=float(np.mean(ok < 1.0)), + n_fail=int(np.sum(ok < 1.0)), + beta_normal=(mean - 1.0) / std if std > 0 else None, + beta_lognormal=beta_ln, + required=required, + p_below_required=float(np.mean(ok < required)) if required else None, + histogram={"edges": edges.tolist(), "counts": counts.tolist()}, + cdf={"p": p.tolist(), "F": np.quantile(ok, p).tolist()}, + ) + return out + + +# ---- 변수 → 모형 --------------------------------------------------------------- +def perturb(model: dict, variables: list[dict], row) -> dict: + """모형 사본 — 표본 한 줄을 물성 · 지하수위에 넣음(search 갈래).""" + out = dict(model) + mats = {m["id"]: dict(m) for m in model["materials"]} + dz = 0.0 + for v, x in zip(variables, row): + if v["prop"] == "water": + dz += float(x) + continue + if v["prop"] == "ru": + gw = model.get("groundwater") or {} + if gw.get("mode") == "ru": + out["groundwater"] = {**gw, "ru": float(x)} + continue + m = mats[v["material"]] + if v["prop"] == "gamma": + gt = float(m["gamma_t"]) + m["gamma_sat"] = float(m.get("gamma_sat", gt)) + float(x) - gt + m["gamma_t"] = float(x) + else: + m[v["prop"]] = float(x) + out["materials"] = list(mats.values()) + gw = out.get("groundwater") or {} + if dz and gw.get("mode") == "line": + out["groundwater"] = {**gw, "line": [[x, z + dz] for x, z in gw["line"]]} + return out + + +class Fixed: + """임계면 고정 — 모형 · 면 하나를 한 번 준비해 표본마다 물성 칸만 갈아 끼우고 절편 묶음으로 평가.""" + + def __init__(self, model: dict, surface: dict, direction: int, n_slices: int = N_SLICES): + self.prep = lem.Prepared(model, direction) + self.n = n_slices + s = self.prep.direction + self.index = {m["id"]: i for i, m in enumerate(self.prep.materials)} + if surface["kind"] == "circle": + xs = sorted((s * surface["entry"][0], s * surface["exit"][0])) + self.geom = ( + "circle", + s * surface["center"][0], + surface["center"][1], + surface["radius"], + ) + self.geom += tuple(xs) + else: + pts = sorted((s * x, z) for x, z in surface["points"]) + self.geom = ("polyline", np.array([p[0] for p in pts]), np.array([p[1] for p in pts])) + + def _prep(self, variables: list[dict], row) -> lem.Prepared: + base = self.prep + p = copy.copy(base) + gt, gs = base.gamma_t.copy(), base.gamma_sat.copy() + c, tp = base.c.copy(), base.tanphi.copy() + dz = 0.0 + for v, x in zip(variables, row): + if v["prop"] == "water": + dz += float(x) + continue + if v["prop"] == "ru": + if base.gw_mode == "ru": + p.ru = float(x) + continue + i = self.index[v["material"]] + if v["prop"] == "c": + c[i] = x + elif v["prop"] == "phi": + tp[i] = math.tan(math.radians(x)) + else: + gs[i] += x - gt[i] + gt[i] = x + p.gamma_t, p.gamma_sat, p.c, p.tanphi = gt, gs, c, tp + p.table = base.table.copy() + p.table[:, 4, :] = gt[base.mat] + p.table[:, 5, :] = gs[base.mat] - gt[base.mat] + if dz and base.gw_mode == "line": + p.water_z = base.water_z + dz + return p + + def _slices(self, prep: lem.Prepared) -> tuple[dict, np.ndarray]: + g = self.geom + if g[0] == "circle": + _, xc, zc, R, xE, xX = g + sl = circle_slices(prep, [xc], [zc], [R], [xE], [xX], self.n) + return sl, np.array([[xc, zc]]) + vx, vz = g[1][None, :], g[2][None, :] + return poly_slices(prep, vx, vz, self.n), three_point_center(vx, vz) + + def F(self, variables: list[dict], X: np.ndarray, method: str) -> np.ndarray: + """표본 줄마다 안전율(해 없음 = nan).""" + X = np.atleast_2d(np.asarray(X, float)) + parts = [self._slices(self._prep(variables, row)) for row in X] + sl = _stack([p[0] for p in parts]) + center = np.vstack([p[1] for p in parts]) + mp_seed = None + if method == "morgenstern_price": # describe 와 같은 출발 참고값(같은 면 Spencer) + mp_seed = evaluate(sl, center, ("spencer",))["spencer"]["F"] + sl.pop("_pre", None) + F = evaluate(sl, center, (method,), mp_seed=mp_seed)[method]["F"] + return np.where(sl["valid"], F, np.nan) + + +def _stack(parts: list[dict]) -> dict: + """한 줄짜리 절편 묶음들 → (S, …) 하나(배열 칸만 잇고 스칼라는 그대로).""" + out = {} + for key, value in parts[0].items(): + if key == "_pre": + continue + if isinstance(value, np.ndarray) and value.ndim >= 1 and value.shape[0] == 1: + out[key] = np.concatenate([p[key] for p in parts]) + else: + out[key] = value + return out + + +def _chunks(n: int, size: int): + for i in range(0, n, size): + yield slice(i, min(i + size, n)) + + +def sensitivity( + fixed: Fixed, variables: list[dict], method: str, points: int = SWEEP_POINTS +) -> dict: + """변수 하나씩 범위 훑기(나머지 평균) · 토네이도 = 끝값 F 폭 큰 순.""" + means = np.array([float(v["mean"]) for v in variables]) + rows, spans = [], [] + for j, v in enumerate(variables): + lo, hi = sweep_range(v) + xs = np.linspace(lo, hi, points) if hi > lo else np.full(points, means[j]) + block = np.tile(means, (points, 1)) + block[:, j] = xs + rows.append(block) + spans.append(xs) + F0 = float(fixed.F(variables, means[None, :], method)[0]) + if not rows: + return {"F_mean": F0, "curves": [], "tornado": []} + F = fixed.F(variables, np.vstack(rows), method).reshape(len(variables), points) + num = lambda a: [float(x) if np.isfinite(x) else None for x in a] # noqa: E731 + curves = [] + for v, xs, f in zip(variables, spans, F): + ends = [f[0], f[-1]] + swing = abs(ends[1] - ends[0]) if all(np.isfinite(ends)) else None + curves.append( + { + "name": v["name"], + "x": num(xs), + "F": num(f), + "F_lo": num([ends[0]])[0], + "F_hi": num([ends[1]])[0], + "swing": swing, + } + ) + tornado = sorted( + (c for c in curves if c["swing"] is not None), key=lambda c: c["swing"], reverse=True + ) + return {"F_mean": F0 if math.isfinite(F0) else None, "curves": curves, "tornado": [ + {"name": c["name"], "F_lo": c["F_lo"], "F_hi": c["F_hi"], "swing": c["swing"]} + for c in tornado + ]} # fmt: skip + + +# ---- 매회 탐색(search) ------------------------------------------------------------ +_CTX: dict = {} + + +def _init(ctx: dict) -> None: + _CTX.clear() + _CTX.update(ctx) + + +def _search_one(row) -> float: + c = _CTX + m = c["method"] + methods = tuple(dict.fromkeys(("bishop", m))) + res = analyze( + perturb(c["model"], c["variables"], row), + direction=c["direction"], + limits=c["limits"], + methods=methods, + noncircular=m not in ("ordinary", "bishop"), # 비원호 Fellenius · Bishop = 참고 칸 + min_depth=MIN_DEPTH_M, + ) + best = res["methods"][m]["min"] + return float(best["F"]) if best and best.get("F") is not None else math.nan + + +def search_F(ctx: dict, X: np.ndarray, workers: int = 1, emit=None) -> np.ndarray: + """표본마다 임계면 다시 찾음 — workers > 1 = 프로세스 풀(서버 밖 일꾼에서만).""" + out = np.full(len(X), np.nan) + if workers <= 1: + _init(ctx) + for i, row in enumerate(X): + out[i] = _search_one(row) + emit and emit(i + 1) + return out + from concurrent.futures import ProcessPoolExecutor, as_completed + + with ProcessPoolExecutor(max_workers=workers, initializer=_init, initargs=(ctx,)) as pool: + futures = {pool.submit(_search_one, row): i for i, row in enumerate(X)} + for done, future in enumerate(as_completed(futures), 1): + out[futures[future]] = future.result() + emit and emit(done) + return out + + +# ---- 한 번 해석 --------------------------------------------------------------- +def run( + model: dict, + *, + surface: dict, + direction: int, + limits=None, + variables: list[dict], + correlations: list | None = None, + n: int = 1000, + sampling: str = "lhs", + seed: int = 0, + mode: str = "fixed", + method: str = "bishop", + required: float | None = None, + workers: int = 1, + emit=None, +) -> dict: + """확률 해석 + 민감도 한 벌. emit(끝난 표본 수) = 진행률.""" + t0 = time.perf_counter() + X = sample(variables, n, sampling=sampling, seed=seed, correlations=correlations) + fixed = Fixed(model, surface, direction) + t1 = time.perf_counter() + if mode == "search": + ctx = { + "model": model, + "variables": variables, + "direction": direction, + "limits": limits, + "method": method, + } + F = search_F(ctx, X, workers, emit) + else: + F = np.full(n, np.nan) + for part in _chunks(n, 250): + F[part] = fixed.F(variables, X[part], method) + emit and emit(part.stop) + t2 = time.perf_counter() + sens = sensitivity(fixed, variables, method) + t3 = time.perf_counter() + realized = [ + {"name": v["name"], "mean": float(X[:, j].mean()), "std": float(X[:, j].std(ddof=1))} + for j, v in enumerate(variables) + ] + return { + "mode": mode, + "method": method, + "n": n, + "sampling": sampling, + "seed": seed, + "variables": variables, + "correlations": correlations or [], + "realized": realized, + "stats": statistics(F, required), + "F": [round(float(f), 4) if np.isfinite(f) else None for f in F], + "sensitivity": sens, + "elapsed_s": { + "prepare": round(t1 - t0, 3), + "samples": round(t2 - t1, 3), + "sensitivity": round(t3 - t2, 3), + "total": round(t3 - t0, 3), + }, + } + + +def main() -> None: + """일꾼 — job = run() 인자 + out(결과 파일) · 한 줄마다 진행.""" + path = Path(sys.argv[1]) + job = json.loads(path.read_text(encoding="utf-8")) + out = Path(job.pop("out")) + result = run(**job, emit=lambda k: print(json.dumps({"done": k}), flush=True)) + out.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/E03_StabSection/E03_StabSection_Router_Prob.py b/E03_StabSection/E03_StabSection_Router_Prob.py new file mode 100644 index 000000000..bc8e4fcfe --- /dev/null +++ b/E03_StabSection/E03_StabSection_Router_Prob.py @@ -0,0 +1,400 @@ +"""E03 확률 해석 · 민감도 API (요구 351 · PLAN 537-1) — 대표 단면만. + +- `GET /api/projects/{id}/stab/prob/setup/{station_key}?side=&condition=` — 그 단면 지층 물성(E02 값 = 평균) · + 기본값 후보(`prob_defaults.json` · 근거 · 사용자 확정 전) · 지하수 갈래 · 기준안전율 · 마지막 결과 +- `POST …/stab/prob/{station_key}` — 분포 · 표본 수 · 갈래 → 뒤에서(진행 `GET …/stab/prob/progress`) · + 같은 지문 = 저장본(fresh) +- `GET …/stab/prob` — 지금 2D 결과에 붙은 기록 목록(측점 · 비탈 · 조건마다 가장 최근 · E05 「확률해석」 시트) + +평균 = E02 지반 조건(작업본) 값 · 바꾸려면 E02 에서. 임계면 = 지금 2D 결과의 그 방법 최소 면. +결과 = 파생 저장본 `_derived/stab2d_prob/<지문>` · 지문 = 원본 측점 지문 + 요청 + 코드. +엔진 = `Engine_Prob` — 임계면 고정 = 서버 스레드(빠름) · 매회 탐색 = 일꾼 프로세스(풀 · 느림). +계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +import subprocess +import sys +import tempfile +import time +from functools import lru_cache +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from E02_StabGround import E02_StabGround_Router as e02 +from E03_StabSection import E03_StabSection_Engine_Prob as prob +from E03_StabSection import E03_StabSection_Router as e03 +from E03_StabSection import E03_StabSection_Router_WhatIf as whatif +from E03_StabSection import E03_StabSection_View as view +from E03_StabSection.E03_StabSection_Engine_LEM import WALL_ID +from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06 +from E03_StabSection.E03_StabSection_Types import groundwater as norm_groundwater + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["E03 Stab Probabilistic"]) +KIND = "stab2d_prob" +KEEP = 200 +DEFAULTS_PATH = ( + Path(__file__).resolve().parents[1] / "resources/master_template/stability/prob_defaults.json" +) +#: 매회 탐색 표본 수 상한(측점 하나 탐색 수 초 × N) +SEARCH_MAX_N = 2000 +#: 일꾼 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험) +WORKERS: int | None = None + +_JOBS: dict[str, dict[str, Any]] = {} +_TASKS: set[asyncio.Task] = set() + + +@lru_cache(maxsize=1) +def code_hash() -> str: + digest = hashlib.sha1(e03.engine_hash().encode()) + for name in ("E03_StabSection_Engine_Prob.py", Path(__file__).name): + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def load_defaults() -> dict[str, Any]: + return json.loads(DEFAULTS_PATH.read_text(encoding="utf-8")) + + +class VarIn(BaseModel): + """확률 변수 하나 — c · φ · γ = 변동계수 · 지하수위 이동(m) · r_u = 표준편차 · 절단 = 평균 ± k·σ.""" + + material: str | None = None + prop: Literal["c", "phi", "gamma", "water", "ru"] + dist: Literal["normal", "lognormal"] = "normal" + cov: float | None = Field(None, ge=0.0, le=2.0) + std: float | None = Field(None, ge=0.0, le=20.0) + trunc_sigma: float | None = Field(3.0, gt=0.0, le=10.0) + + +class ProbRequest(BaseModel): + side: Literal["left", "right"] + condition: Literal["dry", "wet", "seismic"] + method: Literal["ordinary", "bishop", "janbu", "spencer", "morgenstern_price"] = "bishop" + mode: Literal["fixed", "search"] = "fixed" + n: int = Field(1000, ge=50, le=20000) + sampling: Literal["lhs", "mc"] = "lhs" + seed: int = Field(0, ge=0, le=2**31 - 1) + variables: list[VarIn] = Field(default_factory=list, max_length=40) + rho_c_phi: float = Field(0.0, ge=-0.95, le=0.95) + + +def _model(ctx: dict[str, Any], condition: str) -> dict[str, Any]: + """그 조건 모형 — `analyze_station` 과 같은 입력(지층 · 지반선 · 벽 · 하중 · E02 지하수 갈래).""" + kw = ctx["kw"] + model = section_model_from_b06( + ctx["cross"], + ctx["design"], + materials=kw["materials"], + layers=kw["layers"], + ground=kw["ground"], + wet=condition == "wet", + seismic_kh=kw["seismic_kh"] if condition == "seismic" else 0.0, + road_surcharge_kpa=kw["road_surcharge_kpa"], + walls=kw.get("walls"), + ) + model["groundwater"] = norm_groundwater(kw["groundwater_for"](condition, model)) + return model + + +def _slot(root: Path, station_key: str, side: str, condition: str, method: str) -> dict: + """측점 · 비탈 · 조건 — 대표 단면만 · 모형 · 임계면 · 기준안전율.""" + ctx = whatif._context(root, station_key) + rows = [dict(r) for r in e03._latest_summary(root, ctx["plan"]["fp"])[0]["stations"]] + view.mark_representative(rows, e03._overrides(root)) + mark = next((r.get("representative") or {} for r in rows if r["key"] == station_key), {}) + if not mark.get("on"): + raise HTTPException( + status_code=422, detail="대표 단면만 확률 해석합니다 — 먼저 대표 단면으로." + ) + if condition not in ctx["kw"]["conditions"]: + raise HTTPException(status_code=422, detail="그 조건은 해석하지 않았습니다.") + case = next( + ( + c + for c in ctx["value"]["out"]["cases"] + if c["condition"] == condition and c["side"] == side + ), + None, + ) + if case is None: + raise HTTPException(status_code=422, detail="그 쪽 비탈이 없습니다.") + methods = case["result"]["methods"] + surface = (methods.get(method) or {}).get("min") or None + model = _model(ctx, condition) + sd = next(s for s in model["sides"] if s["side"] == side) + return { + "ctx": ctx, + "model": model, + "side": sd, + "role": case["role"], + "surface": surface, + "methods": methods, + "required": view.required_factor(ctx["plan"]["factors"], condition, case["role"]), + } + + +def _materials(model: dict[str, Any]) -> list[dict[str, Any]]: + """단면에 쓰인 물성(벽 뺌 · 지층 차례).""" + used = list(dict.fromkeys(r["material_id"] for r in model["regions"])) + by = {m["id"]: m for m in model["materials"]} + return [by[i] for i in used if i != WALL_ID and i in by] + + +def _variables(req: ProbRequest, model: dict[str, Any]) -> tuple[list[dict], list]: + """요청 → 엔진 변수(평균 = 모형 값 · σ 0 은 뺌) · c–φ 상관 짝.""" + mats = {m["id"]: m for m in _materials(model)} + gw = model.get("groundwater") or {} + out = [] + for v in req.variables: + if v.prop in ("water", "ru"): + need = "line" if v.prop == "water" else "ru" + if gw.get("mode") != need: + raise HTTPException( + status_code=422, + detail=f"{'지하수위' if v.prop == 'water' else 'r_u'} 변수는 그 조건 지하수 갈래가 " + f"{'수위선' if need == 'line' else 'r_u'} 일 때만.", + ) + mean, std = (0.0, v.std) if v.prop == "water" else (float(gw["ru"]), v.std) + name = "지하수위" if v.prop == "water" else "r_u" + else: + m = mats.get(v.material or "") + if m is None: + raise HTTPException(status_code=422, detail=f"단면에 없는 물성: {v.material}") + mean = float(m["gamma_t"] if v.prop == "gamma" else m[v.prop]) + std = mean * float(v.cov) if v.cov is not None else v.std + name = f"{m.get('name') or m['id']} {dict(c='c', phi='φ', gamma='γ')[v.prop]}" + if not std: + continue + if v.dist == "lognormal" and (mean <= 0 or v.prop in ("water",)): + raise HTTPException(status_code=422, detail=f"대수정규는 평균 > 0 만: {name}") + k = v.trunc_sigma + out.append( + { + "name": name, + "material": v.material if v.prop not in ("water", "ru") else None, + "prop": v.prop, + "dist": v.dist, + "mean": mean, + "std": float(std), + "lo": None if k is None else k * float(std), + "hi": None if k is None else k * float(std), + } + ) + pairs = [] + if req.rho_c_phi: + for i, a in enumerate(out): + for j, b in enumerate(out): + if a["prop"] == "c" and b["prop"] == "phi" and a["material"] == b["material"]: + pairs.append((i, j, req.rho_c_phi)) + return out, pairs + + +def _run_search(job: dict[str, Any], emit: Any) -> dict[str, Any]: + """매회 탐색 일꾼(`python -m …Engine_Prob job.json`) — 한 줄마다 emit · 실패면 RuntimeError.""" + with tempfile.TemporaryDirectory(prefix="aislo_e03p_") as folder: + path, out = Path(folder) / "job.json", Path(folder) / "out.json" + path.write_text(json.dumps({**job, "out": str(out)}, ensure_ascii=False), encoding="utf-8") + log = Path(folder) / "stderr.txt" + with log.open("w", encoding="utf-8") as errors: + process = subprocess.Popen( + [sys.executable, "-m", "E03_StabSection.E03_StabSection_Engine_Prob", str(path)], + cwd=e03.PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=errors, + text=True, + encoding="utf-8", + ) + for line in process.stdout: + if line.startswith("{"): + emit(json.loads(line).get("done")) + code = process.wait() + if code != 0: + tail = log.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:] + raise RuntimeError(f"확률 해석 일꾼 실패: {tail}") + return json.loads(out.read_text(encoding="utf-8")) + + +def run_prob(root: Path, station_key: str, req: ProbRequest, report: Any) -> dict[str, Any]: + slot = _slot(root, station_key, req.side, req.condition, req.method) + if slot["surface"] is None: + raise HTTPException(status_code=422, detail="그 방법 임계면이 없습니다(해 없음).") + if req.mode == "search" and req.n > SEARCH_MAX_N: + raise HTTPException(status_code=422, detail=f"매회 탐색은 표본 {SEARCH_MAX_N} 회까지.") + variables, pairs = _variables(req, slot["model"]) + if not variables: + raise HTTPException(status_code=422, detail="확률 변수가 없습니다(변동계수 · 표준편차 0).") + row = slot["ctx"]["row"] + key = derived_key(code_hash(), row["fp"], req.model_dump()) + cached = read_derived(root, KIND, key) + if cached: + return cached["value"] + job = { + "model": slot["model"], + "surface": slot["surface"], + "direction": slot["side"]["direction"], + "limits": list(slot["side"]["limits"]), + "variables": variables, + "correlations": pairs, + "n": req.n, + "sampling": req.sampling, + "seed": req.seed, + "mode": req.mode, + "method": req.method, + "required": slot["required"], + } + + def emit(done: int | None) -> None: + if done: + report(2 + 93 * int(done) // req.n, f"표본 {done} / {req.n}") + + report(1, "시작") + workers = WORKERS or max(1, (os.cpu_count() or 2) - 2) + if req.mode == "search" and workers > 1: + result = _run_search({**job, "workers": workers}, emit) + else: + result = prob.run(**job, emit=emit) + record = { + "station_key": station_key, + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "base_fp": row["fp"], + "side": req.side, + "role": slot["role"], + "condition": req.condition, + "required": slot["required"], + "fs_deterministic": slot["surface"]["F"], + "surface_kind": slot["surface"]["kind"], + "request": req.model_dump(), + "result": result, + "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + write_derived(root, KIND, key, record, keep=KEEP) + return record + + +def records(root: Path) -> list[dict[str, Any]]: + """가장 최근 2D 요약 측점에 붙은 기록 — (측점 · 비탈 · 조건)마다 가장 최근 하나 · 누가거리 순.""" + summary, _ = e03._latest_summary(root, "") + fps = {r["fp"] for r in (summary or {}).get("stations") or []} + latest: dict[tuple, dict[str, Any]] = {} + for path in derived_dir(root, KIND).glob("*.json"): + value = (read_derived(root, KIND, path.stem) or {}).get("value") + if not value or value.get("base_fp") not in fps: + continue + slot = (value["station_key"], value["side"], value["condition"]) + if slot not in latest or value["computed_at"] > latest[slot]["computed_at"]: + latest[slot] = value + return sorted( + latest.values(), key=lambda v: (float(v["chainage_m"]), v["side"], v["condition"]) + ) + + +def setup(root: Path, station_key: str, side: str, condition: str) -> dict[str, Any]: + slot = _slot(root, station_key, side, condition, "bishop") + gw = slot["model"].get("groundwater") or {} + last = next( + ( + r + for r in records(root) + if (r["station_key"], r["side"], r["condition"]) == (station_key, side, condition) + ), + None, + ) + return { + "station_key": station_key, + "side": side, + "condition": condition, + "role": slot["role"], + "required": slot["required"], + "fs_deterministic": {m: (v.get("min") or {}).get("F") for m, v in slot["methods"].items()}, + "materials": [ + {k: m.get(k) for k in ("id", "name", "c", "phi", "gamma_t")} + for m in _materials(slot["model"]) + ], + "groundwater": {"mode": gw.get("mode", "none"), "ru": gw.get("ru")}, + "defaults": load_defaults(), + "search_max_n": SEARCH_MAX_N, + "last": last, + } + + +@router.get("/{project_id}/stab/prob/setup/{station_key}", response_model=None) +async def get_setup(project_id: UUID, station_key: str, side: str, condition: str) -> dict: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(setup, root, station_key, side, condition) + + +@router.post("/{project_id}/stab/prob/{station_key}", response_model=None) +async def post_prob(project_id: UUID, station_key: str, payload: ProbRequest) -> dict: + """뒤에서 — 같은 지문이면 바로 저장본(fresh) · 진행 = `GET …/stab/prob/progress`.""" + root, _ = await e02._project(project_id) + key = str(project_id) + if (_JOBS.get(key) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 확률 해석 중입니다.") + # 입력 검사 · 저장본 확인은 바로(422 를 그 자리에서 돌려줌) + slot = await asyncio.to_thread( + _slot, root, station_key, payload.side, payload.condition, payload.method + ) + _variables(payload, slot["model"]) + fp = derived_key(code_hash(), slot["ctx"]["row"]["fp"], payload.model_dump()) + saved = read_derived(root, KIND, fp) + if saved: + return {"status": "fresh", "record": saved["value"]} + job = { + "running": True, + "percent": 0, + "message": "시작", + "error": None, + "station_key": station_key, + } + _JOBS[key] = job + + def report(percent: int, message: str) -> None: + job.update(percent=int(percent), message=message) + + async def work() -> None: + try: + await asyncio.to_thread(run_prob, root, station_key, payload, report) + job.update(percent=100, message="끝") + except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림 + logger.exception("E03 확률 해석 실패: project_id=%s", project_id) + job["error"] = str(getattr(error, "detail", None) or error) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E03 prob {project_id}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted", **job} + + +@router.get("/{project_id}/stab/prob/progress", response_model=None) +async def get_progress(project_id: UUID) -> dict: + await e02._project(project_id) + job = _JOBS.get(str(project_id)) + return dict(job) if job else {"running": False, "percent": 0, "message": "", "error": None} + + +@router.get("/{project_id}/stab/prob", response_model=None) +async def get_records(project_id: UUID) -> dict: + root, _ = await e02._project(project_id) + return {"records": await asyncio.to_thread(records, root)} diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index e9ad0a2df..e458e70f4 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -6,6 +6,7 @@ * 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) · * 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음). * 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본). + * 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률). * ========================================================================== */ import "./E03_StabSection_UI_Style.css"; @@ -40,6 +41,7 @@ import { type WhatIfRecord, } from "./E03_StabSection_UI_Reinforce"; import { buildSupport, type SupportAsk, type SupportRecord } from "./E03_StabSection_UI_Support"; +import { createProbPanel } from "./E03_StabSection_UI_Prob"; import type { Analysis, Condition, @@ -100,6 +102,7 @@ export function renderE03StabSection(root: HTMLElement): void { return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") })); const pid: string = projectId; const base = `/projects/${pid}/stab`; + const prob = createProbPanel(pid, base, () => paintSection()); let result: Result2d | null = null; const sections = new Map(); @@ -110,7 +113,7 @@ export function renderE03StabSection(root: HTMLElement): void { let side: string | null = null; let picked: number | null = null; const toggles: SectionToggles = { map: true, centers: false, candidates: true }; - let mode: "result" | "reinforce" = "result"; + let mode: "result" | "reinforce" | "prob" = "result"; let busy = false; const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 const wallRecs = new Map(); @@ -310,6 +313,7 @@ export function renderE03StabSection(root: HTMLElement): void { options: [ { value: "result", text: t("E03_Tab_Result") }, { value: "reinforce", text: t("E03_Tab_Reinforce") }, + { value: "prob", text: t("E03_Tab_Prob") }, ], value: mode, compact: true, @@ -336,6 +340,12 @@ export function renderE03StabSection(root: HTMLElement): void { if (view.regions.some((r) => r.material_id === WALL_ID)) legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); const row = currentRow(); + if (mode === "prob" && row) { + resultHost.append( + prob.render({ row, condition, side, editable: !!result?.editable && !busy }), + ); + return; + } if (mode === "reinforce" && row) { if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable) queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 })); diff --git a/E03_StabSection/E03_StabSection_UI_Prob.css b/E03_StabSection/E03_StabSection_UI_Prob.css new file mode 100644 index 000000000..4ea757351 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Prob.css @@ -0,0 +1,94 @@ +/* E03 확률 · 민감도 탭(537-1) — 입력 표 · 히스토그램 · 민감도 선 · 토네이도 막대 */ +.e03-prob-vars input[type="number"] { + width: 5.5em; +} + +.e03-prob-vars select { + width: auto; +} + +.e03-prob-run { + flex-wrap: wrap; + align-items: flex-end; +} + +.e03-prob-src summary { + cursor: pointer; + color: var(--color-text-secondary); +} + +.e03-prob-bar { + fill: var(--e03-mat-0); + fill-opacity: 0.75; +} + +.e03-prob-bar--fail { + fill: var(--e03-ng); +} + +.e03-prob-mark { + stroke-width: 1.5; +} + +.e03-prob-mark--one { + stroke: var(--e03-ng); +} + +.e03-prob-mark--req { + stroke: var(--e03-mat-3); + stroke-dasharray: 5 3; +} + +.e03-prob-mark--det { + stroke: var(--e03-critical); + stroke-dasharray: 2 2; +} + +.e03-prob-mark__text { + font-size: 10px; + stroke: none; + fill: var(--color-text-secondary); +} + +.e03-prob-line { + fill: none; + stroke-width: 2; +} + +.e03-prob-line--0 { + stroke: var(--e03-mat-0); +} +.e03-prob-line--1 { + stroke: var(--e03-mat-1); +} +.e03-prob-line--2 { + stroke: var(--e03-mat-2); +} +.e03-prob-line--3 { + stroke: var(--e03-mat-3); +} +.e03-prob-line--4 { + stroke: var(--e03-mat-4); +} +.e03-prob-line--5 { + stroke: var(--e03-mat-5); +} +.e03-prob-line--6 { + stroke: var(--e03-mat-6); +} +.e03-prob-line--7 { + stroke: var(--e03-mat-7); +} + +.e03-prob-tbar--lo { + fill: var(--e03-mat-1); +} + +.e03-prob-tbar--hi { + fill: var(--e03-mat-0); +} + +.e03-prob-tlabel { + font-size: 10px; + fill: var(--color-text-secondary); +} diff --git a/E03_StabSection/E03_StabSection_UI_Prob.ts b/E03_StabSection/E03_StabSection_UI_Prob.ts new file mode 100644 index 000000000..3cd5b777e --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Prob.ts @@ -0,0 +1,384 @@ +/* ============================================================================= + * E03_StabSection_UI_Prob.ts + * E03 「확률 · 민감도」 탭(요구 351 · PLAN 537-1) — 대표 단면 · 고른 비탈 · 조건. + * 분포 입력(단면 물성 × c · φ · γ · 지하수위 · r_u — 쓰기 · 분포 · 변동계수/표준편차 · 절단 ±kσ) · + * 표본(LHS · MC · N · 시드) · 갈래(임계면 고정 · 매회 탐색) · 방법 · c–φ 상관 → + * 서버 계산(POST …/stab/prob/{측점} · 뒤에서 · 진행 GET …/stab/prob/progress · 카드 안 진행률). + * 기본값 후보 = 서버 `prob_defaults.json`(근거 · 사용자 확정 전) · 입력 = sessionStorage 캐시(자동저장 없음). + * 결과 = 요약 표 · 히스토그램 · 누적분포 · 민감도 · 토네이도(`UI_ProbChart`). + * ========================================================================== */ + +import "./E03_StabSection_UI_Prob.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { + changedCount, + fillCache, + probRequest, + probRows, + type ProbCache, + type ProbRecord, + type ProbRow, + type ProbSetup, +} from "./logic/E03_StabSection_Prob"; +import type { Condition, StationRow } from "./logic/E03_StabSection_Logic"; +import { + cdfChart, + histogramChart, + sensitivityChart, + tornadoChart, +} from "./E03_StabSection_UI_ProbChart"; + +export interface ProbView { + row: StationRow; + condition: Condition; + side: string | null; + editable: boolean; +} + +interface Progress { + running: boolean; + percent: number; + message: string; + error: string | null; +} + +const fmt = (v: number | null | undefined, d = 3): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +const METHODS = ["bishop", "janbu", "spencer", "morgenstern_price", "ordinary"] as const; +const methodName = (m: string): string => + t(m === "morgenstern_price" ? "E03_Pb_Method_mp" : (`E03_Method_${m}` as LocaleKey)); + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body; +} + +/** 탭 하나 — 준비(setup) · 결과 · 진행을 들고 다시 그릴 때 `repaint` 를 부름 */ +export function createProbPanel(projectId: string, base: string, repaint: () => void) { + const cacheKey = `e03_prob_cache:${projectId}`; + const setups = new Map(); + const records = new Map(); + let cache: ProbCache | null = JSON.parse(sessionStorage.getItem(cacheKey) ?? "null"); + let busy: Progress | null = null; + + const save = (): void => sessionStorage.setItem(cacheKey, JSON.stringify(cache)); + const slotOf = (v: ProbView, side: string): string => `${v.row.key}|${side}|${v.condition}`; + + async function load(slot: string, key: string, side: string, condition: string): Promise { + setups.set(slot, "loading"); + try { + const q = `side=${side}&condition=${condition}`; + const s = await api(`${base}/prob/setup/${encodeURIComponent(key)}?${q}`); + setups.set(slot, s); + if (s.last) records.set(slot, s.last); + } catch (error) { + setups.set(slot, error instanceof Error ? error.message : t("Common_Status_Error")); + } + repaint(); + } + + async function run(slot: string, setup: ProbSetup): Promise { + if (!cache) return; + busy = { running: true, percent: 0, message: "", error: null }; + repaint(); + try { + const body = probRequest(setup, cache); + const path = `${base}/prob/${encodeURIComponent(setup.station_key)}`; + const first = await api<{ status: string; record?: ProbRecord }>(path, { + method: "POST", + body: JSON.stringify(body), + }); + if (first.status !== "fresh") { + for (;;) { + await sleep(700); + busy = await api(`${base}/prob/progress`); + repaint(); + if (!busy.running) break; + } + if (busy.error) throw new Error(busy.error); + } + busy = null; + await load(slot, setup.station_key, setup.side, setup.condition); + showToast(t("E03_Pb_Done"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + busy = null; + repaint(); + } + } + + function inputTable(setup: ProbSetup): HTMLElement { + const table = el("table", { + className: "e03-table e03-prob-vars", + attrs: { "data-role": "prob-vars" }, + }); + const heads = [ + "E03_Pb_Use", + "E03_Pb_Var", + "E03_Pb_Mean", + "E03_Pb_Dist", + "E03_Pb_Spread", + "E03_Pb_Trunc", + ]; + table.append( + el("thead", { + children: [el("tr", { children: heads.map((k) => el("th", { text: t(k as LocaleKey) })) })], + }), + ); + const body = el("tbody"); + for (const row of probRows(setup)) body.append(inputRow(row)); + table.append(body); + return table; + } + + function inputRow(row: ProbRow): HTMLElement { + const v = cache!.vars[row.key]; + const tr = el("tr", { attrs: { "data-key": row.key } }); + const use = el("input", { attrs: { type: "checkbox", "data-field": "use" } }); + use.checked = v.use; + use.addEventListener("change", () => ((v.use = use.checked), save())); + const dist = el("select", { + className: "ui-input ui-input--sm", + attrs: { "data-field": "dist" }, + }); + for (const d of row.prop === "water" ? ["normal"] : ["normal", "lognormal"]) + dist.append(el("option", { text: t(`E03_Pb_Dist_${d}` as LocaleKey), attrs: { value: d } })); + dist.value = v.dist; + dist.addEventListener("change", () => ((v.dist = dist.value as typeof v.dist), save())); + const num = (value: number | null, field: string, apply: (n: number | null) => void) => { + const input = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "number", step: "0.01", min: "0", "data-field": field }, + }); + input.value = value === null ? "" : String(value); + input.addEventListener("input", () => { + const n = Number(input.value); + if (input.value.trim() === "") apply(null); + else if (Number.isFinite(n) && n >= 0) apply(n); + save(); + }); + return input; + }; + const unit = row.kind === "cov" ? t("E03_Pb_Cov") : row.prop === "water" ? "σ m" : "σ"; + const label = + row.prop === "water" || row.prop === "ru" + ? t(`E03_Pb_Prop_${row.prop}` as LocaleKey) + : `${row.name} ${t(`E03_Pb_Prop_${row.prop}` as LocaleKey)}`; + tr.append( + el("td", { children: [use] }), + el("td", { text: label }), + el("td", { className: "is-num", text: row.prop === "water" ? "±0" : fmt(row.mean, 2) }), + el("td", { children: [dist] }), + el("td", { + children: [ + num(v.value, "value", (n) => (v.value = n ?? 0)), + el("span", { className: "e03-muted", text: ` ${unit}` }), + ], + }), + el("td", { children: [num(v.trunc, "trunc", (n) => (v.trunc = n))] }), + ); + return tr; + } + + function runFields(setup: ProbSetup, slot: string, editable: boolean): HTMLElement { + const r = cache!.run; + const wrap = el("div", { className: "e03-panel__row e03-prob-run" }); + const set = (k: K, v: (typeof r)[K]): void => { + r[k] = v; + save(); + }; + const sel = ( + label: LocaleKey, + value: string, + opts: [string, string][], + on: (v: string) => void, + ) => { + const f = createSelectField({ + label: t(label), + options: opts.map(([value, text]) => ({ value, text })), + value, + compact: true, + onChange: on, + }); + f.select.dataset.field = label; + return f.root; + }; + const numField = (label: LocaleKey, value: number, step: string, on: (n: number) => void) => { + const f = createInputField({ + label: t(label), + type: "number", + compact: true, + value: String(value), + }); + f.input.step = step; + f.input.dataset.field = label; + f.input.addEventListener("input", () => { + const n = Number(f.input.value); + if (f.input.value.trim() !== "" && Number.isFinite(n)) on(n); + }); + return f.root; + }; + wrap.append( + sel( + "E03_Pb_Mode", + r.mode, + [ + ["fixed", t("E03_Pb_Mode_fixed")], + ["search", t("E03_Pb_Mode_search")], + ], + (v) => set("mode", v as typeof r.mode), + ), + sel( + "E03_Pb_Method", + r.method, + METHODS.map((m) => [m, methodName(m)]), + (v) => set("method", v as typeof r.method), + ), + sel( + "E03_Pb_Sampling", + r.sampling, + [ + ["lhs", "LHS"], + ["mc", "Monte Carlo"], + ], + (v) => set("sampling", v as typeof r.sampling), + ), + numField("E03_Pb_N", r.n, "100", (n) => set("n", n)), + numField("E03_Pb_Seed", r.seed, "1", (n) => set("seed", n)), + numField("E03_Pb_Rho", r.rho, "0.1", (n) => set("rho", n)), + ); + const go = createButton({ + label: busy ? `${t("E03_Rf_Busy")} ${busy.percent}%` : t("E03_Pb_Run"), + variant: "filled", + onClick: () => void run(slot, setup), + }); + go.dataset.role = "prob-run"; + go.disabled = !editable || !!busy; + const reset = createButton({ + label: t("E03_Pb_Defaults"), + variant: "ghost", + onClick: () => { + cache = fillCache(setup, null); + save(); + repaint(); + }, + }); + reset.disabled = !!busy; + wrap.append(reset, go); + return wrap; + } + + function summary(rec: ProbRecord): HTMLElement { + const s = rec.result.stats; + const rows: [LocaleKey, string][] = [ + ["E03_Pb_Det", fmt(rec.fs_deterministic)], + ["E03_Pb_MeanF", fmt(s.mean)], + ["E03_Pb_Std", fmt(s.std)], + ["E03_Pb_Pf", `${fmt((s.pf ?? 0) * 100, 2)} %`], + ["E03_Pb_BetaN", fmt(s.beta_normal)], + ["E03_Pb_BetaLn", fmt(s.beta_lognormal)], + [ + "E03_Pb_PReq", + s.p_below_required === null || s.p_below_required === undefined + ? "-" + : `${fmt(s.p_below_required * 100, 2)} %`, + ], + ["E03_Pb_Valid", `${s.n_valid} / ${s.n}`], + ["E03_Pb_Time", `${fmt(rec.result.elapsed_s.total, 1)} s`], + ]; + const dl = el("dl", { className: "e03-kpi", attrs: { "data-role": "prob-summary" } }); + for (const [k, v] of rows) + dl.append(el("dt", { text: t(k) }), el("dd", { text: v, attrs: { "data-k": k } })); + const r = rec.result; + const note = `${t(`E03_Pb_Mode_${r.mode}` as LocaleKey)} · ${methodName(r.method)} · ${r.sampling.toUpperCase()} ${r.n} · seed ${r.seed} · ${rec.computed_at}`; + return el("div", { children: [dl, el("p", { className: "e03-muted", text: note })] }); + } + + function sources(setup: ProbSetup, changed: number): HTMLElement { + const d = el("details", { className: "e03-prob-src" }); + const tag = changed + ? t("E03_Pb_UserInput").replace("{n}", String(changed)) + : t("E03_Pb_Unconfirmed"); + d.append(el("summary", { text: tag })); + for (const [k, v] of Object.entries(setup.defaults.sources)) + d.append(el("p", { className: "e03-muted", text: `${k} — ${v}` })); + return d; + } + + function render(v: ProbView): HTMLElement { + const host = el("div", { className: "e03-card e03-prob", attrs: { "data-role": "prob" } }); + const head = (key: LocaleKey): HTMLElement => + el("h3", { className: "e03-card__title", text: t(key) }); + host.append(head("E03_Tab_Prob")); + if (!v.row.representative?.on) { + host.append(el("p", { className: "e03-empty", text: t("E03_Pb_RepOnly") })); + return host; + } + const side = v.side ?? v.row.sides[0]?.side; + if (!side) return host; + const slot = slotOf(v, side); + const got = setups.get(slot); + if (got === undefined) queueMicrotask(() => void load(slot, v.row.key, side, v.condition)); + if (typeof got !== "object") { + const text = typeof got === "string" && got !== "loading" ? got : t("E03_Loading"); + host.append(el("p", { className: "e03-empty", text })); + return host; + } + cache = fillCache(got, cache); + const sideText = `${t(side === "left" ? "E03_Left" : "E03_Right")} · ${t(got.role === "cut" ? "E03_Cut" : "E03_Fill")}`; + host.append( + el("p", { + className: "e03-muted", + text: `${sideText} · ${t(`E03_Cond_${v.condition}` as LocaleKey)} · ${t("E03_Pb_MeanNote")}`, + }), + ); + host.append( + inputTable(got), + runFields(got, slot, v.editable), + sources(got, changedCount(got, cache)), + ); + if (busy) + host.append( + el("p", { + className: "e03-muted", + text: `${busy.percent}% · ${busy.message}`, + attrs: { "data-role": "prob-progress" }, + }), + ); + const rec = records.get(slot); + if (!rec) return host; + const res = rec.result; + host.append( + head("E03_Pb_Result"), + summary(rec), + head("E03_Pb_Hist"), + histogramChart(res, rec.required, rec.fs_deterministic), + head("E03_Pb_Cdf"), + cdfChart(res, rec.required), + head("E03_Pb_Sens"), + sensitivityChart(res), + head("E03_Pb_Tornado"), + tornadoChart(res), + ); + return host; + } + + return { render, isBusy: (): boolean => !!busy }; +} diff --git a/E03_StabSection/E03_StabSection_UI_ProbChart.ts b/E03_StabSection/E03_StabSection_UI_ProbChart.ts new file mode 100644 index 000000000..57ba2ce39 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_ProbChart.ts @@ -0,0 +1,220 @@ +/* ============================================================================= + * E03_StabSection_UI_ProbChart.ts + * E03 「확률 · 민감도」 그래프 SVG(PLAN 537-1) — 안전율 히스토그램(F < 1 빨강 · F = 1 · 기준 선) · + * 누적분포 · 민감도(가로 = 범위 % · 변수마다 선 · Slide2 Sensitivity Plot) · 토네이도(끝값 F 폭 순). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; +import { axisTicks, type ProbCurve, type ProbResult } from "./logic/E03_StabSection_Prob"; +import { pointsText, type Point } from "./logic/E03_StabSection_Logic"; +import { svgEl } from "./E03_StabSection_UI_Section"; + +const W = 380; +const H = 200; +const PAD = { l: 44, r: 12, t: 12, b: 28 }; +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); + +interface Frame { + svg: SVGSVGElement; + sx: (x: number) => number; + sy: (y: number) => number; +} + +/** 축 틀 — 가로 · 세로 눈금 · 축 이름 */ +function frame( + role: string, + xr: [number, number], + yr: [number, number], + xLabel: string, + yLabel: string, + pad = PAD, + yAxis = true, +): Frame { + const xt = axisTicks(xr[0], xr[1], 5); + const yt = axisTicks(yr[0], yr[1], 4); + const [x0, x1] = [xt[0], xt[xt.length - 1]]; + const [y0, y1] = [yt[0], yt[yt.length - 1]]; + const sx = (x: number): number => + pad.l + ((x - x0) / Math.max(x1 - x0, 1e-9)) * (W - pad.l - pad.r); + const sy = (y: number): number => + H - pad.b - ((y - y0) / Math.max(y1 - y0, 1e-9)) * (H - pad.t - pad.b); + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": role }) as SVGSVGElement; + const axis = svgEl("g", { class: "e03-axis" }); + for (const v of yAxis ? yt : []) { + axis.append(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: sy(v), y2: sy(v) })); + axis.append(svgEl("text", { x: pad.l - 4, y: sy(v) + 4, "text-anchor": "end" }, String(v))); + } + for (const v of xt) + axis.append(svgEl("text", { x: sx(v), y: H - pad.b + 14, "text-anchor": "middle" }, String(v))); + axis.append(svgEl("text", { x: W - pad.r, y: H - 2, "text-anchor": "end" }, xLabel)); + axis.append(svgEl("text", { x: 2, y: pad.t - 2 }, yLabel)); + svg.append(axis); + return { svg, sx, sy }; +} + +function mark(f: Frame, x: number, cls: string, label: string): void { + f.svg.append( + svgEl("line", { + x1: f.sx(x), + x2: f.sx(x), + y1: PAD.t, + y2: H - PAD.b, + class: `e03-prob-mark ${cls}`, + }), + svgEl("text", { x: f.sx(x) + 3, y: PAD.t + 10, class: `e03-prob-mark__text ${cls}` }, label), + ); +} + +function box(role: string, svg: SVGSVGElement, legend?: HTMLElement): HTMLElement { + const host = el("div", { className: "e03-graph", attrs: { "data-role": `${role}-box` } }); + host.append(svg as unknown as HTMLElement); + if (legend) host.append(legend); + return host; +} + +/** 안전율 히스토그램 — 칸 개수 · F < 1 칸 빨강 · F = 1 · 기준 · 결정론 선 */ +export function histogramChart( + res: ProbResult, + required: number | null, + fsDet: number | null, +): HTMLElement { + const h = res.stats.histogram; + if (!h) return el("div"); + const { edges, counts } = h; + const xr: [number, number] = [ + Math.min(edges[0], 1), + Math.max(edges[edges.length - 1], required ?? 0), + ]; + const f = frame("prob-hist", xr, [0, Math.max(...counts, 1)], "Fs", t("E03_Pb_Count")); + counts.forEach((n, i) => { + const [a, b] = [edges[i], edges[i + 1]]; + const bar = svgEl("rect", { + x: f.sx(a), + y: f.sy(n), + width: Math.max(f.sx(b) - f.sx(a) - 0.5, 0.5), + height: f.sy(0) - f.sy(n), + class: b <= 1 ? "e03-prob-bar e03-prob-bar--fail" : "e03-prob-bar", + "data-count": n, + }); + bar.append(svgEl("title", {}, `${fmt(a, 3)} ~ ${fmt(b, 3)} · ${n}`)); + f.svg.append(bar); + }); + mark(f, 1, "e03-prob-mark--one", "F = 1"); + if (required) mark(f, required, "e03-prob-mark--req", `${t("E03_Required")} ${fmt(required)}`); + if (fsDet) mark(f, fsDet, "e03-prob-mark--det", t("E03_Pb_Det")); + return box("prob-hist", f.svg); +} + +/** 누적분포 — 가로 F · 세로 P(F ≤ x) · F = 1 에서 Pf */ +export function cdfChart(res: ProbResult, required: number | null): HTMLElement { + const c = res.stats.cdf; + if (!c) return el("div"); + const xr: [number, number] = [Math.min(c.F[0], 1), Math.max(c.F[c.F.length - 1], required ?? 0)]; + const f = frame("prob-cdf", xr, [0, 1], "Fs", "P(F ≤ x)"); + const pts: Point[] = c.F.map((x, i) => [f.sx(x), f.sy(c.p[i])]); + f.svg.append( + svgEl("polyline", { points: pointsText(pts), class: "e03-prob-line e03-prob-line--0" }), + ); + mark(f, 1, "e03-prob-mark--one", `Pf ${fmt((res.stats.pf ?? 0) * 100, 1)} %`); + if (required) mark(f, required, "e03-prob-mark--req", `${t("E03_Required")} ${fmt(required)}`); + return box("prob-cdf", f.svg); +} + +function legendOf(curves: ProbCurve[]): HTMLElement { + const legend = el("div", { className: "e03-legend" }); + curves.forEach((c, i) => { + const item = el("span", { className: "e03-legend__item" }); + const sw = svgEl("svg", { width: 18, height: 8 }); + sw.append( + svgEl("line", { + x1: 0, + x2: 18, + y1: 4, + y2: 4, + class: `e03-prob-line e03-prob-line--${i % 8}`, + }), + ); + item.append(sw as unknown as HTMLElement, c.name); + legend.append(item); + }); + return legend; +} + +/** 민감도 — 가로 = 범위 %(0 = 아래 끝 · 100 = 위 끝) · 나머지 변수 평균 · 임계면 고정 */ +export function sensitivityChart(res: ProbResult): HTMLElement { + const curves = res.sensitivity.curves; + const all = curves.flatMap((c) => c.F).filter((v): v is number => v !== null); + if (!all.length) return el("div"); + const f = frame( + "prob-sens", + [0, 100], + [Math.min(...all, 1), Math.max(...all)], + t("E03_Pb_Range"), + "Fs", + ); + curves.forEach((c, i) => { + const n = c.F.length; + const pts: Point[] = []; + c.F.forEach((v, k) => { + if (v !== null) pts.push([f.sx((100 * k) / Math.max(n - 1, 1)), f.sy(v)]); + }); + const line = svgEl("polyline", { + points: pointsText(pts), + class: `e03-prob-line e03-prob-line--${i % 8}`, + "data-name": c.name, + }); + line.append(svgEl("title", {}, `${c.name} · ${fmt(c.x[0])} ~ ${fmt(c.x[n - 1])}`)); + f.svg.append(line); + }); + return box("prob-sens", f.svg, legendOf(curves)); +} + +/** 토네이도 — 변수마다 아래 끝 · 위 끝 F 막대(평균 F 기준) · 폭 큰 순 */ +export function tornadoChart(res: ProbResult): HTMLElement { + const rows = res.sensitivity.tornado; + const F0 = res.sensitivity.F_mean; + if (!rows.length || F0 === null) return el("div"); + const vals = rows.flatMap((r) => [r.F_lo, r.F_hi]).filter((v): v is number => v !== null); + const pad = { ...PAD, l: 96 }; + const f = frame( + "prob-tornado", + [Math.min(...vals), Math.max(...vals)], + [0, 1], + "Fs", + "", + pad, + false, + ); + const step = (H - PAD.t - PAD.b) / rows.length; + rows.forEach((r, i) => { + const y = PAD.t + i * step + step * 0.15; + const hh = step * 0.7; + for (const [v, cls] of [ + [r.F_lo, "e03-prob-tbar--lo"], + [r.F_hi, "e03-prob-tbar--hi"], + ] as const) { + if (v === null) continue; + const [a, b] = [Math.min(f.sx(v), f.sx(F0)), Math.max(f.sx(v), f.sx(F0))]; + const bar = svgEl("rect", { + x: a, + y, + width: Math.max(b - a, 0.5), + height: hh, + class: `e03-prob-tbar ${cls}`, + }); + bar.append(svgEl("title", {}, `${r.name} · ${fmt(v, 3)}`)); + f.svg.append(bar); + } + f.svg.append( + svgEl( + "text", + { x: pad.l - 4, y: y + hh / 2 + 4, "text-anchor": "end", class: "e03-prob-tlabel" }, + r.name, + ), + ); + }); + mark(f, F0, "e03-prob-mark--det", fmt(F0, 3)); + return box("prob-tornado", f.svg); +} diff --git a/E03_StabSection/logic/E03_StabSection_Prob.ts b/E03_StabSection/logic/E03_StabSection_Prob.ts new file mode 100644 index 000000000..5d810d405 --- /dev/null +++ b/E03_StabSection/logic/E03_StabSection_Prob.ts @@ -0,0 +1,266 @@ +/* ============================================================================= + * E03_StabSection_Prob.ts + * E03 「확률 · 민감도」 탭 계산(PLAN 537-1) — 변수 줄(단면 물성 × c · φ · γ + 지하수위 · r_u) · + * 기본값 후보(서버 `prob_defaults.json`) · 입력 캐시 → 요청 꼴 · 그래프 눈금. + * 화면 · fetch · sessionStorage 없음(페이지가 부름). + * ========================================================================== */ + +export type ProbProp = "c" | "phi" | "gamma" | "water" | "ru"; +export type ProbDist = "normal" | "lognormal"; +export type ProbMethod = "ordinary" | "bishop" | "janbu" | "spencer" | "morgenstern_price"; + +export interface ProbPropDefault { + dist: ProbDist; + cov?: number; + std?: number; + trunc_sigma: number | null; +} + +export interface ProbDefaults { + run: { + n: number; + sampling: "lhs" | "mc"; + seed: number; + mode: "fixed" | "search"; + method: ProbMethod; + }; + properties: Record; + rho_c_phi: number; + sources: Record; + materials?: Record>>>; +} + +export interface ProbMaterial { + id: string; + name: string | null; + c: number; + phi: number; + gamma_t: number; +} + +export interface ProbStats { + n: number; + n_valid: number; + mean: number | null; + std: number | null; + cov?: number | null; + min?: number; + max?: number; + pf: number | null; + n_fail?: number; + beta_normal: number | null; + beta_lognormal?: number | null; + required?: number | null; + p_below_required?: number | null; + histogram?: { edges: number[]; counts: number[] }; + cdf?: { p: number[]; F: number[] }; +} + +export interface ProbCurve { + name: string; + x: (number | null)[]; + F: (number | null)[]; + F_lo: number | null; + F_hi: number | null; + swing: number | null; +} + +export interface ProbVariable { + name: string; + material: string | null; + prop: ProbProp; + dist: ProbDist; + mean: number; + std: number; + lo: number | null; + hi: number | null; +} + +export interface ProbResult { + mode: "fixed" | "search"; + method: ProbMethod; + n: number; + sampling: string; + seed: number; + variables: ProbVariable[]; + stats: ProbStats; + sensitivity: { + F_mean: number | null; + curves: ProbCurve[]; + tornado: { name: string; F_lo: number | null; F_hi: number | null; swing: number }[]; + }; + elapsed_s: { total: number; samples: number }; +} + +export interface ProbRecord { + station_key: string; + side: string; + condition: string; + required: number | null; + fs_deterministic: number | null; + request: Record; + result: ProbResult; + computed_at: string; +} + +export interface ProbSetup { + station_key: string; + side: string; + condition: string; + role: "cut" | "fill"; + required: number | null; + fs_deterministic: Record; + materials: ProbMaterial[]; + groundwater: { mode: "none" | "line" | "ru"; ru: number | null }; + defaults: ProbDefaults; + search_max_n: number; + last: ProbRecord | null; +} + +/** 변수 줄 하나 — kind cov = 변동계수(평균 곱) · std = 표준편차(m · r_u) */ +export interface ProbRow { + key: string; + material: string | null; + prop: ProbProp; + name: string; + mean: number; + kind: "cov" | "std"; +} + +export interface ProbInput { + use: boolean; + dist: ProbDist; + value: number; + trunc: number | null; +} + +export interface ProbRunInput { + n: number; + sampling: "lhs" | "mc"; + seed: number; + mode: "fixed" | "search"; + method: ProbMethod; + rho: number; +} + +export interface ProbCache { + vars: Record; + run: ProbRunInput; +} + +export const varKey = (material: string | null, prop: ProbProp): string => + `${material ?? "*"}|${prop}`; + +/** 단면 물성마다 c · φ · γ + 지하수 갈래가 수위선이면 지하수위 · r_u 면 r_u */ +export function probRows(setup: ProbSetup): ProbRow[] { + const rows: ProbRow[] = []; + for (const m of setup.materials) { + const name = m.name ?? m.id; + rows.push( + { key: varKey(m.id, "c"), material: m.id, prop: "c", name, mean: m.c, kind: "cov" }, + { key: varKey(m.id, "phi"), material: m.id, prop: "phi", name, mean: m.phi, kind: "cov" }, + { + key: varKey(m.id, "gamma"), + material: m.id, + prop: "gamma", + name, + mean: m.gamma_t, + kind: "cov", + }, + ); + } + const gw = setup.groundwater; + if (gw.mode === "line") + rows.push({ + key: varKey(null, "water"), + material: null, + prop: "water", + name: "", + mean: 0, + kind: "std", + }); + if (gw.mode === "ru") + rows.push({ + key: varKey(null, "ru"), + material: null, + prop: "ru", + name: "", + mean: gw.ru ?? 0, + kind: "std", + }); + return rows; +} + +/** 기본값 후보 — 성질 기본 위에 물성별 덮어쓰기 · 0 이면 끔 */ +export function defaultInput(defaults: ProbDefaults, row: ProbRow): ProbInput { + const base = defaults.properties[row.prop]; + const own = (row.material && defaults.materials?.[row.material]?.[row.prop]) || {}; + const merged = { ...base, ...own }; + const value = Number((row.kind === "cov" ? merged.cov : merged.std) ?? 0); + return { + use: value > 0, + dist: row.prop === "water" ? "normal" : merged.dist, + value, + trunc: merged.trunc_sigma ?? null, + }; +} + +/** 캐시가 없는 줄만 기본값 후보로 채움 */ +export function fillCache(setup: ProbSetup, cache: ProbCache | null): ProbCache { + const d = setup.defaults; + const vars: Record = { ...(cache?.vars ?? {}) }; + for (const row of probRows(setup)) vars[row.key] ??= defaultInput(d, row); + const run = cache?.run ?? { ...d.run, rho: d.rho_c_phi }; + return { vars, run }; +} + +/** 기본값과 다른 줄 수(「사용자 입력」 표기) */ +export function changedCount(setup: ProbSetup, cache: ProbCache): number { + return probRows(setup).filter((row) => { + const a = defaultInput(setup.defaults, row); + const b = cache.vars[row.key]; + return ( + b && (a.use !== b.use || a.dist !== b.dist || a.value !== b.value || a.trunc !== b.trunc) + ); + }).length; +} + +/** POST …/stab/prob/{측점} 몸 — 켠 줄만 · 값 0 은 뺌 */ +export function probRequest(setup: ProbSetup, cache: ProbCache): Record { + const variables = probRows(setup) + .map((row) => ({ row, v: cache.vars[row.key] })) + .filter(({ v }) => v?.use && v.value > 0) + .map(({ row, v }) => ({ + material: row.material, + prop: row.prop, + dist: v.dist, + ...(row.kind === "cov" ? { cov: v.value } : { std: v.value }), + trunc_sigma: v.trunc, + })); + const r = cache.run; + return { + side: setup.side, + condition: setup.condition, + method: r.method, + mode: r.mode, + n: Math.round(r.n), + sampling: r.sampling, + seed: Math.round(r.seed), + rho_c_phi: r.rho, + variables, + }; +} + +/** 그래프 눈금 — 0 을 억지로 넣지 않음(안전율 축) */ +export function axisTicks(min: number, max: number, count = 5): number[] { + const lo = Math.min(min, max); + const hi = Math.max(lo + 1e-9, max); + const raw = (hi - lo) / Math.max(count, 1); + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw; + const out: number[] = []; + for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step) + out.push(Math.round(v * 1e6) / 1e6); + if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step); + return out; +} diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index 0105cd5e1..bf0407150 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -3,14 +3,14 @@ 입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별 `stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약 (있으면) · E07 광역 저장본(있으면) · E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) · -E06 암반 해석 저장본(있으면 · 없으면 s11 뺌) · +E06 암반 해석 저장본(있으면 · 없으면 s11 뺌) · E03 확률 해석 저장본(있으면 · 없으면 s12 뺌) · E01 복사본(측점 · 횡단 · 노선). 양식 = `resources/master_template/report/stab_report.json` 칸 자리 = 같은 폴더 `stab_report_slots.md`. 몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 · 프로그램(목차 s9 · 입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8 · -암반검토 s11 · 부록_절편 s10) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 대표 단면 · 부록 = `_Section` · +암반검토 s11 · 확률해석 s12 · 부록_절편 s10) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 대표 단면 · 부록 = `_Section` · 목차 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔 = `_Print` · 칸 공용 = `_Cells`. 판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임 (인쇄 HTML · xlsx 는 조건부 서식을 안 먹음). @@ -38,6 +38,7 @@ from E04_Stab3D import E04_Stab3D_Run as run3d from E05_StabReport import E05_StabReport_Area as area from E05_StabReport import E05_StabReport_Figure as figure from E05_StabReport import E05_StabReport_Print as printer +from E05_StabReport import E05_StabReport_Prob as prob from E05_StabReport import E05_StabReport_Reinforce as reinforce from E05_StabReport import E05_StabReport_Rock as rock from E05_StabReport import E05_StabReport_Section as section @@ -62,7 +63,7 @@ FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.jso STAGE = "E05_StabReport" REPORT, META = "report.json", "report_meta.json" USER_SHEETS = ("s1", "s2") -PROGRAM_SHEETS = ("s9", "s3", "s4", "s5", "s6", "s7", "s8", "s11", "s10") +PROGRAM_SHEETS = ("s9", "s3", "s4", "s5", "s6", "s7", "s8", "s11", "s12", "s10") REPRESENTATIVE = Path("E03_StabSection") / "representative.json" BAND_KEY = "fig/3d_band.png" @@ -170,6 +171,7 @@ def analysis_fingerprint(project_root: Path) -> str: "area": area.stamp(project_root), "reinforce": reinforce.stamp(project_root), "rock": rock.stamp(project_root), + "prob": prob.stamp(project_root), "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(), } text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) @@ -221,6 +223,7 @@ def gather(project_root: Path) -> dict[str, Any]: "area": area.read(project_root), "reinforce": reinforce.read(project_root), "rock": rock.read(project_root), + "prob": prob.read(project_root), "fingerprint": analysis_fingerprint(project_root), } @@ -453,6 +456,10 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st rock.fill_rock(by["s11"], inputs["rock"], _labels(inputs["stations"])) else: # 암반 해석이 없으면 시트를 뺌 book["시트"] = [s for s in book["시트"] if s["id"] != "s11"] + if inputs.get("prob"): + prob.fill_prob(by["s12"], inputs["prob"], _labels(inputs["stations"])) + else: # 확률 해석이 없으면 시트를 뺌 + book["시트"] = [s for s in book["시트"] if s["id"] != "s12"] printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc` return jobs diff --git a/E05_StabReport/E05_StabReport_Print.py b/E05_StabReport/E05_StabReport_Print.py index ff06e3dbf..d2dfe7603 100644 --- a/E05_StabReport/E05_StabReport_Print.py +++ b/E05_StabReport/E05_StabReport_Print.py @@ -32,6 +32,7 @@ TITLES = { "s7": "광역 자연사면 위험 요약", "s8": "보강 검토 — 비탈 완화 · 소단 · 벽 외적 안정", "s11": "암반 비탈면 검토 — 평사투영 · 평면 · 쐐기 안전율", + "s12": "확률 해석 · 민감도 — 파괴확률 · 신뢰도 지수", "s10": "대표 단면 절편 계산표", } APPENDIX = {"s10"} diff --git a/E05_StabReport/E05_StabReport_Prob.py b/E05_StabReport/E05_StabReport_Prob.py new file mode 100644 index 000000000..4388dce08 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Prob.py @@ -0,0 +1,145 @@ +"""E05 보고서 s12 확률해석 — E03 확률 해석 저장본(대표 단면 · 비탈 · 조건마다 가장 최근) → 시트(PLAN 537-1). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s12. +저장본이 없으면 시트를 뺌(있을 때만) — `build_program` 이 책에서 지움. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +from E03_StabSection.E03_StabSection_Router_Prob import load_defaults, records +from E05_StabReport.E05_StabReport_Cells import _set, mark + +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SIDE_NAMES = {"left": "좌", "right": "우"} +ROLE_NAMES = {"cut": "깎기", "fill": "쌓기"} +METHOD_NAMES = { + "ordinary": "Fellenius", + "bishop": "Bishop", + "janbu": "Janbu 보정", + "spencer": "Spencer", + "morgenstern_price": "M-P", +} +MODE_NAMES = {"fixed": "임계면 고정", "search": "매회 탐색"} +DIST_NAMES = {"normal": "정규", "lognormal": "대수정규"} +COLS = "ABCDEFGHIJK" + + +def read(project_root: Path) -> list[dict[str, Any]]: + return records(Path(project_root)) + + +def stamp(project_root: Path) -> str: + """해석 지문용 — 저장본 목록 내용(표본 F 뺌).""" + body = [ + {**r, "result": {k: v for k, v in r["result"].items() if k != "F"}} + for r in read(project_root) + ] + text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def _is_default(rec: dict[str, Any], defaults: dict[str, Any]) -> bool: + """요청 변수 · 표본 설정이 기본값 후보 그대로인지(「사용자 확정 전」 표기).""" + req = rec["request"] + props, own = defaults["properties"], defaults.get("materials") or {} + for v in req["variables"]: + base = { + **props[v["prop"]], + **((own.get(v.get("material") or "") or {}).get(v["prop"]) or {}), + } + spread = v.get("cov") if v.get("cov") is not None else v.get("std") + want = base.get("cov") if "cov" in base else base.get("std") + if ( + v["dist"] != base["dist"] + or spread != want + or v.get("trunc_sigma") != base["trunc_sigma"] + ): + return False + run = defaults["run"] + same = all(req.get(k) == run[k] for k in ("n", "sampling", "seed", "mode", "method")) + return same and req.get("rho_c_phi", 0.0) == defaults["rho_c_phi"] + + +def fill_prob(sheet: dict[str, Any], recs: list[dict[str, Any]], labels: dict) -> None: + """s12 — 1. 측점 · 비탈 · 조건마다 한 줄(6 행 서식) · 2. 변수마다 한 줄(10 행 서식 · 토네이도 순).""" + tpl = { + r: {c: copy.deepcopy(sheet["칸"].get(f"{c}{r}") or {}) for c in COLS} for r in (6, 8, 9, 10) + } + for a1 in [k for k in sheet["칸"] if int("".join(ch for ch in k if ch.isdigit())) >= 6]: + del sheet["칸"][a1] + sheet["병합"] = [ + m for m in sheet["병합"] if int("".join(ch for ch in m.split(":")[0] if ch.isdigit())) < 6 + ] + sheet["행"] = {k: v for k, v in sheet["행"].items() if int(k) < 6} + + def put_row(r: int, styles: dict[str, dict]) -> None: + for c, cell in styles.items(): + sheet["칸"][f"{c}{r}"] = copy.deepcopy(cell) + + def name(rec: dict[str, Any]) -> str: + ch = float(rec["chainage_m"]) + return rec.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}" + + def where(rec: dict[str, Any]) -> str: + return ( + f"{SIDE_NAMES[rec['side']]} {ROLE_NAMES.get(rec['role'], rec['role'])} · " + f"{CONDITION_NAMES.get(rec['condition'], rec['condition'])}" + ) + + defaults = load_defaults() + same = all(_is_default(rec, defaults) for rec in recs) + _set(sheet, "A3", mark("변동계수 · 분포 · 표본 = 문헌 기본값 후보(근거 E03 탭)", same)) + pct = lambda v: None if v is None else 100.0 * float(v) # noqa: E731 + r = 6 + for rec in recs: + res, st = rec["result"], rec["result"]["stats"] + put_row(r, tpl[6]) + _set(sheet, f"A{r}", name(rec)) + _set(sheet, f"B{r}", where(rec)) + _set( + sheet, + f"C{r}", + f"{METHOD_NAMES.get(res['method'], res['method'])} · {MODE_NAMES[res['mode']]}", + ) + _set(sheet, f"D{r}", int(st["n_valid"])) + _set(sheet, f"E{r}", rec.get("fs_deterministic")) + for c, k in zip("FG", ("mean", "std")): + _set(sheet, f"{c}{r}", st.get(k)) + _set(sheet, f"H{r}", pct(st.get("pf"))) + _set(sheet, f"I{r}", st.get("beta_normal")) + _set(sheet, f"J{r}", st.get("beta_lognormal")) + _set(sheet, f"K{r}", pct(st.get("p_below_required"))) + r += 1 + r += 1 + put_row(r, tpl[8]) + sheet["병합"].append(f"A{r}:K{r}") + sheet["행"][str(r)] = {"높이": 22} + sheet["행"][str(r + 1)] = {"높이": 30} + put_row(r + 1, tpl[9]) + r += 2 + for rec in recs: + res = rec["result"] + curves = {c["name"]: c for c in res["sensitivity"]["curves"]} + rank = {t["name"]: i + 1 for i, t in enumerate(res["sensitivity"]["tornado"])} + for v in sorted(res["variables"], key=lambda v: rank.get(v["name"], 99)): + c = curves.get(v["name"]) or {} + xs = c.get("x") or [None] + put_row(r, tpl[10]) + _set(sheet, f"A{r}", f"{name(rec)} · {where(rec)}") + _set(sheet, f"B{r}", v["name"]) + _set(sheet, f"C{r}", DIST_NAMES.get(v["dist"], v["dist"])) + _set(sheet, f"D{r}", float(v["mean"])) + _set(sheet, f"E{r}", float(v["std"])) + _set(sheet, f"F{r}", v["lo"] / v["std"] if v.get("lo") is not None else "-") + _set(sheet, f"G{r}", xs[0]) + _set(sheet, f"H{r}", c.get("F_lo")) + _set(sheet, f"I{r}", xs[-1]) + _set(sheet, f"J{r}", c.get("F_hi")) + _set(sheet, f"K{r}", rank.get(v["name"])) + r += 1 diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 113e576e5..62d543a72 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -149,6 +149,9 @@ SHEET_SURFACE_METHODS = [ ).split(",") if method.strip() ] +# D 예정노선 [분석] 연도 구간을 나란히 셀 프로세스 수 상한(프로세스 하나 약 0.8 GB) — 여럿이 동시에 +# 돌리면 줄임. +PLAN_ANALYZE_WORKERS = int(os.getenv("PLAN_ANALYZE_WORKERS", "3")) # 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") # 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 diff --git a/main.py b/main.py index a1ce11a71..cd8ab38f4 100644 --- a/main.py +++ b/main.py @@ -86,6 +86,7 @@ from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_route from E07_StabArea.E07_StabArea_Router import router as e07_stab_area_router from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router from E03_StabSection.E03_StabSection_Router_WhatIf import router as e03_stab_whatif_router +from E03_StabSection.E03_StabSection_Router_Prob import router as e03_stab_prob_router from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -499,6 +500,7 @@ app.include_router(e05_stab_report_router, dependencies=protected_with_company) app.include_router(e07_stab_area_router, dependencies=protected_with_company) app.include_router(e03_stab_section_router, dependencies=protected_with_company) app.include_router(e03_stab_whatif_router, dependencies=protected_with_company) +app.include_router(e03_stab_prob_router, dependencies=protected_with_company) app.include_router(e06_stab_rock_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 1b9d49ef2..1136f7c7d 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -4133,6 +4133,452 @@ } } }, + { + "id": "s12", + "이름": "확률해석", + "칸": { + "A1": { + "서식": 1, + "값": "확률 해석 · 민감도 (대표 단면)" + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "G1": { + "서식": 1 + }, + "H1": { + "서식": 1 + }, + "I1": { + "서식": 1 + }, + "J1": { + "서식": 1 + }, + "K1": { + "서식": 1 + }, + "A2": { + "서식": 2, + "값": "물성 = 확률 변수(문헌 추정 변동계수) · Pf = P(Fs < 1) · β 정규 = (μ − 1)/σ · β 대수정규 = ln(μ/√(1+V²))/√ln(1+V²) — Slide2 · SLOPE/W Probabilistic 과 같은 정의" + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "G2": { + "서식": 2 + }, + "H2": { + "서식": 2 + }, + "I2": { + "서식": 2 + }, + "J2": { + "서식": 2 + }, + "K2": { + "서식": 2 + }, + "A3": { + "서식": 12 + }, + "B3": { + "서식": 12 + }, + "C3": { + "서식": 12 + }, + "D3": { + "서식": 12 + }, + "E3": { + "서식": 12 + }, + "F3": { + "서식": 12 + }, + "G3": { + "서식": 12 + }, + "H3": { + "서식": 12 + }, + "I3": { + "서식": 12 + }, + "J3": { + "서식": 12 + }, + "K3": { + "서식": 12 + }, + "A4": { + "서식": 3, + "값": "1. 측점별 결과 — 안전율 분포 · 파괴확률 · 신뢰도 지수" + }, + "B4": { + "서식": 3 + }, + "C4": { + "서식": 3 + }, + "D4": { + "서식": 3 + }, + "E4": { + "서식": 3 + }, + "F4": { + "서식": 3 + }, + "G4": { + "서식": 3 + }, + "H4": { + "서식": 3 + }, + "I4": { + "서식": 3 + }, + "J4": { + "서식": 3 + }, + "K4": { + "서식": 3 + }, + "A5": { + "서식": 6, + "값": "측점" + }, + "B5": { + "서식": 6, + "값": "비탈 · 조건" + }, + "C5": { + "서식": 6, + "값": "방법 · 임계면" + }, + "D5": { + "서식": 6, + "값": "표본" + }, + "E5": { + "서식": 6, + "값": "결정론 Fs" + }, + "F5": { + "서식": 6, + "값": "Fs 평균" + }, + "G5": { + "서식": 6, + "값": "표준편차" + }, + "H5": { + "서식": 6, + "값": "Pf %" + }, + "I5": { + "서식": 6, + "값": "β 정규" + }, + "J5": { + "서식": 6, + "값": "β 대수정규" + }, + "K5": { + "서식": 6, + "값": "P(F<기준) %" + }, + "A6": { + "서식": 7 + }, + "B6": { + "서식": 7 + }, + "C6": { + "서식": 5 + }, + "D6": { + "서식": 9 + }, + "E6": { + "서식": 8 + }, + "F6": { + "서식": 8 + }, + "G6": { + "서식": 8 + }, + "H6": { + "서식": 8 + }, + "I6": { + "서식": 8 + }, + "J6": { + "서식": 8 + }, + "K6": { + "서식": 8 + }, + "A8": { + "서식": 3, + "값": "2. 확률 변수 · 민감도 — 토네이도 순(나머지 평균 · 임계면 고정 · 범위 끝 Fs)" + }, + "B8": { + "서식": 3 + }, + "C8": { + "서식": 3 + }, + "D8": { + "서식": 3 + }, + "E8": { + "서식": 3 + }, + "F8": { + "서식": 3 + }, + "G8": { + "서식": 3 + }, + "H8": { + "서식": 3 + }, + "I8": { + "서식": 3 + }, + "J8": { + "서식": 3 + }, + "K8": { + "서식": 3 + }, + "A9": { + "서식": 6, + "값": "측점 · 비탈 · 조건" + }, + "B9": { + "서식": 6, + "값": "변수" + }, + "C9": { + "서식": 6, + "값": "분포" + }, + "D9": { + "서식": 6, + "값": "평균" + }, + "E9": { + "서식": 6, + "값": "표준편차" + }, + "F9": { + "서식": 6, + "값": "절단 ±σ" + }, + "G9": { + "서식": 6, + "값": "아래 끝" + }, + "H9": { + "서식": 6, + "값": "Fs(아래)" + }, + "I9": { + "서식": 6, + "값": "위 끝" + }, + "J9": { + "서식": 6, + "값": "Fs(위)" + }, + "K9": { + "서식": 6, + "값": "순위" + }, + "A10": { + "서식": 7 + }, + "B10": { + "서식": 5 + }, + "C10": { + "서식": 7 + }, + "D10": { + "서식": 8 + }, + "E10": { + "서식": 8 + }, + "F10": { + "서식": 16 + }, + "G10": { + "서식": 8 + }, + "H10": { + "서식": 8 + }, + "I10": { + "서식": 8 + }, + "J10": { + "서식": 8 + }, + "K10": { + "서식": 9 + }, + "A12": { + "서식": 5, + "값": "확률 해석 없음 — E03 [확률 · 민감도] 뒤 [보고서 갱신]" + }, + "B12": { + "서식": 5 + }, + "C12": { + "서식": 5 + }, + "D12": { + "서식": 5 + }, + "E12": { + "서식": 5 + }, + "F12": { + "서식": 5 + }, + "G12": { + "서식": 5 + }, + "H12": { + "서식": 5 + }, + "I12": { + "서식": 5 + }, + "J12": { + "서식": 5 + }, + "K12": { + "서식": 5 + } + }, + "병합": [ + "A1:K1", + "A2:K2", + "A3:K3", + "A4:K4", + "A8:K8", + "A12:K12" + ], + "열": { + "A": { + "폭": 13 + }, + "B": { + "폭": 16 + }, + "C": { + "폭": 18 + }, + "D": { + "폭": 7 + }, + "E": { + "폭": 8 + }, + "F": { + "폭": 8 + }, + "G": { + "폭": 8 + }, + "H": { + "폭": 8 + }, + "I": { + "폭": 8 + }, + "J": { + "폭": 8 + }, + "K": { + "폭": 8 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 30 + }, + "3": { + "높이": 22 + }, + "4": { + "높이": 22 + }, + "5": { + "높이": 30 + }, + "8": { + "높이": 22 + }, + "9": { + "높이": 30 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "landscape", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + } + } + }, { "id": "s10", "이름": "부록_절편", diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index b566cf62d..47d2cf4d9 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -4,8 +4,8 @@ ## 공통 -- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(목차 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · 보강검토 · 암반검토 · 부록_절편) = 갱신 때 통째 다시 씀 · 작업본에 없는 프로그램 몫 시트는 양식 차례 자리에 끼움 · 새 서식은 작업본 서식 표 끝에 붙여 번호를 바꿈. -- 시트 차례: 표지 · 목차 · 개요 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · (보강검토) · (암반검토) · 부록_절편. +- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(목차 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · 보강검토 · 암반검토 · 확률해석 · 부록_절편) = 갱신 때 통째 다시 씀 · 작업본에 없는 프로그램 몫 시트는 양식 차례 자리에 끼움 · 새 서식은 작업본 서식 표 끝에 붙여 번호를 바꿈. +- 시트 차례: 표지 · 목차 · 개요 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · (보강검토) · (암반검토) · (확률해석) · 부록_절편. - PDF: 표지 = 쪽 번호 · 머리말 없음 · 본문 = 머리말(왼 사업명 = 표지 `C8` · 오른 보고서 이름) · 꼬리말 「- n / N -」(목차 = 1 쪽). - 사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 바꿈 = 「(사용자 입력)」 · 물성 = 「문헌 추정치 · 사용자 확정 전」. - 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00) · 18 head8 · 19 num8(0.00) · 20 num8(0.0) · 21 int8 · 22 목차 내용 · 23 목차 쪽 · 24 목차 장 · 25 sum8(0.00 · 회색) · 26 표지 제목. @@ -126,6 +126,16 @@ | 11 · 12 행 꼴(제목 3 · 머리 6) 다음 절리군 하나 = 한 줄(13 행 서식) | A 이름(7) · B 경사방향 · C 경사 · D φj · E cj(16) · F 지속성(13) · G · H 구간 시작 · 끝(7) · 모르면 「-」 · 없으면 「절리군 없음」 | | 절리 줄 다음 한 줄 띄고 15 · 16 행 꼴 · 그 아래 측점 비탈 × 위험 모드 × 조건 = 한 줄(17 행 서식) | A 측점(7) · B 비탈 글(쪽 · 경사 / 방향 · 높이)(5) · C 파괴 형태(평면 · 쐐기 · 전도 + 절리)(7) · D 조건(7) · E Fs · F 기준(8) · G 판정(7 → 10 · 11 · 전도 「검토」) · H 비고(블록 안 생김 · 미끄러짐 면 · 지속성)(5) · 위험 모드 없는 비탈 = 「없음」 한 줄 | +## s12 확률해석 (프로그램 몫 · E03 확률 해석 저장본 · 없으면 시트를 뺌 · 가로 쪽) + +| 칸 | 내용 | +| --- | --- | +| `A3` (A:K 병합 · 서식 12) | 「변동계수 · 분포 · 표본 = 문헌 기본값 후보(근거 E03 탭)」 + 사용자 확정 전 표기(모든 기록이 `prob_defaults.json` 그대로면 기본값 · 아니면 사용자 입력) | +| 4 · 5 행 꼴(제목 3 · 머리 6) 다음 (측점 · 비탈 · 조건)마다 한 줄(6 행 서식) | A 측점(7) · B 비탈 · 조건(7) · C 방법 · 임계면 고정 / 매회 탐색(5) · D 유효 표본(9) · E 결정론 Fs · F 평균 · G 표준편차 · H Pf % · I β 정규 · J β 대수정규 · K P(F < 기준) %(8) | +| 한 줄 띄고 8 · 9 행 꼴 · 그 아래 기록마다 변수 = 한 줄(10 행 서식 · 토네이도 순) | A 측점 · 비탈 · 조건(7) · B 변수(5) · C 분포(7) · D 평균 · E 표준편차(8) · F 절단 ±σ(16 · 없으면 「-」) · G 아래 끝 · H 그때 Fs · I 위 끝 · J 그때 Fs(8) · K 순위(9) | + +- 저장본 = (측점 · 비탈 · 조건)마다 가장 최근 하나 · 지금 2D 결과 측점 지문에 붙은 것만. + ## s10 부록_절편 (프로그램 몫 · 대표 단면마다 한 쪽) 양식 1~5 행 = 덩이 꼴(1 제목 · 2 단위 · 3 머리 · 4 절편 줄 서식 · 5 합계 서식). 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면의 절편표. diff --git a/resources/master_template/stability/prob_defaults.json b/resources/master_template/stability/prob_defaults.json new file mode 100644 index 000000000..8d3a1119b --- /dev/null +++ b/resources/master_template/stability/prob_defaults.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "note": "확률 해석 기본값 후보 — 문헌 · 상용 SW 예제 값 · 현장 시험 없음 · 사용자 확정 전(E03 확률 · 민감도 탭에서 바꿈)", + "run": { + "n": 1000, + "sampling": "lhs", + "seed": 0, + "mode": "fixed", + "method": "bishop" + }, + "properties": { + "c": { + "dist": "lognormal", + "cov": 0.3, + "trunc_sigma": 3.0 + }, + "phi": { + "dist": "normal", + "cov": 0.1, + "trunc_sigma": 3.0 + }, + "gamma": { + "dist": "normal", + "cov": 0.05, + "trunc_sigma": 3.0 + }, + "water": { + "dist": "normal", + "std": 0.0, + "trunc_sigma": 3.0 + }, + "ru": { + "dist": "normal", + "std": 0.0, + "trunc_sigma": 3.0 + } + }, + "rho_c_phi": 0.0, + "sources": { + "c": "COV 0.30(범위 0.2~0.5) · 대수정규(음수 막음) — 국내 화강풍화토 실측 정남수 외(2011) 0.34~0.49 · 장정윤 · 박혁진(2017) 0.21 · 해석 가정 Cho(2018 · 2019) 0.20 · Lee 외(2020) 0.25 · ISSMGE TC304(2021) 홍콩 완전풍화화강암 0.51 · Harr(1987) 최대 0.40 · 붕적토 0.40 = 국내 자료 없어 국외 범위 위쪽(협의 필요)", + "phi": "COV 0.10(범위 0.05~0.15) · 정규 — 국내 화강풍화토 실측 정남수 외(2011) 0.09~0.12 · 장정윤 · 박혁진(2017) 0.03 · Cho(2018 · 2019) · Lee 외(2020) 0.10 · TC304(2021) 모래 평균 0.079 · 홍콩 CDG 0.096 · Duncan(2000) 2~13 %", + "gamma": "COV 0.05(범위 0.03~0.07) · 정규 — Duncan(2000) 3~7 % · 홍콩 CDG 2.6 % · Li & Lumb(1987) 5 % · 국내 논문은 고정값으로 둠", + "water": "지하수위 이동 표준편차 기본 0(확률 변수 아님) · 수위선 갈래(E02)일 때만", + "ru": "r_u 표준편차 기본 0 · r_u 갈래(E02)일 때만 · Li & Lumb(1987) COV 10 %", + "rho_c_phi": "기본 0(상관 무시 = 보수적) · Slide2 · Cho(2019) 기본 −0.5 · 문헌 −0.25~−0.75(Wolff 1985 −0.47 · Lumb 1970 −0.7~−0.37)", + "trunc_sigma": "평균 ±3σ 절단 — Slide2 「3x 표준편차」 · SLOPE/W James Bay 예제", + "run": "LHS 1000 · 임계면 고정(Slide2 Global Minimum) · Bishop — Slide2 Tutorial 08" + }, + "materials": { + "colluvium": { + "c": { + "cov": 0.4 + } + } + } +} diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index 1e50b9275..cbab24f0f 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -17,8 +17,7 @@ SEGMENTS = [ ] -@pytest.fixture(scope="module") -def hill() -> Terrain: +def _hill() -> Terrain: x = np.arange(0.0, 1102.0, 2.0) y = np.arange(0.0, 402.0, 2.0) xx, yy = np.meshgrid(x, y) @@ -26,6 +25,16 @@ def hill() -> Terrain: return Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) +def hill_loader(root, selection, models_dir, cache_dir): + """나란히 셈 프로세스가 이름으로 부르는 지형(모듈 맨 위 함수).""" + return _hill(), [] + + +@pytest.fixture(scope="module") +def hill() -> Terrain: + return _hill() + + @pytest.fixture def run(tmp_path, hill): _POOLS.clear() @@ -37,7 +46,7 @@ def run(tmp_path, hill): def go(criteria=CRITERIA, corridor_m=150.0): return analyze.analyze_plan( - tmp_path, SEGMENTS, criteria, loader=loader, corridor_m=corridor_m + tmp_path, SEGMENTS, criteria, loader=loader, corridor_m=corridor_m, workers=1 ) go.calls = calls @@ -91,6 +100,54 @@ def test_same_input_reads_store(run, monkeypatch) -> None: run(criteria={**CRITERIA, "max_grade": 0.12}) +def test_parallel_years_match_sequential(tmp_path) -> None: + """연도 구간 나란히(프로세스 둘) = 차례로 — 갈래 넷 노선 좌표 · 잰 값 차 0(531-6).""" + _POOLS.clear() + seq = analyze.analyze_plan( + tmp_path / "a", SEGMENTS, CRITERIA, loader=hill_loader, corridor_m=150.0, workers=1 + ) + _POOLS.clear() + steps = [] + par = analyze.analyze_plan( + tmp_path / "b", + SEGMENTS, + CRITERIA, + loader=hill_loader, + corridor_m=150.0, + workers=2, + on_progress=lambda p, m: steps.append(m), + ) + assert [r["routes"] for r in par] == [r["routes"] for r in seq] + assert [r["fingerprint"] for r in par] == [r["fingerprint"] for r in seq] + assert "나란히 2" in steps[0] and steps[-1] == "분석 끝" + # 저장본은 제 프로세스에서 지문만 보고 그대로 + again = analyze.analyze_plan( + tmp_path / "b", SEGMENTS, CRITERIA, loader=hill_loader, corridor_m=150.0, workers=2 + ) + assert [r["stored"] for r in again] == [True, True] + _POOLS.clear() + + +def test_near_streams_keeps_every_nearest_line(hill) -> None: + """계류 거르기 — 지형 상자 안 점의 계류 거리 · 건너기가 통째와 같고 먼 선은 빠짐(531-6).""" + import shapely + from shapely.geometry import LineString, MultiLineString + + rng = np.random.default_rng(7) + streams = [] + for _ in range(80): # 지형(1.1 × 0.4 km) 둘레 5 km 에 흩은 계류 + start = rng.uniform(-2000.0, 3000.0, 2) + streams.append([tuple(start + step) for step in np.cumsum(rng.normal(0, 60, (6, 2)), 0)]) + kept = analyze.near_streams(hill, streams) + assert 0 < len(kept) < len(streams) + points = shapely.points(rng.uniform((0.0, 0.0), (1100.0, 400.0), (4000, 2))) + whole, part = MultiLineString(streams), MultiLineString(kept) + assert np.array_equal(shapely.distance(points, whole), shapely.distance(points, part)) + route = LineString([(0.0, 0.0), (1100.0, 400.0)]) + assert route.intersection(whole).equals(route.intersection(part)) + assert analyze.near_streams(hill, []) == [] + + def test_corridor_argument_keeps_b_default(hill) -> None: line = [SEGMENTS[0]["start"], SEGMENTS[0]["end"]] base = SearchGrid(line, hill, [], stream_offset_m=10.0) diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py index 4a4bb2c51..cc625b2bc 100644 --- a/resources/tester/test_534_3_stab_report_form.py +++ b/resources/tester/test_534_3_stab_report_form.py @@ -38,7 +38,7 @@ PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", " A1 = re.compile(r"^([A-Z]{1,3})(\d+)$") NAMES = [ "표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토", "암반검토", - "부록_절편", + "확률해석", "부록_절편", ] # fmt: skip needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -151,7 +151,7 @@ def test_양식_계약(): book = _form() _contract(book) assert [s["이름"] for s in book["시트"]] == NAMES - ids = ["s1", "s9", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s11", "s10"] + ids = ["s1", "s9", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s11", "s12", "s10"] assert [s["id"] for s in book["시트"]] == ids @@ -175,6 +175,7 @@ def test_양식_인쇄_쪽나눔_머리행_그림(): "광역요약": 0, "보강검토": 0, "암반검토": 0, + "확률해석": 0, } # N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀) assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9 @@ -239,9 +240,9 @@ def test_견본_pdf_쪽수_머리행(png): sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t] assert len(sections) == 2 # 단면마다 한 쪽 # 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1 + 암반 1 - # + 부록 1 + # + 확률 1 + 부록 1 # (양식 빈 판) - assert len(pages) == 1 + 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 + 1 + 1 + assert len(pages) == 1 + 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 + 1 + 1 + 1 assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab) html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0] assert html.count("= lo and a[:, j].max() <= hi + pa, pb = norm.cdf(-3), norm.cdf(3) + u = (norm.cdf((a[:, j] - v["mean"]) / v["std"]) - pa) / (pb - pa) + assert sorted(np.floor(u * 500).astype(int)) == list(range(500)) + rho = prob.sample(C_PHI, 5000, seed=1, correlations=[(0, 1, -0.5)]) + assert np.corrcoef(rho.T)[0, 1] == pytest.approx(-0.5, abs=0.03) + + +def test_truncation_and_lognormal_moments(): + """대수정규 = 평균 · σ 그대로 · 음수 없음 · 정규 c 는 물리 한계 0 에서 절단.""" + ln = prob.sample([var("c", "c", 10.0, 5.0, dist="lognormal")], 20000, seed=2)[:, 0] + near(ln.mean(), 10.0, 0.01) + near(ln.std(), 5.0, 0.03) + assert ln.min() > 0 + nc = prob.sample([var("c", "c", 5.0, 5.0)], 5000, seed=2)[:, 0] + assert nc.min() >= 0 + + +def test_sensitivity_and_tornado(): + """민감도 — 평균 줄 F = 결정론 F · 곡선 51 점 · c ↑ = F ↑ · 토네이도 = 폭 큰 순.""" + surface = _surface(T08) + out = prob.run(T08, surface=surface, direction=-1, variables=T08_VARS, n=200) + sens = out["sensitivity"] + assert sens["F_mean"] == pytest.approx(surface["F"], rel=1e-9) + curves = {c["name"]: c for c in sens["curves"]} + assert len(curves["c"]["F"]) == 51 and curves["c"]["x"][0] == pytest.approx(2.0) + assert np.all(np.diff(curves["c"]["F"]) > 0) and np.all(np.diff(curves["phi"]["F"]) > 0) + swings = [t["swing"] for t in sens["tornado"]] + assert swings == sorted(swings, reverse=True) and sens["tornado"][0]["name"] in ("c", "phi") + assert out["stats"]["n"] == 200 and len(out["F"]) == 200 + + +def test_search_mode_matches_fixed_at_low_variance(): + """매회 탐색 — 변동이 아주 작으면 고정면과 같은 F(±1 %) · 표본 수 · 시간 칸.""" + surface = _surface(T08) + tiny = [var("c", "c", 5, 0.01), var("phi", "phi", 30, 0.01)] + out = prob.run(T08, surface=surface, direction=-1, variables=tiny, n=4, mode="search") + for f in out["F"]: + near(f, surface["F"], 0.01) + assert out["mode"] == "search" and out["elapsed_s"]["samples"] > 0 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/resources/tester/test_537_1_e03_prob_route.py b/resources/tester/test_537_1_e03_prob_route.py new file mode 100644 index 000000000..8ac57d404 --- /dev/null +++ b/resources/tester/test_537_1_e03_prob_route.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +"""537-1 E03 확률 해석 길(`E03_StabSection_Router_Prob`) — 검증_sub4 동결 복사 · 측점 둘 · Bishop 원호만. + +- 준비(setup): 단면 물성 = E02 값 · 기본값 후보 · 기준안전율 · 결정론 F +- 임계면 고정: 평균 줄 F = 저장된 결정론 F · Pf · β · 표본 수 · 같은 요청 = 저장본(다시 안 셈) · 기록 목록 +- 대표 단면 아님 · 단면에 없는 물성 · 수위선 아닌데 지하수위 변수 · 변수 없음 = 422 +- 매회 탐색 일꾼(프로세스 풀 · 진행 줄 · 결과 파일) +- E05 확률해석 시트(있을 때만 · 결과 = 저장본 · 기본값 그대로 = 사용자 확정 전 · 바꾸면 사용자 입력 표기) +- 화면 계산(logic/E03_StabSection_Prob.ts): 기본값 후보 · 물성 덮어쓰기 · 요청 꼴 = 서버 시험 요청 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_537_1_e03_prob_route.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common_util import common_util_project_copy as cp # noqa: E402 +from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402 +from E02_StabGround import E02_StabGround_Router as e02 # noqa: E402 +from E03_StabSection import E03_StabSection_Router as router # noqa: E402 +from E03_StabSection import E03_StabSection_Router_Prob as rp # noqa: E402 +from E03_StabSection import E03_StabSection_Router_WhatIf as wf # noqa: E402 +from E03_StabSection import E03_StabSection_View as view # noqa: E402 +from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402 +from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402 + +SOURCE_ID = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(읽기만) +PID = "d" * 8 + "-" + "d" * 4 + "-" + "d" * 4 + "-" + "d" * 4 + "-" + "d" * 12 + + +async def _copy(target: Path) -> None: + import aiomysql + + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + connection = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True + ) + try: + await cp.copy_project( + connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target + ) + finally: + connection.close() + + +def _fast(cross, design, **kw): + return analyze_station(cross, design, **{**kw, "methods": ("bishop",), "noncircular": False}) + + +@pytest.fixture(scope="module") +def project(): + folder = Path(tempfile.mkdtemp(prefix="aislo_e03_prob_")) + try: + asyncio.run(_copy(folder / "copy")) + except Exception as error: # noqa: BLE001 + shutil.rmtree(folder, ignore_errors=True) + pytest.skip(f"검증 원본 복사 실패: {error}") + root = folder / "copy" + mp = pytest.MonkeyPatch() + original = src.stations + mp.setattr(router.src, "stations", lambda r: original(r)[20:22]) + + async def fake_project(_pid): + return root, {"company_id": 1, "project_kind": "STABILITY"} + + mp.setattr(e02, "_project", fake_project) + mp.setattr(worker, "analyze_station", _fast) + mp.setattr(wf, "analyze_station", _fast) + mp.setattr(router, "WORKERS", 1) + mp.setattr(rp, "WORKERS", 1) + router._JOBS.clear() + + async def go(): + await router.analyze2d(UUID(PID)) + while router._TASKS: + await asyncio.gather(*list(router._TASKS)) + + asyncio.run(go()) + yield root + mp.undo() + shutil.rmtree(folder, ignore_errors=True) + + +def _slot(root: Path) -> tuple[str, str]: + """대표 단면 중 깎기 · 쌓기 비탈 하나.""" + rows = router._latest_summary(root, "")[0]["stations"] + view.mark_representative(rows, router._overrides(root)) + row = next(r for r in rows if (r.get("representative") or {}).get("on")) + return row["key"], row["sides"][0]["side"] + + +def _req(side, mats=("weathered_soil",), **kw): + """단면 물성마다 c(대수정규 0.3) · φ(0.1) · γ(0.05).""" + variables = [ + v + for m in mats + for v in ( + {"material": m, "prop": "c", "dist": "lognormal", "cov": 0.3}, + {"material": m, "prop": "phi", "cov": 0.1}, + {"material": m, "prop": "gamma", "cov": 0.05}, + ) + ] + return rp.ProbRequest( + **{"side": side, "condition": "dry", "n": 400, "variables": variables, **kw} + ) + + +def test_setup_and_fixed_run(project, monkeypatch): + key, side = _slot(project) + s = rp.setup(project, key, side, "dry") + ids = [m["id"] for m in s["materials"]] + assert "weathered_soil" in ids and "wall" not in ids + assert s["defaults"]["properties"]["c"]["cov"] == 0.3 and s["required"] in (1.5, 1.3) + fs = s["fs_deterministic"]["bishop"] + seen = [] + req = _req(side, ids) + rec = rp.run_prob(project, key, req, lambda p, m: seen.append(p)) + res = rec["result"] + assert res["sensitivity"]["F_mean"] == pytest.approx(fs, rel=1e-9) + assert rec["fs_deterministic"] == fs and res["stats"]["n"] == 400 + st = res["stats"] + assert 0 <= st["pf"] <= 1 and st["std"] > 0 and abs(st["mean"] - fs) < 0.1 * fs + assert st["beta_normal"] == pytest.approx((st["mean"] - 1) / st["std"]) + assert seen and max(seen) <= 95 + # 같은 요청 = 저장본 · 기록 목록 · setup 의 마지막 결과 + calls = [] + monkeypatch.setattr(rp.prob, "run", lambda **kw: calls.append(1)) + again = rp.run_prob(project, key, req, lambda p, m: None) + assert not calls and again["computed_at"] == rec["computed_at"] + listed = rp.records(project) + assert [(r["station_key"], r["side"], r["condition"]) for r in listed] == [(key, side, "dry")] + assert rp.setup(project, key, side, "dry")["last"]["computed_at"] == rec["computed_at"] + + +def test_rejects(project): + key, side = _slot(project) + with pytest.raises(HTTPException) as e: + rp.run_prob( + project, key, _req(side, variables=[{"material": "xx", "prop": "c", "cov": 0.2}]), print + ) + assert e.value.status_code == 422 + with pytest.raises(HTTPException) as e: + rp.run_prob(project, key, _req(side, variables=[{"prop": "water", "std": 0.5}]), print) + assert e.value.status_code == 422 and "수위선" in e.value.detail + with pytest.raises(HTTPException) as e: + rp.run_prob( + project, + key, + _req(side, variables=[{"material": "weathered_soil", "prop": "c", "cov": 0}]), + print, + ) + assert "변수가 없습니다" in e.value.detail + # 대표 단면 끔 → 422 + path = project / router.REPRESENTATIVE + old = path.read_text(encoding="utf-8") if path.is_file() else None + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"overrides": {key: False}}), encoding="utf-8") + try: + with pytest.raises(HTTPException) as e: + rp.run_prob(project, key, _req(side), print) + assert "대표 단면" in e.value.detail + finally: + path.unlink() if old is None else path.write_text(old, encoding="utf-8") + + +def test_search_worker_process(): + """매회 탐색 일꾼 — 프로세스 풀 둘 · 진행 줄 · 결과 파일(Slide2 Tutorial 08 모형 · 작은 변동).""" + from test_537_1_e03_prob import T08, _surface, var + + job = { + "model": T08, + "surface": _surface(T08), + "direction": -1, + "limits": None, + "variables": [var("c", "c", 5, 0.01), var("phi", "phi", 30, 0.01)], + "n": 4, + "mode": "search", + "workers": 2, + } + done = [] + out = rp._run_search(job, done.append) + assert sorted(done) == [1, 2, 3, 4] and out["n"] == 4 + assert all(abs(f - 1.139) < 0.02 for f in out["F"]) + + +def test_e05_prob_sheet(project, monkeypatch): + """E05 s12 — 측점 줄 = 저장본 값 · 변수 줄 = 토네이도 순 · 기본값 그대로 표기 · 없으면 시트 뺌.""" + from E05_StabReport import E05_StabReport_Build as build + + recs = rp.records(project) + assert len(recs) == 1 + rec = recs[0] + book = build.form() + build.build_program(book, build.gather(project)) + s12 = next(s for s in book["시트"] if s["id"] == "s12") + cells = s12["칸"] + st = rec["result"]["stats"] + assert cells["E6"]["값"] == pytest.approx(rec["fs_deterministic"], abs=1e-6) + assert cells["F6"]["값"] == pytest.approx(st["mean"], abs=1e-6) + assert cells["H6"]["값"] == pytest.approx(100 * st["pf"], abs=1e-6) + assert cells["J6"]["값"] == pytest.approx(st["beta_lognormal"], abs=1e-6) + assert "사용자 입력" in cells["A3"]["값"] # 표본 400 ≠ 기본 1000 + d = rp.load_defaults() + plain = { + **rec["request"], + **{k: d["run"][k] for k in ("n", "sampling", "seed", "mode", "method")}, + } + assert build.prob._is_default({"request": plain}, d) + plain["variables"] = [{**plain["variables"][0], "cov": 0.25}] + assert not build.prob._is_default({"request": plain}, d) + first = rec["result"]["sensitivity"]["tornado"][0]["name"] + assert cells["B10"]["값"] == first and cells["K10"]["값"] == 1 + monkeypatch.setattr(build.prob, "read", lambda root: []) + empty = build.form() + build.build_program(empty, build.gather(project)) + assert "s12" not in {s["id"] for s in empty["시트"]} + + +TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript" +LOGIC_TS = ROOT / "E03_StabSection" / "logic" / "E03_StabSection_Prob.ts" +LOGIC_SCRIPT = r""" +const ts = require(process.argv[1]); +const fs = require("fs"); +const src = fs.readFileSync(process.argv[2], "utf8"); +const opts = { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }; +const js = ts.transpileModule(src, { compilerOptions: opts }).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const m = box.exports; +const setup = JSON.parse(fs.readFileSync(0, "utf8")); +const cache = m.fillCache(setup, null); +const req = m.probRequest(setup, cache); +cache.vars[m.varKey("colluvium", "c")].value = 0.5; +process.stdout.write(JSON.stringify({ req, changed: m.changedCount(setup, cache), + ticks: m.axisTicks(0.83, 1.47, 5) })); +""" + + +@pytest.mark.skipif(not TYPESCRIPT.is_dir(), reason="config/node_modules 없음") +def test_logic_ts_request_matches_server(): + """기본값 후보(붕적토 c 0.4 덮어씀) → 요청 꼴 = 서버 ProbRequest 로 읽힘 · 바꾼 줄 수 · 눈금.""" + setup = { + "side": "left", + "condition": "wet", + "materials": [ + {"id": "weathered_soil", "name": "풍화토", "c": 15, "phi": 30, "gamma_t": 18}, + {"id": "colluvium", "name": "붕적토", "c": 5, "phi": 27, "gamma_t": 17.5}, + ], + "groundwater": {"mode": "line", "ru": None}, + "defaults": rp.load_defaults(), + } + out = subprocess.run( + ["node", "-e", LOGIC_SCRIPT, str(TYPESCRIPT), str(LOGIC_TS)], + input=json.dumps(setup), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + got = json.loads(out.stdout) + req = rp.ProbRequest(**got["req"]) + assert (req.side, req.condition, req.n, req.sampling, req.mode) == ( + "left", + "wet", + 1000, + "lhs", + "fixed", + ) + by = {(v.material, v.prop): v for v in req.variables} + assert len(by) == 6 # 물 σ 0 = 뺌 + assert by[("colluvium", "c")].cov == 0.4 and by[("weathered_soil", "c")].cov == 0.3 + assert by[("weathered_soil", "c")].dist == "lognormal" and by[("colluvium", "phi")].cov == 0.1 + assert all(v.trunc_sigma == 3.0 for v in req.variables) + assert got["changed"] == 1 + assert got["ticks"][0] <= 0.83 and got["ticks"][-1] >= 1.47 and 0 not in got["ticks"] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 2b8b49dd7..d336e7733 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -212,4 +212,64 @@ export const ui_locales_e03 = { E03_Sp_Mode_prestress: ["선인장(능동)", "Prestress (active)"], E03_Sp_Mode_not_crossing: ["활동면 안 만남", "Not crossing"], E03_Sp_Mode_pending: ["결과 없음", "No result"], + /* --- 확률 · 민감도 탭(537-1) --- */ + E03_Tab_Prob: ["확률 · 민감도", "Probabilistic · Sensitivity"], + E03_Pb_RepOnly: [ + "대표 단면만 확률 해석합니다 — 좌측 목록에서 별(대표 단면)을 켜고 [대표 단면 저장] 뒤 보세요.", + "Probabilistic analysis runs on representative sections only — star it in the list and save first.", + ], + E03_Pb_MeanNote: [ + "평균 = E02 지반 조건 값(바꾸려면 E02) · 분포 · 변동계수는 기본값 후보", + "Mean = E02 ground values (change in E02) · distributions · COV are default candidates", + ], + E03_Pb_Use: ["쓰기", "Use"], + E03_Pb_Var: ["변수", "Variable"], + E03_Pb_Mean: ["평균", "Mean"], + E03_Pb_Dist: ["분포", "Distribution"], + E03_Pb_Dist_normal: ["정규", "Normal"], + E03_Pb_Dist_lognormal: ["대수정규", "Lognormal"], + E03_Pb_Spread: ["변동", "Spread"], + E03_Pb_Cov: ["변동계수", "COV"], + E03_Pb_Trunc: ["절단 ±σ 배", "Truncate ±kσ"], + E03_Pb_Prop_c: ["c kPa", "c kPa"], + E03_Pb_Prop_phi: ["φ °", "φ °"], + E03_Pb_Prop_gamma: ["γ kN/m³", "γ kN/m³"], + E03_Pb_Prop_water: ["지하수위 이동 m", "Water table shift m"], + E03_Pb_Prop_ru: ["간극수압비 r_u", "Pore pressure ratio r_u"], + E03_Pb_Mode: ["임계면", "Slip surface"], + E03_Pb_Mode_fixed: ["임계면 고정(빠름)", "Fixed critical (fast)"], + E03_Pb_Mode_search: ["매회 탐색(정밀 · 느림)", "Search each sample (slow)"], + E03_Pb_Method: ["방법", "Method"], + E03_Pb_Method_mp: ["M-P(반정현)", "M-P (half-sine)"], + E03_Pb_Sampling: ["표본 방법", "Sampling"], + E03_Pb_N: ["표본 수", "Samples"], + E03_Pb_Seed: ["시드", "Seed"], + E03_Pb_Rho: ["c–φ 상관 ρ", "c–φ correlation ρ"], + E03_Pb_Run: ["확률 해석", "Run Probabilistic"], + E03_Pb_Defaults: ["기본값", "Defaults"], + E03_Pb_Done: ["확률 해석 끝", "Probabilistic analysis done"], + E03_Pb_Unconfirmed: [ + "기본값 후보 · 사용자 확정 전 — 근거", + "Default candidates · not confirmed — sources", + ], + E03_Pb_UserInput: ["사용자 입력 {n} 줄 · 근거", "User input {n} rows · sources"], + E03_Pb_Result: ["확률 해석 결과", "Probabilistic Result"], + E03_Pb_Det: ["결정론 Fs", "Deterministic FS"], + E03_Pb_MeanF: ["Fs 평균", "Mean FS"], + E03_Pb_Std: ["Fs 표준편차", "FS std. dev."], + E03_Pb_Pf: ["파괴확률 Pf(F < 1)", "Probability of failure"], + E03_Pb_BetaN: ["신뢰도 지수 β 정규", "Reliability index (normal)"], + E03_Pb_BetaLn: ["신뢰도 지수 β 대수정규", "Reliability index (lognormal)"], + E03_Pb_PReq: ["P(F < 기준안전율)", "P(F < required)"], + E03_Pb_Valid: ["유효 표본", "Valid samples"], + E03_Pb_Time: ["계산 시간", "Elapsed"], + E03_Pb_Hist: ["안전율 히스토그램", "FS Histogram"], + E03_Pb_Cdf: ["누적분포", "Cumulative Distribution"], + E03_Pb_Sens: [ + "민감도(나머지 평균 · 임계면 고정)", + "Sensitivity (others at mean · fixed surface)", + ], + E03_Pb_Tornado: ["토네이도(범위 끝 Fs)", "Tornado (FS at range ends)"], + E03_Pb_Count: ["표본 수", "Count"], + E03_Pb_Range: ["범위 %", "Percent of range"], } as const satisfies Record;