WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
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:
@@ -0,0 +1,552 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user