52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
1023 lines
47 KiB
Python
1023 lines
47 KiB
Python
# -*- 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/φM<sub>n</sub>' 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'<td[^>]*>(.*?)</td>', row)]
|
||
|
||
|
||
def _rows(page):
|
||
body = page[page.index('<tbody>'):page.index('</tbody>')]
|
||
return re.findall(r'<tr[^>]*>(.*?)</tr>', 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 `<td>` sits
|
||
among the others; see the reorder note above `station_table`'s body
|
||
loop."""
|
||
page = report.station_table(_analysis())
|
||
head = page[:page.index('</thead>')]
|
||
order = re.findall(r'<th[^>]*>(.*?)</th>', 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('<ul class="basis">'):]
|
||
assert 'is a dash at <b>every</b> 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('</tbody>')]
|
||
|
||
|
||
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('<ul class="basis">'):]
|
||
assert 'f<sub>se</sub></b> is a dash at <b>every</b> 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('</tbody>')]
|
||
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('</style>')]
|
||
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('<ul class="basis">'):]
|
||
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('<ul class="basis">'):]
|
||
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('<ul class="basis">'):]
|
||
assert 'as the job file states it' in footnote
|
||
assert 'ASTM' not in footnote # the default branch's sentence
|
||
assert 'f<sub>pu</sub> = 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/φM<sub>n</sub>: 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')
|