8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
778 lines
33 KiB
Python
778 lines
33 KiB
Python
#!/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())
|