WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s

History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
+263
View File
@@ -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