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,302 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Erection is two conditions, and they use two different ratings.
|
||||
|
||||
the pick panel flat, face lifters and edge anchors together. Face
|
||||
lifters are pulled out of the face (tension); the anchors are
|
||||
edge-on to the line, which pulls across them (shear).
|
||||
|
||||
standing panel plumb. The face lifters have let go and carry nothing.
|
||||
The whole piece hangs from the anchors alone, in tension.
|
||||
|
||||
Checking the anchors against tension in the first condition, or running the
|
||||
second one through the beam model, both produce a report that looks complete
|
||||
and answers a question the panel never asks.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import LENGTH_FT, WIDTH_FT, make_panel
|
||||
from panellift import checks, statics
|
||||
from panellift.model import Lifter
|
||||
|
||||
|
||||
def erection_panel(shear_lb=4010.0, tension_lb=7690.0, n=4, weight=35000.0):
|
||||
"""The ASPIRE panel picked on its lower two lines plus a top edge row."""
|
||||
panel = make_panel()
|
||||
face = [dataclasses.replace(l, capacity_lb=12000.0)
|
||||
for l in panel.lifters if l.x < 0.5 * LENGTH_FT]
|
||||
edge = [Lifter(mark='E{0}'.format(i + 1), family='EDGE_ANCHOR',
|
||||
x=LENGTH_FT, y=WIDTH_FT * (i + 1) / (n + 1.0),
|
||||
role='strip', capacity_lb=tension_lb,
|
||||
shear_capacity_lb=shear_lb, on_edge=True)
|
||||
for i in range(n)]
|
||||
return dataclasses.replace(panel, lifters=face + edge, weight_lb=weight)
|
||||
|
||||
|
||||
def analyse(panel, stage, rolling):
|
||||
return statics.analyse(panel, stage, rolling, role='strip')
|
||||
|
||||
|
||||
def codes(findings):
|
||||
return [f.code for f in findings]
|
||||
|
||||
|
||||
def insert_lines(findings, family):
|
||||
return [f for f in findings if f.code == 'INSERT' and family in f.message]
|
||||
|
||||
|
||||
# --------------------------------------------- condition 1: the flat pick
|
||||
|
||||
def test_an_edge_anchor_is_checked_in_both_tension_and_shear(stripping, rolling):
|
||||
"""2026-09-02 owner's ruling: the sling's line force is decomposed into
|
||||
a vertical (tension) and a horizontal (shear) share and BOTH are
|
||||
checked against the lifter's own capacity in that column -- `on_edge`
|
||||
no longer selects a single axis to judge here (it still matters to
|
||||
check_plumb_hang). An edge anchor carries a tension rating same as a
|
||||
face lifter, and it is checked too; at this fixture's vertical
|
||||
(90deg) rigging the shear share is zero, so the shear ratio comes back
|
||||
a trivial 0% rather than being skipped."""
|
||||
a = analyse(erection_panel(), stripping, rolling)
|
||||
found = insert_lines(checks.check_insert_capacity(a, rolling),
|
||||
'EDGE_ANCHOR')
|
||||
assert found
|
||||
assert 'shear' in found[0].message and 'tension' in found[0].message
|
||||
assert '4,010' in found[0].message # the shear rating
|
||||
assert '7,690' in found[0].message # the tension rating
|
||||
|
||||
|
||||
def test_a_face_lifter_is_checked_in_tension(stripping, rolling):
|
||||
a = analyse(erection_panel(), stripping, rolling)
|
||||
found = insert_lines(checks.check_insert_capacity(a, rolling),
|
||||
'TEST_LIFTER')
|
||||
assert found
|
||||
assert 'tension' in found[0].message
|
||||
|
||||
|
||||
def test_an_edge_anchor_with_no_shear_rating_still_judges_tension(stripping,
|
||||
rolling):
|
||||
"""No shear rating means the shear half of the decomposition can't be
|
||||
judged -- but the tension half is a separate question with its own
|
||||
capacity, and it still judges rather than the whole lifter vanishing
|
||||
from the report. The missing half is named, not silently dropped."""
|
||||
a = analyse(erection_panel(shear_lb=None), stripping, rolling)
|
||||
found = checks.check_insert_capacity(a, rolling)
|
||||
assert 'INSERT-UNRATED' in codes(found)
|
||||
lines = insert_lines(found, 'EDGE_ANCHOR')
|
||||
assert lines
|
||||
assert 'tension' in lines[0].message
|
||||
assert 'NOT JUDGED' in lines[0].message
|
||||
assert '7,690' in lines[0].message # the tension rating still judged
|
||||
|
||||
|
||||
# ------------------------------------------ condition 2: standing plumb
|
||||
|
||||
def test_standing_plumb_hangs_the_whole_piece_on_the_anchors(stripping,
|
||||
rolling):
|
||||
a = analyse(erection_panel(n=4, weight=35000.0), stripping, rolling)
|
||||
found = [f for f in checks.check_plumb_hang(a) if f.code == 'PLUMB']
|
||||
assert len(found) == 1
|
||||
assert '8,750' in found[0].message # 35,000 / 4
|
||||
assert '7,690' in found[0].message # the tension rating
|
||||
|
||||
|
||||
def test_standing_plumb_ignores_the_handling_multiplier_and_the_sling(rolling):
|
||||
"""It is a piece hanging straight down off four anchors. There is no
|
||||
distribution question in it, so nothing about the pick can change it."""
|
||||
from panellift.model import Stage
|
||||
panel = erection_panel()
|
||||
gentle = checks.check_plumb_hang(analyse(panel, Stage('a', 1.0, 4000.0),
|
||||
rolling))
|
||||
harsh = checks.check_plumb_hang(analyse(panel, Stage('b', 1.7, 4000.0),
|
||||
rolling))
|
||||
assert gentle[0].message == harsh[0].message
|
||||
|
||||
|
||||
def test_a_row_too_light_for_the_piece_fails(stripping, rolling):
|
||||
a = analyse(erection_panel(tension_lb=2000.0), stripping, rolling)
|
||||
found = [f for f in checks.check_plumb_hang(a) if f.code == 'PLUMB']
|
||||
assert found and found[0].severity == 'FAIL'
|
||||
|
||||
|
||||
def test_an_anchor_with_no_tension_rating_says_so(stripping, rolling):
|
||||
a = analyse(erection_panel(tension_lb=None), stripping, rolling)
|
||||
assert 'PLUMB-UNRATED' in codes(checks.check_plumb_hang(a))
|
||||
|
||||
|
||||
def test_a_stage_with_no_edge_anchors_is_not_stood_up(stripping, rolling):
|
||||
"""Stripping and transportation never put the piece on end, so there is
|
||||
no plumb condition to report and no warning to give about it."""
|
||||
a = analyse(make_panel(), stripping, rolling)
|
||||
assert checks.check_plumb_hang(a) == []
|
||||
|
||||
|
||||
def test_face_lifters_are_not_used_to_stand_the_piece_up(stripping, rolling):
|
||||
"""They have a tension rating too. Using it here would report a condition
|
||||
the panel never sees."""
|
||||
panel = erection_panel()
|
||||
panel = dataclasses.replace(panel, lifters=[
|
||||
dataclasses.replace(l, on_edge=False) for l in panel.lifters])
|
||||
a = analyse(panel, stripping, rolling)
|
||||
assert checks.check_plumb_hang(a) == []
|
||||
|
||||
|
||||
def test_run_all_reports_both_conditions(stripping, rolling):
|
||||
a = analyse(erection_panel(), stripping, rolling)
|
||||
found = codes(checks.run_all(a, rolling))
|
||||
assert 'INSERT' in found and 'PLUMB' in found
|
||||
|
||||
|
||||
|
||||
# ----------------------------------- transverse edge-anchor path
|
||||
|
||||
def _transverse_panel(opening=False):
|
||||
"""A horizontal panel erected on its long edge, top-row anchors along the
|
||||
edge, in TRANSPOSED shape: length = erected span (11.5 ft), width = long
|
||||
dimension (26.83 ft)."""
|
||||
from panellift.model import Blockout, Panel
|
||||
panel = Panel(mark='H', length_ft=11.5, width_ft=26.83, thickness_in=8.0,
|
||||
weight_lb=30800.0, fci_psi=3500.0, fc_psi=6000.0)
|
||||
for i, y in enumerate([3.5, 10.5, 16.33, 24.08]):
|
||||
panel.lifters.append(Lifter(
|
||||
mark='E{0}'.format(i + 1), family='EDGE_ANCHOR', x=11.5, y=y,
|
||||
role='strip', capacity_lb=16000.0, shear_capacity_lb=4010.0,
|
||||
on_edge=True))
|
||||
if opening:
|
||||
# Reaches the anchored edge (x0 + length = 11.5) inside the gap
|
||||
# between the anchors at y = 10.5 and 16.33.
|
||||
panel.blockouts.append(Blockout(name='BIG', x0=7.0, y0=14.0,
|
||||
length=4.5, width=3.0))
|
||||
return panel
|
||||
|
||||
|
||||
def _edge_path(panel):
|
||||
from panellift.model import Stage
|
||||
return checks.check_edge_anchor_path(
|
||||
panel, Stage('erection', 1.4, 6000.0, span='width'))
|
||||
|
||||
|
||||
def _f_t_psi(finding):
|
||||
m = __import__('re').search(r'f_t ([0-9.]+) psi', finding.message)
|
||||
return float(m.group(1))
|
||||
|
||||
|
||||
def test_transverse_edge_path_is_tiny_on_a_solid_horizontal_panel():
|
||||
found = _edge_path(_transverse_panel())
|
||||
ok = [f for f in found if f.code == 'EDGE-PATH']
|
||||
assert ok and ok[0].severity == 'OK'
|
||||
assert 'psi' in ok[0].message
|
||||
assert _f_t_psi(ok[0]) < 20.0 # a few pounds per square inch
|
||||
|
||||
|
||||
def test_transverse_edge_path_respects_an_opening_across_the_anchor_line():
|
||||
solid = _edge_path(_transverse_panel())[0]
|
||||
with_opening = _edge_path(_transverse_panel(opening=True))[0]
|
||||
assert solid.code == 'EDGE-PATH'
|
||||
assert with_opening.code == 'EDGE-PATH'
|
||||
assert 'opening' in with_opening.message.lower()
|
||||
assert _f_t_psi(with_opening) > _f_t_psi(solid)
|
||||
|
||||
|
||||
def test_transverse_edge_path_ignores_length_stages_and_few_anchors():
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Stage
|
||||
panel = _transverse_panel()
|
||||
assert checks.check_edge_anchor_path(
|
||||
panel, Stage('s', 1.4, 6000.0, span='length')) == []
|
||||
few = _dc.replace(panel, lifters=panel.lifters[:1])
|
||||
assert checks.check_edge_anchor_path(
|
||||
few, Stage('s', 1.4, 6000.0, span='width')) == []
|
||||
|
||||
|
||||
# ----------------------- the anchored edge is read, not assumed
|
||||
|
||||
def _mirrored_panel():
|
||||
"""The same piece with the anchor row on the OTHER long edge -- x == 0
|
||||
after transposing -- and the opening cutting in from that edge.
|
||||
|
||||
Which of a horizontal panel's two long edges ends up on top is a fact
|
||||
about the model. Assuming it is always x == length does not give a wrong
|
||||
number here, it gives a clean OK: no opening is ever seen to reach the
|
||||
anchor line, so the strip reports its full depth and the one thing this
|
||||
check exists to catch goes unreported.
|
||||
"""
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Blockout
|
||||
panel = _transverse_panel()
|
||||
panel = _dc.replace(panel, lifters=[_dc.replace(l, x=0.0)
|
||||
for l in panel.lifters])
|
||||
panel.blockouts.append(Blockout(name='BIG', x0=0.0, y0=14.0,
|
||||
length=4.5, width=3.0))
|
||||
return panel
|
||||
|
||||
|
||||
def test_the_anchored_edge_is_taken_from_the_anchors():
|
||||
found = _edge_path(_mirrored_panel())[0]
|
||||
assert 'opening BIG' in found.message
|
||||
# 11.5 ft of span less the 4.5 ft the opening eats = 7.0 ft of web.
|
||||
assert '84.0 in' in found.message
|
||||
|
||||
|
||||
def test_mirrored_and_normal_read_the_same_strip():
|
||||
"""Same geometry, mirrored. The stress must come out the same."""
|
||||
normal = _f_t_psi(_edge_path(_transverse_panel(opening=True))[0])
|
||||
mirrored = _f_t_psi(_edge_path(_mirrored_panel())[0])
|
||||
assert abs(normal - mirrored) < 0.05
|
||||
|
||||
|
||||
# ------------------------------ the load and the ends are not understated
|
||||
|
||||
def test_the_handling_multiplier_is_on_the_load():
|
||||
"""This compares a concrete stress to a concrete allowable, so it is the
|
||||
factored load -- the insert checks divide the multiplier back out because
|
||||
an insert is rated against service load, and that is a different question.
|
||||
"""
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Stage
|
||||
panel = _transverse_panel()
|
||||
gentle = checks.check_edge_anchor_path(
|
||||
panel, Stage('a', 1.0, 6000.0, span='width'))[0]
|
||||
harsh = checks.check_edge_anchor_path(
|
||||
panel, Stage('b', 2.0, 6000.0, span='width'))[0]
|
||||
# the message rounds to a tenth of a psi, so compare at that grain
|
||||
assert abs(_f_t_psi(harsh) - 2.0 * _f_t_psi(gentle)) <= 0.15
|
||||
|
||||
|
||||
def test_the_strip_past_the_last_anchor_is_a_cantilever():
|
||||
"""It hangs off the end anchor; there is nothing beyond it to lean on.
|
||||
Calling it a simple span understates that moment four times over."""
|
||||
import dataclasses as _dc
|
||||
panel = _transverse_panel()
|
||||
# One long overhang, and the two anchors close together so no interior
|
||||
# gap can win: 26.83 ft of piece with anchors at 3.5 and 5.0.
|
||||
panel = _dc.replace(panel, lifters=[
|
||||
_dc.replace(panel.lifters[0], y=3.5),
|
||||
_dc.replace(panel.lifters[1], y=5.0)])
|
||||
found = _edge_path(panel)[0]
|
||||
assert 'overhang' in found.message
|
||||
w = 30800.0 * 1.4 / 26.83
|
||||
gap = 26.83 - 5.0
|
||||
s_in3 = 8.0 * (11.5 * 12.0) ** 2 / 6.0
|
||||
assert abs(_f_t_psi(found) - w * gap ** 2 / 2.0 * 12.0 / s_in3) < 0.05
|
||||
|
||||
|
||||
# --------------------------------- nothing checked never reads as a pass
|
||||
|
||||
def test_no_concrete_strength_is_not_a_pass():
|
||||
from panellift.model import Stage
|
||||
found = checks.check_edge_anchor_path(
|
||||
_transverse_panel(), Stage('e', 1.4, 0.0, span='width'))
|
||||
assert codes(found) == ['EDGE-PATH-UNRATED']
|
||||
assert found[0].severity == 'WARN'
|
||||
|
||||
|
||||
def test_an_opening_that_eats_the_whole_strip_fails():
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Blockout
|
||||
panel = _transverse_panel()
|
||||
panel.blockouts.append(Blockout(name='SLOT', x0=0.0, y0=12.0,
|
||||
length=11.5, width=6.0))
|
||||
found = _edge_path(panel)[0]
|
||||
assert found.severity == 'FAIL'
|
||||
assert 'no section at all' in found.message
|
||||
Reference in New Issue
Block a user