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.
7938 lines
355 KiB
Python
7938 lines
355 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Checks that need arithmetic rather than a comparison.
|
||
|
||
The plain checks in ticketqc line up two stated values and see whether they
|
||
match. These do something with the numbers first: sum a capacity, measure a
|
||
distance, apply a factor.
|
||
|
||
Each check is a function taking (ticket, intent, know, spec) and returning a
|
||
list of Findings. Register it in CHECKS and it runs. Everything it needs to
|
||
be told -- capacities, factors, limits -- comes out of the knowledge file, so
|
||
adding a lifter model is a YAML edit and only a genuinely new *kind* of
|
||
calculation needs code here.
|
||
|
||
A check whose inputs are missing must return nothing, or say plainly that it
|
||
could not run. Silence that reads as a pass is the one failure mode worth
|
||
designing against.
|
||
|
||
IronPython 2.7: no f-strings, no pathlib.
|
||
"""
|
||
# pylint: disable=import-error,invalid-name,broad-except
|
||
import io
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import time
|
||
|
||
from vibe import knowledge as K
|
||
from vibe import specformat
|
||
from vibe.compat import text_type, string_types
|
||
from vibe.ticketqc import Finding, fmt_feet, _fmt_num
|
||
|
||
|
||
# --------------------------------------------------------------- lifting
|
||
|
||
def check_lifter_capacity(t, intent, know, spec):
|
||
"""Total rated capacity of the lifting inserts against the piece weight."""
|
||
lifters = t.get('lifters') or []
|
||
weight = t.get('weight_lb')
|
||
if not lifters:
|
||
return []
|
||
if weight is None:
|
||
return [Finding('WARN', 'LIFT-WEIGHT',
|
||
'piece weight not readable, lifter capacity not checked')]
|
||
|
||
factor = K.get(know, 'lifters.dynamic_factor', 1.5) or 1.0
|
||
demand = weight * factor
|
||
|
||
# The concrete at the lift, not at 28 days. Stripping is the governing
|
||
# handling case and it happens at release, so a catalogue rating derived
|
||
# at a higher strength has to come down for it.
|
||
fc_at_lift = intent.get('fci') or intent.get('fc')
|
||
|
||
# t['bom'] is a family-name -> count dict for every member of the
|
||
# assembly (ticketqc.member_boxes' walk, gathered while it builds `t`) --
|
||
# the same corroboration liftexport hands in as `assembly_families`, just
|
||
# shaped as a BOM instead of a list. Iterating either yields family
|
||
# names, which is all `lifter_capacity`'s tension-bar gate looks at.
|
||
assembly_families = t.get('bom')
|
||
|
||
total = 0.0
|
||
unknown = {}
|
||
known = {}
|
||
for lift in lifters:
|
||
cap, _basis, _why = K.lifter_capacity(
|
||
know, lift['family'], fc_psi=fc_at_lift,
|
||
assembly_families=assembly_families)
|
||
if cap is None:
|
||
unknown[lift['family']] = unknown.get(lift['family'], 0) + 1
|
||
else:
|
||
total += float(cap)
|
||
known[lift['family']] = known.get(lift['family'], 0) + 1
|
||
|
||
out = []
|
||
if unknown:
|
||
# Two hops can fail, and they are fixed in different files: an
|
||
# unaliased family is the plant's to map, a missing catalogue entry is
|
||
# the product data's. Saying which saves the guess.
|
||
detail = []
|
||
for fam in sorted(unknown):
|
||
designation = K.catalogue_item(know, fam)
|
||
detail.append('{0} x{1} ({2})'.format(
|
||
fam, unknown[fam],
|
||
'aliased to ' + designation + ', not in the catalogue'
|
||
if designation else 'no catalogue alias'))
|
||
out.append(Finding(
|
||
'WARN', 'LIFT-UNKNOWN',
|
||
'no rated capacity for {0} -- capacity check is incomplete'
|
||
.format(', '.join(detail))))
|
||
if not known:
|
||
return out
|
||
|
||
mix = ', '.join(['{0} x{1}'.format(k, v) for k, v in sorted(known.items())])
|
||
if total < demand:
|
||
out.append(Finding(
|
||
'FAIL', 'LIFT-CAPACITY',
|
||
'lifters rated {0} lb total but the piece needs {1} lb '
|
||
'({2} lb x {3}) -- {4}{5}'
|
||
.format(_fmt_num(total), _fmt_num(demand), _fmt_num(weight),
|
||
factor, mix, ' plus unrated inserts' if unknown else '')))
|
||
elif not unknown:
|
||
out.append(Finding(
|
||
'OK', 'LIFT-CAPACITY',
|
||
'lifters {0} lb vs {1} lb demand ({2})'
|
||
.format(_fmt_num(total), _fmt_num(demand), mix)))
|
||
return out
|
||
|
||
|
||
# WHICH MINIMUM, AND WHOSE -- fixed 2026-08-31.
|
||
#
|
||
# 6.2 used to read ONE number, `lifters.min_edge_distance_in`, and hold every
|
||
# insert on the piece to it. The catalogue publishes a minimum PER PART --
|
||
# `knowledge.lifter_edge_distance()` -- and the published numbers span roughly
|
||
# 6" to 14", so a single plant-wide value was wrong in both directions at
|
||
# once against a plausible 9" default:
|
||
#
|
||
# * an insert whose published minimum is 14" PASSED at 10". A false OK on
|
||
# an edge-distance check, which is a safety check.
|
||
# * an insert whose published minimum is 6" was FLAGGED at 7".
|
||
# * and `liftexport.py` -- Panel Lift Check, reading the same model, the
|
||
# same insert, the same catalogue -- already went through the per-part
|
||
# function, so the two outputs of this one tool disagreed about the same
|
||
# insert. That is worse than either being wrong alone: a reader who sees
|
||
# them contradict cannot trust either.
|
||
#
|
||
# So the resolution order below is `liftexport`'s, deliberately, and not a
|
||
# third one invented here: the PART's published minimum first, the plant-wide
|
||
# key only as a fallback for a part the catalogue does not publish.
|
||
#
|
||
# NEITHER THE FALLBACK NOR ITS ABSENCE IS A PASS. Where the number came from
|
||
# changes what the finding may claim, and the finding says which it used:
|
||
#
|
||
# published for this part -> FAIL. It is the part's own number.
|
||
# plant-wide fallback -> WARN, and the message says so. This is the
|
||
# old ruling kept (Will 08-28, "not sure" is
|
||
# WARN): a general value may not belong to
|
||
# this insert, and FAIL would assert it does.
|
||
# neither -> nothing is judged for that insert. When no
|
||
# insert on the piece has a minimum at all the
|
||
# check returns empty and the probe below makes
|
||
# 6.2 read NOT RUN with the reason; when only
|
||
# some do, the rest are named in a WARN so the
|
||
# row cannot go green over an unasked question.
|
||
|
||
|
||
def _edge_limit(know, family):
|
||
"""(inches, 'published' | 'plant') for one lifter family, or (None, None).
|
||
|
||
The hop order is `liftexport.py`'s so both consumers ask the catalogue
|
||
the same question of the same data. Truthiness, not `is None`, for the
|
||
same reason it is truthiness there: a published 0" minimum is not a
|
||
minimum, it is an empty cell.
|
||
"""
|
||
published = K.lifter_edge_distance(know, family)
|
||
if published:
|
||
return published, 'published'
|
||
plant = K.get(know, 'lifters.min_edge_distance_in')
|
||
if plant:
|
||
return plant, 'plant'
|
||
return None, None
|
||
|
||
|
||
def _edge_limit_phrase(limit_in, source, family):
|
||
"""Where the number came from, in words, because "9 inches" and "14
|
||
inches for this part" are different claims and a reader must be able to
|
||
tell them apart."""
|
||
if source == 'published':
|
||
return '{0}" is {1}\'s own published minimum'.format(limit_in, family)
|
||
return ('{0}" is the plant-wide default lifters.min_edge_distance_in -- '
|
||
'the catalogue publishes no minimum for {1}, so this limit may '
|
||
'not be {1}\'s'.format(limit_in, family))
|
||
|
||
|
||
def check_lifter_edge_distance(t, intent, know, spec):
|
||
"""Every lifting insert must sit far enough in from the ends of the piece.
|
||
|
||
Measured along the panel's own long axis, against the panel's extents --
|
||
not against the outermost lifter, which would make the end inserts fail by
|
||
definition. The minimum is resolved PER INSERT; see the block above.
|
||
"""
|
||
lifters = [l for l in (t.get('lifters') or []) if l.get('local')]
|
||
extents = t.get('panel_extents')
|
||
if not lifters:
|
||
return []
|
||
if not extents:
|
||
return [Finding('WARN', 'LIFT-EDGE',
|
||
'panel geometry unreadable, lifter edge distance not checked')]
|
||
|
||
lo_pt, hi_pt = extents
|
||
axis = max([(hi_pt[i] - lo_pt[i], i) for i in (0, 1, 2)])[1]
|
||
lo, hi = lo_pt[axis], hi_pt[axis]
|
||
|
||
judged = 0
|
||
unjudged = {}
|
||
worst = []
|
||
held_to = [] # the phrase for every insert actually judged
|
||
for lift in lifters:
|
||
# A member with no readable family name cannot be looked up and must
|
||
# not become a `None` dict key that `sorted` then chokes on -- it is
|
||
# named as unreadable and counted with the rest of the unjudged.
|
||
family = lift.get('family') or '(insert with no family name)'
|
||
limit_in, source = _edge_limit(know, family)
|
||
if limit_in is None:
|
||
unjudged[family] = unjudged.get(family, 0) + 1
|
||
continue
|
||
judged += 1
|
||
held_to.append(_edge_limit_phrase(limit_in, source, family))
|
||
pos = lift['local'][axis]
|
||
gap = min(abs(pos - lo), abs(hi - pos))
|
||
if gap < (limit_in / 12.0):
|
||
worst.append((gap, family, limit_in, source))
|
||
|
||
if not judged:
|
||
# Nothing on this piece has a minimum to be held to. That is a
|
||
# could-not-judge, not a pass, and the probe says so on the row.
|
||
return []
|
||
|
||
# ONE finding, not two. `checklist_states` indexes findings BY CODE and
|
||
# keeps the last one it sees, so a second `LIFT-EDGE` would silently
|
||
# overwrite the first and the row would print half the answer. The
|
||
# unjudged inserts have to ride on the owned code -- moving them to a
|
||
# code no row owns would let the row go green over an unasked question --
|
||
# so both halves are said in one message instead.
|
||
if worst:
|
||
worst.sort()
|
||
# A published minimum is the part's own number, so falling short of
|
||
# it is a failure. A plant-wide fallback is not, so a violation that
|
||
# leaned on one anywhere stays a WARN and the message says which.
|
||
severity = ('FAIL' if all(s == 'published' for (_g, _f, _l, s) in worst)
|
||
else 'WARN')
|
||
message = ('{0} of {1} lifters sit closer to an end than the minimum '
|
||
'for that insert -- {2}'
|
||
.format(len(worst), len(lifters),
|
||
'; '.join(['{0} at {1} (minimum {2})'
|
||
.format(f, fmt_feet(g),
|
||
_edge_limit_phrase(l, s, f))
|
||
for (g, f, l, s) in worst])))
|
||
else:
|
||
severity = 'WARN' if unjudged else 'OK'
|
||
message = ('{0} of {1} lifters clear of their own end minimum ({2})'
|
||
.format(judged, len(lifters),
|
||
'; '.join(sorted(set(held_to)))))
|
||
if unjudged:
|
||
message += ('. {0} of the {1} inserts on this piece were NOT judged: '
|
||
'no minimum edge distance is published for {2} and '
|
||
'lifters.min_edge_distance_in is not set'
|
||
.format(sum(unjudged.values()), len(lifters),
|
||
', '.join(['{0} x{1}'.format(f, n)
|
||
for f, n in sorted(unjudged.items())])))
|
||
return [Finding(severity, 'LIFT-EDGE', message)]
|
||
|
||
|
||
# ------------------------------------------- 6.7, minimum anchor spacing
|
||
#
|
||
# ALP catalog p53, 02/2024, in a footnote under the capacity table:
|
||
#
|
||
# "Minimum anchor spacing is 2x the published edge distance"
|
||
#
|
||
# THE OWNER HANDED OVER THAT PAGE AND THIS HALF OF IT NEVER LANDED. Its
|
||
# edge-distance column became 6.2 and its capacities became the catalogue;
|
||
# the sentence about spacing was read past. Nothing in this tool had ever
|
||
# asked how far apart two anchors must sit, which means two 8T anchors 20
|
||
# inches apart passed every check on the board while the page asked for 28.
|
||
#
|
||
# It is 6.2's shape one comparison further: the same insert positions, the
|
||
# same per-part resolution through the plant's alias, one more measurement.
|
||
# What differs is that the minimum is usually DERIVED rather than printed --
|
||
# 2 x the part's own published edge distance -- so the finding prints the
|
||
# arithmetic. "28 inches" and "twice QLPD8T8WG's published 14 inches" are
|
||
# different claims, and only the second can be checked by a reader.
|
||
#
|
||
# THE THICKNESS AXIS IS EXCLUDED, for `connector_edges`' reason and one of
|
||
# its own. Anchors in a lifting wythe all sit at one depth, so the depth term
|
||
# contributes nothing on a normal piece; and where it would contribute, a
|
||
# three-axis distance is LARGER, which is the direction that passes a pair
|
||
# the page would refuse. In-plane is the conservative read and the one the
|
||
# page means -- a spacing rule is about the cone of concrete each anchor
|
||
# needs, and those cones overlap in the plane.
|
||
|
||
|
||
def _spacing_limit(know, family):
|
||
"""(inches, source, why) for one lifter family. Cached by the caller.
|
||
|
||
No plant-wide fallback, unlike `_edge_limit`. The rule this implements
|
||
is a MULTIPLE of a published number, so where the catalogue publishes no
|
||
edge distance there is nothing to double -- and a general "anchors go 2
|
||
feet apart" is not a thing any layer states here. Standing down is the
|
||
answer, and `knowledge.lifter_spacing_minimum` writes the sentence.
|
||
"""
|
||
return K.lifter_spacing_minimum(know, family)
|
||
|
||
|
||
def _in_plane_axes(t):
|
||
"""The two local axes that are not the panel's thickness, or None.
|
||
|
||
Derived, not assumed to be an index: the span closest to the piece's own
|
||
thickness is the thickness axis, falling back to the smallest span when
|
||
the thickness was not read. That is `ticketqc.connector_edges`' idiom and
|
||
it is used here for its reason -- a nearly square piece makes any
|
||
shortcut pick the wrong axis.
|
||
"""
|
||
extents = t.get('panel_extents')
|
||
if not extents:
|
||
return None
|
||
lo, hi = extents
|
||
span = [hi[i] - lo[i] for i in (0, 1, 2)]
|
||
thickness = t.get('thickness')
|
||
thick = 0
|
||
for axis in (1, 2):
|
||
if thickness:
|
||
if abs(span[axis] - thickness) < abs(span[thick] - thickness):
|
||
thick = axis
|
||
elif span[axis] < span[thick]:
|
||
thick = axis
|
||
return [i for i in (0, 1, 2) if i != thick]
|
||
|
||
|
||
def _spacing_in(a, b, axes):
|
||
"""In-plane centre-to-centre distance between two inserts, in inches."""
|
||
total = 0.0
|
||
for axis in axes:
|
||
total += (a[axis] - b[axis]) ** 2
|
||
return (total ** 0.5) * 12.0
|
||
|
||
|
||
def _spacing_inches(value):
|
||
"""18.0 -> '18"', 12.5 -> '12.5"'. A published minimum is a table
|
||
number, not a measurement, and printing it as 18.00 reads like one."""
|
||
try:
|
||
if abs(value - round(value)) < 0.005:
|
||
return '{0}"'.format(int(round(value)))
|
||
return '{0:.2f}"'.format(value)
|
||
except (TypeError, ValueError):
|
||
return '{0}"'.format(value)
|
||
|
||
|
||
def check_lifter_anchor_spacing(t, intent, know, spec):
|
||
"""6.7: no two lifting inserts closer together than the page allows.
|
||
|
||
Every PAIR of recognised inserts, in the plane of the panel, against the
|
||
larger of the two minimums -- larger because each anchor's own rule has
|
||
to be satisfied and a pair does not get the more forgiving of them.
|
||
|
||
Three states, and the middle one is the point:
|
||
|
||
judged, too close FAIL, naming the gap, the minimum, and where the
|
||
minimum came from.
|
||
judged, clear OK, naming what they were held to.
|
||
not judged the pair is NAMED as unjudged and rides on the
|
||
same finding code. An insert this tool cannot
|
||
recognise, or one whose catalogue entry publishes
|
||
no edge distance to double, is a could-not-judge
|
||
-- never a pass, and never another part's number.
|
||
"""
|
||
lifters = [l for l in (t.get('lifters') or []) if l.get('local')]
|
||
if len(lifters) < 2:
|
||
return []
|
||
axes = _in_plane_axes(t)
|
||
if axes is None:
|
||
return [Finding('WARN', 'LIFT-SPACING',
|
||
'panel geometry unreadable, so the plane of the '
|
||
'piece is not known and anchor spacing was not '
|
||
'measured')]
|
||
|
||
limits = {}
|
||
for lift in lifters:
|
||
family = lift.get('family') or '(insert with no family name)'
|
||
if family not in limits:
|
||
limits[family] = _spacing_limit(know, family)
|
||
|
||
violations = []
|
||
held_to = []
|
||
unjudged = {}
|
||
judged = 0
|
||
for i in range(len(lifters)):
|
||
for j in range(i + 1, len(lifters)):
|
||
a, b = lifters[i], lifters[j]
|
||
fam_a = a.get('family') or '(insert with no family name)'
|
||
fam_b = b.get('family') or '(insert with no family name)'
|
||
lim_a, src_a, why_a = limits[fam_a]
|
||
lim_b, src_b, why_b = limits[fam_b]
|
||
if lim_a is None or lim_b is None:
|
||
# WHICH end could not be judged, not merely that one could
|
||
# not. The reason differs per part and it is what a person
|
||
# would act on.
|
||
for fam, lim, why in ((fam_a, lim_a, why_a),
|
||
(fam_b, lim_b, why_b)):
|
||
if lim is None:
|
||
unjudged[fam] = why
|
||
continue
|
||
judged += 1
|
||
# The LARGER of the two. A 14" anchor beside a 6" one does not
|
||
# get to sit at 12": its own rule is the binding one, and taking
|
||
# the smaller would pass exactly the pair this check is for.
|
||
if lim_a >= lim_b:
|
||
limit, source, fam = lim_a, src_a, fam_a
|
||
else:
|
||
limit, source, fam = lim_b, src_b, fam_b
|
||
held_to.append(limits[fam][2])
|
||
gap = _spacing_in(a['local'], b['local'], axes)
|
||
if gap < limit - 0.005:
|
||
violations.append((gap, limit, source, a.get('mark'),
|
||
b.get('mark'), fam_a, fam_b, limits[fam][2]))
|
||
|
||
if not judged and not unjudged:
|
||
return []
|
||
if not judged:
|
||
# Nothing on this piece has a spacing to be held to. A
|
||
# could-not-judge, and the probe says so on the row.
|
||
return []
|
||
|
||
if violations:
|
||
violations.sort()
|
||
# A minimum that is the part's own -- published outright, or twice
|
||
# the part's own published edge distance, which is the same page --
|
||
# is a claim about THIS insert, so falling short of it is a failure.
|
||
severity = ('FAIL' if all(s in ('published', 'derived')
|
||
for (_g, _l, s, _m, _n, _fa, _fb, _w)
|
||
in violations)
|
||
else 'WARN')
|
||
message = ('{0} pair(s) of lifting inserts sit closer together than '
|
||
'the minimum anchor spacing -- {1}'
|
||
.format(len(violations),
|
||
'; '.join(['{0} and {1} are {2} apart, minimum '
|
||
'{3} ({4})'
|
||
.format(m or fa, n or fb, _spacing_inches(g),
|
||
_spacing_inches(l), why)
|
||
for (g, l, _s, m, n, fa, fb, why)
|
||
in violations])))
|
||
else:
|
||
severity = 'WARN' if unjudged else 'OK'
|
||
message = ('all {0} pair(s) of lifting inserts clear their minimum '
|
||
'anchor spacing ({1})'
|
||
.format(judged, '; '.join(sorted(set(held_to)))))
|
||
if unjudged:
|
||
# ONE finding, not two -- `checklist_states` keeps the LAST finding
|
||
# per code, so a second LIFT-SPACING would silently overwrite this
|
||
# one and the row would print half the answer. 6.2's lesson, and the
|
||
# unjudged half must ride on the owned code or the row goes green
|
||
# over an unasked question.
|
||
message += ('. Not every insert could be judged: {0}'
|
||
.format('; '.join([unjudged[f]
|
||
for f in sorted(unjudged)])))
|
||
return [Finding(severity, 'LIFT-SPACING', message)]
|
||
|
||
|
||
# ------------------------------------- 6.9, lifting-point count vs length
|
||
#
|
||
# MC PLANT RULE, owner verbatim 2026-09-02: "ticketqc在MC加一条check:lifting
|
||
# points: MC的规矩是,panel长度小于20ft则2p lifting,大于20则是4p lifting。
|
||
# 实际上25以内都可以2p。你按照20-25给warn,25以上给fail写。"
|
||
#
|
||
# The nominal rule (under 20 ft, 2-point; 20 ft and over, 4-point) is more
|
||
# conservative than the real one -- 2-point actually holds to 25 ft -- so
|
||
# this does not fail a panel the instant it crosses 20 ft on 2 points. It
|
||
# grades the gap between the nominal rule and the real ceiling:
|
||
#
|
||
# L <= 20 ft, fewer than 4 points OK 2-point covers this length
|
||
# 20 < L <= 25 ft, fewer than 4 WARN over the nominal rule, still
|
||
# inside the real 25 ft ceiling
|
||
# L > 25 ft, fewer than 4 points FAIL past where 2-point holds
|
||
# any length, 4 or more points OK more points is never worse
|
||
#
|
||
# ONLY TOO FEW IS FLAGGED. A panel with 6 points on a 12 ft length is not
|
||
# judged wrong for having "too many" -- the rule has nothing to say about
|
||
# that direction and this check never invents one.
|
||
#
|
||
# THE TWO THRESHOLDS ARE MC PLANT KNOWLEDGE, read through
|
||
# `lifters.lifting_points.two_point_max_length_ft` /
|
||
# `.two_point_absolute_max_ft`, in MC's own plant layer -- never a
|
||
# plant-agnostic constant. A layer that has not set both -- any non-MC
|
||
# plant -- means this rule was never told to apply here, and the check
|
||
# stands down naming the missing keys rather than holding another plant's
|
||
# panels to MC's numbers.
|
||
#
|
||
# WHAT COUNTS AS ONE "POINT". The plant rigs from PICK POINTS, not from
|
||
# anchors one at a time, and a single point can be modelled as more than one
|
||
# recognised lifter sharing a station (an edge anchor and a face lifter an
|
||
# inch apart). Counting `len(t['lifters'])` would count that pair as two
|
||
# points instead of one -- exactly the double-count the owner's phrasing
|
||
# ("2p", "4p") is counting against. `liftexport.rows_of` already answers
|
||
# this for the Panel Lift Check side of the tool by clustering recognised
|
||
# lifters by station along the piece, within `handling.group_tolerance_ft`
|
||
# of each other, so this reuses that function rather than growing a second
|
||
# notion of "how many points" that could quietly disagree with the first.
|
||
# It is called, not copied -- another agent is live in liftexport.py this
|
||
# week and the function is read-only from here.
|
||
#
|
||
# A COUNT BELOW 4 THAT ISN'T 2. The owner's rule names only "2p" and "4p";
|
||
# it says nothing about a 1-point or 3-point lift, which real rigging should
|
||
# not produce but a partial/odd model can. Read conservatively: any count
|
||
# under 4 is held to the SAME length envelope as 2-point, because a smaller
|
||
# count is never a safer bet than 2-point and there is no separate rule to
|
||
# invent for it. This is a judgement call, not a stated ruling -- flagged
|
||
# here rather than assumed silently.
|
||
#
|
||
# ZERO LIFTERS IS 6.1'S TERRITORY, not this row's. `check_lifter_capacity`'s
|
||
# probe already reports why nothing was recognised (a plant mapping gone
|
||
# stale, or a piece that really carries none) -- repeating that sentence on
|
||
# 6.9 would not tell the reader anything 6.1 has not already said, so this
|
||
# check's own probe points at 6.1 by name instead of re-deriving it.
|
||
|
||
def check_lifting_points(t, intent, know, spec):
|
||
"""6.9 (MC plant rule): lifting-point count against panel length.
|
||
|
||
Judges too FEW points for the length; never too many. See the block
|
||
above for the thresholds, how a "point" is counted, and the two states
|
||
this stands down for without emitting a finding (non-MC layer, zero
|
||
recognised lifters -- 6.1's territory).
|
||
"""
|
||
raw_lifters = t.get('lifters') or []
|
||
if not raw_lifters:
|
||
return [] # 6.1 already says why: none recognised
|
||
|
||
warn_ft = K.get(know, 'lifters.lifting_points.two_point_max_length_ft')
|
||
fail_ft = K.get(know, 'lifters.lifting_points.two_point_absolute_max_ft')
|
||
try:
|
||
warn_ft = float(warn_ft) if warn_ft else None
|
||
fail_ft = float(fail_ft) if fail_ft else None
|
||
except (TypeError, ValueError):
|
||
warn_ft = fail_ft = None
|
||
if not warn_ft or not fail_ft:
|
||
return [] # not MC, or MC has not set both
|
||
|
||
length = t.get('length')
|
||
if length is None:
|
||
return [Finding('WARN', 'LIFT-POINTS-UNREAD',
|
||
'panel length not readable, lifting-point count '
|
||
'not checked against it')]
|
||
|
||
lifters = [l for l in raw_lifters if l.get('local')]
|
||
extents = t.get('panel_extents')
|
||
if not lifters or not extents:
|
||
return [Finding('WARN', 'LIFT-POINTS-UNREAD',
|
||
'no lifting insert on this piece has a readable '
|
||
'position, so lifting points could not be grouped '
|
||
'and counted')]
|
||
|
||
lo_pt, hi_pt = extents
|
||
axis = max([(hi_pt[i] - lo_pt[i], i) for i in (0, 1, 2)])[1]
|
||
tolerance = K.get(know, 'handling.group_tolerance_ft', 1.0) or 1.0
|
||
|
||
from vibe import liftexport
|
||
entries = [{'x': l['local'][axis], 'family': l.get('family'),
|
||
'mark': l.get('id')} for l in lifters]
|
||
points = liftexport.rows_of(entries, tolerance, along='x')
|
||
n = len(points)
|
||
|
||
if n >= 4:
|
||
return [Finding('OK', 'LIFT-POINTS',
|
||
'{0} lifting points on a {1} panel -- clears the '
|
||
'plant\'s 4-point rule'.format(n, fmt_feet(length)))]
|
||
if length <= warn_ft:
|
||
return [Finding('OK', 'LIFT-POINTS',
|
||
'{0} lifting point(s) on a {1} panel -- within the '
|
||
'plant\'s {2} ft 2-point limit'
|
||
.format(n, fmt_feet(length), _fmt_num(warn_ft)))]
|
||
if length <= fail_ft:
|
||
return [Finding('WARN', 'LIFT-POINTS',
|
||
'{0} lifting point(s) on a {1} panel -- over the '
|
||
'plant\'s nominal {2} ft 2-point rule but still '
|
||
'inside the {3} ft ceiling 2-point actually holds '
|
||
'to'.format(n, fmt_feet(length), _fmt_num(warn_ft),
|
||
_fmt_num(fail_ft)))]
|
||
return [Finding('FAIL', 'LIFT-POINTS',
|
||
'{0} lifting point(s) on a {1} panel -- past the '
|
||
'plant\'s {2} ft ceiling for 2-point lifting, needs '
|
||
'4-point'.format(n, fmt_feet(length), _fmt_num(fail_ft)))]
|
||
|
||
|
||
# ------------------------------------- 6.10, lifting-point POSITION vs PCI
|
||
#
|
||
# MC PLANT RULE, owner verbatim 2026-09-02: "follow PCI的意思是检查一下
|
||
# lifter的位置,如果跟PCI相差大于6" 则报WARN。很多时候没办法完全follow
|
||
# PCI,但是这件事儿让用户判断可行不可行。"
|
||
#
|
||
# DISTINCT FROM 6.9. 6.9 grades the COUNT of lifting points against panel
|
||
# length; this grades WHERE each point sits, once a count exists, against
|
||
# where PCI says it should sit. Neither re-derives the other's answer: this
|
||
# check assumes whatever count is on the piece and never re-flags a count
|
||
# problem -- that stays 6.9's job -- and 6.9 never looks at station.
|
||
#
|
||
# WARN-ONLY, BY RULING, NEVER FAIL. The owner's own words are the reason:
|
||
# real panels often cannot follow PCI's ideal two-point location exactly
|
||
# (an opening, a haunch, a plant habit moves the pick point), and whether a
|
||
# given miss is FINE is an engineering judgement he wants to make himself,
|
||
# not one this tool hands down as a failure. So every verdict this check
|
||
# can reach is OK or WARN -- there is no FAIL branch, unlike 6.9 or 6.1.
|
||
#
|
||
# THE PCI 0.207L TWO-POINT LOCATION IS A PUBLISHED PCI CONSTANT, not MC
|
||
# plant knowledge -- unlike the 6.9 thresholds, which are this plant's own
|
||
# rule of thumb. `panellift/src/panellift/pci.py` already carries the same
|
||
# closed form as `OPTIMAL_TWO_LINE_FRACTION = (sqrt(2)-1)/2` (~0.20711, the
|
||
# support fraction that equalises +M/-M on a uniformly loaded two-point
|
||
# pick -- see the derivation in that module's own comment). That module is
|
||
# CPython-only and not importable from this IronPython side of the tool
|
||
# (CLAUDE.md section 2: "panellift is not importable from Revit and that
|
||
# is deliberate"), so rather than reach across that boundary this repeats
|
||
# the same PCI geometry as its own named constant below, at the 3-decimal
|
||
# precision ("0.207L") the EMI drawing evidence itself states and the
|
||
# owner's ruling assumes. ONE constant, defined once, cited everywhere it
|
||
# is used -- never scattered as a bare 0.207 or 0.793 literal.
|
||
#
|
||
# FOUR-POINT PCI STATIONS -- owner ruling 2026-09-02: 0.104L / 0.395L /
|
||
# 0.605L / 0.896L, from the PCI notation ".104L / .291L / .291L / .104L"
|
||
# printed on the design sheet, read as a symmetric segment sequence from
|
||
# each end -- end -> P1 = 0.104L, P1 -> P2 = 0.291L (so P2 sits at
|
||
# 0.395L), mirrored from the far end (P4 at 0.896L, P3 at 0.605L), middle
|
||
# span 0.210L. Symmetric: overhang 0.104, shoulder 0.291, mid 0.210. This
|
||
# is the same "read from PCI" basis as the two-point 0.207 fraction above
|
||
# (which cites `panellift/src/panellift/pci.py`'s closed form); the
|
||
# four-point set has no simple closed form in that module, so it lands
|
||
# here as documented PCI constants with the owner's ruling and date, not
|
||
# a derivation this repo can re-check.
|
||
#
|
||
# ANY OTHER COUNT (1, 3, 5+) IS STILL NOT KNOWN. `_pci_recommended_
|
||
# stations` is keyed by point count on purpose, so a future count's
|
||
# fill-in is one new branch there -- never a guess planted here.
|
||
# Returning `None` for an unconfigured count is how this check STANDS
|
||
# DOWN for it (`LIFT-POS-UNREAD`, naming the count), rather than
|
||
# inventing a rule nobody has stated.
|
||
#
|
||
# TOLERANCE IS MC PLANT KNOWLEDGE -- `lifters.lifting_points.
|
||
# pci_position_tolerance_in`, co-located with the 6.9 thresholds under the
|
||
# same `lifters.lifting_points` tree. A plant that has not set it gets no
|
||
# judgement from this rule at all, exactly like 6.9's own stand-down for a
|
||
# non-MC layer.
|
||
#
|
||
# POINT CLUSTERING IS 6.9'S, REUSED, NOT RE-DERIVED. Same
|
||
# `liftexport.rows_of` call within `handling.group_tolerance_ft`, so the
|
||
# two checks can never quietly disagree about how many points there are or
|
||
# where one sits -- an anchor pair sharing a pick point is one point here
|
||
# exactly as it is on 6.9's row.
|
||
#
|
||
# BOUNDARY: "相差大于6\"" is strictly GREATER THAN six inches -- a point
|
||
# exactly 6.00" off PASSES, 6.01" WARNS. Never rounded before the compare.
|
||
|
||
PCI_TWO_POINT_FRACTION = 0.207 # PCI two-point pick, 0.207L equal-moment
|
||
# location -- see the module note above.
|
||
|
||
PCI_FOUR_POINT_FRACTIONS = (0.104, 0.395, 0.605, 0.896)
|
||
# PCI four-point pick, owner ruling
|
||
# 2026-09-02, from the sheet notation
|
||
# ".104L / .291L / .291L / .104L" read
|
||
# end-to-end: overhang 0.104, shoulder
|
||
# 0.291 (0.104 + 0.291 = 0.395), mid
|
||
# 0.210, mirrored -- see the module note
|
||
# above.
|
||
|
||
# Keyed by point count so a future count's fill-in is one new entry here,
|
||
# never a guess -- see the module note above `PCI_TWO_POINT_FRACTION`.
|
||
_PCI_FRACTIONS_BY_COUNT = {
|
||
2: (PCI_TWO_POINT_FRACTION, 1.0 - PCI_TWO_POINT_FRACTION),
|
||
4: PCI_FOUR_POINT_FRACTIONS,
|
||
}
|
||
|
||
|
||
def _pci_recommended_fractions(n_points):
|
||
"""The PCI fraction (of L) for each station, ascending -- or `None`.
|
||
|
||
Companion to `_pci_recommended_stations`: same ascending order, used
|
||
to label each point with the fraction PCI actually states (0.207L,
|
||
0.104L, ...) rather than a bare number.
|
||
"""
|
||
fractions = _PCI_FRACTIONS_BY_COUNT.get(n_points)
|
||
if fractions is None:
|
||
return None
|
||
return sorted(fractions)
|
||
|
||
|
||
def _pci_recommended_stations(n_points, length_ft):
|
||
"""PCI-recommended pick-point stations from the panel's low end.
|
||
|
||
Returns `n_points` stations in feet, ascending, or `None` when this
|
||
point count's PCI recommendation is not yet configured -- ONLY TWO-
|
||
POINT AND FOUR-POINT ARE KNOWN TODAY. A future point count's PCI
|
||
location is a new branch here, never a guess; see the module note
|
||
above `check_lift_positions`.
|
||
"""
|
||
fractions = _pci_recommended_fractions(n_points)
|
||
if fractions is None:
|
||
return None
|
||
return [f * length_ft for f in fractions]
|
||
|
||
|
||
def check_lift_positions(t, intent, know, spec):
|
||
"""6.10 (MC plant rule): lifting-point POSITION vs PCI recommendation.
|
||
|
||
DISTINCT from 6.9 (lifting-point COUNT vs length): this judges WHERE
|
||
each already-counted point sits against PCI's recommended station,
|
||
never how many there are. WARN-only by the owner's own ruling -- see
|
||
the module note above for the full quote and every design decision
|
||
(the PCI constants' sources -- two-point and four-point -- the stand-
|
||
down for every other count, the > 6" boundary, why this reuses 6.9's
|
||
own point clustering).
|
||
"""
|
||
raw_lifters = t.get('lifters') or []
|
||
if not raw_lifters:
|
||
return [] # 6.1 already says why: none recognised
|
||
|
||
tol_in = K.get(know, 'lifters.lifting_points.pci_position_tolerance_in')
|
||
try:
|
||
tol_in = float(tol_in) if tol_in else None
|
||
except (TypeError, ValueError):
|
||
tol_in = None
|
||
if not tol_in:
|
||
return [] # not MC, or MC has not set the tolerance
|
||
|
||
length = t.get('length')
|
||
if length is None:
|
||
return [Finding('WARN', 'LIFT-POS-UNREAD',
|
||
'panel length not readable, lifting-point position '
|
||
'not checked against PCI')]
|
||
|
||
lifters = [l for l in raw_lifters if l.get('local')]
|
||
extents = t.get('panel_extents')
|
||
if not lifters or not extents:
|
||
return [Finding('WARN', 'LIFT-POS-UNREAD',
|
||
'no lifting insert on this piece has a readable '
|
||
'position, so lifting-point positions could not be '
|
||
'compared to PCI')]
|
||
|
||
lo_pt, hi_pt = extents
|
||
axis = max([(hi_pt[i] - lo_pt[i], i) for i in (0, 1, 2)])[1]
|
||
tolerance = K.get(know, 'handling.group_tolerance_ft', 1.0) or 1.0
|
||
|
||
from vibe import liftexport
|
||
entries = [{'x': l['local'][axis], 'family': l.get('family'),
|
||
'mark': l.get('id')} for l in lifters]
|
||
points = liftexport.rows_of(entries, tolerance, along='x')
|
||
n = len(points)
|
||
|
||
recommended = _pci_recommended_stations(n, length)
|
||
if recommended is None:
|
||
return [Finding('WARN', 'LIFT-POS-UNREAD',
|
||
'{0}-point PCI recommended positions not configured '
|
||
'-- owner to supply; only the 2-point (0.207L) and '
|
||
'4-point (0.104L/0.395L/0.605L/0.896L) PCI locations '
|
||
'are known today, so a {0}-point lift is not judged '
|
||
'against PCI'.format(n))]
|
||
fractions = _pci_recommended_fractions(n)
|
||
|
||
# Actual lifters paired to recommended stations in sorted order --
|
||
# i-th nearest-the-low-end actual lifter against the i-th nearest-
|
||
# the-low-end PCI station. Same pairing the 2-point case has always
|
||
# used; unchanged by adding the 4-point branch above.
|
||
stations = sorted([p['x'] - lo_pt[axis] for p in points])
|
||
worst = 'OK'
|
||
details = []
|
||
for i, (actual, rec) in enumerate(zip(stations, recommended)):
|
||
delta_in = abs(actual - rec) * 12.0
|
||
point_ok = not (delta_in > tol_in)
|
||
if not point_ok:
|
||
worst = 'WARN'
|
||
details.append(
|
||
'point {0} at {1} vs PCI {2} ({3}L) -- {4}" off ({5})'.format(
|
||
i + 1, fmt_feet(actual), fmt_feet(rec),
|
||
'{0:.3f}'.format(fractions[i]),
|
||
_fmt_num(delta_in), 'OK' if point_ok else 'WARN'))
|
||
|
||
return [Finding(worst, 'LIFT-POS',
|
||
'{0}-point lift on a {1} panel vs PCI\'s {0}-point '
|
||
'pick, {2}" plant tolerance: {3}'
|
||
.format(n, fmt_feet(length), _fmt_num(tol_in),
|
||
'; '.join(details)))]
|
||
|
||
|
||
# ------------------------------------- 6.11, dunnage-point count vs
|
||
# stripping-point count
|
||
#
|
||
# MC PLANT RULE, owner verbatim 2026-09-02: "默认MC panels dunnage point=
|
||
# stripping points。4p stripping 则4p dunnage。" -- by default an MC panel's
|
||
# dunnage-point count equals its stripping-point count; 4-point stripping
|
||
# means 4-point dunnage. And: "stripping point就是face lifters位置" --
|
||
# stripping points ARE the face-lifter positions. And: "LPA开头的就是face
|
||
# lifter" -- LPA-prefixed anchors are the face lifters.
|
||
#
|
||
# A DEFAULT, NOT AN ABSOLUTE RULE. A mismatch is a deviation from the
|
||
# plant's default, not a defect this tool can grade on its own -- the owner
|
||
# judges feasibility, the same posture as 6.9/6.10 (and 6.8 before them):
|
||
# every verdict this check can reach is OK or WARN, there is no FAIL branch.
|
||
#
|
||
# WHAT COUNTS AS A "FACE" (STRIPPING) LIFTER. The owner's "LPA开头的" is the
|
||
# spoken form of a fact this plant's own knowledge already carries in
|
||
# structured form: `lifting_anchors.parts.<part>.usage: back_stripping` on
|
||
# the M8 chart extraction, mirrored onto the entry `lifter_capacity()`
|
||
# actually resolves a family against (`lifters.capacity_lb.<family>.usage`,
|
||
# landed 2026-09-02 for LPA4T512_LIFTING PIN ANCHOR and LIFT_QL589G_8T --
|
||
# see the plant layer). `_lifter_usage` below reads that tag through the
|
||
# SAME resolver `lifter_capacity()` uses to reach a family's entry
|
||
# (`knowledge._longest_substring_match` against `lifters.capacity_lb`), not
|
||
# a parallel re-parse of the knowledge tree -- so this check and 6.1 can
|
||
# never disagree about which entry a family resolves to. The bare `LPA`
|
||
# family-name prefix is a FALLBACK, reached only when the resolved entry (or
|
||
# the absence of one) carries no usage tag at all -- it never overrides a
|
||
# usage tag that says otherwise, so a family explicitly tagged
|
||
# `erection_stripping` (QL589 -- a top/edge anchor, not a face one) is never
|
||
# miscounted just because a plant later adds an unrelated LPA-prefixed part.
|
||
#
|
||
# N_strip: POINTS, counted exactly like 6.9/6.10 -- `liftexport.rows_of`
|
||
# clusters recognised lifters by station along the panel's long axis,
|
||
# within `handling.group_tolerance_ft`, so an edge anchor and a face lifter
|
||
# an inch apart are one point, not two. A point counts toward N_strip when
|
||
# ANY lifter clustered into it is a face/stripping lifter -- the station IS
|
||
# where a face lifter picks from, whatever else shares it.
|
||
#
|
||
# N_dun: STATIONS, read exactly like 6.4 (`check_dunnage_openings`) reads
|
||
# them -- via the shared `_dunnage_station_reader`, defined above in the
|
||
# dunnage section, factored out so the two checks can never quietly
|
||
# disagree about where the dunnage sits or why a ticket's row would not
|
||
# read. Unlike 6.4, this check needs the count whether or not the piece has
|
||
# an opening for the dunnage to matter against, so it never gates on
|
||
# openings at all -- just the same printed-sheet read.
|
||
#
|
||
# MC PLANT KNOWLEDGE, gated on `lifters.lifting_points.
|
||
# dunnage_matches_stripping` -- co-located with the 6.9/6.10 keys under the
|
||
# same `lifters.lifting_points` tree. A plant that has not set it (any
|
||
# non-MC plant, or an MC layer that predates this rule) gets no judgement
|
||
# from this row at all, same stand-down shape as 6.9/6.10.
|
||
|
||
def _lifter_usage(know, family_name):
|
||
"""The `usage` tag on a lifter's capacity-table entry, or None.
|
||
|
||
Reached through the SAME resolver `knowledge.lifter_capacity()` uses to
|
||
find a family's entry (`_longest_substring_match` against
|
||
`lifters.capacity_lb`, longest key wins) -- never a parallel re-parse.
|
||
None when the family resolves to nothing, or resolves to a bare number
|
||
(no dict, so no usage tag is even possible) or a dict entry that
|
||
carries no `usage` key.
|
||
"""
|
||
entry = K._longest_substring_match(
|
||
K.get(know, 'lifters.capacity_lb', {}) or {}, family_name)
|
||
if isinstance(entry, dict):
|
||
usage = entry.get('usage')
|
||
if usage:
|
||
return str(usage)
|
||
return None
|
||
|
||
|
||
def _is_face_stripping_lifter(know, family_name):
|
||
"""True when `family_name` is a face (back-stripping) lifter.
|
||
|
||
See the module note above `check_dunnage_stripping_match` for the full
|
||
reasoning: the plant's own `usage: back_stripping` tag, read through
|
||
`_lifter_usage` above, is authoritative when present. The bare `LPA`
|
||
family-name prefix (owner ruling 2026-09-02: "LPA开头的就是face
|
||
lifter") is a FALLBACK for a family with no usage tag at all -- it
|
||
never overrides a usage tag naming something else.
|
||
"""
|
||
usage = _lifter_usage(know, family_name)
|
||
if usage is not None:
|
||
return usage == 'back_stripping'
|
||
return (family_name or '').upper().startswith('LPA')
|
||
|
||
|
||
def check_dunnage_stripping_match(t, intent, know, spec):
|
||
"""6.11 (MC plant rule): dunnage-point count vs stripping-point count.
|
||
|
||
By plant default the two counts are equal (owner ruling 2026-09-02 --
|
||
see the module note above for the full quotes). A mismatch is WARN,
|
||
never FAIL -- feasibility is the owner's call, same posture as 6.9/6.10.
|
||
See the module note above for how each side is counted and every
|
||
stand-down this check reaches.
|
||
"""
|
||
if not K.get(know, 'lifters.lifting_points.dunnage_matches_stripping'):
|
||
return [] # not MC, or MC has not enabled this rule
|
||
|
||
raw_lifters = t.get('lifters') or []
|
||
if not raw_lifters:
|
||
return [] # 6.1 already says why: none recognised
|
||
|
||
lifters = [l for l in raw_lifters if l.get('local')]
|
||
extents = t.get('panel_extents')
|
||
if not lifters or not extents:
|
||
return [Finding('WARN', 'DUN-STRIP-UNREAD',
|
||
'no lifting insert on this piece has a readable '
|
||
'position, so stripping points could not be '
|
||
'grouped and counted')]
|
||
|
||
lo_pt, hi_pt = extents
|
||
axis = max([(hi_pt[i] - lo_pt[i], i) for i in (0, 1, 2)])[1]
|
||
tolerance = K.get(know, 'handling.group_tolerance_ft', 1.0) or 1.0
|
||
|
||
from vibe import liftexport
|
||
entries = [{'x': l['local'][axis], 'family': l.get('family'),
|
||
'mark': l.get('id')} for l in lifters]
|
||
points = liftexport.rows_of(entries, tolerance, along='x')
|
||
|
||
strip_points = [p for p in points
|
||
if any(_is_face_stripping_lifter(know, fam)
|
||
for fam in p['families'])]
|
||
n_strip = len(strip_points)
|
||
if n_strip == 0:
|
||
# NOT a 0 == 0 pass: no face/stripping lifter was identified at
|
||
# all, so there is no stripping-point count to compare dunnage
|
||
# against -- a could-not-judge, named, not a quiet OK.
|
||
return [Finding('WARN', 'DUN-STRIP-UNREAD',
|
||
'no back_stripping (face) lifters identified on '
|
||
'this piece -- cannot establish a stripping-point '
|
||
'count to compare dunnage against')]
|
||
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
tdoc = None
|
||
if tdoc is None:
|
||
return [Finding('WARN', 'DUN-STRIP-UNREAD',
|
||
'could not reach the active document to read the '
|
||
'dunnage stations')]
|
||
|
||
stations, reason = _dunnage_station_reader(t, tdoc)
|
||
if stations is None:
|
||
return [Finding('WARN', 'DUN-STRIP-UNREAD',
|
||
'dunnage stations could not be read, so the count '
|
||
'could not be compared to stripping points: {0}'
|
||
.format(reason))]
|
||
n_dun = len(stations)
|
||
|
||
if n_dun == n_strip:
|
||
return [Finding('OK', 'DUN-STRIP',
|
||
'{0} dunnage point(s) match the {1} stripping '
|
||
'(face-lifter) point(s) on this piece -- the '
|
||
"plant's default"
|
||
.format(n_dun, n_strip))]
|
||
return [Finding('WARN', 'DUN-STRIP',
|
||
'{0} dunnage point(s) vs {1} stripping (face-lifter) '
|
||
"point(s) -- the plant default is dunnage = stripping; "
|
||
'a mismatch may still be feasible, for the engineer to '
|
||
'judge'.format(n_dun, n_strip))]
|
||
|
||
|
||
# ---------------------------------------------------------------- handling
|
||
#
|
||
# Whether the piece can be picked up at all. Ticket QC used to say nothing
|
||
# about this; the engineer's rule is: find a finished handling result for
|
||
# this piece, read its FAIL/WARN off it, and if there is no result on file,
|
||
# run one there and then.
|
||
#
|
||
# THREE THINGS HAD TO BE DECIDED HERE, and they are worth writing down
|
||
# because getting any of them wrong makes this check either useless or
|
||
# actively misleading.
|
||
#
|
||
# MATCHING a result to a piece is by MARK. `liftexport.export()` names its
|
||
# job/report after `ticket['mark']` (the assembly's own name), and that is
|
||
# exactly `t['mark']` here -- the Panel Lift Check button's default save name,
|
||
# `tools/sweep_in_revit.py`'s file names, and this check's own on-the-spot
|
||
# writes all agree on it. The one way this can miss: PanelLiftCheck lets the
|
||
# user rename the file in the save dialog, and a renamed report is invisible
|
||
# to this check. That is accepted rather than solved -- there is no way to
|
||
# recover the association from the file system alone, and a wrongly-matched
|
||
# report would be worse than a missed one.
|
||
#
|
||
# STALENESS is decided by FINGERPRINT, not by a file timestamp. A saved job
|
||
# file already carries everything needed to answer "does this still describe
|
||
# the piece": weight, length, width, thickness, which strengths it was
|
||
# checked at, how many strands, and which inserts (by element id) it used.
|
||
# `_fingerprint_stale()` re-derives the same numbers from the ticket that was
|
||
# just read off the live model and compares them. Any material difference --
|
||
# an insert added or removed, a resize, a strength that has since been
|
||
# extracted -- means the saved result predates a change and is DISCARDED, not
|
||
# just flagged: a stale FAIL/WARN is never shown as though it were current,
|
||
# because a wrong answer that looks like an answer is worse than an honest "not
|
||
# checked". A timestamp could not have told the difference between "stale"
|
||
# and "nobody has touched this piece in months, and it is still exactly
|
||
# right" -- the fingerprint can, because it looks at the thing itself rather
|
||
# than at when somebody last saved it.
|
||
#
|
||
# One gap, accepted rather than solved: an insert MOVED without changing which
|
||
# element it is (same id, new position) is invisible to the id-set half of the
|
||
# fingerprint. Catching that needs the same precise geometry read `export()`
|
||
# does, which is the expensive step this whole design exists to avoid paying
|
||
# for on every ticket. Weight and the id SET still catch an insert added,
|
||
# removed, or swapped for a different family.
|
||
#
|
||
# COST. `export()` measures geometry and the actual calculation shells out to
|
||
# CPython -- ~2 seconds well spent on one piece from the Panel Lift Check
|
||
# button, ruinous multiplied across a 184-piece design. Three levers, used
|
||
# together rather than picking one:
|
||
#
|
||
# REUSE FIRST. A sweep (`tools/sweep.py`) already walks the whole model and
|
||
# leaves `problems.json` with FAIL/WARN per mark -- reading that is a
|
||
# dictionary lookup, not a subprocess. A reviewed Panel Lift Check result is
|
||
# preferred over a swept one when both are fresh, because it is a human's
|
||
# picks rather than the grouping rules' guess; a swept result is preferred
|
||
# over running anything, because it is already paid for.
|
||
#
|
||
# BOUND WHAT IS LEFT. Every CPython call this check makes -- re-verifying a
|
||
# reviewed job, or running a piece with no result at all -- draws from one
|
||
# shared, self-resetting rate limit, `handling.qc_cpython_calls_per_min`
|
||
# (default 10/minute). A hard per-run counter would either starve a small
|
||
# job partway through or sit unused on a big one; a rate limit lets a long
|
||
# Ticket QC run keep making progress the whole time it runs, at a pace that
|
||
# never freezes Revit's UI thread for minutes on end. Setting it to 0 turns
|
||
# the CPython calls off entirely -- the opt-out.
|
||
#
|
||
# OPT OUT ENTIRELY. `handling.qc_onspot` (default true) is the other lever:
|
||
# set it false and a piece with nothing on file is reported as unchecked,
|
||
# never run. Reviewed-result re-verification still runs either way -- it is
|
||
# reading an existing decision, not guessing a new one, so it is not what
|
||
# this flag is for.
|
||
#
|
||
# Nothing here is persisted. Ticket QC documents itself as read-only, and an
|
||
# on-the-spot run is explicitly NOT a reviewed result -- writing it to disk
|
||
# where a later run might mistake it for one would undo the whole point of
|
||
# distinguishing the two. Its cost is paid again next time unless a sweep or a
|
||
# reviewed check has since covered the piece; that is the honest trade for
|
||
# not inventing a fourth kind of saved result nobody asked for.
|
||
#
|
||
# LABELLING. An on-the-spot or swept result comes from `default_stages` --
|
||
# the grouping rules' guess, with nobody having picked the inserts. Every
|
||
# finding pulled from one says so, in the message itself, so it can never be
|
||
# mistaken for a reviewed check by someone skimming the report.
|
||
|
||
_RECENT_CPYTHON_CALLS = [] # module-level: shared rate limit, see above
|
||
_SWEEP_PROBLEMS_CACHE = {} # path -> (mtime, {mark: row}); avoids re-parsing
|
||
|
||
|
||
def _read_json(path):
|
||
try:
|
||
with io.open(path, 'r', encoding='utf-8') as fh:
|
||
return json.loads(fh.read())
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _write_json(path, obj):
|
||
text = json.dumps(obj, sort_keys=True)
|
||
if not isinstance(text, text_type):
|
||
text = text.decode('utf-8')
|
||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||
fh.write(text)
|
||
|
||
|
||
def _scratch_dir():
|
||
"""A throwaway folder for job/report files this check writes and deletes.
|
||
|
||
Never beside the model and never in handling.report_dir -- those are
|
||
where a REVIEWED result belongs, and an on-the-spot one must never be
|
||
mistaken for one by turning up next to it.
|
||
"""
|
||
d = os.path.join(tempfile.gettempdir(), 'willtools_ticketqc')
|
||
try:
|
||
if not os.path.isdir(d):
|
||
os.makedirs(d)
|
||
except Exception:
|
||
pass
|
||
return d
|
||
|
||
|
||
def _cpython_budget_ok(know):
|
||
"""One shared, self-resetting rate limit for every CPython call this
|
||
check makes in a Ticket QC run. See the module notes above for why a
|
||
rate limit rather than a hard per-run cap."""
|
||
limit = K.get(know, 'handling.qc_cpython_calls_per_min', 10)
|
||
try:
|
||
limit = int(limit)
|
||
except (TypeError, ValueError):
|
||
limit = 10
|
||
if limit <= 0:
|
||
return False
|
||
now = time.time()
|
||
cutoff = now - 60.0
|
||
while _RECENT_CPYTHON_CALLS and _RECENT_CPYTHON_CALLS[0] < cutoff:
|
||
_RECENT_CPYTHON_CALLS.pop(0)
|
||
if len(_RECENT_CPYTHON_CALLS) >= limit:
|
||
return False
|
||
_RECENT_CPYTHON_CALLS.append(now)
|
||
return True
|
||
|
||
|
||
# -- fingerprinting: is a saved job file still this piece? -----------------
|
||
|
||
_FP_LENGTH_TOL_FT = 1.0 / 12.0 # 1 in -- see the module note on why this
|
||
_FP_THICKNESS_TOL_IN = 0.25 # is looser than a pure rounding tolerance
|
||
_FP_STRENGTH_TOL_PSI = 5.0
|
||
|
||
|
||
def _job_fingerprint(job):
|
||
panel = (job or {}).get('panel') or {}
|
||
lifters = panel.get('lifters') or []
|
||
strands = ((panel.get('prestress') or {}).get('strands')) or []
|
||
ids = set()
|
||
for lift in lifters:
|
||
try:
|
||
ids.add(int(lift.get('mark')))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return {
|
||
'weight_lb': panel.get('weight_lb'),
|
||
'length_ft': panel.get('length_ft'),
|
||
'width_ft': panel.get('width_ft'),
|
||
'thickness_in': panel.get('thickness_in'),
|
||
'fc_psi': panel.get('fc_psi'),
|
||
'fci_psi': panel.get('fci_psi'),
|
||
'lifter_ids': ids,
|
||
'lifter_count': len(lifters),
|
||
'strand_count': len(strands),
|
||
}
|
||
|
||
|
||
def _current_fingerprint(t, intent):
|
||
ids = set()
|
||
for lift in (t.get('lifters') or []):
|
||
try:
|
||
ids.add(int(lift.get('id')))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
thickness_ft = t.get('thickness')
|
||
return {
|
||
'weight_lb': t.get('weight_lb'),
|
||
'length_ft': t.get('length'),
|
||
'width_ft': t.get('width'),
|
||
'thickness_in': thickness_ft * 12.0 if thickness_ft is not None else None,
|
||
'fc_psi': intent.get('fc'),
|
||
'fci_psi': intent.get('fci'),
|
||
'lifter_ids': ids,
|
||
'lifter_count': len(t.get('lifters') or []),
|
||
'strand_count': t.get('strand_qty'),
|
||
}
|
||
|
||
|
||
def _fingerprint_stale(saved, now, know):
|
||
"""Reasons `saved` no longer describes `now`, or [] when it still does."""
|
||
reasons = []
|
||
wtol = K.get(know, 'handling.weight_tolerance', 0.02) or 0.02
|
||
|
||
a, b = saved.get('weight_lb'), now.get('weight_lb')
|
||
if a is not None and b:
|
||
if abs(a - b) / b > wtol:
|
||
reasons.append('weight was {0:,.0f} lb, is {1:,.0f} lb now'
|
||
.format(a, b))
|
||
|
||
for key, label in (('length_ft', 'length'), ('width_ft', 'width')):
|
||
a, b = saved.get(key), now.get(key)
|
||
if a is not None and b is not None and abs(a - b) > _FP_LENGTH_TOL_FT:
|
||
reasons.append('{0} changed ({1} -> {2})'
|
||
.format(label, fmt_feet(a), fmt_feet(b)))
|
||
|
||
a, b = saved.get('thickness_in'), now.get('thickness_in')
|
||
if a is not None and b is not None and abs(a - b) > _FP_THICKNESS_TOL_IN:
|
||
reasons.append('thickness changed ({0}" -> {1}")'
|
||
.format(_fmt_num(a), _fmt_num(b)))
|
||
|
||
a, b = saved.get('lifter_count'), now.get('lifter_count')
|
||
if a != b:
|
||
reasons.append('{0} insert(s) on file, {1} modelled now'.format(a, b))
|
||
elif saved.get('lifter_ids') and now.get('lifter_ids') and \
|
||
saved['lifter_ids'] != now['lifter_ids']:
|
||
reasons.append('not the same inserts this result was run on')
|
||
|
||
a, b = saved.get('strand_count'), now.get('strand_count')
|
||
if a is not None and b is not None and a != b:
|
||
reasons.append('{0} strand(s) on file, {1} modelled now'.format(a, b))
|
||
|
||
for key, label in (('fc_psi', "f'c"), ('fci_psi', "f'ci")):
|
||
a, b = saved.get(key), now.get(key)
|
||
if a is not None and b is not None and abs(a - b) > _FP_STRENGTH_TOL_PSI:
|
||
reasons.append('{0} was {1} psi, is {2} psi now'
|
||
.format(label, _fmt_num(a), _fmt_num(b)))
|
||
|
||
return reasons
|
||
|
||
|
||
# -- finding a saved result --------------------------------------------------
|
||
|
||
def _find_reviewed(doc, know, mark):
|
||
"""The Panel Lift Check output for this mark, when both halves exist.
|
||
|
||
The job file is deliberately kept beside the report -- see liftexport and
|
||
docs/lift-check.md -- so a report with no job beside it, or a job with no
|
||
report, is treated as nothing found rather than half trusted.
|
||
"""
|
||
from vibe import designspec
|
||
report_dir = K.get(know, 'handling.report_dir') or \
|
||
designspec.sub_dir(doc, 'handling')
|
||
if not report_dir:
|
||
return None
|
||
html = os.path.join(report_dir, mark + '.html')
|
||
job_path = os.path.join(report_dir, mark + '.job.json')
|
||
if not (os.path.isfile(html) and os.path.isfile(job_path)):
|
||
return None
|
||
job = _read_json(job_path)
|
||
if job is None:
|
||
return None
|
||
return {'job': job, 'job_path': job_path, 'html': html}
|
||
|
||
|
||
def _find_sweep(doc, know, mark):
|
||
"""The last sweep's record for this mark: an exported job, or why not."""
|
||
from vibe import designspec
|
||
root = designspec.sub_dir(doc, 'sweep')
|
||
meta = _read_json(os.path.join(root, 'meta', mark + '.json'))
|
||
if meta is None:
|
||
return None
|
||
if meta.get('status') != 'exported':
|
||
return {'meta': meta, 'refused': True}
|
||
job = _read_json(os.path.join(root, 'jobs', mark + '.job.json'))
|
||
if job is None:
|
||
return {'meta': meta, 'refused': True}
|
||
return {'meta': meta, 'job': job, 'root': root}
|
||
|
||
|
||
def _sweep_problems_row(root, mark):
|
||
"""The cached FAIL/WARN for one mark from the sweep's own `problems.json`.
|
||
|
||
Reading this is a dictionary lookup once the file is parsed, not a
|
||
subprocess -- it is why a swept design is cheap to reuse in bulk. Parsed
|
||
once per file per Ticket QC run and kept by mtime, not re-read per ticket.
|
||
"""
|
||
path = os.path.join(root, 'problems.json')
|
||
try:
|
||
mtime = os.path.getmtime(path)
|
||
except OSError:
|
||
return None
|
||
cached = _SWEEP_PROBLEMS_CACHE.get(path)
|
||
if cached is None or cached[0] != mtime:
|
||
rows = _read_json(path) or []
|
||
by_mark = dict((r.get('mark'), r) for r in rows if r.get('mark'))
|
||
_SWEEP_PROBLEMS_CACHE[path] = (mtime, by_mark)
|
||
return _SWEEP_PROBLEMS_CACHE[path][1].get(mark)
|
||
|
||
|
||
# The provenance that rides on every handling finding, which is the only
|
||
# place a Ticket QC reader is told what kind of handling answer they have.
|
||
#
|
||
# "no position search" is here because `_run_panellift_json` now forces
|
||
# `recommend=False` (see its docstring). The batch reports print NOT COMPUTED
|
||
# under the heading the positions would have filled; Ticket QC has no such
|
||
# heading -- it never printed positions -- so the same fact has to be said
|
||
# where its handling numbers are, or a reader takes these for the output of a
|
||
# full run. It is SHORTER than the tag it replaces (95 characters against
|
||
# 106) on purpose: `checklist_states` caps a row's note at 180 and this tag
|
||
# is already spending most of that before the finding's own message starts.
|
||
# "nobody has picked the inserts" came out rather than the phrase after the
|
||
# dash, which the button's handling section quotes back verbatim.
|
||
_ONSPOT_TAG = (' (default stages, no position search -- good enough to find '
|
||
'a problem, not good enough to sign)')
|
||
# Left alone: this one points at a reviewed Panel Lift Check REPORT, and that
|
||
# report was produced by a real single-piece run and does carry its
|
||
# recommended positions. Only the numbers were re-read here.
|
||
_REVIEWED_TAG = ' (reviewed Panel Lift Check)'
|
||
|
||
|
||
def _findings_from_summary(summary, mark, tag):
|
||
"""A panellift `--json` summary -> Findings, FAIL/WARN only, plus one OK."""
|
||
out = []
|
||
for stage in (summary.get('stages') or []):
|
||
for f in (stage.get('findings') or []):
|
||
sev = f.get('severity')
|
||
if sev not in ('FAIL', 'WARN'):
|
||
continue
|
||
out.append(Finding(sev, 'HANDLE-' + str(f.get('code') or '?'),
|
||
'{0} [{1}]{2}: {3}'.format(
|
||
mark, stage.get('name') or '?', tag,
|
||
f.get('message'))))
|
||
if not out:
|
||
out.append(Finding('OK', 'HANDLE-OK',
|
||
'{0}: handling checks out clean{1} (verdict {2})'
|
||
.format(mark, tag, summary.get('verdict') or 'OK')))
|
||
return out
|
||
|
||
|
||
def _findings_from_problems_row(row, mark):
|
||
"""A `problems.json` row -> Findings. Same shape as _findings_from_summary
|
||
but the findings are JSON arrays (sev, code, message), not dicts."""
|
||
out = []
|
||
for stage in (row.get('stages') or []):
|
||
for entry in (stage.get('findings') or []):
|
||
try:
|
||
sev, code, message = entry[0], entry[1], entry[2]
|
||
except (IndexError, TypeError):
|
||
continue
|
||
if sev not in ('FAIL', 'WARN'):
|
||
continue
|
||
out.append(Finding(sev, 'HANDLE-' + str(code),
|
||
'{0} [{1}]{2}: {3}'.format(
|
||
mark, stage.get('name') or '?',
|
||
_ONSPOT_TAG, message)))
|
||
if not out:
|
||
out.append(Finding('OK', 'HANDLE-OK',
|
||
'{0}: handling checks out clean{1} (verdict {2})'
|
||
.format(mark, _ONSPOT_TAG, row.get('verdict') or 'OK')))
|
||
return out
|
||
|
||
|
||
_SWEEP_STATUS_TEXT = {
|
||
'refused': 'liftexport could not build a job for it',
|
||
'no stages': 'no handling.stages are configured',
|
||
'dunnage misconfigured': 'the plant dunnage setting is not usable',
|
||
'no strength': "no f'c/f'ci was available for one or more stages",
|
||
'crash': 'the sweep crashed while measuring it',
|
||
}
|
||
|
||
|
||
def _sweep_refusal_finding(meta, mark):
|
||
status = meta.get('status') or 'unknown'
|
||
why = (_SWEEP_STATUS_TEXT.get(status) or status)
|
||
bits = []
|
||
if meta.get('problems'):
|
||
bits.extend(str(p) for p in meta['problems'])
|
||
if meta.get('stage_problems'):
|
||
bits.extend(str(p) for p in meta['stage_problems'])
|
||
if meta.get('missing_fc'):
|
||
bits.append('stage(s) ' + ', '.join(meta['missing_fc']))
|
||
if meta.get('error'):
|
||
# The traceback's last line is usually the exception itself -- the
|
||
# rest is noise for a message meant to be read, not debugged from.
|
||
bits.append(str(meta['error']).strip().splitlines()[-1][:200])
|
||
detail = '; '.join(bits)
|
||
text = ('{0}: the last sweep could not check this piece ({1}{2}). This '
|
||
'may be out of date -- re-run the sweep or Panel Lift Check for a '
|
||
'current answer.'
|
||
.format(mark, why, ': ' + detail if detail else ''))
|
||
return Finding('WARN', 'HANDLE-SWEPT-REFUSED', text)
|
||
|
||
|
||
def _run_panellift_json(python_exe, job_path, mark):
|
||
"""Run panellift against an existing job file and read its --json summary.
|
||
|
||
The HTML this writes is thrown away straight after -- only the summary is
|
||
wanted here, and it must never land beside a reviewed report or in the
|
||
sweep folder where it could be mistaken for either.
|
||
|
||
NO POSITION SEARCH, on all three paths that reach here (a reviewed report
|
||
re-read, a swept job re-run, a check on the spot). Two independent reasons,
|
||
and both have to hold before a flag like this is forced:
|
||
|
||
1. NOTHING HERE CONSUMES IT. The only reader of what comes back is
|
||
`_findings_from_summary`, and it takes exactly two things: the
|
||
top-level `verdict` and every stage's (severity, code, message).
|
||
panellift builds both from `cases` BEFORE `--no-recommend` is even
|
||
consulted -- `cli.main` does `layout.recommend(cases) if args.recommend
|
||
else layout.not_searched()`, and the result lands only under
|
||
`summary['recommendation']`, which nothing on this side opens. Every
|
||
stage is still analysed and every check still runs.
|
||
2. TICKET QC IS A BATCH, and the batch rule already says so. The
|
||
`recommend_positions` run option is documented as governing the
|
||
single-piece check only -- "A BATCH never runs it whatever this says"
|
||
-- and `_run_on_the_spot` already exports with `batch=True` for that
|
||
reason. Forcing the flag here is that declared rule reaching the one
|
||
call site that had not been told, not an override of the engineer's
|
||
setting.
|
||
|
||
WHAT IT IS WORTH. Measured on the live model 2026-08-30: the all-checks
|
||
wall on WP396 (the LEDGE piece) was 41.9 s, and a per-check breakdown put
|
||
98.9% of it -- 40.2 s -- in `check_handling_result` alone, paid again on a
|
||
second call (38.8 s), so it is per-ticket cost and not cold geometry. The
|
||
search is 93% of a panellift run (12.6 s of a 13.6 s median piece, 49 s on
|
||
the worst of the reference sweep). The saving is therefore at least the
|
||
~12.5 s of a median search and, if WP396's 40 s is subprocess-bound rather
|
||
than export-bound, most of the 40 s.
|
||
"""
|
||
from vibe import liftexport
|
||
scratch = os.path.join(_scratch_dir(), 'qc_{0}_{1}.html'
|
||
.format(mark, int(time.time() * 1000)))
|
||
try:
|
||
code, summary, stderr = liftexport.run(python_exe, job_path, scratch,
|
||
recommend=False)
|
||
finally:
|
||
try:
|
||
os.remove(scratch)
|
||
except Exception:
|
||
pass
|
||
if code == 2 or summary is None:
|
||
return None, stderr
|
||
return summary, stderr
|
||
|
||
|
||
def _run_on_the_spot(doc, know, t, intent, mark):
|
||
"""Measure the piece and run the default stages, right now.
|
||
|
||
Every refusal below is a REPORTED FINDING, never a raised exception and
|
||
never an invented number: a missing f'c or a weight that does not
|
||
reconcile stops THIS calculation, exactly as it always has, and says why
|
||
in a way the user can act on -- it does not stop the rest of Ticket QC.
|
||
"""
|
||
from vibe import liftexport
|
||
|
||
if not _cpython_budget_ok(know):
|
||
limit = K.get(know, 'handling.qc_cpython_calls_per_min', 10)
|
||
return [Finding('WARN', 'HANDLE-BUDGET',
|
||
'{0}: no current handling result is on file, and this '
|
||
"run's budget for checking pieces on the spot is used "
|
||
'up ({1}/min). Run Panel Lift Check or the sweep for '
|
||
'this piece, or raise '
|
||
'handling.qc_cpython_calls_per_min.'
|
||
.format(mark, limit))]
|
||
|
||
from Autodesk.Revit.DB import ElementId
|
||
try:
|
||
asm = doc.GetElement(ElementId(t['asm_id']))
|
||
except Exception:
|
||
asm = None
|
||
if asm is None:
|
||
return [Finding('WARN', 'HANDLE-UNAVAILABLE',
|
||
'{0}: could not re-read this assembly to check '
|
||
'handling.'.format(mark))]
|
||
|
||
design = intent.get('name') or t.get('design_number')
|
||
# The design number a ticket carries may be the raw 'WP1.0' while the
|
||
# spec on disk is keyed 'WP1' (assemblies_by_design and list_specs both
|
||
# reduce through design_key). The handling read must use the SAME key
|
||
# the spec was stored under, or it looks for WP1.0.yaml that was never
|
||
# written and tells the user to run Extract Design, which already did.
|
||
# Measured: two environments, one spec says design_number: WP1, the
|
||
# other WP1.0 -- the second broke exactly here.
|
||
from vibe import designspec as _ds
|
||
if design:
|
||
design = _ds.design_key(design, know) or design
|
||
try:
|
||
# Ticket QC runs this itself, over every ticket of a design.
|
||
# Nobody picked the inserts and nobody is waiting on one
|
||
# piece, so it is a batch.
|
||
job, problems = liftexport.export(doc, asm, know, {}, design,
|
||
batch=True)
|
||
except Exception as ex:
|
||
return [Finding('WARN', 'HANDLE-UNAVAILABLE',
|
||
'{0}: the handling check could not run -- {1}: {2}'
|
||
.format(mark, type(ex).__name__, ex))]
|
||
if job is None:
|
||
return [Finding('WARN', 'HANDLE-UNAVAILABLE',
|
||
'{0}: the handling check could not run -- {1}'
|
||
.format(mark, '; '.join(problems) or 'unknown reason'))]
|
||
|
||
ok, message, _numbers = liftexport.weight_check(job, know)
|
||
if not ok:
|
||
# The weight gate stays a refusal FOR THIS CALCULATION -- see the
|
||
# module notes -- it just no longer takes the rest of Ticket QC with
|
||
# it.
|
||
first_line = (message or '').splitlines()[0] if message else ''
|
||
return [Finding('WARN', 'HANDLE-WEIGHT-GATE',
|
||
'{0}: the handling check could not run -- {1}'
|
||
.format(mark, first_line or
|
||
'its geometry does not reconcile with the '
|
||
"model's weight, so nothing was calculated"))]
|
||
|
||
stages, _rows, stage_problems = liftexport.default_stages(job, know)
|
||
if not stages:
|
||
return [Finding('WARN', 'HANDLE-UNAVAILABLE',
|
||
'{0}: no handling.stages are configured, so lifting '
|
||
'was not checked.'.format(mark))]
|
||
if stage_problems:
|
||
return [Finding('WARN', 'HANDLE-STAGE-CONFIG',
|
||
'{0}: {1}'.format(mark, '; '.join(stage_problems)))]
|
||
|
||
missing = [s['name'] for s in stages if not s.get('fc_psi')]
|
||
if missing:
|
||
return [Finding('WARN', 'HANDLE-NO-STRENGTH',
|
||
"{0}: no f'c/f'ci is available for stage(s) {1} -- "
|
||
'the handling check could not run for them. Run '
|
||
'Extract Design, or add the missing strength to the '
|
||
'design summary.'.format(mark, ', '.join(missing)))]
|
||
|
||
python_exe = liftexport.find_python(know)
|
||
if not python_exe:
|
||
return [Finding('WARN', 'HANDLE-NO-PYTHON',
|
||
'{0}: the handling check could not run -- no CPython '
|
||
'3.9+ was found. Set handling.python_exe, or install '
|
||
'Python.'.format(mark))]
|
||
|
||
job['stages'] = stages
|
||
job_path = os.path.join(_scratch_dir(), 'qc_{0}_{1}.job.json'
|
||
.format(mark, int(time.time() * 1000)))
|
||
try:
|
||
_write_json(job_path, liftexport.strip_private(job))
|
||
summary, stderr = _run_panellift_json(python_exe, job_path, mark)
|
||
finally:
|
||
try:
|
||
os.remove(job_path)
|
||
except Exception:
|
||
pass
|
||
|
||
if summary is None:
|
||
return [Finding('WARN', 'HANDLE-CALC-ERROR',
|
||
'{0}: the handling calculation did not complete -- {1}'
|
||
.format(mark, (stderr or 'no error text')[:300]))]
|
||
|
||
return _findings_from_summary(summary, mark, _ONSPOT_TAG)
|
||
|
||
|
||
def check_handling_result(t, intent, know, spec):
|
||
"""Can this piece be picked up? Reuse a finished handling result when one
|
||
is on file and still describes the piece; run one on the spot otherwise.
|
||
|
||
See the module notes above this function for the matching rule, the
|
||
staleness rule, and the cost policy -- they are load-bearing, not
|
||
incidental, and changing this check without re-reading them is how the
|
||
next version quietly starts trusting a stale answer or hanging Ticket QC
|
||
on a big design.
|
||
"""
|
||
if not t.get('panel_id'):
|
||
return []
|
||
if not (K.get(know, 'handling.lifter_groups', {}) or {}):
|
||
# The plant has not set up handling checking at all -- silent, like
|
||
# every other check here that has no prerequisite knowledge to run on.
|
||
return []
|
||
mark = t.get('mark')
|
||
if not mark:
|
||
return []
|
||
|
||
# The document the ticket was read from beats vibe.helpers.doc -- the
|
||
# latter is a button-session global that is invalid on a detached/saved
|
||
# copy (measured: it throws 'referenced object is not valid' there,
|
||
# while the ticket's own doc works).
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'HANDLE-UNAVAILABLE',
|
||
'{0}: could not reach the active Revit document '
|
||
'to check handling.'.format(mark))]
|
||
|
||
# The plumbing below -- _find_reviewed/_find_sweep/_current_fingerprint
|
||
# -- touches the document for its path and its element sets. On a
|
||
# detached/saved copy those reads can hit a foreign document context and
|
||
# throw "referenced object is not valid". That is an environment
|
||
# unavailability, not a finding about THIS TICKET, and it must surface
|
||
# as HANDLE-UNAVAILABLE rather than a generic CHECK-ERROR that buries
|
||
# the actual check output. Wrap the whole look-up phase together so one
|
||
# bad environment assumption cannot take the rest of Ticket QC down.
|
||
try:
|
||
findings = _handling_lookup(tdoc, know, mark, t, intent)
|
||
except Exception as ex:
|
||
findings = [Finding(
|
||
'WARN', 'HANDLE-UNAVAILABLE',
|
||
'{0}: the handling check could not reach a usable document '
|
||
'context -- {1}: {2}'.format(mark, type(ex).__name__,
|
||
str(ex)[:200]))]
|
||
return findings
|
||
|
||
|
||
def _handling_lookup(doc, know, mark, t, intent):
|
||
"""The body of check_handling_result's look-up phase, wrapped separately
|
||
so a foreign-document exception becomes HANDLE-UNAVAILABLE (see above).
|
||
"""
|
||
now_fp = _current_fingerprint(t, intent)
|
||
prefix = []
|
||
|
||
# 1. a human-reviewed Panel Lift Check result -- the best answer there is.
|
||
reviewed = _find_reviewed(doc, know, mark)
|
||
if reviewed is not None:
|
||
stale = _fingerprint_stale(_job_fingerprint(reviewed['job']), now_fp,
|
||
know)
|
||
if not stale:
|
||
from vibe import liftexport
|
||
python_exe = liftexport.find_python(know)
|
||
if not python_exe:
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-NO-PYTHON',
|
||
'{0}: a reviewed Panel Lift Check report is on file, but '
|
||
'no CPython 3.9+ was found to read its numbers back. Set '
|
||
'handling.python_exe, or install Python.'.format(mark)))
|
||
elif not _cpython_budget_ok(know):
|
||
limit = K.get(know, 'handling.qc_cpython_calls_per_min', 10)
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-BUDGET',
|
||
'{0}: a reviewed Panel Lift Check report is on file and '
|
||
"still matches this piece, but this run's budget for "
|
||
'reading it back ({1}/min) is used up -- see {2}.'
|
||
.format(mark, limit, reviewed['html'])))
|
||
else:
|
||
summary, stderr = _run_panellift_json(
|
||
python_exe, reviewed['job_path'], mark)
|
||
if summary is None:
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-CALC-ERROR',
|
||
'{0}: a reviewed Panel Lift Check report is on file, '
|
||
'but re-reading its numbers failed -- {1}'
|
||
.format(mark, (stderr or 'no error text')[:300])))
|
||
else:
|
||
return prefix + _findings_from_summary(
|
||
summary, mark, _REVIEWED_TAG)
|
||
else:
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-STALE',
|
||
'{0}: a reviewed Panel Lift Check report is on file ({1}) but '
|
||
'no longer matches this piece -- {2}. Re-run Panel Lift '
|
||
'Check.'.format(mark, reviewed['html'], '; '.join(stale))))
|
||
|
||
# 2. a sweep's record for this mark -- default stages, but already paid
|
||
# for, and cheap to reuse when problems.json has it cached.
|
||
sweep = _find_sweep(doc, know, mark)
|
||
if sweep is not None:
|
||
if sweep.get('refused'):
|
||
return prefix + [_sweep_refusal_finding(sweep['meta'], mark)]
|
||
|
||
stale = _fingerprint_stale(_job_fingerprint(sweep['job']), now_fp, know)
|
||
if not stale:
|
||
ungated = []
|
||
if sweep['meta'].get('weight_ok') is False:
|
||
ungated.append(Finding(
|
||
'WARN', 'HANDLE-SWEEP-UNGATED',
|
||
"{0}: the sweep's geometry read for this piece did not "
|
||
'reconcile with its weight parameter, so its handling '
|
||
'numbers may be unreliable ({1}). Run Panel Lift Check '
|
||
'for a gated result.'
|
||
.format(mark, (sweep['meta'].get('weight_msg') or '')
|
||
.splitlines()[0] if sweep['meta'].get('weight_msg')
|
||
else 'see the sweep log')))
|
||
row = _sweep_problems_row(sweep['root'], mark)
|
||
if row is not None:
|
||
return prefix + ungated + _findings_from_problems_row(row, mark)
|
||
# problems.json has not been generated for this sweep (or does
|
||
# not carry this mark) -- re-run the saved job to get findings,
|
||
# same budget as everything else that shells out.
|
||
from vibe import liftexport
|
||
python_exe = liftexport.find_python(know)
|
||
if not python_exe:
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-NO-PYTHON',
|
||
'{0}: a swept job file is on file, but no CPython 3.9+ '
|
||
'was found to check it. Set handling.python_exe, or '
|
||
'install Python.'.format(mark)))
|
||
elif not _cpython_budget_ok(know):
|
||
limit = K.get(know, 'handling.qc_cpython_calls_per_min', 10)
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-BUDGET',
|
||
'{0}: a swept job file is on file and still matches this '
|
||
"piece, but this run's budget for checking it ({1}/min) "
|
||
'is used up.'.format(mark, limit)))
|
||
else:
|
||
job_path = os.path.join(sweep['root'], 'jobs', mark + '.job.json')
|
||
summary, stderr = _run_panellift_json(python_exe, job_path,
|
||
mark)
|
||
if summary is None:
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-CALC-ERROR',
|
||
'{0}: a swept job file is on file, but checking it '
|
||
'failed -- {1}'.format(mark, (stderr or
|
||
'no error text')[:300])))
|
||
else:
|
||
return prefix + ungated + _findings_from_summary(
|
||
summary, mark, _ONSPOT_TAG)
|
||
else:
|
||
prefix.append(Finding(
|
||
'WARN', 'HANDLE-STALE',
|
||
"{0}: the last sweep's result for this piece is out of date "
|
||
'-- {1}. Re-run the sweep or Panel Lift Check.'
|
||
.format(mark, '; '.join(stale))))
|
||
|
||
# 3. nothing usable on file -- run it there and then, budget and opt-out
|
||
# permitting.
|
||
if not K.get(know, 'handling.qc_onspot', True):
|
||
return prefix + [Finding(
|
||
'WARN', 'HANDLE-NOT-CHECKED',
|
||
'{0}: no current handling result is on file, and on-the-spot '
|
||
'checking is turned off (handling.qc_onspot). Run Panel Lift '
|
||
'Check or the sweep.'.format(mark))]
|
||
|
||
return prefix + _run_on_the_spot(doc, know, t, intent, mark)
|
||
|
||
|
||
# ------------------------------------------------------- strand locations
|
||
|
||
def check_strand_layout(t, intent, know, spec):
|
||
"""Where the strands actually sit, against the chain on the design summary.
|
||
|
||
Two separate questions, and only one of them is the design:
|
||
|
||
* the SPACINGS between strands. These come straight off the dimension
|
||
chain and must be reproduced exactly. A correct strand count in the
|
||
wrong place is still a wrong piece.
|
||
* the distance from the rail to the first strand. This is free to move,
|
||
because the strands are stressed across the whole bed and a piece
|
||
narrower than the bed can sit anywhere in it. How far it may move is
|
||
not free, though: the slack is exactly what the bed leaves over, so
|
||
|offset| <= rails.max_bed_width_in - panel width.
|
||
|
||
A piece may be modelled in either orientation, so the pattern is read from
|
||
both edges and the better fit wins.
|
||
"""
|
||
layout = t.get('strand_layout')
|
||
design = (spec or {}).get('reinforcing', {}).get('strand_layout') or {}
|
||
# strand_positions() reads either shape: 'columns_in' on a spec written as
|
||
# the matrix, 'positions_in' on one written before it existed. See
|
||
# specformat.py -- both name the same cumulative distances from the datum.
|
||
positions = specformat.strand_positions(design)
|
||
if not positions:
|
||
return []
|
||
if not layout or not layout.get('across'):
|
||
if t.get('strand_qty'):
|
||
return [Finding('WARN', 'STRAND-POS',
|
||
'the design dimensions where the strands sit, but '
|
||
'their positions could not be measured on this piece')]
|
||
return []
|
||
|
||
tol = K.get(know, 'tolerances.dimension_in', 0.375)
|
||
gaps = [round(positions[i] - positions[i - 1], 4)
|
||
for i in range(1, len(positions))]
|
||
|
||
best = None
|
||
for reading, seen in (('as modelled', layout['across']),
|
||
('mirrored', layout.get('across_from_far') or [])):
|
||
fit = _fit_chain(seen, positions, gaps, tol)
|
||
if fit and (best is None or fit['error'] < best['error']):
|
||
fit['reading'] = reading
|
||
fit['seen'] = seen
|
||
best = fit
|
||
if best is None:
|
||
return [Finding('FAIL', 'STRAND-POS',
|
||
'{0} strands per row modelled but the design chain '
|
||
'only locates {1}'.format(
|
||
len(layout['across']), len(positions)))]
|
||
|
||
out = []
|
||
if not best['spacings_ok']:
|
||
out.append(Finding(
|
||
'FAIL', 'STRAND-SPACING',
|
||
'strand spacing differs from the design chain -- modelled {0}, '
|
||
'design {1} (reading from the {2} edge)'
|
||
.format(_inches(best['seen_gaps']), _inches(best['want_gaps']),
|
||
'far' if best['reading'] == 'mirrored' else 'near')))
|
||
else:
|
||
out.append(Finding('OK', 'STRAND-SPACING',
|
||
'strand spacing matches the design chain ({0})'
|
||
.format(_inches(best['seen_gaps']) or 'single strand')))
|
||
|
||
out.extend(_check_bed_offset(t, layout, best, know))
|
||
out.extend(_check_strand_rows(t, layout, design, tol))
|
||
return out
|
||
|
||
|
||
def _fit_chain(seen, positions, gaps, tol):
|
||
"""Slide the design chain onto what was modelled; best contiguous run wins."""
|
||
if not seen or len(seen) > len(positions):
|
||
return None
|
||
best = None
|
||
for start in range(len(positions) - len(seen) + 1):
|
||
offset = seen[0] - positions[start]
|
||
error = 0.0
|
||
for i in range(len(seen)):
|
||
error = max(error, abs(seen[i] - (positions[start + i] + offset)))
|
||
if best is None or error < best['error']:
|
||
seen_gaps = [round(seen[i] - seen[i - 1], 4)
|
||
for i in range(1, len(seen))]
|
||
want_gaps = gaps[start:start + len(seen) - 1]
|
||
best = {
|
||
'start': start, 'offset': offset, 'error': error,
|
||
'seen_gaps': seen_gaps, 'want_gaps': want_gaps,
|
||
'spacings_ok': all([abs(seen_gaps[i] - want_gaps[i]) <= tol
|
||
for i in range(len(seen_gaps))]),
|
||
}
|
||
return best
|
||
|
||
|
||
def _check_bed_offset(t, layout, fit, know):
|
||
"""The pattern may slide -- but only if the bed has no fixed rail.
|
||
|
||
Gathering only. Whether the slide is allowed at all is
|
||
rails.left_rail_is_fixed, and the verdict is
|
||
ticketpage.bed_offset_status (which carries the reasoning). Until
|
||
2026-08-30 this allowed the slide unconditionally and never read the
|
||
flag, which was right for false and wrong for true.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
bed_in = K.get(know, 'rails.max_bed_width_in')
|
||
if not bed_in:
|
||
return []
|
||
width_in = layout.get('width_in')
|
||
if width_in is None:
|
||
return []
|
||
verdict = TP.bed_offset_status(fit['offset'], width_in, bed_in,
|
||
K.get(know, 'rails.left_rail_is_fixed'))
|
||
if verdict is None:
|
||
return []
|
||
return [Finding(verdict[0], verdict[1], verdict[2])]
|
||
|
||
|
||
def _check_strand_rows(t, layout, design, tol):
|
||
"""Rows through the thickness, and whether rows x per-row is the count."""
|
||
want = [r.get('from_bottom_in') for r in (design.get('rows') or [])
|
||
if isinstance(r, dict) and r.get('from_bottom_in') is not None]
|
||
seen = layout.get('rows') or []
|
||
out = []
|
||
if want:
|
||
want = sorted(want)
|
||
if len(seen) != len(want):
|
||
out.append(Finding('FAIL', 'STRAND-ROWS',
|
||
'design has {0} strand row(s) at {1}, modelled {2} at {3}'
|
||
.format(len(want), _inches(want),
|
||
len(seen), _inches(seen))))
|
||
else:
|
||
off = [i for i in range(len(want)) if abs(seen[i] - want[i]) > tol]
|
||
if off:
|
||
out.append(Finding('FAIL', 'STRAND-ROWS',
|
||
'strand rows sit at {0}, design says {1}'
|
||
.format(_inches(seen), _inches(want))))
|
||
else:
|
||
out.append(Finding('OK', 'STRAND-ROWS',
|
||
'{0} strand row(s) at {1}'
|
||
.format(len(seen), _inches(seen))))
|
||
|
||
expected = len(layout.get('across') or []) * len(seen)
|
||
actual = t.get('strand_qty')
|
||
if expected and actual is not None and expected != actual:
|
||
out.append(Finding('WARN', 'STRAND-GRID',
|
||
'{0} across x {1} row(s) is {2} strands, but {3} are '
|
||
'modelled -- the pattern is not a full grid'
|
||
.format(len(layout['across']), len(seen),
|
||
expected, actual)))
|
||
return out
|
||
|
||
|
||
def _inches(values):
|
||
return ' / '.join([_fmt_num(v) for v in values])
|
||
|
||
|
||
# -------------------------------------------------------- interference
|
||
|
||
# Panel-local axes, as named in _VIEW_STAGES below: 0 along the piece,
|
||
# 1 through its thickness, 2 across its width.
|
||
#
|
||
# The three orthographic views of a panel lying in the form, and the two panel
|
||
# axes each one shows.
|
||
#
|
||
# THE ORDER IS FIXED and starts at top-in-form. That is the drawing the piece
|
||
# is actually detailed in: the full 299" x 143" face, where the members are
|
||
# spread out and most pairs separate at once. The other two views look along a
|
||
# long axis at an 8" thickness, where nearly everything overlaps nearly
|
||
# everything and filtering achieves little.
|
||
#
|
||
# Read off the model rather than assumed: the view named "Elevation Top" on a
|
||
# real ticket looks along the panel's local thickness axis, dot product
|
||
# +/-1.000 on every piece checked. (The sign varies because pieces are
|
||
# mirrored; the axis does not.)
|
||
#
|
||
# Deliberately NOT chosen from the data. Which view to look at first is a fact
|
||
# about how wall panels are built, and it does not change piece by piece.
|
||
_VIEW_STAGES = (
|
||
('top in form', (0, 2)), # looking along the thickness: length x width
|
||
('front', (0, 1)), # looking along the width: length x thickness
|
||
('right', (1, 2)), # looking along the length: thickness x width
|
||
)
|
||
|
||
|
||
def check_member_interference(t, intent, know, spec):
|
||
"""Reinforcing that runs through other reinforcing.
|
||
|
||
Off unless interference.check is set, because it is the only check that
|
||
has to look at every member's geometry.
|
||
|
||
Revit's own assembly clash tests every pair, which is O(n^2) in the member
|
||
count and solves geometry for all of it. Three things fix that here:
|
||
|
||
* the view cascade. A pair has to interfere in top-in-form before either
|
||
of the other views is consulted, so the poorly-discriminating views
|
||
only ever see what the good one let through.
|
||
* sweep and prune to enumerate that first view. Sorting the boxes by
|
||
their low edge along the length and walking a scan line forms only the
|
||
pairs that overlap on it -- O(n log n + k) rather than O(n^2) -- and
|
||
the width test then completes the top-in-form rectangle.
|
||
* a broad phase on cheap boxes. get_BoundingBox is a cached read while
|
||
get_Geometry is a solve, so the precise box is computed only for the
|
||
pairs still standing after all three views.
|
||
|
||
Filtering on the loose boxes errs in one direction only, and it is the
|
||
right one: a loose box is bigger, so it can invent an overlap that the
|
||
precise box then rejects, but it can never hide one.
|
||
"""
|
||
if not K.get(know, 'interference.check', False):
|
||
return []
|
||
# The interference geometry is solved HERE, when this check actually
|
||
# runs -- not in read_ticket. read_ticket used to pay for it on every
|
||
# ticket because interference.check was true, even for a ticket that
|
||
# would never be checked for clashes; on a 51-piece design that is the
|
||
# difference between a run the user can watch and 45 minutes they
|
||
# cannot work through. The frame is solved once and shared between the
|
||
# broad-phase boxes and the concrete extents (both used to solve it
|
||
# separately inside read_ticket).
|
||
from Autodesk.Revit.DB import ElementId
|
||
from vibe import ticketqc as Q
|
||
|
||
if not t.get('panel_id'):
|
||
return []
|
||
# The document the ticket was read from (see the dunnage check for why
|
||
# not vibe.helpers.doc -- a button-session global invalid on a detached
|
||
# / saved copy, measured).
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'CLASH-UNREADABLE',
|
||
'could not reach the active document for the '
|
||
'interference check')]
|
||
# LAST RUN'S PAIR LIST GOES NOW, NOT AT THE END.
|
||
#
|
||
# The pair list (below) is written only when there ARE pairs, so a piece
|
||
# that has been fixed must not keep the file that says it has not. Doing
|
||
# that here rather than on the clean path covers the four `return`s
|
||
# between here and the hits: a check that could not read the panel is not
|
||
# a check that confirmed last week's clashes, and the file would outlive
|
||
# it saying otherwise. It is written back, whole, further down.
|
||
root = _qc_root(tdoc)
|
||
stale = clear_clash_csv(root, t.get('mark')) if root else None
|
||
|
||
try:
|
||
panel = tdoc.GetElement(ElementId(t['panel_id']))
|
||
except Exception:
|
||
panel = None
|
||
if panel is None:
|
||
return [Finding('WARN', 'CLASH-UNREADABLE',
|
||
'panel for the interference check is not readable')]
|
||
frame = Q._panel_frame(panel)
|
||
if frame is None:
|
||
return [Finding('WARN', 'CLASH-UNREADABLE',
|
||
'panel geometry for the interference check '
|
||
'is not solvable')]
|
||
extents = Q.concrete_extents(panel, frame)
|
||
if extents:
|
||
lo, hi = extents
|
||
span = [(hi[i] - lo[i]) * 12.0 for i in (0, 1, 2)]
|
||
thick = min(range(3), key=lambda i: span[i])
|
||
rest = [i for i in (0, 1, 2) if i != thick]
|
||
rest.sort(key=lambda i: -span[i])
|
||
t['panel_axes'] = {'thickness': thick,
|
||
'length': rest[0], 'width': rest[1]}
|
||
t['concrete_lo_in'] = [v * 12.0 for v in lo]
|
||
t['concrete_hi_in'] = [v * 12.0 for v in hi]
|
||
asm = tdoc.GetElement(ElementId(t['asm_id']))
|
||
if asm is None:
|
||
return [Finding('WARN', 'CLASH-UNREADABLE',
|
||
'assembly for the interference check is not '
|
||
'readable')]
|
||
boxes = Q.member_boxes(tdoc, asm, panel, frame, know)
|
||
t['member_boxes'] = boxes
|
||
t['frame'] = frame
|
||
if len(boxes) < 2:
|
||
return []
|
||
|
||
tol = K.get(know, 'interference.tolerance_in', 0.125)
|
||
ignore = _ignore_pairs(know)
|
||
limit = K.get(know, 'interference.max_reported', 10)
|
||
|
||
same_family_ok = _same_family_categories(know)
|
||
within_category = K.get(know, 'interference.within_category_only', False)
|
||
exempt, unexplained = _exempt(t, boxes, know, frame)
|
||
groups = _component_groups(know)
|
||
pairs = [(i, j) for i, j in _sweep_pairs(boxes, 0)
|
||
if boxes[i]['id'] not in exempt and boxes[j]['id'] not in exempt
|
||
and not _grouped(boxes[i]['family'], boxes[j]['family'], groups)
|
||
and not _pair_ignored(boxes[i]['family'], boxes[j]['family'], ignore)
|
||
and not _same_family(boxes[i], boxes[j], same_family_ok)
|
||
and not (within_category and _cross_category(boxes[i], boxes[j]))]
|
||
|
||
# The third view never rejects anything, and cannot: top-in-form
|
||
# establishes length and width, front adds thickness, and right asks only
|
||
# about thickness and width -- both already known. Measured on three real
|
||
# pieces it passed 755 of 755, 537 of 537, 596 of 596. It is kept because
|
||
# the code should read like the three drawings it describes, and the cost
|
||
# of a pass over the survivors is nothing; it is not load-bearing.
|
||
trace = []
|
||
for name, axes in _VIEW_STAGES:
|
||
pairs = [(i, j) for i, j in pairs
|
||
if _view_overlap(boxes[i], boxes[j], axes) > tol]
|
||
trace.append('{0} after {1}'.format(len(pairs), name))
|
||
if not pairs:
|
||
break
|
||
|
||
# Only now is it worth solving the geometry, and only for what survived.
|
||
#
|
||
# Two further levels, and both earn their place. Boxes alone are badly
|
||
# wrong here: a box is the product of three intervals, so a bent bar's box
|
||
# is the whole U INCLUDING THE HOLE, and a strand passing cleanly through
|
||
# that hole reads as an interference. Measured on one real piece, one box
|
||
# per element gave 265, one box per solid gave 223, and the exact
|
||
# intersection gave 86 -- so roughly two thirds of what boxes report is
|
||
# air. The whole boolean pass took 0.11 s.
|
||
from vibe.ticketqc import refine_box, intersection_depth
|
||
exact = K.get(know, 'interference.exact', True)
|
||
min_thick = K.get(know, 'interference.min_thickness_in', tol)
|
||
|
||
hits = []
|
||
survived_solid = 0
|
||
for i, j in pairs:
|
||
a, b = refine_box(boxes[i], frame), refine_box(boxes[j], frame)
|
||
|
||
# Per-solid boxes: a nine-piece embed stops being one box spanning
|
||
# itself and the air it straddles.
|
||
depth = 0.0
|
||
for box_a in a.get('solid_boxes') or [(a['lo'], a['hi'])]:
|
||
pa = {'lo': box_a[0], 'hi': box_a[1]}
|
||
for box_b in b.get('solid_boxes') or [(b['lo'], b['hi'])]:
|
||
pb = {'lo': box_b[0], 'hi': box_b[1]}
|
||
deep = min([_view_overlap(pa, pb, axes)
|
||
for _name, axes in _VIEW_STAGES])
|
||
if deep > depth:
|
||
depth = deep
|
||
if depth <= tol:
|
||
continue
|
||
survived_solid += 1
|
||
|
||
if not exact:
|
||
hits.append((depth, None, a, b))
|
||
continue
|
||
|
||
# The exact test: how THICK is the shared material.
|
||
#
|
||
# Not a volume -- a bar grazing a plate over a wide area has volume
|
||
# while being thousandths deep, and one bar spearing another has depth
|
||
# with little volume. Depth is the question.
|
||
#
|
||
# And not a bounding box either, which is the correction that cost the
|
||
# most to find. When two round bars graze, the shared material is a
|
||
# curved lens filling a tenth of its own box, and boxing it reported
|
||
# half an inch of interference for a sliver of 0.0166 cubic inches.
|
||
# Every disputed reading on the piece this was tested against came
|
||
# from that, and all of them vanished at the real thickness.
|
||
found = intersection_depth(a.get('solids') or [], b.get('solids') or [])
|
||
if found is None:
|
||
continue
|
||
thickness, volume, box_least = found
|
||
if thickness > min_thick:
|
||
hits.append((thickness, volume, a, b))
|
||
|
||
trace.append('{0} on solid boxes'.format(survived_solid))
|
||
if exact:
|
||
trace.append('{0} sharing material over {1}" thick'
|
||
.format(len(hits), min_thick))
|
||
# "142 members, 940 after top in form, 507 after front, ..." -- six
|
||
# numbers in one parenthesis, the first counting ELEMENTS and the rest
|
||
# counting PAIRS. Owner, reading exactly that on WP296: "我看这个数字,
|
||
# 怎么感觉这个 interference check 跑了整个模型的,而不是这个 panel 的?"
|
||
# A count nearly seven times the population, one comma after the
|
||
# population, is a whole-model sweep to any reader who does not know the
|
||
# second number changed units. It did not sweep the model -- `member_boxes`
|
||
# iterates `asm.GetMemberIds()` and nothing else -- so the fix is to say
|
||
# what each number counts, and to say WHOSE members the first one is.
|
||
where = ('{0} member(s) of this assembly; pairs surviving each filter: {1}'
|
||
.format(len(boxes), ', '.join(trace)))
|
||
|
||
extra = []
|
||
if unexplained:
|
||
# Not an interference, but found by the same pass and worth saying:
|
||
# a bar with no counterpart in another layer and no insert to belong
|
||
# to is one the drawing does not explain.
|
||
extra.append(Finding(
|
||
'WARN', 'BAR-UNPAIRED',
|
||
'{0} bar(s) have no counterpart in another layer and no insert '
|
||
'nearby: {1}'.format(
|
||
len(unexplained),
|
||
' | '.join(['{0} [{1}] at {2}", depth {3}"'.format(f, i, s, d)
|
||
for i, f, s, d in unexplained[:6]]))))
|
||
|
||
if not hits:
|
||
# The delete happened at the top of the check. It can fail -- the file
|
||
# is open in Excel -- and a stale list of clashes for a piece that has
|
||
# none is a file that WILL be believed, so that failure is said out
|
||
# loud here rather than swallowed.
|
||
clean = ('nothing shares material more than {0}" thick with anything '
|
||
'else ({1})'.format(min_thick, where))
|
||
if stale:
|
||
return extra + [Finding(
|
||
'WARN', 'CLASH',
|
||
'{0} -- but an interference list from an earlier run could '
|
||
'not be deleted and is now wrong: {1}'.format(clean, stale))]
|
||
return extra + [Finding('OK', 'CLASH', clean)]
|
||
|
||
hits.sort()
|
||
hits.reverse()
|
||
shown = []
|
||
for thickness, volume, a, b in hits[:limit]:
|
||
amount = ('{0}" thick, {1} in3'.format(_fmt_num(round(thickness, 3)),
|
||
_fmt_num(round(volume or 0, 3)))
|
||
if volume is not None
|
||
else '{0}" of box'.format(_fmt_num(round(thickness, 3))))
|
||
# Element ids AND marks: Will's words -- a report whose objects
|
||
# cannot be selected in the model is a report that is not
|
||
# actionable. id is the select-by-id; mark is the name a person
|
||
# reads (bars are SR393-class; none prints as a dash).
|
||
def side(x):
|
||
ident = 'id {0}'.format(x.get('id'))
|
||
if x.get('mark'):
|
||
ident = '{0} mark {1}'.format(ident, x['mark'])
|
||
return ident
|
||
shown.append('{0} [{1}] x {2} [{3}]: {4}'.format(
|
||
a['family'], side(a), b['family'], side(b), amount))
|
||
more = '' if len(hits) <= limit else ' | ... {0} more'.format(len(hits) - limit)
|
||
|
||
# THE PAIRS GO IN A FILE; THE NOTE LINKS TO IT. Owner, 2026-08-30:
|
||
# "interference check 如果是 warning,那么 note 那边应该是一个文件的链接,
|
||
# 单击打开那个文件,写成 csv,里面包含干涉的 mark#,ID,X,mark#,ID 这样。
|
||
# 带表头。"
|
||
#
|
||
# The old note carried the pairs inline and was cut at 180 characters.
|
||
# Measured on his run: 4 interferences on a 142-member piece, and the cut
|
||
# landed inside the word `mark` of the FIRST pair --
|
||
#
|
||
# 4 interference(s) (142 members, 940 after top in form, 507 after
|
||
# front, 507 after right, 104 on solid boxes, 4 sharing material over
|
||
# 0.125" thick): <family> [id 24086619 mar...
|
||
#
|
||
# -- so the counts survived whole and the EVIDENCE did not: six numbers
|
||
# and half of one of four pairs. In a file nothing is cut, all four pairs
|
||
# are there, and the row keeps only what a row is for: how many, out of
|
||
# what population, and where the rest is.
|
||
#
|
||
# The filter trace is deliberately NOT in this note any more. It is the
|
||
# run's own diagnostics, it is what made the population unreadable, and
|
||
# it was competing with the evidence for the same 180 characters. It
|
||
# stays on the OK message, which has the room and alarms nobody.
|
||
path, unwritten = None, None
|
||
if root:
|
||
try:
|
||
path = write_clash_csv(root, t.get('mark'), clash_csv_rows(hits))
|
||
except Exception as ex:
|
||
unwritten = '{0}: {1}'.format(type(ex).__name__, ex)
|
||
else:
|
||
unwritten = 'this model has no QC data folder'
|
||
if path:
|
||
# THE DEPTH BAND IS IN THE NOTE BECAUSE IT IS NO LONGER IN THE FILE.
|
||
# THICKNESS_IN was dropped from the CSV on the owner's ruling
|
||
# (2026-08-30), and it was the one column that answered "why is this
|
||
# pair here" -- it is the quantity the filter tests. The band and
|
||
# the file's ordering say the same thing in one clause, in front of
|
||
# the separator, where the cap trims prose and never the path.
|
||
from vibe import ticketpage as _page
|
||
band = clash_depth_band(hits, min_thick, exact)
|
||
return extra + [Finding(
|
||
'WARN', 'CLASH',
|
||
'{0} interference(s) among the {1} member(s) of this piece\'s own '
|
||
'assembly{2}{3}{4}'.format(len(hits), len(boxes),
|
||
'; ' + band if band else '',
|
||
_page.NOTE_FILE_SEP, path))]
|
||
|
||
# NO FILE MEANS THE PAIRS COME BACK INLINE. A link that cannot be written
|
||
# must not take the findings with it -- capped and half-readable beats
|
||
# gone, and the note says why there is no file.
|
||
# Will 2026-08-28: the general rule for "not sure" is WARN, not FAIL
|
||
# and not SILENT. CLASH cannot be FAIL: on 57 std pieces every hit was
|
||
# strand/mesh/bars crossing an anchor or embed -- by-design construction,
|
||
# not two solids colliding -- so a FAIL would be crying wolf on every
|
||
# ticket. It cannot be OFF either: silence looks exactly like "checked,
|
||
# fine". It is WARN: the engineer sees it, reads the family pair, and
|
||
# decides. (The family pairs that are known construction crossings belong
|
||
# in interference.ignore_family_pairs / component_groups -- adding them
|
||
# quiets the noise, not this line.)
|
||
return extra + [Finding('WARN', 'CLASH',
|
||
'{0} interference(s) ({1}) -- the pair list could '
|
||
'not be written ({2}), so here are the worst: '
|
||
'{3}{4}'
|
||
.format(len(hits), where, unwritten,
|
||
' | '.join(shown), more))]
|
||
|
||
|
||
# ------------------------------------------------ the clash pairs, as a file
|
||
#
|
||
# "写成 csv,里面包含干涉的 mark#,ID,X,mark#,ID 这样。带表头。"
|
||
#
|
||
# THE FIRST FIVE COLUMNS ARE HIS, IN HIS ORDER, AND NOTHING GOES BETWEEN THEM.
|
||
# `X` is not data: it is the versus between the two sides of a pair, and it
|
||
# holds the literal letter `x` on every row, because that is what he drew.
|
||
# The two family names are appended AFTER his five and headed in full, so a
|
||
# spreadsheet built on the first five columns keeps working.
|
||
#
|
||
# THICKNESS_IN AND VOLUME_IN3 WERE TWO MORE AND ARE GONE (2026-08-30). Owner,
|
||
# reading a real file: "7.1的csv输出里面不需要thinkness_in跟volumn_in^3这俩。
|
||
# 可以保留着,但是默认折叠(hide?)我不知道这是不是在excel里面可行。不可行的话
|
||
# 就直接关掉吧。" -- keep them if they can ship hidden, drop them if they
|
||
# cannot. They cannot: a .csv is plain text and carries no column formatting,
|
||
# so there is no such thing as a hidden column in one. Excel would show both.
|
||
#
|
||
# WHAT LEFT WITH THEM, AND WHERE IT WENT. THICKNESS_IN was not decoration --
|
||
# it is the very quantity the filter tests (`thickness > min_thick`), so it
|
||
# was the reader's only per-pair answer to "how bad is this one, and why is
|
||
# it here at all". Deleting it outright would leave a list that cannot
|
||
# explain itself, so the range moved into the WARN note the reader opens the
|
||
# file from (`clash_depth_band`), and the rows in the file stay sorted
|
||
# deepest-first, which the note now says out loud. VOLUME_IN3 carried no
|
||
# such duty -- this check's own comment above says depth is the question and
|
||
# volume is not -- so it is simply gone.
|
||
#
|
||
# There is no clash LOCATION to report: `ticketqc.intersection_depth` returns
|
||
# a thickness, a volume and the least side of the shared solid's box, and no
|
||
# point. Rather than invent one from a bounding box, the row carries the ids,
|
||
# which is what actually gets a person to the clash -- both are select-by-id
|
||
# in Revit (Will: a report whose objects cannot be selected is not
|
||
# actionable).
|
||
#
|
||
# WHERE IT LIVES: the QC data root, beside `last-ticketqc.txt`, next to the
|
||
# model. Not in the repo, and never near it -- the marks in it are the
|
||
# client's.
|
||
CLASH_CSV_COLUMNS = ('MARK', 'ID', 'X', 'MARK', 'ID',
|
||
'FAMILY_1', 'FAMILY_2')
|
||
CLASH_CSV_X = 'x'
|
||
# A member with no mark is not a member whose mark could not be read: bars
|
||
# and hardware simply do not all carry one (`ticketqc._member_mark` returns
|
||
# None for genuinely no mark). A dash says "there is none" where an empty
|
||
# cell would read as "the tool did not manage to get it".
|
||
CLASH_CSV_NO_MARK = '-'
|
||
|
||
|
||
def clash_csv_rows(hits):
|
||
"""The pairs as CSV rows -- `hits` is [(thickness, volume, a, b)].
|
||
|
||
`a` and `b` are the refined member entries: id, mark, family. Pure; the
|
||
geometry was solved long before this is called.
|
||
|
||
The thickness and volume still arrive here and are deliberately not
|
||
written -- see the block above. Their ORDER is the surviving trace of
|
||
them: the caller sorts `hits` deepest-first and this preserves it, so
|
||
row 2 of the file is the worst pair whether or not a column says so.
|
||
"""
|
||
rows = []
|
||
for hit in (hits or []):
|
||
a, b = hit[2], hit[3]
|
||
rows.append([_clash_mark(a), _clash_ident(a), CLASH_CSV_X,
|
||
_clash_mark(b), _clash_ident(b),
|
||
_clash_family(a), _clash_family(b)])
|
||
return rows
|
||
|
||
|
||
def clash_depth_band(hits, min_thickness_in, exact=True):
|
||
"""How deep the flagged pairs are, as one clause for the CLASH note.
|
||
|
||
Pure, and host-tested. `hits` is [(thickness, volume, a, b)] already
|
||
sorted deepest-first; `min_thickness_in` is the filter that put them
|
||
there. Returns '' when there is nothing to say.
|
||
|
||
This is where THICKNESS_IN went when it left the CSV. It has to fit in
|
||
front of `ticketpage.NOTE_FILE_SEP` inside a 180-character cap that
|
||
trims the prose and never the path, so it is one short clause and not a
|
||
paragraph: the band, the filter it cleared, and the fact that the file
|
||
is ordered.
|
||
"""
|
||
if not hits:
|
||
return ''
|
||
depths = [hit[0] for hit in hits]
|
||
deepest, shallowest = max(depths), min(depths)
|
||
what = 'shared material' if exact else 'box overlap'
|
||
if round(deepest, 3) == round(shallowest, 3):
|
||
span = '{0}"'.format(_thick_num(deepest))
|
||
else:
|
||
span = '{0}"-{1}"'.format(_thick_num(shallowest),
|
||
_thick_num(deepest))
|
||
return ('{0} {1}, over the {2}" that flags a pair, deepest row first'
|
||
.format(what, span, _thick_num(min_thickness_in)))
|
||
|
||
|
||
def _thick_num(value):
|
||
"""An interference depth as text: three decimals, trailing zeros gone.
|
||
|
||
NOT `_fmt_num`, which gives two, and two is not enough here. The default
|
||
filter is an EIGHTH of an inch: `_fmt_num(0.125)` is '0.12', which reads
|
||
as a threshold slightly below the real one and would make a pair at
|
||
exactly 0.125" look as though it had cleared a bar it only met. The
|
||
numbers this clause carries are all in that range.
|
||
"""
|
||
try:
|
||
text = '{0:.3f}'.format(float(value))
|
||
except (TypeError, ValueError):
|
||
return text_type(value)
|
||
if '.' in text:
|
||
text = text.rstrip('0').rstrip('.')
|
||
return text or '0'
|
||
|
||
|
||
def _clash_mark(side):
|
||
mark = (side or {}).get('mark')
|
||
return text_type(mark) if mark else CLASH_CSV_NO_MARK
|
||
|
||
|
||
def _clash_ident(side):
|
||
ident = (side or {}).get('id')
|
||
return '' if ident is None else text_type(ident)
|
||
|
||
|
||
def _clash_family(side):
|
||
return text_type((side or {}).get('family') or '')
|
||
|
||
|
||
# `_clash_number` lived here and formatted the two dropped columns (empty
|
||
# rather than 0 when `interference.exact` was off and there was no volume at
|
||
# all). It went with them rather than sitting unused: a helper nothing calls
|
||
# is a column somebody will re-add by accident.
|
||
|
||
|
||
def csv_text(header, rows):
|
||
"""RFC 4180 text: CRLF rows, quotes doubled, a field quoted when it needs
|
||
to be.
|
||
|
||
Hand-written rather than `csv` because that module wants a binary file on
|
||
IronPython 2.7 and a text one on CPython 3 -- the one incompatibility
|
||
that would have to be branched around -- and this is nine lines.
|
||
"""
|
||
out = [_csv_line(header)]
|
||
for row in (rows or []):
|
||
out.append(_csv_line(row))
|
||
return '\r\n'.join(out) + '\r\n'
|
||
|
||
|
||
def _csv_line(values):
|
||
return ','.join([_csv_cell(v) for v in values])
|
||
|
||
|
||
def _csv_cell(value):
|
||
cell = '' if value is None else text_type(value)
|
||
for ch in (',', '"', '\r', '\n'):
|
||
if ch in cell:
|
||
return '"' + cell.replace('"', '""') + '"'
|
||
return cell
|
||
|
||
|
||
def clash_csv_name(mark):
|
||
"""`interference-<mark>.csv` -- one file per piece, named after it.
|
||
|
||
Per piece because a run checks many, and a single shared file would be
|
||
whichever piece happened to be checked last.
|
||
"""
|
||
from vibe.specsave import safe_name
|
||
return 'interference-{0}.csv'.format(safe_name(mark))
|
||
|
||
|
||
def write_clash_csv(root, mark, rows):
|
||
"""Write the pair list and return its path. UTF-8, no BOM."""
|
||
path = os.path.join(root, clash_csv_name(mark))
|
||
payload = csv_text(CLASH_CSV_COLUMNS, rows)
|
||
handle = io.open(path, 'wb')
|
||
try:
|
||
handle.write(payload.encode('utf-8'))
|
||
finally:
|
||
handle.close()
|
||
return path
|
||
|
||
|
||
def clear_clash_csv(root, mark):
|
||
"""Delete this piece's pair list; return its path only if it SURVIVED.
|
||
|
||
Called when the piece is now clean. Nothing there, or removed, is None --
|
||
the caller has nothing to say. A file that could not be deleted is
|
||
returned so the note can say it: a list of clashes for a piece that no
|
||
longer has any is a file that will be believed.
|
||
"""
|
||
path = os.path.join(root or '', clash_csv_name(mark))
|
||
if not os.path.exists(path):
|
||
return None
|
||
try:
|
||
os.remove(path)
|
||
except Exception:
|
||
return path
|
||
return None
|
||
|
||
|
||
def _qc_root(tdoc):
|
||
"""The QC data folder for this model, or None -- never a guess."""
|
||
try:
|
||
from vibe import designspec
|
||
return designspec.root_dir(tdoc)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _view_overlap(a, b, axes):
|
||
"""How deeply two boxes interfere in ONE view, or <=0 when clear in it.
|
||
|
||
This is the whole 2D test, and the reason it is six lines rather than a
|
||
polygon-clipping routine is worth spelling out.
|
||
|
||
A VIEW IS A DROPPED COORDINATE. Looking at the panel along its thickness
|
||
is exactly discarding axis 1 and keeping (0, 2). Nothing is rotated or
|
||
re-projected: `axes` simply names the two that survive.
|
||
|
||
PROJECTING A BOX LOSES NOTHING. An axis-aligned box IS the product of
|
||
three intervals -- [lo0,hi0] x [lo1,hi1] x [lo2,hi2] -- so dropping one
|
||
coordinate leaves the product of the other two, which is an axis-aligned
|
||
RECTANGLE, exactly. A general solid would project to some outline needing
|
||
real geometry; a box never does. (This is a second reason the boxes earn
|
||
their keep, beyond being cheap to compute.)
|
||
|
||
SO A RECTANGLE TEST IS TWO INTERVAL TESTS. Two rectangles overlap if and
|
||
only if they overlap on BOTH axes independently -- there is no diagonal
|
||
case to worry about, because neither rectangle is tilted:
|
||
|
||
axis 0: a ├───────────┤
|
||
b ├──────────┤
|
||
└── overlap = min(hi) - max(lo) ──┘
|
||
|
||
axis 2: a ├────────┤
|
||
b ├─────┤ no overlap: gap <= 0
|
||
-> clear in this view, stop.
|
||
|
||
The overlap on one axis is `min(hi) - max(lo)`: the right-hand end of the
|
||
intersection minus its left-hand end. Negative or zero means they do not
|
||
reach each other at all, and one such axis is enough to declare the pair
|
||
clear in this view -- hence the early return, which is what makes the
|
||
cascade cheap.
|
||
|
||
THE DEPTH IS THE SHORTER SIDE. When both axes overlap, the intersection is
|
||
a rectangle whose sides are those two overlaps. Its "least length" is the
|
||
shorter one, because that is the direction you would slide the pair apart
|
||
along on this drawing -- the smaller nudge that separates them. That is
|
||
the number compared against the 1/8" tolerance.
|
||
|
||
Called once per view per pair; the caller keeps only pairs that clear the
|
||
tolerance in every view.
|
||
"""
|
||
worst = None
|
||
for axis in axes:
|
||
# 1D interval overlap on this axis. Positive means they share a span.
|
||
gap = min(a['hi'][axis], b['hi'][axis]) - max(a['lo'][axis], b['lo'][axis])
|
||
if gap <= 0:
|
||
# Separated on one axis is separated in this whole view. No need
|
||
# to look at the other one.
|
||
return 0.0
|
||
if worst is None or gap < worst:
|
||
worst = gap
|
||
return worst
|
||
|
||
|
||
def _sweep_pairs(boxes, axis):
|
||
"""Sweep and prune: only the pairs that overlap on `axis`.
|
||
|
||
Walk the boxes left to right by their low edge, holding an "active set" of
|
||
the ones the scan line is currently inside. Anything whose high edge is
|
||
already behind the line can never meet what comes next, so it drops out;
|
||
whatever is left must overlap the box arriving now, by construction.
|
||
|
||
boxes sorted by low edge, scan line at |
|
||
|
||
A ├──────────┤
|
||
B ├────────────┤
|
||
C ├───┤
|
||
D ├─────┤
|
||
|
|
||
arriving C: A and B are still active -> emit (A,C) and (B,C).
|
||
arriving D: the line has passed A's and B's and C's high edges, so
|
||
the active set empties first and D pairs with nothing.
|
||
|
||
The pairs never formed are the win: A-D and B-D were never even looked at.
|
||
That is the difference from the double loop, which would have tested all
|
||
six pairs to reject three of them.
|
||
|
||
Sorting is O(n log n) and the walk emits k pairs, so this is O(n log n + k)
|
||
where k is how many pairs genuinely overlap on this axis -- against
|
||
O(n^2) for testing everything against everything.
|
||
"""
|
||
# Indices, sorted by where each box starts on this axis.
|
||
order = sorted(range(len(boxes)), key=lambda i: boxes[i]['lo'][axis])
|
||
active = [] # indices the scan line is currently inside
|
||
pairs = []
|
||
for i in order:
|
||
low = boxes[i]['lo'][axis]
|
||
# Retire everything that ended before this box begins.
|
||
active = [j for j in active if boxes[j]['hi'][axis] > low]
|
||
# Everything still open necessarily overlaps this box on this axis.
|
||
for j in active:
|
||
pairs.append((j, i))
|
||
active.append(i)
|
||
return pairs
|
||
|
||
|
||
def _ignore_pairs(know):
|
||
"""Family pairs that are expected to cross, as (a, b) upper-case tuples.
|
||
|
||
Reinforcing drawn as continuous through a strand is a drafting
|
||
convention, not a defect -- the shop weaves them. Without a way to say so
|
||
the check reports the same dozens of crossings on every piece and stops
|
||
being read, which is worse than not running it.
|
||
"""
|
||
raw = K.get(know, 'interference.ignore_family_pairs', []) or []
|
||
out = []
|
||
for entry in raw:
|
||
parts = [p.strip().upper() for p in str(entry).split('|')]
|
||
if len(parts) == 2 and parts[0] and parts[1]:
|
||
out.append((parts[0], parts[1]))
|
||
return out
|
||
|
||
|
||
def _pair_ignored(family_a, family_b, ignore):
|
||
up_a = (family_a or '').upper()
|
||
up_b = (family_b or '').upper()
|
||
for first, second in ignore:
|
||
if (first in up_a and second in up_b) or (first in up_b and second in up_a):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _exempt(t, boxes, know, frame):
|
||
"""(exempt ids, unexplained extras). Nothing is reported against the ids.
|
||
|
||
NAMED FAMILIES -- interference.exempt_families. Hardware whose modelled
|
||
solid is known to sit inside other things and always will.
|
||
|
||
INSERT BARS -- see _insert_bars. Those belong to the insert rather than to
|
||
the reinforcing schedule, and the plant may move them freely.
|
||
"""
|
||
exempt = set()
|
||
for name in _upper_list(K.get(know, 'interference.exempt_families', [])):
|
||
for entry in boxes:
|
||
if name in (entry.get('family') or '').upper():
|
||
exempt.add(entry['id'])
|
||
|
||
bars, unexplained = _insert_bars(t, boxes, know, frame)
|
||
exempt.update(bars)
|
||
return exempt, unexplained
|
||
|
||
|
||
def _insert_bars(t, boxes, know, frame):
|
||
"""Bars that belong to a lifting insert rather than to the schedule.
|
||
|
||
The plant sets these out with the insert and may move them freely, so
|
||
there is nothing for a checker to hold them to. What makes them hard is
|
||
that NOTHING about a bar says which it is: same family, same size, same
|
||
direction, same layer as ordinary reinforcing.
|
||
|
||
They are found by symmetry instead. Reinforcing comes in layers through
|
||
the thickness, and the layers mirror each other -- every ordinary bar has
|
||
a counterpart at the same station in another layer. A bar with no
|
||
counterpart is an EXTRA, and an extra sitting within reach of an insert is
|
||
that insert's bar.
|
||
|
||
Measured on a real piece: two layers of twenty #4 at identical stations,
|
||
plus ten #5 in one layer only, in pairs 8" apart centred on each of the
|
||
five lifter stations. Nothing else distinguishes those ten.
|
||
|
||
THE MIRROR IS NOT ASSUMED TO BE AT (thickness - depth). On this job one
|
||
face carries extra clear cover, so the layer at 2.5" mirrors one at 6.0"
|
||
rather than 5.5". Rather than configure the offset, a bar simply has to
|
||
find its counterpart in SOME other layer of the same run direction -- so
|
||
a broken symmetry costs nothing and no layer pairing has to be worked out.
|
||
|
||
An extra that is NOT near an insert is returned separately. It is not
|
||
exempt and it is worth saying out loud: it is a bar the symmetry does not
|
||
explain and no lifter accounts for.
|
||
"""
|
||
from vibe.ticketqc import refine_box
|
||
|
||
inserts = _upper_list(K.get(know, 'interference.insert_bars.insert_families', []))
|
||
families = _upper_list(K.get(know, 'interference.insert_bars.bar_families', []))
|
||
axes = t.get('panel_axes')
|
||
if not inserts or not families or not axes:
|
||
return set(), []
|
||
|
||
reach = K.get(know, 'interference.insert_bars.within_of_insert_in', 5.0)
|
||
station_tol = K.get(know, 'interference.insert_bars.station_tol_in', 1.0)
|
||
layer_tol = K.get(know, 'interference.insert_bars.layer_tol_in', 0.75)
|
||
thick = axes['thickness']
|
||
|
||
def mid(entry, axis):
|
||
return (entry['lo'][axis] + entry['hi'][axis]) / 2.0
|
||
|
||
insert_stations = {}
|
||
for entry in boxes:
|
||
if any(n in (entry.get('family') or '').upper() for n in inserts):
|
||
refine_box(entry, frame)
|
||
for axis in (axes['length'], axes['width']):
|
||
insert_stations.setdefault(axis, []).append(mid(entry, axis))
|
||
|
||
# every candidate bar, with the direction it runs, the layer it sits in,
|
||
# and its station across that direction
|
||
candidates = []
|
||
for entry in boxes:
|
||
if not any(n in (entry.get('family') or '').upper() for n in families):
|
||
continue
|
||
refine_box(entry, frame)
|
||
size = [entry['hi'][ax] - entry['lo'][ax] for ax in (0, 1, 2)]
|
||
runs = max(range(3), key=lambda i: size[i])
|
||
if runs == thick:
|
||
continue # not lying in the plane of the piece
|
||
station_axis = [ax for ax in (0, 1, 2) if ax not in (runs, thick)][0]
|
||
candidates.append({'entry': entry, 'runs': runs,
|
||
'station_axis': station_axis,
|
||
'station': mid(entry, station_axis),
|
||
'depth': mid(entry, thick),
|
||
'dia': round(sorted(size)[1], 3)})
|
||
|
||
exempt, unexplained = set(), []
|
||
for bar in candidates:
|
||
# a counterpart is the same size at the same station, in a different
|
||
# layer of the same run direction
|
||
paired = False
|
||
for other in candidates:
|
||
if other is bar or other['runs'] != bar['runs']:
|
||
continue
|
||
if abs(other['dia'] - bar['dia']) > 0.05:
|
||
continue
|
||
if abs(other['station'] - bar['station']) > station_tol:
|
||
continue
|
||
if abs(other['depth'] - bar['depth']) > layer_tol:
|
||
paired = True
|
||
break
|
||
if paired:
|
||
continue
|
||
|
||
near = [s for s in insert_stations.get(bar['station_axis'], [])
|
||
if abs(s - bar['station']) <= reach]
|
||
if near:
|
||
exempt.add(bar['entry']['id'])
|
||
else:
|
||
# depth quoted from the concrete face, which is how a layer is
|
||
# named on a drawing -- the raw local coordinate means nothing
|
||
face = (t.get('concrete_lo_in') or [0, 0, 0])[thick]
|
||
unexplained.append((bar['entry']['id'], bar['entry']['family'],
|
||
round(bar['station'], 1),
|
||
round(bar['depth'] - face, 2)))
|
||
return exempt, unexplained
|
||
|
||
|
||
def _upper_list(value):
|
||
return [str(v).strip().upper() for v in (value or []) if str(v).strip()]
|
||
|
||
|
||
def _component_groups(know):
|
||
"""Assemblies of hardware that arrive as several families but are one part.
|
||
|
||
A connection is not one family. A top-of-panel connection is a plate, its
|
||
rebar, its washer, its sleeve -- separate families, modelled to touch,
|
||
because they are one thing that gets installed together. So is an embed
|
||
and the bar welded to it.
|
||
|
||
Revit records none of this: the family name of every component is just its
|
||
own part number, with nothing pointing at the assembly it belongs to.
|
||
Checked on a real model -- no parameter, no nested family, no shared
|
||
parameter carries it. So the ownership has to be stated here.
|
||
|
||
Two strengths, and the difference matters:
|
||
|
||
interferes: false nothing in the group is reported at all, with
|
||
anything. For hardware that is set out as a
|
||
unit and simply is not the detailer's to
|
||
check.
|
||
interferes: within_group the components may overlap EACH OTHER freely,
|
||
but a component hitting something outside the
|
||
group is still reported. The default, and the
|
||
one to reach for first: it explains the
|
||
overlaps that are by design without going
|
||
blind to the ones that are not.
|
||
|
||
Returns [(names, strength)] with names upper-cased for substring matching.
|
||
"""
|
||
out = []
|
||
for group in (K.get(know, 'interference.component_groups', []) or []):
|
||
if not isinstance(group, dict):
|
||
continue
|
||
names = _upper_list(group.get('families'))
|
||
if not names:
|
||
continue
|
||
strength = str(group.get('interferes', 'within_group')).strip().lower()
|
||
if strength in ('false', 'no', 'none', 'never'):
|
||
strength = 'never'
|
||
else:
|
||
strength = 'within_group'
|
||
out.append((names, strength))
|
||
return out
|
||
|
||
|
||
def _grouped(family_a, family_b, groups):
|
||
"""True when this pair is explained by a component group."""
|
||
up_a = (family_a or '').upper()
|
||
up_b = (family_b or '').upper()
|
||
for names, strength in groups:
|
||
in_a = any(n in up_a for n in names)
|
||
in_b = any(n in up_b for n in names)
|
||
if strength == 'never':
|
||
if in_a or in_b:
|
||
return True
|
||
elif in_a and in_b:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _cross_category(a, b):
|
||
"""Two members from different Revit categories.
|
||
|
||
Reinforcing meeting an embed, or a bar running into a ledge, is how the
|
||
piece is put together -- those are different trades sharing space by
|
||
design and the model draws them touching. What has to be caught is two
|
||
things of the SAME kind occupying the same place: two bars, two embeds.
|
||
|
||
A blunt rule, and it does hide real cross-category problems. It is off
|
||
unless a project turns it on, and it is the difference between a report
|
||
somebody reads and one nobody does.
|
||
"""
|
||
return (a.get('category') or '') != (b.get('category') or '')
|
||
|
||
|
||
def _same_family_categories(know):
|
||
"""Categories in which two instances of ONE family may legitimately touch."""
|
||
raw = K.get(know, 'interference.same_family_ok_categories', []) or []
|
||
return [str(c).strip().upper() for c in raw if str(c).strip()]
|
||
|
||
|
||
def _same_family(a, b, categories):
|
||
"""Two instances of the same family, in a category where that is deliberate.
|
||
|
||
Some hardware is modelled as several instances of one family placed to
|
||
overlap ON PURPOSE, so that creating the assembly picks them up together.
|
||
That is a modelling technique, not a defect, and reporting it buries the
|
||
interferences that matter.
|
||
|
||
Deliberately narrow: same family AND same listed category. Two DIFFERENT
|
||
families overlapping is still reported, because the reason above does not
|
||
cover it -- if some of those are intentional too they go in
|
||
ignore_family_pairs, one named pair at a time, rather than being waved
|
||
through by a rule nobody checked.
|
||
"""
|
||
if not categories:
|
||
return False
|
||
if (a.get('family') or '') != (b.get('family') or ''):
|
||
return False
|
||
cat_a = (a.get('category') or '').upper()
|
||
return cat_a == (b.get('category') or '').upper() and cat_a in categories
|
||
|
||
|
||
# -------------------------------------------------------------- concrete
|
||
|
||
def check_release_ratio(t, intent, know, spec):
|
||
"""f'ci must be at least the required fraction of f'c."""
|
||
ratio = K.get(know, 'concrete.min_fci_ratio')
|
||
fc, fci = intent.get('fc'), intent.get('fci')
|
||
if not ratio or not fc or not fci:
|
||
return None # did not run: missing data, not "passed"
|
||
need = fc * ratio
|
||
if fci < need - 0.5:
|
||
return [Finding('FAIL', 'FCI-RATIO',
|
||
"f'ci {0} psi is below {1:.0%} of f'c ({2} psi required)"
|
||
.format(_fmt_num(fci), ratio, _fmt_num(need)))]
|
||
return [Finding('OK', 'FCI-RATIO',
|
||
"f'ci {0} psi meets {1:.0%} of f'c".format(_fmt_num(fci), ratio))]
|
||
|
||
|
||
# ------------------------------------------------------------ spec health
|
||
|
||
def check_spec_unresolved(t, intent, know, spec):
|
||
"""Surface anything the extractor refused to interpret.
|
||
|
||
Reported once per ticket so it cannot be scrolled past: a design summary
|
||
line nobody has classified is a hole in the check, not a clean result.
|
||
|
||
ITS SILENCES ARE NOT THE SAME SILENCE (2026-08-30). 8.1 got the
|
||
enumeration upgrade above but no skip probe -- it was the last row in the
|
||
table with none -- so every empty return landed in `run_all` as RAN-EMPTY
|
||
and the row read "NOT RUN -- no probe declared" on two live tickets: a
|
||
sentence about the tool, on a question that had in fact been answered for
|
||
most pieces. The two that matter are now told apart:
|
||
|
||
* no spec on disk for this design -- there is nothing whose unresolved
|
||
lines could be counted, and the piece was judged off the regex
|
||
summary instead. NOT RUN, with that reason, via the probe below.
|
||
* a spec that records zero unresolved lines -- the extractor read the
|
||
design summary and every line of it matched a rule. That is a
|
||
judged, clean result and it says so with an explicit OK, the same
|
||
way check_strand_debond now answers a piece with no through-thickness
|
||
opening rather than returning a reason for it.
|
||
|
||
The third is the one worth naming: a spec that carries no readable
|
||
`unresolved` field at all was written before the field existed or was
|
||
hand-edited out of shape, and "the extractor never wrote down what it
|
||
could not interpret" is not "the extractor interpreted everything". That
|
||
stays NOT RUN -- see _unresolved_readable.
|
||
|
||
WHICHEVER PARSER ACTUALLY RAN (2026-08-30, same day, after the live
|
||
post-restart run). Narrowing the check to spec-backed intent made "no
|
||
spec on disk" the answer on EVERY ticket, because no design has a spec
|
||
yet -- and the owner's earlier run had shown him the four lines of a
|
||
design summary nobody had classified. Two sources feed this ticket's
|
||
intent and both keep a list of what they could not place:
|
||
|
||
* a spec on disk -- `unresolved` as the extractor wrote it.
|
||
* the regex reader -- `ticketqc.parse_design_summary` now carries
|
||
`unresolved_in_view`'s list on the intent dict it returns, which is
|
||
the same {text, reason} shape.
|
||
|
||
So 8.1 reports from whichever one produced the intent, and NOT RUN is
|
||
reserved for the case it was always meant for: no record from either,
|
||
nothing to judge. The clean answer names its source, because "every
|
||
line was interpreted" is a much weaker claim from a reader whose rules
|
||
are a handful of regexes than from a reviewed spec.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
if spec is not None:
|
||
if not _unresolved_readable(spec):
|
||
return []
|
||
unresolved = spec.get('unresolved') or []
|
||
source = SPEC_SOURCE_SPEC
|
||
else:
|
||
unresolved = _intent_unresolved(intent)
|
||
if unresolved is None:
|
||
return []
|
||
source = _intent_source(intent)
|
||
# THE SUMMARY VOCABULARY RUNS HERE AND NOT IN THE EXTRACTOR, and that
|
||
# is deliberate. `spec['unresolved']` on disk is the extractor's own
|
||
# verbatim record of what ITS rules could not place, and it must keep
|
||
# saying exactly that -- an evidence file rewritten by a later reader's
|
||
# opinion is no longer evidence. So the grammar is applied at REPORT
|
||
# time, over whichever list this row was handed, and the split is:
|
||
#
|
||
# placed -- a line whose SHAPE the vocabulary knows. It is no longer
|
||
# uninterpreted, and 8.1 says what it was taken to be
|
||
# rather than going green in silence.
|
||
# unplaced -- exactly what 8.1 has always reported, verbatim.
|
||
#
|
||
# A LINE PLACED IS NOT A LINE JUDGED. Nothing here claims the piece
|
||
# honours a requirement; that is 5.9's job for the ones it can consume,
|
||
# and no row's job at all for the rest. `summary_requirement_inventory`
|
||
# keeps the conditionals and the un-valued spacings visible for exactly
|
||
# that reason.
|
||
placed, unresolved = TP.classify_summary_lines(unresolved)
|
||
inventory = TP.summary_requirement_inventory(placed)
|
||
if not unresolved:
|
||
# WHICH CLEAN ANSWER THIS IS, because there are two and they are not
|
||
# the same claim. "the extractor placed every line" is stronger than
|
||
# "the extractor could not place seven and this reader recognised
|
||
# all seven" -- the second is a vocabulary's reading and the reader
|
||
# is entitled to know that is what he is looking at.
|
||
if placed:
|
||
said = ('every line of the design summary is accounted for {0}: '
|
||
'the extractor placed all but {1} of them, and the '
|
||
'summary vocabulary categorised those'
|
||
.format(source, len(placed)))
|
||
else:
|
||
said = ('every line of the design summary was interpreted {0} '
|
||
'-- {1}'.format(source, _CLEAN_TAIL[source]))
|
||
if inventory:
|
||
said += '. ' + inventory
|
||
return [Finding('OK', 'SPEC-UNRESOLVED', said)]
|
||
# EVERY LINE, NOT THE FIRST THREE AND AN ELLIPSIS. The owner read a
|
||
# WP133 run on 2026-08-30 and asked the only question the old message
|
||
# invited -- "都哪些lines not interpreted?" -- because it printed
|
||
# "4 line(s) ... | ..." and named three of them. A count the reader
|
||
# cannot resolve into the actual lines is not information; it is a
|
||
# prompt to go and look somewhere the tool already looked. The page
|
||
# splits this back apart on SPEC_UNRESOLVED_SEP and prints one line per
|
||
# entry, in a detail block that opens itself.
|
||
shown = SPEC_UNRESOLVED_SEP.join(
|
||
[_unresolved_text(u) for u in unresolved])
|
||
said = ('{0} line(s) of the design summary were not interpreted: {1}'
|
||
.format(len(unresolved), shown))
|
||
if inventory:
|
||
said += '{0}{1}'.format(SPEC_UNRESOLVED_SEP, inventory)
|
||
return [Finding('WARN', 'SPEC-UNRESOLVED', said)]
|
||
|
||
|
||
# The separator the 8.1 message uses between the lines it enumerates. It is
|
||
# named rather than inlined because ticketpage splits on it to render one row
|
||
# per line, and a separator that two modules spell independently is a
|
||
# separator that will disagree.
|
||
SPEC_UNRESOLVED_SEP = ' | '
|
||
|
||
# Which parser is speaking. Only the clean answer prints it -- the WARN
|
||
# enumerates the lines themselves, and the lines are the evidence whoever
|
||
# read them. `checklist_details` partitions the message on the FIRST ': ',
|
||
# so nothing here may contain one.
|
||
SPEC_SOURCE_SPEC = '(spec)'
|
||
SPEC_SOURCE_REGEX = '(design summary, regex)'
|
||
|
||
_CLEAN_TAIL = {
|
||
SPEC_SOURCE_SPEC: 'the spec records no unresolved lines',
|
||
SPEC_SOURCE_REGEX: ('every note in the summary view matched a rule, '
|
||
'which is a narrower claim than a reviewed spec '
|
||
'makes'),
|
||
}
|
||
|
||
|
||
def _intent_source(intent):
|
||
"""Which parser built this intent, as the tag the message prints.
|
||
|
||
`spec_to_intent` stamps 'spec' and the regex branches of `ticketqc.run`
|
||
stamp 'regex'. Anything else came from a caller that did not say, and
|
||
the regex reader is the weaker of the two to claim -- so it is what an
|
||
unstamped intent is reported as.
|
||
"""
|
||
if (intent or {}).get('source') == 'spec':
|
||
return SPEC_SOURCE_SPEC
|
||
return SPEC_SOURCE_REGEX
|
||
|
||
|
||
def _intent_unresolved(intent):
|
||
"""The intent dict's own record of lines it could not place, or None.
|
||
|
||
None means NO RECORD -- the same distinction `_unresolved_readable`
|
||
draws for a spec, and for the same reason: an intent built before
|
||
`parse_design_summary` kept the list, or by a caller that assembled the
|
||
dict by hand, never wrote down what it could not interpret, and that is
|
||
not a design whose every line was interpreted.
|
||
"""
|
||
if not intent or 'unresolved' not in intent:
|
||
return None
|
||
value = intent.get('unresolved')
|
||
if value is None:
|
||
return [] # explicitly empty, not missing
|
||
if isinstance(value, (list, tuple)):
|
||
return list(value)
|
||
return None # some other shape: not countable
|
||
|
||
|
||
def _unresolved_readable(spec):
|
||
"""Does this spec actually RECORD what the extractor could not interpret?
|
||
|
||
`unresolved: []` (what the emitter writes, and what a bare `unresolved:`
|
||
a human cleared by hand loads back as) means the field is there and holds
|
||
nothing -- zero unresolved lines, a real answer. A spec with no
|
||
`unresolved` key at all, or one whose `unresolved` is some other shape
|
||
entirely, is a spec that cannot be counted: the old code would have
|
||
walked a stray string character by character and printed "3 line(s) ...
|
||
f | o | o".
|
||
"""
|
||
if 'unresolved' not in spec:
|
||
return False
|
||
value = spec.get('unresolved')
|
||
if value is None:
|
||
return True # explicitly emptied, not missing
|
||
return isinstance(value, (list, tuple))
|
||
|
||
|
||
def _unresolved_text(entry):
|
||
"""An unresolved entry is {text, reason}, or a bare string on older specs."""
|
||
if isinstance(entry, dict):
|
||
return str(entry.get('text') or entry)[:60]
|
||
return str(entry)[:60]
|
||
|
||
|
||
# ------------------------------------------------------------- BOM strands
|
||
|
||
def check_bom_strand_count(t, intent, know, spec):
|
||
"""Three-way agreement on strands: BOM linear-feet / length vs the
|
||
section A-A print vs the model.
|
||
|
||
This plant's tickets have no pattern legend; the schedule is the BOM
|
||
table, and its strand row is a LENGTH in feet (249 on a 24'-11" piece is
|
||
ten strands). The section print `(n) 0010001 (E.L.)` and the modelled
|
||
count must both agree with BOM / length.
|
||
|
||
The section view contents are only read when legend.section_strand_view
|
||
names one -- that is the expensive view-generation read -- so the check
|
||
works two-way (BOM vs model) without it and three-way with it.
|
||
|
||
No BOM, or a BOM we could not read, is NOT a finding here: the check
|
||
reports NOT RUN through its skip probe with the reason, and the two
|
||
states read differently. (Until 2026-08-30 the only thing that said
|
||
anything about a BOM-less ticket was `LGD-MISSING`, which no checklist
|
||
item owned and which called an unreadable schedule a defect.)
|
||
"""
|
||
from vibe import ticketpage
|
||
from vibe.ticketqc import section_strand_count
|
||
|
||
raw_bom = t.get('bom_schedules')
|
||
state, why = ticketpage.bom_state(raw_bom)
|
||
if state in (ticketpage.BOM_ABSENT, ticketpage.BOM_UNREADABLE,
|
||
ticketpage.BOM_UNKNOWN):
|
||
return [] # probe explains the NOT RUN
|
||
bom = raw_bom or {}
|
||
linear_ft = bom.get('strand_linear_ft')
|
||
bom_count = ticketpage.strand_count_from_linear_ft(
|
||
linear_ft, t.get('length'))
|
||
model_count = t.get('strand_qty')
|
||
section_count = None
|
||
sheets = t.get('sheets') or []
|
||
if sheets:
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
tdoc = None
|
||
if tdoc is not None:
|
||
try:
|
||
section_count = section_strand_count(tdoc, sheets, know)
|
||
except Exception:
|
||
section_count = None
|
||
|
||
agree, what = ticketpage.count_agrees(bom_count, model_count,
|
||
section_count)
|
||
if state == ticketpage.BOM_PARTIAL:
|
||
# It ran, but not on everything. Say which part we could not read
|
||
# rather than presenting a partial read as a whole one.
|
||
what = '{0} -- note: {1}'.format(what, why)
|
||
if agree:
|
||
return [Finding('OK', 'BOM-STRAND', what)]
|
||
return [Finding('FAIL', 'BOM-STRAND', what)]
|
||
|
||
|
||
# ------------------------------------------------------------- dunnage
|
||
|
||
def _dunnage_station_reader(t, tdoc):
|
||
"""(stations, reason) -- the dunnage support stations printed on this
|
||
ticket's sheet.
|
||
|
||
Factored out of `check_dunnage_openings` (6.4) 2026-09-02 so
|
||
`check_dunnage_stripping_match` (6.11) can count dunnage POINTS the
|
||
same way 6.4 locates them for its own opening-clearance judgement,
|
||
without a second reader that could quietly disagree with it. 6.4 only
|
||
reaches this once it already knows the piece has an opening for the
|
||
dunnage to matter against (geometry solved first -- see its own
|
||
docstring); 6.11 needs the raw count whether or not the piece has one,
|
||
so this reads only what a station count needs -- the sheet's printed
|
||
insets -- and asks nothing about the panel's geometry or openings.
|
||
|
||
Needs only `t['panel_id']` / `t['length']` (to gate, same wording 6.4's
|
||
own `_dunnage_silence` has always used for this) and `tdoc` -- the
|
||
document already resolved by the caller, not re-derived here, so a
|
||
caller that has already paid for that resolution never pays twice.
|
||
|
||
stations is None when the read failed; `reason` then names why, in the
|
||
SAME sentences 6.4 has always used for this half of the read. stations
|
||
is the (list, possibly of one, never empty once `inset_ft` is
|
||
non-empty -- see `ticketpage.dunnage_stations`) list of stations, in
|
||
feet from the piece's low end, when the read succeeded; reason is then
|
||
None.
|
||
"""
|
||
if not t.get('panel_id') or not t.get('length'):
|
||
return None, ('no panel or no piece length on this ticket -- the '
|
||
'dunnage stations could not be located along the '
|
||
'piece')
|
||
sheets = t.get('sheets') or []
|
||
if not sheets:
|
||
return None, ('this ticket has no sheet, so the printed dunnage '
|
||
'insets could not be read')
|
||
|
||
from vibe import ticketpage as TP
|
||
from Autodesk.Revit.DB import FilteredElementCollector, BuiltInCategory
|
||
|
||
# the dunnage row on the sheet: text notes in the DUNNAGE diagram's
|
||
# band, found by clustering size strings into the dia's y band is not
|
||
# reliable across tickets -- the row is identified by the diagram
|
||
# boundaries, which the notes do not name. Fallback: take the two
|
||
# size notes on sheet 1 that are NOT picking (the larger y band) and
|
||
# NOT erection; the dunnage row is the lowest size row. This is the
|
||
# measured shape on WP199/WP213.
|
||
notes = []
|
||
s0 = sheets[0]
|
||
for n in FilteredElementCollector(tdoc, s0.Id).OfCategory(
|
||
BuiltInCategory.OST_TextNotes).ToElements():
|
||
try:
|
||
txt = n.Text.replace('\r', '').strip()
|
||
if txt.endswith('"'):
|
||
notes.append({'y': round(n.Coord.Y, 3), 'x': round(n.Coord.X, 3),
|
||
'text': txt})
|
||
except Exception:
|
||
pass
|
||
notes.sort(key=lambda r: r['y'])
|
||
if not notes:
|
||
# NOT A PASS -- see check_dunnage_openings' own docstring for the
|
||
# trap this guards. Finding no size note on sheet 1 means the
|
||
# ticket's dunnage row was never read, not that there is nothing
|
||
# to report.
|
||
return None, (
|
||
'no size notes (text ending in a double-quote) found on sheet 1 '
|
||
'-- the printed dunnage insets could not be read, so no support '
|
||
'station was tested')
|
||
# lowest band = dunnage (measured: WP199 dunnage y~0.59, WP213 same
|
||
# shape); take the lowest two y values' notes.
|
||
lowest_y = notes[0]['y']
|
||
dunnage_notes = [n for n in notes if abs(n['y'] - lowest_y) < 0.05]
|
||
inset_ft = []
|
||
for n in sorted(dunnage_notes, key=lambda r: r['x']):
|
||
ft = TP.parse_feet_text(n['text'])
|
||
if ft is not None:
|
||
inset_ft.append(round(ft, 4))
|
||
if not inset_ft:
|
||
# ALSO NOT A PASS -- the row was found and none of it parsed into
|
||
# feet, which is a failure to read the ticket.
|
||
return None, (
|
||
'{0} size note(s) found in the lowest band of sheet 1 but none '
|
||
'parsed into feet, so no dunnage station could be located'
|
||
.format(len(dunnage_notes)))
|
||
|
||
return TP.dunnage_stations(inset_ft, t['length']), None
|
||
|
||
|
||
# Owner, verbatim (08-28): "这类问题全都走 warning 吧" -- everything of this
|
||
# kind goes out as WARN.
|
||
# Owner, verbatim (2026-08-31), reading a live report: "6.4 应该是 OK, 既然
|
||
# 没有 opening" -- 6.4 should be OK, since there is no opening.
|
||
def check_dunnage_openings(t, intent, know, spec):
|
||
"""Dunnage support points against the openings: the finding this whole
|
||
refactor exists for.
|
||
|
||
The ticket prints dunnage insets as TEXTNOTES on the sheet (not as
|
||
dimensions in the note views -- measured). The block sits at
|
||
inset-from-left and length - inset-from-right, in PRINTING ORDER.
|
||
What is under each block is asked of the geometry: an opening, or an
|
||
edge this close. Clearance thresholds are plant knowledge
|
||
(handling.dunnage.opening_clearance_fail_in / _warn_in).
|
||
|
||
William's ruling (08-28): every dunnage finding goes out as WARN,
|
||
INSIDE included -- "everything of this kind goes out as warning". The two numbers
|
||
therefore ONLY decide whether anything is reported, never the
|
||
severity:
|
||
|
||
>= 6" (warn_in) away -> silence
|
||
< 6" away -> WARN
|
||
inside an opening -> WARN
|
||
|
||
opening_clearance_fail_in is INERT as a severity knob (it can never
|
||
produce a FAIL); it is kept because it records the plant's grading
|
||
and no one should have to guess at the boundary. See the caller loop
|
||
below, where every Finding is built with severity 'WARN'.
|
||
|
||
THE OWNER'S CORRECTION (2026-08-31, reading a live report): "6.4 should
|
||
be OK, since there is no opening" -- a piece with zero openings settles this
|
||
row on its own, because dunnage cannot sit over an opening that does
|
||
not exist. Until this fix, the openings were only ever consulted
|
||
AFTER the printed dunnage insets had been read off the sheet, so a
|
||
piece whose size notes failed to parse rendered NOT RUN even when it
|
||
had no opening for the notes to matter against. The geometry is now
|
||
solved FIRST, before the sheet is even asked for: zero openings, once
|
||
the geometry has genuinely been read, is a judged OK and the dunnage
|
||
stations are never needed. This is the same move as 4.7's flip
|
||
(`_strand_debond_openings`) and 6.6's zero-is-even precedent -- zero,
|
||
MEASURED, is an answer, not a silence.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
from vibe import ticketpage as TP
|
||
from vibe import liftexport
|
||
from Autodesk.Revit.DB import (FilteredElementCollector,
|
||
BuiltInCategory, ElementId)
|
||
|
||
# EVERY silent return below leaves its reason here, and the skip probe
|
||
# reads it. Until 2026-08-30 this check had six `return []` paths and no
|
||
# probe at all, so 6.4 rendered NOT RUN with the reason "no probe
|
||
# declared" -- the row said nothing about the piece, only about the
|
||
# tool. Same breadcrumb shape as `_bed_reverse_skip` on 5.2.
|
||
t['_dunnage_silence'] = None
|
||
if not t.get('panel_id') or not t.get('length'):
|
||
t['_dunnage_silence'] = (
|
||
'no panel or no piece length on this ticket -- the dunnage '
|
||
'stations could not be located along the piece')
|
||
return []
|
||
fail_in = K.get(know, 'handling.dunnage.opening_clearance_fail_in', 3.0)
|
||
warn_in = K.get(know, 'handling.dunnage.opening_clearance_warn_in', 6.0)
|
||
|
||
# The document the ticket was read from is carried on the ticket
|
||
# (read_ticket hands it over). vibe.helpers.doc is a button-session
|
||
# global that is not valid on a detached/saved copy -- measured: on the
|
||
# detached model it throws 'referenced object is not valid', while the
|
||
# ticket's own doc works. Prefer t['doc'] when present. Needed here
|
||
# first, now that the geometry is read before the sheet is.
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'DUN-UNREADABLE',
|
||
'could not reach the active document for the '
|
||
'dunnage check')]
|
||
|
||
# geometry FIRST, before the sheet is ever asked for: solve once for
|
||
# this check (same cost discipline as the interference deferral --
|
||
# dunnage is why this check exists, so it pays for its own geometry
|
||
# when it runs), because zero openings can settle the whole row
|
||
# without the dunnage stations at all.
|
||
try:
|
||
panel = tdoc.GetElement(ElementId(t['panel_id']))
|
||
except Exception:
|
||
panel = None
|
||
if panel is None:
|
||
return [Finding('WARN', 'DUN-UNREADABLE',
|
||
'panel for the dunnage check is not readable')]
|
||
frame = Q._panel_frame(panel)
|
||
extents = Q.concrete_extents(panel, frame) if frame else None
|
||
if extents is None:
|
||
return [Finding('WARN', 'DUN-UNREADABLE',
|
||
'panel geometry for the dunnage check is not '
|
||
'solvable')]
|
||
lo = [v * 12.0 for v in extents[0]]
|
||
hi = [v * 12.0 for v in extents[1]]
|
||
L, W, T = liftexport._axes(lo, hi)
|
||
solid = liftexport.concrete_solid(panel)
|
||
if solid is None:
|
||
# OPENINGS UNGATHERED, and now told apart from openings MEASURED
|
||
# at zero. `liftexport._openings` hands back an empty list both
|
||
# for a solid with no opening and for a solid it was never given
|
||
# (its own guard: `if solid is None: return []`) -- so the
|
||
# distinction has to be made HERE, before that call, not read off
|
||
# its result. `extents` above already proves a large-enough solid
|
||
# exists: `Q.concrete_extents` and `liftexport.concrete_solid`
|
||
# walk the same panel geometry under the same >MIN_SOLID_FT3
|
||
# threshold, so a None here is that SECOND read coming back empty,
|
||
# not the first one being wrong -- a read failure, still reported
|
||
# as NOT RUN, never as a clear piece.
|
||
t['_dunnage_silence'] = (
|
||
'panel geometry solved but the panel solid could not be read a '
|
||
'second time for the openings -- which this check cannot treat '
|
||
'as a piece with no openings, so no claim is made about where '
|
||
'the dunnage lands')
|
||
return []
|
||
rects = []
|
||
for r in liftexport._openings(solid, frame, lo, hi, (L, W, T)):
|
||
x0, x1 = sorted([r[0], r[1]])
|
||
y0, y1 = sorted([r[2], r[3]])
|
||
rects.append((round(x0, 3), round(x1, 3),
|
||
round(y0, 3), round(y1, 3)))
|
||
if not rects:
|
||
# JUDGED, AND THE OWNER'S CORRECTION THIS IS FOR. The geometry WAS
|
||
# solved -- `solid` above is not None, so this is not the read
|
||
# failure handled above -- and it found zero openings. Dunnage
|
||
# cannot sit over an opening that does not exist, so the row is
|
||
# settled without ever reading the sheet or locating a station.
|
||
return [Finding('OK', 'DUN-OPENING',
|
||
'no openings on this piece -- dunnage cannot sit '
|
||
'over one; dunnage stations were not needed')]
|
||
|
||
# From here down the piece DOES have at least one opening, so the
|
||
# dunnage stations are needed after all and the sheet must be read --
|
||
# via the shared reader (`_dunnage_station_reader`) 6.11
|
||
# (check_dunnage_stripping_match) also calls, so the two checks can
|
||
# never quietly disagree about how many dunnage stations there are or
|
||
# why a ticket's row would not read. The reasons below are unchanged
|
||
# from before the 2026-09-02 extraction -- see that function for them
|
||
# (no sheet; no size notes on sheet 1, which is the trap the whole
|
||
# breadcrumb exists to avoid, not a pass; size notes found but none
|
||
# parsed into feet, also not a pass -- the openings are already known
|
||
# by now, so this NOT RUN is honest about a piece that DOES have one).
|
||
stations, reason = _dunnage_station_reader(t, tdoc)
|
||
if stations is None:
|
||
t['_dunnage_silence'] = reason
|
||
return []
|
||
|
||
# Which openings count at all? The dunnage judgement is torque, and
|
||
# torque only asks whether the void goes through the thickness. A
|
||
# through opening under a support is the finding; a blind recess is
|
||
# not. Per opening, answered by shooting a line along the thickness at
|
||
# the opening's centre (liftexport.opening_penetration). This is
|
||
# capability, not configuration -- plant knowledge says whether the
|
||
# through-thickness rule applies (opening_counts_only_if_through_
|
||
# thickness); the measurement itself is the code's job and always runs.
|
||
require_through = K.get(
|
||
know, 'handling.dunnage.opening_counts_only_if_through_thickness',
|
||
False)
|
||
penetrations = liftexport.opening_penetration(
|
||
panel, frame, solid, rects, (L, W, T), lo, hi)
|
||
if require_through:
|
||
relevant = [i for i, p in enumerate(penetrations)
|
||
if p['through']]
|
||
else:
|
||
relevant = list(range(len(rects)))
|
||
if not relevant:
|
||
# Reached only when the plant has set
|
||
# opening_counts_only_if_through_thickness and every opening on the
|
||
# piece turned out to be a blind recess. That is arguably an
|
||
# ANSWER -- the plant's own rule says a blind recess cannot matter
|
||
# under a block -- and a case can be made for an OK here. It is not
|
||
# taken without the owner: the hand-back's rule is that the
|
||
# fall-through below is the only pass, and a NOT RUN that
|
||
# under-claims is the safe direction. Named in the report so the
|
||
# owner can settle it.
|
||
t['_dunnage_silence'] = (
|
||
'{0} opening(s) on this piece, none of them through the '
|
||
'thickness -- the plant counts only through openings under '
|
||
'dunnage, so no support station was tested'.format(len(rects)))
|
||
return []
|
||
|
||
findings = []
|
||
for station in stations:
|
||
sev, name, dist_in = TP.dunnage_clearance(
|
||
station, rects, relevant, fail_in, warn_in)
|
||
if sev == 'OK':
|
||
continue
|
||
# William's ruling (08-28): these findings ALL go as warnings, no
|
||
# exceptions, INSIDE included -- "这类问题全都走 warning 吧".
|
||
# The 3"/6" grading still decides WHETHER anything is reported
|
||
# (>= 6" silent, < 6" WARN, inside WARN), it just does not decide
|
||
# the severity. opening_clearance_fail_in is therefore INERT: it
|
||
# exists to record William's grading and shape the boundary with
|
||
# warn_in, but it can never produce a FAIL. Keep the plant key so
|
||
# the intent is documented; changing the grading later means
|
||
# touching this line AND the knowledge comment, not guessing.
|
||
if name == 'INSIDE':
|
||
msg = ('dunnage support at {0} sits over an opening -- a block '
|
||
'on a hole leaves the piece only partly supported'
|
||
.format(Q.fmt_feet(station)))
|
||
else:
|
||
msg = ('dunnage support at {0} is {1} from {2}'
|
||
.format(Q.fmt_feet(station),
|
||
'{0}"'.format(dist_in), name))
|
||
findings.append(Finding('WARN', 'DUN-OPENING', msg))
|
||
if not findings:
|
||
# THE ONE PASS ON THIS CHECK, and it now says so out loud. Until
|
||
# 2026-08-30 a clear piece returned an empty list, which -- with no
|
||
# probe registered -- rendered as NOT RUN "no probe declared". The
|
||
# geometry WAS solved, every station WAS measured against every
|
||
# opening, and they all cleared: that is an answer, and the same
|
||
# reasoning as 4.7's flip. Every other silent path in this function
|
||
# is a failure to read something and keeps reporting NOT RUN.
|
||
return [Finding('OK', 'DUN-OPENING',
|
||
'every one of the {0} dunnage station(s) is at least '
|
||
'{1}" clear of all {2} relevant opening(s)'
|
||
.format(len(stations), _fmt_num(warn_in),
|
||
len(relevant)))]
|
||
return findings
|
||
|
||
|
||
# --------------------------------------------------------- strand debond
|
||
|
||
def _strand_debond_openings(t):
|
||
"""The through-thickness openings on this piece, for check_strand_debond.
|
||
|
||
Returns (rects, None) once it has actually looked -- rects may be []
|
||
when the piece has no opening, or when every opening is a blind
|
||
recess. Returns (None, reason) when the geometry itself could not be
|
||
solved at all (no readable panel, no frame, no solid extents), which
|
||
is a different failure from "looked, and there was nothing there".
|
||
Shared with the check's skip probe so both agree on why.
|
||
"""
|
||
from vibe import liftexport
|
||
from vibe import ticketqc as Q
|
||
from Autodesk.Revit.DB import ElementId
|
||
|
||
if not t.get('panel_id'):
|
||
return None, 'no panel on this ticket to measure openings against'
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return None, ('could not reach the active document for the '
|
||
'debond check')
|
||
try:
|
||
panel = tdoc.GetElement(ElementId(t['panel_id']))
|
||
except Exception:
|
||
panel = None
|
||
if panel is None:
|
||
return None, 'panel for the debond check is not readable'
|
||
frame = Q._panel_frame(panel)
|
||
extents = Q.concrete_extents(panel, frame) if frame else None
|
||
if extents is None:
|
||
return None, 'panel geometry for the debond check is not solvable'
|
||
lo = [v * 12.0 for v in extents[0]]
|
||
hi = [v * 12.0 for v in extents[1]]
|
||
L, W, T = liftexport._axes(lo, hi)
|
||
solid = liftexport.concrete_solid(panel)
|
||
rects = []
|
||
for r in liftexport._openings(solid, frame, lo, hi, (L, W, T)):
|
||
x0, x1 = sorted([r[0], r[1]])
|
||
y0, y1 = sorted([r[2], r[3]])
|
||
rects.append((round(x0, 3), round(x1, 3),
|
||
round(y0, 3), round(y1, 3)))
|
||
if not rects:
|
||
return [], None # no openings at all -- nothing applies
|
||
|
||
penetrations = liftexport.opening_penetration(
|
||
panel, frame, solid, rects, (L, W, T), lo, hi)
|
||
through = [rects[i] for i, p in enumerate(penetrations) if p['through']]
|
||
return through, None
|
||
|
||
|
||
def check_strand_debond(t, intent, know, spec):
|
||
"""Checklist 4.7: a strand crossing a short run of concrete must be
|
||
debonded.
|
||
|
||
Not the same question as 4.5 (checking the ticket calls out debonding
|
||
the DESIGN specified). This one derives from the geometry where
|
||
debonding is REQUIRED regardless of what the design says, so it can
|
||
catch a design that failed to specify it. The reasoning: an opening
|
||
leaves a strand nowhere to develop -- its transfer length exceeds the
|
||
concrete available, so it never reaches force -- and burning the
|
||
strands at release drives a shock transfer through a stretch too
|
||
short to take it, opening a flexural crack that bursting steel has no
|
||
detailing answer for.
|
||
|
||
The threshold (strand.debond_below_continuous_ft) is plant knowledge,
|
||
read fresh every call -- never hardcoded, never defaulted here. A
|
||
piece with no strand data, no length, or no way to solve its geometry
|
||
is reported through the skip probe as NOT RUN with the reason, never
|
||
as OK: 'ran clean' and 'could not look' must never look the same on
|
||
this checklist (Claude 08-30).
|
||
"""
|
||
req = _debond_requirement(t, know)
|
||
threshold_ft = req['threshold_ft']
|
||
if req['reason']:
|
||
return [] # probe explains the NOT RUN
|
||
if not req['has_openings']:
|
||
# JUDGED, AND THERE WAS NOTHING THERE. Until 2026-08-30 this was a
|
||
# NOT RUN, which broke the rule the whole checklist is built on in
|
||
# the other direction: NOT RUN means "could not judge", and here
|
||
# the geometry WAS solved and it found zero through-thickness
|
||
# openings. A piece that cannot interrupt a strand run does not
|
||
# need debonding, and saying so is a pass, not a silence. (The
|
||
# read-failure path above still reports NOT RUN, which is the
|
||
# distinction that matters.)
|
||
return [Finding('OK', 'STRAND-DEBOND',
|
||
'no opening interrupts any strand run -- nothing '
|
||
'requires debonding')]
|
||
runs = req['runs']
|
||
if not runs:
|
||
return [Finding('OK', 'STRAND-DEBOND',
|
||
'every strand crossing an opening still develops '
|
||
'over at least {0} ft of continuous concrete'
|
||
.format(_fmt_num(threshold_ft)))]
|
||
|
||
findings = []
|
||
for run in runs:
|
||
strands = ', '.join(['{0}"'.format(_fmt_num(s))
|
||
for s in run['strands_in']])
|
||
findings.append(Finding(
|
||
'WARN', 'STRAND-DEBOND',
|
||
'concrete runs only {0} ft ({1} to {2} ft along the piece) -- '
|
||
'shorter than the {3} ft a strand needs to develop -- the '
|
||
'strand(s) at {4} across the width must be debonded through '
|
||
'this run'
|
||
.format(_fmt_num(run['length_ft']), _fmt_num(run['x0']),
|
||
_fmt_num(run['x1']), _fmt_num(threshold_ft), strands)))
|
||
return findings
|
||
|
||
|
||
def _debond_requirement(t, know):
|
||
"""WHERE debonding is required on this piece -- item 4.7's answer, once.
|
||
|
||
The single derivation. 4.7 reports it, 4.5 consumes it, and both skip
|
||
probes ask it the same question, so there is exactly one place that
|
||
decides and no chance of the two items disagreeing about whether a run
|
||
is short. Cached on the ticket because it reads the panel's solid, and
|
||
four callers reading it four times is three too many.
|
||
|
||
Returns {'runs', 'reason', 'threshold_ft', 'has_openings'}:
|
||
|
||
reason set -- COULD NOT JUDGE, and the sentence says why.
|
||
runs is None. 4.5 stands down on this.
|
||
has_openings False -- judged: the piece has no through-thickness
|
||
opening, so nothing along a strand runs
|
||
short. runs is [].
|
||
runs [] -- judged: every strand's run is long enough.
|
||
runs [...] -- judged: these runs require debonding.
|
||
"""
|
||
cached = t.get('_debond_requirement')
|
||
if cached is not None:
|
||
return cached
|
||
from vibe import ticketpage as TP
|
||
|
||
out = {'runs': None, 'reason': None, 'has_openings': False,
|
||
'threshold_ft': K.get(know, 'strand.debond_below_continuous_ft')}
|
||
layout = t.get('strand_layout') or {}
|
||
positions_in = layout.get('across') or []
|
||
length_ft = t.get('length')
|
||
if not out['threshold_ft']:
|
||
out['reason'] = ('no strand.debond_below_continuous_ft in plant '
|
||
'knowledge -- short-run debonding could not be '
|
||
'judged')
|
||
elif not positions_in:
|
||
out['reason'] = ('no strand positions on this piece -- crossing runs '
|
||
'could not be measured')
|
||
elif not length_ft:
|
||
out['reason'] = ('no piece length -- runs along the strand could not '
|
||
'be measured')
|
||
else:
|
||
through, reason = _strand_debond_openings(t)
|
||
if reason:
|
||
out['reason'] = reason
|
||
elif not through:
|
||
out['runs'] = []
|
||
else:
|
||
out['has_openings'] = True
|
||
out['runs'] = TP.strand_debond_runs(
|
||
length_ft, through, positions_in, out['threshold_ft']) or []
|
||
t['_debond_requirement'] = out
|
||
return out
|
||
|
||
|
||
def check_debond_callout(t, intent, know, spec):
|
||
"""Checklist 4.5: the ticket calls out the debonding 4.7 requires.
|
||
|
||
Owner's ruling: where the debond condition is met, expect a call-out;
|
||
where there is none, WARN. So this check has exactly two inputs and
|
||
neither of them is geometry:
|
||
|
||
* 4.7's requirement, via `_debond_requirement` -- the SAME derivation
|
||
item 4.7 reports, never a second one. When 4.7 could not judge,
|
||
4.5 returns nothing and its probe repeats 4.7's own reason: a WARN
|
||
about a requirement nobody established would be inventing it.
|
||
* what the ticket says, via `_ticket_debond_callouts` -- the plant's
|
||
own call-out wording (`strand.debond_callout_tokens`) looked for in
|
||
the ticket's BOM rows and its placed views' text. The wording is
|
||
nomenclature and is never hardcoded here; with none declared the
|
||
check reports NOT RUN with that reason rather than passing a ticket
|
||
whose language it does not speak.
|
||
|
||
The verdict itself is ticketpage.debond_callout_status, which also
|
||
carries why a present call-out only shortlists (it names no station).
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
tokens = _debond_callout_tokens(know)
|
||
if not tokens:
|
||
return [] # probe explains the NOT RUN
|
||
req = _debond_requirement(t, know)
|
||
if req['reason']:
|
||
return [] # probe repeats 4.7's reason
|
||
callouts = _ticket_debond_callouts(t, tokens)
|
||
if callouts is None:
|
||
return [] # probe explains: ticket unreadable
|
||
verdict = TP.debond_callout_status(req['runs'], callouts,
|
||
req['threshold_ft'])
|
||
if verdict is None:
|
||
return []
|
||
return [Finding(verdict[0], verdict[1], verdict[2])]
|
||
|
||
|
||
def _debond_callout_tokens(know):
|
||
"""The words this plant prints when a strand is debonded.
|
||
|
||
Plant nomenclature, so it is a knowledge key with an EMPTY default and
|
||
no literal anywhere in this file. Measured on this job: the BED PLAN's
|
||
text carries a bare two-word note among the embed callouts, and the
|
||
manufacturer's own standard has a note sheet whose entire content is a
|
||
longer form of the same words -- so it is a list of alternatives,
|
||
matched as an upper-case substring.
|
||
"""
|
||
raw = K.get(know, 'strand.debond_callout_tokens', []) or []
|
||
if isinstance(raw, string_types):
|
||
raw = [raw]
|
||
return [text_type(tok).strip().upper() for tok in raw
|
||
if text_type(tok).strip()]
|
||
|
||
|
||
def _ticket_debond_callouts(t, tokens):
|
||
"""Where this ticket says 'debonded'. None means the ticket was unreadable.
|
||
|
||
None vs [] is the whole discipline here: [] asserts the ticket is
|
||
SILENT, which is what 4.5 warns on, and it may only be returned once
|
||
the ticket has actually been read. If no document is reachable the
|
||
answer is None and the row reports NOT RUN.
|
||
"""
|
||
text_by_view = _ticket_view_text(t)
|
||
if text_by_view is None:
|
||
return None
|
||
out = []
|
||
for where in sorted(text_by_view):
|
||
blob = (text_by_view[where] or '').upper()
|
||
for tok in tokens:
|
||
if tok in blob:
|
||
out.append({'where': where, 'text': _snippet(blob, tok),
|
||
'token': tok})
|
||
break
|
||
for row in ((t.get('bom_schedules') or {}).get('parts') or []):
|
||
blob = ' '.join([text_type(c) for c in row]).upper()
|
||
for tok in tokens:
|
||
if tok in blob:
|
||
out.append({'where': 'BOM row', 'text': blob.strip(),
|
||
'token': tok})
|
||
break
|
||
return out
|
||
|
||
|
||
def _snippet(blob, token, width=40):
|
||
"""The matched words with a little of what sits either side of them.
|
||
|
||
A finding has to name WHAT and WHERE, and the token alone names only
|
||
what we searched for -- the surrounding words are what a person needs
|
||
to find the note on the sheet.
|
||
"""
|
||
at = blob.find(token)
|
||
if at < 0:
|
||
return token
|
||
start = max(0, at - width // 2)
|
||
end = min(len(blob), at + len(token) + width // 2)
|
||
return ('...' if start else '') + blob[start:end] + \
|
||
('...' if end < len(blob) else '')
|
||
|
||
|
||
def _ticket_view_text(t):
|
||
"""{view name: its flat text} for every view placed on this ticket.
|
||
|
||
None when no document is reachable -- distinct from a ticket whose
|
||
views carry no text, which is an empty-valued dict.
|
||
|
||
Cached on the ticket: asking a view for its contents makes Revit
|
||
generate a view nobody has opened, and two checklist items (2.4 and
|
||
4.5) both need the ticket's words. One gather, shared. Views whose
|
||
text cannot be read are recorded as empty rather than dropped, so a
|
||
caller can never mistake an unread view for a silent one -- which is
|
||
why 4.5 also asks _ticket_view_text for None before asserting silence.
|
||
"""
|
||
cached = t.get('_view_text')
|
||
if cached is not None:
|
||
return cached
|
||
from vibe import ticketqc as Q
|
||
tdoc = _ticket_doc(t)
|
||
if tdoc is None:
|
||
return None
|
||
out = {}
|
||
for view in (_placed_views(t) or []):
|
||
try:
|
||
name = view.Name
|
||
except Exception:
|
||
continue
|
||
try:
|
||
out[name] = Q._flat_text(tdoc, view.Id)
|
||
except Exception:
|
||
out[name] = ''
|
||
t['_view_text'] = out
|
||
return out
|
||
|
||
|
||
# ------------------------------------------------------------- weight
|
||
|
||
def check_weight_three_way(t, intent, know, spec):
|
||
"""Checklist 2.2: printed, reconstructed and measured weight.
|
||
|
||
The three numbers, and why three rather than two: two agreeing proves
|
||
nothing about which is right, while the PATTERN of a three-way
|
||
disagreement says where to look --
|
||
|
||
printed the title block's WEIGHT field (plant-mapped role
|
||
`sheet.weight`; the parameter name is plant knowledge)
|
||
reconstructed length x width x depth less the blockouts plus the
|
||
cast extras, x unit weight -- the piece as the drawing
|
||
describes it, and OUR reading of it
|
||
measured the modelled concrete solid (plus the cast extras)
|
||
x unit weight -- the piece as it was actually drawn
|
||
|
||
ticketpage.weight_agreement decides; it names the odd one out, and when
|
||
only two of the three can be read it says so instead of reporting a
|
||
two-way as a clean three-way.
|
||
|
||
A fourth number, the panel's own stored weight parameter (t['weight_lb']),
|
||
is not evidence about the piece -- somebody wrote it down -- but it is
|
||
what the handling weight gate reads, so a stale one is reported on its
|
||
own code (WGT-PARAM) and only when it disagrees.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
src = _weight_sources(t, know)
|
||
if src['why'].get('measured') == TP.expensive_geometry_off_reason():
|
||
# The run-time switch is off, so two of the three weights were never
|
||
# read. Return nothing at all and let the probe report NOT RUN
|
||
# naming the switch: anything emitted here would grade item 2.2 (it
|
||
# owns the whole `WGT-` prefix) and hide the fact that the item did
|
||
# not run. An OFF state has to be visible and attributable to the
|
||
# switch, never a quiet pass and never a cheaper comparison
|
||
# presented as the real three-way.
|
||
return []
|
||
tolerance = K.get(know, 'handling.weight_tolerance', 0.02)
|
||
out = []
|
||
verdict = TP.weight_agreement(src['printed'], src['measured'],
|
||
src['reconstructed'], tolerance,
|
||
src['why'])
|
||
if verdict is not None:
|
||
out.append(Finding(verdict[0], verdict[1], verdict[2]))
|
||
note = TP.weight_param_note(src['stated'], src['measured'],
|
||
src['printed'], tolerance)
|
||
if note is not None:
|
||
out.append(Finding(note[0], note[1], note[2]))
|
||
return out
|
||
|
||
|
||
def _weight_sources(t, know):
|
||
"""The three weights 2.2 compares, and why any of them is missing.
|
||
|
||
Returns {'printed', 'reconstructed', 'measured', 'stated', 'why'} in
|
||
pounds, where 'why' maps a missing role to the sentence that says why
|
||
-- "could not judge" has to be sayable, not inferred from a blank.
|
||
|
||
Cached on the ticket: reading the panel's solid is not free, and the
|
||
skip probe asks exactly the same question the check does.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
cached = t.get('_weight_sources')
|
||
if cached is not None:
|
||
return cached
|
||
out = {'printed': None, 'reconstructed': None, 'measured': None,
|
||
'stated': t.get('weight_lb'), 'why': {}}
|
||
raw = _sheet_param_value(t, know, 'sheet.weight')
|
||
out['printed'] = TP.weight_lb(raw)
|
||
if raw is None:
|
||
out['why']['printed'] = (
|
||
'the plant maps no sheet.weight role, so the printed weight '
|
||
'cannot be read off the title block')
|
||
elif out['printed'] is None:
|
||
out['why']['printed'] = (
|
||
'the title block\'s weight field reads "{0}", which is not a '
|
||
'number'.format(text_type(raw)[:40]))
|
||
reason = _weight_geometry(t, know, out)
|
||
if reason:
|
||
out['why'].setdefault('measured', reason)
|
||
out['why'].setdefault('reconstructed', reason)
|
||
t['_weight_sources'] = out
|
||
return out
|
||
|
||
|
||
def _weight_geometry(t, know, out):
|
||
"""Fill in the two geometry-derived weights. Returns why not, or None.
|
||
|
||
One geometry read per ticket (`liftexport.weight_volumes`), which is the
|
||
price of item 2.2 being a three-way at all; the erected orientation is
|
||
not solved, because which way a piece stands up does not change what it
|
||
weighs.
|
||
|
||
That read is the whole reason the `expensive_geometry` run-time option
|
||
exists (default ON). When it is off, the reason returned is the switch
|
||
itself -- said in those words -- so the report cannot confuse "we chose
|
||
not to spend the minute" with "the input was missing".
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
if not _expensive_geometry_on(t):
|
||
return TP.expensive_geometry_off_reason()
|
||
pcf = t.get('pcf')
|
||
if not pcf:
|
||
return ('the panel has no unit-weight parameter, so a volume '
|
||
'cannot be turned into pounds')
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return 'no usable Revit document to read the geometry from'
|
||
if not t.get('panel_id'):
|
||
return ('this assembly has no structural framing member, so there '
|
||
'is no concrete solid to weigh')
|
||
from Autodesk.Revit.DB import ElementId
|
||
from vibe import liftexport
|
||
panel = tdoc.GetElement(ElementId(t['panel_id']))
|
||
asm = (tdoc.GetElement(ElementId(t['asm_id']))
|
||
if t.get('asm_id') else None)
|
||
try:
|
||
vols = liftexport.weight_volumes(tdoc, asm, panel, know)
|
||
except Exception as ex:
|
||
return ('the panel geometry could not be read -- {0}: {1}'
|
||
.format(type(ex).__name__, str(ex)[:120]))
|
||
if vols.get('reason'):
|
||
return vols['reason']
|
||
out['measured'] = vols['solid_ft3'] * pcf
|
||
out['reconstructed'] = vols['reconstructed_ft3'] * pcf
|
||
return None
|
||
|
||
|
||
# Owner, verbatim (2026-08-30): "可以做成开关,但是默认是开的" -- it can be
|
||
# made a toggle, but it defaults to ON.
|
||
def _expensive_geometry_on(t):
|
||
"""Whether this run is willing to read the panel's concrete solid.
|
||
|
||
The run-time option lives at `<qc root>/run-options.yaml`
|
||
(`configmodel.load_run_options`), NOT in the knowledge layers: whether a
|
||
run will spend a minute a piece is a property of the run, not of the
|
||
plant. Will, 2026-08-30: "it can be made a toggle, but it defaults to
|
||
ON".
|
||
|
||
DEFAULT ON, and every failure to answer the question defaults ON too --
|
||
an unreadable options file must never turn a check off silently. The
|
||
answer is cached per ticket so a sweep does not re-read the file for
|
||
every check that asks.
|
||
"""
|
||
if '_expensive_geometry' in t:
|
||
return t['_expensive_geometry']
|
||
on = True
|
||
try:
|
||
from vibe import configmodel
|
||
from vibe import designspec
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
on = bool(configmodel.load_run_options(
|
||
designspec.root_dir(tdoc))['expensive_geometry'])
|
||
except Exception:
|
||
on = True
|
||
t['_expensive_geometry'] = on
|
||
return on
|
||
|
||
|
||
def _sheet_param_value(t, know, role):
|
||
"""A sheet role's value from the ticket's sheets, first non-empty."""
|
||
from vibe.ticketqc import rstr
|
||
for s in (t.get('sheets') or []):
|
||
v = rstr(s, know, role)
|
||
if v not in (None, ''):
|
||
return v
|
||
return None
|
||
|
||
|
||
# ------------------------------------------------------------- picking
|
||
|
||
def check_picking_match(t, intent, know, spec):
|
||
"""The ticket's PICKING stations against the modelled picking inserts.
|
||
|
||
The PICKING diagram prints segment lengths from each end with a blank
|
||
in the middle (WP412: 2'-6\" 7'-6\" [blank] 7'-6\" 2'-6\" on 24'-11\");
|
||
the crew locates the points from the ends. The recovered stations must
|
||
match the model's picking inserts (handling.lifter_groups.face.families
|
||
-- P136) to within a tolerance. Any printed station without a modelled
|
||
insert is a mismatch the engineer should see; the ranking is WARN, the
|
||
engineer judges (Will 08-27: dunnage-style findings are never the
|
||
tool's verdict).
|
||
|
||
Reads the top TextNote band on the ticket sheet as the PICKING row
|
||
(measured: picking prints at the highest y of the size notes, dunnage
|
||
at the lowest). Model stations come from t['lifters'] restricted to
|
||
the face group's families -- P76 erection inserts are excluded
|
||
(measured: on WP199 they sit 0.25 ft from the picking inserts and
|
||
would otherwise fake a match).
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
from vibe import ticketqc as Q
|
||
from Autodesk.Revit.DB import (FilteredElementCollector,
|
||
BuiltInCategory)
|
||
|
||
if not t.get('sheets') or not t.get('length'):
|
||
return []
|
||
groups = K.get(know, 'handling.lifter_groups', {}) or {}
|
||
face_fams = list((groups.get('face') or {}).get('families') or [])
|
||
if not face_fams:
|
||
return [] # no picking families known -- silent
|
||
tol = K.get(know, 'handling.picking_tolerance_ft', 1.0 / 12.0)
|
||
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'PICK-UNREADABLE',
|
||
'could not reach a usable document to check '
|
||
'the picking stations')]
|
||
s0 = t['sheets'][0]
|
||
notes = []
|
||
for n in FilteredElementCollector(tdoc, s0.Id).OfCategory(
|
||
BuiltInCategory.OST_TextNotes).ToElements():
|
||
try:
|
||
txt = n.Text.replace('\r', '').strip()
|
||
if txt.endswith('"'):
|
||
notes.append({'y': round(n.Coord.Y, 3),
|
||
'x': round(n.Coord.X, 3), 'text': txt})
|
||
except Exception:
|
||
pass
|
||
notes.sort(key=lambda r: r['y'])
|
||
if not notes:
|
||
return []
|
||
top_y = notes[-1]['y']
|
||
picking_notes = [n for n in notes if abs(n['y'] - top_y) < 0.05]
|
||
sized = [(n['x'], TP.parse_feet_text(n['text']))
|
||
for n in sorted(picking_notes, key=lambda r: r['x'])]
|
||
sized = [(x, v) for (x, v) in sized if v is not None]
|
||
if not sized:
|
||
return []
|
||
# The top band can carry the erection value too -- measured three
|
||
# times (WP195/WP210/WP219): the erection note sits at the right edge
|
||
# of the band, a clean x-gap past the picking chain (x ~1.35 vs ~1.18),
|
||
# because the erection diagram is drawn one column right of picking.
|
||
# Drop the rightmost note when its x is a clear gap past the rest.
|
||
xs = [x for (x, _v) in sized]
|
||
if len(xs) >= 3 and (max(xs) - sorted(xs)[-2]) > 0.05:
|
||
sized = sized[:-1]
|
||
printed_ft = [v for (_x, v) in sized]
|
||
if len(printed_ft) < 2:
|
||
return []
|
||
printed = TP.stations_from_ends(t['length'], printed_ft)
|
||
|
||
# modelled stations: only the picking families (face group)
|
||
model = sorted(set([
|
||
round(t['length'] / 2.0 + l['local'][0], 4)
|
||
for l in (t.get('lifters') or [])
|
||
if l.get('local') and l.get('family') in face_fams]))
|
||
matched, unmatched = TP.pick_stations_match(printed, model, tol)
|
||
if not unmatched:
|
||
return [Finding('OK', 'PICK-STATIONS',
|
||
'{0} picking station(s) match the modelled '
|
||
'inserts'.format(len(matched)))]
|
||
return [Finding(
|
||
'WARN', 'PICK-STATIONS',
|
||
'ticket prints picking station(s) {0} with no modelled insert '
|
||
'within {1}" -- engineer to judge'
|
||
.format(', '.join(Q.fmt_feet(s) for s in unmatched),
|
||
round(tol * 12.0, 1)))]
|
||
|
||
|
||
# -------------------------------------------------------- 2.4 cast extras
|
||
|
||
def _cast_include_tokens(know):
|
||
"""The plant's words for cast concrete that is its own family.
|
||
|
||
`families.cast_include` -- the SAME key liftexport.extra_cast_ft3 reads
|
||
to decide what goes into 2.2's reconstructed weight. Read here only to
|
||
find those words on the ticket; nothing about which families count is
|
||
decided in this file.
|
||
"""
|
||
raw = K.get(know, 'families.cast_include', []) or []
|
||
if isinstance(raw, string_types):
|
||
raw = [raw]
|
||
return [text_type(tok).strip().upper() for tok in raw
|
||
if text_type(tok).strip()]
|
||
|
||
|
||
def check_cast_extras(t, intent, know, spec):
|
||
"""Checklist 2.4: the cast extras the ticket draws are the ones counted.
|
||
|
||
2.2 compares three weights and they can all agree while all three are
|
||
wrong the same way -- a ledge left out of the model AND off the drawing
|
||
agrees with itself, and 2.2 stays quiet. 2.4 asks about the SET, and
|
||
that is why it is a separate item rather than another weight.
|
||
|
||
THE TWO SIDES, and how they are told apart:
|
||
|
||
counted -- `liftexport.extra_cast_members(doc, asm, know)`: the very
|
||
members `extra_cast_ft3` sums, with the Volume it read out
|
||
of each. Not a re-implementation of the rule -- the same
|
||
function, so what 2.4 checks cannot drift from what 2.2
|
||
added. A member with no readable Volume added NOTHING and
|
||
is reported: drawn, and not counted.
|
||
|
||
drawn -- the ticket's own words: a BOM row, or the text on a placed
|
||
view, naming one of those same `families.cast_include`
|
||
tokens. The ledge IS drawn as geometry and no query answers
|
||
"does this elevation show a ledge", so text is the proxy
|
||
and the finding never claims the drawing was examined.
|
||
|
||
The five BOM states are respected, not collapsed (ticketpage.bom_state):
|
||
OK and PARTIAL are judged (PARTIAL names what failed); ABSENT is judged
|
||
from view text and says so; UNREADABLE and UNKNOWN mean a BOM row naming
|
||
the extra may exist and we missed it, so "the ticket does not name it"
|
||
is not assertable and the comparison stands down -- while a member whose
|
||
volume could not be read is still reported, because that finding needs
|
||
no ticket at all.
|
||
"""
|
||
from vibe import liftexport
|
||
from vibe import ticketpage as TP
|
||
from Autodesk.Revit.DB import ElementId
|
||
|
||
t['_cast_extra_skip'] = None
|
||
tokens = _cast_include_tokens(know)
|
||
if not tokens or not t.get('asm_id'):
|
||
return [] # probe explains the NOT RUN
|
||
tdoc = _ticket_doc(t)
|
||
if tdoc is None:
|
||
return []
|
||
try:
|
||
asm = tdoc.GetElement(ElementId(t['asm_id']))
|
||
except Exception:
|
||
asm = None
|
||
if asm is None:
|
||
# Every empty return has to leave a reason: a probe IS registered
|
||
# for this check, and a probe that returns None turns silence into
|
||
# an OK row.
|
||
t['_cast_extra_skip'] = ('this ticket\'s assembly is not readable, '
|
||
'so the cast extras in its weight could '
|
||
'not be listed')
|
||
return []
|
||
counted = liftexport.extra_cast_members(tdoc, asm, know)
|
||
|
||
text_by_view = _ticket_view_text(t)
|
||
if text_by_view is None:
|
||
return []
|
||
mentions = []
|
||
for where in sorted(text_by_view):
|
||
blob = (text_by_view[where] or '').upper()
|
||
for tok in tokens:
|
||
if tok in blob:
|
||
mentions.append({'where': where, 'text': _snippet(blob, tok),
|
||
'token': tok})
|
||
for row in ((t.get('bom_schedules') or {}).get('parts') or []):
|
||
blob = ' '.join([text_type(c) for c in row]).upper()
|
||
for tok in tokens:
|
||
if tok in blob:
|
||
mentions.append({'where': 'BOM row', 'text': blob.strip(),
|
||
'token': tok})
|
||
|
||
verdicts = TP.cast_extra_agreement(
|
||
counted, mentions, TP.bom_state(t.get('bom_schedules')), tokens)
|
||
if verdicts is None:
|
||
return [] # probe explains the NOT RUN
|
||
return [Finding(v[0], v[1], v[2]) for v in verdicts]
|
||
|
||
|
||
# ------------------------------------------------------------- BOM members
|
||
|
||
def check_bom_members(t, intent, know, spec):
|
||
"""BOM rows that name a mark against the assembly's members.
|
||
|
||
The BOM's first rows carry named, described items (P106, P114, SP29,
|
||
SP30...) -- the shipped parts. Rows with an empty mark are loose
|
||
hardware (studs, nuts, weld inserts) that the model does not name the
|
||
same way; they are not compared. Neither are the model's own extras
|
||
that never appear on a BOM: strand bundles, lifting hardware (P136/
|
||
P76), and rebars nested inside their parent (SR5602 inside an anchor,
|
||
SR4996 covered by SP29/SP30 per drawings.annotation_covered_by_parent)
|
||
-- a BOM that does not list them is not an error.
|
||
|
||
Every BOM row WITH a mark must have that many top-level members under
|
||
that family name. A count that disagrees is a real discrepancy: the
|
||
ticket promised parts the model does not carry (or vice versa).
|
||
Nested children are skipped (SuperComponent is not None), so a part
|
||
embedded inside another is not double counted -- measured: SR5602
|
||
reads as its own member in some assemblies, and the BOM does not list
|
||
it, which is the covered-by-parent case, not a defect.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
from vibe import ticketpage
|
||
from Autodesk.Revit.DB import ElementId
|
||
|
||
if not t.get('asm_id'):
|
||
return [] # probe explains the NOT RUN
|
||
|
||
# No BOM, or one we could not read, is NOT a silent [] -- the probe
|
||
# below reports NOT RUN with the reason, and an absent BOM reads
|
||
# differently from an unreadable one. See ticketpage.bom_state.
|
||
raw_bom = t.get('bom_schedules')
|
||
state, why = ticketpage.bom_state(raw_bom)
|
||
if state in (ticketpage.BOM_ABSENT, ticketpage.BOM_UNREADABLE,
|
||
ticketpage.BOM_UNKNOWN):
|
||
return [] # probe explains the NOT RUN
|
||
parts = (raw_bom or {}).get('parts') or []
|
||
named = [p for p in parts
|
||
if len(p) >= 2 and str(p[1]).strip() and str(p[0]).strip()]
|
||
partial = ('' if state != ticketpage.BOM_PARTIAL
|
||
else ' -- note: {0}'.format(why))
|
||
if not named:
|
||
# The BOM was read and carries no row that names a mark: loose
|
||
# hardware only. Judged, and found nothing to compare -- which is
|
||
# not the same as not having run, so it says so out loud.
|
||
return [Finding('OK', 'BOM-MEMBERS',
|
||
'the BOM carries {0} row(s) but none of them names a '
|
||
'mark (loose hardware only), so there is nothing to '
|
||
'compare against the assembly members{1}'
|
||
.format(len(parts), partial))]
|
||
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'BOM-MEMBERS',
|
||
'could not reach a usable document for the '
|
||
'BOM-vs-members check')]
|
||
try:
|
||
asm = tdoc.GetElement(ElementId(t['asm_id']))
|
||
except Exception:
|
||
asm = None
|
||
if asm is None:
|
||
return [Finding('WARN', 'BOM-MEMBERS',
|
||
'assembly not readable -- BOM cannot be compared '
|
||
'against members')]
|
||
|
||
# count members by family name. Every element GetMemberIds returns
|
||
# IS a direct member of this assembly; SuperComponent is the element
|
||
# having ITS OWN nested children, not a marker of being nested itself
|
||
# -- measured: P106/SP29/SP30 all carry SuperComponent and are exactly
|
||
# the parts the BOM invoices. Skipping them emptied the counts.
|
||
counts = {}
|
||
for mid in asm.GetMemberIds():
|
||
el = tdoc.GetElement(mid)
|
||
try:
|
||
fn = el.Symbol.Family.Name
|
||
except Exception:
|
||
try:
|
||
fn = el.GetType().FamilyName
|
||
except Exception:
|
||
continue
|
||
counts[fn] = counts.get(fn, 0) + 1
|
||
|
||
findings = []
|
||
for row in named:
|
||
qty_s, mark = row[0], str(row[1]).strip()
|
||
try:
|
||
qty = int(str(qty_s).replace(',', '').strip())
|
||
except ValueError:
|
||
continue
|
||
have = counts.get(mark)
|
||
if have is None or have != qty:
|
||
findings.append(Finding(
|
||
'WARN', 'BOM-MEMBERS',
|
||
'BOM lists {0} of {1} but the assembly carries {2} -- '
|
||
'the model does not match the bill of materials'
|
||
.format(qty, mark,
|
||
have if have is not None else 'none')))
|
||
if not findings:
|
||
return [Finding('OK', 'BOM-MEMBERS',
|
||
'{0} named BOM item(s) match the assembly members{1}'
|
||
.format(len(named), partial))]
|
||
return findings
|
||
|
||
|
||
BED_PLAN_KEY = 'drawings.bed_plan'
|
||
|
||
BED_PLAN_UNSET = (
|
||
'no {0} in plant knowledge -- which view on a ticket IS the bed plan is '
|
||
'this plant naming its own drawing, and the tool will not guess at an '
|
||
'English view name. Name it in the knowledge file and this row runs'
|
||
.format(BED_PLAN_KEY))
|
||
|
||
|
||
def _bed_plan_name(know):
|
||
"""(the plant's bed-plan view name, reason it is unknown).
|
||
|
||
ONE resolver, added 2026-08-31 (triage B1). `_bed_plan_text` read
|
||
`drawings.bed_plan` while `check_bed_plan_reverse` -- in this same file,
|
||
ten screens down -- compared against the literal `'Elevation Top'`. A
|
||
plant name baked into code is wrong twice over: it is client-shaped
|
||
information in the repository, and it silently works for exactly one
|
||
plant and silently fails for every other, which is the worse half.
|
||
|
||
THERE IS NO LITERAL FALLBACK ANY MORE, in either reader. A fallback to
|
||
the name this tool shipped with is a guess about the plant dressed as a
|
||
default, and the row it feeds would report on a view nobody chose.
|
||
Unknown stands down with the sentence above.
|
||
"""
|
||
name = (K.get(know, BED_PLAN_KEY, '') or '')
|
||
name = str(name).strip()
|
||
if not name:
|
||
return None, BED_PLAN_UNSET
|
||
return name, None
|
||
|
||
|
||
def _bed_plan_text(t, know):
|
||
"""The BED PLAN view's flat text on this ticket, or None.
|
||
|
||
None is could-not-read -- no reachable document, no view by the plant's
|
||
name on the sheets, or no name declared at all (`_bed_plan_name`).
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
tdoc = _ticket_doc(t)
|
||
if tdoc is None:
|
||
return None
|
||
bed_name, _why = _bed_plan_name(know)
|
||
if not bed_name:
|
||
return None
|
||
for s in (t.get('sheets') or []):
|
||
try:
|
||
ids = s.GetAllPlacedViews()
|
||
except Exception:
|
||
continue
|
||
for vp in ids:
|
||
v = tdoc.GetElement(vp)
|
||
try:
|
||
nm = v.Name if v is not None else None
|
||
except Exception:
|
||
nm = None
|
||
if nm and nm == bed_name:
|
||
return Q._flat_text(tdoc, v.Id)
|
||
return None
|
||
|
||
|
||
def check_detail_callouts(t, intent, know, spec):
|
||
"""Checklist 5.1, the detail-sheet half -- as far as it goes today.
|
||
|
||
5.1 asks whether every embed the BED PLAN calls out exists in the
|
||
model. Not every call-out on the plan is a part: this plant collects
|
||
its typical sections onto DETAIL SHEETS and the plan cites one as a
|
||
bubble (a detail number over a sheet name, `06/X1`), whose embeds
|
||
are drawn there and never on the plan.
|
||
|
||
`nomenclature.detail_sheet_prefix` is the plant naming that series,
|
||
and it is the only thing that tells a bubble from a part call-out.
|
||
Until now nothing read it, which is why this half had no way to even
|
||
begin (D5 in QNA/drift-triage.md; the owner's ruling was to read it).
|
||
|
||
WHAT THIS DOES NOT DO, AND SAYS SO. Nothing anywhere reads a detail
|
||
sheet's CONTENTS -- following `06/X1` to sheet X1 and comparing
|
||
the parts it draws against this piece's members needs those sheets,
|
||
and no code here opens one. So when the plan cites a detail this
|
||
check returns nothing and its probe reports NOT RUN naming the sheet.
|
||
It reports OK only when the plan cites NO detail at all, and that is
|
||
a real verdict: every call-out on the plan is then a part named
|
||
directly on it, and nothing on this piece reaches the model through a
|
||
detail sheet.
|
||
|
||
The verdict itself is ticketpage.detail_callout_status -- text in,
|
||
sentences out, host-testable with no model.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
t['_detail_callout_skip'] = None
|
||
bed_text = _bed_plan_text(t, know)
|
||
findings, reason = TP.detail_callout_status(
|
||
bed_text, K.get(know, 'nomenclature.detail_sheet_prefix'))
|
||
if reason:
|
||
t['_detail_callout_skip'] = reason
|
||
return []
|
||
return [Finding(sev, 'DETAIL-CALLOUT', msg) for (sev, msg) in findings]
|
||
|
||
|
||
def check_invisible(t, intent, know, spec):
|
||
"""Checklist 8.2: no invisible characters in ticket text.
|
||
|
||
clean_text strips them at the boundary; this check verifies nothing
|
||
survived into the checked values. Scans the sheet title-block values
|
||
that were read for the ticket.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
vals = []
|
||
for s in (t.get('sheets') or []):
|
||
for p in s.Parameters:
|
||
try:
|
||
v = p.AsString()
|
||
except Exception:
|
||
v = None
|
||
if v:
|
||
vals.append(v)
|
||
sev, msg = TP.invisible_chars(vals)
|
||
return [Finding(sev, 'INVISIBLE', msg)]
|
||
|
||
|
||
def check_sheet_name(t, intent, know, spec):
|
||
"""Checklist 1.1: the sheet's Name equals the piece mark.
|
||
|
||
The sheet Name holds the piece mark (measured: WP133 -> 'WP133' x2).
|
||
Compared normalized (case, whitespace). A sheet named differently
|
||
means the ticket cannot be found by mark -- the oldest of the
|
||
'compare two values the model already has' checks.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
names = [s.Name for s in (t.get('sheets') or [])]
|
||
sev, msg = TP.sheet_name_matches(t.get('mark'), names)
|
||
return [Finding(sev, 'SHEET-NAME', msg)]
|
||
|
||
|
||
def check_ticket_set(t, intent, know, spec):
|
||
"""Checklist 1.3: exactly one ticket set per piece.
|
||
|
||
Ground truth is the sheet NUMBER ('1 OF 2'/'2 OF 2' live in the
|
||
SheetNumber parameter, NOT the sheet Name -- Name holds the piece
|
||
mark; measured WP133: Name 'WP133' x2, SheetNumber '1 OF 2'/'2 OF 2').
|
||
The plant's TKT_SHEET_COUNT parameter is itself a checked object:
|
||
when it disagrees with the number of pages parsed from SheetNumber,
|
||
THAT is a finding (measured WP133: TKT_SHEET_COUNT=1 but two pages).
|
||
Missing page, duplicate page, no sheets -- each reported, never
|
||
silent.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
from Autodesk.Revit.DB import ElementId
|
||
|
||
def sheet_number(sheet):
|
||
# ViewSheet.SheetNumber is a NATIVE property, not a parameter:
|
||
# LookupParameter('SheetNumber') returns None (measured WP133).
|
||
# The property carries '1 OF 2' / '2 OF 2'.
|
||
try:
|
||
v = sheet.SheetNumber
|
||
return v or ''
|
||
except Exception:
|
||
pass
|
||
return ''
|
||
|
||
def sheet_count(sheet):
|
||
try:
|
||
p = sheet.LookupParameter('TKT_SHEET_COUNT')
|
||
if p is not None and p.HasValue:
|
||
v = p.AsValueString() or p.AsString()
|
||
if v:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
nums = [sheet_number(s) for s in (t.get('sheets') or [])]
|
||
nums = [n for n in nums if n]
|
||
sev, msg = TP.ticket_set_status(nums)
|
||
findings = [Finding(sev, 'TICKET-SET', msg)]
|
||
|
||
# the plant's count parameter vs the pages actually found
|
||
pages = 0
|
||
import re as _re
|
||
for n in nums:
|
||
m = _re.match(r'.*\b(\d+)\s+OF\s+(\d+)\b', n, _re.IGNORECASE)
|
||
if m:
|
||
pages = max(pages, int(m.group(2)))
|
||
counts = set([c for c in (sheet_count(s) for s in (t.get('sheets') or []))
|
||
if c])
|
||
if counts and pages and str(pages) not in counts:
|
||
findings.append(Finding(
|
||
'WARN', 'TICKET-SET',
|
||
'TKT_SHEET_COUNT says {0} but SheetNumber shows {1} pages '
|
||
'-- the count parameter disagrees with the sheets'
|
||
.format(', '.join(sorted(counts)), pages)))
|
||
return findings
|
||
|
||
|
||
def _jacking_tolerance_lb(know):
|
||
"""How far the printed jacking force may sit from the design, IN POUNDS.
|
||
|
||
`tolerances.it_lb` -- the key is named `_lb` and holds POUNDS. The
|
||
ticket prints KIPS; the one conversion lives in
|
||
ticketpage.jacking_force_status and nowhere else.
|
||
|
||
THE FALLBACK IS 100 lb, WHICH IS THE OWNER'S RULING (2026-08-30) AND
|
||
IS EXACTLY THE `0.1` KIPS THIS CHECK USED TO COMPARE AGAINST AS A
|
||
HARDCODED LITERAL. The literal is gone: a constant sitting beside a
|
||
knowledge key of the same meaning is how `tolerances.it_lb` came to be
|
||
declared, documented and read by nothing. Wiring the key makes the
|
||
tolerance a knob a plant can turn -- it was never meant to move
|
||
today's number, so the number here is the number that was there.
|
||
|
||
Returns None for a value that is present and unusable, which is a
|
||
could-not-judge (the probe says so), never a fall back to a default
|
||
nobody chose.
|
||
|
||
THE DECLARED VALUE MUST AGREE WITH THIS FALLBACK.
|
||
`knowledge.DEFAULTS['tolerances']['it_lb']` and the STARTER template
|
||
beside it have to read 100.0 for the same reason B3-B5 had to carry
|
||
the value their call sites already passed: a declaration that
|
||
disagrees with the fallback moves the check the moment anybody looks
|
||
at it, and moves it silently. That file is not this agent's to edit
|
||
and the change is routed separately -- if a layer here still says
|
||
1.0, the check is a hundred times stricter than the ruling.
|
||
"""
|
||
tol = K.get(know, 'tolerances.it_lb', 100.0)
|
||
try:
|
||
tol = float(tol)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if tol < 0:
|
||
return None
|
||
return tol
|
||
|
||
|
||
def check_jacking_force(t, intent, know, spec):
|
||
"""Checklist 4.3: jacking force on the ticket vs the design.
|
||
|
||
The ticket's TICKET NOTE view prints 'JACKING FORCE: 0.70 fpu =
|
||
28.9 kips'; the design intent's strand group carries it_lb (28,920
|
||
lb = 28.92 kips). They must agree within `tolerances.it_lb` POUNDS
|
||
-- 100 lb as the plant layers stand, the same 0.1 kips this check
|
||
always used. Missing either side is a SKIPPED case with a probe
|
||
reason (NOT RUN on the table), never a silent pass.
|
||
"""
|
||
# No Revit import here: `ElementId` was imported and never used, and
|
||
# dropping it is what lets a host test drive the tolerance paths
|
||
# below without a document.
|
||
from vibe import ticketpage as TP
|
||
import re as _re
|
||
|
||
t['_jack_force_skip'] = None
|
||
tol_lb = _jacking_tolerance_lb(know)
|
||
if tol_lb is None:
|
||
t['_jack_force_skip'] = (
|
||
'tolerances.it_lb is set to something that is not a tension in '
|
||
'pounds ({0!r}), so how close the printed jacking force has to '
|
||
'be was not decided by anybody'
|
||
.format(K.get(know, 'tolerances.it_lb', None)))
|
||
return []
|
||
|
||
# the ticket's side: the jacking-force note view text
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'JACK-FORCE',
|
||
'could not reach a document to read the '
|
||
'jacking-force note')]
|
||
note_text = None
|
||
for s in (t.get('sheets') or []):
|
||
for vp in s.GetAllPlacedViews():
|
||
v = tdoc.GetElement(vp)
|
||
if v is not None and 'JACKING' in (v.Name or '').upper():
|
||
from vibe import ticketqc as Q
|
||
note_text = Q._flat_text(tdoc, v.Id)
|
||
break
|
||
if note_text:
|
||
break
|
||
if not note_text:
|
||
return [] # no jacking note on this ticket
|
||
|
||
m = _re.search(r'=\s*([\d.]+)\s*kips', note_text)
|
||
if not m:
|
||
return [Finding('WARN', 'JACK-FORCE',
|
||
'jacking note present but no "= X kips" found: '
|
||
'{0}'.format(note_text[:60]))]
|
||
printed_kips = float(m.group(1))
|
||
|
||
# the design's side: strand group initial tension, converted to kips.
|
||
# A missing it_lb is a SKIPPED case (probe says why -> NOT RUN on the
|
||
# table), not a finding -- the checklist's NOT RUN is a render-layer
|
||
# state, never a finding severity.
|
||
its = [s.get('it') for s in ((intent or {}).get('strands') or [])
|
||
if s.get('it')]
|
||
if not its:
|
||
return []
|
||
# Both sides and the tolerance go into ticketpage in the units they
|
||
# arrive in -- kips off the ticket, POUNDS from the design and from
|
||
# tolerances.it_lb -- and the comparison is done there, in pounds.
|
||
sev, msg = TP.jacking_force_status(printed_kips, its[0], tol_lb)
|
||
if sev == 'NOT RUN':
|
||
t['_jack_force_skip'] = msg
|
||
return []
|
||
return [Finding(sev, 'JACK-FORCE', msg)]
|
||
|
||
|
||
# A THIRD DEFINITION OF "MESH" LIVED HERE, and it was the worst of the three.
|
||
#
|
||
# `takeoffgather._is_mesh` reads `families.wwf_prefix` defaulting to `''`;
|
||
# `ticketqc` reads it defaulting to `'WWF'`; and this check -- the one that
|
||
# OWNS row 5.4 -- read no key at all and hardcoded `'MESH' in name or 'WWF'
|
||
# in name`. A plant that calls its mesh anything else therefore got a SILENT
|
||
# PASS on 5.4: no family matched, so the row reported "no mesh family is
|
||
# modelled" as a WARN about the model rather than as the tool not knowing the
|
||
# word. Wired to the key 2026-08-31.
|
||
#
|
||
# THE BUILT-IN WORDS ARE KEPT, and only as a declared fallback for a NULL
|
||
# prefix. What a null prefix should mean plant-wide is still an open question
|
||
# with the owner (it is exactly what the two readers above disagree about), so
|
||
# this keeps today's behaviour for null rather than inventing an answer -- and
|
||
# says out loud, in the finding, that no plant fact was consulted.
|
||
#
|
||
# A family that matches the built-in words but NOT the plant's prefix is
|
||
# reported under `WWF-UNMATCHED`, a code no row owns. It is the signature of
|
||
# a prefix set to the wrong word, and it must not be able to decide 5.4 or to
|
||
# vanish.
|
||
|
||
WWF_PREFIX_KEY = 'families.wwf_prefix'
|
||
|
||
WWF_FALLBACK_WORDS = ('MESH', 'WWF')
|
||
|
||
|
||
def check_wwf_designation(t, intent, know, spec):
|
||
"""Checklist 5.4: mesh/WWF designation vs the design.
|
||
|
||
The design's spec carries reinforcing.wwf.mesh; the ticket carries
|
||
the mesh read off the sheet. When the spec HAS a designation the two
|
||
are compared; when the spec has none, this is a SKIPPED case whose
|
||
reason says where to go verify (Extract Design + that design's
|
||
summary view) -- NOT RUN names the missing input, never guesses
|
||
(Claude 08-28).
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
mesh_design = ((intent or {}).get('wwf_mesh') or '').strip()
|
||
if not mesh_design:
|
||
return [] # probe explains the NOT RUN
|
||
# model/ticket side: mesh family present in the assembly
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'WWF',
|
||
'could not reach a document to read the mesh '
|
||
'family')]
|
||
try:
|
||
from Autodesk.Revit.DB import ElementId
|
||
asm = tdoc.GetElement(ElementId(t['asm_id']))
|
||
except Exception:
|
||
asm = None
|
||
prefix = str(K.get(know, WWF_PREFIX_KEY, '') or '').strip()
|
||
families, by_word = set(), set()
|
||
if asm is not None:
|
||
for mid in asm.GetMemberIds():
|
||
el = tdoc.GetElement(mid)
|
||
try:
|
||
fn = el.Symbol.Family.Name
|
||
except Exception:
|
||
continue
|
||
up = (fn or '').upper()
|
||
if prefix and prefix.upper() in up:
|
||
families.add(fn)
|
||
elif any(word in up for word in WWF_FALLBACK_WORDS):
|
||
by_word.add(fn)
|
||
|
||
if not prefix:
|
||
# THE KEY IS UNSET, so today's built-in words are all there is. What
|
||
# a null prefix should mean plant-wide is an open question with the
|
||
# owner (the two other readers of this key disagree about it -- see
|
||
# the block above), so nothing is invented here: the behaviour is
|
||
# exactly what it was, and the finding says which words it used so
|
||
# the reader is not told a plant fact was consulted when none was.
|
||
families = by_word
|
||
by_word = set()
|
||
|
||
if not families:
|
||
if by_word:
|
||
# A family that LOOKS like mesh but does not match the plant's
|
||
# own prefix. Neither a pass nor a comparison -- it is the key
|
||
# being wrong, and saying so is the whole point of reading it.
|
||
return [Finding(
|
||
'WARN', 'WWF',
|
||
'design calls for mesh "{0}" and no modelled family matches '
|
||
'{1} = {2!r}, but {3} famil(ies) read like mesh by name: {4}. '
|
||
'Nothing was compared -- set {1} to the word this plant '
|
||
'really uses'
|
||
.format(mesh_design, WWF_PREFIX_KEY, prefix, len(by_word),
|
||
', '.join(sorted(by_word))))]
|
||
return [Finding('WARN', 'WWF',
|
||
'design calls for mesh "{0}" but no modelled family '
|
||
'matches {1} = {2!r}'
|
||
.format(mesh_design, WWF_PREFIX_KEY,
|
||
prefix or '(unset -- {0} were used instead)'
|
||
.format('/'.join(WWF_FALLBACK_WORDS))))]
|
||
sev, msg = TP.wwf_designation_status(mesh_design, sorted(families))
|
||
out = [Finding(sev, 'WWF', msg)]
|
||
if by_word:
|
||
# Under its OWN code, unowned by row 5.4, so it can neither decide
|
||
# the row nor disappear -- 5.2's BED-REVERSE-UNCLASSIFIED shape.
|
||
out.append(Finding(
|
||
'OK', 'WWF-UNMATCHED',
|
||
'{0} famil(ies) read like mesh by name but do not match {1} = '
|
||
'{2!r}, so nothing is claimed about them: {3}'
|
||
.format(len(by_word), WWF_PREFIX_KEY, prefix,
|
||
', '.join(sorted(by_word)))))
|
||
return out
|
||
|
||
|
||
# WIRED TO `knowledge.reinforcing_verdict` 2026-08-31.
|
||
#
|
||
# What used to be here was `_is_reinforcing`, the SUPERSEDED shape: Revit's
|
||
# category plus a family-name substring list, returning a bool. The corrected
|
||
# rule -- the `member.manufacture_component` parameter, instance-then-type
|
||
# precedence, per-row scopes -- was written, tested about twenty tests deep,
|
||
# and had NO production caller. So the bug and its fix sat next to each other.
|
||
#
|
||
# THE BOOL WAS THE BUG. `_is_reinforcing` returned False for a member it could
|
||
# not judge, and `reinforcing_verdict`'s own docstring names that collapse as
|
||
# how 5.6 and 5.7 went quiet in the first place: "could not judge" silently
|
||
# became "not a bar", and a piece full of unrecognised bars produced the same
|
||
# empty gather as a piece with no steel in it. `None` now travels all the way
|
||
# to `_no_bars_reason`, which is the only place that can say which of the two
|
||
# happened.
|
||
#
|
||
# WHY THE CATEGORY IS STILL ASKED FIRST. Revit's own category is a fact
|
||
# RECORDED IN THE MODEL, not an inference from a name, and it is the only live
|
||
# signal on a plant that has declared nothing. Asking the verdict first would
|
||
# have let a `mode == 'list'` plant's enumeration turn a genuine
|
||
# rebar-category member into "not a bar" -- a regression, on the one signal
|
||
# that cannot be wrong. So the order is category, then the verdict, and the
|
||
# verdict's `None` is what changes.
|
||
#
|
||
# THE RULE IS DORMANT ON PURPOSE AND STAYS DORMANT. `drawings.reinforcing_
|
||
# rule` ships `parameter_verified: false` pending the owner, so
|
||
# `reinforcing_mode` returns None and every unrecognised member comes back
|
||
# None with the reason. Wiring the call does not flip that switch -- it makes
|
||
# the tool loud through the right function instead of quietly wrong through
|
||
# the old one.
|
||
|
||
|
||
def _reinforcing_verdict(family, category, know, instance_value=None,
|
||
type_value=None, item=None):
|
||
"""(True / False / None, why) -- is this member reinforcing?
|
||
|
||
NONE IS A REAL ANSWER and every caller must keep it. See the block above.
|
||
`item` is the checklist row asking ('5.6', '5.7'); it selects the per-row
|
||
scope, and with no scope declared every row sees the same population,
|
||
which is today's behaviour exactly.
|
||
"""
|
||
cat = (category or '').upper()
|
||
if 'REBAR' in cat or 'REINFORC' in cat:
|
||
return True, ('Revit\'s own category {0!r} says this member is '
|
||
'reinforcing.'.format(category))
|
||
return K.reinforcing_verdict(know, family, instance_value, type_value,
|
||
item)
|
||
|
||
|
||
def _reinforcing_values(el, know):
|
||
"""(instance value, type value) for the parameter the rule names.
|
||
|
||
Both, because they are MUTUALLY EXCLUSIVE per element on the model this
|
||
was measured against: reading either alone finds nothing on half the
|
||
members and the result looks clean. `reinforcing_verdict` decides which
|
||
to prefer -- `read_from` is the plant's to state, not this function's to
|
||
assume.
|
||
|
||
MUST use `Q.rstr_instance_only` / `Q.rstr_type_only`, never the plain
|
||
`Q.rstr`. `rstr` (via `pstr`) unions instance-then-type since
|
||
2026-09-02 -- correct for almost every caller, because a role mapped to
|
||
a type parameter used to read as a silent `None` forever. But this
|
||
function's two return values are reported downstream as which one WON
|
||
('source': 'instance' or 'type' in the 5.6/5.7 finding text), and a
|
||
plant's `read_from` states which one it trusts. If `instance_value`
|
||
here could silently absorb the type's value, a finding could say
|
||
"instance" when the value actually came from the type, and a plant that
|
||
deliberately prefers 'instance' would be satisfied by a type-only read
|
||
it was explicitly configured to ignore. That is a wrong attribution
|
||
that is indistinguishable from a right one -- see CLAUDE.md Sec.1 on
|
||
why that class of bug is the serious one, and Sec.3's contradiction
|
||
ruling on why a future "simplification" back to `Q.rstr` here must not
|
||
happen quietly. Keep the two reads genuinely separate.
|
||
|
||
Nothing is read when no rule names a parameter: a Revit parameter read
|
||
per member on a 377-assembly model is not free, and a value nobody will
|
||
consult is a cost for nothing.
|
||
"""
|
||
role = (K.get(know, 'drawings.reinforcing_rule') or {}).get('parameter')
|
||
if not role:
|
||
return None, None
|
||
from vibe import ticketqc as Q
|
||
instance_value = Q.rstr_instance_only(el, know, role)
|
||
try:
|
||
symbol = el.Symbol
|
||
except Exception:
|
||
symbol = None
|
||
# `symbol` IS the element's type object, so reading its OWN parameters
|
||
# (instance-only on the symbol, not a further instance-then-type walk
|
||
# off the symbol) is what "the type's value" means here. Do not swap
|
||
# this for `rstr_type_only(symbol, ...)` -- that would ask for the
|
||
# symbol's own type, which is not a thing this rule means.
|
||
type_value = Q.rstr_instance_only(symbol, know, role) if symbol is not None else None
|
||
return instance_value, type_value
|
||
|
||
|
||
def _panel_axes_for(t, tdoc):
|
||
"""(axes, concrete lo/hi in inches, frame) for this piece, or Nones.
|
||
|
||
check_member_interference already computes exactly this and caches it
|
||
on `t`, but it is off by default, so 5.2 cannot depend on it having
|
||
run. Reuses the cache when it is there and fills it when it is not,
|
||
so the two never pay for the same solve twice.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
from Autodesk.Revit.DB import ElementId
|
||
if t.get('panel_axes') and t.get('frame') and t.get('concrete_lo_in'):
|
||
return t['panel_axes'], t['concrete_lo_in'], t['concrete_hi_in'], t['frame']
|
||
if not t.get('panel_id'):
|
||
return None, None, None, None
|
||
try:
|
||
panel = tdoc.GetElement(ElementId(int(t['panel_id'])))
|
||
except Exception:
|
||
panel = None
|
||
if panel is None:
|
||
return None, None, None, None
|
||
frame = t.get('frame') or Q._panel_frame(panel)
|
||
if frame is None:
|
||
return None, None, None, None
|
||
extents = Q.concrete_extents(panel, frame)
|
||
if not extents:
|
||
return None, None, None, frame
|
||
lo, hi = extents
|
||
span = [(hi[i] - lo[i]) * 12.0 for i in (0, 1, 2)]
|
||
thick = min(range(3), key=lambda i: span[i])
|
||
rest = [i for i in (0, 1, 2) if i != thick]
|
||
rest.sort(key=lambda i: -span[i])
|
||
axes = {'thickness': thick, 'length': rest[0], 'width': rest[1]}
|
||
lo_in = [v * 12.0 for v in lo]
|
||
hi_in = [v * 12.0 for v in hi]
|
||
t['panel_axes'], t['frame'] = axes, frame
|
||
t['concrete_lo_in'], t['concrete_hi_in'] = lo_in, hi_in
|
||
return axes, lo_in, hi_in, frame
|
||
|
||
|
||
def _classify_bars(t, tdoc, know, bar_members):
|
||
"""Checklist 5.2: where the standard draws each reinforcing family.
|
||
|
||
Returns (homes, why): homes maps family -> set of
|
||
ticketpage.BAR_HOME_* (None in the set means at least one instance
|
||
could not be classified), why maps family -> the first reason a
|
||
classification failed.
|
||
|
||
The geometry is solved per BAR ONLY -- refine_box on the handful of
|
||
reinforcing members, never on the whole assembly. A world-axis
|
||
bounding box on a rotated panel over-states the reach along an axis,
|
||
and over-stating reach is what turns a partial bar into a
|
||
'full-length' one, so the loose box is not good enough here.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
from vibe import ticketpage as TP
|
||
homes, why = {}, {}
|
||
if not bar_members:
|
||
return homes, why
|
||
fraction = K.get(know, 'drawings.full_length_bar_fraction')
|
||
axes, lo_in, hi_in, frame = _panel_axes_for(t, tdoc)
|
||
for (fam, el) in bar_members:
|
||
seen = homes.setdefault(fam, set())
|
||
if axes is None or lo_in is None:
|
||
seen.add(None)
|
||
why.setdefault(fam, 'the panel\'s own axes could not be solved, '
|
||
'so no bar could be measured against it')
|
||
continue
|
||
entry = {'element': el}
|
||
try:
|
||
box = Q._world_box(el, frame)
|
||
except Exception:
|
||
box = None
|
||
if box is None:
|
||
seen.add(None)
|
||
why.setdefault(fam, 'bar geometry not readable')
|
||
continue
|
||
entry['lo'], entry['hi'] = box[0], box[1]
|
||
try:
|
||
Q.refine_box(entry, frame)
|
||
except Exception:
|
||
pass
|
||
size = [entry['hi'][a] - entry['lo'][a] for a in (0, 1, 2)]
|
||
run = TP.bar_run_axis(size, axes['length'], axes['width'],
|
||
axes['thickness'])
|
||
if run is None:
|
||
seen.add(None)
|
||
why.setdefault(fam, 'no readable run direction in the plane of '
|
||
'the piece')
|
||
continue
|
||
ax = axes[run]
|
||
home, reason = TP.bar_drawing_home(run, size[ax],
|
||
hi_in[ax] - lo_in[ax], fraction)
|
||
seen.add(home)
|
||
if home is None:
|
||
why.setdefault(fam, reason)
|
||
return homes, why
|
||
|
||
|
||
def check_bed_plan_reverse(t, intent, know, spec):
|
||
"""Checklist 5.2, first pass: model embeds that the ticket does not print.
|
||
|
||
REVERSE of 5.1 (which asks whether the BED PLAN's callouts exist in
|
||
the model). This asks what the model carries that the ticket never
|
||
mentions.
|
||
|
||
THIS FIRST VERSION REPORTS THE DIFFERENCE, NOT A VERDICT (Claude
|
||
08-28: 5.2 is the one most likely to become noise -- the plant may
|
||
simply not print small hardware; a distribution comes first, FAIL /
|
||
WARN severity waits for Will's ruling on the 57-piece census).
|
||
|
||
Which model families count as 'an embed that should be printed' is
|
||
plant knowledge; the conservative first cut is every non-concrete,
|
||
non-strand, non-mesh family -- reported as a WARN naming the
|
||
families missing from the ticket, with no claim about whether that
|
||
is a defect. (The docstring said 'non-rebar' until 2026-08-30 and
|
||
the filter never was: reinforcing went straight through it into the
|
||
residual, which is the over-flag described below.)
|
||
|
||
2026-08-30 -- REINFORCING NO LONGER GOES THROUGH THE RESIDUAL.
|
||
|
||
The rule this check held for reinforcing was "anything not shown in
|
||
A-A / B-B must appear on the BED PLAN". The manufacturer's standards
|
||
state it as an enumeration instead, and the two disagree in the
|
||
over-flagging direction: a full-length bar belongs in Section A-A
|
||
ONLY and a full-width transverse bar in Section B-B ONLY, and the
|
||
residual reports both as missing from the plan. The classification
|
||
and the reasoning are in ticketpage.bar_drawing_home; the manual's
|
||
own words are in the section-10000 extraction in QNA/ A-23.
|
||
|
||
NARROWED, NOT REPLACED (judgement call): the enumeration governs
|
||
REINFORCING, which is all notes 25/26/40 speak about. The residual
|
||
still governs embeds -- plates, lifters, inserts -- because the
|
||
standard says nothing about where those are drawn and the embed
|
||
difference is what 5.2 is actually worth today. So:
|
||
|
||
* a bar the enumeration puts in a SECTION is dropped from the
|
||
BED PLAN difference: absent from the plan is CORRECT for it.
|
||
* a bar the enumeration puts in the PLAN is tested exactly as
|
||
before.
|
||
* a bar that CANNOT BE CLASSIFIED is neither passed nor flagged.
|
||
It is named in its own finding that says plainly it could not be
|
||
classified and why, and when nothing else could be judged at all
|
||
the row reads NOT RUN through the skip probe. It is never
|
||
allowed to fall back into the residual and be flagged.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
from vibe import ticketpage as TP
|
||
from Autodesk.Revit.DB import FilteredElementCollector, ElementId, View
|
||
|
||
t['_bed_reverse_skip'] = None
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return [Finding('WARN', 'BED-REVERSE',
|
||
'could not reach a document to compare the '
|
||
'BED PLAN against the model')]
|
||
try:
|
||
asm = tdoc.GetElement(ElementId(t['asm_id']))
|
||
except Exception:
|
||
asm = None
|
||
if asm is None:
|
||
return [Finding('WARN', 'BED-REVERSE',
|
||
'assembly not readable -- cannot do the reverse '
|
||
'BED PLAN check')]
|
||
|
||
# ticket side: the BED PLAN view's flat text (callouts it prints).
|
||
# THE VIEW NAME IS THE PLANT'S, NOT A LITERAL. This compared against
|
||
# `'Elevation Top'` until 2026-08-31 while `_bed_plan_text` in this same
|
||
# file read the key -- triage B1. There is no fallback to the old literal:
|
||
# see `_bed_plan_name`.
|
||
bed_name, bed_why = _bed_plan_name(know)
|
||
if not bed_name:
|
||
t['_bed_reverse_skip'] = bed_why
|
||
return []
|
||
bed_text = None
|
||
for s in (t.get('sheets') or []):
|
||
for vp in s.GetAllPlacedViews():
|
||
v = tdoc.GetElement(vp)
|
||
try:
|
||
nm = v.Name if v is not None else None
|
||
except Exception:
|
||
nm = None
|
||
if nm == bed_name:
|
||
bed_text = Q._flat_text(tdoc, v.Id)
|
||
break
|
||
if bed_text:
|
||
break
|
||
if not bed_text:
|
||
# EVERY empty return from this check must leave a reason behind:
|
||
# a probe is registered for it now, and a probe that returns None
|
||
# turns an empty return into a silent OK.
|
||
t['_bed_reverse_skip'] = (
|
||
'no view named {0!r} ({1}) is placed on this ticket, so there is '
|
||
'nothing to compare the model against'
|
||
.format(bed_name, BED_PLAN_KEY))
|
||
return []
|
||
|
||
# model side: family names of members, minus concrete / strand / mesh
|
||
def is_embed(name):
|
||
up = (name or '').upper()
|
||
if any(k in up for k in ('STRAND', 'SCHEDULED', 'PANEL',
|
||
'CAST', 'MESH')):
|
||
return False
|
||
return True
|
||
|
||
model_embeds = set()
|
||
bar_members = [] # (family, element) for reinforcing
|
||
for mid in asm.GetMemberIds():
|
||
el = tdoc.GetElement(mid)
|
||
if el is None:
|
||
continue
|
||
try:
|
||
fn = el.Symbol.Family.Name
|
||
except Exception:
|
||
try:
|
||
fn = el.GetType().FamilyName
|
||
except Exception:
|
||
continue
|
||
if not is_embed(fn):
|
||
continue
|
||
model_embeds.add(fn)
|
||
try:
|
||
cat = el.Category.Name if el.Category else ''
|
||
except Exception:
|
||
cat = ''
|
||
# 5.2 asks only "is this definitely a bar", and `is True` says so
|
||
# at the call site rather than letting a bool hide it. What 5.2
|
||
# should do with a COULD-NOT-JUDGE member is a separate ruling and
|
||
# is not made here: it already has an `unclassified` bucket further
|
||
# down, and moving members into it would change what this row
|
||
# reports. 5.6 and 5.7 are where the None is carried (see
|
||
# `_bar_members`).
|
||
_inst, _type = _reinforcing_values(el, know)
|
||
if _reinforcing_verdict(fn, cat, know, _inst, _type)[0] is True:
|
||
bar_members.append((fn, el))
|
||
|
||
# THE ENUMERATION. Every reinforcing family is classified, and the
|
||
# result decides whether it is even eligible for the BED PLAN
|
||
# difference. A family is judged over all its instances:
|
||
# any instance unclassifiable -> unclassified (said out loud, not
|
||
# flagged, and not passed either)
|
||
# any instance plan-bound -> stays in the difference
|
||
# otherwise (all in a section) -> correctly absent from the plan
|
||
bar_homes, bar_why = _classify_bars(t, tdoc, know, bar_members)
|
||
_plan_bound, section_bound, unclassified = TP.bed_plan_bar_disposition(
|
||
bar_homes)
|
||
for fam in section_bound + unclassified:
|
||
model_embeds.discard(fam)
|
||
|
||
# tokens the ticket prints: match the part NUMBER in the family name
|
||
# (P76, P106, SR5602...) -- not the prefixed vendor token, which is
|
||
# not what the callout prints (measured: a vendor-prefixed embed
|
||
# family splits on '_' first to the vendor word, missing the part
|
||
# number; the callout prints the part number alone).
|
||
#
|
||
# annotation_covered_by_parent (plant knowledge) declares covered
|
||
# families -- a bar that ships inside the parent item (SR4996 inside
|
||
# the SP29/30 angles) and is never drawn on its own. The 08-28 census
|
||
# over 57 std tickets: the covered trio (SP29/SP30/SR4996) shows as
|
||
# 'missing from BED PLAN' on ~40 of them -- the plant simply does not
|
||
# print covered hardware, so those families are excluded from the
|
||
# difference. What is LEFT is the real signal: SP126 / SR5602 /
|
||
# SR3997-8 etc.
|
||
# annotation_covered_by_parent (plant knowledge) declares covered
|
||
# families -- a bar that ships inside the parent item (SR4996 inside
|
||
# the SP29/30 angles) and is never drawn on its own. Only the CHILD
|
||
# (the key) is excluded from the difference: the parents (SP29/SP30)
|
||
# are the items the BED PLAN should be calling out, so they stay in
|
||
# the difference if absent (Claude 08-28: excluding the parents hid
|
||
# exactly what the reverse check exists to find).
|
||
#
|
||
# drawings.called_out_on_detail (plant/job knowledge) declares the
|
||
# families whose callout lives on a DETAIL sheet instead. Its absence
|
||
# from the BED PLAN is correct and was never a finding -- but until
|
||
# 2026-08-30 nothing read the key, so the job file documented an
|
||
# exclusion that did not exist. Wired here; the key's own comment in
|
||
# the job file says which families it covers and why one is left out.
|
||
#
|
||
# Matched EXACTLY, like annotation_covered_by_parent beside it, and
|
||
# not as a substring. The job file already records that P126 and SP126
|
||
# are two names nobody has separated yet; a substring match would let
|
||
# the entry for one silently swallow the other, which is the kind of
|
||
# quiet exclusion that hid forty tickets the first time.
|
||
import re as _re
|
||
num_re = _re.compile(r'([A-Z]{1,3}\d+[A-Z]?\d*)')
|
||
covered = set()
|
||
on_detail = {}
|
||
if know is not None:
|
||
from vibe import knowledge as K
|
||
for child, parents in (K.get(know, 'drawings.annotation_covered_by_parent', {}) or {}).items():
|
||
covered.add(str(child).upper())
|
||
for fam, where in (K.get(know, 'drawings.called_out_on_detail', {}) or {}).items():
|
||
on_detail[str(fam).upper()] = where
|
||
missing = []
|
||
for fam in sorted(model_embeds):
|
||
up = fam.upper()
|
||
if up in covered:
|
||
continue # declared covered: never printed
|
||
if up in on_detail:
|
||
continue # called out on a detail sheet
|
||
nums = num_re.findall(up)
|
||
if nums and not any(n in bed_text.upper() for n in nums):
|
||
missing.append(fam)
|
||
|
||
unclassified_note = '; '.join(
|
||
'{0} ({1})'.format(f, bar_why.get(f, 'no reason recorded'))
|
||
for f in unclassified)
|
||
|
||
out = []
|
||
if missing:
|
||
out.append(Finding('WARN', 'BED-REVERSE',
|
||
'model carries embed family(ies) not called out '
|
||
'on the BED PLAN: {0}'.format(', '.join(missing))))
|
||
elif model_embeds:
|
||
note = ''
|
||
if section_bound:
|
||
note = ('; {0} reinforcing family(ies) correctly live in a '
|
||
'section, not the plan: {1}'
|
||
.format(len(section_bound), ', '.join(section_bound)))
|
||
out.append(Finding('OK', 'BED-REVERSE',
|
||
'every embed family in the model is called out on '
|
||
'the BED PLAN (covered families excluded){0}'
|
||
.format(note)))
|
||
else:
|
||
# NOTHING was compared. Every family was excluded, or was a bar
|
||
# the enumeration could not place. An empty return plus the skip
|
||
# probe is what makes the row read NOT RUN with a reason -- the
|
||
# one outcome that must never be reachable here is a quiet OK
|
||
# standing for 'ran and found nothing wrong'.
|
||
t['_bed_reverse_skip'] = (
|
||
'nothing on this piece could be compared against the BED PLAN'
|
||
+ (' -- could not classify {0} reinforcing family(ies): {1}'
|
||
.format(len(unclassified), unclassified_note)
|
||
if unclassified else
|
||
' -- every family is declared covered or called out on a '
|
||
'detail'))
|
||
return []
|
||
|
||
# NOT a verdict and NOT a defect claim: the bars the tool could not
|
||
# place. Reported at OK severity, under its own code, so it can never
|
||
# masquerade as a finding nor decide 5.2's status. Saying it out loud
|
||
# is the point -- an unclassifiable bar must not silently disappear
|
||
# and must not be flagged either.
|
||
if unclassified:
|
||
out.append(Finding('OK', 'BED-REVERSE-UNCLASSIFIED',
|
||
'could not classify {0} reinforcing family(ies), '
|
||
'so nothing is claimed about where they are '
|
||
'drawn: {1}'.format(len(unclassified),
|
||
unclassified_note)))
|
||
return out
|
||
|
||
|
||
def _view_design_token_re(know):
|
||
"""The unanchored pattern for a design label inside a view name (1.4).
|
||
|
||
Plant knowledge names it outright; left None it is derived from the
|
||
anchored design_number_re by dropping the leading '^'. Returns None
|
||
when neither is usable -- the probe then says so and the row reads
|
||
NOT RUN.
|
||
"""
|
||
explicit = K.get(know, 'nomenclature.view_design_token_re')
|
||
if explicit:
|
||
return explicit
|
||
anchored = K.get(know, 'nomenclature.design_number_re')
|
||
if not anchored:
|
||
return None
|
||
return anchored[1:] if anchored.startswith('^') else anchored
|
||
|
||
|
||
def _ticket_doc(t):
|
||
tdoc = t.get('doc')
|
||
if tdoc is not None:
|
||
return tdoc
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
return tdoc
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _placed_views(t):
|
||
"""Every view placed on this ticket's sheets, or None.
|
||
|
||
None means no document was reachable -- distinct from a ticket that
|
||
genuinely has no placed views, which is an empty list. The whole
|
||
checklist rests on that distinction.
|
||
"""
|
||
tdoc = _ticket_doc(t)
|
||
if tdoc is None:
|
||
return None
|
||
views = []
|
||
for s in (t.get('sheets') or []):
|
||
try:
|
||
ids = s.GetAllPlacedViews()
|
||
except Exception:
|
||
continue
|
||
for vid in ids:
|
||
v = tdoc.GetElement(vid)
|
||
if v is not None:
|
||
views.append(v)
|
||
return views
|
||
|
||
|
||
def _view_names(views):
|
||
names = []
|
||
for v in (views or []):
|
||
try:
|
||
names.append(v.Name)
|
||
except Exception:
|
||
pass
|
||
return names
|
||
|
||
|
||
def check_view_name_design(t, intent, know, spec):
|
||
"""Checklist 1.4: no placed view labels itself with another design.
|
||
|
||
Gathering only; the verdict is ticketpage.view_name_design_status,
|
||
which carries the reasoning about why this is WARN and never FAIL.
|
||
The design side comes from the PANEL's design number reduced through
|
||
designspec.design_key -- never from a view name, which is the point
|
||
(a view name does not identify a design; this check exists because
|
||
one CLAIMS to and is wrong).
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
from vibe import designspec
|
||
|
||
views = _placed_views(t)
|
||
if views is None:
|
||
return [Finding('WARN', 'VIEW-DSN',
|
||
'could not reach a document to read the placed '
|
||
'view names')]
|
||
out = TP.view_name_design_status(
|
||
designspec.design_key(t.get('design_number'), know),
|
||
_view_names(views), _view_design_token_re(know))
|
||
if out is None:
|
||
return [] # could not judge -- the probe says why
|
||
sev, msg = out
|
||
return [Finding(sev, 'VIEW-DSN', msg)]
|
||
|
||
|
||
def _notes_config(know):
|
||
notes = K.get(know, 'notes', {}) or {}
|
||
return notes if isinstance(notes, dict) else {}
|
||
|
||
|
||
def _instruction_groups(know, t):
|
||
"""(groups, reason) for this ticket -- see ticketpage for the shape.
|
||
|
||
Selecting the groups needs the placed view names (the product family
|
||
is chosen by which one the ticket actually carries), so this cannot
|
||
be answered from knowledge alone.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
views = _placed_views(t)
|
||
return TP.instruction_view_groups(
|
||
_notes_config(know), _view_names(views or []))
|
||
|
||
|
||
def check_note_views(t, intent, know, spec):
|
||
"""Checklist 6.5: the DUNNAGE / ERECTION / PICKING notes say something.
|
||
|
||
Gathering only; ticketpage decides. Which views are even WANTED is
|
||
decided there too (instruction_view_groups): a declared entry is a
|
||
group of interchangeable variants, and only the product family this
|
||
ticket actually carries is asked of it. Two things are collected per
|
||
placed view:
|
||
|
||
* its TEXT NOTES -- the instruction, when it is written; and
|
||
* a COUNT OF EVERY OTHER VIEW-OWNED ELEMENT (detail lines, filled
|
||
regions, detail components, symbols, dimensions).
|
||
|
||
The second is not defensive padding. If a plant DRAWS its stacking
|
||
instruction instead of writing it, counting text notes alone would
|
||
report every one of those views as empty and the check would fail
|
||
every ticket at the plant it was meant to help. Anything view-owned
|
||
counts as content; whether that content is the RIGHT instruction is
|
||
not decided here and is not claimed to be.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
from vibe import ticketqc as Q
|
||
from Autodesk.Revit.DB import FilteredElementCollector
|
||
|
||
tdoc = _ticket_doc(t)
|
||
views = _placed_views(t)
|
||
if tdoc is None or views is None:
|
||
return [Finding('WARN', 'NOTE-CONTENT',
|
||
'could not reach a document to read the note '
|
||
'views')]
|
||
|
||
groups, reason = TP.instruction_view_groups(
|
||
_notes_config(know), _view_names(views))
|
||
if reason:
|
||
return [] # could not judge -- the probe says why
|
||
|
||
placed = {}
|
||
for v in views:
|
||
try:
|
||
placed.setdefault(TP._norm_view_name(v.Name), v)
|
||
except Exception:
|
||
pass
|
||
|
||
rows = []
|
||
for group in groups:
|
||
label = group['label']
|
||
if not group['placed']:
|
||
rows.append({'name': label, 'placed': False, 'texts': [],
|
||
'other_elements': 0})
|
||
continue
|
||
# One row per placed alternative: a note that prints a heading
|
||
# over nothing is the finding even when a sibling variant is fine.
|
||
for name in group['placed']:
|
||
v = placed.get(TP._norm_view_name(name))
|
||
if v is None:
|
||
continue
|
||
try:
|
||
notes = Q._text_notes(tdoc, v.Id)
|
||
texts = []
|
||
for n in notes:
|
||
try:
|
||
texts.append(Q.clean_text(n.Text))
|
||
except Exception:
|
||
texts.append('')
|
||
total = len(list(FilteredElementCollector(tdoc, v.Id)
|
||
.WhereElementIsNotElementType()
|
||
.ToElementIds()))
|
||
except Exception as ex:
|
||
return [Finding('WARN', 'NOTE-CONTENT',
|
||
'note view "{0}" could not be read: {1}'
|
||
.format(name, type(ex).__name__))]
|
||
rows.append({'name': label, 'matched': name, 'placed': True,
|
||
'texts': texts,
|
||
'other_elements': max(0, total - len(texts))})
|
||
|
||
out = TP.note_view_content_status(rows)
|
||
if out is None:
|
||
return []
|
||
sev, msg = out
|
||
return [Finding(sev, 'NOTE-CONTENT', msg)]
|
||
|
||
|
||
# =============================================== reinforcing bars, gathered
|
||
#
|
||
# ONE definition of "which member is a bar", and it is `_is_reinforcing` --
|
||
# Revit's own category, plus the families the plant declares in
|
||
# `drawings.reinforcing_families`. 5.2 built it and 5.6 and 5.7 reuse it
|
||
# rather than growing a second rule that could disagree with the first.
|
||
#
|
||
# THE COST OF THAT, STATED. Measured through the bridge on the live model
|
||
# 2026-08-30: this plant models its bars in the "Specialty Equipment"
|
||
# category, not "Structural Rebar", so Revit's own signal says nothing and
|
||
# everything rests on `drawings.reinforcing_families` -- which is empty by
|
||
# default and is not yet filled in. Until it is, 5.6 and 5.7 report NOT RUN
|
||
# naming that key. That is the correct conservative outcome and it is also
|
||
# what keeps the length check from opening with 2,122 findings derived from
|
||
# a family list nobody has vetted.
|
||
|
||
|
||
def _bar_members(t, know, item=None):
|
||
"""(bars, reason, unknown) -- the reinforcing members of this assembly.
|
||
|
||
A bar is {'id', 'family', 'mark', 'length_ft', 'size', 'size_basis',
|
||
'size_why'} -- the size travels with WHICH FIELD ANSWERED for it and with
|
||
the sentence explaining an absence, because two disjoint populations are
|
||
read by two different fields and a size with no provenance cannot be
|
||
audited. `reason` is set
|
||
when the gather could not happen at all and is None once it has looked,
|
||
so a ticket with no bars is an empty list and NOT a silence.
|
||
|
||
`unknown` IS THE THIRD RETURN AND IT IS THE POINT. It holds every member
|
||
the tool COULD NOT JUDGE -- `{'family', 'why'}` -- which used to be
|
||
flattened into "not a bar" by a boolean filter and disappear. A piece
|
||
whose members are all unrecognised and a piece with genuinely no steel
|
||
in it produced the same empty list, and the second is a claim the first
|
||
has no right to make. `_no_bars_reason` is the only place that can tell
|
||
them apart, and it can only do it if this list survives the filter.
|
||
|
||
`item` is the checklist row asking. It selects the per-row reinforcing
|
||
scope, so the CACHE IS KEYED BY IT: with scopes declared, 5.6 and 5.7 can
|
||
legitimately see different populations, and one cache serving both would
|
||
hand the second row the first row's answer.
|
||
"""
|
||
# Cache first, THEN the Revit import -- the same discipline
|
||
# `_debond_requirement` follows, and for the same reason: with the
|
||
# answer already on the ticket these two checks are pure decisions and
|
||
# can be exercised off-model. A test that has to stand up a Revit stub
|
||
# to reach a decision is testing the stub.
|
||
slot = '_bar_members' if item is None else '_bar_members:{0}'.format(item)
|
||
cached = t.get(slot)
|
||
if cached is None and item is not None:
|
||
# A test (or an older caller) that seeded the unkeyed slot still
|
||
# answers for every row while no scope is declared -- which is the
|
||
# only condition under which the populations are the same.
|
||
cached = t.get('_bar_members')
|
||
if cached is not None:
|
||
return (cached['bars'], cached['reason'], cached.get('unknown') or [])
|
||
from Autodesk.Revit.DB import ElementId
|
||
|
||
bars, reason, unknown = [], None, []
|
||
tdoc = _ticket_doc(t)
|
||
if tdoc is None:
|
||
reason = ('could not reach a document to read this piece\'s '
|
||
'reinforcing')
|
||
elif not t.get('asm_id'):
|
||
reason = ('this ticket has no assembly, so its reinforcing could '
|
||
'not be enumerated')
|
||
else:
|
||
try:
|
||
asm = tdoc.GetElement(ElementId(t['asm_id']))
|
||
except Exception:
|
||
asm = None
|
||
if asm is None:
|
||
reason = 'the assembly is not readable, so its bars were not read'
|
||
else:
|
||
for mid in asm.GetMemberIds():
|
||
el = tdoc.GetElement(mid)
|
||
if el is None:
|
||
continue
|
||
fam = _family_name(el)
|
||
cat = _category_name(el)
|
||
# Spelled out rather than `*_reinforcing_values(...)`: a
|
||
# star-unpack FOLLOWED BY a keyword argument is a syntax
|
||
# error on IronPython 2.7, and this file ships to it.
|
||
inst_value, type_value = _reinforcing_values(el, know)
|
||
verdict, why = _reinforcing_verdict(
|
||
fam, cat, know, inst_value, type_value, item)
|
||
if verdict is None:
|
||
# COULD NOT JUDGE. Kept, never dropped -- see the
|
||
# docstring. It is not a bar and it is not "not a bar".
|
||
unknown.append({'family': fam, 'why': why})
|
||
continue
|
||
if verdict is not True:
|
||
continue
|
||
row = {'id': _eid(mid),
|
||
'family': fam,
|
||
'mark': _bar_mark(el, know),
|
||
'length_ft': _bar_length_ft(el, know)}
|
||
# Spelled out for the same IronPython reason as
|
||
# `_reinforcing_values` above: the read touches Revit, the
|
||
# decision does not, and the bar row carries the size, the
|
||
# basis and the reason together so nothing downstream has to
|
||
# guess which field answered.
|
||
size_param, description = _bar_size_values(el, know)
|
||
row.update(_bar_size(know, size_param, description))
|
||
bars.append(row)
|
||
t[slot] = {'bars': bars, 'reason': reason, 'unknown': unknown}
|
||
return bars, reason, unknown
|
||
|
||
|
||
def _eid(element_id):
|
||
"""An ElementId as a string. `.IntegerValue` is deprecated in 2024+ and
|
||
`.Value` does not exist before it, so both are tried -- CLAUDE.md's
|
||
standing rule for this API."""
|
||
for attr in ('IntegerValue', 'Value'):
|
||
try:
|
||
return str(getattr(element_id, attr))
|
||
except Exception:
|
||
continue
|
||
return str(element_id)
|
||
|
||
|
||
def _family_name(el):
|
||
try:
|
||
return el.Symbol.Family.Name
|
||
except Exception:
|
||
pass
|
||
try:
|
||
return el.GetType().FamilyName
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
def _category_name(el):
|
||
try:
|
||
return el.Category.Name if el.Category else ''
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
def _bar_mark(el, know):
|
||
"""A bar's mark, instance first and then its TYPE.
|
||
|
||
NOT `ticketqc._member_mark`, and the difference is measured rather than
|
||
invented. That function reads the INSTANCE only, which is right for the
|
||
hardware it was written for. On bars it is not enough: read off the live
|
||
model 2026-08-30, one bar family carries its mark on the instance
|
||
(`SR310`, `SR324`) while another carries it only on the TYPE (`SR4996`,
|
||
whose instance parameter is empty and whose instance carries no other
|
||
field holding a mark at all). A check that read the instance alone would
|
||
report every bar of the second kind as unmarked and could never judge
|
||
it.
|
||
|
||
The type fallback is confined to this function on purpose: widening
|
||
`_member_mark` itself would change what four other checks call a mark,
|
||
which is not this task's ruling to make.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
mark = Q._member_mark(el, know)
|
||
if mark:
|
||
return mark
|
||
try:
|
||
symbol = el.Symbol
|
||
except Exception:
|
||
symbol = None
|
||
if symbol is None:
|
||
return None
|
||
return Q._member_mark(symbol, know)
|
||
|
||
|
||
# Owner, verbatim (2026-08-30), on a bar's length with bent bars included:
|
||
# "钢筋的长度,即使是弯筋的长度,也都不要自己测量" -- never measure it
|
||
# yourself, even a bent bar's length; read the parameter.
|
||
def _bar_length_ft(el, know):
|
||
"""A bar's length in feet from the member.length role, or None.
|
||
|
||
NEVER GEOMETRY. Will, 2026-08-30, on a bar's length with BENT BARS
|
||
INCLUDED: "the length of the rebar, even a bent bar's length, must
|
||
never be measured yourself" -- never
|
||
measure it yourself; read the parameter. (He named the parameter by
|
||
name; the name itself is written down in the plant layer under the
|
||
`member.length` role, which is where a parameter name belongs.) A bent
|
||
bar consumes its DEVELOPED length off the stick, which the parameter holds
|
||
and what a bounding box cannot see -- a box only measures the envelope
|
||
the bar folds into, so it understates every bent bar and would pass the
|
||
exact pieces this check exists to catch. There is no fallback here.
|
||
|
||
None means the role is unmapped or the parameter is absent -- a
|
||
could-not-judge the caller names. A parameter that is PRESENT and holds
|
||
0.0 returns 0.0, which is a third state: measured, 260 elements on the
|
||
live model are in it, and zero is neither a length nor an absence.
|
||
Verified on the live model: the parameter is on the INSTANCE and its
|
||
internal unit is decimal feet (4.41666.. reads as 4'-5").
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
value = Q.rdouble(el, know, 'member.length')
|
||
if value is not None:
|
||
return value
|
||
try:
|
||
symbol = el.Symbol
|
||
except Exception:
|
||
return None
|
||
if symbol is None:
|
||
return None
|
||
return Q.rdouble(symbol, know, 'member.length')
|
||
|
||
|
||
BAR_SIZE_ROLE = 'member.bar_size'
|
||
|
||
# WHEN NEITHER OF THE TWO SIZE FIELDS CAN BE READ AT ALL -- the plant-level
|
||
# condition, said once, instead of the same per-bar sentence repeated for
|
||
# every bar on the piece.
|
||
#
|
||
# It used to say the role was unmapped and cite the owner's open question A3.
|
||
# BOTH OF THOSE ARE NOW FALSE: A3 was answered by measurement on 2026-08-31,
|
||
# `member.bar_size` is declared in `knowledge.PARAMETER_ROLES`, and a second
|
||
# reader exists for the population that field does not cover. A message that
|
||
# sends a reader to a closed question is worse than no message.
|
||
BAR_SIZE_UNMAPPED = (
|
||
'neither of the two fields a bar\'s size is read from can be read here. '
|
||
'parameters.{0} is mapped in no knowledge layer -- that is the field the '
|
||
'STRAIGHT bars carry their size in -- and '
|
||
'drawings.bar_size_rule.description_fallback is off, which is the reader '
|
||
'for the BENT ones, whose size is the leading token of the part '
|
||
'description on their family. The two cover DISJOINT populations, so '
|
||
'neither is the other\'s fallback and turning on one says nothing about '
|
||
'the bars of the other. Until one of them can be read a bar is not '
|
||
'judged against the generic stock length, because a #4 at a plant that '
|
||
'stocks #4 in 20 ft lengths would pass a 40 ft comparison'
|
||
.format(BAR_SIZE_ROLE))
|
||
|
||
|
||
def _bar_size_rule(know):
|
||
rule = K.get(know, 'drawings.bar_size_rule')
|
||
return rule if isinstance(rule, dict) else {}
|
||
|
||
|
||
def _no_size_reader(know):
|
||
"""True when NEITHER size field is readable at this plant.
|
||
|
||
AN `and`, NOT AN `or`, and that is the whole point of the two-reader
|
||
shape: with the size parameter mapped the straight bars can still be
|
||
judged even with the description reader off, and with the description
|
||
reader on the bent ones can be judged even with nothing mapped. Only when
|
||
both are shut is `BAR_SIZE_UNMAPPED` the true sentence.
|
||
"""
|
||
return (not K.param_names(know, BAR_SIZE_ROLE)
|
||
and not _bar_size_rule(know).get('description_fallback'))
|
||
|
||
|
||
def _bar_size_values(el, know):
|
||
"""(size parameter value, part description) -- the two fields a size
|
||
can be read out of, read off the element.
|
||
|
||
TWO READS FOR TWO POPULATIONS, and neither is the other's fallback. The
|
||
same shape, and for the same measured reason, as `_reinforcing_values`:
|
||
the READ touches Revit and the DECISION does not, so the decision lives
|
||
in `_bar_size` where a host test can reach it.
|
||
|
||
Measured over 24,539 rebar members in 13 families, 2026-08-31: the one
|
||
STRAIGHT family carries the size parameter and not one of the twelve bent
|
||
families does; all twelve bent families carry the part description on
|
||
their TYPE (instance count zero) and no straight member does. The sets
|
||
are disjoint, so no bar is ever read twice and no precedence between them
|
||
has ever decided anything.
|
||
|
||
The size parameter is read instance-then-type -- `_bar_mark`'s precedence,
|
||
for `_bar_mark`'s measured reason, and it is a UNION rather than a
|
||
ranking. The description is read where the plant says to read it
|
||
(`drawings.bar_size_rule.description_read_from`, `[type]` by default),
|
||
and WHICH ELEMENT IT IS READ OFF IS LOAD-BEARING: the ASSEMBLY's identity
|
||
description is the owner's freeform notes and the FAMILY's is structured
|
||
plant data, one parameter name over two kinds of element (see
|
||
`docs/knowledge-override.md`). Reading the wrong one gets prose where
|
||
structure was expected and looks fine doing it.
|
||
|
||
Nothing is read for a role no layer maps -- `param_names` comes back
|
||
empty and `rstr` never touches the element -- so a plant carrying only
|
||
one of the two pays for only one read per member.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
try:
|
||
symbol = el.Symbol
|
||
except Exception:
|
||
symbol = None
|
||
|
||
raw = Q.rstr(el, know, BAR_SIZE_ROLE)
|
||
if not raw and symbol is not None:
|
||
raw = Q.rstr(symbol, know, BAR_SIZE_ROLE)
|
||
|
||
rule = _bar_size_rule(know)
|
||
role = rule.get('description_role')
|
||
description = None
|
||
if role:
|
||
order = rule.get('description_read_from')
|
||
order = [str(w).strip().lower() for w in order] \
|
||
if isinstance(order, list) else []
|
||
order = [w for w in order if w in ('instance', 'type')] or ['type']
|
||
for where in order:
|
||
source = symbol if where == 'type' else el
|
||
if source is None:
|
||
continue
|
||
description = Q.rstr(source, know, role)
|
||
if description:
|
||
break
|
||
return raw, description
|
||
|
||
|
||
def _bar_size(know, parameter_value, description):
|
||
"""A bar's size row fields: the size, WHICH FIELD ANSWERED, and why.
|
||
|
||
Returns `{'size', 'size_basis', 'size_why'}`, always -- `size` None when
|
||
nothing could read one, and `size_why` a sentence in every case, because
|
||
a bar set aside without a reason is the silence this whole table exists
|
||
to prevent.
|
||
|
||
THE DECISION IS `knowledge.bar_size_of` AND IS NOT REIMPLEMENTED HERE.
|
||
That function owns both readers: the size field behind
|
||
`parameters.member.bar_size` (`basis: 'parameter'`) and the size at the
|
||
front of the part description (`basis: 'description'`), which the twelve
|
||
bent families are the entire population of. `_bar_size_values` does the
|
||
element reads; this turns the answer into the three fields a bar row
|
||
carries downstream.
|
||
|
||
`basis` IS CARRIED, NOT DROPPED. "40 ft" and "40 ft for a #4" are
|
||
different claims, and so are "read from a dedicated size parameter" and
|
||
"parsed off the front of a description" -- a verdict that cannot say what
|
||
it read cannot be audited, so 5.7 prints the basis under its own finding
|
||
code (`BAR-LENGTH-SIZE-BASIS`).
|
||
|
||
A DESCRIPTION THAT DOES NOT PARSE LEAVES THE BAR UNJUDGED. Not because
|
||
the field is unreliable -- it is structured plant data, and 7,405 of
|
||
7,405 bent members parse in one uniform shape -- but because that shape
|
||
IS the evidence that the leading token is a size. A reworded description
|
||
must leave a bar unjudged rather than mis-sized.
|
||
|
||
NO PARAMETER NAME IS NAMED HERE, in either half. Both fields are reached
|
||
through roles, so the moment a layer maps one the per-size table starts
|
||
being used with no code change -- which is exactly how the straight half
|
||
turned on when question A3 was answered by measurement on 2026-08-31.
|
||
"""
|
||
result = K.bar_size_of(know, parameter_value, description)
|
||
return {'size': result.get('size'),
|
||
'size_basis': result.get('basis'),
|
||
'size_why': result.get('why')}
|
||
|
||
|
||
# ------------------------------------------- 5.7 bar length vs stock
|
||
|
||
def check_bar_stock_length(t, intent, know, spec):
|
||
"""Checklist 5.7: no bar is longer than the stock it is cut from.
|
||
|
||
Owner's ruling, 2026-08-30: "rebars come in 40ft stock. unless
|
||
overwritten by other knowledge, all rebars pieces must be less than
|
||
40ft." The number is PUBLISHED data, not a plant habit, so it lives in
|
||
`knowledge/catalogue.yaml` as `catalogue.rebar.stock_length_ft` and any
|
||
higher layer may override it. With the key null the check does not run
|
||
and says so -- it never assumes 40.
|
||
|
||
The length comes from the `member.length` role and from nothing else;
|
||
see `_bar_length_ft` for why a bounding box is not an acceptable
|
||
fallback. Gathering only: the verdict is
|
||
`ticketpage.bar_stock_length_status`.
|
||
|
||
WHAT IT DOES NOT CLAIM. A bar at or over stock is not automatically a
|
||
defect -- a continuous or spliced run is a real detail at this plant
|
||
(the catalogue records `x CONT. (SEE PLAN) LAP SPLICE`), and the
|
||
owner's ruling on whether the 60 ft marks in this model are those has
|
||
been asked for and has not landed. So the finding is a WARN that says
|
||
the ruling is pending, grouped BY MARK rather than by instance, and the
|
||
answer when it comes is a list in
|
||
`catalogue.rebar.stock_exempt_marks` -- a config edit, not a code
|
||
change. Nothing is pre-exempted here.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
t['_bar_length_silence'] = None
|
||
stock_ft = K.get(know, 'catalogue.rebar.stock_length_ft')
|
||
by_size = K.get(know, 'catalogue.rebar.stock_by_size') or {}
|
||
if not stock_ft and not by_size:
|
||
t['_bar_length_silence'] = (
|
||
'no catalogue.rebar.stock_length_ft and no '
|
||
'catalogue.rebar.stock_by_size -- the stock a bar is cut '
|
||
'from is not written down, so no bar length could be judged')
|
||
return []
|
||
if not K.param_names(know, 'member.length'):
|
||
t['_bar_length_silence'] = (
|
||
'no parameters.member.length in plant knowledge -- the tool does '
|
||
'not know which parameter holds a bar\'s length, and it will not '
|
||
'measure one off the geometry')
|
||
return []
|
||
bars, reason, unknown = _bar_members(t, know, item='5.7')
|
||
if reason:
|
||
t['_bar_length_silence'] = reason
|
||
return []
|
||
if not bars:
|
||
t['_bar_length_silence'] = _no_bars_reason(
|
||
know, 'No bar length was compared against stock.', unknown)
|
||
return []
|
||
|
||
# PER SIZE, THROUGH `knowledge.rebar_stock`. This used to hold every bar
|
||
# to the one generic `stock_length_ft`, so at a plant stocking #4 in 20 ft
|
||
# lengths a 30 ft #4 PASSED -- silently, on a real defect class, which is
|
||
# the false OK `stock_by_size` was built to fix and which was still live
|
||
# because nothing called it. Wired 2026-08-31.
|
||
#
|
||
# BOTH HALVES OF THE POPULATION ARE READ, and the bent half is not a
|
||
# rounding error: roughly 30% of the reinforcing in this model is bent.
|
||
# The straight bars answer from the size parameter and the bent ones from
|
||
# the front of their family's part description -- two disjoint
|
||
# populations, two readers, no precedence between them (`_bar_size`).
|
||
# Wired 2026-08-31; the straight half had been live since the morning and
|
||
# the bent half was still standing down.
|
||
#
|
||
# A BAR WHOSE SIZE CANNOT BE RESOLVED IS NOT JUDGED. Not against the
|
||
# generic number either: "40 ft" and "40 ft for THIS SIZE" are different
|
||
# claims, and the generic one is wrong in the direction that passes a
|
||
# defect. Those bars are set aside by name, with the reason THAT bar
|
||
# failed to resolve, and the row cannot go green over them.
|
||
sized, sizeless = [], []
|
||
generic_used = set()
|
||
for bar in bars:
|
||
stock = K.rebar_stock(know, bar.get('size'))
|
||
if stock.get('length_ft') is None:
|
||
# THE REASON HAS TO BE THIS BAR'S OWN. `rebar_stock` only knows
|
||
# that a size was missing; the gather knows WHY it was missing
|
||
# HERE -- a bent bar whose description was reworded and a plant
|
||
# that maps nothing are different problems with different fixes,
|
||
# and a reader sent to the wrong one re-checks a file that is
|
||
# already right.
|
||
why = stock.get('why')
|
||
if not bar.get('size') and bar.get('size_why'):
|
||
why = bar['size_why']
|
||
sizeless.append((bar, why))
|
||
continue
|
||
if stock.get('basis') == 'generic':
|
||
generic_used.add(stock.get('size') or '(unnamed size)')
|
||
row = dict(bar)
|
||
row['stock_ft'] = stock['length_ft']
|
||
row['stock_why'] = stock.get('why')
|
||
sized.append(row)
|
||
|
||
if not sized:
|
||
# `_no_size_reader` is the plant-level condition -- NEITHER field
|
||
# readable -- and it is said once instead of repeating one identical
|
||
# per-bar sentence for every bar on the piece. With either reader on,
|
||
# the bars' own sentences are the precise ones and they are what
|
||
# prints.
|
||
t['_bar_length_silence'] = (
|
||
'no bar on this piece has a size that could be resolved to a '
|
||
'stock length, so nothing was judged -- {0}{1}'
|
||
.format(BAR_SIZE_UNMAPPED if _no_size_reader(know) else
|
||
'; '.join(sorted(set([w for (_b, w) in sizeless if w])))
|
||
or BAR_SIZE_UNMAPPED,
|
||
'' if not sizeless else
|
||
'. Affected marks: ' + ', '.join(sorted(set(
|
||
[str(b.get('mark') or b.get('family') or b.get('id'))
|
||
for (b, _w) in sizeless])))))
|
||
return []
|
||
|
||
findings, unread, zero, exempted = TP.bar_stock_length_status(
|
||
sized, stock_ft, K.get(know, 'catalogue.rebar.stock_exempt_marks', []))
|
||
# What was set aside, said out loud. Under its OWN code, so it can
|
||
# neither decide the row nor disappear -- 5.2's BED-REVERSE-UNCLASSIFIED
|
||
# shape.
|
||
said = []
|
||
if unread:
|
||
said.append('{0} bar(s) carry no readable length: {1}'
|
||
.format(len(unread), ', '.join(unread)))
|
||
if zero:
|
||
said.append('{0} bar(s) hold a length of 0.0, which is not a '
|
||
'measurable length and is not an absence either: {1}'
|
||
.format(len(zero), ', '.join(zero)))
|
||
if exempted:
|
||
said.append('{0} mark(s) were not asked, being listed in '
|
||
'catalogue.rebar.stock_exempt_marks: {1}'
|
||
.format(len(exempted), ', '.join(exempted)))
|
||
if sizeless:
|
||
# Set aside for a THIRD reason, and it is not the other two: the
|
||
# length was readable, the mark was not exempt, and nobody knows what
|
||
# length of stick this size arrives in. Never folded into the pass.
|
||
said.append('{0} bar(s) have no resolvable size, so no stock length '
|
||
'applies to them and they were not judged: {1} -- {2}'
|
||
.format(len(sizeless), ', '.join(sorted(set(
|
||
[str(b.get('mark') or b.get('family') or b.get('id'))
|
||
for (b, _w) in sizeless]))),
|
||
'; '.join(sorted(set(
|
||
[w for (_b, w) in sizeless if w]))) or
|
||
'no reason was recorded, which is itself a defect'))
|
||
|
||
if not findings:
|
||
# NOTHING WAS COMPARED, and returning only the set-aside note would
|
||
# be worse than saying nothing: `checklist_states` gives a row whose
|
||
# owner code is absent but whose check logged RAN the status OK, so
|
||
# a ticket where every bar was unreadable would render GREEN. The
|
||
# detail goes into the probe's reason instead, and the row goes red.
|
||
t['_bar_length_silence'] = (
|
||
'no bar on this piece had a length that could be compared '
|
||
'against stock{0}'
|
||
.format(' -- ' + '; '.join(said) if said else ''))
|
||
return []
|
||
|
||
out = []
|
||
for (sev, msg) in findings:
|
||
if sev == 'WARN':
|
||
msg += (' -- whether a continuous or spliced run may exceed '
|
||
'stock is with the owner; a mark he rules exempt goes '
|
||
'in catalogue.rebar.stock_exempt_marks')
|
||
out.append(Finding(sev, 'BAR-LENGTH', msg))
|
||
if said:
|
||
out.append(Finding('OK', 'BAR-LENGTH-UNREAD',
|
||
'nothing is claimed about these: {0}'
|
||
.format('; '.join(said))))
|
||
if generic_used:
|
||
# WHICH NUMBER WAS USED, AND WHOSE. "40 ft" and "40 ft for this size"
|
||
# are different claims and a reader cannot tell them apart from a
|
||
# verdict alone. Its own code, unowned by the row, so it can neither
|
||
# decide 5.7 nor disappear -- 5.2's BED-REVERSE-UNCLASSIFIED shape.
|
||
out.append(Finding(
|
||
'OK', 'BAR-LENGTH-GENERIC',
|
||
'{0} size(s) are not listed in catalogue.rebar.stock_by_size, so '
|
||
'they were judged against the generic '
|
||
'catalogue.rebar.stock_length_ft, which is not size specific and '
|
||
'is commonly much longer than the small sizes really arrive in: '
|
||
'{1}'.format(len(generic_used), ', '.join(sorted(generic_used)))))
|
||
basis = _size_basis_note(know, sized)
|
||
if basis:
|
||
# WHICH FIELD ANSWERED, per bar, kept out of the row's own code for
|
||
# the same reason as the two notes above: provenance may never decide
|
||
# 5.7 and may never disappear from it either. "read from a dedicated
|
||
# size parameter" and "parsed off the front of a description" are
|
||
# different claims about the same `#4`, and a reader cannot tell them
|
||
# apart from a verdict.
|
||
out.append(Finding('OK', 'BAR-LENGTH-SIZE-BASIS', basis))
|
||
return out
|
||
|
||
|
||
def _size_basis_note(know, sized):
|
||
"""Which field answered for the bars that were judged, in one sentence.
|
||
|
||
TWO READERS, TWO POPULATIONS -- and this is where a report can say so for
|
||
a given piece. Measured 2026-08-31 over 24,539 rebar members: the size
|
||
parameter answers for the one straight family and the part description
|
||
for all twelve bent ones, with no member in both. So a note naming both
|
||
on one piece is two populations reported, never one bar read twice.
|
||
|
||
A judged bar whose basis was not recorded is named as such rather than
|
||
folded into either group: an unrecorded provenance is a gap, and the
|
||
likeliest way this ever breaks is a refactor quietly dropping the field.
|
||
"""
|
||
role = _bar_size_rule(know).get('description_role') or 'the description role'
|
||
phrases = {
|
||
'parameter': 'read from the size field behind parameters.{0}'
|
||
.format(BAR_SIZE_ROLE),
|
||
'description': 'parsed from the part description behind parameters.{0}'
|
||
.format(role),
|
||
None: 'from a source this run did not record',
|
||
}
|
||
groups = {}
|
||
for bar in sized:
|
||
groups.setdefault(bar.get('size_basis'), []).append(bar)
|
||
if not groups:
|
||
return None
|
||
parts = []
|
||
for key in sorted(groups, key=lambda k: str(k)):
|
||
rows = groups[key]
|
||
sizes = sorted(set([str(b.get('size')) for b in rows if b.get('size')]))
|
||
parts.append('{0} bar(s) {1}{2}'
|
||
.format(len(rows),
|
||
phrases.get(key, 'from {0!r}'.format(key)),
|
||
' ({0})'.format(', '.join(sizes)) if sizes
|
||
else ''))
|
||
return ('how each judged bar\'s size was resolved -- {0}'
|
||
.format('; '.join(parts)))
|
||
|
||
|
||
# Owner, verbatim: read a report and called it "5.6/5.7 没跑" ("5.6/5.7
|
||
# didn't run").
|
||
def _no_bars_reason(know, consequence, unknown=None):
|
||
"""Why a piece produced no bars -- and the reason is usually the key.
|
||
|
||
`consequence` is the asking row's own half-sentence: what could not be
|
||
compared because no bar was recognised. 5.6 and 5.7 stand down together
|
||
on the same gather and used to print the SAME paragraph twice, one row
|
||
under the other, which reads as a copy-paste artefact rather than as two
|
||
checks each saying what it lost.
|
||
|
||
THE TWO CASES ARE NOT THE SAME CLAIM, and the old text made only one.
|
||
"no reinforcing found on this piece" is a statement ABOUT THE PIECE, and
|
||
with `drawings.reinforcing_families` empty the tool is in no position to
|
||
make it: this plant models its bars outside any rebar category
|
||
(measured through the bridge 2026-08-30), so a piece with three hundred
|
||
members and a bar line in its design summary produces the same sentence
|
||
as a piece with genuinely no steel in it. That is the sentence the owner
|
||
read as "5.6/5.7 didn't run". It now says the tool could not RECOGNISE any,
|
||
names the key that would let it, and keeps the claim about the piece for
|
||
the case where the key IS filled in and the search really did come back
|
||
empty.
|
||
|
||
Either way the row stays NOT RUN and red. Naming the switch is not the
|
||
same as flipping it, and nothing here decides that a piece is clean.
|
||
|
||
`unknown` IS WHAT THE BOOLEAN USED TO THROW AWAY -- the members
|
||
`reinforcing_verdict` could not judge, which `_is_reinforcing` collapsed
|
||
into "not a bar". With it, the two sentences below stop being a guess
|
||
about which case this is: a piece with unjudged members is NOT a piece
|
||
with no steel, and it is now said with a count and a name.
|
||
|
||
`knowledge.reinforcing_mode` supplies the sentence saying WHY the rule is
|
||
dormant. That sentence already exists, is already written for a reader,
|
||
and is the difference between "nothing was recognised" and "nothing was
|
||
recognised BECAUSE the parameter behind the rule has not been verified
|
||
against a model yet" -- the second is a thing somebody can act on.
|
||
"""
|
||
_mode, mode_why = K.reinforcing_mode(know)
|
||
said = []
|
||
for row in (unknown or []):
|
||
fam = (row or {}).get('family') or '(unnamed family)'
|
||
if fam not in said:
|
||
said.append(fam)
|
||
unjudged = ''
|
||
if unknown:
|
||
unjudged = (' {0} member(s), in {1} famil(ies), COULD NOT BE JUDGED '
|
||
'either way and are not counted as "not a bar": {2}.'
|
||
.format(len(unknown), len(said), ', '.join(sorted(said))))
|
||
|
||
families = _upper_list(K.get(know, 'drawings.reinforcing_families', []))
|
||
if not families:
|
||
return ('no member of this piece could be RECOGNISED as reinforcing, '
|
||
'which is not the same as the piece carrying none: nothing '
|
||
'on it sits in a rebar category and '
|
||
'drawings.reinforcing_families is empty, so a bar modelled '
|
||
'as a generic family is invisible here.{1} {2} HOW THE TOOL '
|
||
'IS DECIDING: {0} TO MAKE THIS ROW RUN: name the bar '
|
||
'families in drawings.reinforcing_families (job or plant '
|
||
'knowledge, matched as substrings), or finish the parameter '
|
||
'rule.'.format(mode_why, unjudged, consequence))
|
||
return ('no member of this piece is in a rebar category or matches any '
|
||
'of the {0} name(s) in drawings.reinforcing_families ({1}), so '
|
||
'this piece appears to carry no reinforcing.{3} {2} HOW THE TOOL '
|
||
'IS DECIDING: {4}'
|
||
.format(len(families), ', '.join(families), consequence,
|
||
unjudged, mode_why))
|
||
|
||
|
||
# --------------------------------------- 5.6 bar marks unique
|
||
|
||
def check_bar_mark_uniqueness(t, intent, know, spec):
|
||
"""Checklist 5.6: the printed bar marks and the modelled ones agree.
|
||
|
||
The computable half of the old 5.5, split on the owner's approval.
|
||
Legibility stays with a person at 5.5 (grade D); uniqueness is a
|
||
comparison and is done here.
|
||
|
||
WHERE THE PRINTED SIDE COMES FROM, AND WHY IT IS NOT TAGS. The owner's
|
||
spec named tags' `TagText` first. Read off the live model 2026-08-30
|
||
through the bridge: this plant's tickets carry **zero** IndependentTag
|
||
and zero keynote elements -- every printed bar call-out is a plain
|
||
TextNote on a Detail view (`SR324 (F.F.)`, `SR310 (E.L.)`). Both paths
|
||
are gathered anyway, because a tag-carrying plant must not fall through
|
||
to silence, but the tag path finds nothing here and the check does not
|
||
depend on it.
|
||
|
||
WHICH VIEWS, AND THE POLLUTION QUESTION. Asked and answered on the same
|
||
read: the note views are Legends and carry headings and prose, the
|
||
call-outs live on Detail views. A Legend's prose is exactly the text
|
||
that would swamp a mark enumeration, so LEGENDS ARE EXCLUDED. That is a
|
||
view-TYPE test, not a name test -- no plant nomenclature is needed for
|
||
it and none is invented.
|
||
|
||
Gathering only; `ticketpage.bar_mark_uniqueness_status` decides.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
t['_barmark_silence'] = None
|
||
bars, reason, unknown = _bar_members(t, know, item='5.6')
|
||
if reason:
|
||
t['_barmark_silence'] = reason
|
||
return []
|
||
if not bars:
|
||
t['_barmark_silence'] = _no_bars_reason(
|
||
know, 'No printed bar mark was compared against the model.',
|
||
unknown)
|
||
return []
|
||
# Decided BEFORE the sheet is read, on purpose. With no mark on any bar
|
||
# there is nothing to compare the printed text against, and asking a
|
||
# view for its contents makes Revit generate a view nobody has opened --
|
||
# a real cost, paid here for an answer that cannot change.
|
||
if not [bar for bar in bars if bar.get('mark')]:
|
||
t['_barmark_silence'] = (
|
||
'no modelled bar carried a mark on its instance or its type, so '
|
||
'there was nothing to compare the sheet against -- {0}'
|
||
.format(', '.join(sorted(set(
|
||
bar.get('family') or '(unnamed family)' for bar in bars)))))
|
||
return []
|
||
printed, why = _printed_callouts(t)
|
||
if printed is None:
|
||
t['_barmark_silence'] = why
|
||
return []
|
||
|
||
# THE TWO NARROWINGS, BOTH OFF THE KNOWLEDGE LAYERS AND NEITHER INVENTED
|
||
# HERE (2026-08-31, on the owner's "SR4996 是 SP29/30 的附属 rebar,我记得
|
||
# 我增加过这条知识了,为什么还在 WP117 报 warn?").
|
||
#
|
||
# He had recorded it. `drawings.annotation_covered_by_parent` carried
|
||
# SR4996 -> SP29/SP30, 5.2 read it, the takeoff read it, and THIS CHECK
|
||
# DID NOT -- so a bar that is by design never drawn on its own was
|
||
# reported as never printed. Landed knowledge with no reader, live on a
|
||
# check, which is the drift detector's oldest category.
|
||
#
|
||
# The second narrowing is the same run's other half: WP115 reported P78,
|
||
# P114, SP29, SP30 and SP31 as "printed bar marks matching no bar in the
|
||
# model". They are hardware. `part_numbers.prefixes` can tell, and until
|
||
# now nothing asked it.
|
||
#
|
||
# BOTH ARE INJECTED RATHER THAN READ INSIDE `bar_mark_uniqueness_status`,
|
||
# which is what keeps that function Revit-free and host-testable, and the
|
||
# same seam `muted_checks(getter=)` uses.
|
||
from vibe import knowledge as K
|
||
findings, unread = TP.bar_mark_uniqueness_status(
|
||
printed, bars,
|
||
attached=K.attached_parents(know),
|
||
classify=lambda token: K.printed_token_population(know, token))
|
||
if not findings:
|
||
# Every bar was unmarked. Returning only the set-aside note would
|
||
# render the row GREEN -- see the same guard in 5.7 -- so the detail
|
||
# goes into the probe's reason and the row goes red.
|
||
t['_barmark_silence'] = (
|
||
'no modelled bar carried a mark on its instance or its type, so '
|
||
'there was nothing to compare the sheet against{0}'
|
||
.format(' -- ' + ', '.join(unread) if unread else ''))
|
||
return []
|
||
out = [Finding(sev, 'BARMARK-UNIQUE', msg) for (sev, msg) in findings]
|
||
if unread:
|
||
out.append(Finding(
|
||
'OK', 'BARMARK-UNREAD',
|
||
'{0} bar(s) carry no mark on the instance or the type, so '
|
||
'nothing is claimed about whether they are printed: {1}'
|
||
.format(len(unread), ', '.join(unread))))
|
||
return out
|
||
|
||
|
||
# ----------------------------------- 1.5 call-outs must not overlap
|
||
|
||
# Views whose text is prose, not call-outs. A Legend on this plant's
|
||
# tickets is a note view: it carries its own heading, or the jacking-force
|
||
# paragraph. Enumerating marks out of that produces noise in 5.6, and
|
||
# comparing its one heading against itself produces nothing in 1.5 -- so
|
||
# both checks skip it, by view TYPE, which needs no plant fact.
|
||
_PROSE_VIEW_TYPES = ('LEGEND',)
|
||
|
||
|
||
def _view_type_name(view):
|
||
try:
|
||
return str(view.ViewType).upper()
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
def _annotation_views(t):
|
||
"""(views, reason) -- the placed views whose text is call-outs."""
|
||
views = _placed_views(t)
|
||
if views is None:
|
||
return None, ('could not reach a document to read this ticket\'s '
|
||
'annotations')
|
||
keep = [v for v in views
|
||
if _view_type_name(v) not in _PROSE_VIEW_TYPES]
|
||
if not keep:
|
||
return None, ('this ticket places no drawing view -- every placed '
|
||
'view is a legend, which carries prose rather than '
|
||
'call-outs')
|
||
return keep, None
|
||
|
||
|
||
def _printed_callouts(t):
|
||
"""(rows, reason) -- every printed call-out string on the drawing views.
|
||
|
||
A row is {'text', 'view', 'kind', 'element'}. TextNotes and
|
||
IndependentTags both, because a plant that tags is a plant this must
|
||
still read; measured here, the tag list comes back empty.
|
||
"""
|
||
from vibe import ticketqc as Q
|
||
from Autodesk.Revit.DB import FilteredElementCollector, BuiltInCategory
|
||
|
||
views, reason = _annotation_views(t)
|
||
if views is None:
|
||
return None, reason
|
||
tdoc = _ticket_doc(t)
|
||
if tdoc is None:
|
||
return None, ('could not reach a document to read this ticket\'s '
|
||
'call-outs')
|
||
rows = []
|
||
for view in views:
|
||
try:
|
||
name = view.Name
|
||
except Exception:
|
||
name = '(unnamed view)'
|
||
try:
|
||
for note in Q._text_notes(tdoc, view.Id):
|
||
rows.append({'text': Q.clean_text(note.Text),
|
||
'view': name, 'kind': 'text note',
|
||
'element': note})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for tag in (FilteredElementCollector(tdoc, view.Id)
|
||
.OfCategory(BuiltInCategory.OST_MultiCategoryTags)
|
||
.WhereElementIsNotElementType().ToElements()):
|
||
rows.append({'text': _tag_text(tag), 'view': name,
|
||
'kind': 'tag', 'element': tag})
|
||
except Exception:
|
||
pass
|
||
return rows, None
|
||
|
||
|
||
def _tag_text(tag):
|
||
for attr in ('TagText', 'TagLabel'):
|
||
try:
|
||
value = getattr(tag, attr)
|
||
except Exception:
|
||
continue
|
||
if value:
|
||
return str(value)
|
||
return ''
|
||
|
||
|
||
def _annotation_rotated(el):
|
||
"""True / False / None -- whether this annotation prints at an angle.
|
||
|
||
None means it could not be established, which is NOT False: an
|
||
annotation whose rotation is unknown is set aside rather than compared
|
||
with an axis-aligned box that may overstate its ink by the length of
|
||
the string.
|
||
"""
|
||
for attr in ('RotationAngle', 'Rotation'):
|
||
try:
|
||
value = getattr(el, attr)
|
||
except Exception:
|
||
continue
|
||
try:
|
||
return abs(float(value)) > 1e-6
|
||
except (TypeError, ValueError):
|
||
continue
|
||
try:
|
||
direction = el.BaseDirection
|
||
except Exception:
|
||
return None
|
||
try:
|
||
return abs(float(direction.Y)) > 1e-6
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def check_callout_overlap(t, intent, know, spec):
|
||
"""Checklist 1.5: no two printed call-outs sit on top of each other.
|
||
|
||
Owner's spec: text-bearing annotations (text notes, tags, keynotes) on
|
||
the SAME placed view whose view-space bounding boxes intersect with
|
||
positive area -> WARN naming both texts and the size of the overlap.
|
||
|
||
TEXT AGAINST TEXT ONLY. Text against LINEWORK is deliberately out of
|
||
scope and this is the docstring that says so: a call-out printed over a
|
||
drawn edge is the normal condition on every elevation on every ticket,
|
||
so flagging it would report the whole sheet and teach the reader to
|
||
ignore the row.
|
||
|
||
THE TWO HONESTY LIMITS, BOTH REPORTED BY THE CHECK ITSELF:
|
||
|
||
* a bounding box APPROXIMATES the printed extent. It carries the
|
||
text's leading and side bearings, so it is a little larger than the
|
||
ink and a small intersection can be two notes that print clear.
|
||
That is why the message states the overlap SIZE and why the item is
|
||
grade C -- a shortlist for a person, not a defect claim.
|
||
* ROTATED TEXT reports an AXIS-ALIGNED box, which circumscribes the
|
||
rotated glyphs and overstates them badly. Those annotations are set
|
||
aside and named under CALLOUT-OVERLAP-UNREAD rather than compared,
|
||
because comparing them manufactures overlaps that do not print.
|
||
An annotation whose rotation cannot be determined is set aside too.
|
||
|
||
Boxes are converted from view space to PRINTED INCHES through the
|
||
view's scale, because inches on paper is the only unit a reader can act
|
||
on -- and a view whose scale cannot be read is set aside, not guessed.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
t['_callout_silence'] = None
|
||
rows, reason = _printed_callouts(t)
|
||
if rows is None:
|
||
t['_callout_silence'] = reason
|
||
return []
|
||
if not rows:
|
||
t['_callout_silence'] = ('no text note or tag on any drawing view of '
|
||
'this ticket, so no pair could overlap')
|
||
return []
|
||
|
||
items = _callout_items(t, rows)
|
||
if items is None:
|
||
t['_callout_silence'] = ('no placed view of this ticket reported a '
|
||
'scale, so no overlap could be sized in '
|
||
'printed inches')
|
||
return []
|
||
|
||
findings, unread, rotated = TP.callout_overlap_status(items)
|
||
said = []
|
||
if unread:
|
||
said.append('{0} annotation(s) reported no readable box or no view '
|
||
'scale: {1}'.format(len(unread), ', '.join(unread)))
|
||
if rotated:
|
||
said.append('{0} annotation(s) print at an angle, or at an angle '
|
||
'that could not be read, and an axis-aligned box '
|
||
'overstates rotated text: {1}'
|
||
.format(len(rotated), ', '.join(rotated)))
|
||
if not findings:
|
||
# Every annotation was set aside. Returning only the set-aside note
|
||
# would render the row GREEN -- see the same guard in 5.7.
|
||
t['_callout_silence'] = (
|
||
'no annotation on this ticket could be placed on paper, so no '
|
||
'pair was compared{0}'
|
||
.format(' -- ' + '; '.join(said) if said else ''))
|
||
return []
|
||
out = [Finding(sev, 'CALLOUT-OVERLAP', msg) for (sev, msg) in findings]
|
||
if said:
|
||
out.append(Finding('OK', 'CALLOUT-OVERLAP-UNREAD',
|
||
'not compared, so nothing is claimed about them: '
|
||
'{0}'.format('; '.join(said))))
|
||
return out
|
||
|
||
|
||
def _callout_items(t, rows):
|
||
"""The call-out rows as ticketpage items, boxed in PRINTED INCHES.
|
||
|
||
Returns None when not one view reported a usable scale -- distinct from
|
||
every box being unreadable, which comes back as items with box None and
|
||
is reported as could-not-judge per annotation.
|
||
"""
|
||
views, _reason = _annotation_views(t)
|
||
by_name = {}
|
||
for v in (views or []):
|
||
try:
|
||
by_name[v.Name] = v
|
||
except Exception:
|
||
pass
|
||
any_scale = False
|
||
items = []
|
||
for row in rows:
|
||
view = by_name.get(row['view'])
|
||
scale = _view_scale(view)
|
||
if scale:
|
||
any_scale = True
|
||
box = _printed_box(row['element'], view, scale)
|
||
items.append({'view': row['view'], 'text': row['text'],
|
||
'kind': row['kind'], 'box': box,
|
||
'rotated': _annotation_rotated(row['element'])
|
||
is not False})
|
||
if not any_scale:
|
||
return None
|
||
return items
|
||
|
||
|
||
def _view_scale(view):
|
||
try:
|
||
scale = int(view.Scale)
|
||
except Exception:
|
||
return None
|
||
return scale if scale > 0 else None
|
||
|
||
|
||
def _printed_box(el, view, scale):
|
||
"""(x0, y0, x1, y1) in inches on the printed sheet, or None.
|
||
|
||
`get_BoundingBox(view)` hands back view-space FEET; a view at 1:48
|
||
prints those 48 times smaller, so printed inches is feet * 12 / scale.
|
||
Verified against the live model: a `DUNNAGE` note on a 1:96 view boxes
|
||
5.198 x 2.156 ft, which prints 0.65" x 0.27" -- the size of the word.
|
||
"""
|
||
if view is None or not scale:
|
||
return None
|
||
try:
|
||
box = el.get_BoundingBox(view)
|
||
except Exception:
|
||
return None
|
||
if box is None:
|
||
return None
|
||
try:
|
||
factor = 12.0 / float(scale)
|
||
return (box.Min.X * factor, box.Min.Y * factor,
|
||
box.Max.X * factor, box.Max.Y * factor)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ============================================ 6.6: parity and a bed note
|
||
#
|
||
# Owner's ruling, 2026-08-31: when the count of one part on a piece is ODD,
|
||
# the ticket's BED PLAN must carry a particular phrase. The verdict is
|
||
# `ticketpage.parity_note_status` -- numbers and text in, sentences out,
|
||
# host-testable with no model. What lives here is the two READS the verdict
|
||
# needs, and the reasons each of them can fail.
|
||
#
|
||
# WHERE THE COUNT COMES FROM, AND WHY NOT THE OTHER PLACE. It comes from
|
||
# the MODEL, over `asm.GetMemberIds()` scoped to this piece -- the idiom
|
||
# `check_bom_members` and `_bar_members` already use, and the only one that
|
||
# costs a single scoped pass. The printed BOM is the obvious alternative
|
||
# and it is the wrong one HERE, measurably: `check_bom_members` records
|
||
# that this plant's lifting hardware is among the model extras that NEVER
|
||
# APPEAR ON A BOM ROW. A count taken from the BOM would therefore be zero
|
||
# on every piece, zero is even, and the rule would never once fire -- a
|
||
# check that passes everything, silently, forever.
|
||
#
|
||
# WHAT THE BOM WOULD HAVE CAUGHT AND THIS DOES NOT: a piece whose model and
|
||
# whose printed bill disagree about how many of the part there are. That is
|
||
# a real defect and it is 5.1's question, not this one's; a second reading
|
||
# of it here would be a second answer able to disagree with the first.
|
||
# Likewise the call-outs drawn ON the bed plan -- what a person counting
|
||
# the ticket by eye would actually count -- are 5.2's question. This row
|
||
# asks only what the piece IS and what the plan SAYS.
|
||
|
||
def _parity_note_rule(know):
|
||
"""`drawings.parity_note_rule` as a dict, never None.
|
||
|
||
THE PATH IS A LITERAL HERE ON PURPOSE, and it is the one place in this
|
||
check where a literal is right. `tools/knowledge_drift.py` pairs the
|
||
keys a layer DECLARES against the keys the code READS by scanning for
|
||
the string, so a path reached through a module constant is invisible to
|
||
it -- which is why `drawings.bed_plan` still reports as "declared but
|
||
never read" while `_bed_plan_name` reads it on every ticket. A key this
|
||
audit cannot see is a key nobody notices going stale.
|
||
"""
|
||
rule = K.get(know, 'drawings.parity_note_rule')
|
||
return rule if isinstance(rule, dict) else {}
|
||
|
||
|
||
def _as_text_list(value):
|
||
"""A knowledge value that is a scalar OR a list, as a list of strings.
|
||
|
||
Both shapes have to work for the same reason `knowledge.param_names`
|
||
accepts both: pyRevit's YAML reader is happiest with plain scalars and
|
||
a plant with one wording should be able to write one wording.
|
||
"""
|
||
if value is None:
|
||
return []
|
||
if isinstance(value, string_types):
|
||
value = [value]
|
||
if not isinstance(value, (list, tuple)):
|
||
return []
|
||
return [text_type(v).strip() for v in value if text_type(v).strip()]
|
||
|
||
|
||
def _parity_members(t, know):
|
||
"""(count, total, unknown, reason) -- how many of the part this piece has.
|
||
|
||
`count` is the members whose family name is one the rule names; `total`
|
||
is every member read, printed beside a count of zero so a family name
|
||
that matches nothing is visible instead of silently even. `unknown` is
|
||
every member that could not be IDENTIFIED -- kept, never dropped, the
|
||
same discipline `_bar_members` follows and for a sharper reason here:
|
||
one unread member flips an odd count to an even one.
|
||
|
||
THE MATCH IS ON THE WHOLE FAMILY NAME, CASE-INSENSITIVELY, and that is
|
||
a deliberate choice between the two shapes already in the tree rather
|
||
than a new one. `families.lifter_include` and `drawings.
|
||
reinforcing_families` match SUBSTRINGS; `drawings.
|
||
reinforcing_family_overrides` matches the whole name, because "an entry
|
||
for `SR1` would swallow `SR1101`". A part number is exactly that shape
|
||
of token -- this job's own piece marks are `WP<number>`, so a substring
|
||
token would swallow the piece named for the same number -- so the exact
|
||
rule is the one that applies. A plant whose family name is longer than
|
||
its part number writes the longer name.
|
||
|
||
Cached on the ticket, cache first and the Revit import after it, so a
|
||
test can seed the answer and never stand up a document -- the same
|
||
shape `_bar_members` and `_debond_requirement` use.
|
||
"""
|
||
cached = t.get('_parity_members')
|
||
if cached is not None:
|
||
return (cached['count'], cached.get('total'),
|
||
cached.get('unknown') or [], cached['reason'])
|
||
|
||
names = [n.upper() for n in
|
||
_as_text_list(_parity_note_rule(know).get('family_names'))]
|
||
if not names:
|
||
# Nothing to count. `parity_note_status` owns that sentence, so
|
||
# nothing is cached and no document is opened for a question that
|
||
# has not been asked.
|
||
return None, None, [], None
|
||
|
||
from Autodesk.Revit.DB import ElementId
|
||
|
||
count, total, unknown, reason = 0, 0, [], None
|
||
tdoc = _ticket_doc(t)
|
||
if tdoc is None:
|
||
reason = ('could not reach a document to count this piece\'s '
|
||
'members, so the parity of {0} is unknown'
|
||
.format(', '.join(names)))
|
||
elif not t.get('asm_id'):
|
||
reason = ('this ticket has no assembly, so its members could not be '
|
||
'counted and the parity of {0} is unknown'
|
||
.format(', '.join(names)))
|
||
else:
|
||
try:
|
||
asm = tdoc.GetElement(ElementId(t['asm_id']))
|
||
except Exception:
|
||
asm = None
|
||
if asm is None:
|
||
reason = ('the assembly is not readable, so its members were '
|
||
'not counted and the parity of {0} is unknown'
|
||
.format(', '.join(names)))
|
||
else:
|
||
for mid in asm.GetMemberIds():
|
||
total += 1
|
||
el = tdoc.GetElement(mid)
|
||
if el is None:
|
||
unknown.append(
|
||
{'family': '',
|
||
'why': 'a member id did not resolve to an element'})
|
||
continue
|
||
fam = _family_name(el)
|
||
if not fam:
|
||
unknown.append(
|
||
{'family': '',
|
||
'why': 'a member carries no readable family name'})
|
||
continue
|
||
if text_type(fam).strip().upper() in names:
|
||
count += 1
|
||
if reason:
|
||
count, total = None, None
|
||
t['_parity_members'] = {'count': count, 'total': total,
|
||
'unknown': unknown, 'reason': reason}
|
||
return count, total, unknown, reason
|
||
|
||
|
||
def check_parity_note(t, intent, know, spec):
|
||
"""Checklist 6.6: an odd count of one part demands a BED PLAN note.
|
||
|
||
Owner, 2026-08-31: when the count of that part on a piece is odd, the
|
||
ticket's BED PLAN must carry a particular phrase. WHICH part, WHICH
|
||
parity and WHICH phrase are three separate plant facts and none of them
|
||
is written here -- see `drawings.parity_note_rule` and the block comment
|
||
over `ticketpage.parity_note_status`, which is where the verdict lives.
|
||
|
||
EVERY EMPTY RETURN LEAVES ITS REASON ON THE TICKET. `_parity_note_
|
||
silence` is what the skip probe reads, so a silence is always NOT RUN
|
||
with the sentence that caused it, never a quiet OK.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
|
||
t['_parity_note_silence'] = None
|
||
rule = _parity_note_rule(know)
|
||
count, total, unknown, gather_reason = _parity_members(t, know)
|
||
|
||
# THE BED PLAN IS READ ONLY IF IT MIGHT BE NEEDED. Walking every
|
||
# sheet's placed views to flatten one view's text notes is not free and
|
||
# an even count never needs it, so what is handed over is the reader
|
||
# rather than the text -- `parity_note_status` calls it after the
|
||
# parity gate, or never. It hands back the sentence too: "no view of
|
||
# that name is on this ticket" is only known once the look has happened.
|
||
bed_name, bed_unset = _bed_plan_name(know)
|
||
|
||
def read_bed_plan():
|
||
text = _bed_plan_text(t, know)
|
||
if text is not None:
|
||
return text, None
|
||
return None, ('no view named {0!r} ({1}) could be read on this '
|
||
'ticket, so the note this piece\'s count requires was '
|
||
'not looked for'.format(bed_name, BED_PLAN_KEY))
|
||
|
||
bed_text = None if bed_unset else read_bed_plan
|
||
bed_reason = bed_unset or None
|
||
|
||
findings, reason = TP.parity_note_status(
|
||
count, unknown, bed_text,
|
||
_as_text_list(rule.get('family_names')),
|
||
rule.get('parity'),
|
||
_as_text_list(rule.get('require_text')),
|
||
verified=bool(rule.get('family_verified')),
|
||
unverified_because=rule.get('family_unverified_because'),
|
||
gather_reason=gather_reason,
|
||
bed_reason=bed_reason,
|
||
total=total)
|
||
if reason:
|
||
t['_parity_note_silence'] = reason
|
||
return []
|
||
return [Finding(sev, 'PARITY-NOTE', msg) for (sev, msg) in findings]
|
||
|
||
|
||
# ==================================== 5.8 / 5.9 / 5.10 connectors
|
||
#
|
||
# Owner, 2026-08-31: "insulated panel 加一个 check,检查 connector 是不是存在,
|
||
# 以及够不够" and, the same day, "再增加 connector edge distance 的 check".
|
||
#
|
||
# ONE RECOGNITION GATE, THREE QUESTIONS. `_connector_gate` answers "may this
|
||
# piece's connectors be counted at all", and all three checks call it. Three
|
||
# copies of the gate would drift, and the one that drifted would be the one
|
||
# reporting a count nobody validated.
|
||
#
|
||
# ALL THREE ARE INSULATED-ONLY AND THAT SCOPING IS NOT IN THIS FILE. It is
|
||
# `checks.only_applies_to` in the plant layer, so a plant that builds
|
||
# sandwich panels differently is not arguing with code. On a solid panel the
|
||
# question is meaningless rather than quiet; on a piece nothing could
|
||
# classify, the rows read NOT RUN in red.
|
||
|
||
|
||
def _connector_gate(t, know):
|
||
"""(connectors, reason). `reason` is why nothing may be claimed.
|
||
|
||
THE `family_verified` IDIOM FROM 6.6, and it is here because of what a
|
||
wrong answer costs: 5.8 FAILS on zero, so a recognition rule matching
|
||
nothing would fail every insulated panel in the job, and one matching
|
||
the wrong thing would pass them all. Neither may happen on an
|
||
unconfirmed key.
|
||
"""
|
||
from vibe import knowledge as K
|
||
marks = K.connector_marks(know or {})
|
||
if not marks:
|
||
return None, (
|
||
'connectors.marks is empty: no layer says which part numbers are '
|
||
'connectors at this plant, so none could be recognised. That is a '
|
||
'missing plant fact, not a finding about this piece')
|
||
if not K.get(know or {}, 'connectors.recognition_verified'):
|
||
return None, (
|
||
'connectors.recognition_verified is false -- how a connector is '
|
||
'recognised in the model has not been confirmed, so a count taken '
|
||
'now would be a number nobody has validated. {0}'
|
||
.format(K.get(know or {},
|
||
'connectors.recognition_unverified_because') or
|
||
'No reason is recorded.'))
|
||
return (t.get('connectors') or []), None
|
||
|
||
|
||
def check_connectors_exist(t, intent, know, spec):
|
||
"""5.8: an insulated panel has connectors crossing its insulation."""
|
||
from vibe import ticketpage as TP
|
||
t['_connector_silence'] = None
|
||
connectors, reason = _connector_gate(t, know)
|
||
if reason:
|
||
t['_connector_silence'] = reason
|
||
return []
|
||
severity, message = TP.connectors_exist_status(
|
||
len(connectors), t.get('connector_gated_out') or 0)
|
||
return [Finding(severity, 'CONNECTOR-EXIST', message)]
|
||
|
||
|
||
def check_connectors_sufficient(t, intent, know, spec):
|
||
"""5.9: enough of them for what the design calls for.
|
||
|
||
THE DESIGN SIDE ARRIVES THROUGH THE SUMMARY VOCABULARY (2026-08-31).
|
||
Before that, `intent['connector_spacing_in']` was read and nobody ever
|
||
wrote it, so this row stood down on every press with "the summary parser
|
||
cannot read it yet" -- while the line it needed,
|
||
`HK ST100'S @1'-4" O.C.`, sat in 8.1's uninterpreted list.
|
||
|
||
Two ways in, strongest first, because an intent key is an ANSWER and a
|
||
summary line is a READING:
|
||
|
||
1. `intent['connector_count']` / `intent['connector_spacing_in']` --
|
||
whatever put them there stated them outright, and this defers.
|
||
2. `intent['requirements']`, joined to the plant's own connector
|
||
alias table by `knowledge.connector_spacing_requirement`.
|
||
|
||
A requirement it cannot resolve leaves its own sentence behind and the
|
||
row is NOT RUN with that sentence -- never OK off a count nobody asked
|
||
for.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
t['_connector_count_silence'] = None
|
||
connectors, reason = _connector_gate(t, know)
|
||
if reason:
|
||
t['_connector_count_silence'] = reason
|
||
return []
|
||
stations = None
|
||
if t.get('connectors'):
|
||
stations = [c['local'][0] * 12.0 for c in t['connectors']
|
||
if c.get('local')]
|
||
required_spacing = (intent or {}).get('connector_spacing_in')
|
||
note = None
|
||
if required_spacing is None:
|
||
requirement, why = K.connector_spacing_requirement(
|
||
know or {}, (intent or {}).get('requirements'))
|
||
note = why
|
||
if requirement:
|
||
required_spacing = requirement.get('spacing_in')
|
||
tolerance, tolerance_why = K.connector_spacing_tolerance(know or {})
|
||
severity, message = TP.connectors_sufficient_status(
|
||
len(connectors), (intent or {}).get('connector_count'),
|
||
spacing_in=TP.connector_spacing(stations) if stations else None,
|
||
required_spacing_in=required_spacing,
|
||
requirement_note=note,
|
||
tolerance_in=tolerance, tolerance_why=tolerance_why)
|
||
if severity is None:
|
||
t['_connector_count_silence'] = message
|
||
return []
|
||
return [Finding(severity, 'CONNECTOR-COUNT', message)]
|
||
|
||
|
||
def check_connector_edge_distance(t, intent, know, spec):
|
||
"""5.10: no connector closer to an edge than its product allows."""
|
||
from vibe import knowledge as K
|
||
from vibe import ticketpage as TP
|
||
t['_connector_edge_silence'] = None
|
||
connectors, reason = _connector_gate(t, know)
|
||
if reason:
|
||
t['_connector_edge_silence'] = reason
|
||
return []
|
||
if not connectors:
|
||
# 5.8 OWNS "THERE ARE NONE". Saying it twice sends the reader to two
|
||
# rows for one fact.
|
||
t['_connector_edge_silence'] = (
|
||
'no connector was recognised on this piece, so there was no edge '
|
||
'distance to measure -- 5.8 owns whether that is a defect')
|
||
return []
|
||
edges = t.get('connector_edges') or []
|
||
rows, minimums = [], {}
|
||
for index, connector in enumerate(connectors):
|
||
mark = connector.get('mark')
|
||
if mark not in minimums:
|
||
minimums[mark] = K.connector_edge_minimum(know or {}, mark)
|
||
rows.append((mark, edges[index] if index < len(edges) else None))
|
||
findings, silences = [], []
|
||
for mark in sorted(set([row[0] for row in rows])):
|
||
minimum, source, why = minimums[mark]
|
||
severity, message = TP.connector_edge_status(
|
||
[row[1] for row in rows if row[0] == mark], minimum, source,
|
||
mark=mark)
|
||
if severity is None:
|
||
silences.append('{0} -- {1}'.format(message, why))
|
||
continue
|
||
findings.append(Finding(severity, 'CONNECTOR-EDGE', message))
|
||
if silences and not findings:
|
||
t['_connector_edge_silence'] = '; '.join(silences)
|
||
return []
|
||
if silences:
|
||
# SOME MARKS JUDGED AND SOME NOT. The judged ones are real findings
|
||
# and the rest must not vanish behind them, so the unjudged half
|
||
# rides as its own OK-severity note -- visible, and unable to set
|
||
# the row, which is what an unanswered question deserves.
|
||
findings.append(Finding('OK', 'CONNECTOR-EDGE-UNKNOWN',
|
||
'; '.join(silences)))
|
||
return findings
|
||
|
||
|
||
# ==========================================================================
|
||
# SOLID ZONES AND INSULATION RECESSES -- rows 5.11 to 5.15
|
||
# ==========================================================================
|
||
#
|
||
# Owner, 2026-08-31, having asked twice: "没有 solid zone 相关的检查。lifter
|
||
# 自带 solid zone,没检查这些 solid zone 是不是被加入了 assembly,是不是被显示
|
||
# 在 ticket 上面,hatch pattern 对不对。这些都没有啊" -- then "不光是 solid
|
||
# zone,insulation recess 也得加上。你看看 page 2 里面的那些 hatch pattern
|
||
# 就知道了。"
|
||
#
|
||
# ONE GATE, AND IT IS NOT A FIFTH RECOGNISER. `_solid_zone_gate` asks
|
||
# whether this piece's solid zones may be counted at all, and the zones
|
||
# themselves arrive already gathered off the LIFTERS -- the same recognition
|
||
# the connector and lifter checks use, reached through `GetSubComponentIds()`
|
||
# in `ticketqc.read_ticket`. A second reader for the same parts is how two
|
||
# rows come to disagree about what is on a piece.
|
||
#
|
||
# ALL FIVE ARE INSULATED-ONLY and that scoping is `checks.only_applies_to`
|
||
# in the plant layer, never here.
|
||
|
||
|
||
def _solid_zone_gate(t, know):
|
||
"""(zones, reason). `reason` is why nothing may be claimed.
|
||
|
||
The `family_verified` idiom from 6.6 and the connector gate, and here it
|
||
guards the same failure: a solid-zone family name that matches nothing
|
||
counts ZERO zones on every piece, and zero zones reads as "no lifter is
|
||
missing one". A permanent invisible pass over the whole defect class the
|
||
owner just commissioned.
|
||
"""
|
||
from vibe import knowledge as K
|
||
family, why = K.solid_zone_rule(know or {})
|
||
if not family:
|
||
return None, why
|
||
if t.get('solid_zones') is None:
|
||
return None, (
|
||
'the solid zones nested in this piece\'s lifters were not '
|
||
'gathered on this run, so nothing about them could be judged. '
|
||
'The gather is `GetSubComponentIds()` on each recognised lifter '
|
||
'in `ticketqc.read_ticket`; it is skipped when no lifter was '
|
||
'recognised')
|
||
return (t.get('solid_zones') or []), None
|
||
|
||
|
||
def _hatch_gather_note(t, silence):
|
||
"""A hatch row's silence, with the GATHER's own sentences behind it.
|
||
|
||
`ticketqc._read_insulation_hatch` leaves a sentence per read it could
|
||
not take (`insulation_hatch_notes`) -- which legend was not placed,
|
||
which filter carries the Detail Items category, which view template
|
||
governed. A row that stood down saying only "it was not gathered"
|
||
would be throwing those away, and the reader would then have to
|
||
reproduce the gather by hand to find out why.
|
||
"""
|
||
notes = [n for n in ((t or {}).get('insulation_hatch_notes') or []) if n]
|
||
if not notes:
|
||
return silence
|
||
return '{0} -- what the gather recorded: {1}'.format(
|
||
silence, '; '.join(notes))
|
||
|
||
|
||
def check_solid_zone_members(t, intent, know, spec):
|
||
"""5.11: every lifter's nested solid zone is in the piece's assembly."""
|
||
from vibe import ticketpage as TP
|
||
t['_solid_zone_silence'] = None
|
||
zones, reason = _solid_zone_gate(t, know)
|
||
if reason:
|
||
t['_solid_zone_silence'] = reason
|
||
return []
|
||
findings = []
|
||
# THE ABSENT-ZONE FINDING FIRST, and on its own code. Zero today (0 of
|
||
# 192 lifters, measured 2026-08-31) and built anyway: the day it fires
|
||
# it is the bigger of the two defects, because there is no zone to add.
|
||
absent = TP.solid_zone_absent_status(t.get('lifters_without_zone') or 0,
|
||
len(t.get('lifters') or []))
|
||
if absent:
|
||
findings.append(Finding(absent[0], 'SOLID-ZONE-ABSENT', absent[1]))
|
||
severity, message = TP.solid_zone_membership_status(
|
||
zones, t.get('lifters_without_zone') or 0,
|
||
t.get('lifters_unread') or 0)
|
||
if severity is None:
|
||
if findings:
|
||
# The row is still answered -- by the absent finding, which is a
|
||
# FAIL -- so this is not a silence. What could not be judged
|
||
# rides as its own OK-severity note: visible, and unable to set
|
||
# the row, which is what an unanswered question deserves.
|
||
findings.append(Finding('OK', 'SOLID-ZONE-UNJUDGED', message))
|
||
return findings
|
||
t['_solid_zone_silence'] = message
|
||
return []
|
||
findings.append(Finding(severity, 'SOLID-ZONE-ASM', message))
|
||
return findings
|
||
|
||
|
||
def check_solid_zone_on_ticket(t, intent, know, spec):
|
||
"""5.12: those zones appear on the ticket's insulation layout view."""
|
||
from vibe import knowledge as K
|
||
from vibe import ticketpage as TP
|
||
t['_solid_zone_ticket_silence'] = None
|
||
zones, reason = _solid_zone_gate(t, know)
|
||
if reason:
|
||
t['_solid_zone_ticket_silence'] = reason
|
||
return []
|
||
view_name, why = K.insulation_layout_view(know or {})
|
||
if not view_name:
|
||
t['_solid_zone_ticket_silence'] = why
|
||
return []
|
||
# NO SHEET IS ITS OWN ANSWER AND IT IS NOT A VERDICT. 12 of the 27
|
||
# insulated pieces have no sheet issued yet (measured 2026-08-31). A
|
||
# piece with no ticket has not got a ticket defect, and it has not got a
|
||
# clean ticket either.
|
||
if not (t.get('sheets') or []):
|
||
t['_solid_zone_ticket_silence'] = (
|
||
'no sheet has been issued for this piece, so there is no ticket '
|
||
'for its solid zones to appear on -- nothing is claimed either '
|
||
'way')
|
||
return []
|
||
severity, message = TP.solid_zone_ticket_status(zones, view_name)
|
||
if severity is None:
|
||
t['_solid_zone_ticket_silence'] = message
|
||
return []
|
||
return [Finding(severity, 'SOLID-ZONE-TICKET', message)]
|
||
|
||
|
||
def check_solid_zone_hatch(t, intent, know, spec):
|
||
"""5.13: the solid zones' hatch equals the legend's own swatch."""
|
||
from vibe import knowledge as K
|
||
from vibe import ticketpage as TP
|
||
t['_solid_zone_hatch_silence'] = None
|
||
zones, reason = _solid_zone_gate(t, know)
|
||
if reason:
|
||
t['_solid_zone_hatch_silence'] = reason
|
||
return []
|
||
expected, source, why = K.expected_hatch_pattern(
|
||
know or {}, 'solid_zone', t.get('legend_patterns'))
|
||
severity, message = TP.hatch_status(
|
||
'the solid zones on this ticket', t.get('solid_zone_pattern'),
|
||
expected, source, why, count=len(zones or []) or None)
|
||
if severity is None:
|
||
t['_solid_zone_hatch_silence'] = _hatch_gather_note(t, message)
|
||
return []
|
||
return [Finding(severity, 'SOLID-ZONE-HATCH', message)]
|
||
|
||
|
||
def check_recess_regions(t, intent, know, spec):
|
||
"""5.14: the recess regions the design calls for are drawn."""
|
||
from vibe import knowledge as K
|
||
from vibe import ticketpage as TP
|
||
t['_recess_silence'] = None
|
||
types = K.recess_region_types(know or {})
|
||
if not types:
|
||
t['_recess_silence'] = (
|
||
'insulation.recess.region_types is empty: no layer says which '
|
||
'filled-region types this plant draws a recess with, so none '
|
||
'could be recognised. That is a missing plant fact, not a '
|
||
'finding about this piece')
|
||
return []
|
||
if t.get('recess_regions') is None:
|
||
t['_recess_silence'] = _hatch_gather_note(t, (
|
||
'the drafted recess regions on this ticket were not gathered on '
|
||
'this run, so nothing about them could be judged. The gather is '
|
||
'`ticketqc._read_insulation_hatch` -- a view-scoped '
|
||
'`FilledRegion` collector over the ticket\'s own views, with the '
|
||
'legend views excluded because their swatches are pictures of a '
|
||
'recess and not recesses'))
|
||
return []
|
||
note = None
|
||
if not K.get(know or {}, 'insulation.recess.modelled_path_verified'):
|
||
note = ('The modelled-recess path was NOT consulted: {0}'.format(
|
||
K.get(know or {},
|
||
'insulation.recess.modelled_path_unverified_because') or
|
||
'no reason is recorded.'))
|
||
severity, message = TP.recess_presence_status(
|
||
t.get('recess_regions') or {}, (intent or {}).get('recesses'), note)
|
||
if severity is None:
|
||
t['_recess_silence'] = _hatch_gather_note(t, message)
|
||
return []
|
||
return [Finding(severity, 'RECESS-PRESENT', message)]
|
||
|
||
|
||
def check_recess_hatch(t, intent, know, spec):
|
||
"""5.15: each recess region type carries its legend swatch."""
|
||
from vibe import knowledge as K
|
||
from vibe import ticketpage as TP
|
||
t['_recess_hatch_silence'] = None
|
||
concepts = K.legend_swatch_concepts(know or {})
|
||
if not concepts:
|
||
t['_recess_hatch_silence'] = (
|
||
'insulation.legend.swatch_types is empty: no layer says which '
|
||
'filled-region type means which insulation concept, so no '
|
||
'rendered hatch could be held to anything')
|
||
return []
|
||
rendered = t.get('recess_patterns')
|
||
if rendered is None:
|
||
t['_recess_hatch_silence'] = _hatch_gather_note(t, (
|
||
'the patterns the recess regions actually render with were not '
|
||
'read on this run, so none was compared. The gather is the '
|
||
'foreground pattern of each recess `FilledRegionType`, taken '
|
||
'ONLY where no view filter on the layout view\'s governing '
|
||
'template selects the Detail Items category -- because a filter '
|
||
'that does beats the type, and the type then says what the '
|
||
'region WOULD have drawn with rather than what it drew'))
|
||
return []
|
||
# ONE FINDING FOR EVERY RECESS TYPE, NOT ONE PER TYPE. There are two
|
||
# types today and both can fail at once; `checklist_states` keeps the
|
||
# LAST finding per code, so a second `RECESS-HATCH` would silently
|
||
# replace the first and the row would print half the answer. That is
|
||
# 6.7's ruling, obeyed rather than rediscovered -- and it is why the
|
||
# unjudged types ride in the SAME sentence too.
|
||
judged, silences, worst = [], [], 'OK'
|
||
ranks = {'OK': 0, 'WARN': 1, 'FAIL': 2}
|
||
for type_name in K.recess_region_types(know or {}):
|
||
concept = concepts.get(text_type(type_name).strip().upper())
|
||
if not concept:
|
||
silences.append(
|
||
'{0} is not named in insulation.legend.swatch_types, so what '
|
||
'it should be drawn with is not written down'.format(type_name))
|
||
continue
|
||
expected, source, why = K.expected_hatch_pattern(
|
||
know or {}, concept, t.get('legend_patterns'))
|
||
severity, message = TP.hatch_status(
|
||
type_name, rendered.get(type_name), expected, source, why)
|
||
if severity is None:
|
||
silences.append(message)
|
||
continue
|
||
judged.append(message)
|
||
if ranks.get(severity, 1) > ranks.get(worst, 0):
|
||
worst = severity
|
||
if not judged:
|
||
t['_recess_hatch_silence'] = _hatch_gather_note(
|
||
t, '; '.join(silences) or 'no recess region type could be held '
|
||
'to a legend swatch on this run')
|
||
return []
|
||
parts = list(judged)
|
||
if silences:
|
||
parts.append('NOT judged: {0}'.format('; '.join(silences)))
|
||
parts.append(TP.recess_size_caveat())
|
||
return [Finding(worst, 'RECESS-HATCH', '. '.join(parts))]
|
||
|
||
|
||
CHECKS = (
|
||
check_connectors_exist,
|
||
check_connectors_sufficient,
|
||
check_connector_edge_distance,
|
||
check_solid_zone_members,
|
||
check_solid_zone_on_ticket,
|
||
check_solid_zone_hatch,
|
||
check_recess_regions,
|
||
check_recess_hatch,
|
||
check_lifter_capacity,
|
||
check_lifter_edge_distance,
|
||
check_lifter_anchor_spacing,
|
||
check_lifting_points,
|
||
check_lift_positions,
|
||
check_dunnage_stripping_match,
|
||
check_handling_result,
|
||
check_strand_layout,
|
||
check_member_interference,
|
||
check_release_ratio,
|
||
check_spec_unresolved,
|
||
check_bom_strand_count,
|
||
check_dunnage_openings,
|
||
check_strand_debond,
|
||
check_debond_callout,
|
||
check_cast_extras,
|
||
check_picking_match,
|
||
check_bom_members,
|
||
check_detail_callouts,
|
||
check_weight_three_way,
|
||
check_ticket_set,
|
||
check_jacking_force,
|
||
check_bed_plan_reverse,
|
||
check_sheet_name,
|
||
check_invisible,
|
||
check_wwf_designation,
|
||
check_view_name_design,
|
||
check_note_views,
|
||
check_callout_overlap,
|
||
check_bar_mark_uniqueness,
|
||
check_bar_stock_length,
|
||
check_parity_note,
|
||
)
|
||
|
||
|
||
# ------------------------------------------------- Phase 5: checklist
|
||
#
|
||
# The checklist table lives in ticketpage (Revit-free, host-testable).
|
||
# ticketchecks re-exports it so the button keeps one import.
|
||
from vibe.ticketpage import (
|
||
CHECKLIST_ITEMS, CHECKLIST_FOR_ENGINEER, checklist_states,
|
||
ROW_NOTE_PREFIX,
|
||
)
|
||
# ------------------------- checks a knowledge layer says do not apply here
|
||
#
|
||
# `knowledge.check_not_applicable` decides per ROW; the checks are per
|
||
# FUNCTION, and the two are not one-to-one. Rows 4.4 and 4.6 both name
|
||
# `check_strand_layout`, and 5.1 names two functions. So:
|
||
#
|
||
# A CHECK IS SKIPPED ONLY WHEN EVERY CHECKLIST ROW THAT NAMES IT IS
|
||
# DECLARED NOT APPLICABLE ON THIS PIECE.
|
||
#
|
||
# Skipping `check_strand_layout` because 4.4 does not apply would take 4.6
|
||
# with it -- a row nobody switched off, going quiet, because of a
|
||
# declaration about a different row. That is a silently-disabled safety
|
||
# check arriving through the mechanism built to make disabling one visible,
|
||
# so the rule is the strict one and the asymmetry is the reason: a check that
|
||
# runs and does not apply is noise on one row; a check that does not run and
|
||
# did apply is a silent pass.
|
||
#
|
||
# THE ROW IS STILL REPAINTED EITHER WAY. `ticketpage.apply_applicability`
|
||
# owns that, and it runs over the rows whether or not the check was skipped
|
||
# here -- so a check kept alive for a sibling row does not put a verdict on
|
||
# the row that was switched off. It also keeps a FAIL, which is why this
|
||
# skipping is an optimisation of honesty rather than the mechanism itself.
|
||
NOT_APPLICABLE_DISPOSITION = 'NOT-APPLICABLE'
|
||
|
||
|
||
def applicability_decisions_for(t, know):
|
||
"""(decisions, classification, member, profile) -- the ONE resolution.
|
||
|
||
Both consumers read this: `run_all` to decide what not to run, and the
|
||
button to decide what to print. Two independent resolutions of the same
|
||
question is how a report comes to describe a run that did not happen.
|
||
|
||
A ticket carrying no classification (any caller older than 2026-08-31,
|
||
and every host test that builds `t` by hand) resolves as UNCLASSIFIED
|
||
and unverified, so only a plant-wide `any` declaration can switch
|
||
anything off. That is the safe default, not a degraded one.
|
||
"""
|
||
from vibe import knowledge as K
|
||
from vibe import ticketpage as TP
|
||
classification = (t or {}).get('panel_type')
|
||
classification = classification if isinstance(classification, dict) else {}
|
||
member = (t or {}).get('member_kind')
|
||
if not isinstance(member, dict):
|
||
member = K.member_kind_of(know or {}, (t or {}).get('mark'))
|
||
# THE PROFILE IS RESOLVED ON THE PAIR: the member kind places the piece,
|
||
# the panel type refines it within that kind. An insulated wall panel is
|
||
# a wall panel first, so a profile naming both beats one naming only the
|
||
# kind -- see `knowledge.profile_for`.
|
||
profile = K.profile_for(
|
||
know or {}, member_kind=member.get('member_kind'),
|
||
panel_type=(classification.get('panel_type')
|
||
if classification.get('verified') else None))
|
||
decisions = TP.applicability_decisions(
|
||
know or {},
|
||
panel_type=classification.get('panel_type'),
|
||
verified=bool(classification.get('verified')),
|
||
profile=profile)
|
||
return decisions, classification, member, profile
|
||
|
||
|
||
def _applicability_skips(t, know):
|
||
"""{check function name: the reason no row needs it any more}."""
|
||
from vibe import ticketpage as TP
|
||
decisions = applicability_decisions_for(t, know)[0]
|
||
wanted, dropped = {}, {}
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
decision = decisions.get(item[0]) or {}
|
||
# UNASKABLE COUNTS AS DROPPED, and for the opposite reason to
|
||
# inapplicable: the row is scoped to a panel type and this piece
|
||
# could not be placed, so running the check would answer a question
|
||
# about a kind of piece this may not be. The ROW still ends up red --
|
||
# `apply_applicability` makes it NOT RUN with the reason -- which is
|
||
# the whole difference between the two.
|
||
off = bool(decision.get('not_applicable') or decision.get('unaskable'))
|
||
for name in TP._check_names(item[5]):
|
||
if off:
|
||
dropped.setdefault(name, []).append((item[0], decision))
|
||
else:
|
||
wanted[name] = True
|
||
skips = {}
|
||
for name in dropped:
|
||
if wanted.get(name):
|
||
continue
|
||
rows = dropped[name]
|
||
skips[name] = (
|
||
'no checklist row this check answers ({0}) could be asked of this '
|
||
'piece, so it was not run. {1}'
|
||
.format(', '.join([num for (num, _d) in rows]),
|
||
'; '.join([_declaration_sentence(num, d)
|
||
for (num, d) in rows])))
|
||
return skips
|
||
|
||
|
||
def _declaration_sentence(num, decision):
|
||
if decision.get('unaskable'):
|
||
return '{0}: not asked -- {1}'.format(
|
||
num, decision.get('reason') or 'no reason was recorded')
|
||
condition = decision.get('condition')
|
||
where = ('every piece this plant makes' if condition == 'any'
|
||
else 'a {0} piece'.format(condition))
|
||
said = '{0}: not asked on {1}'.format(num, where)
|
||
if decision.get('reason'):
|
||
said += ' -- ' + decision['reason']
|
||
if decision.get('since'):
|
||
said += ' (declared {0})'.format(decision['since'])
|
||
return said
|
||
|
||
|
||
def run_all(t, intent, know, spec=None):
|
||
findings = []
|
||
log = [] # (check_name, 'RAN' | 'FAILED', note-or-None)
|
||
skips = _applicability_skips(t, know)
|
||
for check in CHECKS:
|
||
if check.__name__ in skips:
|
||
log.append((check.__name__, NOT_APPLICABLE_DISPOSITION,
|
||
skips[check.__name__]))
|
||
continue
|
||
try:
|
||
out = check(t, intent, know, spec)
|
||
except Exception as ex:
|
||
findings.append(Finding('WARN', 'CHECK-ERROR',
|
||
'{0} failed: {1}: {2}'
|
||
.format(check.__name__, type(ex).__name__, ex)))
|
||
log.append((check.__name__, 'FAILED',
|
||
'{0}: {1}'.format(type(ex).__name__, str(ex)[:80])))
|
||
continue
|
||
if not out:
|
||
# An empty/None return is ambiguous: ran-and-found-nothing,
|
||
# or did-not-run for lack of data. The probe disambiguates:
|
||
# probe present, returns None -> RAN, nothing found (OK)
|
||
# probe present, returns text -> SKIPPED, that's the why
|
||
# probe absent -> RAN-EMPTY: ran but stayed
|
||
# silent with no explanation. That is the exact "silence
|
||
# looks like fine" case the table exists to surface, so
|
||
# the checklist shows NOT RUN, reason "no probe".
|
||
probe = _SKIP_PROBES.get(check.__name__)
|
||
if probe is not None:
|
||
try:
|
||
reason = probe(t, intent, know, spec)
|
||
except Exception:
|
||
reason = 'probe raised'
|
||
if reason:
|
||
log.append((check.__name__, 'SKIPPED', reason))
|
||
else:
|
||
log.append((check.__name__, 'RAN', None))
|
||
else:
|
||
log.append((check.__name__, 'RAN-EMPTY', None))
|
||
else:
|
||
log.append((check.__name__, 'RAN', None))
|
||
findings.extend(out)
|
||
log.extend(row_silences(t, intent, know, spec))
|
||
return findings, log
|
||
|
||
|
||
# ------------------------------------- rows answered by a CODE, not a check
|
||
#
|
||
# THE GUARANTEE HAD A HOLE ONE LEVEL ABOVE WHERE IT WAS INSTALLED. Everything
|
||
# above this line protects a checklist row that names a CHECK FUNCTION: an
|
||
# empty return is disambiguated by `_SKIP_PROBES`, and a check with no probe
|
||
# is reported as the defect it is. Seven rows name no check at all -- they
|
||
# are answered by codes `ticketqc.check_ticket` emits directly -- so none of
|
||
# that machinery could reach them, and when no code was emitted the row went
|
||
# to the page as `NOT RUN` with `reason: null` and nothing underneath.
|
||
#
|
||
# Measured on a live run, 2026-08-31: six rows in that state on one piece
|
||
# (2.1, 2.3, 3.1, 3.2, 4.1, 4.2), eleven codes waited for and not one
|
||
# emitted. The owner read the page as a wall of unnecessary red, and he was
|
||
# right to: **the tool gave him no sentence to disagree with.**
|
||
#
|
||
# A ROW PROBE IS THE SAME CONTRACT AS A SKIP PROBE, keyed by checklist row
|
||
# number instead of by function name: it returns the reason the row WOULD be
|
||
# silent, or None when every input it needs is present. It is computed
|
||
# unconditionally and used only where no owned finding arrived, so a probe
|
||
# that speaks while a code was emitted costs nothing and decides nothing.
|
||
#
|
||
# THE ANSWER TRAVELS IN THE LOG, under `row:<number>`, because the log is
|
||
# the one channel that already reaches every consumer -- the button hands it
|
||
# to `checklist_states`, `ticketvision` writes it into `qc-run.json` -- so
|
||
# no caller had to be edited to carry a new argument. `missing.reason` in the
|
||
# JSON was NOT the fix: it is a generic catch that never reaches the text
|
||
# report at all.
|
||
|
||
_ROW_PROBES = {}
|
||
|
||
|
||
def _register_row_probe(num):
|
||
def deco(fn):
|
||
_ROW_PROBES[num] = fn
|
||
return fn
|
||
return deco
|
||
|
||
|
||
def row_silences(t, intent, know, spec=None):
|
||
"""[(row:<num>, 'NO-CODE', reason)] -- why each code-answered row could
|
||
be quiet on this piece.
|
||
|
||
A probe that raises is reported rather than swallowed: a probe whose own
|
||
failure was silent would reproduce, one level further out, exactly the
|
||
silence this whole mechanism exists to close.
|
||
"""
|
||
out = []
|
||
for num in sorted(_ROW_PROBES):
|
||
try:
|
||
reason = _ROW_PROBES[num](t, intent, know, spec)
|
||
except Exception as ex:
|
||
reason = ('the reason-probe for this row raised {0}, so why the '
|
||
'row is quiet could not be established -- read the row '
|
||
'as NOT RUN with an unknown reason, never as a pass'
|
||
.format(type(ex).__name__))
|
||
if reason:
|
||
out.append((ROW_NOTE_PREFIX + num, 'NO-CODE', reason))
|
||
return out
|
||
|
||
|
||
def _no_design_side(intent, spec, key, label, consequence,
|
||
empty_is_missing=False):
|
||
"""The reason a summary-vs-ticket comparison had no design side.
|
||
|
||
THE COMMON CASE ON THIS JOB, and the one that produced six blank rows:
|
||
the design has no extracted spec, so `intent['source']` is `regex` --
|
||
the weaker reading of the summary view -- and every column of the intent
|
||
table is a dash. There is nothing to compare the ticket against, which
|
||
is not a statement about the ticket at all.
|
||
|
||
`label` is a bare noun ("f'c", "unit weight"); the sentence supplies the
|
||
grammar around it. `empty_is_missing` is for a LIST-valued key, where an
|
||
empty list and an absent one are the same absence -- for a number they
|
||
are not, which is why it is not the default.
|
||
|
||
Returns None when the value IS there: then the row's silence, if any, is
|
||
something else's to explain and this must not answer for it.
|
||
"""
|
||
value = (intent or {}).get(key)
|
||
if value is not None and (value or not empty_is_missing):
|
||
return None
|
||
source = ((intent or {}).get('source') or '').lower()
|
||
# THE SHARED HALF COMES FIRST, then what THIS row lost. That is not a
|
||
# style: `ticketpage.name_shared_silences` groups rows by the clause
|
||
# before the first ` -- `, so four rows standing down on one missing
|
||
# spec say so to each other instead of reading as four problems.
|
||
if not spec and source == 'regex':
|
||
return ('no spec has been extracted for this design, so its intent '
|
||
'was read by the fall-back regex over the summary view '
|
||
'(intent.source = regex) and its columns are blank -- it '
|
||
'carries no {0}, so {1}. Run Extract Design on this design; '
|
||
'nothing here is a statement about the ticket'
|
||
.format(label, consequence))
|
||
if not spec:
|
||
return ('no design summary has been extracted for this design -- it '
|
||
'carries no {0}, so {1}. Run Extract Design on that design'
|
||
.format(label, consequence))
|
||
return ('this design\'s extracted spec records no {0}, so {1}. The '
|
||
'ticket was not asked and nothing about it is claimed'
|
||
.format(label, consequence))
|
||
|
||
|
||
@_register_row_probe('3.1')
|
||
def _probe_row_fc(t, intent, know, spec):
|
||
"""3.1 -- f'c, ticket vs summary."""
|
||
return _no_design_side(intent, spec, 'fc', "f'c",
|
||
'the printed strength was compared against '
|
||
'nothing')
|
||
|
||
|
||
@_register_row_probe('3.2')
|
||
def _probe_row_fci(t, intent, know, spec):
|
||
"""3.2 -- f'ci, ticket vs summary."""
|
||
return _no_design_side(intent, spec, 'fci', "f'ci",
|
||
'the printed release strength was compared '
|
||
'against nothing')
|
||
|
||
|
||
@_register_row_probe('2.3')
|
||
def _probe_row_unit_weight(t, intent, know, spec):
|
||
"""2.3 -- unit weight, ticket and model vs summary.
|
||
|
||
BOTH of this row's codes come off the same design-side number: with no
|
||
`pcf` in the intent, `_compare` emits neither PCF-NOTE nor PCF-MODEL.
|
||
"""
|
||
return _no_design_side(intent, spec, 'pcf', 'unit weight',
|
||
'neither the ticket note nor the model was '
|
||
'compared against one')
|
||
|
||
|
||
@_register_row_probe('4.2')
|
||
def _probe_row_strand_diameter(t, intent, know, spec):
|
||
"""4.2 -- strand diameter and grade vs the design.
|
||
|
||
The design side is `intent['strands']`, a LIST, so an empty one is the
|
||
same absence as a missing key -- `_check_strands` reaches its diameter
|
||
comparison only `if summary_strands`.
|
||
"""
|
||
return _no_design_side(intent, spec, 'strands', 'strand group',
|
||
'the diameters modelled on this piece were '
|
||
'compared against nothing',
|
||
empty_is_missing=True)
|
||
|
||
|
||
@_register_row_probe('4.1')
|
||
def _probe_row_strand_count(t, intent, know, spec):
|
||
"""4.1 -- printed strand count vs modelled.
|
||
|
||
TWO INPUTS AND EITHER WILL DO: the count printed in the ticket's own
|
||
section, or -- failing that -- the design summary's strand groups. The
|
||
row is silent only when NEITHER is readable, and the two absences are
|
||
different things, so both are named. The printed one is the same parse
|
||
failure row 6.4 reports in a full sentence on the same piece; a fact
|
||
that answers one row and leaves another blank is the defect here.
|
||
"""
|
||
if t.get('ticket_strand_count') is not None:
|
||
return None
|
||
if (intent or {}).get('strands'):
|
||
return None
|
||
return ('no strand count could be read from this ticket -- its section '
|
||
'call-out did not parse into a count -- and the design intent '
|
||
'carries no strand group to fall back on, so the modelled '
|
||
'strands were compared against nothing. Both halves are '
|
||
'missing, and neither absence is a defect in the piece')
|
||
|
||
|
||
@_register_row_probe('2.1')
|
||
def _probe_row_printed_size(t, intent, know, spec):
|
||
"""2.1 -- printed length/width/depth vs modelled.
|
||
|
||
THIS ROW IS ANSWERED BY THE ENVELOPE COMPARISON (`SIZE-*`), which needs
|
||
a measured dimension AND a design envelope carrying both a maximum and a
|
||
minimum for it. Each absence is named separately because each has a
|
||
different fix: a missing measurement is a piece the tool could not
|
||
measure, and a missing envelope is a design nobody extracted.
|
||
"""
|
||
envelope = ((intent or {}).get('envelope') or {})
|
||
measured = [label for label in ('length', 'width')
|
||
if t.get(label) is not None]
|
||
bounded = [label for label in ('length', 'width')
|
||
if envelope.get('MAX_' + label.upper())
|
||
or envelope.get('MIN_' + label.upper())]
|
||
if measured and bounded:
|
||
return None
|
||
if not measured and not bounded:
|
||
return ('this piece\'s length and width were not measured AND the '
|
||
'design intent carries no size envelope, so nothing was '
|
||
'compared -- two missing inputs, neither of them a '
|
||
'statement about the ticket')
|
||
if not measured:
|
||
return ('this piece\'s length and width could not be measured off '
|
||
'the model, so the design envelope had nothing to be '
|
||
'compared against')
|
||
return ('the design intent carries no size envelope (no maximum or '
|
||
'minimum for length or width), so the measured {0} was compared '
|
||
'against nothing -- run Extract Design on this design'
|
||
.format(' and '.join(measured)))
|
||
|
||
|
||
@_register_row_probe('1.2')
|
||
def _probe_row_design_number(t, intent, know, spec):
|
||
"""1.2 -- the sheet's design number reduces to the piece's.
|
||
|
||
`_check_design_number` speaks on every piece that has both numbers, so
|
||
this is quiet in one case: a piece or a sheet carrying no design number
|
||
at all, where there is nothing on one side of the comparison.
|
||
"""
|
||
if t.get('design_number') and t.get('tkt_design_number'):
|
||
return None
|
||
missing = []
|
||
if not t.get('design_number'):
|
||
missing.append('the piece carries no design number')
|
||
if not t.get('tkt_design_number'):
|
||
missing.append('the sheet carries no design number')
|
||
return ('{0}, so the two could not be reduced and compared'
|
||
.format(' and '.join(missing)))
|
||
|
||
|
||
# Checks that can say why they returned nothing, keyed by function name.
|
||
# A probe returns a truthy reason when the check did NOT run because of
|
||
# missing data/config; None means "it ran and found nothing".
|
||
_SKIP_PROBES = {}
|
||
|
||
|
||
def _skip_reason(name, t, intent, know, spec):
|
||
probe = _SKIP_PROBES.get(name)
|
||
if probe is None:
|
||
return None
|
||
try:
|
||
return probe(t, intent, know, spec)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _register_skip_probe(name):
|
||
def deco(fn):
|
||
_SKIP_PROBES[name] = fn
|
||
return fn
|
||
return deco
|
||
|
||
|
||
@_register_skip_probe('check_release_ratio')
|
||
def _probe_release_ratio(t, intent, know, spec):
|
||
ratio = K.get(know, 'concrete.min_fci_ratio')
|
||
fc, fci = (intent or {}).get('fc'), (intent or {}).get('fci')
|
||
if not ratio or not fc or not fci:
|
||
return ('no f\'c / f\'ci (or no release ratio) in the design '
|
||
'intent -- f\'ci/f\'c could not be judged')
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_strand_layout')
|
||
def _probe_strand_layout(t, intent, know, spec):
|
||
if not t.get('strand_points') and not t.get('strand_layout'):
|
||
return ('no strand geometry on this piece -- strand positions and '
|
||
'row spacing could not be judged')
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_bom_strand_count')
|
||
def _probe_bom_strand_count(t, intent, know, spec):
|
||
"""Why the BOM-vs-model strand comparison said nothing.
|
||
|
||
Part of retiring LGD-MISSING (2026-08-30): the check that WANTS the BOM
|
||
is the one that names its absence, and it names an unreadable BOM
|
||
differently -- an input we failed to read is not a defect in the ticket.
|
||
"""
|
||
from vibe import ticketpage
|
||
return ticketpage.bom_not_run_reason(
|
||
t.get('bom_schedules'),
|
||
"the BOM's strand row against the modelled strand count")
|
||
|
||
|
||
@_register_skip_probe('check_bom_members')
|
||
def _probe_bom_members(t, intent, know, spec):
|
||
"""Why the BOM-vs-members comparison said nothing. Same rule as above."""
|
||
from vibe import ticketpage
|
||
if not t.get('asm_id'):
|
||
return ('this ticket has no assembly, so the BOM rows have nothing '
|
||
'to be compared against')
|
||
return ticketpage.bom_not_run_reason(
|
||
t.get('bom_schedules'), 'the BOM rows against the assembly members')
|
||
|
||
|
||
@_register_skip_probe('check_detail_callouts')
|
||
def _probe_detail_callouts(t, intent, know, spec):
|
||
"""Why 5.1's detail-sheet half said nothing.
|
||
|
||
Three silences and they are different things: the plant's detail-sheet
|
||
naming is not written down; the BED PLAN could not be read; or the
|
||
plan cites details whose contents nothing reads. The check leaves the
|
||
sentence behind, so the reason is the one it actually hit rather than
|
||
a second guess at it.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
left = (t or {}).get('_detail_callout_skip')
|
||
if left:
|
||
return left
|
||
_findings, reason = TP.detail_callout_status(
|
||
_bed_plan_text(t, know),
|
||
K.get(know, 'nomenclature.detail_sheet_prefix'))
|
||
return reason
|
||
|
||
|
||
@_register_skip_probe('check_weight_three_way')
|
||
def _probe_weight_three_way(t, intent, know, spec):
|
||
"""Why 2.2 said nothing: fewer than two of its three weights were
|
||
readable. The sources are the cached ones the check itself used, so the
|
||
reason is the real one and the geometry is not read twice."""
|
||
from vibe import ticketpage as TP
|
||
src = _weight_sources(t, know)
|
||
if src['why'].get('measured') == TP.expensive_geometry_off_reason():
|
||
# Attributed to the switch, not to a missing input. The check
|
||
# returns nothing at all in this state precisely so this sentence
|
||
# is what the checklist row carries.
|
||
return TP.expensive_geometry_off_reason()
|
||
have = [role for role in ('printed', 'reconstructed', 'measured')
|
||
if src.get(role) is not None]
|
||
if len(have) >= 2:
|
||
return None # it ran; silence means it found nothing
|
||
reasons = []
|
||
for role in ('printed', 'reconstructed', 'measured'):
|
||
why = src['why'].get(role)
|
||
if why and why not in reasons:
|
||
reasons.append(why)
|
||
return ('fewer than two of the three weights could be read, so 2.2 '
|
||
'could not run -- {0}'.format('; '.join(reasons) or
|
||
'no weight was readable'))
|
||
|
||
|
||
@_register_skip_probe('check_jacking_force')
|
||
def _probe_jacking_force(t, intent, know, spec):
|
||
# Wiring tolerances.it_lb added a silence of its own -- a tolerance
|
||
# that is present and unusable -- and every silence gets a sentence.
|
||
# The check leaves it here rather than the probe re-deriving it, so
|
||
# the reason is the real one.
|
||
left = (t or {}).get('_jack_force_skip')
|
||
if left:
|
||
return left
|
||
if _jacking_tolerance_lb(know) is None:
|
||
return ('tolerances.it_lb is not a tension in pounds, so how close '
|
||
'the printed jacking force has to be was not decided by '
|
||
'anybody')
|
||
tdoc = t.get('doc')
|
||
if tdoc is None:
|
||
try:
|
||
from vibe.helpers import _doc; tdoc = _doc()
|
||
except Exception:
|
||
return 'no usable document to read the jacking-force note'
|
||
has_note = False
|
||
for s in (t.get('sheets') or []):
|
||
for vp in s.GetAllPlacedViews():
|
||
v = tdoc.GetElement(vp)
|
||
if v is not None and 'JACKING' in (v.Name or '').upper():
|
||
has_note = True
|
||
break
|
||
if has_note:
|
||
break
|
||
its = [s.get('it') for s in ((intent or {}).get('strands') or [])
|
||
if s.get('it')]
|
||
if not has_note:
|
||
return ('no STRAND JACKING FORCE note on this ticket -- nothing '
|
||
'to compare')
|
||
if not its:
|
||
return ('design intent has no strand initial tension (it_lb) -- '
|
||
'the jacking force could not be compared')
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_strand_debond')
|
||
def _probe_strand_debond(t, intent, know, spec):
|
||
"""Why 4.7 said nothing -- and ONLY when it truly could not look.
|
||
|
||
`req['reason']` is the could-not-judge path (no threshold in plant
|
||
knowledge, no strand positions, no length, unsolvable geometry) and it
|
||
is the only thing that belongs here. A piece with no through-thickness
|
||
opening used to return a reason too, which rendered as NOT RUN on a
|
||
question that had in fact been answered -- see check_strand_debond,
|
||
which now returns an OK finding for that case instead.
|
||
"""
|
||
req = _debond_requirement(t, know)
|
||
if req['reason']:
|
||
return req['reason']
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_debond_callout')
|
||
def _probe_debond_callout(t, intent, know, spec):
|
||
"""Why 4.5 said nothing.
|
||
|
||
Two silences, and they are different things. If the plant has not
|
||
written down what its debond call-out says, 4.5 does not speak the
|
||
ticket's language and cannot look. If 4.7 could not judge, there is no
|
||
requirement to compare a call-out against, and the row repeats 4.7's
|
||
own reason rather than inventing one -- 'judged and found nothing' is
|
||
not what happened.
|
||
"""
|
||
if not _debond_callout_tokens(know):
|
||
return ('no strand.debond_callout_tokens in plant knowledge -- this '
|
||
'plant\'s debond call-out wording is not written down, so '
|
||
'the ticket could not be asked whether it says so')
|
||
req = _debond_requirement(t, know)
|
||
if req['reason']:
|
||
return ('4.7 could not establish where debonding is required, so '
|
||
'whether the ticket calls it out could not be judged '
|
||
'either -- {0}'.format(req['reason']))
|
||
if _ticket_view_text(t) is None:
|
||
return ('no usable document to read this ticket\'s text, so its '
|
||
'debond call-out could not be looked for')
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_cast_extras')
|
||
def _probe_cast_extras(t, intent, know, spec):
|
||
"""Why 2.4 said nothing."""
|
||
from vibe import ticketpage as TP
|
||
if not _cast_include_tokens(know):
|
||
return ('no families.cast_include in plant knowledge -- which '
|
||
'families are cast concrete of the piece is not written '
|
||
'down, so the extras in the weight could not be identified')
|
||
if not t.get('asm_id'):
|
||
return ('this ticket has no assembly, so there are no cast extras '
|
||
'to compare against what it draws')
|
||
if _ticket_doc(t) is None or _ticket_view_text(t) is None:
|
||
return ('no usable document to read this ticket\'s text, so the '
|
||
'extras it names could not be read')
|
||
left = (t or {}).get('_cast_extra_skip')
|
||
if left:
|
||
return left
|
||
state, why = TP.bom_state(t.get('bom_schedules'))
|
||
if state in (TP.BOM_UNREADABLE, TP.BOM_UNKNOWN):
|
||
return ('the cast extras could not be compared against what the '
|
||
'ticket names: {0}'.format(why))
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_spec_unresolved')
|
||
def _probe_spec_unresolved(t, intent, know, spec):
|
||
"""Why 8.1 said nothing. The last row that had no probe at all.
|
||
|
||
NO BREADCRUMB HERE, deliberately, and that is not the pattern being
|
||
forgotten. `_dunnage_silence` exists because 6.4 reaches its silences
|
||
only after doing real work, so recomputing the reason would mean
|
||
measuring the piece twice. 8.1's silences are three dictionary lookups
|
||
on `spec`, which the probe is handed -- recomputing is free and cannot
|
||
drift out of step with the check the way a breadcrumb can.
|
||
|
||
The clean case returns None only because it can no longer get here: a
|
||
spec that records zero unresolved lines now returns an explicit OK
|
||
finding, so `run_all` logs it RAN and never asks. The None below is the
|
||
honest answer to "the check returned findings", not a pass invented for
|
||
a silence.
|
||
|
||
"NO SPEC ON DISK" IS NO LONGER ENOUGH TO STAND DOWN (2026-08-30). It
|
||
was, for one afternoon, and it silenced 8.1 on every live ticket --
|
||
because no design has a spec yet and the regex reader's own list was
|
||
sitting right there on the intent. A missing spec now only matters when
|
||
the intent has no record either.
|
||
"""
|
||
if spec is not None:
|
||
if not _unresolved_readable(spec):
|
||
return ('this design\'s spec carries no readable "unresolved" '
|
||
'list, so it never recorded what the extractor could '
|
||
'not interpret -- that is not the same as having '
|
||
'interpreted everything. Re-run Extract Design for '
|
||
'this design.')
|
||
return None
|
||
if _intent_unresolved(intent) is None:
|
||
return ('nothing wrote down which design-summary lines went '
|
||
'uninterpreted, so 8.1 could not be judged: this design has '
|
||
'no spec on disk (the spec is produced by Extract Design), '
|
||
'and its intent carries no list of notes the regex reader '
|
||
'could not place.')
|
||
return None
|
||
|
||
|
||
# Owner, verbatim: read 5.4 as "没跑" ("didn't run") on a live ticket.
|
||
@_register_skip_probe('check_wwf_designation')
|
||
def _probe_wwf_designation(t, intent, know, spec):
|
||
"""Why 5.4 said nothing -- and WHICH of three silences it was.
|
||
|
||
Handed back 2026-08-30 after the owner read 5.4 as "didn't run" on a live
|
||
ticket. It had not failed: the design it was checking genuinely records
|
||
no mesh (its extracted spec carries `reinforcing.wwf.mesh: null` and its
|
||
mild reinforcing is a BAR line, not a mesh one), so there was no design
|
||
side to compare and the row was right to stay unjudged.
|
||
|
||
What was wrong was the sentence. One reason covered three different
|
||
situations and pointed at Extract Design in all of them:
|
||
|
||
* no spec at all -- run Extract Design; nothing has been
|
||
extracted for this design yet.
|
||
* a spec with no wwf section -- extracted before the field existed;
|
||
re-run it.
|
||
* a spec that records no mesh -- nothing to re-extract. Either this
|
||
design reinforces with bars rather
|
||
than mesh, or the summary's mesh line
|
||
was not parsed, and only the design
|
||
summary itself settles which.
|
||
|
||
The third is the common one and the old text sent the reader to re-run
|
||
an extraction that would return exactly the same nothing. It still
|
||
reports NOT RUN -- 5.4 cannot judge a designation nobody has -- but it
|
||
no longer accuses the extractor of a failure it did not have.
|
||
"""
|
||
mesh_design = ((intent or {}).get('wwf_mesh') or '').strip()
|
||
if mesh_design:
|
||
return None
|
||
if not spec:
|
||
source = ((intent or {}).get('source') or '').lower()
|
||
if source == 'regex':
|
||
return ('no design summary has been extracted for this design, '
|
||
'and the fall-back reader found no mesh/WWF line in the '
|
||
'summary view -- there is no design side to compare the '
|
||
'ticket against. Run Extract Design on that design to '
|
||
'settle whether the line is there')
|
||
return ('no design summary has been extracted for this design, so '
|
||
'there is no mesh/WWF designation to compare the ticket '
|
||
'against -- run Extract Design on that design')
|
||
wwf = ((spec or {}).get('reinforcing') or {}).get('wwf')
|
||
if not isinstance(wwf, dict):
|
||
return ('this design\'s extracted spec has no reinforcing.wwf '
|
||
'section at all, so it predates the field -- re-run Extract '
|
||
'Design on that design before judging the ticket')
|
||
return ('this design\'s extracted spec records no mesh '
|
||
'(reinforcing.wwf.mesh is empty), so there is no designation to '
|
||
'compare the ticket against. Either the design reinforces with '
|
||
'bars rather than mesh -- in which case 5.4 has nothing to ask '
|
||
'here -- or its summary\'s mesh line was not parsed; the design '
|
||
'summary view settles which, and re-extracting will not')
|
||
|
||
|
||
@_register_skip_probe('check_view_name_design')
|
||
def _probe_view_name_design(t, intent, know, spec):
|
||
from vibe import designspec
|
||
if not _view_design_token_re(know):
|
||
return ('no nomenclature.design_number_re (or '
|
||
'view_design_token_re) in plant knowledge -- a design '
|
||
'label inside a view name could not be recognised')
|
||
if not designspec.design_key(t.get('design_number'), know):
|
||
return ('the piece carries no readable design number -- there is '
|
||
'nothing for a view name to agree or disagree with')
|
||
views = _placed_views(t)
|
||
if views is None:
|
||
return 'no usable document to read the placed view names'
|
||
if not _view_names(views):
|
||
return 'no views are placed on this ticket\'s sheets'
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_note_views')
|
||
def _probe_note_views(t, intent, know, spec):
|
||
from vibe import ticketpage as TP
|
||
notes = _notes_config(know)
|
||
groups, reason = TP.instruction_view_groups(notes, [])
|
||
if reason and not notes.get('instruction_view_sets'):
|
||
return reason # nothing declared at all
|
||
if not (t.get('sheets') or []):
|
||
return 'no sheets for this piece -- no note views to read'
|
||
if _ticket_doc(t) is None:
|
||
return 'no usable document to read the note views'
|
||
# Declared, and a document to read: the only remaining silence is a
|
||
# product family this plant has not written down yet, which is a
|
||
# NOT RUN with a reason and never a pass.
|
||
groups, reason = _instruction_groups(know, t)
|
||
return reason
|
||
|
||
|
||
@_register_skip_probe('check_bed_plan_reverse')
|
||
def _probe_bed_plan_reverse(t, intent, know, spec):
|
||
"""Why 5.2 said nothing.
|
||
|
||
The check leaves its reason on `t` on every path that returns
|
||
empty -- no BED PLAN view, or nothing left to compare once the
|
||
covered / on-detail / section-bound families are set aside. Without
|
||
this probe an empty return is 'RAN-EMPTY' and the row already read
|
||
NOT RUN; WITH a probe registered, a None reason would turn the same
|
||
silence into an OK, so the fallback below is a reason, never None.
|
||
"""
|
||
reason = (t or {}).get('_bed_reverse_skip')
|
||
if reason:
|
||
return reason
|
||
return ('5.2 returned nothing and left no reason -- treat as not run, '
|
||
'not as a pass')
|
||
|
||
|
||
@_register_skip_probe('check_bar_stock_length')
|
||
def _probe_bar_stock_length(t, intent, know, spec):
|
||
"""Why 5.7 said nothing. Never None on a silence -- see 5.2's probe."""
|
||
reason = (t or {}).get('_bar_length_silence')
|
||
if reason:
|
||
return reason
|
||
return ('5.7 returned nothing and left no reason -- treat as not run, '
|
||
'not as a pass')
|
||
|
||
|
||
@_register_skip_probe('check_bar_mark_uniqueness')
|
||
def _probe_bar_mark_uniqueness(t, intent, know, spec):
|
||
"""Why 5.6 said nothing."""
|
||
reason = (t or {}).get('_barmark_silence')
|
||
if reason:
|
||
return reason
|
||
return ('5.6 returned nothing and left no reason -- treat as not run, '
|
||
'not as a pass')
|
||
|
||
|
||
@_register_skip_probe('check_callout_overlap')
|
||
def _probe_callout_overlap(t, intent, know, spec):
|
||
"""Why 1.5 said nothing."""
|
||
reason = (t or {}).get('_callout_silence')
|
||
if reason:
|
||
return reason
|
||
return ('1.5 returned nothing and left no reason -- treat as not run, '
|
||
'not as a pass')
|
||
|
||
|
||
@_register_skip_probe('check_dunnage_openings')
|
||
def _probe_dunnage_openings(t, intent, know, spec):
|
||
"""Why 6.4 said nothing.
|
||
|
||
Handed back 2026-08-30: this check had six silent `return []` paths and
|
||
NO probe, so `run_all` logged it RAN-EMPTY and the row rendered NOT RUN
|
||
with the reason "no probe declared" -- a sentence about the tool that
|
||
told the reader nothing about the piece. The check now leaves
|
||
`_dunnage_silence` at every one of those sites; this reads it, the same
|
||
shape `_probe_bed_plan_reverse` already used for 5.2.
|
||
|
||
THE ONES THAT ARE NOT PASSES, and they are why the item came back: "no
|
||
size notes found" and "size notes found but none parsed into feet" are
|
||
failures to READ THE TICKET, reached only once the geometry already
|
||
proved the piece has an opening for the dunnage to matter against. A
|
||
ticket whose dunnage row could not be read is not a ticket whose blocks
|
||
are clear of the openings, and both keep reporting NOT RUN -- as does a
|
||
panel solid the geometry solve could not read a second time for the
|
||
openings themselves (`solid is None`, told apart from zero openings
|
||
measured -- see `check_dunnage_openings`, owner's correction 08-31,
|
||
"6.4 should be OK, since there is no opening"). Two passes reach the table without
|
||
ever setting `_dunnage_silence`, and this probe never sees them: a
|
||
piece with zero openings, measured, and the fall-through after the
|
||
clearance loop.
|
||
|
||
The fallback is a reason, never None: with a probe registered, a None
|
||
return would turn every one of those silences into a quiet OK, which is
|
||
a worse failure than the one this fixes.
|
||
"""
|
||
reason = (t or {}).get('_dunnage_silence')
|
||
if reason:
|
||
return reason
|
||
return ('6.4 returned nothing and left no reason -- treat as not run, '
|
||
'not as a pass')
|
||
|
||
|
||
@_register_skip_probe('check_parity_note')
|
||
def _probe_parity_note(t, intent, know, spec):
|
||
"""Why 6.6 said nothing.
|
||
|
||
Registered with the check, not after it: a check added to `CHECKS`
|
||
without a probe logs `RAN-EMPTY` and renders NOT RUN reading "no probe
|
||
declared" -- a sentence about the tool that tells a reader nothing
|
||
about the piece. `check_parity_note` leaves `_parity_note_silence` on
|
||
every path that returns empty, and there are seven of them: the part,
|
||
the parity and the phrase each unset, the family name unverified, the
|
||
census unmakeable, a member unidentifiable, and an unreadable BED PLAN.
|
||
|
||
The fallback is a reason and NEVER None. With a probe registered a None
|
||
return converts a silence into a quiet OK, which is a worse failure
|
||
than the one the probe fixes.
|
||
"""
|
||
reason = (t or {}).get('_parity_note_silence')
|
||
if reason:
|
||
return reason
|
||
return ('6.6 returned nothing and left no reason -- treat as not run, '
|
||
'not as a pass')
|
||
|
||
|
||
@_register_skip_probe('check_lifter_edge_distance')
|
||
def _probe_lifter_edge_distance(t, intent, know, spec):
|
||
"""Why 6.2 said nothing.
|
||
|
||
Registered 2026-08-31 with the per-part fix above. Before it, the check
|
||
opened `if not limit_in: return []` -- a job that had never set
|
||
`lifters.min_edge_distance_in` produced no finding, no probe, and 6.2
|
||
read "no probe declared": a sentence about the tool that told the reader
|
||
nothing about the piece, on a safety check.
|
||
|
||
The two silences are different answers and are named differently. No
|
||
insert with a readable position is a fact about the PIECE; no minimum
|
||
for any insert is a fact about the CATALOGUE and the job file, and it is
|
||
fixed somewhere else. Neither is a pass.
|
||
|
||
The fallback is a reason and never None: with a probe registered, a None
|
||
return would turn a silence into a quiet OK, which is the failure this
|
||
exists to prevent.
|
||
"""
|
||
lifters = [l for l in ((t or {}).get('lifters') or []) if l.get('local')]
|
||
if not lifters:
|
||
return ('no lifting insert on this piece has a readable position, so '
|
||
'no edge distance could be measured')
|
||
unknown = []
|
||
for lift in lifters:
|
||
family = lift.get('family') or '(insert with no family name)'
|
||
limit_in, _source = _edge_limit(know, family)
|
||
if limit_in is not None:
|
||
return None # something was judged; it ran
|
||
if family not in unknown:
|
||
unknown.append(family)
|
||
return ('no minimum edge distance is published for {0} and '
|
||
'lifters.min_edge_distance_in is not set, so how far an insert '
|
||
'must sit from an end is not known on this job -- nothing was '
|
||
'judged'.format(', '.join(sorted([str(f) for f in unknown]))))
|
||
|
||
|
||
@_register_skip_probe('check_lifter_anchor_spacing')
|
||
def _probe_lifter_anchor_spacing(t, intent, know, spec):
|
||
"""Why 6.7 said nothing.
|
||
|
||
Three silences and they are three different facts, so they are three
|
||
different sentences. One is about the PIECE (there is no pair to
|
||
measure), one is about the CATALOGUE (no part on it publishes a spacing
|
||
or an edge distance to double), and one is about the GEOMETRY. None is
|
||
a pass; a spacing check that finds nothing to say about a piece with two
|
||
anchors on it has told the reader nothing about the anchors.
|
||
|
||
The fallback is a reason and never None: with a probe registered, a None
|
||
return turns a silence into a quiet OK.
|
||
"""
|
||
lifters = [l for l in ((t or {}).get('lifters') or []) if l.get('local')]
|
||
if len(lifters) < 2:
|
||
return ('fewer than two lifting inserts on this piece have a '
|
||
'readable position, so there is no pair to measure a '
|
||
'spacing between')
|
||
if _in_plane_axes(t) is None:
|
||
return ('the panel geometry could not be read, so the plane the '
|
||
'anchors sit in is unknown and no spacing was measured')
|
||
unknown = []
|
||
for lift in lifters:
|
||
family = lift.get('family') or '(insert with no family name)'
|
||
limit_in, _source, why = _spacing_limit(know, family)
|
||
if limit_in is not None:
|
||
return None # something was judged; it ran
|
||
if why not in unknown:
|
||
unknown.append(why)
|
||
return ('no minimum anchor spacing could be resolved for any insert on '
|
||
'this piece, so how far apart two of them must sit is not known '
|
||
'-- nothing was judged. {0}'.format('; '.join(sorted(unknown))))
|
||
|
||
|
||
@_register_skip_probe('check_lifting_points')
|
||
def _probe_lifting_points(t, intent, know, spec):
|
||
"""Why 6.9 said nothing.
|
||
|
||
Two silences, and only two -- everything else that can go wrong once
|
||
the rule applies is said as a `LIFT-POINTS-UNREAD` finding by the check
|
||
itself, not left for this probe. Ordered PLANT fact before PIECE fact:
|
||
whether MC's numbers even apply here does not depend on what is
|
||
modelled, so it is answered first and for free.
|
||
|
||
The fallback is a reason and never None: with a probe registered, a
|
||
None return turns a silence into a quiet OK.
|
||
"""
|
||
warn_ft = K.get(know, 'lifters.lifting_points.two_point_max_length_ft')
|
||
fail_ft = K.get(know, 'lifters.lifting_points.two_point_absolute_max_ft')
|
||
if not warn_ft or not fail_ft:
|
||
return ('lifters.lifting_points.two_point_max_length_ft / '
|
||
'.two_point_absolute_max_ft are not both set -- this is an '
|
||
"MC plant rule (2-point nominal to 20 ft, 2-point actually "
|
||
'good to 25 ft) and it was not applied to a job that has '
|
||
'not declared both numbers')
|
||
if not ((t or {}).get('lifters') or []):
|
||
return ('no lifting insert was recognised on this piece -- see 6.1 '
|
||
'(LIFT-CAPACITY), which already reports why')
|
||
return None # something was judged; it ran
|
||
|
||
|
||
@_register_skip_probe('check_lift_positions')
|
||
def _probe_lift_positions(t, intent, know, spec):
|
||
"""Why 6.10 said nothing.
|
||
|
||
Same shape as 6.9's own probe, and for the same reason -- PLANT fact
|
||
before PIECE fact. Every other silence this check can reach (length
|
||
unreadable, no lifter position readable, or the point count's PCI
|
||
station not yet configured) is already said as a `LIFT-POS-UNREAD`
|
||
finding by the check itself, so it never reaches this probe with an
|
||
empty return; only the two genuine silences below do.
|
||
"""
|
||
tol_in = K.get(know, 'lifters.lifting_points.pci_position_tolerance_in')
|
||
if not tol_in:
|
||
return ('lifters.lifting_points.pci_position_tolerance_in is not '
|
||
"set -- this is an MC plant rule (owner ruling 2026-09-02: "
|
||
'a lift point more than 6" off PCI\'s recommended station '
|
||
'warns) and it was not applied to a job that has not '
|
||
'declared the tolerance')
|
||
if not ((t or {}).get('lifters') or []):
|
||
return ('no lifting insert was recognised on this piece -- see 6.1 '
|
||
'(LIFT-CAPACITY), which already reports why')
|
||
return None # something was judged; it ran
|
||
|
||
|
||
@_register_skip_probe('check_dunnage_stripping_match')
|
||
def _probe_dunnage_stripping_match(t, intent, know, spec):
|
||
"""Why 6.11 said nothing.
|
||
|
||
Same shape as 6.9/6.10's own probes -- PLANT fact before PIECE fact.
|
||
Zero recognised lifters defers to 6.1 by name, the same pattern as 6.9.
|
||
Every other silence this check can reach (no readable lifter position,
|
||
no back_stripping/face lifter identified, dunnage stations unreadable)
|
||
is already said as a `DUN-STRIP-UNREAD` finding by the check itself, so
|
||
it never reaches this probe with an empty return.
|
||
|
||
The fallback is a reason and never None: with a probe registered, a
|
||
None return would turn a silence into a quiet OK.
|
||
"""
|
||
if not K.get(know, 'lifters.lifting_points.dunnage_matches_stripping'):
|
||
return ('lifters.lifting_points.dunnage_matches_stripping is not '
|
||
"set -- this is an MC plant rule (owner ruling 2026-09-02: "
|
||
'dunnage-point count defaults to matching stripping-point '
|
||
'(face-lifter) count) and it was not applied to a job that '
|
||
'has not enabled it')
|
||
if not ((t or {}).get('lifters') or []):
|
||
return ('no lifting insert was recognised on this piece -- see 6.1 '
|
||
'(LIFT-CAPACITY), which already reports why')
|
||
return None # something was judged; it ran
|
||
|
||
|
||
# ---- the connector probes (5.8, 5.9, 5.10). A silence here is always
|
||
# explained and the sentence is the gate's own -- see `_connector_gate`.
|
||
# =========================================================================
|
||
# THE SEVEN CHECKS THAT HAD NO PROBE -- closed 2026-08-31
|
||
# =========================================================================
|
||
#
|
||
# Owner, on a QC run that came back with more NOT RUN rows than verdicts:
|
||
# "qc别再给我出这么多not-run。看看能跑的,能做的,现在都做了去。"
|
||
#
|
||
# `RAN-EMPTY -> "no probe declared"` is a whole CLASS of NOT RUN, and it is
|
||
# the one that says nothing about the piece at all -- a sentence about the
|
||
# tool, printed in red, on a row that may well be fine. Seven checks could
|
||
# still produce it (re-derived here, not copied from a document: `CHECKS`
|
||
# minus the keys of `_SKIP_PROBES`, 37 - 30). This block is the last of
|
||
# them, and `test_every_registered_check_has_a_skip_probe` keeps it closed.
|
||
#
|
||
# TWO SHAPES OF PROBE LIVE BELOW AND THEY ARE NOT INTERCHANGEABLE:
|
||
#
|
||
# * a check with a real silent path returns the REASON for that path and
|
||
# `None` otherwise -- the ordinary contract;
|
||
# * a check with NO silent path returns a reason ALWAYS, saying so. It is
|
||
# never consulted while the check behaves, and the day a refactor makes
|
||
# one of them return `[]` the row says which check went quiet and that
|
||
# the silence is a defect in the tool -- which beats "no probe
|
||
# declared" by naming the place to look.
|
||
#
|
||
# Every sentence names a verifiable place: a knowledge key, a ticket key, or
|
||
# the function whose empty return produced it.
|
||
|
||
|
||
@_register_skip_probe('check_lifter_capacity')
|
||
def _probe_lifter_capacity(t, intent, know, spec):
|
||
"""6.1. One silent path: no lifting insert was recognised.
|
||
|
||
Recognition is `families.lifter_include` / `families.lifter_exclude`
|
||
against the family name, so a piece with inserts the plant has not
|
||
named reaches this the same way a piece with none does -- and those are
|
||
different things. Both are named, because the repair differs.
|
||
"""
|
||
if not (t.get('lifters') or []):
|
||
return ('no lifting insert was recognised on this piece, so there '
|
||
'was no rated capacity to add up. Either the piece carries '
|
||
'none, or none of its inserts matches '
|
||
'families.lifter_include / families.lifter_exclude -- and '
|
||
'the second is a plant fact, not a defect in the piece')
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_handling_result')
|
||
def _probe_handling_result(t, intent, know, spec):
|
||
"""6.3. THREE silent paths, and they were the three PROJECT.md §4.4
|
||
named without saying what each one meant. Each is a different repair."""
|
||
if not t.get('panel_id'):
|
||
return ('the concrete panel of this assembly was not identified, so '
|
||
'no handling check could be found or run for it. '
|
||
'`read_ticket` sets `panel_id` from the assembly members it '
|
||
'recognises as the panel')
|
||
if not (K.get(know, 'handling.lifter_groups', {}) or {}):
|
||
return ('handling.lifter_groups is empty: no layer says which '
|
||
'families carry which handling stage at this plant, so '
|
||
'handling checking is not set up and nothing was run. That '
|
||
'is a missing plant fact, not a finding about this piece')
|
||
if not t.get('mark'):
|
||
return ('this assembly carries no mark, so no handling result could '
|
||
'be looked up -- results are filed by mark')
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_member_interference')
|
||
def _probe_member_interference(t, intent, know, spec):
|
||
"""7.1. Two silent paths before any geometry is touched.
|
||
|
||
The third empty return inside the check is the CLEAN one -- geometry
|
||
solved, no pair interfering -- and this must return None for it, which
|
||
it does by falling through.
|
||
"""
|
||
if not K.get(know, 'interference.check', False):
|
||
return ('interference.check is off, so the clash check did not run. '
|
||
'It is the only check that solves every member\'s geometry, '
|
||
'so it is opt-in per job -- turn it on in the job layer')
|
||
if not t.get('panel_id'):
|
||
return ('the concrete panel of this assembly was not identified, so '
|
||
'there was no piece to test the members against. '
|
||
'`read_ticket` sets `panel_id`')
|
||
return None
|
||
|
||
|
||
@_register_skip_probe('check_picking_match')
|
||
def _probe_picking_match(t, intent, know, spec):
|
||
"""6.8. SIX silent paths, and they fall in three groups.
|
||
|
||
The last group is the one worth the sentence: the check recovers the
|
||
picking stations from the ticket's own printed size band, and when that
|
||
band does not parse there is nothing to compare -- which is a READING
|
||
failure of the tool and never a defect in the piece. Saying so is the
|
||
difference between "the ticket is wrong" and "we could not read it".
|
||
"""
|
||
if not (t.get('sheets') or []):
|
||
return ('no sheet has been issued for this piece, so there is no '
|
||
'printed picking diagram to compare the modelled inserts '
|
||
'against -- nothing is claimed either way')
|
||
if t.get('length') is None:
|
||
return ('this piece\'s length could not be measured, and the picking '
|
||
'diagram prints segments FROM EACH END -- without the length '
|
||
'they do not resolve into stations')
|
||
groups = K.get(know, 'handling.lifter_groups', {}) or {}
|
||
if not list((groups.get('face') or {}).get('families') or []):
|
||
return ('handling.lifter_groups.face.families is empty: no layer '
|
||
'says which families are the picking inserts at this plant, '
|
||
'so the printed stations had nothing to be matched against. '
|
||
'That is a missing plant fact, not a finding about this '
|
||
'piece')
|
||
return ('the picking stations printed on this ticket could not be '
|
||
'recovered from its top size band -- `check_picking_match` reads '
|
||
'the TextNotes at the highest y on the sheet and needs at least '
|
||
'two that parse as a length, so either the band is not there or '
|
||
'it is not written the way this reader knows. Nothing about the '
|
||
'modelled inserts is claimed')
|
||
|
||
|
||
# ---- the three that have NO silent path ---------------------------------
|
||
#
|
||
# 1.1, 1.3 and 8.2 each return a Finding on every path they can reach:
|
||
# `sheet_name_matches`, `ticket_set_status` and `invisible_chars` all answer
|
||
# with a (severity, message) pair even for an empty input -- a piece with
|
||
# no sheet gets FAIL "no sheet found for this piece", not silence. So these
|
||
# probes never legitimately fire, and each says exactly that plus where to
|
||
# look if it ever does.
|
||
|
||
def _cannot_be_silent(row, check, worker):
|
||
def probe(t, intent, know, spec):
|
||
return ('{0} returned nothing, and `{1}` has no path that can: '
|
||
'`{2}` answers with a severity and a sentence for every '
|
||
'input, including no sheets at all. Read this row as NOT '
|
||
'RUN and treat the silence as a defect in the tool -- it is '
|
||
'not a statement about the piece'.format(row, check, worker))
|
||
return probe
|
||
|
||
|
||
_SKIP_PROBES['check_sheet_name'] = _cannot_be_silent(
|
||
'1.1', 'check_sheet_name', 'ticketpage.sheet_name_matches')
|
||
_SKIP_PROBES['check_ticket_set'] = _cannot_be_silent(
|
||
'1.3', 'check_ticket_set', 'ticketpage.ticket_set_status')
|
||
_SKIP_PROBES['check_invisible'] = _cannot_be_silent(
|
||
'8.2', 'check_invisible', 'ticketpage.invisible_chars')
|
||
|
||
|
||
# Registered at the END of the file because `_register_skip_probe` is
|
||
# defined below `CHECKS`; a decorator used above it is a NameError at
|
||
# import, which would take every button down with it.
|
||
|
||
|
||
@_register_skip_probe('check_connectors_exist')
|
||
def _probe_connectors_exist(t, intent, know, spec):
|
||
return t.get('_connector_silence')
|
||
|
||
|
||
@_register_skip_probe('check_connectors_sufficient')
|
||
def _probe_connectors_sufficient(t, intent, know, spec):
|
||
return t.get('_connector_count_silence')
|
||
|
||
|
||
@_register_skip_probe('check_connector_edge_distance')
|
||
def _probe_connector_edge(t, intent, know, spec):
|
||
return t.get('_connector_edge_silence')
|
||
|
||
|
||
# ---- the solid-zone and recess probes (5.11 - 5.15) ----------------------
|
||
#
|
||
# EVERY EMPTY PATH IN THOSE FIVE CHECKS LEAVES A SENTENCE BEHIND, and these
|
||
# read it. The fallback below is a REASON and never None: with a probe
|
||
# registered, a None return converts a silence into a quiet OK -- a worse
|
||
# failure than the "no probe declared" one the probe exists to fix.
|
||
#
|
||
# The gates a reader will actually meet, in the order they fire:
|
||
# * the solid-zone family unset, or set and unverified -> plant fact
|
||
# * no lifter recognised, so nothing to look inside -> plant fact
|
||
# * no sheet issued for this piece -> NOT RUN, and
|
||
# it is not a pass and not a failure. 12 of the 27 insulated pieces
|
||
# have no sheet yet (measured 2026-08-31).
|
||
# * the layout view unnamed at this plant -> plant fact
|
||
# * no legend read AND no plant map -> the reference
|
||
# itself is missing, which is named rather than filled in
|
||
# * the rendered pattern ungathered -> the gather
|
||
# half, named precisely in the sentence
|
||
|
||
def _zone_probe(key, row, what):
|
||
def probe(t, intent, know, spec):
|
||
reason = (t or {}).get(key)
|
||
if reason:
|
||
return reason
|
||
return ('{0} returned nothing and left no reason -- treat as not '
|
||
'run, not as a pass. It was asked whether {1}'
|
||
.format(row, what))
|
||
return probe
|
||
|
||
|
||
_SKIP_PROBES['check_solid_zone_members'] = _zone_probe(
|
||
'_solid_zone_silence', '5.11',
|
||
'every lifter\'s nested solid zone is a member of this assembly')
|
||
_SKIP_PROBES['check_solid_zone_on_ticket'] = _zone_probe(
|
||
'_solid_zone_ticket_silence', '5.12',
|
||
'those solid zones appear on the ticket\'s insulation layout view')
|
||
_SKIP_PROBES['check_solid_zone_hatch'] = _zone_probe(
|
||
'_solid_zone_hatch_silence', '5.13',
|
||
'the solid zones draw with the hatch the legend\'s own swatch shows')
|
||
_SKIP_PROBES['check_recess_regions'] = _zone_probe(
|
||
'_recess_silence', '5.14',
|
||
'the recess regions the design calls for are drawn on this ticket')
|
||
_SKIP_PROBES['check_recess_hatch'] = _zone_probe(
|
||
'_recess_hatch_silence', '5.15',
|
||
'each recess region draws with the hatch its legend swatch shows')
|