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.
426 lines
17 KiB
Python
426 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Check every WP assembly in the open model, and rank what is wrong.
|
|
|
|
python tools/sweep.py measure in Revit, then check
|
|
python tools/sweep.py --check re-check the saved job files only
|
|
python tools/sweep.py --out DIR somewhere other than beside the model
|
|
|
|
Two halves, deliberately separable:
|
|
|
|
**Measure** drives `tools/sweep_in_revit.py` through the bridge, ten pieces a
|
|
call, until every assembly has been measured. It only reads the model. It is
|
|
resumable -- stop it, run it again, it carries on.
|
|
|
|
**Check** runs `panellift` over the job files that came out. That half needs
|
|
no Revit at all, because a job file is the complete input to the check. So a
|
|
change to the mechanics can be re-checked against the whole model in a couple
|
|
of minutes, and that is the point of keeping the job files rather than only the
|
|
reports.
|
|
|
|
That promise had quietly stopped being true. The position search grew until it
|
|
was 93% of a run and this sweep took **42 minutes**, which is long enough that
|
|
nobody re-checks anything. It passes `--no-recommend` now -- it never read a
|
|
recommendation in the first place -- and is back to about a minute of compute.
|
|
|
|
That separability has a trap in it, and this refuses on it now: a job file is
|
|
the complete input to the check, so a sweep folder can be re-checked against a
|
|
model it was never measured from and every report will still render, still
|
|
reconcile, and still be about the wrong piece. Each job file records
|
|
`source.model`; if that names a model other than the open document, nothing is
|
|
checked and the message names both. `--any-model` proceeds anyway and says so
|
|
at the top of the output; `--model TITLE` states the model when Revit is not
|
|
running. With neither, the guard reports that it could not judge -- which is
|
|
not the same answer as agreement, and is printed rather than assumed.
|
|
|
|
Writes `problems.txt` and `problems.json` next to the jobs.
|
|
|
|
What this does NOT do is choose the inserts. The stages come from
|
|
`liftexport.default_stages`, which is what the grouping rules imply with
|
|
nobody having clicked anything -- good enough to find what breaks across a
|
|
whole model, not good enough to sign. See docs/handoff.md.
|
|
"""
|
|
from __future__ import print_function
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
|
|
RVT = os.path.join(HERE, 'rvt.py')
|
|
IN_REVIT = os.path.join(HERE, 'sweep_in_revit.py')
|
|
PANELLIFT = os.path.join(REPO, 'panellift')
|
|
SRC = os.path.join(PANELLIFT, 'src')
|
|
|
|
|
|
# ------------------------------------------------------------------- files
|
|
|
|
def read(path):
|
|
with io.open(path, 'r', encoding='utf-8') as fh:
|
|
return json.loads(fh.read())
|
|
|
|
|
|
def write(path, obj):
|
|
with io.open(path, 'w', encoding='utf-8') as fh:
|
|
fh.write(json.dumps(obj, indent=1, sort_keys=True, default=str))
|
|
|
|
|
|
# ------------------------------------------------------------------- Revit
|
|
|
|
def bridge(*args):
|
|
"""One rvt.py call. Returns (ok, parsed stdout or raw text)."""
|
|
r = subprocess.run([sys.executable, RVT] + list(args),
|
|
cwd=REPO, capture_output=True, text=True)
|
|
text = (r.stdout or '').strip()
|
|
try:
|
|
return r.returncode == 0, json.loads(text)
|
|
except ValueError:
|
|
return False, text or (r.stderr or '').strip()
|
|
|
|
|
|
def sweep_dir():
|
|
"""Ask the open model where its sweep folder is."""
|
|
ok, out = bridge('exec', '-c',
|
|
"from vibe import designspec; "
|
|
"result = designspec.sub_dir(doc, 'sweep')")
|
|
if not ok or not isinstance(out, str):
|
|
raise SystemExit(
|
|
'Could not reach Revit: {0}\n\n'
|
|
'Revit closed, or a modal dialog is open in it -- handlers run in '
|
|
"Revit's API context and cannot fire behind a dialog.".format(out))
|
|
return out
|
|
|
|
|
|
def open_model_title():
|
|
"""`doc.Title` of the open model, or None if Revit is not reachable.
|
|
|
|
None is "could not ask", never "it matches" -- see `check_source_model`.
|
|
"""
|
|
ok, out = bridge('exec', '-c', 'result = doc.Title')
|
|
if not ok or not isinstance(out, str) or not out.strip():
|
|
return None
|
|
return out.strip()
|
|
|
|
|
|
# ------------------------------------------------- which model is this OF?
|
|
#
|
|
# A job file is the complete input to the check, which is what makes the
|
|
# offline half possible -- and it is also how a whole sweep can be re-checked
|
|
# against a model it was never measured from without anything looking wrong.
|
|
# Measured 2026-09-01: every one of the reference sweep's job files carries
|
|
# `source.model` naming one model, while the document open in Revit was a
|
|
# detached copy that had since DIVERGED from it -- one notch 3.5 in in one
|
|
# and 2.5 in in the other. Every report still rendered, every number still
|
|
# reconciled against the job file it came from, and the two models were only
|
|
# told apart by hand. `source.model` is the field that would have caught it
|
|
# immediately, and nothing was reading it.
|
|
|
|
def model_stem(name):
|
|
"""'sample_job.rvt' and 'sample_job' are the same model.
|
|
|
|
`source.model` is written as `os.path.basename(doc.PathName)`, so it
|
|
carries the extension; `doc.Title` does not. Compared case-insensitively
|
|
because Windows does. Returns None for anything empty -- and None is a
|
|
question, not a match.
|
|
"""
|
|
if not name:
|
|
return None
|
|
stem = os.path.basename(str(name).strip())
|
|
if stem.lower().endswith('.rvt'):
|
|
stem = stem[:-len('.rvt')]
|
|
return stem.strip().lower() or None
|
|
|
|
|
|
def check_source_model(jobs, doc_title):
|
|
"""Which of these job files were exported from a different model.
|
|
|
|
`jobs` is [(mark, job dict)]. Returns (mismatched, unstamped, reason):
|
|
|
|
mismatched [(mark, the model the job file names)] -- exported from a
|
|
DIFFERENT model from the one open. This is the refusal.
|
|
unstamped [mark] for job files carrying no `source.model` at all.
|
|
Written before the field existed, or by hand: they cannot
|
|
be judged, and they are NOT thereby cleared.
|
|
reason a sentence, when nothing could be judged at all because
|
|
there is no open document to compare against. `mismatched`
|
|
is then empty and that emptiness means "did not ask" --
|
|
which is why the caller has to print the reason rather than
|
|
read an empty list as agreement.
|
|
"""
|
|
want = model_stem(doc_title)
|
|
if want is None:
|
|
return [], [mark for mark, _job in jobs], (
|
|
'no open document to compare against, so nothing here says '
|
|
'whether these job files were measured from the model you mean')
|
|
mismatched = []
|
|
unstamped = []
|
|
for mark, job in jobs:
|
|
said = (job.get('source') or {}).get('model')
|
|
stem = model_stem(said)
|
|
if stem is None:
|
|
unstamped.append(mark)
|
|
elif stem != want:
|
|
mismatched.append((mark, said))
|
|
return mismatched, unstamped, None
|
|
|
|
|
|
def model_refusal(mismatched, doc_title, total):
|
|
"""The message the mismatch refuses with. Names BOTH models, always."""
|
|
said = sorted(set(name for _mark, name in mismatched))
|
|
return (
|
|
'These job files were exported from a different model.\n\n'
|
|
' the open document says {open}\n'
|
|
' the job files say {said}\n\n'
|
|
'{n} of {total} job file(s) name a model that is not the one open, '
|
|
'and the first of them is {first}.\n\n'
|
|
'Nothing was checked. A job file is the complete input to the check, '
|
|
'so this would have run to completion and produced a full set of '
|
|
'plausible reports about a model nobody is looking at -- which is '
|
|
'exactly what happened before this refusal existed, on two copies of '
|
|
'one model that had since diverged.\n\n'
|
|
'Re-export the sweep from the open model, point --out at the sweep '
|
|
'folder that belongs to it, or pass --any-model if you really do mean '
|
|
'to check these files against this document.'.format(
|
|
open=doc_title, said=', '.join(said),
|
|
n=len(mismatched), total=total,
|
|
first=sorted(mark for mark, _name in mismatched)[0]))
|
|
|
|
|
|
def measure():
|
|
"""Drive the in-Revit half until every assembly has a meta file."""
|
|
root = sweep_dir()
|
|
print('sweep folder: %s' % root)
|
|
meta_dir = os.path.join(root, 'meta')
|
|
todo_path = os.path.join(root, 'todo.json')
|
|
|
|
stuck = 0
|
|
while True:
|
|
total = len(read(todo_path)) if os.path.isfile(todo_path) else 0
|
|
done = len(os.listdir(meta_dir)) if os.path.isdir(meta_dir) else 0
|
|
if total and done >= total:
|
|
print('measured %d/%d' % (done, total))
|
|
return root
|
|
|
|
ok, out = bridge('exec', IN_REVIT)
|
|
print('[%d/%d] %s' % (done, total, str(out)[:200]))
|
|
sys.stdout.flush()
|
|
if not ok or 'did 0 ' in str(out):
|
|
stuck += 1
|
|
if stuck >= 3:
|
|
print('STOPPING: three calls in a row got nowhere. Revit is '
|
|
'busy, or a dialog is open in it.')
|
|
return root
|
|
time.sleep(10)
|
|
else:
|
|
stuck = 0
|
|
|
|
|
|
# --------------------------------------------------------------- the check
|
|
|
|
def check(root, doc_title=None, any_model=False):
|
|
jobs_dir = os.path.join(root, 'jobs')
|
|
meta_dir = os.path.join(root, 'meta')
|
|
reports = os.path.join(root, 'reports')
|
|
if not os.path.isdir(reports):
|
|
os.makedirs(reports)
|
|
|
|
env = dict(os.environ, PYTHONPATH=SRC)
|
|
rows = []
|
|
names = sorted(n for n in os.listdir(jobs_dir) if n.endswith('.job.json'))
|
|
|
|
# WHICH MODEL, before a single number is computed. See check_source_model.
|
|
jobs = [(n[:-len('.job.json')], read(os.path.join(jobs_dir, n)))
|
|
for n in names]
|
|
mismatched, unstamped, reason = check_source_model(jobs, doc_title)
|
|
notes = []
|
|
if mismatched and not any_model:
|
|
raise SystemExit(model_refusal(mismatched, doc_title, len(jobs)))
|
|
if mismatched:
|
|
notes.append(
|
|
'!! --any-model: %d of %d job file(s) name a model that is not '
|
|
'%s. These reports are about a different model.'
|
|
% (len(mismatched), len(jobs), doc_title))
|
|
if reason:
|
|
notes.append('!! model NOT verified: %s.' % reason)
|
|
if unstamped and not reason:
|
|
notes.append(
|
|
'!! %d of %d job file(s) carry no source.model, so which model '
|
|
'they came from was not checked.' % (len(unstamped), len(jobs)))
|
|
for note in notes:
|
|
print(note)
|
|
|
|
for i, name in enumerate(names, 1):
|
|
mark = name[:-len('.job.json')]
|
|
meta = read(os.path.join(meta_dir, mark + '.json'))
|
|
row = dict((k, meta.get(k)) for k in
|
|
('mark', 'design', 'axis', 'length_ft', 'width_ft',
|
|
'thickness_in', 'lifters', 'strands'))
|
|
# --no-recommend, because this sweep never reads a recommendation:
|
|
# `row` below takes the verdict, the per-stage ratios and the findings
|
|
# and nothing else. The position search is 93% of a run -- 41 minutes
|
|
# of this sweep's 42 -- and every second of it was being computed,
|
|
# written to an HTML file and thrown away. The verdicts, ratios and
|
|
# findings are untouched by the flag; the reports say on the page that
|
|
# the positions were not computed, so nobody reads one as a piece with
|
|
# nothing to recommend. Re-run a single piece through the button, or
|
|
# `python -m panellift <job> -o <html>` by hand, to get positions.
|
|
r = subprocess.run(
|
|
[sys.executable, '-m', 'panellift', os.path.join(jobs_dir, name),
|
|
'-o', os.path.join(reports, mark + '.html'), '--json',
|
|
'--no-recommend'],
|
|
cwd=PANELLIFT, env=env, capture_output=True, text=True)
|
|
text = (r.stdout or '').strip()
|
|
# A FAIL verdict exits non-zero, so the exit code says nothing about
|
|
# whether the check ran. The JSON does.
|
|
if not text.startswith('{'):
|
|
row['verdict'] = 'CHECK CRASHED'
|
|
row['error'] = (r.stderr or r.stdout or '')[-1200:]
|
|
else:
|
|
res = json.loads(text)
|
|
row['verdict'] = res.get('verdict')
|
|
row['stages'] = [
|
|
{'name': s.get('name'), 'ratio': s.get('ratio'),
|
|
'verdict': s.get('verdict'),
|
|
'findings': [(f.get('severity'), f.get('code'),
|
|
f.get('message'))
|
|
for f in s.get('findings') or []]}
|
|
for s in res.get('stages') or []]
|
|
if i % 25 == 0 or i == len(names):
|
|
print('checked %d/%d' % (i, len(names)))
|
|
sys.stdout.flush()
|
|
rows.append(row)
|
|
return rows, notes
|
|
|
|
|
|
def refused(root):
|
|
"""The pieces that never produced a job file, and why."""
|
|
meta_dir = os.path.join(root, 'meta')
|
|
out = []
|
|
for name in sorted(os.listdir(meta_dir)):
|
|
meta = read(os.path.join(meta_dir, name))
|
|
if meta.get('status') != 'exported':
|
|
out.append(meta)
|
|
return out
|
|
|
|
|
|
# -------------------------------------------------------------- the report
|
|
|
|
def worst(row):
|
|
return max([s.get('ratio') or 0.0
|
|
for s in row.get('stages') or []] or [0.0])
|
|
|
|
|
|
def render(rows, not_exported, notes=()):
|
|
lines = []
|
|
|
|
def say(s=''):
|
|
lines.append(s)
|
|
|
|
# At the top, and inside problems.txt as well as on the terminal: a
|
|
# caveat that only ever scrolled past is a caveat nobody read.
|
|
for note in notes or ():
|
|
say(note)
|
|
if notes:
|
|
say()
|
|
|
|
say('%d pieces checked, %d never got that far'
|
|
% (len(rows), len(not_exported)))
|
|
counts = {}
|
|
for r in rows:
|
|
counts[r.get('verdict')] = counts.get(r.get('verdict'), 0) + 1
|
|
for k in sorted(counts, key=lambda k: -counts[k]):
|
|
say(' %-14s %d' % (k, counts[k]))
|
|
say()
|
|
|
|
checked = [r for r in rows if r.get('stages')]
|
|
over = sorted([r for r in checked if worst(r) > 0.8], key=worst,
|
|
reverse=True)
|
|
say('== OVERSTRESSED: a stage above 0.80 of its allowable tension ==')
|
|
if not over:
|
|
say(' none')
|
|
for r in over:
|
|
say(' %-8s %6.1f x %5.1f ft %s'
|
|
% (r['mark'], r.get('length_ft') or 0, r.get('width_ft') or 0,
|
|
' '.join('%s %.2f' % (s['name'][:4], s.get('ratio') or 0)
|
|
for s in r['stages'])))
|
|
say()
|
|
|
|
for severity in ('FAIL', 'WARN'):
|
|
buckets = {}
|
|
for r in checked:
|
|
for stage in r['stages']:
|
|
for sev, code, message in stage['findings']:
|
|
if sev == severity:
|
|
buckets.setdefault(code, {})[r['mark']] = message
|
|
say('== %s findings, by code ==' % severity)
|
|
if not buckets:
|
|
say(' none')
|
|
for code in sorted(buckets, key=lambda c: -len(buckets[c])):
|
|
marks = sorted(buckets[code])
|
|
say(' %-8s %3d %s' % (code, len(marks), ', '.join(marks[:10])))
|
|
say(' %s' % list(buckets[code].values())[0][:150])
|
|
say()
|
|
|
|
crashed = [r for r in rows if r.get('verdict') == 'CHECK CRASHED']
|
|
if crashed:
|
|
say('== CHECK CRASHED: %d ==' % len(crashed))
|
|
for r in crashed:
|
|
say(' %-8s lifters=%s %s'
|
|
% (r['mark'], r.get('lifters'),
|
|
str(r.get('error'))[-200:].replace(chr(10), ' ')))
|
|
say()
|
|
|
|
if not_exported:
|
|
reasons = {}
|
|
for meta in not_exported:
|
|
why = '; '.join(meta.get('problems') or []) or meta.get('status')
|
|
reasons.setdefault(str(why)[:200], []).append(meta['mark'])
|
|
say('== NEVER CHECKED: %d, by reason ==' % len(not_exported))
|
|
for why in sorted(reasons, key=lambda w: -len(reasons[w])):
|
|
say(' %d x %s' % (len(reasons[why]), why))
|
|
say(' %s' % ', '.join(sorted(reasons[why])[:8]))
|
|
return lines
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('--check', action='store_true',
|
|
help='skip the Revit half and re-check saved job files')
|
|
ap.add_argument('--out', help='sweep folder (default: beside the model)')
|
|
ap.add_argument('--model', help='the model title the job files should '
|
|
'name; default is whatever Revit has open')
|
|
ap.add_argument('--any-model', action='store_true',
|
|
help='check these job files even though they were '
|
|
'exported from a different model')
|
|
args = ap.parse_args()
|
|
|
|
if args.out:
|
|
root = args.out
|
|
elif args.check:
|
|
root = sweep_dir()
|
|
else:
|
|
root = measure()
|
|
|
|
# --model lets the guard work with no Revit at all (an offline re-check of
|
|
# a sweep folder whose model is not open); with neither, the title is
|
|
# None and the guard says it could not judge instead of saying it agrees.
|
|
doc_title = args.model or open_model_title()
|
|
|
|
rows, notes = check(root, doc_title, any_model=args.any_model)
|
|
write(os.path.join(root, 'problems.json'), rows)
|
|
text = chr(10).join(render(rows, refused(root), notes))
|
|
with io.open(os.path.join(root, 'problems.txt'), 'w',
|
|
encoding='utf-8') as fh:
|
|
fh.write(text + chr(10))
|
|
print()
|
|
print(text)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|