# -*- 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