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,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The mild-reinforced flexural path: capacity.mild_flexure_at.
|
||||
|
||||
The reference tool (Eriksson Wall) computes a Whitney-block flexural capacity
|
||||
for a NON-prestressed (mild-steel) wall panel -- and for a COMBINED piece --
|
||||
which `capacity_at` dashes ('no prestressing on this piece'). This pins that
|
||||
path, which is additive: `capacity_at` and the strand-only `handling_flexure_at`
|
||||
are untouched, and a pure-strand piece never reaches the mild solver.
|
||||
|
||||
Every number in the hand-check section is HAND COMPUTED, with the arithmetic in
|
||||
the comment beside it, on a rectangular section chosen so the closed forms are
|
||||
exact:
|
||||
|
||||
b = 48 in. t = 8 in. f'c = 4000 psi w_c = 150 pcf fy = 60 ksi
|
||||
|
||||
f'c = 4000 is the corner of ACI Table 22.2.2.4.3, so beta1 = 0.85 with no
|
||||
interpolation; w_c = 150 puts lambda at 1.0. Es = 29000 ksi (mild).
|
||||
|
||||
The parity against the reference tool's own golden phi*Mn (SWA1.0 172.1 k-ft,
|
||||
WPA1.0-12in 480.1 k-ft) lives in scratchpad/parity_check.py, off the client
|
||||
decks; this file is the reproducible-on-paper unit check.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, 'src'))
|
||||
|
||||
from panellift import capacity, pci # noqa: E402
|
||||
from panellift.model import (Panel, Prestress, Reinforcement, # noqa: E402
|
||||
Stage, Strand)
|
||||
|
||||
|
||||
B_IN = 48.0
|
||||
T_IN = 8.0
|
||||
FC = 4000.0
|
||||
|
||||
|
||||
def mild_panel(layers, width_ft=B_IN / 12.0, thickness_in=T_IN):
|
||||
return Panel(mark='MILD-1', length_ft=32.0, width_ft=width_ft,
|
||||
thickness_in=thickness_in, unit_weight_pcf=150.0,
|
||||
fci_psi=FC, fc_psi=FC, reinforcement=layers)
|
||||
|
||||
|
||||
def one_layer(area_in2, z_in, fy=60.0, count=1):
|
||||
return Reinforcement(kind='bar', label='M', area_in2=area_in2, z_in=z_in,
|
||||
grade_ksi=fy, count=count)
|
||||
|
||||
|
||||
STAGE = Stage(name='mild', multiplier=1.0, fc_psi=FC)
|
||||
|
||||
|
||||
# ---------------------------------------------------- 1. hand calculations
|
||||
|
||||
def test_mild_mn_no_axial_matches_the_hand_calculation():
|
||||
"""One layer As = 2.0 in^2 at d = 6.0 in (z = t - d = 2.0), fy = 60, Pu = 0.
|
||||
|
||||
T = As*fy = 2.0 * 60,000 = 120,000 lb (yields)
|
||||
a = T/(0.85 f'c b) = 120,000/(0.85*4000*48) = 0.735294 in
|
||||
c = a/beta1 = 0.735294/0.85 = 0.865052 in
|
||||
eps_t = 0.003*(6 - 0.865052)/0.865052 = 0.017808 -> phi 0.90
|
||||
M_n = T*(d - a/2) = 120,000*(6 - 0.367647) = 675,882.4 lb-in
|
||||
= 56,323.53 lb-ft
|
||||
phi*M_n = 0.9 * 56,323.53 = 50,691.18 lb-ft
|
||||
"""
|
||||
# sagging (+M): compression on the z=t face, so d = t - z = 8 - 2 = 6.
|
||||
mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0)
|
||||
assert abs(mp.as_in2 - 2.0) < 1e-9
|
||||
assert abs(mp.a_in - 0.735294) < 1e-4
|
||||
assert abs(mp.c_in - 0.865052) < 1e-4
|
||||
assert abs(mp.strain_t - 0.017808) < 1e-5
|
||||
assert mp.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED == 0.90
|
||||
assert abs(mp.mn_lbft - 56323.53) < 1.0
|
||||
assert abs(mp.phi_mn_lbft - 50691.18) < 1.0
|
||||
assert abs(mp.m_over_phi_mn - 10000.0 / 50691.18) < 1e-6
|
||||
|
||||
|
||||
def test_mild_mn_with_axial_shifts_the_neutral_axis_and_raises_mn():
|
||||
"""Same layer, now with Pu = 50,000 lb COMPRESSION on the P-M surface.
|
||||
|
||||
Equilibrium C - Pu - T = 0, steel still yields, so
|
||||
C = Pu + T = 50,000 + 120,000 = 170,000 lb
|
||||
a = C/(0.85 f'c b) = 170,000/163,200 = 1.041667 in
|
||||
c = a/beta1 = 1.041667/0.85 = 1.225490 in
|
||||
M_n = C*(c - a/2) - T*(c - d) - Pu*(c - Cy), Cy = t/2 = 4
|
||||
= 170,000*0.704657 - 120,000*(-4.774510) - 50,000*(-2.774510)
|
||||
= 119,791.7 + 572,941.2 + 138,725.5 = 831,458.4 lb-in
|
||||
= 69,288.2 lb-ft
|
||||
phi*M_n = 0.9 * 69,288.2 = 62,359.4 lb-ft
|
||||
|
||||
The axial compression deepens the block and raises M_n -- the P-M coupling
|
||||
(docs sec.4.2). A tool that ignored Pu would report the 56.3 k-ft above.
|
||||
"""
|
||||
mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0,
|
||||
pu_lb=50000.0)
|
||||
assert abs(mp.a_in - 1.041667) < 1e-4
|
||||
assert abs(mp.c_in - 1.225490) < 1e-4
|
||||
assert abs(mp.mn_lbft - 69288.2) < 1.0
|
||||
assert abs(mp.phi_mn_lbft - 62359.4) < 1.0
|
||||
# and it is strictly larger than the no-axial capacity
|
||||
base = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0)
|
||||
assert mp.mn_lbft > base.mn_lbft
|
||||
|
||||
|
||||
def test_two_symmetric_layers_reproduce_the_SWA_style_couple():
|
||||
"""(2)#11 at d-CG 3 and 9 in a 12 in x 14 ft solid section, f'c = 6 ksi,
|
||||
Pu = 21.1 k hogging -- the SWA1.0 shape. Golden phi*Mn = 172.1 k-ft, and
|
||||
the strain-compat couple here lands at 171.1 k-ft (-0.6%). Kept as a
|
||||
reproducible echo of the parity harness inside the suite."""
|
||||
panel = Panel(mark='SWA1.0', length_ft=32.0, width_ft=14.0,
|
||||
thickness_in=12.0, unit_weight_pcf=150.0,
|
||||
fci_psi=2500.0, fc_psi=6000.0,
|
||||
reinforcement=[one_layer(1.56, z_in=3.0, count=2),
|
||||
one_layer(1.56, z_in=9.0, count=2)])
|
||||
stage = Stage(name='inplace', multiplier=1.0, fc_psi=6000.0)
|
||||
mp = capacity.mild_flexure_at(panel, stage, x=16.0, moment_lbft=-100300.0,
|
||||
pu_lb=21100.0)
|
||||
assert abs(mp.as_in2 - 6.24) < 1e-9
|
||||
assert mp.phi == 0.90
|
||||
assert abs(mp.phi_mn_lbft / 1000.0 - 172.1) / 172.1 < 0.03 # within 3%
|
||||
assert abs(mp.m_over_phi_mn - 0.58) < 0.02
|
||||
|
||||
|
||||
# ------------------------------------------------ 2. the strain-based phi
|
||||
|
||||
def test_phi_uses_the_MILD_075_floor_not_the_prestressed_065():
|
||||
"""A heavily reinforced layer drops eps_t into the transition band, so phi
|
||||
is between the floor and 0.90 -- and the floor is the MILD 0.75, read
|
||||
through the SAME phi_flexure_from_strain helper (docs sec.2.2)."""
|
||||
mp = capacity.mild_flexure_at(mild_panel([one_layer(8.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0)
|
||||
assert pci.PHI_FLEXURE_COMPRESSION_CONTROLLED_MILD == 0.75
|
||||
assert capacity.PHI_FLEXURE_MILD_FLOOR == 0.75
|
||||
# eps_t is in the 0.002..0.005 transition, so phi is strictly between
|
||||
assert 0.002 < mp.strain_t < 0.005
|
||||
assert 0.75 < mp.phi < 0.90
|
||||
# and it is exactly the helper's value on the mild floor
|
||||
assert abs(mp.phi - capacity.phi_flexure_from_strain(
|
||||
mp.strain_t, phi_min=0.75)) < 1e-12
|
||||
# the prestressed 0.65 floor would give a strictly lower phi here
|
||||
assert capacity.phi_flexure_from_strain(mp.strain_t, phi_min=0.65) < mp.phi
|
||||
|
||||
|
||||
# ------------------------------------------------------- 3. combined piece
|
||||
|
||||
def test_combined_strand_plus_mild_credits_both():
|
||||
"""A piece with BOTH strand and mild steel carries them on one P-M surface;
|
||||
adding a strand group to a mild section raises M_n and flags combined."""
|
||||
mild_only = mild_panel([one_layer(2.0, z_in=2.0)])
|
||||
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
|
||||
initial_pull_lb=30000.0) for i in range(4)]
|
||||
combined = mild_panel([one_layer(2.0, z_in=2.0)])
|
||||
combined.prestress = Prestress(strands=strands, loss_pct=10.0,
|
||||
diameter_in=0.5, transfer_length_in=30.0)
|
||||
mp_mild = capacity.mild_flexure_at(mild_only, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
mp_comb = capacity.mild_flexure_at(combined, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert mp_mild.combined is False
|
||||
assert mp_comb.combined is True
|
||||
# both bars are on the tension side (z=2 -> d=6 sagging), so the extra
|
||||
# strand tension raises the couple
|
||||
assert mp_comb.mn_lbft > mp_mild.mn_lbft
|
||||
# as_in2 counts MILD only; the strand rides its own layer
|
||||
assert abs(mp_comb.as_in2 - 2.0) < 1e-9
|
||||
|
||||
|
||||
# ----------------------------------------------- 4. could-not-judge, dashes
|
||||
|
||||
def test_a_piece_with_no_mild_steel_says_so_rather_than_computing_zero():
|
||||
mp = capacity.mild_flexure_at(mild_panel([]), STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert mp.phi_mn_lbft is None
|
||||
assert mp.mn_lbft is None
|
||||
assert 'no mild reinforcement' in mp.why
|
||||
|
||||
|
||||
def test_has_mild_reinforcement_predicate():
|
||||
assert capacity.has_mild_reinforcement(
|
||||
mild_panel([one_layer(2.0, z_in=2.0)])) is True
|
||||
assert capacity.has_mild_reinforcement(mild_panel([])) is False
|
||||
# a strand layer is not mild
|
||||
strand_layer = Reinforcement(kind='strand', label='S', area_in2=0.153,
|
||||
z_in=2.0, grade_ksi=270.0, count=4,
|
||||
initial_pull_lb=30000.0)
|
||||
assert capacity.has_mild_reinforcement(mild_panel([strand_layer])) is False
|
||||
|
||||
|
||||
# ------------------------------- 5. the strand-only path is byte-unchanged
|
||||
|
||||
def test_flexure_at_leaves_a_pure_strand_piece_on_the_existing_path():
|
||||
"""flexure_at must not route a strand-only piece through the mild solver.
|
||||
On such a piece it returns EXACTLY handling_flexure_at(capacity_at(...)) --
|
||||
the additive path the task requires to stay byte-for-byte unchanged."""
|
||||
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
|
||||
initial_pull_lb=30000.0) for i in range(4)]
|
||||
panel = Panel(mark='STRAND-1', length_ft=32.0, width_ft=B_IN / 12.0,
|
||||
thickness_in=T_IN, unit_weight_pcf=150.0,
|
||||
fci_psi=FC, fc_psi=FC,
|
||||
prestress=Prestress(strands=strands, loss_pct=10.0,
|
||||
diameter_in=0.5, transfer_length_in=30.0))
|
||||
assert capacity.has_mild_reinforcement(panel) is False
|
||||
cap = capacity.capacity_at(panel, STAGE, 16.0, 10000.0, 0.0)
|
||||
want = capacity.handling_flexure_at(cap, 10000.0)
|
||||
got = capacity.flexure_at(panel, STAGE, 16.0, 10000.0, cap=cap)
|
||||
assert got == want
|
||||
assert got[1] is not None # it really did compute one
|
||||
|
||||
|
||||
# --------------------- 6. from_ft/to_ft range (PANELANALYSIS-4 mild_bars)
|
||||
|
||||
def test_layer_with_a_range_contributes_only_inside_it():
|
||||
"""A `Reinforcement` carrying `from_ft`/`to_ft` is present only there --
|
||||
phi*Mn INSIDE the run must exceed phi*Mn at a station OUTSIDE it (the
|
||||
outside station falls back to whatever other steel is on the panel, here
|
||||
none at all, so it dashes with 'no mild reinforcement').
|
||||
"""
|
||||
layer = Reinforcement(kind='bar', label='M', area_in2=2.0, z_in=2.0,
|
||||
grade_ksi=60.0, count=1, from_ft=10.0, to_ft=20.0)
|
||||
panel = mild_panel([layer], width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
|
||||
inside = capacity.mild_flexure_at(panel, STAGE, x=15.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert inside.why is None
|
||||
assert inside.phi_mn_lbft is not None
|
||||
assert inside.as_in2 == pytest.approx(2.0)
|
||||
|
||||
outside = capacity.mild_flexure_at(panel, STAGE, x=25.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert outside.why == 'no mild reinforcement on this piece'
|
||||
assert outside.phi_mn_lbft is None
|
||||
|
||||
|
||||
def test_layer_with_no_range_is_unchanged_everywhere():
|
||||
"""`from_ft`/`to_ft` both None (every layer before this field existed)
|
||||
behaves identically at two different stations -- the byte-identical
|
||||
guarantee for every deck that never sets a range."""
|
||||
layer = Reinforcement(kind='bar', label='M', area_in2=2.0, z_in=2.0,
|
||||
grade_ksi=60.0, count=1)
|
||||
panel = mild_panel([layer], width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
a = capacity.mild_flexure_at(panel, STAGE, x=4.0, moment_lbft=+10000.0)
|
||||
b = capacity.mild_flexure_at(panel, STAGE, x=28.0, moment_lbft=+10000.0)
|
||||
assert a.phi_mn_lbft == pytest.approx(b.phi_mn_lbft)
|
||||
assert a.as_in2 == pytest.approx(b.as_in2) == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_mild_bars_with_depth_raise_phi_mn_over_bars_without():
|
||||
"""A depth-bearing mild-bar layer measurably raises phi*Mn over a bare
|
||||
section (no reinforcement at all) at the same station -- pins that a
|
||||
`mild_bars` row with a real depth genuinely enters the capacity, not
|
||||
just parses without error."""
|
||||
bare = mild_panel([], width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
reinforced = mild_panel(
|
||||
[Reinforcement(kind='bar', label='#4', area_in2=4.8, z_in=2.0,
|
||||
grade_ksi=60.0, count=1)],
|
||||
width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
|
||||
bare_pt = capacity.mild_flexure_at(bare, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
reinforced_pt = capacity.mild_flexure_at(reinforced, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert bare_pt.why == 'no mild reinforcement on this piece'
|
||||
assert bare_pt.phi_mn_lbft is None
|
||||
assert reinforced_pt.phi_mn_lbft is not None
|
||||
assert reinforced_pt.phi_mn_lbft > 0.0
|
||||
Reference in New Issue
Block a user