WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

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.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-
"""Shared fixtures, built from the ASPIRE Winter 2019 worked examples.
Brown, K. "Calculations for Handling and Shipping Precast Concrete Deck
Panels." ASPIRE, Winter 2019, pp. 24-27. The article reproduces PCI Design
Handbook Table 8.3.1 and Figure 8.3.2 and works two examples end to end, which
makes it the one published check available without a PCI licence.
Panel: 10 ft x 32 ft x 8.75 in., normalweight, f'ci = 4000, f'c = 5000,
four-point pick at the Figure 8.3.2 locations.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from panellift.model import Blockout, Lifter, Panel, Rigging, Stage # noqa: E402
from panellift import pci # noqa: E402
WIDTH_FT = 10.0
LENGTH_FT = 32.0
THICKNESS_IN = 8.75
# The article's rounded values, quoted so a failure names the source number.
ASPIRE = {
'w_psf': 109.4,
'moment_lbft': 23973.0,
's_solid_in3': 1531.0,
's_pocketed_in3': 1148.0,
'strip_stress_psi': 244.0,
'strip_allowable_psi': 316.0,
'ship_stress_psi': 282.0,
'ship_allowable_psi': 354.0,
'pocketed_strip_stress_psi': 326.0,
'pocketed_ship_stress_psi': 376.0,
}
def four_point_lifters(length_ft=LENGTH_FT, width_ft=WIDTH_FT):
"""Two lines of two, at the Figure 8.3.2 equalising positions."""
left, right = pci.optimal_two_line_supports(length_ft)
ys = (0.25 * width_ft, 0.75 * width_ft)
return [Lifter(mark='L{0}'.format(i + 1), family='TEST_LIFTER',
x=x, y=y, role='strip', capacity_lb=8000.0)
for i, (x, y) in enumerate([(left, ys[0]), (left, ys[1]),
(right, ys[0]), (right, ys[1])])]
def make_panel(mark='ASPIRE-1', blockouts=None):
"""A fresh panel every time.
Built by a plain function rather than by one fixture mutating another:
pytest caches a fixture per test, so a test asking for both the solid and
the pocketed panel would otherwise be handed the same object twice and
quietly compare it with itself.
"""
return Panel(
mark=mark,
length_ft=LENGTH_FT,
width_ft=WIDTH_FT,
thickness_in=THICKNESS_IN,
unit_weight_pcf=150.0,
fci_psi=4000.0,
fc_psi=5000.0,
finish='smooth',
product_type='flat',
lifters=four_point_lifters(),
blockouts=list(blockouts or []),
)
def aspire_pockets():
"""Five 6 in. x 24 in. full-depth pockets in one row across the width.
The article puts them 'at or near the lifting points' and notes that is
not good practice -- it is the location of maximum moment. Five pockets
six inches wide take 2.5 ft out of a 10 ft width, giving S = 1148 in^3.
"""
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
return [Blockout(name='P{0}'.format(i + 1),
x0=left - 1.0, y0=1.0 + i * 1.5,
length=2.0, width=0.5)
for i in range(5)]
@pytest.fixture
def solid_panel():
return make_panel()
@pytest.fixture
def pocketed_panel():
return make_panel(mark='ASPIRE-2', blockouts=aspire_pockets())
@pytest.fixture
def stripping():
return Stage(name='stripping',
multiplier=pci.stripping_multiplier('flat', 'smooth'),
fc_psi=4000.0)
@pytest.fixture
def shipping():
return Stage(name='transportation',
multiplier=pci.TRANSPORTATION_MULTIPLIER,
fc_psi=5000.0)
@pytest.fixture
def rolling():
return Rigging(mode='rolling', sling_angle_deg=90.0)
@pytest.fixture
def static_lift():
return Rigging(mode='static', sling_angle_deg=90.0)
# ============================================================================
# Appended by tools/make_dist.py -- knowledge-content test exclusion
# ============================================================================
# See knowledge/catalogue.yaml's own header. test_safety_factors.py already
# self-skips its whole module when knowledge/catalogue.yaml is ABSENT (see
# its own pytestmark) -- but this distribution's carve-out ships that file
# with `catalogue.inserts` EMPTY, so the file-existence check alone no
# longer catches the 1 test(s) below that need real ALP rows in it.
# AUTO-DETECTING the same way: real inserts present, and these run normally.
_KNOWLEDGE_CONTENT_TESTS = ['test_the_catalogue_publishes_four_to_one_in_shear_for_every_qlpd_row']
def _dist_real_inserts_present():
import io as _io
import os as _os
here = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
path = _os.path.join(_os.path.dirname(here), 'knowledge', 'catalogue.yaml')
try:
import yaml as _yaml
with _io.open(path, encoding='utf-8') as fh:
data = _yaml.safe_load(fh.read()) or {}
except Exception:
return False
return bool(((data.get('catalogue') or {}).get('inserts')) or None)
def pytest_collection_modifyitems(config, items):
import pytest as _pytest
if _dist_real_inserts_present():
return
skip = _pytest.mark.skip(
reason='knowledge-content test: asserts the owner\'s real product '
'capacity data, which this distribution does not ship -- '
'see knowledge/catalogue.yaml\'s header and CONTRIBUTING.md')
for item in items:
if item.originalname in _KNOWLEDGE_CONTENT_TESTS or item.name in _KNOWLEDGE_CONTENT_TESTS:
item.add_marker(skip)
+141
View File
@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""The two worked examples from ASPIRE Winter 2019, end to end.
Every number asserted here appears in print in the article. Where our model is
deliberately more refined than the article -- it reduces the self weight for
the pockets, which the article says it neglects -- the difference is asserted
in the direction it must go, rather than papered over with a loose tolerance.
"""
from panellift import pci, section, statics
from panellift.model import Stage
from conftest import ASPIRE
def _stress_psi(moment_lbft, s_in3):
return moment_lbft * 12.0 / s_in3
# ------------------------------------------------------- section properties
def test_solid_section_modulus(solid_panel):
s = section.section_modulus_in3(solid_panel, 16.0)
assert abs(s - ASPIRE['s_solid_in3']) < 1.0
def test_pocketed_section_modulus(pocketed_panel):
"""Five 6 in. pockets in one row: 1148 in^3, printed in the article."""
x = pocketed_panel.blockouts[0].x0 + 1.0
s = section.section_modulus_in3(pocketed_panel, x)
assert abs(s - ASPIRE['s_pocketed_in3']) < 1.0
def test_pockets_only_reduce_the_section_where_they_are(pocketed_panel):
away = section.section_modulus_in3(pocketed_panel, 16.0)
assert abs(away - ASPIRE['s_solid_in3']) < 1.0
# ------------------------------------------------------------- allowables
def test_allowable_tension_at_stripping():
assert abs(pci.allowable_tension_psi(4000.0) -
ASPIRE['strip_allowable_psi']) < 1.0
def test_allowable_tension_at_shipping():
assert abs(pci.allowable_tension_psi(5000.0) -
ASPIRE['ship_allowable_psi']) < 1.0
def test_table_831_multipliers():
assert pci.stripping_multiplier('flat', 'smooth') == 1.3
assert pci.stripping_multiplier('flat', 'exposed') == 1.2
assert pci.stripping_multiplier('sculptured', 'smooth') == 1.7
assert pci.TRANSPORTATION_MULTIPLIER == 1.5
assert pci.YARD_AND_ERECTION_MULTIPLIER == 1.2
# --------------------------------------------------- example 1, solid panel
def test_example_1_stripping(solid_panel, stripping, rolling):
"""1.3 x 23,973 lb-ft over S = 1531 in^3 gives 244 psi, under 316 psi."""
result = statics.analyse(solid_panel, stripping, rolling,
reconcile_weight=False)
_, moment = result.diagram.max_positive
stress = _stress_psi(moment, ASPIRE['s_solid_in3'])
assert abs(stress - ASPIRE['strip_stress_psi']) < 3.0
assert stress < pci.allowable_tension_psi(4000.0)
def test_example_1_shipping(solid_panel, shipping, rolling):
"""1.5 x 23,973 lb-ft over S = 1531 in^3 gives 282 psi, under 354 psi."""
result = statics.analyse(solid_panel, shipping, rolling,
reconcile_weight=False)
_, moment = result.diagram.max_positive
stress = _stress_psi(moment, ASPIRE['s_solid_in3'])
assert abs(stress - ASPIRE['ship_stress_psi']) < 3.0
assert stress < pci.allowable_tension_psi(5000.0)
# ------------------------------------------------ example 2, with pockets
def test_example_2_stripping_fails_as_printed(solid_panel, stripping, rolling):
"""326 psi against 316 psi allowable. No good, says the article.
Computed the article's way: the solid-panel moment against the reduced
section, because the article explicitly neglects the weight the pockets
remove.
"""
result = statics.analyse(solid_panel, stripping, rolling,
reconcile_weight=False)
_, moment = result.diagram.max_positive
stress = _stress_psi(moment, ASPIRE['s_pocketed_in3'])
assert abs(stress - ASPIRE['pocketed_strip_stress_psi']) < 3.0
assert stress > pci.allowable_tension_psi(4000.0)
def test_example_2_shipping_fails_as_printed(solid_panel, shipping, rolling):
"""376 psi against 354 psi allowable."""
result = statics.analyse(solid_panel, shipping, rolling,
reconcile_weight=False)
_, moment = result.diagram.max_positive
stress = _stress_psi(moment, ASPIRE['s_pocketed_in3'])
assert abs(stress - ASPIRE['pocketed_ship_stress_psi']) < 3.0
assert stress > pci.allowable_tension_psi(5000.0)
def test_modelling_the_pocket_weight_relieves_the_moment_slightly(
pocketed_panel, solid_panel, stripping, rolling):
"""Our full model is less conservative than the article, and must say so.
The article neglects the 1.6% of self weight the pockets remove. We do
not, so our moment comes out slightly lower -- a few percent at most. If
that gap were ever large it would mean the blockouts were misread, so the
test pins the size of the difference, not just its sign.
"""
full = statics.analyse(pocketed_panel, stripping, rolling,
reconcile_weight=False)
article = statics.analyse(solid_panel, stripping, rolling,
reconcile_weight=False)
_, m_full = full.diagram.max_positive
_, m_article = article.diagram.max_positive
assert m_full < m_article
assert (m_article - m_full) / m_article < 0.05
def test_pocketed_panel_still_fails_when_modelled_fully(pocketed_panel,
stripping, rolling):
"""The refinement must not turn a printed failure into a pass."""
result = statics.analyse(pocketed_panel, stripping, rolling,
reconcile_weight=False)
x0 = pocketed_panel.blockouts[0].x0
x = x0 + 1.0
moment = abs(result.diagram.moment_at(x))
s = section.section_modulus_in3(pocketed_panel, x)
assert _stress_psi(moment, s) > pci.allowable_tension_psi(4000.0)
File diff suppressed because it is too large Load Diff
+482
View File
@@ -0,0 +1,482 @@
# -*- 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
+607
View File
@@ -0,0 +1,607 @@
# -*- coding: utf-8 -*-
"""The job file is a contract, so it is tested like one.
Revit cannot import this package and this package cannot import Revit; the
JSON between them is the only place the two agree. Every way of getting it
wrong that would still produce a plausible-looking report is worth a test,
because a handling report that looks ordinary and is wrong is the failure this
whole package exists to prevent.
"""
import io
import json
import pytest
from panellift import checks, cli
from conftest import ASPIRE, LENGTH_FT, THICKNESS_IN, WIDTH_FT
from panellift import pci
def aspire_job(**overrides):
"""The ASPIRE panel, written the way Revit would write it."""
left, right = pci.optimal_two_line_supports(LENGTH_FT)
job = {
'schema': 'panellift/1',
'panel': {
'mark': 'ASPIRE-1',
'length_ft': LENGTH_FT,
'width_ft': WIDTH_FT,
'thickness_in': THICKNESS_IN,
'unit_weight_pcf': 150.0,
'fci_psi': 4000.0,
'fc_psi': 5000.0,
'lifters': [
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
'y': 7.5, 'capacity_lb': 12000.0},
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
'y': 7.5, 'capacity_lb': 12000.0},
],
},
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
'stages': [
{'name': 'stripping',
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
'fc_psi': 4000.0},
],
}
job.update(overrides)
return job
# --------------------------------------------------------------- it computes
def test_job_reproduces_the_published_stripping_stress():
"""A job file must reach the same number the fixtures do, or it is a
second implementation rather than a second front door."""
_panel, _rig, cases = cli.analyse_job(aspire_job())
summary = cli.summarise(cases)
peak = summary['stages'][0]['peak_tension_psi']
assert peak == pytest.approx(ASPIRE['strip_stress_psi'], abs=1.5)
def test_stage_without_lifters_key_uses_every_insert():
_panel, _rig, cases = cli.analyse_job(aspire_job())
assert cases[0][0].rig.points == 4
def test_stage_may_carry_the_piece_on_a_subset():
"""The whole reason stages name their inserts: a crane that cannot reach
the top row makes erection a different pick from stripping."""
job = aspire_job()
job['stages'][0]['lifters'] = ['L1', 'L2', 'L3']
_panel, _rig, cases = cli.analyse_job(job)
assert cases[0][0].rig.points == 3
# ------------------------------------------------------------ it refuses well
def test_unknown_lifter_mark_is_an_error_not_a_smaller_pick():
"""Silently analysing three picks where four were meant is
unconservative and looks entirely normal in the report."""
job = aspire_job()
job['stages'][0]['lifters'] = ['L1', 'L2', 'L9']
with pytest.raises(cli.JobError) as err:
cli.analyse_job(job)
assert 'L9' in str(err.value)
def test_duplicate_lifter_marks_are_refused():
job = aspire_job()
job['panel']['lifters'][1]['mark'] = 'L1'
with pytest.raises(cli.JobError):
cli.analyse_job(job)
# --------------------------------------------- capacity_lb: the dict shape
#
# `knowledge.lifter_capacity()` (private repo `pyrevit-knowledge`, week of
# 2026-09-01) grew a richer shape for MC hardware: `capacity_lb` and
# `shear_capacity_lb` can now arrive as a dict instead of a bare number, and
# `liftexport.export()` started writing that dict straight through. A real
# MC dry-run (WPA594) hit `read_panel` doing `float({...})` and died with
# `"capacity_lb" is not a number` -- no report, exit 2, before a single
# check ran. These pin the fix: `cli._resolve_capacity` reading both the
# split-number shape and the angle-unresolved stand-down shape, with the
# existing plain-number shape passing through byte-for-byte unchanged.
def test_a_plain_number_capacity_is_unchanged_the_regression_case():
"""The critical backward-compat: a plain-number deck carries a bare
number and must read identically to before the dict shape ever
existed."""
job = aspire_job()
job['panel']['lifters'][0]['capacity_lb'] = 12000.0
job['panel']['lifters'][0]['shear_capacity_lb'] = 9000.0
panel = cli.read_panel(job['panel'])
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
assert l1.capacity_lb == 12000.0
assert l1.shear_capacity_lb == 9000.0
assert l1.capacity_why is None
assert l1.shear_capacity_why is None
def test_a_split_tension_shear_dict_reads_both_capacities():
"""{'tension_lb': N, 'shear_lb': M} -- MC's QL589-shaped rating."""
job = aspire_job()
job['panel']['lifters'][0]['capacity_lb'] = {
'tension_lb': 13800, 'shear_lb': 3860}
job['panel']['lifters'][0]['shear_capacity_lb'] = {
'tension_lb': 13800, 'shear_lb': 3860}
panel = cli.read_panel(job['panel'])
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
assert l1.capacity_lb == 13800.0
assert l1.shear_capacity_lb == 3860.0
# A dict that resolved to a real number carries no could-not-judge
# reason of its own.
assert l1.capacity_why is None
assert l1.shear_capacity_why is None
def test_a_zero_capacity_inside_a_dict_is_a_real_rating_not_a_missing_one():
"""MC's LPA4T512 publishes `shear_lb: 0` -- the pin genuinely takes no
shear. That is a judgeable 0.0, not the same fact as "no rating"
(which reads as None): a dict carrying the key at all, even as 0, must
resolve to the number, never fall through to the reason branch."""
job = aspire_job()
job['panel']['lifters'][0]['shear_capacity_lb'] = {
'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680,
'tension_lb_unresolved_because': 'sling angle not confirmed'}
panel = cli.read_panel(job['panel'])
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
assert l1.shear_capacity_lb == 0.0
assert l1.shear_capacity_why is None
def test_an_angle_unresolved_dict_leaves_tension_none_with_its_reason():
"""The MC LPA4T512 shape: no settled `tension_lb`, only 60/75-degree
columns and a stated reason nobody has confirmed which applies. This
must NOT pick 60 or 75 -- tension resolves to None, carrying the exact
reason string, never a silently chosen number."""
job = aspire_job()
reason = ('M8 (manufacturer Lifting Anchor Chart published 12.02.22, '
'footnote b) publishes LPA4T512 tension by sling angle -- '
'5,990 lb @ 60 deg, 6,680 lb @ 75 deg -- and asks the plant '
'to confirm which column applies. lifting_anchors.'
'sling_angle_choice is still null: nobody has confirmed it.')
job['panel']['lifters'][0]['capacity_lb'] = {
'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680,
'tension_lb_unresolved_because': reason}
panel = cli.read_panel(job['panel'])
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
assert l1.capacity_lb is None
assert l1.capacity_why == reason
# Neither 60 nor 75 was silently picked.
assert '5990' not in (l1.capacity_why or '')
assert '6680' not in (l1.capacity_why or '')
def test_a_dict_reason_beats_the_generic_top_level_why():
"""The exporter also writes a generic top-level `capacity_why`
("taken as stated (<dict repr>)") beside the dict. The SPECIFIC
per-axis reason inside the dict is what a reader needs, and must win
over that generic wrapper rather than being shadowed by it."""
job = aspire_job()
job['panel']['lifters'][0]['capacity_lb'] = {
'shear_lb': 0, 'tension_60_lb': 5990,
'tension_lb_unresolved_because': 'sling angle not confirmed'}
job['panel']['lifters'][0]['capacity_why'] = (
'TEST_LIFTER is a job- or plant-pinned rating, taken as stated')
panel = cli.read_panel(job['panel'])
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
assert l1.capacity_why == 'sling angle not confirmed'
def test_a_dict_with_no_number_and_no_reason_is_not_judged_not_crashed():
"""A malformed dict -- neither a settled number nor a stated reason for
the axis being read -- must still refuse to guess, but as a named
could-not-judge rather than `float(dict)` raising TypeError."""
job = aspire_job()
job['panel']['lifters'][0]['capacity_lb'] = {'something_else': 1}
panel = cli.read_panel(job['panel'])
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
assert l1.capacity_lb is None
assert l1.capacity_why is not None
assert 'NOT JUDGED' in l1.capacity_why
def test_a_wpa594_shaped_deck_with_empty_stages_refuses_legibly():
"""The real dry-run failure this whole fix is about: a deck with MC's
dict-shaped capacities and zero stages (no `handling.lifter_groups`
mapping yet). Before the fix this died in `read_panel` on
`float({...})`, exit 2, before the empty-stage guard ever ran. After
it, `read_panel` succeeds and the ordinary empty-stage refusal fires
instead -- a clean JobError, not a crash, and not a report."""
job = aspire_job()
job['panel']['lifters'][0]['capacity_lb'] = {
'shear_lb': 0, 'tension_60_lb': 5990,
'tension_lb_unresolved_because': 'sling angle not confirmed'}
job['stages'] = []
with pytest.raises(cli.JobError) as err:
cli.analyse_job(job)
assert 'not a number' not in str(err.value)
def test_a_wpa594_shaped_deck_with_empty_stages_exits_clean_via_main(
tmp_path, capsys):
job = aspire_job()
job['panel']['lifters'][0]['capacity_lb'] = {
'shear_lb': 0, 'tension_60_lb': 5990,
'tension_lb_unresolved_because': 'sling angle not confirmed'}
job['stages'] = []
job_file = tmp_path / 'job.json'
out = tmp_path / 'report.html'
job_file.write_text(json.dumps(job), encoding='utf-8')
assert cli.main([str(job_file), '-o', str(out)]) == 2
assert not out.exists()
captured = capsys.readouterr()
assert 'not a number' not in captured.err
assert 'Traceback' not in captured.err
def test_a_job_with_no_stages_checks_nothing_and_says_so():
job = aspire_job()
job['stages'] = []
with pytest.raises(cli.JobError):
cli.analyse_job(job)
# ------------------------------------------- zero lifters: NOT JUDGED, loudly
#
# `families.lifter_include` matching zero families on the Revit side is
# CLAUDE.md's own named cardinal sin, and it has bitten twice already: the
# shipped defaults matching no real family, and P78 on insulated panels --
# both times a silent degrade into a report that completed and looked like a
# clean pass. `liftexport.export` now refuses before writing a deck shaped
# like this at all, but a HAND-WRITTEN job file skips the exporter entirely,
# so this package has to refuse on its own too -- defense in depth. See
# `cli._refuse_zero_lifters`.
def test_zero_lifters_is_refused_not_judged():
job = aspire_job()
job['panel']['lifters'] = []
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(job)
assert 'NOT JUDGED' in str(caught.value)
assert 'panel.lifters' in str(caught.value)
def test_zero_lifters_names_the_panel_wide_fact_not_one_stages_pick_list():
"""Without the panel-wide guard, the per-stage 'carries the piece on no
inserts' message would fire first -- once statics reached that stage --
and read as one stage's narrower pick problem rather than the fact that
the piece has no lifters modelled at all."""
job = aspire_job()
job['panel']['lifters'] = []
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(job)
assert 'carries the piece on no inserts' not in str(caught.value)
def test_zero_lifters_with_an_explicit_empty_stage_list_is_also_refused():
"""The exporter writes an explicit `"lifters": []` on a stage, not an
absent key (see default_stages() on the Revit side) -- the guard must
catch that shape too, not just a stage with no 'lifters' key at all."""
job = aspire_job()
job['panel']['lifters'] = []
job['stages'][0]['lifters'] = []
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(job)
assert 'NOT JUDGED' in str(caught.value)
def test_zero_lifters_on_dunnage_only_stages_still_computes():
"""A piece genuinely carried on dunnage for every stage that runs needs
no insert at all -- the guard must not refuse a legitimate dunnage-only
deck just because panel.lifters happens to be empty."""
job = _supported_job()
job['panel']['lifters'] = []
_panel, _rig, cases = cli.analyse_job(job)
assert cases[0][0].rig.points == 0
def test_zero_lifters_with_every_lift_stage_disabled_computes_the_rest():
"""A lift stage that is switched off is not going to run, so it does not
need lifters either -- only an ENABLED, non-dunnage stage does."""
dunnage_stage = _supported_job()['stages'][0]
job = aspire_job()
job['panel']['lifters'] = []
job['stages'] = [
dict(job['stages'][0], enabled=False,
disabled_reason='no crane on this site yet'),
dunnage_stage,
]
_panel, _rig, cases = cli.analyse_job(job)
assert len(cases) == 1
assert cases[0][0].stage.supports == dunnage_stage['supports']
def test_a_normal_deck_with_lifters_is_unaffected_by_the_guard():
"""The other side of the same rule: a piece that DOES have lifters must
not trip on a guard meant for the piece that has none."""
_panel, _rig, cases = cli.analyse_job(aspire_job())
assert len(cases) == 1
def test_main_reports_not_judged_without_a_traceback(tmp_path, capsys):
job = aspire_job()
job['panel']['lifters'] = []
job_file = tmp_path / 'job.json'
out = tmp_path / 'report.html'
job_file.write_text(json.dumps(job), encoding='utf-8')
assert cli.main([str(job_file), '-o', str(out)]) == 2
assert not out.exists()
captured = capsys.readouterr()
assert 'NOT JUDGED' in captured.err
assert 'Traceback' not in captured.err
def test_missing_thickness_names_the_field():
job = aspire_job()
del job['panel']['thickness_in']
with pytest.raises(cli.JobError) as err:
cli.analyse_job(job)
assert 'thickness_in' in str(err.value)
def test_a_future_schema_is_refused_rather_than_guessed_at():
with pytest.raises(cli.JobError):
cli.analyse_job(aspire_job(schema='panellift/99'))
def test_unknown_rigging_mode_is_refused():
with pytest.raises(cli.JobError):
cli.analyse_job(aspire_job(rigging={'mode': 'magic'}))
def test_a_stage_with_neither_multiplier_nor_pci_case_is_refused():
"""Defaulting a missing load factor to 1.0 would produce a report that
looks complete and is 30% light."""
job = aspire_job()
del job['stages'][0]['multiplier']
with pytest.raises(cli.JobError) as err:
cli.analyse_job(job)
assert 'pci_case' in str(err.value)
# --------------------------------------------------------- Table 8.3.1 lookup
def test_pci_case_reads_the_table_for_this_panel():
job = aspire_job()
job['stages'][0]['multiplier'] = None
job['stages'][0]['pci_case'] = 'stripping'
_p, _r, cases = cli.analyse_job(job)
assert cases[0][0].stage.multiplier == pytest.approx(1.3) # flat, smooth
def test_pci_case_follows_the_finish():
job = aspire_job()
job['panel']['product_type'] = 'fluted'
job['stages'][0]['multiplier'] = None
job['stages'][0]['pci_case'] = 'stripping'
_p, _r, cases = cli.analyse_job(job)
assert cases[0][0].stage.multiplier == pytest.approx(1.6) # fluted, smooth
def test_a_stated_multiplier_beats_the_table():
"""A plant working to its own number is not overruled by the handbook."""
job = aspire_job()
job['stages'][0]['multiplier'] = 1.4
job['stages'][0]['pci_case'] = 'stripping'
_p, _r, cases = cli.analyse_job(job)
assert cases[0][0].stage.multiplier == pytest.approx(1.4)
def test_a_product_type_not_in_the_table_is_refused():
job = aspire_job()
job['panel']['product_type'] = 'corrugated'
job['stages'][0]['multiplier'] = None
job['stages'][0]['pci_case'] = 'stripping'
with pytest.raises(cli.JobError):
cli.analyse_job(job)
# ------------------------------------------------------------------ the main
def test_main_writes_one_file_covering_every_stage(tmp_path):
job = aspire_job()
job['stages'].append({'name': 'transportation',
'multiplier': pci.TRANSPORTATION_MULTIPLIER,
'fc_psi': 5000.0})
job_file = tmp_path / 'job.json'
out = tmp_path / 'report.html'
job_file.write_text(json.dumps(job), encoding='utf-8')
assert cli.main([str(job_file), '-o', str(out)]) == 0
html = io.open(str(out), encoding='utf-8').read()
assert html.count('<!doctype html>') <= 1
assert 'stripping' in html and 'transportation' in html
def test_main_exits_nonzero_when_a_stage_fails(tmp_path):
"""The exit code is what a caller checks when it is not going to read the
HTML -- it has to mean something."""
job = aspire_job()
job['stages'][0]['fc_psi'] = 500.0 # nothing passes at 500 psi
job_file = tmp_path / 'job.json'
job_file.write_text(json.dumps(job), encoding='utf-8')
assert cli.main([str(job_file), '-o', str(tmp_path / 'r.html')]) == 1
def test_main_rejects_a_bad_job_file_without_writing_a_report(tmp_path):
job_file = tmp_path / 'job.json'
out = tmp_path / 'report.html'
job_file.write_text('{"schema": "panellift/1"}', encoding='utf-8')
assert cli.main([str(job_file), '-o', str(out)]) == 2
assert not out.exists()
# ------------------------------------------------- a stage may rig its own way
def _peak(analysis):
points = checks.stress_profile(analysis)
return max(abs(p.stress_psi) for p in points)
def _three_line_job(**stage_extra):
"""Three support lines, where rolling and static actually disagree.
On two lines the two modes are the same problem: statics alone fixes both
reactions. It takes a third line before stiffness has anything to say.
"""
job = aspire_job()
mid = LENGTH_FT / 2.0
job['panel']['lifters'].extend([
{'mark': 'L5', 'family': 'TEST_LIFTER', 'x': mid, 'y': 2.5,
'capacity_lb': 12000.0},
{'mark': 'L6', 'family': 'TEST_LIFTER', 'x': mid, 'y': 7.5,
'capacity_lb': 12000.0},
])
job['stages'][0].update(stage_extra)
return job
def test_a_stage_without_a_rig_mode_inherits_the_job():
_p, _r, cases = cli.analyse_job(_three_line_job())
_analysis, rigging, _f = cases[0]
assert rigging.mode == 'rolling'
def test_a_stage_can_rig_differently_from_the_job():
"""The setting has to reach the mechanics, not just be stored.
This is the whole point of it: a commercial check of one real piece
stripped on fixed slings and erected on an equalising block, and the
stripping moment came out 14.1 k-ft against 23.8 for one mode everywhere.
Whichever is right for a given plant, the two must not produce the same
answer -- if they do, the setting is decorative.
"""
rolling = cli.analyse_job(_three_line_job(rig_mode='rolling'))[2][0]
static = cli.analyse_job(_three_line_job(rig_mode='static'))[2][0]
assert rolling[1].mode == 'rolling'
assert static[1].mode == 'static'
assert _peak(rolling[0]) != pytest.approx(_peak(static[0]), rel=1e-6)
def test_the_job_rigging_is_left_alone_by_a_stage_override():
"""One stage overriding must not re-rig the stages after it."""
job = _three_line_job()
job['stages'] = [dict(job['stages'][0], name='stripping',
rig_mode='static'),
dict(job['stages'][0], name='erection')]
_p, rigging, cases = cli.analyse_job(job)
assert [c[1].mode for c in cases] == ['static', 'rolling']
assert rigging.mode == 'rolling'
def test_a_misspelt_rig_mode_is_refused_rather_than_inherited():
"""'Static' silently inheriting 'rolling' would change every moment in the
stage and look exactly like a stage that meant to inherit."""
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(_three_line_job(rig_mode='fixed'))
assert 'rig_mode' in str(caught.value)
def test_the_summary_says_which_mode_each_stage_used():
"""A per-stage setting the output does not show is one nobody can check."""
# The job rigs one way and the stage the other, so reporting the stage's
# mode is the only way to pass: printing job['rigging']['mode'] would say
# 'static' here and nobody reading the report would know the difference.
job = aspire_job(rigging={'mode': 'static', 'sling_angle_deg': 90.0})
job['stages'][0]['rig_mode'] = 'rolling'
_p, _r, cases = cli.analyse_job(job)
assert cli.summarise(cases)['stages'][0]['rig_mode'] == 'rolling'
# ------------------------------------------------------------- supports
def _supported_job():
job = aspire_job()
job['stages'][0] = {
'name': 'transportation',
'pci_case': 'transportation',
'multiplier': 1.4,
'fc_psi': 5000.0,
'supports': [0.2 * LENGTH_FT, 0.8 * LENGTH_FT],
}
return job
def test_a_stage_with_supports_is_solved_without_a_pick():
"""Dunnage is not rigging: no lifters, no sling, no insert findings."""
_p, _r, cases = cli.analyse_job(_supported_job())
analysis, rigging, findings = cases[0]
assert analysis.rig.points == 0
assert analysis.stage.supports == [0.2 * LENGTH_FT, 0.8 * LENGTH_FT]
assert analysis.rig.lines[0].lifters == []
codes = set(f.code for f in findings)
assert 'INSERT' not in codes and 'INSERT-UNRATED' not in codes
assert 'PLUMB' not in codes
assert 'PICK' not in codes
def test_two_symmetrical_supports_share_the_piece_evenly():
_p, _r, cases = cli.analyse_job(_supported_job())
total = cases[0][0].profile.total_lb
reactions = [line.reaction_lb for line in cases[0][0].rig.lines]
assert reactions[0] == pytest.approx(total / 2.0, rel=1e-6)
assert reactions[1] == pytest.approx(total / 2.0, rel=1e-6)
def test_the_summary_carries_the_support_positions():
_p, _r, cases = cli.analyse_job(_supported_job())
summary = cli.summarise(cases)
assert summary['stages'][0]['supports'] == \
[0.2 * LENGTH_FT, 0.8 * LENGTH_FT]
def test_a_stage_cannot_name_both_supports_and_lifters():
job = _supported_job()
job['stages'][0]['lifters'] = ['L1', 'L2']
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(job)
assert 'both supports and lifters' in str(caught.value)
def test_one_support_line_is_refused():
job = _supported_job()
job['stages'][0]['supports'] = [0.2 * LENGTH_FT]
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(job)
assert 'at least two supports' in str(caught.value)
def test_a_support_outside_the_piece_is_refused():
job = _supported_job()
job['stages'][0]['supports'] = [0.2 * LENGTH_FT, LENGTH_FT + 1.0]
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(job)
assert 'outside the piece' in str(caught.value)
def test_an_empty_lifter_list_does_not_contradict_a_support_line():
"""The Revit exporter writes `lifters: []` on a stage it turned into
supports, and an empty list names no insert. Refusing it made every job
file a plant with dunnage configured could write unreadable -- which no
test caught, because the shape came from the other side of the language
boundary and the tests here built their own."""
job = aspire_job()
haul = dict(job['stages'][0], name='transportation', lifters=[],
supports=[8.0, 24.0])
job['stages'] = [job['stages'][0], haul]
_p, _r, cases = cli.analyse_job(job)
assert cases[1][0].stage.supports == [8.0, 24.0]
def test_a_support_line_and_a_named_insert_are_still_refused():
job = aspire_job()
haul = dict(job['stages'][0], name='transportation', lifters=['L1'],
supports=[8.0, 24.0])
job['stages'] = [haul]
with pytest.raises(cli.JobError) as caught:
cli.analyse_job(job)
assert 'one or the other' in str(caught.value)
+285
View File
@@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
"""The compression limit is a job decision too, following the tensile one.
`compression_limit_ratio` multiplies f'c (f'ci at stripping, since the ratio
reads whatever `Stage.fc_psi` the stage was built with) to give the
compression allowable in psi. Unlike the tensile limit, the owner did not
leave two schools of thought open here -- he closed the question,
2026-09-01:
"0.6保持可调" ("0.6 stays adjustable")
0.6 remains the shipped default (ACI 318-19 Table 24.5.3.1, "all other
locations", transfer; Sec. 24.5.4.1, service) and becomes a per-job,
per-stage field exactly like `tension_limit_sqrt_fc`: a job file may state
it, a `stages[]` entry may override it for that stage, and
`compression_limit_source` says which. ACI's higher 0.70*f'ci at member ends
is deliberately NOT implemented -- "先不用实现0.7...别搞这么复杂" -- so
there is one ratio for the whole piece, and nothing here tests a location
split.
Mirrors `test_tension_limit.py` structurally; see that file's docstring for
the reasoning behind the pattern.
"""
import pytest
from panellift import checks, cli, layout, report
from panellift.checks import Finding, StressPoint
from panellift.model import COMPRESSION_LIMIT_SOURCES, Panel, Stage
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT
from panellift import pci
# --------------------------------------------------- the model-level ratio
def test_the_default_ratio_and_source():
"""Byte-identity when nobody sets it -- the same contract
`tension_limit_sqrt_fc` gives at 5.0."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
assert stage.compression_limit_ratio == 0.6
assert stage.compression_limit_source == 'default'
def test_the_ratio_multiplies_the_stages_own_strength():
"""The requirement this whole feature turns on: the ratio multiplies
THIS STAGE'S f'c/f'ci, not the job's f'c or a strength borrowed from
another stage. Two stages with the same ratio and different strengths
(f'ci at stripping vs f'c at yard, say) must scale independently."""
stripping = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
compression_limit_ratio=0.6)
yard = Stage(name='yard', multiplier=1.0, fc_psi=5000.0,
compression_limit_ratio=0.6)
assert stripping.allowable_compression_psi == pytest.approx(1800.0)
assert yard.allowable_compression_psi == pytest.approx(3000.0)
# Same ratio, different psi allowable, because the strength that changed
# was the STAGE's, not the ratio's.
assert stripping.allowable_compression_psi != yard.allowable_compression_psi
def test_a_different_ratio_moves_the_same_stages_allowable():
"""The other half of the same requirement, isolating the ratio instead
of the strength: hold f'ci fixed and change only the ratio."""
base = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
compression_limit_ratio=0.6)
relaxed = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
compression_limit_ratio=0.7)
assert base.allowable_compression_psi == pytest.approx(1800.0)
assert relaxed.allowable_compression_psi == pytest.approx(2100.0)
def test_compression_basis_names_ratio_and_source():
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_ratio=0.7, compression_limit_source='job')
assert stage.compression_basis == "0.7*f'c, job"
def test_the_basis_string_is_ascii():
"""Findings are printed to whatever console launched the run -- no
character here may be outside cp1252."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
stage.compression_basis.encode('ascii')
def test_an_unrecognised_source_word_prints_stated_not_a_guess():
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_source='typo')
assert stage.compression_limit_source_word == 'stated'
# -------------------------------------------------- the check actually uses it
def _points(compression_psi, prestress_lb=1000.0):
return [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0,
stress_psi=0.0, compression_psi=compression_psi,
prestress_lb=prestress_lb)]
def test_no_prestress_means_no_compression_finding():
"""Compression only becomes a question once there is prestress -- see
`check_compression`'s own docstring. Unaffected by this field."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
points = [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0, stress_psi=0.0,
compression_psi=500.0, prestress_lb=0.0)]
assert checks.check_compression(points, stage) == []
def test_the_ratio_decides_the_verdict():
"""0.6*4000 = 2400 psi allowable. 2000 psi passes comfortably; 2500
fails; the stage's OWN ratio is what makes the difference, not a
hard-coded 0.6 inside the check."""
stage_default = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
assert checks.check_compression(_points(2000.0), stage_default)[0] \
.severity == 'OK'
assert checks.check_compression(_points(2500.0), stage_default)[0] \
.severity == 'FAIL'
# The same 2500 psi passes once the job relaxes the ratio: proof the
# number that moved the verdict was the ratio, not the stress.
stage_relaxed = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_ratio=0.7,
compression_limit_source='job')
assert checks.check_compression(_points(2500.0), stage_relaxed)[0] \
.severity == 'OK'
def test_every_compression_finding_states_the_basis():
"""CRUSH is the check the ratio decides, and its line has to carry the
ratio and who chose it beside the psi -- exactly as CRACK does for
tension."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_ratio=0.7, compression_limit_source='job')
found = checks.check_compression(_points(2500.0), stage)
assert found
assert "0.7*f'c, job" in found[0].message
# ------------------------------------------------------- the deck round trip
def _panel():
return Panel(mark='p', length_ft=1.0, width_ft=1.0, thickness_in=1.0)
def test_absent_from_the_deck_is_the_default():
"""A job file written before this field existed leaves both keys out,
and that has to read exactly as it always has: 0.6, default."""
stage, _marks = cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0}, 0, _panel())
assert stage.compression_limit_ratio == 0.6
assert stage.compression_limit_source == 'default'
def test_a_stated_ratio_is_read_as_job():
stage, _marks = cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 0.7}, 0, _panel())
assert stage.compression_limit_ratio == 0.7
assert stage.compression_limit_source == 'job'
def test_the_exporter_can_say_a_stage_overrode_the_job():
stage, _marks = cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 0.7,
'compression_limit_source': 'stage'}, 0, _panel())
assert stage.compression_limit_source == 'stage'
def test_an_unknown_provenance_word_is_refused_not_displayed():
with pytest.raises(cli.JobError) as exc:
cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 0.7,
'compression_limit_source': 'jobb'}, 0, _panel())
assert 'compression_limit_source' in str(exc.value)
def test_a_non_numeric_ratio_is_refused():
"""Mirrors `tension_limit_sqrt_fc`'s own refusal shape: garbage is
refused loudly, never silently coerced to the 0.6 default."""
with pytest.raises(cli.JobError) as exc:
cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 'static_lift'}, 0, _panel())
assert 'compression_limit_ratio' in str(exc.value)
def test_the_provenance_words_are_the_ones_the_model_knows():
assert set(COMPRESSION_LIMIT_SOURCES) == set(['default', 'job', 'stage'])
# ------------------------------------------------ end to end, with a real pick
def _job(compression_limit_ratio=None, compression_limit_source=None):
"""The ASPIRE panel, prestressed at mid-depth (e=0) so the only thing
the strands add is uniform P/A compression -- enough to exercise
`check_compression` without pulling in the eccentricity mechanics
`test_prestress.py` already covers on its own piece."""
left, right = pci.optimal_two_line_supports(LENGTH_FT)
pull = 0.153 * 270_000.0 * 0.75
mid_z = THICKNESS_IN / 2.0
stage = {'name': 'stripping',
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
'fc_psi': 4000.0}
if compression_limit_ratio is not None:
stage['compression_limit_ratio'] = compression_limit_ratio
if compression_limit_source is not None:
stage['compression_limit_source'] = compression_limit_source
return {
'schema': 'panellift/1',
'panel': {
'mark': 'ASPIRE-1',
'length_ft': LENGTH_FT,
'width_ft': WIDTH_FT,
'thickness_in': THICKNESS_IN,
'unit_weight_pcf': 150.0,
'fci_psi': 4000.0,
'fc_psi': 5000.0,
'lifters': [
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
'y': 7.5, 'capacity_lb': 12000.0},
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
'y': 7.5, 'capacity_lb': 12000.0},
],
'prestress': {
'strands': [
{'y': 2.5, 'z_in': mid_z, 'initial_pull_lb': pull},
{'y': 7.5, 'z_in': mid_z, 'initial_pull_lb': pull},
],
'loss_pct': 8.0,
},
},
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
'stages': [stage],
}
def _analysed(**kw):
_panel, _rigging, cases = cli.analyse_job(_job(**kw))
return cases[0]
def test_the_stage_carries_the_ratio_through_analyse_job():
stage = _analysed(compression_limit_ratio=0.7,
compression_limit_source='job')[0].stage
assert stage.compression_limit_ratio == 0.7
assert stage.compression_limit_source == 'job'
assert stage.allowable_compression_psi == pytest.approx(0.7 * 4000.0)
def test_a_real_pick_produces_a_named_crush_finding():
"""End to end: a prestressed piece really does report compression, and
the line names the ratio and who chose it -- the report-side
requirement, exercised through the actual arithmetic rather than a
synthetic StressPoint."""
findings = _analysed(compression_limit_ratio=0.6,
compression_limit_source='default')[2]
crush = [f for f in findings if f.code == 'CRUSH']
assert crush
assert "0.6*f'c, default" in crush[0].message
def test_the_default_note_cites_aci_and_the_conservative_choice():
"""The report's note beside the compression allowable has to name the
source -- and when that source is the untouched default, cite ACI and
say plainly that the flat ratio (vs. ACI's 0.70 at member ends) is a
deliberate, conservative simplification."""
page = report.build_many(
[_analysed(compression_limit_ratio=0.6,
compression_limit_source='default')],
recommendation=layout.not_searched())
assert '24.5.3.1' in page or '24.5.4.1' in page
assert '0.70' in page
assert 'ACI' in page
def test_a_job_set_ratio_does_not_get_the_citation():
"""The citation belongs to the number nobody set. A job that chose its
own ratio does not need ACI quoted back at it."""
page = report.build_many(
[_analysed(compression_limit_ratio=0.7,
compression_limit_source='job')],
recommendation=layout.not_searched())
assert 'Compression allowable:' not in page
+72
View File
@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""An insert is worth less at release than at 28 days.
A catalogue rating is derived at one concrete strength and the anchor pulls a
cone of concrete out, so below that strength the capacity follows sqrt(f'c).
One handling report checks the same insert at stripping AND at erection, which
is why the rating cannot be derated once on the way in: it has to be done per
stage, at the strength that stage actually has.
"""
import dataclasses
import pytest
from conftest import make_panel
from panellift import checks, pci, statics
from panellift.model import Stage
def rated_panel(basis=3500.0, capacity=8000.0):
panel = make_panel()
return dataclasses.replace(panel, lifters=[
dataclasses.replace(l, capacity_lb=capacity, basis_fc_psi=basis)
for l in panel.lifters])
# ------------------------------------------------------------- the factor
def test_below_the_basis_it_follows_the_square_root():
assert pci.insert_derate(3500.0, 2500.0) == pytest.approx(0.8452, abs=1e-4)
def test_at_or_above_the_basis_nothing_is_gained():
"""Past the basis the anchor's own steel governs; stronger concrete around
it does not make the anchor stronger."""
assert pci.insert_derate(3500.0, 3500.0) == 1.0
assert pci.insert_derate(3500.0, 6000.0) == 1.0
def test_an_unstated_basis_does_not_silently_reduce_a_rating():
assert pci.insert_derate(None, 2500.0) == 1.0
assert pci.insert_derate(3500.0, None) == 1.0
# --------------------------------------------------------- through a check
def test_the_same_insert_is_rated_differently_at_two_stages():
panel = rated_panel()
lifter = panel.lifters[0]
at_release = Stage('stripping', 1.3, 2500.0)
at_28 = Stage('erection', 1.2, 6000.0)
assert checks.rated(lifter, at_release) == pytest.approx(6761, abs=1)
assert checks.rated(lifter, at_28) == pytest.approx(8000, abs=1)
def test_the_capacity_check_uses_the_derated_number(rolling):
panel = rated_panel()
stage = Stage('stripping', 1.3, 2500.0)
a = statics.analyse(panel, stage, rolling, role='strip')
inserts = [f for f in checks.check_insert_capacity(a, rolling)
if f.code == 'INSERT']
assert inserts
assert 'derated from 8,000 lb' in inserts[0].message
def test_no_basis_means_the_rating_is_taken_as_stated(rolling):
"""A plant that types a number into capacity_lb meant that number."""
panel = rated_panel(basis=None)
stage = Stage('stripping', 1.3, 2500.0)
a = statics.analyse(panel, stage, rolling, role='strip')
inserts = [f for f in checks.check_insert_capacity(a, rolling)
if f.code == 'INSERT']
assert inserts and 'derated' not in inserts[0].message
+233
View File
@@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
"""The detailed Vci/Vcw shear method: `capacity.detailed_shear_at`.
Parity gap #6 -- our simplified V_c = 2*sqrt(f'c)*bw*d does not reproduce the
reference tool's (Eriksson Wall) PCI detailed min(Vci, Vcw), which left the
handling shear utilisation low by -35% (MWV1.0) to -54% (WPB3.0) on the parity
sweep. The formulas are `docs/eriksson-wall-algorithms.md` sec.3, transcribed
from that tool's QC document (QC p.37 prestressed, QC p.76 non-prestressed).
Every number below is HAND COMPUTED, the arithmetic written into the comment
beside it, on a rectangular prestressed section chosen so the closed forms are
exact -- the same discipline `test_capacity.py` uses:
b = 48 in. t = 8 in. L = 32 ft f'c = 4000 psi w_c = 150 pcf
4 strands, 0.153 in.^2 each, all at z = 2.0 in. above the form face
jacked at 30,000 lb each, 10% loss, l_t stated at 30 in.
f'c = 4000 puts sqrt(f'c) = 63.24555..., w_c = 150 puts lambda = 1.0, and a
mid-span station (x = 16 ft, no blockout) develops every strand fully, so:
Pi = 4 * 30000 * (1 - 0.10) = 108,000 lb
A = 48 * 8 = 384 in^2
fpc = Pi/A = 281.25 psi (centroid, Vcw)
dv = 0.8 * 8 = 6.40 in (ACI 22.5.8.3.1)
b*d = 48 * 6.40 = 307.2 in^2
I = 48 * 8^3 / 12 = 2048 in^4
St = I / (t/2) = 2048 / 4 = 512 in^3
e = z_strand - t/2 = 2.0 - 4.0 = -2.0 in (low strand)
fpe = Pi/A + Pi*|e|*(t/2)/I (compression at
= 281.25 + 108000*2.0*4/2048 = 703.125 psi the sagging tension
fibre, for Mcre)
Mcre = St*(6*sqrt(f'c) + fpe) (PCI 5-26)
= 512*(6*63.24555 + 703.125) = 554,290.4 lb-in
= 46,190.86 lb-ft
Vcw = (3.5*sqrt(f'c) + 0.3*fpc)*b*d (PCI 5-28)
= (3.5*63.24555 + 0.3*281.25)*307.2
= (221.3594 + 84.375)*307.2 = 93,921.62 lb
The pins are WRITTEN-DOWN goldens, not recomputations. A test that rebuilds
both sides agrees with any refactor, including a wrong one -- PROJECT.md 3.7.
"""
import math
from panellift import capacity, pci
from panellift.model import Blockout, Panel, Prestress, Stage, Strand
SQRT_4000 = math.sqrt(4000.0) # 63.245553...
MID_X = 16.0 # mid-span: every strand fully developed
def _pre_panel(blockouts=None):
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
initial_pull_lb=30000.0)
for i in range(4)]
return Panel(
mark='HS-PRE', length_ft=32.0, width_ft=48.0 / 12.0, thickness_in=8.0,
unit_weight_pcf=150.0, fci_psi=4000.0, fc_psi=4000.0,
blockouts=list(blockouts or []),
prestress=Prestress(strands=strands, loss_pct=10.0, diameter_in=0.5,
transfer_length_in=30.0))
def _stage():
return Stage(name='strip', multiplier=1.4, fc_psi=4000.0)
# --------------------------------------------------------- the shared terms
def test_section_terms_match_the_hand_calc():
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0,
8000.0)
assert d.prestressed is True
assert abs(d.dv_in - 6.40) < 1e-9 # 0.8 * 8
assert abs(d.bd_in2 - 307.2) < 1e-9 # 48 * 6.40
assert abs(d.fpc_psi - 281.25) < 1e-6 # Pi/A = 108000/384
assert abs(d.fpe_psi - 703.125) < 1e-6 # 281.25 + 108000*2*4/2048
assert abs(d.mcre_lbft - 46190.8616) < 1e-2 # St*(6 sqrt f'c + fpe)/12
assert abs(d.vcw_lb - 93921.6188) < 1e-2 # (3.5 sqrt f'c + 0.3 fpc)bd
# --------------------------------------------------------- Vci.1 governs
def test_vci_flexure_shear_term_governs_above_the_floor():
"""M = 10,000 lb-ft, V = 8,000 lb -- Vci.1 clears the 1.7 floor.
Vci.1 = 0.6*sqrt(f'c)*b*d + Vu*Mcre/Mmax (PCI 5-25)
= 0.6*63.24555*307.2 + 8000*46190.86/10000
= 11,657.36 + 36,952.69 = 48,610.11 lb
Vci.2 = 1.7*sqrt(f'c)*b*d = 1.7*63.24555*307.2 = 33,029.36 lb [floor]
Vci = max(48,610.11, 33,029.36) = 48,610.11 lb
Vcw = 93,921.62 lb ; Vc = min = 48,610.11 lb
phiVn = 0.75 * 48,610.11 = 36,457.58 lb
util = 8000 / 36,457.58 = 0.219433
"""
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0,
8000.0)
assert d.governed_by == 'Vci'
assert abs(d.vci_lb - 48610.1097) < 1e-2
assert abs(d.vc_lb - 48610.1097) < 1e-2
assert abs(d.phi_vn_lb - 36457.5822) < 1e-2
assert abs(d.v_over_phi_vn - 0.2194331) < 1e-6
# --------------------------------------------------------- the 1.7 floor
def test_vci_floor_governs_when_the_flexure_shear_term_is_small():
"""M = 20,000 lb-ft, V = 8,000 lb -- Vci.1 falls under the floor.
Vci.1 = 11,657.36 + 8000*46190.86/20000
= 11,657.36 + 18,476.34 = 30,133.70 lb
Vci.2 = 33,029.36 lb [floor wins]
Vci = max(30,133.70, 33,029.36) = 33,029.36 lb
Vc = min(33,029.36, 93,921.62) = 33,029.36 lb
phiVn = 0.75 * 33,029.36 = 24,772.02 lb
util = 8000 / 24,772.02 = 0.322945
This is the 1.7*sqrt(f'c)*bw*d FLOOR (PCI 5-27), a max and not a cap -- the
correction #4 in docs/eriksson-wall-algorithms.md. It is exactly this floor
that lands MWV1.0 on Eriksson's 0.19 in the parity harness (the reduced
end section where Vci.1 sits below it); dropping the floor overshoots.
"""
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 20000.0,
8000.0)
assert d.governed_by == 'Vci'
assert abs(d.vci_lb - 33029.3577) < 1e-2 # the floor, 1.7 sqrt f'c bd
assert abs(d.vc_lb - 33029.3577) < 1e-2
assert abs(d.v_over_phi_vn - 0.3229450) < 1e-6
# ---------------------------------------------- zero moment -> Vcw governs
def test_zero_moment_station_sends_vci_to_infinity_and_vcw_governs():
"""M -> 0 : Vci.1 -> +inf, so Vci = +inf and Vc = Vcw (docs sec.3.1).
This is the reference tool's printed "inf" in the Vci column at a
zero-moment station (QC p.15/p.18). Vcw is independent of the moment:
Vc = Vcw = 93,921.62 lb
phiVn = 0.75 * 93,921.62 = 70,441.21 lb
util = 8000 / 70,441.21 = 0.113570
"""
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 0.0, 8000.0)
assert d.vci_lb == float('inf')
assert d.governed_by == 'Vcw'
assert abs(d.vc_lb - 93921.6188) < 1e-2
assert abs(d.phi_vn_lb - 70441.2141) < 1e-2
assert abs(d.v_over_phi_vn - 0.1135699) < 1e-6
def test_a_moment_just_under_one_lbft_is_treated_as_zero():
"""The Mmax->0 guard fires at the same 1 lb-ft floor the ratios use."""
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 0.5, 8000.0)
assert d.vci_lb == float('inf')
assert d.governed_by == 'Vcw'
# --------------------------------------------------- non-prestressed path
def test_non_prestressed_uses_the_aci_max_not_a_cap():
"""ACI 318-14 22.5.6.1, phi*Vn = 0.75*max(Vc, Vc.min) -- QC p.76.
Same rectangular section, no strand, no axial (Nu = 0):
Vc = 2*lambda*(1 + 0)*sqrt(f'c)*bw*d
= 2*63.24555*307.2 = 38,858.07 lb
Vc.min = (4/3)*lambda*sqrt(f'c)*Ag
= 1.33333*63.24555*384 = 32,381.72 lb
Vc = max(38,858.07, 32,381.72) = 38,858.07 lb (Vc governs)
phiVn = 0.75 * 38,858.07 = 29,143.55 lb
It is a MAX, not a cap (docs correction #5, a genuine surprise). With
dv = 0.8h the flexure term always clears the (4/3)Ag floor for a solid
rectangle at Nu = 0, so Vc governs here; the branch is still a `max`, which
is asserted directly below so a future edit to a `min`/cap is caught.
"""
panel = Panel(mark='HS-NP', length_ft=32.0, width_ft=48.0 / 12.0,
thickness_in=8.0, unit_weight_pcf=150.0,
fci_psi=4000.0, fc_psi=4000.0)
d = capacity.detailed_shear_at(panel, _stage(), MID_X, 5000.0, 8000.0)
assert d.prestressed is False
assert d.governed_by == 'Vc'
vc_flex = 2.0 * SQRT_4000 * 0.8 * 384.0 # 38,858.07
vc_min = (4.0 / 3.0) * SQRT_4000 * 384.0 # 32,381.72
assert abs(d.vc_lb - vc_flex) < 1e-2
assert abs(d.vc_lb - max(vc_flex, vc_min)) < 1e-9 # a MAX, never a cap
assert d.vc_lb > vc_min # the floor did not win
assert abs(d.phi_vn_lb - 29143.5509) < 1e-2
# -------------------------------------------------- additive: nothing moved
def test_simplified_shear_capacity_is_untouched():
"""`capacity_at` and its simplified V_c are what Panel Lift Check reads;
the detailed path must not change them.
V_c(simplified) = 2*lambda*sqrt(f'c)*bw*d, d = max(dp, 0.8h)
Here dp(sagging) = t - z = 6.0 in, floored at 0.8h = 6.4 in, so d = 6.4:
V_c = 2*63.24555*48*6.4 = 38,858.07 lb
phiVn = 0.75 * 38,858.07 = 29,143.55 lb
"""
cap = capacity.capacity_at(_pre_panel(), _stage(), MID_X, 10000.0, 8000.0)
assert abs(cap.phi_vn_lb - 29143.5509) < 1e-2
assert abs(cap.d_shear_in - 6.4) < 1e-9 # 0.8h floor, dp = 6.0 < 6.4
def test_detailed_and_simplified_disagree_by_design():
"""The whole point of the gap: the two methods give different V_c, and the
detailed one is what matches Eriksson. At this station Vci (48,610) beats
the simplified V_c (38,858), so the detailed util is LOWER here -- but at a
reduced section where the 1.7 floor bites (a blockout), the detailed util
runs HIGHER, which is what closes MWV1.0's -35%."""
stage = _stage()
det = capacity.detailed_shear_at(_pre_panel(), stage, MID_X, 10000.0, 8000.0)
old = capacity.capacity_at(_pre_panel(), stage, MID_X, 10000.0, 8000.0)
assert det.phi_vn_lb != old.phi_vn_lb
# ------------------------------------------ prestress reduced at a blockout
def test_undeveloped_strand_lowers_fpc_and_fpe():
"""A blockout that cuts strands drops Pi at the station, so fpc and fpe
fall with it -- the detailed shear reads the SAME developed prestress the
stress and flexure paths do (prestress.state_at), never the full jacking
force. A full-depth blockout over the whole width from x=10 to x=14 cuts
every strand; just past it at x=15 the strands are still redeveloping."""
blocked = _pre_panel(blockouts=[Blockout(
name='O', x0=10.0, y0=0.0, length=4.0, width=48.0 / 12.0)])
near = capacity.detailed_shear_at(blocked, _stage(), 15.0, 10000.0, 8000.0)
far = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0,
8000.0)
assert near.fpc_psi < far.fpc_psi # less strand developed here
assert near.vcw_lb < far.vcw_lb # so Vcw is smaller too
@@ -0,0 +1,130 @@
# -*- coding: utf-8 -*-
"""The reference tool's (Eriksson Wall) §1.3 developed-strand-at-opening rule:
a strand a blockout SAW-CUT re-develops over the DEVELOPMENT length from that
edge, while a strand's transfer from the PIECE END is unchanged.
docs/eriksson-wall-algorithms.md sec.1.3 / sec.9. ADDITIVE and OPT-IN: it fires
only when a caller passes `ld_ft` (the handling STRESS path,
`capacity.handling_stress_at`). `prestress.state_at` with no `ld_ft` -- which is
what Panel Lift Check's `checks.stress_profile` uses -- keeps the physically
correct transfer-length force model, so a clean solid panel is byte-for-byte
what it was (validated against Eriksson to ~1%).
"""
import pytest
from panellift import capacity, prestress
from panellift.model import Blockout, Panel, Prestress, Stage, Strand
def _panel_with_one_opening():
"""20 ft panel, a full-depth opening x = 8..12 across the strand's y band.
The single strand at y = 2 is cut by it, so it has two bonded runs,
[0, 8] and [12, 20]. l_t = 2 ft (stated), and the tests pass l_d = 5 ft.
"""
strand = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0)
pre = Prestress(strands=[strand], loss_pct=0.0, diameter_in=0.5,
fpu_ksi=270.0, transfer_length_in=24.0) # l_t = 2 ft
panel = Panel(mark='O', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
prestress=pre)
panel.blockouts = [Blockout(name='O1', x0=8.0, y0=0.0, length=4.0,
width=4.0)]
return panel, strand
def test_developed_uses_development_length_from_an_opening_edge():
"""At x = 6, 2 ft short of the opening edge at x = 8, over l_d = 5:
fraction = (8 - 6)/5 = 0.4 -- against 1.0 with transfer only.
The piece-end side (x = 6, l_t = 2) is already full, so the opening edge
governs.
"""
panel, strand = _panel_with_one_opening()
# transfer only: (8-6)/2 = 1.0 on the opening side -> full
assert prestress.developed(strand, panel, 6.0, lt_ft=2.0) == pytest.approx(1.0)
# development length from the opening edge: (8-6)/5 = 0.4
assert prestress.developed(strand, panel, 6.0, lt_ft=2.0,
ld_ft=5.0) == pytest.approx(0.4)
def test_developed_keeps_transfer_length_from_the_piece_end():
"""At x = 1, near the piece end (x = 0), the transfer length still governs
even when l_d is supplied: (1 - 0)/2 = 0.5, the same both ways. Releasing
at the bed transfers f_se over l_t; only a saw-cut rebuilds over l_d."""
panel, strand = _panel_with_one_opening()
assert prestress.developed(strand, panel, 1.0, lt_ft=2.0) == pytest.approx(0.5)
assert prestress.developed(strand, panel, 1.0, lt_ft=2.0,
ld_ft=5.0) == pytest.approx(0.5)
def test_developed_on_the_far_side_of_the_opening():
"""In the second run [12, 20], the near edge x = 12 is an OPENING edge, so
at x = 14: (14 - 12)/5 = 0.4 with l_d, 1.0 with transfer only."""
panel, strand = _panel_with_one_opening()
assert prestress.developed(strand, panel, 14.0, lt_ft=2.0) == pytest.approx(1.0)
assert prestress.developed(strand, panel, 14.0, lt_ft=2.0,
ld_ft=5.0) == pytest.approx(0.4)
def test_state_at_ld_reduces_pi_near_an_opening():
"""`state_at(..., ld_ft=5)` credits less prestress force approaching an
opening than the transfer-only default. Pi = 30000 * developed."""
panel, _ = _panel_with_one_opening()
s_transfer = prestress.state_at(panel, 6.0)
s_dev = prestress.state_at(panel, 6.0, ld_ft=5.0)
assert s_transfer.force_lb == pytest.approx(30000.0) # full
assert s_dev.force_lb == pytest.approx(30000.0 * 0.4) # 12000
def test_clean_panel_is_untouched_by_the_ld_rule():
"""A panel with NO openings has only piece-end boundaries, so passing l_d
changes nothing -- the WPA2.0 clean-panel match is preserved. Checked at
several stations, near end and midspan alike."""
strand = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0)
pre = Prestress(strands=[strand], loss_pct=0.0, diameter_in=0.5,
fpu_ksi=270.0, transfer_length_in=24.0)
panel = Panel(mark='C', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
prestress=pre) # no blockouts
for x in (0.5, 1.5, 5.0, 10.0, 18.5, 19.5):
a = prestress.state_at(panel, x)
b = prestress.state_at(panel, x, ld_ft=5.0)
assert a.force_lb == pytest.approx(b.force_lb)
assert a.cy_in == pytest.approx(b.cy_in)
def test_stress_development_length_is_built_on_the_transfer_length():
"""The representative l_d handling_stress_at uses is l_t + (f_ps - f_se) d_b
-- the ACI 25.4.8.1 form on the SAME transfer length the force model uses,
so it is always at least the transfer length."""
panel, _ = _panel_with_one_opening()
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
ld = capacity.stress_development_length_ft(panel, stage)
lt = prestress.transfer_length_in(panel.prestress) / 12.0
assert ld >= lt
assert ld == pytest.approx(2.0, abs=1e-9) or ld > 2.0 # l_t was 2.0 ft
def test_handling_stress_solid_panel_unchanged_by_the_rule():
"""On a solid panel the §1.3 rule is a no-op, so the extreme-fibre stress
is exactly the transfer-model value -- the guard that the clean-panel
parity match cannot regress."""
strand_b = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0)
strand_t = Strand(y=2.0, z_in=6.5, area_in2=0.153, initial_pull_lb=30000.0)
pre = Prestress(strands=[strand_b, strand_t], loss_pct=0.0, diameter_in=0.5,
fpu_ksi=270.0)
panel = Panel(mark='C', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
prestress=pre)
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0,
tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6)
sp = capacity.handling_stress_at(panel, stage, 8.0, 12000.0)
# rebuild with the plain transfer-model state, no ld gating
from panellift import section
net = section.NetSection(panel, 8.0)
st = prestress.state_at(panel, 8.0) # no ld_ft
f_form, f_far = capacity.extreme_fibre_stresses(
st.force_lb, net.area_in2, 0.0, panel.thickness_in / 2.0,
net.iz_in4, 12000.0)
assert sp.f_form_psi == pytest.approx(f_form)
assert sp.f_far_psi == pytest.approx(f_far)
+302
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""An opening that all but reaches an edge must not leave a fibre behind.
A real piece had an opening drawn to 11.1041 ft on a panel 11.1042 ft wide.
The 0.001 in of concrete left over was, arithmetically, the outermost material
in the section -- so every in-plane stress was reported at a fibre 110 in from
the centroid instead of 23 in, on a sliver nothing could be cast into. The
piece came back at 432 psi where a commercial check of the same piece said
162, and it was one of only two failures in a 194-piece sweep.
The stress at a fibre that is not really there is a stress nobody has.
"""
import pytest
from panellift import section
from panellift.model import Blockout
from conftest import make_panel
def _panel_with_opening(gap_ft):
"""A panel whose only opening stops `gap_ft` short of the far edge."""
panel = make_panel()
panel.blockouts = [Blockout(
name='O1', x0=10.0, y0=3.0, length=4.0,
width=panel.width_ft - 3.0 - gap_ft)]
return panel
@pytest.mark.parametrize('gap_ft', [0.0, 1e-4, 1e-3, 0.004])
def test_a_drafting_sliver_is_not_the_extreme_fibre(gap_ft):
"""Whether the opening lands exactly on the edge or a thousandth short,
the section has to be the same piece of concrete."""
exact = section.NetSection(_panel_with_opening(0.0), 12.0)
nearly = section.NetSection(_panel_with_opening(gap_ft), 12.0)
assert nearly.width_in == pytest.approx(exact.width_in, abs=0.1)
assert nearly.y_extremes_in[1] == pytest.approx(exact.y_extremes_in[1],
abs=0.1)
assert nearly.iy_in4 == pytest.approx(exact.iy_in4, rel=0.01)
def test_the_extreme_fibre_is_inside_the_concrete():
"""The outermost fibre cannot be further from the centroid than the
material is. This is what the sliver broke: 110 in of lever arm on a
section only 46 in wide."""
net = section.NetSection(_panel_with_opening(1e-3), 12.0)
reach = max(abs(v) for v in net.y_extremes_in)
assert reach <= net.width_in, (
'extreme fibre %.1f in from the centroid, but the section is only '
'%.1f in wide' % (reach, net.width_in))
def test_an_opening_well_short_of_the_edge_keeps_its_strip():
"""The snap is a noise floor, not a licence to delete real concrete. A
strip an inch and a half wide is still a strip."""
panel = _panel_with_opening(0.125) # 1.5 in of concrete left
net = section.NetSection(panel, 12.0)
strips = section.strips_at(panel, 12.0)
assert len(strips) == 2
assert net.y_extremes_in[1] > 0.5 * panel.width_ft * 12.0
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Concrete of the cast piece that is not the panel rectangle.
A slab-seat ledge is modelled as its own family inside the assembly. It is
real concrete -- on WP371 it carried 8.99 ft3 = 1,349 lb, 7.5% of the piece
-- and leaving it out made the weight reconciliation accuse a correct model.
The lump rides at no station, so it is added to the geometric weight whole;
the statics rescale to the stated weight regardless, and this is what makes
that reconciliation honest.
"""
import pytest
from panellift import cli, section
from conftest import make_panel
LEDGE_FT3 = 8.992 # what WP371's LEDGE family actually measured
LEDGE_LB = LEDGE_FT3 * 150.0
def test_extra_cast_is_added_to_the_geometric_weight():
"""The ledge is weight whether or not it sits at a station."""
bare = make_panel()
ledged = make_panel()
ledged.extra_cast_ft3 = LEDGE_FT3
expected = section.geometric_weight_lb(bare) + LEDGE_LB
assert section.geometric_weight_lb(ledged) == pytest.approx(expected)
def test_reconciliation_clears_when_the_ledge_is_counted():
"""A panel whose stated weight includes the ledge must not be accused."""
panel = make_panel()
panel.extra_cast_ft3 = LEDGE_FT3
panel.weight_lb = section.geometric_weight_lb(panel)
_geom, _stated, error = section.weight_reconciliation(panel)
assert abs(error) < 0.001
def test_reconciliation_still_catches_a_missing_ledge():
"""Without the field, the old wrong answer is still visible.
The ledge here is sized as a share of THIS panel -- on the smaller
WP371 it was 7.5% of the piece, and check_weight warns past 5%.
"""
panel = make_panel()
ledge_ft3 = 0.075 * (panel.length_ft * panel.width_ft *
panel.thickness_ft)
panel.weight_lb = (section.geometric_weight_lb(panel)
+ ledge_ft3 * panel.unit_weight_pcf)
_geom, _stated, error = section.weight_reconciliation(panel)
assert error < -0.05 # we are light by more than 5%
def test_cli_reads_extra_cast_ft3():
"""The job file carries the ledge, and the reader keeps it."""
import json
panel = make_panel()
panel.extra_cast_ft3 = LEDGE_FT3
data = json.loads(json.dumps({
'mark': panel.mark,
'length_ft': panel.length_ft,
'width_ft': panel.width_ft,
'thickness_in': panel.thickness_in,
'unit_weight_pcf': panel.unit_weight_pcf,
'extra_cast_ft3': LEDGE_FT3,
}))
assert cli.read_panel(data).extra_cast_ft3 == pytest.approx(LEDGE_FT3)
def test_cli_treats_a_missing_field_as_no_ledge():
"""Old job files without the key keep working, as zero."""
import json
data = json.loads(json.dumps({
'mark': 'OLD-1',
'length_ft': 32.0,
'width_ft': 10.0,
'thickness_in': 8.75,
}))
assert cli.read_panel(data).extra_cast_ft3 == 0.0
+153
View File
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""The PCI Design Aid 15.2.3 strand power curve and the strain-compatibility
flexure it feeds -- docs/eriksson-wall-algorithms.md sec.4.2.
ADDITIVE. `pci.fps_approximate_ksi` (the ACI approximate f_ps that the shared
`capacity_at`/`phi_mn_lbft` and Panel Lift Check keep) is untouched; these pin
the more exact law the reference tool (Eriksson Wall) uses and that the additive
`handling_flexure_at` (Panel Analysis) now prefers.
"""
import pytest
from panellift import capacity, pci
from panellift.model import Panel, Prestress, Stage, Strand
# ------------------------------------------------------- the pure power curve
def test_fps_power_curve_elastic_branch():
"""eps_ps <= 0.0085 is elastic: f_ps = Ep*eps_ps.
0.005 * 28500 = 142.5 ksi. At the limit itself, 0.0085 * 28500 = 242.25.
"""
assert pci.fps_power_curve_ksi(0.005, 270.0) == pytest.approx(142.5)
assert pci.fps_power_curve_ksi(0.0085, 270.0) == pytest.approx(242.25)
def test_fps_power_curve_power_branch():
"""eps_ps > 0.0085: f_ps = f_pu - 0.04/(eps_ps - 0.007).
At 0.010: 270 - 0.04/(0.010 - 0.007) = 270 - 13.3333 = 256.6667 ksi.
At 0.020: 270 - 0.04/0.013 = 270 - 3.0769 = 266.9231 ksi.
"""
assert pci.fps_power_curve_ksi(0.010, 270.0) == pytest.approx(256.6667,
abs=1e-3)
assert pci.fps_power_curve_ksi(0.020, 270.0) == pytest.approx(266.9231,
abs=1e-3)
def test_fps_power_curve_is_nearly_continuous_at_the_knee():
"""The two branches meet within ~1 ksi at eps_ps = 0.0085 -- one smooth
law, not a step. Elastic 242.25; power 270 - 0.04/0.0015 = 243.33."""
elastic = pci.fps_power_curve_ksi(0.0085, 270.0)
power = 270.0 - 0.04 / (0.0085 - 0.007)
assert abs(power - elastic) < 1.1
def test_fps_power_curve_caps_at_fpu_and_floors_at_zero():
# The power branch approaches f_pu from below but never reaches it: at
# eps = 1.0, 270 - 0.04/0.993 = 269.96 ksi, under f_pu.
assert pci.fps_power_curve_ksi(1.0, 270.0) == pytest.approx(269.96, abs=0.01)
assert pci.fps_power_curve_ksi(1.0, 270.0) < 270.0
# The cap DOES bind if the elastic branch is pushed past f_pu (an out-of-
# range Ep): 0.005 * 100000 = 500 ksi -> capped to f_pu = 270.
assert pci.fps_power_curve_ksi(0.005, 270.0, ep_ksi=100000.0) \
== pytest.approx(270.0)
# Non-positive strain is no stress, not a negative one.
assert pci.fps_power_curve_ksi(0.0, 270.0) == 0.0
assert pci.fps_power_curve_ksi(-0.001, 270.0) == 0.0
# --------------------------------------- strain-compat Mn on a solid panel
def _solid_prestressed_panel():
"""A plain solid panel, one bottom strand row, no openings -- so the
power-curve solve has a single fully-developed tension layer and can be
reproduced by an independent bisection in the test."""
strands = [Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=28900.0),
Strand(y=6.0, z_in=1.5, area_in2=0.153, initial_pull_lb=28900.0)]
pre = Prestress(strands=strands, loss_pct=0.0, diameter_in=0.5,
fpu_ksi=270.0)
return Panel(mark='S', length_ft=24.0, width_ft=8.0, thickness_in=8.0,
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
prestress=pre)
def test_power_curve_mn_matches_an_independent_strain_compat_solve():
"""`capacity_at` stores a power-curve M_n; reproduce it from the same law.
Midspan, fully developed: every strand sees the full power-curve f_ps at
its own strain, and equilibrium 0.85 f'ci beta1 c b = sum(A f_ps) fixes c.
The test re-solves that balance independently and checks the stored M_n.
"""
panel = _solid_prestressed_panel()
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
x = 12.0
cap = capacity.capacity_at(panel, stage, x, moment_lbft=1.0, shear_lb=0.0)
assert cap.mn_powercurve_lbft is not None
# Independent solve, same inputs: two identical strands at d = 8 - 1.5 =
# 6.5 in, f_se = 28900/0.153/1000 = 188.9 ksi, eps_se = f_se/28500.
import math
fc = 3500.0
b = panel.thickness_in and (panel.width_ft * 12.0) # solid, full width
beta1 = pci.beta1(fc)
ep = pci.STRAND_ELASTIC_MODULUS_KSI
d = 8.0 - 1.5
area = 0.153 * 2
fse = 28900.0 / 0.153 / 1000.0
eps_se = fse / ep
def tension(c):
eps = eps_se + 0.003 * (d - c) / c
return area * pci.fps_power_curve_ksi(eps, 270.0, ep) * 1000.0
lo, hi = 1e-4, d
for _ in range(80):
mid = 0.5 * (lo + hi)
if 0.85 * fc * beta1 * mid * b - tension(mid) > 0.0:
hi = mid
else:
lo = mid
c = 0.5 * (lo + hi)
a = beta1 * c
mn = tension(c) * (d - a / 2.0) / 12.0
assert cap.mn_powercurve_lbft == pytest.approx(mn, rel=1e-3)
def test_handling_flexure_prefers_the_power_curve_mn():
"""handling_flexure_at reads the power-curve M_n, not the ACI-approximate
one, when the point carries it -- and applies the strain-based phi to it."""
panel = _solid_prestressed_panel()
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
x = 12.0
cap = capacity.capacity_at(panel, stage, x, moment_lbft=4000.0, shear_lb=0.0)
phi, phi_mn, ratio = capacity.handling_flexure_at(cap, 4000.0)
# Fully developed, low rho -> tension-controlled -> phi 0.90, and phi*M_n
# is 0.90 times the stored power-curve M_n, NOT the approximate phi_mn_lbft.
assert phi == pytest.approx(0.90)
assert phi_mn == pytest.approx(0.90 * cap.mn_powercurve_lbft)
assert phi_mn != pytest.approx(cap.phi_mn_lbft) # differs from approximate
assert ratio == pytest.approx(4000.0 / phi_mn)
def test_power_curve_mn_is_none_without_developed_strand():
"""A free end has no developed area, so no power-curve M_n is stored."""
panel = _solid_prestressed_panel()
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
cap = capacity.capacity_at(panel, stage, x=0.0, moment_lbft=1.0,
shear_lb=0.0)
assert cap.mn_powercurve_lbft is None
def test_power_curve_free_end_flexure_is_not_a_full_phi_mn():
"""The development cap keeps a station near a free end from claiming the
full midspan phi*M_n: at x just inside the transfer length the strand has
only begun to develop, so phi*M_n there is well below the midspan value."""
panel = _solid_prestressed_panel()
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
mid = capacity.capacity_at(panel, stage, 12.0, 1.0, 0.0)
near = capacity.capacity_at(panel, stage, 0.5, 1.0, 0.0)
assert mid.mn_powercurve_lbft is not None
if near.mn_powercurve_lbft is not None:
assert near.mn_powercurve_lbft < mid.mn_powercurve_lbft
+203
View File
@@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
"""The Tier-1 handling additions to `capacity`: the extreme-fibre STRESS check
and the phi development RAMP.
Both are ADDITIVE -- `capacity_at` and every type it returns are untouched, and
their tests (test_capacity.py, test_station_table.py, ...) stand as they were.
These pin the new functions on numbers a reviewer can reproduce on paper:
* `extreme_fibre_stresses` is the task's Pi/A -/+ (Mg + Pi*e)*c/I, hand
checked to the psi.
* `handling_stress_at` builds those from a real panel/stage and divides by
the STAGE's own allowables (tension_limit_sqrt_fc*sqrt(f'c),
compression_limit_ratio*f'c) -- the settings that were read by nothing
until this landed. A stage set to 5 vs 7.5 sqrt(f'c) moves the tension
utilisation by exactly 1.5.
* `phi_flexure_ramped` / `ramped_phi_mn` are the 0.75->0.90 ramp across the
transfer/development length.
"""
import math
import pytest
from panellift import capacity, prestress
from panellift.model import Panel, Prestress, Stage, Strand
# --------------------------------------------------- the pure stress formula
def test_extreme_fibre_stresses_hand_check():
"""Pi=100000, A=1000, e=0, c=4, I=2000, Mg=10000 -> form +140, far -340.
S = I/c = 500 in3; Pi/A = 100 psi (compression, negative); Mg/S = 240 psi.
Form face (z=0): -100 + 240 = +140 (tension). Far face: -100 - 240 = -340.
"""
f_form, f_far = capacity.extreme_fibre_stresses(
pi_lb=100000.0, area_in2=1000.0, e_in=0.0, c_in=4.0,
i_in4=2000.0, moment_lbft=10000.0)
assert f_form == pytest.approx(140.0)
assert f_far == pytest.approx(-340.0)
def test_extreme_fibre_stresses_eccentric_prestress():
"""A low strand (e<0) hogs the piece: it ADDS compression on the form face.
e = -2 in, Pi=120000, so Pi*e = -240000 lb-in. With A=1000, c=4, I=2000
(S=500) and Mg=0: form = -120 + (-240000/500) = -120 - 480 = -600;
far = -120 + 480 = +360 (tension on the far face, which is the hogging a
low strand causes).
"""
f_form, f_far = capacity.extreme_fibre_stresses(
pi_lb=120000.0, area_in2=1000.0, e_in=-2.0, c_in=4.0,
i_in4=2000.0, moment_lbft=0.0)
assert f_form == pytest.approx(-600.0)
assert f_far == pytest.approx(360.0)
def test_extreme_fibre_stresses_degenerate_returns_none():
assert capacity.extreme_fibre_stresses(1.0, 0.0, 0.0, 4.0, 2000.0, 1.0) \
== (None, None)
assert capacity.extreme_fibre_stresses(1.0, 1000.0, 0.0, 4.0, 0.0, 1.0) \
== (None, None)
# ------------------------------------------------ handling_stress_at on panel
def _solid_panel():
"""A plain solid panel with two symmetric strands -- e_z = 0, no openings.
t=8 in, so mid-depth is 4 in; strands at z=1.5 and z=6.5 (= 8-1.5) with
equal force put the group centroid at mid-depth (e_z=0) and, being full
width, leave the in-plane term e_y=0 too. So the fibre stress is exactly
the 1-D Pi/A -/+ Mg*c/I with no prestress bending -- hand-reproducible.
"""
strands = [Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0),
Strand(y=2.0, z_in=6.5, area_in2=0.153, initial_pull_lb=30000.0)]
pre = Prestress(strands=strands, loss_pct=0.0, diameter_in=0.5,
fpu_ksi=270.0)
return Panel(mark='S', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
prestress=pre)
def test_handling_stress_at_matches_the_pure_formula():
panel = _solid_panel()
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0,
tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6)
x = 8.0
m = 12000.0
sp = capacity.handling_stress_at(panel, stage, x, m)
# Rebuild the ingredients the way the function does, independently.
from panellift import section
net = section.NetSection(panel, x)
state = prestress.state_at(panel, x)
pi = state.force_lb
assert pi > 0
assert sp.e_in == pytest.approx(0.0, abs=1e-9) # symmetric -> e_z = 0
f_form, f_far = capacity.extreme_fibre_stresses(
pi, net.area_in2, 0.0, panel.thickness_in / 2.0, net.iz_in4, m)
assert sp.f_form_psi == pytest.approx(f_form)
assert sp.f_far_psi == pytest.approx(f_far)
assert sp.in_plane_psi == pytest.approx(0.0, abs=1e-6)
def test_handling_stress_utils_use_the_stages_allowables():
"""The utilisation divides by the STAGE's own allowables -- the settings
that nothing read before. tension over tension_limit_sqrt_fc*sqrt(f'c),
compression over compression_limit_ratio*f'c."""
panel = _solid_panel()
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0,
tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6)
sp = capacity.handling_stress_at(panel, stage, 10.0, 15000.0)
allow_t = 7.5 * math.sqrt(3500.0)
allow_c = 0.6 * 3500.0
assert sp.tension_util == pytest.approx(sp.tension_psi / allow_t)
assert sp.compression_util == pytest.approx(sp.compression_psi / allow_c)
def test_handling_stress_limit_coefficient_moves_the_tension_util_by_1p5():
"""5 vs 7.5 sqrt(f'c) is the whole 1.5x that the two schools argue about,
and it lands on the tension utilisation and nowhere else."""
panel = _solid_panel()
common = dict(multiplier=1.4, fc_psi=3500.0, compression_limit_ratio=0.6)
five = Stage(name='a', tension_limit_sqrt_fc=5.0, **common)
seven = Stage(name='b', tension_limit_sqrt_fc=7.5, **common)
a = capacity.handling_stress_at(panel, five, 10.0, 15000.0)
b = capacity.handling_stress_at(panel, seven, 10.0, 15000.0)
assert a.tension_psi == pytest.approx(b.tension_psi) # same stress
# 5 is the stricter school -> a higher utilisation, by exactly 1.5.
assert a.tension_util == pytest.approx(b.tension_util * 1.5)
def test_handling_stress_no_allowable_is_a_dash_not_a_zero():
"""A stage stating no strength has no allowable to divide by -- util is
None with a reason, never a fabricated 0, the same could-not-judge
discipline as every ratio in this module."""
panel = _solid_panel()
stage = Stage(name='s', multiplier=1.0, fc_psi=0.0)
sp = capacity.handling_stress_at(panel, stage, 10.0, 15000.0)
assert sp.tension_util is None and sp.compression_util is None
assert sp.why
# --------------------------------- phi from net tensile strain (Table 21.2.2)
def test_phi_flexure_from_strain_boundaries_and_slope():
"""ACI 318-14 Table 21.2.2, prestressed floor 0.65, eps_ty 0.002."""
assert capacity.phi_flexure_from_strain(0.001) == pytest.approx(0.65)
assert capacity.phi_flexure_from_strain(0.002) == pytest.approx(0.65)
# midway: 0.65 + 0.25*(0.0035-0.002)/(0.005-0.002) = 0.65 + 0.125 = 0.775
assert capacity.phi_flexure_from_strain(0.0035) == pytest.approx(0.775)
assert capacity.phi_flexure_from_strain(0.005) == pytest.approx(0.90)
assert capacity.phi_flexure_from_strain(0.010) == pytest.approx(0.90)
def test_phi_flexure_from_strain_none_is_the_flat_value():
"""No strain to judge on is not a reason to drop to the floor."""
assert capacity.phi_flexure_from_strain(None) == pytest.approx(0.90)
def test_handling_flexure_at_applies_the_strain_phi_to_the_same_mn():
"""M_n does not change; only phi moves, from the section's strain."""
pt = capacity.CapacityPoint(x=5.0)
pt.phi = 0.90
pt.phi_mn_lbft = 9000.0 # M_n = 10000 lb-ft
pt.strain_t = 0.0035 # transition -> phi 0.775
phi, phi_mn, ratio = capacity.handling_flexure_at(pt, moment_lbft=3100.0)
assert phi == pytest.approx(0.775)
assert phi_mn == pytest.approx(10000.0 * 0.775) # = 7750
assert ratio == pytest.approx(3100.0 / 7750.0)
def test_handling_flexure_at_tension_controlled_is_flat_0p90():
pt = capacity.CapacityPoint(x=5.0)
pt.phi = 0.90
pt.phi_mn_lbft = 9000.0
pt.strain_t = 0.020 # well tension-controlled
phi, phi_mn, _ = capacity.handling_flexure_at(pt, moment_lbft=3000.0)
assert phi == pytest.approx(0.90)
assert phi_mn == pytest.approx(9000.0) # unchanged from flat
def test_handling_flexure_at_rebuilds_mn_in_the_transition_region():
"""Where capacity_at bailed (no phi*M_n) but left the equilibrium geometry,
M_n is rebuilt from C = 0.85 f'c a b at lever arm (d_p - a/2)."""
pt = capacity.CapacityPoint(x=5.0) # phi_mn_lbft stays None
pt.a_in = 1.0
pt.b_in = 100.0
pt.dp_in = 6.0
pt.fc_psi = 3500.0
pt.strain_t = 0.003 # transition
phi, phi_mn, ratio = capacity.handling_flexure_at(pt, moment_lbft=5000.0)
tension = 0.85 * 3500.0 * 1.0 * 100.0
mn = tension * (6.0 - 0.5) / 12.0
assert phi == pytest.approx(0.65 + 0.25 * (0.003 - 0.002) / 0.003)
assert phi_mn == pytest.approx(phi * mn)
assert ratio == pytest.approx(5000.0 / (phi * mn))
def test_handling_flexure_at_no_strength_is_none_not_zero():
pt = capacity.CapacityPoint(x=5.0) # no phi_mn, no geometry, no eps
assert capacity.handling_flexure_at(pt, moment_lbft=1000.0) \
== (None, None, None)
+257
View File
@@ -0,0 +1,257 @@
# -*- coding: utf-8 -*-
"""The In-Place strength model: load cases, combinations, and the checks.
Neutral fixtures only -- no client geometry, no client numbers. The parity
against the reference tool's real pieces lives in the scratchpad harness; these
are the identities any correct in-place answer must satisfy:
* a cantilever under a uniform wind gives w*L^2/2 at the support (statics);
* a load combination assembles exactly sum(factor * case) and nothing else;
* the flexure util is |Mu| / (phi*Mn from capacity_at), reused not rebuilt;
* axial phi*Pn(C) is 0.85 f'c Ag * 0.80 * 0.65 for a plain/mild section, and
carries the strand reduction where strand is developed;
* fewer than two restraints refuses loudly, the engine's own words.
"""
import math
import pytest
from panellift import capacity, inplace, section
from panellift.model import Panel, Prestress, Strand
# ------------------------------------------------------------------ fixtures
def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
fc_psi=5000.0):
"""A solid, non-prestressed wall -- the mild-wall shape (no strand)."""
return Panel(mark='TEST-WALL', length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in, unit_weight_pcf=150.0,
fc_psi=fc_psi, fci_psi=3500.0, prestress=None)
def strand_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
fc_psi=5000.0):
"""A solid wall carrying two straight strands near the tension face."""
strands = [Strand(y=2.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0,
label='S1'),
Strand(y=8.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0,
label='S2')]
pre = Prestress(strands=strands, loss_pct=8.0, diameter_in=0.5,
fpu_ksi=270.0)
return Panel(mark='TEST-STRAND', length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in, unit_weight_pcf=150.0,
fc_psi=fc_psi, fci_psi=3500.0, prestress=pre)
def cantilever_inputs(pressure_psf=100.0):
"""Restraints at 0 and 20 ft, a 10 ft cantilever above with wind on it."""
return inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0],
wind_zones=[inplace.LateralPressureZone(20.0, 30.0, pressure_psf,
'W cantilever')])
# ------------------------------------------------------- cantilever statics
def test_cantilever_moment_is_w_l_squared_over_two():
"""A uniform wind on a cantilever gives w*L^2/2 at the support.
This is the whole reason the beam reuse is trusted: the reference tool's
own governing WPA1.0 flexure moment IS a cantilever moment, and the closed
form is unarguable.
"""
panel = plain_wall()
inputs = cantilever_inputs(pressure_psf=100.0)
dia, rig = inplace.solve_lateral(panel, inputs.restraint_xs,
inputs.wind_zones, panel.width_ft,
panel.fc_psi)
w_plf = 100.0 * panel.width_ft # 1000 plf
cantilever_ft = 10.0
expected = w_plf * cantilever_ft ** 2 / 2.0 # 50,000 lb-ft
at_support = dia.moment_at(20.0)
assert abs(abs(at_support) - expected) / expected < 0.01
# and the free top closes to zero
assert abs(dia.moment_at(30.0)) < 0.02 * expected
# --------------------------------------------------- load-case superposition
def test_load_case_fields_split_dead_and_wind():
"""Dead is axial only; wind is moment/shear only. No crosstalk."""
panel = plain_wall()
inputs = cantilever_inputs()
xs = inplace.key_stations(panel, inputs)
cases = inplace.build_load_cases(panel, inputs, xs)
d = cases['D']
w = cases['W']
# Dead carries axial, no moment/shear.
assert max(abs(v) for v in d.axial_lb) > 0.0
assert all(v == 0.0 for v in d.moment_lbft)
assert all(v == 0.0 for v in d.shear_lb)
# Wind carries moment/shear, no axial.
assert max(abs(v) for v in w.moment_lbft) > 0.0
assert all(v == 0.0 for v in w.axial_lb)
# Dead axial at the base is the whole self weight; at the top it is zero.
assert d.axial_at(0.0) > d.axial_at(20.0) > 0.0
assert abs(d.axial_at(panel.length_ft)) < 1.0
def test_combination_demand_is_pure_superposition():
"""Mu, Vu, Pu are exactly sum(factor * case) -- LC7 = 0.9D + 1.0W."""
panel = plain_wall()
inputs = cantilever_inputs()
xs = inplace.key_stations(panel, inputs)
cases = inplace.build_load_cases(panel, inputs, xs)
lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7')
x = 20.0
pu, mu, vu = inplace.combination_demand(cases, lc7, x)
# 0.9 * dead axial, 1.0 * wind moment, and T/H are zero cases.
assert pu == pytest.approx(0.9 * cases['D'].axial_at(x), rel=1e-9)
assert mu == pytest.approx(1.0 * cases['W'].moment_at(x), rel=1e-9)
assert vu == pytest.approx(1.0 * cases['W'].shear_at(x), rel=1e-9)
def test_known_aci_combo_assembles_the_right_factored_demand():
"""LC5 = 1.2D + 1.0L + 0.5R + 1.0W applies each factor to its own case.
Built from a hand-set case table so the arithmetic is unarguable: dead 100k
axial, live 20k axial, wind 10 k-ft moment -> LC5 Pu = 1.2*100 + 1.0*20 =
140 k and Mu = 1.0*10 = 10 k-ft.
"""
xs = [0.0, 1.0]
zeros = [0.0, 0.0]
cases = {
'D': inplace.LoadField('D', 'D', xs, [100000.0, 100000.0], list(zeros),
list(zeros)),
'L': inplace.LoadField('L', 'L', xs, [20000.0, 20000.0], list(zeros),
list(zeros)),
'R': inplace.LoadField('R', 'R', xs, [5000.0, 5000.0], list(zeros),
list(zeros)),
'W': inplace.LoadField('W', 'W', xs, list(zeros),
[10000.0, 10000.0], [3000.0, 3000.0]),
}
lc5 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC5')
pu, mu, vu = inplace.combination_demand(cases, lc5, 0.0)
assert pu == pytest.approx(1.2 * 100000 + 1.0 * 20000 + 0.5 * 5000)
assert mu == pytest.approx(1.0 * 10000)
assert vu == pytest.approx(1.0 * 3000)
# ----------------------------------------------------- axial capacity (new)
def test_axial_phi_pn_c_plain_section():
"""phi*Pn(C) = 0.65 * 0.80 * 0.85 f'c Ag for a plain/mild section."""
panel = plain_wall(width_ft=10.0, thickness_in=12.0, fc_psi=5000.0)
stage = inplace.in_place_stage(panel, cantilever_inputs())
ag = section.NetSection(panel, 5.0).area_in2 # 120 in * 12 in
ax = inplace.axial_capacity(panel, stage, 5.0, pu_lb=100000.0,
restraint_xs=[0.0, 20.0])
expected = (inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR
* 0.85 * 5000.0 * ag)
assert ax.phi_pn_c_lb == pytest.approx(expected, rel=1e-9)
assert ax.util == pytest.approx(100000.0 / expected, rel=1e-9)
def test_axial_strand_term_reduces_po():
"""A developed strand REDUCES Po by (f_se - 0.003 Ep) Aps (docs sec.5.1)."""
panel = strand_wall()
stage = inplace.in_place_stage(panel, cantilever_inputs())
# A mid-span station where both strands are fully developed.
x = 15.0
ax = inplace.axial_capacity(panel, stage, x, pu_lb=50000.0,
restraint_xs=[0.0, 20.0])
plain = inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR * \
0.85 * panel.fc_psi * section.NetSection(panel, x).area_in2
# With strand present phi*Pn(C) is LOWER than the plain 0.85 f'c Ag figure.
assert ax.aps_in2 > 0.0
assert ax.phi_pn_c_lb < plain
def test_axial_reports_pcrit():
"""phi*Pcrit is computed (Euler on EI_eff) and flagged approximate."""
panel = plain_wall()
stage = inplace.in_place_stage(panel, cantilever_inputs())
ax = inplace.axial_capacity(panel, stage, 10.0, pu_lb=100000.0,
restraint_xs=[0.0, 20.0])
assert ax.phi_pcrit_lb is not None and ax.phi_pcrit_lb > 0.0
assert ax.pcrit_note and 'approximate' in ax.pcrit_note
# ------------------------------------------------- strength util from capacity
def test_flexure_util_comes_from_capacity_at():
"""The in-place flexure util IS capacity_at's m_over_phi_mn on Mu.
The strength check reuses capacity_at rather than reimplementing phi*Mn;
this pins that the util the run reports is that same number fed the combo's
factored moment.
"""
panel = strand_wall()
inputs = cantilever_inputs(pressure_psf=300.0) # enough to bend it
res = inplace.run(panel, inputs)
assert res.refusal is None
# find a combo/station where flexure was judged
found = False
for cr in res.combos:
for sd in cr.stations:
if sd.flex_util is not None:
direct = capacity.capacity_at(panel, res.stage, sd.x,
sd.mu_lbft, sd.vu_lb)
assert sd.flex_util == pytest.approx(direct.m_over_phi_mn)
found = True
assert found, 'no station produced a flexural util to check'
def test_mild_wall_flexure_dashes_not_passes():
"""A no-strand wall dashes phi*Mn with a reason -- never a fabricated pass.
capacity_at is strand-only; the mild flexural capacity is the concurrent
capacity layer's job. The in-place check must surface that as a
could-not-judge, exactly like every other dash in the engine.
"""
panel = plain_wall()
inputs = cantilever_inputs(pressure_psf=300.0)
res = inplace.run(panel, inputs)
combo = res.combos[0]
sd = combo.stations[len(combo.stations) // 2]
assert sd.flex_util is None
assert sd.cap.reason(capacity.PHI_MN) # a reason is present
# ---------------------------------------------------------------- refusal
def test_fewer_than_two_restraints_refuses_loudly():
"""One restraint is not a span -- the engine refuses, in its own words."""
panel = plain_wall()
inputs = inplace.InPlaceInputs(restraint_xs=[0.0], wind_zones=[])
res = inplace.run(panel, inputs)
assert res.refusal is not None
assert 'two supports' in res.refusal
assert not res.combos
def test_run_finds_an_overall_governing_check():
"""A wired run names an overall governing combo/check/util/x."""
panel = strand_wall()
res = inplace.run(panel, cantilever_inputs(pressure_psf=300.0))
assert res.gov_combo and res.gov_check and res.gov_util is not None
assert res.gov_util > 0.0
# ---------------------------------------------------------- combination set
def test_asce_strength_set_matches_the_printed_combinations():
"""The strength set is the ASCE 7-16 2.3 combos with Eriksson's T/H."""
names = [c.name for c in inplace.ASCE_7_16_STRENGTH]
assert names == ['LC1', 'LC2', 'LC3', 'LC4', 'LC5', 'LC6', 'LC7', 'LC8']
lc1 = inplace.ASCE_7_16_STRENGTH[0]
assert lc1.factors['D'] == 1.4
lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7')
assert lc7.factors['D'] == 0.9 and lc7.factors['W'] == 1.0
# every strength combo folds in 1.0T and 1.6H, per docs sec.7
for c in inplace.ASCE_7_16_STRENGTH:
assert c.factors.get('T') == 1.0
assert c.factors.get('H') == 1.6
+202
View File
@@ -0,0 +1,202 @@
# -*- coding: utf-8 -*-
"""The In-Place SERVICE slice: ASCE 2.4 combos, extreme-fibre stress vs the
cracking / compression limits, deflection, and the P-Delta magnifier.
Neutral fixtures only -- the parity against the reference tool's real pieces
lives in the scratchpad harness (parity_service.py). These pin the identities
any correct service answer must satisfy:
* the service set is ASCE 7-16 2.4.1(1)-(5), T at 0.75, no 0.6D+0.6W;
* SC5 = 1.0D + 0.6W assembles exactly 1.0*P_D and 0.6*M_W;
* extreme-fibre tension = M/S - P/A (mild), util = tension / 7.5 sqrt(f'c),
and a >1 util is REPORTED (the tool flags it, docs 6.1);
* the P-Delta magnifier is 1/(1-gamma), gamma = Pu L^2 / (8 EI_eff), and an
instability is NOT JUDGED, named -- never a fabricated number;
* a deflection with no L/n limit is computed but its LIMIT check is NOT
JUDGED, named.
"""
import math
from panellift import capacity, inplace, section
from panellift.model import Panel
def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
fc_psi=6000.0):
return Panel(mark='SVC-WALL', length_ft=length_ft, width_ft=width_ft,
thickness_in=thickness_in, unit_weight_pcf=150.0,
fc_psi=fc_psi, fci_psi=3500.0, prestress=None)
def _service_cases(x_lo, x_hi, p_lb, m_lbft, wind_factor=0.6):
"""A cases dict where SC5 (1.0D + 0.6W) assembles to (p_lb, m_lbft): D
carries the axial, W carries m/0.6 so 0.6*W = m."""
xs = [x_lo, x_hi]
return {
'D': inplace.LoadField('D', 'Dead', xs, [p_lb, p_lb], [0.0, 0.0],
[0.0, 0.0]),
'W': inplace.LoadField('W', 'Wind', xs, [0.0, 0.0],
[m_lbft / wind_factor, m_lbft / wind_factor],
[0.0, 0.0]),
}
def _stage(panel):
return inplace.in_place_stage(panel, inplace.InPlaceInputs(
restraint_xs=[0.0, panel.length_ft]))
def _sc5():
return next(c for c in inplace.ASCE_7_16_SERVICE if c.name == 'SC5')
# --------------------------------------------------------------- the combos
def test_service_set_is_the_asce_2_4_basic_combinations():
names = [c.name for c in inplace.ASCE_7_16_SERVICE]
assert names == ['SC1', 'SC2', 'SC3', 'SC4', 'SC5']
for c in inplace.ASCE_7_16_SERVICE:
assert c.kind == 'service'
assert c.factors.get('T') == 0.75
sc5 = _sc5()
assert sc5.factors['D'] == 1.0 and sc5.factors['W'] == 0.6
# combination (7) 0.6D+0.6W is deliberately NOT in the reference set.
assert not any(c.factors.get('D') == 0.6 for c in inplace.ASCE_7_16_SERVICE)
def test_service_combo_assembles_the_right_demand():
"""SC5 = 1.0D + 0.6W puts 1.0*P_D and 0.6*M_W, nothing else."""
panel = plain_wall()
cases = _service_cases(0.0, panel.length_ft, p_lb=40000.0, m_lbft=100000.0)
pu, mu, vu = inplace.combination_demand(cases, _sc5(), 12.0)
assert abs(pu - 40000.0) < 1e-6
assert abs(mu - 100000.0) < 1e-6
assert abs(vu) < 1e-9
# --------------------------------------------------------------- the stress
def test_service_tension_matches_hand_stress_and_cracking_util():
"""WPA1.0's governing service tension row: a 12 in x 164 in solid mild wall
at M = 260.99 k-ft, P = 36.9 k gives 776.9 psi vs 580.9 (7.5 sqrt 6000)."""
panel = Panel(mark='SVC', length_ft=43.08, width_ft=164.0 / 12.0,
thickness_in=12.0, unit_weight_pcf=150.0, fci_psi=3500.0,
fc_psi=6000.0, prestress=None)
cases = _service_cases(0.0, panel.length_ft, p_lb=36900.0, m_lbft=260990.0)
st = inplace.service_station(panel, _stage(panel), cases, [_sc5()], 25.1)
assert st.why is None
assert abs(st.cracking_limit_psi - 580.9) < 0.5
assert abs(st.tension_psi - 776.9) < 1.0
assert abs(st.compression_psi - 814.4) < 1.0
# util = tension / cracking; over 1.0 here -- the tool FLAGS it, we report it
assert st.tension_util > 1.0
assert abs(st.tension_util - 776.9 / 580.9) < 0.01
def test_prestress_precompression_term_is_the_reused_primitive():
"""WPA5.0's developed-strand compression row: Pi = 123.3 k, M = 17.22 k-ft,
P = 19.7 k -> 815.6 psi through capacity.extreme_fibre_stresses + (-P/A)."""
A, i_in4, c_in = 313.5, 3161.0, 5.5
f_form, f_far = capacity.extreme_fibre_stresses(
123300.0, A, 0.0, c_in, i_in4, 17220.0)
f_form -= 19700.0 / A
f_far -= 19700.0 / A
comp = max(0.0, -f_form, -f_far)
assert abs(comp - 815.6) < 1.0
def test_no_section_dashes_with_a_reason_not_a_zero():
"""A station where NetSection cannot be built is None+why, never a fake 0."""
panel = plain_wall(thickness_in=0.0)
cases = _service_cases(0.0, panel.length_ft, 1000.0, 1000.0)
st = inplace.service_station(panel, _stage(panel), cases, [_sc5()], 5.0)
assert st.why is not None
assert st.tension_util is None and st.compression_util is None
def test_cracking_class_t_raises_the_limit_to_12_sqrt_fc():
stage = _stage(plain_wall(fc_psi=6000.0))
assert abs(inplace.cracking_limit_psi(stage, class_t=False)
- 7.5 * math.sqrt(6000.0)) < 0.1
assert abs(inplace.cracking_limit_psi(stage, class_t=True)
- 12.0 * math.sqrt(6000.0)) < 0.1
# -------------------------------------------------------------- the P-Delta
def test_pdelta_magnifier_on_a_hand_case():
"""delta = 1/(1-gamma), gamma = Pu L^2 / (8 EI_eff), EI_eff = 0.875 Ec Ig."""
panel = plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
fc_psi=6000.0)
pu, span_ft = 50000.0, 30.0
m = inplace.pdelta_magnifier(panel, _stage(panel), span_ft, pu, 15.0,
beta_d=0.0)
ec = inplace.ec_aci_psi(6000.0, 150.0)
ig = section.NetSection(panel, 15.0).iz_in4
ei = 0.875 * ec * ig
gamma = pu * (span_ft * 12.0) ** 2 / (8.0 * ei)
assert abs(m.gamma - gamma) < 1e-9
assert abs(m.magnifier - 1.0 / (1.0 - gamma)) < 1e-9
assert m.magnifier > 1.0
def test_pdelta_reports_instability_not_a_fake_number():
panel = plain_wall(length_ft=60.0, width_ft=4.0, thickness_in=6.0,
fc_psi=5000.0)
m = inplace.pdelta_magnifier(panel, _stage(panel), 60.0, 5.0e6, 30.0)
assert m.magnifier is None
assert m.why and 'stability' in m.why
def test_tension_axial_does_not_magnify():
panel = plain_wall(fc_psi=6000.0)
m = inplace.pdelta_magnifier(panel, _stage(panel), panel.length_ft,
-30000.0, 15.0)
assert m.magnifier == 1.0 and m.gamma == 0.0
# ------------------------------------------------------------ deflection/run
def test_run_populates_service_with_deflection_limit():
panel = plain_wall(length_ft=30.0, width_ft=10.0, fc_psi=6000.0)
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 30.0],
wind_zones=[inplace.LateralPressureZone(0.0, 30.0, 50.0, 'W')],
deflection_limit_ratio=150.0)
res = inplace.run(panel, inputs)
assert res.service is not None and res.service.stations
d = res.service.deflection
assert d.combo == 'SC5'
assert d.delta_wind_in > 0.0
assert d.delta_total_in is not None and d.delta_total_in >= d.delta_i_in
assert abs(d.limit_in - 30.0 * 12.0 / 150.0) < 1e-6
assert d.util is not None and d.util > 0.0
def test_deflection_limit_not_supplied_is_not_judged_not_faked():
panel = plain_wall(length_ft=30.0, width_ft=10.0, fc_psi=6000.0)
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 30.0],
wind_zones=[inplace.LateralPressureZone(0.0, 30.0, 50.0, 'W')])
res = inplace.run(panel, inputs)
d = res.service.deflection
assert d.delta_total_in is not None # deflection still computed
assert d.util is None # but not judged against a limit
assert d.why and 'deflection limit' in d.why
def test_deflection_recovers_the_simple_beam_closed_form():
"""Uniform service wind, pinned-pinned: our double integration of the
moment field gives 5 w L^4 / (384 EI_eff) at midspan."""
panel = plain_wall(length_ft=20.0, width_ft=8.0, thickness_in=10.0,
fc_psi=6000.0)
press = 40.0
inputs = inplace.InPlaceInputs(
restraint_xs=[0.0, 20.0],
wind_zones=[inplace.LateralPressureZone(0.0, 20.0, press, 'W')],
deflection_limit_ratio=240.0)
d = inplace.run(panel, inputs).service.deflection
w_lb_per_in = (0.6 * press * panel.width_ft) / 12.0
l_in = 20.0 * 12.0
closed = 5.0 * w_lb_per_in * l_in ** 4 / (384.0 * d.ei_eff_lbin2)
assert abs(d.delta_wind_in - closed) / closed < 0.01
+174
View File
@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""The search remembers layouts it has already costed, and two dead branches.
`_peak_psi` is the inner loop of the whole package -- 884 calls on the median
job of the reference sweep, 15 ms each, 93% of the run. 17% of those calls ask
for a layout the search has already costed: the coarse sweep and the first
refinement pass over the same stations, and each of the three refinement sweeps
re-tries every line's CURRENT position before deciding it cannot improve on it.
Memoising is only safe because the function is pure, and it is pure for a
reason that has to stay true: everything it reads apart from `xs` lives on the
frame, and the frame holds the parts of the problem that DO NOT MOVE when a
pick moves -- the load profile, the section, the station list. The first test
below is the one that matters; it is what catches somebody later making the
frame depend on the positions.
"""
import dataclasses
import pytest
from panellift import layout, section, statics
from conftest import LENGTH_FT, aspire_pockets, make_panel
def _frame(panel, stage, rigging, counts=(2, 2)):
return layout._make_frame(panel, stage, rigging, list(counts))
# ------------------------------------------------------------- it is pure
def test_the_frame_does_not_depend_on_where_the_picks_are(solid_panel,
stripping, rolling):
"""The memo's whole licence. If costing a layout could change the frame,
a remembered answer would be an answer to a different question."""
frame = _frame(solid_panel, stripping, rolling)
before = (list(frame.inv_s), list(frame.jumps), list(frame.counts),
list(frame.profile.w), frame.profile.dx)
for xs in ([4.0, 28.0], [8.0, 24.0], [2.0, 30.0], [15.0, 16.0]):
layout._peak_psi(frame, xs)
after = (list(frame.inv_s), list(frame.jumps), list(frame.counts),
list(frame.profile.w), frame.profile.dx)
assert before == after
def test_a_remembered_answer_is_the_answer(solid_panel, stripping, rolling):
"""Bit-identical, not merely close. The search compares improvements at
1e-9, so a memo that returned a differently-rounded number could pick a
different layout."""
frame = _frame(solid_panel, stripping, rolling)
first = layout._peak_psi(frame, [6.0, 26.0])
again = layout._peak_psi(frame, [6.0, 26.0])
assert again == first
fresh = _frame(solid_panel, stripping, rolling)
assert layout._peak_psi(fresh, [6.0, 26.0]) == first
def test_the_memo_actually_hits(solid_panel, stripping, rolling):
"""A cache nobody hits is a dict that costs memory and buys nothing."""
frame = _frame(solid_panel, stripping, rolling)
layout._peak_psi(frame, [6.0, 26.0])
assert len(frame.memo) == 1
layout._peak_psi(frame, [6.0, 26.0])
assert len(frame.memo) == 1 # hit, not a second entry
layout._peak_psi(frame, [7.0, 26.0])
assert len(frame.memo) == 2
def test_a_real_search_repeats_itself_enough_to_be_worth_it(solid_panel,
stripping,
rolling):
"""Measured 17% repeats on the reference sweep; this pins that the
repetition is structural and not a property of one job file."""
calls = []
real = layout._peak_psi
def counting(frame, xs):
calls.append(tuple(xs))
return real(frame, xs)
layout._peak_psi = counting
try:
layout.best_stations(solid_panel, stripping, rolling,
start=[6.0, 26.0], counts=[2, 2],
free=[True, True])
finally:
layout._peak_psi = real
assert len(calls) > len(set(calls))
def test_an_unbuildable_layout_is_remembered_too(solid_panel, stripping,
rolling):
"""Two lines on one station raise out of group_lifters and cost inf. That
is an answer like any other and re-deriving it is the same wasted solve."""
frame = _frame(solid_panel, stripping, rolling)
value = layout._peak_psi(frame, [10.0, 10.0])
assert value == float('inf')
assert frame.memo[(10.0, 10.0)] == float('inf')
def test_the_memo_is_bounded(solid_panel, stripping, rolling, monkeypatch):
"""A pathological piece must not turn a cache into a memory problem.
Overflowing costs a recomputation, never a different answer."""
monkeypatch.setattr(layout, '_MEMO_MAX', 2)
frame = _frame(solid_panel, stripping, rolling)
for x in (4.0, 5.0, 6.0, 7.0, 8.0):
layout._peak_psi(frame, [x, 26.0])
assert len(frame.memo) == 2
# and the answers are still right past the cap
assert layout._peak_psi(frame, [8.0, 26.0]) == \
layout._peak_psi(_frame(solid_panel, stripping, rolling), [8.0, 26.0])
# --------------------------------------------- the search answer is unchanged
@pytest.mark.parametrize('with_pockets', [False, True])
def test_the_recommendation_is_what_it_was_without_the_memo(
with_pockets, stripping, rolling, monkeypatch):
"""The memo must be invisible in the answer. Run the same search with the
cache disabled (cap 0) and demand the same stations to the last bit."""
panel = make_panel(blockouts=aspire_pockets() if with_pockets else None)
def search():
return layout.best_stations(panel, stripping, rolling,
start=[6.0, 26.0], counts=[2, 2],
free=[True, True])
monkeypatch.setattr(layout, '_MEMO_MAX', 0)
xs_off, best_off = search()
monkeypatch.setattr(layout, '_MEMO_MAX', 200000)
xs_on, best_on = search()
assert xs_on == xs_off
assert best_on == best_off
# ------------------------------------------------- the branch that was deleted
def test_the_section_is_solved_on_exactly_the_diagram_stations(solid_panel,
stripping,
rolling):
"""`_peak_psi` used to carry a guard re-solving the section when the
station list "changed under us". It could not fire: `_make_frame` builds
the station list out of the same profile and the same discontinuities that
`diagram()` does, from the same expression. The guard is gone; this is
what it was guarding, asserted where it can actually fail.
A dead branch in the hottest loop in the package is a branch nobody can
test, and the comment on it claimed a thing that could not happen.
"""
for panel in (solid_panel, make_panel(blockouts=aspire_pockets())):
frame = _frame(panel, stripping, rolling)
for xs in ([6.0, 26.0], [4.0, 28.0], [10.0, 22.0]):
lifters = []
for i, (x, n) in enumerate(zip(xs, frame.counts)):
for j in range(n):
lifters.append(layout.Lifter(
mark='r{0}.{1}'.format(i, j), family='?',
x=x, y=0.0, role='strip'))
staged = dataclasses.replace(panel, lifters=lifters)
rig = statics.group_lifters(staged, rolling, role='strip')
statics.solve_rolling(rig, frame.profile)
dia = statics.diagram(rig, frame.profile,
extra_stations=frame.jumps)
assert len(dia.xs) == len(frame.inv_s)
def test_the_frame_carries_no_spare_lifters():
"""`_Frame._spares` claimed a reuse optimisation that did not exist --
nothing ever read it or wrote it. A field whose comment describes code
that is not there is worse than no field."""
names = [f.name for f in dataclasses.fields(layout._Frame)]
assert '_spares' not in names
assert 'memo' in names
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""Two constraints, not one, and they are not the same inserts.
A panel erected on its END carries its anchors in that end: they cannot move
along the piece, and where they sit across it is still a choice.
A panel erected on its long EDGE carries them along that edge: they may slide
freely down the length, and they may not come off the edge.
Collapsing both into one "pinned" flag gets one of the two wrong, and the
wrong one is a recommendation to put an anchor where the piece has no edge.
"""
import dataclasses
import pytest
from conftest import LENGTH_FT, WIDTH_FT, make_panel
from panellift import cli, layout, statics
from panellift.model import Lifter, Rigging, Stage
def build(anchor_kwargs, anchor_xs=(LENGTH_FT,), anchor_ys=None):
panel = make_panel()
face = [dataclasses.replace(l, capacity_lb=12000.0)
for l in panel.lifters if l.x < 0.5 * LENGTH_FT]
anchors = []
for n, x in enumerate(anchor_xs):
for m, y in enumerate(anchor_ys or (0.25 * WIDTH_FT, 0.75 * WIDTH_FT)):
anchors.append(Lifter(
mark='E{0}{1}'.format(n, m), family='EDGE_ANCHOR', x=x, y=y,
role='strip', capacity_lb=7690.0, shear_capacity_lb=4010.0,
on_edge=True, **anchor_kwargs))
return dataclasses.replace(panel, lifters=face + anchors,
weight_lb=35000.0)
def recommend(panel, stage, rigging):
a = statics.analyse(panel, stage, rigging, role='strip')
return layout.recommend([(a, rigging, [])])
def row_at(rec, x, tol=0.5):
return [r for r in rec.rows if abs(r.x - x) < tol][0]
# ------------------------------------------------- erected on its end
def test_an_end_anchor_row_holds_its_station(stripping, rolling):
rec = recommend(build({'pinned': True}), stripping, rolling)
row = row_at(rec, LENGTH_FT)
assert row.to_x == pytest.approx(LENGTH_FT)
assert row.pinned and not row.pinned_y
def test_an_end_anchor_row_may_still_be_moved_across_the_width(stripping,
rolling):
"""Being in the end says nothing about where across the piece it sits.
Drawn hard against one side, so there is something to move: the pair keeps
its spacing and is brought back onto the centreline.
"""
rec = recommend(build({'pinned': True}, anchor_ys=(1.0, 2.0)),
stripping, rolling)
moves = [m for m in rec.moves if m.family == 'EDGE_ANCHOR']
assert all(m.dx_in == pytest.approx(0.0) for m in moves)
assert all(abs(m.dy_in) > 0.5 for m in moves)
assert sum(m.to_y for m in moves) / len(moves) == pytest.approx(
WIDTH_FT / 2.0)
# ------------------------------------------- erected on its long edge
def test_an_edge_anchor_row_is_not_lifted_off_its_edge(stripping, rolling):
"""The one that was broken: a horizontal panel's anchors live on the top
edge, and recommending a y in the middle of the panel asks for an anchor
where there is no edge to cast it into."""
rec = recommend(build({'pinned_y': True},
anchor_xs=(8.0, 24.0),
anchor_ys=(WIDTH_FT, WIDTH_FT)),
stripping, rolling)
for move in [m for m in rec.moves if m.family == 'EDGE_ANCHOR']:
assert move.to_y == pytest.approx(WIDTH_FT)
assert move.dy_in == pytest.approx(0.0)
def test_an_edge_anchor_row_may_still_slide_along_the_piece(stripping,
rolling):
"""Sliding along the edge is the one degree of freedom it has, and it is
exactly the one the search exists to use."""
rec = recommend(build({'pinned_y': True},
anchor_xs=(1.0,), anchor_ys=(WIDTH_FT, WIDTH_FT)),
stripping, rolling)
row = row_at(rec, 1.0)
assert not row.pinned and row.pinned_y
assert row.to_x != pytest.approx(1.0)
# ------------------------------------------------------ the job file
def test_pinned_y_survives_the_job_file():
"""It is written by the Revit side; if the reader drops it the constraint
is silently gone and only a horizontal panel would ever show it."""
job = {
'schema': cli.SCHEMA,
'panel': {
'mark': 'H1', 'length_ft': 30.0, 'width_ft': 8.0,
'thickness_in': 8.0, 'weight_lb': 20000.0,
'lifters': [
{'mark': 'A', 'family': 'F', 'x': 5.0, 'y': 8.0,
'pinned_y': True, 'on_edge': True, 'capacity_lb': 7690.0},
{'mark': 'B', 'family': 'F', 'x': 25.0, 'y': 8.0,
'pinned_y': True, 'on_edge': True, 'capacity_lb': 7690.0},
],
},
'stages': [{'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0}],
}
panel, _rig, _cases = cli.analyse_job(job)
assert all(l.pinned_y and not l.pinned for l in panel.lifters)
+211
View File
@@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
"""The mild-reinforced flexural path: capacity.mild_flexure_at.
The reference tool (Eriksson Wall) computes a Whitney-block flexural capacity
for a NON-prestressed (mild-steel) wall panel -- and for a COMBINED piece --
which `capacity_at` dashes ('no prestressing on this piece'). This pins that
path, which is additive: `capacity_at` and the strand-only `handling_flexure_at`
are untouched, and a pure-strand piece never reaches the mild solver.
Every number in the hand-check section is HAND COMPUTED, with the arithmetic in
the comment beside it, on a rectangular section chosen so the closed forms are
exact:
b = 48 in. t = 8 in. f'c = 4000 psi w_c = 150 pcf fy = 60 ksi
f'c = 4000 is the corner of ACI Table 22.2.2.4.3, so beta1 = 0.85 with no
interpolation; w_c = 150 puts lambda at 1.0. Es = 29000 ksi (mild).
The parity against the reference tool's own golden phi*Mn (SWA1.0 172.1 k-ft,
WPA1.0-12in 480.1 k-ft) lives in scratchpad/parity_check.py, off the client
decks; this file is the reproducible-on-paper unit check.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, 'src'))
from panellift import capacity, pci # noqa: E402
from panellift.model import (Panel, Prestress, Reinforcement, # noqa: E402
Stage, Strand)
B_IN = 48.0
T_IN = 8.0
FC = 4000.0
def mild_panel(layers, width_ft=B_IN / 12.0, thickness_in=T_IN):
return Panel(mark='MILD-1', length_ft=32.0, width_ft=width_ft,
thickness_in=thickness_in, unit_weight_pcf=150.0,
fci_psi=FC, fc_psi=FC, reinforcement=layers)
def one_layer(area_in2, z_in, fy=60.0, count=1):
return Reinforcement(kind='bar', label='M', area_in2=area_in2, z_in=z_in,
grade_ksi=fy, count=count)
STAGE = Stage(name='mild', multiplier=1.0, fc_psi=FC)
# ---------------------------------------------------- 1. hand calculations
def test_mild_mn_no_axial_matches_the_hand_calculation():
"""One layer As = 2.0 in^2 at d = 6.0 in (z = t - d = 2.0), fy = 60, Pu = 0.
T = As*fy = 2.0 * 60,000 = 120,000 lb (yields)
a = T/(0.85 f'c b) = 120,000/(0.85*4000*48) = 0.735294 in
c = a/beta1 = 0.735294/0.85 = 0.865052 in
eps_t = 0.003*(6 - 0.865052)/0.865052 = 0.017808 -> phi 0.90
M_n = T*(d - a/2) = 120,000*(6 - 0.367647) = 675,882.4 lb-in
= 56,323.53 lb-ft
phi*M_n = 0.9 * 56,323.53 = 50,691.18 lb-ft
"""
# sagging (+M): compression on the z=t face, so d = t - z = 8 - 2 = 6.
mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
STAGE, x=16.0, moment_lbft=+10000.0)
assert abs(mp.as_in2 - 2.0) < 1e-9
assert abs(mp.a_in - 0.735294) < 1e-4
assert abs(mp.c_in - 0.865052) < 1e-4
assert abs(mp.strain_t - 0.017808) < 1e-5
assert mp.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED == 0.90
assert abs(mp.mn_lbft - 56323.53) < 1.0
assert abs(mp.phi_mn_lbft - 50691.18) < 1.0
assert abs(mp.m_over_phi_mn - 10000.0 / 50691.18) < 1e-6
def test_mild_mn_with_axial_shifts_the_neutral_axis_and_raises_mn():
"""Same layer, now with Pu = 50,000 lb COMPRESSION on the P-M surface.
Equilibrium C - Pu - T = 0, steel still yields, so
C = Pu + T = 50,000 + 120,000 = 170,000 lb
a = C/(0.85 f'c b) = 170,000/163,200 = 1.041667 in
c = a/beta1 = 1.041667/0.85 = 1.225490 in
M_n = C*(c - a/2) - T*(c - d) - Pu*(c - Cy), Cy = t/2 = 4
= 170,000*0.704657 - 120,000*(-4.774510) - 50,000*(-2.774510)
= 119,791.7 + 572,941.2 + 138,725.5 = 831,458.4 lb-in
= 69,288.2 lb-ft
phi*M_n = 0.9 * 69,288.2 = 62,359.4 lb-ft
The axial compression deepens the block and raises M_n -- the P-M coupling
(docs sec.4.2). A tool that ignored Pu would report the 56.3 k-ft above.
"""
mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
STAGE, x=16.0, moment_lbft=+10000.0,
pu_lb=50000.0)
assert abs(mp.a_in - 1.041667) < 1e-4
assert abs(mp.c_in - 1.225490) < 1e-4
assert abs(mp.mn_lbft - 69288.2) < 1.0
assert abs(mp.phi_mn_lbft - 62359.4) < 1.0
# and it is strictly larger than the no-axial capacity
base = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
STAGE, x=16.0, moment_lbft=+10000.0)
assert mp.mn_lbft > base.mn_lbft
def test_two_symmetric_layers_reproduce_the_SWA_style_couple():
"""(2)#11 at d-CG 3 and 9 in a 12 in x 14 ft solid section, f'c = 6 ksi,
Pu = 21.1 k hogging -- the SWA1.0 shape. Golden phi*Mn = 172.1 k-ft, and
the strain-compat couple here lands at 171.1 k-ft (-0.6%). Kept as a
reproducible echo of the parity harness inside the suite."""
panel = Panel(mark='SWA1.0', length_ft=32.0, width_ft=14.0,
thickness_in=12.0, unit_weight_pcf=150.0,
fci_psi=2500.0, fc_psi=6000.0,
reinforcement=[one_layer(1.56, z_in=3.0, count=2),
one_layer(1.56, z_in=9.0, count=2)])
stage = Stage(name='inplace', multiplier=1.0, fc_psi=6000.0)
mp = capacity.mild_flexure_at(panel, stage, x=16.0, moment_lbft=-100300.0,
pu_lb=21100.0)
assert abs(mp.as_in2 - 6.24) < 1e-9
assert mp.phi == 0.90
assert abs(mp.phi_mn_lbft / 1000.0 - 172.1) / 172.1 < 0.03 # within 3%
assert abs(mp.m_over_phi_mn - 0.58) < 0.02
# ------------------------------------------------ 2. the strain-based phi
def test_phi_uses_the_MILD_075_floor_not_the_prestressed_065():
"""A heavily reinforced layer drops eps_t into the transition band, so phi
is between the floor and 0.90 -- and the floor is the MILD 0.75, read
through the SAME phi_flexure_from_strain helper (docs sec.2.2)."""
mp = capacity.mild_flexure_at(mild_panel([one_layer(8.0, z_in=2.0)]),
STAGE, x=16.0, moment_lbft=+10000.0)
assert pci.PHI_FLEXURE_COMPRESSION_CONTROLLED_MILD == 0.75
assert capacity.PHI_FLEXURE_MILD_FLOOR == 0.75
# eps_t is in the 0.002..0.005 transition, so phi is strictly between
assert 0.002 < mp.strain_t < 0.005
assert 0.75 < mp.phi < 0.90
# and it is exactly the helper's value on the mild floor
assert abs(mp.phi - capacity.phi_flexure_from_strain(
mp.strain_t, phi_min=0.75)) < 1e-12
# the prestressed 0.65 floor would give a strictly lower phi here
assert capacity.phi_flexure_from_strain(mp.strain_t, phi_min=0.65) < mp.phi
# ------------------------------------------------------- 3. combined piece
def test_combined_strand_plus_mild_credits_both():
"""A piece with BOTH strand and mild steel carries them on one P-M surface;
adding a strand group to a mild section raises M_n and flags combined."""
mild_only = mild_panel([one_layer(2.0, z_in=2.0)])
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
initial_pull_lb=30000.0) for i in range(4)]
combined = mild_panel([one_layer(2.0, z_in=2.0)])
combined.prestress = Prestress(strands=strands, loss_pct=10.0,
diameter_in=0.5, transfer_length_in=30.0)
mp_mild = capacity.mild_flexure_at(mild_only, STAGE, x=16.0,
moment_lbft=+10000.0)
mp_comb = capacity.mild_flexure_at(combined, STAGE, x=16.0,
moment_lbft=+10000.0)
assert mp_mild.combined is False
assert mp_comb.combined is True
# both bars are on the tension side (z=2 -> d=6 sagging), so the extra
# strand tension raises the couple
assert mp_comb.mn_lbft > mp_mild.mn_lbft
# as_in2 counts MILD only; the strand rides its own layer
assert abs(mp_comb.as_in2 - 2.0) < 1e-9
# ----------------------------------------------- 4. could-not-judge, dashes
def test_a_piece_with_no_mild_steel_says_so_rather_than_computing_zero():
mp = capacity.mild_flexure_at(mild_panel([]), STAGE, x=16.0,
moment_lbft=+10000.0)
assert mp.phi_mn_lbft is None
assert mp.mn_lbft is None
assert 'no mild reinforcement' in mp.why
def test_has_mild_reinforcement_predicate():
assert capacity.has_mild_reinforcement(
mild_panel([one_layer(2.0, z_in=2.0)])) is True
assert capacity.has_mild_reinforcement(mild_panel([])) is False
# a strand layer is not mild
strand_layer = Reinforcement(kind='strand', label='S', area_in2=0.153,
z_in=2.0, grade_ksi=270.0, count=4,
initial_pull_lb=30000.0)
assert capacity.has_mild_reinforcement(mild_panel([strand_layer])) is False
# ------------------------------- 5. the strand-only path is byte-unchanged
def test_flexure_at_leaves_a_pure_strand_piece_on_the_existing_path():
"""flexure_at must not route a strand-only piece through the mild solver.
On such a piece it returns EXACTLY handling_flexure_at(capacity_at(...)) --
the additive path the task requires to stay byte-for-byte unchanged."""
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
initial_pull_lb=30000.0) for i in range(4)]
panel = Panel(mark='STRAND-1', length_ft=32.0, width_ft=B_IN / 12.0,
thickness_in=T_IN, unit_weight_pcf=150.0,
fci_psi=FC, fc_psi=FC,
prestress=Prestress(strands=strands, loss_pct=10.0,
diameter_in=0.5, transfer_length_in=30.0))
assert capacity.has_mild_reinforcement(panel) is False
cap = capacity.capacity_at(panel, STAGE, 16.0, 10000.0, 0.0)
want = capacity.handling_flexure_at(cap, 10000.0)
got = capacity.flexure_at(panel, STAGE, 16.0, 10000.0, cap=cap)
assert got == want
assert got[1] is not None # it really did compute one
+192
View File
@@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""Switching the position search off, and saying so.
The search is 93% of a run -- measured 2026-08-30 across the 184-piece
reference sweep: 12.6 s of a 13.6 s median, and 41 minutes of a 42 minute
batch. `--no-recommend` is what makes a batch usable.
Everything here is about the same one risk. A report whose "Recommended
positions" section had simply gone missing reads exactly like a report on a
piece with nothing to recommend -- and the report's own words for that case
are "the inserts as drawn are already at the best position this search can
find", which is a sentence about a search nobody ran. Same rule as a stage
with `enabled: false`: NOT RUN is never silent.
The other half is that the default must not move. `searched` is written into
the JSON summary only when the search was skipped, the same way the job writer
emits `enabled` only when it is false, so a run with no flag produces the same
bytes it did before the flag existed.
"""
import io
import json
from panellift import cli, layout, report
from test_cli import aspire_job
def _run(tmp_path, *flags):
"""The CLI, end to end, on a real job file. Returns (code, out, html)."""
tmp_path.mkdir(parents=True, exist_ok=True)
job_path = tmp_path / 'job.json'
out_path = tmp_path / 'report.html'
with io.open(str(job_path), 'w', encoding='utf-8') as fh:
fh.write(json.dumps(aspire_job()))
code = cli.main([str(job_path), '-o', str(out_path)] + list(flags))
with io.open(str(out_path), 'r', encoding='utf-8') as fh:
return code, out_path, fh.read()
# ----------------------------------------------------- the flag does its job
def test_no_recommend_leaves_every_verdict_alone(tmp_path, capsys):
"""The flag drops an ANSWER, never a CHECK. If it could change a verdict
it would be a different calculation, not a faster one."""
with_search = _run(tmp_path / 'a', )
capsys.readouterr()
without = _run(tmp_path / 'b', '--no-recommend')
capsys.readouterr()
assert with_search[0] == without[0]
job = aspire_job()
_p, _r, cases = cli.analyse_job(job)
full = cli.summarise(cases, layout.recommend(cases))
skipped = cli.summarise(cases, layout.not_searched())
assert full['verdict'] == skipped['verdict']
assert [s['verdict'] for s in full['stages']] == \
[s['verdict'] for s in skipped['stages']]
assert [s['peak_tension_psi'] for s in full['stages']] == \
[s['peak_tension_psi'] for s in skipped['stages']]
def test_no_recommend_is_actually_faster(tmp_path, capsys):
"""Not a behaviour test -- a claim test. The flag exists for one reason
and a version of it that still searched would pass every other test
here."""
import time
job = aspire_job()
_p, _r, cases = cli.analyse_job(job)
t0 = time.perf_counter()
layout.recommend(cases)
searched = time.perf_counter() - t0
t0 = time.perf_counter()
layout.not_searched()
skipped = time.perf_counter() - t0
assert skipped < searched / 10.0
# ------------------------------------------------------- and says it did it
def test_the_report_says_the_positions_were_not_computed(tmp_path, capsys):
"""Present, closed, flagged NOT RUN, honest when opened.
Skipping the search is the ordinary way this runs now, so the section is
no longer a warning box on an open heading -- it is a collapsed
disclosure like every other section on the page, carrying NOT RUN on the
line you can read without opening it.
"""
_code, _path, html = _run(tmp_path, '--no-recommend')
capsys.readouterr()
assert 'Recommended positions' in html # the section stays
assert 'Not computed' in html
# the closed summary line says so, and nothing shouts
assert '<b class="notrun-tag">not run</b>' in html
assert 'class="caveat"' not in html
# and none of the things a search that RAN would have said
assert 'already at the best position' not in html
assert 'recommended x' not in html
def test_the_not_run_section_is_a_collapsed_details(tmp_path, capsys):
"""The engineer's own words: it is always there, and closed by default.
Both halves matter. A section that vanishes on a skipped search reads as
a piece with nothing to move; a section that opens itself puts a notice
nobody asked for above the numbers they did.
"""
_code, _path, skipped_html = _run(tmp_path / 'skipped', '--no-recommend')
capsys.readouterr()
_code, _path, ran_html = _run(tmp_path / 'ran')
capsys.readouterr()
for html, tagged in ((skipped_html, True), (ran_html, False)):
i = html.index('Recommended positions')
head = html[max(0, i - 200):i]
# inside a <details class="section">, and never an open one
assert '<details class="section">' in head
assert '<details class="section" open' not in html
assert '<h2>Recommended positions</h2>' not in html
assert ('notrun-tag">not run' in html) is tagged
def test_a_skipped_search_never_reads_as_nothing_to_move():
"""The exact confusion this is all here to prevent."""
ran = report._recommendation(layout.Recommendation())
skipped = report._recommendation(layout.not_searched())
assert 'already at the best position' in ran
assert 'already at the best position' not in skipped
assert 'did not run' in skipped
def test_the_stdout_line_says_so_too(tmp_path, capsys):
"""A batch scrolling past has to be visible without opening a report."""
_run(tmp_path, '--no-recommend')
out = capsys.readouterr().out
assert 'NOT COMPUTED' in out
_run(tmp_path / 'again')
out = capsys.readouterr().out
assert 'NOT COMPUTED' not in out
def test_the_summary_carries_searched_false(tmp_path, capsys):
job = aspire_job()
_p, _r, cases = cli.analyse_job(job)
skipped = cli.summarise(cases, layout.not_searched())
assert skipped['recommendation']['searched'] is False
assert skipped['recommendation']['note']
assert skipped['recommendation']['moves'] == []
def test_searched_is_absent_when_the_search_ran():
"""Written only when it was skipped -- the same rule the job writer
follows for `enabled`. Absent means it ran, which is what every summary
written before this flag existed means.
"""
job = aspire_job()
_p, _r, cases = cli.analyse_job(job)
full = cli.summarise(cases, layout.recommend(cases))
assert 'searched' not in full['recommendation']
assert full['recommendation'].get('searched', True) is True
# --------------------------------------------------------- nothing else moved
def test_a_default_run_is_byte_for_byte_what_it_was(tmp_path, capsys):
"""The flag is additive. With nothing asked for, every byte of the
report and of the JSON summary is what it was before the flag existed --
which is checked here on one job and, out of band, on all 184 of the
reference sweep with the clock frozen."""
job = aspire_job()
_p, _r, cases = cli.analyse_job(job)
rec = layout.recommend(cases)
html_default = report.build_many(cases, recommendation=rec)
summary_default = json.dumps(cli.summarise(cases, rec), sort_keys=True)
# the same call the pre-flag code made, with no recommendation passed in
_p2, _r2, cases2 = cli.analyse_job(aspire_job())
html_implicit = report.build_many(cases2)
summary_implicit = json.dumps(cli.summarise(cases2), sort_keys=True)
assert html_default == html_implicit
assert summary_default == summary_implicit
def test_the_flag_is_off_unless_asked_for(tmp_path, capsys):
"""A default that has to be asked for is a default nobody gets."""
_code, _path, html = _run(tmp_path)
capsys.readouterr()
assert 'Not computed' not in html
+276
View File
@@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
"""Real typography in the HTML report, and why the source strings stay ASCII.
`report.notate()` and `report.notate_plain()` are the ONE place engineering
shorthand -- "phi-Mn", "5*sqrt(f'c)", "f_se", "60deg" -- becomes real
subscripts, a real radical, a real degree sign. Everywhere else in this
package, in `checks.py`, `capacity.py` and `model.py`, that text stays plain
ASCII on purpose: the SAME strings are written to sweep/problems.txt, printed
to the console, exported into the job/problem JSON, and printed inside
Revit's pyRevit output window under IronPython 2.7 (see
`WillTools.extension/lib/vibe/liftexport.py`). A radical sign or a raw
`<sub>` tag in any of those is a crash or mojibake, not a nicer report --
this project has already been bitten by exactly that shape of bug once, in
the Discord bridge.
So these tests do two things: pin the rendering (given a known ASCII string,
the HTML/plain output is exactly this), and guard the boundary (the
producers stay ASCII; nothing here should ever need to change if `notate()`
changes).
Every string tested against notate()/notate_plain() below is copied VERBATIM
from `checks.py`, `capacity.py` or `model.py` -- not invented -- because an
untested token is exactly the "regex over prose" failure this was built to
avoid (`psi` the unit must never become `&psi;`; there is no rule for it at
all, on purpose -- see test_units_and_ratios_pass_through_untouched below).
"""
import re
from conftest import aspire_pockets, make_panel
from panellift import checks, report, statics
from panellift.model import Blockout, Prestress, Rigging, Stage, Strand
# --------------------------------------------------------- notate(): HTML
def test_the_radical_is_real_mathml_with_a_plain_fallback_beside_it():
"""A bare &radic; glued to a CSS overline is the old half-measure: the
glyph and the border never actually join. RADICAL_FC now has to carry a
real <msqrt> -- drawn as one shape by the browser's own MathML layout --
AND, right beside it, the plain-Unicode reading a non-MathML viewer gets
instead, because <math> has no fallback of its own."""
assert report.RADICAL_FC == (
'<math><msqrt><mrow><msub><mrow><mi>f</mi><mo>&#x2032;</mo></mrow>'
'<mi>c</mi></msub></mrow></msqrt></math>'
'<span class="radical-fallback">√f′c</span>')
assert report.RADICAL_FCI == (
'<math><msqrt><mrow><msub><mrow><mi>f</mi><mo>&#x2032;</mo></mrow>'
'<mi>ci</mi></msub></mrow></msqrt></math>'
'<span class="radical-fallback">√f′ci</span>')
# The CSS-overline technique is gone; nothing should still reference it.
assert '.rt{' not in report.CSS
assert 'class="rt"' not in report.RADICAL_FC
# The fallback switch: hidden by default, shown only behind a real
# MathML capability test, never behind a guess or a browser sniff.
assert 'math{display:none}' in report.CSS
assert '@supports (math-depth:0)' in report.CSS
assert '.radical-fallback{display:none}' in report.CSS
def test_fc_prime_gets_a_real_prime_and_a_real_subscript():
# Copied from Stage.tension_limit_text's shape: "5*sqrt(f'c)".
assert report.notate("5*sqrt(f'c)") == '5' + report.RADICAL_FC
assert report.notate("7.5*sqrt(f'c)") == '7.5' + report.RADICAL_FC
# f'ci (stripping strength) is a DIFFERENT subscript and must not be
# truncated to f'c's -- "f'ci" contains "f'c" as a substring.
assert report.notate("f'ci") == 'f&prime;<sub>ci</sub>'
assert report.notate("f'c") == 'f&prime;<sub>c</sub>'
# Bare, mid sentence -- from capacity.py's `point.why[col] = "stage
# states no f'c"`.
assert report.notate("stage states no f'c") == (
'stage states no f&prime;<sub>c</sub>')
def test_phi_mn_and_phi_vn_are_real_subscripts_not_hyphenated_ascii():
# From capacity.py's _FLEX_BASIS / _SHEAR_BASIS and checks.py's RATIO_WORD.
assert report.notate('phi-Mn') == '&phi;M<sub>n</sub>'
assert report.notate('phi-Vn') == '&phi;V<sub>n</sub>'
assert report.notate('M/phi-Mn') == 'M/&phi;M<sub>n</sub>'
assert report.notate('V/phi-Vn') == 'V/&phi;V<sub>n</sub>'
# Bare phi, e.g. checks.py's `detail += ' (phi {0:.2f})'`.
assert report.notate('(phi 0.90)') == '(&phi; 0.90)'
def test_a_real_finding_message_renders_with_real_markup():
"""Not a synthetic string -- an actual CapacityPoint.why /
development_note() sentence, copied from capacity.py, run through the
same function the Checks table uses."""
why = ('within a strand\'s development length of a free end (36.0 in. '
'from the end of the piece; l_d 63.7 in.) -- f_ps is 187.9 of '
'243.4 ksi here, so phi-Mn, which is strand only, tends to zero '
'while M_cr does not')
out = report.notate(why)
assert '&#8467;<sub>d</sub> 63.7 in.' in out
assert 'f<sub>ps</sub> is 187.9 of 243.4 ksi' in out
assert '&phi;M<sub>n</sub>, which is strand only' in out
assert 'M<sub>cr</sub> does not' in out
# And nothing here should ever come back out as raw ASCII shorthand.
assert 'phi-Mn' not in out and 'M_cr' not in out and 'l_d' not in out
def test_the_shear_basis_sentence_renders_v_n_equals_v_c():
# capacity.py's _SHEAR_BASIS, verbatim.
basis = ('phi-Vn here is CONCRETE ALONE -- ACI 318-19 22.5.5.1 '
'simplified, no shear reinforcement in the handling direction, '
'so V_n = V_c')
out = report.notate(basis)
assert '&phi;V<sub>n</sub> here is CONCRETE ALONE' in out
assert 'V<sub>n</sub> = V<sub>c</sub>' in out
def test_aps_in2_in3_and_degrees():
# checks.py: 'P {0} lb over Aps {1:.2f} in2 ...'
assert report.notate('Aps 0.61 in2') == 'A<sub>ps</sub> 0.61 in&sup2;'
# checks.py: 'S {6} in3'
assert report.notate('S 1,472 in3') == 'S 1,472 in&sup3;'
# checks.py: 'sin {4:.0f}deg' and 'the 60deg usually taken as ...'
assert report.notate('sin 60deg') == 'sin 60&deg;'
assert report.notate('below the 60deg usually') == (
'below the 60&deg; usually')
def test_the_multiplication_idiom_not_the_coordinate_one():
# checks.py SLING-ANGLE: 'line force is {1:.2f}x the vertical reaction'
assert report.notate('1.03x the vertical reaction') == (
'1.03&times; the vertical reaction')
# A coordinate is always written "x=1.23 ft" in this package -- no digit
# immediately in front of the letter -- so it must NOT be touched.
assert report.notate('the pick line at x=1.23 ft') == (
'the pick line at x=1.23 ft')
def test_units_and_ratios_pass_through_untouched():
"""The owner's own example: psi/ksi/plf/pli/lb/kips are already correct
and must never be touched, and a ratio like 4:1 stays exactly as typed.
There is no rule for any of these in _NOTATION_RULES -- this asserts the
omission, not an exception that could rot."""
for s in ('444 psi', '270 ksi', '109.4 plf', '80 pli', '34,638 lb',
'8 kips', 'Capacity in Concrete 4:1 SWL', '0.207'):
assert report.notate(s) == report.esc(s)
def test_data_is_still_escaped_around_the_markup():
"""A panel mark or a job-authored reason can carry HTML-significant
characters; notate() has to escape those exactly like esc() does, in the
SAME string that also carries real notation."""
out = report.notate("Tom & Jerry's <panel> f'c 4000 psi")
assert '&amp;' in out
assert '&lt;panel&gt;' in out
assert "Jerry&#x27;s" in out
assert 'f&prime;<sub>c</sub>' in out
# And never literal angle brackets from the data itself.
assert '<panel>' not in out
def test_le_ge_and_none_input():
assert report.notate('a <= b') == 'a &le; b'
assert report.notate('a >= b') == 'a &ge; b'
assert report.notate(None) == ''
# ---------------------------------------------- notate_plain(): SVG / JS text
def test_plain_mode_never_emits_a_tag():
"""<sub> in an <svg> <text> node or in JS textContent renders as four
literal characters, not a subscript -- notate_plain() must never emit
one."""
samples = ["5*sqrt(f'c)", 'phi-Mn', 'f_t', 'f_se', 'M_cr', 'Aps 0.61 in2',
'sin 60deg', '1.03x the vertical reaction', '(phi 0.90)']
for s in samples:
out = report.notate_plain(s)
assert '<' not in out and '>' not in out, (s, out)
assert '&' not in out, (s, out)
def test_plain_mode_uses_unicode_subscripts_where_they_exist():
assert report.notate_plain('f_t') == 'fₜ' # fₜ
assert report.notate_plain('phi-Mn') == 'φMₙ' # φMₙ
assert report.notate_plain("5*sqrt(f'c)") == "5√f′c"
# No Unicode subscript 'c' exists -- f_c degrades to plain "fc", not a
# fabricated subscript and not the bare underscore.
assert report.notate_plain('f_c') == 'fc'
assert '_' not in report.notate_plain('f_c')
# ---------------------------------------------------- the architecture edge
def test_the_source_strings_stay_ascii():
"""The producers -- Stage.tension_limit_text, and a real finding message
-- must still be pure ASCII after this change. Only report.py's renderer
may carry Unicode or HTML; console/sweep/JSON/IronPython read this."""
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
text = stage.tension_limit_text
assert text == "7.5*sqrt(f'c)"
text.encode('ascii') # raises if it is not
panel = make_panel(blockouts=aspire_pockets())
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
findings = checks.run_all(analysis, Rigging(mode='rolling'))
for f in findings:
f.message.encode('ascii')
def test_a_rendered_report_carries_no_raw_ascii_shorthand_in_its_body():
"""End to end: build a real report and check the BODY (not the CSS
comments, which are developer prose, not rendered text) for the ASCII
forms this whole module exists to replace."""
panel = make_panel(blockouts=aspire_pockets())
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
findings = checks.run_all(analysis, Rigging(mode='rolling'))
page = report.build(analysis, Rigging(mode='rolling'), findings,
recommendation=False)
body = page[page.index('</style>'):]
for pattern in (r"\bphi-Mn\b", r"\bphi-Vn\b", r"f'c\b", r"f'ci\b",
r"\bf_t\b", r"\bf_c\b", r"\bf_se\b", r"\bf_pu\b",
r"\bf_ps\b", r"\bl_d\b", r"\bl_t\b", r"\bM_cr\b",
r"\bV_n\b", r"\bV_c\b", r"\bAps\b", r"\bin2\b",
r"\bin3\b", r"\bd_b\b", r"\beps_t\b"):
assert not re.search(pattern, body), pattern
# And the positive side of the same assertion: the markup is actually
# there, not just absent.
assert '<sub>' in body and report.RADICAL_FC in body
# --------------------------------------- _prestress_line() vs. _rows()/notate()
def _panel_with_a_cut_strand():
"""A panel with real prestress and a blockout that severs the one
strand -- enough to exercise every clause of `_prestress_line()`,
including the `<strong>...cut by a blockout</strong>` branch."""
panel = make_panel(blockouts=[
Blockout(name='OPENING', x0=15.0, y0=1.5, length=2.0, width=1.0)])
panel.prestress = Prestress(
strands=[Strand(y=2.0, z_in=4.0, area_in2=0.153,
initial_pull_lb=0.153 * 270_000 * 0.75)],
loss_pct=8.0, diameter_in=0.5)
return panel
def test_prestress_line_returns_real_markup_never_escaped():
"""`_prestress_line()` builds its own <br>/<strong>/<sub> markup and
escapes only the free-form pieces itself (see its own docstring) -- this
pins that raw output. See the end-to-end test below for the regression
it was written to catch: `_rows()` running this same markup through
notate() a second time, printing the literal text "<br>" on the page."""
line = report._prestress_line(_panel_with_a_cut_strand())
assert '<br>' in line
assert '<sub>ps</sub>' in line and '&sup2;' in line
assert '<strong>1 cut by a blockout</strong>' in line
assert '&lt;' not in line and '&gt;' not in line
def test_the_rendered_piece_table_prestress_row_is_not_double_escaped():
"""Regression: the Piece table used to build the Prestress row with
`_rows()`, which runs every value through `notate()` -- safe for the
other rows' plain prose, but `_prestress_line()`'s value is already real
HTML it built and escaped itself, so notate() escaped it a SECOND time
and the report printed the literal text "<br>" / "<strong>" instead of a
line break and bold. Build a whole report and check the actual row."""
panel = _panel_with_a_cut_strand()
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
findings = checks.run_all(analysis, Rigging(mode='rolling'))
page = report.build(analysis, Rigging(mode='rolling'), findings,
recommendation=False)
assert '&lt;br&gt;' not in page and '&lt;strong&gt;' not in page
assert '<tr><th>Prestress</th><td>' in page
row = page[page.index('<tr><th>Prestress</th><td>'):]
row = row[:row.index('</td></tr>') + len('</td></tr>')]
assert '<br>' in row
assert '<strong>' in row and 'cut by a blockout' in row
+110
View File
@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""The beam solver must reproduce PCI Figure 8.3.2 from first principles.
This is the test the whole design rests on. Figure 8.3.2's coefficients are
not a separate method -- they are the closed-form solution of a uniformly
loaded beam with two overhangs, with the supports placed where the positive
and negative moments come out equal. If our solver cannot land on 0.0214 with
the supports there, it cannot be trusted with the supports somewhere else,
which is the only case anyone actually needs it for.
"""
import math
from panellift import pci, section, statics
from panellift.model import Stage
from conftest import ASPIRE, LENGTH_FT, WIDTH_FT
def test_optimal_support_fraction_is_the_root_of_the_quadratic():
a = pci.OPTIMAL_TWO_LINE_FRACTION
assert abs(4 * a ** 2 + 4 * a - 1) < 1e-12
assert abs(a - 0.20710678) < 1e-7
def test_closed_form_coefficient_is_pcis_0_0214():
"""M = w*(aL)^2/2 with a = (sqrt(2)-1)/2 gives 0.02145, printed as 0.0214."""
a = pci.OPTIMAL_TWO_LINE_FRACTION
coefficient = a ** 2 / 2.0
assert abs(coefficient - 0.0214) < 0.0001
def test_solver_reproduces_my_coefficient(solid_panel, rolling):
"""The numerical diagram must match 0.0214 w a b^2 to within 0.5%."""
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
result = statics.analyse(solid_panel, stage, rolling,
reconcile_weight=False)
w_psf = section.unit_weight_psf(solid_panel)
_, expected = pci.fig_832_four_point(w_psf, WIDTH_FT, LENGTH_FT)
_, m_pos = result.diagram.max_positive
_, m_neg = result.diagram.max_negative
assert abs(m_pos - expected) / expected < 0.005
assert abs(abs(m_neg) - expected) / expected < 0.005
def test_positive_and_negative_moments_are_equal_at_the_optimum(solid_panel,
rolling):
"""That equality is the definition of the Figure 8.3.2 layout."""
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
result = statics.analyse(solid_panel, stage, rolling,
reconcile_weight=False)
_, m_pos = result.diagram.max_positive
_, m_neg = result.diagram.max_negative
assert abs(m_pos - abs(m_neg)) / m_pos < 0.01
def test_unit_weight_matches_the_article(solid_panel):
assert abs(section.unit_weight_psf(solid_panel) -
ASPIRE['w_psf']) < 0.1
def _peak_abs_moment(result):
"""The governing moment, whichever sign it has.
Bunching the picks inboard turns the panel into two long cantilevers, and
then the whole interior hogs -- the largest positive moment is zero, at
the ends. Comparing only sagging would read that as an improvement.
"""
_, positive = result.diagram.max_positive
_, negative = result.diagram.max_negative
return max(abs(positive), abs(negative))
def test_moving_the_supports_inboard_raises_the_governing_moment(solid_panel,
rolling):
"""A sanity check that the solver is solving, not returning a constant."""
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
optimum = _peak_abs_moment(
statics.analyse(solid_panel, stage, rolling, reconcile_weight=False))
for lifter in solid_panel.lifters:
lifter.x = LENGTH_FT / 2.0 + math.copysign(
4.0, lifter.x - LENGTH_FT / 2.0)
moved = _peak_abs_moment(
statics.analyse(solid_panel, stage, rolling, reconcile_weight=False))
assert moved > optimum * 1.5
def test_the_optimum_layout_really_is_the_optimum(solid_panel, rolling):
"""No other support position beats (sqrt(2)-1)/2. Worth proving once.
This is what makes the PCI layout a benchmark the report can measure a
detailer's actual layout against, rather than just another arrangement.
"""
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
best = _peak_abs_moment(
statics.analyse(solid_panel, stage, rolling, reconcile_weight=False))
for fraction in (0.10, 0.15, 0.18, 0.23, 0.26, 0.30):
left = fraction * LENGTH_FT
for lifter in solid_panel.lifters:
lifter.x = left if lifter.x < LENGTH_FT / 2.0 else LENGTH_FT - left
trial = _peak_abs_moment(
statics.analyse(solid_panel, stage, rolling,
reconcile_weight=False))
assert trial >= best * 0.999, (
'fraction {0} beat the supposed optimum'.format(fraction))
+237
View File
@@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
"""The figures have to be the data, and the data has to be readable.
Two complaints from the engineer using this, both about the same thing --
a picture that does not answer a question is decoration on a calculation:
1. The free body's load was the same drawing on every piece. It was: the
tall stroke at each station came out of `beam_y - 30 - h + h`, so the
load height cancelled itself out and the one stroke that DID scale was
drawn inside the constant one. A panel with a run of openings looked
exactly like a solid slab. `test_two_load_profiles_draw_differently` is
the guard.
2. Shear, moment and stress were curves with a peak label and nothing else,
so reading a value at a station meant measuring the page with your eye.
They now carry their own samples and the cursor reads them off, in the
units the engineer states them in: kip, kip-ft, psi.
The rule underneath both: the report never shows a number the engine did not
compute. The hover values are the plotted samples, converted once at the point
of display, written onto the SVG -- not recalculated in the browser.
"""
import re
from conftest import aspire_pockets, make_panel
from panellift import checks, layout, report, statics
from panellift.model import Blockout
def _analyse(panel, stage, rigging):
return statics.analyse(panel, stage, rigging)
def _svg_of(page, title_fragment):
"""The one <svg> whose title contains this fragment."""
i = page.index(title_fragment)
return page[page.rindex('<svg', 0, i):page.index('</svg>', i)]
# --------------------------------------------------- 1. the free body is data
def test_the_load_band_steps_where_the_section_steps(stripping, rolling):
"""A blockout takes width out of the piece, so w drops over it. The
drawing has to drop over it too, or it is not the free body of anything.
"""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
svg = _svg_of(report.free_body(a), 'Free body')
band = re.search(r'<path class="loadband" d="([^"]+)"', svg).group(1)
ys = set(re.findall(r',([0-9.]+)', band))
assert len(ys) >= 3 # beam line, full load, reduced load
def test_two_load_profiles_draw_differently(stripping, rolling):
"""The complaint, as a test. Same piece, same pick, same everything but
the openings -- and before this the two free bodies were identical."""
solid = _analyse(make_panel(), stripping, rolling)
pocketed = _analyse(make_panel(blockouts=aspire_pockets()),
stripping, rolling)
a = _svg_of(report.free_body(solid), 'Free body')
b = _svg_of(report.free_body(pocketed), 'Free body')
band_a = re.search(r'<path class="loadband" d="([^"]+)"', a).group(1)
band_b = re.search(r'<path class="loadband" d="([^"]+)"', b).group(1)
assert band_a != band_b
# and the arrows, which are what makes the band read as a load
arrows_a = re.findall(r'<path class="load" d="([^"]+)"', a)
arrows_b = re.findall(r'<path class="load" d="([^"]+)"', b)
assert arrows_a != arrows_b
def test_a_uniform_piece_draws_one_flat_band(stripping, rolling):
"""The other half of the same guarantee: an unbroken piece must not be
given a shape it does not have."""
a = _analyse(make_panel(), stripping, rolling)
segs = report.load_segments(a.profile)
assert len(segs) == 1
svg = _svg_of(report.free_body(a), 'Free body')
assert 'plf factored' in svg
assert ' to ' not in re.search(r'<text class="note"[^>]*>([^<]*)<',
svg).group(1)
def test_the_segments_carry_their_own_magnitudes(stripping, rolling):
"""Per-segment labels, in the unit the profile is in -- plf, factored."""
panel = make_panel(blockouts=[
Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)])
a = _analyse(panel, stripping, rolling)
svg = _svg_of(report.free_body(a), 'Free body')
labels = re.findall(r'<text class="loadlab"[^>]*>([^<]*)</text>', svg)
assert len(labels) >= 2
assert len(set(labels)) >= 2 # not the same number twice
note = re.search(r'<text class="note"[^>]*>([^<]*)<', svg).group(1)
assert 'plf factored' in note and ' to ' in note
def test_the_load_segments_are_the_profile_not_an_average(stripping, rolling):
"""load_segments must return the real w in each run, and cover the piece
end to end -- the free body's numbers are quoted, so they are exact."""
panel = make_panel(blockouts=[
Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)])
a = _analyse(panel, stripping, rolling)
segs = report.load_segments(a.profile)
assert len(segs) == 3
assert segs[0][0] == 0.0
assert abs(segs[-1][1] - panel.length_ft) < 1e-9
for i in range(len(segs) - 1):
assert segs[i][1] == segs[i + 1][0]
assert segs[1][2] < segs[0][2] # the opening lightens the middle
# every value is one the profile actually holds
held = set(a.profile.w)
assert all(w in held for _x0, _x1, w in segs)
# ------------------------------------------------- 2 and 3. hover, and units
def test_the_diagrams_carry_their_samples_in_display_units(stripping,
rolling):
"""Shear in kip, moment in kip-ft, stress in psi, x in feet. Converted
once, here, and written onto the plot -- nothing converts in the browser.
"""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
units = re.findall(r'data-yu="([^"]*)" data-yl="([^"]*)"', page)
# 'fₜ' (real Unicode subscript), not 'f_t': this label lands in an <svg>
# <text> node and in the hover script's textContent, which parse a
# <sub> tag as four literal characters rather than a subscript -- see
# report.notate_plain().
assert units == [('kip', 'V'), ('kip-ft', 'M'), ('psi', 'fₜ')]
assert page.count('data-xu="ft"') == 3
shear_svg = _svg_of(page, 'Shear V(x)')
hy = re.search(r'data-hy="([^"]*)"', shear_svg).group(1).split()
peak_kip = max(abs(float(v)) for v in hy)
peak_lb = max(abs(v) for v in a.diagram.shear_lb)
assert abs(peak_kip - peak_lb / report.LB_PER_KIP) < 0.02
assert peak_kip < peak_lb # it really was divided
moment_svg = _svg_of(page, 'Moment M(x)')
hy = re.search(r'data-hy="([^"]*)"', moment_svg).group(1).split()
peak_kipft = max(abs(float(v)) for v in hy)
assert abs(peak_kipft -
max(abs(m) for m in a.diagram.moment_lbft)
/ report.LB_PER_KIP) < 0.2
assert all(len(v.split('.')[1]) == 1 for v in hy) # 1 dp, consistent
def test_every_hover_sample_has_a_pixel_and_a_value(stripping, rolling):
"""Four arrays of the same length, or the lookup is off by one somewhere
and the tooltip quietly reports the wrong station."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
for svg in re.findall(r'<svg[^>]*class="hoverplot"[^>]*>', page):
got = [len(re.search(k + r'="([^"]*)"', svg).group(1).split())
for k in ('data-hx', 'data-hy', 'data-px', 'data-py')]
assert len(set(got)) == 1 and got[0] > 10
assert re.search(r'data-t="([-0-9.]+)"', svg)
assert re.search(r'data-b="([-0-9.]+)"', svg)
def test_the_stations_are_feet_to_two_decimals(stripping, rolling):
a = _analyse(make_panel(), stripping, rolling)
svg = _svg_of(report.stress_plot(a), 'Tensile stress')
hx = re.search(r'data-hx="([^"]*)"', svg).group(1).split()
assert all(len(v.split('.')[1]) == 2 for v in hx)
assert float(hx[0]) >= 0.0
assert float(hx[-1]) <= round(a.panel.length_ft, 2) + 0.01
def test_the_script_is_present_exactly_once(stripping, rolling):
"""One document, one listener. build() nested inside build_many() must
not bring a second copy with it."""
a = _analyse(make_panel(), stripping, rolling)
found = checks.run_all(a, rolling)
one = report.build(a, rolling, found, recommendation=False)
assert one.count('<script>') == one.count('</script>') == 1
nested = report.build(a, rolling, found, recommendation=False,
standalone=False)
assert '<script>' not in nested
many = report.build_many([(a, rolling, found)],
recommendation=layout.not_searched())
assert many.count('<script>') == many.count('</script>') == 1
assert many.count("querySelectorAll('svg.hoverplot')") == 1
def test_a_page_with_no_script_still_shows_every_curve(stripping, rolling):
"""Hover is additive. Strip the script out and the report is exactly the
report it was -- same curves, same peak rules, same disclosures, and the
print overrides untouched. That is the no-JS and the printed case."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
without = page[:page.index('<script>')]
assert without.count('<svg') == without.count('</svg>') == 5
assert without.count('class="series"') == 3
assert 'kip-ft' in without and 'allowable' in without
assert 'details::details-content' in without # print override intact
assert '<details class="section">' in without # closed by default
assert '<details class="section" open' not in without
assert '.hoverlayer{display:none !important}' in without
def test_hover_values_are_the_values_that_were_plotted(stripping, rolling):
"""The samples on the <svg> are the samples the path was drawn from --
the same decimation, the same points. A tooltip reading off a different
series is a second, unreviewed calculation."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
svg = _svg_of(report.stress_plot(a), 'Tensile stress')
px = re.search(r'data-px="([^"]*)"', svg).group(1).split()
py = re.search(r'data-py="([^"]*)"', svg).group(1).split()
drawn = re.search(r'<path class="series" d="M ([^"]+)"', svg).group(1)
pairs = [seg.strip().split(',') for seg in drawn.split(' L ')]
assert len(pairs) == len(px)
for (dx, dy), hx, hy in zip(pairs, px, py):
assert abs(float(dx) - float(hx)) < 0.06
assert abs(float(dy) - float(hy)) < 0.06
def test_the_stations_are_monotonic_so_the_lookup_can_bisect(stripping,
rolling):
"""The script binary-searches data-px. It may do that only because the
stations come out of a sorted diagram."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
for svg in re.findall(r'<svg[^>]*class="hoverplot"[^>]*>', page):
px = [float(v) for v in
re.search(r'data-px="([^"]*)"', svg).group(1).split()]
assert px == sorted(px)
+208
View File
@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
"""Prestress, transfer length, and what a blockout does to both.
The numbers in `test_matches_a_commercial_check` are from an independent
run of the same piece through Eriksson Wall v5.2.0. That program was not
consulted while this was written -- its inputs were read off its own file and
its outputs off its own report -- so agreement to the last printed digit is
worth having, and disagreement is worth stopping for.
The piece: 38'-7.5" x 10'-7.5" x 8", one opening 111.25 x 40.25 set 16 in.
from one edge, twelve 1/2 in. 270k strands in two rows.
"""
import pytest
from panellift import checks, prestress, section
from panellift.model import Blockout, Panel, Prestress, Strand
PULL = 0.153 * 270_000 * 0.75 # 75% pull on a 1/2 in. 270k strand
POSITIONS_IN = [3.5, 16.5, 40.5, 59.5, 87.5, 109.5]
ROWS_IN = [1.75, 6.25]
def sample_panel(loss_pct=1.58, blockout=True):
strands = [Strand(y=y / 12.0, z_in=z, area_in2=0.153,
initial_pull_lb=PULL)
for z in ROWS_IN for y in POSITIONS_IN]
blockouts = []
if blockout:
blockouts.append(Blockout(name='OPENING 1', x0=330.0 / 12.0,
y0=16.0 / 12.0, length=111.25 / 12.0,
width=40.25 / 12.0))
return Panel(
mark='sample', length_ft=463.5 / 12.0, width_ft=127.5 / 12.0,
thickness_in=8.0, fc_psi=6000.0, fci_psi=3500.0,
blockouts=blockouts,
prestress=Prestress(strands=strands, loss_pct=loss_pct,
diameter_in=0.5))
# ------------------------------------------------------------------ transfer
def test_transfer_length_follows_the_effective_stress():
panel = sample_panel()
# f_se = 0.75 * 270 * (1 - 0.0158) = 199.3 ksi; lt = f_se * db / 3
assert prestress.transfer_length_in(panel.prestress) == \
pytest.approx(33.2, abs=0.1)
def test_a_strand_picks_its_force_up_over_the_transfer_length():
panel = sample_panel()
lt = prestress.transfer_length_in(panel.prestress) / 12.0
strand = panel.prestress.strands[0] # y = 3.5 in, never cut
assert prestress.developed(strand, panel, 0.0, lt) == 0.0
assert prestress.developed(strand, panel, lt / 2, lt) == pytest.approx(0.5)
assert prestress.developed(strand, panel, lt, lt) == pytest.approx(1.0)
assert prestress.developed(strand, panel, 10.0, lt) == pytest.approx(1.0)
def test_the_far_end_is_a_free_end_too():
panel = sample_panel()
lt = prestress.transfer_length_in(panel.prestress) / 12.0
strand = panel.prestress.strands[0]
assert prestress.developed(strand, panel, panel.length_ft, lt) == 0.0
assert prestress.developed(
strand, panel, panel.length_ft - lt / 2, lt) == pytest.approx(0.5)
# ------------------------------------------------------- blockouts cut strands
def test_a_blockout_cuts_only_the_strands_it_passes_over():
panel = sample_panel()
cut = [s for s in panel.prestress.strands if prestress.cuts_for(s, panel)]
# the opening spans y 16 -> 56.25 in, which is 16.5 and 40.5
assert len(cut) == 4
assert sorted(set(round(s.y * 12.0, 2) for s in cut)) == [16.5, 40.5]
def test_area_inside_the_opening_is_the_uncut_strands_only():
panel = sample_panel()
state = prestress.state_at(panel, 28.97) # mid-opening
assert state.area_in2 == pytest.approx(1.22, abs=0.01)
def test_area_ramps_down_approaching_the_cut():
"""Eriksson reports 1.84 / 1.75 / 1.33 / 1.22 at these four stations."""
panel = sample_panel()
for x_ft, expected in ((22.62, 1.84), (25.11, 1.75),
(27.04, 1.33), (27.50, 1.22)):
state = prestress.state_at(panel, x_ft)
assert state.area_in2 == pytest.approx(expected, abs=0.01), x_ft
def test_a_strand_the_opening_misses_keeps_its_force_through_it():
panel = sample_panel()
lt = prestress.transfer_length_in(panel.prestress) / 12.0
outer = [s for s in panel.prestress.strands
if round(s.y * 12.0, 2) == 109.5][0]
assert prestress.developed(outer, panel, 28.97, lt) == pytest.approx(1.0)
# ------------------------------------------------------------- net section
def test_an_off_centre_opening_moves_the_centroid():
panel = sample_panel()
net = section.NetSection(panel, 28.97)
assert net.area_in2 == pytest.approx(698.0, abs=1.0)
assert net.cy_in == pytest.approx(76.49, abs=0.05)
assert net.sz_in3 == pytest.approx(930.7, abs=1.0)
assert net.iy_in4 == pytest.approx(979_000, rel=0.005)
def test_a_solid_section_is_centred():
panel = sample_panel(blockout=False)
net = section.NetSection(panel, 19.0)
assert net.cy_in == pytest.approx(63.75, abs=0.01)
assert net.area_in2 == pytest.approx(1020.0, abs=0.5)
# ------------------------------------------------- the number that matters
def _corners(panel, x, moment_lbft):
net = section.NetSection(panel, x)
return [c[0] for c in checks._corner_stresses(panel, x, moment_lbft, net)]
def _corner_max(panel, x, moment_lbft):
return max(_corners(panel, x, moment_lbft))
def test_matches_a_commercial_check():
"""417.6 psi at 28.97 ft, from Eriksson Wall on the same piece.
Their factored service moment there is 48.17 k-ft. Feed the same moment
into the same section and the answer has to be the same, or one of us is
wrong about the mechanics rather than about the panel.
"""
panel = sample_panel(loss_pct=1.58)
got = _corner_max(panel, 28.97, 48_170.0)
assert got == pytest.approx(417.6, abs=2.0)
def test_without_prestress_the_same_piece_reads_far_higher():
"""The bug this whole feature exists to fix, kept as a number."""
panel = sample_panel()
bare = Panel(**{**panel.__dict__, 'prestress': None})
assert _corner_max(bare, 28.97, 48_170.0) == pytest.approx(621.0, abs=2.0)
def test_precompression_wipes_out_a_light_stage():
"""Our own stripping moment on this piece, which is larger than theirs.
23.83 k-ft of bending on the solid section against ~359 psi of
precompression: 210 psi of it goes, and the piece comes back well under
the limit instead of at 47% of it.
"""
panel = sample_panel()
assert _corner_max(panel, 10.94, 23_830.0) < 40.0
def test_precompression_and_compression_both_match_at_stripping():
"""Eriksson's stripping at 0.25L: tension 0.0 psi, compression 667.4.
Their moment there is 14.10 k-ft, not ours -- they idealise a static
lift and this idealises rolling blocks, so the two disagree about the
reactions before either says anything about stress. Feeding THEIR moment
in isolates the section from the rigging, and both faces then have to
agree, which is a stronger check than the tension alone: the tension is
zero either way once there is enough precompression, but the compression
is not, and it pins P/A and the eccentricity together.
"""
panel = sample_panel(loss_pct=1.58)
corners = _corners(panel, 9.66, 14_100.0)
assert max(corners) <= 0.0
assert -min(corners) == pytest.approx(667.4, abs=2.0)
def test_a_bigger_loss_gives_back_tension():
"""The loss assumption is a real lever, so it has to move the answer."""
lean = _corner_max(sample_panel(loss_pct=1.58), 28.97, 48_170.0)
fat = _corner_max(sample_panel(loss_pct=8.0), 28.97, 48_170.0)
assert fat > lean + 10.0
# --------------------------------------------------------- sign conventions
def _one_row_panel(z_in):
strands = [Strand(y=y / 12.0, z_in=z_in, initial_pull_lb=PULL)
for y in [15.0, 45.0, 75.0, 105.0]]
return Panel(mark='s', length_ft=30.0, width_ft=10.0, thickness_in=8.0,
fc_psi=6000.0,
prestress=Prestress(strands=strands, loss_pct=0.0))
def test_a_low_strand_helps_the_face_that_sagging_puts_in_tension():
"""Sagging puts tension on the form face, z = 0. A strand below mid-depth
hogs the piece and takes tension off that face; the same strand above
mid-depth adds to it.
Nothing in the commercial comparison can catch this: that piece has two
symmetric rows, so its eccentricity is exactly zero and the term this
tests does not exist. It was written wrong the first time and this is
what found it.
"""
low = _corners(_one_row_panel(1.0), 15.0, 20_000.0)[:2] # cz = -t/2
mid = _corners(_one_row_panel(4.0), 15.0, 20_000.0)[:2]
high = _corners(_one_row_panel(7.0), 15.0, 20_000.0)[:2]
assert max(low) < max(mid) < max(high)
+261
View File
@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
"""Blind recesses: carried through the job file, and taken into nothing.
THE DEFECT, fixed on the Revit side 2026-09-01
-----------------------------------------------
`liftexport._openings` reads rectangles off the side faces of the concrete
solid, and a blind pocket has side faces exactly like a hole does. The
exporter emitted every one of them as a `blockout`, and this package reads a
blockout as a void through the thickness: it takes that width out of the
section, CUTS every strand crossing it, and restarts development at its edges.
So a corner pocket 0.29 x 0.67 ft with 3.5 in of concrete behind it, a foot
from the end of a piece, severed fourteen strands that pass a clear 3.5 in
below and above it. `test_the_pocket_as_a_blockout_is_the_defect_itself`
measures what that did, and it is not subtle.
The exporter now splits them: `blockouts` are the through ones, `recesses` are
the blind ones with `concrete_in` and `depth_in`. This side reads the new key,
carries it on the model, counts it in the report -- and uses it in no
calculation whatever. That is an approximation and it is unconservative over
the pocket, so the report says so in a line of its own rather than leaving a
reader to assume the pocket was accounted for.
"""
import pytest
from panellift import capacity, cli, report
from panellift.model import Recess
RECT = {'x0': 11.2083, 'y0': 5.0, 'length': 0.2917, 'width': 0.6667}
# --------------------------------------------------------- reading the key
def test_a_recess_round_trips_through_the_job_file():
"""THE CONTRACT, from this side. The other half is
`WillTools.extension/tests/test_liftexport.py::
test_the_recess_keys_are_the_ones_panellift_reads` -- neither package can
import the other, so a rename under either is caught only by a pin at
each end."""
panel = cli.read_panel({
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
'thickness_in': 8.0,
'recesses': [dict(RECT, name='RECESS 1', concrete_in=3.5,
depth_in=4.5)],
})
assert panel.recesses == [Recess(name='RECESS 1', x0=11.2083, y0=5.0,
length=0.2917, width=0.6667,
concrete_in=3.5, depth_in=4.5)]
assert panel.blockouts == []
def test_a_recess_may_arrive_with_neither_depth_measured():
"""The exporter writes None where the geometry did not answer, and None
has to survive as None -- a 0 would read as a pocket of no depth."""
panel = cli.read_panel({
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
'thickness_in': 8.0,
'recesses': [dict(RECT, name='RECESS 1')],
})
assert panel.recesses[0].concrete_in is None
assert panel.recesses[0].depth_in is None
def test_a_job_file_from_before_the_split_still_reads():
"""Every job file written to date has no `recesses` key at all, including
the 184 of the reference sweep. Absent is an empty list here, and the
report says nothing about recesses on such a piece -- absence is never
rendered as a claim that it was looked for."""
panel = cli.read_panel({
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
'thickness_in': 8.0,
'blockouts': [dict(RECT, name='OPENING 1')],
})
assert panel.recesses == []
assert len(panel.blockouts) == 1
def test_a_recess_missing_a_dimension_is_refused_by_name():
"""Same treatment a blockout gets. A rectangle with no x0 is not a
rectangle at 0."""
with pytest.raises(cli.JobError) as excinfo:
cli.read_panel({
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
'thickness_in': 8.0,
'recesses': [{'name': 'RECESS 1', 'y0': 5.0, 'length': 0.3,
'width': 0.7}],
})
assert 'panel.recesses[0]' in str(excinfo.value)
assert 'x0' in str(excinfo.value)
def test_a_recess_is_turned_with_the_piece():
"""A panel erected on its long edge is transposed rather than taught
about orientation. A pocket left un-turned would be printed where the
piece does not have one."""
panel = cli.read_panel({
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
'thickness_in': 8.0,
'recesses': [dict(RECT, name='RECESS 1', concrete_in=3.5,
depth_in=4.5)],
})
turned = cli.transpose(panel)
assert (turned.recesses[0].x0, turned.recesses[0].y0) == (5.0, 11.2083)
assert (turned.recesses[0].length, turned.recesses[0].width) == \
(0.6667, 0.2917)
# Depth is through the panel either way, like the thickness.
assert turned.recesses[0].depth_in == 4.5
# --------------------------------------------------- WP128, the real shape
#
# 11.5 x 10.667 ft x 8 in, one 0.2917 x 0.6667 ft pocket at x0 11.2083 with
# 3.5 in of concrete behind it, fourteen 1/2 in strands in two layers at
# z 1.75 and 6.25 -- so the pocket is clear of every one of them. The piece is
# nearly square, so it trips ASPECT whatever else is true; that finding is
# unrelated and none of these tests reads the overall verdict.
def wp128_job(**panel_keys):
strands = ([{'y': 5.3333, 'z_in': 1.75, 'area_in2': 0.153,
'initial_pull_lb': 28920.0}] * 7 +
[{'y': 5.3333, 'z_in': 6.25, 'area_in2': 0.153,
'initial_pull_lb': 28920.0}] * 7)
def face(mark, x, y):
return {'mark': mark, 'family': 'TEST_LIFTER', 'x': x, 'y': y,
'capacity_lb': 8000.0, 'shear_capacity_lb': 8000.0,
'tension_fos': 4.0, 'shear_fos': 4.0}
def edge(mark, y):
return {'mark': mark, 'family': 'TEST_EDGE_ANCHOR', 'x': 11.5, 'y': y,
'capacity_lb': 16000.0, 'shear_capacity_lb': 16000.0,
'on_edge': True, 'pinned': True,
'tension_fos': 4.0, 'shear_fos': 4.0}
panel = {
'mark': 'WP128', 'length_ft': 11.5, 'width_ft': 10.6667,
'thickness_in': 8.0, 'unit_weight_pcf': 150.0, 'weight_lb': 12267.0,
'fc_psi': 6000.0, 'fci_psi': 3500.0,
'finish': 'smooth', 'product_type': 'flat',
'lifters': [face('L1', 2.1667, 2.0012), face('L2', 2.1667, 8.6678),
face('L3', 9.3333, 2.0012), face('L4', 9.3333, 8.6678),
edge('E1', 2.0), edge('E2', 8.6667)],
'prestress': {'diameter_in': 0.5, 'loss_pct': 8,
'transfer_length_in': None, 'strands': strands},
}
panel.update(panel_keys)
return {
'schema': 'panellift/1',
'panel': panel,
'rigging': {'mode': 'rolling', 'sling_angle_deg': 60.0,
'group_tolerance_ft': 0.4},
# Erection: the bottom face pair plus the two edge anchors in the end
# the piece stands on. This is the stage the phantom opening failed.
'stages': [{'name': 'erection', 'multiplier': 1.4, 'fc_psi': 6000.0,
'lifters': ['L1', 'L2', 'E1', 'E2']}],
}
THE_POCKET = dict(RECT, name='RECESS 1', concrete_in=3.5, depth_in=4.5)
THE_POCKET_AS_A_HOLE = dict(RECT, name='OPENING 1')
def erection(job):
"""(panel, capacity points, findings) for the one stage."""
panel, _rigging, cases = cli.analyse_job(job)
analysis, _rig, findings = cases[0]
return panel, capacity.profile_for(analysis), findings
def worst_flexural(points):
return max([p.m_over_phi_mn for p in points
if p.m_over_phi_mn is not None] or [None])
def test_the_pocket_carries_no_blockout_at_all():
panel, _points, _findings = erection(
wp128_job(recesses=[THE_POCKET]))
assert panel.blockouts == []
assert len(panel.recesses) == 1
def test_a_recess_restarts_no_strand_development():
"""The assertion this whole fixture exists for. Development restarts at a
free end, and a blockout edge is one; a pocket is not, and no station may
be measured from it."""
_panel, points, _findings = erection(wp128_job(recesses=[THE_POCKET]))
ends = sorted(set(p.free_end for p in points if p.free_end))
assert ends == ['the end of the piece']
def test_a_recess_is_arithmetically_identical_to_solid_concrete():
"""Not merely "does not fail" -- the same numbers, station for station.
That is what "carried and used in nothing" has to mean, and a check that
only looked at the verdict would pass while the section quietly moved."""
_panel, with_pocket, _f = erection(wp128_job(recesses=[THE_POCKET]))
_panel2, solid, _f2 = erection(wp128_job())
assert [p.x for p in with_pocket] == [p.x for p in solid]
assert [p.b_in for p in with_pocket] == [p.b_in for p in solid]
assert [p.m_over_phi_mn for p in with_pocket] == \
[p.m_over_phi_mn for p in solid]
def test_no_station_exceeds_its_flexural_capacity_on_erection():
"""The piece as its model really is: solid, with a pocket in a corner.
The reference sweep reported M/phi-Mn 1.04 on this piece and it was the
phantom hole, not the panel."""
_panel, points, findings = erection(wp128_job(recesses=[THE_POCKET]))
assert worst_flexural(points) < 1.0
assert [f for f in findings if f.code == 'FLEX-CAP' and
f.severity == 'FAIL'] == []
def test_the_pocket_as_a_blockout_is_the_defect_itself():
"""The before picture, kept so the fixture above is proving something.
One key moved and the same 0.29 x 0.67 ft rectangle severs every strand
it stands over, restarts their development at its edges a foot from the
end of the piece, and produces a flexural-capacity FAIL on a panel whose
model has no opening in it."""
_panel, points, findings = erection(
wp128_job(blockouts=[THE_POCKET_AS_A_HOLE]))
assert 'an opening edge, which cuts every strand it crosses' in \
[p.free_end for p in points]
assert worst_flexural(points) > 1.0
assert [f.code for f in findings if f.severity == 'FAIL'
and f.code == 'FLEX-CAP'] == ['FLEX-CAP']
# ------------------------------------------------------- what the page says
def one_page(job):
panel, rigging, cases = cli.analyse_job(job)
analysis, rig, findings = cases[0]
return report.build(analysis, rig, findings, recommendation=False)
def test_the_piece_summary_says_the_recess_is_not_in_the_numbers():
"""A stated approximation. The alternative is a page that counts the
pocket nowhere and mentions it nowhere, which is the silent omission this
codebase treats as the defect rather than the shortcut."""
page = one_page(wp128_job(recesses=[THE_POCKET]))
assert 'Recesses' in page
assert '1 blind recess recorded' in page
assert 'NOT reflected in the section properties or in strand development' \
in page
assert 'treats the piece as solid concrete there' in page
def test_the_count_is_the_count():
page = one_page(wp128_job(recesses=[
THE_POCKET, dict(THE_POCKET, name='RECESS 2', x0=0.5)]))
assert '2 blind recesses recorded' in page
def test_a_piece_with_no_recesses_says_nothing_about_them():
"""Absence is not a claim, and a row saying "Recesses: none" would be one
-- it would read as "we looked", on every job file written before the key
existed."""
page = one_page(wp128_job())
assert 'Recesses' not in page
assert 'blind recess' not in page
+395
View File
@@ -0,0 +1,395 @@
# -*- 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
+244
View File
@@ -0,0 +1,244 @@
# -*- coding: utf-8 -*-
"""tension_fos / shear_fos: a number that had no source, and its removal.
They divide a lifter's rated capacity -- `shear_fos` when the lifter sits on
an edge, `tension_fos` otherwise -- and only when `capacity_is_ultimate` is
true, i.e. only where the catalogue quotes an ultimate rather than a working
load. Nothing in this repository sets that flag, so the arithmetic is not
reached on any shipped path; it is reachable, it is public API, and what it
would have computed was wrong.
WHAT WAS WRONG. `shear_fos` was 2.67, as a `Rigging` default, as a
`read_rigging` default, as a `catalogue.safety_factors` entry and as what
`liftexport` wrote into every job file. No page published it, nobody typed
it, and the comment justifying it as "the manufacturer's 4:1 tension /
2.67:1 shear" was written after the constant and cited nothing. The one
datasheet in this repository for the parts it reached -- ALP catalog p53,
02/2024 -- heads its capacity table "Capacity in Concrete 4:1 SWL" over BOTH
load columns and prints shear equal to tension on all seven QLPD rows.
WHAT IT WOULD HAVE DONE. 32,000 lb ultimate / 2.67 = 11,985 lb allowed,
against a published SWL of 8,000. Half again as much load on an anchor as
the manufacturer permits, in the direction that passes one which would fail.
SO THE GLOBAL IS GONE, and it was not replaced by a global 4.0 -- that is
the same defect wearing the right number, because the next manufacturer's
insert would inherit a factor read off ALP's page. The factors are per
product in the catalogue and per insert in `Lifter`, and an insert with none
is NOT JUDGED: `INSERT-NO-FOS`, named, never defaulted.
"""
import re
from pathlib import Path
import pytest
from panellift import checks, cli, statics
from panellift.model import Lifter, Rigging, Stage
from conftest import make_panel
CATALOGUE = (Path(__file__).resolve().parents[2]
/ 'knowledge' / 'catalogue.yaml')
# This is the one file in this suite that is NOT self-contained -- it pins
# published catalogue rows against the real, tracked `knowledge/catalogue.yaml`
# rather than a fixture, on purpose (see the module docstring: this is a
# golden-value check against a page, not a recomputation). A knowledge-less
# distribution (tools/make_dist.py) ships no catalogue.yaml at all, so the
# question this file asks -- "does the catalogue publish X" -- has no
# catalogue to ask it of. SKIP, not FAIL: the same "could not judge" vs.
# "judged, found nothing" discipline the rest of this project uses, applied
# to a test file rather than a knowledge lookup.
pytestmark = pytest.mark.skipif(
not CATALOGUE.is_file(),
reason='knowledge/catalogue.yaml is not shipped in this checkout '
'(see knowledge/README.md) -- nothing to pin these golden values '
'against')
# The seven QuikLift disk-anchor rows of ALP catalog p53, 02/2024, typed in
# from the page: (part, tension SWL, shear SWL, ultimate tension).
# A pin against a written-down golden, not a recomputation.
PAGE_53 = [
('QLPD2T3WG', 4000, 4000, 16000),
('QLPD4T3WG', 6257, 6257, 32000),
('QLPD4T4WG', 7200, 7200, 32000),
('QLPD4T5WG', 8000, 8000, 32000),
('QLPD8T6WG', 11019, 11019, 64000),
('QLPD8T7WG', 14640, 14640, 64000),
('QLPD8T8WG', 16000, 16000, 64000),
]
def catalogue_text():
return CATALOGUE.read_text(encoding='utf-8')
def entry_text(part):
"""One `catalogue.inserts` entry, as raw YAML text.
Read with a regex rather than a parser: this package has no YAML
dependency and is not going to grow one to check a handful of numbers.
"""
block = re.search(r'\n %s:\n((?: \S.*\n| .*\n)+)' % part,
catalogue_text())
assert block, 'catalogue.yaml has no entry for {0}'.format(part)
return block.group(1)
def field(part, name):
found = re.search(r'^\s+%s:\s*([-\d.]+)\s*$' % name, entry_text(part),
re.M)
return float(found.group(1)) if found else None
# ------------------------------------------------------------------ the page
@pytest.mark.parametrize('part,tension,shear,_ultimate', PAGE_53)
def test_the_catalogue_publishes_four_to_one_in_shear_for_every_qlpd_row(
part, tension, shear, _ultimate):
"""The page's own heading, landed as data on the part it belongs to."""
text = entry_text(part)
assert 'safety_factors:' in text, (
'{0} carries no safety_factors block, so an ultimate rating for it '
'has no published factor and it will be reported unjudged'
.format(part))
assert field(part, 'tension_fos') == 4.0
assert field(part, 'shear_fos') == 4.0, (
'ALP catalog p53 heads the capacity table "Capacity in Concrete 4:1 '
'SWL" across BOTH load columns, so shear is 4:1 here too. 2.67 is '
'the number this test exists to keep out.')
assert 'p53' in text and '02/2024' in text, (
'a safety factor with no page beside it is how 2.67 survived for '
'twelve days')
# ...and the working loads it is a factor on are still the page's.
assert field(part, 'working_load_lb') == tension
assert field(part, 'shear_load_lb') == shear
def test_no_global_safety_factor_survives_anywhere():
"""A tombstone. The failure was not the value 2.67 but the SHAPE that
let one number apply to every manufacturer's insert, so replacing it
with a global 4.0 would have been the same defect with a better number.
"""
block = re.search(r'\n safety_factors:\n((?: \w+:.*\n| .*\n)+)',
catalogue_text())
assert block, 'catalogue.yaml has no catalogue.safety_factors block'
assert re.search(r'^ tension_fos: null\s*$', block.group(1), re.M)
assert re.search(r'^ shear_fos: null\s*$', block.group(1), re.M)
assert Rigging().tension_fos is None
assert Rigging().shear_fos is None
assert cli.read_rigging({}).tension_fos is None
assert cli.read_rigging({}).shear_fos is None
assert Lifter(mark='m', family='f', x=0.0, y=0.0).shear_fos is None
# 2.67 may still be NAMED in the catalogue -- the comment that records
# what it was and why it went is the provenance, and deleting the number
# would leave the next reader to rediscover it from the same page. What
# may not come back is 2.67 as a VALUE.
live = re.findall(r'^\s*\w*fos:\s*2\.67\s*$', catalogue_text(), re.M)
assert not live, live
def test_the_ultimate_route_overshoots_the_published_swl_even_at_four_to_one():
"""Why `capacity_is_ultimate` stays false for these parts.
The page shades four of its seven rows and says the shading marks where
the anchor's own mechanical capacity governs -- so the printed SWL is
the LESSER of ultimate/4 and what the concrete holds. Reading an
ultimate through the right factor is still unconservative on those four,
which is a fact about the route and not about the factor, and it is here
so that nobody "fixes" the factor and then turns the flag on.
"""
over = [(part, ultimate / 4.0, tension)
for (part, tension, _shear, ultimate) in PAGE_53
if ultimate / 4.0 > tension + 0.5]
assert [p for (p, _a, _b) in over] == [
'QLPD4T3WG', 'QLPD4T4WG', 'QLPD8T6WG', 'QLPD8T7WG']
assert max(a / b for (_p, a, b) in over) > 1.4 # QLPD8T6WG, +45%
# ------------------------------------------------------- what the check does
def _panel_with(factor_kind, factor):
"""A rating and a factor on ONLY the axis under test.
2026-09-02: check_insert_capacity now decomposes the sling's line force
into a tension and a shear share and judges both, for every lifter --
`on_edge` no longer picks a single axis to check. These tests are about
factor resolution on one column, not about the decomposition, so the
OTHER column is left with no capacity at all (rated() returns None,
joining INSERT-UNRATED, never INSERT-NO-FOS) rather than a rating with
no factor of its own, which would otherwise also land in
INSERT-NO-FOS and be mistaken for the thing under test.
"""
panel = make_panel()
lifters = [
Lifter(mark=l.mark, family=l.family, x=l.x, y=l.y, role=l.role,
capacity_lb=32000.0 if factor_kind == 'tension' else None,
shear_capacity_lb=32000.0 if factor_kind == 'shear' else None,
on_edge=(factor_kind == 'shear'),
**{factor_kind + '_fos': factor})
for l in panel.lifters]
return dataclass_replace_lifters(panel, lifters)
def dataclass_replace_lifters(panel, lifters):
import dataclasses
return dataclasses.replace(panel, lifters=lifters)
def _findings(panel, stage, ultimate=True):
rigging = Rigging(mode='rolling', sling_angle_deg=60.0)
analysis = statics.analyse(panel, stage, rigging, reconcile_weight=False)
return checks.check_insert_capacity(analysis, rigging, ultimate)
def test_an_insert_with_no_published_factor_is_named_not_defaulted(stripping):
"""The whole point. An ultimate rating and no factor used to become an
allowed load through a constant; it now becomes a sentence."""
out = _findings(_panel_with('shear', None), stripping)
codes = [f.code for f in out]
assert 'INSERT-NO-FOS' in codes
note = [f for f in out if f.code == 'INSERT-NO-FOS'][0]
assert note.severity == 'WARN'
assert 'NOT judged' in note.message
assert 'borrowed' in note.message
# and no insert was judged behind its back
assert not [f for f in out if f.code == 'INSERT']
def test_the_page_factor_gives_exactly_the_published_working_load(stripping):
"""32,000 / 4 = 8,000, which is what p53 prints for QLPD4T5WG. The old
2.67 gave 11,985 for the same insert -- allowing nearly half again as
much shear as the manufacturer's own page. Both numbers appear in the
message (2026-09-02: on the SHEAR clause specifically, since the check
now decomposes the line force rather than comparing it whole), so this
compares them."""
with_page = _findings(_panel_with('shear', 4.0), stripping)
with_2_67 = _findings(_panel_with('shear', 2.67), stripping)
page_msg = ' '.join(f.message for f in with_page if f.code == 'INSERT')
old_msg = ' '.join(f.message for f in with_2_67 if f.code == 'INSERT')
assert 'shear' in page_msg
assert 'vs 8,000 lb' in page_msg or 'vs 8000 lb' in page_msg
assert page_msg != old_msg
assert '11,98' in old_msg or '11985' in old_msg
def test_the_insert_s_own_factor_beats_the_job_wide_one(stripping):
"""Resolution order, and the direction that matters: a job file naming
2.67 must not override what the part's datasheet publishes."""
panel = _panel_with('shear', 4.0)
rigging = Rigging(mode='rolling', sling_angle_deg=60.0, shear_fos=2.67)
analysis = statics.analyse(panel, stripping, rigging,
reconcile_weight=False)
out = checks.check_insert_capacity(analysis, rigging, True)
assert not [f for f in out if f.code == 'INSERT-NO-FOS']
msg = ' '.join(f.message for f in out if f.code == 'INSERT')
assert '11,98' not in msg and '11985' not in msg
def test_a_working_load_reading_never_touches_a_factor(stripping):
"""The default path, which is every shipped path: with the ratings read
as working loads the factors are not applied at all, so an insert with
none is judged normally and no INSERT-NO-FOS is raised."""
out = _findings(_panel_with('shear', None), stripping, ultimate=False)
assert not [f for f in out if f.code == 'INSERT-NO-FOS']
assert [f for f in out if f.code == 'INSERT']
+594
View File
@@ -0,0 +1,594 @@
# -*- coding: utf-8 -*-
"""Insulated (sandwich) panels: the refusal, and the section it refuses over.
Two kinds of test here and they are kept apart on purpose.
**The mechanics** are checked against arithmetic done by hand, written out in
a comment beside every number, on the dimensions of a real 16 in. insulated
piece. A symmetric-enough sandwich has closed-form section properties and
there is no excuse for a test that recomputes the code's own expression --
a pin that recomputes both sides pins nothing and agrees with any refactor,
including a wrong one.
**The refusal** is pinned as behaviour: which job files trip it, which do not,
that it produces no report, and that it never returns one of OK/WARN/FAIL.
THE PIECE THE FIXTURE IS. Measured on the live model 2026-08-31: envelope
16.0 in, 4 in. EPS between the wythes, two concrete solids of 79.885 ft3 and
39.557 ft3, both at 150.28 pcf, length 27.94 ft, width across 5.33 ft.
the wythes are in the ratio 79.885 / 39.557 = 2.0195
they share 16 - 4 = 12 in. of concrete
12 * 2.0195 / 3.0195 = 8.025 -> 8 in. structural
12 - 8 = 4 -> 4 in. architectural
so the layup is 4 in. concrete + 4 in. EPS + 8 in. concrete = 16 in., which
is a textbook layup and is what the fixture below is built from. Everything
is worked at b = 64 in. (the 5.33 ft width, no opening), because a clean
width makes the hand arithmetic checkable by eye.
"""
import json
import pytest
from panellift import cli, report, sandwich
# The layup of the real piece, and the width it is worked at.
T_ARCH = 4.0
T_STRUCT = 8.0
ENVELOPE = 16.0
B = 64.0
def wp_layup():
"""4 in. concrete, 4 in. EPS, 8 in. concrete, form face at z = 0."""
return sandwich.Layup(
layers=[
sandwich.Layer(sandwich.CONCRETE, 0.0, 4.0, 'ARCH WYTHE',
150.28, 39.557),
sandwich.Layer(sandwich.NOT_CONCRETE, 4.0, 8.0, 'EPS', 1.5, 39.8),
sandwich.Layer(sandwich.CONCRETE, 8.0, 16.0, 'STRUCT WYTHE',
150.28, 79.885),
],
envelope_in=ENVELOPE, measured=True, why='test fixture')
def sandwich_source(positioned=True):
"""`source` as the exporter writes it for this piece."""
parts = [
{'volume_ft3': 79.885, 'material': 'STRUCT WYTHE',
'density_pcf': 150.28, 'counted': True},
{'volume_ft3': 39.557, 'material': 'ARCH WYTHE',
'density_pcf': 150.28, 'counted': True},
{'volume_ft3': 39.8, 'material': 'EPS', 'density_pcf': 1.5,
'counted': False},
]
if positioned:
for part, (z0, z1) in zip(parts, [(8.0, 16.0), (0.0, 4.0),
(4.0, 8.0)]):
part['z0_in'], part['z1_in'] = z0, z1
return {'solid_parts': parts, 'envelope_thickness_in': ENVELOPE,
'solid_basis': 'material density'}
# ==================================================================== section
#
# Every expected number below is arithmetic, written out, on b = 64 in.
def test_non_composite_inertia_is_the_sum_about_each_wythes_own_axis():
# I = b*t1^3/12 + b*t2^3/12
# = 64*4^3/12 + 64*8^3/12
# = 64*64/12 + 64*512/12 = 341.3333 + 2730.6667 = 3072.0 in^4
bound = sandwich.non_composite(B, wp_layup())
assert bound.i_in4 == pytest.approx(3072.0, abs=1e-9)
def test_non_composite_section_modulus_is_governed_by_the_thicker_wythe():
# Wythes tied by the connectors deflect together, so each takes the
# moment in proportion to its own stiffness:
# M_i = M * I_i / sum(I), f_i = M_i*(t_i/2)/I_i = M*(t_i/2)/sum(I)
# so the extreme fibre stress is set by the THICKEST wythe:
# S = sum(I) / (t_max/2) = 3072 / 4 = 768.0 in^3
# and the 4 in. wythe's own fibres are at 3072/2 = 1536 in^3.
bound = sandwich.non_composite(B, wp_layup())
assert bound.s_governing_in3 == pytest.approx(768.0, abs=1e-9)
moduli = sorted(set(round(f.s_in3, 6) for f in bound.fibres))
assert moduli == [768.0, 1536.0]
def test_fully_composite_neutral_axis_and_inertia():
# A1 = 64*4 = 256 in^2 at z = 2.0; A2 = 64*8 = 512 in^2 at z = 12.0
# zbar = (256*2 + 512*12)/768 = (512 + 6144)/768 = 8.666667 in
# I = 64*4^3/12 + 256*(2 - 8.666667)^2
# + 64*8^3/12 + 512*(12 - 8.666667)^2
# = 341.3333 + 11377.7778 + 2730.6667 + 5688.8889 = 20138.6667 in^4
bound = sandwich.fully_composite(B, wp_layup())
assert bound.axis_z_in == pytest.approx(8.6666667, abs=1e-6)
assert bound.i_in4 == pytest.approx(20138.6667, abs=1e-3)
def test_fully_composite_section_moduli_at_both_faces():
# form face c = 8.666667 in -> S = 20138.6667/8.666667 = 2323.6923
# far face c = 7.333333 in -> S = 20138.6667/7.333333 = 2746.1818
bound = sandwich.fully_composite(B, wp_layup())
by_label = dict((f.label, f.s_in3) for f in bound.fibres)
assert by_label['form face'] == pytest.approx(2323.6923, abs=1e-3)
assert by_label['far face'] == pytest.approx(2746.1818, abs=1e-3)
assert bound.s_governing_in3 == pytest.approx(2323.6923, abs=1e-3)
def test_structural_wythe_alone_is_the_smallest_section_of_the_three():
# The conservative reading of "is the face wythe structural": the 8 in.
# wythe alone, S = b*t^2/6 = 64*64/6 = 682.6667 in^3.
bound = sandwich.structural_wythe_alone(B, wp_layup())
assert bound.i_in4 == pytest.approx(2730.6667, abs=1e-3)
assert bound.s_governing_in3 == pytest.approx(682.6667, abs=1e-3)
def test_the_solid_idealisation_is_unconservative_against_BOTH_bounds():
"""The whole reason this work exists, as a number.
solid at 16 in.: I = 64*16^3/12 = 21845.333, S = 64*256/6 = 2730.667
against fully composite 2730.667/2323.692 = 1.1751
against non-composite 2730.667/768.0 = 3.5556
Both above 1.0 -- so there is no reading of a sandwich under which
treating it as solid concrete at the envelope depth is the safe
direction. That is the owner's "把夹芯板当实心算并不 conservative", and
if this assertion ever flips the refusal has lost its argument.
"""
bounds = sandwich.section_bounds(wp_layup(), B)
assert bounds.solid.i_in4 == pytest.approx(21845.3333, abs=1e-3)
assert bounds.solid.s_governing_in3 == pytest.approx(2730.6667, abs=1e-3)
against_composite, against_non = bounds.solid_overstates_by
assert against_composite == pytest.approx(1.1751, abs=1e-4)
assert against_non == pytest.approx(3.5556, abs=1e-4)
assert against_composite > 1.0 and against_non > 1.0
def test_shear_flow_is_the_same_from_either_wythe_as_equilibrium_requires():
# Q(bottom) = 256*(8.666667 - 2.0) = 256*6.666667 = 1706.6667 in^3
# Q(top) = 512*(12.0 - 8.666667) = 512*3.333333 = 1706.6667 in^3
# q/V = Q/I = 1706.6667/20138.6667 = 0.0847458 per inch
layup = wp_layup()
comp = sandwich.fully_composite(B, layup)
flow = sandwich.shear_flow(comp, layup, B)
assert flow.q_in3 == pytest.approx(1706.6667, abs=1e-3)
assert flow.per_lb_of_shear == pytest.approx(0.0847458, abs=1e-7)
top = B * T_STRUCT * abs(comp.axis_z_in - 12.0)
assert top == pytest.approx(flow.q_in3, abs=1e-6)
def test_connector_demand_at_the_measured_spacing():
# The design summary says HK ST100'S @ 1'-4" o.c., and the model has
# 5 rows across the width. Per connector, per pound of shear:
# 0.0847458 * 16.0 / 5 = 0.271186 lb
layup = wp_layup()
flow = sandwich.shear_flow(sandwich.fully_composite(B, layup), layup, B,
v_lb=10000.0)
demand = sandwich.connector_demand(flow, spacing_in=16.0, per_row=5)
# q = 0.0847458 * 10000 = 847.458 lb/in; *16/5 = 2711.86 lb
assert demand.per_connector_lb == pytest.approx(2711.864, abs=1e-2)
# And with no capacity anywhere it is a DEMAND, never a verdict.
assert demand.capacity_lb is None
assert demand.verdict is None
assert 'NOT KNOWN' in demand.why
def test_a_demand_with_no_flow_stands_down_rather_than_reading_zero():
demand = sandwich.connector_demand(None, spacing_in=16.0, per_row=5)
assert demand.per_connector_lb is None
assert demand.verdict is None
# ================================================================= detection
def test_no_solid_parts_means_could_not_tell_and_never_solid():
said = sandwich.detect({})
assert said.sandwich is None
assert said.sandwich is not False
assert 'solid_parts' in said.why
def test_one_concrete_solid_and_nothing_left_out_is_a_solid_panel():
said = sandwich.detect({'solid_parts': [
{'volume_ft3': 20.0, 'material': 'CONC', 'density_pcf': 150.0,
'counted': True}]})
assert said.sandwich is False
def test_a_solid_left_out_as_not_concrete_is_a_sandwich():
said = sandwich.detect(sandwich_source())
assert said.sandwich is True
assert said.basis == 'solid_parts'
assert 'EPS' in said.why
def test_two_concrete_solids_with_nothing_left_out_still_refuses():
"""Over-refusing costs an answer; under-refusing prints a third of the
real stress. Nothing in the file can tell a wythe pair from a panel and a
ledge, so the safe direction is taken and said out loud."""
said = sandwich.detect({'solid_parts': [
{'volume_ft3': 79.9, 'material': 'CONC', 'counted': True},
{'volume_ft3': 39.6, 'material': 'CONC', 'counted': True}]})
assert said.sandwich is True
assert 'ledge' in said.why
def test_solid_parts_without_positions_knows_it_is_a_sandwich_and_no_more():
said = sandwich.detect(sandwich_source(positioned=False))
assert said.sandwich is True
assert said.layup.measured is False
assert said.layup.complete is False
bounds = sandwich.section_bounds(said.layup, B)
assert bounds.usable is False
assert 'z0_in' in bounds.why
def test_a_layup_that_was_not_measured_computes_no_section():
"""The state a job file written before the exporter measured positions is
in. It must refuse, not invent a layup that would look ordinary."""
bounds = sandwich.section_bounds(
sandwich.layup_from_source(sandwich_source(positioned=False)), B)
assert bounds.usable is False
assert bounds.non_composite is None
assert bounds.composite is None
# ========================================================== composite ratio
def test_the_composite_ratio_stands_down_and_names_both_definitions():
"""A computed ratio is impossible without connector data -- not hard,
impossible -- and an unlabelled percentage is a number nobody can check.
Two definitions, both named, both None."""
ratio = sandwich.composite_ratio(
sandwich.non_composite(B, wp_layup()),
sandwich.fully_composite(B, wp_layup()))
assert ratio.stiffness is None
assert ratio.strength is None
assert 'I_eff' in ratio.definition_stiffness
assert 'DEFLECTION' in ratio.definition_stiffness
assert 'STRENGTH' in ratio.definition_strength
# The two bounding inertias ARE printed: the width of the band is on the
# page even though the position in it is not.
assert ratio.i_non_composite_in4 == pytest.approx(3072.0)
assert ratio.i_full_composite_in4 == pytest.approx(20138.6667, abs=1e-3)
# And what would unblock each is stated per number, not as one shrug.
joined = ' '.join(ratio.unblocked_by)
assert 'stiffness' in joined and 'failure mode' in joined
def test_the_ratio_never_prints_a_percentage_anywhere_in_its_own_text():
ratio = sandwich.composite_ratio()
assert '%' not in ratio.why
# ================================================================== concerns
def test_the_two_bounds_bracket_flexure_and_shear_flow_and_nothing_else():
"""可能出现的问题, enumerated. Exactly three quantities are bracketed;
everything else about a sandwich panel is not a bounds question, and a
reader who takes the two-bound report as an envelope over all of it is
the failure this list exists to prevent."""
items = sandwich.concerns(wp_layup())
bracketed = [c.title for c in items if c.bracketed]
assert bracketed == ['wythe flexural tension', 'interface shear flow',
'deflection and bowing']
not_bracketed = [c.title for c in items if not c.bracketed]
assert 'connector force distribution along the span' in not_bracketed
assert 'connector NORMAL (tension) demand' in not_bracketed
assert ('local flexure of the face wythe between connectors'
in not_bracketed)
def test_unequal_wythes_get_their_own_concern():
items = [c.title for c in sandwich.concerns(wp_layup())]
assert 'the wythes are unequal' in items
def test_equal_wythes_do_not():
even = sandwich.Layup(
layers=[sandwich.Layer(sandwich.CONCRETE, 0.0, 6.0),
sandwich.Layer(sandwich.NOT_CONCRETE, 6.0, 8.0),
sandwich.Layer(sandwich.CONCRETE, 8.0, 14.0)],
envelope_in=14.0, measured=True)
assert 'the wythes are unequal' not in [c.title
for c in sandwich.concerns(even)]
def test_the_sign_flip_at_the_inner_face_is_written_down():
"""The trap a two-fibre check misses: the inner face of the outer wythe
is in compression non-composite and in tension fully composite, so it
changes sign between the bounds."""
layup = wp_layup()
nc = sandwich.non_composite(B, layup)
fc = sandwich.fully_composite(B, layup)
# M = 1,000,000 lb-in, sagging.
m = 1.0e6
# non-composite, the 4 in. wythe's own top fibre at z = 4:
# f = -M*(+2.0)/3072 = -651.0 psi, COMPRESSION
inner_nc = -m * (T_ARCH / 2.0) / nc.i_in4
# fully composite at z = 4, which is 4.6667 in BELOW the axis:
# f = -M*(4 - 8.666667)/20138.667 = +231.7 psi, TENSION
inner_fc = -m * (4.0 - fc.axis_z_in) / fc.i_in4
assert inner_nc < 0.0 and inner_fc > 0.0
said = [c.sentence for c in sandwich.concerns(layup)
if c.title == 'wythe flexural tension'][0]
assert 'changes sign between the bounds' in said
# ================================================================== refusal
def sandwich_job():
"""A real-shaped job file for an insulated piece, layup MEASURED.
Deliberately an OTHERWISE VALID job: everything here analyses fine, on the
CONCRETE-WYTHE composite section (insulation excluded) that `section.py`
now builds. Before 2026-09-02 this was refused outright; the point of the
refusal was never "a sandwich cannot be analysed", it was "a sandwich must
not be analysed as one SOLID prism". Now that it is not, a measured
sandwich runs.
"""
from test_cli import aspire_job
job = aspire_job()
job['source'] = sandwich_source()
job['panel']['thickness_in'] = ENVELOPE
return job
def unmeasured_sandwich_job():
"""The same piece, but the exporter could not measure the layup positions.
This is the makeup that STILL refuses: without z0/z1 there is no centroid,
no I and no d_v, so there is no section to build and no solid guess is
allowed to stand in for one.
"""
from test_cli import aspire_job
job = aspire_job()
job['source'] = sandwich_source(positioned=False)
job['panel']['thickness_in'] = ENVELOPE
return job
def test_a_measured_sandwich_now_analyses_instead_of_being_refused():
"""The defect this closes: a measured sandwich must be analysed, and on
the concrete-wythe composite section, not refused and not treated solid."""
from panellift import section
panel, _rig, cases = cli.analyse_job(sandwich_job())
assert len(cases) >= 1
net = section.NetSection(cases[0][0].panel, panel.length_ft / 2.0)
assert net.is_sandwich is True
# the section is the concrete only: 4 + 8 = 12 in of concrete, NOT the
# 16 in envelope, and the shear depth follows the concrete.
assert net.shear_thickness_in == pytest.approx(T_ARCH + T_STRUCT)
assert net.thickness_in == pytest.approx(ENVELOPE) # envelope still drawn
# composite centroid off mid-depth, so the two faces have unequal S.
assert net.zbar_in == pytest.approx(8.6666667, abs=1e-6)
assert net.c_form_in != pytest.approx(net.c_far_in)
# and it is NOT the solid idealisation: area is both wythes, insulation out
b_in = panel.width_ft * 12.0
assert net.area_in2 == pytest.approx(b_in * (T_ARCH + T_STRUCT), abs=1e-6)
def test_a_measured_sandwich_writes_a_report_and_does_not_exit_two(tmp_path):
path = tmp_path / 'job.json'
out = tmp_path / 'out.html'
path.write_text(json.dumps(sandwich_job()), encoding='utf-8')
code = cli.main([str(path), '-o', str(out)])
assert code in (0, 1) # a verdict, not a refusal (2)
assert out.exists()
def test_an_unmeasured_sandwich_still_refuses_before_anything_is_calculated():
with pytest.raises(cli.SandwichSection) as caught:
cli.analyse_job(unmeasured_sandwich_job())
message = str(caught.value)
assert sandwich.REFUSAL_CODE in message
assert 'overstates the section modulus' in message
def test_the_refusal_is_a_joberror_so_every_existing_caller_still_refuses():
"""`tools/sweep.py`, the button and the CLI all catch JobError. A new
exception class they have never heard of would escape as a traceback."""
assert issubclass(cli.SandwichSection, cli.JobError)
with pytest.raises(cli.JobError):
cli.analyse_job(unmeasured_sandwich_job())
def test_the_refusal_carries_the_evidence_it_can_still_give(tmp_path):
"""An unmeasured layup cannot be bounded, but the refusal still says WHY
-- the missing positions -- rather than one bare word."""
with pytest.raises(cli.SandwichSection) as caught:
cli.analyse_job(unmeasured_sandwich_job())
bounds = caught.value.bounds
assert bounds.usable is False
assert 'z0_in' in bounds.why
def test_the_cli_writes_no_report_and_exits_two_on_an_unmeasured_sandwich(
tmp_path):
path = tmp_path / 'job.json'
out = tmp_path / 'out.html'
path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8')
code = cli.main([str(path), '-o', str(out)])
assert code == 2
assert not out.exists()
def test_the_refusal_json_never_says_ok_warn_or_fail(tmp_path, capsys):
path = tmp_path / 'job.json'
out = tmp_path / 'out.html'
path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8')
assert cli.main([str(path), '-o', str(out), '--json']) == 2
payload = json.loads(capsys.readouterr().out)
assert payload['verdict'] == 'REFUSED'
assert payload['verdict'] not in ('OK', 'WARN', 'FAIL')
assert payload['complete'] is False
assert payload['composite_ratio']['stiffness'] is None
assert payload['connector_sufficiency']['verdict'] is None
def test_the_refusal_text_prints_the_evidence_not_just_the_word_no():
detection = sandwich.detect(sandwich_source())
bounds = sandwich.section_bounds(detection.layup, B)
text = sandwich.refusal_text(bounds, detection, 'PNL115', 16.0, 5)
assert 'NO STAGE WAS ANALYSED' in text
assert 'non-composite' in text and 'fully composite' in text
assert 'NOT COMPUTED' in text # the composite ratio
assert 'NOT CHECKED' in text # connector sufficiency
assert 'NOT BRACKETED' in text
# every bound's assumption travels with its numbers
assert 'no shear is transferred across the insulation' in text
def test_the_refusal_text_survives_a_layup_it_could_not_measure():
detection = sandwich.detect(sandwich_source(positioned=False))
bounds = sandwich.section_bounds(detection.layup, B)
text = sandwich.refusal_text(bounds, detection, 'PNL115')
assert 'could not be bounded' in text
assert 'NOT BRACKETED' in text
# ============================================== the solid path does not move
SOLID_GOLDEN = {
# Typed in, not recomputed. A pin that recomputes both sides agrees with
# any refactor, including a wrong one -- the rule the takeoff count was
# pinned under. These are the ASPIRE four-point pick as it stood before
# any of the sandwich work.
'verdict': 'WARN',
'complete': True,
'multiplier': 1.3,
'fc_psi': 4000.0,
'peak_tension_psi': 244.71194093343502,
'peak_at_ft': 15.992,
'allowable_psi': 316.2277660168379,
'ratio': 0.7738471039902456,
'as_built_ratio': 0.7738471039902419,
'recommended_ratio': 0.7682552280488836,
}
def test_a_solid_panel_is_byte_for_byte_the_number_it_always_was():
from test_cli import aspire_job
_panel, _rig, cases = cli.analyse_job(aspire_job())
summary = cli.summarise(cases)
stage = summary['stages'][0]
assert summary['verdict'] == SOLID_GOLDEN['verdict']
assert summary['complete'] is SOLID_GOLDEN['complete']
for key in ('multiplier', 'fc_psi', 'peak_tension_psi', 'peak_at_ft',
'allowable_psi', 'ratio'):
assert repr(stage[key]) == repr(SOLID_GOLDEN[key]), key
rec = summary['recommendation']
assert repr(rec['as_built_ratio']) == repr(SOLID_GOLDEN['as_built_ratio'])
assert repr(rec['recommended_ratio']) == \
repr(SOLID_GOLDEN['recommended_ratio'])
def test_a_solid_job_gains_no_new_summary_keys():
"""`basis` is written only when the up-end rule did NOT answer, the same
"only when it is not the ordinary case" rule `searched` and `enabled`
follow. A job file with no source block must summarise exactly as it did
before this existed."""
from test_cli import aspire_job
_panel, _rig, cases = cli.analyse_job(aspire_job())
assert sorted(cli.summarise(cases)) == [
'complete', 'not_run', 'recommendation', 'stages', 'verdict']
# ==================================================== which end is up (G2)
def test_the_up_end_fallback_is_disclosed_on_the_page_and_in_the_summary():
"""The refusal `knowledge.up_end_from_origin` makes is the project's
flagship declared-coverage refusal, and until now `erected_orientation`
could override it with a fallback whose reason nothing read. If the
best-argued refusal in the tree can be undone by an `or` in its caller,
the idiom is a comment."""
from test_cli import aspire_job
job = aspire_job()
job['source'] = {
'up_end_basis': 'global elevation',
'up_end_basis_why': 'assembly X is not horizontal, so the rule '
'stands down. Which end is up was therefore read '
'from GLOBAL ELEVATION instead.'}
_panel, _rig, cases = cli.analyse_job(job)
summary = cli.summarise(cases)
assert summary['basis']['basis'] == 'global elevation'
assert 'stands down' in summary['basis']['why']
html = report.build_many(cases)
# Shortened 2026-09-01 (owner: "把这一段从lift的结果里面删掉,或者缩短
# ...太长了压根不会看的") -- the always-visible line states the fact in
# a few words; the producer's own reasoning (which still names GLOBAL
# ELEVATION) is folded, not deleted.
assert 'read from global elevation, not the assembly origin' in html
assert 'GLOBAL ELEVATION' in html
# and it says which stage it actually decides
assert 'erection' in html
def test_the_ruled_basis_is_not_shouted_about():
"""A page that shouts about a rule that worked teaches the reader to skip
the block on the day it did not."""
from test_cli import aspire_job
job = aspire_job()
job['source'] = {'up_end_basis': report.RULED_UP_END_BASIS,
'up_end_basis_why': 'the origin answered'}
_panel, _rig, cases = cli.analyse_job(job)
assert 'basis' not in cli.summarise(cases)
assert 'came from a fallback' not in report.build_many(cases)
def test_the_job_level_source_actually_reaches_the_panel():
"""It did not, for the whole life of the exporter: `read_panel` looks for
`source` inside `panel` and the exporter writes it at job level, so
`panel.source` came out empty on every real job file and everything the
Revit side recorded was unreadable from here."""
from test_cli import aspire_job
job = aspire_job()
job['source'] = {'up_end_basis': 'global elevation', 'model': 'sample_job'}
panel, _rig, _cases = cli.analyse_job(job)
assert panel.source['up_end_basis'] == 'global elevation'
def test_transpose_still_wins_over_the_job_source_for_its_own_key():
"""`transpose` writes `prestress_not_credited` about the TURNED copy;
a job-level key of the same name must not overwrite it."""
from panellift.model import Panel, Prestress, Strand
panel = Panel(mark='T', length_ft=20.0, width_ft=8.0, thickness_in=8.0,
prestress=Prestress(strands=[Strand(y=1.0, z_in=4.0,
initial_pull_lb=1000.0)]),
source={'model': 'sample_job'})
turned = cli.transpose(panel)
assert 'prestress_not_credited' in turned.source
assert turned.source['model'] == 'sample_job'
def test_the_solid_parts_keys_are_the_ones_the_exporter_writes():
"""THE CONTRACT, pinned from this side.
`vibe.liftexport._solid_parts` writes exactly these keys on each entry of
`source.solid_parts`. Neither package can import the other -- that is the
whole design -- so nothing but a pin at each end stops one being renamed
under the other. The far half is `test_liftexport.py::
test_the_solid_parts_keys_are_the_ones_panellift_reads`.
"""
part = sandwich_source()['solid_parts'][0]
assert sorted(part) == ['counted', 'density_pcf', 'material',
'volume_ft3', 'z0_in', 'z1_in']
# and every one of them is actually consumed
layer = sandwich._layer_from_part(part)
assert layer.kind == sandwich.CONCRETE
assert layer.z0_in == 8.0 and layer.z1_in == 16.0
assert layer.material == 'STRUCT WYTHE'
assert layer.density_pcf == 150.28
assert layer.volume_ft3 == 79.885
def test_the_layup_block_the_exporter_writes_is_not_needed_to_detect():
"""`source.layup` is the same facts arranged for the section; the
DETECTION runs off `solid_parts` alone, so a job file from an exporter
that could not build a layup still refuses."""
source = sandwich_source(positioned=False)
source['layup'] = {'measured': False, 'layers': [], 'why': 'x'}
assert sandwich.detect(source).sandwich is True
+196
View File
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""The insulated-sandwich SECTION path in `section.py` / `capacity.py`.
Every expected number here is arithmetic done by hand, written out beside it,
and -- where a golden figure exists -- pinned to the value Eriksson Wall's own
section table prints, NOT to the code's own expression. A pin that recomputes
both sides agrees with any refactor, including a wrong one.
THE GOLDEN PIECE is WPB3.0 Run 1 (docs/eriksson-wall-algorithms.md sec.12 and
the extracted `WPB3.0.md`): a horizontal insulated wall panel, layup
3 in. concrete + 4 in. insulation + 4 in. concrete = 11 in. envelope
at a clear width of 24 ft = 288 in (no opening at the checked station).
Eriksson reports, for this section (p.11 of that packet):
Area 2016.00 in2 Cy 5.79 in Izz 29955.43 in4 Sb 5177.48 St 5744.88
which is the FULLY-COMPOSITE transformed section of the two concrete wythes
about their common centroid, with the insulation EXCLUDED. That is what
`section.NetSection` must reproduce for a sandwich, and it is what closes the
"treated as a solid 11 in prism" defect: the solid idealisation would give
Izz = 288*11^3/12 = 31944 and S = 288*11^2/6 = 5808, both larger than the real
section (unconservative), and its shear b*d would be 288*0.8*11, a third too
large -- see `test_shear_bd_drops_by_the_36_percent_the_parity_run_found`.
The task framed Eriksson's treatment as NON-composite; the golden figures do
not bear that out (Area 2016 is BOTH wythes, and Izz 29955 is their composite
inertia, not one wythe's), so this implements and pins the composite section
Eriksson actually reports. The %composite knock-down Eriksson then applies
(95% for stress/strength, 35% for in-place deflection) and the strand
development layer are out of scope here; the more conservative non-composite /
structural-wythe-alone bounds live in `sandwich.py`.
"""
import math
import pytest
from panellift import capacity, section
from panellift.model import Panel, Stage
ENVELOPE = 11.0
WIDTH_FT = 24.0
B_IN = WIDTH_FT * 12.0 # 288 in
def wpb3_source():
"""`source` as the exporter writes it for the WPB3.0 layup.
3 in wythe at the form face (z 0-3), 4 in insulation (z 3-7), 4 in wythe
(z 7-11). The order does not change Area / Izz / the governing S; it only
labels which face is Sb and which is St.
"""
return {
'solid_parts': [
{'counted': True, 'z0_in': 0.0, 'z1_in': 3.0,
'material': 'WYTHE 3IN', 'density_pcf': 150.0, 'volume_ft3': 1.0},
{'counted': False, 'z0_in': 3.0, 'z1_in': 7.0,
'material': 'INSULATION', 'density_pcf': 2.0, 'volume_ft3': 1.0},
{'counted': True, 'z0_in': 7.0, 'z1_in': 11.0,
'material': 'WYTHE 4IN', 'density_pcf': 150.0, 'volume_ft3': 1.0},
],
'envelope_thickness_in': ENVELOPE,
}
def wpb3_panel():
return Panel(mark='WPB3', length_ft=24.0, width_ft=WIDTH_FT,
thickness_in=ENVELOPE, unit_weight_pcf=150.0,
fci_psi=3500.0, fc_psi=6000.0, source=wpb3_source())
def _mid_net(panel):
return section.NetSection(panel, panel.length_ft / 2.0)
# ------------------------------------------------ the section, vs the golden
def test_area_is_both_wythes_with_the_insulation_excluded():
# A = b * (t1 + t2) = 288 * (3 + 4) = 2016.00 in2, NOT 288*11 = 3168.
net = _mid_net(wpb3_panel())
assert net.is_sandwich is True
assert net.area_in2 == pytest.approx(2016.0, abs=1e-6)
def test_centroid_is_the_composite_neutral_axis():
# zbar = (A1*z1 + A2*z2)/A, wythes at z=1.5 and z=9:
# = (3*1.5 + 4*9)/7 = (4.5 + 36)/7 = 5.785714 in -> Eriksson Cy 5.79
net = _mid_net(wpb3_panel())
assert net.zbar_in == pytest.approx(5.7857143, abs=1e-6)
def test_through_thickness_inertia_matches_the_reported_izz():
# Iz = b*[ t1^3/12 + t1*(z1 - zbar)^2 + t2^3/12 + t2*(z2 - zbar)^2 ]
# = 288*[ 3^3/12 + 3*(1.5-5.785714)^2 + 4^3/12 + 4*(9-5.785714)^2 ]
# = 288*[ 2.25 + 55.1020 + 5.3333 + 41.3265 ] = 288*104.0119 = 29955.4
net = _mid_net(wpb3_panel())
assert net.iz_in4 == pytest.approx(29955.43, abs=0.5) # reported 29955.43
def test_the_two_faces_have_the_unequal_moduli_eriksson_prints():
# c_form = 5.785714 (to z=0), c_far = 11 - 5.785714 = 5.214286 (to z=11)
# Sb = 29955.43 / 5.785714 = 5177.5 (Eriksson 5177.48)
# St = 29955.43 / 5.214286 = 5744.9 (Eriksson 5744.88)
net = _mid_net(wpb3_panel())
assert net.c_form_in == pytest.approx(5.7857143, abs=1e-6)
assert net.c_far_in == pytest.approx(5.2142857, abs=1e-6)
sb = net.iz_in4 / net.c_form_in
st = net.iz_in4 / net.c_far_in
assert sb == pytest.approx(5177.48, abs=1.0)
assert st == pytest.approx(5744.88, abs=1.0)
# sz_in3 is the GOVERNING (smaller) modulus, the one that decides tension.
assert net.sz_in3 == pytest.approx(min(sb, st), abs=1e-6)
def test_the_solid_idealisation_would_have_overstated_this_section():
"""The number the refusal was built on: the solid 11 in prism is bigger
than the real composite section, both in I and S -- unconservative."""
net = _mid_net(wpb3_panel())
solid_iz = B_IN * ENVELOPE ** 3 / 12.0 # 31944.0
solid_sz = B_IN * ENVELOPE ** 2 / 6.0 # 5808.0
assert solid_iz > net.iz_in4 # 31944 > 29955
assert solid_sz > net.sz_in3 # 5808 > 5177
# --------------------------------------------------------------- shear b*d
def test_shear_bd_drops_by_the_36_percent_the_parity_run_found():
"""docs sec.3.1: for a sandwich d_v = 0.8*th uses the CONCRETE depth (the
summed wythes), not the envelope. Here that is 0.8*7 vs 0.8*11, so the
shear area is 7/11 of the solid one -- a 36.4% drop, which is exactly the
WPB3.0 shear residual the harness traced to b*d not excluding insulation.
"""
net = _mid_net(wpb3_panel())
assert net.shear_thickness_in == pytest.approx(7.0) # concrete, not 11
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
pt = capacity.detailed_shear_at(wpb3_panel(), stage,
x=12.0, moment_lbft=5000.0, shear_lb=8000.0)
solid_bd = net.width_in * capacity.pci.DV_OVER_H_SHEAR * ENVELOPE
assert pt.bd_in2 == pytest.approx(solid_bd * 7.0 / 11.0, rel=1e-9)
drop = 1.0 - pt.bd_in2 / solid_bd
assert drop == pytest.approx(4.0 / 11.0, abs=1e-6) # 0.3636...
# --------------------------------------- contiguous two-layer -> merged solid
def test_a_contiguous_two_concrete_body_is_the_merged_solid():
"""An architectural face cast monolithically on a structural wythe (no
insulation gap) is not a hole in the middle -- its composite section is
simply the solid over the whole concrete span, and that is what comes
out. 4 in (z0-4) + 12 in (z4-16) contiguous -> a solid 16 in section."""
src = {'solid_parts': [
{'counted': True, 'z0_in': 4.0, 'z1_in': 16.0, 'material': 'STRUCT',
'density_pcf': 150.0, 'volume_ft3': 1.0},
{'counted': True, 'z0_in': 0.0, 'z1_in': 4.0, 'material': 'ARCH',
'density_pcf': 150.0, 'volume_ft3': 1.0}],
'envelope_thickness_in': 16.0}
panel = Panel(mark='C', length_ft=20.0, width_ft=5.0, thickness_in=12.0,
source=src)
net = section.NetSection(panel, 10.0)
b = 5.0 * 12.0
assert net.area_in2 == pytest.approx(b * 16.0) # all 16 in concrete
assert net.zbar_in == pytest.approx(8.0) # mid-depth: solid
assert net.iz_in4 == pytest.approx(b * 16.0 ** 3 / 12.0)
assert net.shear_thickness_in == pytest.approx(16.0)
# --------------------------------------------- the solid path does not move
def test_a_solid_panel_is_untouched():
"""No source -> not a sandwich -> the familiar b*t, b*t^2/6, b*t^3/12,
d_v = 0.8*t, centroid t/2. Byte-for-byte what it always was."""
panel = Panel(mark='S', length_ft=20.0, width_ft=5.0, thickness_in=8.0)
net = section.NetSection(panel, 10.0)
b = 5.0 * 12.0
assert net.is_sandwich is False
assert net.area_in2 == pytest.approx(b * 8.0)
assert net.zbar_in == pytest.approx(4.0)
assert net.c_form_in == pytest.approx(4.0)
assert net.c_far_in == pytest.approx(4.0)
assert net.iz_in4 == pytest.approx(b * 8.0 ** 3 / 12.0)
assert net.sz_in3 == pytest.approx(b * 8.0 ** 2 / 6.0)
assert net.shear_thickness_in == pytest.approx(8.0)
def test_the_free_functions_follow_the_same_section():
"""`section.area_in2` / `section_modulus_in3` / `inertia_in4` are what
`statics` (EI) and `layout` read; they must be the sandwich section too."""
panel = wpb3_panel()
x = 12.0
net = section.NetSection(panel, x)
assert section.area_in2(panel, x) == pytest.approx(net.area_in2, abs=1e-6)
assert section.inertia_in4(panel, x) == pytest.approx(net.iz_in4, abs=1e-6)
assert section.section_modulus_in3(panel, x) == pytest.approx(net.sz_in3,
abs=1e-6)
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""A piece does not always hang the way it was cast.
A horizontal panel is stripped flat, spanning its long direction like
everything else, and then stood up on its long EDGE. Gravity then runs across
what used to be the width, and that is the direction it spans.
The solver is not taught about orientation. The piece is turned instead, so
the mechanics stay one implementation checked against published examples.
"""
import dataclasses
import pytest
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT, make_panel
from panellift import cli, statics
from panellift.model import Blockout, Lifter
def horizontal_job(span):
"""Anchors along one long edge, face lifters inboard of it."""
return {
'schema': cli.SCHEMA,
'panel': {
'mark': 'H1', 'length_ft': 30.0, 'width_ft': 8.0,
'thickness_in': 8.0, 'weight_lb': 24000.0, 'fc_psi': 5000.0,
'blockouts': [{'name': 'O', 'x0': 10.0, 'y0': 2.0,
'length': 6.0, 'width': 3.0}],
'lifters': [
{'mark': 'A', 'family': 'ANCHOR', 'x': 6.0, 'y': 8.0,
'on_edge': True, 'pinned_y': True, 'capacity_lb': 7690.0,
'shear_capacity_lb': 4010.0},
{'mark': 'B', 'family': 'ANCHOR', 'x': 24.0, 'y': 8.0,
'on_edge': True, 'pinned_y': True, 'capacity_lb': 7690.0,
'shear_capacity_lb': 4010.0},
{'mark': 'C', 'family': 'FACE', 'x': 6.0, 'y': 1.5,
'capacity_lb': 8000.0},
{'mark': 'D', 'family': 'FACE', 'x': 24.0, 'y': 1.5,
'capacity_lb': 8000.0},
],
},
'stages': [{'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0,
'span': span}],
}
# --------------------------------------------------------- the transposition
def test_turning_the_piece_swaps_its_two_in_plane_dimensions():
panel = make_panel()
turned = cli.transpose(panel)
assert turned.length_ft == pytest.approx(WIDTH_FT)
assert turned.width_ft == pytest.approx(LENGTH_FT)
assert turned.thickness_in == pytest.approx(THICKNESS_IN)
def test_turning_the_piece_takes_the_blockouts_with_it():
panel = dataclasses.replace(make_panel(), blockouts=[
Blockout(name='O', x0=4.0, y0=1.0, length=6.0, width=2.0)])
hole = cli.transpose(panel).blockouts[0]
assert (hole.x0, hole.y0, hole.length, hole.width) == (1.0, 4.0, 2.0, 6.0)
def test_turning_the_piece_swaps_which_way_an_insert_is_pinned():
"""An anchor pinned to a long edge is pinned across the piece as cast and
ALONG it once the piece has been stood up on that edge. Carrying the flag
through unchanged would free it to slide off its own edge."""
panel = dataclasses.replace(make_panel(), lifters=[
Lifter(mark='A', family='F', x=2.0, y=9.0, pinned_y=True)])
turned = cli.transpose(panel).lifters[0]
assert (turned.x, turned.y) == (9.0, 2.0)
assert turned.pinned and not turned.pinned_y
def test_turning_twice_is_the_piece_you_started_with():
panel = make_panel()
there_and_back = cli.transpose(cli.transpose(panel))
assert there_and_back.length_ft == pytest.approx(panel.length_ft)
assert [(l.x, l.y) for l in there_and_back.lifters] == \
[(l.x, l.y) for l in panel.lifters]
# ------------------------------------------------------------ through a job
def test_a_stage_spanning_the_width_really_spans_the_width():
_p, _r, cases = cli.analyse_job(horizontal_job('width'))
assert cases[0][0].panel.length_ft == pytest.approx(8.0)
assert cases[0][0].stage.span == 'width'
def test_the_same_inserts_group_differently_when_the_piece_turns():
"""The anchors along the top edge are ONE support when the piece is
standing and TWO when it is flat. That is the whole reason to turn it."""
_p, _r, flat = cli.analyse_job(horizontal_job('length'))
_p, _r, stood = cli.analyse_job(horizontal_job('width'))
assert len(flat[0][0].rig.lines) == 2 # by x: 6 ft and 24 ft
assert len(stood[0][0].rig.lines) == 2 # by y: 1.5 ft and 8 ft
stood_line = max(stood[0][0].rig.lines, key=lambda l: l.x)
assert stood_line.count == 2 # both anchors, one support
def test_a_stage_defaults_to_spanning_its_length():
_p, _r, cases = cli.analyse_job(horizontal_job(None))
assert cases[0][0].stage.span == 'length'
assert cases[0][0].panel.length_ft == pytest.approx(30.0)
def test_an_unknown_span_is_refused():
with pytest.raises(cli.JobError):
cli.analyse_job(horizontal_job('diagonally'))
def test_the_position_search_stands_aside_for_a_turned_stage():
"""It optimises one set of coordinates. A turned stage's x is the panel's
y, so including it would be optimising against two different pieces.
It still answers for the stages it can, and says which one it left out --
silently optimising against two of three stages would read as an answer
for all three.
"""
from panellift import layout
job = horizontal_job('width')
job['stages'].insert(0, {'name': 'stripping', 'multiplier': 1.3,
'fc_psi': 4000.0})
_p, _r, cases = cli.analyse_job(job)
rec = layout.recommend(cases)
assert rec.moves # stripping still answered
assert [s.name for s in rec.stages] == ['stripping']
assert 'erection' in rec.note
def test_the_search_says_so_when_every_stage_is_turned():
from panellift import layout
_p, _r, cases = cli.analyse_job(horizontal_job('width'))
rec = layout.recommend(cases)
assert not rec.moves and 'erection' in rec.note
+259
View File
@@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
"""A stage that was not analysed has to say so, in its own box.
The failure being guarded against is not a wrong number. It is silence: a
stage that nobody ran, missing from the report, read by an engineer as a
stage that passed. "Could not be checked" must never be mistaken for
"checked and fine", and a stage that simply is not on the page is exactly
that mistake with nothing on the page to correct it.
So these tests are mostly about what the OUTPUT says, not about what the
calculation computes -- there is no calculation for a stage that was
switched off, and that is the point.
"""
import re
import pytest
from panellift import cli, layout, report
from test_cli import aspire_job
def two_stage_job(enabled=False, reason=None, **extra):
"""The ASPIRE panel with a second stage that is switched off."""
job = aspire_job()
second = {'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0}
if enabled is not None:
second['enabled'] = enabled
if reason is not None:
second['disabled_reason'] = reason
second.update(extra)
job['stages'] = [job['stages'][0], second]
return job
# ------------------------------------------------- the flag itself is additive
def test_a_job_with_no_enabled_key_runs_every_stage():
"""The 184 job files on disk have no `enabled` anywhere. Absent means
enabled, or adding the flag would have changed every one of them."""
_p, _r, cases = cli.analyse_job(two_stage_job(enabled=None))
assert len(cases) == 2
assert list(cases.skipped) == []
def test_enabled_true_is_the_same_as_no_key_at_all():
a = cli.analyse_job(two_stage_job(enabled=None))[2]
b = cli.analyse_job(two_stage_job(enabled=True))[2]
assert len(a) == len(b) == 2
assert not b.skipped
def test_a_disabled_stage_is_not_analysed():
_p, _r, cases = cli.analyse_job(two_stage_job())
assert [a.stage.name for a, _r, _f in cases] == ['stripping']
def test_a_disabled_stage_is_still_carried():
"""Not analysed is not the same as not there. If it were, the flag would
be an elaborate way of deleting the stage."""
_p, _r, cases = cli.analyse_job(two_stage_job(reason='crane cannot reach'))
assert [s.name for s in cases.skipped] == ['erection']
assert cases.skipped[0].reason == 'crane cannot reach'
assert cases.skipped[0].index == 1
def test_the_stage_note_serves_as_the_reason_when_none_is_given():
_p, _r, cases = cli.analyse_job(
two_stage_job(note='checked by hand off the rigging drawing'))
assert cases.skipped[0].reason == 'checked by hand off the rigging drawing'
def test_cases_is_still_an_ordinary_list_of_triples():
"""Every existing caller unpacks (panel, rigging, cases) and then
(analysis, rigging, findings) out of cases. That has to keep working."""
_p, _r, cases = cli.analyse_job(two_stage_job())
assert isinstance(cases, list)
analysis, rigging, findings = cases[0]
assert analysis.stage.name == 'stripping'
assert rigging is not None and findings is not None
# --------------------------------------------------------- refusals worth having
def test_a_disabled_stage_is_not_validated():
"""It has no multiplier and no f'c, which would be fatal in a stage that
ran. Refusing the job over fields nobody reads would push authors back to
deleting the stage, which is the thing that leaves no record."""
job = aspire_job()
job['stages'] = [job['stages'][0],
{'name': 'erection', 'enabled': False}]
_p, _r, cases = cli.analyse_job(job)
assert [s.name for s in cases.skipped] == ['erection']
def test_a_disabled_stage_still_needs_a_name():
"""Something has to appear in the box."""
job = aspire_job()
job['stages'] = [job['stages'][0], {'enabled': False}]
with pytest.raises(cli.JobError):
cli.analyse_job(job)
@pytest.mark.parametrize('value', ['false', 'no', 0, 1, []])
def test_enabled_refuses_anything_that_is_not_a_boolean(value):
"""`"enabled": "false"` is truthy in Python. Coercing it would run the
stage the author meant to switch off and never tell them."""
with pytest.raises(cli.JobError):
cli.analyse_job(two_stage_job(enabled=value))
def test_a_job_with_every_stage_disabled_is_refused():
job = aspire_job()
job['stages'] = [dict(job['stages'][0], enabled=False)]
with pytest.raises(cli.JobError) as exc:
cli.analyse_job(job)
assert 'disabled' in str(exc.value)
# ------------------------------------------------------------- the summary json
def test_the_summary_carries_the_stage_with_no_numbers():
_p, _r, cases = cli.analyse_job(two_stage_job(reason='crane cannot reach'))
summary = cli.summarise(cases)
rows = dict((s['name'], s) for s in summary['stages'])
assert rows['erection']['analysed'] is False
assert rows['erection']['verdict'] == 'NOT RUN'
assert rows['erection']['reason'] == 'crane cannot reach'
# Never a zero. A zero peak against a 350 psi allowable reads as the best
# result on the piece.
for key in ('peak_tension_psi', 'allowable_psi', 'ratio', 'multiplier',
'fc_psi'):
assert rows['erection'][key] is None
def test_the_skipped_stage_keeps_its_place_in_the_list():
job = aspire_job()
job['stages'] = [{'name': 'erection', 'enabled': False},
job['stages'][0]]
_p, _r, cases = cli.analyse_job(job)
assert [s['name'] for s in cli.summarise(cases)['stages']] == \
['erection', 'stripping']
def test_the_summary_says_it_is_incomplete():
_p, _r, cases = cli.analyse_job(two_stage_job())
summary = cli.summarise(cases)
assert summary['complete'] is False
assert [s['name'] for s in summary['not_run']] == ['erection']
def test_a_job_with_nothing_skipped_is_complete():
_p, _r, cases = cli.analyse_job(aspire_job())
summary = cli.summarise(cases)
assert summary['complete'] is True
assert summary['not_run'] == []
def test_a_skipped_stage_does_not_change_the_verdict():
"""It cannot: nothing was measured. What it changes is the CLAIM, and
that is `complete`, not `verdict`."""
ran = cli.summarise(cli.analyse_job(aspire_job())[2])
partial = cli.summarise(cli.analyse_job(two_stage_job())[2])
assert partial['verdict'] == ran['verdict']
# ------------------------------------------------------------------- the report
def _html(job):
_p, _r, cases = cli.analyse_job(job)
return report.build_many(cases, recommendation=layout.recommend(cases))
def test_the_report_prints_the_sentence_not_a_number():
html = _html(two_stage_job(reason='the crane cannot reach the top row'))
assert 'Erection analysis not performed.' in html
assert 'the crane cannot reach the top row' in html
assert 'not analysed' in html
def test_the_skipped_box_carries_no_ratio():
"""The other boxes print '96% of allowable tension'. This one must not
print a percentage at all -- not even '-', which the eye scores as the
best number on the page."""
html = _html(two_stage_job())
box = html.split('<div class="stagebox NOTRUN">')[1].split('</div>')[0] + \
html.split('<div class="stagebox NOTRUN">')[1].split('</div>')[1]
assert 'of allowable tension' not in box
assert '%' not in box
def test_the_skipped_box_sits_in_the_open_summary():
"""Beside the stages that ran, above the first collapsed disclosure --
Eriksson's arrangement, and the reason the summary was left open."""
html = _html(two_stage_job())
assert html.index('stagebox NOTRUN') < html.index('<details class="stage"')
def test_the_skipped_box_keeps_its_place_among_the_others():
job = aspire_job()
job['stages'] = [{'name': 'erection', 'enabled': False},
job['stages'][0]]
html = _html(job)
order = [m.group(1) for m in
re.finditer(r'<div class="stagebox (\w+)"', html)]
assert order[0] == 'NOTRUN' and len(order) == 2
def test_the_overall_banner_says_what_it_covers():
html = _html(two_stage_job())
banner = html.split('<div class="verdict">')[1].split('</div>')[0]
assert 'not analysed' in banner
assert 'partial' in banner
def test_the_banner_does_not_claim_the_piece_when_a_stage_was_skipped():
"""'All checks satisfied' is a statement about the panel. It is false the
moment a handling condition was not looked at."""
html = _html(two_stage_job())
banner = html.split('<div class="verdict">')[1].split('</div>')[0]
assert 'All checks satisfied<' not in banner
assert 'analysed' in banner
def test_the_banner_is_untouched_when_every_stage_ran():
html = _html(aspire_job())
banner = html.split('<div class="verdict">')[1].split('</div>')[0]
assert 'partial' not in banner
assert 'not analysed' not in banner
def test_the_skipped_stage_gets_its_own_disclosure_and_it_is_closed():
html = _html(two_stage_job())
assert '<details class="stage NOTRUN">' in html
assert '<details class="stage NOTRUN" open' not in html
def test_the_detail_says_the_verdict_does_not_cover_it():
html = _html(two_stage_job())
assert 'does not cover this one' in html
def test_a_report_with_nothing_skipped_is_byte_for_byte_unchanged():
"""The flag is additive at the output end too: a job file that does not
use it must render exactly what it rendered before, or 184 reports on
disk stop matching what the tool produces today."""
job = aspire_job()
_p, _r, cases = cli.analyse_job(job)
rec = layout.recommend(cases)
assert report.build_many(cases, recommendation=rec) == \
report.build_many(list(cases), recommendation=rec, skipped=[])
def test_write_many_takes_the_skipped_stages_off_the_cases(tmp_path):
"""A caller that just passes `cases` along gets it right without knowing
the attribute exists."""
_p, _r, cases = cli.analyse_job(two_stage_job())
out = str(tmp_path / 'r.html')
report.write_many(out, cases, recommendation=layout.recommend(cases))
assert 'analysis not performed' in open(out, encoding='utf-8').read()
+356
View File
@@ -0,0 +1,356 @@
# -*- 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
+363
View File
@@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
"""The numbers behind the curves, station by station.
Eriksson Wall prints, after each stage's plots, a summary table of the same
quantities at engineering stations with the governing row bolded. This is our
version of it, and these are the properties that make it worth printing:
1. It carries the columns we actually compute, in the units the engineer
states them in -- ft, kip, kip-ft, psi -- and does NOT carry the ones we
don't. A zero in a capacity column is worse than no column at all,
because somebody would divide by it. Six capacity columns landed
2026-08-31 (M_cr, phi, phi-Mn, M/phi-Mn, phi-Vn, V/phi-Vn); the rest are
still named as absent in the footnote, and a station whose capacity
cannot be computed prints a dash and a REASON -- never a zero, never the
neighbouring station's number.
2. Its rows are the analysis's own stations. Thinning may drop a station
that is only there because the grid is uniform; it may never drop an
opening edge, a pick line, a peak, or the governing station.
3. The governing row is marked once. Not zero times, and not twice --
"which row is the answer" has exactly one answer.
4. It is closed on load like every other section, and it survives the
print overrides, because a printed report that hides its numbers is a
report that says nothing.
"""
import re
from pathlib import Path
import pytest
from conftest import aspire_pockets, make_panel
from panellift import checks, layout, report, statics
from panellift.model import Blockout, Stage
def _analyse(panel, stage, rigging):
return statics.analyse(panel, stage, rigging)
def _table_of(page, after='Station table'):
"""The one <table class="stations"> that follows this heading."""
i = page.index(after)
j = page.index('<table class="stations"', i)
return page[j:page.index('</table>', j) + 8]
def _rows(table):
return re.findall(r'<tr[^>]*>(.*?)</tr>', table[table.index('<tbody>'):])
def _cells(row):
return [re.sub(r'<[^>]+>', '', c)
for c in re.findall(r'<td[^>]*>(.*?)</td>', row)]
# ------------------------------------------------ 1. the columns, converted
def test_the_columns_are_the_ones_we_compute_in_display_units(stripping,
rolling):
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
table = _table_of(report.station_table(a), '<table')
head = re.sub(r'<[^>]+>', '|', table[:table.index('</thead>')])
for column in ('x, ft', 'V, kip', 'M, kip-ft', 'S, in', 'allow, psi',
'/allow', 'note'):
assert column in head, column
assert head.count(', psi') == 3 # f_t, f_c, allow
def test_the_capacity_columns_are_carried_and_the_rest_are_named_absent(
stripping, rolling):
"""The load-bearing honesty of the whole table, in its 2026-08-31 form.
Six capacity columns are computed and carried. The ones that are still
not computed are NAMED in the footnote rather than being quietly missing,
and the sentence that forbids zero-filling them survives.
"""
a = _analyse(make_panel(), stripping, rolling)
html = report.station_table(a)
table = _table_of(html, '<table')
head = table[:table.index('</thead>')]
for carried in ('M<sub>cr</sub>', '&phi;M<sub>n</sub>',
'M/&phi;M<sub>n</sub>', '&phi;V<sub>n</sub>',
'V/&phi;V<sub>n</sub>', '&phi;'):
assert carried in head, carried
footnote = html[html.index('<ul class="basis">'):]
for still_absent in ('V<sub>ci</sub>', 'V<sub>cw</sub>', 'deflection',
'A<sub>v</sub>'):
assert still_absent in footnote, still_absent
assert 'left out rather than filled with zeros' in footnote
# and the footnote does not claim the new ones are missing any more
assert 'This revision computes none of those' not in footnote
def test_every_value_is_the_engines_value_converted_once(stripping, rolling):
"""A row's V and M are diagram.shear_lb / moment_lbft at that station
over LB_PER_KIP -- not a re-solve, not an interpolation."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
points = dict((p.x, p) for p in checks.stress_profile(a))
table = _table_of(report.station_table(a), '<table')
xs = a.diagram.xs
# Column order since the 2026-09-01 reorder (f_se, added later the same
# day, sits after f_c): x=0, M/phi-Mn=1, V/phi-Vn=2, M=3, phi-Mn=4,
# M_cr=5, V=6, phi-Vn=7, phi=8, S=9, f_c=10, f_se=11, f_t/allow=12,
# f_t=13, allow=14, note=15.
for row in _rows(table):
c = _cells(row)
x = float(c[0])
i = min(range(len(xs)), key=lambda j: abs(xs[j] - x))
assert abs(float(c[6].replace(',', ''))
- a.diagram.shear_lb[i] / report.LB_PER_KIP) < 0.01
assert abs(float(c[3].replace(',', ''))
- a.diagram.moment_lbft[i] / report.LB_PER_KIP) < 0.1
# and the stress column is the same profile the plot was drawn from
gov = [r for r in _rows(table) if 'gov' in r][0]
worst = max(points.values(), key=lambda p: p.stress_psi)
assert abs(float(_cells(gov)[13]) - worst.stress_psi) < 0.05
assert _cells(gov)[12] == '{0:.0%}'.format(
worst.stress_psi / stripping.allowable_tension_psi)
def test_a_negative_zero_never_reaches_a_cell(stripping, rolling):
a = _analyse(make_panel(), stripping, rolling)
table = _table_of(report.station_table(a), '<table')
for row in _rows(table):
for cell in _cells(row):
assert not re.match(r'^-0(\.0*)?$', cell), cell
# --------------------------------- 2. what thinning may and may not drop
def test_the_table_is_thinned_but_keeps_every_jump_station(stripping,
rolling):
"""Five pockets in a row: ten edge stations, every one of them a row,
out of two thousand computed."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
assert len(a.stations) >= 2
table = _table_of(report.station_table(a), '<table')
rows = _rows(table)
assert len(rows) < len(a.diagram.xs) / 10 # it really is thinned
shown = [float(_cells(r)[0]) for r in rows]
for x in a.stations:
assert any(abs(x - s) < 0.005 for s in shown), x
assert sum('opening edge' in r for r in rows) == len(a.stations)
def test_an_opening_edge_is_shown_from_both_sides(stripping, rolling):
"""The moment is continuous across the edge and S is not. Two rows at the
same station with different S is the section jumping -- the one thing a
uniform grid would step over."""
panel = make_panel(blockouts=[
Blockout(name='B1', x0=10.0, y0=0.0, length=6.0, width=4.0)])
a = _analyse(panel, stripping, rolling)
rows = _rows(_table_of(report.station_table(a), '<table'))
edges = [r for r in rows if 'opening edge' in r]
assert len(edges) == 4 # two edges, both sides
pair = [_cells(r) for r in edges[:2]]
assert pair[0][0] == pair[1][0] # same station
assert pair[0][9] != pair[1][9] # different S (col 9)
def test_the_peaks_and_the_pick_lines_survive_thinning(stripping, rolling):
a = _analyse(make_panel(), stripping, rolling)
rows = _rows(_table_of(report.station_table(a), '<table'))
text = ''.join(rows)
for tag in ('pick line', 'max V', 'min V', 'max M', 'min M'):
assert tag in text, tag
shown = [float(_cells(r)[0]) for r in rows]
for line in a.rig.lines:
assert any(abs(line.x - s) < 0.02 for s in shown), line.x
# the ends of the piece are both there
assert shown[0] == 0.0
assert abs(shown[-1] - a.panel.length_ft) < 0.01
def test_a_supported_stage_names_its_supports_not_pick_lines(stripping,
rolling):
"""Transportation on dunnage is not a lift, and the note column says the
word the rest of that stage uses."""
stage = Stage(name='transportation', multiplier=1.5, fc_psi=5000.0,
supports=[6.0, 26.0])
a = _analyse(make_panel(), stage, rolling)
html = report.station_table(a)
rows = _rows(_table_of(html, '<table'))
assert sum('support' in r for r in rows) == 2
assert 'pick line' not in ''.join(rows)
assert 'every support' in html
def test_the_rule_that_was_applied_is_printed(stripping, rolling):
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
html = report.station_table(a)
kept = len(_rows(_table_of(html, '<table')))
assert '{0} rows out of {1}'.format(kept, len(a.diagram.xs)) in html
assert 'thinned' in html and 'never drop' in html
def test_a_short_diagram_is_not_thinned_at_all(stripping, rolling):
"""The stride is a cap, not a rule: nothing is dropped that there was
room for."""
a = _analyse(make_panel(), stripping, rolling)
a.diagram.xs = a.diagram.xs[:8]
a.diagram.shear_lb = a.diagram.shear_lb[:8]
a.diagram.moment_lbft = a.diagram.moment_lbft[:8]
a.stations = []
rows, _gov = report.station_indices(a, ())
assert [i for i, _tags in rows] == list(range(8))
# --------------------------------------- 3. the governing row, marked once
def test_the_governing_row_is_marked_exactly_once(stripping, rolling):
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
for html in (report.station_table(a),
report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)):
table = _table_of(html, '<table class="stations"')
assert table.count('<tr class="gov">') == 1
assert table.count('governing') == 1
gov = [r for r in _rows(table) if 'gov' in r][0]
assert 'governing' in gov
def test_the_governing_row_is_the_worst_ratio_on_the_page(stripping, rolling):
"""It has to be the same station the verdict, the red dot on the stress
plot and the CRACK finding all point at."""
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
points = checks.stress_profile(a)
worst = max(points, key=lambda p: p.stress_psi)
table = _table_of(report.station_table(a, points), '<table')
gov = _cells([r for r in _rows(table) if 'gov' in r][0])
assert abs(float(gov[0]) - worst.x) < 0.005
# f_t/allow is column 12 since the 2026-09-01 reorder, f_se included (see
# the column map at the top of this file's test_every_value_is_the_
# engines_value...)
ratios = [int(_cells(r)[12].rstrip('%')) for r in _rows(table)
if _cells(r)[12].endswith('%')]
assert int(gov[12].rstrip('%')) == max(ratios)
def test_the_mark_is_bold_and_tinted_and_also_a_word(stripping, rolling):
"""Three ways, because a print drops the tint and a monochrome print
drops the colour. The word in the note column survives both."""
a = _analyse(make_panel(), stripping, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
assert ('table.stations tr.gov td'
'{background:var(--fillc);font-weight:700}') in page
gov = [r for r in _rows(_table_of(page)) if 'gov' in r][0]
assert 'governing' in gov
def test_every_stage_of_a_multi_stage_report_gets_its_own_table(stripping,
shipping,
rolling):
panel = make_panel(blockouts=aspire_pockets())
cases = [(_analyse(panel, stage, rolling), rolling, [])
for stage in (stripping, shipping)]
for a, rig, _f in cases:
_f.extend(checks.run_all(a, rig))
page = report.build_many(cases, recommendation=layout.not_searched())
assert page.count('<table class="stations"') == 2
assert page.count('<tr class="gov">') == 2
assert page.count('>Station table</summary>') == 2
# ------------------------------------------- 4. collapsed, and it prints
def test_the_section_is_closed_on_load_like_every_other(stripping, rolling):
a = _analyse(make_panel(), stripping, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
assert ('<details class="section stationsec"><summary>Station table'
'</summary>') in page
assert '<details class="section stationsec" open' not in page
# nested inside the stage's own disclosure, not beside it
stage_body = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False, standalone=False)
assert 'stationsec' in stage_body
def test_the_table_survives_the_print_overrides(stripping, rolling):
"""A closed <details> is hidden on screen by two different mechanisms in
two generations of engine, and the print block overrides both. The table
must be inside that, and its scroll box must open up on paper."""
a = _analyse(make_panel(), stripping, rolling)
page = report.build(a, rolling, checks.run_all(a, rolling),
recommendation=False)
printed = page[page.index('@media print'):]
printed = printed[:printed.index('</style>')]
assert 'details>*:not(summary){display:block !important}' in printed
assert 'details::details-content' in printed
assert '.tablewrap{overflow:visible !important}' in printed
assert 'table.stations thead{display:table-header-group}' in printed
assert 'details.stationsec{break-inside:auto}' in printed
# and the table is inside a details, so those overrides reach it
assert '<details class="section stationsec">' in page
without = page[:page.index('<script>')]
assert '<table class="stations"' in without # no JS needed at all
# ----------------------------------------------------- 5. a real piece
def _sweep_jobs():
root = Path(__file__).resolve().parents[2] / 'knowledge' / 'job'
if not root.exists():
return []
return sorted(root.glob('*/sweep/jobs/*.job.json'))
def _is_sandwich_job(job):
"""Two or more concrete solids in the layup -- an insulated (or
two-wythe) body, the makeup that used to be refused outright."""
parts = ((job.get('source') or {}).get('solid_parts')) or []
return sum(1 for p in parts if p.get('counted')) >= 2
def test_a_real_job_renders_a_station_table():
"""The fixtures are a published worked example; a real piece is a real
piece. Skipped where the local job library is not checked out.
Renders the first real jobs that ANALYSE, and requires at least one of
them to be an insulated/two-wythe sandwich -- the makeup that was refused
before 2026-09-02 and now runs on the concrete-wythe composite section.
A job that cannot be LIFT-analysed for a reason unrelated to its section
(e.g. an erection stage whose two picked inserts collapse onto one line
across the span -- a rigging-geometry refusal, `statics` ValueError, that
fires on WP100-102 whether the piece is solid or sandwich) is skipped,
not counted as a rendering failure: this test is about the station table,
not about those pieces' rigging.
"""
from panellift import cli
jobs = _sweep_jobs()
if not jobs:
pytest.skip('no local job library')
import json
rendered = 0
sandwich_rendered = 0
for path in jobs:
with open(str(path), 'r', encoding='utf-8') as fh:
job = json.load(fh)
try:
_panel, _rig, cases = cli.analyse_job(job)
except (cli.JobError, ValueError):
# Refused (an unmeasured sandwich) or un-riggable (WP100-style
# one-line erection pick). Neither is a station-table failure.
continue
page = report.build_many(cases, recommendation=layout.not_searched())
assert page.count('<table class="stations"') == len(cases)
assert page.count('<tr class="gov">') == len(cases)
for table in re.findall(
r'<table class="stations".*?</table>', page, re.S):
rows = _rows(table)
assert 8 <= len(rows) <= 200
assert all(len(_cells(r)) == 16 for r in rows)
rendered += 1
if _is_sandwich_job(job):
sandwich_rendered += 1
if rendered >= 3 and sandwich_rendered >= 1:
break
assert rendered >= 3
# the sandwich path is the point of this work: at least one real
# insulated/two-wythe piece must render its table, not be refused.
assert sandwich_rendered >= 1
+239
View File
@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
"""The tensile limit is a job decision, and the report has to say whose.
`tension_limit_sqrt_fc` multiplies sqrt(f'c) to give the allowable tension in
psi. 5.0 is the modulus of rupture over a 1.5 safety factor -- "no
discernible cracking"; 7.5 is the rupture modulus itself, first cracking with
no margin. The two are a factor of 1.5 apart on EVERY stress verdict, which
is more than any refinement of the analysis moves, and a commercial check of
one of these panels ran at 7.5 while this defaulted to 5.0 -- that difference
alone accounted for most of the disagreement between the two.
The owner declined to settle it, 2026-08-30:
"应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自己
填写。I can see it both ways."
So the engineer fills it in per job, and these tests hold the two halves of
that: the number the job states is the number the check uses, and the page
says which number that was and who chose it. A report that states 666 psi
with nothing else on it cannot be told apart from the same panel at 444 psi
by anybody reading it later, which is the whole failure being prevented.
"""
import re
import pytest
from panellift import cli, layout, report
from panellift.model import Stage
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT
from panellift import pci
def job(limit=None, source=None, stage_extra=None):
"""The ASPIRE panel as the exporter writes it, at whatever limit.
`limit=None` leaves the key out entirely -- a job file written before
anybody could set this, which must behave exactly as it always did.
"""
left, right = pci.optimal_two_line_supports(LENGTH_FT)
stage = {'name': 'stripping',
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
'fc_psi': 4000.0}
if limit is not None:
stage['tension_limit_sqrt_fc'] = limit
if source is not None:
stage['tension_limit_source'] = source
stage.update(stage_extra or {})
return {
'schema': 'panellift/1',
'panel': {
'mark': 'ASPIRE-1',
'length_ft': LENGTH_FT,
'width_ft': WIDTH_FT,
'thickness_in': THICKNESS_IN,
'unit_weight_pcf': 150.0,
'fci_psi': 4000.0,
'fc_psi': 5000.0,
'lifters': [
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
'y': 7.5, 'capacity_lb': 12000.0},
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
'y': 7.5, 'capacity_lb': 12000.0},
],
},
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
'stages': [stage],
}
def analysed(**kw):
"""(analysis, rigging, findings) for the one stage of that job."""
_panel, _rigging, cases = cli.analyse_job(job(**kw))
return cases[0]
def rendered(**kw):
"""One stage of one piece, all the way to HTML."""
return report.build_many([analysed(**kw)],
recommendation=layout.not_searched())
# ------------------------------------------------- the value reaches the sum
def test_the_job_files_limit_is_the_limit_the_check_uses():
"""The whole chain in one assertion: knowledge -> job file -> Stage ->
allowable. A field that writes a key nothing reads is the drift this
repository keeps finding, so the number has to move the arithmetic."""
five = analysed(limit=5.0)[0].stage
seven = analysed(limit=7.5)[0].stage
assert five.allowable_tension_psi == pytest.approx(5.0 * 4000.0 ** 0.5)
assert seven.allowable_tension_psi == pytest.approx(7.5 * 4000.0 ** 0.5)
# 1.5x, exactly, which is the entire argument.
assert (seven.allowable_tension_psi /
five.allowable_tension_psi) == pytest.approx(1.5)
def test_the_limit_changes_the_number_printed_on_a_real_report():
"""Not just the model object -- the rendered page. Two reports of the
same piece under the two schools must not be able to look alike."""
five = rendered(limit=5.0)
seven = rendered(limit=7.5)
assert '316 psi' in five and '474 psi' in seven
assert '474 psi' not in five and '316 psi' not in seven
def test_a_permissive_limit_can_turn_a_fail_into_a_pass():
"""Why this is the largest single source of disagreement: the verdict
itself moves, on the same panel, with the same geometry and loads."""
def verdict(limit):
# Same panel, same picks, same loads. Only f'ci is dropped to 1500
# psi, which puts the stripping stress between the two allowables:
# 5*sqrt(1500) = 194 psi and 7.5*sqrt(1500) = 290 psi.
found = [f for f in analysed(limit=limit,
stage_extra={'fc_psi': 1500.0})[2]
if f.code == 'CRACK']
return found[0].severity if found else None
assert verdict(5.0) == 'FAIL'
assert verdict(7.5) in ('OK', 'WARN')
# ----------------------------------------------------- unset is unchanged
def test_a_job_file_with_no_limit_still_gets_five():
"""Byte-identity for everything already on disk. Every job file written
before this was settable leaves the key out, and absent has to mean the
5.0 it has always meant -- not "ask", not "inherit something"."""
assert analysed()[0].stage.tension_limit_sqrt_fc == 5.0
assert Stage(name='s', multiplier=1.0, fc_psi=4000.0) \
.tension_limit_sqrt_fc == 5.0
def test_absent_and_explicit_five_compute_identically():
"""The default is the same code path as a stated 5.0, down to the
findings text -- apart from the one word saying who chose it."""
absent, stated = analysed(), analysed(limit=5.0)
a = [(f.severity, f.code) for f in absent[2]]
b = [(f.severity, f.code) for f in stated[2]]
assert a == b
assert (absent[0].stage.allowable_tension_psi ==
stated[0].stage.allowable_tension_psi)
# --------------------------------------------------------- who chose it
def test_provenance_is_derived_when_the_file_does_not_say():
"""A stage that states the limit is a stage somebody set; one that does
not is running panellift's own number. Neither needs a second key."""
assert analysed()[0].stage.tension_limit_source == 'default'
assert analysed(limit=7.5)[0].stage.tension_limit_source == 'job'
def test_the_exporter_can_say_a_stage_overrode_the_job():
"""The one thing this side cannot work out for itself: a handling.stages
entry that beat the job-wide Project Config field. Without it, the
engineer who typed 5.0 into that field and got a 7.5 report has nothing
on the page that explains the difference."""
stage = analysed(limit=7.5, source='stage')[0].stage
assert stage.tension_limit_source == 'stage'
assert 'stage' in stage.tension_basis
def test_an_unknown_provenance_word_is_refused_not_displayed():
"""A label that can be quietly wrong is worse than no label: 'jobb' read
as 'job' would print a confident sentence about the wrong layer."""
with pytest.raises(cli.JobError) as exc:
cli.analyse_job(job(limit=7.5, source='jobb'))
assert 'tension_limit_source' in str(exc.value)
def test_the_basis_string_is_ascii():
"""Findings are printed to whatever console launched the run. A radical
sign there is a crash on a cp1252 code page, not a nicer report."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
stage.tension_basis.encode('ascii')
assert stage.tension_limit_text == "5*sqrt(f'c)"
assert Stage(name='s', multiplier=1.0, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5).tension_limit_text == \
"7.5*sqrt(f'c)"
# ------------------------------------------------ the report cannot be mute
def test_every_finding_that_states_an_allowable_states_the_limit():
"""CRACK is the check the limit decides, and its line has to carry the
coefficient beside the psi -- 444 and 666 on the same concrete are two
doctrines, and the psi alone does not say which."""
found = [f for f in analysed(limit=7.5)[2] if f.code == 'CRACK']
assert found
assert "7.5*sqrt(f'c), job" in found[0].message
def test_the_stress_plot_title_names_the_limit_it_drew():
"""It used to say 5*sqrt(f'c) whatever the stage ran at -- the one place
a 7.5 job was labelled as a 5.0 one."""
svg = report.stress_plot(analysed(limit=7.5)[0])
assert '7.5' in svg
assert 'against 5' not in svg
def test_the_basis_list_says_where_the_coefficient_came_from():
page = rendered(limit=7.5)
assert 'as the job file states it' in page
# notate() dresses "7.5*sqrt(f'c)" up as a real radical over a real
# f-prime-sub-c, not the ASCII the underlying strings still carry.
assert '7.5' + report.RADICAL_FC in page
def test_the_default_is_named_as_a_default_on_the_page():
"""The report must not present panellift's own fallback as a decision
somebody made."""
page = rendered()
assert "panellift&#x27;s own default" in page or \
"panellift's own default" in page
def test_the_stage_table_carries_the_limit_under_the_allowable():
"""Two stages of one piece may run at different limits, so it is per row
rather than a footnote."""
page = rendered(limit=7.5)
assert 'class="limbasis"' in page
# notate() renders "7.5*sqrt(f'c)" as a real radical, so the cell now
# carries nested tags -- match to the closing </span> of the limbasis
# element itself, not to the first '<' inside it.
cell = re.search(r'<span class="limbasis">(.*?)</span></td>', page)
assert cell and '7.5' in cell.group(1) and 'job' in cell.group(1)
assert report.RADICAL_FC in cell.group(1)
def test_the_stage_box_shows_the_limit_beside_the_ratio():
"""The boxes are what a reader takes in and moves on from."""
page = rendered(limit=7.5)
box = page[page.index('<div class="stagebox-detail">'):][:260]
# notate() renders "7.5*sqrt(f'c), job" as a real radical + f-prime-sub-c.
assert '7.5' + report.RADICAL_FC + ', job' in box
+196
View File
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""Which end of the piece is up, when it came from a fallback -- and the red
line telling the reader to go check TOT on the model.
`report.up_end_disclosure()` / `report._up_end_block()` already print the
fallback's own explanation, unconditionally, whenever `source.up_end_basis`
is anything but the ruled mechanism (`RULED_UP_END_BASIS`). What is new here
is `report._up_end_is_origin_defect()`: of the three reasons the ruled
mechanism can decline (see `up_end_from_origin()` in
`WillTools.extension/lib/vibe/knowledge.py`), only the FIRST -- the origin's
own up vector does not point up -- is a defect on the assembly's origin
itself. The other two (near-square; not yet confirmed for this piece's
orientation) are gaps in what the rule covers, not something wrong with the
model, and must never carry the same red call to action.
"""
import re
from conftest import make_panel
from panellift import report
# Verbatim from `up_end_from_origin()`'s FIRST refusal
# (WillTools.extension/lib/vibe/knowledge.py) -- the exact shape
# `liftexport` writes into `source.up_end_basis_why`, and the real text on
# WP401 (2026-09-01).
ORIGIN_NOT_UP_WHY = (
"The origin of assembly WP401 does not stand the piece up: its up "
"basis is (0.0, 1.0, 0.0) rather than world up. One assembly in 282 is "
"like this -- its face normal points up instead -- and on such a "
"piece every reading off the frame is wrong, so nothing is read off "
"this one. Which end is up was therefore read from GLOBAL ELEVATION "
"instead -- the end of the piece that is higher in the model. That is "
"a fallback, not the rule.")
# Verbatim shape of the SECOND refusal (near square) -- must NOT flag.
NEAR_SQUARE_WHY = (
"Assembly PNL127 is near square (11.63 x 11.58 ft, ratio 1.00), and "
"below 1.25 the long direction cannot be told from the up direction.")
# Verbatim shape of the THIRD refusal (not yet confirmed for this
# orientation) -- must NOT flag either; this is a coverage gap, not a
# defect on the model. Matches `up_end_from_origin()`'s wording as fixed
# 2026-09-01: green (BasisY/world-up) is now confirmed for a vertical wall
# panel too, so the message no longer claims nobody has said how vertical
# panels work -- only that this function has not been extended to run
# there, which is a coverage gap and NOT a defect on the origin.
NOT_HORIZONTAL_WHY = (
"The origin rule is confirmed for HORIZONTAL panels only, and the "
"origin of assembly PNL1 stands its length up, which is not one. The "
"owner has confirmed the same green-arrow reading also holds on a "
"vertical wall panel (2026-09-01), but this rule has not been "
"extended to run there yet, so the origin is not read on this piece.")
def _panel_with_fallback(why, basis='global elevation'):
panel = make_panel()
panel.source = {
'up_end_basis': basis,
'up_end_basis_why': why,
'up_end_affects': 'erection -- which row of face lifters the crane '
'cannot reach, and which edge the anchors are on',
}
return panel
# ------------------------------------------------- _up_end_is_origin_defect
def test_the_origin_not_up_refusal_is_flagged():
assert report._up_end_is_origin_defect(ORIGIN_NOT_UP_WHY) is True
def test_the_near_square_refusal_is_not_flagged():
assert report._up_end_is_origin_defect(NEAR_SQUARE_WHY) is False
def test_the_not_yet_confirmed_orientation_refusal_is_not_flagged():
assert report._up_end_is_origin_defect(NOT_HORIZONTAL_WHY) is False
def test_empty_or_none_why_is_not_flagged():
assert report._up_end_is_origin_defect('') is False
assert report._up_end_is_origin_defect(None) is False
# --------------------------------------------------------- _up_end_block()
def test_the_red_line_appears_when_the_origin_itself_is_wrong():
"""WP401's real shape: the origin does not point up at all, so this is
a defect on the assembly's own TOT, not a caveat about the analysis."""
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
block = report._up_end_block(panel)
assert 'tot-flag' in block
assert 'TOT' in block
# The short fact is always visible; the measured reasoning ("does not
# stand the piece up") is inside the fold, not repeated at top level.
assert 'read from global elevation, not the assembly origin' in block
assert 'does not stand the piece up' in block
def test_the_block_is_short_a_few_lines_not_a_wall_of_text():
"""The owner's own words, 2026-09-01: '把这一段从lift的结果里面删掉,
或者缩短...太长了压根不会看的' -- delete this section or shorten it, it
is so long nobody will read it at all. The block he cut was ~700
characters; the always-visible text (outside the fold) must be a small
fraction of that."""
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
block = report._up_end_block(panel)
# Everything inside the fold is closed by default and does not count
# against "what a reader sees at a glance" -- strip it out before
# measuring, the same way a browser would hide it on load. Tags do not
# count either: they are never visible text, only the characters
# between them are.
fold_start = block.index('<details')
fold_end = block.index('</details>') + len('</details>')
always_visible = block[:fold_start] + block[fold_end:]
visible_text = re.sub(r'<[^>]+>', '', always_visible)
assert len(visible_text) < 250, (len(visible_text), visible_text)
def test_fallback_not_the_rule_appears_at_most_once():
"""The old block said this exact clause TWICE in one paragraph -- once
as the report's own intro sentence, once again from the end of the
producer string it quoted. That repetition, not the length alone, was
what the owner named as the reason he stopped reading."""
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
block = report._up_end_block(panel)
assert block.count('fallback, not the rule') <= 1
def test_the_full_reasoning_is_folded_using_the_report_s_own_mechanism():
"""Not deleted, not moved to a different wall of visible text -- folded
behind the SAME <details>/_sec() idiom every other section already
uses, closed by default."""
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
block = report._up_end_block(panel)
assert '<details class="section upend-why">' in block
assert '<summary>Why</summary>' in block
assert 'open' not in block.split('<details')[1].split('>')[0]
# The full producer reasoning is still there -- just inside the fold.
assert 'One assembly in 282' in block
assert 'That is a fallback, not the rule.' in block
def test_the_red_line_is_absent_for_the_near_square_refusal():
"""A near-square piece is genuinely undecidable, not a broken origin --
the red 'go check TOT' line would send the reader to fix a model that
is not wrong."""
panel = _panel_with_fallback(NEAR_SQUARE_WHY)
block = report._up_end_block(panel)
assert block # the disclosure itself still prints
assert 'tot-flag' not in block
assert 'TOT' not in block
def test_the_red_line_is_absent_for_the_not_yet_confirmed_orientation():
"""A vertical panel the rule has not been extended to cover yet is a
known gap, not a defect -- same non-flag as the near-square case."""
panel = _panel_with_fallback(NOT_HORIZONTAL_WHY)
block = report._up_end_block(panel)
assert block
assert 'tot-flag' not in block
assert 'TOT' not in block
def test_no_block_at_all_when_the_ruled_mechanism_answered():
"""`up_end_basis` == the ruled mechanism (`RULED_UP_END_BASIS`) means
there is nothing to disclose -- and therefore nothing to flag."""
panel = _panel_with_fallback('assembly origin should never get here',
basis=report.RULED_UP_END_BASIS)
assert report._up_end_block(panel) == ''
def test_no_block_at_all_when_the_panel_has_no_source_disclosure():
panel = make_panel()
panel.source = {}
assert report._up_end_block(panel) == ''
def test_the_flag_uses_the_same_red_every_fail_on_the_page_already_uses():
"""Match the report's existing severity styling rather than inventing a
new colour -- var(--fail), the same variable `.FAIL` uses."""
assert '.tot-flag{color:var(--fail)' in report.CSS
def test_the_flag_is_small_text_not_a_banner():
"""It sits inside the existing warn-styled .upend box; it must not carry
its own background, border or oversized font that would read as a
second banner on the page."""
css = report.CSS
start = css.index('.tot-flag{')
end = css.index('}', start)
rule = css[start:end]
assert 'font-size:12px' in rule
assert 'background' not in rule
assert 'border' not in rule
+263
View File
@@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""Host tests for the ASCE 7 C&C wall wind-pressure engine (`windload.py`).
Two things are pinned here, both HAND COMPUTED with the arithmetic written into
the test so a failure names the number that moved (PROJECT.md 3.7 discipline,
same as test_capacity.py):
1. The velocity-pressure (qz) chain, and the 7-16 vs 7-22 Kd-placement
difference: qh differs between editions by the factor Kd, but the final
design pressure is identical.
2. The GCp -> pressure step, with the figure-read +/-0.05 uncertainty carried
onto the pressure.
NO CLIENT NUMBERS. Every input below is a generic, round ASCE example chosen so
the closed-form arithmetic is reproducible on paper. The validation against the
Eriksson golden sample (which carries client/job context) lives in scratchpad,
not here -- see the module report.
Worked example used throughout (a textbook-style low-rise wall):
Vult = 115 mph Risk Category II Exposure C
Enclosed Kzt = 1.0 Ke = 1.0
mean roof height h = 30 ft (so Kh = Kz at 30 ft, Exp C = 0.98, both editions)
Kd (C&C) = 0.85 effective wind area A = 100 ft^2
Chain arithmetic (Q_CONST = 0.00256, V^2 = 115^2 = 13225):
7-22 qh = 0.00256 * 0.98 * 1.0 * 1.0 * 13225 = 33.17888 psf (no Kd)
7-16 qh = 0.00256 * 0.98 * 1.0 * 0.85 * 1.0 * 13225 = 28.20205 psf (Kd in qh)
ratio = 28.20205 / 33.17888 = 0.85 = Kd. <-- the version fork
effective pressure multiplier q_eff = 28.20205 psf in BOTH editions
(7-16: q_eff = qh; 7-22: q_eff = qh * Kd = 33.17888 * 0.85 = 28.20205)
GCp at A = 100 ft^2 (Fig. 30.3-1, log-linear between anchors A=10 and A=500):
log10(100) = 2 ; log10(10) = 1 ; log10(500) = 2.698970 ; frac = 1/1.698970
= 0.588593
Zone 4 neg: -1.1 + (-0.8-(-1.1))*0.588593 = -1.1 + 0.176578 = -0.923422
Zone 5 neg: -1.4 + ( 0.6)*0.588593 = -1.4 + 0.353156 = -1.046844
positive : +1.0 + (-0.3)*0.588593 = +1.0 - 0.176578 = +0.823422
Zone 4 design pressures (GCpi = 0.18, enclosed), q_eff = 28.20205 psf:
p_pos = q_eff * ( +0.823422 - (-0.18) ) = 28.20205 * 1.003422 = 28.2986 psf
p_neg = q_eff * ( -0.923422 - (+0.18) ) = 28.20205 * -1.103422 = -31.117 psf
uncertainty band = q_eff * 0.05 = 28.20205 * 0.05 = +/-1.4101 psf
"""
import math
import pytest
from panellift import windload
from panellift.windload import compute
# ------------------------------------------------------------------ constants
V = 115.0
V2 = V * V # 13225
Q = windload.Q_CONST_US # 0.00256
KH_30_C = 0.98 # Table 26.10-1, 30 ft, Exposure C (both editions)
KD = 0.85
def _base(code_version, **kw):
kwargs = dict(
v_ult_mph=V, risk_category="II", exposure="C", enclosure="enclosed",
code_version=code_version, mean_roof_height_ft=30.0,
effective_wind_area_ft2=100.0,
)
kwargs.update(kw)
return compute(**kwargs)
# ============================================================ the qz chain
def test_kh_table_lookup_and_version_difference():
# 30 ft Exposure C is 0.98 in both editions...
assert windload.velocity_pressure_coefficient(30.0, "C", "7-16") == 0.98
assert windload.velocity_pressure_coefficient(30.0, "C", "7-22") == 0.98
# ...but the Exposure B column was revised: 40 ft B is 0.76 in 7-16, 0.74 in
# 7-22. This is a real version difference the engine must not smear.
assert windload.velocity_pressure_coefficient(40.0, "B", "7-16") == 0.76
assert windload.velocity_pressure_coefficient(40.0, "B", "7-22") == 0.74
def test_kh_linear_interpolation():
# 35 ft, Exp C, 7-16: halfway between 0.98 (30) and 1.04 (40) = 1.01.
kh = windload.velocity_pressure_coefficient(35.0, "C", "7-16")
assert kh == pytest.approx(1.01, abs=1e-9)
def test_qh_chain_hand_computed_both_editions():
r16 = _base("7-16")
r22 = _base("7-22")
qh_722 = Q * KH_30_C * 1.0 * 1.0 * V2 # 33.17888
qh_716 = Q * KH_30_C * 1.0 * KD * 1.0 * V2 # 28.20205
assert r22.qh_psf.value == pytest.approx(qh_722, rel=1e-9)
assert r16.qh_psf.value == pytest.approx(qh_716, rel=1e-9)
# The Kd-placement fork, stated as an invariant: 7-16 qh is smaller by Kd.
assert r16.qh_psf.value == pytest.approx(r22.qh_psf.value * KD, rel=1e-12)
assert r16.kd_placement == "in_qz"
assert r22.kd_placement == "in_pressure"
# qh values are genuinely different numbers (not the same reported twice).
assert abs(r16.qh_psf.value - r22.qh_psf.value) > 4.0
def test_ke_from_elevation_and_default():
assert windload.ground_elevation_factor(None) == 1.0
# 5000 ft -> exp(-0.0000362*5000) = exp(-0.181) = 0.83446 (Table 26.9-1 ~0.83)
assert windload.ground_elevation_factor(5000.0) == pytest.approx(0.83446, abs=1e-4)
# ==================================================== GCp -> pressure step
def test_wall_gcp_log_linear_matches_note_intermediates():
# The engine's log-linear read must reproduce the mined notes' derived
# intermediate table (ASCE7-22-ch30-figGCp.md Sec 1.2).
assert windload.wall_gcp(20.0, "zone4_neg") == pytest.approx(-1.05, abs=0.005)
assert windload.wall_gcp(50.0, "zone4_neg") == pytest.approx(-0.98, abs=0.005)
assert windload.wall_gcp(100.0, "zone4_neg") == pytest.approx(-0.92, abs=0.005)
assert windload.wall_gcp(200.0, "zone4_neg") == pytest.approx(-0.87, abs=0.005)
assert windload.wall_gcp(100.0, "zone5_neg") == pytest.approx(-1.05, abs=0.005)
assert windload.wall_gcp(100.0, "pos") == pytest.approx(+0.82, abs=0.005)
# plateaus hold outside [10, 500]
assert windload.wall_gcp(5.0, "zone5_neg") == -1.4
assert windload.wall_gcp(800.0, "pos") == +0.7
def test_zone4_pressures_hand_computed():
r = _base("7-16")
z4 = next(z for z in r.zones if z.zone == 4)
q_eff = 28.20205
gcp_pos = 0.823422
gcp_neg = -0.923422
p_pos = q_eff * (gcp_pos + 0.18) # 28.2986
p_neg = q_eff * (gcp_neg - 0.18) # -31.117
assert z4.gcp_positive == pytest.approx(gcp_pos, abs=1e-4)
assert z4.gcp_negative == pytest.approx(gcp_neg, abs=1e-4)
assert z4.positive_psf == pytest.approx(p_pos, rel=1e-4)
assert z4.negative_psf == pytest.approx(p_neg, rel=1e-4)
def test_zone5_suction_exceeds_zone4():
# Zone 5 (edge/corner strips) is the higher-magnitude suction than Zone 4.
r = _base("7-22")
z4 = next(z for z in r.zones if z.zone == 4)
z5 = next(z for z in r.zones if z.zone == 5)
assert abs(z5.negative_psf) > abs(z4.negative_psf)
# positive is the single combined 4&5 curve -> same for both zones.
assert z4.positive_psf == pytest.approx(z5.positive_psf, rel=1e-12)
def test_final_pressure_identical_across_editions():
# THE headline invariant: Kd sits in a different equation in each edition,
# but the design pressure a caller receives is the same to the last psf.
r16 = _base("7-16")
r22 = _base("7-22")
for zone in (4, 5):
a = next(z for z in r16.zones if z.zone == zone)
b = next(z for z in r22.zones if z.zone == zone)
assert a.positive_psf == pytest.approx(b.positive_psf, rel=1e-12)
assert a.negative_psf == pytest.approx(b.negative_psf, rel=1e-12)
# ==================================================== figure-read uncertainty
def test_figure_read_flag_and_uncertainty_carried():
r = _base("7-16")
q_eff = 28.20205
for z in r.zones:
assert z.figure_read is True
assert z.gcp_pm == 0.05
# +/-0.05 on a figure-read GCp propagates to +/- q_eff*0.05 psf.
assert z.uncertainty_psf == pytest.approx(q_eff * 0.05, rel=1e-6)
assert z.uncertainty_psf == pytest.approx(1.4101, abs=1e-3)
def test_pressures_carry_a_cite():
r = _base("7-22")
assert "Fig. 30.3-1" in r.zones[0].basis
assert "figure-read" in r.zones[0].basis
assert "Eq. 26.10-1" in r.qh_psf.cite
assert "Table 26.10-1" in r.kh.cite
assert "Table 26.13-1" in r.gcpi.cite
# ==================================================== enclosure / GCpi
def test_partially_enclosed_uses_larger_gcpi():
enc = _base("7-16", enclosure="enclosed")
par = _base("7-16", enclosure="partially_enclosed")
assert enc.gcpi.value == 0.18
assert par.gcpi.value == 0.55
# larger internal pressure -> larger design pressures (magnitudes).
ze = next(z for z in enc.zones if z.zone == 4)
zp = next(z for z in par.zones if z.zone == 4)
assert abs(zp.negative_psf) > abs(ze.negative_psf)
def test_open_building_gcpi_zero():
r = _base("7-22", enclosure="open")
assert r.gcpi.value == 0.0
# ==================================================== roof-angle 10% reduction
def test_roof_angle_reduction_applied_only_when_supplied():
no_angle = _base("7-16") # roof_angle_deg None
low_slope = _base("7-16", roof_angle_deg=5.0) # <= 10 deg -> 10% reduction
z_none = next(z for z in no_angle.zones if z.zone == 4)
z_low = next(z for z in low_slope.zones if z.zone == 4)
# GCp reduced 10% when theta <= 10 deg (Fig. 30.3-1 Note 5).
assert z_low.gcp_negative == pytest.approx(z_none.gcp_negative * 0.90, rel=1e-9)
assert any("Note 5 applied" in n for n in low_slope.notes)
assert any("NOT applied" in n for n in no_angle.notes)
# ==================================================== minimum pressure floor
def test_min_16psf_flag():
# A very low wind + interior zone can fall under the 16 psf net floor.
r = compute(v_ult_mph=70.0, risk_category="II", exposure="B",
enclosure="enclosed", code_version="7-22",
mean_roof_height_ft=15.0, effective_wind_area_ft2=500.0)
z4 = next(z for z in r.zones if z.zone == 4)
# positive at low speed / large area should trip the 16-psf floor flag.
assert z4.min_governs_positive == (abs(z4.positive_psf) < 16.0)
if z4.min_governs_positive:
assert any("16 psf" in n for n in r.notes)
# ==================================================== NOT-JUDGED paths
def test_h_over_60_not_judged_names_the_figure():
r16 = _base("7-16", mean_roof_height_ft=77.4)
r22 = _base("7-22", mean_roof_height_ft=77.4)
assert r16.not_judged and "NOT JUDGED" in r16.not_judged
assert r16.zones == []
assert "Fig. 30.5-1" in r16.not_judged and "Eq. 30.5-1" in r16.not_judged
assert "Fig. 30.4-1" in r22.not_judged and "Eq. 30.4-1" in r22.not_judged
def test_unknown_version_not_judged():
r = _base("7-10")
assert r.not_judged and "ASCE_CODE_VERSION" in r.not_judged
assert r.zones == []
def test_wrong_chapter_not_judged():
r = _base("7-16", cc_chapter=27)
assert r.not_judged and "Chapter 30" in r.not_judged
def test_bad_exposure_and_enclosure_not_judged():
assert _base("7-16", exposure="A").not_judged is not None
assert _base("7-16", enclosure="mostly_shut").not_judged is not None
def test_nonpositive_area_not_judged():
assert _base("7-16", effective_wind_area_ft2=0).not_judged is not None
# ==================================================== format smoke test
def test_format_result_runs():
txt = windload.format_result(_base("7-22"))
assert "C&C wall pressures" in txt
assert "Zone 4" in txt and "Zone 5" in txt
txt2 = windload.format_result(_base("7-16", mean_roof_height_ft=100.0))
assert "NOT JUDGED" in txt2