Files
willtools-demo/WillTools.extension/lib/vibe/ticketchecks.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

7938 lines
355 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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')