25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
5826 lines
266 KiB
Python
5826 lines
266 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Pure arithmetic and parsing for the ticket-page checks.
|
||
|
||
WHY THIS MODULE EXISTS, AND WHY IT IS FREE OF REVIT.
|
||
|
||
Phase 2 of the ticket refactor repoints the legend-dependent checks at the
|
||
information a ticket really carries. The `read_ticket` half of that lives in
|
||
ticketqc.py, which imports the Revit API at module scope -- correct for the
|
||
extension, fatal for tests. The arithmetic half -- recover a strand count
|
||
from the BOM's linear-feet row, decide whether a title-block parameter
|
||
tolerances a strength comparison, compare BOM/length against model -- needs
|
||
no Revit at all, and it is where a misjudged number turns into a false
|
||
finding. So the numbers live here, tested on plain CPython the way panellift
|
||
is; ticketqc.read_ticket feeds the raw values in and ticketqc.check_ticket
|
||
calls the comparison functions back.
|
||
|
||
Everything here is IronPython 2.7 compatible: no f-strings, no pathlib.
|
||
|
||
Two traps this module is designed around, both measured on real tickets:
|
||
|
||
* The BOM's strand row is a LENGTH IN FEET, not a count. WP210 prints 249 on
|
||
a 24'-11" piece, and 249 / 24.9167 = 9.993 -- the section prints (10).
|
||
Reading the row as a quantity produced a "23x error" report on a correct
|
||
ticket once. The count is recovered by division, never read directly.
|
||
|
||
* A count printed in the DESIGN SUMMARY is the design family's figure, not
|
||
this piece's. The summary says (14); individual pieces print 10, 6 or 14
|
||
according to what they carry. Comparing the summary's count against a
|
||
piece's model is a guaranteed false finding. The ticket's own strand
|
||
number is the only one a ticket check may be held to.
|
||
"""
|
||
|
||
# THE ONLY MODULE-SCOPE IMPORTS THIS FILE HAS, AND THEY ARE STDLIB ONLY.
|
||
# `ticketpage` is the Revit-free half by design (CLAUDE.md): the whole test
|
||
# suite reaches it on host CPython with no licence, and nothing here may
|
||
# import the Revit API, `vibe.ticketqc`, or anything that does. `os` and `io`
|
||
# arrived with the master checklist, which is read off disk at import.
|
||
import io
|
||
import os
|
||
|
||
|
||
def strand_count_from_linear_ft(linear_ft, length_ft, tolerance=0.05):
|
||
"""Strands implied by the BOM's linear-feet row.
|
||
|
||
linear_ft -- the BOM strand row (a length of strand, in feet).
|
||
length_ft -- the panel length, in feet.
|
||
tolerance -- how far the ratio may sit from a whole number before the
|
||
row is treated as unusable. Measurement, not invention:
|
||
12 real tickets' remainders span 0.0032 .. 0.0226, so
|
||
0.05 is a clean gap above them. A row that is actually a
|
||
COUNT or a misread length sits far outside it (10 / 24.9 =
|
||
0.40; 249 / 2.49 = 99.93), and a confident wrong count
|
||
from such a row is worse than an honest "unusable".
|
||
|
||
Returns the count, rounded to the nearest whole strand, or None when
|
||
either input is unusable or the ratio is not close to a whole number.
|
||
The round is deliberate: 321 / 22.9167 = 14.007 and 249 / 24.9167 =
|
||
9.993 -- the printed section counts are the rounded values, and half a
|
||
strand is not a thing.
|
||
"""
|
||
if linear_ft is None or not length_ft:
|
||
return None
|
||
try:
|
||
ratio = float(linear_ft) / float(length_ft)
|
||
except (TypeError, ValueError, ZeroDivisionError):
|
||
return None
|
||
nearest = round(ratio)
|
||
if abs(ratio - nearest) > tolerance:
|
||
return None
|
||
return int(nearest)
|
||
|
||
|
||
def strengths_agree(summary_psi, ticket_psi, tolerance=0.5):
|
||
"""Whether the ticket's printed strength matches the design's.
|
||
|
||
summary_psi -- f'c or f'ci from the reviewed spec / summary.
|
||
ticket_psi -- the strength printed on the ticket (title block), or None
|
||
when the ticket does not carry it.
|
||
|
||
Returns (agree, message). When ticket_psi is None the answer is
|
||
(False, ...) and the caller decides what that means -- an unmapped role
|
||
(this plant never prints it) is not the same as a missing value.
|
||
"""
|
||
if summary_psi is None:
|
||
return True, ''
|
||
if ticket_psi is None:
|
||
return False, 'ticket carries no value to compare against'
|
||
try:
|
||
want, got = float(summary_psi), float(ticket_psi)
|
||
except (TypeError, ValueError):
|
||
return False, 'non-numeric value on the ticket: {0!r}'.format(ticket_psi)
|
||
if abs(want - got) <= tolerance:
|
||
return True, '{0:,.0f} psi'.format(got)
|
||
return False, 'summary {0:,.0f} psi, ticket {1:,.0f} psi'.format(want, got)
|
||
|
||
|
||
# ------------------------------------------------------- BOM disposition
|
||
#
|
||
# `LGD-MISSING` used to be the one place that said anything about a ticket
|
||
# with no bill of materials, and it was wrong three ways (retired
|
||
# 2026-08-30):
|
||
#
|
||
# * no checklist item owned LGD-*, so neither its OK nor its FAIL ever
|
||
# reached the table -- an orphan finding nobody could act on;
|
||
# * it could not tell "this ticket has no BOM" from "the BOM could not be
|
||
# read", because the gatherer swallowed every per-schedule failure, and
|
||
# it called both a FAIL -- an unreadable input reported as a defect;
|
||
# * its premise is contradicted by the plant standard. Section 8000 of the
|
||
# manufacturer's manual (p713) says "Label all rebar - no description
|
||
# under material list", so an empty BOM description is CORRECT here.
|
||
#
|
||
# What replaces it: every check that wants the BOM asks these functions and
|
||
# reports its own NOT RUN with its own reason. A BOM-less ticket is named by
|
||
# everything that wanted one, and "absent" never reads as "unreadable".
|
||
|
||
BOM_OK = 'ok' # a BOM was read and it carries content
|
||
BOM_PARTIAL = 'partial' # content read, but some schedule failed
|
||
BOM_ABSENT = 'absent' # read fine; the ticket simply has no BOM
|
||
BOM_UNREADABLE = 'unreadable' # the ticket may have one; we could not read it
|
||
BOM_UNKNOWN = 'unknown' # nothing was gathered -- we never even looked
|
||
|
||
|
||
def bom_state(bom):
|
||
"""What can be said about a ticket's BOM. Returns (state, reason).
|
||
|
||
`bom` is what ticketqc._read_bom_schedules returned:
|
||
|
||
{'parts': [...], 'strand_linear_ft': x or None,
|
||
'schedules': [names read],
|
||
'unreadable': [(name, why), ...]}
|
||
|
||
reason is None only for BOM_OK. For every other state it is a sentence a
|
||
reader can act on, and ABSENT and UNREADABLE never share one: the first
|
||
is a fact about the ticket, the second is a failure of ours.
|
||
"""
|
||
if bom is None:
|
||
return BOM_UNKNOWN, ('no BOM was gathered for this ticket at all, so '
|
||
'nothing about it could be judged')
|
||
unreadable = list(bom.get('unreadable') or [])
|
||
read = [str(n) for n in (bom.get('schedules') or [])]
|
||
has_content = bool(bom.get('parts')) or bom.get('strand_linear_ft') is not None
|
||
if has_content:
|
||
if unreadable:
|
||
return BOM_PARTIAL, bom_unreadable_sentence(unreadable)
|
||
return BOM_OK, None
|
||
if unreadable:
|
||
return BOM_UNREADABLE, bom_unreadable_sentence(unreadable)
|
||
if read:
|
||
return BOM_ABSENT, (
|
||
'the ticket carries {0} schedule(s) ({1}) and every one of them '
|
||
'was read, but none is a bill of materials -- this ticket has no '
|
||
'BOM'.format(len(read), ', '.join(read)))
|
||
return BOM_ABSENT, ('this ticket carries no schedule at all, so it has '
|
||
'no bill of materials to read')
|
||
|
||
|
||
def bom_unreadable_sentence(unreadable):
|
||
"""The 'we could not read it' sentence, naming what failed and why.
|
||
|
||
Deliberately says out loud that this is OUR failure, not the ticket's:
|
||
the ticket may well carry a perfectly good BOM.
|
||
"""
|
||
bits = []
|
||
for entry in list(unreadable)[:4]:
|
||
if isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
||
bits.append('{0}: {1}'.format(entry[0], entry[1]))
|
||
else:
|
||
bits.append(str(entry))
|
||
more = '' if len(unreadable) <= 4 else ' (+{0} more)'.format(
|
||
len(unreadable) - 4)
|
||
return ('{0} schedule(s) on this ticket could not be read -- {1}{2}. That '
|
||
'is an unreadable input, not a missing BOM: the ticket may carry '
|
||
'one'.format(len(unreadable), '; '.join(bits), more))
|
||
|
||
|
||
def bom_not_run_reason(bom, what):
|
||
"""Why a BOM-reading check did not run, or None when it can run.
|
||
|
||
`what` names the comparison, so a BOM-less ticket is named once by every
|
||
check that wanted the BOM rather than judged once by an orphan code.
|
||
"""
|
||
state, reason = bom_state(bom)
|
||
if state in (BOM_OK, BOM_PARTIAL):
|
||
return None
|
||
return '{0} could not be judged: {1}'.format(what, reason)
|
||
|
||
|
||
def count_agrees(bom_count, model_count, section_count=None):
|
||
"""Three-way agreement on strands: BOM/length vs model vs section print.
|
||
|
||
bom_count -- strand_count_from_linear_ft(...) -- the BOM row's
|
||
implied count, or None if the row was unusable.
|
||
model_count -- strands actually in the assembly.
|
||
section_count -- the (n) printed in section A-A, or None to compare two
|
||
ways only.
|
||
|
||
Returns (agree, what). The check must not fail a ticket when the BOM
|
||
row was unusable and only whisper the model agrees with itself.
|
||
"""
|
||
if bom_count is None:
|
||
return False, ('BOM strand row unusable -- count cannot be '
|
||
'recovered from it')
|
||
if model_count is None:
|
||
return False, 'strand count in the model is unavailable'
|
||
if not bom_count and not model_count:
|
||
return True, 'no strands on this piece'
|
||
if bom_count != model_count:
|
||
return False, ('BOM implies {0} strands but the model has {1}'
|
||
.format(bom_count, model_count))
|
||
if section_count is not None and section_count != bom_count:
|
||
return False, ('section prints {0}, BOM implies {1}'
|
||
.format(section_count, bom_count))
|
||
return True, '{0} strands (BOM {1} / model {2} agree)'.format(
|
||
bom_count, bom_count, model_count)
|
||
|
||
|
||
# ------------------------------------------------------------- handling
|
||
|
||
def parse_feet_text(text):
|
||
"""Parse a size string like '5'-0\"' or '4'-9\"' into decimal feet.
|
||
|
||
Sheet TextNotes carry the handling sizes as bare strings; this is the
|
||
boundary parse. Returns None when the text is not a size.
|
||
"""
|
||
import re as _re
|
||
if not text:
|
||
return None
|
||
m = _re.match(r"^\s*(\d+)'-(\d+)(?:\s+(\d+)/(\d+))?\"?\s*$", text.strip())
|
||
if not m:
|
||
return None
|
||
frac_feet = 0.0
|
||
if m.group(3):
|
||
frac_feet = (int(m.group(3)) / float(m.group(4))) / 12.0
|
||
return int(m.group(1)) + int(m.group(2)) / 12.0 + frac_feet
|
||
|
||
|
||
def stations_from_ends(length_ft, printed_ft):
|
||
"""Stations of a handling diagram whose segments run from BOTH ends.
|
||
|
||
The PICKING/STRIPPING diagrams print segments from each end with a blank
|
||
in the middle -- WP412 prints 2'-6" 7'-6" [blank] 7'-6" 2'-6" on a
|
||
24'-11" piece. The crew locates points from the ends; the interior span
|
||
is derived and deliberately left off. So the first half of the printed
|
||
segments places points FROM THE LEFT, the second half FROM THE RIGHT:
|
||
|
||
2'-6" -> 2.5, 7'-6" -> 10.0 (from the left end)
|
||
2'-6" -> 22.417, 7'-6" -> 14.917 (from the right end)
|
||
|
||
which are exactly the four modelled stations. printed_ft is in DRAWING
|
||
order (left-to-right on the diagram); the diagram is symmetric, so the
|
||
chain is split at the middle and mirrored. An odd count means a centre
|
||
segment that belongs on neither side -- it is the unprinted blank half.
|
||
|
||
Returns the stations as a sorted list (excluding 0 and length).
|
||
"""
|
||
n = len(printed_ft)
|
||
if n == 0:
|
||
return []
|
||
half = n // 2
|
||
left = printed_ft[:half]
|
||
right = printed_ft[half:] if n % 2 == 0 else printed_ft[half + 1:]
|
||
stations = []
|
||
acc = 0.0
|
||
for seg in left:
|
||
acc += seg
|
||
stations.append(round(acc, 4))
|
||
acc = 0.0
|
||
for seg in reversed(right):
|
||
acc += seg
|
||
stations.append(round(length_ft - acc, 4))
|
||
seen = set()
|
||
uniq = []
|
||
for s in stations:
|
||
if 0 < s < length_ft and s not in seen:
|
||
seen.add(s)
|
||
uniq.append(s)
|
||
return sorted(uniq)
|
||
|
||
|
||
def dunnage_stations(inset_ft, length_ft=None):
|
||
"""Dunnage blocks: insets from the ends, PRINTING ORDER.
|
||
|
||
The ticket prints the insets left-to-right on the diagram -- WP199's
|
||
NEW version prints 5'-0\" (left) and 3'-0\" (right). The FIRST value is
|
||
from the LEFT end, the SECOND from the RIGHT. Reading them mirrored
|
||
(3 left, 5 right) puts the far block back over the opening on a ticket
|
||
Will already fixed -- the mirror reading is the false one (measured:
|
||
the mirrored station 17.92 falls inside OPENING 1 on the corrected
|
||
ticket).
|
||
|
||
A SINGLE value is the symmetric '4'-9\" each end' form -- one inset,
|
||
both ends.
|
||
|
||
inset_ft -- the printed insets, in drawing order.
|
||
length_ft -- piece length, to compute the right-end station.
|
||
|
||
Returns the stations.
|
||
"""
|
||
if not inset_ft:
|
||
return []
|
||
out = [inset_ft[0]]
|
||
if length_ft is not None:
|
||
if len(inset_ft) > 1:
|
||
out.append(length_ft - inset_ft[1])
|
||
elif inset_ft[0] * 2 < length_ft:
|
||
out.append(length_ft - inset_ft[0]) # 'each end' form
|
||
return sorted(out)
|
||
|
||
|
||
def openings_hit(stations, opening_spans):
|
||
"""Every station that falls strictly inside an opening span.
|
||
|
||
opening_spans -- [(x0, x1), ...] along the length, in feet.
|
||
Returns [(station, (x0, x1)), ...]. A station exactly at an opening's
|
||
edge is a graze, not a hit -- 74% of the width missing under a block is
|
||
the finding, an edge sitting on the jamb is not.
|
||
"""
|
||
hits = []
|
||
for station in stations:
|
||
for (a, b) in opening_spans:
|
||
if a < station < b:
|
||
hits.append((station, (a, b)))
|
||
return hits
|
||
|
||
|
||
# Owner, verbatim (08-28): "这类问题全都走 warning 吧" -- everything of this
|
||
# kind goes out as WARN.
|
||
def dunnage_clearance(station, openings, relevant, fail_in=3.0,
|
||
warn_in=6.0):
|
||
"""Clearance of one dunnage station against the openings that matter.
|
||
|
||
station -- the support point, in feet along the length.
|
||
openings -- full opening rectangles [(x0, x1, y0, y1)] in feet, in
|
||
the piece's local frame (x along length, y across width).
|
||
relevant -- indices into `openings` that count at all. The dunnage
|
||
judgement is torque: only a through-thickness opening
|
||
matters (a support across a hole leaves the piece partly
|
||
unsupported; a shallow recess does not). The caller
|
||
measures penetration (opening_penetration) and passes
|
||
the through openings here. An opening NOT in `relevant`
|
||
is neither INSIDE nor a distance source -- the measured
|
||
false pattern was 1.7%-wide corner pockets reporting WARN
|
||
at 5 inches and FAIL at 1 inch because the distance
|
||
branch did not gate on relevance.
|
||
fail_in, warn_in -- plant edge-clearance thresholds, inches.
|
||
|
||
Returns (severity, name, distance_in):
|
||
'FAIL', 'INSIDE' -- station is inside a relevant opening.
|
||
'FAIL', 'OPENING <n>'-- within fail_in of a relevant opening edge.
|
||
'WARN', 'OPENING <n>'-- within warn_in.
|
||
'OK', None -- clear of every relevant opening.
|
||
|
||
The FAIL here is the inside-the-judgement reading, NOT the reported
|
||
severity: William's ruling (08-28) is that every dunnage finding goes
|
||
out as WARN -- "everything of this kind goes out as warning", INSIDE
|
||
included. The
|
||
3"/6" grading still decides WHETHER anything is reported; the caller
|
||
(check_dunnage_openings) flattens FAIL and WARN both to WARN. fail_in
|
||
is INERT as a severity knob -- it exists to record the plant's
|
||
grading and set the boundary with warn_in, but can never surface as a
|
||
FAIL. See the comment beside the caller.
|
||
"""
|
||
best = None
|
||
for idx in relevant:
|
||
(x0, x1, y0, y1) = openings[idx]
|
||
a, b = x0, x1
|
||
if a < station < b:
|
||
return 'FAIL', 'INSIDE', 0.0
|
||
edge = min(abs(station - a), abs(station - b))
|
||
name = 'OPENING {0}'.format(idx + 1)
|
||
if best is None or edge < best[0]:
|
||
best = (edge, name)
|
||
if best is None:
|
||
return 'OK', None, 0.0
|
||
distance_in = round(best[0] * 12.0, 1)
|
||
if best[0] * 12.0 < fail_in:
|
||
return 'FAIL', best[1], distance_in
|
||
if best[0] * 12.0 < warn_in:
|
||
return 'WARN', best[1], distance_in
|
||
return 'OK', None, distance_in
|
||
|
||
|
||
# ---------------------------------------------------------- strand debond
|
||
|
||
def strand_debond_runs(length_ft, through_openings, strand_positions_in,
|
||
threshold_ft):
|
||
"""Checklist 4.7: runs of concrete too short to develop a strand.
|
||
|
||
length_ft -- piece length along the strand direction, feet.
|
||
through_openings -- THROUGH openings only, as [(x0, x1, y0, y1)] in
|
||
feet, in the piece's local frame (x along the
|
||
length, y across the width, same convention as
|
||
dunnage_clearance). Passing the raw
|
||
liftexport._openings() list is wrong: that
|
||
function cannot tell a hole from a blind
|
||
recess -- both present a side face with an
|
||
in-plane normal -- so the caller must first
|
||
filter with liftexport.opening_penetration and
|
||
pass only the ones marked 'through' (see below).
|
||
strand_positions_in -- one entry per strand: its distance across the
|
||
width, in inches, measured from the same edge
|
||
the openings' y0/y1 are measured from (this is
|
||
ticketqc.strand_layout_of()['across']).
|
||
threshold_ft -- strand.debond_below_continuous_ft, read from
|
||
plant knowledge by the caller. Never defaulted
|
||
here -- a wrong default would silently decide
|
||
what is this plant's rule to set.
|
||
|
||
Returns:
|
||
None -- could not judge at all: length, strand positions or the
|
||
threshold is missing. Distinct from an empty list on
|
||
purpose (Claude 08-30: three of this project's real bugs
|
||
were a check returning OK on missing input; the caller
|
||
must not read None as "ran clean").
|
||
[] -- it COULD judge, and nothing needs debonding: either the
|
||
piece has no through opening at all, or every strand's
|
||
run past its openings is at least threshold_ft. The
|
||
caller does not report a finding for an empty result --
|
||
checklist item 4.7 has nothing to say about a piece with
|
||
no openings, and "ran clean" is not silence either.
|
||
[{'x0':, 'x1':, 'length_ft':, 'strands_in': [...]}]
|
||
-- one entry per run shorter than threshold_ft, sorted by
|
||
x0. strands_in lists (in inches, across the width) every
|
||
strand whose path crosses that run; strands that land on
|
||
the identical run are merged into one entry rather than
|
||
one finding each.
|
||
|
||
WHY A LENGTH-ONLY TEST IS NOT ENOUGH, AND WHY THIS ONE DOES NOT NEED
|
||
ITS OWN HOLE-VS-NOTCH THRESHOLD THE WAY DUNNAGE AND ROW-FLEX DO:
|
||
|
||
Those two checks ask a SECTION question -- how much of the width is
|
||
left to carry a load -- so a void that only nicks a sliver of the
|
||
width has to be told apart from one that spans most of it, and both
|
||
checks carry an explicit fraction for exactly that (DUNNAGE_VOID_
|
||
FRACTION, the ROW-FLEX void-fraction test). This check asks a
|
||
per-strand question instead: does concrete run out along THIS
|
||
strand's own path. That is answered exactly by whether the strand's y
|
||
position falls inside the opening's y0..y1 band (tested below, with a
|
||
hair of tolerance for a strand sitting on a jamb) -- not by how far
|
||
the opening reaches across the width in the abstract. A notch a fifth
|
||
of the width wide still correctly fails this check for the one or two
|
||
strands whose y happens to sit inside it, while a strand two feet
|
||
away never even gets a cut built at its position and is untouched.
|
||
A separate notch/hole threshold would be answering a question this
|
||
check does not ask.
|
||
|
||
WHY A BLIND RECESS DOES NOT COUNT (the caller's filtering job, stated
|
||
here because it drives what "through_openings" must already exclude):
|
||
a recess takes concrete off one face but leaves concrete behind it in
|
||
the thickness direction, so a strand's run through that stretch is
|
||
still against real, continuous concrete -- there is no burst-prone
|
||
stub the way there is across an actual void. That is the same
|
||
judgement dunnage_clearance and ROW-FLEX already make (both act only
|
||
on full-depth / through openings), so this function is written to
|
||
receive the through set pre-filtered rather than repeat the
|
||
raycast here.
|
||
|
||
PIECE ENDS COUNT AS BOUNDARIES TOO: a run bounded by one opening and
|
||
the piece's own end is measured exactly like a run bounded by two
|
||
openings. The same transfer-length argument applies to a strand that
|
||
has to develop in a short stub between an opening and the piece's saw
|
||
cut -- the end of the piece is a real boundary to the concrete THIS
|
||
piece has, not a placeholder meaning "no more openings to consider".
|
||
"""
|
||
if (length_ft is None or length_ft <= 0.0 or not strand_positions_in or
|
||
not threshold_ft or threshold_ft <= 0.0):
|
||
return None
|
||
if not through_openings:
|
||
return []
|
||
|
||
runs_by_key = {}
|
||
for y_in in strand_positions_in:
|
||
y_ft = (y_in or 0.0) / 12.0
|
||
gaps = []
|
||
for (x0, x1, y0, y1) in through_openings:
|
||
lo_y, hi_y = (y0, y1) if y0 <= y1 else (y1, y0)
|
||
if lo_y - 1e-6 <= y_ft <= hi_y + 1e-6:
|
||
lo_x, hi_x = (x0, x1) if x0 <= x1 else (x1, x0)
|
||
gaps.append((max(0.0, min(length_ft, lo_x)),
|
||
max(0.0, min(length_ft, hi_x))))
|
||
if not gaps:
|
||
continue # this strand crosses no opening at all
|
||
|
||
# The runs are the CONCRETE between the openings, not the spans the
|
||
# cut points bound -- an opening's own (x0, x1) is where concrete is
|
||
# ABSENT, so it must be excluded, not reported as a short run of it.
|
||
gaps.sort()
|
||
merged = [list(gaps[0])]
|
||
for (a, b) in gaps[1:]:
|
||
if a <= merged[-1][1] + 1e-9:
|
||
merged[-1][1] = max(merged[-1][1], b)
|
||
else:
|
||
merged.append([a, b])
|
||
|
||
cursor = 0.0
|
||
runs = []
|
||
for (a, b) in merged:
|
||
if a > cursor:
|
||
runs.append((cursor, a))
|
||
cursor = max(cursor, b)
|
||
if cursor < length_ft:
|
||
runs.append((cursor, length_ft))
|
||
|
||
for (a, b) in runs:
|
||
run_len = b - a
|
||
if run_len <= 1e-9:
|
||
continue
|
||
if run_len < threshold_ft:
|
||
key = (round(a, 3), round(b, 3))
|
||
runs_by_key.setdefault(key, set()).add(round(y_in, 3))
|
||
|
||
out = []
|
||
for (x0, x1), strands in sorted(runs_by_key.items()):
|
||
out.append({
|
||
'x0': x0, 'x1': x1, 'length_ft': round(x1 - x0, 4),
|
||
'strands_in': sorted(strands),
|
||
})
|
||
return out
|
||
|
||
|
||
def _n(value):
|
||
"""A number as a person would write it. Local to this module on purpose:
|
||
ticketqc._fmt_num is the same function, but ticketqc imports the Revit
|
||
API at module scope and this file must stay host-testable."""
|
||
if value is None:
|
||
return '-'
|
||
try:
|
||
if abs(value - round(value)) < 1e-9:
|
||
return '{0:,.0f}'.format(value)
|
||
return '{0:,.2f}'.format(value)
|
||
except Exception:
|
||
return str(value)
|
||
|
||
|
||
def _runs_phrase(runs):
|
||
"""The runs 4.7 found, said as 'what and where', never as a count."""
|
||
bits = []
|
||
for run in runs[:4]:
|
||
strands = ', '.join(['{0}"'.format(_n(s))
|
||
for s in (run.get('strands_in') or [])])
|
||
bits.append('{0} ft of concrete from {1} to {2} ft along the piece '
|
||
'(strand(s) at {3} across the width)'
|
||
.format(_n(run.get('length_ft')), _n(run.get('x0')),
|
||
_n(run.get('x1')), strands or 'unknown position'))
|
||
if len(runs) > 4:
|
||
bits.append('+{0} more'.format(len(runs) - 4))
|
||
return '; '.join(bits)
|
||
|
||
|
||
# Owner, verbatim (2026-08-30): "4.5 做掉。如果满足 debond 要求, 那么就期待有
|
||
# debond, 没有 debond 给个 warning."
|
||
def debond_callout_status(required_runs, callouts, threshold_ft):
|
||
"""Checklist 4.5: does the ticket CALL OUT the debonding 4.7 requires?
|
||
|
||
Owner's ruling, 2026-08-30, verbatim: "Get 4.5 done. If the debond
|
||
requirement is met, then expect a debond call-out; if there is no
|
||
debond, give a warning." Where the debond condition is met, expect a
|
||
call-out; where there is none, WARN.
|
||
|
||
This is deliberately NOT a second derivation of the geometry. 4.7
|
||
(`strand_debond_runs`) is the only place that decides where debonding is
|
||
required; 4.5 consumes its answer and asks the ticket the question. So:
|
||
|
||
required_runs -- EXACTLY what strand_debond_runs returned.
|
||
None -- 4.7 could not judge. 4.5 cannot either, and returns None so
|
||
the caller reports NOT RUN. Warning about a requirement
|
||
nobody established would be inventing the requirement.
|
||
[] -- 4.7 judged and nothing requires debonding.
|
||
[..] -- the runs that require it.
|
||
callouts -- where the ticket says so: [{'where': view or BOM row,
|
||
'text': the words found}]. Empty means the ticket is silent;
|
||
the caller must only pass this after actually reading the
|
||
ticket, because "we did not look" is not "it does not say".
|
||
threshold_ft -- strand.debond_below_continuous_ft, for the message only.
|
||
|
||
Returns (severity, code, message), or None when it could not judge.
|
||
|
||
NOT MERGED WITH THE MANUFACTURER'S OWN DEBOND RULE, and that is a
|
||
decision, not an oversight. The manual's plank section (QNA, the
|
||
1000-2000 extraction, A4) debonds a fixed length at each end keyed on
|
||
strand DIAMETER. That is a bed standard; the threshold here is a
|
||
geometric trigger. They are different rules and the manual's fixed
|
||
length must never be substituted for this threshold.
|
||
|
||
WHAT THIS CANNOT SEE, said out loud because a reader will assume
|
||
otherwise: the call-out found on these tickets is a bare note (measured:
|
||
the literal words sitting in the BED PLAN's text alongside the embed
|
||
callouts). It carries no station and no strand number, so a present
|
||
call-out is evidence that SOMEBODY debonded SOMETHING -- not that they
|
||
debonded these runs. That residue is why 4.5 is grade C: code narrows to
|
||
a shortlist, a person decides.
|
||
"""
|
||
if required_runs is None:
|
||
return None
|
||
where = '; '.join(['{0}: "{1}"'.format(c.get('where') or 'ticket',
|
||
(c.get('text') or '').strip())
|
||
for c in (callouts or [])[:3]])
|
||
if not required_runs:
|
||
if callouts:
|
||
return ('OK', 'STRAND-CALLOUT',
|
||
'no run of concrete along a strand is shorter than the '
|
||
'{0} ft a strand needs to develop, so no debond call-out '
|
||
'is required here -- the ticket carries one anyway ({1}). '
|
||
'That is not judged: 4.5 only asks whether a required '
|
||
'call-out is present'.format(_n(threshold_ft), where))
|
||
return ('OK', 'STRAND-CALLOUT',
|
||
'no run of concrete along a strand is shorter than the {0} ft '
|
||
'a strand needs to develop, so this ticket is not expected to '
|
||
'call out any debonding, and it does not'
|
||
.format(_n(threshold_ft)))
|
||
if not callouts:
|
||
return ('WARN', 'STRAND-CALLOUT',
|
||
'debonding is required here -- {0} -- but no debond call-out '
|
||
'appears anywhere on this ticket. Either the call-out is '
|
||
'missing from the drawing or the strands were never debonded'
|
||
.format(_runs_phrase(required_runs)))
|
||
return ('OK', 'STRAND-CALLOUT',
|
||
'debonding is required here -- {0} -- and the ticket calls out '
|
||
'debonding ({1}). The call-out names no station and no strand, '
|
||
'so THAT IT COVERS THESE RUNS WAS NOT VERIFIED -- a person has '
|
||
'to read the drawing to close it'
|
||
.format(_runs_phrase(required_runs), where))
|
||
|
||
|
||
# ---------------------------------------------------------- cast extras
|
||
|
||
def cast_extra_agreement(counted, ticket_mentions, bom_pair, tokens):
|
||
"""Checklist 2.4: are the cast extras the ticket draws the ones counted?
|
||
|
||
2.2 compares three weights, and all three can 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 the different question:
|
||
the SET of cast extras on each side, not the weight they add up to.
|
||
|
||
"The extras that were counted" is exactly what fed 2.2's reconstructed
|
||
weight: `liftexport.extra_cast_members` -- the assembly members whose
|
||
family name matches a `families.cast_include` token, and the Volume that
|
||
`extra_cast_ft3` summed out of each of them. Same function, same rule,
|
||
so 2.4 cannot drift from what 2.2 actually added.
|
||
|
||
"The extras the ticket draws" is read from the ticket's own words: a BOM
|
||
row or a placed view's text naming one of those same tokens. The ledge
|
||
IS drawn as geometry, and no query answers "does this elevation show a
|
||
ledge", so the ticket's text is the proxy -- stated here so nobody reads
|
||
a pass as "the drawing was examined".
|
||
|
||
counted -- [{'family', 'mark', 'volume_ft3' (None when it could
|
||
not be read), 'id'}], or [] for none modelled.
|
||
ticket_mentions -- [{'where', 'text', 'token'}] found on the ticket.
|
||
bom_pair -- bom_state(...)'s (state, reason). Respected as five
|
||
distinct states, never collapsed: OK and PARTIAL can
|
||
be judged (PARTIAL says so in the message), ABSENT can
|
||
be judged from view text alone and says so, while
|
||
UNREADABLE and UNKNOWN mean a row naming the extra may
|
||
have been missed -- so "the ticket does not name it"
|
||
is not assertable and the comparison stands down.
|
||
tokens -- the families.cast_include tokens, upper-case.
|
||
|
||
Returns a list of (severity, code, message), or None when it could not
|
||
judge at all (the caller then reports NOT RUN with the reason).
|
||
"""
|
||
if not tokens:
|
||
return None
|
||
state, why = bom_pair if bom_pair else (BOM_UNKNOWN, None)
|
||
out = []
|
||
|
||
# Model side first, and it needs no ticket at all: an extra whose volume
|
||
# could not be read contributed ZERO to 2.2's reconstructed weight,
|
||
# silently. That is the extra being drawn and NOT counted.
|
||
for item in counted:
|
||
vol = item.get('volume_ft3')
|
||
if vol is None or vol <= 0.0:
|
||
out.append((
|
||
'WARN', 'CAST-EXTRA',
|
||
'{0}{1} is cast concrete of this piece (it matches '
|
||
'families.cast_include) but no usable volume could be read '
|
||
'from it, so it added {2} to the weight 2.2 reconstructed -- '
|
||
'the extra is drawn and not counted'
|
||
.format(item.get('family') or 'an unnamed family',
|
||
_where_id(item),
|
||
'nothing' if vol is None else '0 ft3')))
|
||
|
||
if state in (BOM_UNREADABLE, BOM_UNKNOWN):
|
||
if not out:
|
||
return None # nothing assertable; probe says why
|
||
out.append(('WARN', 'CAST-EXTRA',
|
||
'the cast extras were NOT compared against what the '
|
||
'ticket names, because {0}'.format(why or 'the BOM '
|
||
'could not be read')))
|
||
return out
|
||
|
||
caveat = ''
|
||
if state == BOM_PARTIAL:
|
||
caveat = ' (note: {0})'.format(why)
|
||
elif state == BOM_ABSENT:
|
||
caveat = (' (this ticket has no BOM, so the only ticket-side evidence '
|
||
'is the text drawn on its views)')
|
||
|
||
model_tokens = {}
|
||
for item in counted:
|
||
fam = (item.get('family') or '').upper()
|
||
for tok in tokens:
|
||
if tok and tok in fam:
|
||
model_tokens.setdefault(tok, []).append(item)
|
||
break
|
||
ticket_tokens = {}
|
||
for m in (ticket_mentions or []):
|
||
ticket_tokens.setdefault((m.get('token') or '').upper(), []).append(m)
|
||
|
||
for tok in sorted(model_tokens):
|
||
if tok in ticket_tokens:
|
||
continue
|
||
items = model_tokens[tok]
|
||
total = sum([i.get('volume_ft3') or 0.0 for i in items])
|
||
out.append((
|
||
'WARN', 'CAST-EXTRA',
|
||
'the assembly carries {0} ({1} ft3 of cast concrete, counted '
|
||
'into 2.2\'s reconstructed weight) but nothing on this ticket '
|
||
'names it{2}'
|
||
.format(_families_phrase(items), _n(total), caveat)))
|
||
|
||
for tok in sorted(ticket_tokens):
|
||
if tok in model_tokens:
|
||
continue
|
||
m = ticket_tokens[tok][0]
|
||
out.append((
|
||
'WARN', 'CAST-EXTRA',
|
||
'the ticket names a cast extra -- {0}: "{1}" -- that the '
|
||
'assembly does not carry: no member matches "{2}", so nothing '
|
||
'was added to the reconstructed weight for it. 2.2 cannot see '
|
||
'this: an extra missing from the model is missing from all '
|
||
'three of its weights, which then agree with each other{3}'
|
||
.format(m.get('where') or 'ticket',
|
||
(m.get('text') or '').strip(), tok, caveat)))
|
||
|
||
if out:
|
||
return out
|
||
if not counted and not ticket_mentions:
|
||
return [('OK', 'CAST-EXTRA',
|
||
'no member of this assembly matches families.cast_include '
|
||
'and nothing on the ticket names one, so there is no cast '
|
||
'extra to be missing from the weight. An extra omitted from '
|
||
'BOTH the model and the drawing cannot be detected here and '
|
||
'is not claimed to have been{0}'.format(caveat))]
|
||
total = sum([i.get('volume_ft3') or 0.0 for i in counted])
|
||
return [('OK', 'CAST-EXTRA',
|
||
'{0} ({1} ft3) went into 2.2\'s reconstructed weight and the '
|
||
'ticket names {2}{3}'
|
||
.format(_families_phrase(counted), _n(total),
|
||
_mentions_phrase(ticket_mentions), caveat))]
|
||
|
||
|
||
def _where_id(item):
|
||
ident = item.get('mark') or item.get('id')
|
||
return ' ({0})'.format(ident) if ident else ''
|
||
|
||
|
||
def _families_phrase(items):
|
||
counts = {}
|
||
for i in items:
|
||
fam = i.get('family') or 'an unnamed family'
|
||
counts[fam] = counts.get(fam, 0) + 1
|
||
return ', '.join(['{0} x {1}'.format(counts[f], f)
|
||
for f in sorted(counts)])
|
||
|
||
|
||
def _mentions_phrase(mentions):
|
||
if not mentions:
|
||
return 'none'
|
||
return '; '.join(['{0}: "{1}"'.format(m.get('where') or 'ticket',
|
||
(m.get('text') or '').strip())
|
||
for m in mentions[:3]])
|
||
|
||
|
||
# ------------------------------------------------------------- weight
|
||
|
||
# Owner, verbatim (2026-08-30): "weight 有关的现在都变成 warn。我有点儿想不
|
||
# 清楚,就一律变成 warn。我尝试用过以后可能会再改。"
|
||
def weight_three_way(printed_lb, measured_lb, reconstructed_lb,
|
||
tolerance=0.02):
|
||
"""Which of the three weights disagrees, and what that means.
|
||
|
||
printed_lb -- the ticket's title-block weight (what is printed).
|
||
measured_lb -- the modelled concrete solid x unit weight.
|
||
reconstructed_lb -- length x width x depth less blockouts plus cast
|
||
extras, x unit weight.
|
||
|
||
Returns (severity, code, message):
|
||
'OK' -- all three agree (within tolerance).
|
||
'WARN', 'WGT-PRINTED' -- printed != measured but reconstructed ==
|
||
measured: the ticket's weight field is
|
||
stale, the geometry reads clean.
|
||
'WARN', 'WGT-MODEL' -- printed == reconstructed but measured
|
||
differs: something is modelled that the
|
||
dimensions do not describe.
|
||
'WARN', 'WGT-READ' -- printed == measured but reconstructed
|
||
differs: OUR reading of the geometry is
|
||
wrong. Suspect the tool first.
|
||
'WARN', 'WGT-INCOMPLETE' -- a value is missing; nothing concluded.
|
||
|
||
ALL weight findings are WARN, not FAIL. Will, 2026-08-30: "everything
|
||
related to weight now becomes warn. I'm not entirely sure about it, so
|
||
it's warn across the board for now. I may change it again once I've
|
||
tried using it." Provisional and deliberately so -- the severities go back on the
|
||
table once he has used it on real tickets. Until then a weight
|
||
disagreement is something to look at, never something that condemns a
|
||
ticket on its own.
|
||
|
||
The pattern is the diagnosis (plan 4.2): which pair agrees says which
|
||
side is wrong -- the ticket, the model, or our read.
|
||
"""
|
||
def off(a, b):
|
||
return abs((a - b) / b) if b else None
|
||
|
||
missing = [v is None for v in (printed_lb, measured_lb,
|
||
reconstructed_lb)]
|
||
if any(missing):
|
||
return 'WARN', 'WGT-INCOMPLETE', ('weight comparison incomplete '
|
||
'(printed/model/reconstructed)')
|
||
try:
|
||
p = float(str(printed_lb).replace(',', ''))
|
||
m = float(str(measured_lb).replace(',', ''))
|
||
r = float(str(reconstructed_lb).replace(',', ''))
|
||
except (TypeError, ValueError):
|
||
return 'WARN', 'WGT-INCOMPLETE', (
|
||
'a weight value is not a number -- nothing compared')
|
||
pm = off(p, m)
|
||
rm = off(r, m)
|
||
rp = off(r, p)
|
||
if (pm is not None and pm <= tolerance) and \
|
||
(rm is None or rm <= tolerance):
|
||
return 'OK', 'WGT-OK', '{0:,.0f} lb agrees three ways'.format(m)
|
||
if rm is not None and rm <= tolerance and (pm is None or pm > tolerance):
|
||
return 'WARN', 'WGT-PRINTED', (
|
||
'ticket prints {0:,.0f} lb but the geometry weighs {1:,.0f} lb '
|
||
'-- the printed weight is stale'.format(p, m))
|
||
if rp is not None and rp <= tolerance and (rm is None or rm > tolerance):
|
||
return 'WARN', 'WGT-MODEL', (
|
||
'geometry reads {0:,.0f} lb but the model weighs {1:,.0f} lb -- '
|
||
'something modelled that the dimensions do not describe'
|
||
.format(r, m))
|
||
if pm is not None and pm <= tolerance and (rm is None or rm > tolerance):
|
||
return 'WARN', 'WGT-READ', (
|
||
'our reading of the geometry ({0:,.0f} lb) does not reconcile '
|
||
'with the model ({1:,.0f} lb) -- suspect the tool first'
|
||
.format(r, m))
|
||
return 'WARN', 'WGT-MIXED', (
|
||
'all three weights differ: printed {0:,.0f}, geometry {1:,.0f}, '
|
||
'model {2:,.0f}'.format(p, r, m))
|
||
|
||
|
||
# The three weights of checklist 2.2, in the order the checklist lists
|
||
# them, each with the words a report uses for it. The role names are this
|
||
# module's vocabulary; where the numbers come from is the caller's problem.
|
||
WEIGHT_SOURCES = (
|
||
('printed', "the ticket's printed weight"),
|
||
('reconstructed', 'the weight rebuilt from length x width x depth'),
|
||
('measured', "the modelled solid's own weight"),
|
||
)
|
||
|
||
|
||
def weight_lb(value):
|
||
"""A weight as a float, or None when it is not a number."""
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return float(str(value).replace(',', '').strip())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def weight_agreement(printed_lb, measured_lb, reconstructed_lb,
|
||
tolerance=0.02, unavailable=None):
|
||
"""Checklist 2.2 as it is written, including when it cannot be run.
|
||
|
||
Returns (severity, code, message), or None when fewer than two of the
|
||
three weights could be had. None is 'could not judge' -- the caller
|
||
turns it into NOT RUN with a reason. It is never a pass.
|
||
|
||
With all three: `weight_three_way` decides, and its verdict NAMES which
|
||
of the three is the odd one out, which the checklist requires ("do not
|
||
report a weight finding without saying which of the three disagrees").
|
||
|
||
With exactly two: the pair is still worth comparing, but the result
|
||
says out loud that it ran TWO-way, which two, and why the third was not
|
||
available -- `unavailable` maps a role name to that reason. It is never
|
||
OK, even when the two agree, because a two-way that reads as a clean
|
||
2.2 is exactly the silent degradation this function exists to stop.
|
||
"""
|
||
labels = dict(WEIGHT_SOURCES)
|
||
values = {'printed': weight_lb(printed_lb),
|
||
'measured': weight_lb(measured_lb),
|
||
'reconstructed': weight_lb(reconstructed_lb)}
|
||
have = [role for (role, _label) in WEIGHT_SOURCES
|
||
if values[role] is not None]
|
||
if len(have) >= 3:
|
||
return weight_three_way(values['printed'], values['measured'],
|
||
values['reconstructed'], tolerance)
|
||
if len(have) < 2:
|
||
return None
|
||
|
||
first, second = have
|
||
missing = [role for (role, _label) in WEIGHT_SOURCES
|
||
if role not in have][0]
|
||
reason = (unavailable or {}).get(missing) or 'it could not be read'
|
||
a, b = values[first], values[second]
|
||
base = b if b else a
|
||
off = abs(a - b) / base if base else 0.0
|
||
head = ('only two of the three weights could be read, so this ran '
|
||
'two-way: {0} {1:,.0f} lb vs {2} {3:,.0f} lb'
|
||
.format(labels[first], a, labels[second], b))
|
||
tail = ('. {0} was not available -- {1} -- so which of the three is the '
|
||
'odd one out cannot be said'
|
||
.format(labels[missing][0].upper() + labels[missing][1:], reason))
|
||
if off <= tolerance:
|
||
return 'WARN', 'WGT-TWO-WAY', (head + ', and they agree' + tail)
|
||
return 'WARN', 'WGT-TWO-WAY', (
|
||
head + ', and they disagree by {0:.1%}'.format(off) + tail)
|
||
|
||
|
||
def weight_agreement_no_reconstruction(printed_lb, measured_lb, tolerance,
|
||
reconstruction_reason):
|
||
"""Checklist 2.2 with the reconstruction leg stood down as NOT
|
||
APPLICABLE, never as a WARN of its own.
|
||
|
||
`liftexport._reconstructed_ft3` can find a wythe whose own solid
|
||
`Volume` disagrees with the plan-projection reconstruction by more than
|
||
the wythes's own openings can explain -- a stepped or tapered wythe the
|
||
method has no way to represent (WP100, 2026-09-08). That is a known
|
||
LIMITATION of the tool, not a missing input on the piece, and reporting
|
||
it as `WGT-READ` -- "our reading of the geometry does not reconcile
|
||
with the model, suspect the tool first" -- would be exactly that: a
|
||
warning that blames a fine piece for a hole in the code. So this is a
|
||
SEPARATE function from `weight_agreement`, not a branch inside it:
|
||
`weight_agreement`'s two-way path is deliberately never OK, because a
|
||
missing INPUT is still worth a person's eyes; this one only fires when
|
||
the tool itself has already named why the third number cannot exist,
|
||
and here two-way agreement really is the whole answer.
|
||
|
||
Returns (severity, code, message), or None when printed or measured
|
||
could not be read either -- the caller falls back to NOT RUN.
|
||
"""
|
||
p = weight_lb(printed_lb)
|
||
m = weight_lb(measured_lb)
|
||
if p is None or m is None:
|
||
return None
|
||
tail = (' -- the reconstruction is not applicable: {0}'
|
||
.format(reconstruction_reason))
|
||
off = abs(p - m) / m if m else None
|
||
if off is not None and off <= tolerance:
|
||
return 'OK', 'WGT-OK', (
|
||
'{0:,.0f} lb (measured) agrees with the ticket ({1:,.0f} lb)'
|
||
'{2}'.format(m, p, tail))
|
||
return 'WARN', 'WGT-TWO-WAY-NA', (
|
||
'ticket prints {0:,.0f} lb but the geometry weighs {1:,.0f} lb, '
|
||
'{2:.1%} apart{3}'.format(
|
||
p, m, off if off is not None else 1.0, tail))
|
||
|
||
|
||
# The run-time option `expensive_geometry` (configmodel.default_run_options,
|
||
# DEFAULT ON) decides whether 2.2 is allowed to read the panel's concrete
|
||
# solid -- 30-60 s on the first read of an untouched piece, per ticket.
|
||
#
|
||
# Will, 2026-08-30: "可以做成开关,但是默认是开的". Off-by-default would print
|
||
# NOT RUN on every ticket forever, and a permanently grey row is one people
|
||
# stop reading. So: ON unless somebody turned it off, and when it IS off the
|
||
# check reports NOT RUN naming the switch. Never a silent pass, and never a
|
||
# quieter comparison dressed up as the real three-way.
|
||
EXPENSIVE_GEOMETRY_OFF_REASON = (
|
||
'the run-time option expensive_geometry is OFF for this run, so the '
|
||
"panel's concrete solid was not read -- turn it back on in the project "
|
||
'config (run-options.yaml) to run this check')
|
||
|
||
|
||
def expensive_geometry_off_reason():
|
||
"""The sentence a check prints when the geometry switch is off.
|
||
|
||
A function rather than a bare constant so the reason is one thing, said
|
||
one way, wherever a check has to explain itself.
|
||
"""
|
||
return EXPENSIVE_GEOMETRY_OFF_REASON
|
||
|
||
|
||
def weight_param_note(stated_lb, measured_lb, printed_lb, tolerance=0.02):
|
||
"""The model's stored weight parameter against the weights that are
|
||
evidence. Returns (severity, code, message), or None when there is
|
||
nothing to say.
|
||
|
||
The stored parameter is NOT one of 2.2's three: it is a number somebody
|
||
wrote down, not a number derived from the piece. It is worth reporting
|
||
only when it disagrees with a derived weight, because it is what the
|
||
handling weight gate uses as `stated` -- a parameter that was never
|
||
recalculated stops a healthy piece there, and that has happened.
|
||
"""
|
||
stated = weight_lb(stated_lb)
|
||
against = weight_lb(measured_lb)
|
||
what = "the modelled solid"
|
||
if against is None:
|
||
against = weight_lb(printed_lb)
|
||
what = 'the ticket'
|
||
if stated is None or against is None or not against:
|
||
return None
|
||
if abs(stated - against) / against <= tolerance:
|
||
return None
|
||
return 'WARN', 'WGT-PARAM', (
|
||
"the model's stored weight parameter says {0:,.0f} lb but {1} says "
|
||
'{2:,.0f} lb -- the parameter is not a derived weight and is what '
|
||
'the handling weight gate reads, so it should be recalculated'
|
||
.format(stated, what, against))
|
||
|
||
|
||
def pick_stations_match(printed_stations, model_stations,
|
||
tolerance_ft=1.0 / 12.0):
|
||
"""Printed pick stations against the modelled insert stations.
|
||
|
||
printed_stations -- stations recovered from the ticket (ends-closed).
|
||
model_stations -- insert stations from the model (along the length).
|
||
tolerance_ft -- how close is a match. The ticket is nominal; an inch
|
||
is slack, not a defect.
|
||
|
||
Returns (matched, unmatched_print). Every printed station needs a
|
||
modelled insert within tolerance.
|
||
"""
|
||
matched = []
|
||
for station in printed_stations:
|
||
nearest = min([abs(s - station) for s in model_stations]
|
||
if model_stations else [1e9])
|
||
if nearest <= tolerance_ft:
|
||
matched.append(station)
|
||
unmatched = [s for s in printed_stations if s not in matched]
|
||
return matched, unmatched
|
||
|
||
|
||
# ------------------------------------------------------------- invisible
|
||
|
||
_INVISIBLE_RE = None
|
||
|
||
|
||
def _invisible_re():
|
||
"""The compiled pattern, built lazily so the module stays import-clean."""
|
||
global _INVISIBLE_RE
|
||
if _INVISIBLE_RE is None:
|
||
import re as _re
|
||
_INVISIBLE_RE = _re.compile(
|
||
u'[\u200b-\u200f\ufeff\u00ad\u2028\u2029]')
|
||
return _INVISIBLE_RE
|
||
|
||
|
||
def clean_text(text):
|
||
"""Model text as a string safe to print and to parse.
|
||
|
||
Strips the zero-width and format characters (U+200B..U+200F, U+FEFF,
|
||
U+00AD, line/paragraph separators) that Revit parameters can carry.
|
||
Measured: a model parameter whose ValueString was exactly U+200E (a
|
||
lone left-to-right mark) reached evidence/WP1.json and crashed a tool
|
||
that printed it. Everything that scrapes model text goes through here
|
||
at the boundary. None passes through as None; a value that was
|
||
NOTHING but invisible characters becomes '', which callers treat as
|
||
blank.
|
||
"""
|
||
if text is None:
|
||
return None
|
||
return _invisible_re().sub('', text)
|
||
|
||
|
||
# ------------------------------------------------- Phase 5: checklist table
|
||
|
||
# What docs/ticket-checklist.md asks, in order. Status words (Will/Claude
|
||
# 08-28, mutually exclusive): OK / WARN / FAIL / NOT RUN (with reason) /
|
||
# FOR ENGINEER (grade D). The status is decided by the CHECKS' own
|
||
# disposition (run_all's log), never guessed from missing findings --
|
||
# see checklist_states.
|
||
# ============================================================================
|
||
# THE MASTER CHECKLIST, READ FROM THE PUBLIC LAYER
|
||
# ============================================================================
|
||
#
|
||
# Owner, 2026-08-31: "做一个总的 checklist 放 public knowledge 里面,然后 plant
|
||
# 里面做一层映射,job 里面用静音按键去控制。"
|
||
#
|
||
# This WAS a tuple in this file. It is now `checklist.master` in
|
||
# `knowledge/catalogue.yaml` -- the one knowledge layer that is in this
|
||
# repository, because it identifies nobody. What exists, how it is worded and
|
||
# what grade it carries is publishable knowledge about shop tickets and is
|
||
# the same at every plant; a plant declares which of them it asks of which
|
||
# kind of member (`checklists.profiles`), and a job mutes.
|
||
#
|
||
# WHAT DID NOT MOVE: the check FUNCTIONS. A row is a claim the tool makes,
|
||
# and a claim has to be able to be wrong in a reviewable way -- a skip probe
|
||
# that says why it stayed silent, a finding code, a test. A YAML row with no
|
||
# function behind it would have a number, a sentence and no way to fail.
|
||
#
|
||
# ---- THE NEW FAILURE MODE, AND THE GUARD ---------------------------------
|
||
#
|
||
# A tuple could not drift from the code beside it. This can, in two
|
||
# directions, and both are silent: a master entry naming a function nobody
|
||
# wrote, and a registered check function no master entry names -- the second
|
||
# being a check that RUNS and can never reach a row.
|
||
# `test_ticketchecks_host.py` pairs them and fails on either.
|
||
#
|
||
# ---- AND WHY THIS RAISES RATHER THAN DEGRADES ----------------------------
|
||
#
|
||
# If the catalogue is missing or unreadable this refuses to import, which
|
||
# takes the button down with a sentence naming the file. The alternative is
|
||
# a tool that starts with a shorter checklist, or none, and reports every
|
||
# ticket it looks at as fine. NOT RUN is never a pass, and a checklist that
|
||
# silently lost rows is that failure at the largest scale this tool can
|
||
# manage. A loud refusal is the cheap outcome.
|
||
|
||
def _text(value):
|
||
"""`value` as the interpreter's own text type -- never through `str()`.
|
||
|
||
THE TEXT REPORT LOST THE OWNER'S OWN WORDS THROUGH THIS BOUNDARY.
|
||
`last-ticketqc.txt` is documented as the complete record -- "the whole
|
||
report, nothing folded, long notes wrapped rather than cut" -- and a run
|
||
on 2026-08-31 wrote it with every CJK character of the job file's mute
|
||
sentences REMOVED, leaving the ASCII fragments and the gaps between them.
|
||
The same sentences rendered correctly in the panel and in `qc-run.json`.
|
||
A file promised as complete that quietly drops half a sentence is worse
|
||
than a file that fails to write.
|
||
|
||
`vibe.compat` is the project's one answer to this class: it returns the
|
||
engine's text type, decoding bytes as UTF-8, and the button encodes once
|
||
on the way to disk. IMPORTED LAZILY, and that has not changed even though
|
||
this module now carries two stdlib imports: `vibe/__init__` reaches for
|
||
the Revit API the moment it runs, so nothing here may import a `vibe`
|
||
module at module scope, which is what makes this file host-testable.
|
||
|
||
DEFINED UP HERE, ABOVE EVERYTHING, since 2026-08-31: the master checklist
|
||
is read off disk AT IMPORT and its refusals are sentences, so this has to
|
||
exist before the first line of module-level work rather than beside the
|
||
renderers that used to be its only callers.
|
||
"""
|
||
from vibe import compat
|
||
return compat.as_text('' if value is None else value)
|
||
|
||
|
||
MASTER_KEY = 'checklist.master'
|
||
|
||
# ---- A YAML NULL, HOWEVER THIS ENGINE'S READER SPELLED IT -----------------
|
||
#
|
||
# pyRevit's YAML reader is a YamlDotNet wrapper whose ENTIRE scalar
|
||
# conversion is `return ynode.Value`, so every scalar arrives as a STRING and
|
||
# `null` arrives as the four characters 'null'. `knowledge._merge_file` has
|
||
# always known this and runs `_coerce()` after every read; `_read_yaml` below
|
||
# did not, and it is the only other call site of that reader in the tree.
|
||
#
|
||
# WHAT THAT COST, MEASURED 2026-08-31 on the button engine (invisible to host
|
||
# pytest, which reads through PyYAML and therefore types its scalars):
|
||
#
|
||
# * `CHECKLIST_FOR_ENGINEER` derived EMPTY -- `owner_codes: null` came back
|
||
# as the tuple `('null',)` and `checks: null` as the string `'null'`,
|
||
# both truthy, so `_for_engineer_rows()` matched nothing and 5.3 and 5.5
|
||
# printed red NOT RUN. That is the 2026-08-30 defect resurrected verbatim,
|
||
# and the owner read it off a live report.
|
||
# * 37 of 39 rows carried the WORD 'null' as their `not_run_reason`, which
|
||
# is a non-empty string, so it WON over the derived probe sentence and
|
||
# SATISFIED the guard that walks the table demanding a sentence from every
|
||
# row. The no-silent-row contract broke exactly where its guard is blind.
|
||
#
|
||
# NOT `knowledge._coerce`, and the reason is a row: that one also NUMIFIES,
|
||
# and coercion runs after parsing, so `id: '5.10'` loses its quotes, becomes
|
||
# the float 5.1, collides with the real id 5.1 and `load_master` refuses the
|
||
# whole checklist -- the button dies rather than mis-renders. Null-only, and
|
||
# local to the checklist, is the whole fix.
|
||
_NULL_TEXT = ('null', 'none', '~', '')
|
||
|
||
|
||
def _is_absent(value):
|
||
"""True for a YAML null however this engine's reader spelled it.
|
||
|
||
Applied to the OPTIONAL slots only -- `grade`, `owner_codes`,
|
||
`not_run_reason`, `checks` -- and to list members one level down, because
|
||
`owner_codes: [null]` is the same trap wearing a bracket. `id` and `name`
|
||
deliberately do NOT get it: they already refuse an empty value loudly,
|
||
which is the right behaviour and not something to soften.
|
||
"""
|
||
if value is None:
|
||
return True
|
||
try:
|
||
return _text(value).strip().lower() in _NULL_TEXT
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
class ChecklistUnavailable(RuntimeError):
|
||
"""The master checklist could not be read. Never caught in this package."""
|
||
|
||
|
||
def master_path():
|
||
"""Where the published master checklist lives."""
|
||
try:
|
||
from vibe import knowledge as K
|
||
return K.catalogue_path()
|
||
except Exception:
|
||
# The same two-level walk `knowledge.catalogue_path` does, kept as a
|
||
# fallback so a broken import in one module cannot take the
|
||
# checklist with it.
|
||
here = os.path.dirname(os.path.dirname(os.path.dirname(
|
||
os.path.abspath(__file__))))
|
||
return os.path.join(os.path.dirname(here), 'knowledge',
|
||
'catalogue.yaml')
|
||
|
||
|
||
def _read_yaml(path):
|
||
"""The catalogue as a dict, through whichever YAML reader this engine has.
|
||
|
||
pyRevit's first, because that is the reader the rest of the knowledge
|
||
stack uses in Revit and IronPython 2.7 has no PyYAML; PyYAML second,
|
||
because that is what the host test runner has. If neither is importable
|
||
the caller raises -- guessing at YAML by hand is how a checklist comes
|
||
back one row short.
|
||
"""
|
||
errors = []
|
||
try:
|
||
from pyrevit.coreutils import yaml as pyyaml
|
||
return pyyaml.load_as_dict(path)
|
||
except Exception as ex:
|
||
errors.append('pyrevit.coreutils.yaml: {0}'.format(ex))
|
||
try:
|
||
import yaml as pyyaml_host
|
||
handle = io.open(path, encoding='utf-8')
|
||
try:
|
||
return pyyaml_host.safe_load(handle)
|
||
finally:
|
||
handle.close()
|
||
except Exception as ex:
|
||
errors.append('yaml: {0}'.format(ex))
|
||
raise ChecklistUnavailable(
|
||
'no YAML reader could open {0}, so the master checklist could not be '
|
||
'read and this tool has no checklist at all. Tried: {1}'
|
||
.format(path, '; '.join(errors)))
|
||
|
||
|
||
def _master_entry(entry, index, path):
|
||
"""One master row as the tuple the rest of this module speaks.
|
||
|
||
Every refusal here names the entry and the file. A checklist row that
|
||
arrived malformed and was skipped would be a question nobody asks and
|
||
nobody is told about.
|
||
"""
|
||
where = '{0}, {1}[{2}]'.format(path, MASTER_KEY, index)
|
||
if not isinstance(entry, dict):
|
||
raise ChecklistUnavailable(
|
||
'{0} is {1!r}, not a map of id/name/grade/owner_codes/'
|
||
'not_run_reason/checks.'.format(where, entry))
|
||
ident = entry.get('id')
|
||
ident = _text(ident).strip() if ident is not None else ''
|
||
if not ident:
|
||
raise ChecklistUnavailable(
|
||
'{0} has no `id`. An id is a check\'s STABLE IDENTITY -- every '
|
||
'mute, every applicability rule and every ruling names it -- so '
|
||
'one cannot be invented here.'.format(where))
|
||
name = entry.get('name')
|
||
name = _text(name).strip() if name is not None else ''
|
||
if not name:
|
||
raise ChecklistUnavailable(
|
||
'{0} (id {1!r}) has no `name`, so the row would print blank.'
|
||
.format(where, ident))
|
||
# EVERY OPTIONAL SLOT BELOW GOES THROUGH `_is_absent`, never `is None`.
|
||
# On the button engine the reader hands back 'null' as text; `is None`
|
||
# sees a present value and the row is corrupted rather than absent.
|
||
grade = entry.get('grade')
|
||
grade = '' if _is_absent(grade) else _text(grade).strip()
|
||
|
||
owners = entry.get('owner_codes')
|
||
if _is_absent(owners):
|
||
owners = None
|
||
elif isinstance(owners, (list, tuple)):
|
||
owners = tuple([_text(o).strip() for o in owners
|
||
if not _is_absent(o)]) or None
|
||
else:
|
||
owners = (_text(owners).strip(),)
|
||
|
||
reason = entry.get('not_run_reason')
|
||
reason = None if _is_absent(reason) else _text(reason).strip()
|
||
reason = reason or None
|
||
|
||
checks = entry.get('checks')
|
||
if _is_absent(checks):
|
||
checks = None
|
||
elif isinstance(checks, (list, tuple)):
|
||
checks = tuple([_text(c).strip() for c in checks
|
||
if not _is_absent(c)]) or None
|
||
else:
|
||
checks = _text(checks).strip() or None
|
||
|
||
return (ident, name, grade, owners, reason, checks)
|
||
|
||
|
||
def load_master(path=None):
|
||
"""The master checklist as the tuple-of-tuples this module has always used.
|
||
|
||
Shape unchanged on purpose: every consumer of `CHECKLIST_ITEMS` -- and
|
||
there are many -- keeps working, because what moved is where the data
|
||
lives and not what it is.
|
||
"""
|
||
path = path or master_path()
|
||
if not os.path.isfile(path):
|
||
raise ChecklistUnavailable(
|
||
'the published checklist {0} is not on disk, so this tool has no '
|
||
'checklist at all. It ships with the extension; a run without it '
|
||
'would report every ticket it looked at as fine.'.format(path))
|
||
data = _read_yaml(path)
|
||
node = data if isinstance(data, dict) else {}
|
||
for part in MASTER_KEY.split('.'):
|
||
node = node.get(part) if isinstance(node, dict) else None
|
||
if not isinstance(node, (list, tuple)) or not node:
|
||
raise ChecklistUnavailable(
|
||
'{0} in {1} is {2!r}. It must be a non-empty ORDERED LIST of '
|
||
'checklist entries -- an empty one is a tool with no questions.'
|
||
.format(MASTER_KEY, path, node))
|
||
items, seen = [], {}
|
||
for index, entry in enumerate(node):
|
||
row = _master_entry(entry, index, path)
|
||
if row[0] in seen:
|
||
raise ChecklistUnavailable(
|
||
'{0}[{1}] reuses the id {2!r}, which is already entry {3}. An '
|
||
'id is an identity: two rows under one id means every ruling '
|
||
'that names it points at both.'
|
||
.format(MASTER_KEY, index, row[0], seen[row[0]]))
|
||
seen[row[0]] = index
|
||
items.append(row)
|
||
return tuple(items)
|
||
|
||
|
||
# ---- knowledge-less distribution: absence of the FILE is not corruption ---
|
||
#
|
||
# Everything above this line is unchanged: a catalogue.yaml that IS present
|
||
# and fails to parse, is empty, or carries a malformed row still raises
|
||
# ChecklistUnavailable exactly as before -- that is real corruption of a file
|
||
# that was supposed to be trustworthy, and the owner's 2026-08-31 ruling
|
||
# ("why this raises rather than degrades", above) stands for it unchanged.
|
||
#
|
||
# The one case handled specially: the file is not on disk AT ALL. That is
|
||
# the ordinary state of a colleague trial install built by
|
||
# tools/make_dist.py, which ships no knowledge -- including this one
|
||
# repository-resident layer -- on the owner's own explicit instruction
|
||
# (2026-09-02). It is the same "ordinary absence" the rest of the knowledge
|
||
# stack already treats as unremarkable rather than corrupt
|
||
# (`knowledge._merge_file`'s docstring calls out "no catalogue on a stripped
|
||
# install" by name), and import has to keep succeeding here or every test
|
||
# file and every button that so much as imports this module dies before
|
||
# anything runs -- which is a far larger and less honest failure than an
|
||
# empty checklist naming why it is empty.
|
||
#
|
||
# `CHECKLIST_UNAVAILABLE_REASON` is None on every machine that has the file
|
||
# -- which is every machine that has always mattered -- and is the one thing
|
||
# a caller that is about to render a report (not just import the module) is
|
||
# expected to check first and refuse loudly on, the same shape
|
||
# `knowledge.config_problems()` already uses for `_config_missing` /
|
||
# `_plant_missing`. See TicketQC.pushbutton/script.py for that check.
|
||
CHECKLIST_UNAVAILABLE_REASON = None
|
||
|
||
|
||
def _load_master_or_absent():
|
||
global CHECKLIST_UNAVAILABLE_REASON
|
||
path = master_path()
|
||
if not os.path.isfile(path):
|
||
CHECKLIST_UNAVAILABLE_REASON = (
|
||
'no published checklist at {0} -- this is a knowledge-less '
|
||
'install with no knowledge/catalogue.yaml (see '
|
||
'tools/make_dist.py). Ask the owner for it; '
|
||
'knowledge/catalogue.yaml.template shows the shape the real '
|
||
'file has.'.format(path))
|
||
return ()
|
||
return load_master()
|
||
|
||
|
||
CHECKLIST_ITEMS = _load_master_or_absent()
|
||
|
||
# The universe as bare ids, in master order. What "every row" means to any
|
||
# consumer that must not let one vanish.
|
||
CHECKLIST_IDS = tuple([item[0] for item in CHECKLIST_ITEMS])
|
||
|
||
|
||
def _check_names(slot):
|
||
"""A row's check slot as a tuple of names.
|
||
|
||
A row names one check (a string), several (a tuple), or none. Kept in
|
||
one place so every reader of the slot agrees on what it holds --
|
||
`isinstance(slot, tuple)` scattered over three call sites is how the
|
||
second shape becomes a bug.
|
||
"""
|
||
if not slot:
|
||
return ()
|
||
if isinstance(slot, (tuple, list)):
|
||
return tuple([n for n in slot if n])
|
||
return (slot,)
|
||
|
||
|
||
def rows_owned_by(names):
|
||
"""{checklist row number} for every row owned ENTIRELY by a check
|
||
named in `names` (any container answering `in` -- a dict keyed by
|
||
check name, same shape as `ticketchecks.REINFORCING_EXCLUDED` /
|
||
`plant_scoped_out()`, or a plain set).
|
||
|
||
A row naming two checks where only one is named in `names` still
|
||
belongs in the coloured table -- it is honestly NOT RUN because half
|
||
of it was never asked, which is a true statement about this run, not a
|
||
stage- or plant-scope exclusion. Shared by `ticketqc_run.py` for both
|
||
"not checked at this stage" (Reinforcing QC's ticket-only checks) and
|
||
"not applicable at this plant" (a check gated on a knowledge key this
|
||
job's layer has not set) -- the same shape of pull-out-of-the-table
|
||
logic, so it lives here once rather than twice.
|
||
"""
|
||
nums = set()
|
||
for (num, _name, _grade, _owners, _reason, check_name) in CHECKLIST_ITEMS:
|
||
owned = _check_names(check_name)
|
||
if owned and all(n in names for n in owned):
|
||
nums.add(num)
|
||
return nums
|
||
|
||
|
||
# A ROW A PERSON OWNS BY DESIGN, DERIVED FROM THE TABLE AND NOT KEPT BY HAND.
|
||
#
|
||
# Shown as FOR ENGINEER (yellow), never NOT RUN (red). The three conditions
|
||
# below are exactly what "nobody wrote a check for this ON PURPOSE" looks
|
||
# like in a row of CHECKLIST_ITEMS:
|
||
#
|
||
# * no check function -- nothing computes it,
|
||
# * no owner finding code -- and nothing else is going to claim it,
|
||
# * a reason SPELLED OUT -- someone wrote down why it is a person's.
|
||
#
|
||
# The third is what separates it from a row that simply has not been built
|
||
# yet: an unbuilt row has no reason recorded, stays NOT RUN, and stays red.
|
||
#
|
||
# THIS WAS A HAND-KEPT TUPLE HOLDING ('5.5',) AND THAT IS THE 5.3 DEFECT.
|
||
# 5.3 (face designations) is a person's job by design -- docs/ticket-
|
||
# checklist.md has counted it as one under "what is left for a person" since
|
||
# it was written, next to 5.5 -- and it carries the reason to prove it. It
|
||
# was never added to the tuple, so it fell through to the "no check at all"
|
||
# branch and printed a red NOT RUN on every ticket. The owner read that as
|
||
# "5.3 没跑" (2026-08-30), which is exactly what a red NOT RUN means
|
||
# everywhere else in this table, and he was right to.
|
||
#
|
||
# Deriving it means the next grade-C/D row that a person owns cannot be
|
||
# forgotten in a second list: the reason string in the row IS the
|
||
# declaration.
|
||
def _for_engineer_rows():
|
||
return tuple([item[0] for item in CHECKLIST_ITEMS
|
||
if not _check_names(item[5]) and not item[3] and item[4]])
|
||
|
||
|
||
CHECKLIST_FOR_ENGINEER = _for_engineer_rows()
|
||
|
||
|
||
def checklist_states(findings, log=None):
|
||
"""The ticket-checklist table: item -> status.
|
||
|
||
log is run_all's (check_name, state, note) list -- the checks' own
|
||
disposition. Returns rows (number, name, grade, status, note).
|
||
|
||
Statuses are never guessed from missing findings:
|
||
- owner check RAN and found nothing -> OK (removing its OK finding
|
||
must not flip the row to NOT RUN; Claude 08-28 self-test).
|
||
- owner check SKIPPED (probe said why) or FAILED -> NOT RUN, the
|
||
check's own note.
|
||
- owner check RAN-EMPTY (no probe, silent) -> NOT RUN, "no probe".
|
||
- no owner check at all -> NOT RUN, the item's reason ("not
|
||
implemented").
|
||
- a row a person owns by design -> FOR ENGINEER, in ITS OWN words.
|
||
See CHECKLIST_FOR_ENGINEER for what makes a row one of those.
|
||
|
||
A ROW MAY NAME MORE THAN ONE CHECK (a tuple in the check slot), and
|
||
then it is answered only when EVERY one of them spoke. Any quiet half
|
||
makes the row NOT RUN and the note carries each quiet half's own
|
||
reason. The rule is deliberately the strict one: a row that asks two
|
||
questions and prints OK because the half that could still fail it was
|
||
never asked is the silent pass this whole table exists to prevent.
|
||
Under-claiming is the safe direction; a green row is a claim.
|
||
|
||
A ROW ANSWERED BY FINDING CODES ALONE GETS THE SAME GUARANTEE, and
|
||
until 2026-08-31 it did not. Seven rows name no check function at all --
|
||
they are answered by codes `ticketqc.check_ticket` emits -- so the skip
|
||
probes, which are keyed by check function, could not reach them. On a
|
||
live run six of them (2.1, 2.3, 3.1, 3.2, 4.1, 4.2) came back NOT RUN
|
||
with `reason: null` and printed as a bare red row with nothing under
|
||
it: the tool gave the reader no sentence to disagree with, and he read
|
||
the lot as noise. `ticketchecks` now runs a ROW PROBE for each of them
|
||
and puts the answer in the log under `row:<number>`; `_row_note` reads
|
||
it here, and when even that says nothing the row still gets a sentence
|
||
saying so. **No row may print NOT RUN with an empty reason.**
|
||
"""
|
||
row_notes = {}
|
||
for entry in (log or []):
|
||
nm, _st, nt = (list(entry) + [None, None, None])[:3]
|
||
nm = '' if nm is None else str(nm)
|
||
if nm.startswith(ROW_NOTE_PREFIX) and nt:
|
||
row_notes[nm[len(ROW_NOTE_PREFIX):]] = nt
|
||
by_code = {}
|
||
for f in findings:
|
||
by_code[f.code] = f
|
||
for prefix in ('WGT-', 'HANDLE-', 'SIZE-MAX-', 'SIZE-MIN-',
|
||
'SIZE-LENGTH', 'SIZE-WIDTH'):
|
||
if f.code.startswith(prefix):
|
||
by_code.setdefault(prefix, f)
|
||
log_by_name = dict((name, state) for (name, state, note) in (log or []))
|
||
|
||
rows = []
|
||
for (num, name, grade, owners, reason, check_name) in CHECKLIST_ITEMS:
|
||
if num in CHECKLIST_FOR_ENGINEER:
|
||
# The row's OWN reason, not a sentence about grade D written
|
||
# here: 5.3 is grade C and a stock "(grade D)" line on it would
|
||
# be false. The fallback survives only for a row that declares
|
||
# itself a person's without saying why, which the derivation
|
||
# above cannot actually produce today.
|
||
rows.append((num, name, grade, 'FOR ENGINEER',
|
||
reason or 'person reads the drawing'))
|
||
continue
|
||
check_names = _check_names(check_name)
|
||
quiet = []
|
||
for cn_want in check_names:
|
||
if log_by_name.get(cn_want) not in ('SKIPPED', 'RAN-EMPTY',
|
||
'FAILED'):
|
||
continue
|
||
note = None
|
||
for (cn, st, nt) in (log or []):
|
||
if cn == cn_want:
|
||
note = nt
|
||
break
|
||
quiet.append(note or reason or 'no probe declared')
|
||
if quiet:
|
||
rows.append((num, name, grade, 'NOT RUN', '; '.join(quiet)))
|
||
continue
|
||
owned = []
|
||
if owners:
|
||
for o in owners:
|
||
f = by_code.get(o)
|
||
if f is not None:
|
||
owned.append(f)
|
||
if not owned:
|
||
# Every named check ran. One that is missing from the log
|
||
# never ran at all, and that is not a pass either.
|
||
if check_names and all(log_by_name.get(cn) == 'RAN'
|
||
for cn in check_names):
|
||
rows.append((num, name, grade, 'OK', None))
|
||
continue
|
||
rows.append((num, name, grade, 'NOT RUN',
|
||
_row_note(num, owners, reason, row_notes)))
|
||
continue
|
||
ranks = {'OK': 0, 'WARN': 1, 'FAIL': 2}
|
||
worst = max(owned, key=lambda f: ranks.get(f.severity, 1))
|
||
# note is what the user reads to FIND the thing (Will 08-28:
|
||
# '凡是有 N 条的地方,都要能让他找到那 N 条是什么' -- a number alone is
|
||
# not information).
|
||
#
|
||
# NOTHING IS CUT HERE ANY MORE. This line used to read
|
||
# `capped_note(worst.message)` and it is where 4.5 lost its second
|
||
# half on a live run (2026-08-30) --
|
||
#
|
||
# debonding is required here -- 3.083 ft of concrete from 12.771
|
||
# to 15.854 ft ... ; 0.958 ft of concrete from 25.875 t...
|
||
#
|
||
# -- and with it the second run, the strands in it, and any hint of
|
||
# whether more followed. The owner: "4.5被截断了。我经常在notes里面
|
||
# 有截断。不要有截断". A cap at the DATA layer cannot be fixed by any
|
||
# amount of styling, and it took the file copy down with it: the
|
||
# same short string was written to `last-ticketqc.txt`, so the
|
||
# fallback a reader is told to trust held exactly the same stump.
|
||
# The full message now reaches every consumer, and the only limiter
|
||
# left is VISUAL -- see `note_cell_html`, which folds a long note in
|
||
# the panel without removing a character of it.
|
||
note = worst.message if worst.severity != 'OK' else None
|
||
rows.append((num, name, grade, worst.severity, note))
|
||
return name_shared_silences(rows)
|
||
|
||
|
||
# The log key a ROW-level reason travels under. `run_all` writes
|
||
# `('row:3.1', 'NO-CODE', <why>)` beside the check dispositions, because the
|
||
# log is the one channel that already reaches every consumer -- the button
|
||
# passes it here and `ticketvision` writes it to `qc-run.json` -- so the
|
||
# reason arrives without a new argument on a function three callers use.
|
||
ROW_NOTE_PREFIX = 'row:'
|
||
|
||
NO_REASON_AT_ALL = (
|
||
'no finding under {0} reached this row and nothing in this run recorded '
|
||
'why, so this row was NOT judged. That is a defect in the tool, not a '
|
||
'verdict on the piece: a row answered by finding codes owes the same '
|
||
'explanation a check owes through its skip probe, and this one has '
|
||
'none. Register a row probe for it in `ticketchecks`')
|
||
|
||
|
||
def _row_note(num, owners, declared, row_notes):
|
||
"""A code-answered row's reason, and NEVER None.
|
||
|
||
Three rungs, strongest first: what this run's row probe said, the row's
|
||
own declared reason from `CHECKLIST_ITEMS`, and -- when there is neither
|
||
-- a sentence saying exactly that. The third rung is the same ruling as
|
||
`RAN-EMPTY`'s "no probe declared": an unexplained silence is reported as
|
||
the defect it is rather than printed as a blank.
|
||
"""
|
||
said = row_notes.get(num)
|
||
if said:
|
||
return said
|
||
if declared:
|
||
return declared
|
||
return NO_REASON_AT_ALL.format(
|
||
', '.join(owners) if owners else 'this row\'s code(s)')
|
||
|
||
|
||
def name_shared_silences(rows):
|
||
"""One missing fact, said once and then cross-referenced.
|
||
|
||
Rows 4.4, 4.5, 4.6 and 4.7 all stand down on *no strand geometry on
|
||
this piece*, and four red rows carrying one fact read as four problems
|
||
-- the owner's word for the page was "unnecessary". Rows whose NOT RUN
|
||
reason shares its opening clause (everything before the first ` -- `,
|
||
which is how these sentences are built: the shared half, then what THIS
|
||
row lost) are named to each other.
|
||
|
||
IT ADDS, IT NEVER REPLACES. Each row keeps its own sentence, including
|
||
its own half of the consequence; the cross-reference is appended. A row
|
||
that would lose its own words to a group summary would be trading one
|
||
silence for another.
|
||
"""
|
||
stems = {}
|
||
for row in (rows or []):
|
||
stem = _reason_stem(row)
|
||
if stem:
|
||
stems.setdefault(stem, []).append(row[0])
|
||
out = []
|
||
for row in (rows or []):
|
||
stem = _reason_stem(row)
|
||
family = stems.get(stem) or []
|
||
if len(family) < 2:
|
||
out.append(row)
|
||
continue
|
||
others = [n for n in family if n != row[0]]
|
||
note = ('{0} [the same missing input also left {1} {2} quiet: one '
|
||
'fact, not {3} separate problems]'
|
||
.format(row[4], 'rows' if len(others) > 1 else 'row',
|
||
', '.join(others), len(family)))
|
||
out.append(tuple(row[:4]) + (note,) + tuple(row[5:]))
|
||
return out
|
||
|
||
|
||
def _reason_stem(row):
|
||
"""The shared half of a NOT RUN reason, or None when there is not one."""
|
||
if len(row) < 5 or row[3] != 'NOT RUN' or not row[4]:
|
||
return None
|
||
return _text(row[4]).split(' -- ')[0].strip().lower() or None
|
||
|
||
|
||
# ------------------------------------------------ sheet name vs mark (1.1)
|
||
|
||
def wwf_designation_status(mesh_design, mesh_fams):
|
||
"""Checklist 5.4 core: mesh designation vs modelled families.
|
||
|
||
mesh_design is the design's wwf_mesh token ('' when the spec has
|
||
none); mesh_fams is the modelled family names. Pure logic, host-
|
||
testable. Returns (severity, message); a missing design side is the
|
||
probe's job (NOT RUN), never a verdict here.
|
||
"""
|
||
up_design = (mesh_design or '').upper().replace(' ', '')
|
||
if not up_design:
|
||
return 'NOT RUN', 'no design-side mesh designation to compare'
|
||
matched = any(up_design in (fn or '').upper().replace(' ', '')
|
||
for fn in (mesh_fams or []))
|
||
if not mesh_fams:
|
||
return 'WARN', ('design calls for mesh "{0}" but no mesh family is '
|
||
'modelled'.format(mesh_design))
|
||
if matched:
|
||
return 'OK', ('mesh designation "{0}" is modelled'
|
||
.format(mesh_design))
|
||
return 'WARN', ('design calls for mesh "{0}" but modelled families '
|
||
'are {1}'.format(mesh_design, ', '.join(sorted(mesh_fams))))
|
||
|
||
|
||
# ------------------------------------------------- jacking force (4.3)
|
||
#
|
||
# UNITS, SPELLED OUT, BECAUSE THE TWO SIDES DO NOT AGREE ON THEM.
|
||
#
|
||
# the ticket prints KIPS 'JACKING FORCE: 0.70 fpu = 28.9 kips'
|
||
# the design carries POUNDS reinforcing.strand_groups[].it_lb = 28920
|
||
# tolerances.it_lb is POUNDS -- the key is named _lb and means it
|
||
#
|
||
# 1 kip = 1000 lb. The comparison below is done ENTIRELY IN POUNDS, so
|
||
# there is exactly one conversion in the whole path and it is on the line
|
||
# that reads the ticket's number. This is the class of bug the unit
|
||
# discipline exists for: a tolerance of 1 in the wrong unit is a check a
|
||
# thousand times stricter than anyone asked for, and it fails quietly, on
|
||
# correct tickets.
|
||
|
||
def jacking_force_status(printed_kips, design_it_lb, tolerance_lb):
|
||
"""Checklist 4.3 core: printed jacking force vs the design's tension.
|
||
|
||
printed_kips -- what the ticket's note prints, in KIPS.
|
||
design_it_lb -- the design's initial tension per strand, in POUNDS.
|
||
tolerance_lb -- how far the two may sit apart, in POUNDS. It is
|
||
`tolerances.it_lb`, resolved by the caller.
|
||
|
||
Returns (severity, message). A side that is missing entirely is the
|
||
skip probe's business (NOT RUN on the table), never a verdict here;
|
||
'NOT RUN' comes back only for a value that is present and unusable,
|
||
and the caller turns that into a probe reason rather than a Finding.
|
||
"""
|
||
try:
|
||
printed_lb = float(printed_kips) * 1000.0
|
||
except (TypeError, ValueError):
|
||
return 'NOT RUN', ('the jacking force printed on the ticket is not '
|
||
'a number: {0!r}'.format(printed_kips))
|
||
try:
|
||
design_lb = float(design_it_lb)
|
||
except (TypeError, ValueError):
|
||
return 'NOT RUN', ('the design\'s strand initial tension is not a '
|
||
'number: {0!r}'.format(design_it_lb))
|
||
try:
|
||
tol_lb = float(tolerance_lb)
|
||
except (TypeError, ValueError):
|
||
return 'NOT RUN', ('tolerances.it_lb is not a number: {0!r}'
|
||
.format(tolerance_lb))
|
||
gap_lb = abs(printed_lb - design_lb)
|
||
design_kips = design_lb / 1000.0
|
||
if gap_lb > tol_lb:
|
||
return 'WARN', (
|
||
'ticket prints {0} kips but the design calls for {1:.1f} kips '
|
||
'-- {2:,.0f} lb apart, past the {3:,.0f} lb allowed by '
|
||
'tolerances.it_lb'.format(printed_kips, design_kips, gap_lb,
|
||
tol_lb))
|
||
return 'OK', (
|
||
'{0} kips matches the design ({1:.1f}) -- {2:,.0f} lb apart, within '
|
||
'the {3:,.0f} lb allowed by tolerances.it_lb'
|
||
.format(printed_kips, design_kips, gap_lb, tol_lb))
|
||
|
||
|
||
# --------------------------------------- detail sheets: 5.1's other half
|
||
#
|
||
# 5.1 asks whether every embed the BED PLAN calls out exists in the model.
|
||
# A call-out on the plan is not always a part. This plant collects its
|
||
# typical sections onto DETAIL SHEETS of their own 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 (the
|
||
# letter its detail sheets are numbered from). It is the only thing that
|
||
# tells a bubble apart from a part call-out, and without it the two are
|
||
# the same shape of token.
|
||
#
|
||
# WHAT IS NOT BUILT, AND IS NOT PRETENDED TO BE: nothing anywhere reads a
|
||
# detail sheet's CONTENTS. Following `06/X1` to X1 and comparing the
|
||
# parts it draws against this piece's members needs the sheets themselves,
|
||
# and reading them is a job of its own. So the half below stops at finding
|
||
# the bubbles and saying plainly what is behind them that nobody looked at.
|
||
|
||
def detail_sheet_refs(text, prefix):
|
||
"""The detail bubbles cited in a piece of ticket text, e.g. '06/X1'.
|
||
|
||
`prefix` is nomenclature.detail_sheet_prefix -- the letter this plant
|
||
numbers its detail sheets from. Returns the references sorted and
|
||
de-duplicated, or **None** when no prefix is declared: with the plant's
|
||
detail-sheet naming unwritten a bubble cannot be recognised at all,
|
||
which is could-not-judge and not an empty answer.
|
||
"""
|
||
pre = (prefix or '')
|
||
try:
|
||
pre = pre.strip().upper()
|
||
except AttributeError:
|
||
return None
|
||
if not pre:
|
||
return None
|
||
import re
|
||
pattern = re.compile(
|
||
r'\b(\d+[A-Z]?)\s*/\s*(' + re.escape(pre) + r'\d[\d.]*)')
|
||
out = []
|
||
for num, sheet in pattern.findall(clean_text(text or '').upper()):
|
||
ref = '{0}/{1}'.format(num, sheet.rstrip('.'))
|
||
if ref not in out:
|
||
out.append(ref)
|
||
return sorted(out)
|
||
|
||
|
||
def detail_sheets_of(refs):
|
||
"""The sheet names inside a list of bubbles: '06/X1' -> 'X1'."""
|
||
out = []
|
||
for ref in (refs or []):
|
||
sheet = str(ref).split('/')[-1].strip()
|
||
if sheet and sheet not in out:
|
||
out.append(sheet)
|
||
return sorted(out)
|
||
|
||
|
||
def detail_callout_status(bed_text, prefix):
|
||
"""Checklist 5.1's detail-sheet half, taken as far as it goes today.
|
||
|
||
`bed_text` is the BED PLAN view's flat text, or None when it could not
|
||
be read. `prefix` is nomenclature.detail_sheet_prefix.
|
||
|
||
Returns (findings, reason) -- `findings` a list of (severity, message)
|
||
and `reason` a could-not-judge sentence for the skip probe, None when
|
||
the half was actually answered. Exactly one of the two is ever filled.
|
||
|
||
THREE OUTCOMES, AND ONLY ONE OF THEM IS A PASS:
|
||
|
||
* no prefix declared -> could not judge. This plant's detail-sheet
|
||
naming is not written down, so a `06/X1` bubble on the plan
|
||
cannot be told from a part call-out and the plan cannot even be
|
||
read for them.
|
||
* a prefix, and the plan cites no detail -> OK, and it IS a verdict.
|
||
Nothing on this piece reaches the model through a detail sheet, so
|
||
every call-out on the plan is a part named directly on it and 5.1's
|
||
part-by-part half is the whole question.
|
||
* a prefix, and the plan cites details -> could not judge, naming the
|
||
sheets. The parts those details bring are not read by anything, so
|
||
whether they exist in the model has not been asked.
|
||
"""
|
||
refs = detail_sheet_refs(bed_text, prefix)
|
||
if refs is None:
|
||
return [], ('no nomenclature.detail_sheet_prefix in plant knowledge '
|
||
'-- this plant\'s detail sheets are not named, so a '
|
||
'detail bubble on the BED PLAN cannot be told from a '
|
||
'part call-out and 5.1\'s detail-sheet half could not '
|
||
'be looked at')
|
||
if bed_text is None:
|
||
return [], ('the BED PLAN could not be read on this ticket, so the '
|
||
'details it cites could not be looked for')
|
||
if not refs:
|
||
return [('OK',
|
||
'the BED PLAN cites no {0}-sheet detail, so every call-out '
|
||
'on it names a part directly and nothing on this piece '
|
||
'reaches the model through a detail sheet'
|
||
.format((prefix or '').strip().upper()))], None
|
||
sheets = detail_sheets_of(refs)
|
||
return [], ('the BED PLAN reaches {0} part(s) through a detail bubble '
|
||
'({1}) and nothing reads what detail sheet {2} draws, so '
|
||
'whether those embeds exist in the model has not been '
|
||
'asked'.format(len(refs), ', '.join(refs),
|
||
', '.join(sheets)))
|
||
|
||
|
||
# ----------------------------------- opening diagonal edge bars (wall rule)
|
||
#
|
||
# ONE CHECK THAT JUDGES TWO THINGS about a piece's door openings / blockouts:
|
||
#
|
||
# (1) BAR TYPE -- the bars at the openings carry the plant's opening flag
|
||
# (a parameter named like 'BAR_TYPE_BLOCKOUT' reading 'Yes'). Whether
|
||
# ANY opening bars are modelled at all is this half's question.
|
||
# (2) DIAGONAL -- among those flagged bars, the plant's diagonal edge bar
|
||
# is present in number. WHAT that bar must be is plant knowledge --
|
||
# `reinforce.panel_standard.opening.diagonal_edge_bar`, a string like
|
||
# '#3 x 3'-0" DIAG. E.L.' -- never written here.
|
||
#
|
||
# The bars are MODELLED assembly members of a scheduled rebar family -- not a
|
||
# bar-list line and not a drawing text note; these tickets carry no bar list
|
||
# at all. The rule applies to solid and insulated walls alike; this file
|
||
# neither knows nor needs the distinction, it is told how many openings the
|
||
# piece has.
|
||
#
|
||
# BOTH FINDINGS ARE WARN AT WORST, NEVER FAIL. Owner, verbatim: "很多时候开口
|
||
# 太小我给不了这些钢筋" -- a small opening often cannot take the bars. The
|
||
# flagged-bars half is a pass/fail-shaped fact about the model, but it is
|
||
# still reported at the plant's severity because a person weighs whether an
|
||
# opening too small for bars is at fault; the diagonal half is an upper-bound
|
||
# shortlist. `short_severity`, default 'WARN', is applied to both, and the
|
||
# function never emits 'FAIL' on its own.
|
||
#
|
||
# THE SIZE IS DERIVED FROM THE SPEC, NEVER HELD NEXT TO IT. The modelled
|
||
# diameter parameter spells the size as a fraction ('3/8"'), and `bar_size_
|
||
# fraction` maps the '#N' in the plant's spec onto that fraction, so a plant
|
||
# that maps a different bar size needs no code change and a wrong-size bar is
|
||
# never counted as one of the diagonals.
|
||
|
||
# '#3' -> '3/8", '#8' -> '1": the rebar sizes as the fraction a diameter
|
||
# parameter prints. Sizes above #8 are deliberately absent: nothing maps them
|
||
# yet, and a spec naming one leaves the flagged bars counted for the BAR TYPE
|
||
# half alone while the DIAGONAL half reports its shortfall instead of judging
|
||
# a wrong size (see `opening_diagonal_status`).
|
||
BAR_SIZE_FRACTIONS = {
|
||
3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '1',
|
||
}
|
||
|
||
|
||
def bar_size_fraction(required_spec):
|
||
"""The rebar-size fraction a '#N' spec names, '#3 x 3'-0"' -> '3/8'.
|
||
|
||
Pure and host-testable: the plant maps the required bar as '#<size>' and
|
||
the modelled diameter parameter spells that size as a fraction, so the
|
||
two are joined here and nowhere else. None when the spec carries no
|
||
'#<digits>' or the number is not one of the sizes the map knows -- the
|
||
caller then cannot tell a diagonal from a wrong-size bar, so the flagged
|
||
bars are counted for the BAR TYPE half and the DIAGONAL half reports its
|
||
shortfall rather than guess a size.
|
||
"""
|
||
if not required_spec:
|
||
return None
|
||
import re as _re
|
||
m = _re.search(r'#(\d+)', required_spec)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return BAR_SIZE_FRACTIONS.get(int(m.group(1)))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def opening_diagonal_status(opening_count, flagged_total, diag_count,
|
||
members_readable, required_spec,
|
||
short_severity='WARN'):
|
||
"""Judges a piece's openings twice: flagged opening bars, and the diagonals.
|
||
|
||
Pure decision, host-testable: how many openings a piece carries, how many
|
||
of its members carry the opening bar-type flag, and how many of THOSE are
|
||
the plant's diagonal edge bar go in, sentences come out. `(findings,
|
||
reason)` -- findings a list of (severity, message), reason a could-not-
|
||
judge sentence for the skip probe, exactly one of the two ever filled,
|
||
the same contract `detail_callout_status` and `parity_note_status` use.
|
||
|
||
opening_count -- how many door openings / blockouts the piece has, or
|
||
None when the geometry could not be read. Doors and
|
||
blockouts both count; whether the void is
|
||
through-thickness is not asked.
|
||
flagged_total -- how many of the piece's members carry the opening
|
||
bar-type flag (a parameter named like
|
||
'BAR_TYPE_BLOCKOUT') reading 'Yes', ANY size. None
|
||
when the member walk never produced a count.
|
||
diag_count -- how many of those flagged members are the plant's
|
||
diagonal edge bar (their diameter matches the size
|
||
`required_spec` names). None when the member walk
|
||
never produced a count. Counted by the caller off
|
||
the assembly's members; this function only compares.
|
||
members_readable -- whether the member walk actually happened. False is
|
||
could-not-judge, never a pass and never a "no bars"
|
||
verdict -- the caller must only pass True after the
|
||
assembly members were really read, because this
|
||
function cannot tell "we did not look" from "the
|
||
assembly models none".
|
||
required_spec -- reinforce.panel_standard.opening.diagonal_edge_bar
|
||
from plant knowledge: the diagonal the openings
|
||
need, e.g. '#3 x 3'-0" DIAG. E.L.'. Falsy means the
|
||
plant has not mapped it, which is could-not-judge --
|
||
what to count is the plant's to say, and nothing is
|
||
guessed.
|
||
short_severity -- the severity of a shortfall. The owner's ruling is
|
||
that a small opening often cannot take the bars, so
|
||
the count is a shortlist a person resolves and the
|
||
function never emits 'FAIL' on its own.
|
||
|
||
THE EXPECTATION IS AN UPPER BOUND. One diagonal per corner would be
|
||
4 x the opening count; an opening that runs to an edge of the piece has
|
||
fewer free corners, so a count under 4 x openings is expected on a real
|
||
piece and is exactly what a person weighs. Length is deliberately not
|
||
asked: a modelled bar's length parameter includes its bends.
|
||
|
||
THE DIAGONAL HALF IS ONLY ASKED WHEN THERE ARE FLAGGED BARS. With none,
|
||
"which of them are diagonals" has no answer -- a single finding says the
|
||
opening bars are missing or their bar type is not set, and nothing else.
|
||
"""
|
||
if not required_spec:
|
||
return [], ('the plant has not mapped '
|
||
'reinforce.panel_standard.opening.diagonal_edge_bar, so '
|
||
'the diagonal edge bars could not be judged')
|
||
if opening_count is None:
|
||
return [], ('the openings on this piece could not be read, so the '
|
||
'diagonal edge bars could not be judged')
|
||
if not members_readable:
|
||
return [], ('the assembly members could not be read, so the '
|
||
'diagonal edge bars could not be counted')
|
||
if flagged_total is None or diag_count is None:
|
||
return [], ('the assembly members could not be counted, so the '
|
||
'opening bars could not be judged')
|
||
if opening_count == 0:
|
||
return [('OK', 'no door opening or blockout on this piece -- no '
|
||
'opening bars required')], None
|
||
if flagged_total == 0:
|
||
return [(short_severity, '{0} opening(s) but no bar on this piece '
|
||
'carries the opening bar-type flag -- the opening bars are '
|
||
'missing or their bar type is not set; a person decides'
|
||
.format(opening_count))], None
|
||
|
||
findings = [('OK', '{0} opening(s); {1} bar(s) carry the opening '
|
||
'bar-type flag'.format(opening_count, flagged_total))]
|
||
|
||
# The messages name the spec AS THE PLANT WROTE IT -- the raw string is
|
||
# what a reader matches against the modelled bars on the piece.
|
||
spec = required_spec
|
||
expected = 4 * opening_count
|
||
if diag_count >= expected:
|
||
findings.append(('OK', '{0} x {1} diagonal bar(s) among them '
|
||
'(>= {2})'.format(diag_count, spec, expected)))
|
||
elif diag_count == 0:
|
||
findings.append((short_severity, 'none of the flagged opening bars '
|
||
'is a {0} diagonal -- a small opening often cannot '
|
||
'take them; a person decides'.format(spec)))
|
||
else:
|
||
findings.append((short_severity, 'only {0} x {1} diagonal bar(s) '
|
||
'among them against up to {2} (one per free '
|
||
'corner) -- an edge opening needs fewer; a person '
|
||
'decides'.format(diag_count, spec, expected)))
|
||
return findings, None
|
||
|
||
|
||
# ------------------------------------- 6.6: an odd count wants a bed note
|
||
#
|
||
# THE RULE, AND ALL THREE OF ITS PARTS BELONG TO THE PLANT. Owner,
|
||
# 2026-08-31: when the count of one particular 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 not one rule. Another plant's part number is different, its wording is
|
||
# different, and a plant whose rule bites on the EVEN count is a plant and
|
||
# not a bug. So not one of the three is written here: all three come out of
|
||
# `drawings.parity_note_rule`, and with any one of them unset this stands
|
||
# down naming the one that is missing.
|
||
#
|
||
# ZERO IS AN ANSWER, AND IT IS NOT THE SAME ANSWER AS SILENCE. A piece
|
||
# carrying none of the part has a count of 0, which is even, so the rule
|
||
# does not apply and the row is a genuine OK. A piece whose members could
|
||
# not be recognised at all is could-not-judge, and never a pass. The two
|
||
# arrive here as different arguments -- `count == 0` against a non-empty
|
||
# `unknown` -- so that no later edit can collapse them into one.
|
||
#
|
||
# ONE UNREAD MEMBER FLIPS THE PARITY. That is why an unrecognised member
|
||
# stands the whole check down instead of being noted and worked around: a
|
||
# census one member short is not a slightly worse answer to an odd/even
|
||
# question, it is the opposite answer half the time.
|
||
#
|
||
# WHAT A PRESENT PHRASE DOES AND DOES NOT PROVE. It proves the words are on
|
||
# the bed plan. It does not prove they are ABOUT this part, and this is not
|
||
# a theoretical worry: the owner has already used the same wording for
|
||
# something else entirely (2026-08-16, on the `*.A` packets -- a piece can
|
||
# need a temporary precast beam that exists for the erection and is removed
|
||
# in the field). The phrase this finds may be that one. So a present phrase
|
||
# is an OK that says exactly what it saw and claims nothing more, and it is
|
||
# one of the two reasons the row is graded B rather than A.
|
||
|
||
PARITY_WORDS = ('odd', 'even')
|
||
|
||
|
||
def squash_phrase(text):
|
||
"""Ticket text as one upper-case line with whitespace runs collapsed.
|
||
|
||
The phrase has to be findable through a note typed over two lines, or
|
||
with two spaces where a reader sees one. `ticketqc._flat_text` already
|
||
joins a view's text notes with a single space and collapses runs, so on
|
||
the live path this restates a guarantee rather than adding one; it is
|
||
here so that text arriving from anywhere else is normalised the same
|
||
way, and so the PHRASE is normalised by the same function as the text
|
||
it is looked for in.
|
||
|
||
None in, None out: "there was no text" is not "the text was empty".
|
||
"""
|
||
if text is None:
|
||
return None
|
||
import re
|
||
return re.sub(r'\s+', ' ', clean_text(text) or '').strip().upper()
|
||
|
||
|
||
def parity_note_status(count, unknown, bed_text, family_names, parity,
|
||
phrases, verified=False, unverified_because=None,
|
||
gather_reason=None, bed_reason=None, total=None):
|
||
"""Checklist 6.6: does an odd part count carry its BED PLAN note?
|
||
|
||
Pure decision, host-testable, no Revit. Returns `(findings, reason)` --
|
||
`findings` a list of (severity, message) and `reason` a could-not-judge
|
||
sentence for the skip probe. Exactly one of the two is ever filled, the
|
||
same contract `detail_callout_status` uses.
|
||
|
||
count how many members of this piece are the part, or None.
|
||
unknown members that could not be identified at all, as
|
||
{'family', 'why'} dicts. NON-EMPTY STANDS THE CHECK
|
||
DOWN -- see the block comment above.
|
||
bed_text the BED PLAN view's flat text, None for could-not-read,
|
||
or a CALLABLE returning `(text, reason)` -- see below.
|
||
family_names drawings.parity_note_rule.family_names.
|
||
parity 'odd' or 'even' -- which parity demands the note.
|
||
phrases the wording(s) the BED PLAN must carry.
|
||
verified has the family name been confirmed against a model.
|
||
unverified_because why not, printed when it has not.
|
||
gather_reason why the count could not be made.
|
||
bed_reason why the BED PLAN could not be read.
|
||
total how many members the piece has, printed alongside a
|
||
count of zero so a wrong family name is visible rather
|
||
than silently even.
|
||
|
||
THE ORDER OF THE GATES IS THE DESIGN. Configuration first, then the
|
||
census, then the parity, and the BED PLAN LAST -- because a count whose
|
||
parity does not demand the note is fully judged without ever opening the
|
||
plan, and demanding a readable plan there would print NOT RUN for a
|
||
piece this can answer completely.
|
||
"""
|
||
names = [text for text in
|
||
[str(n).strip() for n in (family_names or [])] if text]
|
||
wanted = [p for p in [squash_phrase(p) for p in (phrases or [])] if p]
|
||
word = (str(parity).strip().lower() if parity is not None else '')
|
||
|
||
if not names:
|
||
return [], ('no drawings.parity_note_rule.family_names in the '
|
||
'knowledge files -- WHICH part this plant counts for the '
|
||
'parity note is the plant naming its own hardware, and '
|
||
'the tool will not guess a part number. Name it and this '
|
||
'row runs')
|
||
listed = ', '.join(names)
|
||
if word not in PARITY_WORDS:
|
||
return [], ('drawings.parity_note_rule.parity is {0} and not one of '
|
||
'{1} -- WHICH parity of {2} demands the note is the '
|
||
"plant's rule and not this tool's to assume"
|
||
.format(repr(parity), ' / '.join(PARITY_WORDS), listed))
|
||
if not wanted:
|
||
return [], ('no drawings.parity_note_rule.require_text in the '
|
||
'knowledge files -- the exact words the BED PLAN must '
|
||
'carry are this plant wording its own drawing, so '
|
||
'nothing was looked for')
|
||
if not verified:
|
||
# THE SAME GUARD `reinforcing_rule.parameter_verified` CARRIES, for
|
||
# the same reason. An unconfirmed family name that matches nothing
|
||
# counts zero on every piece, zero is even, and every row reads a
|
||
# permanent, invisible OK -- a false pass over a whole defect class.
|
||
# Standing down until somebody has read the name off a model is the
|
||
# only outcome that cannot lie.
|
||
return [], ('drawings.parity_note_rule.family_verified is false: '
|
||
'nobody has confirmed that this plant\'s part is modelled '
|
||
'under the family name(s) {0}. {1}'
|
||
.format(listed, unverified_because or
|
||
'Read the family name off a live document and set '
|
||
'the flag.'))
|
||
if gather_reason:
|
||
return [], gather_reason
|
||
if unknown:
|
||
seen = []
|
||
for member in unknown:
|
||
why = (member or {}).get('why') or 'no reason recorded'
|
||
if why not in seen:
|
||
seen.append(why)
|
||
return [], ('{0} member(s) of this piece could not be identified '
|
||
'({1}), so the count of {2} is not trustworthy -- one '
|
||
'unread member flips an odd count to even, so this is '
|
||
'not judged rather than judged from a partial census'
|
||
.format(len(unknown), '; '.join(seen), listed))
|
||
if count is None:
|
||
return [], ('the count of {0} on this piece was not established and '
|
||
'no reason was recorded -- treat as not run, not as a '
|
||
'pass'.format(listed))
|
||
|
||
want = ' or '.join([repr(p) for p in wanted])
|
||
other = 'even' if word == 'odd' else 'odd'
|
||
if (count % 2 == 1) != (word == 'odd'):
|
||
if count:
|
||
return [('OK',
|
||
'this piece carries {0} x {1}, an {2} count, and the BED '
|
||
'PLAN note {3} is required only on an {4} count -- the '
|
||
'rule does not apply here'
|
||
.format(count, listed, other, want, word))], None
|
||
return [('OK',
|
||
'this piece carries no {0} at all ({1} member(s) read, none '
|
||
'of them named {0}), so the count is 0 -- an {4} number, '
|
||
'and the BED PLAN note {2} is required only on an {3} '
|
||
'count. Judged, and the rule does not apply'
|
||
.format(listed,
|
||
total if total is not None else 'an unrecorded '
|
||
'number of', want, word, other))], None
|
||
if callable(bed_text):
|
||
# THE ONE DEFERRED LINE, AND WHY IT IS WORTH THE SHAPE. Reading the
|
||
# BED PLAN means walking every sheet's placed views and flattening
|
||
# a view's text notes, and the gate above means an EVEN count never
|
||
# needs any of it. A caller holding the text passes the text; a
|
||
# caller that would have to go and fetch it passes something that
|
||
# will -- returning `(text, reason)` together, because "no view of
|
||
# that name is placed here" is the reason for the silence and is
|
||
# not known until after the look.
|
||
bed_text, bed_reason = bed_text()
|
||
if bed_text is None:
|
||
return [], (bed_reason or
|
||
'the BED PLAN could not be read on this ticket, so the '
|
||
'note an {0} count of {1} requires was not looked for'
|
||
.format(word, listed))
|
||
blob = squash_phrase(bed_text)
|
||
for phrase in wanted:
|
||
if phrase in blob:
|
||
return [('OK',
|
||
'this piece carries {0} x {1}, an {2} count, and the BED '
|
||
'PLAN carries {3} as that requires. The words are there; '
|
||
'whether they are about this part is not claimed'
|
||
.format(count, listed, word, repr(phrase)))], None
|
||
return [('FAIL',
|
||
'this piece carries {0} x {1}, an {2} count, so the BED PLAN '
|
||
'must carry {3} -- it does not. {4} character(s) of BED PLAN '
|
||
'text were read and none of them say it'
|
||
.format(count, listed, word, want, len(blob)))], None
|
||
|
||
|
||
def _n(x):
|
||
"""Trim a float for a message: 1.0 -> '1', 1.250 -> '1.25'."""
|
||
s = '{0:.3f}'.format(float(x))
|
||
s = s.rstrip('0').rstrip('.')
|
||
return s or '0'
|
||
|
||
|
||
def bed_offset_status(offset_in, width_in, bed_width_in, left_rail_is_fixed):
|
||
"""Checklist 4.6 (BED-OFFSET): may the strand pattern slide, and how far.
|
||
|
||
Pure logic, host-testable. Returns (severity, code, message), or None
|
||
when there is nothing to judge (no bed width, no panel width).
|
||
|
||
`left_rail_is_fixed` is the plant fact, and it decides the whole shape
|
||
of the answer (see the `rails` block in knowledge.py, corrected
|
||
2026-08-30):
|
||
|
||
False -- the bed has NO fixed rail. It does not mean the RIGHT rail
|
||
is fixed; a fixed right rail does not exist in this
|
||
industry. So the piece sits anywhere across the bed, the
|
||
pattern slides with it, and the whole difference is bed
|
||
offset. How far it may slide is not a judgement call:
|
||
|
||
|offset| <= bed width - panel width
|
||
|
||
True -- the left rail IS the bed's fixed rail. Against a fixed rail
|
||
the pattern CANNOT slide freely, so an offset is a finding
|
||
rather than slack to be spent -- but WHAT a fixed-rail plant
|
||
should tolerate is an engineering question nobody has
|
||
answered. Rather than invent a tolerance, or (worse) fall
|
||
through to the False behaviour and spend slack that is not
|
||
there, this reports the offset as a WARN and says plainly
|
||
that it was not judged. Chosen 2026-08-30; the owner still
|
||
has to decide what a true plant's tolerance is.
|
||
|
||
None -- nobody has recorded it. The verdicts are unchanged from the
|
||
False path (the bed-width ceiling holds under any reading of
|
||
which rail is fixed), but the message SAYS the flag is
|
||
unrecorded, so the assumption is visible rather than silent.
|
||
"""
|
||
if bed_width_in is None or width_in is None:
|
||
return None
|
||
slack = round(bed_width_in - width_in, 3)
|
||
offset = abs(round(offset_in, 3))
|
||
if slack < 0:
|
||
return ('FAIL', 'BED-WIDTH',
|
||
'piece is {0}" wide but the bed is only {1}"'
|
||
.format(_n(width_in), _n(bed_width_in)))
|
||
if left_rail_is_fixed is True:
|
||
return ('WARN', 'BED-OFFSET',
|
||
'strand pattern sits {0}" off the design chain. The left '
|
||
'rail is this bed\'s FIXED rail (rails.left_rail_is_fixed '
|
||
'is true), so the pattern cannot simply slide and the {1}" '
|
||
'the bed leaves over is NOT slack to spend -- but no '
|
||
'fixed-rail tolerance has been recorded, so this offset is '
|
||
'reported, not judged.'
|
||
.format(_n(offset), _n(slack)))
|
||
unknown = ''
|
||
if left_rail_is_fixed is None:
|
||
unknown = (' (rails.left_rail_is_fixed is unrecorded; this reads '
|
||
'the bed as having no fixed rail)')
|
||
if offset > slack + 1e-6:
|
||
return ('FAIL', 'BED-OFFSET',
|
||
'strand pattern sits {0}" off the design chain, but a {1}" '
|
||
'piece in a {2}" bed can only move {3}"{4}'
|
||
.format(_n(offset), _n(width_in), _n(bed_width_in),
|
||
_n(slack), unknown))
|
||
return ('OK', 'BED-OFFSET',
|
||
'pattern offset {0}" within the {1}" the bed leaves{2}'
|
||
.format(_n(offset), _n(slack), unknown))
|
||
|
||
|
||
# ------------------------------------- where a bar is DRAWN (checklist 5.2)
|
||
#
|
||
# 5.2 used to hold ONE rule about reinforcing: "anything not shown in the
|
||
# A-A / B-B sections must appear on the BED PLAN". That is a RESIDUAL --
|
||
# it derives the bar's home by subtraction, so every bar the plan does not
|
||
# name is a finding.
|
||
#
|
||
# The manufacturer's own standards state it as an ENUMERATION instead
|
||
# (section 10000, PDF p804 / 10301.1 notes 25, 26, 40, 47; p805 / 10301.2
|
||
# note 15C; recorded with verbatim in the section-10000 extraction in QNA/
|
||
# A-23). Positively, by class of bar:
|
||
#
|
||
# full-length longitudinal bar -> Section A-1
|
||
# partial-length bar -> plan
|
||
# transverse bar -> never in Section A-1
|
||
# full-width transverse bar -> Section B-1
|
||
# additional trim / corner bar -> plan
|
||
# return insulation -> BED PLAN
|
||
#
|
||
# The residual and the enumeration disagree in exactly one direction, and
|
||
# it is the expensive one: a full-length bar lives in Section A-1 ONLY and
|
||
# a full-width transverse bar lives in Section B-1 ONLY -- neither is
|
||
# drawn on the plan, and neither MAY be. The residual reports both as
|
||
# missing from the BED PLAN. That is over-flagging, and an over-flagging
|
||
# check is read once and ignored after.
|
||
#
|
||
# JUDGEMENT CALL 1 -- "Section A-1" is this project's "A-A".
|
||
# The manual uses three forms for the same two cuts (QNA A-23 / C-5): the
|
||
# templates head the notes `SECTION A-1` / `SECTION B-1` and bubble the
|
||
# cuts `A over 1` / `B over 1`; the worked wall-panel example bubbles the
|
||
# same two cuts `A over A` / `B over B`; the prose says "the A-A section".
|
||
# What settles it is not the name but the PAIRING: p819 / 10315.1 says cut
|
||
# a section EACH WAY through the panel, and notes 26 and 40 use the two
|
||
# names as that same pair -- transverse steel is kept OUT of the first and
|
||
# shown in the second. There are only two orthogonal cuts to be had, so
|
||
# `A-1` is the along-the-length cut and `B-1` the across-the-width cut,
|
||
# which is what `A-A` and `B-B` are here. Recorded as a conclusion, not an
|
||
# assumption -- and note that NOTHING BELOW MATCHES A SECTION NAME. The
|
||
# code only needs to know that a bar belongs in *a section rather than the
|
||
# plan*, so if this conclusion is ever overturned the check does not move.
|
||
#
|
||
# JUDGEMENT CALL 2 -- trim/corner bars need not be told apart from
|
||
# partial-length bars. Geometry cannot separate them (a trim bar beside an
|
||
# opening and a short partial-length bar are the same short bar), but the
|
||
# standard sends BOTH to the plan, so the distinction has no consequence
|
||
# here and is not attempted. If a future rule ever treats them
|
||
# differently, this is where that stops being true.
|
||
#
|
||
# JUDGEMENT CALL 3 -- a partial-WIDTH transverse bar is UNPLACED by the
|
||
# standard. Note 26 only forbids it from Section A-1; note 40 places the
|
||
# full-width one in Section B-1. Nothing says where the partial one goes.
|
||
# The tool therefore does not know, says so, and does not flag it.
|
||
|
||
BAR_HOME_PLAN = 'plan'
|
||
BAR_HOME_SECTION_A = 'section_A'
|
||
BAR_HOME_SECTION_B = 'section_B'
|
||
|
||
|
||
def bar_run_axis(size_in, length_axis, width_axis, thickness_axis):
|
||
"""Which panel axis a bar runs along: 'length', 'width', or None.
|
||
|
||
size_in is the bar's local-frame box extents, indexed by axis; the
|
||
three axis arguments are the indices of the panel's own length /
|
||
width / thickness (ticketchecks' `panel_axes`).
|
||
|
||
None means the tool could not say -- unusable input, a bar whose
|
||
longest extent is the THICKNESS (it stands through the piece and is
|
||
neither longitudinal nor transverse), or a bar the two in-plane
|
||
extents tie on (a square hoop or a bent bar's bounding box). None is
|
||
never a pass; the caller must say it could not classify.
|
||
"""
|
||
try:
|
||
axes = (int(length_axis), int(width_axis), int(thickness_axis))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if len(set(axes)) != 3 or not all(0 <= a <= 2 for a in axes):
|
||
return None
|
||
try:
|
||
vals = [float(size_in[a]) for a in axes]
|
||
except (TypeError, ValueError, IndexError, KeyError):
|
||
return None
|
||
along, across, through = vals
|
||
if along <= 0 or across <= 0:
|
||
return None
|
||
if through >= along and through >= across:
|
||
return None # stands through the piece: not placed here
|
||
if abs(along - across) < 1e-9:
|
||
return None # square in plan: no run direction to read
|
||
return 'length' if along > across else 'width'
|
||
|
||
|
||
def bar_drawing_home(run_axis, extent_in, panel_extent_in, full_fraction):
|
||
"""Checklist 5.2: where the standard says this one bar is drawn.
|
||
|
||
Pure logic, host-testable. Returns (home, why):
|
||
|
||
(BAR_HOME_SECTION_A, why) -- full-length longitudinal (note 25)
|
||
(BAR_HOME_SECTION_B, why) -- full-width transverse (note 40)
|
||
(BAR_HOME_PLAN, why) -- partial-length / trim / corner bar
|
||
(None, why) -- COULD NOT CLASSIFY. `why` says what
|
||
is missing. This is never a pass and
|
||
never a finding: the caller reports
|
||
it as an unclassified bar, or the
|
||
row reads NOT RUN.
|
||
|
||
run_axis 'length' | 'width' | None, from bar_run_axis.
|
||
extent_in how far the bar reaches along that axis, inches.
|
||
panel_extent_in the panel's own extent on that axis, inches.
|
||
full_fraction drawings.full_length_bar_fraction -- the fraction of
|
||
the panel a bar must reach to count as full-length
|
||
(or full-width). PLANT KNOWLEDGE, null by default:
|
||
the standard says "full length bar" and never says
|
||
how short still counts, so no number is invented
|
||
here. Unset, this returns None and 5.2's reinforcing
|
||
half stands down, exactly as rails.max_bed_width_in
|
||
stands BED-OFFSET down.
|
||
"""
|
||
if run_axis not in ('length', 'width'):
|
||
return (None, 'bar has no readable run direction in the plane of '
|
||
'the piece')
|
||
if full_fraction is None:
|
||
return (None, 'drawings.full_length_bar_fraction is unset -- how '
|
||
'much of the panel a bar must span to be "full '
|
||
'length" is plant knowledge and is not guessed here')
|
||
try:
|
||
frac = float(full_fraction)
|
||
reach = float(extent_in)
|
||
whole = float(panel_extent_in)
|
||
except (TypeError, ValueError):
|
||
return (None, 'bar or panel extent not readable')
|
||
if whole <= 0 or reach < 0:
|
||
return (None, 'bar or panel extent not readable')
|
||
if not 0.0 < frac <= 1.0:
|
||
return (None, 'drawings.full_length_bar_fraction must be a fraction '
|
||
'in (0, 1]; got {0}'.format(full_fraction))
|
||
spans = reach >= frac * whole - 1e-9
|
||
pct = 100.0 * reach / whole
|
||
if run_axis == 'length':
|
||
if spans:
|
||
return (BAR_HOME_SECTION_A,
|
||
'full-length bar ({0}% of the length) -- Section A-A '
|
||
'only, not the BED PLAN (note 25)'.format(_n(pct)))
|
||
return (BAR_HOME_PLAN,
|
||
'partial-length bar ({0}% of the length) -- plan view '
|
||
'(note 25)'.format(_n(pct)))
|
||
if spans:
|
||
return (BAR_HOME_SECTION_B,
|
||
'full-width transverse bar ({0}% of the width) -- Section '
|
||
'B-B only, not the BED PLAN (note 40)'.format(_n(pct)))
|
||
return (None,
|
||
'transverse bar spanning {0}% of the width -- note 26 only keeps '
|
||
'transverse bars OUT of Section A-A and note 40 covers only the '
|
||
'full-width one; the standard does not say where a part-width '
|
||
'transverse bar is drawn'.format(_n(pct)))
|
||
|
||
|
||
def bed_plan_bar_disposition(homes_by_family):
|
||
"""5.2: which bar families the BED PLAN difference may still judge.
|
||
|
||
homes_by_family maps family -> the set of homes its instances
|
||
classified to (bar_drawing_home's first value, None included).
|
||
Returns (plan_bound, section_bound, unclassified), each a sorted list
|
||
of family names.
|
||
|
||
A family is judged over ALL its instances, and the order of the three
|
||
tests is the whole safety property:
|
||
|
||
1. any instance unclassified -> UNCLASSIFIED. The family leaves the
|
||
difference entirely. Not judged is not a pass and not a flag.
|
||
2. any instance plan-bound -> PLAN. Being drawn on the plan is
|
||
what the difference tests, so one such instance keeps it in.
|
||
3. otherwise -> SECTION. Absence from the plan is
|
||
correct for it and must not be reported.
|
||
|
||
Putting (1) first is deliberate: a family with one full-length bar
|
||
and one bar nobody could measure must NOT be flagged on the strength
|
||
of the half that was readable.
|
||
"""
|
||
plan, section, unknown = [], [], []
|
||
for fam in sorted(homes_by_family or {}):
|
||
homes = homes_by_family.get(fam) or set()
|
||
if not homes or None in homes:
|
||
unknown.append(fam)
|
||
elif BAR_HOME_PLAN in homes:
|
||
plan.append(fam)
|
||
else:
|
||
section.append(fam)
|
||
return plan, section, unknown
|
||
|
||
|
||
# ------------------------------------------- 5.7 bar length vs stock
|
||
#
|
||
# Will, 2026-08-30: "rebars come in 40ft stock. unless overwritten by other
|
||
# knowledge, all rebars pieces must be less than 40ft." A bar longer than a
|
||
# stick cannot be made from one, and nothing else on the ticket notices.
|
||
#
|
||
# THE LENGTH IS READ, NEVER MEASURED. Will, same day, on a bar's length with
|
||
# BENT BARS INCLUDED: "钢筋的长度,即使是弯筋的长度,也都不要自己测量"
|
||
# -- never measure it yourself; read the parameter he named. The
|
||
# gathering side reads `param_names(know, 'member.length')`; a bounding box
|
||
# would understate every bent bar, because what a bent bar consumes off the
|
||
# stick is its DEVELOPED length and a box only sees the envelope it folds
|
||
# into. There is no geometry fallback here and there must never be one.
|
||
#
|
||
# GROUPED BY MARK, NOT BY INSTANCE. Measured on the live model 2026-08-30:
|
||
# 2,122 elements carry a length at or over 40 ft. As one finding per element
|
||
# that is a wall of noise that buries every other check on the ticket; as one
|
||
# finding per MARK it is a short list a person can rule on, which is also the
|
||
# grain the owner's ruling will arrive in ("SR1101 is a continuous run").
|
||
|
||
|
||
def bar_stock_length_status(bars, stock_length_ft, exempt_marks=None):
|
||
"""Checklist 5.7 core: bar lengths against the stock they are cut from.
|
||
|
||
`bars` is a list of dicts, one per BAR INSTANCE, from the gathering
|
||
side: {'mark': str-or-None, 'family': str, 'length_ft': float-or-None}.
|
||
`length_ft` is None when the role was unmapped or the parameter absent
|
||
on that bar, and 0 when the parameter is there and holds zero.
|
||
|
||
Returns (findings, unread, zero, exempted) where `findings` is a list
|
||
of (severity, message) pairs and the other three are sorted lists of
|
||
the labels set aside, so the caller can report them under their own
|
||
code instead of letting them decide the row.
|
||
|
||
FOUR OUTCOMES PER BAR, and they are four different things:
|
||
|
||
over stock -> WARN, grouped by mark, longest first
|
||
under stock -> counted toward the pass
|
||
length 0.0 -> COULD NOT JUDGE. Measured: 260 elements on the
|
||
live model carry the parameter holding zero.
|
||
Zero is not a length and it is not an absence,
|
||
so it is neither passed nor flagged.
|
||
no length at all -> COULD NOT JUDGE, named, never silently dropped.
|
||
|
||
An exempt mark is set aside BEFORE the comparison and listed, so a
|
||
reader can always see what was not asked -- same shape as `checks.muted`
|
||
and as 5.2's covered families.
|
||
|
||
PER-SIZE STOCK, added 2026-08-31. A bar may carry its own `stock_ft`,
|
||
resolved from `knowledge.rebar_stock` by the gathering side; when it does
|
||
it is compared against THAT and not against `stock_length_ft`. The
|
||
argument stays for the bars that carry none. A #4 at a plant stocking #4
|
||
in 20 ft lengths used to be compared against the generic 40 and pass at
|
||
30 ft, silently, on a real defect class.
|
||
|
||
The message keeps its old single-length wording when every bar was held
|
||
to the SAME length -- one number in a sentence is easier to read than the
|
||
same number repeated -- and names the length per mark as soon as they
|
||
differ. A sentence that said "the 40 ft stock length" while half the bars
|
||
were judged against 20 would be worse than no sentence.
|
||
|
||
A bar may also carry its `size`, and where it does the over-stock line
|
||
NAMES IT: "60 ft for a #4" is a claim a person can check against the
|
||
plant's stock list and "60 ft" is not. It is printed only where the
|
||
gather resolved one, never inferred from the stock length it matched.
|
||
"""
|
||
if not stock_length_ft and not any(
|
||
(bar or {}).get('stock_ft') for bar in (bars or [])):
|
||
return [], [], [], []
|
||
exempt = set()
|
||
for name in (exempt_marks or []):
|
||
text = _bar_label(name)
|
||
if text:
|
||
exempt.add(text.upper())
|
||
|
||
over = {} # mark -> [lengths]
|
||
over_limit = {} # mark -> the stock length it was held to
|
||
over_size = {} # mark -> the size(s) that length belongs to
|
||
limits = set() # every stock length actually used
|
||
under = 0
|
||
unread, zero, exempted = {}, {}, {}
|
||
for bar in (bars or []):
|
||
label = _bar_label(bar.get('mark')) or _bar_label(bar.get('family')) \
|
||
or '(unmarked bar)'
|
||
length = bar.get('length_ft')
|
||
limit = bar.get('stock_ft') or stock_length_ft
|
||
if label.upper() in exempt:
|
||
exempted.setdefault(label, 0)
|
||
exempted[label] += 1
|
||
continue
|
||
if length is None:
|
||
unread.setdefault(label, 0)
|
||
unread[label] += 1
|
||
continue
|
||
try:
|
||
length = float(length)
|
||
except (TypeError, ValueError):
|
||
unread.setdefault(label, 0)
|
||
unread[label] += 1
|
||
continue
|
||
if length <= 0:
|
||
zero.setdefault(label, 0)
|
||
zero[label] += 1
|
||
continue
|
||
if not limit:
|
||
# No stock length for this bar at all. Not a pass -- it is the
|
||
# comparison never happening, and it is named like every other
|
||
# could-not-judge here.
|
||
unread.setdefault(label, 0)
|
||
unread[label] += 1
|
||
continue
|
||
limits.add(limit)
|
||
if length >= limit:
|
||
over.setdefault(label, []).append(length)
|
||
over_limit[label] = limit
|
||
if bar.get('size'):
|
||
over_size.setdefault(label, set()).add(str(bar['size']))
|
||
else:
|
||
under += 1
|
||
|
||
one_limit = list(limits)[0] if len(limits) == 1 else None
|
||
findings = []
|
||
if over:
|
||
rows = sorted(over.items(), key=lambda kv: (-max(kv[1]), kv[0]))
|
||
parts = []
|
||
for mark, lengths in rows:
|
||
# THE SIZE IS PART OF THE CLAIM. "60 ft against a 40 ft stick"
|
||
# and "60 ft against the 40 ft stick a #4 comes in" are two
|
||
# different statements, and only the second can be checked by
|
||
# somebody holding the plant's stock list. Named only where the
|
||
# gather actually resolved a size -- a mark whose bars disagree
|
||
# about it says so rather than picking one.
|
||
size = ' for a {0}'.format(', '.join(sorted(over_size[mark]))) \
|
||
if over_size.get(mark) else ''
|
||
if one_limit is not None:
|
||
parts.append('{0} at {1} ft{2} ({3} piece(s))'
|
||
.format(mark, _n(max(lengths)), size,
|
||
len(lengths)))
|
||
else:
|
||
parts.append('{0} at {1} ft vs its {2} ft stock{3} ({4} '
|
||
'piece(s))'.format(mark, _n(max(lengths)),
|
||
_n(over_limit[mark]), size,
|
||
len(lengths)))
|
||
findings.append((
|
||
'WARN',
|
||
'{0} bar mark(s) are at or over the {1} stock length and '
|
||
'cannot be cut from one stick: {2}'
|
||
.format(len(rows),
|
||
'{0} ft'.format(_n(one_limit)) if one_limit is not None
|
||
else 'stock length for their size',
|
||
'; '.join(parts))))
|
||
elif under:
|
||
findings.append((
|
||
'OK',
|
||
'every one of the {0} bar(s) whose length could be read is '
|
||
'shorter than the {1}'
|
||
.format(under,
|
||
'{0} ft stock length'.format(_n(one_limit))
|
||
if one_limit is not None
|
||
else 'stock length for its own size')))
|
||
return (findings, _label_counts(unread), _label_counts(zero),
|
||
_label_counts(exempted))
|
||
|
||
|
||
def _bar_label(value):
|
||
if value is None:
|
||
return ''
|
||
return str(value).strip()
|
||
|
||
|
||
def _label_counts(counts):
|
||
"""{label: n} -> ['LABEL (n)'] sorted, for a message that names things."""
|
||
out = []
|
||
for label in sorted(counts or {}):
|
||
n = counts[label]
|
||
out.append('{0} ({1})'.format(label, n) if n > 1 else label)
|
||
return out
|
||
|
||
|
||
# ------------------------------- 1.5 call-outs must not overlap
|
||
#
|
||
# Two printed call-outs whose boxes intersect are two call-outs a reader has
|
||
# to disentangle, and nothing else on the ticket notices. TEXT AGAINST TEXT
|
||
# ONLY -- text against LINEWORK is deliberately out of scope, because a
|
||
# call-out sitting over a drawn edge is the normal condition on every
|
||
# elevation and flagging it would report the whole sheet.
|
||
#
|
||
# WHAT A BOX IS AND IS NOT. `get_BoundingBox(view)` returns the annotation's
|
||
# extent in view space, which APPROXIMATES the printed glyphs -- 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 of each other.
|
||
# That is why this is grade C: an intersection is a place to look, never a
|
||
# defect claim, and the message says the size so a reader can judge.
|
||
|
||
|
||
def rect_overlap(a, b):
|
||
"""(w, h) of the intersection of two boxes, or None when they miss.
|
||
|
||
A box is (x0, y0, x1, y1). Touching edges are NOT an overlap: the
|
||
owner's spec is a POSITIVE-area intersection, and two boxes sharing a
|
||
boundary print clear of each other.
|
||
"""
|
||
if not a or not b:
|
||
return None
|
||
ax0, ay0, ax1, ay1 = min(a[0], a[2]), min(a[1], a[3]), \
|
||
max(a[0], a[2]), max(a[1], a[3])
|
||
bx0, by0, bx1, by1 = min(b[0], b[2]), min(b[1], b[3]), \
|
||
max(b[0], b[2]), max(b[1], b[3])
|
||
w = min(ax1, bx1) - max(ax0, bx0)
|
||
h = min(ay1, by1) - max(ay0, by0)
|
||
if w <= 0 or h <= 0:
|
||
return None
|
||
return w, h
|
||
|
||
|
||
OVERLAP_MIN_IN = 0.005 # 1/200 in each way: below this it is rounding
|
||
|
||
|
||
def callout_overlap_status(items, min_in=OVERLAP_MIN_IN):
|
||
"""Checklist 1.5 core: text-bearing annotations that print over each other.
|
||
|
||
`items` is a list of dicts from the gathering side, one per annotation:
|
||
|
||
{'view': view name, 'text': printed string, 'kind': 'text note' |
|
||
'tag' | 'keynote', 'box': (x0, y0, x1, y1) in PRINTED INCHES,
|
||
'rotated': True/False/None}
|
||
|
||
The caller converts view-space feet to printed inches through the view
|
||
scale, so the number in the message is the size on paper -- the only
|
||
unit a reader can act on. `box` is None when it could not be read and
|
||
`rotated` is None when rotation could not be determined.
|
||
|
||
Returns (findings, unread, rotated) -- findings a list of
|
||
(severity, message), the other two lists of labels set aside. Only
|
||
annotations on the SAME view are ever compared; two views' coordinates
|
||
have nothing to do with each other.
|
||
|
||
ROTATED TEXT IS SET ASIDE, NOT FLAGGED. A rotated string still reports
|
||
an AXIS-ALIGNED box, which circumscribes the glyphs and overstates the
|
||
ink by up to the length of the string. Comparing one of those against a
|
||
neighbour manufactures overlaps that do not print, so a pair with a
|
||
rotated member is reported as could-not-judge under its own code.
|
||
"""
|
||
by_view = {}
|
||
unread, rotated = [], []
|
||
for item in (items or []):
|
||
label = _callout_label(item)
|
||
if not item.get('box'):
|
||
unread.append(label)
|
||
continue
|
||
if item.get('rotated'):
|
||
rotated.append(label)
|
||
continue
|
||
by_view.setdefault(item.get('view') or '(unnamed view)',
|
||
[]).append(item)
|
||
|
||
hits = []
|
||
for view in sorted(by_view):
|
||
items_on = by_view[view]
|
||
for i in range(len(items_on)):
|
||
for j in range(i + 1, len(items_on)):
|
||
a, b = items_on[i], items_on[j]
|
||
size = rect_overlap(a['box'], b['box'])
|
||
if size is None or size[0] < min_in or size[1] < min_in:
|
||
continue
|
||
hits.append((size[0] * size[1], view, a, b, size))
|
||
|
||
findings = []
|
||
if hits:
|
||
hits.sort(key=lambda h: -h[0])
|
||
parts = []
|
||
for (_area, view, a, b, size) in hits:
|
||
parts.append('on "{0}", {1} overlaps {2} by {3}" x {4}"'
|
||
.format(view, _callout_label(a), _callout_label(b),
|
||
_n(size[0]), _n(size[1])))
|
||
findings.append((
|
||
'WARN',
|
||
'{0} pair(s) of printed call-outs overlap: {1}'
|
||
.format(len(hits), '; '.join(parts))))
|
||
elif by_view:
|
||
findings.append((
|
||
'OK',
|
||
'no two of the {0} printed call-out(s) on this ticket overlap'
|
||
.format(sum(len(v) for v in by_view.values()))))
|
||
return findings, sorted(set(unread)), sorted(set(rotated))
|
||
|
||
|
||
def _callout_label(item):
|
||
"""How a call-out is named in a finding: its words, not its element id."""
|
||
text = clean_text((item or {}).get('text') or '').strip()
|
||
text = ' '.join(text.split())
|
||
kind = (item or {}).get('kind') or 'annotation'
|
||
if not text:
|
||
return 'an untitled {0}'.format(kind)
|
||
if len(text) > 40:
|
||
text = text[:40] + '...'
|
||
return '{0} "{1}"'.format(kind, text)
|
||
|
||
|
||
# ------------------------- 5.6 bar marks unique, model against print
|
||
#
|
||
# THE COMPUTABLE HALF OF THE OLD 5.5. Legibility is a property of the
|
||
# picture and stays with a person (grade D, `ticketvision`); uniqueness is a
|
||
# comparison between what the model carries and what the sheet prints, and a
|
||
# comparison is code's job.
|
||
#
|
||
# WHAT A PRINTED CALL-OUT LOOKS LIKE. Read off the live model 2026-08-30 via
|
||
# the bridge, on a bar-carrying WP ticket: `SR324 (F.F.)`, `SR310 (E.L.)`,
|
||
# `SR310 (N.F.)`. The printed string CONTAINS the mark and is never equal to
|
||
# it -- a face designation follows in parentheses -- so the match is
|
||
# containment of a whole token, never string equality.
|
||
|
||
|
||
def bar_mark_uniqueness_status(printed, bars, attached=None, classify=None):
|
||
"""Checklist 5.6 core: printed bar marks against the modelled bars.
|
||
|
||
`printed` is a list of {'text', 'view'} -- one per printed call-out
|
||
string. `bars` is a list of {'mark', 'family', 'length_ft'} -- one per
|
||
modelled bar instance, `mark` None when the bar carries none.
|
||
|
||
Returns (findings, unread) -- findings a list of (severity, message),
|
||
`unread` the marks of bars that could not be read at all.
|
||
|
||
THREE QUESTIONS, and each is a different defect:
|
||
|
||
(a) one printed mark, two bar populations. Two bars sharing a mark
|
||
but differing in family or in length are two different parts
|
||
under one name, and the drawing cannot tell them apart. The mark
|
||
is what a fabricator cuts to, so this is the expensive one.
|
||
(b) a printed mark no bar carries. The sheet calls out a part the
|
||
model does not have.
|
||
(c) a modelled bar whose mark is never printed. The part exists and
|
||
the drawing never names it.
|
||
|
||
A bar with NO mark at all is not any of the three: it is could-not-judge,
|
||
returned separately, and it is never counted as (c) -- an absent mark
|
||
cannot be looked for in the printed text.
|
||
|
||
---- TWO NARROWINGS, BOTH DECLARED, BOTH INJECTED (2026-08-31) ---------
|
||
|
||
`attached` is `knowledge.attached_parents()`: {BAR MARK: [parent, ...]},
|
||
a bar that ships inside another item and is COVERED BY ITS PARENT'S
|
||
CALLOUT. The plant's own words, written down long before this read them:
|
||
"Looking for SR4996 as an independent bar and failing the ticket when it
|
||
is not found would be the checker misreading the BOM, not the ticket
|
||
being wrong."
|
||
|
||
It narrows (c) ONLY, and into two outcomes, never into silence:
|
||
|
||
* the parent IS printed -> the bar is accounted for. A JUDGED result,
|
||
named in the sentence, because a coverage nobody can see is a
|
||
coverage nobody can question.
|
||
* the parent is NOT printed -> STILL A FINDING, and now it is about
|
||
the PARENT. The bar is unaccounted for either way; what changed is
|
||
whose callout is missing, and a report naming the bar would send the
|
||
reader to fix the wrong thing.
|
||
|
||
`classify` is `knowledge.printed_token_population`: whether a printed
|
||
token is a bar mark, a piece of hardware, or something the grammar cannot
|
||
place. It narrows (b) only. `P78 W/ SR5502 (S.I.F.)` carries BOTH
|
||
populations, and no `W/` splitter is needed or wanted: the grammar
|
||
answers per token, so the part is set aside and the bar after it is
|
||
compared -- one mechanism instead of two that can disagree.
|
||
|
||
A token the grammar CANNOT place is its own finding: could-not-judge,
|
||
never a quiet drop. Both arguments default to None, which is exactly what
|
||
this function did before either existed -- every token a candidate, no
|
||
bar covered.
|
||
"""
|
||
marks = {} # MARK -> [bar, ...]
|
||
unread = {}
|
||
for bar in (bars or []):
|
||
mark = _bar_label(bar.get('mark'))
|
||
if not mark:
|
||
label = _bar_label(bar.get('family')) or '(unnamed family)'
|
||
unread.setdefault(label, 0)
|
||
unread[label] += 1
|
||
continue
|
||
marks.setdefault(mark.upper(), []).append(bar)
|
||
|
||
# EVERY token, whatever it turns out to be. "Is this mark printed
|
||
# anywhere on the ticket" is answered from this set and never from the
|
||
# narrowed one: a bar mark the grammar misfiled as hardware is still ink
|
||
# on the sheet, and (c) must not go on to report it as unprinted.
|
||
printed_tokens = {} # TOKEN -> set(view)
|
||
for row in (printed or []):
|
||
for token in mark_tokens(row.get('text')):
|
||
printed_tokens.setdefault(token, set()).add(
|
||
row.get('view') or '(unnamed view)')
|
||
|
||
candidates, hardware, unplaced = {}, {}, {}
|
||
for token in printed_tokens:
|
||
population, why = ('ungoverned', None)
|
||
if classify is not None:
|
||
population, why = classify(token)
|
||
if population == 'hardware':
|
||
hardware[token] = why
|
||
elif population == 'unknown':
|
||
unplaced[token] = why
|
||
else:
|
||
candidates[token] = why
|
||
|
||
covers = {}
|
||
for child in (attached or {}):
|
||
covers[_text(child).strip().upper()] = [
|
||
_text(parent).strip() for parent in (attached[child] or [])
|
||
if _text(parent).strip()]
|
||
|
||
findings = []
|
||
|
||
# (a) one mark, two populations
|
||
split = []
|
||
for mark in sorted(marks):
|
||
populations = set()
|
||
for bar in marks[mark]:
|
||
populations.add((_bar_label(bar.get('family')),
|
||
_round_ft(bar.get('length_ft'))))
|
||
if len(populations) > 1:
|
||
split.append((marks[mark][0].get('mark') or mark,
|
||
sorted('{0} @ {1} ft'.format(f or '(no family)',
|
||
_n(l) if l is not None
|
||
else '?')
|
||
for (f, l) in populations)))
|
||
if split:
|
||
findings.append((
|
||
'WARN',
|
||
'{0} bar mark(s) name more than one part, so the drawing cannot '
|
||
'tell them apart: {1}'
|
||
.format(len(split),
|
||
'; '.join('{0} = {1}'.format(m, ' / '.join(p))
|
||
for (m, p) in split))))
|
||
|
||
def _where(tok):
|
||
return '{0} (on "{1}")'.format(
|
||
tok, '", "'.join(sorted(printed_tokens[tok])))
|
||
|
||
# (b) printed, but not modelled -- over the BAR-MARK population only.
|
||
orphan = sorted(tok for tok in candidates if tok not in marks)
|
||
if orphan:
|
||
findings.append((
|
||
'WARN',
|
||
'{0} printed bar mark(s) match no bar in the model: {1}'
|
||
.format(len(orphan), ', '.join(_where(tok) for tok in orphan))))
|
||
|
||
# (b2) printed, and the grammar could not say WHAT it is. Not a bar
|
||
# finding, and not a drop either: an unclassifiable token is a fact about
|
||
# the sheet or about the grammar, and it is reported as one.
|
||
if unplaced:
|
||
findings.append((
|
||
'WARN',
|
||
'{0} printed token(s) could not be placed as either a bar mark '
|
||
'or a part, so nothing is claimed about them: {1}'
|
||
.format(len(unplaced),
|
||
' | '.join('{0} -- {1}'.format(
|
||
_where(tok), unplaced[tok] or 'no reason recorded')
|
||
for tok in sorted(unplaced)))))
|
||
|
||
# (c) modelled, but never printed -- split by whether a parent covers it.
|
||
silent, covered, orphaned_parent, unplaceable_parent = [], [], [], []
|
||
for m in sorted(marks):
|
||
if m in printed_tokens:
|
||
continue
|
||
label = marks[m][0].get('mark') or m
|
||
parents = covers.get(m)
|
||
if not parents:
|
||
silent.append(label)
|
||
continue
|
||
# A PARENT IS NAMED IN TWO VOCABULARIES AND BOTH ARE LEGITIMATE.
|
||
# Measured on the live layers 2026-08-31: the plant file names a
|
||
# parent by its FAMILY (`<vendor>_EMBED_P76`), the job file by its
|
||
# PART NUMBER (`SP29`). The ticket prints the part number. So the
|
||
# part number is extracted from whatever the key holds -- the same
|
||
# thing `check_bed_plan_reverse` does with a family name, for the
|
||
# same reason -- rather than demanding one spelling and turning the
|
||
# other into a finding nobody could act on.
|
||
shown, unreadable = [], []
|
||
for parent in parents:
|
||
numbers = part_numbers_in(parent)
|
||
if not numbers:
|
||
unreadable.append(parent)
|
||
continue
|
||
if any(number in printed_tokens for number in numbers):
|
||
shown.append(parent)
|
||
if shown:
|
||
covered.append((label, shown))
|
||
elif unreadable and len(unreadable) == len(parents):
|
||
# NO CLAIM EITHER WAY. Nothing in the parent's name is shaped
|
||
# like a part number, so whether the ticket prints it could not
|
||
# be established -- and "the parent is missing" is not something
|
||
# this may assert on the strength of a lookup it could not do.
|
||
unplaceable_parent.append((label, parents))
|
||
else:
|
||
orphaned_parent.append((label, parents))
|
||
if silent:
|
||
findings.append((
|
||
'WARN',
|
||
'{0} modelled bar mark(s) are never printed on this ticket: {1}'
|
||
.format(len(silent), ', '.join(silent))))
|
||
if orphaned_parent:
|
||
findings.append((
|
||
'WARN',
|
||
'{0} bar(s) are covered by a parent item callout and NEITHER the '
|
||
'bar nor its parent is printed on this ticket, so the bar is '
|
||
'unaccounted for -- what is missing is the PARENT callout, not '
|
||
'the bar: {1}'
|
||
.format(len(orphaned_parent),
|
||
' | '.join('{0} is attached to {1}'
|
||
.format(label, '/'.join(parents))
|
||
for (label, parents) in orphaned_parent))))
|
||
|
||
if unplaceable_parent:
|
||
findings.append((
|
||
'WARN',
|
||
'{0} bar(s) are declared covered by a parent item, and nothing in '
|
||
'that parent name is shaped like a part number, so whether the '
|
||
'ticket calls the parent out could not be established -- nothing '
|
||
'is claimed about the bar: {1}'
|
||
.format(len(unplaceable_parent),
|
||
' | '.join('{0} is attached to {1}'
|
||
.format(label, '/'.join(parents))
|
||
for (label, parents) in unplaceable_parent))))
|
||
|
||
covered_note = ''
|
||
if covered:
|
||
covered_note = ('; {0} bar(s) are never drawn on their own because a '
|
||
'parent item callout covers them, and that parent IS '
|
||
'printed: {1}'
|
||
.format(len(covered),
|
||
', '.join('{0} is attached to {1}'
|
||
.format(label, '/'.join(shown))
|
||
for (label, shown) in covered)))
|
||
hardware_note = ''
|
||
if hardware:
|
||
hardware_note = ('; {0} printed token(s) are hardware part numbers by '
|
||
'the plant part-number grammar, not bar marks, and '
|
||
'were not looked for among the bars: {1}'
|
||
.format(len(hardware), ', '.join(sorted(hardware))))
|
||
|
||
if not findings and marks:
|
||
findings.append((
|
||
'OK',
|
||
'all {0} modelled bar mark(s) are accounted for, each names one '
|
||
'part, and every printed bar mark exists in the model{1}{2}'
|
||
.format(len(marks), covered_note, hardware_note)))
|
||
elif covered_note or hardware_note:
|
||
# SAID EVEN WHEN SOMETHING ELSE FAILED. A narrowing visible only on a
|
||
# green row is a narrowing nobody audits on the runs that matter.
|
||
findings.append((
|
||
'OK', 'what 5.6 set aside on this piece{0}{1}'
|
||
.format(covered_note, hardware_note)))
|
||
return findings, _label_counts(unread)
|
||
|
||
|
||
# =========================================================================
|
||
# THE DESIGN-SUMMARY VOCABULARY -- rows 8.1 and 5.9
|
||
# =========================================================================
|
||
#
|
||
# WHAT THIS IS FOR. Row 8.1 enumerates the design-summary lines nothing
|
||
# could interpret. On a real insulated piece that was SEVEN lines out of
|
||
# seven, and one of them -- `HK ST100'S @1'-4" O.C.` -- is the requirement
|
||
# row 5.9 exists to compare against, so 5.9 stood down on every press with
|
||
# "the summary parser cannot read it yet". Seven uninterpreted lines and a
|
||
# permanently silent row, out of one missing grammar.
|
||
#
|
||
# WHAT IT IS NOT. This grammar CATEGORISES; it does not evaluate. A line it
|
||
# places is a line whose SHAPE is known -- "this is a spacing requirement
|
||
# naming this product at this pitch" -- and nothing here decides whether
|
||
# the piece honours it. Two consequences the next reader must keep:
|
||
#
|
||
# * A LINE THE GRAMMAR CANNOT PLACE STAYS AN 8.1 FINDING. 8.1's job is
|
||
# unparsed lines and it must never be taught to shrug: `classify` puts
|
||
# what it could not place into its second return value, verbatim, and
|
||
# 8.1 reports it exactly as it always did.
|
||
# * A LINE PLACED BUT NOT VALUED IS NOT A CLEAN LINE. `@ "S" O.C.` (a
|
||
# table variable) and `@ __ O.C.` (a literal blank on the sheet) are
|
||
# recognised as spacing requirements whose spacing IS NOT A NUMBER;
|
||
# they carry `spacing_in: None` and `spacing_unread_because`, and 8.1
|
||
# names them in their own clause rather than counting them as read.
|
||
# Anything consuming a requirement must check `spacing_in is None`.
|
||
#
|
||
# THE CORPUS THE PRODUCTIONS COME FROM. Two bodies, both quoted for GRAMMAR
|
||
# only -- never for the job or the client they belong to:
|
||
#
|
||
# (a) the design-summary lines a live run recorded as unresolved, read
|
||
# back out of the evidence files: 9 distinct lines, 7 of them on the
|
||
# one piece the owner read (counted 2026-08-31);
|
||
# (b) the plant standards manual extractions in QNA, which are the same
|
||
# sentences written many more ways: 54 distinct `... @ <spacing>
|
||
# O.C. ...` call-outs over 75 occurrences (counted 2026-08-31).
|
||
#
|
||
# Each production below names how many of those it was drawn from. Where
|
||
# (b) contradicts a shape (a) alone would have suggested, (b) wins -- that
|
||
# is why the spacing reader refuses `"S"` and `__` instead of parsing the
|
||
# first digits it can find.
|
||
|
||
SUMMARY_SPACING = 'spacing'
|
||
SUMMARY_PLACEMENT = 'placement'
|
||
SUMMARY_CONDITIONAL = 'conditional'
|
||
SUMMARY_LABEL = 'label'
|
||
|
||
# The vulgar fractions the manual prints. Written out because a fraction
|
||
# glyph silently dropped turns 1 1/2" into 1", and the engines this ships
|
||
# to do not all carry `unicodedata`.
|
||
_VULGAR = {
|
||
u'½': ' 1/2', u'¼': ' 1/4', u'¾': ' 3/4',
|
||
u'⅛': ' 1/8', u'⅜': ' 3/8', u'⅝': ' 5/8',
|
||
u'⅞': ' 7/8', u'⅓': ' 1/3', u'⅔': ' 2/3',
|
||
}
|
||
|
||
|
||
def _normalise_summary_text(text):
|
||
"""One design-summary line, ready for the productions to match.
|
||
|
||
Curly quotes to straight, vulgar fractions spelled out, whitespace
|
||
collapsed. Nothing is upper-cased here: the verbatim text is what 8.1
|
||
prints and what a person recognises on the sheet.
|
||
"""
|
||
import re as _re
|
||
if text is None:
|
||
return ''
|
||
out = _text(text)
|
||
out = out.replace(u'’', "'").replace(u'”', '"')
|
||
out = out.replace(u'‘', "'").replace(u'“', '"')
|
||
for glyph in _VULGAR:
|
||
out = out.replace(glyph, _VULGAR[glyph])
|
||
return _re.sub(r'\s+', ' ',
|
||
out.replace('\r', ' ').replace('\n', ' ')).strip()
|
||
|
||
|
||
def summary_spacing_inches(text):
|
||
"""A printed spacing as inches: (inches, None) or (None, why).
|
||
|
||
THE REASON IS THE POINT. The manual writes a spacing that is not a
|
||
number in two shapes, and both are in the corpus --
|
||
|
||
`@ "S" O.C.` a table variable; the sheet carries a table of
|
||
widths against spacings and "S" is its column
|
||
(7 occurrences over 5 distinct call-outs)
|
||
`@ __ O.C.` a literal blank left on the sheet (1 occurrence)
|
||
|
||
-- and a reader that scanned for the first digits would answer both
|
||
from whatever happened to sit to the left of the `@`. So the numeric
|
||
shapes are matched WHOLE and anything else is refused by name.
|
||
"""
|
||
import re as _re
|
||
raw = _normalise_summary_text(text)
|
||
if not raw:
|
||
return None, 'no spacing was printed after the @'
|
||
body = raw.strip()
|
||
# <n>'-<n>" and <n>' on its own -- 22 of the 54 distinct call-outs.
|
||
m = _re.match(
|
||
r"^(\d+)\s*'\s*-?\s*(?:(\d+)(?:\s+(\d+)\s*/\s*(\d+))?\s*\"?)?$", body)
|
||
if m:
|
||
inches = float(m.group(1)) * 12.0
|
||
if m.group(2):
|
||
inches += float(m.group(2))
|
||
if m.group(3):
|
||
inches += float(m.group(3)) / float(m.group(4))
|
||
return inches, None
|
||
# bare inches, whole / mixed / fractional -- 18 of the 54.
|
||
m = _re.match(
|
||
r"^(?:(\d+)(?:\s+(\d+)\s*/\s*(\d+))?|(\d+)\s*/\s*(\d+))\s*\"$", body)
|
||
if m:
|
||
if m.group(4):
|
||
return float(m.group(4)) / float(m.group(5)), None
|
||
inches = float(m.group(1))
|
||
if m.group(2):
|
||
inches += float(m.group(2)) / float(m.group(3))
|
||
return inches, None
|
||
if _re.match(r'^"?[A-Z]"?$', body, _re.I):
|
||
return None, (
|
||
'the spacing is printed as the symbol {0}, which is a column of '
|
||
'a table on the sheet and not a length -- the sheet resolves it '
|
||
'against that table and this tool does not read it'.format(body))
|
||
if _re.match(r'^[_\-\s]+$', body):
|
||
return None, (
|
||
'the spacing is a literal blank on the sheet ({0}), so the '
|
||
'design states the shape of the requirement and not its value'
|
||
.format(body))
|
||
# QUOTED, NOT `!r`. On the button engine a repr of a unicode string
|
||
# prints `u'...'`, and this sentence goes on the report.
|
||
return None, (
|
||
'the spacing "{0}" is not written in any form this reader knows '
|
||
'(feet-and-inches, whole inches, or a fraction of an inch)'
|
||
.format(body))
|
||
|
||
|
||
# `<product> @ <spacing> O.C. <qualifiers>`. THE FIRST `@` WINS AND THE REST
|
||
# IS A QUALIFIER, which is not a shortcut -- `#3S @ 18" O.C. +(2)@3" EA.
|
||
# END` states ONE field spacing and then an end densification, and the
|
||
# measured piece honours exactly that reading (16.0 in across the field
|
||
# with tighter pairs at the ends, 2026-08-31). A reader that took the last
|
||
# `@` would report the end pair as the requirement and fail every piece.
|
||
_SUMMARY_RES = {}
|
||
|
||
|
||
def _summary_res():
|
||
import re as _re
|
||
if not _SUMMARY_RES:
|
||
_SUMMARY_RES['spacing'] = _re.compile(
|
||
r"^(?P<product>[^@]*?)\s*@\s*(?P<spacing>[^@]*?)\s*"
|
||
r"O\.?\s*C\.?(?P<tail>.*)$", _re.I)
|
||
# `CL` -- centred. Confirmed as a PLACEMENT rather than a part
|
||
# number by QNA/proposed-checks-2026-08-31.md P12, which reads
|
||
# `CL #3 BARS@ TOP WYTHE` as "a bar size, a placement (centred) and
|
||
# a wythe". 5 of the 9 distinct live lines open with it.
|
||
_SUMMARY_RES['cl'] = _re.compile(r"^C\.?\s*L\.?\s+(?P<items>\S.*)$",
|
||
_re.I)
|
||
# A prose condition. Deliberately a SHORT list of connectives that
|
||
# actually appear, not a general grammar of English: a line whose
|
||
# condition this cannot see stays unplaced, which is the safe
|
||
# direction.
|
||
_SUMMARY_RES['cond'] = _re.compile(
|
||
r"^(?P<requirement>.*?)\s+(?:NEEDED\s+WHEN|ONLY\s+WHEN|WHEN|"
|
||
r"WHERE|IF)\s+(?P<condition>\S.*)$", _re.I)
|
||
_SUMMARY_RES['label'] = _re.compile(r'^[A-Za-z .]+$')
|
||
_SUMMARY_RES['split'] = _re.compile(r'\s*&\s*|\s+AND\s+', _re.I)
|
||
_SUMMARY_RES['tokens'] = _re.compile(r'[\s/,]+')
|
||
_SUMMARY_RES['possessive'] = _re.compile(r"'S$")
|
||
return _SUMMARY_RES
|
||
|
||
|
||
# Words that make an all-letters line a STATEMENT rather than a label, so
|
||
# `REINFORCING PER DESIGN` is never filed as a heading and quietly emptied
|
||
# of its content. A label production without this list is how a deferral
|
||
# ("per design", "as required") becomes a line the tool claims to have
|
||
# understood.
|
||
_NOT_A_LABEL = ('PER', 'DESIGN', 'REQUIRED', 'TYP', 'MIN', 'MAX', 'NOT',
|
||
'NO', 'ALL', 'EACH', 'ONLY', 'SEE', 'NOTE', 'BY', 'OR',
|
||
'AND', 'WITH', 'FROM', 'INTO', 'AT', 'ON', 'TO')
|
||
|
||
|
||
def summary_tokens(text):
|
||
"""A product phrase as comparable tokens.
|
||
|
||
Uppercased, punctuation stripped, and a trailing possessive/plural `'S`
|
||
removed -- the summary writes `HK ST100'S` where the plant's own alias
|
||
writes `ST100`, and those are the same product.
|
||
"""
|
||
res = _summary_res()
|
||
out = []
|
||
for raw in res['tokens'].split(_normalise_summary_text(text).upper()):
|
||
token = res['possessive'].sub('', raw.strip())
|
||
token = token.strip('.,;:()[]"\'')
|
||
if token:
|
||
out.append(token)
|
||
return out
|
||
|
||
|
||
def parse_summary_line(text):
|
||
"""One design-summary line as a typed requirement, or None.
|
||
|
||
None means UNPLACED, and an unplaced line goes back to 8.1 verbatim.
|
||
|
||
The productions, conditional first so a condition WRAPS whatever it
|
||
qualifies rather than being swallowed by it:
|
||
|
||
conditional `<requirement> NEEDED WHEN <condition>` 1 live line
|
||
spacing `<product> @<spacing> O.C. <qualifiers>` 2 live lines,
|
||
54 distinct call-outs in the manual corpus
|
||
placement `CL <items>[@ <zone>]` 3 live lines
|
||
label an all-letters heading naming the group 1 live line
|
||
"""
|
||
res = _summary_res()
|
||
line = _normalise_summary_text(text)
|
||
if not line:
|
||
return None
|
||
|
||
m = res['cond'].match(line)
|
||
if m and m.group('requirement').strip():
|
||
return {
|
||
'kind': SUMMARY_CONDITIONAL,
|
||
'text': line,
|
||
'condition': m.group('condition').strip(),
|
||
'requirement': parse_summary_line(m.group('requirement')),
|
||
'requirement_text': m.group('requirement').strip(),
|
||
}
|
||
|
||
m = res['spacing'].match(line)
|
||
if m and m.group('spacing').strip():
|
||
inches, why = summary_spacing_inches(m.group('spacing'))
|
||
product = m.group('product').strip().strip('-').strip()
|
||
# KEPT VERBATIM, trailing period and all: `E.W.` is each-way and
|
||
# `E.W` is a typo the reader would have to go and check.
|
||
tail = m.group('tail').strip()
|
||
return {
|
||
'kind': SUMMARY_SPACING,
|
||
'text': line,
|
||
'product': product or None,
|
||
'product_tokens': summary_tokens(product),
|
||
'spacing_in': inches,
|
||
'spacing_text': m.group('spacing').strip(),
|
||
'spacing_unread_because': why,
|
||
'qualifiers': [tail] if tail else [],
|
||
}
|
||
|
||
m = res['cl'].match(line)
|
||
if m:
|
||
body = m.group('items').strip()
|
||
zone = None
|
||
# `CL #3 BARS@ TOP WYTHE` -- the zone rides on an `@` with no O.C.
|
||
# after it. Split on the LAST `@`, because a product may carry one.
|
||
if '@' in body:
|
||
body, _sep, zone = body.rpartition('@')
|
||
body, zone = body.strip(), zone.strip()
|
||
items = [part.strip() for part in res['split'].split(body)
|
||
if part.strip()]
|
||
if items:
|
||
return {
|
||
'kind': SUMMARY_PLACEMENT,
|
||
'text': line,
|
||
'placement': 'centred',
|
||
'placement_token': 'CL',
|
||
'items': items,
|
||
'zone': zone or None,
|
||
}
|
||
|
||
words = line.replace('.', '').split()
|
||
if (words and len(words) <= 3 and res['label'].match(line)
|
||
and not [w for w in words if w.upper() in _NOT_A_LABEL]):
|
||
return {
|
||
'kind': SUMMARY_LABEL,
|
||
'text': line,
|
||
'states_no_requirement': True,
|
||
}
|
||
return None
|
||
|
||
|
||
def classify_summary_lines(lines):
|
||
"""([requirement, ...], [unplaced entry, ...]).
|
||
|
||
`lines` is whatever 8.1 was handed: `{text, reason}` entries as the
|
||
extractor writes them, or bare strings from an older spec. The unplaced
|
||
half comes back in the SAME shape it arrived in, because 8.1 prints it
|
||
and a reader has to be able to find the line on the sheet.
|
||
"""
|
||
placed, unplaced = [], []
|
||
for entry in (lines or []):
|
||
text = entry.get('text') if isinstance(entry, dict) else entry
|
||
parsed = parse_summary_line(text)
|
||
if parsed is None:
|
||
unplaced.append(entry)
|
||
else:
|
||
placed.append(parsed)
|
||
return placed, unplaced
|
||
|
||
|
||
def summary_requirement_inventory(placed):
|
||
"""What the grammar made of the lines it placed, as one sentence.
|
||
|
||
8.1 prints this on the clean answer. A row that went from seven red
|
||
lines to green with no sentence under it would have replaced one
|
||
silence with a quieter one -- the reader must be able to see WHAT the
|
||
tool decided each line was, the conditionals must be visible because
|
||
the tool states them and does not evaluate them, and a spacing it could
|
||
not value must not hide inside a count of lines it "read".
|
||
"""
|
||
if not placed:
|
||
return ''
|
||
counts, conditionals, unvalued = {}, [], []
|
||
for req in placed:
|
||
kind = req.get('kind')
|
||
counts[kind] = counts.get(kind, 0) + 1
|
||
if kind == SUMMARY_CONDITIONAL:
|
||
conditionals.append(req.get('text'))
|
||
if kind == SUMMARY_SPACING and req.get('spacing_in') is None:
|
||
unvalued.append('{0} ({1})'
|
||
.format(req.get('text'),
|
||
req.get('spacing_unread_because')))
|
||
order = (SUMMARY_SPACING, SUMMARY_PLACEMENT, SUMMARY_CONDITIONAL,
|
||
SUMMARY_LABEL)
|
||
words = {SUMMARY_SPACING: 'spacing requirement',
|
||
SUMMARY_PLACEMENT: 'placement statement',
|
||
SUMMARY_CONDITIONAL: 'conditional',
|
||
SUMMARY_LABEL: 'group label'}
|
||
parts = []
|
||
for kind in order:
|
||
if counts.get(kind):
|
||
parts.append('{0} {1}{2}'.format(counts[kind], words[kind],
|
||
's' if counts[kind] > 1 else ''))
|
||
said = ('{0} line(s) categorised by the summary vocabulary: {1}'
|
||
.format(len(placed), ', '.join(parts)))
|
||
if conditionals:
|
||
said += ('. {0} of them state a CONDITION this tool records and does '
|
||
'not evaluate: {1}'
|
||
.format(len(conditionals), '; '.join(conditionals)))
|
||
if unvalued:
|
||
said += ('. {0} spacing requirement(s) were recognised but carry no '
|
||
'numeric spacing, so nothing may be compared against them: '
|
||
'{1}'.format(len(unvalued), '; '.join(unvalued)))
|
||
return said
|
||
|
||
|
||
# ------------------------------------------- connectors (5.8, 5.9, 5.10)
|
||
#
|
||
# One recognition gate, three questions. The gate lives in `ticketchecks`
|
||
# (`_connector_gate`) because it reads knowledge and a ticket; what is here
|
||
# is the arithmetic, which is pure and host-testable.
|
||
|
||
|
||
def connectors_exist_status(count, gated_out=0):
|
||
"""5.8: an insulated panel has connectors. (severity, message).
|
||
|
||
ZERO IS A FAIL AND NOT A WARN. A sandwich panel whose wythes are not
|
||
tied together is a structural condition, not a drafting one -- the two
|
||
concrete layers are travelling in company. Most zero-shaped findings in
|
||
this tool are WARNs because a zero usually means the tool could not see
|
||
something; this one is reached ONLY after recognition has been confirmed
|
||
by the gate, so a zero here is a zero in the piece.
|
||
"""
|
||
if count:
|
||
note = ('{0} connector(s) cross the insulation on this piece'
|
||
.format(count))
|
||
if gated_out:
|
||
note += ('; {0} member(s) carried a connector mark and were '
|
||
'excluded by the plant component gate'.format(gated_out))
|
||
return 'OK', note
|
||
if gated_out:
|
||
return 'FAIL', (
|
||
'NO connector was counted on this insulated panel, and {0} '
|
||
'member(s) DID carry a connector mark but were excluded by the '
|
||
'plant component gate (connectors.component_gate). Either this '
|
||
'piece has untied wythes or that gate is wrong -- both need '
|
||
'somebody, and nothing here decides which'.format(gated_out))
|
||
return 'FAIL', (
|
||
'NO connector was found on this insulated panel. A connector is what '
|
||
'crosses the insulation and ties the two concrete wythes together; '
|
||
'without any, the wythes are two slabs travelling in company')
|
||
|
||
|
||
def connector_spacing(positions_in):
|
||
"""Gaps between consecutive connector stations, in inches."""
|
||
values = sorted(set([round(float(v), 3) for v in (positions_in or [])
|
||
if v is not None]))
|
||
return [round(values[i + 1] - values[i], 3)
|
||
for i in range(len(values) - 1)]
|
||
|
||
|
||
def connectors_sufficient_status(count, required, spacing_in=None,
|
||
required_spacing_in=None,
|
||
requirement_note=None,
|
||
tolerance_in=None, tolerance_why=None):
|
||
"""5.9: enough connectors for what the design calls for.
|
||
|
||
`required` / `required_spacing_in` come from the DESIGN side. Until
|
||
2026-08-31 nothing ever supplied either, and this returned (None,
|
||
reason) on every press; the summary vocabulary
|
||
(`classify_summary_lines`) and `knowledge.connector_spacing_requirement`
|
||
now resolve the second from the design summary's own line. It still
|
||
returns (None, reason) when they cannot -- a count on its own is a
|
||
number, not a judgement, and printing OK because nobody stated a
|
||
requirement is the silent pass this table exists to refuse.
|
||
|
||
`requirement_note` is WHERE THE REQUIREMENT CAME FROM, and it is in
|
||
every message this function returns, pass or fail. "16 in on centre" and
|
||
"16 in on centre, from the summary line `HK ST100'S @1'-4" O.C.`, joined
|
||
to this plant's R82 through ST100" are different claims, and only the
|
||
second can be argued with.
|
||
|
||
END DENSIFICATION IS NORMAL AND MUST NOT BE FLAGGED. Measured on one
|
||
insulated piece 2026-08-31: field spacing exactly 16.0 in -- which IS
|
||
the design summary's 1 ft 4 in on centre -- with tighter pairs (9.5 and
|
||
6.5 in) at the ends. So the comparison is against the WIDEST gap, and a
|
||
gap smaller than required is never the defect.
|
||
|
||
THE TOLERANCE IS AN ARGUMENT AND IT IS PASSED IN, not a literal here.
|
||
It was `+ 0.5` in this function; `knowledge.connector_spacing_tolerance`
|
||
now owns the number and its provenance, and a run that cannot resolve
|
||
one COMPARES NOTHING rather than falling back on a constant -- a
|
||
borrowed tolerance is the same class of mistake as a borrowed edge
|
||
distance (5.10) and this row is not going to make it a second time.
|
||
"""
|
||
tail = ' ({0})'.format(requirement_note) if requirement_note else ''
|
||
shortfall = 'this piece carries {0} connector(s) and the design calls '
|
||
if required is None and required_spacing_in is None:
|
||
return None, (
|
||
'no connector requirement could be resolved from the design, so '
|
||
"there is nothing to compare this piece's {0} connector(s) "
|
||
'against. Nothing is claimed{1}'.format(count, tail))
|
||
if required is not None and count < required:
|
||
return 'FAIL', (shortfall + 'for {1}{2}').format(count, required, tail)
|
||
if required_spacing_in:
|
||
gaps = [g for g in (spacing_in or []) if g]
|
||
if not gaps:
|
||
return None, (
|
||
'the design calls for connectors at {0} in on centre and '
|
||
"this piece's connector positions could not be measured, so "
|
||
'the spacing was not compared{1}'
|
||
.format(required_spacing_in, tail))
|
||
if tolerance_in is None:
|
||
return None, (
|
||
'the design calls for connectors at {0} in on centre and the '
|
||
'widest gap on this piece is {1} in, but {2}, so the two were '
|
||
'not compared -- a tolerance borrowed from somewhere else is '
|
||
'not the one this comparison needs{3}'
|
||
.format(required_spacing_in, max(gaps),
|
||
tolerance_why or 'no tolerance was resolved', tail))
|
||
widest = max(gaps)
|
||
allowance = ' (tolerance {0})'.format(tolerance_why or tolerance_in)
|
||
if widest > float(required_spacing_in) + float(tolerance_in):
|
||
return 'FAIL', (
|
||
'the widest gap between connectors is {0} in and the design '
|
||
'calls for {1} in on centre; tighter groups at the ends are '
|
||
'normal and are not counted against this{2}{3}'
|
||
.format(widest, required_spacing_in, tail, allowance))
|
||
return 'OK', (
|
||
'{0} connector(s), widest gap {1} in against the {2} in on '
|
||
'centre the design calls for{3}{4}'
|
||
.format(count, widest, required_spacing_in, tail, allowance))
|
||
return 'OK', ('{0} connector(s) against the {1} the design calls for{2}'
|
||
.format(count, required, tail))
|
||
|
||
|
||
def connector_edge_status(edges_in, minimum_in, source, mark=None):
|
||
"""5.10: no connector closer to an edge than its product allows.
|
||
|
||
`source` is 'product' or 'plant' and it is IN THE MESSAGE: "6 inches"
|
||
and "6 inches for this product" are different claims, and a reader
|
||
deciding whether to move a pin needs to know which one he was handed.
|
||
That is 6.2's lesson carried one part along.
|
||
|
||
A missing minimum is (None, reason) -- never a pass, and never another
|
||
product's number.
|
||
"""
|
||
measured = [e for e in (edges_in or []) if e is not None]
|
||
if not measured:
|
||
return None, ('no connector position could be measured on this '
|
||
'piece, so no edge distance was checked')
|
||
if minimum_in is None:
|
||
return None, ('no minimum edge distance is published for {0}, so how '
|
||
'close to an edge these {1} connector(s) may sit is not '
|
||
'written down anywhere and nothing is claimed. The '
|
||
'closest measured is {2} in'
|
||
.format(mark or 'this connector', len(measured),
|
||
min(measured)))
|
||
close = sorted([e for e in measured if e < float(minimum_in)])
|
||
if source == 'product':
|
||
basis = 'the published minimum for {0}'.format(mark)
|
||
else:
|
||
basis = ('a PLANT-WIDE minimum, which is NOT this product own '
|
||
'number').replace('product own', "product's own")
|
||
if not close:
|
||
return 'OK', ('every connector clears {0} in ({1}); closest is {2} in'
|
||
.format(minimum_in, basis, min(measured)))
|
||
return 'WARN', (
|
||
'{0} connector(s) sit closer to an edge than {1} in ({2}): {3}'
|
||
.format(len(close), minimum_in, basis,
|
||
', '.join(['{0} in'.format(e) for e in close])))
|
||
|
||
|
||
# =========================================================================
|
||
# SOLID ZONES AND INSULATION RECESSES -- master rows 5.11 to 5.15
|
||
# =========================================================================
|
||
#
|
||
# Owner, 2026-08-31, having asked for it before: "没有 solid zone 相关的检查。
|
||
# lifter 自带 solid zone,没检查这些 solid zone 是不是被加入了 assembly,是不是
|
||
# 被显示在 ticket 上面,hatch pattern 对不对。这些都没有啊" -- and then "不光是
|
||
# solid zone,insulation recess 也得加上。你看看 page 2 里面的那些 hatch
|
||
# pattern 就知道了。"
|
||
#
|
||
# THE MEASUREMENTS THESE ARE BUILT ON, all bridge-verified 2026-08-31 and
|
||
# quoted here because a number in a comment is checkable and a number in a
|
||
# commit message is not:
|
||
#
|
||
# * every recognised lifter on an insulated piece nests EXACTLY ONE solid
|
||
# zone, reachable by `GetSubComponentIds()` -- 192 of 192 across the 27
|
||
# insulated pieces. There are 82 standalone zones at openings besides.
|
||
# * only 21 of those 192 nested zones are assembly members; 17 of the 27
|
||
# pieces have ZERO members.
|
||
# * 0 of 192 render on any ticket view. The insulation layout view shows
|
||
# only the standalone ones.
|
||
# * the layout view is ASSEMBLY-SCOPED, so membership is the visibility
|
||
# mechanism. THAT LAST SENTENCE IS AN INFERENCE, not a measurement, and
|
||
# `solid_zone_ticket_status` says so in its own finding rather than
|
||
# letting it pass as a read.
|
||
# * the `SOLID ZONE` view filter on the insulation view template overrides
|
||
# the surface foreground pattern to TRIANG -- the RECESS pattern -- so
|
||
# a model solid zone renders with the wrong hatch today. Nested and
|
||
# standalone zones share ONE TYPE, so fixing visibility alone would add
|
||
# eight more wrongly-hatched zones per piece rather than none. That is
|
||
# why 5.12 and 5.13 are separate rows.
|
||
|
||
|
||
def solid_zone_membership_status(zones, lifters_without_zone=0,
|
||
lifters_unread=0):
|
||
"""5.11: every lifter's nested solid zone is a member of the assembly.
|
||
|
||
`zones` is one dict per nested zone: `member` True / False / None, where
|
||
None is "membership could not be read". Returns (severity, message), or
|
||
(None, reason) when nothing could be judged at all.
|
||
|
||
WHY NOT-A-MEMBER IS A FAIL. A zone outside the assembly is not on the
|
||
ticket: the layout view is assembly-scoped, so the shop is handed a
|
||
drawing of a piece whose solid zones are invisible, and the insert it
|
||
exists for looks as though it pulls against insulation. That is a defect
|
||
in what gets built, not a drafting untidiness.
|
||
|
||
A ZONE WHOSE MEMBERSHIP COULD NOT BE READ IS NAMED IN THE SAME FINDING
|
||
and never in a second one. `checklist_states` keeps the LAST finding per
|
||
code, so a second `SOLID-ZONE-ASM` would silently replace the first and
|
||
the row would print half the answer -- 6.7's lesson, and it is cheaper
|
||
to obey than to rediscover.
|
||
"""
|
||
rows = list(zones or [])
|
||
if not rows:
|
||
return None, (
|
||
'no solid zone was found nested in any lifter on this piece, so '
|
||
'there was no membership to judge -- which is a different '
|
||
'statement from "every zone is a member" and is never printed as '
|
||
'one')
|
||
outside = [z for z in rows if z.get('member') is False]
|
||
unread = [z for z in rows if z.get('member') is None]
|
||
inside = [z for z in rows if z.get('member') is True]
|
||
tail = ''
|
||
if unread:
|
||
tail += ('; {0} zone(s) whose assembly membership could not be read '
|
||
'were NOT judged either way'.format(len(unread)))
|
||
if lifters_unread:
|
||
tail += ('; {0} lifter(s) could not be read, so a zone nested in one '
|
||
'of them would not have been seen'.format(lifters_unread))
|
||
if lifters_without_zone:
|
||
tail += ('; {0} lifter(s) nest no solid zone at all -- see the '
|
||
'separate SOLID-ZONE-ABSENT finding, which is a different '
|
||
'defect'.format(lifters_without_zone))
|
||
if outside:
|
||
return 'FAIL', (
|
||
'{0} of {1} solid zone(s) nested in this piece\'s lifters are NOT '
|
||
'members of its assembly, so they are not on the ticket: the '
|
||
'layout view is assembly-scoped, and the shop is shown insulation '
|
||
'where the insert actually pulls against concrete{2}'
|
||
.format(len(outside), len(rows), tail))
|
||
if not inside:
|
||
return None, (
|
||
'none of the {0} nested solid zone(s) on this piece had a '
|
||
'readable assembly membership, so nothing was judged{1}'
|
||
.format(len(rows), tail))
|
||
return 'OK', (
|
||
'all {0} solid zone(s) nested in this piece\'s lifters are members of '
|
||
'its assembly{1}'.format(len(inside), tail))
|
||
|
||
|
||
def solid_zone_absent_status(lifters_without_zone, lifters_total):
|
||
"""5.11's second code: a lifter that nests NO solid zone at all.
|
||
|
||
ITS OWN FINDING, ON ITS OWN CODE, AND ZERO TODAY. Measured 2026-08-31 it
|
||
is 0 of 192 -- and it is built anyway, because the day it is not zero it
|
||
is a WORSE defect than any membership question: there is no zone to add
|
||
to the assembly, so the repair is in the model and not on the ticket.
|
||
Folding it into the membership sentence would have made the two look
|
||
like one problem with one fix.
|
||
|
||
Returns None when there is nothing to report -- the caller emits no
|
||
finding, and 5.11 is set by the membership code alone.
|
||
"""
|
||
if not lifters_without_zone:
|
||
return None
|
||
return 'FAIL', (
|
||
'{0} of {1} lifter(s) on this insulated piece nest NO solid zone at '
|
||
'all. This is not a ticket problem and adding the lifter to the '
|
||
'assembly will not fix it: there is no block of concrete for the '
|
||
'insert to pull against, so the model is what has to change'
|
||
.format(lifters_without_zone, lifters_total or lifters_without_zone))
|
||
|
||
|
||
def solid_zone_ticket_status(zones, view_name, members_are_visible=True):
|
||
"""5.12: the nested solid zones appear on the insulation layout view.
|
||
|
||
HOW THIS IS DECIDED, SAID OUT LOUD IN THE FINDING. The layout view is
|
||
assembly-scoped, so a zone that is an assembly member reaches it and one
|
||
that is not cannot. That relationship is an INFERENCE from how the view
|
||
is built, not a rendered-geometry read, and the message says so -- a
|
||
reader who is told "this zone is not on the ticket" is owed the basis,
|
||
because the repair (add it to the assembly) follows from the basis and
|
||
not from the verdict.
|
||
|
||
`members_are_visible` is the escape hatch for the day somebody measures
|
||
the rendered view directly: pass the real answer and this stops
|
||
inferring. It is not a default anybody should change casually.
|
||
"""
|
||
rows = list(zones or [])
|
||
if not view_name:
|
||
return None, (
|
||
'the insulation layout view is not named for this plant, so '
|
||
'there was nowhere to look for these solid zones')
|
||
if not rows:
|
||
return None, (
|
||
'no solid zone was found nested in any lifter on this piece, so '
|
||
'none could be looked for on {0}'.format(view_name))
|
||
if not members_are_visible:
|
||
return None, (
|
||
'whether an assembly member reaches {0} was not established on '
|
||
'this run, so nothing is claimed about what the ticket shows'
|
||
.format(view_name))
|
||
unread = [z for z in rows if z.get('member') is None]
|
||
shown = [z for z in rows if z.get('member') is True]
|
||
missing = [z for z in rows if z.get('member') is False]
|
||
tail = ''
|
||
if unread:
|
||
tail = ('; {0} zone(s) whose membership could not be read were not '
|
||
'judged'.format(len(unread)))
|
||
if missing:
|
||
return 'FAIL', (
|
||
'{0} of {1} solid zone(s) do not appear on {2}. INFERRED, and '
|
||
'the basis matters for the repair: that view is assembly-scoped, '
|
||
'so a zone that is not a member of the assembly cannot be drawn '
|
||
'on it -- adding it to the assembly is what puts it on the '
|
||
'ticket{3}'.format(len(missing), len(rows), view_name, tail))
|
||
if not shown:
|
||
return None, (
|
||
'no solid zone on this piece had a readable membership, so what '
|
||
'{0} shows could not be inferred{1}'.format(view_name, tail))
|
||
return 'OK', (
|
||
'all {0} nested solid zone(s) reach {1} -- INFERRED from assembly '
|
||
'membership, which is what that view is scoped on{2}'
|
||
.format(len(shown), view_name, tail))
|
||
|
||
|
||
def hatch_status(code_label, rendered, expected, expected_source, why,
|
||
count=None):
|
||
"""5.13 / 5.15: a rendered hatch against the legend's own swatch.
|
||
|
||
(severity, message), or (None, reason). BOTH PATTERNS AND BOTH
|
||
PROVENANCES ARE IN THE MESSAGE, always. "The hatch is wrong" is not
|
||
actionable; "it draws TRIANG and the legend's own swatch for a solid
|
||
zone is ANSI35" tells a reader which of the two to go and change, and
|
||
lets him disagree with the tool if the legend is what is wrong.
|
||
"""
|
||
if expected is None:
|
||
return None, why
|
||
if rendered is None:
|
||
return None, (
|
||
'the pattern {0} actually renders with could not be read on this '
|
||
'run, so it was not compared against the {1} ({2})'
|
||
.format(code_label, expected, why))
|
||
basis = ('this ticket\'s own legend' if expected_source == 'legend'
|
||
else 'the plant layer\'s copy of the legend')
|
||
where = ' on {0} element(s)'.format(count) if count else ''
|
||
if _text(rendered).strip().upper() == _text(expected).strip().upper():
|
||
return 'OK', (
|
||
'{0} draws {1}{2}, which is what {3} shows'
|
||
.format(code_label, rendered, where, basis))
|
||
return 'FAIL', (
|
||
'{0} draws {1}{2}, and {3} shows {4}. {5}'
|
||
.format(code_label, rendered, where, basis, expected, why))
|
||
|
||
|
||
def recess_presence_status(regions_by_type, design_requirement,
|
||
modelled_path_note=None):
|
||
"""5.14: recess regions present where the design calls for them.
|
||
|
||
SCOPED HONESTLY AND IT STANDS DOWN TODAY. `design_requirement` is what
|
||
the design says this piece should have, and NOTHING PARSES IT: the
|
||
recesses are drafted regions with no model counterpart, and the design
|
||
summary has no line anybody has taught a parser to read. So the row
|
||
reports what it found, names what it could not compare it against, and
|
||
returns (None, reason).
|
||
|
||
THAT IS STILL WORTH THE ROW, and the reason is the architecture's own:
|
||
the inventory is real and it is written down, the unblocker is named in
|
||
the sentence a reader actually sees, and the day either half arrives
|
||
this becomes a comparison with no new plumbing. A presence count printed
|
||
as agreement would be the alternative, and it would be a green row over
|
||
a question nobody asked.
|
||
"""
|
||
found = dict((k, v) for (k, v) in (regions_by_type or {}).items() if v)
|
||
inventory = ', '.join(['{0} x{1}'.format(k, found[k])
|
||
for k in sorted(found)]) or 'none'
|
||
if design_requirement:
|
||
wanted = dict(design_requirement)
|
||
missing = [k for k in sorted(wanted)
|
||
if wanted[k] and not found.get(k)]
|
||
if missing:
|
||
return 'FAIL', (
|
||
'the design calls for {0} and the ticket draws no region of '
|
||
'that type (it draws: {1})'
|
||
.format(', '.join(missing), inventory))
|
||
return 'OK', (
|
||
'every recess the design calls for is drawn on the ticket ({0})'
|
||
.format(inventory))
|
||
tail = ' {0}'.format(modelled_path_note) if modelled_path_note else ''
|
||
if not found:
|
||
# ZERO IS THE WHOLE JOB'S ANSWER TODAY, and it must not read as
|
||
# "judged and correct". Measured 2026-08-31 once the gather landed:
|
||
# NO recess region is placed on any ticket view in this job. The
|
||
# only four that exist are LEGEND SWATCHES, on two different legend
|
||
# views, and the gather excludes legend views for exactly that
|
||
# reason -- counting them would make every job look like it drew
|
||
# two recesses.
|
||
#
|
||
# AND THERE IS NO POSITIVE SPECIMEN. Nothing in this job draws a
|
||
# recess, so this reader has never been shown one it got right, and
|
||
# it cannot tell "this job draws no recesses" from "this job forgot
|
||
# them". The sentence says so rather than implying the first.
|
||
tail += (' NOTHING WAS FOUND, AND THAT IS NOT A CLEAN RESULT: no '
|
||
'recess region is drawn on any ticket view of this piece. '
|
||
'The legend views are excluded from this count on purpose '
|
||
'-- their swatches are pictures of a recess, not recesses '
|
||
'-- so a zero here means the ticket draws none. This '
|
||
'reader has never seen a job that DOES draw one, so it '
|
||
'cannot tell a piece that needs no recess from a piece '
|
||
'whose recesses were forgotten, and it claims neither.')
|
||
return None, (
|
||
'this ticket draws {0} recess region(s) [{1}], and NOTHING WAS '
|
||
'COMPARED: recesses are drafted regions with no model counterpart, '
|
||
'and no design-side source states which recesses this piece should '
|
||
'have -- so a presence count is an inventory, not a judgement, and '
|
||
'is not printed as one. Two unblockers, either of which turns this '
|
||
'into a real comparison: a design-summary reader that states the '
|
||
'recess requirement, or a ruling that the recesses are modelled '
|
||
'after all.{2}'
|
||
.format(sum(found.values()) or 0, inventory, tail))
|
||
|
||
|
||
def recess_size_caveat():
|
||
"""The sentence 5.15 carries about a question that is not its own.
|
||
|
||
The legend's TEXT says 1" and 2"; the region TYPE NAMES say 2in and 3in.
|
||
Both are on the same sheet, the owner has been asked, and this row does
|
||
not decide it. Kept as its own function so the wording is identical
|
||
everywhere it appears and so a reader grepping for it finds one place.
|
||
"""
|
||
return ('NOTE, and not this row\'s question: the legend\'s text says 1" '
|
||
'and 2" while these region type names say 2in and 3in. This row '
|
||
'compares PATTERNS, which have an exact answer; the size '
|
||
'discrepancy is a question for the owner and he has been asked')
|
||
|
||
|
||
_MARK_TOKEN_RE = None
|
||
|
||
|
||
def mark_tokens(text):
|
||
"""The bar-mark-shaped tokens inside a printed call-out, uppercased.
|
||
|
||
`SR310 (E.L.)` -> ['SR310']. The face designation in parentheses is
|
||
dropped because it is a FACE, not a part: measured on the live model,
|
||
every printed bar call-out on the specimen ticket is a bare mark plus
|
||
one of `(F.F.)` / `(N.F.)` / `(E.L.)` / `(S.I.F.)` / `(E.S.)`.
|
||
|
||
A token is one to three letters followed by digits -- the shape of
|
||
every mark this plant prints (`SR310`, `P106`, `SP126`) -- matched as
|
||
a WHOLE token so `SR1` inside `SR1101` is never mistaken for a mark of
|
||
its own. The comparison it feeds is symmetrical: the same function
|
||
reduces both sides, so a shape it does not recognise goes missing from
|
||
BOTH and produces silence, never a false finding.
|
||
"""
|
||
global _MARK_TOKEN_RE
|
||
if _MARK_TOKEN_RE is None:
|
||
import re
|
||
_MARK_TOKEN_RE = re.compile(r'\b([A-Z]{1,3}\d+[A-Z]?\d*)\b')
|
||
if not text:
|
||
return []
|
||
out = []
|
||
for token in _MARK_TOKEN_RE.findall(clean_text(text).upper()):
|
||
if token not in out:
|
||
out.append(token)
|
||
return out
|
||
|
||
|
||
_PART_NUMBER_RE = None
|
||
|
||
|
||
def part_numbers_in(text):
|
||
"""The part numbers inside a NAME, uppercased. `mark_tokens`' sibling.
|
||
|
||
WHY THIS IS NOT `mark_tokens`, AND WHY THE DIFFERENCE IS EXACTLY ONE
|
||
`\b`. `mark_tokens` reads a printed CALL-OUT, where the part number is a
|
||
whole word (`SR310 (E.L.)`) and the word boundary is what stops `SR1`
|
||
being read out of `SR1101`. This reads a family NAME, where the part
|
||
number is embedded after an underscore -- and in regex `_` is a WORD
|
||
character, so `\bP76\b` never matches `<vendor>_EMBED_P76`. That is the
|
||
same blindness that let the scrub gate miss `PREFIX_TERM` for months
|
||
(PROJECT.md 3.4), and it is why these are two functions with two regexes
|
||
rather than one shared by both.
|
||
|
||
`check_bed_plan_reverse` has done it this way since 08-28 for the same
|
||
reason; this is that technique named, so the third caller does not
|
||
reinvent it with a `\b` in it.
|
||
"""
|
||
global _PART_NUMBER_RE
|
||
if _PART_NUMBER_RE is None:
|
||
import re
|
||
_PART_NUMBER_RE = re.compile(r'([A-Z]{1,3}\d+[A-Z]?\d*)')
|
||
if not text:
|
||
return []
|
||
out = []
|
||
for token in _PART_NUMBER_RE.findall(_text(text).upper()):
|
||
if token not in out:
|
||
out.append(token)
|
||
return out
|
||
|
||
|
||
def _round_ft(value):
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return round(float(value), 3)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def invisible_chars(texts):
|
||
"""Checklist 8.2: no invisible characters in ticket text.
|
||
|
||
clean_text strips U+200B..U+200F, U+FEFF, U+00AD, U+2028/U+2029 at
|
||
|
||
clean_text strips U+200B..U+200F, U+FEFF, U+00AD, U+2028/U+2029 at
|
||
the scrape boundary -- so if any survive to the checked values, the
|
||
boundary was bypassed (a parameter read directly, a view text
|
||
scraped without cleaning). This check makes that visible instead of
|
||
silent (measured once: a lone U+200E in a parameter reached a
|
||
report and crashed a tool that printed it). Returns (severity,
|
||
message).
|
||
"""
|
||
bad = []
|
||
for t in (texts or []):
|
||
if t is None:
|
||
continue
|
||
for ch in t:
|
||
o = ord(ch)
|
||
if 0x200B <= o <= 0x200F or o in (0xFEFF, 0xAD, 0x2028,
|
||
0x2029):
|
||
bad.append((ch, hex(o)))
|
||
break
|
||
if not bad:
|
||
return 'OK', 'no invisible characters in ticket text'
|
||
return 'WARN', ('invisible character(s) {0} present in ticket text '
|
||
'-- clean_text was bypassed somewhere'
|
||
.format(', '.join(sorted(set(b[1] for b in bad)))))
|
||
|
||
|
||
def sheet_name_matches(mark, sheet_names):
|
||
"""Checklist 1.1: every sheet named after this piece actually names it.
|
||
|
||
The piece's mark and the sheet's Name, compared case-insensitively
|
||
with whitespace collapsed -- 'WP133 ' and 'wp133' both count. This
|
||
is the simplest of the four 'not implemented' checks: it compares
|
||
two values the model already carries (Claude 08-28: pure code work,
|
||
no judgement). Returns (severity, message).
|
||
"""
|
||
if not sheet_names:
|
||
return 'FAIL', 'no sheet found for this piece'
|
||
def norm(s):
|
||
return ' '.join((s or '').upper().split())
|
||
m = norm(mark)
|
||
bad = [s for s in sheet_names if norm(s) != m]
|
||
if not bad:
|
||
return 'OK', '{0} sheet(s) name this piece'.format(len(sheet_names))
|
||
return 'WARN', ('sheet(s) named differently from the piece mark: '
|
||
'{0}'.format(', '.join(bad)))
|
||
|
||
|
||
# ------------------------------------------------- ticket-set uniqueness (1.3)
|
||
def ticket_set_status(sheet_names):
|
||
"""Checklist 1.3: exactly one ticket set per piece.
|
||
|
||
A ticket is one or more sheets all named after the piece mark. In the
|
||
two-sheet form the sheets carry '1 OF 2' / '2 OF 2' in their NAME; a
|
||
single-sheet ticket is '1 OF 1'. sheet_names is the names of every
|
||
sheet found for this piece's mark.
|
||
|
||
Returns (severity, message):
|
||
'OK' -- exactly one complete set.
|
||
'WARN' -- sheets exist but the set is not perfect: an unmatched
|
||
page number (a '2 OF 2' without its '1 OF 2'), or an
|
||
ambiguous multi-set (two '1 OF 2's).
|
||
'FAIL' -- no sheets at all (orphan piece: the model has this piece
|
||
but nobody drew its ticket). 'FAIL' is safe here: an
|
||
assembly with no ticket is a definite miss, not a
|
||
judgement call.
|
||
Raises nothing. A piece with no sheets is reported, never silent --
|
||
the checklist exists to make exactly that visible.
|
||
"""
|
||
if not sheet_names:
|
||
return 'FAIL', 'no sheet named after this piece -- nobody drew it'
|
||
import re as _re
|
||
pages = []
|
||
for name in sheet_names:
|
||
m = _re.match(r'.*\b(\d+)\s+OF\s+(\d+)\b', name, _re.IGNORECASE)
|
||
if m:
|
||
pages.append((int(m.group(1)), int(m.group(2))))
|
||
if not pages:
|
||
# NO page markers in the input. The criterion source (SheetNumber
|
||
# 'N OF M') is absent, so NOTHING was judged -- and 'OK' here
|
||
# would be the exact 'ran and said fine while seeing nothing'
|
||
# failure (Claude 08-28: the old bug returned OK on ['WP133',
|
||
# 'WP133']). Names alone cannot prove a single set; say so as
|
||
# WARN, never OK.
|
||
return 'WARN', ('{0} sheet(s) found but none carries a page '
|
||
'marker (expect "1 OF 2" style) -- the ticket set '
|
||
'could not be judged'.format(len(sheet_names)))
|
||
total = pages[0][1]
|
||
if any(p[1] != total for p in pages):
|
||
return 'WARN', ('sheet page markers disagree (mix of {0} and '
|
||
'{1})'.format(sorted(set(p[1] for p in pages))))
|
||
needed = set(range(1, total + 1))
|
||
have = set(p[0] for p in pages)
|
||
if have == needed and len(pages) == total:
|
||
return 'OK', '{0}-sheet ticket'.format(total)
|
||
if have != needed:
|
||
missing = sorted(needed - have)
|
||
return 'WARN', ('ticket set incomplete: page(s) {0} missing from '
|
||
'the {1} sheets found'.format(missing, len(pages)))
|
||
# have == needed but duplicates: two '1 OF 2's etc.
|
||
dupes = [p[0] for p in pages if pages.count(p) > 1]
|
||
return 'WARN', ('duplicate page(s) {0} -- more than one ticket set for '
|
||
'this piece?'.format(sorted(set(dupes))))
|
||
|
||
|
||
# --------------------------------------- placed view names vs design (1.4)
|
||
#
|
||
# WHAT THIS CHECK IS AND IS NOT (read before changing it).
|
||
#
|
||
# Recorded plant knowledge says a VIEW NAME DOES NOT IDENTIFY A DESIGN --
|
||
# the design number is the sheet number, and a view may be called anything.
|
||
# This check does not contradict that; it depends on it. It never uses a
|
||
# view name to decide which design a ticket is for (1.2 does that, from the
|
||
# sheet's design-number parameter). It only reports a view name that CARRIES
|
||
# A DESIGN LABEL OF ITS OWN and labels itself with the wrong design.
|
||
#
|
||
# The case that produced it: a view named
|
||
# 'TICKET NOTE - WP2- STRAND JACKING FORCE'
|
||
# is placed on 100 sheets, every one of them a WP1 piece. Reading the
|
||
# content settled it -- '0.70 fpu = 28.9 kips' is WP1's own number, so the
|
||
# NOTE IS RIGHT AND THE NAME IS A LEFTOVER. That is the whole finding, and
|
||
# it is why this is WARN and never FAIL: **never infer the content is wrong
|
||
# from the name**. What it protects against is the next person reading the
|
||
# stale label and believing it -- either trusting a WP2 note on a WP1 sheet,
|
||
# or deleting a correct note because its name looked foreign.
|
||
#
|
||
# Only a token whose LETTER PREFIX matches this design's is compared. A view
|
||
# called 'Elevation Top 2' would otherwise reduce to 'TOP2' and be reported
|
||
# as a foreign design. Same-prefix-only is a deliberate bias towards silence:
|
||
# it cannot manufacture a finding out of an ordinary view name, and it can
|
||
# miss a label from an entirely different piece family. For a hygiene warning
|
||
# that is the right way round.
|
||
|
||
def view_name_design_status(design_key, view_names, token_re):
|
||
"""Checklist 1.4: no placed view labels itself with another design.
|
||
|
||
design_key -- this ticket's design, already reduced (designspec.
|
||
design_key). Reduced means PNL1.0 and PNL1 are one key.
|
||
view_names -- names of every view placed on the ticket's sheets.
|
||
token_re -- UNANCHORED pattern for a design token embedded anywhere
|
||
in a name, two groups (letters, number), same shape as
|
||
nomenclature.design_number_re. Plant knowledge.
|
||
|
||
Returns (severity, message), or None when it could not judge -- no
|
||
design key, no pattern, or no view names. None is NOT a pass: the
|
||
caller must turn it into NOT RUN with the reason.
|
||
"""
|
||
import re as _re
|
||
if not design_key or not token_re or not view_names:
|
||
return None
|
||
try:
|
||
rx = _re.compile(token_re)
|
||
except Exception:
|
||
return None
|
||
key = design_key.strip().upper()
|
||
m = _re.match(r'^([A-Za-z]+)', key)
|
||
if not m:
|
||
return None
|
||
prefix = m.group(1).upper()
|
||
|
||
bad = []
|
||
labelled = 0
|
||
for name in view_names:
|
||
if not name:
|
||
continue
|
||
found = None
|
||
for hit in rx.finditer(name):
|
||
groups = [g for g in hit.groups() if g] or [hit.group(0)]
|
||
token = ''.join([g.strip() for g in groups]).upper()
|
||
tp = _re.match(r'^([A-Za-z]+)', token)
|
||
if not tp or tp.group(1).upper() != prefix:
|
||
continue # not a label from this design family
|
||
found = token
|
||
if token != key:
|
||
break # a wrong one beats a right one
|
||
if found is None:
|
||
continue # name carries no design label -- fine
|
||
labelled += 1
|
||
if found != key:
|
||
bad.append((name, found))
|
||
|
||
if not labelled:
|
||
return ('OK', 'no placed view labels itself with a design number '
|
||
'({0} views read)'.format(len(view_names)))
|
||
if not bad:
|
||
return ('OK', '{0} of {1} placed view name(s) carry design {2} and '
|
||
'all of them agree'.format(labelled, len(view_names), key))
|
||
listed = ', '.join(['{0} (says {1})'.format(n, t) for (n, t) in bad])
|
||
return ('WARN', 'placed view name(s) label another design -- this '
|
||
'ticket is {0}: {1}. The NAME is the finding; do not infer the '
|
||
'note content is wrong from it.'.format(key, listed))
|
||
|
||
|
||
# ------------------------------------ note views that say nothing (6.5)
|
||
#
|
||
# WHAT IS DETERMINISTIC HERE AND WHAT IS NOT.
|
||
#
|
||
# A ticket's DUNNAGE / ERECTION / PICKING-STRIPPING views print a heading
|
||
# and, underneath it, the instruction. On the audited pieces those views
|
||
# contained THEIR OWN TITLE AND NOTHING ELSE -- a heading printed over an
|
||
# empty region, which reads on the sheet exactly like an instruction that
|
||
# was given.
|
||
#
|
||
# 'Is there anything in this view besides its heading' is a counting
|
||
# question the API answers exactly, and that is all this function decides.
|
||
# Content that is present but WRONG, stale, or says nothing useful ('N/A',
|
||
# a leftover line) is a different question, it needs a reader, and this
|
||
# function deliberately does not pretend to answer it -- a check that
|
||
# passed such a view silently would be worse than no check.
|
||
#
|
||
# Graphics count as content. The checklist called this out and it is not a
|
||
# detail: if these views draw their instruction instead of writing it,
|
||
# counting text notes alone reports every one of them as empty. So the
|
||
# caller passes a count of the OTHER view-owned elements (detail lines,
|
||
# filled regions, detail components, symbols, dimensions) and a view with
|
||
# any of them is content-bearing, though the report says the content was
|
||
# graphics so a reader knows what was and was not read.
|
||
|
||
# WHAT A DECLARED NOTE VIEW ACTUALLY IS, measured over 1105 sheets
|
||
# ----------------------------------------------------------------
|
||
# The first version of this read a FLAT LIST of view names and reported
|
||
# "not placed" for every declared name it did not find. That model does
|
||
# not survive the real project, for two separate reasons:
|
||
#
|
||
# VARIANTS SUBSTITUTE FOR ONE ANOTHER. Eight panel note views exist;
|
||
# a ticket places THREE of them -- one dunnage, one erection, one
|
||
# picking. 15 sheets carry PICKING/STRIPPING (V3) rather than the
|
||
# plain one, and a flat list would have called all 15 defective.
|
||
#
|
||
# THE PRODUCT TYPE VARIES. A slab ticket has NO erection note at all
|
||
# and a beam ticket uses a different family of views entirely. There
|
||
# is no one list that is right for every piece.
|
||
#
|
||
# Both mistakes produce the same failure, and it is the failure this
|
||
# project keeps meeting: a steady drip of findings that are not defects.
|
||
# People learn to ignore the output, and then the real findings go
|
||
# unread with the rest. A check that cries wolf is worse than absent.
|
||
#
|
||
# So a declared entry is a GROUP OF ALTERNATIVES: the ticket must carry
|
||
# AT LEAST ONE of them, and whatever it carries must say something. Only
|
||
# a group with none of its alternatives placed can report "not placed",
|
||
# and it reports the GROUP's name -- listing four picking variants as
|
||
# four separate missing views would be four times the noise for one
|
||
# fact.
|
||
#
|
||
# Product type is handled by SETS. Each set is one product family, and
|
||
# only the set that matches this ticket is required of it -- a panel
|
||
# ticket is never asked for the slab family's views. The match is the
|
||
# ticket's own evidence: the set with the most groups actually placed
|
||
# wins. When no declared set matches at all, that is NOT a finding and
|
||
# not a pass -- it is an undeclared product type, and the check says so
|
||
# and reports NOT RUN.
|
||
|
||
def _norm_view_name(s):
|
||
return ' '.join((s or '').upper().split())
|
||
|
||
|
||
_GROUP_VIEW_KEYS = ('views', 'alternatives', 'names', 'any_of')
|
||
|
||
|
||
def _group_option_map(value):
|
||
"""A group written as an OPTION MAP -> (alternatives, optional).
|
||
|
||
Returns None when `value` is not one, so the caller can go on treating
|
||
it the way it always did. The marker is the presence of a views key --
|
||
a map without one is a map OF groups, not one group's options.
|
||
|
||
ERECTION: <- the group
|
||
optional: true
|
||
views:
|
||
- TICKET NOTE - SLAB - ERECTION
|
||
|
||
MERGE BEHAVIOUR of this new shape (this is the part that bites):
|
||
`optional` and `views` are keys of a MAP, so the map DEEP-MERGES
|
||
across knowledge layers -- a job may set `optional: false` on a group
|
||
without restating its views, and vice versa. `views` is itself a
|
||
LIST, so a job naming views REPLACES the layer below it, exactly as
|
||
the bare list form always did. Restate the whole list when overriding.
|
||
"""
|
||
if not isinstance(value, dict):
|
||
return None
|
||
alts = None
|
||
for key in _GROUP_VIEW_KEYS:
|
||
if key in value:
|
||
alts = value[key]
|
||
break
|
||
if alts is None:
|
||
return None
|
||
if not isinstance(alts, (list, tuple)):
|
||
alts = [alts]
|
||
return list(alts), bool(value.get('optional'))
|
||
|
||
|
||
def _normalise_groups(node):
|
||
"""One declaration -> [{'label', 'alternatives', 'optional'}].
|
||
|
||
Accepts, in order of how much it says:
|
||
|
||
'DUNNAGE' one group, one name
|
||
[A, B] two groups of one (TODAY'S SHAPE)
|
||
[A, [B1, B2]] a group of one, a group of two
|
||
[{ERECTION: [B1, B2]}] the same, with the group named
|
||
{DUNNAGE: A, ERECTION: [B1, B2]} groups keyed by name
|
||
{ERECTION: {optional: true, views: [B1]}} an OPTIONAL group
|
||
|
||
The bare-string list is the shape the existing tests and PLANT_STARTER
|
||
use and it means exactly what it meant before: one group per name, so
|
||
every name is still required. Every shape but the last yields
|
||
optional=False, so nothing that was required stops being required.
|
||
|
||
An OPTIONAL group is one the manufacturer's standard itself calls
|
||
conditional (ROTATE TO EDGE), or one a family carries only sometimes.
|
||
Placed, it is checked for content like any other group. Absent, it is
|
||
SILENCE -- not a finding. Declaring such a note as an ordinary group
|
||
would report it 'not placed' on every ticket that legitimately does
|
||
not need it, which is the standing false positive this whole
|
||
mechanism exists to prevent.
|
||
"""
|
||
if node is None or node == '' or node == [] or node == {}:
|
||
return []
|
||
if isinstance(node, dict):
|
||
out = []
|
||
for label in sorted(node.keys()):
|
||
out.extend(_normalise_groups([{label: node[label]}]))
|
||
return out
|
||
if not isinstance(node, (list, tuple)):
|
||
node = [node]
|
||
|
||
groups = []
|
||
for item in node:
|
||
label, alts, optional = None, None, False
|
||
if isinstance(item, dict):
|
||
opts = _group_option_map(item)
|
||
if opts is None and len(item) != 1:
|
||
# A multi-key map here is a map of groups, not one group.
|
||
groups.extend(_normalise_groups(item))
|
||
continue
|
||
if opts is not None:
|
||
# An option map standing on its own has no name of its own.
|
||
alts, optional = opts
|
||
else:
|
||
label = list(item.keys())[0]
|
||
alts = item[label]
|
||
opts = _group_option_map(alts)
|
||
if opts is not None:
|
||
alts, optional = opts
|
||
elif not isinstance(alts, (list, tuple)):
|
||
alts = [alts]
|
||
elif isinstance(item, (list, tuple)):
|
||
alts = list(item)
|
||
else:
|
||
alts = [item]
|
||
alts = [a for a in alts if a not in (None, '')]
|
||
if not alts:
|
||
continue
|
||
if label in (None, ''):
|
||
label = alts[0] # a bare group is named by its first name
|
||
groups.append({'label': label, 'alternatives': list(alts),
|
||
'optional': optional})
|
||
return groups
|
||
|
||
|
||
def instruction_view_groups(notes, placed_names):
|
||
"""Which note-view groups this ticket is answerable for.
|
||
|
||
notes -- the plant knowledge `notes` sub-tree
|
||
placed_names -- the view names actually on this ticket's sheets
|
||
|
||
Returns (groups, reason). `groups` is a list of
|
||
{'label', 'alternatives', 'optional', 'set',
|
||
'placed': [names actually there]}.
|
||
An OPTIONAL group with nothing placed is not returned at all -- see
|
||
_normalise_groups. It still counts as evidence when choosing which
|
||
product family this ticket belongs to, because carrying a family's
|
||
conditional note is as good a sign of the family as any other.
|
||
A truthy `reason` means NOTHING COULD BE JUDGED and says why; it is
|
||
never a pass. Both are never truthy at once.
|
||
"""
|
||
notes = notes if isinstance(notes, dict) else {}
|
||
placed = set([_norm_view_name(n) for n in (placed_names or []) if n])
|
||
|
||
def found(group):
|
||
return [a for a in group['alternatives']
|
||
if _norm_view_name(a) in placed]
|
||
|
||
always = _normalise_groups(notes.get('instruction_views'))
|
||
for g in always:
|
||
g['set'] = None
|
||
|
||
sets = notes.get('instruction_view_sets')
|
||
sets = sets if isinstance(sets, dict) else {}
|
||
chosen, best = None, 0
|
||
for name in sorted(sets.keys()):
|
||
groups = _normalise_groups(sets[name])
|
||
for g in groups:
|
||
g['set'] = name
|
||
score = len([g for g in groups if found(g)])
|
||
if score > best:
|
||
chosen, best = groups, score
|
||
|
||
if not always and not sets:
|
||
return [], ('no notes.instruction_views in plant knowledge -- the '
|
||
'DUNNAGE / ERECTION / PICKING note views are named by '
|
||
'the plant, so nothing was looked at')
|
||
if not always and chosen is None:
|
||
return [], ('none of the declared note-view families '
|
||
'({0}) has a single view on this ticket -- this piece\'s '
|
||
'product type is not declared yet, so its notes were '
|
||
'not judged'.format(', '.join(sorted(sets.keys()))))
|
||
|
||
out = always + (chosen or [])
|
||
kept = []
|
||
for g in out:
|
||
g['placed'] = found(g)
|
||
if g.get('optional') and not g['placed']:
|
||
# A conditional note nobody placed is not a missing note. It
|
||
# is dropped here rather than reported, so it can never become
|
||
# the steady false positive that teaches people to stop
|
||
# reading this check. Placed, it stays and is judged for
|
||
# content exactly like a required group.
|
||
continue
|
||
kept.append(g)
|
||
return kept, None
|
||
|
||
|
||
def _heading_like(text, view_name):
|
||
"""Whether a note's text is just the view's own heading.
|
||
|
||
'PICKING/STRIPPING', 'PICKING - STRIPPING' and 'PICKING STRIPPING' are
|
||
one heading; so is a heading with a trailing colon. Compared on letters
|
||
and digits only, which collapses all of that without a synonym list.
|
||
"""
|
||
import re as _re
|
||
def bare(s):
|
||
return _re.sub(r'[^A-Z0-9]', '', (s or '').upper())
|
||
t, v = bare(text), bare(view_name)
|
||
if not t:
|
||
return True # blank text is not content either
|
||
if not v:
|
||
return False
|
||
return t == v or t in v or v in t
|
||
|
||
|
||
def _row_label(row):
|
||
"""How a placed group is named in the message.
|
||
|
||
The group's name, plus the view actually found when that differs --
|
||
'PICKING/STRIPPING (TICKET NOTE - PANEL - PICKING/STRIPPING (V3))'.
|
||
Without it a reader is told a group is empty and has no way to know
|
||
which of its four variants to go open.
|
||
"""
|
||
name = row.get('name') or '?'
|
||
matched = row.get('matched')
|
||
if matched and _norm_view_name(matched) != _norm_view_name(name):
|
||
return '{0} ({1})'.format(name, matched)
|
||
return name
|
||
|
||
|
||
def note_view_content_status(views):
|
||
"""Checklist 6.5: the note views actually say something.
|
||
|
||
views -- one dict per note view GROUP the plant declared, in the order
|
||
declared:
|
||
{'name': the group's name,
|
||
'placed': bool -- one of the group's alternatives is on
|
||
the ticket's sheets,
|
||
'matched': the alternative actually found, when it is not
|
||
the group's own name (optional),
|
||
'texts': [text-note strings in the view],
|
||
'other_elements': int -- view-owned elements that are not
|
||
text notes (graphics count as content)}
|
||
|
||
A group whose ticket carries TWO of its alternatives gets one
|
||
row per placed view: a placed note that prints a heading over
|
||
nothing is the finding whether or not a sibling covers it.
|
||
|
||
Returns (severity, message), or None when there was nothing to judge
|
||
(no declared note views). None is NOT a pass.
|
||
"""
|
||
if not views:
|
||
return None
|
||
missing, empty, graphics_only, ok = [], [], [], []
|
||
for v in views:
|
||
name = v.get('name') or '?'
|
||
shown = _row_label(v)
|
||
if not v.get('placed'):
|
||
missing.append(name)
|
||
continue
|
||
body = [t for t in (v.get('texts') or [])
|
||
if not _heading_like(t, v.get('matched') or v.get('name'))]
|
||
others = int(v.get('other_elements') or 0)
|
||
if body:
|
||
# A passing view needs naming once, not twice; a failing one is
|
||
# a view somebody has to go open, so it gets the full address.
|
||
ok.append(v.get('matched') or name)
|
||
elif others > 0:
|
||
graphics_only.append(shown)
|
||
else:
|
||
empty.append(shown)
|
||
|
||
# One view can answer two groups (a combined PICKING/ERECTION note), so
|
||
# it lands in a list twice. Say it once.
|
||
def once(names):
|
||
seen, out = set(), []
|
||
for n in names:
|
||
if n not in seen:
|
||
seen.add(n)
|
||
out.append(n)
|
||
return out
|
||
|
||
missing, empty = once(missing), once(empty)
|
||
graphics_only, ok = once(graphics_only), once(ok)
|
||
|
||
parts = []
|
||
if empty:
|
||
parts.append('{0} print(s) a heading and nothing else'
|
||
.format(', '.join(empty)))
|
||
if missing:
|
||
parts.append('{0} not placed on this ticket'
|
||
.format(', '.join(missing)))
|
||
if graphics_only:
|
||
parts.append('{0} carr(y) graphics but no text -- content is '
|
||
'there, a reader must judge it'
|
||
.format(', '.join(graphics_only)))
|
||
if not empty and not missing:
|
||
if graphics_only:
|
||
return ('OK', 'every note view carries content; ' + parts[0])
|
||
return ('OK', 'every note view carries text ({0})'
|
||
.format(', '.join(ok)))
|
||
return 'WARN', '; '.join(parts)
|
||
|
||
|
||
# ==================================================================
|
||
# THE CHECKLIST AS A PAGE
|
||
# ==================================================================
|
||
#
|
||
# Owner, 2026-08-30, having read a real run:
|
||
#
|
||
# "大量的配置信息也都往后放,并且默认折叠。我最在乎的是那张检查的表。
|
||
# 那张检查的表放在最前面,并且字儿可以再大一点。OK用绿色,
|
||
# WARN,FOR_ENGINEER用黄色,NOTRUN,FAIL用红色。"
|
||
#
|
||
# The checklist table IS the product. Everything else -- the design intent
|
||
# being enforced, the scope, the knowledge path, the issue tally -- is
|
||
# appendix, and appendix goes after the table and starts folded
|
||
# (`vibe.foldreport`, which owns that rule for every report this tool
|
||
# prints).
|
||
#
|
||
# WHAT THE COLOUR GROUPING SAYS, AND WHY IT IS NOT SOFTENED
|
||
# ---------------------------------------------------------
|
||
# NOT RUN is RED, in the same bucket as FAIL. That is the owner's grouping
|
||
# and it is this codebase's founding principle expressed as colour: a check
|
||
# that did not run tells you nothing about the piece, so it must not look
|
||
# calmer than one that failed. Silence must never read as a pass. Anyone
|
||
# tempted to grey NOT RUN out "because it is not really an error" is about
|
||
# to rebuild the exact failure this tool exists to prevent -- a clean-looking
|
||
# page over an unasked question.
|
||
#
|
||
# OK green judged, and it is fine
|
||
# WARN, FOR ENGINEER yellow a person has to look
|
||
# NOT RUN, FAIL red the piece is wrong, or nobody knows
|
||
# MUTED grey it ran, it is not a failure, and this
|
||
# job asked for it quietly
|
||
#
|
||
# GREY IS NOT AN EXCEPTION TO THE PARAGRAPH ABOVE, it is the reason that
|
||
# paragraph can hold. The pressure to grey NOT RUN out is real -- a job
|
||
# where 3.3 can never apply prints a red row forever and the reader learns
|
||
# to skip red rows, which is the failure this table exists to prevent
|
||
# arriving by the back door. MUTED is the pressure valve: a DECLARED,
|
||
# per-job, reasoned state that is its own word, so NOT RUN stays red and a
|
||
# quiet row is quiet because somebody said so in the job file and signed
|
||
# for it. `state_color` still sends an unknown word to red.
|
||
#
|
||
# COLOUR IS PRESENTATION; THE STATE WORD IS THE DATA. Nothing below changes
|
||
# a status word except `apply_muting`, which changes it to MUTED and says
|
||
# in the note what it really was.
|
||
|
||
GREEN_STATES = ('OK',)
|
||
YELLOW_STATES = ('WARN', 'FOR ENGINEER')
|
||
RED_STATES = ('NOT RUN', 'FAIL')
|
||
|
||
# WHY THE WORD IS `MUTED` AND NOT `NOT RUN`, so nobody "simplifies" it.
|
||
#
|
||
# The owner asked for a grey NOT RUN. A row that ran and passed cannot print
|
||
# NOT RUN: it is a false statement, in a report whose entire premise is that
|
||
# it never makes one, and a real NOT RUN -- a check that COULD not be asked,
|
||
# which is a defect in the tooling -- would then be one styling rule away
|
||
# from invisible. Two different answers must not share a word.
|
||
#
|
||
# It is a distinct value precisely so the label is a one-line change if he
|
||
# overrules; the question is with him in writing. `configform.MUTED_STATE`
|
||
# is the other half of the seam and
|
||
# `test_configform.py::test_the_muted_state_seam_with_ticketpage` holds the
|
||
# two equal.
|
||
MUTED_STATE = 'MUTED'
|
||
GREY_STATES = (MUTED_STATE,)
|
||
|
||
# NOT APPLICABLE -- the fifth state, and why it is not any of the other four.
|
||
#
|
||
# Owner, 2026-08-31: "ticketqc 检查的内容需要进行分级。根据 plant 和 ticket 的
|
||
# 具体内容进行分级 ... 让 ticketqc 工具读取厂知识来得知啥需要检查,啥不用检查。"
|
||
#
|
||
# A DECLARED INAPPLICABILITY: this question does not apply to this kind of
|
||
# piece at this plant. It is VOLUNTARY and STATED (a dormant check is
|
||
# involuntary and is cured by supplying a fact), it is PLANT-level and
|
||
# conditional on the piece (a mute is per job and unconditional), and the
|
||
# check DOES NOT RUN (a mute runs it and turns the volume down). Grades A-D
|
||
# say who answers the question; this says the question was never asked
|
||
# because it is not a question about this piece.
|
||
#
|
||
# IT GETS ITS OWN COLOUR, NOT A REUSE. Grey already means "it ran and this
|
||
# job asked for it quietly", and a reader who has to work out which kind of
|
||
# quiet a grey row is has stopped trusting the table. Blue is calm on
|
||
# purpose -- nothing is wrong -- and calm is only ever safe here because the
|
||
# declaration is loud somewhere else: the section under the table names
|
||
# every row switched off, its reason, and the layer that declared it, and it
|
||
# opens itself. A mistaken applicability rule is a silently-disabled safety
|
||
# check, so it may never be the quietest thing on the page.
|
||
#
|
||
# `judged` is FALSE. Nothing produced a verdict on this piece. A consumer
|
||
# asking "is this row settled" must get no for exactly the same reason it
|
||
# gets no for NOT RUN -- the difference between them is WHY nobody asked,
|
||
# and that difference is the state word and the reason, not a truth value.
|
||
NOT_APPLICABLE_STATE = 'NOT APPLICABLE'
|
||
BLUE_STATES = (NOT_APPLICABLE_STATE,)
|
||
|
||
# OUT OF SCOPE -- the state `vibe.scope` produces. It is not printed on the
|
||
# checklist table today (scope is declared per PIECE, not per row: an
|
||
# out-of-scope piece is never run, so it has no rows), and no colour group
|
||
# below claims it, so `state_color` sends it to red like any other word this
|
||
# table does not paint. It is named here because the MACHINE-READABLE side
|
||
# has to be able to say it: `scope.py`'s founding rule is that out of scope
|
||
# is a third state and never an absence, and a run file that could only spell
|
||
# OK / WARN / FAIL / NOT RUN would have to collapse it into one of those.
|
||
OUT_OF_SCOPE_STATE = 'OUT OF SCOPE'
|
||
|
||
|
||
# ------------------------------------------- the state vocabulary, as DATA
|
||
#
|
||
# WHY THIS EXISTS AT ALL: colour is not the state. NOT RUN and FAIL are both
|
||
# red -- deliberately, see the block above -- and OK and MUTED differ by a
|
||
# grey nobody can read out of a JSON file. A consumer handed only a colour, or
|
||
# only a truthy/falsey "passed", cannot tell "judged, and it is fine" from
|
||
# "nobody asked", which is the exact failure this whole tool exists to
|
||
# prevent. So every state carries a STABLE MACHINE KEY of its own, distinct
|
||
# from every other, and says whether anything actually produced a verdict.
|
||
#
|
||
# `judged` is the answer to "did this row's question get asked and answered":
|
||
#
|
||
# OK / WARN / FAIL True -- a check ran and said something
|
||
# MUTED True -- the check RAN; only its volume was turned
|
||
# down, and `apply_muting` keeps what it
|
||
# really reported in the note
|
||
# NOT RUN False -- nobody could ask it
|
||
# FOR ENGINEER False -- nothing computes it, by design
|
||
# OUT OF SCOPE False -- it was declared somebody else's
|
||
#
|
||
# A word this table does not know is NOT judged and does not get a key that
|
||
# looks like one -- same reasoning as `state_color`'s red fallthrough.
|
||
|
||
# (status word, machine key, did anything produce a verdict)
|
||
STATE_VOCABULARY = (
|
||
('OK', 'ok', True),
|
||
('WARN', 'warn', True),
|
||
('FAIL', 'fail', True),
|
||
('NOT RUN', 'not_run', False),
|
||
('FOR ENGINEER', 'for_engineer', False),
|
||
(MUTED_STATE, 'muted', True),
|
||
(OUT_OF_SCOPE_STATE, 'out_of_scope', False),
|
||
# Declared not to apply to this kind of piece. NOT judged: the check did
|
||
# not run, and the reason it did not is a ruling, not an answer.
|
||
(NOT_APPLICABLE_STATE, 'not_applicable', False),
|
||
)
|
||
|
||
UNKNOWN_STATE_KEY = 'unrecognised'
|
||
|
||
|
||
def _state_entry(status):
|
||
word = (status or '').strip().upper()
|
||
for (state, key, judged) in STATE_VOCABULARY:
|
||
if state.upper() == word:
|
||
return (state, key, judged)
|
||
return (None, UNKNOWN_STATE_KEY, False)
|
||
|
||
|
||
def state_key(status):
|
||
"""A stable machine name for a status word, distinct for every state.
|
||
|
||
This is what a file writes instead of a colour. `state_key('NOT RUN')`
|
||
and `state_key('FAIL')` differ even though `state_color` sends both to
|
||
red, and an unrecognised word gets its own key rather than borrowing
|
||
one -- never `ok`, and never a key that reads like a verdict.
|
||
"""
|
||
return _state_entry(status)[1]
|
||
|
||
|
||
def state_is_judged(status):
|
||
"""True when this state means a verdict was actually produced.
|
||
|
||
False for NOT RUN, FOR ENGINEER, OUT OF SCOPE and anything unrecognised.
|
||
A consumer that wants "is this row settled" asks this rather than
|
||
inferring it from a missing finding, which is the inference this table
|
||
was built to make impossible.
|
||
"""
|
||
return _state_entry(status)[2]
|
||
|
||
|
||
# Picked to survive an IE-engine WebBrowser on a white panel: saturated
|
||
# enough to tell apart at a glance, dark enough to read text on.
|
||
STATE_COLORS = {
|
||
'green': ('#1c6b2e', '#e5f4e8'), # (ink, ground)
|
||
'yellow': ('#7a5600', '#fdf3d6'),
|
||
'red': ('#a41d1d', '#fbe6e6'),
|
||
'grey': ('#555555', '#f0f0f0'),
|
||
# Its own entry, never a reuse of grey. Dark enough to read, plainly not
|
||
# the muted grey and plainly not the yellow that means somebody must
|
||
# look. See NOT_APPLICABLE_STATE for why a calm colour is allowed here.
|
||
'blue': ('#1f4b73', '#e7eef6'),
|
||
}
|
||
|
||
|
||
def state_color(status):
|
||
"""'green' / 'yellow' / 'grey' / 'red' for a checklist status word.
|
||
|
||
An unknown status is 'red', never 'grey': a state this table does not
|
||
recognise is a state nobody has judged, and the safe reading of an
|
||
unrecognised state is the loud one. Grey is reached only by a word this
|
||
function knows -- a DECLARED mute -- which is what keeps the red
|
||
fallthrough meaning what it says.
|
||
"""
|
||
word = (status or '').strip().upper()
|
||
if word in tuple([s.upper() for s in GREEN_STATES]):
|
||
return 'green'
|
||
if word in tuple([s.upper() for s in YELLOW_STATES]):
|
||
return 'yellow'
|
||
if word in tuple([s.upper() for s in GREY_STATES]):
|
||
return 'grey'
|
||
if word in tuple([s.upper() for s in BLUE_STATES]):
|
||
return 'blue'
|
||
return 'red'
|
||
|
||
|
||
# ------------------------------------------------------- muting, per job
|
||
#
|
||
# Owner, 2026-08-30:
|
||
#
|
||
# "让我可以注释掉一些check。比如说这个项目里面3.3我就想注释掉。
|
||
# 因为所有的panel都不会有这个,一直看着NOT_RUN我也糟心。"
|
||
#
|
||
# A job where no piece can ever have the thing 3.3 asks about prints NOT RUN
|
||
# on 3.3 for every piece forever, and a permanent red row trains the reader
|
||
# to ignore red rows. That is a real cost, so muting is real -- but it is
|
||
# DECLARED, per job, WITH A REASON, and what it removes is still counted:
|
||
#
|
||
# * the key is `checks.muted`, a MAP (so the knowledge layers deep-merge
|
||
# it and a job can mute one item without restating the others);
|
||
# * the reason is MANDATORY and an empty one is refused by
|
||
# `configmodel.validate` -- a row that vanished for a reason nobody
|
||
# wrote down is indistinguishable from a bug;
|
||
# * a muted item STAYS ON THE TABLE, in grey, saying what it really
|
||
# reported, and is also LISTED by number and reason in a folded
|
||
# section. (It used to leave the table; muting is now a volume control
|
||
# and not a switch -- the check runs either way and only its
|
||
# presentation changes. See `apply_muting` and `MUTED_STATE`.)
|
||
# Folded is not gone, and quiet is not gone either;
|
||
# * MUTING CANNOT HIDE A FAIL. See `apply_muting`.
|
||
|
||
|
||
def muted_checks(know, getter=None, resolver=None, plant=None):
|
||
"""`checks.muted` off the merged knowledge, as {number: reason}.
|
||
|
||
`getter` is knowledge.get, injected so this stays Revit-free and
|
||
testable with a plain dict. Entries with no reason are kept and
|
||
reported (see `muting_lines`) rather than dropped: a mute the config UI
|
||
let through without a reason is a thing the reader must be told about,
|
||
not a thing this function quietly un-mutes.
|
||
"""
|
||
if getter is None:
|
||
from vibe import knowledge as K
|
||
getter = K.get
|
||
raw = getter(know or {}, 'checks.muted', {}) or {}
|
||
if not isinstance(raw, dict):
|
||
return {}
|
||
out = {}
|
||
for key, reason in raw.items():
|
||
# `_text`, not `str`: the reason is a sentence the owner typed into
|
||
# the job file, and it survives to the reports only if it is text
|
||
# from the moment it is read.
|
||
written = _text(key).strip()
|
||
stable = written
|
||
if resolver is not None:
|
||
got, _why, problem = resolver(know or {}, written, plant=plant,
|
||
ids=CHECKLIST_IDS)
|
||
if got:
|
||
stable = got
|
||
elif problem:
|
||
# KEPT UNDER WHAT WAS WRITTEN, NOT DROPPED. `apply_muting`
|
||
# already reports a number that mutes nothing, by name, and
|
||
# that is the right place for it: a mute the tool silently
|
||
# discarded is a row the owner believes is quiet and is not.
|
||
stable = written
|
||
out[stable] = ('' if reason is None else _text(reason).strip())
|
||
return out
|
||
|
||
|
||
def muted_reference_problems(know, resolver=None, plant=None, getter=None):
|
||
"""[(what was written, why it could not be placed)].
|
||
|
||
Separate from `muted_checks` so the resolution stays a pure lookup and
|
||
the reporting stays a list -- a resolver that both answered and reported
|
||
would have to be called twice or return a pair nobody wants.
|
||
"""
|
||
if resolver is None:
|
||
from vibe import knowledge as K
|
||
resolver = K.resolve_check_reference
|
||
if getter is None:
|
||
from vibe import knowledge as K
|
||
getter = K.get
|
||
raw = getter(know or {}, 'checks.muted', {}) or {}
|
||
if not isinstance(raw, dict):
|
||
return []
|
||
out = []
|
||
for key in sorted(raw):
|
||
written = _text(key).strip()
|
||
got, _why, problem = resolver(know or {}, written, plant=plant,
|
||
ids=CHECKLIST_IDS)
|
||
if not got and problem:
|
||
out.append((written, problem))
|
||
return out
|
||
|
||
|
||
def apply_muting(rows, muted):
|
||
"""(rows to print, [(number, name, reason, why_kept)]).
|
||
|
||
A MUTED ITEM STAYS ON THE TABLE. It is repainted, not removed: the
|
||
status word becomes `MUTED_STATE`, which `state_color` prints grey, and
|
||
the note says what the check really reported. Muting is a volume
|
||
control -- the check still runs, every time; only its presentation
|
||
changes.
|
||
|
||
That is a change from what this used to do (it dropped the row and left
|
||
only a folded line), and the reason is the same reason the state word
|
||
is MUTED rather than NOT RUN: a row that vanishes cannot say it ran, so
|
||
a reader cannot tell a muted-and-fine item from an item nobody built.
|
||
Grey on the table says both at once -- it was asked, and this job asked
|
||
for the answer quietly.
|
||
|
||
A FAIL IS NEVER SOFTENED. Muting exists to stop a permanent NOT RUN
|
||
from wearing the reader down; it is not a way to make a defect quiet,
|
||
and a job file that could grey out a failure is a job file that will
|
||
eventually be used to. So a FAIL keeps its own word and its own red,
|
||
and the muted section says the mute was declined and why.
|
||
|
||
(Judgement call, Claude 2026-08-30, flagged in the report: the brief
|
||
said "a muted check leaves the table entirely", and this narrows that
|
||
by one case. The owner can overrule it in one line -- delete the
|
||
`is_fail` branch.)
|
||
|
||
Findings are NOT touched here, by anything, ever. This function takes
|
||
rows and returns rows. The findings list the button hands to the JSON
|
||
writer and to the clean/failing/warning tally never passes through it,
|
||
so a muted check that failed still fails the ticket, still lands in
|
||
`qc-run.json`, and still counts in the header line.
|
||
"""
|
||
muted = muted or {}
|
||
if not muted:
|
||
return list(rows or []), []
|
||
known = set([item[0] for item in CHECKLIST_ITEMS])
|
||
kept, notes = [], []
|
||
seen = set()
|
||
for row in (rows or []):
|
||
num = row[0]
|
||
if num not in muted:
|
||
kept.append(row)
|
||
continue
|
||
seen.add(num)
|
||
# The RAW reason, empty string and all. `muting_lines` substitutes a
|
||
# placeholder for display; substituting it here would hide the empty
|
||
# reason from `muting_problem`, which is the one thing that has to
|
||
# see it.
|
||
reason = muted[num]
|
||
was = (row[3] or '').strip().upper()
|
||
# ALREADY SWITCHED OFF BY A DECLARED INAPPLICABILITY. Repainting it
|
||
# grey would replace "this question is not about this piece" with
|
||
# "this job asked for it quietly", which is a weaker and different
|
||
# claim, and it would hide that two mechanisms are doing one job. It
|
||
# is flagged rather than ignored: a job muting a row the plant has
|
||
# already declared inapplicable is a mute that should be deleted, or
|
||
# a plant rule that should not be there.
|
||
if was == NOT_APPLICABLE_STATE.upper():
|
||
kept.append(row)
|
||
notes.append((num, row[1], reason,
|
||
'THE MUTE DID NOTHING: this row is already '
|
||
'declared NOT APPLICABLE by a knowledge layer, so '
|
||
'the check does not run at all. One of the two '
|
||
'declarations is redundant -- see the '
|
||
'applicability section.'))
|
||
continue
|
||
is_fail = was == 'FAIL'
|
||
if is_fail:
|
||
kept.append(row)
|
||
notes.append((num, row[1], reason,
|
||
'REPORTED AT FULL VOLUME: this item FAILED on '
|
||
'this piece, and muting never softens a failure'))
|
||
else:
|
||
kept.append(_muted_row(row, reason))
|
||
# `why` stays None for a routine mute: `muting_problem` reads a
|
||
# non-empty `why` as "somebody has to look at the job file", and
|
||
# a mute that worked exactly as asked is not that.
|
||
notes.append((num, row[1], reason, None))
|
||
# A number in the job file that is not a checklist item at all. It
|
||
# mutes nothing, and a mute that silently does nothing is how somebody
|
||
# believes an item is off when it is on.
|
||
for num in sorted(muted):
|
||
if num in seen:
|
||
continue
|
||
if num not in known:
|
||
notes.append((num, '(not a checklist item)', muted[num] or '',
|
||
'THIS NUMBER MUTES NOTHING -- there is no '
|
||
'checklist item {0}'.format(num)))
|
||
return kept, notes
|
||
|
||
|
||
def _muted_row(row, reason):
|
||
"""One row repainted as MUTED, keeping what it really reported.
|
||
|
||
THE ORIGINAL STATUS GOES INTO THE NOTE, and that is the whole point of
|
||
repainting rather than removing. A grey row saying only "MUTED" has
|
||
thrown away the answer; a grey row saying "ran and reported OK, muted
|
||
for this job because ..." has merely stopped shouting it. Every other
|
||
field of the row -- and any extra field a caller carries past the
|
||
five this module names -- is passed through untouched.
|
||
"""
|
||
out = list(row)
|
||
was = (out[3] or '').strip() if len(out) > 3 else ''
|
||
note = out[4] if len(out) > 4 else None
|
||
while len(out) < 5:
|
||
out.append(None)
|
||
out[3] = MUTED_STATE
|
||
said = ('ran and reported {0}'.format(was) if was
|
||
else 'ran; it reported no status')
|
||
# THE MUTE SENTENCE IS THE OWNER'S OWN WORDS, in whichever language he
|
||
# wrote them in the job file, and it must reach every consumer whole --
|
||
# see `_text`, and the text report that dropped exactly this string's
|
||
# non-ASCII characters on 2026-08-31.
|
||
quiet = _text('{0} -- muted for this job{1}').format(
|
||
_text(said), (_text(': ') + _text(reason)) if reason else
|
||
_text(', and the job file gives no reason'))
|
||
if note:
|
||
quiet += _text('. ') + _text(note)
|
||
out[4] = quiet
|
||
return tuple(out)
|
||
|
||
|
||
def muting_lines(notes):
|
||
"""The muted section's body: one line per muted item. Never a count."""
|
||
out = []
|
||
for (num, name, reason, why) in (notes or []):
|
||
line = '**{0}** {1} — {2}'.format(
|
||
num, name, reason or '(no reason given)')
|
||
if why:
|
||
line += ' — _{0}_'.format(why)
|
||
out.append(line)
|
||
return out
|
||
|
||
|
||
def muting_problem(notes):
|
||
"""A sentence when the muted section must open itself, else None.
|
||
|
||
Routine muting folds. A mute that was declined, a mute with no reason,
|
||
or a number that matches no item is not routine -- somebody has to fix
|
||
the job file, or read a failure they thought was off.
|
||
"""
|
||
if not notes:
|
||
return None
|
||
bad = [n for n in notes if n[3] or not n[2]]
|
||
if not bad:
|
||
return None
|
||
return ('{0} of the {1} muted entr(ies) need attention -- a mute was '
|
||
'declined, or has no reason, or names nothing'
|
||
.format(len(bad), len(notes)))
|
||
|
||
|
||
# ============================================================================
|
||
# DECLARED INAPPLICABILITY -- per plant, per kind of piece
|
||
# ============================================================================
|
||
#
|
||
# The knowledge shape, the classifier and the refusals all live in
|
||
# `vibe.knowledge` (`checks.not_applicable`, `classify_panel`,
|
||
# `check_not_applicable`). What lives HERE is the part that owns the rows:
|
||
# which checklist numbers a run switched off, how such a row is printed, and
|
||
# the statement the report has to carry once per piece.
|
||
#
|
||
# WHERE THE BOUNDARY WITH THE OTHER FOUR SITS, stated once so the next reader
|
||
# does not merge two of them:
|
||
#
|
||
# muting the check RUNS, the row goes grey, per JOB, presentation
|
||
# only. Use it for "I know, stop telling me".
|
||
# declared scope a whole PIECE is somebody else's. Per piece, never a row.
|
||
# dormancy the check WOULD run and cannot, because a fact is unset.
|
||
# Involuntary; cured by supplying the fact.
|
||
# grade A-D who answers the question.
|
||
# INAPPLICABILITY the question is not about this kind of piece. The check
|
||
# does NOT run. Declared, dated, and conditional on the
|
||
# piece.
|
||
#
|
||
# AND THE ONE THING IT MAY NEVER DO. A row that already has a FAIL keeps its
|
||
# FAIL, and the declaration is recorded as DECLINED -- exactly as a mute over
|
||
# a failure is. The case is real rather than theoretical: two rows can name
|
||
# the same check function (4.4 and 4.6 both name `check_strand_layout`), so a
|
||
# check runs for the row that still applies and its findings reach the row
|
||
# that does not. Silencing a finding that already exists is the one thing no
|
||
# switch in this file is allowed to do.
|
||
|
||
|
||
def applicability_decisions(know, panel_type=None, verified=False,
|
||
resolver=None, profile=None):
|
||
"""{row number: decision} for EVERY checklist row, from the knowledge.
|
||
|
||
`decision` is `knowledge.check_not_applicable`'s dict. Every row is
|
||
present, including the ones nothing declared -- a caller reading this as
|
||
"the rows in here are the switched-off ones" would be reading an absence
|
||
as a value, and `not_applicable` on each entry is the answer instead.
|
||
|
||
`resolver` is injected so this stays Revit-free and testable with a
|
||
plain dict, the same seam `muted_checks` uses for `knowledge.get`.
|
||
"""
|
||
if resolver is None:
|
||
from vibe import knowledge as K
|
||
resolver = K.check_not_applicable
|
||
profile = profile or {}
|
||
items = profile.get('items')
|
||
on_board = None
|
||
if profile.get('profile') and items is not None:
|
||
on_board = dict([(check, number) for (number, check) in items])
|
||
closed = (profile.get('unlisted') == 'not_applicable')
|
||
|
||
out = {}
|
||
for item in CHECKLIST_ITEMS:
|
||
stable = item[0]
|
||
if on_board is not None and stable not in on_board and closed:
|
||
out[stable] = {
|
||
'item': stable, 'not_applicable': True,
|
||
'condition': 'profile', 'source': 'profile',
|
||
'reason': ('the {0!r} checklist does not ask this question of '
|
||
'this kind of member. {1}'
|
||
.format(profile.get('profile'),
|
||
profile.get('unlisted_because') or
|
||
'No reason is recorded.')),
|
||
'since': profile.get('unlisted_since'),
|
||
'declared_by': profile.get('declared_by'),
|
||
'declared': True, 'problem': None, 'unaskable': False,
|
||
'only_types': [], 'layer': None, 'off_board': True}
|
||
continue
|
||
decision = resolver(know or {}, stable, panel_type=panel_type,
|
||
verified=verified)
|
||
decision['source'] = 'declaration'
|
||
# ASKED ANYWAY, AND THE PROFILE IS TOLD ON ITSELF. A board that does
|
||
# not name a check has not caught up with the code, and that is a
|
||
# fact about the YAML rather than about the piece.
|
||
decision['off_board'] = bool(on_board is not None and
|
||
stable not in on_board)
|
||
out[stable] = decision
|
||
return out
|
||
|
||
|
||
def off_board(decisions):
|
||
"""Master ids a profile never named but which were asked anyway.
|
||
|
||
A LIST FOR THE REPORT, NOT A FAILURE. The questions were asked -- the
|
||
safe direction -- and saying so is how the board gets updated instead of
|
||
quietly diverging from the code.
|
||
"""
|
||
decisions = decisions or {}
|
||
return [item[0] for item in CHECKLIST_ITEMS
|
||
if (decisions.get(item[0]) or {}).get('off_board')]
|
||
|
||
|
||
def switched_off(decisions):
|
||
"""Just the rows a decision actually switched off, in table order."""
|
||
decisions = decisions or {}
|
||
return [item[0] for item in CHECKLIST_ITEMS
|
||
if (decisions.get(item[0]) or {}).get('not_applicable')]
|
||
|
||
|
||
def applicability_problems(decisions):
|
||
"""Every refusal, in table order: [(row, sentence)].
|
||
|
||
A declaration that did not take effect has to be as visible as one that
|
||
did. The owner who wrote it believes that row is off; while the refusal
|
||
is unreported he has no way to find out it is on.
|
||
"""
|
||
decisions = decisions or {}
|
||
out = []
|
||
for item in CHECKLIST_ITEMS:
|
||
problem = (decisions.get(item[0]) or {}).get('problem')
|
||
if problem:
|
||
out.append((item[0], problem))
|
||
return out
|
||
|
||
|
||
def apply_applicability(rows, decisions):
|
||
"""(rows to print, [(num, name, reason, condition, since, by, layer, why)]).
|
||
|
||
A row declared not applicable is REPAINTED, not removed -- the same
|
||
ruling as muting, for the same reason: a row that vanishes cannot say
|
||
anything, and a reader cannot tell "this does not apply to an insulated
|
||
panel" from "nobody built this". The note carries the declared reason
|
||
verbatim and what the row would have been.
|
||
|
||
A FAIL IS NEVER SWITCHED OFF. See the block above.
|
||
"""
|
||
decisions = decisions or {}
|
||
kept, notes = [], []
|
||
for row in (rows or []):
|
||
num = row[0]
|
||
decision = decisions.get(num) or {}
|
||
# SCOPED TO A PANEL TYPE, ON A PIECE NOBODY COULD PLACE. Not
|
||
# applicable would claim the piece is not that type; OK would answer
|
||
# an insulated question about a piece that may not be one. Nobody
|
||
# could ask it, so it says so, in red -- and a FAIL that somehow
|
||
# exists still keeps its own word.
|
||
if decision.get('unaskable'):
|
||
was = (row[3] or '').strip().upper() if len(row) > 3 else ''
|
||
if was == 'FAIL':
|
||
kept.append(row)
|
||
continue
|
||
out = list(row) + [None] * max(0, 5 - len(row))
|
||
out[3] = 'NOT RUN'
|
||
out[4] = decision.get('reason') or out[4]
|
||
kept.append(tuple(out))
|
||
continue
|
||
if not decision.get('not_applicable'):
|
||
kept.append(row)
|
||
continue
|
||
was = (row[3] or '').strip() if len(row) > 3 else ''
|
||
reason = decision.get('reason')
|
||
entry = (num, row[1] if len(row) > 1 else None, reason,
|
||
decision.get('condition'), decision.get('since'),
|
||
decision.get('declared_by'), decision.get('layer'), None)
|
||
if was.upper() == 'FAIL':
|
||
kept.append(row)
|
||
notes.append(entry[:7] + (
|
||
'REPORTED AT FULL VOLUME: this row FAILED on this piece, so '
|
||
'the declaration that it does not apply was DECLINED. A check '
|
||
'declared inapplicable is not run -- this one ran because '
|
||
'another row needs the same check, and it found something. '
|
||
'Either the declaration is wrong or the finding is; nothing '
|
||
'here decides which, and it is not hidden while somebody '
|
||
'does.',))
|
||
continue
|
||
kept.append(_not_applicable_row(row, decision))
|
||
notes.append(entry)
|
||
return kept, notes
|
||
|
||
|
||
def _not_applicable_row(row, decision):
|
||
"""One row repainted as NOT APPLICABLE, keeping what it really said."""
|
||
out = list(row)
|
||
was = (out[3] or '').strip() if len(out) > 3 else ''
|
||
note = out[4] if len(out) > 4 else None
|
||
while len(out) < 5:
|
||
out.append(None)
|
||
out[3] = NOT_APPLICABLE_STATE
|
||
condition = decision.get('condition')
|
||
where = (_text('every piece this plant makes')
|
||
if condition == 'any'
|
||
else _text('a {0} piece').format(_text(condition)))
|
||
said = _text('declared not to apply to {0}').format(where)
|
||
reason = decision.get('reason')
|
||
if reason:
|
||
said += _text(': ') + _text(reason)
|
||
since = decision.get('since')
|
||
if since:
|
||
said += _text(' (declared {0}').format(_text(since))
|
||
by = decision.get('declared_by')
|
||
said += (_text(' by {0})').format(_text(by)) if by else _text(')'))
|
||
layer = decision.get('layer')
|
||
if layer:
|
||
said += _text(' [{0} layer]').format(_text(layer))
|
||
# WHAT IT WOULD HAVE BEEN, when anything got that far. Normally the check
|
||
# never ran and there is nothing to say; where a sibling row shares the
|
||
# check, the answer exists and throwing it away would be the muting
|
||
# mistake in a new coat.
|
||
if was and was.upper() not in ('NOT RUN', ''):
|
||
said += _text('. Before the declaration this row read {0}').format(
|
||
_text(was))
|
||
if note:
|
||
said += _text('. ') + _text(note)
|
||
out[4] = said
|
||
return tuple(out)
|
||
|
||
|
||
def profile_view(rows, profile=None):
|
||
"""Rows in the profile's ORDER, carrying the profile's DISPLAY NUMBER.
|
||
|
||
THE STABLE ID STAYS AT `row[0]` AND NOTHING MOVES IT. Every consumer
|
||
upstream and downstream keys on it -- `checklist_details`, the note fold
|
||
ids, `apply_muting`, `apply_applicability`, `qc-run.json` -- and the one
|
||
thing this whole design exists to prevent is a number that means one
|
||
question here and a different one there. The display number is APPENDED
|
||
at index 5, which is a field this module already promises to carry
|
||
through untouched (`_muted_row`).
|
||
|
||
So this function only ever REORDERS and LABELS. It cannot change a
|
||
state, drop a row, or add one.
|
||
|
||
ROWS THE BOARD DOES NOT NAME COME LAST, under their own master ids, and
|
||
`off_board` on the decision is what put a sentence about them on the
|
||
report. They are not dropped: a board that has not caught up with the
|
||
code must not be able to make a question disappear.
|
||
"""
|
||
rows = list(rows or [])
|
||
profile = profile or {}
|
||
items = profile.get('items')
|
||
if not profile.get('profile') or not items:
|
||
return rows
|
||
display = dict([(check, number) for (number, check) in items])
|
||
order = dict([(check, index) for (index, (_n, check))
|
||
in enumerate(items)])
|
||
tail = len(order)
|
||
|
||
def _key(row):
|
||
return order.get(row[0], tail)
|
||
|
||
out = []
|
||
for row in sorted(rows, key=_key):
|
||
got = list(row)
|
||
while len(got) < 6:
|
||
got.append(None)
|
||
got[5] = display.get(row[0])
|
||
out.append(tuple(got))
|
||
return out
|
||
|
||
|
||
def row_display(row):
|
||
"""(what to print in the # column, the master id) for one row.
|
||
|
||
One place, because three renderers and the JSON all have to agree about
|
||
which of the two numbers is which, and a row with no profile has only
|
||
one number and must not print it twice.
|
||
"""
|
||
stable = row[0] if row else None
|
||
shown = row[5] if (row and len(row) > 5 and row[5]) else None
|
||
return (shown or stable, stable)
|
||
|
||
|
||
def applicability_lines(notes):
|
||
"""The section's body: one line per row switched off. Never a count."""
|
||
out = []
|
||
for entry in (notes or []):
|
||
num, name, reason, condition, since, by, layer, why = (
|
||
list(entry) + [None] * 8)[:8]
|
||
line = '**{0}** {1} — not asked on {2} — {3}'.format(
|
||
num, name,
|
||
'any piece at this plant' if condition == 'any'
|
||
else '{0} pieces'.format(condition),
|
||
reason or '(no reason given)')
|
||
if since:
|
||
line += ' _(declared {0}{1})_'.format(
|
||
since, ', ' + by if by else '')
|
||
# THE LAYER, ALWAYS SAID. A reader who wants to argue with a
|
||
# switched-off row has to know which file to open, and "somewhere in
|
||
# the five layers" is not an answer. Where it could not be computed
|
||
# the line says that rather than leaving a gap that reads as "the
|
||
# plant", which is the guess a reader makes when nothing is said.
|
||
line += ' — declared in the **{0}** layer'.format(
|
||
layer or 'UNKNOWN (the layer files could not be re-read)')
|
||
if why:
|
||
line += ' — _{0}_'.format(why)
|
||
out.append(line)
|
||
return out
|
||
|
||
|
||
def applicability_problem(notes, problems=None):
|
||
"""A sentence when the section must open itself, else None.
|
||
|
||
IT OPENS WHENEVER ANYTHING WAS SWITCHED OFF, and that is the difference
|
||
from muting, which folds when it is tidy. A mute leaves the check
|
||
running; this stops it. A mistaken rule here is a safety check disabled
|
||
in silence, so the page says so every single run, and the declaration is
|
||
as loud as the scope line.
|
||
"""
|
||
notes = notes or []
|
||
problems = problems or []
|
||
if not notes and not problems:
|
||
return None
|
||
parts = []
|
||
if notes:
|
||
parts.append('{0} checklist row(s) were NOT ASKED on this run '
|
||
'because a knowledge layer declares they do not apply '
|
||
'({1}) -- read them, because a wrong rule here is a '
|
||
'safety check switched off'
|
||
.format(len(notes), ', '.join([n[0] for n in notes])))
|
||
if problems:
|
||
parts.append('{0} declaration(s) were REFUSED and their rows RAN '
|
||
'({1}) -- somebody believes those rows are off'
|
||
.format(len(problems),
|
||
', '.join([p[0] for p in problems])))
|
||
return '; '.join(parts)
|
||
|
||
|
||
def profile_line(mark, plant, member, profile, off_board_ids=None):
|
||
"""WHICH BOARD RAN, in one line, for one piece.
|
||
|
||
Which plant, which kind of member, which checklist, and on what basis --
|
||
the header half that says what the numbers in the table even mean. A
|
||
reader handed a renumbered table with no profile line has been shown a
|
||
checklist and not told whose.
|
||
"""
|
||
member = member or {}
|
||
profile = profile or {}
|
||
kind = member.get('member_kind')
|
||
name = profile.get('profile')
|
||
if name:
|
||
head = ('**{0}** — {1} checklist **{2}**'
|
||
.format(mark, plant or 'this plant',
|
||
profile.get('title') or name))
|
||
head += ' _(profile `{0}`, chosen on {1})_'.format(
|
||
name, (profile.get('basis') or 'nothing')
|
||
.replace('_', ' ').replace('+', ' and '))
|
||
else:
|
||
head = ('**{0}** — no plant checklist applies, so the FULL '
|
||
'master checklist ran under its own numbering'.format(mark))
|
||
head += ' · member kind: {0}'.format(
|
||
'**{0}**'.format(kind) if kind else '**not established**')
|
||
ids = list(off_board_ids or [])
|
||
if ids:
|
||
head += (' · **{0} question(s) this board does not name were '
|
||
'asked anyway** ({1}) — the board has not caught up '
|
||
'with the code'.format(len(ids), ', '.join(ids)))
|
||
why = member.get('why')
|
||
if why:
|
||
head += ' \n_{0}_'.format(why)
|
||
if profile.get('why'):
|
||
head += ' \n_{0}_'.format(profile['why'])
|
||
if profile.get('problem'):
|
||
head += ' \n_**{0}**_'.format(profile['problem'])
|
||
return head
|
||
|
||
|
||
def classification_line(mark, classification, notes):
|
||
"""WHICH PROFILE RAN, in one line, for one piece.
|
||
|
||
Required once per run per piece: what the piece was classified as, on
|
||
what basis, and how many rows the plant then declared do not apply. A
|
||
reader who cannot see the profile cannot audit the rows it removed --
|
||
and a report that shows the removals without the profile has asked him
|
||
to trust a classification he was never shown.
|
||
"""
|
||
classification = classification or {}
|
||
kind = classification.get('panel_type')
|
||
if kind:
|
||
head = '**{0}** classified as **{1}**'.format(mark, kind)
|
||
if not classification.get('verified'):
|
||
head += (' _(panel_types.detect_verified is false, so this '
|
||
'classification was NOT used to switch anything off)_')
|
||
else:
|
||
head = ('**{0}** NOT CLASSIFIED into a panel type, so the FULL '
|
||
'checklist ran'.format(mark))
|
||
if notes:
|
||
head += ' — {0} row(s) declared not applicable: {1}'.format(
|
||
len(notes), ', '.join([n[0] for n in notes]))
|
||
else:
|
||
head += ' — no row was declared not applicable'
|
||
why = classification.get('why')
|
||
if why:
|
||
head += ' \n_{0}_'.format(why)
|
||
return head
|
||
|
||
|
||
# ------------------------------------------- the lines behind a count (8.1)
|
||
#
|
||
# Owner, reading WP133: "都哪些lines not interpreted?" -- 8.1 said "4 line(s)
|
||
# of the design summary were not interpreted" and named three. A count the
|
||
# reader cannot resolve into the things counted is a prompt to go looking,
|
||
# not a report. `ticketchecks.check_spec_unresolved` now enumerates every
|
||
# line; this splits the enumeration back out so the page can print one per
|
||
# row instead of one very long cell.
|
||
|
||
# THERE IS NO NOTE CAP ANY MORE, AND NOTHING MAY PUT ONE BACK.
|
||
#
|
||
# `NOTE_CAP = 180` and `capped_note()` lived here. `checklist_states` ran
|
||
# every non-OK message through them, so the row tuple itself carried a
|
||
# stump -- and the row tuple is what the HTML table, the plain-text file and
|
||
# every other consumer are built from. Owner, 2026-08-30, on 4.5: "不要有截断,
|
||
# item不要折叠,需要一行就一行,需要两行就两行,但是note做成默认折叠显示跟item
|
||
# 一样的行数。" A cut note is worse than a long one, because the reader
|
||
# cannot tell whether the missing half said "and 3 more" or "and one of them
|
||
# is a FAIL".
|
||
#
|
||
# What replaced it is a VISUAL fold and nothing else: `note_cell_html` puts
|
||
# the whole note in the cell and clips the cell's height, so every character
|
||
# is in the document -- selectable, copyable, findable -- and the plain-text
|
||
# file prints all of it, wrapped, with no fold at all.
|
||
#
|
||
# If a cell ever looks too tall again, the answer is the fold, never a cap.
|
||
#
|
||
# THE ONE THING THE FOLD MUST NEVER HIDE: a file path.
|
||
#
|
||
# A note whose evidence is too long for a cell can hand the reader a FILE
|
||
# instead -- the interference check writes every clash pair to a CSV and puts
|
||
# the path here (see `ticketchecks.check_member_interference`). Two rules
|
||
# follow from that, and both are enforced below rather than trusted to
|
||
# whoever writes the next message:
|
||
#
|
||
# * the path goes LAST, behind this marker, and it is rendered OUTSIDE the
|
||
# fold, so a collapsed note still shows its link. A path cut in half is
|
||
# a dead link, and a dead link is worse than a long cell. (Measured on
|
||
# WP296, 2026-08-30 -- back when a cap existed:
|
||
# the old cap landed inside the word `mark SR612` -- `... [id 24086619
|
||
# mar...` -- so the row showed six counts and half of one piece of
|
||
# evidence.)
|
||
# * the VISIBLE TEXT stays the real Windows path, exactly as
|
||
# `specsave.link` and PanelLiftCheck do it, because the path is what gets
|
||
# copied into Explorer. Only the href gets the `file:///` form, and only
|
||
# in HTML -- the text file and the JSON carry the plain path.
|
||
NOTE_FILE_SEP = ' -- every pair, with marks and ids: '
|
||
|
||
|
||
def note_parts(note):
|
||
"""(prose, separator, path) for a note that ends in a file path.
|
||
|
||
('the whole note', '', '') when it does not -- so a caller can always
|
||
unpack three values and only has to test the middle one.
|
||
"""
|
||
text = _text(note or '')
|
||
head, sep, path = text.partition(NOTE_FILE_SEP)
|
||
if not sep or not path.strip() or '\n' in path:
|
||
return (text, '', '')
|
||
return (head, sep, path)
|
||
|
||
|
||
def note_href(path):
|
||
"""`file:///` for a Windows path -- `specsave.href` plus two characters.
|
||
|
||
Spelled again rather than imported: `specsave` reaches for
|
||
`vibe.specformat` at module scope and this module is the one that must
|
||
stay importable with nothing but the standard library.
|
||
|
||
The two characters `specsave.href` does not escape and this does:
|
||
|
||
* `#` ENDS A URL. Everything after it is a fragment, so one `#` in a
|
||
folder name silently truncates the link -- and the marks this job
|
||
talks in are written `mark#`, so a folder named after one is not a
|
||
far-fetched path.
|
||
* `%` would otherwise make the next two characters an escape.
|
||
|
||
A space is left alone, exactly as `specsave` and PanelLiftCheck leave it:
|
||
that form is what the IE control here has been opening all along, and
|
||
this is not the place to change it for every link in the tool at once.
|
||
"""
|
||
url = str(path or '').replace('\\', '/')
|
||
return 'file:///' + url.replace('%', '%25').replace('#', '%23')
|
||
|
||
|
||
# ------------------------------------------------ how tall a folded note is
|
||
#
|
||
# THE OWNER'S RULE, EXACTLY: "item不要折叠,需要一行就一行,需要两行就两行,但是
|
||
# note做成默认折叠显示跟item一样的行数。如果note需要40行,但是item只有两行,那么
|
||
# 默认显示前两行,然后可以打开折叠。" The item cell is never managed; the note
|
||
# cell collapses to the height its OWN row's item cell occupies.
|
||
#
|
||
# WHAT IS EXACT AND WHAT IS A GUESS, SAID PLAINLY.
|
||
#
|
||
# Exact: the note is never shortened, the fold is height-only, and a clipped
|
||
# note keeps every character in the document.
|
||
#
|
||
# A guess: HOW MANY LINES the item cell occupies. That is a rendered
|
||
# measurement -- it depends on the column width the browser chose and on the
|
||
# font it chose to do it in -- and this code runs before either exists.
|
||
# `ITEM_CHARS_PER_LINE` is an estimate for the item column at the panel's
|
||
# 11pt in a window nobody has measured. Being wrong costs a line of PREVIEW,
|
||
# never a character of content: too small and the reader opens the fold one
|
||
# row earlier, too large and the collapsed cell is a line taller than its
|
||
# item. Both are cosmetic, which is the only reason a guess is allowed here
|
||
# at all.
|
||
#
|
||
# NOTE_LINE_HEIGHT MUST MATCH the `line-height` in CHECKLIST_STYLE below --
|
||
# the fold's height is computed as lines x this, in `em`, so a style edit
|
||
# that changes one and not the other silently changes what "two lines" means.
|
||
ITEM_CHARS_PER_LINE = 46
|
||
NOTE_LINE_HEIGHT = 1.35
|
||
|
||
# When the note is not obviously longer than the item, it is not folded at
|
||
# all: a fold on a cell that fits is a control that does nothing. Deliberately
|
||
# generous -- an unfolded long-ish note is a tall row, and a tall row loses
|
||
# nothing.
|
||
NOTE_CHARS_PER_LINE = 62
|
||
|
||
|
||
def item_line_estimate(name, per_line=ITEM_CHARS_PER_LINE):
|
||
"""Roughly how many lines the item cell takes. Never fewer than one."""
|
||
text = str(name or '')
|
||
if not text:
|
||
return 1
|
||
return max(1, (len(text) + per_line - 1) // per_line)
|
||
|
||
|
||
# Owner, verbatim (2026-08-31): "notes太长的话默认折叠"
|
||
def note_folds(prose, item_lines, status):
|
||
"""Whether this note's cell collapses -- ON LENGTH ALONE, every status.
|
||
|
||
Owner, 2026-08-31, overruling the red exemption below: "if notes are too
|
||
long, they fold by default" -- long notes fold by default, full stop. No
|
||
status word is named, and none is exempted.
|
||
|
||
Until this ruling, FAIL and NOT RUN never folded here -- the reasoning
|
||
was that a red row is the one the reader must not have to click to read.
|
||
That reasoning did not survive contact with what red rows actually carry
|
||
now: row probes write full multi-clause reasons, shared-silence
|
||
cross-references ("the same missing input also left rows 3.1, 3.2..."),
|
||
and dated mute reasons -- walls of text, overwhelmingly on NOT RUN, which
|
||
is exactly the status that used to be exempt. The owner is looking at
|
||
those walls and the instruction is blanket: long notes fold, no
|
||
exception for colour.
|
||
|
||
What this does NOT change: `status` still colours the row (`state_color`
|
||
is untouched), a folded note is still WHOLLY IN THE DOM (`note_cell_html`
|
||
never shortens a character, only clips height), and the collapsed cell
|
||
still shows exactly `item_lines` lines before the reader clicks -- a red
|
||
row folds to the SAME number of visible lines its own item takes, never
|
||
to zero. Folding is not the truncation `NOTE_CAP` used to do and is not
|
||
hiding either; it is `foldreport.wtClip`'s max-height clip, unconditional
|
||
on status now.
|
||
"""
|
||
return len(str(prose or '')) > item_lines * NOTE_CHARS_PER_LINE
|
||
|
||
|
||
def note_fold_id(table_id, num):
|
||
"""A DOM id unique on a page that prints one table per ticket.
|
||
|
||
Every ticket's table repeats the same row numbers, so the number alone
|
||
would give twenty elements called `5.3` and one toggle would open
|
||
whichever the browser found first.
|
||
"""
|
||
raw = '{0}_{1}'.format(table_id, num)
|
||
keep = [c if (c.isalnum() or c == '_') else '_' for c in str(raw)]
|
||
return 'wt_ckn_' + ''.join(keep)
|
||
|
||
|
||
_TABLE_SEQ = [0]
|
||
|
||
|
||
def _next_table_id():
|
||
_TABLE_SEQ[0] += 1
|
||
return 't{0}'.format(_TABLE_SEQ[0])
|
||
|
||
# Item number -> the finding code whose message enumerates. Kept as a table
|
||
# rather than a guess about message shape, so a message reworded tomorrow
|
||
# cannot silently stop producing details.
|
||
ENUMERATING_CODES = {
|
||
'SPEC-UNRESOLVED': '8.1',
|
||
}
|
||
|
||
|
||
def checklist_details(findings, sep=' | '):
|
||
"""{item number: [line, ...]} -- what a counted row actually counted.
|
||
|
||
Only for findings that enumerate, and only when there is more than one
|
||
thing to enumerate; a single line already fits the cell.
|
||
"""
|
||
out = {}
|
||
for f in (findings or []):
|
||
num = ENUMERATING_CODES.get(getattr(f, 'code', None))
|
||
if num is None:
|
||
continue
|
||
message = getattr(f, 'message', '') or ''
|
||
head, _found, tail = message.partition(': ')
|
||
if not tail:
|
||
continue
|
||
lines = [part.strip() for part in tail.split(sep)]
|
||
lines = [line for line in lines if line]
|
||
if len(lines) < 2:
|
||
continue
|
||
out[num] = lines
|
||
return out
|
||
|
||
|
||
# Owner, verbatim (2026-08-30), reading the WP296 run: "8.1 跟 8.2 之间那是
|
||
# 啥?我总感觉它在的表格的位置不太对。"
|
||
def note_lead(note, lines):
|
||
"""The note as it prints when its own lines are printed under it.
|
||
|
||
SAID ONCE, NOT TWICE. Owner, reading the WP296 run, 2026-08-30: "what's
|
||
that between 8.1 and 8.2? I keep feeling like its position in the table
|
||
isn't quite right." What sat between the two
|
||
numbered rows was 8.1's own enumeration -- printed in full inside 8.1's
|
||
note cell, and then printed AGAIN one-per-bullet in the block underneath:
|
||
|
||
8.1 WARN C 4 line(s) ... were not interpreted: STRAND PATTERN |
|
||
1/2" DIA. STR. | #3S @ 18" O.C. ... | STRIPPING ...
|
||
- STRAND PATTERN
|
||
- 1/2" DIA. STR.
|
||
...
|
||
|
||
A block that repeats the sentence above it verbatim does not read as
|
||
that sentence's detail; it reads as a second, unlabelled thing. So when
|
||
the lines are printed on their own, the note stops at the colon and lets
|
||
them carry themselves. The finding's `message` is untouched -- this is a
|
||
rendering decision, and `qc-run.json` still holds the whole string.
|
||
"""
|
||
text = _text(note or '')
|
||
if not lines:
|
||
return text
|
||
head, found, _tail = text.partition(': ')
|
||
if not found:
|
||
return text
|
||
return head + ':'
|
||
|
||
|
||
# ------------------------------------------------------------ the styling
|
||
#
|
||
# Everything below writes HTML for an IE-engine WebBrowser (see the module
|
||
# docstring of `vibe.foldreport` for what that engine is and is not). No
|
||
# HTML5 elements, no modern DOM, no CSS newer than IE7 understands: no
|
||
# flexbox, no `border-radius` that matters, no `rgba`.
|
||
#
|
||
# "字儿可以再大一点": the panel's own body text is 8pt. The checklist table is
|
||
# set at 11pt with a 12pt bold status column, and a note that will not fit
|
||
# wraps onto more lines rather than being cut ("一行放不下的就多来几行").
|
||
|
||
CHECKLIST_STYLE = """
|
||
.wt-ck { border-collapse: collapse; width: 100%; margin: 6px 0 14px 0;
|
||
font-size: 11pt; }
|
||
.wt-ck th { background: #33404f; color: #ffffff; font-size: 10pt;
|
||
text-align: left; padding: 5px 7px; }
|
||
.wt-ck td { padding: 5px 7px; border-bottom: 1px solid #d8dde3;
|
||
vertical-align: top; font-size: 11pt; line-height: 1.35;
|
||
white-space: normal; word-wrap: break-word; }
|
||
/* EVERY PER-COLUMN RULE IS WRITTEN `.wt-ck td.x`, NOT `.wt-ck-x`, AND THAT
|
||
IS THE WHOLE REASON THE DETAIL BLOCK LOOKED LIKE A ROW OF ITS OWN.
|
||
|
||
`.wt-ck td` is one class plus one element; a bare `.wt-ck-detail` is one
|
||
class. The bare form LOSES every property the td rule also sets, and it
|
||
loses silently -- the declaration is there in the file, and the browser
|
||
throws it away. Found 2026-08-30 while chasing "what's that between 8.1
|
||
and 8.2?": the detail block asked for `padding-left: 51px` and got the td rule's 7px,
|
||
so it began at the table's left edge, full width, indented by nothing. The
|
||
same bug was eating `white-space: nowrap` on the # and status columns and
|
||
the status column's 12pt -- all three now bind. */
|
||
.wt-ck td.wt-ck-num { font-weight: bold; width: 52px; white-space: nowrap; }
|
||
/* The master checklist id, under a profile's own number and only when the
|
||
two differ. Small and grey: it is what a RULE is written against, not
|
||
what the reader navigates by. */
|
||
.wt-ck .wt-ck-master { font-size: 8pt; font-weight: normal; color: #7a8290; }
|
||
.wt-ck td.wt-ck-grade { width: 40px; text-align: center; color: #667; }
|
||
.wt-ck td.wt-ck-state { font-size: 12pt; font-weight: bold;
|
||
white-space: nowrap; width: 118px;
|
||
text-align: center; }
|
||
/* The note keeps the table's own 11pt: "the text could stand to be a bit
|
||
bigger" was about this table, and the point of fixing the specificity is not to shrink it. */
|
||
.wt-ck-note a { color: #1d4f8c; }
|
||
/* THE NOTE FOLD. Height only -- `max-height` plus `overflow: hidden`, both
|
||
of which this engine has had since IE7. The text inside is the WHOLE
|
||
note; nothing is cut, so a reader can select the cell and copy every
|
||
character of it whether or not the toggle works. The inline
|
||
`max-height` written per row is what the toggle flips; this class exists
|
||
for the clipping and for the cursor.
|
||
|
||
The item column is deliberately NOT styled here. It is never folded --
|
||
one line if it needs one, two if it needs two ("the item must not fold"). */
|
||
.wt-ck-note-clip { overflow: hidden; cursor: pointer; }
|
||
.wt-ck-note-more { font-size: 9pt; color: #1d4f8c; cursor: pointer;
|
||
padding: 2px 0 0 0; }
|
||
/* Outside the fold, always: a collapsed note still shows its link. */
|
||
.wt-ck-note-path { padding: 2px 0 0 0; }
|
||
/* A DETAIL ROW IS ITS PARENT'S SECOND HALF, NOT A ROW OF ITS OWN.
|
||
The rule under the parent is removed, the # column is left blank so the
|
||
block starts under the item and not at the table edge, the ground is
|
||
neutral rather than the parent's status colour -- a pale yellow band the
|
||
full width of the table under a white row is exactly the free-floating
|
||
band the owner could not place -- and the parent's status INK comes back
|
||
as the left rule that ties the two together. */
|
||
.wt-ck tr.wt-ck-open td { border-bottom: none; }
|
||
.wt-ck td.wt-ck-detail-pad { width: 44px; }
|
||
.wt-ck td.wt-ck-detail { font-size: 10pt; padding: 0 7px 7px 0;
|
||
background: #f4f6f8; }
|
||
.wt-ck-detail-body { border-left: 3px solid #99a3b0; padding: 1px 0 1px 10px;
|
||
color: #333333; }
|
||
.wt-ck-detail div { padding: 1px 0; }
|
||
.wt-ck-key { font-size: 9pt; color: #556; margin: 0 0 6px 0; }
|
||
.wt-ck-key b { padding: 2px 6px; }
|
||
.wt-ck-head { font-size: 12pt; font-weight: bold; margin: 12px 0 2px 0; }
|
||
"""
|
||
|
||
|
||
def _swatch(color, label):
|
||
ink, ground = STATE_COLORS[color]
|
||
return ('<b style="color:{0};background:{1}">{2}</b>'
|
||
.format(ink, ground, label))
|
||
|
||
|
||
def checklist_key_html():
|
||
"""The one line that tells the reader what the colours mean.
|
||
|
||
It says what NOT RUN means in words, on the page, next to the red it is
|
||
printed in -- because a red row a reader decodes as "broken tool" gets
|
||
ignored, and a red row he decodes as "nobody checked this" does not.
|
||
|
||
And it says what the grey is, in the same breath, so the two can never
|
||
be confused: grey is a check that RAN and was asked for quietly by this
|
||
job. A reader who thinks grey might mean "skipped" is back to not
|
||
trusting the table.
|
||
"""
|
||
return ('<p class="wt-ck-key">{0} judged, fine {1} a person must '
|
||
'look {2} the piece is wrong, or <i>nobody checked</i> '
|
||
'— an unrun check tells you nothing about the piece, so it '
|
||
'is not quieter than a failure. {3} it ran and this job '
|
||
'asked for it quietly — the row says what it really '
|
||
'reported, and a FAIL is never muted. {4} a knowledge '
|
||
'layer declares this question is not about this kind of piece, '
|
||
'so the check did <i>not run</i> — every one is listed '
|
||
'below with its reason and the layer that declared it.</p>'
|
||
.format(_swatch('green', 'OK'),
|
||
_swatch('yellow', 'WARN / FOR ENGINEER'),
|
||
_swatch('red', 'FAIL / NOT RUN'),
|
||
_swatch('grey', MUTED_STATE),
|
||
_swatch('blue', NOT_APPLICABLE_STATE)))
|
||
|
||
|
||
def checklist_table_html(rows, details=None, escape=None, table_id=None):
|
||
"""The checklist, as one coloured table. The product; printed first.
|
||
|
||
`details` is `checklist_details(findings)`: item number -> the lines
|
||
behind a counted note, printed as their own rows under the item so a
|
||
long enumeration wraps into lines instead of one unreadable cell.
|
||
|
||
`table_id` only has to be unique on the page -- it namespaces the note
|
||
folds' DOM ids, because a run prints one of these tables per ticket and
|
||
every one of them has a row 5.3. It counts up on its own when the caller
|
||
does not care, which is what the button does.
|
||
"""
|
||
escape = escape or _html_escape
|
||
details = details or {}
|
||
table_id = table_id or _next_table_id()
|
||
out = ['<table class="wt-ck">',
|
||
'<tr><th>#</th><th>item</th><th>grade</th><th>status</th>'
|
||
'<th>note</th></tr>']
|
||
for row in (rows or []):
|
||
num, name, grade, status, note = row[0], row[1], row[2], row[3], row[4]
|
||
color = state_color(status)
|
||
ink, ground = STATE_COLORS[color]
|
||
# `details` and the note-fold ids are keyed by the MASTER ID, which
|
||
# is `row[0]` and stays there. Only the # column changes.
|
||
lines = details.get(num)
|
||
shown, stable = row_display(row)
|
||
out.append('<tr class="wt-ck-open">' if lines else '<tr>')
|
||
# THE MASTER ID IS RECOVERABLE ON THE PAGE, and only where it says
|
||
# something: printed under a profile number it differs from, and not
|
||
# at all when the two are the same word. A reader looking up a rule
|
||
# the owner wrote needs the id; a reader of a plant that has not
|
||
# renumbered needs one number, not the same number twice.
|
||
cell = escape(str(shown))
|
||
if str(stable) != str(shown):
|
||
cell += ('<div class="wt-ck-master" title="master checklist id">'
|
||
'{0}</div>'.format(escape(str(stable))))
|
||
out.append('<td class="wt-ck-num">{0}</td>'.format(cell))
|
||
out.append('<td>{0}</td>'.format(escape(str(name))))
|
||
out.append('<td class="wt-ck-grade">{0}</td>'
|
||
.format(escape(str(grade or ''))))
|
||
out.append('<td class="wt-ck-state" style="color:{0};background:{1}">'
|
||
'{2}</td>'.format(ink, ground, escape(str(status))))
|
||
out.append('<td class="wt-ck-note">{0}</td>'
|
||
.format(note_cell_html(note_lead(note, lines), name,
|
||
status, table_id, num, escape)))
|
||
out.append('</tr>')
|
||
if lines:
|
||
body = ''.join(['<div>• {0}</div>'.format(escape(str(line)))
|
||
for line in lines])
|
||
out.append('<tr><td class="wt-ck-detail-pad"> </td>'
|
||
'<td colspan="4" class="wt-ck-detail">'
|
||
'<div class="wt-ck-detail-body" '
|
||
'style="border-left-color:{0}">{1}</div>'
|
||
'</td></tr>'.format(ink, body))
|
||
out.append('</table>')
|
||
return ''.join(out)
|
||
|
||
|
||
def note_html(note, escape=None):
|
||
"""A note cell, flat: escaped, with a trailing file path made clickable.
|
||
|
||
The path is a REAL Windows path in the visible text and a `file:///` URL
|
||
only in the href -- see NOTE_FILE_SEP. An empty note is an em dash, so a
|
||
row that says nothing looks deliberate rather than broken.
|
||
|
||
This is the UNFOLDED shape and it is what every consumer outside the
|
||
checklist table gets. `note_cell_html` wraps it when a row's note is
|
||
long enough to fold.
|
||
"""
|
||
escape = escape or _html_escape
|
||
head, sep, path = note_parts(note)
|
||
if not sep:
|
||
return escape(head) or '—'
|
||
return '{0}{1}<a href="{2}">{3}</a>'.format(
|
||
escape(head), escape(sep), escape(note_href(path)), escape(path))
|
||
|
||
|
||
def note_cell_html(note, name, status, table_id, num, escape=None):
|
||
"""The note cell: whole, and folded to its row's height when it is long.
|
||
|
||
NOTHING IS REMOVED. The full prose is in the div; the div's HEIGHT is
|
||
capped at the item cell's estimated line count and `overflow: hidden`
|
||
does the rest, so the text is still in the document -- select it, copy
|
||
it, or open the fold.
|
||
|
||
THE PATH LIVES OUTSIDE THE FOLD. 7.1's note ends in a link to the pair
|
||
list, and a fold that hid the link would take the evidence with it.
|
||
|
||
THE TOGGLE, AND WHAT HAPPENS IF IT IS DEAD. The panel is an IE-engine
|
||
WebBrowser (see `foldreport`'s module docstring): no `<details>`, no
|
||
modern DOM. The control is the same inline `onclick` + `id`/`id + '_h'`
|
||
convention every other fold in this tool uses, calling `wtClip` out of
|
||
the script the button already injects -- one mechanism, extended, not a
|
||
second one.
|
||
|
||
If the script does not run, a folded note stays visually clipped. That
|
||
is a real cost, and two guards remain against it -- a red row folds like
|
||
any other now (owner, 2026-08-31: "if notes are too long, they fold by
|
||
default", overruling the earlier red exemption; see `note_folds`) but the path is never inside the
|
||
fold, and the whole note is in `last-ticketqc.txt` unfolded, which is the
|
||
fallback the footer already points at. The control also SAYS how much is
|
||
behind it, so a reader is never left guessing whether anything is -- and
|
||
the item-height lines that stay visible above the fold are never
|
||
clipped to nothing, on any row: `item_line_estimate` is always >= 1.
|
||
"""
|
||
escape = escape or _html_escape
|
||
head, sep, path = note_parts(note)
|
||
if not (head or path):
|
||
return '—'
|
||
lines = item_line_estimate(name)
|
||
tail = ''
|
||
if sep:
|
||
tail = ('<div class="wt-ck-note-path">{0} <a href="{1}">{2}</a></div>'
|
||
.format(escape(sep.strip().strip('-').strip()),
|
||
escape(note_href(path)), escape(path)))
|
||
if not note_folds(head, lines, status):
|
||
return '<div class="wt-ck-note-body">{0}</div>{1}'.format(
|
||
escape(head) or '—', tail)
|
||
ident = note_fold_id(table_id, num)
|
||
height = '{0:.2f}em'.format(lines * NOTE_LINE_HEIGHT)
|
||
return ('<div id="{0}" class="wt-ck-note-body wt-ck-note-clip" '
|
||
'style="max-height:{1}" wtcap="{1}">{2}</div>'
|
||
'<div id="{0}_h" class="wt-ck-note-more" '
|
||
'onclick="wtClip(\'{0}\')">[+] the whole note ({3} '
|
||
'characters)</div>{4}'
|
||
.format(ident, height, escape(head), len(head), tail))
|
||
|
||
|
||
# `_text` MOVED TO THE TOP OF THIS FILE (2026-08-31). It is called during
|
||
# module import now -- the master checklist is read off disk there and every
|
||
# refusal it can raise is a sentence -- so it can no longer live down here
|
||
# among the renderers. Its reasoning went with it; read it there before
|
||
# putting a `str()` back anywhere in this file.
|
||
|
||
|
||
def _wrap_text(value, width, indent, hanging=None):
|
||
"""`textwrap`, with its whitespace TRANSLATE step taken out of the path.
|
||
|
||
THE SECOND HALF OF THE SAME INCIDENT, and the half that is invisible on
|
||
a host interpreter. `textwrap` munges whitespace by calling
|
||
`text.translate(<a 256-entry table>)` on anything it considers a `str`.
|
||
On CPython that table is applied to a byte string, where every byte has
|
||
an entry; the extension's engine is IronPython 2.7, whose `str` holds
|
||
.NET UTF-16 text, so a character above U+00FF has no entry in the table
|
||
at all -- and the report came back with exactly those characters missing
|
||
and nothing else disturbed.
|
||
|
||
So the whitespace is flattened HERE, by `split()`/`join()`, and
|
||
`textwrap` is told not to do it again. Wrapping is unchanged; the only
|
||
behavioural difference is that a run of spaces inside a note collapses to
|
||
one, which is what a wrapped line wants anyway. `foldreport._flat` does
|
||
the same flattening for the same reason.
|
||
|
||
This cannot be reproduced on the host interpreter that runs the suite --
|
||
see `test_the_text_report_never_hands_a_note_to_textwraps_translate`,
|
||
which pins the shape rather than the symptom for that reason.
|
||
"""
|
||
import textwrap as _tw
|
||
flat = ' '.join(_text(value).split())
|
||
return _tw.wrap(flat, width=width,
|
||
initial_indent=indent,
|
||
subsequent_indent=indent if hanging is None else hanging,
|
||
expand_tabs=False, replace_whitespace=False)
|
||
|
||
|
||
def checklist_table_text(rows, details=None, width=100):
|
||
"""The same table as plain text -- the log, the file, the CLI.
|
||
|
||
Long notes WRAP onto continuation lines rather than being cut: the note
|
||
is what a reader uses to find the thing, and a truncated note sends him
|
||
looking for something the tool already saw. Colour does not survive
|
||
here and does not need to -- the STATE WORD is the data, and it is
|
||
printed in full.
|
||
|
||
NOR DOES AN ENCODING SURVIVE BY ITSELF: every string that reaches this
|
||
table goes through `_text`, and every wrap through `_wrap_text`. Read
|
||
both docstrings before putting a `str()` or a bare `textwrap` call back
|
||
-- between them they are one measured defect, not two style preferences.
|
||
"""
|
||
details = details or {}
|
||
lines = []
|
||
for row in (rows or []):
|
||
num, name, grade, status, note = row[0], row[1], row[2], row[3], row[4]
|
||
shown, stable = row_display(row)
|
||
# The file is the fallback a reader is told to trust, so the master
|
||
# id is ALWAYS recoverable here -- there is no hover text and no
|
||
# second column to put it in.
|
||
# `_text` on both sides, never the builtin text coercion. The
|
||
# tombstone test forbids that call anywhere in this function -- by
|
||
# searching the SOURCE, comments included -- and it is right to:
|
||
# this is the function that lost the owner's own words through
|
||
# exactly that boundary. See `_text` at the top of the file.
|
||
label = (_text(shown) if _text(shown) == _text(stable)
|
||
else _text('{0} [{1}]').format(_text(shown), _text(stable)))
|
||
lines.append(_text('{0:<5} {1:<13} {2:<1} {3}')
|
||
.format(label, _text(status),
|
||
_text(grade or '-'), _text(name)))
|
||
note = note_lead(note, details.get(num))
|
||
head, sep, path = note_parts(note)
|
||
if sep:
|
||
# A path gets its own line, unwrapped: `textwrap` would break it
|
||
# at a space in a folder name and half a path is not a path.
|
||
for chunk in _wrap_text(head + sep.rstrip(), width, ' ' * 8):
|
||
lines.append(chunk)
|
||
lines.append(_text(' ' * 8) + _text(path))
|
||
elif note:
|
||
for chunk in _wrap_text(note, width, ' ' * 8):
|
||
lines.append(chunk)
|
||
for detail in details.get(num, []):
|
||
for chunk in _wrap_text(_text('- ') + _text(detail), width,
|
||
' ' * 10, hanging=' ' * 12):
|
||
lines.append(chunk)
|
||
return _text('\n').join(lines)
|
||
|
||
|
||
def checklist_tally(rows):
|
||
"""{status word: count} for the rows the reader is actually looking at.
|
||
|
||
Counted off the ROWS, so the sentence over the table and the table
|
||
agree -- which is why a muted row, which is still a row, gets counted.
|
||
|
||
IT GETS ITS OWN BUCKET, `MUTED`, rather than folding into the green
|
||
count. A muted row is not a pass: it is a row somebody chose not to
|
||
read, and a header that said "31 OK" when four of them were nobody's
|
||
judgement would be the quiet lie this whole table is built against.
|
||
(OPEN QUESTION, with the owner: he may want them folded into OK to keep
|
||
the header short. Changing it is a change here and in
|
||
`checklist_headline`'s order, nowhere else.)
|
||
"""
|
||
counts = {}
|
||
for row in (rows or []):
|
||
status = row[3]
|
||
counts[status] = counts.get(status, 0) + 1
|
||
return counts
|
||
|
||
|
||
def checklist_headline(counts):
|
||
"""One sentence over the table, naming the loud states first.
|
||
|
||
MUTED comes last, after OK -- it is the quietest thing on the table and
|
||
the order of this sentence is what a reader skims. It is still SAID,
|
||
because a mute nobody can see is a mute nobody can question.
|
||
"""
|
||
order = ('FAIL', 'NOT RUN', 'WARN', 'FOR ENGINEER', 'OK',
|
||
NOT_APPLICABLE_STATE, MUTED_STATE)
|
||
parts = []
|
||
for word in order:
|
||
if counts.get(word):
|
||
parts.append('{0} {1}'.format(counts[word], word))
|
||
for word in sorted(counts):
|
||
if word not in order:
|
||
parts.append('{0} {1}'.format(counts[word], word))
|
||
if not parts:
|
||
return 'no checklist items'
|
||
return ' · '.join(parts)
|
||
|
||
|
||
def _html_escape(body):
|
||
return (body.replace('&', '&').replace('<', '<')
|
||
.replace('>', '>'))
|