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:
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 `ψ`; 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 √ 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>′</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>′</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′<sub>ci</sub>'
|
||||
assert report.notate("f'c") == 'f′<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′<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') == 'φM<sub>n</sub>'
|
||||
assert report.notate('phi-Vn') == 'φV<sub>n</sub>'
|
||||
assert report.notate('M/phi-Mn') == 'M/φM<sub>n</sub>'
|
||||
assert report.notate('V/phi-Vn') == 'V/φV<sub>n</sub>'
|
||||
# Bare phi, e.g. checks.py's `detail += ' (phi {0:.2f})'`.
|
||||
assert report.notate('(phi 0.90)') == '(φ 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 'ℓ<sub>d</sub> 63.7 in.' in out
|
||||
assert 'f<sub>ps</sub> is 187.9 of 243.4 ksi' in out
|
||||
assert 'φ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 'φ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²'
|
||||
# checks.py: 'S {6} in3'
|
||||
assert report.notate('S 1,472 in3') == 'S 1,472 in³'
|
||||
# checks.py: 'sin {4:.0f}deg' and 'the 60deg usually taken as ...'
|
||||
assert report.notate('sin 60deg') == 'sin 60°'
|
||||
assert report.notate('below the 60deg usually') == (
|
||||
'below the 60° 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× 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 '&' in out
|
||||
assert '<panel>' in out
|
||||
assert "Jerry's" in out
|
||||
assert 'f′<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 ≤ b'
|
||||
assert report.notate('a >= b') == 'a ≥ 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 '²' in line
|
||||
assert '<strong>1 cut by a blockout</strong>' in line
|
||||
assert '<' not in line and '>' 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 '<br>' not in page and '<strong>' 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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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>', 'φM<sub>n</sub>',
|
||||
'M/φM<sub>n</sub>', 'φV<sub>n</sub>',
|
||||
'V/φV<sub>n</sub>', 'φ'):
|
||||
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
|
||||
@@ -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'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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user