WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
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.
This commit is contained in:
@@ -0,0 +1,811 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# The shared engine behind Ticket QC and Reinforcing QC.
|
||||
#
|
||||
# Factored out of Tools.panel/Modeling.pulldown/TicketQC.pushbutton/script.py
|
||||
# on 2026-09-05, when the owner asked for a second, cut-down button --
|
||||
# Reinforcing QC -- that judges a panel's MODELLED reinforcing before its
|
||||
# ticket exists, sharing everything about Ticket QC that is not actually
|
||||
# about a ticket. `run(doc, uidoc, mode=...)` is that whole button, called
|
||||
# with `mode='ticket'` from one script.py and `mode='reinforcing'` from the
|
||||
# other, so the 765 lines this replaced are not copy-pasted twice over --
|
||||
# a fix to one would silently not be a fix to the other.
|
||||
#
|
||||
# `mode='ticket'` IS THE OLD SCRIPT, UNCHANGED. Every branch below that
|
||||
# reads `mode` takes the 'ticket' arm unless said otherwise, and every call
|
||||
# made under that arm is the exact call the old script made, in the same
|
||||
# order, with the same arguments. The owner's rule for this refactor was
|
||||
# explicit: Ticket QC's own behaviour must not change. Where the two modes
|
||||
# really differ:
|
||||
#
|
||||
# * `mode='reinforcing'` never calls `ticketqc.check_ticket` -- its inline
|
||||
# comparisons (design number, f'c/f'ci/pcf, strand count, WWF, envelope)
|
||||
# are ALL ticket-vs-design comparisons by construction, and none of them
|
||||
# belong at a stage with no ticket. It calls
|
||||
# `ticketchecks.run_reinforcing` instead, through `_reinforcing_checker`
|
||||
# below and `ticketqc.run_for_assemblies`'s new `checker=` parameter.
|
||||
# * the checklist rows owned ENTIRELY by a check
|
||||
# `ticketchecks.REINFORCING_EXCLUDED` names are pulled out of the
|
||||
# coloured table -- not rendered as an alarming red NOT RUN for a
|
||||
# question this stage was never going to ask -- and listed once, in
|
||||
# their own section, with the reason. See `_excluded_row_numbers`.
|
||||
# * the vision escalation (reads a rendered ticket sheet) never runs.
|
||||
# * the machine-readable files get their own names, beside but never on
|
||||
# top of Ticket QC's.
|
||||
#
|
||||
# Read-only, exactly as the button it replaces was. Nothing here opens a
|
||||
# transaction, including the image export.
|
||||
#
|
||||
# RE-CUT 2026-09-07, against a real bridge run on three panels (see the
|
||||
# module note above `ticketchecks.REINFORCING_EXCLUDED`): `check_cast_extras`
|
||||
# and `check_weight_three_way` moved from included to excluded, and a
|
||||
# MEMBER-CLASS split was added (`ticketchecks.member_class_of` /
|
||||
# `REINFORCING_APPLIES_TO`) so a wall-panel-only rule (`check_lifting_points`'
|
||||
# 20/25 ft point-count ceiling) is not asked of a flexural member. This file
|
||||
# gained two report pieces for `mode='reinforcing'`: a "not asked for this
|
||||
# member class" section (per piece, reading each result's own `run_log` for
|
||||
# `ticketchecks.NOT_FOR_CLASS_DISPOSITION` entries) and a static "flexural
|
||||
# checks not built yet" spec table (`ticketchecks.FLEXURAL_CHECKS_NOT_BUILT`)
|
||||
# so a flexural-member reader sees the gap named rather than assuming the
|
||||
# checklist already covers it. `mode='ticket'` is untouched by any of this.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
|
||||
import os
|
||||
|
||||
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
||||
from pyrevit import script, forms
|
||||
|
||||
from vibe import ticketqc as Q
|
||||
from vibe import configdump
|
||||
from vibe import designspec, knowledge
|
||||
from vibe import scope as scopelib
|
||||
from vibe import ticketchecks as TC
|
||||
from vibe import ticketvision as TV
|
||||
from vibe import ticketpage as TP
|
||||
from vibe import foldreport as fold
|
||||
from vibe import runlog
|
||||
from vibe import compat
|
||||
|
||||
MODES = ('ticket', 'reinforcing')
|
||||
|
||||
TITLES = {'ticket': 'Ticket QC', 'reinforcing': 'Reinforcing QC'}
|
||||
|
||||
# The stage line, printed under the headline so a reader who lands on this
|
||||
# page from either button knows which question it is answering.
|
||||
STAGE_LINE = {
|
||||
'ticket': ('checks a shop ticket against its design spec, once the '
|
||||
'ticket is drawn.'),
|
||||
'reinforcing': ("checks a panel's modelled reinforcing against its "
|
||||
'design spec, before ticketing has started -- see the '
|
||||
"'Not checked at this stage' section for what that "
|
||||
'leaves out.'),
|
||||
}
|
||||
|
||||
# Distinct machine-readable filenames per mode, so a reinforcing-stage run
|
||||
# never overwrites -- or is overwritten by -- a ticket-stage run of the same
|
||||
# model. Ticket mode keeps the exact names the old script always used.
|
||||
_JSON_NAME = {'ticket': 'qc-run.json', 'reinforcing': 'reinforcingqc-run.json'}
|
||||
_TEXT_NAME = {'ticket': 'last-ticketqc.txt',
|
||||
'reinforcing': 'last-reinforcingqc.txt'}
|
||||
_EVIDENCE_FOLDER = {'ticket': Q.EVIDENCE_FOLDER,
|
||||
'reinforcing': 'evidence-reinforcing'}
|
||||
|
||||
|
||||
def _reinforcing_checker(doc, ticket, intent, know, spec):
|
||||
"""The `checker` `ticketqc.run_for_assemblies` calls per piece in
|
||||
reinforcing mode -- `ticketchecks.run_reinforcing` instead of
|
||||
`ticketqc.check_ticket`. `doc` is unused (the signature is shared with
|
||||
every checker `run_for_assemblies` could call); ticket/intent/know/spec
|
||||
are exactly what `check_ticket` itself receives.
|
||||
"""
|
||||
findings, log, excluded = TC.run_reinforcing(ticket, intent, know, spec)
|
||||
return findings, log, excluded
|
||||
|
||||
|
||||
def _excluded_row_numbers(excluded_names):
|
||||
"""{checklist row number} for every row owned ENTIRELY by a check in
|
||||
`excluded_names`.
|
||||
|
||||
A row naming two checks where only one is excluded 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 stage, not a stage-scope
|
||||
exclusion. Pulled out here rather than left to `checklist_states`, which
|
||||
has no notion of "this stage never intended to ask this" and would
|
||||
otherwise print the same alarming NOT RUN a genuine gap gets.
|
||||
|
||||
Thin wrapper over `ticketpage.rows_owned_by` -- shared with
|
||||
`_plant_scoped_row_numbers` below, and with a host test
|
||||
(`test_ticketpage.py`) that does not need this module's Revit imports
|
||||
to exercise the row-matching logic itself.
|
||||
"""
|
||||
return TP.rows_owned_by(excluded_names)
|
||||
|
||||
|
||||
def _excluded_section_blocks(excluded_names):
|
||||
"""One block per excluded check, with the checklist row(s) it would
|
||||
have owned and the reason it does not run at this stage.
|
||||
"""
|
||||
blocks = []
|
||||
for name in sorted(excluded_names):
|
||||
nums = [num for (num, _n, _g, _o, _r, check_name) in TP.CHECKLIST_ITEMS
|
||||
if name in TP._check_names(check_name)]
|
||||
where = ' ({0})'.format(', '.join(nums)) if nums else ''
|
||||
blocks.append((fold.MD, '- **{0}**{1} -- {2}'
|
||||
.format(name, where, excluded_names[name])))
|
||||
return blocks
|
||||
|
||||
|
||||
def _plant_scoped_row_numbers(plant_scoped):
|
||||
"""Same shape as `_excluded_row_numbers`, for checks
|
||||
`ticketchecks.plant_scoped_out` names -- a check gated on a knowledge
|
||||
key this job's layer has not set, never a piece-level silence.
|
||||
"""
|
||||
return TP.rows_owned_by(plant_scoped)
|
||||
|
||||
|
||||
def _plant_scoped_section_blocks(plant_scoped):
|
||||
"""One block per row a plant-gated check owns, naming the row id, its
|
||||
catalogue name, and the missing key(s) (already inside `plant_scoped`'s
|
||||
own reason text -- see `ticketchecks.plant_gate_reason`).
|
||||
|
||||
Grouped by ROW, not by check name like `_excluded_section_blocks` --
|
||||
the owner asked for "the row id, name and the missing key", and every
|
||||
plant-gated row today owns exactly one check, so this reads the row's
|
||||
own catalogue name rather than a bare function name.
|
||||
"""
|
||||
blocks = []
|
||||
seen = set()
|
||||
for (num, name, _grade, _owners, _reason, check_name) in TP.CHECKLIST_ITEMS:
|
||||
owned = [n for n in TP._check_names(check_name) if n in plant_scoped]
|
||||
if not owned or num in seen:
|
||||
continue
|
||||
seen.add(num)
|
||||
reasons = sorted(set(plant_scoped[n] for n in owned))
|
||||
blocks.append((fold.MD, '- **{0}** {1} -- {2}'
|
||||
.format(num, name, '; '.join(reasons))))
|
||||
return blocks
|
||||
|
||||
|
||||
def run(doc, uidoc, mode='ticket'):
|
||||
"""Run Ticket QC (`mode='ticket'`) or Reinforcing QC
|
||||
(`mode='reinforcing'`) against `doc`, using `uidoc`'s current selection.
|
||||
|
||||
See the module docstring for exactly what differs between the two.
|
||||
"""
|
||||
if mode not in MODES:
|
||||
raise ValueError('unknown ticketqc_run mode: {0!r}'.format(mode))
|
||||
TITLE = TITLES[mode]
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
# ------------------------------------------------------------ context
|
||||
# The published checklist has to exist before anything else here is
|
||||
# worth doing: every row this button ever prints comes off it.
|
||||
if TP.CHECKLIST_UNAVAILABLE_REASON:
|
||||
runlog.record(TITLE, doc_title=doc.Title, outcome='refused',
|
||||
report_text=TP.CHECKLIST_UNAVAILABLE_REASON)
|
||||
forms.alert(TP.CHECKLIST_UNAVAILABLE_REASON, title=TITLE,
|
||||
exitscript=True)
|
||||
|
||||
know = knowledge.load(doc)
|
||||
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
runlog.record(TITLE, doc_title=doc.Title, outcome='refused',
|
||||
report_text=problem)
|
||||
forms.alert(problem, title=TITLE, exitscript=True)
|
||||
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
if missing:
|
||||
_unmapped_msg = (
|
||||
'These parameter roles are not mapped in knowledge.yaml, so the '
|
||||
'checks that need them cannot run:\n\n {0}\n\n'
|
||||
'Edit the "parameters:" section of\n{1}\n\n'
|
||||
'Run Self Check for the full picture.'
|
||||
.format('\n '.join(missing), designspec.knowledge_path(doc)))
|
||||
if 'panel.design_number' in missing:
|
||||
runlog.record(TITLE, doc_title=doc.Title, outcome='refused',
|
||||
report_text=_unmapped_msg)
|
||||
forms.alert(_unmapped_msg, title=TITLE,
|
||||
exitscript=('panel.design_number' in missing))
|
||||
|
||||
# ------------------------------------------------------------- selection
|
||||
# Assemblies picked straight through, a picked member resolves to its
|
||||
# owning assembly, and nothing picked means a checklist of every
|
||||
# assembly. No "batch or single" question: this is always a batch of one
|
||||
# or more pieces, ticket mode or reinforcing mode alike.
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not assemblies:
|
||||
owners = []
|
||||
for el in selection:
|
||||
try:
|
||||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
||||
owners.append(owner)
|
||||
assemblies = owners
|
||||
|
||||
specs = designspec.list_specs(doc) if doc.PathName else {}
|
||||
summaries, _sheets = Q.collect_context(doc)
|
||||
grouped = Q.assemblies_by_design(doc, know=know)
|
||||
|
||||
# ------------------------------------------------------------- the scope
|
||||
the_scope = scopelib.of(know)
|
||||
scope_line = None # what was left off the checklist
|
||||
scope_annotation = None # what was judged outside the scope anyway
|
||||
|
||||
if not assemblies:
|
||||
# Nothing preselected: a checklist of every assembly.
|
||||
items = []
|
||||
for design, asm_list in sorted(grouped.items()):
|
||||
for asm in sorted(asm_list, key=lambda a: a.AssemblyTypeName):
|
||||
if design in specs:
|
||||
state = 'spec'
|
||||
elif design in summaries:
|
||||
state = 'regex fallback'
|
||||
else:
|
||||
state = 'NO spec/summary'
|
||||
items.append((asm, '{0} - {1} - {2}'
|
||||
.format(asm.AssemblyTypeName, design, state)))
|
||||
items.sort(key=lambda r: r[1])
|
||||
offered, left_alone = [], []
|
||||
for row in items:
|
||||
(offered if the_scope.includes(row[0].AssemblyTypeName)
|
||||
else left_alone).append(row)
|
||||
scope_line = the_scope.line(
|
||||
[r[0].AssemblyTypeName for r in left_alone], total=len(items),
|
||||
unit='piece')
|
||||
items = offered
|
||||
if not items:
|
||||
_nothing_msg = 'There is nothing to check.\n\n{0}'.format(scope_line)
|
||||
runlog.record(TITLE, doc_title=doc.Title, outcome='refused',
|
||||
report_text=_nothing_msg)
|
||||
forms.alert(_nothing_msg, title=TITLE, exitscript=True)
|
||||
names = [r[1] for r in items]
|
||||
picked = forms.SelectFromList.show(
|
||||
names, multiselect=True,
|
||||
title='{0} - choose pieces'.format(TITLE),
|
||||
button_name=('Check pieces' if mode == 'reinforcing'
|
||||
else 'Check tickets'))
|
||||
if not picked:
|
||||
script.exit()
|
||||
picked_set = set(picked)
|
||||
assemblies = [r[0] for r in items if r[1] in picked_set]
|
||||
if not assemblies:
|
||||
script.exit()
|
||||
|
||||
if not grouped and not assemblies:
|
||||
_no_design_msg = (
|
||||
'No assembly carries a value for the "panel.design_number" role '
|
||||
'(mapped to: {0}).\n\nEither the mapping is wrong or this model '
|
||||
'does not group pieces that way.'
|
||||
.format(Q.role_label(know, 'panel.design_number')))
|
||||
runlog.record(TITLE, doc_title=doc.Title, outcome='refused',
|
||||
report_text=_no_design_msg)
|
||||
forms.alert(_no_design_msg, exitscript=True)
|
||||
|
||||
# --------------------------------------------------------------------- run
|
||||
context = Q.collect_context(doc)
|
||||
results, missing = [], []
|
||||
total = len(assemblies)
|
||||
checker = None if mode == 'ticket' else _reinforcing_checker
|
||||
bar_title = ('Checking tickets ({0} pieces) - first of a design can be slow'
|
||||
if mode == 'ticket' else
|
||||
'Checking reinforcing ({0} pieces) - first of a design can '
|
||||
'be slow')
|
||||
with forms.ProgressBar(title=bar_title.format(total),
|
||||
cancellable=True) as pb:
|
||||
for i, asm in enumerate(assemblies):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, total)
|
||||
batch, miss = Q.run_for_assemblies(
|
||||
doc, [asm], specs=specs, know=know, context=context,
|
||||
checker=checker)
|
||||
results.extend(batch)
|
||||
missing.extend(miss)
|
||||
pb.update_progress(total, total)
|
||||
|
||||
# ------------------------------------------- the escalation, ticket only
|
||||
# Reinforcing QC never reads a rendered sheet: there is no sheet yet.
|
||||
_vision_on = False
|
||||
if mode == 'ticket':
|
||||
_vision_on, _ = TV.run_settings(designspec.root_dir(doc))
|
||||
for r in results:
|
||||
r['model_read'] = []
|
||||
if _vision_on and results:
|
||||
with forms.ProgressBar(title='Escalating to a model reading the drawing',
|
||||
cancellable=True) as pb:
|
||||
for i, r in enumerate(results):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, len(results))
|
||||
try:
|
||||
r['model_read'] = TV.read_for_ticket(
|
||||
r['ticket'], know, designspec.root_dir(doc))
|
||||
except Exception as _ex:
|
||||
r['model_read'] = [TV.not_run_row(
|
||||
'AI-BARMARK', 'the drawing',
|
||||
'the escalation itself failed ({0})'
|
||||
.format(type(_ex).__name__))]
|
||||
|
||||
# ------------------------------------------------------------ report
|
||||
_excluded_names = dict(TC.REINFORCING_EXCLUDED) if mode == 'reinforcing' else {}
|
||||
_excluded_nums = (_excluded_row_numbers(_excluded_names)
|
||||
if _excluded_names else set())
|
||||
|
||||
# PLANT-SCOPED-OUT rows, both modes alike: a check gated on a knowledge
|
||||
# key (`ticketchecks.PLANT_GATE_KEYS`) this job's layer has not set --
|
||||
# e.g. 6.9/6.10/6.11, MC-only rules keyed on `lifters.lifting_points.*`.
|
||||
# Owner report, 2026-09-08: a Ticket QC on another plant's job showed "a pile
|
||||
# of MC checks" -- these three printed inline as alarming red NOT RUN
|
||||
# on any plant that never declared MC's numbers. Pulled out of the
|
||||
# coloured table entirely, into "Not applicable at this plant" below,
|
||||
# the same treatment `_excluded_nums` gives a stage-scoped-out row.
|
||||
_plant_scoped = TC.plant_scoped_out(know)
|
||||
_plant_scoped_nums = (_plant_scoped_row_numbers(_plant_scoped)
|
||||
if _plant_scoped else set())
|
||||
|
||||
_plant_id = knowledge.plant_id(doc)
|
||||
muted = TP.muted_checks(know, resolver=knowledge.resolve_check_reference,
|
||||
plant=_plant_id)
|
||||
_mute_problems = TP.muted_reference_problems(know, plant=_plant_id)
|
||||
|
||||
try:
|
||||
_layers = configdump.layer_values(doc)
|
||||
except Exception:
|
||||
_layers = []
|
||||
|
||||
def _declaring_layer(item, condition):
|
||||
if not _layers or not condition:
|
||||
return None
|
||||
for branch in ('not_applicable', 'only_applies_to'):
|
||||
found = knowledge.declaring_layer(
|
||||
_layers, ['checks', branch, item, condition])
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
for r in results:
|
||||
_rows = TC.checklist_states(r['findings'], r.get('run_log'))
|
||||
_rows = TV.annotate_rows(_rows, r.get('model_read'))
|
||||
_decisions, _classification, _member, _profile = \
|
||||
TC.applicability_decisions_for(r['ticket'], know)
|
||||
for _num in _decisions:
|
||||
_d = _decisions[_num]
|
||||
if _d.get('not_applicable'):
|
||||
_d['layer'] = _declaring_layer(_num, _d.get('condition'))
|
||||
_rows, _na_notes = TP.apply_applicability(_rows, _decisions)
|
||||
r['applicability_notes'] = _na_notes
|
||||
r['applicability_problems'] = TP.applicability_problems(_decisions)
|
||||
r['classification'] = _classification
|
||||
r['member'] = _member
|
||||
r['profile'] = _profile
|
||||
r['off_board'] = TP.off_board(_decisions)
|
||||
_kept, _muted_notes = TP.apply_muting(_rows, muted)
|
||||
r['rows'] = _kept
|
||||
r['rows_unmuted'] = _rows
|
||||
r['muted_notes'] = _muted_notes
|
||||
r['rows'] = TP.profile_view(r['rows'], _profile)
|
||||
r['rows_unmuted'] = TP.profile_view(r['rows_unmuted'], _profile)
|
||||
if _excluded_nums:
|
||||
# NOT AT THIS STAGE, not NOT RUN: pulled out of the coloured
|
||||
# table entirely -- see `_excluded_row_numbers`. The master id
|
||||
# (row[0]) is stable through `profile_view`, so filtering here
|
||||
# is filtering by the same number the module note above keys on.
|
||||
r['rows'] = [row for row in r['rows']
|
||||
if row[0] not in _excluded_nums]
|
||||
r['rows_unmuted'] = [row for row in r['rows_unmuted']
|
||||
if row[0] not in _excluded_nums]
|
||||
if _plant_scoped_nums:
|
||||
# NOT APPLICABLE AT THIS PLANT, not NOT RUN -- same treatment,
|
||||
# both modes. See `_plant_scoped_row_numbers` above.
|
||||
r['rows'] = [row for row in r['rows']
|
||||
if row[0] not in _plant_scoped_nums]
|
||||
r['rows_unmuted'] = [row for row in r['rows_unmuted']
|
||||
if row[0] not in _plant_scoped_nums]
|
||||
r['details'] = TP.checklist_details(r['findings'])
|
||||
r['counts'] = TP.checklist_tally(r['rows'])
|
||||
|
||||
def _loudness(r):
|
||||
c = r['counts']
|
||||
return (c.get('FAIL', 0), c.get('NOT RUN', 0), c.get('WARN', 0),
|
||||
c.get('FOR ENGINEER', 0))
|
||||
|
||||
loud, all_green = [], []
|
||||
for r in results:
|
||||
(loud if any(_loudness(r)) else all_green).append(r)
|
||||
loud.sort(key=lambda r: (-_loudness(r)[0], -_loudness(r)[1],
|
||||
-_loudness(r)[2], r['ticket']['mark']))
|
||||
|
||||
clean, failed, warned = [], [], []
|
||||
tally = {}
|
||||
for r in results:
|
||||
bad = [f for f in r['findings'] if f.severity != 'OK']
|
||||
for f in bad:
|
||||
tally[f.code] = tally.get(f.code, 0) + 1
|
||||
if not bad:
|
||||
clean.append(r)
|
||||
elif any(f.severity == 'FAIL' for f in bad):
|
||||
failed.append(r)
|
||||
else:
|
||||
warned.append(r)
|
||||
|
||||
output.add_style(TP.CHECKLIST_STYLE)
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
|
||||
# ---- the headline, then the tables. Nothing folded above this line.
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
output.print_md('_{0}_'.format(STAGE_LINE[mode]))
|
||||
output.print_md('Model **{0}** · {1} piece(s) across {2} design '
|
||||
'number(s) · {3} clean · {4} failing '
|
||||
'· {5} warning only'
|
||||
.format(doc.Title, len(results),
|
||||
len(set([r['design'] for r in results])),
|
||||
len(clean), len(failed), len(warned)))
|
||||
output.print_html(TP.checklist_key_html())
|
||||
|
||||
for r in loud:
|
||||
t = r['ticket']
|
||||
head = '**{0}** [{1}] {2} x {3} — {4}'.format(
|
||||
t['mark'], r['design'],
|
||||
Q.fmt_feet(t.get('length')), Q.fmt_feet(t.get('width')),
|
||||
TP.checklist_headline(r['counts']))
|
||||
if mode == 'ticket' and t.get('sheet') is not None:
|
||||
head += ' ' + output.linkify(t['sheet'].Id, 'open ticket')
|
||||
if t.get('panel_id'):
|
||||
head += ' ' + output.linkify(ElementId(t['panel_id']), 'panel')
|
||||
output.print_md(head)
|
||||
output.print_html(TP.checklist_table_html(r['rows'], r['details']))
|
||||
|
||||
if all_green:
|
||||
_quiet = sum([r['counts'].get(TP.NOT_APPLICABLE_STATE, 0)
|
||||
for r in all_green])
|
||||
output.print_md('**{0} piece(s) with nothing to read on the checklist:** '
|
||||
'{1}{2}'
|
||||
.format(len(all_green),
|
||||
', '.join([r['ticket']['mark']
|
||||
for r in all_green]),
|
||||
(' — and {0} row(s) across them were NOT '
|
||||
'ASKED because a knowledge layer declares they '
|
||||
'do not apply; see below'.format(_quiet))
|
||||
if _quiet else ' — every row green'))
|
||||
|
||||
# ------------------------------------------------- everything else, folded
|
||||
sections = []
|
||||
|
||||
def _section(key, title, summary, blocks, problem=None):
|
||||
if not blocks:
|
||||
return
|
||||
sections.append(fold.Section(key, title, summary=summary, blocks=blocks,
|
||||
problem=problem))
|
||||
|
||||
if missing:
|
||||
_section('missing', 'Pieces that could not be checked',
|
||||
'{0} piece(s)'.format(len(missing)),
|
||||
[(fold.MD, '- **{0}** (id {1}): {2}'
|
||||
.format(m.get('mark', '?'), m.get('id', '?'),
|
||||
m.get('why', 'unknown')))
|
||||
for m in missing],
|
||||
problem='{0} piece(s) were not judged at all'.format(len(missing)))
|
||||
|
||||
if _excluded_names:
|
||||
_section('not_at_this_stage', 'Not checked at this stage',
|
||||
'{0} check(s) this stage never asks'.format(len(_excluded_names)),
|
||||
[(fold.MD, 'These checks all need a ticket artefact -- a '
|
||||
'sheet, a placed view, a printed call-out, a '
|
||||
'BOM table -- that does not exist yet at this '
|
||||
'stage. Run Ticket QC once the ticket is drawn '
|
||||
'to have them judged.')] +
|
||||
_excluded_section_blocks(_excluded_names),
|
||||
problem=None)
|
||||
|
||||
if _plant_scoped:
|
||||
_section('not_applicable_at_this_plant', 'Not applicable at this plant',
|
||||
'{0} row(s) this plant\'s knowledge has not turned on'
|
||||
.format(len(_plant_scoped_nums)),
|
||||
[(fold.MD, 'These rows are a PLANT rule, not a universal '
|
||||
'one -- another plant wrote a number into its own '
|
||||
'knowledge layer, and this job\'s layer has never '
|
||||
'set it, so the rule was never asked to apply '
|
||||
'here. Set the named key(s) in this job\'s '
|
||||
'knowledge to have them judged; leaving them '
|
||||
'unset is the correct choice for a plant that '
|
||||
'does not follow the rule.')] +
|
||||
_plant_scoped_section_blocks(_plant_scoped),
|
||||
problem=None)
|
||||
|
||||
if mode == 'reinforcing':
|
||||
_class_blocks = []
|
||||
_class_piece_count = 0
|
||||
for r in results:
|
||||
_mark = r['ticket'].get('mark') or '(no mark)'
|
||||
_class_skips = [(name, note) for (name, state, note)
|
||||
in (r.get('run_log') or [])
|
||||
if state == TC.NOT_FOR_CLASS_DISPOSITION]
|
||||
if not _class_skips:
|
||||
continue
|
||||
_class_piece_count += 1
|
||||
_class_blocks.append(
|
||||
(fold.MD, '**{0}** -- {1}'.format(_mark, _class_skips[0][1])))
|
||||
_class_blocks.extend(
|
||||
(fold.MD, ' - {0}'.format(name)) for (name, _note) in _class_skips)
|
||||
_section('not_for_class', 'Not asked for this member class',
|
||||
'{0} piece(s) with a check narrowed by member class'
|
||||
.format(_class_piece_count),
|
||||
[(fold.MD, 'A wall panel (WPA/WPB) and a flexural member '
|
||||
'(SMD/FSA/DT) answer a different checklist -- '
|
||||
'see `ticketchecks.REINFORCING_APPLIES_TO`. A '
|
||||
'piece whose member class could not be judged '
|
||||
'still gets every check; nothing here is a '
|
||||
'silent narrowing.')] + _class_blocks,
|
||||
problem=None)
|
||||
|
||||
_flex_rows = [[name, evidence, rule] for (name, evidence, rule)
|
||||
in TC.FLEXURAL_CHECKS_NOT_BUILT]
|
||||
_asked_rows = [[concern, '{0} ({1})'.format(row_id, check)]
|
||||
for (concern, check, row_id)
|
||||
in TC.FLEXURAL_CHECKS_ALREADY_ASKED]
|
||||
_section('flexural_gaps', 'Flexural-member checks not built yet',
|
||||
'{0} check(s) named, none of them coded'
|
||||
.format(len(_flex_rows)),
|
||||
[(fold.MD, "SMD/FSA/DT are flexural members -- simply "
|
||||
'supported gravity spans -- and the checklist '
|
||||
'above does not yet ask any of these. Listed '
|
||||
'so a flexural-member reader sees the gap '
|
||||
'rather than assuming it is covered; filed '
|
||||
'separately, not built here.'),
|
||||
(fold.TABLE, ['check', 'evidence in the model', 'verdict rule'],
|
||||
_flex_rows),
|
||||
(fold.MD, 'Already asked on a flexural piece, by checks '
|
||||
'that were never wall-only -- not gaps:'),
|
||||
(fold.TABLE, ['concern', 'checklist row (check)'],
|
||||
_asked_rows)],
|
||||
problem=None)
|
||||
|
||||
regex_designs = sorted(set([r['design'] for r in results
|
||||
if r['spec_source'] == 'regex']))
|
||||
if regex_designs:
|
||||
_section('regex', 'Designs read with regexes, not an extracted spec',
|
||||
', '.join(regex_designs),
|
||||
[(fold.MD, '**{0}** had no extracted spec, so the summary was '
|
||||
'read with regexes instead. That reading is weaker '
|
||||
'-- run **Extract Design** on them.'
|
||||
.format(', '.join(regex_designs)))],
|
||||
problem='{0} design(s) are being judged against a weaker '
|
||||
'reading'.format(len(regex_designs)))
|
||||
|
||||
_na_notes, _na_problems, _profile_lines = [], [], []
|
||||
_off_board = []
|
||||
_seen_na = set()
|
||||
for r in results:
|
||||
_profile_lines.append(TP.profile_line(
|
||||
r['ticket'].get('mark') or '(no mark)', _plant_id,
|
||||
r.get('member'), r.get('profile'), r.get('off_board')))
|
||||
for _num in (r.get('off_board') or []):
|
||||
if _num not in _off_board:
|
||||
_off_board.append(_num)
|
||||
_profile_lines.append(TP.classification_line(
|
||||
r['ticket'].get('mark') or '(no mark)', r.get('classification'),
|
||||
r.get('applicability_notes')))
|
||||
for note in (r.get('applicability_notes') or []):
|
||||
if note[0] not in _seen_na:
|
||||
_seen_na.add(note[0])
|
||||
_na_notes.append(note)
|
||||
for problem in (r.get('applicability_problems') or []):
|
||||
if problem not in _na_problems:
|
||||
_na_problems.append(problem)
|
||||
_pp = (r.get('profile') or {}).get('problem')
|
||||
if _pp and ('profile', _pp) not in _na_problems:
|
||||
_na_problems.append(('profile', _pp))
|
||||
for _written, _why in _mute_problems:
|
||||
_entry = ('mute {0!r}'.format(_written), _why)
|
||||
if _entry not in _na_problems:
|
||||
_na_problems.append(_entry)
|
||||
|
||||
_na_blocks = [(fold.MD, line) for line in _profile_lines]
|
||||
if _na_notes:
|
||||
_na_blocks.append((fold.MD, '**Rows not asked, and who said so:**'))
|
||||
_na_blocks.extend([(fold.MD, line)
|
||||
for line in TP.applicability_lines(_na_notes)])
|
||||
if _na_problems:
|
||||
_na_blocks.append((fold.MD, '**Declarations that were REFUSED, so their '
|
||||
'rows RAN:**'))
|
||||
_na_blocks.extend([(fold.MD, '**{0}** — {1}'.format(num, text))
|
||||
for (num, text) in _na_problems])
|
||||
_na_blocks.append(
|
||||
(fold.MD, '_A row shown as NOT APPLICABLE was **not run at all**. That '
|
||||
'is not muting (a muted check runs and is repainted grey), not '
|
||||
'scope (which sets aside a whole piece), and not dormancy (a '
|
||||
'check whose enabling knowledge is unset). It is a knowledge '
|
||||
'layer declaring that this question is not about this kind of '
|
||||
'piece. A row that FAILED is never switched off -- the '
|
||||
'declaration is declined and says so. A row scoped to a panel '
|
||||
'type, on a piece nothing could classify, is NOT RUN in red, '
|
||||
'never quiet: nobody could ask it._'))
|
||||
_section('applicability', 'Which checklist ran on each piece, and what it '
|
||||
'did not ask',
|
||||
'{0} row(s) not asked; {1} declaration(s) refused'
|
||||
.format(len(_na_notes), len(_na_problems)),
|
||||
_na_blocks,
|
||||
problem=TP.applicability_problem(_na_notes, _na_problems))
|
||||
|
||||
_all_muted = []
|
||||
_seen_muted = set()
|
||||
for r in results:
|
||||
for note in r['muted_notes']:
|
||||
if note[0] not in _seen_muted:
|
||||
_seen_muted.add(note[0])
|
||||
_all_muted.append(note)
|
||||
if _all_muted:
|
||||
_section('muted', 'Checklist items muted on this job',
|
||||
'{0} item(s): {1}'.format(
|
||||
len(_all_muted), ', '.join([n[0] for n in _all_muted])),
|
||||
[(fold.MD, line) for line in TP.muting_lines(_all_muted)] +
|
||||
[(fold.MD, '_Muting does not remove a row -- it stays on the '
|
||||
'table, repainted grey as MUTED, with this note '
|
||||
'saying what the check really reported. The check '
|
||||
'still runs every time; only the presentation '
|
||||
'changes. It never removes a finding: everything '
|
||||
'below still reaches the machine-readable JSON '
|
||||
'and still counts in the header line, and a muted '
|
||||
'item that FAILED keeps its own red on the table '
|
||||
'-- muting never softens a failure._')],
|
||||
problem=TP.muting_problem(_all_muted))
|
||||
|
||||
if tally:
|
||||
_section('tally', 'Issues by code',
|
||||
'{0} code(s) across {1} piece(s)'
|
||||
.format(len(tally), sum(tally.values())),
|
||||
[(fold.TABLE, ['issue', 'pieces'],
|
||||
[[c, str(n)] for c, n in
|
||||
sorted(tally.items(), key=lambda kv: -kv[1])])])
|
||||
|
||||
seen, intent_rows = [], []
|
||||
for r in results:
|
||||
intent = r['intent']
|
||||
if intent['name'] in seen:
|
||||
continue
|
||||
seen.append(intent['name'])
|
||||
env = intent.get('envelope') or {}
|
||||
|
||||
def bound(key, _env=env):
|
||||
return _env[key][1] if key in _env else '-'
|
||||
|
||||
intent_rows.append([
|
||||
intent['name'], r['spec_source'],
|
||||
Q._fmt_num(intent.get('fc')), Q._fmt_num(intent.get('fci')),
|
||||
Q._fmt_num(intent.get('pcf')),
|
||||
Q.strand_signature(intent.get('strands') or []),
|
||||
intent.get('wwf_mesh') or '-',
|
||||
'{0} .. {1}'.format(bound('MIN_LENGTH'), bound('MAX_LENGTH')),
|
||||
'{0} .. {1}'.format(bound('MIN_WIDTH'), bound('MAX_WIDTH')),
|
||||
])
|
||||
_section('intent', 'Design intent being enforced',
|
||||
'{0} design(s), from {1}'.format(
|
||||
len(intent_rows),
|
||||
', '.join(sorted(set([r[1] for r in intent_rows]))) or '-'),
|
||||
[(fold.TABLE,
|
||||
['design', 'from', "f'c", "f'ci", 'pcf', 'strands', 'W.W.F.',
|
||||
'length', 'width'], intent_rows)])
|
||||
|
||||
if scope_annotation is None:
|
||||
scope_annotation = the_scope.annotation(
|
||||
[str(a.AssemblyTypeName) for a in assemblies
|
||||
if not the_scope.includes(a.AssemblyTypeName)], unit='piece')
|
||||
_scope_blocks = [(fold.MD, '> ' + (scope_line or
|
||||
the_scope.line([], unit='piece')))]
|
||||
if scope_annotation:
|
||||
_scope_blocks.append((fold.MD, '> ' + scope_annotation))
|
||||
for _note in the_scope.declaration_lines():
|
||||
_scope_blocks.append((fold.MD, '_{0}_'.format(_note)))
|
||||
if doc.PathName:
|
||||
_scope_blocks.append((fold.MD, 'Knowledge file: `{0}`'
|
||||
.format(designspec.knowledge_path(doc))))
|
||||
_section('scope', 'Scope and knowledge',
|
||||
'what this run was and was not asked to judge',
|
||||
_scope_blocks,
|
||||
problem=(scope_annotation or None))
|
||||
|
||||
if any(f.code.startswith('HANDLE-') for r in results for f in r['findings']):
|
||||
_section('handling', 'How the lifting/handling result was obtained',
|
||||
'`HANDLE-*` rows on this run',
|
||||
[(fold.MD,
|
||||
'A current **Panel Lift Check** or sweep result is reused when '
|
||||
'one is on file; otherwise a default-stage check runs on the '
|
||||
'spot, rate limited so this does not hang on a large design. '
|
||||
'`HANDLE-STALE` means a saved result no longer matches the '
|
||||
'piece and was not used. A result not tagged "reviewed Panel '
|
||||
'Lift Check" is good enough to find a problem, not good enough '
|
||||
'to sign -- see docs/lift-check.md.')])
|
||||
|
||||
_read = [(r['ticket']['mark'], f)
|
||||
for r in results for f in (r.get('model_read') or [])]
|
||||
if _read:
|
||||
_head, _read_rows = TV.report_block(_read)
|
||||
_section('modelread', 'Read by a model (never a verdict)',
|
||||
'{0} row(s) a person must judge'.format(len(_read_rows)),
|
||||
[(fold.MD, _head),
|
||||
(fold.TABLE, list(TV.REPORT_COLUMNS), _read_rows)],
|
||||
problem='a model was asked to look at {0} thing(s); nothing '
|
||||
'here is a pass or a fail'.format(len(_read_rows)))
|
||||
elif _vision_on:
|
||||
output.print_md('> The vision escalation was on and produced nothing, '
|
||||
'which should not happen - every escalation reports a '
|
||||
'reason even when it does not run.')
|
||||
|
||||
# ---- THE MACHINE-READABLE COPY. Written on EVERY press.
|
||||
try:
|
||||
_doc = TV.qc_json([f for r in results for f in r['findings']],
|
||||
[f for r in results
|
||||
for f in (r.get('model_read') or [])],
|
||||
tickets=results,
|
||||
envelope=Q.run_envelope(TV.RUN_SCHEMA, doc=doc))
|
||||
_path = TV.write_run_json(designspec.root_dir(doc), _doc,
|
||||
name=_JSON_NAME[mode])
|
||||
output.print_md('_Machine-readable: `{0}`_'.format(_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the JSON copy could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- THE EVIDENCE FILE.
|
||||
try:
|
||||
_ev_doc = Q.run_evidence(results, doc=doc)
|
||||
_ev_path = Q.write_evidence(
|
||||
designspec.sub_dir(doc, _EVIDENCE_FOLDER[mode]), _ev_doc)
|
||||
output.print_md('_Evidence (nothing reads it yet): `{0}`_'
|
||||
.format(_ev_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the evidence file could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- FOLDED IS NOT GONE.
|
||||
full_path = None
|
||||
try:
|
||||
full_path = os.path.join(designspec.root_dir(doc), _TEXT_NAME[mode])
|
||||
_lines = [TITLE.upper() + ' -- ' + str(doc.Title), '']
|
||||
for r in loud + all_green:
|
||||
_t = r['ticket']
|
||||
_lines.append('')
|
||||
_lines.append('{0} [{1}] {2}'.format(
|
||||
_t['mark'], r['design'],
|
||||
TP.checklist_headline(r['counts']).replace('·', '-')))
|
||||
_lines.append('-' * 78)
|
||||
_lines.append(TP.checklist_table_text(r['rows'], r['details']))
|
||||
_lines.append('')
|
||||
_lines.append(fold.text(sections, show_all=True))
|
||||
payload = compat.as_text('\n'.join(_lines))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception as _exc:
|
||||
full_path = None
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold headers.'
|
||||
.format(_exc))
|
||||
|
||||
if sections:
|
||||
opened = fold.sections_with_problems(sections)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(sections)))
|
||||
else:
|
||||
output.print_md('**Nothing below the tables needs you.** Every section '
|
||||
'is folded; click a header to open one.')
|
||||
if full_path:
|
||||
output.print_md('_The whole report, nothing folded, is in_ `{0}`'
|
||||
.format(full_path))
|
||||
output.print_html(fold.html(sections))
|
||||
|
||||
runlog.record(TITLE, doc_title=doc.Title,
|
||||
marks=[r['ticket'].get('mark') for r in results],
|
||||
outcome='ok', report_path=full_path)
|
||||
Reference in New Issue
Block a user