Files
willtools-demo/panellift/tests/test_sandwich.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

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