# -*- coding: utf-8 -*- """The capacity columns: M_cr, phi, phi*M_n, phi*V_n, and the two ratios. Landed 2026-08-31, closing rows A24 / A26 / A27 / A28 / A34 / A36 / A38 of `QNA/eriksson-gap-inventory.md`. Every number in section 1 is HAND COMPUTED, with the arithmetic written into the comment beside it, on a rectangular prestressed section chosen so the closed forms are exact: 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, f_pu = 270 ksi, l_t stated at 30 in. f'c = 4000 is deliberate: it is exactly the corner of ACI Table 22.2.2.4.3, so beta1 = 0.85 with no interpolation to argue about, and w_c = 150 puts lambda at exactly 1.0. That leaves the arithmetic reproducible on paper. The pin is a WRITTEN-DOWN golden, not a recomputation. A test that rebuilds both sides of the comparison agrees with any refactor, including a wrong one -- `PROJECT.md` 3.7. Section 5 is the one that matters most, and on 2026-09-01 it turned over. The columns were informational for one day; the owner ruled that a demand/capacity ratio over 1.0 is a FAIL and that he, not the tool, decides what to do about it. The three assertions that used to pin "no verdict moved" are inverted in place rather than deleted, so the day somebody makes these columns informational again it is those that fail and name the ruling. """ import re import pytest from conftest import LENGTH_FT, aspire_pockets, make_panel from panellift import capacity, checks, pci, prestress, report, statics from panellift.model import Blockout, Panel, Prestress, Stage, Strand # ------------------------------------------------------------ the fixture HAND_B_IN = 48.0 HAND_T_IN = 8.0 HAND_FC = 4000.0 HAND_LT_IN = 30.0 HAND_DB_IN = 0.5 def hand_panel(blockouts=None, z_in=2.0): strands = [Strand(y=0.5 + i, z_in=z_in, area_in2=0.153, initial_pull_lb=30000.0) for i in range(4)] return Panel( mark='HAND-1', length_ft=32.0, width_ft=HAND_B_IN / 12.0, thickness_in=HAND_T_IN, unit_weight_pcf=150.0, fci_psi=HAND_FC, fc_psi=HAND_FC, blockouts=list(blockouts or []), prestress=Prestress(strands=strands, loss_pct=10.0, diameter_in=HAND_DB_IN, transfer_length_in=HAND_LT_IN)) def hand_stage(): return Stage(name='stripping', multiplier=1.3, fc_psi=HAND_FC) def at(panel, x, moment_lbft=10000.0, shear_lb=5000.0): return capacity.capacity_at(panel, hand_stage(), x, moment_lbft, shear_lb) # ------------------------------------------------- 1. the hand arithmetic def test_the_section_properties_are_the_ones_the_hand_check_used(): """A = 48*8 = 384 in^2, S = 48*8^2/6 = 512 in^3, f_r = 7.5*sqrt(4000).""" from panellift import section net = section.NetSection(hand_panel(), 16.0) assert abs(net.area_in2 - 384.0) < 1e-9 assert abs(net.sz_in3 - 512.0) < 1e-9 assert abs(pci.lambda_factor(150.0) - 1.0) < 1e-12 assert abs(pci.beta1(HAND_FC) - 0.85) < 1e-12 # 7.5 * sqrt(4000) = 7.5 * 63.2455532 = 474.34165 psi assert abs(pci.modulus_of_rupture_psi(HAND_FC) - 474.34165) < 1e-4 def test_mcr_sagging_matches_the_hand_calculation(): """M_cr = S*(f_r + P/A) - P*e_z, tension on the form face. P = 4 * 30,000 * (1 - 0.10) = 108,000 lb P/A = 108,000 / 384 = 281.25 psi e_z = 2.0 - 8.0/2 = -2.00 in. S*(f_r + P/A) = 512 * 755.59165 = 386,862.93 lb-in - P*e_z = - 108,000 * (-2.0) = 216,000.00 lb-in M_cr = 602,862.93 lb-in = 50,238.58 lb-ft """ point = at(hand_panel(), 16.0, moment_lbft=+10000.0) assert point.sagging is True assert abs(point.mcr_lbft - 50238.58) < 0.05 def test_mcr_hogging_is_smaller_by_twice_the_prestress_moment(): """Same section, tension on the top face: |M_cr| = S*(f_r + P/A) + P*e_z. 386,862.93 - 216,000 = 170,862.93 lb-in = 14,238.58 lb-ft A strand below mid-depth helps a sagging piece and hurts a hogging one, and the difference here is 2*P*e_z / 12 = 36,000 lb-ft. Absoluting the sign would have printed the sagging number at a hogging station and overstated the cracking moment 3.5x. """ point = at(hand_panel(), 16.0, moment_lbft=-10000.0) assert point.sagging is False assert abs(point.mcr_lbft - 14238.58) < 0.05 assert abs(at(hand_panel(), 16.0, +10000.0).mcr_lbft - point.mcr_lbft - 36000.0) < 0.1 def test_phi_mn_matches_the_hand_calculation(): """ACI 318-19 Eq. 20.3.2.3.1 then the Whitney block. d_p = 8.0 - 2.0 = 6.0000 in. A_ps = 4 * 0.153 = 0.6120 in^2 rho_p = 0.612 / (48 * 6) = 0.002125 f_ps = 270 * (1 - (0.28/0.85)*(0.002125*270000/4000)) = 270 * (1 - 0.3294118*0.1434375) = 257.2425 ksi T = 0.612 * 257,242.5 = 157,432.4 lb a = T / (0.85*4000*48) = 0.964659 in. c = a / 0.85 = 1.134893 in. eps_t = 0.003*(6.0 - 1.134893)/1.134893 = 0.012861 -> phi = 0.90 M_n = T*(6.0 - 0.482330) = 868,660.1 lb-in phi*M_n= 0.9 * 72,388.3 = 65,149.51 lb-ft """ point = at(hand_panel(), 16.0, moment_lbft=+10000.0) assert abs(point.aps_in2 - 0.612) < 1e-9 assert abs(point.dp_in - 6.0) < 1e-9 assert abs(point.fps_ksi - 257.2425) < 1e-4 assert abs(point.a_in - 0.964659) < 1e-6 assert abs(point.c_in - 1.134893) < 1e-6 assert abs(point.strain_t - 0.012861) < 1e-6 assert point.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED == 0.90 assert abs(point.phi_mn_lbft - 65149.51) < 0.05 # and the ratio is the demand handed in, over that assert abs(point.m_over_phi_mn - 10000.0 / 65149.51) < 1e-9 def test_phi_vn_matches_the_hand_calculation_and_uses_the_code_depth_floor(): """V_c = 2*lambda*sqrt(f'c)*b_w*d, phi = 0.75. d_p = 6.00 in., but ACI 22.5.2.2 floors d at 0.80h = 6.40 in. V_c = 2 * 1.0 * 63.2455532 * 48 * 6.4 = 38,858.07 lb phi*V_n = 0.75 * 38,858.07 = 29,143.55 lb The floor is the interesting half. Without it a shallow strand layout would report a shear capacity the code does not require anybody to take. """ point = at(hand_panel(), 16.0, moment_lbft=+10000.0, shear_lb=5000.0) assert abs(point.d_shear_in - 6.4) < 1e-12 assert point.d_floor_applied is True assert point.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED assert pci.PHI_SHEAR == 0.75 assert abs(point.phi_vn_lb - 29143.55) < 0.05 assert abs(point.v_over_phi_vn - 5000.0 / 29143.55) < 1e-7 def test_a_section_that_is_not_tension_controlled_dashes_phi_rather_than_guessing(): """Hogging on this layout puts d_p at 2.0 in. and eps_t at 0.00287. ACI Table 21.2.2's transition between compression- and tension-controlled is not built, so phi and phi*M_n are None WITH a reason. Inventing a phi off an unimplemented transition is the failure this whole package is against, and 0.90 borrowed from the tension-controlled row would overstate the capacity of exactly the sections where it matters. """ point = at(hand_panel(), 16.0, moment_lbft=-10000.0) assert abs(point.strain_t - 0.0028690) < 1e-6 assert point.strain_t < pci.TENSION_CONTROLLED_STRAIN assert point.phi is None assert point.phi_mn_lbft is None assert point.m_over_phi_mn is None assert 'not tension-controlled' in point.reason(capacity.PHI_MN) # M_cr still answers -- it does not depend on phi assert point.mcr_lbft is not None # and so does shear assert point.phi_vn_lb is not None # ------------------------------- 2. transfer and development, one set of them def test_inside_the_transfer_length_fps_ramps_from_zero(): """12 in. from the end, l_t = 30 in.: f_ps = f_se * 12/30. f_se = 30,000 * 0.90 / 0.153 = 176.4706 ksi f_ps = 176.4706 * 0.4 = 70.5882 ksi T = 0.612 * 70,588.2 = 43,200.0 lb (= 0.4 * P, exactly) a = 43,200 / 163,200 = 0.264706 in. phi*M_n= 0.9 * 43,200 * (6 - 0.132353) / 12 = 19,011.18 lb-ft """ point = at(hand_panel(), 1.0, moment_lbft=+10000.0) assert abs(point.fse_ksi - 176.470588) < 1e-5 assert abs(point.fps_ksi - 70.588235) < 1e-5 assert abs(point.phi_mn_lbft - 19011.18) < 0.05 assert point.under_developed is True def test_between_transfer_and_development_fps_ramps_from_fse_to_fps(): """40 in. from the end. l_d = l_t + (f_ps - f_se)*d_b, ACI 25.4.8.1. l_d = 30 + (257.2425 - 176.4706)*0.5 = 70.3860 in. The slope of the second leg is (f_ps - f_se)/(l_d - l_t) = 1/d_b = 2, so f_ps(40) = 176.4706 + 2*(40 - 30) = 196.4706 ksi exactly. phi*M_n = 50,785.93 lb-ft """ point = at(hand_panel(), 40.0 / 12.0, moment_lbft=+10000.0) assert abs(point.fps_ksi - 196.470588) < 1e-5 assert abs(point.phi_mn_lbft - 50785.93) < 0.05 assert point.under_developed is True # the length itself, straight out of pci, built on the transfer length ld = pci.development_length_in(HAND_LT_IN, 257.2425, 176.470588, HAND_DB_IN) assert abs(ld - 70.38596) < 1e-4 def test_the_transfer_length_is_the_one_the_stress_path_already_uses(): """One transfer length in this package, not two. `pci.development_length_in` takes l_t as an ARGUMENT rather than recomputing f_se*d_b/3, and the caller hands it `prestress.transfer_length_in` -- the same function the P/A columns use, which honours a job file that states one. """ panel = hand_panel() assert prestress.transfer_length_in(panel.prestress) == HAND_LT_IN # ld's first term IS lt, so a job stating a different lt moves both assert pci.development_length_in(0.0, 200.0, 200.0, 0.5) == 0.0 assert pci.development_length_in(31.0, 200.0, 200.0, 0.5) == 31.0 def test_the_prestress_force_saturates_at_lt_while_fps_keeps_climbing(): """Not an inconsistency -- two different quantities. f_se, the EFFECTIVE prestress, is fully in the concrete at the transfer length, which is what a transfer length means; M_cr therefore stops growing there. f_ps, the stress at flexural FAILURE, needs the longer development length. So at 40 in. M_cr is already at its full-section value and phi*M_n is still climbing. """ full = at(hand_panel(), 16.0, +10000.0) forty = at(hand_panel(), 40.0 / 12.0, +10000.0) twelve = at(hand_panel(), 1.0, +10000.0) assert abs(forty.mcr_lbft - full.mcr_lbft) < 1e-6 # saturated assert twelve.mcr_lbft < full.mcr_lbft # still ramping assert forty.phi_mn_lbft < full.phi_mn_lbft # still ramping assert twelve.phi_mn_lbft < forty.phi_mn_lbft def test_a_blockout_restarts_the_run_and_a_strand_inside_it_carries_nothing(): """An opening cuts every strand it passes over -- the same rule the prestress module already applies, reached through `bonded_run` and not re-implemented.""" # y = 0.0 .. 2.0 ft catches the strands at y = 0.5 and 1.5; the ones at # 2.5 and 3.5 carry straight through. hole = Blockout(name='B', x0=14.0, y0=0.0, length=4.0, width=2.0) panel = hand_panel(blockouts=[hole]) inside = at(panel, 16.0, +10000.0) assert abs(inside.aps_in2 - 2 * 0.153) < 1e-9 # two survivors only # just past the far edge, the cut pair restarts from zero embedment past = at(panel, 18.01, +10000.0) assert abs(past.aps_in2 - 4 * 0.153) < 1e-9 # all four are bonded assert past.fps_ksi < inside.fps_ksi # but two are undeveloped assert past.under_developed is True # --------------------------------- 3. could not judge, never a zero def test_a_station_with_no_section_dashes_every_capacity_with_a_reason(): hole = Blockout(name='FULL', x0=10.0, y0=0.0, length=4.0, width=4.0) point = at(hand_panel(blockouts=[hole]), 12.0) for column in (capacity.MCR, capacity.PHI_MN, capacity.PHI_VN): assert point.reason(column), column assert point.mcr_lbft is None assert point.phi_mn_lbft is None assert point.phi_vn_lb is None def test_a_piece_with_no_prestress_says_so_rather_than_computing_zero(): panel = hand_panel() panel.prestress = None point = at(panel, 16.0) assert point.phi_mn_lbft is None assert 'no prestressing' in point.reason(capacity.PHI_MN) # M_cr is still real: plain concrete cracks at f_r. # 512 * 474.34165 = 242,862.9 lb-in = 20,238.58 lb-ft assert abs(point.mcr_lbft - 20238.58) < 0.05 # and shear still answers, on the code's 0.80h depth assert abs(point.d_shear_in - 6.4) < 1e-12 def test_the_approximate_fps_equation_is_refused_outside_its_own_range(): """ACI 20.3.2.3.1 holds only where f_se >= 0.5 f_pu. Below that this stands down with the reason rather than using the equation anyway.""" panel = hand_panel() for strand in panel.prestress.strands: strand.initial_pull_lb = 10000.0 # f_se = 58.8 ksi, 0.218 f_pu point = at(panel, 16.0) assert point.phi_mn_lbft is None assert '0.5 f_pu' in point.reason(capacity.PHI_MN) assert point.mcr_lbft is not None # M_cr does not use the equation def test_no_capacity_is_ever_the_neighbouring_stations_value(): """Every point is solved at its own station. Two stations either side of an opening edge differ in b, so they must differ in capacity.""" hole = Blockout(name='B', x0=10.0, y0=0.0, length=4.0, width=2.0) panel = hand_panel(blockouts=[hole]) outside = at(panel, 9.999, +10000.0) inside = at(panel, 10.001, +10000.0) assert outside.b_in > inside.b_in assert outside.mcr_lbft > inside.mcr_lbft assert outside.phi_vn_lb > inside.phi_vn_lb # ------------------------------------ 4. the constants are named and cited def test_the_phi_values_and_lambda_are_named_constants_not_inline_digits(): """A 0.9 typed into an expression is indistinguishable from a fudge factor six months later. This is the tombstone for that.""" import ast source = open(capacity.__file__, encoding='utf-8').read() forbidden = {0.9, 0.75, 7.5, 0.85, 0.005, 0.003, 0.28, 0.8} literals = set() for node in ast.walk(ast.parse(source)): if isinstance(node, ast.Constant) and isinstance(node.value, float): literals.add(node.value) assert not literals & forbidden, sorted(literals & forbidden) assert 'pci.PHI_FLEXURE_TENSION_CONTROLLED' in source assert 'pci.PHI_SHEAR' in source assert 'pci.lambda_factor' in source assert 'pci.TENSION_CONTROLLED_STRAIN' in source def test_every_landed_constant_cites_its_clause(): source = open(pci.__file__, encoding='utf-8').read() for name in ('PHI_FLEXURE_TENSION_CONTROLLED', 'PHI_SHEAR', 'MODULUS_OF_RUPTURE_SQRT_FC', 'CONCRETE_SHEAR_SQRT_FC', 'MIN_D_OVER_H_PRESTRESSED', 'GAMMA_P_LOW_RELAXATION', 'TENSION_CONTROLLED_STRAIN', 'LAMBDA_NORMALWEIGHT_MIN_PCF'): assert name in source, name # the clauses themselves, so a renumbering cannot go unnoticed for clause in ('Table 21.2.1', 'Table 21.2.2', '19.2.3.1', 'Table 19.2.4.1(a)', 'Table 22.2.2.4.3', '20.3.2.3.1', '25.4.8.1', '22.5.5.1', '22.5.2.2'): assert clause in source, clause def test_the_two_sevenpointfives_are_kept_apart_in_writing(): """The rupture modulus and the handling tensile allowable are both 7.5 on this plant and they are different quantities. The constant carries the warning; the report prints it. This test is what stops a tidy-up merging them.""" source = open(pci.__file__, encoding='utf-8').read() assert 'MODULUS_OF_RUPTURE_SQRT_FC = 7.5' in source assert 'tension_limit_sqrt_fc' in source # named as the other one page = report.station_table(_analysis()) assert 'not the' in page and 'tension allowable' in page def test_lambda_comes_off_the_concretes_own_density(): assert pci.lambda_factor(150.0) == 1.0 assert pci.lambda_factor(136.0) == 1.0 assert pci.lambda_factor(100.0) == pci.LAMBDA_ALL_LIGHTWEIGHT == 0.75 assert abs(pci.lambda_factor(120.0) - 0.90) < 1e-12 # a lightweight piece is credited with less tension, everywhere heavy = at(hand_panel(), 16.0) light_panel = hand_panel() light_panel.unit_weight_pcf = 110.0 light = at(light_panel, 16.0) assert light.lam < heavy.lam assert light.mcr_lbft < heavy.mcr_lbft assert light.phi_vn_lb < heavy.phi_vn_lb def test_phi_mn_is_strand_only_because_nothing_can_express_a_bar_layer(): """The justification for dropping the (omega - omega') term, asserted rather than believed. The day a job file can carry mild steel, this test fails and the footnote has to be rewritten with it.""" from panellift import cli import inspect assert 'reinforcement' not in inspect.getsource(cli.read_panel) assert not make_panel().reinforcement # ------------------------------- 5. a ratio over 1.0 IS a verdict, and FAIL # # This section was the opposite of itself until 2026-09-01. It asserted, three # ways, that nothing here could move a verdict. The owner ruled: # # "M/pMn 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail, # 我做最后决策。" # # so the same three assertions are now inverted, in place, rather than deleted # -- the day somebody quietly makes these columns informational again, these # are what fail and say which ruling was undone. def _analysis(panel=None, stage=None): from panellift.model import Rigging panel = panel or make_panel(blockouts=aspire_pockets()) stage = stage or Stage(name='stripping', multiplier=1.3, fc_psi=4000.0) return statics.analyse(panel, stage, Rigging(mode='rolling')) def _prestressed_analysis(): """The hand panel, on a real pick, so phi-Mn exists at most stations.""" panel = hand_panel() panel.lifters = make_panel(blockouts=[]).lifters return _analysis(panel=panel) def test_the_checks_module_now_reads_this_one_because_the_owner_ruled_so(): """The inverse of what this asserted for one day. `checks` HAS to import `capacity` now: the verdict is taken off these numbers.""" import ast source = open(checks.__file__, encoding='utf-8').read() imported = set() for node in ast.walk(ast.parse(source)): if isinstance(node, ast.ImportFrom): imported.update(a.name for a in node.names) elif isinstance(node, ast.Import): imported.update(a.name for a in node.names) assert 'capacity' in imported, sorted(imported) assert 'check_capacity_ratios' in source # and the ruling itself is beside the rule, in the language it was given in assert '大于1.0你就报fail' in open(capacity.__file__, encoding='utf-8').read() def test_run_all_carries_the_capacity_verdict(): from panellift.model import Rigging analysis = _prestressed_analysis() findings = checks.run_all(analysis, Rigging(mode='rolling')) codes = [f.code for f in findings] assert 'FLEX-CAP' in codes and 'SHEAR-CAP' in codes def test_a_ratio_over_one_is_a_FAIL_and_says_whose_ruling_that_is(): """Forced, as it always was: the moment is multiplied until M/phi*M_n passes 1.0. What changed on 2026-09-01 is the answer -- this used to assert 'no verdict moved' and now asserts that one did.""" from panellift.model import Rigging analysis = _prestressed_analysis() analysis.diagram.moment_lbft = [m * 1000.0 for m in analysis.diagram.moment_lbft] findings = checks.run_all(analysis, Rigging(mode='rolling')) flex = [f for f in findings if f.code == 'FLEX-CAP'] assert len(flex) == 1 assert flex[0].severity == 'FAIL' assert checks.worst_severity(findings) == 'FAIL' assert 'M/phi-Mn' in flex[0].message assert 'STRAND ONLY' in flex[0].message # the basis of phi-Mn assert 'may not bless' in flex[0].message # the ruling assert "owner's ruling, 2026-09-01" in flex[0].message assert flex[0].at_ft is not None # the station it happened # the piece and the stage are named, because a sweep reads these in a list assert analysis.panel.mark in flex[0].message assert analysis.stage.name in flex[0].message # and the page says the same thing rather than 'informational' page = report.station_table(analysis) rows = [r for r in _rows(page) if 'over 1.0' in r] assert rows, 'no station exceeded its capacity even at 1000x the moment' assert 'may not bless' in rows[0] assert 'informational' not in page def test_a_ratio_under_one_raises_no_FAIL(): """The mirror of the forcing test. The same piece with the moment scaled away has capacity to spare, and the check must say OK rather than inventing a severity out of a comfortable number.""" from panellift.model import Rigging analysis = _prestressed_analysis() analysis.diagram.moment_lbft = [m * 1e-6 for m in analysis.diagram.moment_lbft] analysis.diagram.shear_lb = [v * 1e-6 for v in analysis.diagram.shear_lb] findings = checks.check_capacity_ratios(analysis) assert findings, 'the check spoke to nothing at all' assert all(f.severity == 'OK' for f in findings), \ [(f.code, f.severity) for f in findings] assert not any(f.severity == 'FAIL' for f in checks.run_all(analysis, Rigging(mode='rolling')) if f.code in ('FLEX-CAP', 'SHEAR-CAP')) def test_exactly_one_point_zero_is_at_the_capacity_and_not_over_it(): """`RATIO_FAIL_ABOVE` is strictly greater, the same convention every other limit in this package uses. A ratio of exactly 1.0 is the capacity.""" assert capacity.RATIO_FAIL_ABOVE == 1.0 analysis = _prestressed_analysis() points = capacity.profile_for(analysis) index, worst, _judged, _total = capacity.worst_ratio(points, 'm_over_phi_mn') assert worst is not None # pin the ratio to exactly 1.0 at that station and nowhere else for i, p in enumerate(points): p.m_over_phi_mn = 1.0 if i == index else None findings = checks.check_capacity_ratios(analysis) flex = [f for f in findings if f.code == 'FLEX-CAP'] assert len(flex) == 1 and flex[0].severity == 'OK' def test_a_station_that_cannot_be_judged_is_no_verdict_at_all(): """A dash is not a pass and not a fail. The ASPIRE panel has no strand, so phi-Mn dashes at every station with its reason already in the footnote -- and the check says nothing rather than passing the piece or failing it.""" from panellift.model import Rigging analysis = _analysis() # no prestress on this panel points = capacity.profile_for(analysis) assert all(p.phi_mn_lbft is None for p in points) assert all(p.reason(capacity.PHI_MN) for p in points) codes = [f.code for f in checks.check_capacity_ratios(analysis)] assert 'FLEX-CAP' not in codes # ... and nothing else in the run turned that silence into a verdict assert 'FLEX-CAP' not in [f.code for f in checks.run_all(analysis, Rigging(mode='rolling'))] def test_the_development_length_note_is_carried_and_is_not_an_exclusion(): """The owner was told all four known exceedances sat inside a development length and ruled FAIL anyway. So the sentence is on the finding -- it is what he decides with -- and the severity is FAIL regardless.""" # An opening across the whole strand pattern, so the worst ratio lands # just outside its edge -- the geometry every real exceedance has. panel = hand_panel(blockouts=[Blockout(name='DOOR', x0=10.0, y0=0.0, length=8.0, width=3.9)]) panel.lifters = make_panel(blockouts=[]).lifters analysis = _analysis(panel=panel) analysis.diagram.moment_lbft = [m * 1000.0 for m in analysis.diagram.moment_lbft] flex = [f for f in checks.check_capacity_ratios(analysis) if f.code == 'FLEX-CAP'][0] assert flex.severity == 'FAIL' assert 'development length of a free end' in flex.message assert 'an opening edge, which cuts every strand it crosses' in flex.message assert 'tends to zero while M_cr does not' in flex.message # the same sentence, from the point itself, and only where it is true points = capacity.profile_for(analysis) under = [p for p in points if p.under_developed] assert under and under[0].development_note() full = [p for p in points if p.fps_ksi is not None and not p.under_developed] assert full and full[0].development_note() is None def test_the_shear_ratio_gets_the_same_rule_even_though_it_has_never_fired(): """V/phi-Vn never reached 0.36 on 184 real pieces. The doctrine is about what the tool may bless, not about which ratio has fired, so it is checked the same way -- forced here, because reality will not do it.""" analysis = _prestressed_analysis() analysis.diagram.shear_lb = [v * 100000.0 for v in analysis.diagram.shear_lb] shear = [f for f in checks.check_capacity_ratios(analysis) if f.code == 'SHEAR-CAP'] assert len(shear) == 1 and shear[0].severity == 'FAIL' assert 'CONCRETE ALONE' in shear[0].message # the development-length sentence belongs to phi-Mn and must not stray # onto a shear finding, which is a concrete capacity assert 'development length' not in shear[0].message def test_the_verdict_scans_every_station_not_the_tables_thinned_rows(): """A verdict that moved with a display stride would be no verdict. The table keeps about 25 rows of 2000; the check reads all of them.""" analysis = _prestressed_analysis() rows, _gov = report.station_indices(analysis, checks.stress_profile(analysis)) points = capacity.profile_for(analysis) assert len(points) == len(analysis.diagram.xs) assert len(points) > 10 * len(rows) _i, worst, judged, total = capacity.worst_ratio(points, 'm_over_phi_mn') assert worst is not None and total == len(points) and judged <= total def test_the_station_a_FAIL_came_from_is_forced_into_the_table(): """A verdict whose evidence is not on the page is a verdict nobody can check, so the worst station is added to the table if the thinning dropped it.""" analysis = _prestressed_analysis() analysis.diagram.moment_lbft = [m * 1000.0 for m in analysis.diagram.moment_lbft] points = capacity.profile_for(analysis) index, _worst, _j, _t = capacity.worst_ratio(points, 'm_over_phi_mn') rows, _gov = report.station_indices(analysis, checks.stress_profile(analysis)) forced = report._with_exceeded_stations(rows, points) assert index in dict(forced) assert 'worst M/phi-Mn' in dict(forced)[index] # The rendered table dresses the ASCII tag up -- notate() turns # "phi-Mn" into real markup; the tag itself is still produced in plain # ASCII by _with_exceeded_stations, checked above. assert 'worst M/φMn' in report.station_table(analysis) def test_the_cached_profile_never_outlives_the_diagram_it_was_measured_on(): """`profile_for` caches on the analysis so the check and the table cannot disagree. A cache that survived a changed diagram would be the same defect as a capacity inherited from the neighbouring station.""" analysis = _prestressed_analysis() first = capacity.profile_for(analysis) assert capacity.profile_for(analysis) is first # cached analysis.diagram.moment_lbft = [m * 1000.0 for m in analysis.diagram.moment_lbft] second = capacity.profile_for(analysis) assert second is not first # and recomputed assert (max(p.m_over_phi_mn for p in second if p.m_over_phi_mn) > max(p.m_over_phi_mn for p in first if p.m_over_phi_mn)) def test_the_stress_check_still_has_its_own_verdict(): """Cracking and flexural capacity are two questions. The ruling added one; it did not replace the other, and the stress path is untouched.""" from panellift.model import Rigging analysis = _prestressed_analysis() findings = checks.run_all(analysis, Rigging(mode='rolling')) crack = [f for f in findings if f.code == 'CRACK'] assert len(crack) == 1 assert 'psi allowable' in crack[0].message page = report.station_table(analysis) assert 'class="gov"' in page # the governing row is still the stress # one, chosen by f_t/allow # ------------------------------------------- 6. what the table does with it def _cells(row): return [re.sub(r'<[^>]+>', '', c) for c in re.findall(r']*>(.*?)', row)] def _rows(page): body = page[page.index(''):page.index('')] return re.findall(r']*>(.*?)', body) def test_the_columns_follow_the_owners_2026_09_01_reorder(): """Superseded by the owner's ruling on this table's column order: verdict ratios right after X, then the moment values, then M_cr, then the shear values (his list named their ratio but not them -- kept and placed the same way, value after the ratio it feeds), then the single phi this table computes, then section and stress, f_se (his own list, added the same day: "加上" -- add it), the tension cluster together, note last. No value or its formatting changed -- only where each `` sits among the others; see the reorder note above `station_table`'s body loop.""" page = report.station_table(_analysis()) head = page[:page.index('')] order = re.findall(r']*>(.*?)', head) plain = [re.sub(r'<[^>]+>', '', h) for h in order] assert plain == [ 'x, ft', 'M/φMn', 'V/φVn', 'M, kip-ft', 'φMn, kip-ft', 'Mcr, kip-ft', 'V, kip', 'φVn, kip', 'φ, flexure', 'S, in³', 'fc, psi', 'fse, ksi', 'ft/allow', 'ft, psi'.replace('t', 't'), 'allow, psi', 'note', ] for row in _rows(page): assert len(_cells(row)) == 16 def test_a_dashed_capacity_carries_its_reason_into_the_note(): """No strand on the piece, so phi*M_n is a dash at every station -- and a reason that holds EVERYWHERE is stated once in the footnote instead of being repeated down twenty-five note cells.""" page = report.station_table(_analysis()) # ASPIRE panel: no strand for row in _rows(page): assert _cells(row)[4] == '—' # phi*M_n em dash, col 4 footnote = page[page.index('
    '):] assert 'is a dash at every station' in footnote assert 'no prestressing on this piece' in footnote # ... and it is NOT in every note cell assert 'no prestressing on this piece' not in page[:page.index('')] def test_fse_reports_the_capacity_points_own_value_unconverted(): """f_se, added 2026-09-01 (owner: "加上" -- add it), sits after f_c on his own column list -- col 11 in the reordered header. The hand panel carries no blockouts, so every strand is bonded at every station and f_se is the same 176.470588 ksi the hand-calculated fixture already pins in `test_inside_the_transfer_length_fps_ramps_from_zero`: no re-derivation here, the table cell has to read straight off `CapacityPoint.fse_ksi`, in ksi, never converted to psi.""" analysis = _prestressed_analysis() page = report.station_table(analysis) rows = _rows(page) assert rows for row in rows: assert _cells(row)[11] == '176.5' # f_se, col 11, one decimal def test_a_strandless_panel_dashes_fse_with_its_reason(): """The ASPIRE panel `_analysis()` defaults to has no prestress at all, so f_se -- like phi*M_n -- dashes at every station, and the reason ('no prestressing on this piece') holds everywhere and is hoisted into the footnote rather than repeated down every row.""" page = report.station_table(_analysis()) # ASPIRE panel: no strand for row in _rows(page): assert _cells(row)[11] == '—' # f_se em dash, col 11 footnote = page[page.index('
      '):] assert 'fse is a dash at every station' in footnote assert 'no prestressing on this piece' in footnote def test_a_local_dash_stays_in_the_note_where_it_happened(): """The strand is undeveloped at the very ends of a real prestressed piece and developed in the middle, so the reason is local and belongs in the row -- not hoisted into the footnote.""" panel = hand_panel() panel.lifters = make_panel().lifters analysis = _analysis(panel=panel) page = report.station_table(analysis) body = page[:page.index('')] assert 'strand not developed at this station' in body def test_fifteen_columns_still_fit_on_paper(): """Paper has no scroll box, so the print block had to shrink the table when the six capacity columns landed. Losing the right-hand columns off the edge of a sheet is the same defect as an absent column, minus the footnote that would have named it.""" from panellift.model import Rigging analysis = _analysis() page = report.build(analysis, Rigging(mode='rolling'), checks.run_all(analysis, Rigging(mode='rolling')), recommendation=False) printed = page[page.index('@media print'):] printed = printed[:printed.index('')] assert 'table.stations{break-inside:auto;font-size:9.5px}' in printed assert 'table.stations th,table.stations td{padding:2px 4px}' in printed # and the on-screen scroll box -- now a fixed-height two-axis box, since # the 2026-09-01 sticky-header/sticky-X change, rather than overflow-x # alone -- is still what absorbs the width assert '.tablewrap{overflow:auto;max-height:70vh}' in page def test_a_capacity_too_small_to_print_never_reads_as_zero(): assert report._cap_cell(None) == '—' assert report._cap_cell(0.004) == '<0.1' assert report._cap_cell(0.0) == '0.0' assert report._cap_cell(65.1495) == '65.1' def test_the_footnote_states_the_method_the_loss_and_the_stage_strength(): panel = hand_panel() panel.lifters = make_panel().lifters page = report.station_table(_analysis(panel=panel)) footnote = page[page.index('
        '):] for stated in ('20.3.2.3.1', # the f_ps method, by clause 'strand only', 'stated assumption, not a calculation', # the loss 'prestress_loss_pct', '10%', # this piece's loss "this stage’s".replace('’', "'"), 'over 1.0 is a FAIL', # the ruling, not 'informational' 'FLEX-CAP', '22.5.5.1', # the shear clause 'Table 21.2.2', # phi '25.4.8.1', # development length 'lambda'.replace('lambda', 'λ')): assert stated in footnote, stated def test_the_fpu_default_says_it_is_a_default_and_shows_the_audit(): panel = hand_panel() panel.lifters = make_panel().lifters page = report.station_table(_analysis(panel=panel)) footnote = page[page.index('
          '):] assert panel.prestress.fpu_source == 'default' assert 'ASTM\nA416'.replace('\n', ' ') in footnote assert "own default — no layer stated one" in footnote assert 'panel.prestress.fpu_ksi' in footnote # the routing key assert 'grade_ksi' in footnote # where it comes from # f_pi = 30,000/0.153/1000 = 196.078 ksi -> 0.726 f_pu assert '196.1' in footnote and '0.726' in footnote def test_a_job_stated_fpu_is_reported_as_stated(): panel = hand_panel() panel.lifters = make_panel().lifters panel.prestress.fpu_ksi = 250.0 panel.prestress.fpu_source = 'job' page = report.station_table(_analysis(panel=panel)) footnote = page[page.index('
            '):] assert 'as the job file states it' in footnote assert 'ASTM' not in footnote # the default branch's sentence assert 'fpu = 250 ksi, as the job file states it' in footnote def test_the_job_file_can_carry_fpu_and_a_bad_one_is_refused(): from panellift import cli block = {'strands': [{'y': 0.5, 'z_in': 2.0, 'initial_pull_lb': 30000.0}]} assert cli.read_prestress(dict(block)).fpu_source == 'default' assert cli.read_prestress(dict(block)).fpu_ksi == 270.0 stated = cli.read_prestress(dict(block, fpu_ksi=250)) assert stated.fpu_ksi == 250.0 and stated.fpu_source == 'job' with pytest.raises(cli.JobError): cli.read_prestress(dict(block, fpu_ksi=0)) def test_capacity_is_solved_only_at_the_stations_that_earned_a_row(): """A batch was measured down to 31 seconds. Solving flexural capacity at all two thousand stations of every stage would put that back for numbers nothing renders.""" analysis = _analysis() rows, _gov = report.station_indices(analysis, checks.stress_profile(analysis)) assert len(rows) < len(analysis.diagram.xs) / 10 points = capacity.capacity_profile(analysis, [i for i, _t in rows]) assert len(points) == len(rows) assert [round(p.x, 6) for p in points] == [ round(analysis.diagram.xs[i], 6) for i, _t in rows] # ---------- 7. 0/0 at a free end is a could-not-judge, not a verdict # # The 282-piece sweep of 2026-09-01 reported FLEX-CAP FAIL on 29 pieces, the # largest FAIL family it had, reading up to 5,853,000%. Every one was the same # thing: the LAST station of the stage, where the closure sends M to zero and # strand development sends phi-Mn to zero, and the residue of one divided by # the residue of the other. # # WP213 transportation: M/phi-Mn 4526367% at 21.96 ft # -- M 0.0 lb-ft against 0.0 lb-ft capacity # # The ruling is about REAL exceedances. A station whose arithmetic is # meaningless is a could-not-judge -- a dash with a reason -- which is the # discipline the whole package is built on. The tests below pin BOTH halves: # the 0/0 station stops being judged, and the real-demand-over-vanished- # capacity station, which is the undeveloped-strand case the owner ruled FAIL # on, is untouched. def _free_end_analysis(): """The 0/0 shape, reproduced rather than mocked. A 21.9583 ft piece on dunnage -- the geometry of the sweep's WP213. The station grid's last x lands 3.6e-15 ft short of the length, so at that station the strand's own bonded run leaves 4.3e-14 in. of embedment and phi-Mn comes out at 2e-11 lb-ft, while the closure leaves M at 1.6e-7 lb-ft. Neither number is a fixture constant: they fall out of the same floating point the real job file's do. """ import dataclasses from panellift.model import Rigging panel = dataclasses.replace(hand_panel(), length_ft=21.9583) stage = Stage(name='transportation', multiplier=1.4, fc_psi=HAND_FC, supports=[4.6112, 17.3471]) return statics.analyse(panel, stage, Rigging(mode='rolling')) def test_the_free_end_station_really_is_the_0_over_0_shape_the_sweep_hit(): """The premise of everything below, asserted before it is relied on. If this stops reproducing, the tests after it are testing nothing -- so it pins the residue itself: both numbers vanishingly small, and their quotient in the hundreds of thousands of percent. """ analysis = _free_end_analysis() points = capacity.profile_for(analysis) end = points[-1] demand = abs(analysis.diagram.moment_lbft[-1]) assert end.phi_mn_lbft is not None # it WAS computed; it is just tiny assert 0.0 < end.phi_mn_lbft < 1e-6 assert 0.0 < demand < 1e-3 assert demand / end.phi_mn_lbft > 1000.0, \ 'the artifact this guards against no longer reproduces' def test_a_station_where_both_numbers_have_vanished_produces_no_ratio(): """0/0 is a dash with a reason, and the reason says why -- never a silent skip, and never a pass.""" analysis = _free_end_analysis() end = capacity.profile_for(analysis)[-1] assert end.m_over_phi_mn is None why = end.reason(capacity.M_OVER_PHI_MN) assert why assert 'both under 1 lb-ft' in why assert '0/0' in why assert 'a dash is not a pass' in why # and the free-end context travels with it, because that is what a reader # of the dash wants next assert 'development length of a free end' in why def test_the_0_over_0_station_no_longer_fails_the_piece(): """The regression itself: this stage reported FLEX-CAP FAIL at several hundred thousand percent off the last station, and must not now.""" from panellift.model import Rigging analysis = _free_end_analysis() findings = checks.run_all(analysis, Rigging(mode='rolling')) flex = [f for f in findings if f.code == 'FLEX-CAP'] assert flex, 'the check went silent instead of judging the other stations' assert all(f.severity != 'FAIL' for f in flex), [f.message for f in flex] def test_the_finding_says_how_many_stations_went_unjudged_and_why(): """A station excluded from the verdict without a word on the page is the silent skip this package exists to prevent.""" analysis = _free_end_analysis() flex = [f for f in checks.check_capacity_ratios(analysis) if f.code == 'FLEX-CAP'][0] assert 'BOTH vanished' in flex.message assert 'a dash is not a pass' in flex.message assert 'still judged, and still fails' in flex.message def test_the_station_table_prints_the_dash_reason_rather_than_a_bare_dash(): """The ratio column dashes at that station. The note cell has to say why -- `_capacity_cells` reads the ratio columns' reasons now, not only the three capacity ones.""" analysis = _free_end_analysis() page = report.station_table(analysis) # notate() dresses "M/phi-Mn" up as real markup in the rendered table. rows = [r for r in _rows(page) if 'M/φMn: M' in r] assert rows, 'the 0/0 station dashed with no reason beside it' assert 'both under 1 lb-ft' in rows[0] assert 'says nothing about the section' in rows[0] # and the footnote states the rule once, for the reader who wants it assert 'Where both numbers have vanished there is no ratio at all' in page def test_real_demand_against_a_nearly_vanished_capacity_is_still_a_FAIL(): """The over-correction this must not become. A station inside a development length with a REAL moment on it is the undeveloped-strand case the owner ruled FAIL on. It fails the DEMAND half of the negligible test, so it is judged exactly as before. The panel is the one the development-length test uses: an opening across the whole strand pattern, so the worst ratio lands just outside its edge. """ panel = hand_panel(blockouts=[Blockout(name='DOOR', x0=10.0, y0=0.0, length=8.0, width=3.9)]) panel.lifters = make_panel(blockouts=[]).lifters analysis = _analysis(panel=panel) analysis.diagram.moment_lbft = [m * 1000.0 for m in analysis.diagram.moment_lbft] points = capacity.profile_for(analysis) index, worst, _judged, _total = capacity.worst_ratio(points, 'm_over_phi_mn') assert worst is not None demand = abs(analysis.diagram.moment_lbft[index]) assert demand > capacity.NEGLIGIBLE_MOMENT_LBFT # a REAL moment assert worst.m_over_phi_mn > capacity.RATIO_FAIL_ABOVE assert worst.under_developed flex = [f for f in checks.check_capacity_ratios(analysis) if f.code == 'FLEX-CAP'][0] assert flex.severity == 'FAIL' assert 'development length of a free end' in flex.message def test_a_vanished_capacity_alone_is_not_enough_to_withhold_the_ratio(): """The guard is an AND, pinned at the function that decides it. A capacity below the floor with a real demand on it still yields a ratio -- that is the FAIL above. Only both together are 0/0. """ tiny, real = 1e-9, 5000.0 floor = capacity.NEGLIGIBLE_MOMENT_LBFT assert capacity._negligible_ratio_reason(tiny, tiny, floor, 'lb-ft', 'M', 'phi-Mn') assert capacity._negligible_ratio_reason(real, tiny, floor, 'lb-ft', 'M', 'phi-Mn') is None assert capacity._negligible_ratio_reason(tiny, real, floor, 'lb-ft', 'M', 'phi-Mn') is None assert capacity._negligible_ratio_reason(real, real, floor, 'lb-ft', 'M', 'phi-Mn') is None # a negative moment is a moment: the sign must not decide this assert capacity._negligible_ratio_reason(-real, tiny, floor, 'lb-ft', 'M', 'phi-Mn') is None # and the floor is exclusive at the boundary, the same convention # RATIO_FAIL_ABOVE uses: exactly at it is not under it assert capacity._negligible_ratio_reason(floor, tiny, floor, 'lb-ft', 'M', 'phi-Mn') is None def test_a_normal_exceedance_is_untouched_by_the_guard(): """The ordinary over-1.0 FAIL, both numbers far above the floor, is exactly what it was before the guard existed.""" from panellift.model import Rigging analysis = _prestressed_analysis() analysis.diagram.moment_lbft = [m * 1000.0 for m in analysis.diagram.moment_lbft] points = capacity.profile_for(analysis) _i, worst, _j, _t = capacity.worst_ratio(points, 'm_over_phi_mn') assert worst.phi_mn_lbft > capacity.NEGLIGIBLE_MOMENT_LBFT flex = [f for f in checks.run_all(analysis, Rigging(mode='rolling')) if f.code == 'FLEX-CAP'] assert len(flex) == 1 and flex[0].severity == 'FAIL' assert 'may not bless' in flex[0].message def test_the_guard_is_not_a_development_length_exclusion(): """The distinction the whole fix rests on, stated as a test. Most of the stations the guard silences ARE inside a development length -- and so are the ones it must leave alone. What separates them is the demand, not the location, and nothing in the deciding function may start branching on where the station is. """ import inspect source = inspect.getsource(capacity._negligible_ratio_reason) for word in ('embed', 'free_end', 'under_developed', 'ld_in'): assert word not in source, ( '{0!r} in the negligible test: it has become a location rule' .format(word)) def test_the_shear_ratio_carries_the_same_guard(): """The defect is in the shape of a demand/capacity ratio, not in flexure. It cannot fire on the real shear path -- phi-Vn is concrete alone with d floored at 0.80h, and the smallest one on the 282-piece sweep is 17,607 lb over 1,534,019 stations, five orders of magnitude above the floor. That is measured rather than assumed, so the guard and its reach are pinned separately. """ analysis = _prestressed_analysis() points = capacity.profile_for(analysis) assert all(p.reason(capacity.V_OVER_PHI_VN) is None for p in points) assert all(p.phi_vn_lb is None or p.phi_vn_lb > capacity.NEGLIGIBLE_SHEAR_LB for p in points) assert capacity._negligible_ratio_reason( 1e-9, 1e-9, capacity.NEGLIGIBLE_SHEAR_LB, 'lb', 'V', 'phi-Vn')