WillTools: precast shop-ticket QC and panel handling tools
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.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
# -*- 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