Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

553 lines
26 KiB
Python

# -*- coding: utf-8 -*-
"""The In-Place strength model: load cases, combinations, and the checks.
Neutral fixtures only -- no client geometry, no client numbers. The parity
against the reference tool's real pieces lives in the scratchpad harness; these
are the identities any correct in-place answer must satisfy:
* a cantilever under a uniform wind gives w*L^2/2 at the support (statics);
* a load combination assembles exactly sum(factor * case) and nothing else;
* the flexure util is |Mu| / (phi*Mn from capacity_at), reused not rebuilt;
* axial phi*Pn(C) is 0.85 f'c Ag * 0.80 * 0.65 for a plain/mild section, and
carries the strand reduction where strand is developed;
* fewer than two restraints refuses loudly, the engine's own words.
"""
import math
import pytest
from panellift import capacity, inplace, section
from panellift.model import Panel, Prestress, Strand
# ------------------------------------------------------------------ fixtures
def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
fc_psi=5000.0):
"""A solid, non-prestressed wall -- the mild-wall shape (no strand)."""
return Panel(mark='TEST-WALL', length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in, unit_weight_pcf=150.0,
fc_psi=fc_psi, fci_psi=3500.0, prestress=None)
def strand_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
fc_psi=5000.0):
"""A solid wall carrying two straight strands near the tension face."""
strands = [Strand(y=2.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0,
label='S1'),
Strand(y=8.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0,
label='S2')]
pre = Prestress(strands=strands, loss_pct=8.0, diameter_in=0.5,
fpu_ksi=270.0)
return Panel(mark='TEST-STRAND', length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in, unit_weight_pcf=150.0,
fc_psi=fc_psi, fci_psi=3500.0, prestress=pre)
def cantilever_inputs(pressure_psf=100.0):
"""Restraints at 0 and 20 ft, a 10 ft cantilever above with wind on it."""
return inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0],
wind_zones=[inplace.LateralPressureZone(20.0, 30.0, pressure_psf,
'W cantilever')])
# ------------------------------------------------------- cantilever statics
def test_cantilever_moment_is_w_l_squared_over_two():
"""A uniform wind on a cantilever gives w*L^2/2 at the support.
This is the whole reason the beam reuse is trusted: the reference tool's
own governing WPA1.0 flexure moment IS a cantilever moment, and the closed
form is unarguable.
"""
panel = plain_wall()
inputs = cantilever_inputs(pressure_psf=100.0)
dia, rig = inplace.solve_lateral(panel, inputs.restraint_xs,
inputs.wind_zones, panel.width_ft,
panel.fc_psi)
w_plf = 100.0 * panel.width_ft # 1000 plf
cantilever_ft = 10.0
expected = w_plf * cantilever_ft ** 2 / 2.0 # 50,000 lb-ft
at_support = dia.moment_at(20.0)
assert abs(abs(at_support) - expected) / expected < 0.01
# and the free top closes to zero
assert abs(dia.moment_at(30.0)) < 0.02 * expected
# --------------------------------------------------- load-case superposition
def test_load_case_fields_split_dead_and_wind():
"""Dead is axial only; wind is moment/shear only. No crosstalk."""
panel = plain_wall()
inputs = cantilever_inputs()
xs = inplace.key_stations(panel, inputs)
cases = inplace.build_load_cases(panel, inputs, xs)
d = cases['D']
w = cases['W']
# Dead carries axial, no moment/shear.
assert max(abs(v) for v in d.axial_lb) > 0.0
assert all(v == 0.0 for v in d.moment_lbft)
assert all(v == 0.0 for v in d.shear_lb)
# Wind carries moment/shear, no axial.
assert max(abs(v) for v in w.moment_lbft) > 0.0
assert all(v == 0.0 for v in w.axial_lb)
# Dead axial at the base is the whole self weight; at the top it is zero.
assert d.axial_at(0.0) > d.axial_at(20.0) > 0.0
assert abs(d.axial_at(panel.length_ft)) < 1.0
def test_combination_demand_is_pure_superposition():
"""Mu, Vu, Pu are exactly sum(factor * case) -- LC7 = 0.9D + 1.0W."""
panel = plain_wall()
inputs = cantilever_inputs()
xs = inplace.key_stations(panel, inputs)
cases = inplace.build_load_cases(panel, inputs, xs)
lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7')
x = 20.0
pu, mu, vu = inplace.combination_demand(cases, lc7, x)
# 0.9 * dead axial, 1.0 * wind moment, and T/H are zero cases.
assert pu == pytest.approx(0.9 * cases['D'].axial_at(x), rel=1e-9)
assert mu == pytest.approx(1.0 * cases['W'].moment_at(x), rel=1e-9)
assert vu == pytest.approx(1.0 * cases['W'].shear_at(x), rel=1e-9)
def test_known_aci_combo_assembles_the_right_factored_demand():
"""LC5 = 1.2D + 1.0L + 0.5R + 1.0W applies each factor to its own case.
Built from a hand-set case table so the arithmetic is unarguable: dead 100k
axial, live 20k axial, wind 10 k-ft moment -> LC5 Pu = 1.2*100 + 1.0*20 =
140 k and Mu = 1.0*10 = 10 k-ft.
"""
xs = [0.0, 1.0]
zeros = [0.0, 0.0]
cases = {
'D': inplace.LoadField('D', 'D', xs, [100000.0, 100000.0], list(zeros),
list(zeros)),
'L': inplace.LoadField('L', 'L', xs, [20000.0, 20000.0], list(zeros),
list(zeros)),
'R': inplace.LoadField('R', 'R', xs, [5000.0, 5000.0], list(zeros),
list(zeros)),
'W': inplace.LoadField('W', 'W', xs, list(zeros),
[10000.0, 10000.0], [3000.0, 3000.0]),
}
lc5 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC5')
pu, mu, vu = inplace.combination_demand(cases, lc5, 0.0)
assert pu == pytest.approx(1.2 * 100000 + 1.0 * 20000 + 0.5 * 5000)
assert mu == pytest.approx(1.0 * 10000)
assert vu == pytest.approx(1.0 * 3000)
# ----------------------------------------------------- axial capacity (new)
def test_axial_phi_pn_c_plain_section():
"""phi*Pn(C) = 0.65 * 0.80 * 0.85 f'c Ag for a plain/mild section."""
panel = plain_wall(width_ft=10.0, thickness_in=12.0, fc_psi=5000.0)
stage = inplace.in_place_stage(panel, cantilever_inputs())
ag = section.NetSection(panel, 5.0).area_in2 # 120 in * 12 in
ax = inplace.axial_capacity(panel, stage, 5.0, pu_lb=100000.0,
restraint_xs=[0.0, 20.0])
expected = (inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR
* 0.85 * 5000.0 * ag)
assert ax.phi_pn_c_lb == pytest.approx(expected, rel=1e-9)
assert ax.util == pytest.approx(100000.0 / expected, rel=1e-9)
def test_axial_strand_term_reduces_po():
"""A developed strand REDUCES Po by (f_se - 0.003 Ep) Aps (docs sec.5.1)."""
panel = strand_wall()
stage = inplace.in_place_stage(panel, cantilever_inputs())
# A mid-span station where both strands are fully developed.
x = 15.0
ax = inplace.axial_capacity(panel, stage, x, pu_lb=50000.0,
restraint_xs=[0.0, 20.0])
plain = inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR * \
0.85 * panel.fc_psi * section.NetSection(panel, x).area_in2
# With strand present phi*Pn(C) is LOWER than the plain 0.85 f'c Ag figure.
assert ax.aps_in2 > 0.0
assert ax.phi_pn_c_lb < plain
def test_axial_reports_pcrit():
"""phi*Pcrit is computed (Euler on EI_eff) and flagged approximate."""
panel = plain_wall()
stage = inplace.in_place_stage(panel, cantilever_inputs())
ax = inplace.axial_capacity(panel, stage, 10.0, pu_lb=100000.0,
restraint_xs=[0.0, 20.0])
assert ax.phi_pcrit_lb is not None and ax.phi_pcrit_lb > 0.0
assert ax.pcrit_note and 'approximate' in ax.pcrit_note
# ------------------------------------------------- strength util from capacity
def test_flexure_util_comes_from_capacity_at():
"""The in-place flexure util is a P-M-aware capacity fed the combo's Mu/Pu.
A pure-strand piece (no mild reinforcement) is checked through the
additive `capacity.strand_pm_flexure_at` (Eriksson parity gap #2), NOT
`capacity_at`'s flat pure-bending phi*Mn -- `_check_station` overwrites
`sd.cap` with the P-M numbers when they can be judged. This pins that the
util the run reports is that same P-M number, at the SAME station's own
Pu, not the flat one.
"""
panel = strand_wall()
inputs = cantilever_inputs(pressure_psf=300.0) # enough to bend it
res = inplace.run(panel, inputs)
assert res.refusal is None
# find a combo/station where flexure was judged
found = False
for cr in res.combos:
for sd in cr.stations:
if sd.flex_util is not None:
direct = capacity.strand_pm_flexure_at(
panel, res.stage, sd.x, sd.mu_lbft, pu_lb=sd.pu_lb)
assert sd.flex_util == pytest.approx(direct.m_over_phi_mn)
found = True
assert found, 'no station produced a flexural util to check'
def test_mild_wall_flexure_dashes_not_passes():
"""A no-strand wall dashes phi*Mn with a reason -- never a fabricated pass.
capacity_at is strand-only; the mild flexural capacity is the concurrent
capacity layer's job. The in-place check must surface that as a
could-not-judge, exactly like every other dash in the engine.
"""
panel = plain_wall()
inputs = cantilever_inputs(pressure_psf=300.0)
res = inplace.run(panel, inputs)
combo = res.combos[0]
sd = combo.stations[len(combo.stations) // 2]
assert sd.flex_util is None
assert sd.cap.reason(capacity.PHI_MN) # a reason is present
# ---------------------------------------------------------------- refusal
def test_fewer_than_two_restraints_refuses_loudly():
"""One restraint is not a span -- the engine refuses, in its own words."""
panel = plain_wall()
inputs = inplace.InPlaceInputs(restraint_xs=[0.0], wind_zones=[])
res = inplace.run(panel, inputs)
assert res.refusal is not None
assert 'two supports' in res.refusal
assert not res.combos
def test_run_finds_an_overall_governing_check():
"""A wired run names an overall governing combo/check/util/x."""
panel = strand_wall()
res = inplace.run(panel, cantilever_inputs(pressure_psf=300.0))
assert res.gov_combo and res.gov_check and res.gov_util is not None
assert res.gov_util > 0.0
# ---------------------------------------------------------- combination set
def test_asce_strength_set_matches_the_printed_combinations():
"""The strength set is the ASCE 7-16 2.3 combos with Eriksson's T/H."""
names = [c.name for c in inplace.ASCE_7_16_STRENGTH]
assert names == ['LC1', 'LC2', 'LC3', 'LC4', 'LC5', 'LC6', 'LC7', 'LC8']
lc1 = inplace.ASCE_7_16_STRENGTH[0]
assert lc1.factors['D'] == 1.4
lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7')
assert lc7.factors['D'] == 0.9 and lc7.factors['W'] == 1.0
# every strength combo folds in 1.0T and 1.6H, per docs sec.7
for c in inplace.ASCE_7_16_STRENGTH:
assert c.factors.get('T') == 1.0
assert c.factors.get('H') == 1.6
# ------------------------------------------- eccentric bearing loads (gap #1)
def test_eccentric_dead_load_puts_a_real_moment_on_the_d_case():
"""`AppliedAxial.ecc_in` turns a dead bearing load into a D-case moment.
Before this field existed, `dead_axial_field`/`live_axial_field` only
ever populated `axial_lb` -- `D`'s `moment_lbft` was all zeros
(Eriksson in-place parity gap #1, `_scratch/eriksson_parity_2026-09-04.md`
root cause #1). A load with `ecc_in == 0.0` (the default) must still
leave D's moment at all zeros -- a deck with no eccentric loads is
byte-for-byte what this engine reported before `ecc_in` existed.
"""
panel = plain_wall(length_ft=40.0)
concentric = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0, 40.0],
applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0,
label='P1')])
xs = inplace.key_stations(panel, concentric)
cases = inplace.build_load_cases(panel, concentric, xs)
assert all(v == 0.0 for v in cases['D'].moment_lbft)
eccentric = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0, 40.0],
applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0,
ecc_in=6.0, label='P1')])
cases_ecc = inplace.build_load_cases(panel, eccentric, xs)
assert max(abs(v) for v in cases_ecc['D'].moment_lbft) > 0.0
# axial is unchanged by the eccentricity -- same P(x), a real moment on top.
assert cases_ecc['D'].axial_lb == cases['D'].axial_lb
def test_eccentric_moment_matches_the_point_moment_hand_solution():
"""The D-case moment field IS the `PointMoment` beam solve, hand-checked.
Two restraints only (determinate), so `solve_point_moments`'s answer must
match `PointMoment`'s own closed form exactly, on `eccentric_moments`' OWN
`moment_lbft = -(P*ecc_in/12)` (the minus is the golden-pinned sign, see
`eccentric_moments`' docstring): with `m0 = -(P*ecc_in/12)` at x=10 ft on
a 2-support 20 ft span, R0=-m0/L, RL=+m0/L, and M(x) = -m0*x/L for x<10,
m0*(1-x/20) for x>10.
"""
panel = plain_wall(length_ft=20.0)
p_lb, ecc_in = 40000.0, 6.0
m0 = -(p_lb * ecc_in / 12.0)
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0],
applied_axial=[inplace.AppliedAxial(x_ft=10.0, dead_lb=p_lb,
ecc_in=ecc_in, label='P1')])
xs = sorted(set([2.0, 5.0, 8.0, 12.0, 15.0, 18.0]))
cases = inplace.build_load_cases(panel, inputs, xs)
d = cases['D']
for x in xs:
expected = (-m0 * x / 20.0 if x < 10.0
else m0 * (1.0 - x / 20.0))
assert d.moment_at(x) == pytest.approx(expected, abs=1.0), x
def test_eccentric_moment_sign_matches_the_golden_p1_p6_jumps():
"""The golden's own Mu jump at P1 and P6 (WPA2.0/A27 pp.30-31), reproduced.
P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k, L=12.25k) jumps
+38.9 k-ft crossing its own station in the golden's table (14.87 ft:
-16.6 -> 16.35 ft: +22.3) -- close to the LC2-factored P*e/12 =
(1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft, POSITIVE for a NEGATIVE offset.
P1 (x=11'-1.5"=11.125 ft, ecc_in=+10 in, D=9.62k, L=6.01k) jumps -24.2
k-ft (10.41 ft: +6.5 -> 11.12 ft: -17.7) -- close to
-(1.2*9.62+1.6*6.01)*10/12 = -17.63 k-ft (plus the linear slope over the
0.7 ft gap to golden's own pre-jump row), NEGATIVE for a POSITIVE offset.
This is what pins `eccentric_moments`' `-(P*ecc_in/12)` sign, checked
directly on THIS engine's own D-case (LC2's D factor only, isolating the
mechanism from L/W): the D-only jump at each station must have the sign
the golden's own jump has.
"""
panel = plain_wall(length_ft=74.0 + 4.0 / 12.0)
p1_x, p1_ecc, p1_d = 11.0 + 1.5 / 12.0, 10.0, 9.62e3
p6_x, p6_ecc, p6_d = 15.0 + 11.25 / 12.0, -10.0, 20.18e3
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, panel.length_ft],
applied_axial=[
inplace.AppliedAxial(x_ft=p1_x, dead_lb=p1_d, ecc_in=p1_ecc,
label='P1'),
inplace.AppliedAxial(x_ft=p6_x, dead_lb=p6_d, ecc_in=p6_ecc,
label='P6')])
xs = [0.0, p1_x - 0.5, p1_x + 0.001, p6_x - 0.5, p6_x + 0.001,
panel.length_ft]
cases = inplace.build_load_cases(panel, inputs, sorted(xs))
d = cases['D']
# P6: negative offset -> POSITIVE jump.
assert d.moment_at(p6_x + 0.001) > d.moment_at(p6_x - 0.5)
# P1: positive offset -> NEGATIVE jump.
assert d.moment_at(p1_x + 0.001) < d.moment_at(p1_x - 0.5)
# ------------------------------------------------------ wind envelope (gap #3)
def test_wind_envelope_picks_the_worse_of_pressure_and_suction():
"""Every check is enveloped over BOTH wind directions (Eriksson gap #3).
`ASCE_7_16_STRENGTH` only ever carries a POSITIVE W factor, so without
the envelope a station only ever sees pressure. With an eccentric dead
bearing load ALSO putting a real moment into the same stations, pressure
and suction combine with it differently station to station -- this pins
that `inplace.run`'s reported per-station flex/shear utils are exactly
the max of a hand-run of BOTH `wind_suction_cases` directions, and that
the two directions really do disagree somewhere (otherwise the envelope
would be a no-op and this would not be testing anything).
"""
panel = strand_wall(length_ft=40.0)
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0, 40.0],
wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')],
applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0,
ecc_in=4.0, label='P1')])
res = inplace.run(panel, inputs)
assert res.refusal is None
cases_suction = inplace.wind_suction_cases(res.cases)
assert cases_suction is not None
checked = 0
disagreed = False
for cr in res.combos:
if not cr.combo.factors.get('W', 0.0):
continue
for sd in cr.stations:
a = inplace._check_station(panel, res.stage, res.cases, cr.combo,
sd.x, inputs.restraint_xs)
b = inplace._check_station(panel, res.stage, cases_suction,
cr.combo, sd.x, inputs.restraint_xs)
want_flex = max(a.flex_util if a.flex_util is not None else -1.0,
b.flex_util if b.flex_util is not None else -1.0)
got_flex = sd.flex_util if sd.flex_util is not None else -1.0
assert got_flex == pytest.approx(want_flex, abs=1e-9)
if a.mu_lbft != pytest.approx(b.mu_lbft):
disagreed = True
checked += 1
assert checked > 0
assert disagreed, ('pressure and suction produced the same Mu everywhere '
'-- the fixture is not exercising the envelope')
# --------------------------------------------- LC2 magnitude at a full jump
def test_lc2_reproduces_the_golden_p6_jump_at_close_to_full_magnitude():
"""`key_stations` forcing a node at (and just before) each load is what
lets a combo's factored jump reach close to its full magnitude.
WPA2.0/A27's own P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k,
L=12.25k) jumps +38.9 k-ft crossing its station under the golden's own
LC2 (1.2D+1.6L) -- close to (1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft.
Before `key_stations` forced a node onto every eccentric load's own x
(and a hair before it), the coarse D/L `LoadField`s only sampled the
beam solve's fine diagram at the L/20 grid, and a jump landing INSIDE
one of those coarse intervals got smoothed into a gentle ramp across the
whole interval instead of a sharp step -- Mu magnitudes read ~50-60% of
the golden's own printed jumps. This runs `inplace.run` end to end (not
a hand-built `xs` list) and checks the jump `key_stations` itself
produces is within 10% of the LC2-factored P*e/12 (a looser tolerance
than the hand-solution tests because this station's own backoff point
is a genuine finite distance from the load, not an infinitesimal limit).
"""
panel = plain_wall(length_ft=74.0 + 4.0 / 12.0, width_ft=4.0,
thickness_in=8.0)
p6_x = 15.0 + 11.25 / 12.0
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, panel.length_ft],
applied_axial=[inplace.AppliedAxial(x_ft=p6_x, dead_lb=20.18e3,
live_lb=12.25e3, ecc_in=-10.0,
label='P6')])
xs = inplace.key_stations(panel, inputs)
cases = inplace.build_load_cases(panel, inputs, xs)
lc2 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC2')
before = max(x for x in xs if x < p6_x)
after = min(x for x in xs if x >= p6_x)
_pu_a, mu_a, _vu_a = inplace.combination_demand(cases, lc2, before)
_pu_b, mu_b, _vu_b = inplace.combination_demand(cases, lc2, after)
jump = mu_b - mu_a
expected = (1.2 * 20.18e3 + 1.6 * 12.25e3) * 10.0 / 12.0
assert jump == pytest.approx(expected, rel=0.10), (
'jump {0:.1f} lb-ft vs the LC2-factored P*e/12 {1:.1f} lb-ft'
.format(jump, expected))
# ------------------------------------------------- manual per-zone wind (PANELANALYSIS-4)
def test_manual_zones_pressure_and_suction_give_different_w_fields():
"""A zone with pressure != suction gives W+ != W- (not the old negate).
`wind_zone_mode='manual'` plus a zone carrying DIFFERENT `pressure_psf`
and `suction_psf` magnitudes must NOT reproduce `wind_suction_cases`'s
"negate the pressure field" shortcut -- that shortcut is exact only when
the two magnitudes match, which this fixture deliberately violates
(10 psf pressure, 30 psf suction) so a bug that silently falls back to
the legacy path is caught by the moments actually disagreeing by more
than a sign flip.
"""
panel = plain_wall(length_ft=40.0)
zone = inplace.LateralPressureZone(
x0_ft=0.0, x1_ft=40.0, pressure_psf=10.0, suction_psf=30.0,
label='W1')
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone],
wind_zone_mode='manual')
res = inplace.run(panel, inputs)
assert res.refusal is None
key_xs = res.cases['D'].xs
cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs,
res.cases)
assert cases_suction is not None
w_plus = res.cases['W']
w_minus = cases_suction['W']
# A plain negate would give w_minus.moment == -w_plus.moment everywhere;
# the 3x magnitude difference (30 vs 10 psf) must break that exactly.
ratios = [abs(m) for m in w_minus.moment_lbft if abs(m) > 1e-6]
assert ratios, 'suction case carries no moment at all'
peak_plus = max(abs(m) for m in w_plus.moment_lbft)
peak_minus = max(abs(m) for m in w_minus.moment_lbft)
assert peak_minus == pytest.approx(peak_plus * 3.0, rel=0.02), (
'suction (30 psf) should peak at 3x the pressure (10 psf) case, '
'got {0:.1f} vs {1:.1f}'.format(peak_minus, peak_plus))
def test_manual_zone_with_pressure_only_contributes_nothing_to_suction():
"""A zone with pressure_psf and no suction_psf: W- has nothing from it.
PANELANALYSIS-4: "a zone with pressure only contributes nothing to W-".
With every zone pressure-only, the whole deck has no suction data at all,
so `build_wind_suction_cases` returns None (no envelope pass) rather than
inventing a suction field off the pressure magnitude.
"""
panel = plain_wall(length_ft=40.0)
zone = inplace.LateralPressureZone(
x0_ft=0.0, x1_ft=40.0, pressure_psf=15.0, suction_psf=None,
label='W1')
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone],
wind_zone_mode='manual')
res = inplace.run(panel, inputs)
assert res.refusal is None
key_xs = res.cases['D'].xs
cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs,
res.cases)
assert cases_suction is None
def test_manual_zone_per_zone_width_scales_the_line_load():
"""A zone's OWN `width_ft` (not the panel width) sets its line load.
Two otherwise-identical single-zone runs, one at the panel's width and
one at double it via the zone's own `width_ft`, must differ by exactly
that factor -- pins that `lateral_line_load_plf` reads the zone's width
ahead of the panel-wide fallback.
"""
panel = plain_wall(length_ft=40.0, width_ft=8.0)
narrow = inplace.LateralPressureZone(
x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=None, label='W1')
wide = inplace.LateralPressureZone(
x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=16.0, label='W1')
dia_narrow, _r = inplace.solve_lateral(panel, [0.0, 20.0, 40.0],
[narrow], panel.width_ft,
panel.fc_psi)
dia_wide, _r2 = inplace.solve_lateral(panel, [0.0, 20.0, 40.0],
[wide], panel.width_ft,
panel.fc_psi)
m_narrow = max(abs(dia_narrow.moment_at(x)) for x in
[i * 40.0 / 40.0 for i in range(41)])
m_wide = max(abs(dia_wide.moment_at(x)) for x in
[i * 40.0 / 40.0 for i in range(41)])
assert m_wide == pytest.approx(m_narrow * 2.0, rel=0.02)
def test_legacy_zones_still_use_the_negate_shortcut():
"""A deck with NO manual mode (every zone's `suction_psf` left at its
dataclass default None) keeps the exact old `wind_suction_cases` path --
`build_wind_suction_cases` must dispatch to it byte for byte when
`wind_zone_mode` is left at its own default 'legacy'.
"""
panel = strand_wall(length_ft=40.0)
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0, 40.0],
wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')])
assert inputs.wind_zone_mode == 'legacy'
res = inplace.run(panel, inputs)
key_xs = res.cases['D'].xs
dispatched = inplace.build_wind_suction_cases(panel, inputs, key_xs,
res.cases)
direct = inplace.wind_suction_cases(res.cases)
assert dispatched['W'].moment_lbft == direct['W'].moment_lbft
assert dispatched['W'].shear_lb == direct['W'].shear_lb