# -*- 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']