52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
258 lines
11 KiB
Python
258 lines
11 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 capacity_at's m_over_phi_mn on Mu.
|
|
|
|
The strength check reuses capacity_at rather than reimplementing phi*Mn;
|
|
this pins that the util the run reports is that same number fed the combo's
|
|
factored moment.
|
|
"""
|
|
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.capacity_at(panel, res.stage, sd.x,
|
|
sd.mu_lbft, sd.vu_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
|