- 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>
477 lines
23 KiB
Python
477 lines
23 KiB
Python
# -*- 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")
|