Files
willtools-demo/panellift/tests/test_row_flexure.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

396 lines
18 KiB
Python

# -*- coding: utf-8 -*-
"""The strip ACROSS the width, between the inserts of one pick row.
The beam solve calls each support line rigid. It is not: the inserts in a
row are discrete points, and the panel between two of them carries its
share sideways to reach one. These tests hold the ROW-FLEX arithmetic to
hand numbers on the ASPIRE panel -- 10 ft wide, 8.75 in thick, two lines
of two at the Figure 8.3.2 positions -- where every value can be written
on one line.
"""
import dataclasses
import re
from conftest import LENGTH_FT, WIDTH_FT, THICKNESS_IN, make_panel
from panellift import checks, pci, statics
from panellift.model import Blockout, Lifter, Rigging, Stage
WEIGHT_LB = 35000.0 # 32 ft x 10 ft x 8.75 in of 150 pcf concrete
def _panel():
return dataclasses.replace(make_panel(), weight_lb=WEIGHT_LB)
def _findings(panel, stage, rolling):
a = statics.analyse(panel, stage, rolling, role='strip')
return checks.check_row_transverse_flexure(a)
def _codes(findings):
return [f.code for f in findings]
def _f_t_psi(finding):
m = re.search(r'f_t ([0-9.]+) psi', finding.message)
return float(m.group(1))
def _expected_stress(stripping, gap_ft, depth_ft):
"""Hand arithmetic for the ASPIRE pick, straight from the definition.
Two symmetric lines under an equalising rig: each carries half the
factored weight. Spread over the width that is w; the strip between or
past the inserts is w*gap^2/8 inside, /2 at an overhang; the web depth
is the longitudinal concrete beside the row.
"""
w_lbft = WEIGHT_LB * stripping.multiplier / 2.0 / WIDTH_FT
m_lbft = w_lbft * gap_ft ** 2 / 8.0
s_in3 = THICKNESS_IN * (depth_ft * 12.0) ** 2 / 6.0
return m_lbft * 12.0 / s_in3
# ------------------------------------------------------------------- solid
def test_the_strip_is_noise_on_a_solid_panel(stripping, rolling):
"""Same deep-beam stiffness that makes EDGE-PATH ~1% of allowable."""
found = _findings(_panel(), stripping, rolling)
assert len(found) == 1
assert found[0].severity == 'OK'
assert _f_t_psi(found[0]) < 5.0
def test_the_arithmetic_is_locked_to_a_hand_number(stripping, rolling):
left, right = pci.optimal_two_line_supports(LENGTH_FT)
# Interior gap is WIDTH/2; the web beside either row is the inner span.
expected = _expected_stress(stripping, WIDTH_FT / 2.0, right - left)
found = _findings(_panel(), stripping, rolling)[0]
assert abs(_f_t_psi(found) - expected) < 0.05
def test_the_multiplier_is_on_the_load():
"""Concrete stress against concrete allowable: factored, unlike INSERT."""
roll = Rigging(mode='rolling', sling_angle_deg=90.0)
gentle = _findings(_panel(), Stage('a', 1.0, 4000.0), roll)[0]
harsh = _findings(_panel(), Stage('b', 2.0, 4000.0), roll)[0]
assert abs(_f_t_psi(harsh) - 2.0 * _f_t_psi(gentle)) <= 0.15
def test_an_overhang_past_the_end_insert_governs_when_it_must(stripping,
rolling):
"""Both inserts of each row pushed to mid-width: the strips hanging off
the ends are 4.9 ft cantilevers against a 0.2 ft interior span."""
panel = _panel()
panel = dataclasses.replace(panel, lifters=[
dataclasses.replace(l, y=4.9 + (i % 2) * 0.2)
for i, l in enumerate(panel.lifters)])
found = _findings(panel, stripping, rolling)[0]
assert 'overhang' in found.message
left, right = pci.optimal_two_line_supports(LENGTH_FT)
w_lbft = WEIGHT_LB * stripping.multiplier / 2.0 / WIDTH_FT
m_lbft = w_lbft * 4.9 ** 2 / 2.0
s_in3 = THICKNESS_IN * ((right - left) * 12.0) ** 2 / 6.0
assert abs(_f_t_psi(found) - m_lbft * 12.0 / s_in3) < 0.05
# ------------------------------------------------------- openings cut the web
def _row_opening(depth_in=None):
"""Straddles the left pick row and reaches into the insert-to-insert
strip, leaving 1.0 ft of web on each side of the row."""
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
return Blockout(name='BIG', x0=left - 1.0, y0=4.0, length=2.0,
width=3.0, depth_in=depth_in)
def _side_opening(offset_ft, length_ft=4.0, depth_in=None):
"""A full-depth opening wholly on one side of the left pick row.
offset_ft is the clear distance from the row to the opening's near face:
positive puts it inboard (to the right of the row), negative outboard.
"""
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
x0 = (left + offset_ft) if offset_ft > 0 else (left + offset_ft - length_ft)
return Blockout(name='SIDE', x0=x0, y0=3.0, length=length_ft, width=4.0,
depth_in=depth_in)
def test_an_opening_on_one_side_leaves_the_other_side_untouched(stripping,
rolling):
"""The web is the concrete BESIDE the row, not the part of the hole that
happens to lie on each side of it. An opening entirely outboard of the
row does not touch the concrete inboard of it, and the deeper side is
what the strip spans on."""
solid = _findings(_panel(), stripping, rolling)[0]
panel = dataclasses.replace(_panel(), blockouts=[_side_opening(-0.25)])
found = _findings(panel, stripping, rolling)[0]
assert found.severity == 'OK'
assert abs(_f_t_psi(found) - _f_t_psi(solid)) < 0.05
def test_an_opening_inboard_of_the_row_leaves_only_the_outboard_end(
stripping, rolling):
"""Mirror of the above: the inboard web is starved to 3 in, so the strip
spans on the outboard end of the piece instead."""
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
panel = dataclasses.replace(_panel(), blockouts=[_side_opening(0.25)])
found = _findings(panel, stripping, rolling)[0]
assert 'opening SIDE' in found.message
assert '{0:.1f} in'.format(left * 12.0) in found.message
def test_a_bigger_hole_through_the_row_never_reads_as_a_deeper_web(
stripping, rolling):
"""The failure this replaces: the web was measured to the opening's FAR
face, so lengthening a hole that runs through the pick row made the strip
deeper and the stress lower -- monotonically backwards."""
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
depths = []
for length in (1.0, 2.0, 4.0, 8.0):
panel = dataclasses.replace(_panel(), blockouts=[Blockout(
name='GROW', x0=left - length / 2.0, y0=3.0, length=length,
width=4.0)])
found = _findings(panel, stripping, rolling)
codes = _codes(found)
assert 'ROW-FLEX-INTERRUPTED' in codes
depths.append(codes)
# Every one of them is interrupted; none of them is rated as a beam that
# got deeper. The old code produced 12, 24, 48 and 96 in of "web".
assert all('ROW-FLEX-INTERRUPTED' in c for c in depths)
def test_a_row_running_through_an_opening_is_not_rated_at_all(stripping,
rolling):
"""No concrete beside the row means no strip. Rating it would invent a
section; failing it would condemn a piece this model cannot speak about."""
panel = dataclasses.replace(_panel(), blockouts=[_row_opening()])
found = _findings(panel, stripping, rolling)
notes = [f for f in found if f.code == 'ROW-FLEX-INTERRUPTED']
assert len(notes) == 1 # one per row and opening, not gap
assert notes[0].severity == 'WARN'
assert 'runs through opening BIG' in notes[0].message
assert 'cannot rate it' in notes[0].message
# The other row is solid and is still rated.
rated = [f for f in found if f.code == 'ROW-FLEX']
assert len(rated) == 1 and rated[0].severity == 'OK'
def test_an_opening_clear_of_the_strip_across_the_width_does_not_sever_it(
stripping, rolling):
"""Six inches clear is clear. A tolerance that says otherwise turns
concrete that is there into a finding, 184 times over."""
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
solid = _findings(_panel(), stripping, rolling)[0]
# Inserts at y = 2.5 and 7.5; this sits between 7.9 and 9.9, clear of the
# 2.5-7.5 strip by 0.4 ft, and it straddles the row longitudinally.
panel = dataclasses.replace(_panel(), blockouts=[Blockout(
name='CLEAR', x0=left - 1.0, y0=7.9, length=2.0, width=2.0)])
found = _findings(panel, stripping, rolling)
interrupting = [f for f in found if f.code == 'ROW-FLEX-INTERRUPTED']
# It interrupts only the overhang it actually crosses, and says where.
assert len(interrupting) == 1
assert '7.90 to 9.90 ft' in interrupting[0].message
# The strip it is 0.4 ft clear of is not severed: what is still rated
# stays down at solid-panel noise rather than reporting a cut web.
rated = [f for f in found if f.code == 'ROW-FLEX']
assert len(rated) == 1 and rated[0].severity == 'OK'
assert _f_t_psi(rated[0]) < 2.0 * _f_t_psi(solid)
def test_a_partial_depth_void_is_not_a_web_cut(stripping, rolling):
"""House rule: partial-depth blockouts do not reduce section properties.
The finding must come out numerically identical to the solid panel's."""
solid = _findings(_panel(), stripping, rolling)[0]
panel = dataclasses.replace(_panel(),
blockouts=[_row_opening(depth_in=4.0)])
found = _findings(panel, stripping, rolling)[0]
assert 'opening' not in found.message
assert abs(_f_t_psi(found) - _f_t_psi(solid)) < 0.001
# ------------------------------------------------------------------ guards
def test_a_stage_standing_on_its_edge_belongs_to_edge_path(stripping,
rolling):
"""Within-row gaps along the anchored top edge are EDGE-PATH's concrete;
checking them here too would report the same strip under two codes."""
found = _findings(_panel(), Stage('e', 1.4, 4000.0, span='width'),
rolling)
assert found == []
def test_a_dunnage_stage_has_no_pick_rows(shipping, rolling):
panel = dataclasses.replace(_panel())
stage = Stage(name='transportation', multiplier=1.35, fc_psi=5000.0,
supports=[5.0, LENGTH_FT - 5.0])
assert _findings(panel, stage, rolling) == []
def test_no_weight_says_so_instead_of_passing(stripping, rolling):
found = _findings(make_panel(), stripping, rolling) # weight_lb=None
assert _codes(found) == ['ROW-FLEX']
assert found[0].severity == 'WARN'
def test_no_concrete_strength_is_not_a_pass(rolling):
found = _findings(_panel(), Stage('e', 1.52, 0.0), rolling)
assert _codes(found) == ['ROW-FLEX-UNRATED']
assert found[0].severity == 'WARN'
def test_run_all_carries_the_check(stripping, rolling):
a = statics.analyse(_panel(), stripping, rolling, role='strip')
assert 'ROW-FLEX' in _codes(checks.run_all(a, rolling))
# ------------------------------- a line that is not a row is not rated
def _staggered_panel():
"""A real shape out of the model: the two inserts of one row sit 0.5 ft
apart along the piece, which is more than handling.group_tolerance_ft, so
the solve reads them as two support lines each holding one insert."""
panel = _panel()
left, right = pci.optimal_two_line_supports(LENGTH_FT)
return dataclasses.replace(panel, lifters=[
dataclasses.replace(panel.lifters[0], x=left, y=2.5),
dataclasses.replace(panel.lifters[1], x=left + 0.5, y=7.5),
dataclasses.replace(panel.lifters[2], x=right, y=2.5),
dataclasses.replace(panel.lifters[3], x=right, y=7.5)])
def test_a_line_holding_one_insert_is_not_rated_as_a_row(stripping, rolling):
"""One insert has no strip BETWEEN inserts -- the width hangs off a
point. Rating it cantilevered the whole half-width off that point and,
with an opening beside it, reported 33,491 psi on a piece that ships."""
found = _findings(_staggered_panel(), stripping, rolling)
notes = [f for f in found if f.code == 'ROW-FLEX-NOT-A-ROW']
assert notes and all(f.severity == 'WARN' for f in notes)
assert 'one insert' in notes[0].message
assert 'group_tolerance_ft' in notes[0].message
assert not [f for f in found if f.severity == 'FAIL']
def test_a_line_being_pulled_up_is_not_rated_either(stripping):
"""Two support lines half a foot apart make the static solve lift one of
them: -12,621 lb against +38,138 lb next to it. Taking abs() of that and
rating a strip against the result is arithmetic on a number that means
the opposite of what it says.
Static, not rolling, because that is the mode that produces it: an
equalising rig shares the load and never lifts a line, so under rolling
this case cannot arise and a test written on it would pass without
running the code it names.
"""
static = Rigging(mode='static')
a = statics.analyse(_staggered_panel(), stripping, static, role='strip')
lifted = [l for l in a.rig.lines if l.reaction_lb <= 0.0]
assert lifted, 'the fixture stopped producing an uplift reaction'
found = checks.check_row_transverse_flexure(a)
assert not [f for f in found if f.severity == 'FAIL']
# This geometry trips 'one insert' first, so force a two-insert line to
# be the one lifted and check the uplift branch on its own terms.
panel = _panel()
left, right = pci.optimal_two_line_supports(LENGTH_FT)
panel = dataclasses.replace(panel, lifters=[
dataclasses.replace(panel.lifters[0], x=left, y=2.5),
dataclasses.replace(panel.lifters[1], x=left, y=7.5),
dataclasses.replace(panel.lifters[2], x=left + 0.5, y=2.5),
dataclasses.replace(panel.lifters[3], x=left + 0.5, y=7.5)])
a = statics.analyse(panel, stripping, static, role='strip')
assert any(l.reaction_lb <= 0.0 and len(l.lifters) == 2
for l in a.rig.lines), 'no two-insert line was lifted'
found = checks.check_row_transverse_flexure(a)
assert any('pulled UP' in f.message for f in found)
assert not [f for f in found if f.severity == 'FAIL']
def test_a_severed_band_does_not_become_a_zero_depth_failure(stripping,
rolling):
"""WP156's shape: one opening crosses the row inside the strip, another
sits far away on the same strip. The crossing one severs a band -- the
load goes around it -- and the far one must not turn that into a
'no section at all' FAIL naming the wrong opening."""
left, right = pci.optimal_two_line_supports(LENGTH_FT)
panel = dataclasses.replace(_panel(), blockouts=[
# Squeeze the concrete beside the row to 6 in on both sides...
Blockout(name='OUTBOARD', x0=left - 1.17, y0=4.0, length=0.67,
width=3.0),
Blockout(name='INBOARD', x0=left + 0.5, y0=4.0, length=0.67,
width=3.0),
# ...and cut the strip itself with a third that crosses the row.
Blockout(name='CROSSING', x0=left - 0.3, y0=4.0, length=0.67,
width=3.0)])
found = _findings(panel, stripping, rolling)
notes = [f for f in found if f.code == 'ROW-FLEX-INTERRUPTED']
assert len(notes) == 1
assert 'opening CROSSING' in notes[0].message
assert 'INBOARD' not in notes[0].message
# The severed strip must not ALSO be rated behind the note. Carrying on
# with the two squeezing openings rates that row on 6 in of web and
# reports 1,750 psi against 296 -- a FAIL invented for a strip the model
# has just said it cannot speak about.
assert not [f for f in found if f.severity == 'FAIL']
rated = [f for f in found if f.code == 'ROW-FLEX']
assert len(rated) == 1
assert abs(rated[0].at_ft - right) < 0.01
# --------------------------- where the pinch sits is most of the answer
def _pinched(y0, y1):
"""Two openings, one each side of the left pick row, squeezing the web to
6 in over the band y0..y1 of the strip between the inserts at 2.5 and 7.5.
"""
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
return dataclasses.replace(_panel(), blockouts=[
Blockout(name='L', x0=left - 1.17, y0=y0, length=0.67, width=y1 - y0),
Blockout(name='R', x0=left + 0.5, y0=y0, length=0.67, width=y1 - y0)])
def test_a_pinch_beside_an_insert_costs_less_than_one_at_midspan(stripping,
rolling):
"""A strip between two inserts carries its largest moment at midspan and
none at all at the inserts. Concrete missing where the moment is zero is
not the same as concrete missing where it peaks -- and taking the
smallest web anywhere and applying it to the whole strip said it was,
reporting 522 psi against 444 on a piece whose midspan is solid.
"""
at_end = _f_t_psi(_findings(_pinched(2.6, 3.1), stripping, rolling)[0])
at_mid = _f_t_psi(_findings(_pinched(4.75, 5.25), stripping, rolling)[0])
assert at_mid > 2.0 * at_end
def test_the_pinched_band_is_rated_at_its_own_moment(stripping, rolling):
"""Closed form for the band beside the insert: the strip spans 2.5 to
7.5, the band ends at 3.1, and a simply supported span carries
w*(y-a)*(b-y)/2 there -- not w*L^2/8, which belongs to midspan.
w comes off the solved line rather than half the weight: the section is
6 in deep, so a per-cent of reaction is tens of psi here and an
approximate w would make this test about the approximation.
"""
panel = _pinched(2.6, 3.1)
a = statics.analyse(panel, stripping, rolling, role='strip')
line = min(a.rig.lines, key=lambda l: l.x)
w_lbft = line.reaction_lb / WIDTH_FT
m_lbft = w_lbft * (3.1 - 2.5) * (7.5 - 3.1) / 2.0
s_in3 = THICKNESS_IN * 6.0 ** 2 / 6.0 # 6 in of web
found = checks.check_row_transverse_flexure(a)[0]
assert abs(_f_t_psi(found) - m_lbft * 12.0 / s_in3) < 0.2
def test_a_pinch_over_the_whole_strip_still_governs_at_midspan(stripping,
rolling):
"""The uniform case has to survive the change: squeeze the whole strip
and the answer goes back to w*L^2/8 on the reduced section."""
panel = _pinched(2.5, 7.5) # exactly the strip, not the overhangs
a = statics.analyse(panel, stripping, rolling, role='strip')
line = min(a.rig.lines, key=lambda l: l.x)
w_lbft = line.reaction_lb / WIDTH_FT
m_lbft = w_lbft * (WIDTH_FT / 2.0) ** 2 / 8.0
s_in3 = THICKNESS_IN * 6.0 ** 2 / 6.0
found = checks.check_row_transverse_flexure(a)[0]
assert 'overhang' not in found.message
assert abs(_f_t_psi(found) - m_lbft * 12.0 / s_in3) < 0.2