52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
278 lines
11 KiB
Python
278 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The takeoff's Revit-free half: classification, both totals, and the refusals.
|
|
|
|
No Revit, no model, no index. Every fixture here is a synthetic index cache
|
|
in the shape `reinforcecore.piece_record` produces, with invented family
|
|
names -- the point of the split is that the counting rules can be wrong on
|
|
paper and caught here, before anybody opens a model.
|
|
|
|
The rules under test are the ones this project has already paid to learn:
|
|
a mark is not a piece, a nested family is counted twice, an attached bar is
|
|
not a separate order, and a family nobody could classify is a finding rather
|
|
than a silence.
|
|
"""
|
|
# pylint: disable=import-error,invalid-name
|
|
from vibe import takeoffcore as core
|
|
|
|
|
|
def piece(name, families, **extra):
|
|
record = {'name': name, 'families': families, 'features': [],
|
|
'error': None, 'design_number': extra.pop('design', None)}
|
|
record.update(extra)
|
|
return record
|
|
|
|
|
|
def cache(*records):
|
|
return {'schema': 3, 'built_at': 'once',
|
|
'pieces': dict((r['name'], r) for r in records)}
|
|
|
|
|
|
CFG = core.settings({})
|
|
|
|
|
|
# --------------------------------------------------------------- classifying
|
|
|
|
def test_a_bar_prefix_family_is_a_bar_and_keeps_its_mark():
|
|
assert core.classify('SR9001', CFG) == ('bar', 'SR9001', 'rule:bar_prefix')
|
|
|
|
|
|
def test_a_part_number_is_dug_out_of_a_longer_family_name():
|
|
klass, key, basis = core.classify('MAKER_EMBED_P70 with bar', CFG)
|
|
assert (klass, key, basis) == ('hardware', 'P70', 'rule:part_prefix')
|
|
|
|
|
|
def test_the_longer_prefix_wins_so_one_series_is_not_read_as_another():
|
|
"""SP70 must never come back as the P70 inside it."""
|
|
assert core.classify('SP70', CFG)[1] == 'SP70'
|
|
|
|
|
|
def test_a_bend_shape_family_is_reinforcing_with_no_mark():
|
|
"""A shape family carries a different bar mark on every piece.
|
|
|
|
It must not borrow a mark from its own name, and it must not be dropped:
|
|
the instances are real steel whose SKU this index cannot name.
|
|
"""
|
|
klass, key, basis = core.classify('99A_(SCHEDULED)', CFG)
|
|
assert klass == core.BAR_SHAPE
|
|
assert key == '99A_(SCHEDULED)'
|
|
assert basis == 'rule:shape_marker'
|
|
|
|
|
|
def test_a_shape_marker_beats_a_bar_prefix_in_the_family_name():
|
|
assert core.classify('SR9001_(SCHEDULED)', CFG)[0] == core.BAR_SHAPE
|
|
|
|
|
|
def test_an_unknown_family_is_unclassified_and_keeps_its_own_name():
|
|
assert core.classify('WIDGET_THING', CFG) == (
|
|
'unclassified', 'WIDGET_THING', 'none')
|
|
|
|
|
|
def test_a_declared_family_beats_every_pattern():
|
|
cfg = core.settings({'takeoff': {'family_class': {'SR9001': 'cast'}}})
|
|
assert core.classify('SR9001', cfg) == ('cast', 'SR9001', 'declared')
|
|
|
|
|
|
def test_not_ordered_is_a_substring_declaration():
|
|
cfg = core.settings({'takeoff': {'not_ordered': ['PANEL_HOST']}})
|
|
assert core.classify('PANEL_HOST_FLAT', cfg)[0] == core.NOT_ORDERED
|
|
|
|
|
|
def test_strand_and_mesh_come_from_the_existing_family_rules():
|
|
assert core.classify('X_STRAND', CFG, is_strand=lambda f: 'STRAND' in f)[0] \
|
|
== core.STRAND
|
|
assert core.classify('WWF_6X6', CFG, is_mesh=lambda f: f.startswith('WWF'))[0] \
|
|
== core.MESH
|
|
|
|
|
|
# ---------------------------------------------------- the control multiplier
|
|
|
|
def test_per_mark_and_per_job_are_both_reported_and_differ_by_the_multiplier():
|
|
out = core.build(cache(piece('A', {'P70': 4})), CFG,
|
|
multipliers={'A': (3, 'ticket_files')})
|
|
row = out['totals'][0]
|
|
assert row['per_mark_total'] == 4
|
|
assert row['per_job_total'] == 12
|
|
assert row['multiplier'] == 3.0
|
|
|
|
|
|
def test_a_mark_with_no_sourced_multiplier_is_one_and_is_named_an_undercount():
|
|
"""Counted once, and listed, because a multiplier nobody sourced is not 1."""
|
|
out = core.build(cache(piece('A', {'P70': 4})), CFG)
|
|
assert out['totals'][0]['per_job_total'] == 4
|
|
assert out['undercounted'] == ['A']
|
|
assert out['by_piece']['A']['multiplier_basis'] == 'assumed_one'
|
|
|
|
|
|
def test_counting_marks_instead_of_pieces_is_the_under_order_this_prevents():
|
|
"""Two marks, one of them three pieces: 4 per mark, 8 in the job."""
|
|
out = core.build(
|
|
cache(piece('A', {'P70': 2}), piece('B', {'P70': 2})), CFG,
|
|
multipliers={'A': (3, 'ticket_files'), 'B': (1, 'ticket_files')})
|
|
row = out['totals'][0]
|
|
assert (row['per_mark_total'], row['per_job_total']) == (4, 8)
|
|
assert out['pieces_on_the_floor'] == 4
|
|
|
|
|
|
# --------------------------------------------------------------- attached bars
|
|
|
|
def test_an_attached_bar_is_counted_and_kept_off_the_order_by_default():
|
|
out = core.build(cache(piece('A', {'SR9001': 6, 'P70': 2})), CFG,
|
|
attached={'SR9001': ['P70']})
|
|
order, ships, _other = core.order_rows(out['totals'],
|
|
out['order_attached_separately'])
|
|
assert [r['key'] for r in ships] == ['SR9001']
|
|
assert [r['key'] for r in order] == ['P70']
|
|
assert ships[0]['per_mark_total'] == 6 # counted, not dropped
|
|
assert ships[0]['attached_to'] == ['P70']
|
|
|
|
|
|
def test_the_switch_puts_attached_bars_back_on_the_order():
|
|
cfg = core.settings({'takeoff': {'order_attached_bars_separately': True}})
|
|
out = core.build(cache(piece('A', {'SR9001': 6})), cfg,
|
|
attached={'SR9001': ['P70']})
|
|
order, ships, _other = core.order_rows(out['totals'],
|
|
out['order_attached_separately'])
|
|
assert [r['key'] for r in order] == ['SR9001']
|
|
assert ships == []
|
|
|
|
|
|
def test_attached_parents_are_read_from_the_drawing_knowledge():
|
|
know = {'drawings': {'annotation_covered_by_parent': {'SR9001': ['P70']}}}
|
|
assert core.attached_parents(CFG, know) == {'SR9001': ['P70']}
|
|
|
|
|
|
def test_a_single_parent_written_as_a_scalar_still_reads():
|
|
know = {'drawings': {'annotation_covered_by_parent': {'SR9001': 'P70'}}}
|
|
assert core.attached_parents(CFG, know) == {'SR9001': ['P70']}
|
|
|
|
|
|
# ------------------------------------------------------ what it will not count
|
|
|
|
def test_an_unclassified_family_is_listed_by_name_never_bucketed():
|
|
out = core.build(cache(piece('A', {'WIDGET_THING': 5})), CFG,
|
|
multipliers={'A': (2, 'ticket_files')})
|
|
assert [r['family'] for r in out['unclassified']] == ['WIDGET_THING']
|
|
assert out['unclassified'][0]['per_job_total'] == 10
|
|
assert out['unclassified'][0]['examples'] == ['A']
|
|
|
|
|
|
def test_unclassified_is_never_orderable():
|
|
out = core.build(cache(piece('A', {'WIDGET_THING': 5})), CFG)
|
|
order, _ships, other = core.order_rows(out['totals'])
|
|
assert order == []
|
|
assert [r['class'] for r in other] == ['unclassified']
|
|
|
|
|
|
def test_a_piece_with_no_families_is_could_not_judge_not_an_empty_piece():
|
|
"""The distinction the whole index is built around, kept on this side."""
|
|
record = piece('A', {})
|
|
record['error'] = 'members unreadable'
|
|
record['features'] = None
|
|
out = core.build(cache(record), CFG)
|
|
assert out['unjudged'] == [{'piece': 'A', 'reason': 'members unreadable'}]
|
|
assert out['by_piece']['A']['judged'] is False
|
|
assert out['totals'] == []
|
|
|
|
|
|
def test_a_measured_piece_with_no_openings_is_still_counted():
|
|
"""features=None is about GEOMETRY. A takeoff does not need geometry."""
|
|
record = piece('A', {'P70': 2})
|
|
record['features'] = None
|
|
record['error'] = 'no concrete solid'
|
|
out = core.build(cache(record), CFG)
|
|
assert out['unjudged'] == []
|
|
assert out['totals'][0]['per_mark_total'] == 2
|
|
|
|
|
|
def test_coverage_names_the_marks_the_index_never_built():
|
|
out = core.build(cache(piece('A', {'P70': 1})), CFG,
|
|
population={'assemblies': 5})
|
|
assert out['coverage']['indexed_marks'] == 1
|
|
assert out['coverage']['marks_not_indexed'] == 4
|
|
assert out['coverage']['complete'] is False
|
|
|
|
|
|
def test_every_row_records_what_decided_it():
|
|
out = core.build(cache(piece('A', {'P70': 1, 'WIDGET': 1})), CFG)
|
|
assert out['bases'] == {'rule:part_prefix': 1, 'none': 1}
|
|
|
|
|
|
# -------------------------------------------------------------- nesting
|
|
|
|
def test_a_family_that_follows_another_count_for_count_is_flagged():
|
|
out = core.suspected_nesting({
|
|
'A': {'families': {'PARENT': 4, 'CHILD': 4}},
|
|
'B': {'families': {'PARENT': 8, 'CHILD': 8}},
|
|
'C': {'families': {'PARENT': 2, 'CHILD': 2}},
|
|
})
|
|
assert len(out) == 1
|
|
assert set([out[0]['child'], out[0]['parent']]) == set(['PARENT', 'CHILD'])
|
|
assert out[0]['direction'] == 'undecided'
|
|
assert out[0]['agreement'] == 1.0
|
|
|
|
|
|
def test_two_parts_that_are_one_each_are_not_evidence_of_anything():
|
|
"""The filter that took this list from forty rows of noise to three."""
|
|
assert core.suspected_nesting({
|
|
'A': {'families': {'ONE': 1, 'TWO': 1}},
|
|
'B': {'families': {'ONE': 1, 'TWO': 1}},
|
|
'C': {'families': {'ONE': 1, 'TWO': 1}},
|
|
}) == []
|
|
|
|
|
|
def test_a_pair_that_disagrees_on_one_mark_still_surfaces_with_its_agreement():
|
|
out = core.suspected_nesting({
|
|
'A': {'families': {'PARENT': 4, 'CHILD': 4}},
|
|
'B': {'families': {'PARENT': 8, 'CHILD': 8}},
|
|
'C': {'families': {'PARENT': 2, 'CHILD': 2}},
|
|
'D': {'families': {'PARENT': 2, 'CHILD': 1}},
|
|
'E': {'families': {'PARENT': 4, 'CHILD': 4}},
|
|
'F': {'families': {'PARENT': 8, 'CHILD': 8}},
|
|
'G': {'families': {'PARENT': 2, 'CHILD': 2}},
|
|
'H': {'families': {'PARENT': 4, 'CHILD': 4}},
|
|
'I': {'families': {'PARENT': 8, 'CHILD': 8}},
|
|
'J': {'families': {'PARENT': 2, 'CHILD': 2}},
|
|
})
|
|
pair = [r for r in out if r['child'] == 'CHILD' or r['parent'] == 'CHILD']
|
|
assert pair and pair[0]['agreement'] < 1.0
|
|
|
|
|
|
def test_nothing_is_ever_subtracted_on_a_suspicion():
|
|
"""The total is unchanged by the detector. It only prints."""
|
|
pieces = cache(piece('A', {'PARENT': 4, 'CHILD': 4}),
|
|
piece('B', {'PARENT': 8, 'CHILD': 8}),
|
|
piece('C', {'PARENT': 2, 'CHILD': 2}))
|
|
out = core.build(pieces, CFG)
|
|
total = dict((r['key'], r['per_mark_total']) for r in out['totals'])
|
|
assert total['CHILD'] == 14
|
|
assert out['nesting_suspects']
|
|
|
|
|
|
def test_a_declared_nesting_leaves_the_order_list():
|
|
cfg = core.settings({'takeoff': {'nested_in': {'CHILD': 'PARENT'}}})
|
|
out = core.build(cache(piece('A', {'CHILD': 4})), cfg)
|
|
assert out['totals'][0]['class'] == core.NESTED
|
|
order, _ships, other = core.order_rows(out['totals'])
|
|
assert order == []
|
|
assert other[0]['key'] == 'CHILD'
|
|
|
|
|
|
# ------------------------------------------- the index field that does not exist
|
|
|
|
def test_a_marks_counter_resolves_bend_shapes_into_real_bar_marks():
|
|
"""The one-field index change, exercised before the index has it.
|
|
|
|
With `marks` on the record the shape families drop out and named bar
|
|
marks take their place. Nothing else about the takeoff changes, which is
|
|
the whole reason it was written this way.
|
|
"""
|
|
record = piece('A', {'99A_(SCHEDULED)': 12, 'P70': 2})
|
|
record['marks'] = {'SR9001': 8, 'SR9002': 4, 'P70': 2}
|
|
out = core.build(cache(record), CFG)
|
|
got = dict(((r['class'], r['key']), r['per_mark_total'])
|
|
for r in out['totals'])
|
|
assert got[('bar', 'SR9001')] == 8
|
|
assert got[('bar', 'SR9002')] == 4
|
|
assert got[('hardware', 'P70')] == 2 # counted once, not twice
|
|
assert ('bar_shape', '99A_(SCHEDULED)') not in got
|