# -*- coding: utf-8 -*- """536-3 E03 보강 검토 — 벽 외적 안정 · 비탈 완화 · 소단 가정 재해석 · E05 보강검토 시트. - 벽 외적 안정(순수): 교과서 계수표(Coulomb · Rankine · Meyerhof) · M-O 닫힌 꼴 · 손계산 예제 (직사각 벽 · 상재하중 · 수압 · 뒷굽 위 흙) ±2 % - 가정 형상(순수): 1:n 비탈이 원지반과 만나는 점 = 해석해 · 소단 꼭짓점 · 최소 완화 찾기(단조 · 창) - 길(검증_sub2 동결 복사 · 벽 측점 + 하나): 지금 기울기 그대로 = 원본 안전율(±3 %) · 최소 완화가 목표 안전율을 맞춤 · 원본 측점 · 요약 저장본 그대로 · 벽 측점 외적 안정 · 벽 쪽 비탈 가정 거절 · E05 보강검토 시트(있을 때만) - 네일 · 앵커(뒷부분): 배치 → supports 꼴 · 그림 좌표(머리 · 끝 · 정착 구간 · 활동면 교점 · mode) = 해석해(TS) · 엔진 고리 전 = 그림만(supports_pending) · 가짜 엔진(supports 인자 · 길이 비례 Fs)으로 최소 길이 찾기 · E05 네일 · 앵커 줄 ./venv/Scripts/python.exe -m pytest resources/tester/test_536_3_e03_reinforce.py -q """ from __future__ import annotations import asyncio import json import math import shutil import subprocess import sys import tempfile from pathlib import Path from uuid import UUID import pytest from fastapi import HTTPException ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from common_util import common_util_project_copy as cp # noqa: E402 from common_util.common_util_station_key import chainage_key # noqa: E402 from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402 from E02_StabGround import E02_StabGround_Router as e02 # noqa: E402 from E03_StabSection import E03_StabSection_Router as router # noqa: E402 from E03_StabSection import E03_StabSection_Router_WhatIf as wf # noqa: E402 from E03_StabSection import E03_StabSection_View as view # noqa: E402 from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402 from E03_StabSection import E03_StabSection_WallCheck as wc # noqa: E402 from E03_StabSection import E03_StabSection_WhatIf as whatif # noqa: E402 from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402 SOURCE_ID = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(읽기만) PID = "e" * 8 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 12 WALL_KEY = "351.00" # 검증_sub2 기슭막이 측점 SOIL = {"gamma_t": 18.0, "gamma_sat": 19.0, "c": 0.0, "phi": 30.0} def close(a: float, b: float, tol: float = 0.02) -> bool: return abs(a - b) <= tol * abs(b) # ── 벽 외적 안정 ───────────────────────────────────────────────────────────── def test_토압_지지력_계수_교과서표(): """Das(기초공학) 표 — Coulomb δ = 2/3φ · Rankine 경사 배면 · Meyerhof 지지력 계수.""" assert close(wc.ka_coulomb(30, 20, 0, 0), 0.2973) assert close(wc.ka_coulomb(30, 0, 0, 0), 1 / 3) assert close(wc.ka_rankine(30, 0), 1 / 3) assert close(wc.ka_rankine(30, 10), 0.3495) nc, nq, ng = wc.bearing_factors(30) assert close(nc, 30.14) and close(nq, 18.40) and close(ng, 15.67) assert wc.bearing_factors(0) == (5.14, 1.0, 0.0) def test_MO_닫힌꼴(): """δ = θ = β = 0 · kv = 0 — KAE = cos²(φ−ψ) / (cos²ψ [1 + √(sinφ sin(φ−ψ) / cosψ)]²) · ψ = 0 이면 Coulomb.""" for phi, kh in ((30, 0.2), (35, 0.15)): psi = math.atan(kh) f = math.radians(phi) want = math.cos(f - psi) ** 2 / ( math.cos(psi) ** 2 * (1 + math.sqrt(math.sin(f) * math.sin(f - psi) / math.cos(psi))) ** 2 ) assert close(wc.ka_coulomb(phi, 0, 0, 0, math.degrees(psi)), want, 1e-9) assert close(wc.ka_coulomb(30, 0, 0, 0, math.degrees(math.atan(0.2))), 0.4733) assert wc.ka_coulomb(32, 21, 5, 10, 0.0) == pytest.approx(wc.ka_coulomb(32, 21, 5, 10)) RECT = [[0, 0], [2, 0], [2, 4], [0, 4]] # B 2 · H 4 · 배면 = +x(뒷굽 x = 2) def test_직사각_벽_손계산(): """Rankine · q 0 · 물 없음 · μ 0.5 — W 184 · Pa 48(H/3) · 활동 1.917 · 전도 2.875 · e = 1 − 0.6522 · q_max · Meyerhof q_ult(유효폭 · 경사 계수).""" r = wc.check_wall( RECT, +1, backfill=SOIL, foundation=SOIL, method="rankine", surcharge_kpa=0.0, base_friction=0.5, ) # fmt: skip W, Pa = 2 * 4 * 23.0, 0.5 * 18 * 16 / 3 assert close(r["loads"]["W"], W) and close(r["loads"]["Pa"], Pa) and r["loads"]["W_soil"] == 0 assert close(r["fs"]["sliding"], W * 0.5 / Pa) m_res, m_over = W * 1.0, Pa * 4 / 3 assert close(r["fs"]["overturning"], m_res / m_over) xbar = (m_res - m_over) / W e = 1.0 - xbar q_max = W / 2 * (1 + 6 * e / 2) if e <= 2 / 6 else 2 * W / (3 * xbar) alpha = math.degrees(math.atan(Pa / W)) q_ult = 0.5 * 18 * (2 - 2 * e) * 15.668 * (1 - alpha / 30) ** 2 assert close(r["base"]["q_max"], q_max) and close(r["fs"]["bearing"], q_ult / q_max) assert r["case"] == "normal" and r["required"] == wc.REQUIRED["normal"] assert r["ok"] == {k: r["fs"][k] >= r["required"][k] for k in r["fs"]} def test_상재하중_수압_양압력(): """q 10 · 배면 수위 = 벽 높이 — Pa = Ka(½γ'H² + qH) · Pw = ½γwH² · U = ½γwHB · ΣH · V.""" r = wc.check_wall( RECT, +1, backfill=SOIL, foundation=SOIL, method="rankine", surcharge_kpa=10.0, water_ratio=1.0, base_friction=0.5, ) # fmt: skip g = SOIL["gamma_sat"] - wc.GAMMA_W Pa = (0.5 * g * 16 + 10 * 4) / 3 Pw, U = 0.5 * wc.GAMMA_W * 16, 0.5 * wc.GAMMA_W * 4 * 2 assert close(r["loads"]["Pa"], Pa) and close(r["loads"]["Pw"], Pw) and close(r["loads"]["U"], U) assert close(r["loads"]["H"], Pa + Pw) and close(r["loads"]["V"], 184 - U) arm = (0.5 * g * 16 * 4 / 3 + 40 * 2) / (0.5 * g * 16 + 40) # 삼각 + 사각 합력 높이 assert close(r["loads"]["Pa_arm"], arm) m_over = Pa * arm + Pw * 4 / 3 + U * 4 / 3 assert close(r["loads"]["M_overturn"], m_over) def test_L형_벽_뒷굽_위_흙_지진(): """역 T 형(앞굽 0 · 뒷굽 3 · 벽체 0.6~1.0) — Rankine 가상면 위 흙 = (3 − 1.0) × 4.5 × γ · Coulomb 가상면 θ = atan(2 / 5) · 지진 시 = M-O 증분 0.6H + 관성 · 기준 = 지진 시 표.""" L = [[0, 0], [3, 0], [3, 0.5], [1.0, 0.5], [1.0, 5], [0.6, 5], [0.6, 0.5], [0, 0.5]] r = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL, method="rankine") assert close(r["loads"]["W_soil"], 2.0 * 4.5 * 18) c = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL) assert close(c["geometry"]["theta_deg"], math.degrees(math.atan(2 / 5))) # 가상면(x = 3 − 0.4z)과 벽체(x = 1.0) 사이 · 0.5 ≤ z ≤ 5 → ∫(2 − 0.4z)dz = 4.05 m² assert close(c["loads"]["W_soil"], 4.05 * 18) s = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL, kh=0.15) assert s["case"] == "seismic" and s["required"] == wc.REQUIRED["seismic"] assert s["coefficients"]["kae"] > c["coefficients"]["ka"] assert s["loads"]["H"] > c["loads"]["H"] and s["fs"]["sliding"] < c["fs"]["sliding"] def test_배면이_반대쪽이면_거울(): a = wc.check_wall(RECT, +1, backfill=SOIL, foundation=SOIL) b = wc.check_wall([[-x, z] for x, z in RECT], -1, backfill=SOIL, foundation=SOIL) for k in a["fs"]: assert a["fs"][k] == pytest.approx(b["fs"][k]) # ── 가정 형상 · 최소 완화 찾기 ──────────────────────────────────────────────── GROUND = [(-40.0, 120.0), (0.0, 100.0), (40.0, 80.0)] # 1:2 비탈(좌 = +x 낮음) LINE = [(-3.0, 101.5), (-2.0, 100.0), (2.0, 100.0), (3.0, 99.5)] def test_비탈_다시_그림_해석해(): """지반 z = 100 − x/2 · 우(−x) 깎기 시작 (−2, 100) · 1:n — u = −x 에서 (u − 2)/n = u/2 → u = 2 / (1 − n/2) · z = 100 + u/2 · n ≥ 2 = 못 만남. 좌 쌓기 (2, 100) 1:1.5 → (8, 96).""" for n in (0.5, 1.0, 1.5): line = whatif.reshape_side(LINE, GROUND, side="right", role="cut", start_x=-2.0, ratio=n) u = 2.0 / (1.0 - n / 2) x, z = min(line) assert x == pytest.approx(-u) and z == pytest.approx(100.0 + u / 2) assert [p for p in line if p[0] > -2.0 + 1e-9] == [list(p) for p in LINE[2:]] gentle = dict(side="right", role="cut", start_x=-2.0, ratio=2.5) assert whatif.reshape_side(LINE, GROUND, **gentle) is None line = whatif.reshape_side( LINE, GROUND, side="right", role="cut", start_x=-2.0, ratio=0.5, bench_w=1.0, bench_h=1.0 ) corners = [p for p in line if -3.6 < p[0] < -2.4] assert [[round(x, 6), round(z, 6)] for x, z in corners] == [[-3.5, 101.0], [-2.5, 101.0]] fill = whatif.reshape_side(LINE, GROUND, side="left", role="fill", start_x=2.0, ratio=1.5) assert max(fill) == pytest.approx([8.0, 96.0]) assert [p for p in fill if p[0] < 2.0 - 1e-9] == [list(p) for p in LINE[:2]] assert ( whatif.reshape_side(LINE, GROUND, side="left", role="fill", start_x=2.0, ratio=3.0) is None ) def test_최소_완화_찾기(): found = whatif.search_ratio(lambda n: n >= 1.23, 0.4) assert found["found"] == pytest.approx(1.25) window = whatif.search_ratio(lambda n: 1.0 <= n <= 1.3, 0.4) # 완만할수록 늘 안전하지 않음 assert 1.0 <= window["found"] <= 1.3 assert whatif.search_ratio(lambda n: False, 0.4, max_ratio=1.1)["found"] is None assert whatif.search_ratio(lambda n: True, 0.4) == {"found": 0.4, "tried": [(0.4, True)]} assert len(whatif.search_ratio(lambda n: n >= 3.9, 0.4)["tried"]) <= 10 # ── 길(검증_sub2 동결 복사) ────────────────────────────────────────────────── async def _copy(target: Path) -> None: import aiomysql from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER connection = await aiomysql.connect( host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True ) try: await cp.copy_project( connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target ) finally: connection.close() @pytest.fixture(scope="module") def frozen(): folder = Path(tempfile.mkdtemp(prefix="aislo_e03_rf_")) try: asyncio.run(_copy(folder / "copy")) except Exception as error: # noqa: BLE001 shutil.rmtree(folder, ignore_errors=True) pytest.skip(f"검증 원본 복사 실패: {error}") yield folder / "copy" shutil.rmtree(folder, ignore_errors=True) def _fast(cross, design, **kw): """진짜 엔진 — Bishop 원호만(빠르게).""" return analyze_station(cross, design, **{**kw, "methods": ("bishop",), "noncircular": False}) @pytest.fixture(scope="module") def project(frozen): """벽 측점 + 깎기 비탈 있는 벽 없는 측점 하나 · 진짜 엔진(빠르게) · [해석] 한 번.""" mp = pytest.MonkeyPatch() original = src.stations def pick(root): rows = original(root) wall = [r for r in rows if chainage_key(r["chainage_m"]) == WALL_KEY] rest = [r for r in rows if chainage_key(r["chainage_m"]) != WALL_KEY] return wall + rest[10:11] mp.setattr(router.src, "stations", pick) async def fake_project(_pid): return frozen, {"company_id": 1, "project_kind": "STABILITY"} mp.setattr(e02, "_project", fake_project) mp.setattr(worker, "analyze_station", _fast) mp.setattr(wf, "analyze_station", _fast) mp.setattr(router, "WORKERS", 1) router._JOBS.clear() async def go(): await router.analyze2d(UUID(PID)) while router._TASKS: await asyncio.gather(*list(router._TASKS)) asyncio.run(go()) yield frozen mp.undo() def _stamps(root: Path) -> dict[str, bytes]: out = {} for kind in (router.KIND_STATION, router.KIND_SUMMARY): for p in (root / "_derived" / kind).glob("*.json"): out[str(p)] = p.read_bytes() # 읽기가 고친 때를 건드림(최근 표시) — 내용만 견줌 return out def _plain_station(root: Path) -> tuple[str, str, float, dict]: summary, _ = router._latest_summary(root, "") row = next(r for r in summary["stations"] if r["key"] != WALL_KEY) side = next(s for s in row["sides"] if s["role"] == "cut")["side"] return row["key"], side, row["cases"]["dry"][side]["fs"], row def test_지금_기울기_그대로면_원본_안전율(project): key, side, before, _ = _plain_station(project) rec = wf.run_whatif(project, key, wf.WhatIfRequest(side=side)) assert rec["assume"]["ratio"] == rec["assume"]["ratio_before"] assert close(rec["after"]["dry"]["fs"], before, 0.03) assert rec["view"]["analyses"] and rec["before"]["dry"]["fs"] == before def test_최소_완화가_목표를_맞춤_원본_불변(project): key, side, before, _ = _plain_station(project) stamps = _stamps(project) target = round(before + 0.02, 3) rec = wf.run_whatif( project, key, wf.WhatIfRequest(side=side, search=True, condition="dry", target_fs=target), ) search = rec["search"] if search["found"] is None: # 완화가 깎기 높이를 키워 오히려 낮아지는 지형 — 끝값까지 봄 assert all(not ok for _, ok in search["tried"]) else: assert rec["after"]["dry"]["fs"] >= target - 1e-9 assert rec["assume"]["ratio"] == search["found"] > rec["assume"]["ratio_before"] steeper = [n for n, ok in search["tried"] if n < search["found"]] assert all(not ok for n, ok in search["tried"] if n in steeper) assert _stamps(project) == stamps # 원본 측점 · 요약 저장본 그대로 again = wf.run_whatif( project, key, wf.WhatIfRequest(side=side, search=True, condition="dry", target_fs=target), ) assert again["computed_at"] == rec["computed_at"] # 같은 가정 = 저장본 def test_벽_측점_외적_안정_벽쪽_가정_거절(project): rec = wf.run_wallcheck(project, WALL_KEY, wf.WallRequest(kh=0.15)) assert len(rec["walls"]) == 1 wall = rec["walls"][0] assert [c["case"] for c in wall["cases"]] == ["normal", "seismic"] for c in wall["cases"]: assert c["required"] == wc.REQUIRED[c["case"]] assert all(v > 0 for v in c["fs"].values()) assert c["geometry"]["H"] == pytest.approx(1.5, abs=0.05) with pytest.raises(HTTPException) as error: wf.run_whatif(project, WALL_KEY, wf.WhatIfRequest(side=wall["side"])) assert error.value.status_code == 422 def test_E05_보강검토_시트(project, monkeypatch): from E05_StabReport import E05_StabReport_Build as build recs = wf.records(project) assert {r["type"] for r in recs} == {"slope", "wall"} book = build.form() build.build_program(book, build.gather(project)) s8 = next(s for s in book["시트"] if s["id"] == "s8") cells = s8["칸"] slopes = [r for r in recs if r["type"] == "slope"] rows = sum(len(r["after"]) for r in slopes) assert all(cells[f"D{6 + i}"]["값"] in ("건기", "우기", "지진") for i in range(rows)) wall_rows = [a1 for a1, c in cells.items() if a1[0] == "B" and "평상시" in str(c.get("값"))] assert len(wall_rows) == 1 and cells[f"I{wall_rows[0][1:]}"]["값"] in ("O.K", "N.G") monkeypatch.setattr(build.reinforce, "read", lambda root: []) empty = build.form() build.build_program(empty, build.gather(project)) assert "s8" not in {s["id"] for s in empty["시트"]} # 없으면 시트 뺌 # ── 네일 · 앵커(뒷부분) ────────────────────────────────────────────────────── TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript" SUPPORT_TS = ROOT / "E03_StabSection" / "logic" / "E03_StabSection_Support.ts" SUPPORT_SCRIPT = r""" const ts = require(process.argv[1]); const fs = require("fs"); const src = fs.readFileSync(process.argv[2], "utf8"); const opts = { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }; const js = ts.transpileModule(src, { compilerOptions: opts }).outputText; const box = { exports: {} }; new Function("exports", "module", js)(box.exports, box); const m = box.exports; const c = JSON.parse(fs.readFileSync(0, "utf8")); const nail = { ...m.SUPPORT_DEFAULTS.nail, rows: 9, length_m: 6, angle_deg: 15 }; const cut = m.layoutSupports(c.surface, "left", "cut", 4.5, nail); const anchor = { ...m.SUPPORT_DEFAULTS.anchor, rows: 1 }; const fill = m.layoutSupports(c.surface, "right", "fill", 3.0, anchor); process.stdout.write(JSON.stringify({ cut, fill, draws: m.supportDraws(cut, -1, c.critical, [{ index: 0, mobilized_kN_per_m: 12.5, mode: "pullout" }]), anchor: m.supportDraws(fill, -1, null, null), none: m.layoutSupports([[1, 0], [2, 0]], "left", "cut", 3, nail), })); """ # 노면 −3 ~ 3 (EL 100) · 좌 깎기 1:1 (3, 100) → (13, 110) · 우 쌓기 1:1.5 (−3, 100) → (−7.5, 97) SURFACE = [[13, 110], [3, 100], [-3, 100], [-7.5, 97], [-20, 97]] CRITICAL = [[3, 100], [6, 101], [12, 109]] # 머리 (4, 101) 네일과 만나는 활동면 def test_네일_배치_supports_꼴_그림_좌표(): proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", "-e", SUPPORT_SCRIPT, str(TYPESCRIPT), str(SUPPORT_TS)], input=json.dumps({"surface": SURFACE, "critical": CRITICAL}), capture_output=True, text=True, cwd=str(ROOT), ) # fmt: skip assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) cut = out["cut"] # 행 = 1 · 2.5 · 4.0 m(4.5 m 비탈 안만) · 머리 = 1:1 면 위 assert [(p["x"], p["z"]) for p in cut] == [(4.0, 101.0), (5.5, 102.5), (7.0, 104.0)] keys = {"type", "side", "x", "z", "angle_deg", "length_m", "spacing_m", "tensile_kN", "bond_kPa", "bond_length_m", "hole_dia_mm", "prestress_kN"} # fmt: skip assert all(set(p) == keys and p["side"] == "left" and p["prestress_kN"] == 0 for p in cut) assert cut[0]["spacing_m"] == 1.5 and cut[0]["bond_length_m"] == 0 # 쌓기 밑 = 노면 − 3 m · 첫 행 1.5 m = EL 98.5 · 1:1.5 면 x = −3 − 1.5 × 1.5 (anchor,) = out["fill"] assert (anchor["x"], anchor["z"], anchor["side"]) == (-5.25, 98.5, "right") assert anchor["bond_length_m"] == 6 and anchor["prestress_kN"] == 200 assert out["none"] == [] # 그림 — 깎기 좌(활동 −x) = 비탈 속(+x)으로 · 수평 아래 15° · 네일 정착 = 전 길이 d0 = out["draws"][0] a = math.radians(15) tail = (4 + 6 * math.cos(a), 101 - 6 * math.sin(a)) assert d0["tail"] == pytest.approx(tail, abs=1e-3) assert d0["bond"][0] == [4.0, 101.0] and d0["bond"][1] == pytest.approx(tail, abs=1e-3) assert (d0["mode"], d0["force"]) == ("pullout", 12.5) # 교점 — (3,100)~(6,101) 선 z = 100 + (x−3)/3 과 네일 z = 101 − (x−4)·tan15 t15 = math.tan(a) x = (101 + 4 * t15 - 99) / (1 / 3 + t15) assert d0["cross"] == pytest.approx([x, 100 + (x - 3) / 3], abs=1e-4) # 끝 1 mm 반올림 assert [d["mode"] for d in out["draws"][1:]] == ["pending", "pending"] # 앵커 — 쌓기 우(활동 −x 바깥) = 비탈 속(+x)으로 · 정착 = 끝에서 6 m · 결과 없음 (da,) = out["anchor"] b = math.radians(30) want = (-5.25 + 12 * math.cos(b), 98.5 - 12 * math.sin(b)) assert da["tail"] == pytest.approx(want, abs=1e-3) assert da["bond"][0] == pytest.approx( (-5.25 + 6 * math.cos(b), 98.5 - 6 * math.sin(b)), abs=1e-3 ) assert da["cross"] is None and da["mode"] == "pending" def _nails(side: str, n: int = 2) -> list[wf.SupportIn]: return [ wf.SupportIn(type="nail", x=5.0 * (1 if side == "left" else -1), z=1.0 + i, angle_deg=15, length_m=6, spacing_m=1.5, tensile_kN=121.6, bond_kPa=80, hole_dia_mm=100) for i in range(n) ] # fmt: skip def _engine_with_supports(cross, design, *, supports=None, **kw): """가짜 엔진 — supports 인자 · 그 비탈 대표 방법 Fs × (1 + 0.05 L) · 동원력 = 10 L.""" out = _fast(cross, design, **kw) for case in out["cases"]: mine = [i for i, s in enumerate(supports or []) if s["side"] == case["side"]] best = case["result"]["methods"].get(view.PRIMARY, {}).get("min") if not mine or not best: continue length = supports[mine[0]]["length_m"] best["F"] *= 1 + 0.05 * length case["result"]["support_forces"] = [ {"index": i, "mobilized_kN_per_m": 10.0 * length, "mode": "pullout"} for i in mine ] return out def test_네일_엔진_전_그림만_엔진_뒤_최소_길이(project, monkeypatch): from E05_StabReport import E05_StabReport_Build as build key, side, before, _ = _plain_station(project) stamps = _stamps(project) pending = wf.run_whatif(project, key, wf.WhatIfRequest(side=side, supports=_nails(side))) assert pending["type"] == "support" and pending["supports_pending"] is True assert pending["support_forces"] == {} and close(pending["after"]["dry"]["fs"], before, 1e-6) assert [s["side"] for s in pending["assume"]["supports"]] == [side, side] monkeypatch.setattr(wf, "analyze_station", _engine_with_supports) assert wf.engine_takes_supports() target = round(before * 1.24, 3) # L ≥ 4.8 m → 0.5 m 단위 = 5.0 m ask = wf.WhatIfRequest(side=side, supports=_nails(side), search=True, condition="dry", target_fs=target) # fmt: skip rec = wf.run_whatif(project, key, ask) assert rec["supports_pending"] is False and rec["search"]["found"] == 5.0 assert all(s["length_m"] == 5.0 for s in rec["assume"]["supports"]) assert rec["after"]["dry"]["fs"] >= target assert all(not ok for n, ok in rec["search"]["tried"] if n < 5.0) forces = rec["support_forces"]["dry"] assert [(f["index"], f["mobilized_kN_per_m"], f["mode"]) for f in forces] == [ (0, 50.0, "pullout"), (1, 50.0, "pullout"), ] assert _stamps(project) == stamps # 원본 측점 · 요약 저장본 그대로 recs = wf.records(project) assert "support" in {r["type"] for r in recs} book = build.form() build.build_program(book, build.gather(project)) cells = next(s for s in book["시트"] if s["id"] == "s8")["칸"] head = next( int(a1[1:]) for a1, c in cells.items() if str(c.get("값", "")).startswith("3. 네일") ) assert cells[f"I{head + 1}"]["값"] == "지배" rows = [ r for r in range(head + 2, head + 40) if f"C{r}" in cells and "네일" in str(cells[f"C{r}"].get("값")) ] assert len(rows) == 2 * len(rec["after"]) assert cells[f"E{rows[0]}"]["값"] == 5.0 and cells[f"I{rows[0]}"]["값"] == "인발" assert any("네일 2개 · L 5 m" in str(c.get("값")) for a1, c in cells.items() if a1[0] == "C")