Files
willtools-demo/tools/sweep.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

432 lines
18 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 ntpath
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
# `doc.PathName` -- and so `source.model` -- is always a WINDOWS path,
# drive letter and backslashes, even when this runs on a Linux dev box.
# `os.path.basename` only splits on the host's own separator, so it is
# a no-op for a Windows path on POSIX; `ntpath.basename` is correct on
# both.
stem = ntpath.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()