# -*- 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('') == 5 assert '' 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 lift' 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 lift' 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