# -*- 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