diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 0fa1d7e21..0bde3c863 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -31,8 +31,14 @@ const FLOWS: readonly GroupFlow[] = [ }, { routes: STAB_STEP_ROUTES, - labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"], - icons: ["🪨", "📐", "🧊", "📄"], + labels: [ + "DE_Step_StabGround", + "DE_Step_StabSection", + "DE_Step_Stab3D", + "DE_Step_StabReport", + "DE_Step_StabRock", + ], + icons: ["🪨", "📐", "🧊", "📄", "⛰️"], }, ]; diff --git a/E06_StabRock/E06_StabRock_Engine.py b/E06_StabRock/E06_StabRock_Engine.py new file mode 100644 index 000000000..a69b468b3 --- /dev/null +++ b/E06_StabRock/E06_StabRock_Engine.py @@ -0,0 +1,444 @@ +"""E06 암반 비탈면 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율 (요구 345 · 351 · PLAN 536-1). + +근거 = 지식DB `original/안정성평가/03`(Markland 부등식 · Hoek-Bray 평면 · 쐐기) · 06(상용 SW) · +Rocscience RocPlane · SWedge 이론서(힘 벡터 · 미끄럼 방향 8 경우 · 수직력 · 지지력은 수동력). +코드는 직접 짬(Dips · RocPlane · SWedge 코드 안 씀) · NumPy · SciPy 만. + +좌표 = 동(+x) · 북(+y) · 위(+z). 면 = (경사방향 α · 경사 ψ) · 선 = (추향 · 선주각). +단위 = 길이 m · 단위중량 kN/m³ · 점착력 kPa · 각 ° · 힘 kN(평면 = 단위 폭당 kN/m). + +⚠ 지식DB 03 의 평면 볼트 항(수직 T cos(θ+ψp) · 활동 −T sin(θ+ψp))은 θ = 볼트와 면 법선 사이 각일 +때 맞음 — 여기는 θ = 볼트 경사(수평 아래)로 받아 힘 벡터로 풂(수직 T sin(θ+ψp) · 활동 −T cos(θ+ψp)). +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from scipy.optimize import minimize_scalar + +GAMMA_W = 9.81 # 물 단위중량 kN/m³ (E03 Types 와 같음) +LATERAL_PLANAR_DEG = 20.0 # 평면 측방 한계(Dips 기본 · Wyllie & Mah) +LATERAL_TOPPLING_DEG = 20.0 # 전도 측방 한계(Dips 기본 · 지식DB 03 은 30 — 화면에서 바꿈) + + +# ---- 방향 ---------------------------------------------------------------------- +def ang_diff(a: float, b: float) -> float: + """두 방위각 사이 최소 각거리 0~180.""" + d = abs((a - b) % 360.0) + return min(d, 360.0 - d) + + +def plane_normal(dip_dir: float, dip: float) -> np.ndarray: + """면의 위쪽 단위 법선.""" + a, p = math.radians(dip_dir), math.radians(dip) + return np.array([math.sin(a) * math.sin(p), math.cos(a) * math.sin(p), math.cos(p)]) + + +def line_vector(trend: float, plunge: float) -> np.ndarray: + """선(추향 · 선주각) → 아래로 향한 단위 벡터.""" + t, p = math.radians(trend), math.radians(plunge) + return np.array([math.sin(t) * math.cos(p), math.cos(t) * math.cos(p), -math.sin(p)]) + + +def vector_line(v: np.ndarray) -> tuple[float, float]: + """벡터 → (추향 · 선주각) 하반구(위로 향하면 뒤집음).""" + v = np.asarray(v, float) + if v[2] > 0: + v = -v + v = v / np.linalg.norm(v) + trend = math.degrees(math.atan2(v[0], v[1])) % 360.0 + return trend, math.degrees(math.asin(max(-1.0, min(1.0, -v[2])))) + + +def pole(dip_dir: float, dip: float) -> tuple[float, float]: + """면의 극점(하반구) = (α + 180 · 90 − ψ).""" + return (dip_dir + 180.0) % 360.0, 90.0 - dip + + +def intersection(a: dict, b: dict) -> tuple[float, float] | None: + """두 면 교선(추향 · 선주각) — 나란하면 None.""" + v = np.cross( + plane_normal(a["dip_direction"], a["dip"]), plane_normal(b["dip_direction"], b["dip"]) + ) + if np.linalg.norm(v) < 1e-9: + return None + return vector_line(v) + + +def apparent_dip(dip: float, dip_dir: float, trend: float) -> float: + """면 (α · ψ) 의 trend 방향 겉보기 경사 — 반대쪽(90° 넘음)이면 음수.""" + return math.degrees( + math.atan(math.tan(math.radians(dip)) * math.cos(math.radians(trend - dip_dir))) + ) + + +def strike_to_dip_direction(strike: float) -> float: + """오른손 법칙 주향 → 경사방향.""" + return (strike + 90.0) % 360.0 + + +# ---- Markland 판정 ------------------------------------------------------------- +def kinematic( + slope: dict, + joints: list[dict], + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, list]: + """slope = {dip_direction, dip} · joints = [{id, dip_direction, dip, phi}, …]. + + 평면 = Δ(αf, αp) ≤ 측방 · φ < ψp < 겉보기 사면 경사 / 쐐기 = 교선 Δ < 90 · φ < 선주각 < + 겉보기 사면 경사(φ = 두 면 중 작은 값) / + 전도 = Δ(αf + 180, αp) ≤ 측방 · ψp > 90 − ψf + φ (Goodman). + """ + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + planar, toppling, wedge = [], [], [] + for j in joints: + aj, pj, phi = float(j["dip_direction"]), float(j["dip"]), float(j["phi"]) + if ang_diff(af, aj) <= lateral_planar and phi < pj < apparent_dip(pf, af, aj): + planar.append(j["id"]) + if ang_diff(af + 180.0, aj) <= lateral_toppling and pj > 90.0 - pf + phi: + toppling.append(j["id"]) + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is None: + continue + trend, plunge = line + phi = min(float(a["phi"]), float(b["phi"])) + if ang_diff(af, trend) < 90.0 and phi < plunge < apparent_dip(pf, af, trend): + wedge.append( + { + "ids": [a["id"], b["id"]], + "trend": round(trend, 2), + "plunge": round(plunge, 2), + } + ) + return {"planar": planar, "wedge": wedge, "toppling": toppling} + + +# ---- 평면 파괴 (Hoek-Bray · 2D 단위 폭) ----------------------------------------- +def _plane_block(H: float, pf: float, pp: float, ps: float, crack_x: float | None): + """사면 끝(0,0) · x = 산 쪽 수평 · z = 위. 덩이 다각형 · 활동면 끝 x · 균열 깊이. + + 윗면 = 마루(H cotψf, H)에서 ψs 로 오름. 균열 없음 = 활동면이 윗면과 만나는 점까지.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf # 마루 + + def top(x: float) -> float: + return x * tf if x <= xc else H + (x - xc) * ts + + if crack_x is None: + if ts >= tp - 1e-12: + return None # 활동면이 윗면과 안 만남 = 덩이 안 생김 + xe = (H - xc * ts) / (tp - ts) + poly = [(0.0, 0.0), (xc, H), (xe, xe * tp)] + return poly, xe, 0.0 + if crack_x < xc or top(crack_x) - crack_x * tp <= 1e-9: + return None + poly = [(0.0, 0.0), (xc, H), (crack_x, top(crack_x)), (crack_x, crack_x * tp)] + return poly, crack_x, top(crack_x) - crack_x * tp + + +def _area(poly) -> float: + xs, zs = np.array(poly).T + return 0.5 * abs(float(np.dot(xs, np.roll(zs, -1)) - np.dot(zs, np.roll(xs, -1)))) + + +def plane_fs( + *, + H: float, + slope_dip: float, + plane_dip: float, + c: float, + phi: float, + gamma: float, + upper_dip: float = 0.0, + crack: dict | None = None, + water_fill: float = 0.0, + kh: float = 0.0, + bolt_t: float = 0.0, + bolt_plunge: float = 0.0, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """평면 파괴 안전율 — 덩이가 안 생기면 None. + + crack = None | {"mode": "depth", "z": m} | {"mode": "critical"}(안전율 최소 자리 찾음) · + 물 = 균열 있음: zw = fill·z · U = ½γw zw A · V = ½γw zw² / 균열 없음: 가운데 최대 수두 + fill·h/2(h = 활동면 오름) · U = ½γw (fill·h/2) A (Hoek · RocPlane) · + kh = 바깥 수평 · 볼트 = 수동력(산 쪽 · 수평 아래 bolt_plunge°).""" + if not 0 < plane_dip < slope_dip < 90 or H <= 0 or upper_dip >= slope_dip: + return None + mode = (crack or {}).get("mode", "none") + if mode == "critical": + return _critical_crack(locals()) + tp = math.tan(math.radians(plane_dip)) + xc = H / math.tan(math.radians(slope_dip)) + crack_x = None + if mode == "depth": + z = float(crack["z"]) + crack_x = _crack_x_for_depth(H, slope_dip, plane_dip, upper_dip, z) + if crack_x is None: + return None + elif mode == "x": + crack_x = xc + float(crack["b"]) + block = _plane_block(H, slope_dip, plane_dip, upper_dip, crack_x) + if block is None: + return None + poly, x_end, z = block + psi = math.radians(plane_dip) + A = x_end / math.cos(psi) + W = gamma * _area(poly) + if crack_x is not None: + zw = water_fill * z + U, V = 0.5 * gamma_w * zw * A, 0.5 * gamma_w * zw * zw + else: + zw = water_fill * (x_end * tp) / 2.0 + U, V = 0.5 * gamma_w * zw * A, 0.0 + d = np.array([-math.cos(psi), -math.sin(psi)]) # 아래로 미끄러짐(바깥 = −x) + n = np.array([-math.sin(psi), math.cos(psi)]) # 면에서 덩이 쪽 + active = np.array([-kh * W - V, -W]) + U * n + drive = float(active @ d) + if drive <= 0: + return None + bolt = bolt_t * np.array( + [math.cos(math.radians(bolt_plunge)), -math.sin(math.radians(bolt_plunge))] + ) + N = max(0.0, -float((active + bolt) @ n)) + resist = c * A + N * math.tan(math.radians(phi)) - float(bolt @ d) + return { + "F": resist / drive, + "W": W, + "A": A, + "U": U, + "V": V, + "z": z, + "zw": zw, + "crack_b": None if crack_x is None else crack_x - xc, + "N": N, + "drive": drive, + "resist": resist, + "polygon": [[round(x, 3), round(y, 3)] for x, y in poly], + } + + +def _crack_x_for_depth(H, pf, pp, ps, z) -> float | None: + """윗면 아래 균열 깊이 z 가 되는 자리(마루 뒤) — 깊이가 마루 틈보다 크면 None.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf + gap = H - xc * tp # 마루에서 윗면 − 활동면 + if z <= 0 or z >= gap: + return None + if ts >= tp: # 틈이 커지기만 함 = 뒤로 갈수록 깊어짐 + return xc + (z - gap) / (ts - tp) if ts > tp else None + return xc + (gap - z) / (tp - ts) + + +def plane_critical_depth_hoek(H: float, slope_dip: float, plane_dip: float) -> float: + """Hoek-Bray 임계 인장균열 깊이(윗면 수평 · 건조) z = H(1 − √(cotψf tanψp)).""" + return H * ( + 1.0 - math.sqrt(math.tan(math.radians(plane_dip)) / math.tan(math.radians(slope_dip))) + ) + + +def _critical_crack(kw: dict) -> dict | None: + """균열 자리를 마루 뒤로 옮겨 안전율 최소 — 균열 없음보다 크면 균열 없음.""" + args = {k: kw[k] for k in kw if k not in ("crack", "mode")} + tf, tp, ts = (math.tan(math.radians(kw[k])) for k in ("slope_dip", "plane_dip", "upper_dip")) + xc = kw["H"] / tf + x_far = (kw["H"] - xc * ts) / (tp - ts) if ts < tp else xc + 10 * kw["H"] + span = x_far - xc + + def fs_at(b: float) -> float: + r = plane_fs(**args, crack={"mode": "x", "b": b}) + return r["F"] if r else math.inf + + best = minimize_scalar(fs_at, bounds=(span * 1e-3, span * (1 - 1e-3)), method="bounded") + found = plane_fs(**args, crack={"mode": "x", "b": float(best.x)}) + plain = plane_fs(**args, crack=None) + if found is None or (plain is not None and plain["F"] <= found["F"]): + return plain + return found + + +# ---- 쐐기 파괴 (Hoek-Bray · SWedge 꼴 사면체) ------------------------------------ +def _meet(planes: list[tuple[np.ndarray, float]]) -> np.ndarray | None: + """세 면 n·x = d 의 교점.""" + m = np.array([p[0] for p in planes]) + if abs(np.linalg.det(m)) < 1e-10: + return None + return np.linalg.solve(m, np.array([p[1] for p in planes])) + + +def _tri_area(a, b, c) -> float: + return 0.5 * float(np.linalg.norm(np.cross(b - a, c - a))) + + +def wedge_geometry(j1: dict, j2: dict, slope: dict, upper: dict, H: float) -> dict | None: + """사면체 쐐기 — 꼭짓점 O(두 절리 · 사면 = 사면 끝) · A(1 · 사면 · 윗면) · B(2 · 사면 · 윗면) · + C(1 · 2 · 윗면). 사면 높이 H = 사면 위 꼭짓점 A · B 중 높은 쪽의 O 위 높이(SWedge 와 같음 — + Priest 예제 부피 45.201 m³ 재현). 교선이 사면 · 윗면으로 안 나오면 None(쐐기 안 생김).""" + n1 = plane_normal(j1["dip_direction"], j1["dip"]) + n2 = plane_normal(j2["dip_direction"], j2["dip"]) + nf = plane_normal(slope["dip_direction"], slope["dip"]) + nu = plane_normal(upper["dip_direction"], upper["dip"]) + # 윗면 = 사면 위로 H 오른 점을 지남(사면 경사방향 반대로 H cotψf 들어간 점) + back = -line_vector(slope["dip_direction"], 0.0) * (H / math.tan(math.radians(slope["dip"]))) + crest = back + np.array([0.0, 0.0, H]) + du = float(nu @ crest) + O = np.zeros(3) # noqa: E741 — 꼭짓점 이름(SWedge 그림과 같음) + A = _meet([(n1, 0.0), (nf, 0.0), (nu, du)]) + B = _meet([(n2, 0.0), (nf, 0.0), (nu, du)]) + C = _meet([(n1, 0.0), (n2, 0.0), (nu, du)]) + if A is None or B is None or C is None: + return None + # C 는 사면 뒤(산 쪽) · 위 — 교선이 사면으로 나와 윗면에 닿음 + if C[2] <= 1e-9 or float(nf @ C) >= -1e-9 or A[2] <= 1e-9 or B[2] <= 1e-9: + return None + scale = H / max(A[2], B[2]) # 면이 모두 O 를 지나 닮은꼴 — 높이만 맞춤 + A, B, C = A * scale, B * scale, C * scale + volume = abs(float(np.linalg.det(np.array([A - O, B - O, C - O])))) / 6.0 + if volume <= 1e-9: + return None + centroid = (O + A + B + C) / 4.0 + inward = [n if float(n @ centroid) > 0 else -n for n in (n1, n2)] + return { + "vertices": {"O": O, "A": A, "B": B, "C": C}, + "volume": volume, + "areas": [_tri_area(O, A, C), _tri_area(O, B, C)], + "face_area": _tri_area(O, A, B), + "upper_area": _tri_area(A, B, C), + "normals": inward, + "Hw": float(C[2]), + "line_length": float(np.linalg.norm(C)), + } + + +def _unit(v: np.ndarray) -> np.ndarray: + n = float(np.linalg.norm(v)) + return v / n if n > 1e-12 else v * 0.0 + + +def _sliding(A: np.ndarray, n1: np.ndarray, n2: np.ndarray, W: np.ndarray): + """SWedge 미끄럼 방향 — (갈래, 방향). 갈래 = lift · 1 · 2 · 12 · stable.""" + s1 = _unit(np.cross(np.cross(n1, A), n1)) + s2 = _unit(np.cross(np.cross(n2, A), n2)) + c12 = np.cross(n1, n2) + s12 = _unit(c12) * np.sign(float(c12 @ A)) + if A @ n1 > 0 and A @ n2 > 0: + return "lift", _unit(A) + if A @ n1 <= 0 and s1 @ n2 > 0: + return "1", s1 + if A @ n2 <= 0 and s2 @ n1 > 0: + return "2", s2 + if s1 @ n2 <= 0 and s2 @ n1 <= 0: + return "12", s12 + return "stable", None + + +def _normals(mode: str, F: np.ndarray, n1: np.ndarray, n2: np.ndarray) -> tuple[float, float]: + if mode == "1": + return -float(F @ n1), 0.0 + if mode == "2": + return 0.0, -float(F @ n2) + if mode == "12": + c12, c21 = np.cross(n1, n2), np.cross(n2, n1) + return ( + -float(np.cross(F, n2) @ c12) / float(c12 @ c12), + -float(np.cross(F, n1) @ c21) / float(c21 @ c21), + ) + return 0.0, 0.0 + + +def wedge_fs( + *, + j1: dict, + j2: dict, + slope: dict, + upper: dict, + H: float, + gamma: float, + water: dict | None = None, + kh: float = 0.0, + seismic_trend: float | None = None, + external: list[dict] | None = None, + bolt: dict | None = None, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """쐐기 안전율(SWedge 이론서 꼴) — 쐐기가 안 생기면 None. + + j = {dip_direction, dip, c, phi} · water = {"fill": p}(중력식 U = p³γw Hw a/6) | + {"pressure": [u1, u2]}(평균 수압 kPa) · kh = 수평(기본 교선 추향) · external = [{force, trend, + plunge}] · bolt = {force, trend, plunge}(수동력). FS = max(지지 전 · 지지 뒤 · 들림).""" + geo = wedge_geometry(j1, j2, slope, upper, H) + if geo is None: + return None + n1, n2 = geo["normals"] + a1, a2 = geo["areas"] + weight = gamma * geo["volume"] + Wv = np.array([0.0, 0.0, -weight]) + active = Wv.copy() + water = water or {} + if "pressure" in water: + u1, u2 = (float(u) for u in water["pressure"]) + else: + p = float(water.get("fill", 0.0)) + u1 = u2 = p**3 * gamma_w * geo["Hw"] / 6.0 + Uf = [u1 * a1 * n1, u2 * a2 * n2] + active += Uf[0] + Uf[1] + line = vector_line(np.cross(n1, n2)) + if kh: + trend = line[0] if seismic_trend is None else seismic_trend + active += kh * weight * line_vector(trend, 0.0) + for item in external or []: + active += float(item["force"]) * line_vector(item["trend"], item["plunge"]) + passive = np.zeros(3) + if bolt and bolt.get("force"): + passive = float(bolt["force"]) * line_vector(bolt["trend"], bolt["plunge"]) + mode, s = _sliding(active, n1, n2, Wv) + out = { + "volume": geo["volume"], + "W": weight, + "areas": [a1, a2], + "face_area": geo["face_area"], + "upper_area": geo["upper_area"], + "Hw": geo["Hw"], + "line_length": geo["line_length"], + "line": [round(line[0], 2), round(line[1], 2)], + "water_force": [float(np.linalg.norm(Uf[0])), float(np.linalg.norm(Uf[1]))], + "mode": mode, + "vertices": {k: [round(float(x), 3) for x in v] for k, v in geo["vertices"].items()}, + } + if mode == "stable": + return {**out, "F": math.inf, "N": [0.0, 0.0], "drive": 0.0, "resist": 0.0} + drive = float(active @ s) + if mode == "lift": + fs = -float(passive @ s) / drive if drive > 0 else math.inf + return {**out, "F": fs, "N": [0.0, 0.0], "drive": drive, "resist": -float(passive @ s)} + joints = (j1, j2) + + def resisting(F: np.ndarray) -> tuple[float, list[float]]: + N = _normals(mode, F, n1, n2) + total = 0.0 + for k, (n_k, a_k) in enumerate(zip(N, (a1, a2))): + if str(k + 1) not in mode or n_k <= 0: + continue # 떨어진 면 = 저항 없음(SWedge 와 같음) + tau = float(joints[k]["c"]) + n_k / a_k * math.tan( + math.radians(float(joints[k]["phi"])) + ) + total += tau * a_k + return total, [max(0.0, n) for n in N] + + j_u, n_u = resisting(active) + fs_u = j_u / drive if drive > 0 else math.inf + fs, n_used, resist = fs_u, n_u, j_u + if np.any(passive): + j_s, n_s = resisting(active + passive) + fs_s = (-float(passive @ s) + j_s) / drive if drive > 0 else math.inf + fs_lift = -float(passive @ _unit(active)) / float(np.linalg.norm(active)) + if fs_s > fs: + fs, n_used, resist = fs_s, n_s, -float(passive @ s) + j_s + fs = max(fs, fs_lift) + return {**out, "F": fs, "N": n_used, "drive": drive, "resist": resist} diff --git a/E06_StabRock/E06_StabRock_Model.py b/E06_StabRock/E06_StabRock_Model.py new file mode 100644 index 000000000..2cfe7d1ae --- /dev/null +++ b/E06_StabRock/E06_StabRock_Model.py @@ -0,0 +1,446 @@ +"""E06 절리 정본 꼴 · 검사 · 측점 해석 묶음 (요구 345 · 351 · PLAN 536-1). + +정본 = `E06_StabRock/joints.json` · 초기값 = `initial_snapshot/E06_StabRock/joints.json` +(절리 없음 + 기본 설정 · 처음 GET 때 한 번). 절리군 한 줄 = 구간(from_m ~ to_m) · 경사방향 · 경사 · +φj · cj · 지속성(선택). 측점 = 복사본 암 측점(리핑암 · 발파암)의 깎기 쪽만. + +사면 = 측점 frame 방향 + 깎기 쪽(경사방향 = 노면 쪽) · 경사 = 깎기(암) 경사비 · 높이 = E03 과 같은 +`side_cases` 높이 · 윗면 = 비탈 끝 바깥 원지반 경사. 물성 · 지진 · 기준안전율 = E02 지반 조건. +""" + +from __future__ import annotations + +import copy +import hashlib +import math +from functools import lru_cache +from pathlib import Path +from typing import Any + +import numpy as np + +from common_util.common_util_station_key import chainage_key +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Model as ground_model +from E03_StabSection.E03_StabSection_Engine_Model import ground_from_cross, side_cases +from E06_StabRock import E06_StabRock_Engine as engine +from E06_StabRock.E06_StabRock_Stereonet import PROJECTIONS, stereonet + +STAGE_DIR = "E06_StabRock" +JOINTS_NAME = "joints.json" +VERSION = 1 +ENGINE_FILES = ("E06_StabRock_Engine.py", "E06_StabRock_Stereonet.py", "E06_StabRock_Model.py") +DEFAULT_GAMMA_R = 25.0 # 암 단위중량 — E02 암 물성이 없을 때(지식DB 07 Hoek & Bray 25~27) +MIN_HEIGHT_M = 1.0 # 이보다 낮은 깎기는 암반 블록 검토 안 함 +CRACK_MODES = ("none", "critical", "depth") +CONDITIONS = ("dry", "wet", "seismic") + +DEFAULT_SETTINGS: dict[str, Any] = { + "projection": "equal_angle", + "lateral_planar_deg": engine.LATERAL_PLANAR_DEG, + "lateral_toppling_deg": engine.LATERAL_TOPPLING_DEG, + "gamma_r": None, # None = E02 측점 암 물성 γt + "water_fill_wet": 0.5, # 우기 수위 = 비탈 높이 ½(KDS 11 70 05 표 4.3-3 암반) + "crack": {"mode": "none", "z": None}, + "bolt": {"force": 0.0, "plunge": 15.0}, +} + +# 칸 범위 — 화면 입력 · 서버 검사 같은 값 +LIMITS: dict[str, list[float]] = { + "chainage_m": [0.0, 100000.0], + "dip_direction": [0.0, 360.0], + "dip": [0.0, 90.0], + "phi": [0.0, 60.0], + "c": [0.0, 1000.0], + "persistence_m": [0.0, 1000.0], + "lateral_deg": [5.0, 45.0], + "gamma_r": [15.0, 35.0], + "water_fill_wet": [0.0, 1.0], + "crack_z": [0.1, 100.0], + "bolt_force": [0.0, 5000.0], + "bolt_plunge": [-30.0, 60.0], +} + +# 기본 절리면 값(새 줄) — 지식DB 07 표 7: cj 0~30 · φj 25~35(Barton 기본마찰각) 보수 쪽 +NEW_SET = {"phi": 30.0, "c": 0.0} + + +def joints_path(project_root: Path) -> Path: + return Path(project_root) / STAGE_DIR / JOINTS_NAME + + +def initial_path(project_root: Path) -> Path: + return Path(project_root) / "initial_snapshot" / STAGE_DIR / JOINTS_NAME + + +def build_initial() -> dict[str, Any]: + return {"version": VERSION, "sets": [], "settings": copy.deepcopy(DEFAULT_SETTINGS)} + + +def upgrade(doc: dict[str, Any]) -> dict[str, Any]: + """빠진 설정 칸만 기본값으로 채운 사본.""" + out = copy.deepcopy(doc) + settings = out.setdefault("settings", {}) + for key, value in DEFAULT_SETTINGS.items(): + settings.setdefault(key, copy.deepcopy(value)) + out.setdefault("sets", []) + return out + + +# ---- 검사 ---------------------------------------------------------------------- +def _num(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) + + +def _in(value: Any, key: str) -> bool: + lo, hi = LIMITS[key] + return _num(value) and lo <= value <= hi + + +def check_joints(doc: dict[str, Any]) -> list[dict[str, str]]: + """틀린 칸 [{path, code}] — code = range · order · name · id_dup · enum · type.""" + errors: list[dict[str, str]] = [] + + def bad(path: str, code: str = "range") -> None: + errors.append({"path": path, "code": code}) + + sets = doc.get("sets") + if not isinstance(sets, list): + return [{"path": "sets", "code": "type"}] + seen: set[str] = set() + for k, row in enumerate(sets): + base = f"sets.{k}" + if not isinstance(row, dict): + bad(base, "type") + continue + ident = str(row.get("id") or "") + if not ident or ident in seen: + bad(f"{base}.id", "id_dup") + seen.add(ident) + if not str(row.get("name") or "").strip(): + bad(f"{base}.name", "name") + for key, limit in ( + ("from_m", "chainage_m"), + ("to_m", "chainage_m"), + ("dip_direction", "dip_direction"), + ("dip", "dip"), + ("phi", "phi"), + ("c", "c"), + ): + if not _in(row.get(key), limit): + bad(f"{base}.{key}") + if _num(row.get("from_m")) and _num(row.get("to_m")) and row["to_m"] < row["from_m"]: + bad(f"{base}.to_m", "order") + if row.get("persistence_m") is not None and not _in(row["persistence_m"], "persistence_m"): + bad(f"{base}.persistence_m") + settings = doc.get("settings") + if not isinstance(settings, dict): + return errors + [{"path": "settings", "code": "type"}] + if settings.get("projection") not in PROJECTIONS: + bad("settings.projection", "enum") + for key in ("lateral_planar_deg", "lateral_toppling_deg"): + if not _in(settings.get(key), "lateral_deg"): + bad(f"settings.{key}") + if settings.get("gamma_r") is not None and not _in(settings["gamma_r"], "gamma_r"): + bad("settings.gamma_r") + if not _in(settings.get("water_fill_wet"), "water_fill_wet"): + bad("settings.water_fill_wet") + crack = settings.get("crack") if isinstance(settings.get("crack"), dict) else {} + if crack.get("mode") not in CRACK_MODES: + bad("settings.crack.mode", "enum") + elif crack["mode"] == "depth" and not _in(crack.get("z"), "crack_z"): + bad("settings.crack.z") + bolt = settings.get("bolt") if isinstance(settings.get("bolt"), dict) else {} + if not _in(bolt.get("force"), "bolt_force"): + bad("settings.bolt.force") + if not _in(bolt.get("plunge"), "bolt_plunge"): + bad("settings.bolt.plunge") + return errors + + +def clean(doc: dict[str, Any]) -> dict[str, Any]: + """정본 칸만 · 숫자는 float.""" + sets = [] + for row in doc["sets"]: + item = { + "id": str(row["id"]), + "name": str(row["name"]).strip(), + **{ + key: float(row[key]) + for key in ("from_m", "to_m", "dip_direction", "dip", "phi", "c") + }, + "persistence_m": None + if row.get("persistence_m") is None + else float(row["persistence_m"]), + } + item["dip_direction"] %= 360.0 + sets.append(item) + s = doc["settings"] + crack = s["crack"] + return { + "version": VERSION, + "sets": sets, + "settings": { + "projection": s["projection"], + "lateral_planar_deg": float(s["lateral_planar_deg"]), + "lateral_toppling_deg": float(s["lateral_toppling_deg"]), + "gamma_r": None if s.get("gamma_r") is None else float(s["gamma_r"]), + "water_fill_wet": float(s["water_fill_wet"]), + "crack": { + "mode": crack["mode"], + "z": float(crack["z"]) if crack["mode"] == "depth" else None, + }, + "bolt": {"force": float(s["bolt"]["force"]), "plunge": float(s["bolt"]["plunge"])}, + }, + } + + +# ---- 측점 사면 ------------------------------------------------------------------ +def rock_stations(source: Path) -> list[dict[str, Any]]: + """암 측점(리핑암 · 발파암).""" + return [s for s in src.stations(source) if s.get("ground_type") in ground_model.ROCK_LABELS] + + +def _upper_dip(ground: list, start: float, sign: float, H: float, face_dip: float) -> float: + """비탈 끝 바깥 max(H, 5 m) 원지반 평균 경사(오르막만 · 사면 경사 − 1° 아래).""" + gx = np.array([p[0] for p in ground]) + gz = np.array([p[1] for p in ground]) + end = start + sign * max(H, 5.0) + end = min(max(end, gx[0]), gx[-1]) + run = abs(end - start) + if run < 0.5: + return 0.0 + rise = float(np.interp(end, gx, gz) - np.interp(start, gx, gz)) + return float(min(max(0.0, math.degrees(math.atan2(rise, run))), face_dip - 1.0)) + + +def station_slopes(cross: dict, design: dict) -> list[dict[str, Any]]: + """측점의 깎기 쪽마다 사면 — {side, dip_direction, dip, height_m, upper_dip}.""" + ground = ground_from_cross(cross) + line = sorted( + (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] + ) + ratio = design.get("cut_slope_ratio") + if len(ground) < 2 or len(line) < 2 or not ratio: + return [] + face_dip = math.degrees(math.atan2(1.0, float(ratio))) + left = np.array([float(v) for v in cross["frame"]["left_xy"]]) + out = [] + for case in side_cases(design, ground, line): + if case["role"] != "cut" or case["height_m"] < MIN_HEIGHT_M: + continue + side = case["side"] + sign = 1.0 if side == "left" else -1.0 + # 비탈 끝 = 탐색 끝에서 되짚음(side_cases: reach = 비탈 끝 + sign · max(5, 2H)) + day = case["limits"][1 if sign > 0 else 0] - sign * max(5.0, 2.0 * case["height_m"]) + face = -sign * left # 노면 쪽으로 기움 + dip_dir = math.degrees(math.atan2(face[0], face[1])) % 360.0 + out.append( + { + "side": side, + "dip_direction": round(dip_dir, 2), + "dip": round(face_dip, 2), + "height_m": round(case["height_m"], 3), + "upper_dip": round(_upper_dip(ground, day, sign, case["height_m"], face_dip), 2), + } + ) + return out + + +# ---- 해석 ---------------------------------------------------------------------- +@lru_cache(maxsize=1) +def engine_hash() -> str: + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def _ground_inputs(ground: dict[str, Any]) -> dict[str, Any]: + """E02 에서 E06 이 쓰는 칸 — 지문에도 이것만.""" + return { + k: ground.get(k) + for k in ("materials", "label_map", "stations", "zones", "safety_factors", "seismic") + } + + +def _gamma_for(resolved: dict | None, materials: dict, override: float | None) -> float: + if override: + return float(override) + mat = materials.get((resolved or {}).get("lower") or (resolved or {}).get("upper")) + return float(mat["gamma_t"]) if mat else DEFAULT_GAMMA_R + + +def _fmt(value: float | None) -> float | None: + if value is None or not math.isfinite(value): + return None + return round(value, 4) + + +def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma: float, conds): + """위험 모드만 안전율 — 조건 = [(이름, 수위 비, kh)].""" + upper = {"dip_direction": slope["dip_direction"], "dip": slope["upper_dip"]} + by_id = {j["id"]: j for j in joints} + bolt = settings["bolt"] + crack = settings["crack"] if settings["crack"]["mode"] != "none" else None + out = [] + for jid in kin["planar"]: + j = by_id[jid] + for name, fill, kh in conds: + r = engine.plane_fs( + H=slope["height_m"], + slope_dip=slope["dip"], + plane_dip=j["dip"], + c=j["c"], + phi=j["phi"], + gamma=gamma, + upper_dip=slope["upper_dip"], + crack=crack, + water_fill=fill, + kh=kh, + bolt_t=bolt["force"], + bolt_plunge=bolt["plunge"], + ) + short = ( + r is not None and j.get("persistence_m") is not None and j["persistence_m"] < r["A"] + ) + out.append( + { + "mode": "planar", + "ids": [jid], + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "W": _fmt(r["W"]) if r else None, + "A": _fmt(r["A"]) if r else None, + "z": _fmt(r["z"]) if r else None, + } + ) + for item in kin["wedge"]: + a, b = (by_id[k] for k in item["ids"]) + for name, fill, kh in conds: + r = engine.wedge_fs( + j1=a, + j2=b, + slope=slope, + upper=upper, + H=slope["height_m"], + gamma=gamma, + water={"fill": fill}, + kh=kh, + bolt={ + "force": bolt["force"], + "trend": (slope["dip_direction"] + 180.0) % 360.0, + "plunge": bolt["plunge"], + }, + ) + length = r["line_length"] if r else None + short = any( + j.get("persistence_m") is not None + and length is not None + and j["persistence_m"] < length + for j in (a, b) + ) + out.append( + { + "mode": "wedge", + "ids": list(item["ids"]), + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "sliding": r["mode"] if r else None, + "W": _fmt(r["W"]) if r else None, + "volume": _fmt(r["volume"]) if r else None, + } + ) + return out + + +def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]]: + """암 측점 × 깎기 쪽 — 사면 · 절리 · Markland · 안전율 · 판정.""" + settings = joints_doc["settings"] + sets = joints_doc["sets"] + materials = {m["id"]: m for m in ground.get("materials") or []} + resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} + seismic = ground.get("seismic") or {} + factors = ground.get("safety_factors") or {} + conds = [("dry", 0.0, 0.0), ("wet", float(settings["water_fill_wet"]), 0.0)] + if seismic.get("enabled") and seismic.get("kh"): + conds.append(("seismic", 0.0, float(seismic["kh"]))) + out = [] + for station in rock_stations(source): + ch = float(station["chainage_m"]) + key = chainage_key(ch) + joints = [j for j in sets if j["from_m"] <= ch <= j["to_m"]] + cross = src.cross_section(source, ch) + gamma = _gamma_for(resolved.get(key), materials, settings.get("gamma_r")) + for slope in station_slopes(cross, cross["design"]): + kin = engine.kinematic( + slope, joints, settings["lateral_planar_deg"], settings["lateral_toppling_deg"] + ) + cases = _cases_for(slope, joints, kin, settings, gamma, conds) + for case in cases: + need = (factors.get(case["condition"]) or {}).get("cut") + case["required"] = need + case["ok"] = None if case["F"] is None or need is None else case["F"] >= need + out.append( + { + "key": key, + "chainage_m": ch, + "label": station.get("label"), + "ground_type": station.get("ground_type"), + "slope": slope, + "gamma_r": gamma, + "joints": [ + {k: j[k] for k in ("id", "name", "dip_direction", "dip", "phi", "c")} + for j in joints + ], + "kinematic": kin, + "cases": cases, + **_verdict(kin, cases), + } + ) + return out + + +def _verdict(kin: dict, cases: list[dict]) -> dict[str, Any]: + """판정 — NG(기준 미달 하나라도) · check(전도만 · 블록 안 생김) · OK · none(위험 모드 없음).""" + values = [c["F"] for c in cases if c["F"] is not None] + min_f = min(values) if values else None + if any(c["ok"] is False for c in cases): + verdict = "NG" + elif kin["toppling"] or (cases and not values): + verdict = "check" + elif cases: + verdict = "OK" + else: + verdict = "none" + return { + "counts": { + "planar": len(kin["planar"]), + "wedge": len(kin["wedge"]), + "toppling": len(kin["toppling"]), + }, + "min_F": min_f, + "verdict": verdict, + } + + +def station_net(entry: dict, settings: dict) -> dict[str, Any]: + """한 측점 쪽 스테레오넷(해석 때 절리 그대로) — 두 투영 모두(화면이 고름).""" + joints = entry["joints"] + phi = min((j["phi"] for j in joints), default=NEW_SET["phi"]) + nets = { + kind: stereonet( + entry["slope"], + joints, + phi, + kind, + settings["lateral_planar_deg"], + settings["lateral_toppling_deg"], + ) + for kind in PROJECTIONS + } + return {**nets, "phi": phi} diff --git a/E06_StabRock/E06_StabRock_Router.py b/E06_StabRock/E06_StabRock_Router.py new file mode 100644 index 000000000..559e41d2d --- /dev/null +++ b/E06_StabRock/E06_StabRock_Router.py @@ -0,0 +1,204 @@ +"""E06 암반 평사투영 해석 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1). + +- `GET /api/projects/{id}/stab/rock` — 절리 작업본(없으면 초기값) · 범위 · 암 측점 · 고칠 수 있는지 +- `PUT …/stab/rock` — [저장] 검사 뒤 작업본 `E06_StabRock/joints.json` +- `POST …/stab/rock/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) +- `POST …/stab/rock/analyze` — [해석] 암 측점 × 깎기 쪽 · 지문이 같으면 안 셈(`fresh`) +- `GET …/stab/rock/result` — 측점별 모드 · 안전율 · 판정(+ 지문이 지금과 다른지) +- `GET …/stab/rock/result?station=&side=` — 그 측점 쪽 + 스테레오넷(등각 · 등면적) + +입력 = E01 복사본 + E02 지반 조건(물성 · 지진 · 기준안전율) + 절리 작업본(저장 안 한 캐시는 안 씀). +계산 자리 = ③ 서버 단독. 결과 = 파생 저장본 `_derived/stab_rock/<지문>`. +공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from common_util.common_util_json import atomic_write_json +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Router as e02 +from E06_StabRock import E06_StabRock_Model as model + +router = APIRouter(prefix="/api/projects", tags=["E06 Stab Rock"]) + +KIND = "stab_rock" + + +class JointsRequest(BaseModel): + joints: dict[str, Any] + + +def ensure_initial(project_root: Path) -> dict[str, Any]: + """초기값이 없을 때만 한 번 — 절리 없음 + 기본 설정.""" + path = model.initial_path(project_root) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + doc = model.build_initial() + atomic_write_json(path, doc) + return doc + + +def _working(project_root: Path) -> dict[str, Any] | None: + path = model.joints_path(project_root) + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _joints(project_root: Path) -> dict[str, Any]: + return model.upgrade(_working(project_root) or ensure_initial(project_root)) + + +def _source(project_root: Path) -> Path: + root = src.source_dir(project_root) + if not (root / src.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.") + return root + + +def _fingerprint(project_root: Path, source: Path) -> tuple[str, dict, dict]: + joints = _joints(project_root) + ground = e02._read_working(project_root) or e02.ensure_initial(project_root) + fp = derived_key( + model.engine_hash(), + src.manifest(source)["source_fingerprint"], + { + "sets": joints["sets"], + "settings": {k: v for k, v in joints["settings"].items() if k != "projection"}, + }, + model._ground_inputs(ground), + ) + return fp, joints, ground + + +def _latest(project_root: Path) -> dict[str, Any] | None: + """가장 최근 저장본(지문이 달라도 — 화면이 「다시 해석」 띠를 띄움).""" + folder = derived_dir(project_root, KIND) + files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime_ns, reverse=True) + for path in files: + saved = read_derived(project_root, KIND, path.stem) + if saved: + return saved + return None + + +@router.get("/{project_id}/stab/rock") +async def get_rock( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root, project = await e02._project(project_id) + initial = ensure_initial(root) + working = _working(root) + try: + stations = [ + {"chainage_m": s["chainage_m"], "label": s["label"], "ground_type": s["ground_type"]} + for s in model.rock_stations(_source(root)) + ] + except HTTPException: + stations = [] + return { + "joints": model.upgrade(working or initial), + "saved": working is not None, + "defaults": { + "settings": model.DEFAULT_SETTINGS, + "new_set": model.NEW_SET, + "limits": model.LIMITS, + }, + "stations": stations, + "editable": e02._editable(session, project), + } + + +@router.put("/{project_id}/stab/rock") +async def put_rock(project_id: UUID, payload: JointsRequest) -> dict[str, Any]: + root, _ = await e02._project(project_id) + errors = model.check_joints(payload.joints) + if errors: + raise HTTPException(status_code=422, detail={"errors": errors}) + doc = model.clean(payload.joints) + atomic_write_json(model.joints_path(root), doc) + return {"joints": doc} + + +@router.post("/{project_id}/stab/rock/reset") +async def reset_rock(project_id: UUID) -> dict[str, Any]: + root, _ = await e02._project(project_id) + initial = ensure_initial(root) + atomic_write_json(model.joints_path(root), initial) + return {"joints": model.upgrade(initial)} + + +def _summary(stations: list[dict[str, Any]]) -> dict[str, Any]: + verdicts: dict[str, int] = {} + for item in stations: + verdicts[item["verdict"]] = verdicts.get(item["verdict"], 0) + 1 + return { + "slopes": len(stations), + "verdicts": verdicts, + "modes": { + mode: sum(item["counts"][mode] for item in stations) + for mode in ("planar", "wedge", "toppling") + }, + } + + +def _run(root: Path) -> dict[str, Any]: + source = _source(root) + fp, joints, ground = _fingerprint(root, source) + if read_derived(root, KIND, fp): + return {"status": "fresh", "fingerprint": fp} + stations = model.analyze(source, joints, ground) + value = { + "fingerprint": fp, + "settings": joints["settings"], + "summary": _summary(stations), + "stations": stations, + } + write_derived(root, KIND, fp, value) + return {"status": "done", "fingerprint": fp, "summary": value["summary"]} + + +@router.post("/{project_id}/stab/rock/analyze") +async def analyze_rock(project_id: UUID) -> dict[str, Any]: + """[해석] — 암 측점 수십 개 · 1 초 안쪽이라 바로 셈(진행률 없음).""" + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_run, root) + + +def _result(root: Path, station: str | None, side: str | None) -> dict[str, Any]: + source = _source(root) + fp, _, _ = _fingerprint(root, source) + saved = read_derived(root, KIND, fp) or _latest(root) + if not saved: + return {"status": "empty", "stale": False, "result": None} + value = saved["value"] + stale = value["fingerprint"] != fp + if station is None: + return {"status": "success", "stale": stale, "result": value} + for entry in value["stations"]: + if entry["key"] == station and (side is None or entry["slope"]["side"] == side): + net = model.station_net(entry, value["settings"]) + return {"status": "success", "stale": stale, "entry": entry, "stereonet": net} + raise HTTPException(status_code=404, detail="그 측점의 암반 해석 결과가 없습니다.") + + +@router.get("/{project_id}/stab/rock/result") +async def result_rock( + project_id: UUID, station: str | None = None, side: str | None = None +) -> dict[str, Any]: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_result, root, station, side) diff --git a/E06_StabRock/E06_StabRock_Stereonet.py b/E06_StabRock/E06_StabRock_Stereonet.py new file mode 100644 index 000000000..fb0b3cd3e --- /dev/null +++ b/E06_StabRock/E06_StabRock_Stereonet.py @@ -0,0 +1,137 @@ +"""E06 스테레오넷 투영 좌표 — 하반구 · 등각(Wulff) · 등면적(Schmidt) (PLAN 536-1). + +화면(SVG)은 이 좌표를 그리기만 함 — 반지름 1 원 · x = 동 · y = 북(화면은 y 를 뒤집음). +극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역(Markland 부등식과 같은 경계 — +`E06_StabRock_Engine.kinematic`)을 냄. 투영식 = 표준 구면 투영(mplstereonet 등은 대조만). +""" + +from __future__ import annotations + +import math +from typing import Any + +from E06_StabRock.E06_StabRock_Engine import ( + LATERAL_PLANAR_DEG, + LATERAL_TOPPLING_DEG, + apparent_dip, + intersection, + pole, +) + +PROJECTIONS = ("equal_angle", "equal_area") +STEP_DEG = 2.0 # 곡선 표본 간격 + + +def radius(plunge: float, kind: str) -> float: + """선주각 → 중심에서 거리(0 = 연직 · 1 = 수평).""" + half = math.radians(90.0 - plunge) / 2.0 + return math.tan(half) if kind == "equal_angle" else math.sqrt(2.0) * math.sin(half) + + +def project(trend: float, plunge: float, kind: str) -> list[float]: + r = radius(plunge, kind) + t = math.radians(trend) + return [round(r * math.sin(t), 5), round(r * math.cos(t), 5)] + + +def inverse(x: float, y: float, kind: str) -> tuple[float, float]: + """투영 좌표 → (추향 · 선주각) — 왕복 시험 · 화면 마우스 자리 읽기.""" + r = math.hypot(x, y) + half = math.atan(r) if kind == "equal_angle" else math.asin(min(1.0, r / math.sqrt(2.0))) + return math.degrees(math.atan2(x, y)) % 360.0, 90.0 - math.degrees(2.0 * half) + + +def _samples(lo: float, hi: float) -> list[float]: + n = max(2, int(math.ceil((hi - lo) / STEP_DEG)) + 1) + return [lo + (hi - lo) * k / (n - 1) for k in range(n)] + + +def great_circle(dip_dir: float, dip: float, kind: str) -> list[list[float]]: + """면의 대원 — 주향 한끝 → 경사 방향 → 다른 끝.""" + return [ + project(dip_dir + b, max(0.0, apparent_dip(dip, dip_dir, dip_dir + b)), kind) + for b in _samples(-90.0, 90.0) + ] + + +def _zone(trend_lo, trend_hi, plunge_lo, plunge_hi, kind) -> list[list[float]] | None: + """(추향 · 선주각) 띠 → 닫힌 다각형 — plunge_lo/hi 는 추향의 함수 · 비면 None.""" + ts = _samples(trend_lo, trend_hi) + lows = [plunge_lo(t) for t in ts] + highs = [plunge_hi(t) for t in ts] + keep = [k for k in range(len(ts)) if highs[k] > lows[k]] + if len(keep) < 2: + return None + outer = [project(ts[k], lows[k], kind) for k in keep] + inner = [project(ts[k], highs[k], kind) for k in reversed(keep)] + return outer + inner + + +def stereonet( + slope: dict, + joints: list[dict], + phi: float, + kind: str = "equal_angle", + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, Any]: + """slope = {dip_direction, dip} · joints = [{id, dip_direction, dip}] · phi = 영역 마찰각.""" + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + + def face_app(t: float) -> float: + return apparent_dip(pf, af, t) + + planes = [] + for j in joints: + tr, pl = pole(j["dip_direction"], j["dip"]) + planes.append( + { + "id": j["id"], + "pole": project(tr, pl, kind), + "great_circle": great_circle(j["dip_direction"], j["dip"], kind), + } + ) + lines = [] + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is not None: + lines.append({"ids": [a["id"], b["id"]], "point": project(*line, kind)}) + # 일광 엔벨로프(극점 꼴) = 사면 겉보기 경사로 기운 면들의 극점 + envelope = [ + project(af + b + 180.0, 90.0 - face_app(af + b), kind) for b in _samples(-90.0, 90.0) + ] + zones = { + # 평면(극점) — 극점 추향 αf + 180 ± 측방 · 선주각 90 − ψapp ~ 90 − φ + "planar": _zone( + af + 180.0 - lateral_planar, + af + 180.0 + lateral_planar, + lambda t: 90.0 - face_app(t - 180.0), + lambda t: 90.0 - phi, + kind, + ), + # 쐐기(교선) — 추향 αf ± 90 · 선주각 φ ~ ψapp + "wedge": _zone(af - 90.0, af + 90.0, lambda t: phi, face_app, kind), + # 전도(극점) — 극점 추향 αf ± 측방 · 선주각 0 ~ ψf − φ + "toppling": _zone( + af - lateral_toppling, + af + lateral_toppling, + lambda t: 0.0, + lambda t: pf - phi, + kind, + ), + } + return { + "projection": kind, + "slope": { + "pole": project(*pole(af, pf), kind), + "great_circle": great_circle(af, pf, kind), + }, + "planes": planes, + "intersections": lines, + # 마찰원 — 극점 꼴 = 중심에서 φ(선주각 90 − φ) · 교선 꼴 = 선주각 φ + "friction_pole_r": round(radius(90.0 - phi, kind), 5), + "friction_line_r": round(radius(phi, kind), 5), + "daylight_envelope": envelope, + "zones": zones, + } diff --git a/E06_StabRock/E06_StabRock_UI_Joints.ts b/E06_StabRock/E06_StabRock_UI_Joints.ts new file mode 100644 index 000000000..6350ae45f --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Joints.ts @@ -0,0 +1,304 @@ +/* ============================================================================= + * E06_StabRock_UI_Joints.ts + * E06 좌측 — 절리군 표(구간 · 경사방향 · 경사 · φj · cj · 지속성 · [추가] [지움]) + 해석 설정 + * (측방 한계 · 암 단위중량 · 우기 수위 · 인장균열 · 볼트). 칸을 바꾸면 doc 을 고치고 onChange — + * 캐시 · 저장은 페이지 몫. 범위 = 서버 LIMITS(벗어나면 칸 빨강 · 마우스 글). + * ========================================================================== */ + +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export interface JointSet { + id: string; + name: string; + from_m: number; + to_m: number; + dip_direction: number; + dip: number; + phi: number; + c: number; + persistence_m: number | null; +} + +export type Projection = "equal_angle" | "equal_area"; + +export interface RockSettings { + projection: Projection; + lateral_planar_deg: number; + lateral_toppling_deg: number; + gamma_r: number | null; + water_fill_wet: number; + crack: { mode: "none" | "critical" | "depth"; z: number | null }; + bolt: { force: number; plunge: number }; +} + +export interface JointsDoc { + version: number; + sets: JointSet[]; + settings: RockSettings; +} + +export interface RockDefaults { + settings: RockSettings; + new_set: { phi: number; c: number }; + limits: Record; +} + +export interface JointsView { + doc: JointsDoc; + defaults: RockDefaults; + editable: boolean; + endM: number; // 마지막 암 측점(새 줄 구간 끝) + change: () => void; // 값만 바뀜 + rebuild: () => void; // 줄 수가 바뀜 +} + +/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */ +function numberInput( + view: JointsView, + value: number | null, + limit: string, + set: (v: number | null) => void, + opts: { optional?: boolean; step?: string; path: string }, +): HTMLInputElement { + const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity]; + const input = el("input", { + className: "e06-input", + attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path }, + }) as HTMLInputElement; + input.value = value === null ? "" : String(value); + input.disabled = !view.editable; + const check = (): boolean => { + const raw = input.value.trim(); + const num = Number(raw); + const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi; + input.classList.toggle("is-invalid", !ok); + input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`; + return ok; + }; + input.addEventListener("input", () => { + const raw = input.value.trim(); + set(raw === "" ? null : Number(raw)); + check(); + view.change(); + }); + check(); + return input; +} + +function nextId(sets: JointSet[]): string { + let k = sets.length + 1; + while (sets.some((s) => s.id === `J${k}`)) k += 1; + return `J${k}`; +} + +const COLUMNS: LocaleKey[] = [ + "E06_Col_Name", + "E06_Col_From", + "E06_Col_To", + "E06_Col_DipDir", + "E06_Col_Dip", + "E06_Col_Phi", + "E06_Col_C", + "E06_Col_Persist", +]; + +function jointTable(view: JointsView): HTMLElement { + const wrap = el("div", { className: "e06-table-wrap" }); + const table = el("table", { className: "e06-table" }); + const head = el("tr"); + for (const key of COLUMNS) head.append(el("th", { text: t(key) })); + head.append(el("th")); + table.append(el("thead"), el("tbody")); + table.tHead!.append(head); + view.doc.sets.forEach((row, k) => { + const tr = el("tr", { attrs: { "data-id": row.id } }); + const name = el("input", { + className: "e06-input e06-input--name", + attrs: { type: "text", "data-path": `sets.${k}.name` }, + }) as HTMLInputElement; + name.value = row.name; + name.disabled = !view.editable; + name.addEventListener("input", () => { + row.name = name.value; + name.classList.toggle("is-invalid", !name.value.trim()); + view.change(); + }); + tr.append(el("td", { children: [name] })); + const fields: [keyof JointSet, string, boolean][] = [ + ["from_m", "chainage_m", false], + ["to_m", "chainage_m", false], + ["dip_direction", "dip_direction", false], + ["dip", "dip", false], + ["phi", "phi", false], + ["c", "c", false], + ["persistence_m", "persistence_m", true], + ]; + for (const [key, limit, optional] of fields) { + const input = numberInput( + view, + row[key] as number | null, + limit, + (v) => ((row as unknown as Record)[key] = v), + { optional, path: `sets.${k}.${key}` }, + ); + tr.append(el("td", { children: [input] })); + } + const remove = createButton({ + label: "×", + variant: "danger-ghost", + size: "sm", + onClick: () => { + view.doc.sets.splice(k, 1); + view.rebuild(); + }, + }); + remove.title = t("E06_Remove"); + remove.disabled = !view.editable; + tr.append(el("td", { children: [remove] })); + table.tBodies[0].append(tr); + }); + wrap.append(table); + if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") })); + return wrap; +} + +function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { + const row = el("label", { className: "e06-setting" }); + row.append(el("span", { text: t(label) }), control); + return row; +} + +function select( + view: JointsView, + value: T, + options: [T, LocaleKey][], + set: (v: T) => void, + path: string, +): HTMLSelectElement { + const box = el("select", { + className: "e06-input", + attrs: { "data-path": path }, + }) as HTMLSelectElement; + for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } })); + box.value = value; + box.disabled = !view.editable; + box.addEventListener("change", () => { + set(box.value as T); + view.rebuild(); + }); + return box; +} + +export function buildJointCards(view: JointsView): HTMLElement[] { + const s = view.doc.settings; + const setsCard = createPanelCard(t("E06_Card_Joints"), false); + const add = createButton({ + label: t("E06_Add"), + variant: "ghost", + size: "sm", + onClick: () => { + const id = nextId(view.doc.sets); + view.doc.sets.push({ + id, + name: id, + from_m: 0, + to_m: Math.ceil(view.endM), + dip_direction: 0, + dip: 45, + phi: view.defaults.new_set.phi, + c: view.defaults.new_set.c, + persistence_m: null, + }); + view.rebuild(); + }, + }); + add.disabled = !view.editable; + setsCard.append( + el("p", { className: "e06-hint", text: t("E06_JointsHint") }), + jointTable(view), + el("div", { className: "e06-row-actions", children: [add] }), + ); + + const setCard = createPanelCard(t("E06_Card_Settings"), true); + const crack = s.crack; + setCard.append( + settingRow( + "E06_Set_LateralPlanar", + numberInput( + view, + s.lateral_planar_deg, + "lateral_deg", + (v) => (s.lateral_planar_deg = v ?? NaN), + { + path: "settings.lateral_planar_deg", + }, + ), + ), + settingRow( + "E06_Set_LateralTopple", + numberInput( + view, + s.lateral_toppling_deg, + "lateral_deg", + (v) => (s.lateral_toppling_deg = v ?? NaN), + { path: "settings.lateral_toppling_deg" }, + ), + ), + settingRow( + "E06_Set_Gamma", + numberInput(view, s.gamma_r, "gamma_r", (v) => (s.gamma_r = v), { + optional: true, + path: "settings.gamma_r", + }), + ), + settingRow( + "E06_Set_WaterWet", + numberInput(view, s.water_fill_wet, "water_fill_wet", (v) => (s.water_fill_wet = v ?? NaN), { + step: "0.05", + path: "settings.water_fill_wet", + }), + ), + settingRow( + "E06_Set_Crack", + select( + view, + crack.mode, + [ + ["none", "E06_Crack_None"], + ["critical", "E06_Crack_Critical"], + ["depth", "E06_Crack_Depth"], + ], + (v) => { + crack.mode = v; + crack.z = v === "depth" ? (crack.z ?? 1) : null; + }, + "settings.crack.mode", + ), + ), + ); + if (crack.mode === "depth") + setCard.append( + settingRow( + "E06_Set_CrackZ", + numberInput(view, crack.z, "crack_z", (v) => (crack.z = v), { path: "settings.crack.z" }), + ), + ); + setCard.append( + settingRow( + "E06_Set_BoltForce", + numberInput(view, s.bolt.force, "bolt_force", (v) => (s.bolt.force = v ?? NaN), { + path: "settings.bolt.force", + }), + ), + settingRow( + "E06_Set_BoltPlunge", + numberInput(view, s.bolt.plunge, "bolt_plunge", (v) => (s.bolt.plunge = v ?? NaN), { + path: "settings.bolt.plunge", + }), + ), + el("p", { className: "e06-hint", text: t("E06_SettingsHint") }), + ); + return [setsCard, setCard]; +} diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts new file mode 100644 index 000000000..f96156bd7 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -0,0 +1,397 @@ +/* ============================================================================= + * E06_StabRock_UI_Page.ts + * E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow). + * 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석] + * 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도) + * 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표 + * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset · + * [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET. + * ========================================================================== */ + +import "./E06_StabRock_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { + buildJointCards, + type JointsDoc, + type JointsView, + type Projection, + type RockDefaults, +} from "./E06_StabRock_UI_Joints"; +import { + buildBand, + buildDetail, + buildList, + entryId, + type RockResult, + type StationEntry, +} from "./E06_StabRock_UI_Result"; +import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet"; + +interface RockResponse { + joints: JointsDoc; + saved?: boolean; + defaults?: RockDefaults; + stations?: { chainage_m: number; label: string; ground_type: string }[]; + editable?: boolean; +} + +interface ResultResponse { + status: string; + stale: boolean; + result: RockResult | null; +} + +interface StationResponse { + entry: StationEntry; + stereonet: Record & { phi: number }; +} + +const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`; + +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) { + const detail = body.detail; + if (typeof detail === "string") throw new Error(detail); + const errors = (detail as { errors?: unknown[] } | undefined)?.errors; + throw new Error(errors ? `${t("E06_Invalid")} ${errors.length}` : `HTTP ${response.status}`); + } + return body; +} + +function toggle(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) { + const group = el("div", { className: "e06-toggle" }); + const buttons = options.map(([key, v]) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = v; + button.classList.toggle("is-on", v === value); + button.addEventListener("click", () => { + buttons.forEach((b) => b.classList.toggle("is-on", b === button)); + onPick(v); + }); + return button; + }); + group.append(...buttons); + return group; +} + +export function renderE06StabRock(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e06-panel" }); + const main = el("div", { className: "e06-page" }); + mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") })); + + // ---- 좌 ---- + const cards = el("div", { className: "e06-cards" }); + const status = el("p", { className: "e06-status" }); + const saveButton = createButton({ + label: t("Common_Btn_Save"), + variant: "ghost", + onClick: () => void save(), + }); + const resetButton = createButton({ + label: t("Common_Btn_Reset"), + variant: "danger-ghost", + onClick: () => void reset(), + }); + const analyzeButton = createButton({ + label: t("E06_Analyze"), + variant: "filled", + onClick: () => void analyze(), + }); + saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(resetButton, saveButton, analyzeButton); + panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions); + attachCollapsible(cards); + + // ---- 가운데 · 우 ---- + const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") }); + staleBar.hidden = true; + const bandSlot = el("div"); + const title = el("strong", { className: "e06-title" }); + const netSlot = el("div", { className: "e06-net-slot" }); + const legendSlot = el("div"); + let projection: Projection = "equal_angle"; + let netMode: NetMode = "all"; + const toolbar = el("div", { className: "e06-toolbar" }); + const projectionToggle = el("div"); + toolbar.append( + title, + projectionToggle, + toggle( + [ + ["E06_View_All", "all"], + ["E06_Mode_planar", "planar"], + ["E06_Mode_wedge", "wedge"], + ["E06_Mode_toppling", "toppling"], + ], + netMode, + (v) => { + netMode = v; + paintNet(); + }, + ), + ); + const stage = el("div", { className: "e06-stage" }); + stage.append(netSlot, legendSlot); + const center = el("div", { className: "e06-center" }); + center.append(staleBar, bandSlot, toolbar, stage); + const listSlot = el("div", { className: "e06-list-slot" }); + const detailSlot = el("div"); + const sideBody = el("div", { className: "e06-side" }); + sideBody.append(listSlot, detailSlot); + const side = createSidePanels({ + prefKey: "e06-stab-rock-result", + panels: [{ id: "result", title: t("E06_Result_Title"), body: sideBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e06-row" }); + row.append(center, side.root); + main.append(row); + + // ---- 상태 ---- + let view: JointsView | null = null; + let dirty = false; + let result: RockResult | null = null; + let active: string | null = null; + let shown: StationResponse | null = null; + + const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length; + const paintStatus = (): void => { + const lines: string[] = []; + if (view && !view.editable) lines.push(t("E06_ReadOnly")); + if (dirty) lines.push(t("E06_Unsaved")); + const bad = invalidCount(); + if (bad) lines.push(`${t("E06_Invalid")} ${bad}`); + status.textContent = lines.join("\n"); + status.classList.toggle("is-error", bad > 0); + }; + + const renderCards = (): void => { + if (!view) return; + // 다시 그려도 접힘은 그대로 + const folded = [...cards.children].map((c) => c.classList.contains("is-collapsed")); + const built = buildJointCards(view); + built.forEach( + (c, i) => folded[i] !== undefined && c.classList.toggle("is-collapsed", folded[i]), + ); + cards.replaceChildren(...built); + paintStatus(); + }; + + const remember = (): void => { + if (!view) return; + dirty = true; + sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc)); + }; + + function paintProjection(): void { + projectionToggle.replaceChildren( + toggle( + [ + ["E06_Proj_Angle", "equal_angle"], + ["E06_Proj_Area", "equal_area"], + ], + projection, + (v) => { + projection = v; // 보기 고름 — 정본 아님(해석 지문에도 안 들어감) + paintNet(); + }, + ), + ); + } + + function paintNet(): void { + legendSlot.replaceChildren(netLegend(netMode)); + if (!shown) { + title.textContent = ""; + netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") })); + return; + } + const e = shown.entry; + title.textContent = `${e.label ?? e.key} · ${t(e.slope.side === "left" ? "E06_Left" : "E06_Right")} · φ ${shown.stereonet.phi}°`; + netSlot.replaceChildren( + drawStereonet(shown.stereonet[projection], netMode, { + planar: e.kinematic.planar, + toppling: e.kinematic.toppling, + wedge: e.kinematic.wedge.map((w) => w.ids.join("|")), + }), + ); + } + + function paintResult(): void { + const stations = result?.stations ?? []; + bandSlot.replaceChildren(buildBand(stations, active, (id) => void select(id))); + listSlot.replaceChildren( + stations.length + ? buildList(stations, active, (id) => void select(id)) + : el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }), + ); + detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span")); + paintNet(); + } + + async function select(id: string): Promise { + active = id; + const [key, sideName] = id.split("|"); + try { + shown = await api( + `/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`, + ); + } catch (error) { + shown = null; + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + paintResult(); + } + + async function loadResult(): Promise { + const body = await api(`/projects/${projectId}/stab/rock/result`); + result = body.result; + staleBar.hidden = !body.stale; + const stations = result?.stations ?? []; + const keep = stations.find((e) => entryId(e) === active); + const first = keep ?? stations.find((e) => e.verdict === "NG") ?? stations[0]; + if (first) await select(entryId(first)); + else { + active = null; + shown = null; + paintResult(); + } + } + + const adopt = (body: RockResponse, keepCache: boolean): void => { + if (!view) return; + const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; + if (!cached) sessionStorage.removeItem(cacheKey(projectId)); + view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints; + dirty = Boolean(cached); + saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable; + renderCards(); + }; + + async function putJoints(): Promise { + if (!view?.editable) return false; + if (invalidCount()) { + showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error"); + return false; + } + const body = await api(`/projects/${projectId}/stab/rock`, { + method: "PUT", + body: JSON.stringify({ joints: view.doc }), + }); + adopt(body, false); + return true; + } + + async function save(): Promise { + showLoadingOverlay(); + try { + if (await putJoints()) { + showToast(t("E06_Saved"), "success"); + await loadResult(); + } + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function reset(): Promise { + if (!view?.editable) return; + const ok = await showConfirmDialog( + t("E06_ResetConfirm"), + t("Common_Btn_Reset"), + t("Common_Btn_Cancel"), + ); + if (!ok) return; + showLoadingOverlay(); + try { + const body = await api(`/projects/${projectId}/stab/rock/reset`, { + method: "POST", + }); + adopt(body, false); + showToast(t("E06_ResetDone"), "success"); + await loadResult(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function analyze(): Promise { + if (!view?.editable) return; + analyzeButton.disabled = true; + status.textContent = t("E06_Running"); + try { + if (dirty && !(await putJoints())) return; + const body = await api<{ status: string; summary?: RockResult["summary"] }>( + `/projects/${projectId}/stab/rock/analyze`, + { method: "POST" }, + ); + await loadResult(); + showToast(t(body.status === "fresh" ? "E06_Fresh" : "E06_Done"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + analyzeButton.disabled = !view.editable; + paintStatus(); + } + } + + void (async () => { + try { + const body = await api(`/projects/${projectId}/stab/rock`); + const stations = body.stations ?? []; + projection = body.joints.settings.projection; + paintProjection(); + view = { + doc: body.joints, + defaults: body.defaults as RockDefaults, + editable: body.editable ?? false, + endM: stations.length ? stations[stations.length - 1].chainage_m : 0, + change: () => { + remember(); + paintStatus(); + }, + rebuild: () => { + remember(); + renderCards(); + }, + }; + adopt(body, true); + await loadResult(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e06-empty", + text: error instanceof Error ? error.message : t("Common_Status_Error"), + }), + ); + } + })(); +} diff --git a/E06_StabRock/E06_StabRock_UI_Result.ts b/E06_StabRock/E06_StabRock_UI_Result.ts new file mode 100644 index 000000000..a2d3352f8 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Result.ts @@ -0,0 +1,219 @@ +/* ============================================================================= + * E06_StabRock_UI_Result.ts + * E06 결과 — 위험 띠(누가 축 · 측점 판정 색 · 누르면 그 측점) · 우측 측점 목록(쪽 · 모드 수 · + * 최소 안전율 · 판정) · 고른 측점 사면 · 모드별 안전율 표. 값은 서버 저장본 그대로(계산 없음). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export type Verdict = "NG" | "check" | "OK" | "none"; + +export interface RockCase { + mode: "planar" | "wedge"; + ids: string[]; + condition: "dry" | "wet" | "seismic"; + F: number | null; + formed: boolean; + persistence_short: boolean; + required: number | null; + ok: boolean | null; + sliding?: string | null; +} + +export interface StationEntry { + key: string; + chainage_m: number; + label: string | null; + ground_type: string; + slope: { side: string; dip_direction: number; dip: number; height_m: number; upper_dip: number }; + gamma_r: number; + joints: { + id: string; + name: string; + dip_direction: number; + dip: number; + phi: number; + c: number; + }[]; + kinematic: { + planar: string[]; + toppling: string[]; + wedge: { ids: [string, string]; trend: number; plunge: number }[]; + }; + cases: RockCase[]; + counts: { planar: number; wedge: number; toppling: number }; + min_F: number | null; + verdict: Verdict; +} + +export interface RockResult { + fingerprint: string; + summary: { + slopes: number; + verdicts: Partial>; + modes: Record; + }; + stations: StationEntry[]; +} + +const ORDER: Verdict[] = ["NG", "check", "OK", "none"]; +export const entryId = (e: StationEntry): string => `${e.key}|${e.slope.side}`; +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined ? "–" : v.toFixed(d); + +function badge(verdict: Verdict): HTMLElement { + return el("span", { + className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`, + text: t(`E06_V_${verdict}` as LocaleKey), + }); +} + +/** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */ +export function buildBand( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const box = el("div", { className: "e06-band", attrs: { "data-role": "band" } }); + const worst = new Map(); + for (const e of stations) { + const prev = worst.get(e.chainage_m); + if (!prev || ORDER.indexOf(e.verdict) < ORDER.indexOf(prev.verdict)) worst.set(e.chainage_m, e); + } + const list = [...worst.values()].sort((a, b) => a.chainage_m - b.chainage_m); + const legend = el("div", { className: "e06-band__legend" }); + legend.append(el("strong", { text: t("E06_Band") })); + for (const v of ORDER) { + const n = list.filter((e) => e.verdict === v).length; + if (n) legend.append(badge(v), el("span", { className: "e06-band__count", text: String(n) })); + } + const strip = el("div", { className: "e06-band__strip" }); + if (list.length) { + const lo = list[0].chainage_m; + const hi = Math.max(list[list.length - 1].chainage_m, lo + 1); + list.forEach((e, i) => { + const a = i ? (list[i - 1].chainage_m + e.chainage_m) / 2 : lo; + const b = i < list.length - 1 ? (e.chainage_m + list[i + 1].chainage_m) / 2 : hi; + const seg = el("button", { + className: `e06-band__seg e06-band__seg--${e.verdict.toLowerCase()}${ + active?.startsWith(`${e.key}|`) ? " is-active" : "" + }`, + attrs: { + type: "button", + "data-key": e.key, + title: `${e.label ?? e.key} · ${t(`E06_V_${e.verdict}` as LocaleKey)}`, + }, + }); + seg.style.left = `${((a - lo) / (hi - lo)) * 100}%`; + seg.style.width = `${Math.max(0.4, ((b - a) / (hi - lo)) * 100)}%`; + seg.addEventListener("click", () => onSelect(entryId(e))); + strip.append(seg); + }); + const ticks = el("div", { className: "e06-band__ticks" }); + ticks.append( + el("span", { text: `${lo.toFixed(0)} m` }), + el("span", { text: `${hi.toFixed(0)} m` }), + ); + box.append(legend, strip, ticks); + } else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") })); + return box; +} + +export function buildList( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } }); + const head = el("tr"); + for (const key of [ + "E06_L_Station", + "E06_L_Side", + "E06_L_Modes", + "E06_L_MinF", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const e of stations) { + const id = entryId(e); + const tr = el("tr", { + className: id === active ? "is-active" : "", + attrs: { "data-id": id, "data-verdict": e.verdict }, + }); + tr.append( + el("td", { text: e.label ?? e.key }), + el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }), + el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }), + el("td", { text: fmt(e.min_F) }), + el("td", { children: [badge(e.verdict)] }), + ); + tr.addEventListener("click", () => onSelect(id)); + table.tBodies[0].append(tr); + } + return table; +} + +export function buildDetail(e: StationEntry): HTMLElement { + const box = el("div", { className: "e06-detail", attrs: { "data-role": "detail" } }); + const s = e.slope; + box.append( + el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }), + el("p", { + className: "e06-hint", + text: t("E06_SlopeLine") + .replace("{dd}", s.dip_direction.toFixed(0)) + .replace("{dip}", s.dip.toFixed(1)) + .replace("{h}", s.height_m.toFixed(2)) + .replace("{up}", s.upper_dip.toFixed(1)) + .replace("{g}", e.gamma_r.toFixed(1)), + }), + ); + const kin = e.kinematic; + const kinText = [ + `${t("E06_Mode_planar")} ${kin.planar.join(", ") || "–"}`, + `${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`, + `${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`, + ]; + box.append(el("p", { className: "e06-kin", text: kinText.join("\n") })); + if (!e.cases.length) { + box.append( + el("p", { + className: "e06-hint", + text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"), + }), + ); + return box; + } + const table = el("table", { className: "e06-cases" }); + const head = el("tr"); + for (const key of [ + "E06_C_Mode", + "E06_C_Joints", + "E06_C_Cond", + "E06_C_F", + "E06_C_Req", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const c of e.cases) { + const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; + const note = !c.formed ? t("E06_NotFormed") : c.persistence_short ? t("E06_Persist") : ""; + const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } }); + tr.append( + el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }), + el("td", { text: c.ids.join("·") }), + el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }), + el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }), + el("td", { text: fmt(c.required) }), + el("td", { children: [badge(verdict)] }), + ); + table.tBodies[0].append(tr); + } + box.append(table); + if (kin.toppling.length) + box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") })); + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Stereonet.ts b/E06_StabRock/E06_StabRock_UI_Stereonet.ts new file mode 100644 index 000000000..73e2b9e21 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Stereonet.ts @@ -0,0 +1,193 @@ +/* ============================================================================= + * E06_StabRock_UI_Stereonet.ts + * E06 가운데 스테레오넷(SVG · 하반구) — 서버가 내려 준 투영 좌표(반지름 1 · x = 동 · y = 북)를 + * 그리기만 함(계산 = E06_StabRock_Stereonet.py). 보기 = 전체 · 평면 · 쐐기 · 전도. + * 평면 · 전도 = 극점 + 극점 마찰원(φ) · 쐐기 = 교선 + 교선 마찰원 · 위험 영역 = 모드 색 · + * 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const SIZE = 440; +const R = 190; +const C = SIZE / 2; + +export type NetMode = "all" | "planar" | "wedge" | "toppling"; +type XY = [number, number]; + +export interface NetData { + projection: string; + slope: { pole: XY; great_circle: XY[] }; + planes: { id: string; pole: XY; great_circle: XY[] }[]; + intersections: { ids: [string, string]; point: XY }[]; + friction_pole_r: number; + friction_line_r: number; + daylight_envelope: XY[]; + zones: Record<"planar" | "wedge" | "toppling", XY[] | null>; +} + +export interface NetMarks { + planar: string[]; + toppling: string[]; + wedge: string[]; // "J1|J2" +} + +function svg( + tag: K, + attrs: Record = {}, + text?: string, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + if (text !== undefined) node.textContent = text; + return node; +} + +const sx = (p: XY): number => C + R * p[0]; +const sy = (p: XY): number => C - R * p[1]; +const path = (pts: XY[], close = false): string => + pts.map((p, i) => `${i ? "L" : "M"}${sx(p).toFixed(1)},${sy(p).toFixed(1)}`).join("") + + (close ? "Z" : ""); + +/** 절리 색 번호(0~5) — 표 · 그림 같은 차례 */ +export const jointTone = (index: number): number => index % 6; + +export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVGSVGElement { + const root = svg("svg", { + viewBox: `0 0 ${SIZE} ${SIZE}`, + class: "e06-net", + "data-role": "stereonet", + "data-projection": net.projection, + "data-mode": mode, + }); + const clip = svg("clipPath", { id: "e06-net-clip" }); + clip.append(svg("circle", { cx: C, cy: C, r: R })); + const defs = svg("defs"); + defs.append(clip); + root.append(defs, svg("circle", { cx: C, cy: C, r: R, class: "e06-net__rim" })); + const body = svg("g", { "clip-path": "url(#e06-net-clip)" }); + root.append(body); + + const show = (m: Exclude): boolean => mode === "all" || mode === m; + for (const m of ["planar", "wedge", "toppling"] as const) { + const zone = net.zones[m]; + if (zone && show(m)) + body.append( + svg("path", { + d: path(zone, true), + class: `e06-net__zone e06-net__zone--${m}`, + "data-zone": m, + }), + ); + } + // 마찰원 + if (show("planar") || show("toppling")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_pole_r, + class: "e06-net__friction", + "data-role": "friction-pole", + }), + ); + if (show("wedge")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_line_r, + class: "e06-net__friction e06-net__friction--line", + "data-role": "friction-line", + }), + ); + if (show("planar")) + body.append( + svg("path", { + d: path(net.daylight_envelope), + class: "e06-net__envelope", + "data-role": "daylight", + }), + ); + // 대원 + net.planes.forEach((p, i) => + body.append( + svg("path", { + d: path(p.great_circle), + class: `e06-net__gc e06-tone-${jointTone(i)}`, + "data-id": p.id, + }), + ), + ); + body.append( + svg("path", { d: path(net.slope.great_circle), class: "e06-net__slope", "data-role": "slope" }), + ); + + // 극점(평면 · 전도 보기) · 교선(쐐기 보기) + if (mode !== "wedge") + net.planes.forEach((p, i) => { + const risky = marks.planar.includes(p.id) || marks.toppling.includes(p.id); + const g = svg("g", { + class: `e06-net__pole e06-tone-${jointTone(i)}${risky ? " is-risk" : ""}`, + "data-pole": p.id, + }); + g.append(svg("circle", { cx: sx(p.pole), cy: sy(p.pole), r: 5 })); + g.append(svg("text", { x: sx(p.pole) + 7, y: sy(p.pole) - 6 }, p.id)); + root.append(g); + }); + if (mode === "all" || mode === "wedge") + for (const line of net.intersections) { + const key = line.ids.join("|"); + const risky = marks.wedge.includes(key); + const [x, y] = [sx(line.point), sy(line.point)]; + const g = svg("g", { class: `e06-net__line${risky ? " is-risk" : ""}`, "data-line": key }); + g.append(svg("path", { d: `M${x},${y - 6}L${x + 5.5},${y + 4}L${x - 5.5},${y + 4}Z` })); + g.append(svg("text", { x: x + 7, y: y + 12 }, line.ids.join("·"))); + root.append(g); + } + // 사면 극점 · 방위 + root.append( + svg("rect", { + x: sx(net.slope.pole) - 4, + y: sy(net.slope.pole) - 4, + width: 8, + height: 8, + class: "e06-net__slope-pole", + }), + ); + for (const [label, x, y] of [ + ["N", C, C - R - 8], + ["E", C + R + 10, C + 4], + ["S", C, C + R + 18], + ["W", C - R - 10, C + 4], + ] as const) + root.append(svg("text", { x, y, class: "e06-net__dir", "text-anchor": "middle" }, label)); + root.append(svg("line", { x1: C - 6, y1: C, x2: C + 6, y2: C, class: "e06-net__tick" })); + root.append(svg("line", { x1: C, y1: C - 6, x2: C, y2: C + 6, class: "e06-net__tick" })); + return root; +} + +export function netLegend(mode: NetMode): HTMLElement { + const box = el("div", { className: "e06-legend" }); + const items: [string, LocaleKey][] = [ + ["slope", "E06_Lg_Slope"], + ["friction", "E06_Lg_Friction"], + ]; + if (mode === "all" || mode === "planar") + items.push(["planar", "E06_Lg_Planar"], ["daylight", "E06_Lg_Daylight"]); + if (mode === "all" || mode === "wedge") + items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]); + if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]); + if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]); + for (const [cls, key] of items) { + const row = el("span", { className: "e06-legend__row" }); + row.append( + el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }), + el("span", { text: t(key) }), + ); + box.append(row); + } + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Style.css b/E06_StabRock/E06_StabRock_UI_Style.css new file mode 100644 index 000000000..47b4a8b49 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Style.css @@ -0,0 +1,485 @@ +/* ============================================================================= + * E06_StabRock_UI_Style.css + * E06 암반 해석 — 좌측 절리 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우측 측점 결과 + * 색은 테마 토큰(밝음 · 어두움 같이) · 판정 · 모드 · 절리 색만 고정색(E03 판정색과 같음) + * ========================================================================== */ + +.e06-page { + --e06-ng: #d03b3b; + --e06-ok: #0ca30c; + --e06-check: #e0a106; + --e06-none: #9a9a95; + --e06-planar: #d03b3b; + --e06-wedge: #f1963b; + --e06-toppling: #9b6dde; + height: 100dvh; + box-sizing: border-box; +} + +.e06-row { + height: 100%; +} + +.e06-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; + overflow: auto; +} + +.e06-empty, +.e06-hint, +.e06-status, +.e06-kin { + margin: 0; + white-space: pre-line; + color: var(--color-text-muted); + font-size: 12px; +} + +.e06-empty { + padding: var(--spacing-16); +} + +.e06-status.is-error { + color: var(--color-danger); +} + +.e06-kin { + color: var(--color-text); +} + +.e06-panel .ui-sidebar-actions { + flex-wrap: wrap; +} + +.e06-stale { + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + color: var(--color-text); +} + +.e06-stale[hidden] { + display: none; +} + +/* ---- 좌측 표 · 설정 ---- */ +.e06-table-wrap { + overflow-x: auto; +} + +.e06-table { + border-collapse: collapse; + font-size: 12px; +} + +.e06-table th { + padding: 2px 3px; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.e06-table td { + padding: 1px 2px; +} + +.e06-input { + width: 4.2em; + padding: 2px 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 12px; + box-sizing: border-box; +} + +.e06-input--name { + width: 3.6em; +} + +select.e06-input { + width: auto; +} + +.e06-input.is-invalid { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface)); +} + +.e06-row-actions { + display: flex; + justify-content: flex-end; + margin-top: var(--spacing-4); +} + +.e06-setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + margin: 2px 0; + font-size: 12px; + color: var(--color-text); +} + +.e06-setting .e06-input { + width: 5.5em; +} + +/* ---- 가운데 ---- */ +.e06-toolbar { + display: flex; + align-items: center; + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.e06-title { + color: var(--color-text); +} + +.e06-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e06-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e06-stage { + display: flex; + gap: var(--spacing-12); + align-items: flex-start; + flex-wrap: wrap; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.e06-net-slot { + flex: 1 1 360px; + max-width: 560px; +} + +.e06-net { + display: block; + width: 100%; + height: auto; + color: var(--color-text); + font-size: 11px; +} + +.e06-net__rim { + fill: var(--color-surface); + stroke: var(--color-text); + stroke-width: 1.4; +} + +.e06-net__zone { + stroke: none; + opacity: 0.28; +} + +.e06-net__zone--planar { + fill: var(--e06-planar); +} + +.e06-net__zone--wedge { + fill: var(--e06-wedge); +} + +.e06-net__zone--toppling { + fill: var(--e06-toppling); +} + +.e06-net__friction { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1; + stroke-dasharray: 5 3; +} + +.e06-net__friction--line { + stroke-dasharray: 2 3; +} + +.e06-net__envelope { + fill: none; + stroke: var(--e06-planar); + stroke-width: 1; + stroke-dasharray: 6 3; +} + +.e06-net__gc { + fill: none; + stroke: currentColor; + stroke-width: 1.2; +} + +.e06-net__slope { + fill: none; + stroke: var(--color-text); + stroke-width: 2.6; +} + +.e06-net__slope-pole { + fill: var(--color-text); +} + +.e06-net__pole circle { + fill: currentColor; + stroke: var(--color-surface); + stroke-width: 1; +} + +.e06-net__pole.is-risk circle, +.e06-net__line.is-risk path { + stroke: var(--e06-ng); + stroke-width: 2.4; +} + +.e06-net__pole text, +.e06-net__line text, +.e06-net__dir { + fill: var(--color-text); +} + +.e06-net__line path { + fill: var(--e06-wedge); + stroke: var(--color-surface); +} + +.e06-net__tick { + stroke: var(--color-text-secondary); +} + +.e06-tone-0 { + color: #2f6fd6; +} +.e06-tone-1 { + color: #1f9d6a; +} +.e06-tone-2 { + color: #c2410c; +} +.e06-tone-3 { + color: #7c4dcc; +} +.e06-tone-4 { + color: #b8860b; +} +.e06-tone-5 { + color: #0e7490; +} + +.e06-legend { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--color-text); +} + +.e06-legend__row { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.e06-legend__mark { + display: inline-block; + width: 16px; + height: 10px; + border-radius: 2px; +} + +.e06-legend__mark--slope { + height: 3px; + background: var(--color-text); +} +.e06-legend__mark--friction { + height: 0; + border-top: 1px dashed var(--color-text-secondary); +} +.e06-legend__mark--daylight { + height: 0; + border-top: 1px dashed var(--e06-planar); +} +.e06-legend__mark--planar { + background: color-mix(in srgb, var(--e06-planar) 35%, transparent); +} +.e06-legend__mark--wedge { + background: color-mix(in srgb, var(--e06-wedge) 35%, transparent); +} +.e06-legend__mark--toppling { + background: color-mix(in srgb, var(--e06-toppling) 35%, transparent); +} +.e06-legend__mark--line { + width: 10px; + background: var(--e06-wedge); + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} +.e06-legend__mark--pole { + width: 10px; + border-radius: 50%; + background: #2f6fd6; +} + +/* ---- 위험 띠 ---- */ +.e06-band { + display: flex; + flex-direction: column; + gap: 4px; +} + +.e06-band__legend { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + color: var(--color-text); +} + +.e06-band__count { + margin-right: 6px; + color: var(--color-text-secondary); +} + +.e06-band__strip { + position: relative; + height: 18px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e06-band__seg { + position: absolute; + top: 0; + bottom: 0; + padding: 0; + border: none; + cursor: pointer; +} + +.e06-band__seg--ng { + background: var(--e06-ng); +} +.e06-band__seg--ok { + background: var(--e06-ok); +} +.e06-band__seg--check { + background: var(--e06-check); +} +.e06-band__seg--none { + background: var(--e06-none); +} + +.e06-band__seg.is-active { + outline: 2px solid var(--color-text); + outline-offset: -2px; + z-index: 1; +} + +.e06-band__ticks { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--color-text-secondary); +} + +/* ---- 우측 ---- */ +.e06-side { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.e06-list-slot { + max-height: 46vh; + overflow: auto; +} + +.e06-list, +.e06-cases { + width: 100%; + border-collapse: collapse; + font-size: 12px; + color: var(--color-text); +} + +.e06-list th, +.e06-cases th { + position: sticky; + top: 0; + padding: 3px 4px; + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + text-align: left; +} + +.e06-list td, +.e06-cases td { + padding: 3px 4px; + border-top: 1px solid var(--color-border); +} + +.e06-list tbody tr { + cursor: pointer; +} + +.e06-list tbody tr:hover { + background: var(--color-surface-raised); +} + +.e06-list tr.is-active { + background: color-mix(in srgb, var(--color-primary) 16%, transparent); +} + +.e06-detail h4 { + margin: 0 0 4px; + color: var(--color-text); +} + +.e06-verdict { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.e06-verdict::before { + content: ""; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--e06-none); +} + +.e06-verdict--ng::before { + background: var(--e06-ng); + border-radius: 2px; + transform: rotate(45deg); +} +.e06-verdict--ok::before { + background: var(--e06-ok); +} +.e06-verdict--check::before { + background: var(--e06-check); + border-radius: 0; + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} diff --git a/E06_StabRock/draft/E06_StabRock_Route.ts b/E06_StabRock/draft/E06_StabRock_Route.ts new file mode 100644 index 000000000..627bed897 --- /dev/null +++ b/E06_StabRock/draft/E06_StabRock_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E06_StabRock_Route.ts + * 길 선언(PLAN 536-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E06 암반 평사투영 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E06_STAB_ROCK, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E06_StabRock_UI_Page")).renderE06StabRock, +}; diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py index d4d4e997d..b62289bfa 100644 --- a/common_util/common_util_project_kind.py +++ b/common_util/common_util_project_kind.py @@ -20,7 +20,13 @@ from config.config_db import get_db_pool KIND_STAGE_DIRS = { "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"), - "STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"), + "STABILITY": ( + "E02_StabGround", + "E03_StabSection", + "E04_Stab3D", + "E05_StabReport", + "E06_StabRock", + ), } diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 3c0c4c7d5..b6bb4f882 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -20,11 +20,11 @@ STAGE_KEYS = [ ] -# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05. +# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E06. STAGE_KEYS_BY_KIND = { "ROAD": STAGE_KEYS, "PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"], - "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"], + "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT", "STAB_ROCK"], } diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 9d295ec1c..2b373e02c 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -130,6 +130,7 @@ export const ROUTES = { E03_STAB_SECTION: "e03-stab-section", E04_STAB_3D: "e04-stab-3d", E05_STAB_REPORT: "e05-stab-report", + E06_STAB_ROCK: "e06-stab-rock", } as const; /** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */ @@ -139,12 +140,13 @@ export const PLAN_STEP_ROUTES = [ ROUTES.D04_PLAN_CONFIRM, ] as const; -/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */ +/** E 안정성평가 단계 주소(E02 → E06 · 생성 E01 은 단계 밖) */ export const STAB_STEP_ROUTES = [ ROUTES.E02_STAB_GROUND, ROUTES.E03_STAB_SECTION, ROUTES.E04_STAB_3D, ROUTES.E05_STAB_REPORT, + ROUTES.E06_STAB_ROCK, ] as const; export type RouteKey = keyof typeof ROUTES; diff --git a/config/config_system.py b/config/config_system.py index 2fa29bb33..26c19abdc 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -220,6 +220,7 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset( "E03_StabSection", "E04_Stab3D", "E05_StabReport", + "E06_StabRock", } ) diff --git a/main.py b/main.py index f9d6e072d..0e6c54572 100644 --- a/main.py +++ b/main.py @@ -84,6 +84,7 @@ from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_route from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_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 from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -494,6 +495,7 @@ app.include_router(e02_stab_ground_router, dependencies=protected_with_company) app.include_router(e04_stab_3d_router, dependencies=protected_with_company) app.include_router(e05_stab_report_router, dependencies=protected_with_company) app.include_router(e03_stab_section_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) app.include_router(b05_route_terrain_router, dependencies=protected_with_company) diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index e63553e50..6a912e3f4 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -257,7 +257,7 @@ def test_register_ignores_sent_company(): assert "company_name" not in data -@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)]) +@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 5)]) def test_stage_seed_by_kind(kind, count): log = [] asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind)) diff --git a/resources/tester/test_536_1_e06_rock.py b/resources/tester/test_536_1_e06_rock.py new file mode 100644 index 000000000..35fe8869b --- /dev/null +++ b/resources/tester/test_536_1_e06_rock.py @@ -0,0 +1,426 @@ +"""PLAN 536-1 — E06 암반 평사투영 · Markland · Hoek-Bray 평면 · 쐐기 (요구 345 · 351). + +기준 예제(문헌 값 · Rocscience 검증서가 옮긴 값): +- 평면 = Hoek(Sau Mau Ping, 『Practical Rock Engineering』 7장 · RocPlane 검증 #1) 균열 없음 0.8184254 · + 임계 균열 + 수위 0.9z 1.0654738 / Sharma 외(1995) 윗면 0~30° 무게(RocPlane 검증 #2) +- 쐐기 = Hoek & Bray 쐐기 계수(대칭 쐐기 마찰만 · 해석해) · Kovári & Fritz · Kumsar 외(2000) 실험 · + Priest(1993) 두 예제(SWedge 검증 #1~#7 값) +- Markland 경계 · 투영 왕복 · 위험 영역 = 판정 부등식 +""" + +import math + +import numpy as np +import pytest + +from E06_StabRock import E06_StabRock_Engine as eng +from E06_StabRock import E06_StabRock_Model as model +from E06_StabRock import E06_StabRock_Stereonet as net + +TOL = 0.02 # 완료 기준 ±2 % + + +def close(value, expect, tol=TOL): + assert value == pytest.approx(expect, rel=tol), (value, expect) + + +# ---- 평면 (Hoek-Bray) ------------------------------------------------------------- +SAU_MAU = dict( + H=60, slope_dip=50, plane_dip=35, c=100, phi=35, gamma=27, kh=0.08, gamma_w=10 +) # MN → kN · 0.1 MPa = 100 kPa + + +def test_plane_hoek_no_crack(): + r = eng.plane_fs(**SAU_MAU, water_fill=1.0) + close(r["F"], 0.8184254, 1e-4) + close(r["W"], 28627.8, 1e-4) + close(r["A"], 104.6068, 1e-5) + close(r["U"], 15691.0, 1e-4) + + +def test_plane_hoek_tension_crack(): + z = eng.plane_critical_depth_hoek(60, 50, 35) + close(z, 14.0092, 1e-5) + r = eng.plane_fs(**SAU_MAU, water_fill=0.9, crack={"mode": "depth", "z": z}) + close(r["F"], 1.0654738, 1e-4) + close(r["W"], 24843.9, 1e-4) + close(r["A"], 80.1826, 1e-5) + close(r["crack_b"], 15.3357, 1e-4) # 마루 뒤 거리(RocPlane 검증 #1) + + +def test_plane_critical_crack_search_matches_hoek(): + """건조 · 윗면 수평 — 수치 탐색 임계 자리 = Hoek-Bray 식 z · b.""" + r = eng.plane_fs( + H=60, slope_dip=50, plane_dip=35, c=20, phi=35, gamma=27, crack={"mode": "critical"} + ) + close(r["z"], eng.plane_critical_depth_hoek(60, 50, 35), 1e-4) + close(r["crack_b"], 15.3357, 1e-4) + + +@pytest.mark.parametrize( + "upper,weight,fs,fill", + [ + (0, 2392.38, 1.58612, 1.00), + (10, 2446.29, 1.58148, 0.84), + (20, 2503.66, 1.58382, 0.71), + (30, 2568.90, 1.57849, 0.61), + ], +) +def test_plane_upper_slope_sharma(upper, weight, fs, fill): + """윗면 기운 사면 · 연직 균열 마루 뒤 15.336 m · 균열 수위(RocPlane 정수 %).""" + kw = dict( + H=60, slope_dip=50, plane_dip=35, c=12, phi=45, gamma=2.6, gamma_w=1.0, upper_dip=upper + ) + r = eng.plane_fs(**kw, crack={"mode": "x", "b": 15.33576}, water_fill=fill) + close(r["W"], weight, 1e-4) + close(r["F"], fs) + + +def test_plane_bolt_vector(): + """수동 볼트 — 저항 += T cos(θ+ψp) + T sin(θ+ψp) tanφ (힘 벡터 풀이).""" + base = dict(H=10, slope_dip=60, plane_dip=35, c=10, phi=30, gamma=25) + r0 = eng.plane_fs(**base) + r1 = eng.plane_fs(**base, bolt_t=100, bolt_plunge=15) + a = math.radians(50) + expect = ( + r0["resist"] + 100 * math.cos(a) + 100 * math.sin(a) * math.tan(math.radians(30)) + ) / r0["drive"] + close(r1["F"], expect, 1e-9) + + +def test_plane_not_formed(): + assert eng.plane_fs(H=10, slope_dip=40, plane_dip=45, c=0, phi=30, gamma=25) is None + # 윗면이 활동면보다 가파르면 덩이 안 생김(균열 없음) + assert ( + eng.plane_fs(H=10, slope_dip=60, plane_dip=30, c=0, phi=20, gamma=25, upper_dip=35) is None + ) + + +# ---- 쐐기 (Hoek-Bray · SWedge 꼴) ------------------------------------------------ +FLAT = {"dip_direction": 180, "dip": 0} + + +def _wedge(j1, j2, slope, phi, H=10, upper=FLAT, **kw): + return eng.wedge_fs( + j1={"dip_direction": j1[0], "dip": j1[1], "c": 0, "phi": phi}, + j2={"dip_direction": j2[0], "dip": j2[1], "c": 0, "phi": phi}, + slope={"dip_direction": slope[0], "dip": slope[1]}, + upper=upper, + H=H, + gamma=26, + **kw, + ) + + +@pytest.mark.parametrize( + "j1,j2", [((141, 45), (219, 45)), ((119, 50), (241, 50)), ((150, 60), (230, 55))] +) +def test_wedge_hoek_bray_wedge_factor(j1, j2): + """마찰만 · 같은 φ — Hoek & Bray FS = K tanφ / tanψi · K = sinβ / sin(ξ/2) + (ξ = 두 면 사이 각(교선에 수직 단면) · β = 그 단면에서 쐐기 이등분선의 기울기).""" + phi = 35 + r = _wedge(j1, j2, (180, 70), phi) + n1 = eng.plane_normal(*j1) + n2 = eng.plane_normal(*j2) + i = eng.line_vector(*r["line"]) + # 교선에 수직인 면 안에서 두 면 안쪽 법선(위쪽) 사이 각 → 쐐기 사이 각 ξ = 180 − 그 각 + xi = math.pi - math.acos(float(n1 @ n2)) + bis = (n1 + n2) / np.linalg.norm(n1 + n2) # 쐐기 이등분선(교선에 수직 단면 안) + h = np.cross(i, [0.0, 0.0, 1.0]) + h /= np.linalg.norm(h) # 그 단면의 수평 + beta = math.acos(abs(float(bis @ h))) + K = math.sin(beta) / math.sin(xi / 2) + expect = K * math.tan(math.radians(phi)) / math.tan(math.radians(r["line"][1])) + close(r["F"], expect, 1e-3) + + +@pytest.mark.parametrize( + "j1,j2,slope,phi,plunge,fs", + [ + ((141, 45), (219, 45), (180, 70), 35, 37.85, 1.0), # Kovári & Fritz · SWedge #1 + ((119, 50), (241, 50), (180, 70), 35, 30.02, 1.6325), # SWedge #2 + ((195, 45), (105, 70), (160, 70), 30, None, 0.73), # Kumsar Case 3(Ankara) · SWedge 0.7123 + ((33.5, 75), (248, 75), (337.5, 75), 40.8, None, 2.02), # Kumsar Case 4(Dinar) + ], +) +def test_wedge_kumsar_kovari(j1, j2, slope, phi, plunge, fs): + r = _wedge(j1, j2, slope, phi) + assert r["mode"] == "12" + close(r["F"], fs, 0.03 if fs == 0.73 else 0.01) # 실험값 0.73 = 판독(SWedge 0.7123) + if plunge: + close(r["line"][1], plunge, 1e-3) + + +def test_wedge_kumsar_seismic(): + r = _wedge((33.5, 75), (248, 75), (337.5, 75), 40.8, kh=0.3303) + close(r["F"], 0.99) # Kumsar 0.99 · SWedge 0.9872 + + +def test_wedge_priest_double_plane(): + """Priest(1993) 예제 1 — 두 면 미끄럼 · 수압 5 · 15 kPa · 철탑 180 kN(168/70).""" + r = eng.wedge_fs( + j1={"dip_direction": 203, "dip": 47, "c": 10, "phi": 40}, + j2={"dip_direction": 287, "dip": 52, "c": 20, "phi": 35}, + slope={"dip_direction": 230, "dip": 60}, + upper={"dip_direction": 225, "dip": 5}, + H=6.7978, + gamma=26, + water={"pressure": [5, 15]}, + external=[{"force": 180, "trend": 168, "plunge": 70}], + ) + assert r["mode"] == "12" + close(r["F"], 1.4966, 1e-3) # Priest ≈ 1.5 + close(r["volume"], 45.201, 1e-3) + close(r["areas"][0], 41.147, 1e-3) + close(r["areas"][1], 20.428, 1e-3) + close(r["face_area"], 38.955, 1e-3) + close(r["N"][0], 407, 3e-3) + close(r["N"][1], 251, 3e-3) + + +def test_wedge_priest_single_plane(): + """Priest(1993) 예제 2 — 면 2 하나로 미끄럼(면 1 떨어짐) · 수압 25 · 15 kPa.""" + r = eng.wedge_fs( + j1={"dip_direction": 65, "dip": 74, "c": 15, "phi": 32}, + j2={"dip_direction": 186, "dip": 41, "c": 5, "phi": 40}, + slope={"dip_direction": 134, "dip": 65}, + upper={"dip_direction": 122, "dip": 11}, + H=6.8471, + gamma=25, + water={"pressure": [25, 15]}, + ) + assert r["mode"] == "2" + close(r["F"], 0.8493, 1e-3) # Priest ≈ 0.86 · SWedge 0.8493 + close(r["volume"], 81.741, 1e-3) + assert r["N"][0] == 0.0 + + +def test_wedge_gravity_water_and_bolt(): + """중력 수압 U = p³ γw Hw a / 6(SWedge · Hoek-Bray) · 볼트는 안전율을 낮추지 않음.""" + dry = _wedge((141, 45), (219, 45), (180, 70), 35) + wet = _wedge((141, 45), (219, 45), (180, 70), 35, water={"fill": 1.0}) + expect = eng.GAMMA_W * wet["Hw"] / 6 * wet["areas"][0] + close(wet["water_force"][0], expect, 1e-9) + assert wet["F"] < dry["F"] + bolted = _wedge( + (141, 45), (219, 45), (180, 70), 35, bolt={"force": 50, "trend": 0, "plunge": 15} + ) + assert bolted["F"] > dry["F"] + + +def test_wedge_not_formed(): + # 교선이 사면보다 가파름 → 사면으로 안 나옴 + assert _wedge((141, 75), (219, 75), (180, 50), 35) is None + + +# ---- Markland 경계 ------------------------------------------------------------- +SLOPE = {"dip_direction": 90.0, "dip": 70.0} + + +def _j(dd, dip, phi=30.0, jid="J"): + return {"id": jid, "dip_direction": dd, "dip": dip, "phi": phi} + + +@pytest.mark.parametrize( + "dd,dip,hit", + [ + (109.9, 45, True), + (110.1, 45, False), + (70.1, 45, True), + (90, 30.1, True), + (90, 29.9, False), + (90, 69.9, True), + (90, 70.1, False), + ], +) +def test_markland_planar(dd, dip, hit): + assert (eng.kinematic(SLOPE, [_j(dd, dip)])["planar"] == ["J"]) is hit + + +def test_markland_planar_apparent_daylight(): + """측방 15° — 겉보기 사면 경사(tanψf cos15°)를 넘는 절리는 안 나옴.""" + app = eng.apparent_dip(70, 90, 105) + assert eng.kinematic(SLOPE, [_j(105, app - 0.1)])["planar"] == ["J"] + assert eng.kinematic(SLOPE, [_j(105, app + 0.1)])["planar"] == [] + + +@pytest.mark.parametrize( + "dd,dip,hit", [(270, 50.1, True), (270, 49.9, False), (289.9, 80, True), (290.1, 80, False)] +) +def test_markland_toppling(dd, dip, hit): + """ψp > 90 − ψf + φ = 50 · 역방향 ±20.""" + assert (eng.kinematic(SLOPE, [_j(dd, dip)])["toppling"] == ["J"]) is hit + + +def test_markland_wedge_plunge_bounds(): + a, b = _j(50, 60, jid="A"), _j(130, 60, jid="B") + trend, plunge = eng.intersection(a, b) + assert trend == pytest.approx(90.0, abs=1e-6) + hit = eng.kinematic(SLOPE, [a, b])["wedge"] + assert hit and hit[0]["ids"] == ["A", "B"] + # φ 를 선주각 위로 올리면 사라짐 + hi = [dict(a, phi=plunge + 0.1), dict(b, phi=plunge + 0.1)] + assert eng.kinematic(SLOPE, hi)["wedge"] == [] + # 사면을 교선보다 완만하게 하면 안 나옴 + assert eng.kinematic({"dip_direction": 90, "dip": plunge - 0.1}, [a, b])["wedge"] == [] + + +# ---- 투영 ---------------------------------------------------------------------- +@pytest.mark.parametrize("kind", net.PROJECTIONS) +def test_projection_round_trip(kind): + rng = np.random.default_rng(536) + for trend, plunge in zip(rng.uniform(0, 360, 200), rng.uniform(0, 89.9, 200)): + x, y = net.project(trend, plunge, kind) + t2, p2 = net.inverse(x, y, kind) + assert p2 == pytest.approx(plunge, abs=2e-3) + # 좌표는 소수 다섯째 자리 — 중심 가까이는 추향 오차가 1e-5 / r rad 까지 + assert eng.ang_diff(t2, trend) <= math.degrees(1e-5 / math.hypot(x, y)) + 1e-6 + + +def test_projection_known_radii(): + assert net.radius(0, "equal_angle") == pytest.approx(1.0) + assert net.radius(0, "equal_area") == pytest.approx(1.0) + assert net.radius(90, "equal_angle") == pytest.approx(0.0) + assert net.radius(45, "equal_angle") == pytest.approx(math.tan(math.radians(22.5))) + assert net.radius(45, "equal_area") == pytest.approx( + math.sqrt(2) * math.sin(math.radians(22.5)) + ) + # 동쪽 수평선 = (1, 0) · 북쪽 = (0, 1) + assert net.project(90, 0, "equal_angle") == pytest.approx([1.0, 0.0], abs=1e-5) + assert net.project(0, 0, "equal_area") == pytest.approx([0.0, 1.0], abs=1e-5) + + +def _inside(pt, poly): + x, y = pt + hit = False + for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]): + if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1): + hit = not hit + return hit + + +@pytest.mark.parametrize("kind", net.PROJECTIONS) +def test_zones_match_kinematic(kind): + """위험 영역 다각형 안 극점 · 교선 = Markland 판정(경계 1° 띠 밖 표본).""" + rng = np.random.default_rng(7) + phi = 30.0 + data = net.stereonet(SLOPE, [], phi, kind) + checked = 0 + for dd, dip in zip(rng.uniform(0, 360, 1500), rng.uniform(1, 89, 1500)): + kin = eng.kinematic(SLOPE, [_j(dd, dip, phi)]) + near = ( + abs(eng.ang_diff(dd, 90) - 20) < 1 + or abs(eng.ang_diff(dd, 270) - 20) < 1 + or abs(dip - phi) < 1 + or abs(dip - eng.apparent_dip(70, 90, dd)) < 1 + or abs(dip - 50) < 1 + ) + if near: + continue + pole = net.project(*eng.pole(dd, dip), kind) + assert _inside(pole, data["zones"]["planar"]) == bool(kin["planar"]), (dd, dip) + assert _inside(pole, data["zones"]["toppling"]) == bool(kin["toppling"]), (dd, dip) + checked += 1 + assert checked > 1000 + + +# ---- 정본 · 측점 사면 ------------------------------------------------------------ +def _doc(): + doc = model.build_initial() + doc["sets"] = [ + { + "id": "J1", + "name": "J1", + "from_m": 0, + "to_m": 100, + "dip_direction": 90, + "dip": 45, + "phi": 30, + "c": 5, + "persistence_m": None, + } + ] + return doc + + +def test_check_and_clean(): + assert model.check_joints(_doc()) == [] + bad = _doc() + bad["sets"][0].update(dip=95, to_m=-1, name=" ") + bad["sets"].append(dict(bad["sets"][0])) + bad["settings"]["crack"] = {"mode": "depth", "z": None} + paths = {e["path"] for e in model.check_joints(bad)} + assert {"sets.0.dip", "sets.0.to_m", "sets.0.name", "sets.1.id", "settings.crack.z"} <= paths + clean = model.clean(dict(_doc(), extra=1)) + assert set(clean) == {"version", "sets", "settings"} + assert clean["sets"][0]["dip"] == 45.0 + + +def _cross(side_cut: str): + """좌(+offset) 또는 우 깎기 — 노선 북쪽 진행(left = 서쪽 −x) · 원지반 깎기 쪽으로 0.6 오름 · + 노면 99 · 깎기 1:0.4 · 쌓기 1:1.5.""" + sign = 1.0 if side_cut == "left" else -1.0 + offs = np.arange(-20, 20.5, 0.5) + ground = [[float(o), 100 + 0.6 * sign * o] for o in offs] + design = [] + for o, z in ground: + d = sign * o + if abs(o) <= 2: + z = 99.0 + elif d > 2: + z = min(z, 99 + 2.5 * (d - 2)) + else: + z = max(z, 99 - (abs(d) - 2) / 1.5) + design.append([o, z]) + return ( + { + "frame": {"left_xy": [-1.0, 0.0]}, + "samples": [{"offset_m": o, "elevation_m": z} for o, z in ground], + }, + { + "section_mode": f"{side_cut}_cut", + "cut_slope_ratio": 0.4, + "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, + "design_line": [{"offset_m": o, "elevation_m": z} for o, z in design], + }, + ) + + +@pytest.mark.parametrize("side,dd", [("left", 90.0), ("right", 270.0)]) +def test_station_slope_orientation(side, dd): + """좌 = 서쪽 산 · 사면이 동(노면)으로 기욺 = 경사방향 90 · 우는 270 · 경사 = atan(1/0.4).""" + cross, design = _cross(side) + slopes = model.station_slopes(cross, design) + assert len(slopes) == 1 and slopes[0]["side"] == side + assert slopes[0]["dip_direction"] == pytest.approx(dd) + assert slopes[0]["dip"] == pytest.approx(math.degrees(math.atan(2.5)), abs=0.01) + assert slopes[0]["height_m"] > 1 + assert slopes[0]["upper_dip"] == pytest.approx(math.degrees(math.atan(0.6)), abs=0.5) + + +def test_analyze_station(monkeypatch): + cross, design = _cross("left") + monkeypatch.setattr( + model, + "rock_stations", + lambda _s: [{"chainage_m": 20.0, "label": "STA", "ground_type": "ripping_rock"}], + ) + monkeypatch.setattr(model.src, "cross_section", lambda _s, _c: {**cross, "design": design}) + ground = { + "materials": [], + "stations": [], + "safety_factors": {"dry": {"cut": 1.5}, "wet": {"cut": 1.2}}, + "seismic": {"enabled": False}, + } + out = model.analyze(None, _doc(), ground) + assert len(out) == 1 + e = out[0] + assert e["kinematic"]["planar"] == ["J1"] + assert {c["condition"] for c in e["cases"]} == {"dry", "wet"} + dry = next(c for c in e["cases"] if c["condition"] == "dry") + assert dry["required"] == 1.5 and dry["ok"] == (dry["F"] >= 1.5) + assert e["verdict"] in ("OK", "NG") + assert e["gamma_r"] == model.DEFAULT_GAMMA_R + nets = model.station_net(e, _doc()["settings"]) + assert set(nets) == {"equal_angle", "equal_area", "phi"} + assert nets["equal_area"]["zones"]["planar"] diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a5309b16a..7972847af 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -35,6 +35,7 @@ import { ui_locales_m2 } from "./ui_template_locale_m2"; import { ui_locales_de } from "./ui_template_locale_de"; import { ui_locales_e } from "./ui_template_locale_e"; import { ui_locales_e05 } from "./ui_template_locale_e05"; +import { ui_locales_e06 } from "./ui_template_locale_e06"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -77,6 +78,7 @@ export const ui_locales = { ...ui_locales_de, ...ui_locales_e, ...ui_locales_e05, + ...ui_locales_e06, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index a08abd22d..03c017e1e 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -17,6 +17,7 @@ export const ui_locales_de = { DE_Step_StabSection: ["2D 해석", "2D Analysis"], DE_Step_Stab3D: ["3D 해석", "3D Analysis"], DE_Step_StabReport: ["보고서", "Report"], + DE_Step_StabRock: ["암반 해석", "Rock Analysis"], DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."], /* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */ diff --git a/ui_template/ui_template_locale_e06.ts b/ui_template/ui_template_locale_e06.ts new file mode 100644 index 000000000..e604dca01 --- /dev/null +++ b/ui_template/ui_template_locale_e06.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * ui_template_locale_e06.ts + * 다국어 사전 — E06 암반 평사투영 해석(요구 345 · 351 · PLAN 536-1) + * + * 신규 문구는 해당 섹션 최하단에 추가. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_e06 = { + /* --- E06 좌측 --- */ + E06_Note: [ + "암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [저장] → [해석]. 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.", + "Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, [Save] → [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).", + ], + E06_Card_Joints: ["절리군", "Joint sets"], + E06_JointsHint: [ + "현장 조사값 — 경사방향 · 경사(°) · 절리면 φj(°) · cj(kPa) · 지속성(m · 비우면 무한). 구간(m) 안 측점에만 씁니다.", + "Field data — dip direction · dip (°) · joint φj (°) · cj (kPa) · persistence (m, blank = unlimited). Applies to stations within the range (m).", + ], + E06_Col_Name: ["이름", "Name"], + E06_Col_From: ["시작", "From"], + E06_Col_To: ["끝", "To"], + E06_Col_DipDir: ["경사방향", "Dip dir."], + E06_Col_Dip: ["경사", "Dip"], + E06_Col_Phi: ["φj", "φj"], + E06_Col_C: ["cj", "cj"], + E06_Col_Persist: ["지속성", "Persist."], + E06_Add: ["+ 절리군", "+ Joint set"], + E06_Remove: ["지움", "Remove"], + E06_NoSets: [ + "절리군이 없습니다 — [+ 절리군] 으로 넣으세요.", + "No joint sets — add one with [+ Joint set].", + ], + E06_Card_Settings: ["해석 설정", "Analysis settings"], + E06_Set_LateralPlanar: ["평면 측방 한계 (°)", "Planar lateral limit (°)"], + E06_Set_LateralTopple: ["전도 측방 한계 (°)", "Toppling lateral limit (°)"], + E06_Set_Gamma: ["암 단위중량 (kN/m³ · 비우면 E02)", "Rock unit weight (kN/m³, blank = E02)"], + E06_Set_WaterWet: ["우기 수위 비 (0~1)", "Wet water ratio (0–1)"], + E06_Set_Crack: ["인장균열", "Tension crack"], + E06_Crack_None: ["없음", "None"], + E06_Crack_Critical: ["임계(최소 안전율 자리)", "Critical (minimum FS)"], + E06_Crack_Depth: ["깊이 지정", "Given depth"], + E06_Set_CrackZ: ["균열 깊이 (m)", "Crack depth (m)"], + E06_Set_BoltForce: ["볼트 힘 (평면 kN/m · 쐐기 kN)", "Bolt force (plane kN/m · wedge kN)"], + E06_Set_BoltPlunge: ["볼트 경사 (수평 아래 °)", "Bolt plunge (below horizontal °)"], + E06_SettingsHint: [ + "측방 한계 기본 20° (Dips 기본 · 지식DB 03 전도는 30°) · 우기 = 비탈 높이 ½ 수위(KDS 11 70 05) · 볼트 = 수동 지지력.", + "Lateral limit default 20° (Dips default; knowledge DB uses 30° for toppling) · wet = water at half height (KDS 11 70 05) · bolts = passive support.", + ], + E06_Analyze: ["해석", "Analyze"], + E06_Running: ["해석 중…", "Analyzing…"], + E06_Done: ["암반 해석을 마쳤습니다.", "Rock analysis done."], + E06_Fresh: ["입력이 같아 지난 결과를 씁니다.", "Inputs unchanged — previous result kept."], + E06_Saved: ["저장했습니다.", "Saved."], + E06_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E06_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], + E06_Invalid: ["틀린 칸", "Invalid fields"], + E06_Err_Range: ["범위 밖", "Out of range"], + E06_ResetConfirm: [ + "절리군 · 설정을 초기값(절리 없음)으로 되돌릴까요?", + "Reset joint sets and settings to the initial state (no joints)?", + ], + E06_ResetDone: ["초기값으로 되돌렸습니다.", "Reset to initial values."], + E06_NoProject: ["열린 프로젝트가 없습니다.", "No project is open."], + + /* --- E06 가운데 · 우측 --- */ + E06_Stale: [ + "절리 · 지반 조건이 해석 뒤 바뀌었습니다 — [해석] 을 다시 누르세요.", + "Joints or ground changed since the analysis — press [Analyze] again.", + ], + E06_Band: ["위험 띠", "Risk band"], + E06_View_All: ["전체", "All"], + E06_Mode_planar: ["평면", "Planar"], + E06_Mode_wedge: ["쐐기", "Wedge"], + E06_Mode_toppling: ["전도", "Toppling"], + E06_Proj_Angle: ["등각", "Equal angle"], + E06_Proj_Area: ["등면적", "Equal area"], + E06_PickStation: ["측점을 고르세요.", "Pick a station."], + E06_Lg_Slope: ["사면 대원 · 극점", "Slope plane · pole"], + E06_Lg_Friction: ["마찰원 φ", "Friction circle φ"], + E06_Lg_Planar: ["평면 위험(극점)", "Planar zone (poles)"], + E06_Lg_Daylight: ["일광 엔벨로프", "Daylight envelope"], + E06_Lg_Wedge: ["쐐기 위험(교선)", "Wedge zone (intersections)"], + E06_Lg_Line: ["교선", "Intersection"], + E06_Lg_Toppling: ["전도 위험(극점)", "Toppling zone (poles)"], + E06_Lg_Pole: ["절리 극점", "Joint pole"], + E06_Result_Title: ["측점 결과", "Station results"], + E06_NoResult: ["해석 결과가 없습니다 — [해석] 을 누르세요.", "No results — press [Analyze]."], + E06_NoRock: ["암 측점 깎기 비탈이 없습니다.", "No rock cut slopes."], + E06_L_Station: ["측점", "Station"], + E06_L_Side: ["쪽", "Side"], + E06_L_Modes: ["평 · 쐐 · 전", "P · W · T"], + E06_L_MinF: ["최소 Fs", "Min Fs"], + E06_L_Verdict: ["판정", "Verdict"], + E06_Left: ["좌", "L"], + E06_Right: ["우", "R"], + E06_V_NG: ["N.G", "N.G"], + E06_V_check: ["검토", "Check"], + E06_V_OK: ["O.K", "O.K"], + E06_V_none: ["위험 없음", "No risk"], + E06_SlopeLine: [ + "사면 {dd}° / {dip}° · 높이 {h} m · 윗면 {up}° · 암 γ {g} kN/m³", + "Slope {dd}° / {dip}° · height {h} m · upper {up}° · rock γ {g} kN/m³", + ], + E06_NoRisk: ["위험 모드 없음(Markland).", "No kinematic failure mode (Markland)."], + E06_NoJointsHere: ["이 측점 구간에 절리군이 없습니다.", "No joint sets cover this station."], + E06_C_Mode: ["모드", "Mode"], + E06_C_Joints: ["절리", "Joints"], + E06_C_Cond: ["조건", "Case"], + E06_C_F: ["Fs", "Fs"], + E06_C_Req: ["기준", "Req."], + E06_Cond_dry: ["건기", "Dry"], + E06_Cond_wet: ["우기", "Wet"], + E06_Cond_seismic: ["지진", "Seismic"], + E06_NotFormed: ["(블록 안 생김)", "(no block)"], + E06_Persist: ["(지속성 < 활동면)", "(persistence < plane)"], + E06_ToppleNote: [ + "전도 = 기하 판정만(블록 전도 안전율은 Goodman-Bray 정밀 검토 몫).", + "Toppling = kinematic only (block toppling FS needs a Goodman–Bray check).", + ], +} as const satisfies Record;