25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
595 lines
26 KiB
Python
595 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Insulated (sandwich) panels: the refusal, and the section it refuses over.
|
|
|
|
Two kinds of test here and they are kept apart on purpose.
|
|
|
|
**The mechanics** are checked against arithmetic done by hand, written out in
|
|
a comment beside every number, on the dimensions of a real 16 in. insulated
|
|
piece. A symmetric-enough sandwich has closed-form section properties and
|
|
there is no excuse for a test that recomputes the code's own expression --
|
|
a pin that recomputes both sides pins nothing and agrees with any refactor,
|
|
including a wrong one.
|
|
|
|
**The refusal** is pinned as behaviour: which job files trip it, which do not,
|
|
that it produces no report, and that it never returns one of OK/WARN/FAIL.
|
|
|
|
THE PIECE THE FIXTURE IS. Measured on the live model 2026-08-31: envelope
|
|
16.0 in, 4 in. EPS between the wythes, two concrete solids of 79.885 ft3 and
|
|
39.557 ft3, both at 150.28 pcf, length 27.94 ft, width across 5.33 ft.
|
|
|
|
the wythes are in the ratio 79.885 / 39.557 = 2.0195
|
|
they share 16 - 4 = 12 in. of concrete
|
|
12 * 2.0195 / 3.0195 = 8.025 -> 8 in. structural
|
|
12 - 8 = 4 -> 4 in. architectural
|
|
|
|
so the layup is 4 in. concrete + 4 in. EPS + 8 in. concrete = 16 in., which
|
|
is a textbook layup and is what the fixture below is built from. Everything
|
|
is worked at b = 64 in. (the 5.33 ft width, no opening), because a clean
|
|
width makes the hand arithmetic checkable by eye.
|
|
"""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from panellift import cli, report, sandwich
|
|
|
|
|
|
# The layup of the real piece, and the width it is worked at.
|
|
T_ARCH = 4.0
|
|
T_STRUCT = 8.0
|
|
ENVELOPE = 16.0
|
|
B = 64.0
|
|
|
|
|
|
def wp_layup():
|
|
"""4 in. concrete, 4 in. EPS, 8 in. concrete, form face at z = 0."""
|
|
return sandwich.Layup(
|
|
layers=[
|
|
sandwich.Layer(sandwich.CONCRETE, 0.0, 4.0, 'ARCH WYTHE',
|
|
150.28, 39.557),
|
|
sandwich.Layer(sandwich.NOT_CONCRETE, 4.0, 8.0, 'EPS', 1.5, 39.8),
|
|
sandwich.Layer(sandwich.CONCRETE, 8.0, 16.0, 'STRUCT WYTHE',
|
|
150.28, 79.885),
|
|
],
|
|
envelope_in=ENVELOPE, measured=True, why='test fixture')
|
|
|
|
|
|
def sandwich_source(positioned=True):
|
|
"""`source` as the exporter writes it for this piece."""
|
|
parts = [
|
|
{'volume_ft3': 79.885, 'material': 'STRUCT WYTHE',
|
|
'density_pcf': 150.28, 'counted': True},
|
|
{'volume_ft3': 39.557, 'material': 'ARCH WYTHE',
|
|
'density_pcf': 150.28, 'counted': True},
|
|
{'volume_ft3': 39.8, 'material': 'EPS', 'density_pcf': 1.5,
|
|
'counted': False},
|
|
]
|
|
if positioned:
|
|
for part, (z0, z1) in zip(parts, [(8.0, 16.0), (0.0, 4.0),
|
|
(4.0, 8.0)]):
|
|
part['z0_in'], part['z1_in'] = z0, z1
|
|
return {'solid_parts': parts, 'envelope_thickness_in': ENVELOPE,
|
|
'solid_basis': 'material density'}
|
|
|
|
|
|
# ==================================================================== section
|
|
#
|
|
# Every expected number below is arithmetic, written out, on b = 64 in.
|
|
|
|
def test_non_composite_inertia_is_the_sum_about_each_wythes_own_axis():
|
|
# I = b*t1^3/12 + b*t2^3/12
|
|
# = 64*4^3/12 + 64*8^3/12
|
|
# = 64*64/12 + 64*512/12 = 341.3333 + 2730.6667 = 3072.0 in^4
|
|
bound = sandwich.non_composite(B, wp_layup())
|
|
assert bound.i_in4 == pytest.approx(3072.0, abs=1e-9)
|
|
|
|
|
|
def test_non_composite_section_modulus_is_governed_by_the_thicker_wythe():
|
|
# Wythes tied by the connectors deflect together, so each takes the
|
|
# moment in proportion to its own stiffness:
|
|
# M_i = M * I_i / sum(I), f_i = M_i*(t_i/2)/I_i = M*(t_i/2)/sum(I)
|
|
# so the extreme fibre stress is set by the THICKEST wythe:
|
|
# S = sum(I) / (t_max/2) = 3072 / 4 = 768.0 in^3
|
|
# and the 4 in. wythe's own fibres are at 3072/2 = 1536 in^3.
|
|
bound = sandwich.non_composite(B, wp_layup())
|
|
assert bound.s_governing_in3 == pytest.approx(768.0, abs=1e-9)
|
|
moduli = sorted(set(round(f.s_in3, 6) for f in bound.fibres))
|
|
assert moduli == [768.0, 1536.0]
|
|
|
|
|
|
def test_fully_composite_neutral_axis_and_inertia():
|
|
# A1 = 64*4 = 256 in^2 at z = 2.0; A2 = 64*8 = 512 in^2 at z = 12.0
|
|
# zbar = (256*2 + 512*12)/768 = (512 + 6144)/768 = 8.666667 in
|
|
# I = 64*4^3/12 + 256*(2 - 8.666667)^2
|
|
# + 64*8^3/12 + 512*(12 - 8.666667)^2
|
|
# = 341.3333 + 11377.7778 + 2730.6667 + 5688.8889 = 20138.6667 in^4
|
|
bound = sandwich.fully_composite(B, wp_layup())
|
|
assert bound.axis_z_in == pytest.approx(8.6666667, abs=1e-6)
|
|
assert bound.i_in4 == pytest.approx(20138.6667, abs=1e-3)
|
|
|
|
|
|
def test_fully_composite_section_moduli_at_both_faces():
|
|
# form face c = 8.666667 in -> S = 20138.6667/8.666667 = 2323.6923
|
|
# far face c = 7.333333 in -> S = 20138.6667/7.333333 = 2746.1818
|
|
bound = sandwich.fully_composite(B, wp_layup())
|
|
by_label = dict((f.label, f.s_in3) for f in bound.fibres)
|
|
assert by_label['form face'] == pytest.approx(2323.6923, abs=1e-3)
|
|
assert by_label['far face'] == pytest.approx(2746.1818, abs=1e-3)
|
|
assert bound.s_governing_in3 == pytest.approx(2323.6923, abs=1e-3)
|
|
|
|
|
|
def test_structural_wythe_alone_is_the_smallest_section_of_the_three():
|
|
# The conservative reading of "is the face wythe structural": the 8 in.
|
|
# wythe alone, S = b*t^2/6 = 64*64/6 = 682.6667 in^3.
|
|
bound = sandwich.structural_wythe_alone(B, wp_layup())
|
|
assert bound.i_in4 == pytest.approx(2730.6667, abs=1e-3)
|
|
assert bound.s_governing_in3 == pytest.approx(682.6667, abs=1e-3)
|
|
|
|
|
|
def test_the_solid_idealisation_is_unconservative_against_BOTH_bounds():
|
|
"""The whole reason this work exists, as a number.
|
|
|
|
solid at 16 in.: I = 64*16^3/12 = 21845.333, S = 64*256/6 = 2730.667
|
|
against fully composite 2730.667/2323.692 = 1.1751
|
|
against non-composite 2730.667/768.0 = 3.5556
|
|
|
|
Both above 1.0 -- so there is no reading of a sandwich under which
|
|
treating it as solid concrete at the envelope depth is the safe
|
|
direction. That is the owner's "把夹芯板当实心算并不 conservative", and
|
|
if this assertion ever flips the refusal has lost its argument.
|
|
"""
|
|
bounds = sandwich.section_bounds(wp_layup(), B)
|
|
assert bounds.solid.i_in4 == pytest.approx(21845.3333, abs=1e-3)
|
|
assert bounds.solid.s_governing_in3 == pytest.approx(2730.6667, abs=1e-3)
|
|
against_composite, against_non = bounds.solid_overstates_by
|
|
assert against_composite == pytest.approx(1.1751, abs=1e-4)
|
|
assert against_non == pytest.approx(3.5556, abs=1e-4)
|
|
assert against_composite > 1.0 and against_non > 1.0
|
|
|
|
|
|
def test_shear_flow_is_the_same_from_either_wythe_as_equilibrium_requires():
|
|
# Q(bottom) = 256*(8.666667 - 2.0) = 256*6.666667 = 1706.6667 in^3
|
|
# Q(top) = 512*(12.0 - 8.666667) = 512*3.333333 = 1706.6667 in^3
|
|
# q/V = Q/I = 1706.6667/20138.6667 = 0.0847458 per inch
|
|
layup = wp_layup()
|
|
comp = sandwich.fully_composite(B, layup)
|
|
flow = sandwich.shear_flow(comp, layup, B)
|
|
assert flow.q_in3 == pytest.approx(1706.6667, abs=1e-3)
|
|
assert flow.per_lb_of_shear == pytest.approx(0.0847458, abs=1e-7)
|
|
top = B * T_STRUCT * abs(comp.axis_z_in - 12.0)
|
|
assert top == pytest.approx(flow.q_in3, abs=1e-6)
|
|
|
|
|
|
def test_connector_demand_at_the_measured_spacing():
|
|
# The design summary says HK ST100'S @ 1'-4" o.c., and the model has
|
|
# 5 rows across the width. Per connector, per pound of shear:
|
|
# 0.0847458 * 16.0 / 5 = 0.271186 lb
|
|
layup = wp_layup()
|
|
flow = sandwich.shear_flow(sandwich.fully_composite(B, layup), layup, B,
|
|
v_lb=10000.0)
|
|
demand = sandwich.connector_demand(flow, spacing_in=16.0, per_row=5)
|
|
# q = 0.0847458 * 10000 = 847.458 lb/in; *16/5 = 2711.86 lb
|
|
assert demand.per_connector_lb == pytest.approx(2711.864, abs=1e-2)
|
|
# And with no capacity anywhere it is a DEMAND, never a verdict.
|
|
assert demand.capacity_lb is None
|
|
assert demand.verdict is None
|
|
assert 'NOT KNOWN' in demand.why
|
|
|
|
|
|
def test_a_demand_with_no_flow_stands_down_rather_than_reading_zero():
|
|
demand = sandwich.connector_demand(None, spacing_in=16.0, per_row=5)
|
|
assert demand.per_connector_lb is None
|
|
assert demand.verdict is None
|
|
|
|
|
|
# ================================================================= detection
|
|
|
|
def test_no_solid_parts_means_could_not_tell_and_never_solid():
|
|
said = sandwich.detect({})
|
|
assert said.sandwich is None
|
|
assert said.sandwich is not False
|
|
assert 'solid_parts' in said.why
|
|
|
|
|
|
def test_one_concrete_solid_and_nothing_left_out_is_a_solid_panel():
|
|
said = sandwich.detect({'solid_parts': [
|
|
{'volume_ft3': 20.0, 'material': 'CONC', 'density_pcf': 150.0,
|
|
'counted': True}]})
|
|
assert said.sandwich is False
|
|
|
|
|
|
def test_a_solid_left_out_as_not_concrete_is_a_sandwich():
|
|
said = sandwich.detect(sandwich_source())
|
|
assert said.sandwich is True
|
|
assert said.basis == 'solid_parts'
|
|
assert 'EPS' in said.why
|
|
|
|
|
|
def test_two_concrete_solids_with_nothing_left_out_still_refuses():
|
|
"""Over-refusing costs an answer; under-refusing prints a third of the
|
|
real stress. Nothing in the file can tell a wythe pair from a panel and a
|
|
ledge, so the safe direction is taken and said out loud."""
|
|
said = sandwich.detect({'solid_parts': [
|
|
{'volume_ft3': 79.9, 'material': 'CONC', 'counted': True},
|
|
{'volume_ft3': 39.6, 'material': 'CONC', 'counted': True}]})
|
|
assert said.sandwich is True
|
|
assert 'ledge' in said.why
|
|
|
|
|
|
def test_solid_parts_without_positions_knows_it_is_a_sandwich_and_no_more():
|
|
said = sandwich.detect(sandwich_source(positioned=False))
|
|
assert said.sandwich is True
|
|
assert said.layup.measured is False
|
|
assert said.layup.complete is False
|
|
bounds = sandwich.section_bounds(said.layup, B)
|
|
assert bounds.usable is False
|
|
assert 'z0_in' in bounds.why
|
|
|
|
|
|
def test_a_layup_that_was_not_measured_computes_no_section():
|
|
"""The state a job file written before the exporter measured positions is
|
|
in. It must refuse, not invent a layup that would look ordinary."""
|
|
bounds = sandwich.section_bounds(
|
|
sandwich.layup_from_source(sandwich_source(positioned=False)), B)
|
|
assert bounds.usable is False
|
|
assert bounds.non_composite is None
|
|
assert bounds.composite is None
|
|
|
|
|
|
# ========================================================== composite ratio
|
|
|
|
def test_the_composite_ratio_stands_down_and_names_both_definitions():
|
|
"""A computed ratio is impossible without connector data -- not hard,
|
|
impossible -- and an unlabelled percentage is a number nobody can check.
|
|
Two definitions, both named, both None."""
|
|
ratio = sandwich.composite_ratio(
|
|
sandwich.non_composite(B, wp_layup()),
|
|
sandwich.fully_composite(B, wp_layup()))
|
|
assert ratio.stiffness is None
|
|
assert ratio.strength is None
|
|
assert 'I_eff' in ratio.definition_stiffness
|
|
assert 'DEFLECTION' in ratio.definition_stiffness
|
|
assert 'STRENGTH' in ratio.definition_strength
|
|
# The two bounding inertias ARE printed: the width of the band is on the
|
|
# page even though the position in it is not.
|
|
assert ratio.i_non_composite_in4 == pytest.approx(3072.0)
|
|
assert ratio.i_full_composite_in4 == pytest.approx(20138.6667, abs=1e-3)
|
|
# And what would unblock each is stated per number, not as one shrug.
|
|
joined = ' '.join(ratio.unblocked_by)
|
|
assert 'stiffness' in joined and 'failure mode' in joined
|
|
|
|
|
|
def test_the_ratio_never_prints_a_percentage_anywhere_in_its_own_text():
|
|
ratio = sandwich.composite_ratio()
|
|
assert '%' not in ratio.why
|
|
|
|
|
|
# ================================================================== concerns
|
|
|
|
def test_the_two_bounds_bracket_flexure_and_shear_flow_and_nothing_else():
|
|
"""可能出现的问题, enumerated. Exactly three quantities are bracketed;
|
|
everything else about a sandwich panel is not a bounds question, and a
|
|
reader who takes the two-bound report as an envelope over all of it is
|
|
the failure this list exists to prevent."""
|
|
items = sandwich.concerns(wp_layup())
|
|
bracketed = [c.title for c in items if c.bracketed]
|
|
assert bracketed == ['wythe flexural tension', 'interface shear flow',
|
|
'deflection and bowing']
|
|
not_bracketed = [c.title for c in items if not c.bracketed]
|
|
assert 'connector force distribution along the span' in not_bracketed
|
|
assert 'connector NORMAL (tension) demand' in not_bracketed
|
|
assert ('local flexure of the face wythe between connectors'
|
|
in not_bracketed)
|
|
|
|
|
|
def test_unequal_wythes_get_their_own_concern():
|
|
items = [c.title for c in sandwich.concerns(wp_layup())]
|
|
assert 'the wythes are unequal' in items
|
|
|
|
|
|
def test_equal_wythes_do_not():
|
|
even = sandwich.Layup(
|
|
layers=[sandwich.Layer(sandwich.CONCRETE, 0.0, 6.0),
|
|
sandwich.Layer(sandwich.NOT_CONCRETE, 6.0, 8.0),
|
|
sandwich.Layer(sandwich.CONCRETE, 8.0, 14.0)],
|
|
envelope_in=14.0, measured=True)
|
|
assert 'the wythes are unequal' not in [c.title
|
|
for c in sandwich.concerns(even)]
|
|
|
|
|
|
def test_the_sign_flip_at_the_inner_face_is_written_down():
|
|
"""The trap a two-fibre check misses: the inner face of the outer wythe
|
|
is in compression non-composite and in tension fully composite, so it
|
|
changes sign between the bounds."""
|
|
layup = wp_layup()
|
|
nc = sandwich.non_composite(B, layup)
|
|
fc = sandwich.fully_composite(B, layup)
|
|
# M = 1,000,000 lb-in, sagging.
|
|
m = 1.0e6
|
|
# non-composite, the 4 in. wythe's own top fibre at z = 4:
|
|
# f = -M*(+2.0)/3072 = -651.0 psi, COMPRESSION
|
|
inner_nc = -m * (T_ARCH / 2.0) / nc.i_in4
|
|
# fully composite at z = 4, which is 4.6667 in BELOW the axis:
|
|
# f = -M*(4 - 8.666667)/20138.667 = +231.7 psi, TENSION
|
|
inner_fc = -m * (4.0 - fc.axis_z_in) / fc.i_in4
|
|
assert inner_nc < 0.0 and inner_fc > 0.0
|
|
said = [c.sentence for c in sandwich.concerns(layup)
|
|
if c.title == 'wythe flexural tension'][0]
|
|
assert 'changes sign between the bounds' in said
|
|
|
|
|
|
# ================================================================== refusal
|
|
|
|
def sandwich_job():
|
|
"""A real-shaped job file for an insulated piece, layup MEASURED.
|
|
|
|
Deliberately an OTHERWISE VALID job: everything here analyses fine, on the
|
|
CONCRETE-WYTHE composite section (insulation excluded) that `section.py`
|
|
now builds. Before 2026-09-02 this was refused outright; the point of the
|
|
refusal was never "a sandwich cannot be analysed", it was "a sandwich must
|
|
not be analysed as one SOLID prism". Now that it is not, a measured
|
|
sandwich runs.
|
|
"""
|
|
from test_cli import aspire_job
|
|
job = aspire_job()
|
|
job['source'] = sandwich_source()
|
|
job['panel']['thickness_in'] = ENVELOPE
|
|
return job
|
|
|
|
|
|
def unmeasured_sandwich_job():
|
|
"""The same piece, but the exporter could not measure the layup positions.
|
|
|
|
This is the makeup that STILL refuses: without z0/z1 there is no centroid,
|
|
no I and no d_v, so there is no section to build and no solid guess is
|
|
allowed to stand in for one.
|
|
"""
|
|
from test_cli import aspire_job
|
|
job = aspire_job()
|
|
job['source'] = sandwich_source(positioned=False)
|
|
job['panel']['thickness_in'] = ENVELOPE
|
|
return job
|
|
|
|
|
|
def test_a_measured_sandwich_now_analyses_instead_of_being_refused():
|
|
"""The defect this closes: a measured sandwich must be analysed, and on
|
|
the concrete-wythe composite section, not refused and not treated solid."""
|
|
from panellift import section
|
|
panel, _rig, cases = cli.analyse_job(sandwich_job())
|
|
assert len(cases) >= 1
|
|
net = section.NetSection(cases[0][0].panel, panel.length_ft / 2.0)
|
|
assert net.is_sandwich is True
|
|
# the section is the concrete only: 4 + 8 = 12 in of concrete, NOT the
|
|
# 16 in envelope, and the shear depth follows the concrete.
|
|
assert net.shear_thickness_in == pytest.approx(T_ARCH + T_STRUCT)
|
|
assert net.thickness_in == pytest.approx(ENVELOPE) # envelope still drawn
|
|
# composite centroid off mid-depth, so the two faces have unequal S.
|
|
assert net.zbar_in == pytest.approx(8.6666667, abs=1e-6)
|
|
assert net.c_form_in != pytest.approx(net.c_far_in)
|
|
# and it is NOT the solid idealisation: area is both wythes, insulation out
|
|
b_in = panel.width_ft * 12.0
|
|
assert net.area_in2 == pytest.approx(b_in * (T_ARCH + T_STRUCT), abs=1e-6)
|
|
|
|
|
|
def test_a_measured_sandwich_writes_a_report_and_does_not_exit_two(tmp_path):
|
|
path = tmp_path / 'job.json'
|
|
out = tmp_path / 'out.html'
|
|
path.write_text(json.dumps(sandwich_job()), encoding='utf-8')
|
|
code = cli.main([str(path), '-o', str(out)])
|
|
assert code in (0, 1) # a verdict, not a refusal (2)
|
|
assert out.exists()
|
|
|
|
|
|
def test_an_unmeasured_sandwich_still_refuses_before_anything_is_calculated():
|
|
with pytest.raises(cli.SandwichSection) as caught:
|
|
cli.analyse_job(unmeasured_sandwich_job())
|
|
message = str(caught.value)
|
|
assert sandwich.REFUSAL_CODE in message
|
|
assert 'overstates the section modulus' in message
|
|
|
|
|
|
def test_the_refusal_is_a_joberror_so_every_existing_caller_still_refuses():
|
|
"""`tools/sweep.py`, the button and the CLI all catch JobError. A new
|
|
exception class they have never heard of would escape as a traceback."""
|
|
assert issubclass(cli.SandwichSection, cli.JobError)
|
|
with pytest.raises(cli.JobError):
|
|
cli.analyse_job(unmeasured_sandwich_job())
|
|
|
|
|
|
def test_the_refusal_carries_the_evidence_it_can_still_give(tmp_path):
|
|
"""An unmeasured layup cannot be bounded, but the refusal still says WHY
|
|
-- the missing positions -- rather than one bare word."""
|
|
with pytest.raises(cli.SandwichSection) as caught:
|
|
cli.analyse_job(unmeasured_sandwich_job())
|
|
bounds = caught.value.bounds
|
|
assert bounds.usable is False
|
|
assert 'z0_in' in bounds.why
|
|
|
|
|
|
def test_the_cli_writes_no_report_and_exits_two_on_an_unmeasured_sandwich(
|
|
tmp_path):
|
|
path = tmp_path / 'job.json'
|
|
out = tmp_path / 'out.html'
|
|
path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8')
|
|
code = cli.main([str(path), '-o', str(out)])
|
|
assert code == 2
|
|
assert not out.exists()
|
|
|
|
|
|
def test_the_refusal_json_never_says_ok_warn_or_fail(tmp_path, capsys):
|
|
path = tmp_path / 'job.json'
|
|
out = tmp_path / 'out.html'
|
|
path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8')
|
|
assert cli.main([str(path), '-o', str(out), '--json']) == 2
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload['verdict'] == 'REFUSED'
|
|
assert payload['verdict'] not in ('OK', 'WARN', 'FAIL')
|
|
assert payload['complete'] is False
|
|
assert payload['composite_ratio']['stiffness'] is None
|
|
assert payload['connector_sufficiency']['verdict'] is None
|
|
|
|
|
|
def test_the_refusal_text_prints_the_evidence_not_just_the_word_no():
|
|
detection = sandwich.detect(sandwich_source())
|
|
bounds = sandwich.section_bounds(detection.layup, B)
|
|
text = sandwich.refusal_text(bounds, detection, 'PNL115', 16.0, 5)
|
|
assert 'NO STAGE WAS ANALYSED' in text
|
|
assert 'non-composite' in text and 'fully composite' in text
|
|
assert 'NOT COMPUTED' in text # the composite ratio
|
|
assert 'NOT CHECKED' in text # connector sufficiency
|
|
assert 'NOT BRACKETED' in text
|
|
# every bound's assumption travels with its numbers
|
|
assert 'no shear is transferred across the insulation' in text
|
|
|
|
|
|
def test_the_refusal_text_survives_a_layup_it_could_not_measure():
|
|
detection = sandwich.detect(sandwich_source(positioned=False))
|
|
bounds = sandwich.section_bounds(detection.layup, B)
|
|
text = sandwich.refusal_text(bounds, detection, 'PNL115')
|
|
assert 'could not be bounded' in text
|
|
assert 'NOT BRACKETED' in text
|
|
|
|
|
|
# ============================================== the solid path does not move
|
|
|
|
SOLID_GOLDEN = {
|
|
# Typed in, not recomputed. A pin that recomputes both sides agrees with
|
|
# any refactor, including a wrong one -- the rule the takeoff count was
|
|
# pinned under. These are the ASPIRE four-point pick as it stood before
|
|
# any of the sandwich work.
|
|
'verdict': 'WARN',
|
|
'complete': True,
|
|
'multiplier': 1.3,
|
|
'fc_psi': 4000.0,
|
|
'peak_tension_psi': 244.71194093343502,
|
|
'peak_at_ft': 15.992,
|
|
'allowable_psi': 316.2277660168379,
|
|
'ratio': 0.7738471039902456,
|
|
'as_built_ratio': 0.7738471039902419,
|
|
'recommended_ratio': 0.7682552280488836,
|
|
}
|
|
|
|
|
|
def test_a_solid_panel_is_byte_for_byte_the_number_it_always_was():
|
|
from test_cli import aspire_job
|
|
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
|
summary = cli.summarise(cases)
|
|
stage = summary['stages'][0]
|
|
assert summary['verdict'] == SOLID_GOLDEN['verdict']
|
|
assert summary['complete'] is SOLID_GOLDEN['complete']
|
|
for key in ('multiplier', 'fc_psi', 'peak_tension_psi', 'peak_at_ft',
|
|
'allowable_psi', 'ratio'):
|
|
assert repr(stage[key]) == repr(SOLID_GOLDEN[key]), key
|
|
rec = summary['recommendation']
|
|
assert repr(rec['as_built_ratio']) == repr(SOLID_GOLDEN['as_built_ratio'])
|
|
assert repr(rec['recommended_ratio']) == \
|
|
repr(SOLID_GOLDEN['recommended_ratio'])
|
|
|
|
|
|
def test_a_solid_job_gains_no_new_summary_keys():
|
|
"""`basis` is written only when the up-end rule did NOT answer, the same
|
|
"only when it is not the ordinary case" rule `searched` and `enabled`
|
|
follow. A job file with no source block must summarise exactly as it did
|
|
before this existed."""
|
|
from test_cli import aspire_job
|
|
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
|
assert sorted(cli.summarise(cases)) == [
|
|
'complete', 'not_run', 'recommendation', 'stages', 'verdict']
|
|
|
|
|
|
# ==================================================== which end is up (G2)
|
|
|
|
def test_the_up_end_fallback_is_disclosed_on_the_page_and_in_the_summary():
|
|
"""The refusal `knowledge.up_end_from_origin` makes is the project's
|
|
flagship declared-coverage refusal, and until now `erected_orientation`
|
|
could override it with a fallback whose reason nothing read. If the
|
|
best-argued refusal in the tree can be undone by an `or` in its caller,
|
|
the idiom is a comment."""
|
|
from test_cli import aspire_job
|
|
job = aspire_job()
|
|
job['source'] = {
|
|
'up_end_basis': 'global elevation',
|
|
'up_end_basis_why': 'assembly X is not horizontal, so the rule '
|
|
'stands down. Which end is up was therefore read '
|
|
'from GLOBAL ELEVATION instead.'}
|
|
_panel, _rig, cases = cli.analyse_job(job)
|
|
summary = cli.summarise(cases)
|
|
assert summary['basis']['basis'] == 'global elevation'
|
|
assert 'stands down' in summary['basis']['why']
|
|
html = report.build_many(cases)
|
|
# Shortened 2026-09-01 (owner: "把这一段从lift的结果里面删掉,或者缩短
|
|
# ...太长了压根不会看的") -- the always-visible line states the fact in
|
|
# a few words; the producer's own reasoning (which still names GLOBAL
|
|
# ELEVATION) is folded, not deleted.
|
|
assert 'read from global elevation, not the assembly origin' in html
|
|
assert 'GLOBAL ELEVATION' in html
|
|
# and it says which stage it actually decides
|
|
assert 'erection' in html
|
|
|
|
|
|
def test_the_ruled_basis_is_not_shouted_about():
|
|
"""A page that shouts about a rule that worked teaches the reader to skip
|
|
the block on the day it did not."""
|
|
from test_cli import aspire_job
|
|
job = aspire_job()
|
|
job['source'] = {'up_end_basis': report.RULED_UP_END_BASIS,
|
|
'up_end_basis_why': 'the origin answered'}
|
|
_panel, _rig, cases = cli.analyse_job(job)
|
|
assert 'basis' not in cli.summarise(cases)
|
|
assert 'came from a fallback' not in report.build_many(cases)
|
|
|
|
|
|
def test_the_job_level_source_actually_reaches_the_panel():
|
|
"""It did not, for the whole life of the exporter: `read_panel` looks for
|
|
`source` inside `panel` and the exporter writes it at job level, so
|
|
`panel.source` came out empty on every real job file and everything the
|
|
Revit side recorded was unreadable from here."""
|
|
from test_cli import aspire_job
|
|
job = aspire_job()
|
|
job['source'] = {'up_end_basis': 'global elevation', 'model': 'sample_job'}
|
|
panel, _rig, _cases = cli.analyse_job(job)
|
|
assert panel.source['up_end_basis'] == 'global elevation'
|
|
|
|
|
|
def test_transpose_still_wins_over_the_job_source_for_its_own_key():
|
|
"""`transpose` writes `prestress_not_credited` about the TURNED copy;
|
|
a job-level key of the same name must not overwrite it."""
|
|
from panellift.model import Panel, Prestress, Strand
|
|
panel = Panel(mark='T', length_ft=20.0, width_ft=8.0, thickness_in=8.0,
|
|
prestress=Prestress(strands=[Strand(y=1.0, z_in=4.0,
|
|
initial_pull_lb=1000.0)]),
|
|
source={'model': 'sample_job'})
|
|
turned = cli.transpose(panel)
|
|
assert 'prestress_not_credited' in turned.source
|
|
assert turned.source['model'] == 'sample_job'
|
|
|
|
|
|
def test_the_solid_parts_keys_are_the_ones_the_exporter_writes():
|
|
"""THE CONTRACT, pinned from this side.
|
|
|
|
`vibe.liftexport._solid_parts` writes exactly these keys on each entry of
|
|
`source.solid_parts`. Neither package can import the other -- that is the
|
|
whole design -- so nothing but a pin at each end stops one being renamed
|
|
under the other. The far half is `test_liftexport.py::
|
|
test_the_solid_parts_keys_are_the_ones_panellift_reads`.
|
|
"""
|
|
part = sandwich_source()['solid_parts'][0]
|
|
assert sorted(part) == ['counted', 'density_pcf', 'material',
|
|
'volume_ft3', 'z0_in', 'z1_in']
|
|
# and every one of them is actually consumed
|
|
layer = sandwich._layer_from_part(part)
|
|
assert layer.kind == sandwich.CONCRETE
|
|
assert layer.z0_in == 8.0 and layer.z1_in == 16.0
|
|
assert layer.material == 'STRUCT WYTHE'
|
|
assert layer.density_pcf == 150.28
|
|
assert layer.volume_ft3 == 79.885
|
|
|
|
|
|
def test_the_layup_block_the_exporter_writes_is_not_needed_to_detect():
|
|
"""`source.layup` is the same facts arranged for the section; the
|
|
DETECTION runs off `solid_parts` alone, so a job file from an exporter
|
|
that could not build a layup still refuses."""
|
|
source = sandwich_source(positioned=False)
|
|
source['layup'] = {'measured': False, 'layers': [], 'why': 'x'}
|
|
assert sandwich.detect(source).sandwich is True
|