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