Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5

This commit is contained in:
2026-10-26 03:20:40 +09:00
26 changed files with 3494 additions and 18 deletions
+8 -2
View File
@@ -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: ["🪨", "📐", "🧊", "📄", "⛰️"],
},
];
+13 -2
View File
@@ -48,7 +48,7 @@ LOWEST_N = 5 # representative.json 이 없을 때 — 최소 안전율 하위 N
CASES = ("dry", "wet", "seismic")
SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
METHOD_NAMES = {
"bishop": "Bishop 간편법",
"bishop": "Bishop",
"janbu": "Janbu 보정",
"janbu_corrected": "Janbu 보정",
"ordinary": "Fellenius",
@@ -589,5 +589,16 @@ def sheet_sign(sheet: dict[str, Any]) -> str:
"행": {k: v for k, v in (sheet.get("행") or {}).items() if v},
"열": {k: v for k, v in (sheet.get("열") or {}).items() if v},
}
text = json.dumps(body, ensure_ascii=False, sort_keys=True)
text = json.dumps(_numbers(body), ensure_ascii=False, sort_keys=True)
return hashlib.sha1(text.encode("utf-8")).hexdigest()
def _numbers(value: Any) -> Any:
"""수는 float 로 — 화면(JS)을 거치면 10.0 이 10 으로 옴(같은 값을 다르다고 보지 않게)."""
if isinstance(value, dict):
return {k: _numbers(v) for k, v in value.items()}
if isinstance(value, list):
return [_numbers(v) for v in value]
if isinstance(value, int | float) and not isinstance(value, bool):
return round(float(value), 9)
return value
+5 -2
View File
@@ -21,6 +21,7 @@ BAND_W, BAND_H = 684, 320
OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222"
CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"}
CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
SLIP_DASH = {"wet": "7 4", "seismic": "2 3"} # 건기 · 우기 임계면이 겹쳐도 둘 다 보이게
FONT = "font-family='Malgun Gothic, sans-serif'"
@@ -155,7 +156,9 @@ def section_svg(
# 임계 활동면
for slip in slips:
color = CONDITION_COLORS.get(slip["condition"], NG)
out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2"))
dash = SLIP_DASH.get(slip["condition"])
extra = {"stroke_dasharray": dash} if dash else {}
out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2", **extra))
# 치수 — 노폭 · 비탈 높이
if road:
r_off, l_off = road
@@ -206,7 +209,7 @@ def section_svg(
(
CONDITION_COLORS.get(slip["condition"], NG),
f"{name} 임계면 Fs {f:.2f}" if f else name,
"",
SLIP_DASH.get(slip["condition"], ""),
)
)
if wet:
+3 -3
View File
@@ -137,7 +137,7 @@ async def put_report(project_id: UUID, payload: SaveRequest) -> dict[str, Any]:
status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요."
)
try:
await asyncio.to_thread(build.save, root, payload.book)
await asyncio.to_thread(store.save, root, payload.book)
except RecalcError as error:
raise _recalc_errors(error) from error
return {"status": "success", **await asyncio.to_thread(_state, root)}
@@ -153,7 +153,7 @@ async def refresh_report(project_id: UUID, payload: RevRequest) -> dict[str, Any
status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요."
)
try:
result = await asyncio.to_thread(build.refresh, root)
result = await asyncio.to_thread(store.refresh, root)
except build.MissingInput as error:
raise HTTPException(status_code=409, detail=str(error)) from error
except (RecalcError, PrintError) as error:
@@ -169,7 +169,7 @@ async def refresh_report(project_id: UUID, payload: RevRequest) -> dict[str, Any
async def reset_report(project_id: UUID) -> dict[str, Any]:
root, _ = await _project(project_id)
async with _lock(project_id):
if not await asyncio.to_thread(build.reset, root):
if not await asyncio.to_thread(store.reset, root):
raise HTTPException(status_code=404, detail="초기값이 없습니다.")
return {"status": "success", **await asyncio.to_thread(_state, root)}
+1 -1
View File
@@ -6,7 +6,7 @@
.e05-page {
display: flex;
flex-direction: column;
height: 100%;
height: calc(100dvh - var(--app-header-h)); /* 본문 칸은 min-height 뿐 — 100% 가 안 풀림 */
min-height: 0;
box-sizing: border-box;
}
+444
View File
@@ -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}
+446
View File
@@ -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}
+204
View File
@@ -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)
+137
View File
@@ -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,
}
+304
View File
@@ -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<string, [number, number]>;
}
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<string, number | null>)[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<T extends string>(
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];
}
+397
View File
@@ -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<Projection, NetData> & { phi: number };
}
const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`;
async function api<T>(path: string, init?: RequestInit): Promise<T> {
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<T extends string>(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<NetMode>(
[
["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<Projection>(
[
["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<void> {
active = id;
const [key, sideName] = id.split("|");
try {
shown = await api<StationResponse>(
`/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<void> {
const body = await api<ResultResponse>(`/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<boolean> {
if (!view?.editable) return false;
if (invalidCount()) {
showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error");
return false;
}
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`, {
method: "PUT",
body: JSON.stringify({ joints: view.doc }),
});
adopt(body, false);
return true;
}
async function save(): Promise<void> {
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<void> {
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<RockResponse>(`/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<void> {
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<RockResponse>(`/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"),
}),
);
}
})();
}
+219
View File
@@ -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<Record<Verdict, number>>;
modes: Record<string, number>;
};
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<number, StationEntry>();
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;
}
+193
View File
@@ -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<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number> = {},
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<NetMode, "all">): 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;
}
+485
View File
@@ -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%);
}
+16
View File
@@ -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,
};
+7 -1
View File
@@ -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",
),
}
+2 -2
View File
@@ -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"],
}
+3 -1
View File
@@ -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;
+1
View File
@@ -220,6 +220,7 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset(
"E03_StabSection",
"E04_Stab3D",
"E05_StabReport",
"E06_StabRock",
}
)
+2
View File
@@ -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)
+1 -1
View File
@@ -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))
+52 -3
View File
@@ -1,7 +1,8 @@
"""534-3(빌더 · storage) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store`.
"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`.
가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로
측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · xlsx 되읽기 · PDF 쪽 수.
가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 ·
복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 ·
덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수.
"""
import io
@@ -294,3 +295,51 @@ def test_단면_그림_SVG():
)
assert svg.startswith("<svg") and "건기 임계면 Fs 1.30" in svg and "노폭 4.00 m" in svg
assert "H 4.00 m" in svg and svg.count("<polyline") >= 4
def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from E05_StabReport import E05_StabReport_Router as router_mod
async def fake_project(_pid):
return project, 3
monkeypatch.setattr(router_mod, "_project", fake_project)
app = FastAPI()
app.include_router(router_mod.router)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3}
client = TestClient(app)
url = "/api/projects/3d3776a1-2973-4385-8dcc-fdc6e49a900e/stab/report"
try:
got = client.get(url).json()
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 6
book = got["book"]
_sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5}
assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409
saved = client.put(url, json={"rev": got["rev"], "book": book}).json()
assert saved["rev"] != got["rev"]
assert client.post(f"{url}/refresh", json={"rev": got["rev"]}).status_code == 409
out = client.post(f"{url}/refresh", json={"rev": saved["rev"]}).json()
assert out["overwritten"] == [] and _sheets(out["book"])["표지"]["칸"]["C10"]["값"] == "산림청"
fig = client.get(f"{url}/fig", params={"key": build.fig_key("grid:3")})
assert fig.status_code == 200 and fig.content[:4] == b"\x89PNG"
assert client.get(f"{url}/fig", params={"key": "../x"}).status_code == 400
x = client.post(f"{url}/xlsx", json={"book": out["book"]})
assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지"
back = client.post(f"{url}/reset").json()
assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {})
def test_서명_화면_왕복_같음(project):
"""JS 를 거친 책(10.0 → 10)도 같은 서명 — 안 고친 시트를 덮임으로 알리지 않음."""
book = build.form()
build.build_program(book, build.gather(project))
s3 = _sheets(book)["입력조건"]
assert s3["칸"]["B22"]["값"] == 10.0
trip = json.loads(json.dumps(s3).replace("10.0", "10"))
assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3)
+426
View File
@@ -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"]
+2
View File
@@ -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;
+1
View File
@@ -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) --- */
+122
View File
@@ -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<string, LocaleEntry>;