8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
245 lines
11 KiB
Python
245 lines
11 KiB
Python
# -*- 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']
|