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
+208
View File
@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
"""Prestress, transfer length, and what a blockout does to both.
The numbers in `test_matches_a_commercial_check` are from an independent
run of the same piece through Eriksson Wall v5.2.0. That program was not
consulted while this was written -- its inputs were read off its own file and
its outputs off its own report -- so agreement to the last printed digit is
worth having, and disagreement is worth stopping for.
The piece: 38'-7.5" x 10'-7.5" x 8", one opening 111.25 x 40.25 set 16 in.
from one edge, twelve 1/2 in. 270k strands in two rows.
"""
import pytest
from panellift import checks, prestress, section
from panellift.model import Blockout, Panel, Prestress, Strand
PULL = 0.153 * 270_000 * 0.75 # 75% pull on a 1/2 in. 270k strand
POSITIONS_IN = [3.5, 16.5, 40.5, 59.5, 87.5, 109.5]
ROWS_IN = [1.75, 6.25]
def sample_panel(loss_pct=1.58, blockout=True):
strands = [Strand(y=y / 12.0, z_in=z, area_in2=0.153,
initial_pull_lb=PULL)
for z in ROWS_IN for y in POSITIONS_IN]
blockouts = []
if blockout:
blockouts.append(Blockout(name='OPENING 1', x0=330.0 / 12.0,
y0=16.0 / 12.0, length=111.25 / 12.0,
width=40.25 / 12.0))
return Panel(
mark='sample', length_ft=463.5 / 12.0, width_ft=127.5 / 12.0,
thickness_in=8.0, fc_psi=6000.0, fci_psi=3500.0,
blockouts=blockouts,
prestress=Prestress(strands=strands, loss_pct=loss_pct,
diameter_in=0.5))
# ------------------------------------------------------------------ transfer
def test_transfer_length_follows_the_effective_stress():
panel = sample_panel()
# f_se = 0.75 * 270 * (1 - 0.0158) = 199.3 ksi; lt = f_se * db / 3
assert prestress.transfer_length_in(panel.prestress) == \
pytest.approx(33.2, abs=0.1)
def test_a_strand_picks_its_force_up_over_the_transfer_length():
panel = sample_panel()
lt = prestress.transfer_length_in(panel.prestress) / 12.0
strand = panel.prestress.strands[0] # y = 3.5 in, never cut
assert prestress.developed(strand, panel, 0.0, lt) == 0.0
assert prestress.developed(strand, panel, lt / 2, lt) == pytest.approx(0.5)
assert prestress.developed(strand, panel, lt, lt) == pytest.approx(1.0)
assert prestress.developed(strand, panel, 10.0, lt) == pytest.approx(1.0)
def test_the_far_end_is_a_free_end_too():
panel = sample_panel()
lt = prestress.transfer_length_in(panel.prestress) / 12.0
strand = panel.prestress.strands[0]
assert prestress.developed(strand, panel, panel.length_ft, lt) == 0.0
assert prestress.developed(
strand, panel, panel.length_ft - lt / 2, lt) == pytest.approx(0.5)
# ------------------------------------------------------- blockouts cut strands
def test_a_blockout_cuts_only_the_strands_it_passes_over():
panel = sample_panel()
cut = [s for s in panel.prestress.strands if prestress.cuts_for(s, panel)]
# the opening spans y 16 -> 56.25 in, which is 16.5 and 40.5
assert len(cut) == 4
assert sorted(set(round(s.y * 12.0, 2) for s in cut)) == [16.5, 40.5]
def test_area_inside_the_opening_is_the_uncut_strands_only():
panel = sample_panel()
state = prestress.state_at(panel, 28.97) # mid-opening
assert state.area_in2 == pytest.approx(1.22, abs=0.01)
def test_area_ramps_down_approaching_the_cut():
"""Eriksson reports 1.84 / 1.75 / 1.33 / 1.22 at these four stations."""
panel = sample_panel()
for x_ft, expected in ((22.62, 1.84), (25.11, 1.75),
(27.04, 1.33), (27.50, 1.22)):
state = prestress.state_at(panel, x_ft)
assert state.area_in2 == pytest.approx(expected, abs=0.01), x_ft
def test_a_strand_the_opening_misses_keeps_its_force_through_it():
panel = sample_panel()
lt = prestress.transfer_length_in(panel.prestress) / 12.0
outer = [s for s in panel.prestress.strands
if round(s.y * 12.0, 2) == 109.5][0]
assert prestress.developed(outer, panel, 28.97, lt) == pytest.approx(1.0)
# ------------------------------------------------------------- net section
def test_an_off_centre_opening_moves_the_centroid():
panel = sample_panel()
net = section.NetSection(panel, 28.97)
assert net.area_in2 == pytest.approx(698.0, abs=1.0)
assert net.cy_in == pytest.approx(76.49, abs=0.05)
assert net.sz_in3 == pytest.approx(930.7, abs=1.0)
assert net.iy_in4 == pytest.approx(979_000, rel=0.005)
def test_a_solid_section_is_centred():
panel = sample_panel(blockout=False)
net = section.NetSection(panel, 19.0)
assert net.cy_in == pytest.approx(63.75, abs=0.01)
assert net.area_in2 == pytest.approx(1020.0, abs=0.5)
# ------------------------------------------------- the number that matters
def _corners(panel, x, moment_lbft):
net = section.NetSection(panel, x)
return [c[0] for c in checks._corner_stresses(panel, x, moment_lbft, net)]
def _corner_max(panel, x, moment_lbft):
return max(_corners(panel, x, moment_lbft))
def test_matches_a_commercial_check():
"""417.6 psi at 28.97 ft, from Eriksson Wall on the same piece.
Their factored service moment there is 48.17 k-ft. Feed the same moment
into the same section and the answer has to be the same, or one of us is
wrong about the mechanics rather than about the panel.
"""
panel = sample_panel(loss_pct=1.58)
got = _corner_max(panel, 28.97, 48_170.0)
assert got == pytest.approx(417.6, abs=2.0)
def test_without_prestress_the_same_piece_reads_far_higher():
"""The bug this whole feature exists to fix, kept as a number."""
panel = sample_panel()
bare = Panel(**{**panel.__dict__, 'prestress': None})
assert _corner_max(bare, 28.97, 48_170.0) == pytest.approx(621.0, abs=2.0)
def test_precompression_wipes_out_a_light_stage():
"""Our own stripping moment on this piece, which is larger than theirs.
23.83 k-ft of bending on the solid section against ~359 psi of
precompression: 210 psi of it goes, and the piece comes back well under
the limit instead of at 47% of it.
"""
panel = sample_panel()
assert _corner_max(panel, 10.94, 23_830.0) < 40.0
def test_precompression_and_compression_both_match_at_stripping():
"""Eriksson's stripping at 0.25L: tension 0.0 psi, compression 667.4.
Their moment there is 14.10 k-ft, not ours -- they idealise a static
lift and this idealises rolling blocks, so the two disagree about the
reactions before either says anything about stress. Feeding THEIR moment
in isolates the section from the rigging, and both faces then have to
agree, which is a stronger check than the tension alone: the tension is
zero either way once there is enough precompression, but the compression
is not, and it pins P/A and the eccentricity together.
"""
panel = sample_panel(loss_pct=1.58)
corners = _corners(panel, 9.66, 14_100.0)
assert max(corners) <= 0.0
assert -min(corners) == pytest.approx(667.4, abs=2.0)
def test_a_bigger_loss_gives_back_tension():
"""The loss assumption is a real lever, so it has to move the answer."""
lean = _corner_max(sample_panel(loss_pct=1.58), 28.97, 48_170.0)
fat = _corner_max(sample_panel(loss_pct=8.0), 28.97, 48_170.0)
assert fat > lean + 10.0
# --------------------------------------------------------- sign conventions
def _one_row_panel(z_in):
strands = [Strand(y=y / 12.0, z_in=z_in, initial_pull_lb=PULL)
for y in [15.0, 45.0, 75.0, 105.0]]
return Panel(mark='s', length_ft=30.0, width_ft=10.0, thickness_in=8.0,
fc_psi=6000.0,
prestress=Prestress(strands=strands, loss_pct=0.0))
def test_a_low_strand_helps_the_face_that_sagging_puts_in_tension():
"""Sagging puts tension on the form face, z = 0. A strand below mid-depth
hogs the piece and takes tension off that face; the same strand above
mid-depth adds to it.
Nothing in the commercial comparison can catch this: that piece has two
symmetric rows, so its eccentricity is exactly zero and the term this
tests does not exist. It was written wrong the first time and this is
what found it.
"""
low = _corners(_one_row_panel(1.0), 15.0, 20_000.0)[:2] # cz = -t/2
mid = _corners(_one_row_panel(4.0), 15.0, 20_000.0)[:2]
high = _corners(_one_row_panel(7.0), 15.0, 20_000.0)[:2]
assert max(low) < max(mid) < max(high)