WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user