# -*- 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_mcr_rises_with_a_concurrent_axial_pu_by_the_hand_calculation(): """M_cr's P/A term picks up a concurrent Pu (Eriksson in-place parity #2). Same section/strand as the sagging M_cr hand check above, PLUS a concurrent axial Pu = 50,000 lb (compression, e.g. gravity through the wall's restraints) fed to `capacity_at`'s new `pu_lb` argument: P/A term now = (108,000 + 50,000) / 384 = 411.4583 psi S*(f_r + P/A) = 512 * (474.34165 + 411.4583) = 453,529.59 lb-in - P*e_z (prestress only, e_z = -2.0) = 216,000.00 lb-in M_cr = 669,529.59 lb-in = 55,794.13 lb-ft `pu_lb` defaults to 0.0, so `test_mcr_sagging_matches_the_hand_ calculation` (no `pu_lb` argument at all) is untouched -- this is the SAME hand-calculated Mcr with one more term added, not a different path. """ panel = hand_panel() stage = hand_stage() point = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0, pu_lb=50000.0) assert abs(point.mcr_lbft - 55794.13) < 0.05 # and it is EXACTLY the pu=0 case plus S*pu/(A*12) -- the pure P/A term. baseline = at(panel, 16.0, moment_lbft=10000.0).mcr_lbft assert abs(point.mcr_lbft - baseline - 512.0 * 50000.0 / 384.0 / 12.0) < 1e-6 def test_pu_zero_capacity_at_leaves_mcr_unchanged(): """`pu_lb`'s default (0.0) reproduces the pre-existing Mcr exactly. Every handling caller (Panel Lift Check) never passes `pu_lb` at all; this pins that NOT passing it and passing 0.0 explicitly are the same number, so the golden handling numbers this module's docstring cites (WPA1.0/WPA5.0) cannot have moved. """ panel = hand_panel() stage = hand_stage() implicit = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0) explicit = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0, pu_lb=0.0) assert implicit.mcr_lbft == explicit.mcr_lbft # ------------------------------------------- strand-only P-M flexure (gap #2) def test_strand_pm_at_pu_zero_matches_the_power_curve_mn(): """`strand_pm_flexure_at(pu_lb=0)` reproduces `_power_curve_flexure`'s Mn. Both solve the SAME neutral-axis equilibrium (C(c) = T(c), no axial) with the SAME per-strand PCI power-curve force (`_pc_strand_force_lb`) -- one reached through `capacity_at`'s `mn_powercurve_lbft` (about the compression resultant), the other through the P-M surface's `mn_lbft` (about the section centroid, Pu=0 in the equilibrium). Algebraically identical (Mn about either point agrees once C = T), so this pins them to stay that way rather than silently drift into two different answers for "the same section, no axial." """ panel = hand_panel() stage = hand_stage() cap = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0) pm = capacity.strand_pm_flexure_at(panel, stage, 16.0, 10000.0, pu_lb=0.0) assert cap.mn_powercurve_lbft is not None assert pm.mn_lbft is not None assert abs(pm.mn_lbft - cap.mn_powercurve_lbft) < 1.0 def test_strand_pm_flexure_rises_with_concurrent_axial_compression(): """More concurrent Pu (compression) raises the strand-only phi*Mn. Measured on the hand section: Mn = 74,763 lb-ft at Pu=0, rising to 79,517 / 86,257 / 96,437 lb-ft at Pu = 20k / 50k / 100k lb -- the same P-M-interaction direction the golden's own printed phi*Mn shows moving with its own Pu column (WPA2.0/A27, pp.30-31: phi*Mn runs 35-104 k-ft in lockstep with Pu 0-374 k in the same row), and the mechanism this closes (root cause #2 in `_scratch/eriksson_parity_2026-09-04.md`). """ panel = hand_panel() stage = hand_stage() mns = [] for pu in (0.0, 20000.0, 50000.0, 100000.0): pm = capacity.strand_pm_flexure_at(panel, stage, 16.0, 10000.0, pu_lb=pu) assert pm.why is None, pm.why mns.append(pm.mn_lbft) assert mns == sorted(mns), 'Mn should rise monotonically with Pu here' assert mns[-1] > mns[0] * 1.2 def test_strand_pm_phi_floor_is_the_strand_065_not_the_mild_075(): """The strand-only P-M path's phi floor is 0.65, not `mild_flexure_at`'s. `phi_flexure_from_strain`'s own default `phi_min` is already the strand (prestressed) floor `pci.PHI_FLEXURE_COMPRESSION_CONTROLLED` -- this pins `strand_pm_flexure_at` never overrides it with the mild 0.75 floor `mild_flexure_at` uses, which the golden's own printed phi column (0.68 at its lowest, WPA2.0/A27) would fall below if this drifted onto the mild floor at a heavily axial-loaded, compression-controlled station. """ panel = hand_panel() stage = hand_stage() # A large enough Pu pushes the section toward compression-controlled -- # measured 0.002536 net tensile strain at 300k lb, inside the transition # (0.002-0.005) where the mild and strand floors would actually disagree. pm = capacity.strand_pm_flexure_at(panel, stage, 16.0, 10000.0, pu_lb=300000.0) assert pm.why is None, pm.why assert pm.phi == pytest.approx( capacity.phi_flexure_from_strain(pm.strain_t)) assert pm.phi < pci.PHI_FLEXURE_COMPRESSION_CONTROLLED_MILD, ( 'phi in the transition should sit BELOW the mild floor, reading on ' 'the lower strand floor (0.65), not the mild one (0.75)') 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'