Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Host tests for the ASCE 7 C&C wall wind-pressure engine (`windload.py`).
|
||||
|
||||
Two things are pinned here, both HAND COMPUTED with the arithmetic written into
|
||||
the test so a failure names the number that moved (PROJECT.md 3.7 discipline,
|
||||
same as test_capacity.py):
|
||||
|
||||
1. The velocity-pressure (qz) chain, and the 7-16 vs 7-22 Kd-placement
|
||||
difference: qh differs between editions by the factor Kd, but the final
|
||||
design pressure is identical.
|
||||
2. The GCp -> pressure step, with the figure-read +/-0.05 uncertainty carried
|
||||
onto the pressure.
|
||||
|
||||
NO CLIENT NUMBERS. Every input below is a generic, round ASCE example chosen so
|
||||
the closed-form arithmetic is reproducible on paper. The validation against the
|
||||
Eriksson golden sample (which carries client/job context) lives in scratchpad,
|
||||
not here -- see the module report.
|
||||
|
||||
Worked example used throughout (a textbook-style low-rise wall):
|
||||
|
||||
Vult = 115 mph Risk Category II Exposure C
|
||||
Enclosed Kzt = 1.0 Ke = 1.0
|
||||
mean roof height h = 30 ft (so Kh = Kz at 30 ft, Exp C = 0.98, both editions)
|
||||
Kd (C&C) = 0.85 effective wind area A = 100 ft^2
|
||||
|
||||
Chain arithmetic (Q_CONST = 0.00256, V^2 = 115^2 = 13225):
|
||||
7-22 qh = 0.00256 * 0.98 * 1.0 * 1.0 * 13225 = 33.17888 psf (no Kd)
|
||||
7-16 qh = 0.00256 * 0.98 * 1.0 * 0.85 * 1.0 * 13225 = 28.20205 psf (Kd in qh)
|
||||
ratio = 28.20205 / 33.17888 = 0.85 = Kd. <-- the version fork
|
||||
effective pressure multiplier q_eff = 28.20205 psf in BOTH editions
|
||||
(7-16: q_eff = qh; 7-22: q_eff = qh * Kd = 33.17888 * 0.85 = 28.20205)
|
||||
|
||||
GCp at A = 100 ft^2 (Fig. 30.3-1, log-linear between anchors A=10 and A=500):
|
||||
log10(100) = 2 ; log10(10) = 1 ; log10(500) = 2.698970 ; frac = 1/1.698970
|
||||
= 0.588593
|
||||
Zone 4 neg: -1.1 + (-0.8-(-1.1))*0.588593 = -1.1 + 0.176578 = -0.923422
|
||||
Zone 5 neg: -1.4 + ( 0.6)*0.588593 = -1.4 + 0.353156 = -1.046844
|
||||
positive : +1.0 + (-0.3)*0.588593 = +1.0 - 0.176578 = +0.823422
|
||||
|
||||
Zone 4 design pressures (GCpi = 0.18, enclosed), q_eff = 28.20205 psf:
|
||||
p_pos = q_eff * ( +0.823422 - (-0.18) ) = 28.20205 * 1.003422 = 28.2986 psf
|
||||
p_neg = q_eff * ( -0.923422 - (+0.18) ) = 28.20205 * -1.103422 = -31.117 psf
|
||||
uncertainty band = q_eff * 0.05 = 28.20205 * 0.05 = +/-1.4101 psf
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import windload
|
||||
from panellift.windload import compute
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ constants
|
||||
V = 115.0
|
||||
V2 = V * V # 13225
|
||||
Q = windload.Q_CONST_US # 0.00256
|
||||
KH_30_C = 0.98 # Table 26.10-1, 30 ft, Exposure C (both editions)
|
||||
KD = 0.85
|
||||
|
||||
|
||||
def _base(code_version, **kw):
|
||||
kwargs = dict(
|
||||
v_ult_mph=V, risk_category="II", exposure="C", enclosure="enclosed",
|
||||
code_version=code_version, mean_roof_height_ft=30.0,
|
||||
effective_wind_area_ft2=100.0,
|
||||
)
|
||||
kwargs.update(kw)
|
||||
return compute(**kwargs)
|
||||
|
||||
|
||||
# ============================================================ the qz chain
|
||||
def test_kh_table_lookup_and_version_difference():
|
||||
# 30 ft Exposure C is 0.98 in both editions...
|
||||
assert windload.velocity_pressure_coefficient(30.0, "C", "7-16") == 0.98
|
||||
assert windload.velocity_pressure_coefficient(30.0, "C", "7-22") == 0.98
|
||||
# ...but the Exposure B column was revised: 40 ft B is 0.76 in 7-16, 0.74 in
|
||||
# 7-22. This is a real version difference the engine must not smear.
|
||||
assert windload.velocity_pressure_coefficient(40.0, "B", "7-16") == 0.76
|
||||
assert windload.velocity_pressure_coefficient(40.0, "B", "7-22") == 0.74
|
||||
|
||||
|
||||
def test_kh_linear_interpolation():
|
||||
# 35 ft, Exp C, 7-16: halfway between 0.98 (30) and 1.04 (40) = 1.01.
|
||||
kh = windload.velocity_pressure_coefficient(35.0, "C", "7-16")
|
||||
assert kh == pytest.approx(1.01, abs=1e-9)
|
||||
|
||||
|
||||
def test_qh_chain_hand_computed_both_editions():
|
||||
r16 = _base("7-16")
|
||||
r22 = _base("7-22")
|
||||
|
||||
qh_722 = Q * KH_30_C * 1.0 * 1.0 * V2 # 33.17888
|
||||
qh_716 = Q * KH_30_C * 1.0 * KD * 1.0 * V2 # 28.20205
|
||||
assert r22.qh_psf.value == pytest.approx(qh_722, rel=1e-9)
|
||||
assert r16.qh_psf.value == pytest.approx(qh_716, rel=1e-9)
|
||||
|
||||
# The Kd-placement fork, stated as an invariant: 7-16 qh is smaller by Kd.
|
||||
assert r16.qh_psf.value == pytest.approx(r22.qh_psf.value * KD, rel=1e-12)
|
||||
assert r16.kd_placement == "in_qz"
|
||||
assert r22.kd_placement == "in_pressure"
|
||||
# qh values are genuinely different numbers (not the same reported twice).
|
||||
assert abs(r16.qh_psf.value - r22.qh_psf.value) > 4.0
|
||||
|
||||
|
||||
def test_ke_from_elevation_and_default():
|
||||
assert windload.ground_elevation_factor(None) == 1.0
|
||||
# 5000 ft -> exp(-0.0000362*5000) = exp(-0.181) = 0.83446 (Table 26.9-1 ~0.83)
|
||||
assert windload.ground_elevation_factor(5000.0) == pytest.approx(0.83446, abs=1e-4)
|
||||
|
||||
|
||||
# ==================================================== GCp -> pressure step
|
||||
def test_wall_gcp_log_linear_matches_note_intermediates():
|
||||
# The engine's log-linear read must reproduce the mined notes' derived
|
||||
# intermediate table (ASCE7-22-ch30-figGCp.md Sec 1.2).
|
||||
assert windload.wall_gcp(20.0, "zone4_neg") == pytest.approx(-1.05, abs=0.005)
|
||||
assert windload.wall_gcp(50.0, "zone4_neg") == pytest.approx(-0.98, abs=0.005)
|
||||
assert windload.wall_gcp(100.0, "zone4_neg") == pytest.approx(-0.92, abs=0.005)
|
||||
assert windload.wall_gcp(200.0, "zone4_neg") == pytest.approx(-0.87, abs=0.005)
|
||||
assert windload.wall_gcp(100.0, "zone5_neg") == pytest.approx(-1.05, abs=0.005)
|
||||
assert windload.wall_gcp(100.0, "pos") == pytest.approx(+0.82, abs=0.005)
|
||||
# plateaus hold outside [10, 500]
|
||||
assert windload.wall_gcp(5.0, "zone5_neg") == -1.4
|
||||
assert windload.wall_gcp(800.0, "pos") == +0.7
|
||||
|
||||
|
||||
def test_zone4_pressures_hand_computed():
|
||||
r = _base("7-16")
|
||||
z4 = next(z for z in r.zones if z.zone == 4)
|
||||
|
||||
q_eff = 28.20205
|
||||
gcp_pos = 0.823422
|
||||
gcp_neg = -0.923422
|
||||
p_pos = q_eff * (gcp_pos + 0.18) # 28.2986
|
||||
p_neg = q_eff * (gcp_neg - 0.18) # -31.117
|
||||
|
||||
assert z4.gcp_positive == pytest.approx(gcp_pos, abs=1e-4)
|
||||
assert z4.gcp_negative == pytest.approx(gcp_neg, abs=1e-4)
|
||||
assert z4.positive_psf == pytest.approx(p_pos, rel=1e-4)
|
||||
assert z4.negative_psf == pytest.approx(p_neg, rel=1e-4)
|
||||
|
||||
|
||||
def test_zone5_suction_exceeds_zone4():
|
||||
# Zone 5 (edge/corner strips) is the higher-magnitude suction than Zone 4.
|
||||
r = _base("7-22")
|
||||
z4 = next(z for z in r.zones if z.zone == 4)
|
||||
z5 = next(z for z in r.zones if z.zone == 5)
|
||||
assert abs(z5.negative_psf) > abs(z4.negative_psf)
|
||||
# positive is the single combined 4&5 curve -> same for both zones.
|
||||
assert z4.positive_psf == pytest.approx(z5.positive_psf, rel=1e-12)
|
||||
|
||||
|
||||
def test_final_pressure_identical_across_editions():
|
||||
# THE headline invariant: Kd sits in a different equation in each edition,
|
||||
# but the design pressure a caller receives is the same to the last psf.
|
||||
r16 = _base("7-16")
|
||||
r22 = _base("7-22")
|
||||
for zone in (4, 5):
|
||||
a = next(z for z in r16.zones if z.zone == zone)
|
||||
b = next(z for z in r22.zones if z.zone == zone)
|
||||
assert a.positive_psf == pytest.approx(b.positive_psf, rel=1e-12)
|
||||
assert a.negative_psf == pytest.approx(b.negative_psf, rel=1e-12)
|
||||
|
||||
|
||||
# ==================================================== figure-read uncertainty
|
||||
def test_figure_read_flag_and_uncertainty_carried():
|
||||
r = _base("7-16")
|
||||
q_eff = 28.20205
|
||||
for z in r.zones:
|
||||
assert z.figure_read is True
|
||||
assert z.gcp_pm == 0.05
|
||||
# +/-0.05 on a figure-read GCp propagates to +/- q_eff*0.05 psf.
|
||||
assert z.uncertainty_psf == pytest.approx(q_eff * 0.05, rel=1e-6)
|
||||
assert z.uncertainty_psf == pytest.approx(1.4101, abs=1e-3)
|
||||
|
||||
|
||||
def test_pressures_carry_a_cite():
|
||||
r = _base("7-22")
|
||||
assert "Fig. 30.3-1" in r.zones[0].basis
|
||||
assert "figure-read" in r.zones[0].basis
|
||||
assert "Eq. 26.10-1" in r.qh_psf.cite
|
||||
assert "Table 26.10-1" in r.kh.cite
|
||||
assert "Table 26.13-1" in r.gcpi.cite
|
||||
|
||||
|
||||
# ==================================================== enclosure / GCpi
|
||||
def test_partially_enclosed_uses_larger_gcpi():
|
||||
enc = _base("7-16", enclosure="enclosed")
|
||||
par = _base("7-16", enclosure="partially_enclosed")
|
||||
assert enc.gcpi.value == 0.18
|
||||
assert par.gcpi.value == 0.55
|
||||
# larger internal pressure -> larger design pressures (magnitudes).
|
||||
ze = next(z for z in enc.zones if z.zone == 4)
|
||||
zp = next(z for z in par.zones if z.zone == 4)
|
||||
assert abs(zp.negative_psf) > abs(ze.negative_psf)
|
||||
|
||||
|
||||
def test_open_building_gcpi_zero():
|
||||
r = _base("7-22", enclosure="open")
|
||||
assert r.gcpi.value == 0.0
|
||||
|
||||
|
||||
# ==================================================== roof-angle 10% reduction
|
||||
def test_roof_angle_reduction_applied_only_when_supplied():
|
||||
no_angle = _base("7-16") # roof_angle_deg None
|
||||
low_slope = _base("7-16", roof_angle_deg=5.0) # <= 10 deg -> 10% reduction
|
||||
z_none = next(z for z in no_angle.zones if z.zone == 4)
|
||||
z_low = next(z for z in low_slope.zones if z.zone == 4)
|
||||
# GCp reduced 10% when theta <= 10 deg (Fig. 30.3-1 Note 5).
|
||||
assert z_low.gcp_negative == pytest.approx(z_none.gcp_negative * 0.90, rel=1e-9)
|
||||
assert any("Note 5 applied" in n for n in low_slope.notes)
|
||||
assert any("NOT applied" in n for n in no_angle.notes)
|
||||
|
||||
|
||||
# ==================================================== minimum pressure floor
|
||||
def test_min_16psf_flag():
|
||||
# A very low wind + interior zone can fall under the 16 psf net floor.
|
||||
r = compute(v_ult_mph=70.0, risk_category="II", exposure="B",
|
||||
enclosure="enclosed", code_version="7-22",
|
||||
mean_roof_height_ft=15.0, effective_wind_area_ft2=500.0)
|
||||
z4 = next(z for z in r.zones if z.zone == 4)
|
||||
# positive at low speed / large area should trip the 16-psf floor flag.
|
||||
assert z4.min_governs_positive == (abs(z4.positive_psf) < 16.0)
|
||||
if z4.min_governs_positive:
|
||||
assert any("16 psf" in n for n in r.notes)
|
||||
|
||||
|
||||
# ==================================================== NOT-JUDGED paths
|
||||
def test_h_over_60_not_judged_names_the_figure():
|
||||
r16 = _base("7-16", mean_roof_height_ft=77.4)
|
||||
r22 = _base("7-22", mean_roof_height_ft=77.4)
|
||||
assert r16.not_judged and "NOT JUDGED" in r16.not_judged
|
||||
assert r16.zones == []
|
||||
assert "Fig. 30.5-1" in r16.not_judged and "Eq. 30.5-1" in r16.not_judged
|
||||
assert "Fig. 30.4-1" in r22.not_judged and "Eq. 30.4-1" in r22.not_judged
|
||||
|
||||
|
||||
def test_unknown_version_not_judged():
|
||||
r = _base("7-10")
|
||||
assert r.not_judged and "ASCE_CODE_VERSION" in r.not_judged
|
||||
assert r.zones == []
|
||||
|
||||
|
||||
def test_wrong_chapter_not_judged():
|
||||
r = _base("7-16", cc_chapter=27)
|
||||
assert r.not_judged and "Chapter 30" in r.not_judged
|
||||
|
||||
|
||||
def test_bad_exposure_and_enclosure_not_judged():
|
||||
assert _base("7-16", exposure="A").not_judged is not None
|
||||
assert _base("7-16", enclosure="mostly_shut").not_judged is not None
|
||||
|
||||
|
||||
def test_nonpositive_area_not_judged():
|
||||
assert _base("7-16", effective_wind_area_ft2=0).not_judged is not None
|
||||
|
||||
|
||||
# ==================================================== format smoke test
|
||||
def test_format_result_runs():
|
||||
txt = windload.format_result(_base("7-22"))
|
||||
assert "C&C wall pressures" in txt
|
||||
assert "Zone 4" in txt and "Zone 5" in txt
|
||||
txt2 = windload.format_result(_base("7-16", mean_roof_height_ft=100.0))
|
||||
assert "NOT JUDGED" in txt2
|
||||
Reference in New Issue
Block a user