"""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_wet_default_kds_half_depth(): """KDS 11 70 05 표 4.3-3 암반 우기 = 균열 · 활동면 높이 ½ 심도 포화 · 기준 1.2.""" import json from pathlib import Path fill = model.DEFAULT_SETTINGS["water_fill_wet"] assert fill == 0.5 z = 10.0 r = eng.plane_fs(**SAU_MAU, water_fill=fill, crack={"mode": "depth", "z": z}) close(r["V"], 0.5 * 10 * (z / 2) ** 2, 1e-9) # 균열 안 수위 = z/2 dry = eng.plane_fs(**SAU_MAU) # 균열 없음 = 활동면 오름 60 · 가운데 수두 = (½ · 60)/2 close(eng.plane_fs(**SAU_MAU, water_fill=fill)["U"], 0.5 * 10 * 15 * dry["A"], 1e-9) root = Path(__file__).resolve().parents[2] ground = json.loads( (root / "resources/master_template/stability/ground_defaults.json").read_text("utf-8") ) assert ground["safety_factors"]["wet"]["cut"] == 1.2 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_fmt_negative_zero(): assert str(model._fmt(-0.0)) == "0.0" # 들림 · 볼트 없음 = 0(「-0.0」 아님) assert model._fmt(math.inf) is None 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"] def test_e05_rock_sheet(monkeypatch): """E05 암반검토 s11 — 조건 글 · 절리군 줄 · 측점 위험 모드 × 조건 줄 · 판정 서식.""" from E05_StabReport import E05_StabReport_Build as build from E05_StabReport import E05_StabReport_Rock as rock from E06_StabRock import E06_StabRock_Router as router 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}, } doc = _doc() stations = model.analyze(None, doc, ground) value = { "fingerprint": "fp", "settings": doc["settings"], "sets": doc["sets"], "summary": router._summary(stations), "stations": stations, "stale": False, } sheet = next(s for s in build.form()["시트"] if s["id"] == "s11") rock.fill_rock(sheet, value, {}) cells = sheet["칸"] assert "기본값 · 사용자 확정 전" in cells["B7"]["값"] and "½" in cells["B7"]["값"] assert cells["B9"]["값"].startswith("깎기 비탈 1") assert cells["A13"]["값"] == doc["sets"][0]["name"] assert sheet["인쇄"]["머리행"] == [16, 16] and cells["A16"]["값"] == "측점" # 절리 한 줄 rows = [r for r in range(17, 40) if cells.get(f"A{r}", {}).get("값") == "STA"] assert len(rows) == len(stations[0]["cases"]) + len(stations[0]["kinematic"]["toppling"]) for r, case in zip(rows, stations[0]["cases"]): assert cells[f"E{r}"]["값"] == pytest.approx(case["F"]) assert cells[f"G{r}"]["값"] == ("O.K" if case["ok"] else "N.G") assert cells[f"G{r}"]["서식"] == (build.ST_OK if case["ok"] else build.ST_NG) old = {k: v for k, v in value.items() if k != "sets"} # 옛 저장본 = 측점에 실린 절리 sheet = next(s for s in build.form()["시트"] if s["id"] == "s11") rock.fill_rock(sheet, old, {}) assert sheet["칸"]["A13"]["값"] == doc["sets"][0]["name"] assert sheet["칸"]["G13"]["값"] == "-" # 구간 모름 # ---- 최소 블록 크기 (요구 351 · 536-1 덧) -------------------------------------------- def _cases_with_volume(monkeypatch, volume: float, min_block: float): """엔진을 가짜 부피로 바꿔 경계(< 최소 블록)만 본다 — 평면(W = γ·면적) · 쐐기 둘.""" gamma = 25.0 monkeypatch.setattr( model.engine, "plane_fs", lambda **_k: {"F": 0.5, "W": volume * gamma, "A": 5.0, "z": 0.0}, ) monkeypatch.setattr( model.engine, "wedge_fs", lambda **_k: {"F": 0.0, "volume": volume, "line_length": 2.0, "mode": "lift", "W": 1.0}, ) joints = [ {"id": "J1", "dip_direction": 90, "dip": 40, "phi": 30, "c": 0}, {"id": "J2", "dip_direction": 200, "dip": 50, "phi": 30, "c": 0}, ] slope = {"dip_direction": 90, "dip": 68, "upper_dip": 0, "height_m": 5} kin = {"planar": ["J1"], "wedge": [{"ids": ["J1", "J2"]}], "toppling": []} settings = dict(model.DEFAULT_SETTINGS, min_block_m3=min_block) return model._cases_for(slope, joints, kin, settings, gamma, [("dry", 0.0, 0.0)]) def test_min_block_default(): assert model.DEFAULT_SETTINGS["min_block_m3"] == 0.1 assert model.upgrade({"sets": [], "settings": {}})["settings"]["min_block_m3"] == 0.1 @pytest.mark.parametrize( "volume,small", [(0.0999, True), (0.1, False), (0.3, False), (0.0003, True)] ) def test_min_block_boundary(monkeypatch, volume, small): """최소 블록 0.1 m³ — 미만만 소규모(경계 = 거르지 않음) · 평면 · 쐐기 같은 규칙.""" cases = _cases_with_volume(monkeypatch, volume, 0.1) assert [c["small"] for c in cases] == [small, small] def test_min_block_zero_keeps_all(monkeypatch): cases = _cases_with_volume(monkeypatch, 0.0003, 0.0) assert not any(c["small"] for c in cases) def test_min_block_verdict_and_counts(monkeypatch): """얇은 쐐기(들림 Fs 0)가 N.G 를 내던 자리 — 거르면 판정 · 최소 Fs 에서 빠지고 거른 블록 수가 남음.""" kin = {"planar": ["J1"], "wedge": [{"ids": ["J1", "J2"]}], "toppling": []} for min_block, verdict, n_small in ((0.0, "NG", 0), (0.1, "none", 2)): cases = _cases_with_volume(monkeypatch, 0.0003, min_block) for c in cases: c["required"] = 1.2 c["ok"] = None if c["small"] else c["F"] >= 1.2 out = model._verdict(kin, cases) assert (out["verdict"], out["small_blocks"]) == (verdict, n_small) assert out["min_F"] == (0.0 if not n_small else None) # 큰 블록 + 작은 블록 섞임 — 작은 것만 빠지고 큰 것으로 판정 big = dict(_cases_with_volume(monkeypatch, 0.5, 0.1)[0], required=1.2, ok=False) tiny = dict(_cases_with_volume(monkeypatch, 0.0003, 0.1)[1], required=1.2, ok=None) tiny["F"] = 0.0 out = model._verdict(kin, [big, tiny]) assert (out["verdict"], out["min_F"], out["small_blocks"]) == ("NG", 0.5, 1) def test_min_block_check_range(): doc = _doc() doc["settings"]["min_block_m3"] = -1 assert {"path": "settings.min_block_m3", "code": "range"} in model.check_joints(doc) doc["settings"]["min_block_m3"] = 2.5 assert model.check_joints(doc) == [] assert model.clean(doc)["settings"]["min_block_m3"] == 2.5 def test_analyze_min_block_filter(monkeypatch): """측점 해석 전체 길 — 최소 블록을 크게 주면 모든 경우 소규모 · ok 없음 · 판정 none.""" 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}, } base = model.analyze(None, _doc(), ground)[0] assert not any(c["small"] for c in base["cases"]) and base["small_blocks"] == 0 doc = _doc() doc["settings"]["min_block_m3"] = 1000.0 e = model.analyze(None, doc, ground)[0] assert all(c["small"] and c["ok"] is None for c in e["cases"]) assert (e["verdict"], e["min_F"], e["small_blocks"]) == ("none", None, 1) def test_e05_rock_sheet_small(monkeypatch): """E05 s11 — 최소 블록 글 · 소규모 요약 · 줄 비고.""" from E05_StabReport import E05_StabReport_Build as build from E05_StabReport import E05_StabReport_Rock as rock from E06_StabRock import E06_StabRock_Router as router 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}, } doc = _doc() doc["settings"]["min_block_m3"] = 1000.0 stations = model.analyze(None, doc, ground) value = { "settings": doc["settings"], "sets": doc["sets"], "summary": router._summary(stations), "stations": stations, "stale": False, } sheet = next(s for s in build.form()["시트"] if s["id"] == "s11") rock.fill_rock(sheet, value, {}) cells = sheet["칸"] assert "최소 블록 1000 m³" in cells["B8"]["값"] and "사용자 입력" in cells["B8"]["값"] assert "소규모 블록 1" in cells["B9"]["값"] assert any("소규모 · 낙석 관리" in str(c.get("값")) for c in cells.values())