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.
367 lines
15 KiB
Python
367 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The design-summary vocabulary -- rows 8.1 and 5.9.
|
|
|
|
WHAT THIS FILE IS PINNING. Row 8.1 reported SEVEN uninterpreted lines on a
|
|
real insulated piece and row 5.9 stood down on every press because one of
|
|
those seven WAS the requirement it needed. The grammar in
|
|
`ticketpage.parse_summary_line` closes both, and every production in it was
|
|
drawn from a corpus rather than from one line:
|
|
|
|
(a) the live design-summary lines the evidence files recorded as
|
|
unresolved -- 9 distinct, 7 of them on the piece the owner read;
|
|
(b) 54 distinct `... @ <spacing> O.C. ...` call-outs over 75 occurrences
|
|
in the plant standards manual extractions.
|
|
|
|
Both counted 2026-08-31. The lines below are quoted for GRAMMAR only: a bar
|
|
size, a product code and a pitch are naming conventions, not identities
|
|
(CLAUDE.md ss3).
|
|
|
|
THE TWO INVARIANTS THIS FILE EXISTS TO KEEP, and they are the ones a future
|
|
"tidy up" will attack first:
|
|
|
|
* a line the grammar cannot place STAYS an 8.1 finding, verbatim;
|
|
* a line it places but cannot VALUE (`@ "S" O.C.`, `@ __ O.C.`) is not a
|
|
line it read -- it carries `spacing_in: None` and a reason, and nothing
|
|
may compare a piece against it.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import conftest # noqa: F401 -- installs the bare `vibe` package stub
|
|
|
|
from vibe import ticketpage as TP # noqa: E402
|
|
from vibe import knowledge as K # noqa: E402
|
|
|
|
|
|
# The seven lines of one real design summary, as the evidence file recorded
|
|
# them. This is the golden the whole exercise is measured against.
|
|
LIVE_SUMMARY = [
|
|
u'STRAND PATTERN',
|
|
u'CL STRANDS',
|
|
u'#3S @ 18" O.C. +(2)@3" EA. END',
|
|
u'CL #7 BARS NEEDED WHEN THERE ARE OPENINGS',
|
|
u'CL STRANDS & #7 BARS',
|
|
u"HK ST100'S @1'-4\" O.C.",
|
|
u'CL #3 BARS@ TOP WYTHE',
|
|
]
|
|
|
|
|
|
def test_the_seven_lines_the_owner_read_are_all_placed():
|
|
"""8.1 went from seven uninterpreted lines to none, and it did it by
|
|
understanding them -- not by dropping any."""
|
|
placed, unplaced = TP.classify_summary_lines(LIVE_SUMMARY)
|
|
assert unplaced == []
|
|
assert len(placed) == 7
|
|
kinds = sorted([p['kind'] for p in placed])
|
|
assert kinds == ['conditional', 'label', 'placement', 'placement',
|
|
'placement', 'spacing', 'spacing']
|
|
|
|
|
|
def test_the_inventory_names_what_each_line_was_taken_to_be():
|
|
"""A row that went from seven red lines to green with no sentence under
|
|
it would have swapped one silence for a quieter one."""
|
|
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
|
said = TP.summary_requirement_inventory(placed)
|
|
assert '2 spacing requirements' in said
|
|
assert '3 placement statements' in said
|
|
assert '1 conditional' in said
|
|
assert '1 group label' in said
|
|
|
|
|
|
# ----------------------------------------------------- the spacing production
|
|
|
|
def test_the_field_spacing_is_the_first_at_and_the_rest_is_a_qualifier():
|
|
"""`#3S @ 18" O.C. +(2)@3" EA. END` states ONE field spacing and then an
|
|
end densification. A reader that took the LAST `@` would report the end
|
|
pair as the requirement and fail every piece that is correct."""
|
|
req = TP.parse_summary_line(u'#3S @ 18" O.C. +(2)@3" EA. END')
|
|
assert req['kind'] == 'spacing'
|
|
assert req['spacing_in'] == 18.0
|
|
assert req['product'] == '#3S'
|
|
assert req['qualifiers'] == [u'+(2)@3" EA. END']
|
|
|
|
|
|
def test_feet_and_inches_and_bare_inches_and_fractions_all_read():
|
|
"""22 of the 54 manual call-outs write feet-and-inches, 18 write bare
|
|
inches, and several of those carry a vulgar fraction glyph -- which is
|
|
the one that silently turns 1 1/2 into 1 if nobody spells it out."""
|
|
cases = [(u"HK ST100'S @1'-4\" O.C.", 16.0),
|
|
(u'#3 E.L. @ 4\'-0" O.C.', 48.0),
|
|
(u'#4 REBAR @ 48" O.C.', 48.0),
|
|
(u'(2) #4 E.L. @ 1½" O.C.', 1.5),
|
|
(u'(2) #4 E.L. @ 1 1/2" O.C.', 1.5),
|
|
(u'30 SPACES @ 7½" O.C.', 7.5)]
|
|
for text, inches in cases:
|
|
req = TP.parse_summary_line(text)
|
|
assert req is not None and req['kind'] == 'spacing', text
|
|
assert req['spacing_in'] == inches, text
|
|
|
|
|
|
def test_a_symbolic_spacing_is_recognised_and_never_valued():
|
|
"""`@ "S" O.C.` is a COLUMN OF A TABLE on the sheet -- 7 occurrences over
|
|
5 distinct call-outs in the corpus. A reader that scanned for the first
|
|
digits would answer it from the plate size to the left of the `@`."""
|
|
req = TP.parse_summary_line(u'PLATE 3/8"x4" x 0\'-4" (P410) @ "S" O.C.')
|
|
assert req['kind'] == 'spacing'
|
|
assert req['spacing_in'] is None
|
|
assert 'table' in req['spacing_unread_because']
|
|
|
|
|
|
def test_a_blank_spacing_is_recognised_and_never_valued():
|
|
"""The sheet carries a literal `__`. The design has stated the SHAPE of
|
|
the requirement and not its value, and those are different."""
|
|
req = TP.parse_summary_line(u'#4 REBAR U-BEND @ __ O.C.')
|
|
assert req['kind'] == 'spacing'
|
|
assert req['spacing_in'] is None
|
|
assert 'blank' in req['spacing_unread_because']
|
|
|
|
|
|
def test_an_unvalued_spacing_is_called_out_in_the_inventory():
|
|
"""It must not hide inside a count of lines the tool "read"."""
|
|
placed, _ = TP.classify_summary_lines(
|
|
[u'PLATE (P410) @ "S" O.C.', u'#4 @ 12" O.C.'])
|
|
said = TP.summary_requirement_inventory(placed)
|
|
assert 'carry no numeric spacing' in said
|
|
assert 'P410' in said
|
|
|
|
|
|
# --------------------------------------------- placement, conditional, label
|
|
|
|
def test_cl_is_a_placement_and_the_wythe_is_its_zone():
|
|
"""`CL #3 BARS@ TOP WYTHE` is a bar size, a placement (centred) and a
|
|
wythe -- QNA/proposed-checks-2026-08-31.md P12. The `@` here carries the
|
|
zone and not a spacing, which is why the spacing production must not
|
|
match a line with no `O.C.` on it."""
|
|
req = TP.parse_summary_line(u'CL #3 BARS@ TOP WYTHE')
|
|
assert req['kind'] == 'placement'
|
|
assert req['placement'] == 'centred'
|
|
assert req['items'] == ['#3 BARS']
|
|
assert req['zone'] == 'TOP WYTHE'
|
|
|
|
|
|
def test_a_placement_naming_two_things_keeps_both():
|
|
req = TP.parse_summary_line(u'CL STRANDS & #7 BARS')
|
|
assert req['items'] == ['STRANDS', '#7 BARS']
|
|
assert req['zone'] is None
|
|
|
|
|
|
def test_a_conditional_wraps_the_requirement_it_qualifies():
|
|
"""The tool STATES this and does not evaluate it. What it must not do is
|
|
lose the requirement inside the condition."""
|
|
req = TP.parse_summary_line(u'CL #7 BARS NEEDED WHEN THERE ARE OPENINGS')
|
|
assert req['kind'] == 'conditional'
|
|
assert req['condition'] == 'THERE ARE OPENINGS'
|
|
assert req['requirement']['kind'] == 'placement'
|
|
assert req['requirement']['items'] == ['#7 BARS']
|
|
|
|
|
|
def test_the_conditionals_are_printed_and_marked_as_unevaluated():
|
|
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
|
said = TP.summary_requirement_inventory(placed)
|
|
assert 'does not evaluate' in said
|
|
assert 'NEEDED WHEN THERE ARE OPENINGS' in said
|
|
|
|
|
|
def test_a_deferral_is_not_a_label_and_stays_unplaced():
|
|
"""`REINFORCING PER DESIGN` is three capitalised words with no digits --
|
|
exactly the shape of a heading -- and it is a DEFERRAL. Filing it as a
|
|
label would be the tool claiming to have understood a line that says the
|
|
answer is somewhere else."""
|
|
assert TP.parse_summary_line(u'REINFORCING PER DESIGN') is None
|
|
placed, unplaced = TP.classify_summary_lines(
|
|
[u'STRAND PATTERN', u'REINFORCING PER DESIGN'])
|
|
assert [p['kind'] for p in placed] == ['label']
|
|
assert unplaced == [u'REINFORCING PER DESIGN']
|
|
|
|
|
|
def test_an_unplaceable_line_comes_back_verbatim_and_in_its_own_shape():
|
|
"""8.1 prints it and a reader has to be able to find it on the sheet, so
|
|
a `{text, reason}` entry must come back as that entry and not as a
|
|
string this function invented."""
|
|
entry = {'text': u'SOMETHING NOBODY HAS TAUGHT THIS 42', 'reason': u'why'}
|
|
placed, unplaced = TP.classify_summary_lines([entry])
|
|
assert placed == []
|
|
assert unplaced == [entry]
|
|
assert unplaced[0] is entry
|
|
|
|
|
|
# ----------------------------------------------- 5.9: the design side, joined
|
|
|
|
def _plant():
|
|
"""A plant that declares connectors the way the real one does: part
|
|
numbers of its own, and an alias onto a published product."""
|
|
return {
|
|
'connectors': {
|
|
'marks': {'group_1': ['R80', 'R81', 'R82'],
|
|
'group_2': ['R87']},
|
|
'catalogue_alias': {'R80': 'HK Composites (ST50)',
|
|
'R82': 'HK Composites (ST100)',
|
|
'R81': None, 'R87': None},
|
|
},
|
|
'tolerances': {'connector_spacing_in': 0.5},
|
|
}
|
|
|
|
|
|
def test_the_summary_line_joins_to_the_plants_connector_through_its_code():
|
|
"""The whole point of the exercise: the design says `HK ST100'S`, the
|
|
plant says `R82 = HK Composites (ST100)`, and ST100 is what joins them.
|
|
Nothing in the code knows what an ST100 is."""
|
|
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
|
req, why = K.connector_spacing_requirement(_plant(), placed)
|
|
assert req['spacing_in'] == 16.0
|
|
assert 'ST100' in why and 'R82' in why
|
|
|
|
|
|
def test_the_join_is_exact_so_one_product_never_answers_for_another():
|
|
"""`ST50` and `ST100` are both 'HK Composites' and they are not the same
|
|
part. A substring join would let either answer for the other, which is
|
|
the `connector_product` lesson one join along."""
|
|
placed, _ = TP.classify_summary_lines([u"HK ST50'S @2'-0\" O.C."])
|
|
req, why = K.connector_spacing_requirement(_plant(), placed)
|
|
assert req['spacing_in'] == 24.0
|
|
assert 'ST50' in why and 'R80' in why
|
|
assert 'R82' not in why
|
|
|
|
|
|
def test_a_spacing_that_is_not_about_a_connector_is_refused_by_name():
|
|
"""A bar's pitch read as a connector's would be a confident wrong
|
|
answer, and 5.9 FAILS on a shortfall."""
|
|
placed, _ = TP.classify_summary_lines([u'#3S @ 18" O.C.'])
|
|
req, why = K.connector_spacing_requirement(_plant(), placed)
|
|
assert req is None
|
|
assert 'none of them names a part number' in why
|
|
assert '#3S @ 18" O.C.' in why
|
|
|
|
|
|
def test_a_connector_line_with_no_number_stands_the_row_down_saying_so():
|
|
"""The one case a reader must not mistake for agreement: the
|
|
requirement is real, it is about a connector, and it has no value."""
|
|
placed, _ = TP.classify_summary_lines([u'HK ST100 PINS @ "S" O.C.'])
|
|
req, why = K.connector_spacing_requirement(_plant(), placed)
|
|
assert req is None
|
|
assert 'ST100' in why
|
|
assert 'no number to hold this piece to' in why
|
|
|
|
|
|
def test_a_design_that_states_nothing_says_that_and_not_something_else():
|
|
req, why = K.connector_spacing_requirement(_plant(), [])
|
|
assert req is None
|
|
assert 'no line this tool could read as a requirement' in why
|
|
|
|
|
|
def test_a_plant_that_declares_no_connector_cannot_recognise_one():
|
|
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
|
req, why = K.connector_spacing_requirement({}, placed)
|
|
assert req is None
|
|
assert 'no layer declares a connector part number' in why
|
|
|
|
|
|
def test_the_tolerance_is_a_key_with_its_provenance_in_the_sentence():
|
|
"""It was a `+ 0.5` literal inside the comparison. A tolerance that
|
|
cannot be named cannot be argued with, and one that cannot be resolved
|
|
must stop the comparison rather than fall back on a constant."""
|
|
value, why = K.connector_spacing_tolerance(_plant())
|
|
assert value == 0.5
|
|
assert 'tolerances.connector_spacing_in' in why
|
|
value, why = K.connector_spacing_tolerance({'tolerances': {}})
|
|
assert value is None
|
|
assert 'tolerances.connector_spacing_in is unset' in why
|
|
|
|
|
|
def test_the_measured_piece_passes_end_to_end():
|
|
"""THE CALIBRATION, and it is the reason the comparison is against the
|
|
WIDEST gap. Measured 2026-08-31 on one insulated piece: 16.0 in across
|
|
the field -- which IS the summary's 1'-4" -- with 9.5 and 6.5 in pairs
|
|
at the ends. End densification is normal and must never be flagged."""
|
|
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
|
know = _plant()
|
|
req, why = K.connector_spacing_requirement(know, placed)
|
|
tolerance, tolerance_why = K.connector_spacing_tolerance(know)
|
|
severity, message = TP.connectors_sufficient_status(
|
|
115, None, spacing_in=[9.5, 16.0, 16.0, 16.0, 6.5],
|
|
required_spacing_in=req['spacing_in'], requirement_note=why,
|
|
tolerance_in=tolerance, tolerance_why=tolerance_why)
|
|
assert severity == 'OK'
|
|
assert '16.0' in message
|
|
assert 'ST100' in message
|
|
assert 'tolerances.connector_spacing_in' in message
|
|
|
|
|
|
# ------------------------------------------------- 8.1's two clean answers
|
|
|
|
def _spec(lines):
|
|
return {'unresolved': [{'text': t, 'reason': 'r'} for t in lines]}
|
|
|
|
|
|
def _checks():
|
|
"""`ticketchecks` needs the inert Revit stub; borrow it and put it back.
|
|
|
|
`sys.modules` is process-wide and `test_reinforceoffline.py` asserts that
|
|
no `Autodesk*` module is loaded, so leaving it behind would make this
|
|
file tell that test a lie.
|
|
"""
|
|
import types
|
|
added = []
|
|
if 'Autodesk.Revit.DB' not in sys.modules:
|
|
autodesk = types.ModuleType('Autodesk')
|
|
revit = types.ModuleType('Autodesk.Revit')
|
|
db = types.ModuleType('Autodesk.Revit.DB')
|
|
for name in ('FilteredElementCollector', 'AssemblyInstance', 'View',
|
|
'ViewSheet', 'ViewType', 'BuiltInCategory', 'Element',
|
|
'StorageType', 'ElementId', 'XYZ', 'Transform',
|
|
'Options', 'Solid', 'BoundingBoxXYZ'):
|
|
setattr(db, name, type(str(name), (object,), {}))
|
|
autodesk.Revit = revit
|
|
revit.DB = db
|
|
sys.modules['Autodesk'] = autodesk
|
|
sys.modules['Autodesk.Revit'] = revit
|
|
sys.modules['Autodesk.Revit.DB'] = db
|
|
added = ['Autodesk.Revit.DB', 'Autodesk.Revit', 'Autodesk']
|
|
try:
|
|
from vibe import ticketchecks
|
|
return ticketchecks
|
|
finally:
|
|
for name in added:
|
|
sys.modules.pop(name, None)
|
|
|
|
|
|
def test_81_tells_its_two_clean_answers_apart():
|
|
""""the extractor placed every line" and "the extractor could not place
|
|
seven and this vocabulary recognised all seven" are different claims,
|
|
and the second is a reading. The reader is entitled to know which one he
|
|
is looking at."""
|
|
TC = _checks()
|
|
strong = TC.check_spec_unresolved({}, {}, {}, _spec([]))[0]
|
|
assert strong.severity == 'OK'
|
|
assert 'was interpreted' in strong.message
|
|
weaker = TC.check_spec_unresolved({}, {}, {}, _spec(LIVE_SUMMARY))[0]
|
|
assert weaker.severity == 'OK'
|
|
assert 'the summary vocabulary categorised those' in weaker.message
|
|
assert '7 line(s) categorised' in weaker.message
|
|
|
|
|
|
def test_81_still_warns_and_still_enumerates_what_it_could_not_place():
|
|
"""The row's job is unparsed lines and it must never learn to shrug."""
|
|
TC = _checks()
|
|
found = TC.check_spec_unresolved(
|
|
{}, {}, {}, _spec([u'STRAND PATTERN', u'REINFORCING PER DESIGN',
|
|
u'SOMETHING ELSE ENTIRELY 42']))[0]
|
|
assert found.severity == 'WARN'
|
|
assert found.message.startswith('2 line(s)')
|
|
assert 'REINFORCING PER DESIGN' in found.message
|
|
assert 'SOMETHING ELSE ENTIRELY 42' in found.message
|
|
# and what it DID place is still visible on the same row
|
|
assert 'group label' in found.message
|
|
|
|
|
|
def test_81_leaves_the_specs_verbatim_record_alone():
|
|
"""`spec['unresolved']` is the EXTRACTOR's account of what its own rules
|
|
could not place. A later reader's opinion written back over it would
|
|
stop it being evidence."""
|
|
TC = _checks()
|
|
spec = _spec(LIVE_SUMMARY)
|
|
before = [dict(entry) for entry in spec['unresolved']]
|
|
TC.check_spec_unresolved({}, {}, {}, spec)
|
|
assert spec['unresolved'] == before
|