Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

483 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""The checks, and that the report they feed actually builds.
The ASPIRE examples are re-used here as verdict tests rather than number
tests: example 1 must come back OK and the pocketed example 2 must come back
FAIL. A refactor that quietly turns a printed failure into a pass is the
regression worth catching.
"""
import re
import pytest
from panellift import checks, pci, report, statics
from panellift.model import Blockout, Lifter, Panel, Prestress, Rigging, Stage, Strand
from conftest import make_panel, aspire_pockets
def _analyse(panel, stage, rigging):
return statics.analyse(panel, stage, rigging, reconcile_weight=False)
# ------------------------------------------------------------ verdicts
def test_solid_panel_passes_stripping(stripping, rolling):
a = _analyse(make_panel(), stripping, rolling)
found = checks.check_cracking(a, stripping)
assert [f.severity for f in found] == ['OK']
def test_pocketed_panel_fails_stripping(stripping, rolling):
"""Same panel, five pockets at the pick, and the answer must flip."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
found = checks.check_cracking(a, stripping)
assert found[0].severity == 'FAIL'
assert 'psi' in found[0].message
def test_the_governing_station_is_at_a_pocket(stripping, rolling):
"""The worst stress must be found at the blockout, not on the grid."""
panel = make_panel(blockouts=aspire_pockets())
a = _analyse(panel, stripping, rolling)
found = checks.check_cracking(a, stripping)
edge = panel.blockouts[0]
assert edge.x0 - 0.1 <= found[0].at_ft <= edge.x1 + 0.1
# --------------------------------------------------- prestress loss note
PULL_LB = 0.153 * 270_000 * 0.75 # 75% pull, 1/2 in. 270k strand
def _near_miss_analysis(loss_pct):
"""A hand-built Analysis, bypassing the beam solve entirely.
Station and moment are chosen so the worst corner lands at 95% of the
tensile limit -- squarely inside the WARN band rather than balanced on
its edge -- for a 4 x 20 ft, 6 in. solid panel. loss_pct=None gives a
panel with no prestress at all, at a moment tuned to land in the same
band on bending alone.
"""
panel = Panel(mark='NM', length_ft=20.0, width_ft=4.0, thickness_in=6.0,
fc_psi=5000.0, fci_psi=3500.0)
if loss_pct is not None:
# y and z at the section's own centroid, so neither eccentricity
# term fires and the axial term alone does the work of getting this
# close to the limit.
strand = Strand(y=2.0, z_in=3.0, area_in2=0.153,
initial_pull_lb=PULL_LB)
panel.prestress = Prestress(strands=[strand], loss_pct=loss_pct)
moment_lbft = 10_320.0
else:
moment_lbft = 8_061.0
diagram = statics.Diagram(xs=[10.0], shear_lb=[0.0],
moment_lbft=[moment_lbft])
stage = Stage(name='test', multiplier=1.0, fc_psi=5000.0)
analysis = statics.Analysis(panel=panel, stage=stage, rigging=Rigging(),
rig=None, profile=None, diagram=diagram)
return analysis, stage
def test_near_miss_with_prestress_names_the_loss_assumption():
analysis, stage = _near_miss_analysis(loss_pct=12.5)
found = checks.check_cracking(analysis, stage)
crack = found[0]
assert crack.severity == 'WARN' # verdict is unchanged
assert 'within 10% of the limit' in crack.message
assert '12.5%' in crack.message # read from the config,
assert '8.0%' not in crack.message # not the 8% default
assert 'stated assumption, not a calculated one' in crack.message
assert 'computed over time' in crack.message
def test_near_miss_without_prestress_has_no_loss_note():
"""No prestress credited, so there is no loss assumption to question."""
analysis, stage = _near_miss_analysis(loss_pct=None)
found = checks.check_cracking(analysis, stage)
crack = found[0]
assert crack.severity == 'WARN'
assert 'within 10% of the limit' in crack.message
assert 'loss' not in crack.message
# ------------------------------------------------------------- inserts
def test_insert_check_defactors_the_reaction(solid_panel, stripping, rolling):
"""PCI Table 8.3.1 note (a): the handling multiplier is not for inserts.
Two stages differing only in multiplier must give the same line force.
"""
def line_force(mult):
stage = Stage('s', mult, 4000.0)
a = _analyse(make_panel(), stage, rolling)
found = [f for f in checks.check_insert_capacity(a, rolling)
if f.code == 'INSERT']
return found[0].message
assert line_force(1.0) == line_force(1.7)
def test_shallow_sling_raises_the_line_force(solid_panel, stripping):
vertical = Rigging(mode='rolling', sling_angle_deg=90.0)
shallow = Rigging(mode='rolling', sling_angle_deg=30.0)
a = _analyse(make_panel(), stripping, vertical)
v = checks.check_insert_capacity(a, vertical)
s = checks.check_insert_capacity(a, shallow)
assert not any(f.code == 'SLING-ANGLE' for f in v)
assert any(f.code == 'SLING-ANGLE' for f in s)
def test_unrated_insert_warns_rather_than_passing(stripping, rolling):
panel = make_panel()
for lifter in panel.lifters:
lifter.capacity_lb = None
a = _analyse(panel, stripping, rolling)
found = checks.check_insert_capacity(a, rolling)
assert found[0].code == 'INSERT-UNRATED'
assert found[0].severity == 'WARN'
def test_an_overloaded_insert_fails(stripping, rolling):
panel = make_panel()
for lifter in panel.lifters:
lifter.capacity_lb = 100.0
a = _analyse(panel, stripping, rolling)
found = checks.check_insert_capacity(a, rolling)
assert any(f.severity == 'FAIL' for f in found)
# --------------------------------- owner's ruling, 2026-09-02: decomposition
#
# "panel lift 里面,现在如果有sling angle的话,是直接给tension放大。不要直接给
# tension放大,把力分解成tension跟shear,并且依次检查lifter的tension和shear
# capacity。举例来说P136 at 18.83 ft: line force 7,609 lb (service 6,589 lb /
# sin 60°) vs 8,000 lb tension (95%),就是tension部分6589/8000,shear部分拆
# 出来单独算,并且跟shear capacity进行对比。二者都小于1即可,不考虑interaction."
#
# The wrong arithmetic compared the WHOLE amplified line force (7,609 lb)
# to the tension rating and printed 95% -- a near-WARN that isn't real. The
# decomposition the owner asked for compares only the vertical share (the
# service reaction itself, 6,589 lb) to tension, and separately the
# horizontal share (6,589 * cot(60deg) = 3,804 lb) to shear.
def _p136_analysis(sling_angle_deg=60.0, capacity_lb=8000.0,
shear_capacity_lb=8000.0, service=6589.0):
lifter = Lifter(mark='P136', family='P136', x=18.83, y=0.0,
capacity_lb=capacity_lb,
shear_capacity_lb=shear_capacity_lb)
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=service)
rig = statics.Rig(lines=[line], points=1, mode='rolling')
stage = Stage('erect', 1.0, 5000.0)
rigging = Rigging(mode='rolling', sling_angle_deg=sling_angle_deg)
analysis = statics.Analysis(panel=make_panel(), stage=stage,
rigging=rigging, rig=rig, profile=None,
diagram=None)
return analysis, rigging
def test_owner_worked_example_p136_decomposes_tension_and_shear():
"""The pinned numbers from the ruling: 82% tension (not the old 95% on
the amplified line force), and a shear share of ~3,804 lb checked on
its own against the shear column -- both under 1, no interaction."""
analysis, rigging = _p136_analysis()
found = [f for f in checks.check_insert_capacity(analysis, rigging)
if f.code == 'INSERT']
assert found
msg = found[0].message
assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg
assert 'shear 3,804 lb vs 8,000 lb (48%)' in msg
# The old (wrong) arithmetic's 95% verdict does not survive.
assert '95%' not in msg
# 82% and 48% are each under 1 -- the finding must read as OK, not the
# WARN the old single-ratio comparison against the amplified 7,609 lb
# line force gave.
assert found[0].severity == 'OK'
def test_vertical_rigging_degrades_the_shear_share_to_zero():
"""theta = 90 (a vertical line, sling_angle_deg's own convention for
'straight up') must fall back to the old plumb-vertical arithmetic
exactly: cot(90) = 0, so the shear share is zero and only tension is a
real question."""
analysis, rigging = _p136_analysis(sling_angle_deg=90.0)
found = [f for f in checks.check_insert_capacity(analysis, rigging)
if f.code == 'INSERT']
assert found
msg = found[0].message
assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg
assert 'shear 0 lb vs 8,000 lb (0%)' in msg
def test_a_missing_shear_capacity_still_judges_the_tension_half():
"""Some lifters carry no shear rating. That is a could-not-judge on the
shear half, named inline -- never a silent pass -- and it must not stop
the tension half, which has everything it needs, from judging."""
analysis, rigging = _p136_analysis(shear_capacity_lb=None)
found = [f for f in checks.check_insert_capacity(analysis, rigging)
if f.code == 'INSERT']
assert found
msg = found[0].message
assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg
assert 'no shear rating' in msg and 'NOT JUDGED' in msg
codes = [f.code for f in checks.check_insert_capacity(analysis, rigging)]
assert 'INSERT-UNRATED' in codes
# --------------------- MC dict-shaped capacities: cli.py resolves, checks.py
# judges what comes out (a number, or None + a reason)
#
# `cli._resolve_capacity` is what turns MC's richer `capacity_lb` /
# `shear_capacity_lb` dicts into the (float-or-None, reason) pair
# `check_insert_capacity` has always consumed -- these exercise the CHECK
# side of that split: a None tension carrying a reason must surface as
# NOT JUDGED naming it, and a real published zero on the other axis (MC's
# LPA4T512 pin: `shear_lb: 0`) must still be judged, not treated as absent.
def _mc_lifter(tension_reason=None, capacity_lb=8000.0,
shear_capacity_lb=0.0):
"""`tension_reason` given makes tension itself unrated (None) with that
reason -- the angle-unresolved shape. Left None, tension carries a real
`capacity_lb` instead, so a shear-only test is not also incidentally an
unrated-tension test."""
return Lifter(mark='PIN', family='LPA4T512', x=18.83, y=0.0,
capacity_lb=(None if tension_reason else capacity_lb),
capacity_why=tension_reason,
shear_capacity_lb=shear_capacity_lb)
def test_an_unresolved_tension_dict_is_not_judged_carrying_its_reason():
"""The angle-unresolved shape -- tension resolves to None upstream in
cli.py, with `capacity_why` carrying the specific "sling angle not
confirmed" sentence rather than the generic "no rating" one. The check
must print that reason on the tension clause, not swallow it."""
reason = ('M8 publishes LPA4T512 tension by sling angle and the plant '
'has not confirmed which column applies')
lifter = _mc_lifter(tension_reason=reason)
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0)
rig = statics.Rig(lines=[line], points=1, mode='rolling')
stage = Stage('erect', 1.0, 5000.0)
rigging = Rigging(mode='rolling', sling_angle_deg=60.0)
analysis = statics.Analysis(panel=make_panel(), stage=stage,
rigging=rigging, rig=rig, profile=None,
diagram=None)
found = [f for f in checks.check_insert_capacity(analysis, rigging)
if f.code == 'INSERT']
assert found
msg = found[0].message
assert 'no tension rating' in msg and 'NOT JUDGED' in msg
assert reason in msg
def test_a_published_zero_shear_capacity_is_judged_not_skipped():
"""MC's LPA4T512 publishes `shear_lb: 0` -- a real rating, not a
could-not-judge -- so a real shear demand against it must come back a
genuine (very bad) ratio, never an INSERT-UNRATED could-not-judge and
never a ZeroDivisionError crash."""
lifter = _mc_lifter(shear_capacity_lb=0.0)
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0)
rig = statics.Rig(lines=[line], points=1, mode='rolling')
stage = Stage('erect', 1.0, 5000.0)
rigging = Rigging(mode='rolling', sling_angle_deg=60.0) # nonzero shear
analysis = statics.Analysis(panel=make_panel(), stage=stage,
rigging=rigging, rig=rig, profile=None,
diagram=None)
found = [f for f in checks.check_insert_capacity(analysis, rigging)
if f.code == 'INSERT']
assert found
msg = found[0].message
assert 'no shear rating' not in msg # judged, not could-not-judge
assert 'vs 0 lb' in msg
assert found[0].severity == 'FAIL' # any real demand over 0 fails
codes = [f.code for f in checks.check_insert_capacity(analysis, rigging)]
assert 'INSERT-UNRATED' not in codes
def test_a_published_zero_shear_capacity_with_no_shear_demand_is_ok():
"""0/0 -- a vertical sling puts no shear share on this axis at all, and
an axis with no capacity that also carries no demand is OK, not a
fabricated FAIL."""
lifter = _mc_lifter(shear_capacity_lb=0.0)
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0)
rig = statics.Rig(lines=[line], points=1, mode='rolling')
stage = Stage('erect', 1.0, 5000.0)
rigging = Rigging(mode='rolling', sling_angle_deg=90.0) # cot(90) = 0
analysis = statics.Analysis(panel=make_panel(), stage=stage,
rigging=rigging, rig=rig, profile=None,
diagram=None)
found = [f for f in checks.check_insert_capacity(analysis, rigging)
if f.code == 'INSERT']
assert found
assert 'shear 0 lb vs 0 lb (0%)' in found[0].message
# -------------------------------------------------------- aspect ratio
@pytest.mark.parametrize('width,expected', [(4.0, 'OK'), (20.0, 'WARN'),
(28.0, 'FAIL')])
def test_near_square_panels_are_refused(width, expected):
panel = make_panel()
panel.width_ft = width
assert checks.check_aspect_ratio(panel)[0].severity == expected
@pytest.mark.parametrize('width', [4.0, 20.0, 28.0])
def test_turning_a_piece_does_not_change_whether_it_is_square(width):
"""transpose() stands a horizontal panel up by swapping length and width.
A 26.8 x 11.5 panel is 0.43 either way round. When this check read
width/length it came back 2.33 turned, failed, and reported the piece as
"close to square" -- which is how all 184 horizontal panels in a real
model failed erection and no genuine overstress could be seen behind it.
"""
flat = make_panel()
flat.width_ft = width
stood_up = make_panel()
stood_up.length_ft, stood_up.width_ft = width, flat.length_ft
assert (checks.check_aspect_ratio(flat)[0].severity ==
checks.check_aspect_ratio(stood_up)[0].severity)
# ------------------------------------------------------------- dunnage
def _dunnage_stage(supports):
return Stage(name='transportation', multiplier=1.4, fc_psi=5000.0,
pci_case='transportation', supports=supports)
def test_support_under_a_big_opening_is_named(rolling):
"""One real piece: the plant's fraction-of-span rule put a support at
5.23 ft on a panel whose opening ran 3.23 to 12.52 ft -- a support line
under a hole. Here an 8 ft wide, full-depth opening removes 80% of a
10 ft panel's width at x=5, which is well past 'most of the width'."""
panel = make_panel()
panel.blockouts = [Blockout(name='DOOR', x0=3.0, y0=0.0,
length=4.0, width=8.0)]
stage = _dunnage_stage([5.0, 25.0])
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
found = checks.check_dunnage_position(a)
assert len(found) == 1 # only the 5.0 ft support
f = found[0]
assert f.severity == 'WARN'
assert f.code == 'DUNNAGE-OPENING'
assert f.at_ft == 5.0
assert '5.00 ft' in f.message
assert 'DOOR' in f.message
assert '3.00 to 7.00 ft' in f.message
assert '80%' in f.message
def test_support_clear_of_any_opening_is_not_flagged(rolling):
panel = make_panel() # no blockouts at all
stage = _dunnage_stage([5.0, 25.0])
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
assert checks.check_dunnage_position(a) == []
def test_a_small_opening_under_a_support_does_not_trigger(rolling):
"""A opening that leaves most of the width intact is a different case
from a support bearing mostly on a hole -- below the fraction this draws
the line at, it is left alone."""
panel = make_panel()
panel.blockouts = [Blockout(name='SMALL', x0=3.0, y0=0.0,
length=4.0, width=2.5)] # 25% of 10 ft
stage = _dunnage_stage([5.0, 25.0])
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
assert checks.check_dunnage_position(a) == []
def test_dunnage_check_does_not_run_on_a_lifted_stage(stripping, rolling):
"""supports is None on a normal pick -- nothing to ask voids_at about."""
a = statics.analyse(make_panel(), stripping, rolling,
reconcile_weight=False)
assert checks.check_dunnage_position(a) == []
def test_run_all_includes_the_dunnage_check_on_a_supported_stage(rolling):
panel = make_panel()
panel.blockouts = [Blockout(name='DOOR', x0=3.0, y0=0.0,
length=4.0, width=8.0)]
stage = _dunnage_stage([5.0, 25.0])
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
found = checks.run_all(a, rolling)
assert any(f.code == 'DUNNAGE-OPENING' for f in found)
# -------------------------------------------------------------- report
def test_report_builds_and_is_self_contained(stripping, rolling):
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
found = checks.run_all(a, rolling)
page = report.build(a, rolling, found)
assert page.count('<svg') == page.count('</svg>') == 5
assert '</table>' in page
# A calc report has to open from disk on a machine with no network.
assert 'http://' not in page and 'https://' not in page
assert 'ASPIRE-1' in page or 'ASPIRE' in page
def test_report_states_the_verdict(stripping, rolling):
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
found = checks.run_all(a, rolling)
assert checks.worst_severity(found) == 'FAIL'
assert 'not satisfied' in report.build(a, rolling, found)
def test_transportation_report_says_it_was_checked_as_a_lift(stripping,
rolling):
"""Transportation sits on dunnage, not the inserts. Until the support
model exists, the report must say which model produced the numbers."""
strip = _analyse(make_panel(), stripping, rolling)
strip_page = report.build(strip, rolling,
checks.run_all(strip, rolling),
recommendation=False)
assert 'dunnage' not in strip_page
haul = Stage(name='haul', multiplier=1.4, fc_psi=5000.0,
pci_case='transportation')
haul_a = _analyse(make_panel(), haul, rolling)
haul_page = report.build(haul_a, rolling,
checks.run_all(haul_a, rolling),
recommendation=False)
assert 'dunnage' in haul_page
assert 'checked as a <b>lift</b>' in haul_page
def test_transportation_on_dunnage_reports_supports_not_a_pick(rolling):
panel = make_panel()
haul = Stage(name='transportation', multiplier=1.4, fc_psi=5000.0,
pci_case='transportation',
supports=[0.2 * panel.length_ft, 0.8 * panel.length_ft])
a = _analyse(panel, haul, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
assert 'dunnage line' in page
assert 'checked as a <b>lift</b>' not in page
assert 'no insert check applies' in page
# ------------------------------------------------------ demo-safe: no CJK
#
# Owner, 2026-09-01: no Chinese anywhere in a report or a UI surface, even
# when quoting him -- he is demoing this to colleagues before the weekend.
# The owner's own rulings behind these findings (`capacity.py`'s M/phi-Mn
# ruling, `sandwich.py`'s "not conservative" ruling, both quoted again in
# `checks.py` and `cli.py`) now live in `#` comments only, never in a
# `Finding.message`. This renders the real HTML page -- `report.build`,
# the surface a colleague actually sees -- off a pocketed panel forced
# into a FAIL, so the capacity and cracking findings are both live.
def test_the_rendered_findings_page_carries_no_chinese_text_or_html(
stripping, rolling):
panel = make_panel(blockouts=aspire_pockets())
a = _analyse(panel, stripping, rolling)
findings = checks.run_all(a, rolling)
assert any(f.severity == 'FAIL' for f in findings) # the FAIL path ran
cjk = re.compile(u'[一-鿿]')
for f in findings:
assert not cjk.search(f.message), f.message
page = report.build(a, rolling, findings, recommendation=False)
assert not cjk.search(page), page