Files
Aislo/resources/tester/test_536_3_e03_reinforce.py
T
eomsangdonandClaude Opus 5.5 8aeae00470 feat(536-3): E 보강 검토 뒷부분 — 네일 · 앵커 가정 입력 · supports 꼴 POST whatif · 최소 길이 찾기 · 횡단 그림 · 결과 표 · E05 네일 · 앵커 줄 (엔진 고리 전 = 그림만)
- logic/E03_StabSection_Support: 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격 · 제원) → supports(브레인 정함 꼴 + side) · 머리 = 비탈 면 · 방향 = 활동 반대(비탈 속) · 그림 좌표(머리 · 끝 · 정착 구간 · 임계면 교점 · mode)
- UI_Support: 「네일 · 앵커 가정」 카드 · 기본값 후보(지식DB 안정성평가 02 §4 · 사용자 확정 전) · [보강 해석] · [최소 길이 찾기] · 동원력 · 지배 표 · 후 단면 mode 색
- Router_WhatIf: supports 받음 · 엔진에 supports 인자 있으면 넘김(cases[i].result.support_forces 읽음) · 없으면 supports_pending · 최소 길이 = 성긴 걸음 + 이분(0.5 m · 25 m 까지) · 지금 기울기 그대로면 원본 설계선 · 저장본 종류 support
- E05 s8: 1 절 가정 줄에 네일 · 앵커 · 3 절 네일마다 동원력 · 지배(있을 때만)
- 시험 test_536_3 14(배치 해석해 · 그림 좌표 · 엔진 전 그림만 · 가짜 엔진 최소 길이 5.0 m · E05 줄)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 04:18:18 +09:00

477 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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")