Files
willtools-demo/panellift/tests/test_statics.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

357 lines
16 KiB
Python

# -*- coding: utf-8 -*-
"""Equilibrium identities, and the two load paths against each other.
None of these need a textbook: they are things that must be true of any
correct answer. They are the tests that catch a sign error in a case nobody
wrote a worked example for.
"""
import pytest
from conftest import LENGTH_FT, aspire_pockets, make_panel
from panellift import pci, statics
from panellift.model import Blockout, Lifter, Panel, Rigging, Stage
def _lifters(xs, width=10.0):
out = []
for i, x in enumerate(xs):
for j, y in enumerate((0.25 * width, 0.75 * width)):
out.append(Lifter(mark='L{0}{1}'.format(i, j), family='T',
x=x, y=y, role='strip', capacity_lb=8000.0))
return out
# ------------------------------------------------------------ equilibrium
@pytest.mark.parametrize('mode', ['rolling', 'static'])
def test_reactions_sum_to_the_factored_weight(solid_panel, stripping,
rolling, static_lift, mode):
rigging = rolling if mode == 'rolling' else static_lift
result = statics.analyse(solid_panel, stripping, rigging,
reconcile_weight=False)
total = sum(line.reaction_lb for line in result.rig.lines)
assert abs(total - result.profile.total_lb) / result.profile.total_lb < 1e-6
@pytest.mark.parametrize('mode', ['rolling', 'static'])
def test_moment_closes_at_both_ends(solid_panel, stripping, rolling,
static_lift, mode):
rigging = rolling if mode == 'rolling' else static_lift
result = statics.analyse(solid_panel, stripping, rigging,
reconcile_weight=False)
_, peak = result.diagram.max_positive
assert abs(result.diagram.moment_at(0.0)) < peak * 1e-3
assert abs(result.diagram.moment_at(solid_panel.length_ft)) < peak * 1e-3
# ------------------------------------------------------- the closure invariant
#
# `statics.diagram`'s docstring makes a promise about itself: deriving V and M
# from equilibrium rather than from the element forces means "M(L) coming back
# to zero is then a real check on the reactions rather than a tautology". It
# was failing that check. `solve_static` sampled the load once per finite
# element while the diagram integrated the 2,000-station profile, and
# `_nodes` put 1e-4 ft sliver elements at every blockout edge, so the reactions
# missed the load by up to 4.05% and M at the free right end came back at up to
# 70% of peak M. 280 of 549 stage-cases on the 184-file reference sweep closed
# worse than 0.1%; the `rolling` path, which is pure statics, always closed
# exactly. Measured 2026-09-01, fixed the same day.
#
# The tolerance below is deliberately tight. This is an identity, not a
# convergence criterion: the reactions either sum to the load the diagram
# integrates or they do not. 1e-6 of peak M is roughly a hundred times the
# residual the sweep's worst case now shows (5.8e-8) and about four orders
# tighter than anything the old code could have passed.
CLOSURE_TOL = 1e-6
def _closure(result):
"""(sum(R) - integral(w))/integral(w), and |M(L)|/peak|M|."""
total = result.profile.total_lb
reactions = sum(line.reaction_lb for line in result.rig.lines)
peak = max(abs(m) for m in result.diagram.moment_lbft)
return ((reactions - total) / total if total else 0.0,
abs(result.diagram.moment_lbft[-1]) / peak if peak else 0.0)
def _closure_cases():
"""One case per (panel shape, stage, load path) worth distinguishing.
Named rather than generated so a failure says which shape broke: it is the
blockout cases that used to fail, and a solid panel closes even under the
old midpoint sampling because w is constant and the one sample is right.
"""
cases = []
for panel_name, blockouts in (('solid', None),
('pocketed', 'aspire'),
('one big opening', 'big'),
('opening off a support', 'at support')):
for stage in (Stage('stripping', 1.4, 4000.0),
Stage('transportation', 1.5, 5000.0)):
for mode in ('rolling', 'static'):
cases.append(pytest.param(
blockouts, stage, mode, None,
id='{0}-{1}-{2}'.format(panel_name, stage.name, mode)))
cases.append(pytest.param(
blockouts, Stage('transportation', 1.5, 5000.0), 'static',
[4.0, 27.0],
id='{0}-on dunnage'.format(panel_name)))
return cases
def _closure_panel(kind):
if kind is None:
return make_panel()
if kind == 'aspire':
return make_panel(blockouts=aspire_pockets())
if kind == 'big':
# Most of the width gone over an eighth of the span, so w(x) drops by
# 60% at two stations that no uniform mesh would land on.
return make_panel(blockouts=[Blockout(name='O1', x0=5.3333, y0=0.0,
length=4.3542, width=6.0)])
# An edge landing on top of a support line is the case where the mesh has
# to choose between refining and putting the reaction in the right place.
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
return make_panel(blockouts=[Blockout(name='O1', x0=left, y0=0.0,
length=3.0, width=6.0)])
@pytest.mark.parametrize('blockouts,stage,mode,supports', _closure_cases())
def test_every_stage_case_closes_the_moment_at_the_free_end(blockouts, stage,
mode, supports):
panel = _closure_panel(blockouts)
if supports is not None:
stage = Stage(stage.name, stage.multiplier, stage.fc_psi,
supports=supports)
rigging = Rigging(mode=mode, sling_angle_deg=90.0)
result = statics.analyse(panel, stage, rigging, reconcile_weight=False)
reaction_error, end_moment = _closure(result)
assert abs(reaction_error) < CLOSURE_TOL, (
'sum(R) misses the load the diagram integrates by {0:.4%}'
.format(reaction_error))
assert end_moment < CLOSURE_TOL, (
'M at the free end is {0:.4%} of peak M; it should be zero'
.format(end_moment))
def _wp313_transportation():
"""The worst-closing case on the reference sweep, rebuilt from its job file.
WP313 on dunnage: a 22.5 ft piece with an opening that takes 61% of the
width out over 3.35 ft, starting 1.33 ft in -- so the load drops by nearly
two thirds a foot and a half OUTSIDE the left support and comes back three
feet inside it. Both edges fall between uniform mesh stations, and the
smaller opening puts a third and fourth edge in mid-span.
That shape is what turned a one-sample-per-element load into a 4.05%
reaction error and left M(22.5 ft) at 19,914 lb-ft against a peak of
28,580 -- 69.7% of peak, on the stage that carries 106 of the 147 capacity
exceedances the free-end stations produce.
"""
panel = Panel(
mark='WP313', length_ft=22.5, width_ft=11.9167, thickness_in=8.0,
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
finish='smooth', product_type='flat', weight_lb=25711.0,
lifters=[Lifter(mark='L1', family='T', x=5.1863, y=9.6667,
role='strip', capacity_lb=8000.0),
Lifter(mark='L2', family='T', x=17.3, y=2.4167,
role='strip', capacity_lb=8000.0)],
blockouts=[Blockout(name='OPENING 1', x0=1.3333, y0=0.0,
length=3.3542, width=7.2708),
Blockout(name='OPENING 2', x0=8.75, y0=10.3333,
length=1.0, width=0.5)])
stage = Stage('transportation', 1.5, 6000.0, supports=[4.725, 17.775])
return panel, stage
def test_the_worst_case_on_the_sweep_closes():
"""Regression on the shape that measured 4.05%, with the weight scaling on.
`reconcile_weight` is left ON here, unlike the parametrised cases above,
because that is how the sweep runs it: the profile is rescaled to the
model's own cast weight and the FE loads have to follow the SAME rescaled
profile. Two representations of the load is the defect; a scaling applied
to one of them and not the other would be the same defect again.
"""
panel, stage = _wp313_transportation()
result = statics.analyse(panel, stage, Rigging(mode='rolling',
sling_angle_deg=60.0),
reconcile_weight=True)
reaction_error, end_moment = _closure(result)
assert abs(reaction_error) < CLOSURE_TOL, (
'{0:.4%}; this case measured 4.0477% before 2026-09-01'
.format(reaction_error))
assert end_moment < CLOSURE_TOL, (
'{0:.4%} of peak M; this case measured 69.67% before 2026-09-01'
.format(end_moment))
def test_the_element_loads_integrate_the_profile_the_diagram_integrates():
"""The invariant one layer down, so a failure says WHICH half moved.
The closure tests above can only report that the reactions and the load
disagree. This one asks the element loads directly: summed over the mesh
they must reproduce the profile's own total and first moment, because
those two numbers are the whole of what `diagram` needs to bring M(L) back
to zero.
"""
panel, stage = _wp313_transportation()
profile = statics.load_profile(panel, stage.multiplier)
rig = statics._support_rig(stage.supports)
nodes = statics._nodes(panel, rig)
force = moment = 0.0
for i in range(len(nodes) - 1):
x0, x1 = nodes[i], nodes[i + 1]
f = statics._element_load(profile, x0, x1)
force -= f[0] + f[2]
moment -= f[0] * x0 + f[2] * x1 + f[1] + f[3]
assert abs(force - profile.total_lb) < 1e-6 * profile.total_lb
assert abs(moment - profile.Q[-1]) < 1e-6 * profile.Q[-1]
def test_the_mesh_never_leaves_a_sliver_element():
"""What made the reactions drift even where the loads were right.
A node 1e-4 ft from its neighbour beside elements 0.19 ft long is a
stiffness ratio of 6e9 across two adjacent rows of the matrix, and a dense
Gaussian solve gives up about that many digits. The mesh is allowed a node
exactly on a support and exactly on a section jump; it is not allowed one
a hair away from either.
"""
panel, stage = _wp313_transportation()
rig = statics._support_rig(stage.supports)
nodes = statics._nodes(panel, rig)
for x in stage.supports:
assert min(abs(n - x) for n in nodes) < 1e-12, (
'no node on the support at x={0}'.format(x))
floor = (panel.length_ft / 120.0) * statics.MIN_ELEMENT_FRACTION
shortest = min(nodes[i + 1] - nodes[i] for i in range(len(nodes) - 1))
assert shortest >= floor * (1.0 - 1e-9), (
'shortest element {0:.3e} ft against a floor of {1:.3e}'
.format(shortest, floor))
def test_factored_weight_scales_with_the_multiplier(solid_panel, rolling):
light = statics.analyse(solid_panel, Stage('a', 1.0, 4000.0), rolling,
reconcile_weight=False)
heavy = statics.analyse(solid_panel, Stage('b', 1.3, 4000.0), rolling,
reconcile_weight=False)
_, m_light = light.diagram.max_positive
_, m_heavy = heavy.diagram.max_positive
assert abs(m_heavy / m_light - 1.3) < 1e-6
# ------------------------------------------------- rolling versus static
def test_two_supports_agree_between_the_two_load_paths(solid_panel, stripping,
rolling, static_lift):
"""With two support lines the beam is determinate, so stiffness cannot matter."""
a = statics.analyse(solid_panel, stripping, rolling, reconcile_weight=False)
b = statics.analyse(solid_panel, stripping, static_lift,
reconcile_weight=False)
for la, lb in zip(a.rig.lines, b.rig.lines):
assert abs(la.reaction_lb - lb.reaction_lb) / la.reaction_lb < 1e-3
def test_three_lines_are_indeterminate_and_the_paths_diverge(solid_panel,
stripping,
rolling,
static_lift):
"""A middle support takes far more load than equalised sheaves would give it."""
solid_panel.lifters = _lifters([4.0, 16.0, 28.0])
equalised = statics.analyse(solid_panel, stripping, rolling,
reconcile_weight=False)
continuous = statics.analyse(solid_panel, stripping, static_lift,
reconcile_weight=False)
middle_equalised = equalised.rig.lines[1].reaction_lb
middle_continuous = continuous.rig.lines[1].reaction_lb
assert middle_continuous > middle_equalised * 1.2
# --------------------------------------------------------------- grouping
def test_four_lines_split_into_two_cable_groups(solid_panel, rolling):
solid_panel.lifters = _lifters([3.0, 8.0, 24.0, 29.0])
rig = statics.group_lifters(solid_panel, rolling)
assert len(rig.lines) == 4
assert rig.points == 8
assert [line.group for line in rig.lines] == [0, 0, 1, 1]
assert 8.0 < rig.grouped_at < 24.0
def test_three_lines_pair_the_two_that_are_close(solid_panel, rolling):
solid_panel.lifters = _lifters([4.0, 24.0, 28.0])
rig = statics.group_lifters(solid_panel, rolling)
assert [line.group for line in rig.lines] == [0, 1, 1]
def test_lifters_at_the_same_station_collapse_to_one_line(solid_panel, rolling):
rig = statics.group_lifters(solid_panel, rolling)
assert len(rig.lines) == 2
assert rig.points == 4
assert all(line.count == 2 for line in rig.lines)
def test_one_support_line_is_refused(solid_panel, rolling):
solid_panel.lifters = _lifters([16.0])
with pytest.raises(ValueError, match='one line'):
statics.group_lifters(solid_panel, rolling)
# ------------------------------------------------------------ sling angle
def test_vertical_sling_does_not_amplify(rolling):
forces = statics.sling_forces(10000.0, rolling)
assert abs(forces.amplification - 1.0) < 1e-9
assert abs(forces.horizontal_lb) < 1e-6
def test_sixty_degree_sling_amplifies_by_two_over_root_three(rolling):
rolling.sling_angle_deg = 60.0
forces = statics.sling_forces(10000.0, rolling)
assert abs(forces.line_force_lb - 10000.0 / 0.8660254) < 0.1
assert abs(forces.horizontal_lb - 5773.5) < 1.0
def test_shallow_sling_is_much_worse(rolling):
rolling.sling_angle_deg = 30.0
forces = statics.sling_forces(10000.0, rolling)
assert abs(forces.line_force_lb - 20000.0) < 1.0
def test_a_zero_sling_angle_refuses_loudly_rather_than_dividing_by_zero(
rolling):
rolling.sling_angle_deg = 0.0
with pytest.raises(ValueError, match='greater than zero'):
statics.sling_forces(10000.0, rolling)
def test_a_negative_sling_angle_refuses_loudly(rolling):
"""Not physically meaningful, and math.sin of it is negative -- caught
by the same guard as zero, not a NaN or a silently flipped sign."""
rolling.sling_angle_deg = -30.0
with pytest.raises(ValueError, match='greater than zero'):
statics.sling_forces(10000.0, rolling)
# --------------------------------------------- weight reconciliation
def test_reconciliation_scales_the_load_to_the_stated_weight(solid_panel,
stripping,
rolling):
geometric = statics.load_profile(solid_panel, 1.0).total_lb
solid_panel.weight_lb = geometric * 1.05
result = statics.analyse(solid_panel, stripping, rolling,
reconcile_weight=True)
expected = solid_panel.weight_lb * stripping.multiplier
assert abs(result.profile.total_lb - expected) / expected < 1e-6
assert abs(result.weight_error + 0.047619) < 1e-4