8e6ee31596
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.
1960 lines
86 KiB
Python
1960 lines
86 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for the Revit-free half of vibe.liftexport.
|
|
|
|
`liftexport.export()` measures a live assembly and needs a real Revit
|
|
document -- out of scope here, same as the rest of `vibe`'s Revit-shaped
|
|
code (see conftest.py). Everything below `export()` -- the stage rules, the
|
|
grouping, the dunnage/edge-snap arithmetic -- takes and returns plain dicts
|
|
and floats, and that is what is tested here, built directly from
|
|
`knowledge.DEFAULTS` so nothing needs a knowledge.yaml on disk either.
|
|
|
|
Two of the three bugs found on 2026-08-23 trace to this module:
|
|
|
|
* `default_stages()` used to leave an EMPTY `'lifters': []` on a stage it
|
|
had turned into dunnage supports (fixed in 37b422c, this repo's history).
|
|
`test_dunnage_stage_has_no_lifters_key_at_all` pins the fix: the key must
|
|
be ABSENT, not empty, because that is the contract two callers
|
|
(`tools/sweep_in_revit.py`, the PanelLiftCheck button) rely on to tell
|
|
"picked nothing" apart from "not an insert stage at all".
|
|
* Those two callers then did `len(s['lifters'])` unconditionally and
|
|
KeyError'd the moment a stage actually took that shape (fixed in
|
|
6c41d6e). That fix lives one file away and needs Revit to exercise
|
|
directly; the regression test for the bug is the one above, which pins
|
|
the contract those callers must honour.
|
|
"""
|
|
import sys
|
|
import types
|
|
|
|
from vibe import knowledge, liftexport
|
|
|
|
|
|
# --------------------------------------------------------------- fixtures
|
|
|
|
def make_know(dunnage=None):
|
|
"""A deep copy of knowledge.DEFAULTS, optionally with handling.dunnage set.
|
|
|
|
DEFAULTS already carries the real three-stage stripping/transportation/
|
|
erection schedule (see knowledge.py), so tests exercise the actual
|
|
production stage rules rather than a hand-trimmed copy of them.
|
|
"""
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
if dunnage is not None:
|
|
know['handling']['dunnage'] = dunnage
|
|
return know
|
|
|
|
|
|
def make_lifter(mark, family, x, y, group):
|
|
return {'mark': mark, 'family': family, 'x': x, 'y': y,
|
|
'group': group, 'capacity_lb': 8000.0}
|
|
|
|
|
|
# Two rows of face lifters (bottom at x=3, top at x=17) plus one edge anchor
|
|
# at the very bottom (x=0) -- enough to exercise rows_of's grouping,
|
|
# exclude_top_rows on erection, and both dunnage modes.
|
|
FACE_AND_EDGE_LIFTERS = [
|
|
make_lifter('L1', 'ANCHOR_FACE_4T', 3.0, 2.5, 'face'),
|
|
make_lifter('L2', 'ANCHOR_FACE_4T', 3.0, 7.5, 'face'),
|
|
make_lifter('L3', 'ANCHOR_FACE_4T', 17.0, 2.5, 'face'),
|
|
make_lifter('L4', 'ANCHOR_FACE_4T', 17.0, 7.5, 'face'),
|
|
make_lifter('E1', 'ANCHOR_EDGE', 0.0, 5.0, 'edge_top'),
|
|
]
|
|
|
|
|
|
def make_job(lifters, length_ft=20.0, width_ft=10.0, fc=5000.0, fci=3500.0,
|
|
erected_axis='length'):
|
|
"""A minimal job dict: just enough for default_stages() and friends.
|
|
|
|
`_groups` is carried outside `panel` exactly as liftexport.export()
|
|
leaves it -- see its docstring -- so default_stages() can look a mark
|
|
back up to the group it belongs to.
|
|
"""
|
|
return {
|
|
'panel': {
|
|
'length_ft': length_ft, 'width_ft': width_ft,
|
|
'fc_psi': fc, 'fci_psi': fci,
|
|
'lifters': [dict((k, v) for k, v in l.items() if k != 'group')
|
|
for l in lifters],
|
|
},
|
|
'rigging': {'group_tolerance_ft': 0.4},
|
|
'source': {'erected_vertical_axis': erected_axis},
|
|
'_groups': dict((l['mark'], l['group']) for l in lifters),
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------ strand_area_in2
|
|
|
|
def test_strand_area_in2_known_diameter():
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
assert liftexport.strand_area_in2(know, 0.5) == 0.153
|
|
assert liftexport.strand_area_in2(know, 0.375) == 0.085
|
|
|
|
|
|
def test_strand_area_in2_unknown_diameter_is_none():
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
assert liftexport.strand_area_in2(know, 0.25) is None
|
|
|
|
|
|
def test_strand_area_in2_empty_table_is_none():
|
|
know = {'strands': {'area_in2': {}}}
|
|
assert liftexport.strand_area_in2(know, 0.5) is None
|
|
|
|
|
|
# ------------------------------------------------------------- snap_to_edge
|
|
|
|
def test_snap_to_edge_passthrough_when_not_top_or_bottom():
|
|
# 'measured' (the default 'at') and anything else that is not top/bottom
|
|
# leaves the modelled position exactly alone.
|
|
x, y, fixed_x, fixed_y = liftexport.snap_to_edge(
|
|
'measured', 'length', 4.2, 1.1, 20.0, 10.0)
|
|
assert (x, y, fixed_x, fixed_y) == (4.2, 1.1, False, False)
|
|
|
|
|
|
def test_snap_to_edge_vertical_panel_snaps_x():
|
|
# Erected on end (vertical_axis == 'length'): the top/bottom is an END
|
|
# of the piece, so x snaps to the piece extent and y is left alone.
|
|
x, y, fixed_x, fixed_y = liftexport.snap_to_edge(
|
|
'top', 'length', 4.2, 1.1, 20.0, 10.0)
|
|
assert (x, y, fixed_x, fixed_y) == (20.0, 1.1, True, False)
|
|
|
|
x, y, fixed_x, fixed_y = liftexport.snap_to_edge(
|
|
'bottom', 'length', 4.2, 1.1, 20.0, 10.0)
|
|
assert (x, y, fixed_x, fixed_y) == (0.0, 1.1, True, False)
|
|
|
|
|
|
def test_snap_to_edge_horizontal_panel_snaps_y():
|
|
# Erected on its long edge (vertical_axis == 'width'): the top/bottom is
|
|
# a long EDGE, so y snaps and x -- where along the length -- is a real
|
|
# choice and stays put.
|
|
x, y, fixed_x, fixed_y = liftexport.snap_to_edge(
|
|
'top', 'width', 4.2, 1.1, 20.0, 10.0)
|
|
assert (x, y, fixed_x, fixed_y) == (4.2, 10.0, False, True)
|
|
|
|
x, y, fixed_x, fixed_y = liftexport.snap_to_edge(
|
|
'bottom', 'width', 4.2, 1.1, 20.0, 10.0)
|
|
assert (x, y, fixed_x, fixed_y) == (4.2, 0.0, False, True)
|
|
|
|
|
|
# ----------------------------------------------------------------- rows_of
|
|
|
|
def test_rows_of_groups_within_tolerance():
|
|
lifters = [make_lifter('A', 'F', 3.0, 1.0, 'face'),
|
|
make_lifter('B', 'F', 3.3, 9.0, 'face'),
|
|
make_lifter('C', 'F', 17.0, 1.0, 'face')]
|
|
rows = liftexport.rows_of(lifters, tolerance=0.4, along='x')
|
|
assert len(rows) == 2
|
|
assert rows[0]['marks'] == ['A', 'B']
|
|
assert rows[0]['x'] == (3.0 + 3.3) / 2.0
|
|
assert rows[1]['marks'] == ['C']
|
|
|
|
|
|
def test_rows_of_grouping_is_against_the_running_average_not_the_first_member():
|
|
# Documents the actual grouping rule rather than an idealised one: each
|
|
# new lifter is compared against the ROW'S RUNNING AVERAGE so far, not
|
|
# against the first member of the row. Three lifters half a tolerance
|
|
# apart therefore end up split 2/1, not merged into one row, because the
|
|
# third is compared against the already-shifted average of the first two.
|
|
lifters = [make_lifter('A', 'F', 0.0, 0.0, 'face'),
|
|
make_lifter('B', 'F', 0.35, 0.0, 'face'),
|
|
make_lifter('C', 'F', 0.70, 0.0, 'face')]
|
|
rows = liftexport.rows_of(lifters, tolerance=0.4, along='x')
|
|
assert [r['marks'] for r in rows] == [['A', 'B'], ['C']]
|
|
|
|
|
|
def test_rows_of_along_y():
|
|
lifters = [make_lifter('A', 'F', 1.0, 2.0, 'face'),
|
|
make_lifter('B', 'F', 5.0, 2.1, 'face')]
|
|
rows = liftexport.rows_of(lifters, tolerance=0.4, along='y')
|
|
assert len(rows) == 1
|
|
assert sorted(rows[0]['marks']) == ['A', 'B']
|
|
|
|
|
|
def test_rows_of_group_is_a_set_not_the_first_members_group():
|
|
# An edge anchor and a face lifter at the same station must not have the
|
|
# row stamped with whichever sorted first -- see the docstring in
|
|
# liftexport.rows_of. Losing this cost a real piece two of eight
|
|
# inserts in every stage that wanted them.
|
|
lifters = [make_lifter('L1', 'ANCHOR_FACE_4T', 5.0, 1.0, 'face'),
|
|
make_lifter('E1', 'ANCHOR_EDGE', 5.1, 9.0, 'edge_top')]
|
|
rows = liftexport.rows_of(lifters, tolerance=0.4, along='x')
|
|
assert len(rows) == 1
|
|
assert rows[0]['groups'] == set(['face', 'edge_top'])
|
|
|
|
|
|
def test_rows_of_counts_per_family():
|
|
lifters = [make_lifter('A', 'FAM_A', 1.0, 0.0, 'face'),
|
|
make_lifter('B', 'FAM_A', 1.1, 0.0, 'face'),
|
|
make_lifter('C', 'FAM_B', 1.2, 0.0, 'face')]
|
|
rows = liftexport.rows_of(lifters, tolerance=0.4, along='x')
|
|
assert len(rows) == 1
|
|
assert rows[0]['counts'] == {'FAM_A': 2, 'FAM_B': 1}
|
|
|
|
|
|
# ------------------------------------------------------------ dunnage_supports
|
|
|
|
def test_dunnage_supports_mode_none_is_the_old_approximation():
|
|
know = make_know(dunnage=None)
|
|
supports, problems = liftexport.dunnage_supports(know, 20.0, [])
|
|
assert supports is None
|
|
assert problems == []
|
|
|
|
|
|
def test_dunnage_supports_lifter_rows_mode():
|
|
know = make_know(dunnage={'mode': 'lifter_rows', 'fraction': None})
|
|
rows = [{'x': 3.0}, {'x': 17.0}]
|
|
supports, problems = liftexport.dunnage_supports(know, 20.0, rows)
|
|
assert supports == [3.0, 17.0]
|
|
assert problems == []
|
|
|
|
|
|
def test_dunnage_supports_lifter_rows_refuses_fewer_than_two_rows():
|
|
know = make_know(dunnage={'mode': 'lifter_rows', 'fraction': None})
|
|
supports, problems = liftexport.dunnage_supports(know, 20.0, [{'x': 10.0}])
|
|
assert supports is None
|
|
assert len(problems) == 1
|
|
assert 'lifter_rows' in problems[0]
|
|
assert 'fewer than two' in problems[0]
|
|
|
|
|
|
def test_dunnage_supports_fraction_mode():
|
|
know = make_know(dunnage={'mode': 'fraction', 'fraction': 0.2})
|
|
supports, problems = liftexport.dunnage_supports(know, 20.0, [])
|
|
assert supports == [4.0, 16.0]
|
|
assert problems == []
|
|
|
|
|
|
def test_dunnage_supports_fraction_mode_requires_a_fraction():
|
|
know = make_know(dunnage={'mode': 'fraction', 'fraction': None})
|
|
supports, problems = liftexport.dunnage_supports(know, 20.0, [])
|
|
assert supports is None
|
|
assert len(problems) == 1
|
|
assert 'fraction is not set' in problems[0]
|
|
|
|
|
|
def test_dunnage_supports_fraction_mode_rejects_a_non_number():
|
|
know = make_know(dunnage={'mode': 'fraction', 'fraction': 'lots'})
|
|
supports, problems = liftexport.dunnage_supports(know, 20.0, [])
|
|
assert supports is None
|
|
assert 'is not a number' in problems[0]
|
|
|
|
|
|
def test_dunnage_supports_fraction_mode_rejects_out_of_range():
|
|
know = make_know(dunnage={'mode': 'fraction', 'fraction': 0.6})
|
|
supports, problems = liftexport.dunnage_supports(know, 20.0, [])
|
|
assert supports is None
|
|
assert 'must be between 0 and 0.5' in problems[0]
|
|
|
|
|
|
# ------------------------------------------------------------- default_stages
|
|
|
|
def test_default_stages_basic_shape():
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS)
|
|
stages, all_rows, problems = liftexport.default_stages(job, know)
|
|
|
|
assert [s['name'] for s in stages] == \
|
|
['stripping', 'transportation', 'erection']
|
|
assert problems == []
|
|
|
|
stripping = stages[0]
|
|
# strength: fci for stripping, fc for the other two -- see DEFAULTS.
|
|
assert stripping['fc_psi'] == 3500.0
|
|
assert sorted(stripping['lifters']) == ['L1', 'L2', 'L3', 'L4']
|
|
assert stages[1]['fc_psi'] == 5000.0
|
|
assert sorted(stages[1]['lifters']) == ['L1', 'L2', 'L3', 'L4']
|
|
|
|
|
|
def test_default_stages_erection_excludes_top_row_and_keeps_edge_anchor():
|
|
# DEFAULTS erection: groups face+edge_top, exclude_top_rows: 1. The row
|
|
# dropped must be the topmost row that is PURELY the movable ('face')
|
|
# group -- a row holding the edge anchor too still holds an anchor and
|
|
# must survive. See the long comment above the drop logic in
|
|
# default_stages().
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS)
|
|
stages, _, _ = liftexport.default_stages(job, know)
|
|
erection = stages[2]
|
|
|
|
assert sorted(erection['lifters']) == ['E1', 'L1', 'L2']
|
|
assert erection['_dropped_marks'] == 2 # L3, L4 (the top row)
|
|
assert erection['_available'] == 5 # all 4 face + 1 edge
|
|
|
|
|
|
def test_default_stages_erection_top_row_merged_with_anchor_keeps_both_lines():
|
|
"""Reproduces a real refusal: WPA659, a piece only ~4 ft across the axis
|
|
it erects on. Its two face rows sit 2 ft apart across that axis and the
|
|
edge_top anchor row sits only 1 ft above the higher face row -- within
|
|
group_tolerance_ft, so rows_of() merges the anchor row into the face row
|
|
nearest the erected-up end, leaving ONE mixed row up top and one plain
|
|
face row at the other end.
|
|
|
|
The old exclude_top_rows code filtered to "purely one group" BEFORE
|
|
ranking by position, so once the true top row lost its purity by
|
|
merging with the anchor, the lone surviving pure row -- the one at the
|
|
OTHER end -- was mistaken for "the top face row" and dropped. That left
|
|
a single support line, which panellift's group_lifters correctly
|
|
refuses to analyse as a pick ("all N lifters fall on one line"). The
|
|
fix ranks ALL rows by position first and only drops one that is both in
|
|
the top `exclude_top_rows` slots and still purely the movable group, so
|
|
a row that merged with the anchor is protected and the other end's row
|
|
is left alone.
|
|
"""
|
|
know = make_know()
|
|
lifters = [
|
|
make_lifter('B1', 'ANCHOR_FACE_4T', 5.0, 1.0, 'face'),
|
|
make_lifter('B2', 'ANCHOR_FACE_4T', 15.0, 1.0, 'face'),
|
|
make_lifter('T1', 'ANCHOR_FACE_4T', 5.0, 3.0, 'face'),
|
|
make_lifter('T2', 'ANCHOR_FACE_4T', 15.0, 3.0, 'face'),
|
|
make_lifter('E1', 'ANCHOR_EDGE', 5.0, 4.0, 'edge_top'),
|
|
make_lifter('E2', 'ANCHOR_EDGE', 15.0, 4.0, 'edge_top'),
|
|
]
|
|
job = make_job(lifters, width_ft=4.0, erected_axis='width')
|
|
job['rigging']['group_tolerance_ft'] = 1.0
|
|
stages, _, _ = liftexport.default_stages(job, know)
|
|
erection = next(s for s in stages if s['name'] == 'erection')
|
|
|
|
assert erection['span'] == 'width'
|
|
assert sorted(erection['lifters']) == ['B1', 'B2', 'E1', 'E2', 'T1', 'T2']
|
|
assert erection['_dropped_marks'] == 0
|
|
|
|
|
|
def test_dunnage_stage_has_no_lifters_key_at_all():
|
|
"""THE regression test. See the module docstring.
|
|
|
|
A stage that becomes dunnage supports must have its 'lifters' key
|
|
dropped, not emptied -- panellift refuses a stage naming both a support
|
|
line and an insert, and `sweep_in_revit.py` / PanelLiftCheck used to do
|
|
`len(s['lifters'])` unconditionally, which is exactly what an empty list
|
|
would have kept alive silently.
|
|
"""
|
|
know = make_know(dunnage={'mode': 'fraction', 'fraction': 0.2})
|
|
job = make_job(FACE_AND_EDGE_LIFTERS)
|
|
stages, _, problems = liftexport.default_stages(job, know)
|
|
|
|
transportation = next(s for s in stages if s['name'] == 'transportation')
|
|
assert 'lifters' not in transportation
|
|
assert transportation['supports'] == [4.0, 16.0]
|
|
assert problems == []
|
|
|
|
# The other two stages are unaffected -- only the stage whose pci_case is
|
|
# 'transportation' turns into supports.
|
|
stripping = next(s for s in stages if s['name'] == 'stripping')
|
|
erection = next(s for s in stages if s['name'] == 'erection')
|
|
assert 'lifters' in stripping
|
|
assert 'lifters' in erection
|
|
|
|
|
|
def test_dunnage_stage_falls_back_to_the_old_approximation_on_refusal():
|
|
# Fewer than two stripping rows -> dunnage_supports refuses -> the
|
|
# transportation stage keeps its ordinary 'lifters' key (the old lift
|
|
# approximation) rather than ending up with neither key.
|
|
know = make_know(dunnage={'mode': 'lifter_rows', 'fraction': None})
|
|
one_row = [make_lifter('L1', 'ANCHOR_FACE_4T', 10.0, 2.5, 'face'),
|
|
make_lifter('L2', 'ANCHOR_FACE_4T', 10.0, 7.5, 'face')]
|
|
job = make_job(one_row)
|
|
stages, _, problems = liftexport.default_stages(job, know)
|
|
|
|
transportation = next(s for s in stages if s['name'] == 'transportation')
|
|
assert 'lifters' in transportation
|
|
assert 'supports' not in transportation
|
|
assert len(problems) == 1
|
|
assert 'fewer than two' in problems[0]
|
|
|
|
|
|
# ------------------------------------------------------------- strip_private
|
|
|
|
def test_strip_private_drops_underscored_keys_at_every_level():
|
|
job = {
|
|
'schema': 'panellift/1',
|
|
'_groups': {'L1': 'face'},
|
|
'stages': [
|
|
{'name': 'stripping', 'lifters': ['L1'], '_rows': [{'x': 1.0}],
|
|
'_available': 1, '_dropped_marks': 0},
|
|
{'name': 'transportation', 'supports': [4.0, 16.0],
|
|
'_rows': [], '_available': 0, '_dropped_marks': 0},
|
|
],
|
|
}
|
|
clean = liftexport.strip_private(job)
|
|
|
|
assert '_groups' not in clean
|
|
assert clean['schema'] == 'panellift/1'
|
|
for stage in clean['stages']:
|
|
assert not any(k.startswith('_') for k in stage)
|
|
assert clean['stages'][0] == {'name': 'stripping', 'lifters': ['L1']}
|
|
assert clean['stages'][1] == {'name': 'transportation',
|
|
'supports': [4.0, 16.0]}
|
|
|
|
|
|
def test_strip_private_does_not_mutate_the_original_job():
|
|
job = {'_groups': {'L1': 'face'}, 'stages': [{'name': 's', '_rows': []}]}
|
|
liftexport.strip_private(job)
|
|
assert '_groups' in job
|
|
assert '_rows' in job['stages'][0]
|
|
|
|
|
|
# --------------------------------------- which run mode gets which measure
|
|
|
|
def test_a_single_piece_is_measured_fast_and_a_batch_precisely():
|
|
"""The shipped defaults, and they are opposite on purpose: a batch runs
|
|
unattended so it should be right, one piece is somebody waiting."""
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
assert liftexport._fast_box(know, batch=False) is True
|
|
assert liftexport._fast_box(know, batch=True) is False
|
|
|
|
|
|
def test_each_run_mode_reads_its_own_key():
|
|
know = {'handling': {'fast_lifter_box': False,
|
|
'fast_lifter_box_batch': True}}
|
|
assert liftexport._fast_box(know, batch=False) is False
|
|
assert liftexport._fast_box(know, batch=True) is True
|
|
|
|
|
|
def test_a_missing_key_falls_back_to_that_mode_and_not_the_other():
|
|
"""An empty config must not make a batch fast: nobody chose that, and the
|
|
two keys mean opposite things."""
|
|
assert liftexport._fast_box({}, batch=True) is False
|
|
assert liftexport._fast_box({}, batch=False) is True
|
|
|
|
|
|
# ------------------------------------------------------ stages[].enabled
|
|
|
|
def test_default_stages_writes_no_enabled_key_when_every_stage_is_on():
|
|
"""The flag is additive at the WRITING end too. Every plant configured
|
|
today has no `enabled` in its stage specs, and absent means enabled at
|
|
both ends -- so emitting `"enabled": true` on all of them would rewrite
|
|
every job file on disk to say nothing."""
|
|
stages, _rows, _problems = liftexport.default_stages(
|
|
make_job(FACE_AND_EDGE_LIFTERS), make_know())
|
|
assert all('enabled' not in s for s in stages)
|
|
assert all('disabled_reason' not in s for s in stages)
|
|
|
|
|
|
def test_default_stages_carries_a_switched_off_stage_through():
|
|
"""It stays in the list. That is the whole reason for having a flag
|
|
instead of deleting the stage from handling.stages, where its absence
|
|
would be indistinguishable from a stage that passed."""
|
|
know = make_know()
|
|
know['handling']['stages'][2]['enabled'] = False
|
|
know['handling']['stages'][2]['disabled_reason'] = \
|
|
'the crane on this site cannot reach the top insert row'
|
|
stages, _rows, _problems = liftexport.default_stages(
|
|
make_job(FACE_AND_EDGE_LIFTERS), know)
|
|
|
|
assert [s['name'] for s in stages] == \
|
|
['stripping', 'transportation', 'erection']
|
|
assert stages[2]['enabled'] is False
|
|
assert stages[2]['disabled_reason'] == \
|
|
'the crane on this site cannot reach the top insert row'
|
|
# And the stages around it are untouched.
|
|
assert 'enabled' not in stages[0] and 'enabled' not in stages[1]
|
|
|
|
|
|
def test_a_switched_off_stage_survives_strip_private():
|
|
"""strip_private is what actually reaches the job file, and it drops
|
|
every key of ours. The flag is not one of ours -- panellift reads it."""
|
|
know = make_know()
|
|
know['handling']['stages'][0]['enabled'] = False
|
|
know['handling']['stages'][0]['disabled_reason'] = 'checked by hand'
|
|
job = make_job(FACE_AND_EDGE_LIFTERS)
|
|
job['stages'], _rows, _problems = liftexport.default_stages(job, know)
|
|
clean = liftexport.strip_private(job)
|
|
assert clean['stages'][0]['enabled'] is False
|
|
assert clean['stages'][0]['disabled_reason'] == 'checked by hand'
|
|
|
|
|
|
def test_stage_audit_says_nothing_about_a_stage_that_is_switched_off():
|
|
"""How many inserts a stage was entitled to is not a finding about a
|
|
stage nobody is analysing, and an audit that reports non-findings is an
|
|
audit people learn to skim."""
|
|
# A stage that lost an insert it was entitled to: four available, none
|
|
# deliberately dropped, three picked. That IS a finding when the stage
|
|
# runs, which is what makes the switched-off case worth asserting.
|
|
losing = {'name': 'erection', 'lifters': ['L1', 'L2', 'L3'],
|
|
'_available': 4, '_dropped_marks': 0}
|
|
assert liftexport.stage_audit([dict(losing)])
|
|
assert liftexport.stage_audit([dict(losing, enabled=False)]) == []
|
|
|
|
|
|
# ------------------------------------------------- cast extras, member by member
|
|
#
|
|
# Checklist 2.4 needs the LIST of cast extras, not their total: a number
|
|
# cannot be compared against the set of extras a ticket draws. So
|
|
# `extra_cast_ft3` was split -- `extra_cast_members` is the rule, the total
|
|
# is its sum -- and these pin that the split did not change the total and
|
|
# that an unreadable Volume stays None rather than becoming a quiet 0.0.
|
|
#
|
|
# The fakes below are duck types: extra_cast_members touches only
|
|
# GetMemberIds / GetElement / Symbol.Family.Name / Parameters / Id, and the
|
|
# Mark read is wrapped so it degrades to None off Revit.
|
|
|
|
class _FakeDefinition(object):
|
|
def __init__(self, name):
|
|
self.Name = name
|
|
|
|
|
|
class _FakeParam(object):
|
|
def __init__(self, name, value):
|
|
self.Definition = _FakeDefinition(name)
|
|
self.HasValue = value is not None
|
|
self._value = value
|
|
|
|
def AsDouble(self):
|
|
return self._value
|
|
|
|
|
|
class _FakeId(object):
|
|
def __init__(self, value):
|
|
self.IntegerValue = value
|
|
|
|
|
|
class _FakeFamily(object):
|
|
def __init__(self, name):
|
|
self.Name = name
|
|
|
|
|
|
class _FakeSymbol(object):
|
|
def __init__(self, family):
|
|
self.Family = _FakeFamily(family)
|
|
|
|
|
|
class _FakeMember(object):
|
|
def __init__(self, family, volume, eid=1):
|
|
self.Symbol = _FakeSymbol(family)
|
|
self.Parameters = [_FakeParam('Comments', 1.0),
|
|
_FakeParam('Volume', volume)]
|
|
self.Id = _FakeId(eid)
|
|
|
|
|
|
class _FakeAssembly(object):
|
|
def __init__(self, members):
|
|
self._members = members
|
|
|
|
def GetMemberIds(self):
|
|
return list(range(len(self._members)))
|
|
|
|
|
|
class _FakeDoc(object):
|
|
def __init__(self, members):
|
|
self._members = members
|
|
|
|
def GetElement(self, mid):
|
|
return self._members[mid]
|
|
|
|
|
|
def _cast_doc(members):
|
|
return _FakeDoc(members), _FakeAssembly(members)
|
|
|
|
|
|
def test_extra_cast_members_lists_what_the_total_sums():
|
|
know = make_know()
|
|
members = [_FakeMember('SLAB SEAT LEDGE', 8.99, 11),
|
|
_FakeMember('EMBED PLATE', 0.5, 12),
|
|
_FakeMember('LEDGE B', 1.01, 13)]
|
|
doc, asm = _cast_doc(members)
|
|
out = liftexport.extra_cast_members(doc, asm, know)
|
|
assert [m['family'] for m in out] == ['SLAB SEAT LEDGE', 'LEDGE B']
|
|
assert [m['id'] for m in out] == ['11', '13']
|
|
assert liftexport.extra_cast_ft3(doc, asm, know) == 10.0
|
|
|
|
|
|
def test_an_unreadable_volume_is_none_not_a_quiet_zero():
|
|
"""It contributed nothing to 2.2's reconstructed weight, and 2.4 has to
|
|
be able to SAY that rather than see a 0.0 it cannot tell from a real
|
|
zero-volume member."""
|
|
know = make_know()
|
|
members = [_FakeMember('LEDGE', None, 21)]
|
|
doc, asm = _cast_doc(members)
|
|
out = liftexport.extra_cast_members(doc, asm, know)
|
|
assert out[0]['volume_ft3'] is None
|
|
assert liftexport.extra_cast_ft3(doc, asm, know) == 0.0
|
|
|
|
|
|
def test_no_cast_include_list_means_no_extras_not_a_guess():
|
|
know = make_know()
|
|
know['families']['cast_include'] = []
|
|
doc, asm = _cast_doc([_FakeMember('LEDGE', 8.99)])
|
|
assert liftexport.extra_cast_members(doc, asm, know) == []
|
|
assert liftexport.extra_cast_ft3(doc, asm, know) == 0.0
|
|
|
|
|
|
# ------------------------------------- what the calculation is actually asked
|
|
|
|
# `run()` imports System.Diagnostics and cannot be called off IronPython at
|
|
# all, so the one string that decides what the subprocess is asked to do had
|
|
# no test on it. `runner_script` is that string, split out so it does.
|
|
#
|
|
# The batch passes recommend=False and gets 40 seconds instead of 41 minutes;
|
|
# a flag that silently never reached the command line would look exactly like
|
|
# a batch that is still slow, and a setting that does nothing is the trap
|
|
# CLAUDE.md names for the project config file.
|
|
|
|
def test_the_runner_asks_for_the_position_search_by_default():
|
|
text = liftexport.runner_script('py.exe', 'j.json', 'r.html',
|
|
's.json', 'r.log')
|
|
assert '--no-recommend' not in text
|
|
assert '-m panellift "j.json" -o "r.html" --json > "s.json" 2> "r.log"' \
|
|
in text
|
|
|
|
|
|
def test_the_runner_passes_no_recommend_when_told_to():
|
|
text = liftexport.runner_script('py.exe', 'j.json', 'r.html',
|
|
's.json', 'r.log', recommend=False)
|
|
assert '--json --no-recommend > "s.json"' in text
|
|
|
|
|
|
def test_the_runner_still_redirects_both_streams_to_files():
|
|
"""Two pipes deadlock the moment the child fills one of them; the whole
|
|
reason this is a cmd wrapper rather than two Popen pipes."""
|
|
for flag in (True, False):
|
|
text = liftexport.runner_script('py.exe', 'j.json', 'r.html',
|
|
's.json', 'r.log', recommend=flag)
|
|
assert '> "s.json"' in text and '2> "r.log"' in text
|
|
assert text.startswith('@echo off\r\n')
|
|
assert text.endswith('exit /b %ERRORLEVEL%\r\n')
|
|
assert 'PYTHONIOENCODING=utf-8' in text
|
|
assert 'chcp 65001' in text
|
|
|
|
|
|
# ------------------------------------------------- what the solid sum is OF
|
|
#
|
|
# THE DEFECT THESE PIN (measured 2026-08-31, live runs). On the job's
|
|
# insulated 16 in. panels the modelled-solid weight came out at about 65% of
|
|
# the printed weight, consistently -- WP115 printed 18,573 lb, its own stored
|
|
# weight parameter 18,376 lb, the modelled solid 11,983 lb; WP109 4,189 lb
|
|
# against 2,880 lb. The two independent statements about the piece agreed
|
|
# with each other to ~1% and the solid sum was the outlier.
|
|
#
|
|
# The cause was one line: `concrete_solid` took the LARGEST solid the body
|
|
# draws and called it the piece. That is right for an 8 in. solid panel,
|
|
# whose body draws one. A 16 in. insulated body draws several, and the
|
|
# largest of those is one wythe. The weight gate then refused -- correctly,
|
|
# on a number that was wrong -- so 2.2 warned and 6.3 reported that the
|
|
# handling check could not run, and the whole handling analysis computed
|
|
# nothing on 27 pieces, 15 of them with issued tickets.
|
|
#
|
|
# `choose_concrete` is the repair and it is PURE, which is the point: the
|
|
# geometry walk needs Revit, the decision does not, so the decision is
|
|
# tested here on host CPython with no model and no licence. What only a live
|
|
# press can settle is what `solid_entries` actually reads off an insulated
|
|
# body -- how many solids it draws, and whether their materials publish a
|
|
# density.
|
|
|
|
|
|
def entry(volume, material=None, density=None, mid=None):
|
|
"""One solid, as `solid_entries` hands it to `choose_concrete`."""
|
|
return {'volume_ft3': volume, 'material_name': material,
|
|
'material_id': mid if mid is not None else material,
|
|
'density_pcf': density, 'mixed_material': False}
|
|
|
|
|
|
def test_one_solid_is_the_whole_piece_and_says_so():
|
|
"""The 8 in. solid panel -- 184 of the 184 pieces in the reference
|
|
sweep. Behaviour here must not move."""
|
|
out = liftexport.choose_concrete([entry(88.4132, 'CONCRETE', 150.0)])
|
|
assert abs(out['volume_ft3'] - 88.4132) < 1e-9
|
|
assert out['basis'] == 'one material'
|
|
assert 'one solid' in out['why']
|
|
|
|
|
|
def test_two_wythes_of_the_same_concrete_are_both_counted():
|
|
"""The bug at its simplest: two solids, and the old code weighed the
|
|
bigger one. 65% of a piece is not the piece."""
|
|
out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0),
|
|
entry(44.0, 'CONCRETE', 150.0)])
|
|
assert out['volume_ft3'] == 124.0
|
|
assert out['basis'] == 'one material'
|
|
assert len(out['included']) == 2
|
|
|
|
|
|
def test_insulation_is_left_out_by_its_density_not_by_its_name():
|
|
"""No product name anywhere in this. Concrete is 145-150 pcf and the
|
|
board between the wythes is under 5, so a threshold in the middle tells
|
|
them apart without knowing what the plant buys."""
|
|
out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0),
|
|
entry(44.0, 'CONCRETE', 150.0),
|
|
entry(70.0, 'RIGID BOARD', 2.0)])
|
|
assert out['volume_ft3'] == 124.0
|
|
assert out['basis'] == 'material density'
|
|
assert [e['volume_ft3'] for e in out['excluded']] == [70.0]
|
|
|
|
|
|
def test_the_sum_says_what_went_into_it_and_what_did_not():
|
|
"""Rule 4 of this repair: the weight may not travel as a bare number.
|
|
Both wythes with the insulation left out is a different claim from
|
|
18,500 lb."""
|
|
out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0),
|
|
entry(44.0, 'CONCRETE', 150.0),
|
|
entry(70.0, 'RIGID BOARD', 2.0)])
|
|
why = out['why']
|
|
assert 'summed 2 of the 3 solids' in why
|
|
assert 'CONCRETE' in why and 'RIGID BOARD' in why
|
|
assert '70.00 ft3' in why and 'under 50 pcf' in why
|
|
|
|
|
|
def test_a_body_of_one_unnamed_material_is_summed_whole_and_says_so():
|
|
"""Materials unread is not a licence to pick one solid. Everything
|
|
counts, and the sentence says the weight is too heavy by whatever of it
|
|
is not concrete -- which the weight gate then catches."""
|
|
out = liftexport.choose_concrete([entry(80.0), entry(44.0)])
|
|
assert out['volume_ft3'] == 124.0
|
|
assert 'ALL counted as concrete' in out['why']
|
|
assert 'too heavy' in out['why']
|
|
|
|
|
|
def test_several_materials_none_priced_refuses_rather_than_guessing():
|
|
"""The one case with no honest answer: several materials, no density on
|
|
any of them, and no layer naming which is concrete. Summing them weighs
|
|
the insulation; taking the largest is the defect being repaired. So it
|
|
stands down, and names the two wrong answers it declined."""
|
|
out = liftexport.choose_concrete([entry(80.0, 'MIX A'),
|
|
entry(70.0, 'MIX B')])
|
|
assert out['volume_ft3'] is None
|
|
assert out['basis'] is None
|
|
assert 'no material on them publishes a density' in out['why']
|
|
assert 'drop a wythe' in out['why']
|
|
|
|
|
|
def test_a_plant_may_name_its_concrete_when_no_density_is_published():
|
|
know = make_know()
|
|
know['materials'] = {'concrete_names': ['MIX A']}
|
|
out = liftexport.choose_concrete([entry(80.0, 'MIX A'),
|
|
entry(70.0, 'MIX B')], know)
|
|
assert out['volume_ft3'] == 80.0
|
|
assert out['basis'] == 'material name'
|
|
assert 'Left out 70.00 ft3' in out['why']
|
|
|
|
|
|
def test_a_named_list_still_refuses_where_a_solid_has_no_material_name():
|
|
"""A solid nobody can name is not a solid that is not concrete. Leaving
|
|
it out quietly is a piece missing a wythe."""
|
|
know = make_know()
|
|
know['materials'] = {'concrete_names': ['MIX A']}
|
|
out = liftexport.choose_concrete([entry(80.0, 'MIX A'), entry(70.0)],
|
|
know)
|
|
assert out['volume_ft3'] is None
|
|
assert 'no readable material name' in out['why']
|
|
|
|
|
|
def test_the_density_threshold_is_a_knowledge_key():
|
|
know = make_know()
|
|
know['materials'] = {'concrete_min_pcf': 100.0}
|
|
solids = [entry(80.0, 'HEAVY', 150.0), entry(44.0, 'LIGHT', 90.0)]
|
|
assert liftexport.choose_concrete(solids, know)['volume_ft3'] == 80.0
|
|
assert liftexport.choose_concrete(solids)['volume_ft3'] == 124.0
|
|
|
|
|
|
def test_nothing_to_weigh_is_none_and_never_zero():
|
|
"""0.0 ft3 reads downstream as a piece that weighs nothing, which the
|
|
weight gate would report as a model that is wrong rather than as a read
|
|
that never happened."""
|
|
out = liftexport.choose_concrete([])
|
|
assert out['volume_ft3'] is None
|
|
assert 'no solid' in out['why']
|
|
|
|
|
|
def test_a_body_that_is_all_insulation_is_none_not_zero():
|
|
out = liftexport.choose_concrete([entry(70.0, 'RIGID BOARD', 2.0),
|
|
entry(10.0, 'FOAM', 1.5)])
|
|
assert out['volume_ft3'] is None
|
|
assert 'none of them is concrete' in out['why']
|
|
|
|
|
|
def test_zero_volume_solids_are_not_counted_as_a_material():
|
|
"""A degenerate sliver must not be able to make a one-material body look
|
|
like a two-material one and push it down the refusing branch."""
|
|
out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0),
|
|
entry(0.0, 'AIR', 0.0)])
|
|
assert out['volume_ft3'] == 80.0
|
|
assert out['basis'] == 'one material'
|
|
|
|
|
|
def test_two_solids_of_one_material_are_two_entries_not_one():
|
|
"""Equal dicts. A split done with `in` would put both on whichever side
|
|
the first one landed, and the second wythe would vanish or double."""
|
|
know = make_know()
|
|
know['materials'] = {'concrete_names': ['CONCRETE']}
|
|
out = liftexport.choose_concrete([entry(80.0, 'CONCRETE'),
|
|
entry(80.0, 'CONCRETE'),
|
|
entry(70.0, 'BOARD')], know)
|
|
assert out['volume_ft3'] == 160.0
|
|
assert len(out['included']) == 2
|
|
|
|
|
|
# --------------------------------- the gate prints what the sum was OF
|
|
|
|
def _weight_job(solid_ft3, note, basis='one material'):
|
|
return {
|
|
'panel': {'length_ft': 20.0, 'width_ft': 10.0, 'thickness_in': 8.0,
|
|
'unit_weight_pcf': 150.0, 'weight_lb': 20000.0,
|
|
'blockouts': [], 'extra_cast_ft3': 0.0},
|
|
'source': {'solid_ft3': solid_ft3, 'solid_basis': basis,
|
|
'solid_note': note},
|
|
}
|
|
|
|
|
|
def test_the_gate_prints_the_note_beside_the_solid_weight():
|
|
ok, message, numbers = liftexport.weight_check(
|
|
_weight_job(100.0, 'summed 2 of the 3 solids the body draws'))
|
|
assert not ok
|
|
assert 'summed 2 of the 3 solids the body draws' in message
|
|
assert numbers['solid_basis'] == 'one material'
|
|
|
|
|
|
def test_the_gate_says_why_there_is_no_solid_weight_at_all():
|
|
"""A missing measured weight used to leave a silent gap: the gate fell
|
|
back to the two dimension-derived weights, they agreed with each other
|
|
because they are built from the same dimensions, and the piece passed a
|
|
check that never ran. Note the job below: stated 20,000 lb and
|
|
reconstructed 20,000 lb agree exactly, and it still refuses."""
|
|
ok, message, _numbers = liftexport.weight_check(
|
|
_weight_job(None, 'the body draws 2 solids of 2 different materials',
|
|
basis=None))
|
|
assert not ok
|
|
assert 'not established' in message
|
|
assert 'the body draws 2 solids of 2 different materials' in message
|
|
assert 'never weighed' in message
|
|
|
|
|
|
def test_the_gate_still_passes_a_piece_whose_three_weights_agree():
|
|
"""The other side of the rule above: nothing here makes the gate
|
|
fussier about a piece it can actually measure."""
|
|
ok, message, _numbers = liftexport.weight_check(
|
|
_weight_job(133.3333, 'the body draws one solid'))
|
|
assert ok and message == ''
|
|
|
|
|
|
# ------------------------------------------------------- the sandwich layup
|
|
#
|
|
# `choose_concrete` above answers WHAT THE BODY WEIGHS. These answer WHERE
|
|
# ITS SOLIDS SIT, which is a different question and the one the section needs.
|
|
# Volumes alone are enough to say "this is a sandwich" and refuse; they are
|
|
# not enough to compute a section, and the difference between those two is
|
|
# the difference between a refusal and a wrong number.
|
|
#
|
|
# `layup_of` and `_solid_parts` are pure -- plain dicts in, plain dicts out --
|
|
# for the same reason `choose_concrete` is: the geometry walk needs Revit,
|
|
# the arrangement does not.
|
|
#
|
|
# The dimensions are a real 16 in. insulated piece measured 2026-08-31:
|
|
# 8 in. structural wythe, 4 in. EPS, 4 in. architectural wythe.
|
|
|
|
L_AXIS, W_AXIS, T_AXIS = 0, 1, 2
|
|
AXES = (L_AXIS, W_AXIS, T_AXIS)
|
|
|
|
|
|
def placed(volume, material, density, z0, z1):
|
|
"""One solid as `solid_entries` hands it over, positioned.
|
|
|
|
lo_in / hi_in are (length, width, thickness) in inches in the panel's
|
|
local axes; only the thickness component is exercised here, and it is
|
|
deliberately offset from zero so a test cannot pass by accident on an
|
|
origin the code happened to assume.
|
|
"""
|
|
return {'volume_ft3': volume, 'material_name': material,
|
|
'material_id': material, 'density_pcf': density,
|
|
'mixed_material': False,
|
|
'lo_in': [0.0, 0.0, 100.0 + z0],
|
|
'hi_in': [335.3, 64.0, 100.0 + z1]}
|
|
|
|
|
|
def wp_entries():
|
|
"""The real piece, largest solid first, as panel_solids sorts them."""
|
|
return [placed(79.885, 'STRUCT WYTHE', 150.28, 8.0, 16.0),
|
|
placed(39.8, 'EPS', 1.5, 4.0, 8.0),
|
|
placed(39.557, 'ARCH WYTHE', 150.28, 0.0, 4.0)]
|
|
|
|
|
|
def test_the_layup_comes_out_ordered_through_the_thickness():
|
|
"""Solids arrive largest-first, which is the order the WEIGHT wants and
|
|
is meaningless to a section. The layup is bottom-up."""
|
|
out = liftexport.layup_of(wp_entries(), AXES)
|
|
assert out['measured'] is True
|
|
assert [l['material'] for l in out['layers']] == \
|
|
['ARCH WYTHE', 'EPS', 'STRUCT WYTHE']
|
|
assert [(l['z0_in'], l['z1_in']) for l in out['layers']] == \
|
|
[(0.0, 4.0), (4.0, 8.0), (8.0, 16.0)]
|
|
|
|
|
|
def test_the_envelope_is_measured_over_every_solid_not_the_largest():
|
|
"""The whole point. `panel.thickness_in` comes off the largest solid, so
|
|
on this piece it is the 8 in. wythe; the envelope is 16 in. Reporting the
|
|
first as the section depth is the unconservative reading, and reporting
|
|
it as the envelope is the wrong number in the other direction -- so both
|
|
are written and neither is inferred from the other."""
|
|
out = liftexport.layup_of(wp_entries(), AXES)
|
|
assert out['envelope_thickness_in'] == 16.0
|
|
largest = max(wp_entries(), key=lambda e: e['volume_ft3'])
|
|
assert largest['hi_in'][T_AXIS] - largest['lo_in'][T_AXIS] == 8.0
|
|
|
|
|
|
def test_an_unmeasured_solid_makes_the_whole_layup_unmeasured():
|
|
"""An incomplete layup is not a thinner panel. One solid nobody could
|
|
place makes the arrangement unknown, and a partial answer here would
|
|
read as a section with a wythe missing."""
|
|
entries = wp_entries()
|
|
entries[1] = dict(entries[1], lo_in=None, hi_in=None)
|
|
out = liftexport.layup_of(entries, AXES)
|
|
assert out['measured'] is False
|
|
assert out['layers'] == []
|
|
assert out['envelope_thickness_in'] is None
|
|
assert 'nobody measured' in out['why']
|
|
|
|
|
|
def test_no_frame_means_no_layup_and_says_so():
|
|
out = liftexport.layup_of(wp_entries(), None)
|
|
assert out['measured'] is False
|
|
assert 'frame' in out['why']
|
|
|
|
|
|
def test_an_empty_body_stands_the_layup_down_rather_than_returning_nothing():
|
|
out = liftexport.layup_of([], AXES)
|
|
assert out['measured'] is False
|
|
assert out['why']
|
|
# Every key present whether or not it could be answered -- rule 3 of the
|
|
# file contract. A missing key reads downstream as "nothing to check".
|
|
for key in ('layers', 'envelope_thickness_in', 'z_origin_offset_in',
|
|
'measured', 'why', 'z_origin'):
|
|
assert key in out
|
|
|
|
|
|
def test_solid_parts_carry_their_place_through_the_thickness():
|
|
"""This is the signal panellift detects a sandwich on, and it has to
|
|
travel IN the job file: the file is the boundary, and re-deriving a
|
|
geometry fact on the far side of it would be a guess with an accent."""
|
|
concrete = liftexport.choose_concrete(wp_entries())
|
|
parts = liftexport._solid_parts(concrete, AXES)
|
|
assert [p['counted'] for p in parts] == [True, True, False]
|
|
by_material = dict((p['material'], (p['z0_in'], p['z1_in']))
|
|
for p in parts)
|
|
assert by_material['ARCH WYTHE'] == (0.0, 4.0)
|
|
assert by_material['EPS'] == (4.0, 8.0)
|
|
assert by_material['STRUCT WYTHE'] == (8.0, 16.0)
|
|
|
|
|
|
def test_solid_parts_are_rebased_onto_the_envelope_not_the_largest_solid():
|
|
"""`concrete_extents`' origin is the largest solid's, under which the
|
|
other wythe of a sandwich sits at a NEGATIVE z. A negative depth would
|
|
look like a modelling error rather than an origin choice, so the list is
|
|
rebased onto the lowest face of any solid the body draws."""
|
|
parts = liftexport._solid_parts(
|
|
liftexport.choose_concrete(wp_entries()), AXES)
|
|
assert min(p['z0_in'] for p in parts) == 0.0
|
|
assert max(p['z1_in'] for p in parts) == 16.0
|
|
|
|
|
|
def test_an_unplaced_solid_leaves_z_null_and_never_zero():
|
|
"""Null is 'not measured'. Zero is 'the form face', and the two must
|
|
never be written with the same character."""
|
|
entries = wp_entries()
|
|
entries[0] = dict(entries[0], lo_in=None, hi_in=None)
|
|
parts = liftexport._solid_parts(liftexport.choose_concrete(entries), AXES)
|
|
unplaced = [p for p in parts if p['material'] == 'STRUCT WYTHE'][0]
|
|
assert unplaced['z0_in'] is None
|
|
assert unplaced['z1_in'] is None
|
|
# and nothing is rebased off a partial set, because the floor of a set
|
|
# missing a member is not the floor.
|
|
assert [p['z0_in'] for p in parts if p['z0_in'] is not None] == \
|
|
[100.0, 104.0]
|
|
|
|
|
|
def test_a_solid_panel_gains_z_and_nothing_else_changes():
|
|
"""184 of the 184 pieces in the reference sweep draw one solid. The new
|
|
keys ride along; the weight, the basis and the sentence do not move."""
|
|
one = [placed(88.4132, 'CONCRETE', 150.0, 0.0, 8.0)]
|
|
concrete = liftexport.choose_concrete(one)
|
|
assert abs(concrete['volume_ft3'] - 88.4132) < 1e-9
|
|
assert concrete['basis'] == 'one material'
|
|
parts = liftexport._solid_parts(concrete, AXES)
|
|
assert len(parts) == 1
|
|
assert parts[0]['counted'] is True
|
|
assert (parts[0]['z0_in'], parts[0]['z1_in']) == (0.0, 8.0)
|
|
|
|
|
|
# ------------------------------------------------------------- the connectors
|
|
|
|
def test_the_connector_block_is_written_null_with_a_reason():
|
|
"""A MISSING `connectors` key would read downstream as 'this piece has
|
|
no connectors', which on an insulated panel is the one thing it
|
|
certainly is not. So the block is written whether or not anything could
|
|
be read, every field null, with the sentence that says why."""
|
|
block = liftexport._connectors(make_know())
|
|
for key in ('count', 'spacing_in', 'per_row', 'family',
|
|
'capacity_shear_lb', 'capacity_tension_lb',
|
|
'stiffness_lb_per_in'):
|
|
assert block[key] is None, key
|
|
assert 'not read' in block['why']
|
|
# and it asks for capacity PER FAILURE MODE, not one number
|
|
assert 'FAILURE MODE' in block['why']
|
|
assert 'slip stiffness' in block['why']
|
|
|
|
|
|
def test_the_plants_declaration_travels_when_a_layer_makes_one():
|
|
"""The plant may describe its pins as non-composite. That is evidence
|
|
about design intent, it is carried verbatim, and nothing in the
|
|
mechanics branches on it."""
|
|
know = make_know()
|
|
know.setdefault('handling', {})['connectors'] = {
|
|
'declared_action': 'the plant describes these pins as non-composite'}
|
|
block = liftexport._connectors(know)
|
|
assert block['declared_action'] == \
|
|
'the plant describes these pins as non-composite'
|
|
|
|
|
|
def test_no_declaration_is_null_and_not_a_guess():
|
|
assert liftexport._connectors(make_know())['declared_action'] is None
|
|
|
|
|
|
# ---------------------------------------- the erected axis is never assumed
|
|
#
|
|
# G10 of the overfitting audit, and it is the same shape as the up-end
|
|
# fallback one step further on: `default_stages` used to read the erected
|
|
# axis as `... or 'length'`, so a piece whose erected axis nobody
|
|
# established was quietly taken to span its length. Right for a wall panel;
|
|
# it puts the anchors on the wrong edge of a horizontal one.
|
|
|
|
def test_an_absent_erected_axis_is_said_out_loud():
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS)
|
|
job['source'] = {}
|
|
_stages, _rows, problems = liftexport.default_stages(job, know)
|
|
said = [p for p in problems if 'erected_vertical_axis' in p]
|
|
assert said, problems
|
|
assert 'taken as the LENGTH' in said[0]
|
|
assert 'wrong edge of a horizontal one' in said[0]
|
|
|
|
|
|
def test_a_stated_erected_axis_says_nothing():
|
|
"""No noise on the ordinary case -- a warning that fires every run is a
|
|
warning nobody reads."""
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS)
|
|
job['source'] = {'erected_vertical_axis': 'length'}
|
|
_stages, _rows, problems = liftexport.default_stages(job, know)
|
|
assert [p for p in problems if 'erected_vertical_axis' in p] == []
|
|
|
|
|
|
def test_the_assumption_only_touches_the_stage_that_uses_it():
|
|
"""Stripping and transportation are analysed FLAT and span the long
|
|
direction whatever the piece ends up doing, so the erected axis decides
|
|
nothing for them. Only the erected stage is named."""
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS)
|
|
job['source'] = {}
|
|
_stages, _rows, problems = liftexport.default_stages(job, know)
|
|
said = [p for p in problems if 'erected_vertical_axis' in p]
|
|
assert len(said) == 1
|
|
assert 'erection' in said[0]
|
|
assert 'stripping' not in said[0]
|
|
|
|
|
|
def test_the_solid_parts_keys_are_the_ones_panellift_reads():
|
|
"""THE CONTRACT, pinned from this side.
|
|
|
|
`panellift.sandwich.layup_from_source` reads exactly these keys off each
|
|
entry of `source.solid_parts`, and neither package can import the other,
|
|
so nothing but a pin at each end stops one being renamed under the other.
|
|
The far half is `test_sandwich.py::
|
|
test_the_solid_parts_keys_are_the_ones_the_exporter_writes`.
|
|
"""
|
|
parts = liftexport._solid_parts(
|
|
liftexport.choose_concrete(wp_entries()), AXES)
|
|
assert sorted(parts[0]) == ['counted', 'density_pcf', 'material',
|
|
'volume_ft3', 'z0_in', 'z1_in']
|
|
|
|
|
|
# ------------------------------------------- through openings vs blind ones
|
|
#
|
|
# `_openings` reads rectangles off the side faces of the solid and CANNOT tell
|
|
# a hole from a pocket -- a corner pocket has side faces too. `split_openings`
|
|
# is where that is decided, and it is the seam these tests sit on: the
|
|
# geometry solve (`opening_penetration`, which shoots a line through the solid
|
|
# and needs a live Revit) is stubbed by handing in the list it would have
|
|
# returned, exactly as `export()` does.
|
|
#
|
|
# The defect this pins, fixed 2026-09-01: every rectangle went out as a
|
|
# blockout, panellift reads a blockout as a void through the thickness, and a
|
|
# 0.29 x 0.67 ft corner pocket with 3.5 in of concrete behind it therefore cut
|
|
# every strand it stood over and restarted their development at its edges --
|
|
# one foot from the end of a piece whose model has no opening in it at all.
|
|
|
|
def _inches_to_feet(value_in):
|
|
"""The export's `to_x` / `to_y` for a piece whose x and y ascend from 0."""
|
|
return value_in / 12.0
|
|
|
|
|
|
AXES_LWT = (0, 1, 2)
|
|
ORIGIN_IN = [0.0, 0.0, 0.0]
|
|
|
|
|
|
def split(rects, penetration, thickness_in=8.0):
|
|
return liftexport.split_openings(
|
|
rects, penetration, _inches_to_feet, _inches_to_feet,
|
|
ORIGIN_IN, AXES_LWT, thickness_in)
|
|
|
|
|
|
def pen(through, concrete_in):
|
|
"""One entry of `opening_penetration`'s list, as it really writes it."""
|
|
return {'through': through, 'concrete_in': concrete_in,
|
|
'x0': 0.0, 'x1': 0.0, 'y0': 0.0, 'y1': 0.0}
|
|
|
|
|
|
def test_a_through_opening_goes_out_as_a_blockout():
|
|
blockouts, recesses, unjudged = split(
|
|
[[1.0, 4.0, 2.0, 5.0]], [pen(True, 0.0)])
|
|
assert recesses == [] and unjudged == 0
|
|
assert blockouts == [{'name': 'OPENING 1', 'x0': 1.0, 'y0': 2.0,
|
|
'length': 3.0, 'width': 3.0, 'removed_ft3': None,
|
|
'removed_err': None}]
|
|
|
|
|
|
def test_a_blind_recess_is_not_a_blockout():
|
|
"""The WP128 shape: 0.2917 x 0.6667 ft at x0 11.2083, with 3.5 in of
|
|
concrete left behind it in an 8 in panel. It emitted OPENING 1 -- a hole
|
|
through the piece -- until 2026-09-01."""
|
|
blockouts, recesses, unjudged = split(
|
|
[[11.2083, 11.5, 5.0, 5.6667]], [pen(False, 3.5)])
|
|
assert blockouts == [] and unjudged == 0
|
|
assert recesses == [{'name': 'RECESS 1', 'x0': 11.2083, 'y0': 5.0,
|
|
'length': 0.2917, 'width': 0.6667,
|
|
'concrete_in': 3.5, 'depth_in': 4.5,
|
|
'removed_ft3': None, 'removed_err': None}]
|
|
|
|
|
|
def test_the_blockout_shape_is_exactly_what_it_always_was():
|
|
"""The far side reads these five keys and this package cannot import it.
|
|
A recess key leaking into a blockout would be read as a hole that has a
|
|
depth, which is a third thing again."""
|
|
blockouts, _recesses, _unjudged = split(
|
|
[[1.0, 4.0, 2.0, 5.0]], [pen(True, 0.0)])
|
|
assert sorted(blockouts[0]) == ['length', 'name', 'removed_err',
|
|
'removed_ft3', 'width', 'x0', 'y0']
|
|
|
|
|
|
def test_the_recess_keys_are_the_ones_panellift_reads():
|
|
"""THE CONTRACT, pinned from this side the way `solid_parts` is.
|
|
`panellift.cli.read_panel` reads exactly these off each `recesses` entry.
|
|
The far half is `test_cli.py::
|
|
test_a_recess_round_trips_through_the_job_file`."""
|
|
_blockouts, recesses, _unjudged = split(
|
|
[[11.2083, 11.5, 5.0, 5.6667]], [pen(False, 3.5)])
|
|
assert sorted(recesses[0]) == ['concrete_in', 'depth_in', 'length',
|
|
'name', 'removed_err', 'removed_ft3',
|
|
'width', 'x0', 'y0']
|
|
|
|
|
|
def test_each_list_is_numbered_on_its_own():
|
|
"""A piece with one hole and two pockets gets OPENING 1, RECESS 1,
|
|
RECESS 2 -- not OPENING 1, RECESS 2, RECESS 3. The number is an index
|
|
into the list it is printed in, and a gap in it reads as a lost feature.
|
|
"""
|
|
blockouts, recesses, _unjudged = split(
|
|
[[1.0, 2.0, 1.0, 2.0], [3.0, 4.0, 1.0, 2.0], [5.0, 6.0, 1.0, 2.0]],
|
|
[pen(False, 3.5), pen(True, 0.0), pen(False, 3.5)])
|
|
assert [b['name'] for b in blockouts] == ['OPENING 1']
|
|
assert [r['name'] for r in recesses] == ['RECESS 1', 'RECESS 2']
|
|
|
|
|
|
def test_an_unsolved_penetration_stays_a_blockout_and_is_counted():
|
|
""""Could not tell" is not "blind". The through reading is the one that
|
|
takes section and strand away, it is what this file emitted before, and
|
|
standing it down on a failed solve would quietly make a piece pass. The
|
|
count comes back so the export can SAY so rather than only do it."""
|
|
blockouts, recesses, unjudged = split(
|
|
[[1.0, 4.0, 2.0, 5.0]], [pen(None, None)])
|
|
assert recesses == []
|
|
assert [b['name'] for b in blockouts] == ['OPENING 1']
|
|
assert unjudged == 1
|
|
|
|
|
|
def test_a_penetration_solve_that_failed_outright_leaves_every_rect_alone():
|
|
"""`opening_penetration` raising hands the export an EMPTY list, not a
|
|
short one per rect. Same answer, and every rectangle is counted."""
|
|
blockouts, recesses, unjudged = split(
|
|
[[1.0, 2.0, 1.0, 2.0], [3.0, 4.0, 1.0, 2.0]], [])
|
|
assert len(blockouts) == 2 and recesses == [] and unjudged == 2
|
|
|
|
|
|
def test_a_recess_with_no_thickness_is_still_a_recess():
|
|
"""depth_in is the thickness less the concrete behind the pocket, so it
|
|
cannot be had without a thickness. That makes the depth None -- never a
|
|
0 that reads as a pocket of no depth, and never a reason to call the
|
|
pocket a hole instead."""
|
|
_blockouts, recesses, _unjudged = split(
|
|
[[11.2083, 11.5, 5.0, 5.6667]], [pen(False, 3.5)], thickness_in=None)
|
|
assert recesses[0]['concrete_in'] == 3.5
|
|
assert recesses[0]['depth_in'] is None
|
|
|
|
|
|
def test_nothing_read_is_two_empty_lists():
|
|
assert split([], []) == ([], [], 0)
|
|
|
|
|
|
# ------------------------------ the weight gate, now that recesses are apart
|
|
|
|
def _pocket_job(recesses):
|
|
"""The reconciling 20 x 10 ft x 8 in job above, with pockets on it."""
|
|
job = _weight_job(133.3333, 'the body draws one solid')
|
|
job['panel']['recesses'] = recesses
|
|
return job
|
|
|
|
|
|
def _recess(depth_in, concrete_in):
|
|
return {'name': 'RECESS 1', 'x0': 1.0, 'y0': 1.0, 'length': 2.0,
|
|
'width': 3.0, 'concrete_in': concrete_in, 'depth_in': depth_in}
|
|
|
|
|
|
def test_a_recess_comes_out_of_the_reconstruction_at_its_own_depth():
|
|
"""A pocket stopped being in `blockouts` on 2026-09-01, so without this
|
|
the reconstruction would start calling every pocketed piece heavier than
|
|
its own measured solid. It comes out at the POCKET's depth and not at the
|
|
thickness: subtracting a 4.5 in pocket as if it went through was the
|
|
other half of the same error."""
|
|
_ok, _message, numbers = liftexport.weight_check(
|
|
_pocket_job([_recess(4.5, 3.5)]))
|
|
# 2 x 3 ft x 4.5 in = 2.25 ft3, at 150 pcf.
|
|
assert abs(numbers['blockout_lb'] - 337.5) < 0.01
|
|
assert numbers['recesses'] == 1
|
|
|
|
|
|
def test_a_recess_whose_depth_was_never_solved_is_left_in_the_concrete():
|
|
"""An unmeasured pocket is not guessed at. The gate then reads high,
|
|
which is a refusal somebody has to explain -- not a piece quietly
|
|
blessed."""
|
|
_ok, _message, numbers = liftexport.weight_check(
|
|
_pocket_job([_recess(None, None)]))
|
|
assert numbers['blockout_lb'] == 0.0
|
|
assert numbers['recesses'] == 1
|
|
|
|
|
|
def test_a_job_file_with_no_recesses_key_weighs_what_it_always_did():
|
|
ok, message, numbers = liftexport.weight_check(
|
|
_weight_job(133.3333, 'the body draws one solid'))
|
|
assert ok and message == ''
|
|
assert numbers['recesses'] == 0
|
|
|
|
|
|
# ------------------------------------------------- the cardinal sin, refused
|
|
#
|
|
# `families.lifter_include` matching zero families used to leave `export()`
|
|
# returning a job with `panel.lifters: []` and a soft note buried in
|
|
# `problems` -- a report that completes and looks like a clean pass, the
|
|
# exact shape CLAUDE.md's error-pattern record calls this project's cardinal
|
|
# sin (bitten twice: the shipped defaults matching no real family, and P78 on
|
|
# insulated panels). `no_lifters_message` is the pure half of the fix -- the
|
|
# three-state sentence -- and is what these pin. The Revit-shaped half,
|
|
# `_lifter_include_provenance`, needs a live doc and is out of scope here,
|
|
# same as `export()` itself.
|
|
|
|
def test_unset_state_names_the_defaults_and_says_it_is_expected():
|
|
msg = liftexport.no_lifters_message(
|
|
['LIFT', 'LPA', 'ANCHOR'], [], ['EMBED_PLATE_X4'],
|
|
r'C:\plants\sample_plant\knowledge.yaml')
|
|
assert 'no modelled family matched families.lifter_include' in msg
|
|
assert 'the plant layer maps LIFT, LPA, ANCHOR' in msg
|
|
assert 'nothing was lifted-checked' in msg
|
|
assert 'UNSET' in msg
|
|
assert 'expected shape for a brand-new plant' in msg
|
|
assert 'EMBED_PLATE_X4' in msg
|
|
assert r'C:\plants\sample_plant\knowledge.yaml' in msg
|
|
# Must not claim a mapping went stale -- nobody has customised anything.
|
|
assert 'gone stale' not in msg
|
|
|
|
|
|
def test_stale_state_names_the_layer_and_the_incident_it_matches():
|
|
msg = liftexport.no_lifters_message(
|
|
['LIFT_OLD'], ['plant'], ['EMBED_PLATE_X4'],
|
|
r'C:\plants\sample_client\knowledge.yaml')
|
|
assert 'the plant layer maps LIFT_OLD' in msg
|
|
assert 'IS SET' in msg
|
|
assert 'from the plant layer' in msg
|
|
assert 'gone stale' in msg
|
|
# Must not claim this is the expected new-plant shape.
|
|
assert 'expected shape for a brand-new plant' not in msg
|
|
|
|
|
|
def test_stale_state_names_every_declaring_layer():
|
|
msg = liftexport.no_lifters_message(
|
|
['LIFT_OLD'], ['plant', 'job'], [], None)
|
|
assert 'from the plant + job layer' in msg
|
|
|
|
|
|
def test_provenance_unknown_still_refuses_but_hedges():
|
|
msg = liftexport.no_lifters_message(['LIFT'], None, [], None)
|
|
assert 'could not be re-checked' in msg
|
|
assert 'UNSET' not in msg
|
|
assert 'IS SET' not in msg
|
|
|
|
|
|
def test_an_empty_include_list_is_named_as_none_not_an_empty_string():
|
|
msg = liftexport.no_lifters_message([], [], [], None)
|
|
assert 'the plant layer maps none' in msg
|
|
|
|
|
|
def test_no_other_family_on_the_assembly_says_so_rather_than_nothing():
|
|
msg = liftexport.no_lifters_message(['LIFT'], [], [], None)
|
|
assert 'No other family names were read off this assembly either' in msg
|
|
|
|
|
|
def test_the_families_actually_on_the_piece_are_named_and_deduplicated():
|
|
msg = liftexport.no_lifters_message(
|
|
['LIFT'], [], ['EMBED_A', 'EMBED_B', 'EMBED_A'], None)
|
|
assert 'EMBED_A, EMBED_B' in msg
|
|
assert msg.count('EMBED_A') == 1
|
|
|
|
|
|
def test_no_plant_path_says_it_could_not_be_resolved():
|
|
msg = liftexport.no_lifters_message(['LIFT'], [], [], None)
|
|
assert 'could not be resolved to a knowledge file path' in msg
|
|
|
|
|
|
# ------------------------------------------------ f'c: summary vs parameter
|
|
#
|
|
# `export()`'s Revit-shaped half wires two sources into
|
|
# `resolve_concrete_strength` -- the drawing summary (`designspec` +
|
|
# `ticketqc.parse_design_summary`) and the sheet.fc/fci MODEL parameter
|
|
# (`ticketqc.read_ticket`'s `tkt_fc`/`tkt_fci`, read through the same
|
|
# `_sheet_param(sheets, know, 'sheet.fc')` Ticket QC's own comparison row
|
|
# uses) -- and cannot be exercised without a live document. This is the pure
|
|
# resolution rule itself, host-testable in isolation.
|
|
|
|
def test_resolve_concrete_strength_summary_only_unchanged_historical_case():
|
|
"""A job whose drawing carries a printed summary and no model parameter
|
|
role (the historical case, before this fallback existed) must resolve
|
|
exactly as before -- the summary value, no problem."""
|
|
value, conflict = liftexport.resolve_concrete_strength(6000.0, None,
|
|
"f'c")
|
|
assert value == 6000.0
|
|
assert conflict is None
|
|
|
|
|
|
def test_resolve_concrete_strength_parameter_only_the_sample_job_case():
|
|
"""A job whose drawing carries NO summary but whose model has the
|
|
sheet.fc parameter mapped (the sample_job gap this fix closes) must now
|
|
resolve off the parameter alone."""
|
|
value, conflict = liftexport.resolve_concrete_strength(None, 6000.0,
|
|
"f'c")
|
|
assert value == 6000.0
|
|
assert conflict is None
|
|
|
|
|
|
def test_resolve_concrete_strength_both_agree_uses_it():
|
|
value, conflict = liftexport.resolve_concrete_strength(6000.0, 6000.0,
|
|
"f'c")
|
|
assert value == 6000.0
|
|
assert conflict is None
|
|
|
|
|
|
def test_resolve_concrete_strength_both_present_and_disagree_is_not_judged():
|
|
"""Mirrors `weight_check`'s rule for a printed-vs-modelled mismatch:
|
|
neither number is silently preferred or averaged. The value comes back
|
|
None and the conflict names BOTH numbers so a human resolves it."""
|
|
value, conflict = liftexport.resolve_concrete_strength(5000.0, 6000.0,
|
|
"f'c")
|
|
assert value is None
|
|
assert conflict is not None
|
|
assert '5000' in conflict or '5,000' in conflict
|
|
assert '6000' in conflict or '6,000' in conflict
|
|
assert "f'c" in conflict
|
|
|
|
|
|
def test_resolve_concrete_strength_neither_present_is_plain_none():
|
|
value, conflict = liftexport.resolve_concrete_strength(None, None,
|
|
"f'c")
|
|
assert value is None
|
|
assert conflict is None
|
|
|
|
|
|
def test_resolve_concrete_strength_tiny_float_noise_still_agrees():
|
|
"""Values within 1 psi are treated as the same reading, not a conflict
|
|
-- these are whole-number prints, and float/str round-tripping should
|
|
not manufacture a disagreement out of nothing."""
|
|
value, conflict = liftexport.resolve_concrete_strength(6000.0, 6000.4,
|
|
"f'c")
|
|
assert value == 6000.0
|
|
assert conflict is None
|
|
|
|
|
|
# ---------------------------------------- the reconstruction sums every wythe
|
|
#
|
|
# THE DEFECT (measured live 2026-09-02, WP113, an insulated sandwich). The
|
|
# reconstruction leg -- gross-from-the-drawing, the self-check that catches a
|
|
# blockout read wrong -- rebuilt only the SINGLE LARGEST solid and so on a
|
|
# sandwich rebuilt ONE WYTHE. WP113: 68.0 ft3 rebuilt against 102.2 ft3 of
|
|
# actual solid, a 33% gap that refused a piece whose model is right, on every
|
|
# insulated piece in the job.
|
|
#
|
|
# `_wythe_gross_ft3` is the per-wythe unit and `_pick_concrete_solids` is the
|
|
# seam that hands the reconstruction EVERY concrete solid rather than the
|
|
# largest. Both are pure -- the geometry walk is Revit's, the arithmetic and
|
|
# the mapping are not -- so the fix is pinned here on host CPython.
|
|
|
|
|
|
def test_one_wythe_gross_is_length_x_width_x_its_own_thickness():
|
|
"""The smallest span is the thickness, exactly as `_axes` picks it, so a
|
|
thin wythe is measured across its OWN 4 in and not the envelope."""
|
|
# 20 ft x 10 ft x 4 in, no openings.
|
|
gross = liftexport._wythe_gross_ft3([0.0, 0.0, 0.0], [240.0, 120.0, 4.0],
|
|
[])
|
|
assert abs(gross - (20.0 * 10.0 * (4.0 / 12.0))) < 1e-9
|
|
|
|
|
|
def test_one_wythe_subtracts_its_own_openings_at_its_own_thickness():
|
|
"""A blockout takes its footprint x the wythe's thickness, not the
|
|
envelope's -- the same rule the old single-solid leg used, kept."""
|
|
# A 2 ft x 2 ft = 4 ft2 hole through a 4 in wythe removes 4 x 4/12 ft3.
|
|
gross = liftexport._wythe_gross_ft3([0.0, 0.0, 0.0], [240.0, 120.0, 4.0],
|
|
[[2.0, 4.0, 3.0, 5.0]])
|
|
expected = 20.0 * 10.0 * (4.0 / 12.0) - 4.0 * (4.0 / 12.0)
|
|
assert abs(gross - expected) < 1e-9
|
|
|
|
|
|
def test_the_reconstruction_sums_all_wythes_not_just_the_largest():
|
|
"""The whole repair. Two concrete wythes of one body: the reconstruction
|
|
is their sum, and the largest one alone -- what the leg used to return --
|
|
is short by the other wythe."""
|
|
thick = liftexport._wythe_gross_ft3(
|
|
[0.0, 0.0, 0.0], [240.0, 120.0, 4.0], []) # 66.67 ft3, largest
|
|
thin = liftexport._wythe_gross_ft3(
|
|
[0.0, 0.0, 0.0], [240.0, 120.0, 2.0], []) # 33.33 ft3
|
|
both = thick + thin
|
|
assert abs(both - 100.0) < 1e-6
|
|
# The defect: the largest alone is ~33% short of the piece, which is the
|
|
# gap that fired the false refusal.
|
|
assert abs((both - thick) / both - 0.3333) < 0.01
|
|
|
|
|
|
# ------------------------------- a stepped/tapered wythe stands down, named
|
|
#
|
|
# WP100, 2026-09-08 (`_scratch/wp100_weight.md`). The backup wythe steps down
|
|
# in thickness over its last ~0.5 ft, a face `_openings` has no way to close
|
|
# into a plan-view hole at all -- its normal has no thickness-axis component
|
|
# (so `_openings` treats it as a hole side, not the outside of the piece) but
|
|
# nothing else is anywhere near it to cluster against, so it comes back as a
|
|
# DEGENERATE rectangle (zero width in one plan direction) that already
|
|
# deducts 0.0 ft3, silently. Before this fix that silence let the gross
|
|
# estimate run 19.37 ft3 (47%) heavier than the wythe's own 41.22 ft3 solid,
|
|
# which fired a WARN blaming a fine piece (`WGT-READ`, "suspect the tool
|
|
# first") for a hole in the tool.
|
|
#
|
|
# The OTHER failure this leg exists to catch -- a hole `_openings` misses
|
|
# ENTIRELY, the classic door-jamb-read-as-outside bug its own docstring
|
|
# describes -- must NOT be swept into the same "not applicable" answer: it
|
|
# leaves no rect at all, degenerate or otherwise, so the reconstruction still
|
|
# comes back a real (wrong, overstated) number and the caller still warns.
|
|
|
|
|
|
def _patch_ticketqc(monkeypatch, extents_of_solid):
|
|
"""Stand up a minimal fake `vibe.ticketqc` with just `extents_of_solid`.
|
|
|
|
`_reconstructed_ft3` does `from vibe import ticketqc` INSIDE its own
|
|
body -- the real module imports the Revit API at module scope
|
|
(conftest.py's docstring), which is exactly the dependency this test
|
|
must not need. Patching both `sys.modules['vibe.ticketqc']` and the
|
|
`vibe` package's own `ticketqc` attribute (when one already exists, set
|
|
by an earlier real import elsewhere in the same pytest session) covers
|
|
both of CPython's `from X import Y` resolution paths.
|
|
"""
|
|
fake = types.ModuleType('vibe.ticketqc')
|
|
fake.extents_of_solid = extents_of_solid
|
|
monkeypatch.setitem(sys.modules, 'vibe.ticketqc', fake)
|
|
vibe_pkg = sys.modules.get('vibe')
|
|
if vibe_pkg is not None:
|
|
monkeypatch.setattr(vibe_pkg, 'ticketqc', fake, raising=False)
|
|
return fake
|
|
|
|
|
|
class _FakeSolid(object):
|
|
def __init__(self, volume):
|
|
self.Volume = volume
|
|
|
|
|
|
# The backup wythe's real envelope, in feet: 11.5417 (L) x 5.25 (W) x 1.00
|
|
# (T) -- taken straight from the diagnosis so the numbers in these tests are
|
|
# the numbers that were actually measured live, not round ones invented for
|
|
# the test.
|
|
_STEPPED_LO = (0.0, 0.0, 0.0)
|
|
_STEPPED_HI = (11.5417, 5.25, 1.0)
|
|
_STEPPED_GROSS_FT3 = 11.5417 * 5.25 * 1.0 # 60.59 ft3, no holes found
|
|
_STEPPED_ACTUAL_FT3 = 41.22 # the wythe's own Volume
|
|
|
|
|
|
def test_degenerate_rects_finds_the_unpaired_side_face():
|
|
"""A real hole clusters into a rect with positive extent in BOTH plan
|
|
directions (see WP100's own small corner notch, which measured fine);
|
|
a lone side face with nothing to pair against -- the step's signature
|
|
-- stays zero-width in one of them."""
|
|
rects = [[2.0, 4.0, 3.0, 5.0], [11.04, 11.04, 0.0, 5.25]]
|
|
assert liftexport._degenerate_rects(rects) == [[11.04, 11.04, 0.0, 5.25]]
|
|
|
|
|
|
def test_degenerate_rects_empty_when_every_rect_closed():
|
|
rects = [[2.0, 4.0, 3.0, 5.0], [0.0, 1.0, 0.0, 2.0]]
|
|
assert liftexport._degenerate_rects(rects) == []
|
|
|
|
|
|
def test_reconstructed_ft3_stands_a_stepped_wythe_down_by_name(monkeypatch):
|
|
solid = _FakeSolid(_STEPPED_ACTUAL_FT3)
|
|
_patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI))
|
|
monkeypatch.setattr(
|
|
liftexport, '_openings',
|
|
lambda s, f, lo, hi, axes: [[11.04, 11.04, 0.0, 5.25]])
|
|
total, reason = liftexport._reconstructed_ft3([solid], ('frame',),
|
|
make_know())
|
|
assert total is None
|
|
assert reason is not None
|
|
assert 'wythe 1 of 1' in reason
|
|
assert 'stepped or tapered' in reason
|
|
# The excess it names is the real, measured gap -- not an approximation.
|
|
assert '{0:,.2f}'.format(_STEPPED_GROSS_FT3 - _STEPPED_ACTUAL_FT3) \
|
|
in reason
|
|
|
|
|
|
def test_reconstructed_ft3_a_genuinely_missed_hole_still_reads_wrong(
|
|
monkeypatch):
|
|
"""The classic door-jamb bug -- a hole `_openings` misses OUTRIGHT --
|
|
leaves no rect at all, degenerate or otherwise. That must keep coming
|
|
back as a real (overstated) number, exactly as it did before this fix,
|
|
so the caller still reports WGT-READ rather than waving the piece
|
|
through as 'not applicable' just because a gap exists."""
|
|
solid = _FakeSolid(_STEPPED_ACTUAL_FT3)
|
|
_patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI))
|
|
monkeypatch.setattr(liftexport, '_openings',
|
|
lambda s, f, lo, hi, axes: [])
|
|
total, reason = liftexport._reconstructed_ft3([solid], ('frame',),
|
|
make_know())
|
|
assert reason is None
|
|
assert total is not None
|
|
assert abs(total - _STEPPED_GROSS_FT3) < 1e-6
|
|
|
|
|
|
def test_reconstructed_ft3_a_degenerate_rect_within_tolerance_is_not_flagged(
|
|
monkeypatch):
|
|
"""Condition 1 (a degenerate rect) alone is not enough to stand a wythe
|
|
down -- only a degenerate rect THAT ALSO costs more than the check's own
|
|
tolerance does."""
|
|
solid = _FakeSolid(_STEPPED_GROSS_FT3 * 0.999) # within 2%
|
|
_patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI))
|
|
monkeypatch.setattr(
|
|
liftexport, '_openings',
|
|
lambda s, f, lo, hi, axes: [[11.04, 11.04, 0.0, 5.25]])
|
|
total, reason = liftexport._reconstructed_ft3([solid], ('frame',),
|
|
make_know())
|
|
assert reason is None
|
|
assert total is not None
|
|
|
|
|
|
def test_reconstructed_ft3_extents_unreadable_is_the_old_none_none(
|
|
monkeypatch):
|
|
"""The pre-existing failure -- a wythe's extents could not be read at
|
|
all -- must still come back `(None, None)`, not a reason: this is the
|
|
case `weight_volumes` still treats as a full stand-down."""
|
|
_patch_ticketqc(monkeypatch, lambda s, f: None)
|
|
total, reason = liftexport._reconstructed_ft3(
|
|
['solid'], ('frame',), make_know())
|
|
assert total is None
|
|
assert reason is None
|
|
|
|
|
|
def test_reconstructed_ft3_reads_the_tolerance_from_knowledge(monkeypatch):
|
|
"""The tolerance is `handling.weight_tolerance`, read off `know` like
|
|
every other caller of it (`check_weight_three_way`, `weight_check`) --
|
|
never a hardcoded 2%. A plant that widens it should stop flagging the
|
|
same gap."""
|
|
solid = _FakeSolid(_STEPPED_ACTUAL_FT3)
|
|
_patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI))
|
|
monkeypatch.setattr(
|
|
liftexport, '_openings',
|
|
lambda s, f, lo, hi, axes: [[11.04, 11.04, 0.0, 5.25]])
|
|
loose = make_know()
|
|
loose['handling']['weight_tolerance'] = 0.60 # wider than the 47% gap
|
|
total, reason = liftexport._reconstructed_ft3([solid], ('frame',), loose)
|
|
assert reason is None
|
|
assert total is not None
|
|
|
|
|
|
# ---------------------------- weight_volumes wires the not-applicable state
|
|
#
|
|
# `weight_volumes` must stand down ONLY the reconstruction leg when
|
|
# `_reconstructed_ft3` names a reason, leaving `solid_ft3` (the measured
|
|
# leg, which never called `_reconstructed_ft3` at all) untouched -- and must
|
|
# still fully stand both legs down on the OLDER failure (extents
|
|
# unreadable, no reason). `_reconstructed_ft3` itself is stubbed out here;
|
|
# its own decision is pinned by the tests just above.
|
|
|
|
def _patch_weight_volumes_geometry(monkeypatch, concrete, reconstructed):
|
|
"""Stand up everything `weight_volumes` reads before it ever reaches
|
|
`_reconstructed_ft3`, off host fakes -- `reconstructed` is
|
|
`_reconstructed_ft3`'s own `(total, reason)` return, injected directly
|
|
so this test is only about `weight_volumes`'s wiring, not the decision
|
|
`_reconstructed_ft3` makes."""
|
|
monkeypatch.setattr(liftexport, 'panel_solids',
|
|
lambda p: ['A', 'B'][:len(concrete['included'])])
|
|
monkeypatch.setattr(liftexport, 'concrete_volume',
|
|
lambda doc, p, know, s: concrete)
|
|
monkeypatch.setattr(liftexport, 'extra_cast_ft3', lambda *a: 0.0)
|
|
monkeypatch.setattr(liftexport, 'solid_zone_ft3', lambda *a: 0.0)
|
|
monkeypatch.setattr(liftexport, '_reconstructed_ft3',
|
|
lambda solids, frame, know=None: reconstructed)
|
|
_patch_ticketqc(monkeypatch, lambda s, f: None)
|
|
vibe_pkg = sys.modules.get('vibe')
|
|
if vibe_pkg is not None:
|
|
monkeypatch.setattr(vibe_pkg.ticketqc, '_panel_frame',
|
|
lambda p: ('frame',), raising=False)
|
|
|
|
|
|
def test_weight_volumes_stands_down_only_the_reconstruction_leg(monkeypatch):
|
|
concrete = {'volume_ft3': 60.32, 'basis': 'material density',
|
|
'why': 'both wythes', 'included': [{'_ix': 0}, {'_ix': 1}]}
|
|
_patch_weight_volumes_geometry(
|
|
monkeypatch, concrete,
|
|
(None, 'wythe 1 of 2 ... stepped or tapered wythe'))
|
|
out = liftexport.weight_volumes(None, None, object(), make_know())
|
|
assert out['solid_ft3'] == 60.32
|
|
assert out['reconstructed_ft3'] is None
|
|
assert out['reason'] is None
|
|
assert 'stepped or tapered' in out['reconstruction_reason']
|
|
assert out['solid_basis'] == 'material density'
|
|
|
|
|
|
def test_weight_volumes_still_stands_down_fully_when_extents_unreadable(
|
|
monkeypatch):
|
|
concrete = {'volume_ft3': 41.22, 'basis': 'material density',
|
|
'why': 'one wythe', 'included': [{'_ix': 0}]}
|
|
_patch_weight_volumes_geometry(monkeypatch, concrete, (None, None))
|
|
out = liftexport.weight_volumes(None, None, object(), make_know())
|
|
assert out['solid_ft3'] is None
|
|
assert out['reconstructed_ft3'] is None
|
|
assert out['reconstruction_reason'] is None
|
|
assert 'extents could not be solved' in out['reason']
|
|
|
|
|
|
def test_weight_volumes_normal_path_is_unchanged_when_reconstruction_works(
|
|
monkeypatch):
|
|
concrete = {'volume_ft3': 60.32, 'basis': 'material density',
|
|
'why': 'both wythes', 'included': [{'_ix': 0}, {'_ix': 1}]}
|
|
_patch_weight_volumes_geometry(monkeypatch, concrete, (55.0, None))
|
|
out = liftexport.weight_volumes(None, None, object(), make_know())
|
|
assert out['reconstructed_ft3'] == 55.0
|
|
assert out['solid_ft3'] == 60.32
|
|
assert out['reason'] is None
|
|
assert out['reconstruction_reason'] is None
|
|
|
|
|
|
def test_pick_concrete_solids_returns_every_counted_solid_by_index():
|
|
"""`choose_concrete` decides which solids are concrete on plain dicts;
|
|
`_pick_concrete_solids` maps its `included` set back to the Solid OBJECTS
|
|
by the `_ix` stamped before the choice. Equal wythes make equal dicts, so
|
|
only the index can tell them apart -- and the map must return BOTH concrete
|
|
solids, never just the biggest."""
|
|
solids = ['WYTHE_A', 'INSULATION', 'WYTHE_B']
|
|
concrete = {'included': [{'_ix': 0}, {'_ix': 2}]}
|
|
assert liftexport._pick_concrete_solids(solids, concrete) == \
|
|
['WYTHE_A', 'WYTHE_B']
|
|
|
|
|
|
def test_pick_concrete_solids_skips_a_missing_or_out_of_range_index():
|
|
"""Defensive: a decision with no index, or one past the end, drops that
|
|
entry rather than raising -- the reconstruction stands down elsewhere on
|
|
an empty pick, it does not crash here."""
|
|
solids = ['A', 'B']
|
|
concrete = {'included': [{'_ix': 0}, {'_ix': None}, {'_ix': 5}, {}]}
|
|
assert liftexport._pick_concrete_solids(solids, concrete) == ['A']
|
|
|
|
|
|
def test_solid_entries_index_survives_the_concrete_choice():
|
|
"""The stamp and the map are two halves of one mechanism, so pin that the
|
|
index `solid_entries` writes is the one `choose_concrete` hands back on the
|
|
included entries -- built here as plain dicts, the same shape the real walk
|
|
produces."""
|
|
entries = [dict(entry(80.0, 'CONCRETE', 150.0), _ix=0),
|
|
dict(entry(2.0, 'RIGID BOARD', 2.0), _ix=1),
|
|
dict(entry(40.0, 'CONCRETE', 150.0), _ix=2)]
|
|
concrete = liftexport.choose_concrete(entries)
|
|
assert [e['_ix'] for e in concrete['included']] == [0, 2]
|
|
assert liftexport._pick_concrete_solids(['a', 'b', 'c'], concrete) == \
|
|
['a', 'c']
|
|
|
|
|
|
# ---------------------------------------------- solid-zone concrete, by knowledge
|
|
#
|
|
# The thickened concrete zones of an insulated sandwich are their OWN family
|
|
# members inside the assembly -- separate Generic Models, not geometry of the
|
|
# panel element -- so the panel-body solid sum and the reconstruction, both of
|
|
# which walk only the panel body, never see them. On WP113 that was 9.63 ft3,
|
|
# ~7.8% of the piece, silently missing from every weight. Which family they are
|
|
# is plant knowledge (`insulation.solid_zone.family`, a placed example being
|
|
# SOLID_ZONE_RECTANGULAR); with no such key the walk does nothing and a piece
|
|
# without solid zones is unchanged.
|
|
#
|
|
# The fakes are duck types: solid_zone_members touches only GetMemberIds /
|
|
# GetElement / Symbol.Family.Name / Id, and the per-member concrete goes
|
|
# through `solid_entries` (stubbed here so the REAL `choose_concrete` density
|
|
# discipline runs with no Revit).
|
|
|
|
|
|
class _FakeZoneMember(object):
|
|
def __init__(self, family, entries, eid=1):
|
|
self.Symbol = _FakeSymbol(family)
|
|
self.Id = _FakeId(eid)
|
|
self.Parameters = []
|
|
self._entries = entries
|
|
|
|
|
|
def _zone_know(family='SOLID_ZONE_RECTANGULAR'):
|
|
know = make_know()
|
|
know['insulation'] = {'solid_zone': {'family': family}}
|
|
return know
|
|
|
|
|
|
def _stub_solid_entries(monkeypatch):
|
|
"""`solid_entries` reads Revit geometry; here each member simply carries
|
|
the entries it would have produced, so the real `choose_concrete` runs."""
|
|
monkeypatch.setattr(liftexport, 'solid_entries',
|
|
lambda doc, member, *a, **k: member._entries)
|
|
|
|
|
|
def test_solid_zone_members_are_counted_when_the_family_is_named(monkeypatch):
|
|
_stub_solid_entries(monkeypatch)
|
|
members = [_FakeZoneMember('SOLID_ZONE_RECTANGULAR',
|
|
[entry(6.0, 'CONCRETE', 150.0)], 11),
|
|
_FakeZoneMember('SOLID_ZONE_RECTANGULAR',
|
|
[entry(3.628, 'CONCRETE', 150.0)], 12)]
|
|
doc, asm = _cast_doc(members)
|
|
know = _zone_know()
|
|
out = liftexport.solid_zone_members(doc, asm, know)
|
|
assert [m['id'] for m in out] == ['11', '12']
|
|
assert abs(liftexport.solid_zone_ft3(doc, asm, know) - 9.628) < 1e-9
|
|
|
|
|
|
def test_no_solid_zone_key_is_a_no_op_not_a_guess(monkeypatch):
|
|
"""A plant that casts no solid zones names no family. The walk must return
|
|
nothing and add nothing -- the weight of every piece without solid zones is
|
|
exactly what it was before this existed."""
|
|
_stub_solid_entries(monkeypatch)
|
|
members = [_FakeZoneMember('SOLID_ZONE_RECTANGULAR',
|
|
[entry(6.0, 'CONCRETE', 150.0)])]
|
|
doc, asm = _cast_doc(members)
|
|
know = make_know() # no 'insulation' key at all
|
|
assert liftexport.solid_zone_members(doc, asm, know) == []
|
|
assert liftexport.solid_zone_ft3(doc, asm, know) == 0.0
|
|
|
|
|
|
def test_only_the_named_family_is_a_solid_zone_steel_is_not(monkeypatch):
|
|
"""The concrete/steel split is the family filter: an embed or lifter is a
|
|
DIFFERENT family and is never reached, so its steel never lands in the
|
|
concrete weight."""
|
|
_stub_solid_entries(monkeypatch)
|
|
members = [_FakeZoneMember('SOLID_ZONE_RECTANGULAR',
|
|
[entry(6.0, 'CONCRETE', 150.0)], 11),
|
|
_FakeZoneMember('EMBED_PLATE',
|
|
[entry(0.4, 'STEEL', 490.0)], 12),
|
|
_FakeZoneMember('ANCHOR_FACE_4T',
|
|
[entry(0.1, 'STEEL', 490.0)], 13)]
|
|
doc, asm = _cast_doc(members)
|
|
know = _zone_know()
|
|
out = liftexport.solid_zone_members(doc, asm, know)
|
|
assert [m['family'] for m in out] == ['SOLID_ZONE_RECTANGULAR']
|
|
assert liftexport.solid_zone_ft3(doc, asm, know) == 6.0
|
|
|
|
|
|
def test_a_solid_zone_members_insulation_is_left_out_by_density(monkeypatch):
|
|
"""WITHIN a matched member the density discipline still applies exactly as
|
|
it does on the panel body: where a member draws both concrete and sub-50
|
|
pcf board, only the concrete is counted -- `choose_concrete`'s own rule,
|
|
reused, not a second copy of it."""
|
|
_stub_solid_entries(monkeypatch)
|
|
members = [_FakeZoneMember(
|
|
'SOLID_ZONE_RECTANGULAR',
|
|
[entry(6.0, 'CONCRETE', 150.0), entry(2.0, 'RIGID BOARD', 2.0)], 11)]
|
|
doc, asm = _cast_doc(members)
|
|
know = _zone_know()
|
|
out = liftexport.solid_zone_members(doc, asm, know)
|
|
assert out[0]['volume_ft3'] == 6.0 # the board is left out
|
|
assert liftexport.solid_zone_ft3(doc, asm, know) == 6.0
|
|
|
|
|
|
def test_solid_zones_are_not_double_counted_by_cast_include(monkeypatch):
|
|
"""One mechanism owns the solid zones. `extra_cast` is a SUBSTRING match,
|
|
so a plant that named its zone family in `families.cast_include` ('SOLID'
|
|
would catch it) would otherwise weigh that concrete twice. The extra-cast
|
|
walk must skip the zone family, and the zone walk must count it -- exactly
|
|
once, between them."""
|
|
_stub_solid_entries(monkeypatch)
|
|
zone = _FakeZoneMember('SOLID_ZONE_RECTANGULAR',
|
|
[entry(6.0, 'CONCRETE', 150.0)], 11)
|
|
# A real ledge, and a Volume param the way extra_cast reads it.
|
|
ledge = _FakeMember('SLAB SEAT LEDGE', 8.99, 12)
|
|
doc, asm = _cast_doc([zone, ledge])
|
|
know = _zone_know()
|
|
know['families']['cast_include'] = ['SOLID', 'LEDGE'] # 'SOLID' would hit
|
|
# extra_cast sees the ledge only -- the zone is claimed by the zone walk.
|
|
extras = liftexport.extra_cast_members(doc, asm, know)
|
|
assert [m['family'] for m in extras] == ['SLAB SEAT LEDGE']
|
|
# ...and the zone walk sees the zone only.
|
|
zones = liftexport.solid_zone_members(doc, asm, know)
|
|
assert [m['family'] for m in zones] == ['SOLID_ZONE_RECTANGULAR']
|
|
# Between the two, the concrete is counted once each and never together.
|
|
assert liftexport.extra_cast_ft3(doc, asm, know) == 8.99
|
|
assert liftexport.solid_zone_ft3(doc, asm, know) == 6.0
|
|
|
|
|
|
# --------------------------------- the gate reconciles a sandwich, on its merits
|
|
#
|
|
# `weight_check` is where the false refusal actually fired: it rebuilds the
|
|
# reconstruction from `panel.thickness_in`, which is the LARGEST solid's extent
|
|
# and so ONE WYTHE of a sandwich. The exporter now hands it a wythe-summed
|
|
# `source.reconstructed_ft3` for a multi-solid body, and adds the solid-zone
|
|
# concrete to both legs. These pin that the gate USES those, without losing the
|
|
# single-solid dimension-and-recess reconstruction or its teeth.
|
|
|
|
|
|
def _sandwich_job(reconstructed_ft3, solid_ft3, zone_ft3=0.0,
|
|
weight_lb=None, thickness_in=8.0):
|
|
"""A job as the exporter writes it for a sandwich: the panel dims are the
|
|
LARGEST wythe (thickness_in), and the wythe-summed reconstruction and the
|
|
solid sum ride in `source`."""
|
|
return {
|
|
'panel': {'length_ft': 20.0, 'width_ft': 10.0,
|
|
'thickness_in': thickness_in, 'unit_weight_pcf': 150.0,
|
|
'weight_lb': weight_lb, 'blockouts': [],
|
|
'extra_cast_ft3': 0.0},
|
|
'source': {'solid_ft3': solid_ft3, 'solid_basis': 'material density',
|
|
'solid_note': 'summed 2 of the 3 solids the body draws',
|
|
'reconstructed_ft3': reconstructed_ft3,
|
|
'solid_zone_ft3': zone_ft3},
|
|
}
|
|
|
|
|
|
def test_the_gate_uses_the_wythe_summed_reconstruction_for_a_sandwich():
|
|
"""WP113 in miniature. Reconstruction and solid both ~111.8 ft3 and the
|
|
stated weight ~16,634 lb: the piece passes on its merits, where the old
|
|
one-wythe reconstruction (~68 ft3) refused it. The panel thickness_in is
|
|
still one wythe -- the gate must NOT be rebuilding from it."""
|
|
# 102.2 ft3 of wythes + 9.63 ft3 of solid zone = 111.83 ft3.
|
|
job = _sandwich_job(reconstructed_ft3=111.83, solid_ft3=111.83,
|
|
zone_ft3=9.63, weight_lb=111.83 * 150.0)
|
|
ok, message, numbers = liftexport.weight_check(job)
|
|
assert ok and message == ''
|
|
# It really used the wythe-summed number, not length x width x one wythe.
|
|
assert abs(numbers['reconstructed_lb'] - 111.83 * 150.0) < 1.0
|
|
|
|
|
|
def test_the_gate_still_catches_a_misread_blockout_on_a_sandwich():
|
|
"""The leg keeps its teeth. If the wythe-summed reconstruction disagrees
|
|
with the actual solid -- a blockout read wrong -- the gate still refuses,
|
|
now on the correct, wythe-aware pair rather than on a dropped wythe."""
|
|
# Reconstruction says 111.8, the solid it is checked against says 90.
|
|
job = _sandwich_job(reconstructed_ft3=111.83, solid_ft3=90.0,
|
|
weight_lb=111.83 * 150.0)
|
|
ok, message, _ = liftexport.weight_check(job)
|
|
assert not ok
|
|
assert 'blockouts were read wrong' in message
|
|
|
|
|
|
def test_a_single_solid_piece_keeps_the_dimension_reconstruction():
|
|
"""No `source.reconstructed_ft3` on a one-solid body, so the gate keeps its
|
|
own length x width x thickness (and recess-depth) reconstruction -- the
|
|
184 solid panels in the reference sweep must not move."""
|
|
job = _sandwich_job(reconstructed_ft3=None, solid_ft3=133.3333,
|
|
weight_lb=133.3333 * 150.0)
|
|
# length x width x thickness = 20 x 10 x 8/12 = 133.33 ft3.
|
|
ok, message, numbers = liftexport.weight_check(job)
|
|
assert ok and message == ''
|
|
assert abs(numbers['reconstructed_lb'] - 133.3333 * 150.0) < 5.0
|
|
|
|
|
|
def test_solid_zone_concrete_reconciles_a_single_solid_piece_too():
|
|
"""A single-solid piece can still carry solid-zone members. With no
|
|
wythe-summed reconstruction, the gate adds the zone concrete to its
|
|
dimension reconstruction so it still matches a solid sum that includes the
|
|
zones -- otherwise the zone would look like a blockout misread."""
|
|
# 133.33 ft3 body + 6 ft3 of solid zone in BOTH legs.
|
|
job = _sandwich_job(reconstructed_ft3=None, solid_ft3=133.3333 + 6.0,
|
|
zone_ft3=6.0, weight_lb=(133.3333 + 6.0) * 150.0)
|
|
ok, message, numbers = liftexport.weight_check(job)
|
|
assert ok and message == ''
|
|
assert abs(numbers['solid_zone_lb'] - 6.0 * 150.0) < 1e-6
|
|
|
|
|
|
# ------------------------------------------------- default_stages: flat piece
|
|
|
|
def test_default_stages_flat_piece_switches_erection_off():
|
|
"""A slab: its thickness points up. Stripping and transportation run as
|
|
the flat lifts they are; erection is switched off with the reason on the
|
|
stage, never solved with the thickness as a span (owner, 2026-09-03)."""
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='thickness')
|
|
stages, all_rows, problems = liftexport.default_stages(job, know)
|
|
by_name = dict((s['name'], s) for s in stages)
|
|
assert by_name['stripping'].get('enabled', True) is True
|
|
assert by_name['transportation'].get('enabled', True) is True
|
|
erection = by_name['erection']
|
|
assert erection['enabled'] is False
|
|
assert 'lies flat' in erection['disabled_reason']
|
|
assert erection['span'] != 'thickness'
|
|
|
|
|
|
def test_default_stages_flat_attitude_alone_switches_erection_off():
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='width')
|
|
job['source']['vertical_share'] = {'attitude': 'flat'}
|
|
stages, _, _ = liftexport.default_stages(job, know)
|
|
assert dict((s['name'], s) for s in stages)['erection']['enabled'] is False
|
|
|
|
|
|
def test_default_stages_wall_panel_keeps_erection():
|
|
know = make_know()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='length')
|
|
stages, _, _ = liftexport.default_stages(job, know)
|
|
erection = dict((s['name'], s) for s in stages)['erection']
|
|
assert erection.get('enabled', True) is True
|
|
|
|
|
|
# ------------------------------------------------- handling_view: when_flat
|
|
|
|
def make_know_with_flat_overlay():
|
|
know = make_know()
|
|
know['handling']['tension_limit_sqrt_fc'] = 5.0
|
|
know['handling']['when_flat'] = {
|
|
'tension_limit_sqrt_fc': 6.0,
|
|
'compression_limit_ratio': 0.7,
|
|
'ignore_lateral_prestress': True,
|
|
}
|
|
return know
|
|
|
|
|
|
def test_handling_view_overlays_only_when_flat():
|
|
know = make_know_with_flat_overlay()
|
|
assert liftexport.handling_view(know, False) is know
|
|
view = liftexport.handling_view(know, True)
|
|
assert view is not know
|
|
assert view['handling']['tension_limit_sqrt_fc'] == 6.0
|
|
assert view['handling']['compression_limit_ratio'] == 0.7
|
|
assert view['handling']['ignore_lateral_prestress'] is True
|
|
assert 'stages' in view['handling']
|
|
|
|
|
|
def test_handling_view_leaves_the_original_untouched():
|
|
know = make_know_with_flat_overlay()
|
|
liftexport.handling_view(know, True)
|
|
assert know['handling']['tension_limit_sqrt_fc'] == 5.0
|
|
assert 'ignore_lateral_prestress' not in know['handling']
|
|
assert liftexport.handling_view(make_know(), True) is not None
|
|
|
|
|
|
def test_default_stages_flat_piece_runs_on_the_flat_overlay():
|
|
know = make_know_with_flat_overlay()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='thickness')
|
|
stages, _, _ = liftexport.default_stages(job, know)
|
|
stripping = dict((s['name'], s) for s in stages)['stripping']
|
|
assert stripping['tension_limit_sqrt_fc'] == 6.0
|
|
assert stripping['compression_limit_ratio'] == 0.7
|
|
|
|
|
|
def test_default_stages_wall_panel_keeps_the_plant_wide_limits():
|
|
know = make_know_with_flat_overlay()
|
|
job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='length')
|
|
stages, _, _ = liftexport.default_stages(job, know)
|
|
stripping = dict((s['name'], s) for s in stages)['stripping']
|
|
assert stripping['tension_limit_sqrt_fc'] == 5.0
|
|
assert stripping['compression_limit_ratio'] == 0.6
|