WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
# -*- 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))
|
||||
|
||||
|
||||
# --------------------------------------------------------- point moments
|
||||
|
||||
def test_a_point_moment_matches_the_hand_solution(solid_panel):
|
||||
"""A concentrated moment on a 2-support span against a hand solution.
|
||||
|
||||
Derived (limit of two opposite point loads a vanishing distance apart,
|
||||
`PointMoment`'s own docstring): span L, moment M0 (sagging convention) at
|
||||
x0, on 2 supports (0, L):
|
||||
|
||||
R0 = -M0/L, RL = +M0/L
|
||||
M(x) = -M0*x/L for x < x0
|
||||
M(x) = M0*(1 - x/L) for x > x0
|
||||
|
||||
With 2 supports the beam is determinate, so EI (the panel's own) cannot
|
||||
matter -- same reasoning `test_two_supports_agree_between_the_two_load_
|
||||
paths` relies on.
|
||||
"""
|
||||
L = solid_panel.length_ft
|
||||
x0 = 0.3 * L
|
||||
m0 = 10000.0
|
||||
rig = statics._support_rig([0.0, L])
|
||||
profile = statics.load_profile(solid_panel, 0.0) # w = 0 everywhere
|
||||
pm = [statics.PointMoment(x=x0, moment_lbft=m0)]
|
||||
statics.solve_static(rig, profile, solid_panel, 5000.0, point_moments=pm)
|
||||
|
||||
r0 = rig.lines[0].reaction_lb
|
||||
rl = rig.lines[1].reaction_lb
|
||||
assert abs(r0 - (-m0 / L)) < 1e-6 * m0
|
||||
assert abs(rl - (m0 / L)) < 1e-6 * m0
|
||||
|
||||
dia = statics.diagram(rig, profile, point_moments=pm)
|
||||
for x_check, expected in ((0.1 * L, -m0 * 0.1 * L / L),
|
||||
(0.9 * L, m0 * (1.0 - 0.9 * L / L))):
|
||||
got = dia.moment_at(x_check)
|
||||
assert abs(got - expected) < 1e-6 * m0, (
|
||||
'M({0:.2f})={1:.2f}, expected {2:.2f}'
|
||||
.format(x_check, got, expected))
|
||||
|
||||
# M(0) = M(L) = 0 -- a pin at the very end of the piece with no other
|
||||
# load carries no moment, same closure identity the other tests assert.
|
||||
assert abs(dia.moment_at(0.0)) < 1e-6 * m0
|
||||
assert abs(dia.moment_at(L)) < 1e-6 * m0
|
||||
|
||||
# The jump crossing x0 is exactly +moment_lbft (sagging convention).
|
||||
# Offsets are a few grid steps (profile.xs is a 2000-station uniform
|
||||
# grid, ~0.016 ft here) so the probe lands cleanly on one side of x0
|
||||
# rather than interpolating across the single node that carries the
|
||||
# post-jump value (`diagram`'s `pm.x <= x + 1e-9` folds the jump into
|
||||
# x0's own entry).
|
||||
# A little short of exactly m0: M(x) also ramps (slope R0) across the
|
||||
# 10-step gap the probe straddles, which is real linear variation, not
|
||||
# slop -- the two closed-form checks above already pin the exact shape.
|
||||
step = solid_panel.length_ft / 2000.0
|
||||
just_below = dia.moment_at(x0 - 5.0 * step)
|
||||
just_above = dia.moment_at(x0 + 5.0 * step)
|
||||
ramp = abs(r0) * 10.0 * step
|
||||
assert abs((just_above - just_below) - m0) < ramp + 1.0
|
||||
|
||||
|
||||
def test_point_moment_forces_a_node_at_its_own_x(solid_panel):
|
||||
rig = statics._support_rig([0.0, solid_panel.length_ft])
|
||||
nodes = statics._nodes(solid_panel, rig, point_moment_xs=[7.7])
|
||||
assert min(abs(n - 7.7) for n in nodes) < 1e-12
|
||||
|
||||
|
||||
def test_point_moment_off_the_piece_is_refused(solid_panel):
|
||||
rig = statics._support_rig([0.0, solid_panel.length_ft])
|
||||
profile = statics.load_profile(solid_panel, 0.0)
|
||||
pm = [statics.PointMoment(x=solid_panel.length_ft + 5.0,
|
||||
moment_lbft=1000.0)]
|
||||
with pytest.raises(ValueError, match='outside the piece'):
|
||||
statics.solve_static(rig, profile, solid_panel, 5000.0,
|
||||
point_moments=pm)
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user