8e6ee31596
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.
553 lines
26 KiB
Python
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
|