WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
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.
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user