Files
willtools-demo/panellift/tests/test_compression_limit.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

286 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""The compression limit is a job decision too, following the tensile one.
`compression_limit_ratio` multiplies f'c (f'ci at stripping, since the ratio
reads whatever `Stage.fc_psi` the stage was built with) to give the
compression allowable in psi. Unlike the tensile limit, the owner did not
leave two schools of thought open here -- he closed the question,
2026-09-01:
"0.6保持可调" ("0.6 stays adjustable")
0.6 remains the shipped default (ACI 318-19 Table 24.5.3.1, "all other
locations", transfer; Sec. 24.5.4.1, service) and becomes a per-job,
per-stage field exactly like `tension_limit_sqrt_fc`: a job file may state
it, a `stages[]` entry may override it for that stage, and
`compression_limit_source` says which. ACI's higher 0.70*f'ci at member ends
is deliberately NOT implemented -- "先不用实现0.7...别搞这么复杂" -- so
there is one ratio for the whole piece, and nothing here tests a location
split.
Mirrors `test_tension_limit.py` structurally; see that file's docstring for
the reasoning behind the pattern.
"""
import pytest
from panellift import checks, cli, layout, report
from panellift.checks import Finding, StressPoint
from panellift.model import COMPRESSION_LIMIT_SOURCES, Panel, Stage
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT
from panellift import pci
# --------------------------------------------------- the model-level ratio
def test_the_default_ratio_and_source():
"""Byte-identity when nobody sets it -- the same contract
`tension_limit_sqrt_fc` gives at 5.0."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
assert stage.compression_limit_ratio == 0.6
assert stage.compression_limit_source == 'default'
def test_the_ratio_multiplies_the_stages_own_strength():
"""The requirement this whole feature turns on: the ratio multiplies
THIS STAGE'S f'c/f'ci, not the job's f'c or a strength borrowed from
another stage. Two stages with the same ratio and different strengths
(f'ci at stripping vs f'c at yard, say) must scale independently."""
stripping = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
compression_limit_ratio=0.6)
yard = Stage(name='yard', multiplier=1.0, fc_psi=5000.0,
compression_limit_ratio=0.6)
assert stripping.allowable_compression_psi == pytest.approx(1800.0)
assert yard.allowable_compression_psi == pytest.approx(3000.0)
# Same ratio, different psi allowable, because the strength that changed
# was the STAGE's, not the ratio's.
assert stripping.allowable_compression_psi != yard.allowable_compression_psi
def test_a_different_ratio_moves_the_same_stages_allowable():
"""The other half of the same requirement, isolating the ratio instead
of the strength: hold f'ci fixed and change only the ratio."""
base = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
compression_limit_ratio=0.6)
relaxed = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
compression_limit_ratio=0.7)
assert base.allowable_compression_psi == pytest.approx(1800.0)
assert relaxed.allowable_compression_psi == pytest.approx(2100.0)
def test_compression_basis_names_ratio_and_source():
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_ratio=0.7, compression_limit_source='job')
assert stage.compression_basis == "0.7*f'c, job"
def test_the_basis_string_is_ascii():
"""Findings are printed to whatever console launched the run -- no
character here may be outside cp1252."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
stage.compression_basis.encode('ascii')
def test_an_unrecognised_source_word_prints_stated_not_a_guess():
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_source='typo')
assert stage.compression_limit_source_word == 'stated'
# -------------------------------------------------- the check actually uses it
def _points(compression_psi, prestress_lb=1000.0):
return [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0,
stress_psi=0.0, compression_psi=compression_psi,
prestress_lb=prestress_lb)]
def test_no_prestress_means_no_compression_finding():
"""Compression only becomes a question once there is prestress -- see
`check_compression`'s own docstring. Unaffected by this field."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
points = [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0, stress_psi=0.0,
compression_psi=500.0, prestress_lb=0.0)]
assert checks.check_compression(points, stage) == []
def test_the_ratio_decides_the_verdict():
"""0.6*4000 = 2400 psi allowable. 2000 psi passes comfortably; 2500
fails; the stage's OWN ratio is what makes the difference, not a
hard-coded 0.6 inside the check."""
stage_default = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
assert checks.check_compression(_points(2000.0), stage_default)[0] \
.severity == 'OK'
assert checks.check_compression(_points(2500.0), stage_default)[0] \
.severity == 'FAIL'
# The same 2500 psi passes once the job relaxes the ratio: proof the
# number that moved the verdict was the ratio, not the stress.
stage_relaxed = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_ratio=0.7,
compression_limit_source='job')
assert checks.check_compression(_points(2500.0), stage_relaxed)[0] \
.severity == 'OK'
def test_every_compression_finding_states_the_basis():
"""CRUSH is the check the ratio decides, and its line has to carry the
ratio and who chose it beside the psi -- exactly as CRACK does for
tension."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
compression_limit_ratio=0.7, compression_limit_source='job')
found = checks.check_compression(_points(2500.0), stage)
assert found
assert "0.7*f'c, job" in found[0].message
# ------------------------------------------------------- the deck round trip
def _panel():
return Panel(mark='p', length_ft=1.0, width_ft=1.0, thickness_in=1.0)
def test_absent_from_the_deck_is_the_default():
"""A job file written before this field existed leaves both keys out,
and that has to read exactly as it always has: 0.6, default."""
stage, _marks = cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0}, 0, _panel())
assert stage.compression_limit_ratio == 0.6
assert stage.compression_limit_source == 'default'
def test_a_stated_ratio_is_read_as_job():
stage, _marks = cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 0.7}, 0, _panel())
assert stage.compression_limit_ratio == 0.7
assert stage.compression_limit_source == 'job'
def test_the_exporter_can_say_a_stage_overrode_the_job():
stage, _marks = cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 0.7,
'compression_limit_source': 'stage'}, 0, _panel())
assert stage.compression_limit_source == 'stage'
def test_an_unknown_provenance_word_is_refused_not_displayed():
with pytest.raises(cli.JobError) as exc:
cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 0.7,
'compression_limit_source': 'jobb'}, 0, _panel())
assert 'compression_limit_source' in str(exc.value)
def test_a_non_numeric_ratio_is_refused():
"""Mirrors `tension_limit_sqrt_fc`'s own refusal shape: garbage is
refused loudly, never silently coerced to the 0.6 default."""
with pytest.raises(cli.JobError) as exc:
cli.read_stage(
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
'compression_limit_ratio': 'static_lift'}, 0, _panel())
assert 'compression_limit_ratio' in str(exc.value)
def test_the_provenance_words_are_the_ones_the_model_knows():
assert set(COMPRESSION_LIMIT_SOURCES) == set(['default', 'job', 'stage'])
# ------------------------------------------------ end to end, with a real pick
def _job(compression_limit_ratio=None, compression_limit_source=None):
"""The ASPIRE panel, prestressed at mid-depth (e=0) so the only thing
the strands add is uniform P/A compression -- enough to exercise
`check_compression` without pulling in the eccentricity mechanics
`test_prestress.py` already covers on its own piece."""
left, right = pci.optimal_two_line_supports(LENGTH_FT)
pull = 0.153 * 270_000.0 * 0.75
mid_z = THICKNESS_IN / 2.0
stage = {'name': 'stripping',
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
'fc_psi': 4000.0}
if compression_limit_ratio is not None:
stage['compression_limit_ratio'] = compression_limit_ratio
if compression_limit_source is not None:
stage['compression_limit_source'] = compression_limit_source
return {
'schema': 'panellift/1',
'panel': {
'mark': 'ASPIRE-1',
'length_ft': LENGTH_FT,
'width_ft': WIDTH_FT,
'thickness_in': THICKNESS_IN,
'unit_weight_pcf': 150.0,
'fci_psi': 4000.0,
'fc_psi': 5000.0,
'lifters': [
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
'y': 7.5, 'capacity_lb': 12000.0},
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
'y': 7.5, 'capacity_lb': 12000.0},
],
'prestress': {
'strands': [
{'y': 2.5, 'z_in': mid_z, 'initial_pull_lb': pull},
{'y': 7.5, 'z_in': mid_z, 'initial_pull_lb': pull},
],
'loss_pct': 8.0,
},
},
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
'stages': [stage],
}
def _analysed(**kw):
_panel, _rigging, cases = cli.analyse_job(_job(**kw))
return cases[0]
def test_the_stage_carries_the_ratio_through_analyse_job():
stage = _analysed(compression_limit_ratio=0.7,
compression_limit_source='job')[0].stage
assert stage.compression_limit_ratio == 0.7
assert stage.compression_limit_source == 'job'
assert stage.allowable_compression_psi == pytest.approx(0.7 * 4000.0)
def test_a_real_pick_produces_a_named_crush_finding():
"""End to end: a prestressed piece really does report compression, and
the line names the ratio and who chose it -- the report-side
requirement, exercised through the actual arithmetic rather than a
synthetic StressPoint."""
findings = _analysed(compression_limit_ratio=0.6,
compression_limit_source='default')[2]
crush = [f for f in findings if f.code == 'CRUSH']
assert crush
assert "0.6*f'c, default" in crush[0].message
def test_the_default_note_cites_aci_and_the_conservative_choice():
"""The report's note beside the compression allowable has to name the
source -- and when that source is the untouched default, cite ACI and
say plainly that the flat ratio (vs. ACI's 0.70 at member ends) is a
deliberate, conservative simplification."""
page = report.build_many(
[_analysed(compression_limit_ratio=0.6,
compression_limit_source='default')],
recommendation=layout.not_searched())
assert '24.5.3.1' in page or '24.5.4.1' in page
assert '0.70' in page
assert 'ACI' in page
def test_a_job_set_ratio_does_not_get_the_citation():
"""The citation belongs to the number nobody set. A job that chose its
own ratio does not need ACI quoted back at it."""
page = report.build_many(
[_analysed(compression_limit_ratio=0.7,
compression_limit_source='job')],
recommendation=layout.not_searched())
assert 'Compression allowable:' not in page