52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
323 lines
12 KiB
Python
323 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# Report whether this machine, this Revit and this model can run the QC tools.
|
|
#
|
|
# Written for the moment the extension lands on a computer it was not developed
|
|
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
|
|
# so a failure names itself instead of arriving as a traceback from four frames
|
|
# deep in a checking routine.
|
|
#
|
|
# Reads nothing but metadata and does not touch the model.
|
|
|
|
# pylint: disable=import-error,invalid-name,broad-except
|
|
__title__ = 'Self Check'
|
|
|
|
import os
|
|
import sys
|
|
|
|
from pyrevit import revit, script, HOST_APP
|
|
|
|
output = script.get_output()
|
|
output.close_others()
|
|
|
|
doc = revit.doc
|
|
rows = []
|
|
|
|
|
|
def report(area, item, state, detail):
|
|
rows.append([area, item, state, detail])
|
|
|
|
|
|
def probe(area, item, fn, missing='FAIL'):
|
|
"""Run one probe. An exception is a finding, never a crash."""
|
|
try:
|
|
state, detail = fn()
|
|
report(area, item, state or missing, detail)
|
|
except Exception as ex:
|
|
report(area, item, missing, '{0}: {1}'.format(type(ex).__name__, ex))
|
|
|
|
|
|
# ------------------------------------------------------------------- host
|
|
|
|
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
|
|
|
|
|
|
def _revit():
|
|
app = HOST_APP.app
|
|
version = app.VersionNumber
|
|
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
|
|
if version == DEVELOPED_ON['revit']:
|
|
return 'OK', detail
|
|
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
|
|
DEVELOPED_ON['revit'])
|
|
|
|
|
|
def _pyrevit():
|
|
from pyrevit import versionmgr
|
|
version = versionmgr.get_pyrevit_version().get_formatted()
|
|
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
|
|
return state, version
|
|
|
|
|
|
def _engine():
|
|
detail = sys.version.split('\n')[0]
|
|
major = sys.version_info[0]
|
|
if major == 2:
|
|
return 'OK', detail
|
|
# The code is written for both, but only Python 2 has been exercised.
|
|
return 'WARN', detail + ' (only Python 2 has been run in anger)'
|
|
|
|
|
|
def _routes():
|
|
from pyrevit.userconfig import user_config
|
|
if not user_config.routes_server:
|
|
return 'WARN', ('routes server off - out-of-process tools will not '
|
|
'connect. The buttons themselves do not need it.')
|
|
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
|
|
user_config.routes_port)
|
|
|
|
|
|
probe('host', 'Revit', _revit)
|
|
probe('host', 'pyRevit', _pyrevit)
|
|
probe('host', 'python engine', _engine)
|
|
probe('host', 'routes bridge', _routes, missing='WARN')
|
|
|
|
|
|
# ---------------------------------------------------------------- library
|
|
|
|
def _import_all():
|
|
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
|
|
ticketqc, ticketchecks)
|
|
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
|
|
'ticketchecks'
|
|
|
|
|
|
def _units():
|
|
from vibe import ft, mm
|
|
round_trip = mm(ft(1000.0))
|
|
if abs(round_trip - 1000.0) > 1e-6:
|
|
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
|
|
return 'OK', '1000 mm round-trips exactly'
|
|
|
|
|
|
def _checklist():
|
|
from vibe import ticketpage
|
|
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
|
|
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
|
|
return 'OK', '{0} row(s) from {1}'.format(
|
|
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
|
|
|
|
|
|
probe('library', 'modules import', _import_all)
|
|
probe('library', 'unit conversion', _units)
|
|
probe('library', 'published checklist', _checklist, missing='WARN')
|
|
|
|
|
|
# ------------------------------------------------------------------- model
|
|
|
|
def _saved():
|
|
if not doc.PathName:
|
|
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
|
|
return 'OK', doc.PathName
|
|
|
|
|
|
def _data_dir():
|
|
from vibe import designspec
|
|
return 'OK', designspec.root_dir(doc)
|
|
|
|
|
|
def _knowledge():
|
|
"""Every knowledge layer, in the order load() merges them.
|
|
|
|
Which layers exist decides what the tool actually believes, so the answer
|
|
is the list, not a yes/no: naming conventions that should be plant-wide
|
|
sitting only in a project file is a real and invisible mistake.
|
|
"""
|
|
from vibe import knowledge
|
|
layers = knowledge.sources(doc)
|
|
detail = ' | '.join(
|
|
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
|
|
for label, path, ok in layers])
|
|
|
|
# A project config that has gone missing is worse than one never set: the
|
|
# run looks entirely normal and uses a configuration nobody chose.
|
|
gone = [path for label, path, ok in layers
|
|
if label == 'project config' and not ok]
|
|
if gone:
|
|
return 'FAIL', ('the project config this model runs on is not there: '
|
|
'{0} || {1}'.format(gone[0], detail))
|
|
if not any(ok for _label, _p, ok in layers):
|
|
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
|
|
return 'OK', detail
|
|
|
|
|
|
def _knowledge_parses():
|
|
"""Read every layer file that IS there, exactly the way load() reads it.
|
|
|
|
The layer list above says which files exist. It does not say whether any
|
|
of them can be READ, and those are different questions with the same
|
|
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
|
|
mapping raises `Duplicate key` and it returns nothing -- and a layer that
|
|
fails to load looks exactly like a layer that agrees with the defaults.
|
|
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
|
|
layer, every parameter role came back None, the sweep enumerated 0
|
|
assemblies, and every report rendered normally.
|
|
|
|
So this re-reads each file through the same reader and prints ok or the
|
|
parser's own message. `knowledge.config_problems()` reports the same
|
|
failure from the merge itself; this is the early warning, on the layer
|
|
list, before a button is pressed. `python tools/knowledgelint.py` is the
|
|
same check on the host, where the edit is made.
|
|
"""
|
|
from pyrevit.coreutils import yaml as pyyaml
|
|
from vibe import knowledge
|
|
|
|
notes = []
|
|
bad = []
|
|
for label, path, ok in knowledge.sources(doc):
|
|
if not ok:
|
|
notes.append('{0}: absent'.format(label))
|
|
continue
|
|
try:
|
|
pyyaml.load_as_dict(path)
|
|
notes.append('{0}: ok'.format(label))
|
|
except Exception as ex:
|
|
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
|
|
label, path, type(ex).__name__, ex))
|
|
if bad:
|
|
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
|
|
'was used, and the run fell back to the layers under '
|
|
'it. Fix the file, then run '
|
|
'tools/knowledgelint.py to confirm.')
|
|
if not notes:
|
|
return 'WARN', 'no knowledge layer to read yet'
|
|
return 'OK', ' | '.join(notes)
|
|
|
|
|
|
def _specs():
|
|
from vibe import designspec
|
|
specs = designspec.list_specs(doc)
|
|
if not specs:
|
|
return 'WARN', 'none yet - run Extract Design'
|
|
unreviewed = [d for d, s in specs.items()
|
|
if not (s.get('source') or {}).get('reviewed_by')]
|
|
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
|
|
if unreviewed:
|
|
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
|
|
return 'OK', detail
|
|
|
|
|
|
def _scope():
|
|
"""What the job declares this engineer is answerable for.
|
|
|
|
Never a FAIL. An undeclared scope is a legitimate state -- it means every
|
|
button judges every piece -- and it is reported as such rather than as a
|
|
misconfiguration, because 'judge everything' is the honest default and
|
|
the only wrong answer here would be a scope the tool chose for itself.
|
|
"""
|
|
from vibe import knowledge
|
|
from vibe import scope as scopelib
|
|
the_scope = scopelib.of(knowledge.load(doc))
|
|
if not the_scope.is_declared:
|
|
return 'WARN', ('no scope declared -- every button judges every '
|
|
'piece on the job, including work whose plant '
|
|
'knowledge has never been validated. Set '
|
|
'scope.mark_prefixes in the job knowledge file to '
|
|
'be told what is being taken on trust.')
|
|
detail = the_scope.label()
|
|
if the_scope.declared_by:
|
|
detail += ' (declared by {0})'.format(the_scope.declared_by)
|
|
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
|
|
|
|
|
|
probe('model', 'saved', _saved)
|
|
probe('model', 'declared scope', _scope, missing='WARN')
|
|
probe('model', 'data folder', _data_dir)
|
|
probe('model', 'knowledge layers', _knowledge, missing='WARN')
|
|
probe('model', 'knowledge layers parse', _knowledge_parses)
|
|
probe('model', 'design specs', _specs, missing='WARN')
|
|
|
|
|
|
# --------------------------------------------------- project conventions
|
|
# These are the assumptions most likely to be wrong on a different job: they
|
|
# are naming and parameter conventions, not Revit API.
|
|
|
|
def _mapping():
|
|
"""Which parameter roles this office has told us the names for.
|
|
|
|
Nothing in the code knows what a panel's length parameter is called, so
|
|
an unmapped role is not a detail -- it is a check that will not run. It
|
|
reports before the convention probes below, because they depend on it.
|
|
"""
|
|
from vibe import knowledge, designspec
|
|
know = knowledge.load(doc)
|
|
missing, not_applicable = knowledge.unmapped_roles(know)
|
|
total = len(knowledge.PARAMETER_ROLES)
|
|
if missing:
|
|
# The template is the plant's, so that is where the names belong; a
|
|
# project file may still override one.
|
|
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
|
|
len(missing), total, ', '.join(missing),
|
|
designspec.plant_knowledge_path(),
|
|
'; {0} declared not applicable: {1}'.format(
|
|
len(not_applicable), ', '.join(sorted(not_applicable)))
|
|
if not_applicable else '')
|
|
mapped = ['{0}={1}'.format(r.split('.')[-1],
|
|
knowledge.param_names(know, r)[0])
|
|
for r in knowledge.PARAMETER_ROLES
|
|
if knowledge.param_names(know, r)]
|
|
extra = ''
|
|
if not_applicable:
|
|
extra = '; {0} declared not applicable at this plant ({1})'.format(
|
|
len(not_applicable), ', '.join(sorted(not_applicable)))
|
|
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
|
|
|
|
|
|
def _designs():
|
|
from vibe import ticketqc
|
|
from vibe import knowledge
|
|
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
|
|
if not grouped:
|
|
return 'FAIL', ('no assembly carries a design number - either the '
|
|
'panel.design_number role is mapped to the wrong '
|
|
'parameter, or this model does not group pieces so')
|
|
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
|
|
return 'OK', '{0} designs, {1} pieces: {2}'.format(
|
|
len(grouped), sum([len(v) for v in grouped.values()]),
|
|
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
|
|
|
|
|
|
def _summaries():
|
|
from vibe import ticketqc
|
|
summaries, sheets = ticketqc.collect_context(doc)
|
|
if not summaries:
|
|
return 'FAIL', ('no drafting view is named after a design number - '
|
|
'Extract Design has nothing to read')
|
|
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
|
|
len(sheets))
|
|
|
|
|
|
probe('conventions', 'parameter role mapping', _mapping)
|
|
probe('conventions', 'design number groups assemblies', _designs)
|
|
probe('conventions', 'design summary views', _summaries)
|
|
|
|
|
|
# ------------------------------------------------------------------ verdict
|
|
|
|
output.print_md('# Self check')
|
|
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
|
|
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
|
|
|
|
fails = len([r for r in rows if r[2] == 'FAIL'])
|
|
warns = len([r for r in rows if r[2] == 'WARN'])
|
|
|
|
if fails:
|
|
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
|
|
output.print_md('A **FAIL** means a QC button will not work here. Start '
|
|
'with the first one - they cascade.')
|
|
elif warns:
|
|
output.print_md('## Ready, with {0} warning(s)'.format(warns))
|
|
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
|
|
else:
|
|
output.print_md('## Everything checks out')
|