WillTools: precast shop-ticket QC and panel handling tools
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.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Is a frozen job deck still what CURRENT knowledge would freeze today?
python tools/deckcheck.py JOBS_DIR
python tools/deckcheck.py JOBS_DIR --json OUT.json
python tools/deckcheck.py JOBS_DIR --job-root DIR # skip the auto-guess
JOBS_DIR is a sweep's `jobs/` folder -- `*.job.json` files, each one panel's
complete frozen input deck (`docs/lift-check.md`, `docs/plan-panellift-
three-stage.md` SS5). Job files are input decks on purpose: a deck must
reproduce its own result forever, so `liftexport.py` freezes every knowledge-
derived value into it at export and `panellift` never re-reads the catalogue.
That is correct, and it is not what this tool checks.
WHAT WENT WRONG, TWICE, ON 2026-09-01
--------------------------------------
1. A capacity in `knowledge/catalogue.yaml` was corrected after 282 decks had
already been exported carrying the old number. Five real panels showed
FAIL at 113% off the stale value before anyone could say why, and
explaining it took a git-history dig across two repositories.
2. A frozen `panel.design_number` was read as CURRENT by an agent answering
the owner, and the model had moved since the export.
Freezing was never the defect. The defect is that staleness was
UNDETECTABLE -- nothing recorded which knowledge version froze a value, and
nothing could tell a live deck from a fossil. This tool detects it, for the
knowledge half only (point 4 below); a full-fidelity version, with a sha per
layer folded into every future deck, is designed in
`docs/plan-panellift-three-stage.md` SS5 and is not built here.
WHAT IT DOES
------------
For every `*.job.json` in JOBS_DIR, it re-runs the SAME knowledge lookups
`liftexport.py` ran at export time -- `knowledge.lifter_capacity()`,
`lifter_basis_fc()`, `lifter_edge_distance()`, `lifter_safety_factor()`, and
the stage-level `handling.tension_limit_sqrt_fc` -- against knowledge
re-resolved from the files ON DISK RIGHT NOW, and compares the fresh answer
to the number the deck already carries. A field is judged only when the deck
actually carries it (an older deck missing a field is silently not compared
on that field, never treated as a match).
Four outcomes, never collapsed into each other:
current the deck's frozen value is what current knowledge gives too
stale they disagree -- named: mark, field, frozen value, current
value, and (when `lifter_capacity`'s own `why` names one)
which layer moved
conditional `lifter_capacity`'s with-tension-bar branch depends on
`assembly_families` -- every OTHER member's family name on
the same assembly, which `liftexport.py` reads off the
live model and NEVER WRITES INTO THE DECK (see its own
comment on this: the basis/why strings ride along
specifically so this information is not lost, and the
gate's corroborating evidence is lost anyway). So when the
family is gated and the deck cannot settle which side of
the gate applies, this reports BOTH values the current
catalogue could give and does not guess which one this
piece would get. This is not a shortfall in this tool; it
is a real gap in what the deck records, named rather than
papered over.
unjudgeable current knowledge cannot resolve the field at all (no
catalogue alias, no matching stage spec, ...) -- named,
with why. An unjudgeable deck is NOT a passing deck; the
summary counts it apart from `current` for exactly that
reason (see CLAUDE.md SS3, "could not judge" and "judged,
found nothing" are different answers).
WHAT IT DOES NOT DO
--------------------
This checks a deck against KNOWLEDGE, not against the MODEL. A deck can pass
every check here and still describe a panel whose geometry has since changed
in Revit -- design numbers, lifter positions, strand layouts. Verifying that
half needs the live model and is out of scope offline; incident 2 above is
that half, and it is not what this tool answers. The caveat prints once, at
the top of every run, so it cannot be scrolled past.
WHY THIS DOES NOT CALL `knowledge.load(doc)`
---------------------------------------------
`load()` takes a live Revit `doc` and its file layers merge through
`knowledge._merge_file`, which reaches for `pyrevit.coreutils.yaml` -- a
reader that exists only inside Revit. Off Revit that import fails on every
layer, `_merge_file` records each one "unreadable" and returns the tree
unmerged, and every lookup would silently run against bare `DEFAULTS`. This
tool is the same substitution `tools/knowledge_drift.py` documents making,
for the same reason: `build_knowledge()` below re-implements the identical
five-layer order and the identical `job.plant` two-pass `load()`'s own
docstring insists on, reading each YAML file with PyYAML instead and reusing
`knowledge._coerce` / `knowledge._deep_update` / `knowledge.DEFAULTS`
directly -- so a coercion rule fixed in `knowledge.py` (the leading-zero
label rule, for one) is not a second bug waiting to happen here.
CONSTRAINTS THIS RESPECTS
--------------------------
Host CPython, no Revit, standalone. Imports `vibe.knowledge` and
`vibe.plantpath` the way `tools/ticket_delta.py` / `tools/snapshot_defer.py`
do -- see the stub registration below, and
`WillTools.extension/tests/conftest.py`'s docstring for why a plain
`import vibe.knowledge` cannot work off Revit.
Prints no plant or client name of its own. Family names, marks and plant ids
read out of a job file are DATA flowing through, exactly as `sweep.py` and
`knowledge_drift.py` already print them at runtime; nothing about that is
baked into this file's own strings. See `.scrub-terms` and CLAUDE.md SS "No
client or job information in the repo" -- design numbers, marks and part
codes are not client information.
"""
from __future__ import annotations
import argparse
import io
import json
import math
import os
import sys
import time
import types
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
LIB = os.path.join(REPO, 'WillTools.extension', 'lib')
if LIB not in sys.path:
sys.path.insert(0, LIB)
# Same trick `tools/ticket_delta.py`, `tools/snapshot_defer.py` and
# `WillTools.extension/tests/conftest.py` all use, and for the same reason:
# `vibe/__init__.py` imports the Revit API at module scope, which is right
# inside Revit and fatal on host CPython. Register a bare `vibe` package
# pointed at the directory so the Revit-free submodules import normally and
# `__init__.py` never runs.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(LIB, 'vibe')]
sys.modules['vibe'] = _stub
from vibe import knowledge # noqa: E402
from vibe import plantpath # noqa: E402
try:
import yaml
except ImportError: # pragma: no cover
sys.stderr.write('PyYAML is required: pip install pyyaml\n')
raise SystemExit(2)
CURRENT = 'current'
STALE = 'stale'
CONDITIONAL = 'conditional'
UNJUDGEABLE = 'unjudgeable'
# Worse first. A deck's overall status is the worst of its findings' -- the
# same "not-judged-is-not-passed" rule as everywhere else in this codebase:
# an unjudged deck is not a clean deck, and a conditional one is not either.
_SEVERITY = {STALE: 3, CONDITIONAL: 2, UNJUDGEABLE: 1, CURRENT: 0}
# ------------------------------------------------------------- knowledge
class KnowledgeLayers(object):
"""What `build_knowledge` resolved, and what it found on disk.
Kept apart from the merged dict itself so a report can name which
layer's file is newest, and which layers were absent or unreadable,
without re-deriving any path a second time.
"""
def __init__(self):
self.defaults_path = None
self.catalogue_path = None
self.catalogue_status = None
self.plant_id = None
self.plant_path = None
self.plant_status = None
self.job_path = None
self.job_status = None
self.config_path = None
self.config_status = None
def rows(self):
"""(label, path, status) for every layer that names a path."""
return [
('defaults', self.defaults_path, 'source'),
('catalogue', self.catalogue_path, self.catalogue_status),
('plant', self.plant_path, self.plant_status),
('job', self.job_path, self.job_status),
('config', self.config_path, self.config_status),
]
def newest_mtime(self):
best = None
for _label, path, status in self.rows():
if not path or status in ('absent', 'missing', None):
continue
if not os.path.isfile(path):
continue
m = os.path.getmtime(path)
if best is None or m > best:
best = m
return best
def _read_yaml(path):
with io.open(path, 'r', encoding='utf-8') as fh:
return yaml.safe_load(fh)
def _merge_layer(merged, path):
"""Merge one YAML layer file, PyYAML instead of pyRevit's reader.
Mirrors `knowledge._merge_file`'s contract: a file that is not there is
ordinary and silent ('absent'); a file that IS there and will not parse
is recorded and skipped WHOLE, never partially merged ('unreadable') --
the same discipline `_merge_file`'s own docstring explains, because a
layer that fails to load must never look like a layer that agrees with
the defaults.
"""
if not path or not os.path.isfile(path):
return 'absent'
try:
data = _read_yaml(path)
except Exception:
return 'unreadable'
if isinstance(data, dict):
knowledge._deep_update(merged, knowledge._coerce(data))
return 'merged'
def build_knowledge(job_root):
"""Re-resolve knowledge the way `knowledge.load(doc)` does, no `doc`.
Same five layers, same order, same `job.plant` two-pass `load()`'s own
docstring insists cannot be collapsed into one pass (the plant file has
to be read from the name the job file states, and the job file merges
AFTER the plant layer -- so the name is read on its own, first, exactly
as `knowledge.plant_id()` does for the live-doc path).
`job_root` is the folder that holds the job's OWN `knowledge.yaml` --
for a sweep folder `<job_root>/sweep/jobs/*.job.json`, that is
`<job_root>/knowledge.yaml`, the same relationship
`designspec.root_dir()` / `sub_dir(doc, 'sweep')` establish for the live
path. See `_guess_job_root` for how the CLI finds it when not told.
The project config layer (last of the five) is included only in the
thin sense the rest of this tool needs: unwrap `knowledge:` if present,
else merge the file whole. The `decisions:` friendly-name mapping
`knowledge._merge_decisions` does for a live project config is NOT
replicated here -- no active-config.txt exists on the reference sweep
this was built against, and a config layer written entirely under
`decisions:` would be under-read by this tool. Named here rather than
silently: if that ever matters, `_merge_config_file`'s mapping needs a
host-side twin, not a guess bolted on here.
"""
layers = KnowledgeLayers()
merged = knowledge._deep_copy(knowledge.DEFAULTS)
layers.defaults_path = os.path.abspath(knowledge.__file__)
layers.catalogue_path = knowledge.catalogue_path()
layers.catalogue_status = _merge_layer(merged, layers.catalogue_path)
job_path = os.path.join(job_root, 'knowledge.yaml') if job_root else None
layers.job_path = job_path
plant = None
if job_path and os.path.isfile(job_path):
try:
raw = _read_yaml(job_path) or {}
except Exception:
raw = {}
job_block = raw.get('job') if isinstance(raw, dict) else None
if isinstance(job_block, dict) and job_block.get('plant') is not None:
plant = str(job_block['plant']).strip() or None
layers.plant_id = plant
if plant is not None and not plantpath.valid_plant_id(plant):
layers.plant_status = 'invalid_plant_id'
else:
plant_path = plantpath.plant_knowledge_path(plant)
layers.plant_path = plant_path
if plant is not None and not (plant_path and os.path.isfile(plant_path)):
# Same discipline as `knowledge.load()`'s `_plant_missing`:
# NEVER fall back to the machine-wide file when a plant was
# named and its folder is not there.
layers.plant_status = 'missing'
else:
layers.plant_status = _merge_layer(merged, plant_path)
layers.job_status = _merge_layer(merged, job_path) if job_path else 'absent'
if job_root:
pointer = os.path.join(job_root, 'active-config.txt')
if os.path.isfile(pointer):
with io.open(pointer, 'r', encoding='utf-8') as fh:
config_path = fh.read().strip()
layers.config_path = config_path or None
if config_path:
if os.path.isfile(config_path):
try:
holder = _read_yaml(config_path) or {}
except Exception:
layers.config_status = 'unreadable'
else:
body = holder.get('knowledge')
body = body if isinstance(body, dict) else holder
knowledge._deep_update(merged, knowledge._coerce(body))
layers.config_status = 'merged'
else:
layers.config_status = 'missing'
merged['_plant_id'] = plant
return merged, layers
def _guess_job_root(jobs_dir):
"""`<job_root>/sweep/jobs` -> `<job_root>`, verified against disk.
`tools/sweep.py`'s `sweep_dir()` writes `jobs/` at
`designspec.sub_dir(doc, 'sweep')/jobs`, i.e. two levels under
`root_dir(doc)` -- the same folder `knowledge_path(doc)` puts
`knowledge.yaml` in. So the parent of JOBS_DIR's parent is tried first;
if it holds no `knowledge.yaml`, every ancestor up to five levels is
tried in turn (a renamed sweep folder, or `--check`'s custom `--out`,
can move the depth). None found returns the two-level guess anyway, so
the caller always has a slightly wrong parent to complain about instead
of no parent at all.
"""
jobs_dir = os.path.abspath(jobs_dir)
two_up = os.path.dirname(os.path.dirname(jobs_dir))
candidate = two_up
node = jobs_dir
for _ in range(5):
node = os.path.dirname(node)
if not node or node == os.path.dirname(node):
break
if os.path.isfile(os.path.join(node, 'knowledge.yaml')):
return node
return two_up
# ------------------------------------------------------------ comparisons
def _num_eq(a, b):
if a is None or b is None:
return a is b
try:
return math.isclose(float(a), float(b), rel_tol=1e-9, abs_tol=1e-6)
except (TypeError, ValueError):
return a == b
def _verdict(current_val, frozen_val):
"""current/stale/unjudgeable for one plain numeric field.
Both None: current (frozen agreement on 'nothing known'). current is
None, frozen is not: unjudgeable -- current knowledge cannot confirm OR
deny the frozen value, which is not the same as it being wrong. frozen
is None, current is not: stale -- something is knowable now that was not
at export, which is exactly the kind of drift this tool exists to catch.
Both known: compared with a float tolerance.
"""
if current_val is None and frozen_val is None:
return CURRENT
if current_val is None:
return UNJUDGEABLE
if frozen_val is None:
return STALE
return CURRENT if _num_eq(current_val, frozen_val) else STALE
def check_capacity(know, family, kind, frozen_val, tension_bar_families):
"""(status, detail) for `capacity_lb` / `shear_capacity_lb`.
The gate `knowledge.lifter_capacity` applies (tension only; see its own
docstring) needs `assembly_families` -- every OTHER member's family
name on the same assembly -- which the deck never carries (see this
module's own docstring). Calling with `assembly_families=None` always
resolves SOMETHING (the conservative without-bar figure, by design --
"an omitted list never earns the 2x rating by accident"), and that
number can even happen to equal the frozen one. Trusting that match
would be exactly the guess point 1 of the spec this was built against
forbids: it is not evidence the piece lacks its tension bar, only that
nothing here was told either way. So whenever the without-bar branch is
the one that resolved, the with-bar branch is also computed (by forcing
corroboration with the plant's own `tension_bar_families`) and, if it
gives a different number, this reports CONDITIONAL naming both --
regardless of which one the frozen value happens to match.
"""
val, basis, why = knowledge.lifter_capacity(
know, family, kind=kind, assembly_families=None)
if kind == 'tension' and basis == 'without_bar':
alt_val, alt_basis, alt_why = knowledge.lifter_capacity(
know, family, kind=kind, assembly_families=tension_bar_families)
if alt_basis == 'with_bar' and not _num_eq(alt_val, val):
matches = [b for b, v in (('without_bar', val), ('with_bar', alt_val))
if _num_eq(v, frozen_val)]
return CONDITIONAL, {
'frozen': frozen_val,
'without_bar': val, 'without_bar_why': why,
'with_bar': alt_val, 'with_bar_why': alt_why,
'frozen_matches': matches,
'reason': (
"gated on a welded tension bar; this deck does not "
"carry the assembly's other member families, so "
"whether the bar was corroborated on THIS piece cannot "
"be re-derived offline. Current catalogue gives {0} lb "
"without the bar or {1} lb with it corroborated; "
"frozen {2} lb matches {3}."
).format(val, alt_val, frozen_val,
'/'.join(matches) if matches else 'neither'),
}
status = _verdict(val, frozen_val)
return status, {'current': val, 'basis': basis, 'why': why, 'frozen': frozen_val}
def _match_current_stage_spec(know, stage):
specs = knowledge.get(know, 'handling.stages', []) or []
pci = stage.get('pci_case')
name = stage.get('name')
for spec in specs:
if pci and spec.get('pci_case') == pci:
return spec
for spec in specs:
if name and spec.get('name') == name:
return spec
return None
def check_stage_tension_limit(know, stage, job_default_limit):
frozen = stage.get('tension_limit_sqrt_fc')
source = stage.get('tension_limit_source')
if source in (None, 'job'):
current = job_default_limit
why = 'handling.tension_limit_sqrt_fc, current job layer'
elif source == 'stage':
spec = _match_current_stage_spec(know, stage)
if spec is None:
return UNJUDGEABLE, {
'frozen': frozen,
'reason': (
'no current handling.stages entry matches pci_case={0!r}'
' / name={1!r} to compare the pinned limit against'
.format(stage.get('pci_case'), stage.get('name'))),
}
spec_limit = spec.get('tension_limit_sqrt_fc')
current = spec_limit if spec_limit is not None else job_default_limit
why = ('handling.stages[pci_case={0!r}].tension_limit_sqrt_fc'
.format(stage.get('pci_case')) if spec_limit is not None else
'handling.tension_limit_sqrt_fc, current job layer (matched '
'stage spec states none of its own)')
else:
return UNJUDGEABLE, {
'frozen': frozen,
'reason': 'unrecognised tension_limit_source {0!r} on this deck'
.format(source),
}
return _verdict(current, frozen), {'current': current, 'frozen': frozen,
'why': why}
# ------------------------------------------------------------- per deck
def check_deck(path, know):
"""The findings for one job file. Never raises on a well-formed deck."""
with io.open(path, 'r', encoding='utf-8') as fh:
deck = json.load(fh)
panel = deck.get('panel') or {}
mark = panel.get('mark') or os.path.splitext(os.path.basename(path))[0]
tension_bar_families = knowledge.get(know, 'lifters.tension_bar_families',
[]) or []
findings = []
def add(field, status, detail, lifter_mark=None, family=None):
findings.append({
'deck': path, 'mark': mark, 'lifter_mark': lifter_mark,
'family': family, 'field': field, 'status': status,
'detail': detail,
})
for lifter in panel.get('lifters') or []:
family = lifter.get('family')
lmark = lifter.get('mark')
if not family:
add('family', UNJUDGEABLE,
{'reason': 'this lifter carries no family name in the deck'},
lmark, family)
continue
if 'capacity_lb' in lifter:
status, detail = check_capacity(
know, family, 'tension', lifter['capacity_lb'],
tension_bar_families)
add('capacity_lb', status, detail, lmark, family)
if 'shear_capacity_lb' in lifter:
status, detail = check_capacity(
know, family, 'shear', lifter['shear_capacity_lb'],
tension_bar_families)
add('shear_capacity_lb', status, detail, lmark, family)
if 'basis_fc_psi' in lifter:
cur = knowledge.lifter_basis_fc(know, family)
add('basis_fc_psi', _verdict(cur, lifter['basis_fc_psi']),
{'current': cur, 'frozen': lifter['basis_fc_psi']},
lmark, family)
if 'min_edge_distance_in' in lifter:
cur = (knowledge.lifter_edge_distance(know, family)
or knowledge.get(know, 'lifters.min_edge_distance_in'))
add('min_edge_distance_in',
_verdict(cur, lifter['min_edge_distance_in']),
{'current': cur, 'frozen': lifter['min_edge_distance_in']},
lmark, family)
if 'tension_fos' in lifter:
cur, src, why = knowledge.lifter_safety_factor(
know, family, kind='tension')
add('tension_fos', _verdict(cur, lifter['tension_fos']),
{'current': cur, 'frozen': lifter['tension_fos'],
'source': src, 'why': why}, lmark, family)
if 'shear_fos' in lifter:
cur, src, why = knowledge.lifter_safety_factor(
know, family, kind='shear')
add('shear_fos', _verdict(cur, lifter['shear_fos']),
{'current': cur, 'frozen': lifter['shear_fos'],
'source': src, 'why': why}, lmark, family)
job_default_limit = knowledge.get(know, 'handling.tension_limit_sqrt_fc',
5.0)
for i, stage in enumerate(deck.get('stages') or []):
if 'tension_limit_sqrt_fc' not in stage:
continue
status, detail = check_stage_tension_limit(know, stage,
job_default_limit)
field = 'stages[{0}:{1}].tension_limit_sqrt_fc'.format(
i, stage.get('name') or stage.get('pci_case') or '?')
add(field, status, detail)
return findings
def deck_status(findings):
if not findings:
return CURRENT
return max((f['status'] for f in findings), key=lambda s: _SEVERITY[s])
# ----------------------------------------------------------------- CLI
def _fmt_num(v):
if v is None:
return 'null'
if isinstance(v, float) and v == int(v):
return str(int(v))
return str(v)
def _group_key(f):
"""Findings that would print an identical line collapse into one.
A deck with several lifters of the same gated family produces the same
CONDITIONAL (or STALE) numbers once per lifter; grouping is what keeps
"one line per stale deck" true instead of one line per lifter on it.
Two lifters of the same family that land on genuinely DIFFERENT numbers
-- a plant override that only matched one of them, say -- still get
separate lines, because the key includes the numbers themselves.
"""
d = f['detail']
if f['status'] == CONDITIONAL:
tail = (d.get('frozen'), d.get('without_bar'), d.get('with_bar'))
elif f['status'] == STALE:
tail = (d.get('frozen'), d.get('current'))
else:
tail = (d.get('reason') or d.get('why'),)
return (f['field'], f['status']) + tail
def _deck_lines(mark, findings):
"""One line per DISTINCT (field, status, values) on this deck.
Not one line per lifter -- see `_group_key`. `current` findings never
print here; pass `--all` for the raw per-finding dump instead.
"""
non_current = [f for f in findings if f['status'] != CURRENT]
if not non_current:
return []
groups = {}
for f in non_current:
groups.setdefault(_group_key(f), []).append(f)
lines = []
for key in sorted(groups, key=lambda k: (-_SEVERITY[k[1]], k)):
fs = groups[key]
f0 = fs[0]
d = f0['detail']
families = sorted(set(x['family'] for x in fs if x['family']))
marks = sorted(set(x['lifter_mark'] for x in fs if x['lifter_mark']))
note = ''
if marks:
note = ' ({0} lifter{1}{2})'.format(
len(marks), '' if len(marks) == 1 else 's',
' ' + '/'.join(families) if families else '')
status = f0['status']
if status == CONDITIONAL:
line = (' CONDITIONAL {0}{1} {2}: frozen={3} without_bar={4} '
'with_bar={5} -- {6}'
.format(mark, note, f0['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('without_bar')),
_fmt_num(d.get('with_bar')), d.get('reason')))
elif status == STALE:
why = d.get('why') or d.get('reason') or ''
line = (' STALE {0}{1} {2}: frozen={3} -> current={4}{5}'
.format(mark, note, f0['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('current')),
(' (' + why + ')') if why else ''))
else:
line = (' UNJUDGEABLE {0}{1} {2}: {3}'
.format(mark, note, f0['field'],
d.get('reason') or d.get('why') or
'current knowledge could not resolve this'))
lines.append(line)
return lines
def _finding_line(f):
d = f['detail']
who = f['mark']
if f.get('lifter_mark'):
who = '{0} lifter {1} ({2})'.format(f['mark'], f['lifter_mark'],
f['family'])
if f['status'] == CONDITIONAL:
return (' CONDITIONAL {0} {1}: frozen={2} without_bar={3} '
'with_bar={4} -- {5}'
.format(who, f['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('without_bar')),
_fmt_num(d.get('with_bar')), d.get('reason')))
if f['status'] == STALE:
why = d.get('why') or d.get('reason') or ''
return (' STALE {0} {1}: frozen={2} -> current={3}{4}'
.format(who, f['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('current')),
(' (' + why + ')') if why else ''))
if f['status'] == UNJUDGEABLE:
return (' UNJUDGEABLE {0} {1}: {2}'
.format(who, f['field'], d.get('reason') or d.get('why') or
'current knowledge could not resolve this'))
return ' current {0} {1}'.format(who, f['field'])
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('jobs_dir', help="a sweep's jobs/ folder of *.job.json")
ap.add_argument('--job-root', help='the folder holding this job\'s own '
'knowledge.yaml (default: guessed '
'from jobs_dir, see _guess_job_root)')
ap.add_argument('--json', dest='json_out', help='write machine-readable '
'output here too')
ap.add_argument('--all', action='store_true',
help='print every finding, not just non-current ones')
args = ap.parse_args(argv)
jobs_dir = args.jobs_dir
if not os.path.isdir(jobs_dir):
sys.stderr.write('deckcheck: not a directory: {0}\n'.format(jobs_dir))
return 2
job_root = args.job_root or _guess_job_root(jobs_dir)
try:
know, layers = build_knowledge(job_root)
except Exception as exc: # pragma: no cover
sys.stderr.write('deckcheck: could not build current knowledge: '
'{0}: {1}\n'.format(type(exc).__name__, exc))
return 2
names = sorted(n for n in os.listdir(jobs_dir) if n.endswith('.job.json'))
if not names:
sys.stderr.write('deckcheck: no *.job.json under {0}\n'.format(jobs_dir))
return 2
deck_paths = [os.path.join(jobs_dir, n) for n in names]
newest_deck_mtime = max(os.path.getmtime(p) for p in deck_paths)
newest_knowledge_mtime = layers.newest_mtime()
print('=' * 78)
print('DECKCHECK -- knowledge staleness only, NOT model staleness.')
print('This verifies each deck against KNOWLEDGE, never against the live')
print('MODEL: a deck can pass every check here and still describe a')
print('panel whose geometry has since changed in Revit.')
print('=' * 78)
if newest_knowledge_mtime is not None:
age_hours = (newest_knowledge_mtime - newest_deck_mtime) / 3600.0
if age_hours > 0:
print('these decks predate the current knowledge by {0:.1f} hour(s)'
.format(age_hours))
elif -age_hours > 0:
print('current knowledge is {0:.1f} hour(s) OLDER than the newest '
'deck -- decks were exported after the last knowledge edit'
.format(-age_hours))
else:
print('newest deck and newest knowledge layer share a timestamp')
else:
print('no knowledge layer file found to compare a timestamp against')
print('')
print('job root: {0}'.format(job_root))
for label, path, status in layers.rows():
print(' {0:<10} {1} [{2}]'.format(label, path or '(none)', status))
print('')
all_findings = []
deck_statuses = []
stale_lines = []
for path in deck_paths:
try:
findings = check_deck(path, know)
except Exception as exc:
findings = [{
'deck': path, 'mark': os.path.basename(path),
'lifter_mark': None, 'family': None, 'field': '(deck read)',
'status': UNJUDGEABLE,
'detail': {'reason': '{0}: {1}'.format(type(exc).__name__, exc)},
}]
all_findings.extend(findings)
status = deck_status(findings)
deck_statuses.append(status)
mark = findings[0]['mark'] if findings else os.path.basename(path)
if args.all:
stale_lines.extend(_finding_line(f) for f in findings)
elif status != CURRENT:
stale_lines.extend(_deck_lines(mark, findings))
if stale_lines:
print('-' * 78)
for line in stale_lines:
print(line)
print('-' * 78)
n_total = len(deck_statuses)
n_current = sum(1 for s in deck_statuses if s == CURRENT)
n_stale = sum(1 for s in deck_statuses if s == STALE)
n_conditional = sum(1 for s in deck_statuses if s == CONDITIONAL)
n_unjudgeable = sum(1 for s in deck_statuses if s == UNJUDGEABLE)
unjudgeable_reasons = sorted(set(
f['detail'].get('reason') or f['detail'].get('why') or '(no reason given)'
for f in all_findings if f['status'] == UNJUDGEABLE
))
print('')
print('{0} deck(s) checked, {1} current, {2} stale, {3} conditional, '
'{4} unjudgeable.'
.format(n_total, n_current, n_stale, n_conditional, n_unjudgeable))
if n_unjudgeable:
print('unjudgeable because:')
for r in unjudgeable_reasons:
print(' - {0}'.format(r))
if args.json_out:
payload = {
'job_root': job_root,
'layers': [{'layer': label, 'path': path, 'status': status}
for label, path, status in layers.rows()],
'newest_deck_mtime': newest_deck_mtime,
'newest_knowledge_mtime': newest_knowledge_mtime,
'decks_checked': n_total, 'current': n_current, 'stale': n_stale,
'conditional': n_conditional, 'unjudgeable': n_unjudgeable,
'findings': all_findings,
}
with io.open(args.json_out, 'w', encoding='utf-8') as fh:
json.dump(payload, fh, indent=1, default=str)
print('\njson written to {0}'.format(args.json_out))
return 0 if n_current == n_total else 1
if __name__ == '__main__':
sys.exit(main())