Files
willtools-demo/WillTools.extension/lib/vibe/knowledge.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

8694 lines
422 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Hand-written checking knowledge that outlives any one project.
The design spec says what *this* design calls for. The knowledge file says
what the shop knows: which families count as strand, what a lifter is rated
for, how close to an edge it may sit, what tolerance a dimension gets. It is
YAML so it can carry comments, and it is meant to be edited by hand and grown
over time.
Everything here has a built-in default, so the checker runs before the file
exists. A value in the file always wins.
WHICH LAYER WINS, AND WHAT THAT COSTS
-------------------------------------
Job beats plant beats what everybody in the trade knows. Will 2026-08-30,
and it is not a tie-break rule -- it says where truth lives. The trade knows
that a bar mark is a recipe. The plant knows its own hardware. The job knows
what was actually drawn, and the job is the thing being built, so it wins.
Whether P89 carries an attached bar is the example that produced the rule: it
depends on where the P89 sits. The manual describes a slotted insert with no
bar; a particular job may weld bars to it. Neither is wrong. The manual is
right about the part and the job is right about this piece.
**A job entry that contradicts a higher layer needs permission to do it.**
Precedence and permission are different things, and only the first is
implemented. `_deep_update` below simply assigns -- the later layer replaces
the earlier one and nothing anywhere records that a plant rule was set aside.
Which means an override and a typo are indistinguishable: both parse, both
take effect, and neither says a word.
That is the same failure this whole toolchain keeps meeting. A check that
cannot run reports OK; an exclusion removes findings and leaves a clean page;
a job value quietly displaces a plant one. Every time, the wrong outcome
looks exactly like the right one.
So an override should have to declare itself -- carry a note saying what it
sets aside and why -- and the tool should say, in its output, that one is in
force. Not refuse it. Just never let it happen silently.
Two more things that are true of the merge today and worth knowing before
relying on it:
* **Lists replace, they do not merge.** A job file naming two families in
`interference.exempt_families` removes the plant's five. That is usually
not what the author meant.
* **The job file's path follows the document, not the model name.** Once a
model has been saved, the job knowledge is read from `_ticketqc` beside the
.rvt, and the copy under %APPDATA% -- which is where it lives for an unsaved
document -- stops being read at all. Measured 2026-08-30: a batch of job
edits was inert for exactly this reason and looked, from the outside, like
the edits had simply not worked.
**A LAYER THAT FAILS TO LOAD LOOKS EXACTLY LIKE A LAYER THAT AGREES WITH THE
DEFAULTS.** Measured 2026-09-01: one duplicate key nested in one mapping of a
plant file -- pyRevit's reader is strict and raises on it -- silently threw
away that whole layer, and every report still rendered. So a file that EXISTS
and will not parse is skipped (a half-parsed layer must never merge) and
recorded: `_catalogue_unreadable`, `_plant_unreadable`, `_job_unreadable`,
`_config_unreadable`, each `(path, parser message)`, read back by
`unreadable_layers()` and turned into a refusal by `config_problems()`. Same
discipline as `_plant_missing` / `_config_missing`, and for the same reason:
whether a file could be READ is a fact about the moment it was opened, and by
the time anyone looks at the tree the only evidence left is values that are
missing -- which is indistinguishable from a layer that never set them.
`python tools/knowledgelint.py` is the host-side gate that catches it before a
button ever sees it.
Computed checks -- the ones that need arithmetic rather than a comparison --
are registered in ticketchecks.py and read their numbers from here. Adding a
new lifter model is a YAML edit; adding a new *kind* of calculation is a small
Python function.
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import fnmatch
import os
import io
import re
from vibe.compat import string_types, as_text
# `designspec` is imported inside the functions that need it, not here. It
# pulls in the Revit API at module scope (evidence scraping needs the live
# document), and this module has real logic -- _deep_update, _merge_decisions,
# _coerce, the family/lifter lookups -- that is worth testing on plain
# CPython with no Revit anywhere. A module-level import here would make the
# whole file un-importable off a Revit host, for the sake of four functions
# that only need it to find files on disk.
DEFAULTS = {
# ---- where this job is being made ---------------------------------
#
# WHICH PLANT produces this job. A JOB fact -- the same tool, the same
# plant knowledge folder, but this job is poured at one of them -- so it
# is declared in `<model>/_ticketqc/knowledge.yaml` and NULL here.
#
# It is also the key that CHOOSES THE PLANT LAYER: load() reads it out of
# the job file before merging anything, and resolves the plant file to
# `<root>/<plant>/knowledge.yaml`. See the two-pass note in load().
#
# Null means NO PLANT NAMED, which is not an error and is deliberately
# quiet: the machine-wide plant file (`<root>/knowledge.yaml`) is read,
# exactly as it always was, and that is how every install that predates
# this key is set up. Naming a plant that has no folder IS reported --
# `_plant_missing`, and `config_problems` says so in a sentence -- because
# then somebody asked for a specific plant and did not get it.
'job': {
'plant': None, # e.g. 'northgate' -- one folder name
},
# ---- whose work this is -------------------------------------------
#
# The DECLARED SCOPE: which pieces of the job this engineer is answerable
# for. Several engineers work one job, and the knowledge below was
# validated against one discipline's pieces -- so a tool that judges all
# of them is issuing verdicts it has no basis for.
#
# It is a JOB fact (who is responsible for what, on this job), so it is
# declared in `<model>/_ticketqc/knowledge.yaml` and EMPTY here. Empty
# means NO SCOPE DECLARED, which means judge everything and say so on
# every report. It never means judge nothing, and no prefix is ever
# written into this file -- an exclusion the code chose for itself is the
# one failure this project has already paid for.
#
# See `vibe/scope.py` for the predicate and why it is the mark prefix.
'scope': {
'mark_prefixes': [], # e.g. [PNL] -- the letters a mark starts with
'declared_by': None, # who said so, so it can be argued with
'note': None, # one sentence, printed with the scope
},
# ---- which producer's drawing habits the extractor may recognise ----
#
# A PLANT fact: how an office titles its details and words its standing
# instructions. Empty here and empty is the honest default -- a machine
# told nothing about an office's habits should claim to recognise
# nothing, and every note the generic reader cannot place goes on landing
# in the spec's `unresolved` list under the generic reason.
#
# Declared, never inferred. `plant.id` exists and would be enough to
# guess with; a guess that is right today goes wrong silently the first
# time a job is drawn to another producer's standard on this plant's
# template. A name this build does not ship is refused loudly and NO pack
# loads -- a half-loaded rule set classifies some notes and not others,
# and the reader cannot tell which sentences to trust.
#
# A pack may only move a note from `unresolved` into `notes`, or leave it
# in `unresolved` with a better sentence beside it. Nothing it can say
# makes a line disappear. See `vibe/extractrules/__init__.py`.
'extract': {
'rule_packs': [], # e.g. [mc] -- see vibe/extractrules
},
# ---- checks this job has DECLARED it does not want asked -----------
#
# Owner, 2026-08-30: "让我可以注释掉一些check。比如说这个项目里面3.3我就
# 想注释掉。因为所有的panel都不会有这个,一直看着NOT_RUN我也糟心。"
#
# A job where no piece can ever carry what item 3.3 asks about prints
# NOT RUN on 3.3 for every piece, forever. A permanently red row is not
# a warning; it is training the reader to skip red rows, and that costs
# more than the row is worth.
#
# So: `muted` is a MAP from ticket-checklist number to the REASON it is
# muted on this job. A map, not a list, because the layers deep-merge
# (`_deep_update`) -- a job can mute one more item without restating the
# ones the plant layer already muted. The reason is MANDATORY
# (`configmodel.validate('checks.muted', ...)` refuses an empty one): a
# row that disappeared for a reason nobody wrote down cannot be told
# apart from a bug.
#
# EMPTY HERE, AND EMPTY IS THE ONLY HONEST DEFAULT. Nothing ships muted.
# A muted item STAYS on the checklist table, repainted grey as MUTED
# (see `ticketpage.apply_muting`), with the note saying what the check
# really reported -- the check still runs every time, only the
# presentation changes. It can never soften a FAIL, which keeps its own
# red and its own word, nor stop a finding reaching the machine-readable
# JSON. MUTED has its own tally bucket, separate from clean/failing/
# warning.
# ---- checks that DO NOT APPLY to this kind of piece ----------------
#
# Owner, 2026-08-31: "ticketqc 检查的内容需要进行分级。根据 plant 和
# ticket 的具体内容进行分级 ... 所以把 ticketqc rules 写进厂知识,
# 让 ticketqc 工具读取厂知识来得知啥需要检查,啥不用检查。"
#
# THIS IS A FIFTH STATE AND IT IS NOT ANY OF THE FOUR ALREADY HERE.
# Getting the difference wrong is how one of them ends up doing a job it
# was not built for, quietly:
#
# `checks.muted` the check RUNS and the row is repainted grey. "I
# know, stop telling me" -- a PRESENTATION act, per
# JOB, and a FAIL is never softened.
# scope.mark_prefixes a whole PIECE somebody else is responsible for.
# Per piece, never per row, and the piece is not
# checked at all.
# a DORMANT check the knowledge that would enable it is unset, so it
# stands down with a reason. INVOLUNTARY: it is
# resolved by supplying a fact, not by a ruling.
# grades A-D WHO answers the question (code or a person), never
# WHETHER the question applies.
# `checks.not_applicable` the question DOES NOT APPLY to this kind of
# piece at this plant. VOLUNTARY and STATED (unlike
# dormancy), PLANT-level and conditional on the piece
# (unlike a job mute), and the check DOES NOT RUN
# (unlike a mute).
#
# THE SHAPE, and every level of it is a MAP on purpose:
#
# checks:
# not_applicable:
# '<checklist row>': # e.g. '3.3'
# '<panel type id, or `any`>':
# applies: false # false -> the row is not asked
# not_applicable_because: <sentence> # REQUIRED when false
# not_applicable_since: 'YYYY-MM-DD' # REQUIRED when false
# applicable_because: <sentence> # REQUIRED when true
# applicable_since: 'YYYY-MM-DD' # REQUIRED when true
# declared_by: <who said so, so it can be argued with>
#
# WHY THERE IS NOT A LIST ANYWHERE IN IT. Lists REPLACE across the
# knowledge layers and maps deep-merge (section 2.3). A list of row
# numbers in the job file would DELETE the plant's whole table the moment
# a job wanted to add one entry -- the rebar stock-length trap, on a
# safety switch this time. With maps all the way down, a job NARROWS one
# (row, panel type) pair by writing `applies: true` under exactly that
# pair, and EXTENDS by adding a pair, and in both cases every other entry
# the plant declared survives untouched. The wipe is not guarded against;
# it is unspellable.
#
# AND WHY THE REASON KEY IS NAMED FOR THE VALUE IT JUSTIFIES. After the
# merge nothing can tell which layer wrote which key, so a single
# `because:` would let a job flip `applies: true` and silently inherit the
# plant's sentence for the opposite ruling -- a reason that now reads
# backwards, over a re-enabled check. Turning a row back on needs
# `applicable_because`, which the plant's entry does not carry, so the
# narrowing states its own case or is refused.
#
# `any` IS THE WHOLE-PLANT AXIS. It matches whatever the piece is,
# including a piece nothing could classify -- that is a plant asserting a
# fact about every piece it makes ("we do not do that here"). A key that
# is a panel type id is the PIECE axis and needs a classification: while
# nothing can classify a piece, a type-conditioned entry is REFUSED and
# reported, and the row runs. Skipping a check that may not apply is
# noise; running one that does not apply is noise too -- but skipping one
# that DOES apply is a silent pass, and those are not symmetric.
#
# EMPTY HERE, AND EMPTY IS THE ONLY HONEST DEFAULT. Nothing ships
# switched off. A row switched off renders as its own state,
# `ticketpage.NOT_APPLICABLE_STATE`, in its own colour, with the reason
# and the layer that declared it; and a row that would have FAILED keeps
# its FAIL -- the declaration is refused and says so, exactly as a mute
# over a failure is.
#
# A NEW QUESTION IS STILL A CODE CHANGE. A plant declares applicability
# OVER the rows in `ticketpage.CHECKLIST_ITEMS`; it does not define new
# rows. See docs/knowledge-override.md for why that boundary is there.
# ---- and the same fact pointing the OTHER way ----------------------
#
# `not_applicable` switches a row OFF for a kind of piece. Some rows only
# exist FOR one kind: the owner commissioned an insulated-only check on
# 2026-08-31 (every lifter on an insulated panel must have a solid zone
# through the insulation), and on a solid panel that question is not
# quiet, it is meaningless -- there is no insulation for a solid zone to
# interrupt.
#
# Enumerating "not applicable to every OTHER type" would be the same fact
# written the long way, and it breaks the moment a third type is
# declared: the new type inherits the row by default, which is the
# unclassified-piece-reads-as-ordinary failure again.
#
# checks:
# only_applies_to:
# '<checklist row>':
# '<panel type id>':
# scoped: true # the row is only about this
# scoped_because: <sentence> # REQUIRED when true
# scoped_since: 'YYYY-MM-DD' # REQUIRED when true
# unscoped_because: <sentence> # REQUIRED when false
# unscoped_since: 'YYYY-MM-DD'
# declared_by: <who>
#
# A job WIDENS a row back to every piece with `scoped: false` under
# exactly that pair, stating its own case in its own key -- the same
# narrow/extend discipline, and the same reason the reason key is named
# for the value it justifies.
#
# `any` IS REFUSED HERE. Scoping a row to every type is not a scope.
#
# THE THREE ANSWERS A SCOPED ROW HAS, and the third is the one that had
# to be thought about:
#
# the piece IS one of the types -> the row is asked, normally
# the piece is ANOTHER type -> NOT APPLICABLE, in blue, saying
# which type it is and which the row
# is about
# the piece is UNCLASSIFIED, or
# nothing can classify at all -> NOT RUN, in RED
#
# THE THIRD ONE IS WHERE "decline to classify -> the FULL checklist"
# needed refining, because the full checklist now contains rows that are
# meaningful for one type only. Running such a row on a piece nobody
# could place would assert a solid-panel answer to an insulated-panel
# question; declaring it NOT APPLICABLE would claim the piece is not that
# type, which is exactly what could not be established. So it is NOT RUN
# -- nobody could ask it -- which is red, loud, and cured by making the
# classification work rather than by anybody's ruling. That is the
# dormant-check shape, arrived at from the other side, and it keeps the
# invariant intact: silence is never a pass, in either direction.
'checks': {
'muted': {}, # {'3.3': 'why this job never has one'}
'not_applicable': {}, # {'3.3': {'insulated': {...}}}
'only_applies_to': {}, # {'6.7': {'insulated': {...}}}
},
# ---- THE CHECKLIST ITSELF, PER PLANT AND PER KIND OF MEMBER -------
#
# Owner, 2026-08-31: "按照 plant-member 这种级别,给 ticketqc 的 item
# 重新编号,让 ticketqc 自动识别 plant-member, 并且做正确的 check。"
#
# `checks.not_applicable` was the switch; this is the panel board. A
# PROFILE says which checks this plant asks of this kind of member, in
# what order, and under what numbers -- and a different plant, or a
# different member kind at the same plant, gets a different board.
#
# ================= THE ONE THING THAT MUST NOT MOVE ================
#
# A CHECK'S IDENTITY IS ITS STABLE ID AND THE PROFILE ONLY SUPPLIES A
# DISPLAY NUMBER. The stable ids are the numbers `ticketpage.
# CHECKLIST_ITEMS` has always carried, FROZEN -- `CHECKLIST_IDS` is the
# written-down golden and a test holds the table to it.
#
# Everything that has ever referred to a check refers to it by that id:
# the owner's mutes ('3.3', '5.4', '1.4'), `checks.not_applicable`, the
# QNA records, `docs/checklist-code-map.md`, `qc-run.json` and its
# consumers, and the suite. If a number became profile-relative, every
# one of those would either break or -- far worse -- keep working while
# pointing at a different question. His mutes survived a renumbering
# today only because nobody renumbered. After this, a renumbering is a
# YAML edit that cannot reach them.
#
# WHY THE ID IS THE OLD NUMBER RATHER THAN A NEW SLUG. Minting slugs
# would mean translating every existing reference at once -- a mass
# rename is exactly the orphaning risk this design exists to remove, and
# it would be paid on day one for a tidier spelling. The number is
# already unique, already tested unique, and already what everyone
# writes. It is frozen, not renamed. The cost is that an id LOOKS like a
# display number, which is how it would get renumbered by accident --
# hence the golden.
#
# =================== THE SHAPE ======================================
#
# checklists:
# profiles:
# <profile id>:
# member_kind: <id from nomenclature.member_kinds>
# panel_type: <id from panel_types.types, or absent>
# title: <what to call this board on the report>
# because: <why this plant asks this of this member kind>
# since: 'YYYY-MM-DD'
# declared_by: <who>
# unlisted: ask | not_applicable
# unlisted_because: <required when `not_applicable`>
# unlisted_since: 'YYYY-MM-DD'
# items: # ORDERED. A LIST, on purpose.
# - {number: '1.1', check: '1.1'}
# - {number: '1.2', check: '2.3'}
#
# `items` IS THE ONE LIST IN ALL OF THIS, and it is a list because it is
# ORDERED and because order is the one thing a map cannot carry (a dict
# is unordered on the engine this runs on). Lists REPLACE across the
# layers -- which is right here and nowhere else in this branch: a
# checklist's order is a single statement, and a job that restates it
# means to restate it. Everything else is a map and deep-merges, so a
# job can retitle a profile, or change only its `unlisted` policy,
# without touching its board.
#
# ============ AND THE HOLE A CLOSED LIST WOULD OPEN =================
#
# `unlisted` DEFAULTS TO `ask`. A profile names the checks that existed
# when somebody wrote it; the code universe grows. With a closed list
# and no policy, THE NEXT CHECK ANYONE BUILDS WOULD BE SILENTLY UNASKED
# at every plant with a profile -- a new safety check, shipped, running
# nowhere, and nothing on the report saying so. So a check the profile
# does not name is ASKED ANYWAY, appended under its own stable id, and
# the report says the profile is out of date with the code.
#
# `unlisted: not_applicable` is the deliberate closed board, and it
# needs a reason and a date like every other switch here, because it is
# a standing promise about checks nobody has written yet.
#
# ================== HOW IT COMPOSES WITH THE SWITCH =================
#
# PROFILE FIRST, THEN `checks.not_applicable`. The profile says what is
# on the board at all; the switch says whether a question that IS on the
# board applies to this particular piece. A check the profile never put
# up cannot be switched off, and does not need to be -- so a profile
# should not restate what `not_applicable` already says. Both render as
# the same NOT APPLICABLE state and the report names which of the two
# did it.
#
# EMPTY HERE, AND EMPTY IS THE COMPATIBILITY CONTRACT. No profile
# declared -> the full table, in its own order, under its own numbers:
# byte-identical to what this tool printed before profiles existed, and
# a test pins it.
'checklists': {
'profiles': {},
},
# ---- how this plant names things ----------------------------------
#
# Nomenclature varies between plants more than any other assumption here,
# and it is the one thing that silently mis-reads a whole job when it is
# wrong -- so it is data, not code, and it belongs to the PLANT layer
# rather than the job (see load()).
'nomenclature': {
# ---- WHAT KIND OF MEMBER A MARK NAMES -------------------------
#
# A MAP from mark prefix to member-kind id: {'AAA': 'wall_panel'}.
# Plant knowledge, and it sits here with the rest of nomenclature
# because that is exactly what it is -- how this plant names the
# things it makes. A different plant spells the same kinds
# differently and this is the one key that has to change.
#
# THE PREFIX IS READ BY `scope.prefix_of`, NOT BY A MATCHER OF ITS
# OWN, and that is the whole reconciliation. `scope.py` already owns
# "what is the prefix of this mark", it takes the WHOLE leading
# letter run, and it argues at length against matching a declared
# prefix as a prefix -- so `AAA` does not reach `AAAB001`, whose
# prefix is `AAAB`. Two readers with two rules would disagree about
# which pieces a job's declared scope covers versus which checklist
# they get, and they would disagree silently. There is one reader.
#
# EMPTY MEANS NOBODY HAS DECLARED THE MAPPING. It never means "every
# piece is the same kind": an unrecognised prefix resolves to None,
# no profile is selected, and the FULL checklist runs under its own
# numbering. Which is what this tool did before profiles existed.
'member_kinds': {},
# A design packet's cover sheet carries the design number with a page
# suffix: design PNL1 has cover sheet PNL1.0, then PNL1.0A, PNL1.A and
# so on for the rest of the packet. A piece may carry either form.
# This pattern says which leading part actually identifies the design;
# the groups it captures are joined to make the key everything is
# matched on. Default: the letters and the first number.
'design_number_re': r'^([A-Za-z]+)\s*(\d+)',
# Sheets whose number starts with one of these are dead and skipped.
'ignore_sheet_prefixes': ['zvoid', 'zzvoid', 'xxx'],
# Project-browser folder holding the design packets. Empty string
# means do not filter by folder at all.
'design_folder': '',
# A view whose name matches this is a stale duplicate of another view
# and is dropped when two views would otherwise both look like the
# summary.
'stale_view_re': r'(\s+Copy\s+\d+|\s+old)\s*$',
# Checklist 1.4. design_number_re is ANCHORED -- it reads a value
# that IS a design number ('PNL1.0'). A view name carries its design
# label in the middle ('TICKET NOTE - WP2- STRAND JACKING FORCE'), so
# the same fact is needed unanchored. Left None it is DERIVED from
# design_number_re by dropping the leading '^', which is right
# whenever that pattern is the usual anchored form; a plant whose
# view names label the design differently sets it here rather than
# bending design_number_re, because the two are read by different
# checks and a change made for one silently moves the other.
'view_design_token_re': None,
# What one exported TICKET covers, and therefore whether a folder of
# them is a count of pieces or a count of drawings. A JOB fact -- the
# same plant does it both ways -- so it is declared in the job layer
# and NULL here. Null means NOBODY HAS SAID: the takeoff withdraws
# the ticket source and says so, rather than assuming one ticket is
# one piece. 'control_number' | 'piece_mark'.
'ticket_unit': None,
},
# ---- the casting bed ----------------------------------------------
#
# A design with strands always dimensions where they sit: a chain running
# across the pattern, first segment from a rail to the first strand, then
# strand to strand, last segment back to the far edge. The numbers are
# meaningless without knowing which rail the chain starts from, and
# reading it from the wrong end mirrors the entire pattern -- so it is
# stated here rather than inferred from geometry.
'rails': {
# Edge the strand dimension chain is measured from.
'strand_datum': 'left_rail',
# Whether that rail is the bed's FIXED rail. Some plants cast against
# a fixed left rail and move the right one to the piece width; others
# do the opposite. It decides which edge a tolerance accumulates away
# from, so None means nobody has said -- never a guess.
'left_rail_is_fixed': None,
# Clear width of the bed, in inches. The strands are stressed across
# the whole bed, so a piece narrower than the bed can sit anywhere in
# it -- and the amount the pattern may slide off the design chain is
# exactly what is left over:
#
# |offset| <= max_bed_width_in - panel width
#
# A full-width piece therefore has almost no freedom, which is what
# makes this checkable rather than advisory.
'max_bed_width_in': None,
},
# ---- the design summary view ---------------------------------------
#
# `strandmatrix` reads the strand pattern off the drawing, not off text:
# it counts a drawn symbol in the strand-pattern section of the design
# summary and checks that count against the strands the summary already
# states a quantity for. WHICH symbol marks one strand is a drafting-
# standard choice, not a geometry fact -- one plant fills a small
# FilledRegion at each strand position, another places a named detail
# component instead -- so it has to be named here rather than guessed.
# Get this wrong and the reader does not read a wrong grid quietly: the
# symbol count will not match the stated quantity, and the whole matrix
# refuses with that mismatch as the reason, which is the point -- a
# wrong name should fail loudly, not draw a grid nobody drew.
'design_summary': {
'strand_symbol': 'FilledRegion',
},
# ---- what goes on which drawing -----------------------------------
#
# Checklist 5.2. The manufacturer's standards place reinforcing by CLASS
# of bar, not by subtraction (section 10000, p804 notes 25/26/40; see
# ticketpage.bar_drawing_home). Telling the classes apart needs one
# number the standard never states: how much of the panel a bar must
# span before it is "full length" (and, across the piece, "full width").
#
# That is plant knowledge. Null by default and NOT guessed: with it
# unset, 5.2 still checks the embeds, and its reinforcing half stands
# down and says why -- the same shape as rails.max_bed_width_in standing
# BED-OFFSET down. A wrong default here would put a full-length bar in
# the plan's column and flag every ticket that correctly omits it.
#
# A fraction in (0, 1]. 0.9 would read "a bar reaching 90% of the panel
# is drawn as a full-length bar"; the right number is the detailer's.
# Which modelled families ARE reinforcing. Revit's own category says so
# for real rebar, and that is used first; a plant that models bars as
# generic-model families has to name them, because then nothing in the
# model says it is a bar. Empty by default, matched as substrings.
#
# The cost of leaving it empty is stated rather than hidden: a bar this
# does not identify keeps going through the OLD residual and can still
# be reported as missing from the BED PLAN when it correctly lives in a
# section. Filling it in is what stops that.
# A LIST is an enumeration and it goes stale in silence: the day the plant
# adds a bar family and nobody re-lists it, 5.6 and 5.7 stop seeing those
# bars and report nothing, which reads exactly like nothing being wrong.
# `reinforcing_rule` says the same thing as a RULE instead -- a PARAMETER
# to read and a token to find in its value -- so a family added tomorrow
# is recognised without an edit. See `reinforcing_verdict` for how the
# three settings below resolve against each other.
'drawings': {
'full_length_bar_fraction': None,
# THE ENUMERATION. Unchanged, still read, and it still WINS where it
# is set: a plant that would rather name its families one by one must
# be able to, and being overtaken by a pattern it did not write would
# be worse than the pattern is worth. A LIST, so it replaces.
'reinforcing_families': [],
# THE SAME ENUMERATION, WRITTEN AS A REGULAR EXPRESSION PER ENTRY,
# for a plant whose bar-family name is a fixed wrapper around an
# OPEN-ENDED CODE the wrapper alone cannot describe -- e.g. a family
# literally named `ACME-17_(SCHEDULED)`, `ACME-S4_(SCHEDULED)`,
# `ACME-T2_(SCHEDULED)` ... where the bend-type code in the middle is
# a growing series, not a fixed vocabulary. A plain substring token
# wide enough to catch every code (`ACME-` alone) would also swallow
# any future ACME-prefixed family that is not a bar; a token narrow
# enough to be safe (the wrapper's
# closing `_(SCHEDULED)`) would swallow every OTHER `(SCHEDULED)`
# family at the plant that happens to share that suffix (a strand
# family has been seen doing exactly that). Neither a fixed string
# nor `reinforcing_families`' plain "contains" match can express
# BOTH ends of the wrapper at once; a regex can.
#
# SAME EVIDENCE CLASS AS reinforcing_families, not a fourth way of
# knowing: `reinforcing_mode` treats a non-empty list here exactly
# like a non-empty `reinforcing_families` -- 'list' mode, wins over
# the rule -- and `reinforcing_verdict` checks the plain substrings
# first, then these patterns, before falling through to "no match".
#
# Matched with `re.search`, case-insensitively, against the family
# name AS WRITTEN (not upper-cased first, unlike the plain list) --
# write the parenthesis and any other regex metacharacter escaped,
# e.g. `\\(SCHEDULED\\)`. A pattern that fails to compile never
# matches, silently, exactly like a plain-list token that never
# appears in any family name -- it is not reported as an error,
# because nothing here can tell a deliberately-inert entry from a
# typo.
'reinforcing_family_patterns': [],
# THE RULE. A MAP, so it deep-merges -- a job can correct the token
# without restating the parameter, and vice versa.
#
# IT WAS A CATEGORY PLUS A FAMILY-NAME TOKEN UNTIL 2026-08-30, AND
# THAT WAS WRONG. The census it rested on read `FamilySymbol` TYPES
# only, and this plant's categorisation is written on the INSTANCE --
# so the census missed the entire reinforcing population and the
# category never resolved. Scoped to real assemblies the bars are
# right there: 112 members on one piece, 84 on another, none of them
# visible to a type read.
#
# The real rule is a PARAMETER and a token in its VALUE, and it is not
# an invention -- the plant has already encoded it in the model.
# Thirteen view filters key on this one parameter and every string
# rule among them is "contains", not "equals".
#
# parameter a parameter ROLE (see PARAMETER_ROLES). The role
# is named here; the plant maps it to its own
# parameter name in `parameters.<role>`, so the
# office's parameter name never enters this
# repository.
# read_from ['instance', 'type'], in order. They are
# MUTUALLY EXCLUSIVE per element -- where the
# instance carries a value the type is empty and
# vice versa -- so reading one alone finds nothing
# on half the model and looks like a clean result.
# value_contains tokens to look for in that value, matched
# case-insensitively as SUBSTRINGS because that is
# what the plant's own filters do. A list, so it
# replaces.
#
# BOTH ARE REQUIRED for the rule to run, and that is the safety: a
# bare token with no parameter behind it has nothing to read.
#
# `parameter_verified` FALSE IS NOT "there is no such parameter". It
# is "nobody has confirmed it against a model", which has to stay a
# separate state: guessing it wrong un-recognises every bar at once,
# silently, which is precisely the dormancy this rule exists to end.
# `parameter_unverified_because` says what is missing, and
# `reinforcing_mode()` reports the rule unrunnable and quotes it.
'reinforcing_rule': {
'parameter': 'member.manufacture_component',
'parameter_verified': False,
'parameter_unverified_because': None,
'read_from': ['instance', 'type'],
'value_contains': [],
# PER-CHECKLIST-ROW SCOPE, and it is DORMANT ON PURPOSE.
#
# The token is not a clean boundary and the exceptions are known
# in both directions. Some bar-formed HARDWARE carries the token
# (one of them is a strand loop); some genuine reinforcement
# carries no token at all. So 5.6 ("every bar on the ticket is
# marked") and 5.7 ("no bar is longer than its stock") may
# legitimately want DIFFERENT populations -- a strand loop has no
# bar mark and no stock length, and neither absence is a finding.
#
# Keyed by checklist item so the two rows can differ; `also` and
# `except` are lists of parameter VALUES matched the same way the
# token is. Both empty, because nobody has ruled which way each
# row should go, and inventing a scope here would decide it
# silently. `reinforcing_verdict(..., item=)` reads them; with
# nothing here every row sees the same population, which is
# today's behaviour exactly.
#
# THE MERGE COST, STATED: `scopes` is a map so a job can add one
# row's scope without restating the other's -- but `also` and
# `except` are LISTS INSIDE it, and a layer that sets one replaces
# it whole rather than adding to it.
'scopes': {},
'scopes_unresolved_because': None,
},
# PER-FAMILY CORRECTIONS, applied before either of the above and
# therefore able to overrule both: `FAMILY_NAME: true` forces one in,
# `false` forces one out. A MAP, so a job adds one correction and
# inherits the plant's; as a list it could only replace them.
# Matched EXACTLY, case-insensitively -- not as a substring, or an
# entry for `SR1` would swallow `SR1101`.
'reinforcing_family_overrides': {},
# ---- WHERE A BAR'S SIZE COMES FROM -------------------------------
#
# Checklist 5.7 measures a bar against the stock length for ITS OWN
# SIZE, and at a plant there may be TWO FIELDS holding it, covering
# DISJOINT POPULATIONS.
#
# the size parameter the `member.bar_size` role. A string
# holding exactly `#4`. Measured over 24,539
# rebar members: carried by the STRAIGHT
# family and by not one of the twelve bent
# ones.
#
# the part description the `member.identity_description` role,
# read off the FAMILY, whose value is
# `#4 BENT BAR (17)`. Carried by all twelve
# bent families, 100% on the type, and by no
# straight member.
#
# SO THERE IS NO PRECEDENCE HERE AND NO FALLBACK. Two readers for two
# populations. `bar_size_of()` tries the size parameter first purely
# because one of them has to be first; no bar has both, so the order
# has never decided anything and must not be read as a ranking.
#
# AND THE DESCRIPTION IS NOT PROSE. Owner, 2026-08-31: "assembly 的
# identity description 是我的随笔。family 的不是。" -- the ASSEMBLY's
# identity description is his running notes (that is the field the
# reinforcing donor work reads, where `MASTER` is a stable token
# inside free text); the FAMILY's is structured plant data. Two
# different fields on two different kinds of element sharing one name.
# `description_read_from` is what keeps them apart, and reading the
# wrong one gets prose where structure was expected.
#
# `bar_size_of()` still returns WHICH FIELD ANSWERED as `basis` --
# same discipline as `rebar_stock`'s basis and `rebar_fy`'s. Not
# because one is weaker, but because a verdict that cannot say what it
# read cannot be audited.
#
# `bent_bar_tokens` GATES the description parse as well as explaining
# an absence. A description that no longer reads as `#N BENT BAR`
# leaves the bar UNJUDGED rather than mis-sized -- the shape is the
# evidence that the value means what we think, so a shape that changes
# must stop the read, not be parsed through.
'bar_size_rule': {
'description_role': 'member.identity_description',
'description_read_from': ['type'],
'description_fallback': False,
'description_verified': False,
'description_unverified_because': None,
'bent_bar_tokens': [],
},
# ---- checklist 6.6: a note required where a count has a parity ----
#
# A MAP, so a job can correct the wording without restating the family
# and vice versa. Read by the 6.6 check through `K.get`, which is why
# the keys start working the moment a layer sets them.
#
# family_names the families whose members are counted. A LIST,
# so it REPLACES -- what a layer names is exactly
# what is counted.
# family_verified whether anyone has confirmed those names
# against a model.
# parity which parity of the count requires the note.
# require_text the note that must then appear. A string, or a
# list of acceptable spellings.
#
# `family_verified: False` IS NOT "THERE IS NO SUCH FAMILY". It is
# "nobody has confirmed the name against a model", and it has to stay
# its own state, because the failure mode here is worse than usual: a
# family name that matches nothing counts ZERO on every piece, zero is
# even, and the row reads a permanent invisible OK. That is
# silence-is-a-pass wearing a parity costume -- the check must report
# the rule unrunnable and name the reason instead. Same discipline,
# and the same reason, as `reinforcing_rule.parameter_verified`.
'parity_note_rule': {
'family_names': [],
'family_verified': False,
'family_unverified_because': None,
'parity': None,
'require_text': None,
# WORDINGS THAT ARE REPORTED AND NEVER ACCEPTED.
#
# A piece whose note says nearly the right thing is a DIFFERENT
# DEFECT from a piece with no note at all: one needs a wording
# corrected, the other needs a note written. A report that reads
# identically for both sends the reader to the wrong job.
#
# It is NOT a second accepted spelling and must never become one.
# Where an owner names one phrase, widening the rule to accept a
# near-miss quietly overrules him -- so a piece carrying one of
# these still FAILS, and the finding names the wording it found
# instead of saying nothing was there.
#
# A LIST, so it replaces. Every entry should be a wording actually
# seen in a model, never a guess at what somebody might type: this
# key's whole value is that it describes the real corpus.
'require_text_also_seen': [],
# A COUNT IS NOT A RECOGNITION, and this key is where the two part
# company. `families.lifter_include` may safely hold every panel
# type's face lifter at once -- an extra family there produces a
# visible finding about a real insert. A parity COUNT cannot: a
# piece carrying two families where one is the lifter and the
# other is present for some unrelated reason counts wrong, and a
# wrong parity is a wrong verdict with nothing to show for it.
# So this list must be narrowed per panel type before it is
# filled, and `families_for()` is what narrows it.
'family_names_by_panel_type': {},
},
},
# ---- lateral extraction -------------------------------------------
#
# Which members resist lateral load is an engineering judgement, and
# this tool does not make it. The user marks the members BY HAND, by
# typing a token into the element's identity description. Everything
# here is about reading that mark, never about deciding it.
'lateral': {
# The token, and its spellings. The owner types 'lateral-resistent';
# the conventional English is 'lateral-resistant'. Both are accepted
# because a hand-typed field is not a controlled vocabulary. Matched
# case-insensitively, tolerant of the separator (-, _, space) and of
# surrounding text; NOT tolerant of a missing separator. It is a
# LIST so a plant with its own word ('LFRS') adds it here rather
# than editing code -- see latmodel.match_marker for exactly what
# each spelling accepts and rejects.
'marker_tokens': ['lateral-resistent', 'lateral-resistant'],
#
# WHICH FIELD the mark is written into is NOT configured here. It is
# a parameter name, so it is a ROLE: `parameters.member.identity_
# description`, mapped in the plant file like every other parameter
# this codebase reads. Naming it here as well would be a second
# place to set the same fact, and the one that drifts is always the
# copy. Unmapped, Lateral Extract stands down and says so; it never
# falls back to a guessed name, because a guess that happens to be
# right on one template does nothing at all on the next one.
#
# Scanned when nothing is preselected. Always a list -- an
# unfiltered collector took Revit down once already.
'categories': ['OST_StructuralFraming', 'OST_StructuralColumns',
'OST_Walls', 'OST_StructuralFoundation',
'OST_GenericModel'],
# How close two member ends must be to become one analysis node,
# in FEET. Null uses latmodel.DEFAULT_NODE_TOL_FT (1/8").
'node_tolerance_ft': None,
# What to do with a member that has no location curve -- which, in
# a point-placed precast model, is all of them. Null means REFUSE:
# the member is skipped and the reason is reported. The only other
# value implemented is 'bounding_box_longest', which is opt-in
# precisely because it is a guess: measured on this model, a 90 ft
# column has a 140 ft bounding box, so the axis it yields is wrong
# by tens of feet while looking entirely plausible. Same shape as
# rails.left_rail_is_fixed -- a wrong default here is invisible.
'point_member_axis': None,
# Where the extracted files go. Null puts them beside the MODEL, in
# its `_ticketqc/lateral/` folder -- never in the repository.
'output_dir': None,
},
# ---- what this shop calls things ----------------------------------
#
# Every Revit parameter this tool reads is named by *role*, never by the
# literal name a particular office template happens to use. The mapping
# from role to real name lives in knowledge.yaml, which sits beside the
# model and is not part of this repository.
#
# The defaults are deliberately empty. A wrong default would be read as a
# missing value and the check that needed it would quietly not run, which
# is the exact failure this whole design exists to prevent. Unmapped means
# unmapped, and Self Check says so.
#
# A value may be one name or a list of candidates tried in order:
# length: PANEL_LENGTH
# length: [PANEL_LENGTH, PIECE_LENGTH, Length]
'parameters': {
'panel': {
'design_number': None, # groups pieces to one design summary
'length': None, # long dimension, a Revit length
'width': None, # short dimension, a Revit length
'unit_weight': None, # concrete density, pcf
'weight': None, # cast weight of the piece, lb
},
'panel_type': {
'thickness': None, # on the type, not the instance
},
'strand_type': {
'diameter': None, # nominal strand diameter
'description': None, # carries the grade, e.g. '... 270 KSI'
},
'wwf_type': {
'description': None, # carries the mesh designation
},
'sheet': {
'design_number': None, # design number as printed on the ticket
'length': None,
'width': None,
'depth': None,
'plant': None, # producing plant, optional
'fc': None, # final strength, as printed (title block)
'fci': None, # release strength, as printed (title block)
'unit_weight': None, # unit weight, as printed -- many plants
# do not print it at all, and then the
# PCF-NOTE check says so rather than
# failing the ticket (see ticketqc)
},
},
# ---- the reinforcing-suggestion donor pool -------------------------
#
# `TICKET_IS_REINFORCED` says somebody drew reinforcing on a piece. It
# does not say anybody CHECKED it, and the suggestion feature had no way
# to tell a reviewed template from a merely-flagged one. This plant types
# a word into the assembly's identity comment to mark the reviewed ones,
# and the suggester ranks those first BETWEEN OTHERWISE EQUAL donor sets
# -- a tie-break, never a filter, and never ahead of a better geometric
# fit (owner's ruling, 2026-08-30).
#
# The word is the plant's, so it is a key rather than a literal in the
# code. Matched case-insensitively and as a whole word anywhere in the
# field, because the field is typed by hand: see
# `reinforcecore.is_master_text` for exactly what that accepts.
#
# Set it to '' or null to switch the signal off entirely; the ranking
# then falls back to what it was before the signal existed.
'reinforce': {
'master_token': 'MASTER',
},
# ---- the once-a-day snapshot -------------------------------------------
#
# Snapshot & Close: the engineer syncs, presses one button, the tool
# indexes the OPEN model (phase A), Revit closes (phase B), and -- only
# if this section is filled in -- a deferred process opens the cloud model
# detached ONCE REVIT HAS EXITED and writes a timestamped copy (phase C).
#
# EVERY PATH KEY DEFAULTS TO None AND None MEANS NOT CONFIGURED. It never
# means "use a sensible folder": a tool that invents somewhere to put a
# 300 MB model is a tool that fills a disk nobody is watching. The button
# says the feature is not set up and stops after phase B, which is the
# useful half and needs no configuration at all.
#
# These are JOB facts -- where this job's archive lives, which Revit this
# job opens in -- so they are declared in the job layer and are empty
# here. See `vibe/snapshotmeta.py` for the naming and discovery rules and
# for why the timestamp goes in the FOLDER name by default.
'snapshot': {
# Where timestamped detached copies are written. None = phase C off.
'detached_dir': None,
# True to chain phase C from the button press. Ignored (and reported)
# when `detached_dir` is None, so turning it on is two deliberate
# edits rather than one that half-works.
'after_close': False,
# 'folder' (default) puts the stamp in the containing folder name and
# keeps doc.Title -- and therefore the job folder -- constant.
# 'filename' puts it in the model's own name, as first asked for; that
# moves the job folder every day when WILLTOOLS_JOBS is set, and
# `snapshotmeta.FILENAME_WARNING` is what the tool prints about it.
'layout': 'folder',
# Which Revit the deferred run opens, e.g. 2025. None lets the pyRevit
# CLI choose, which is only safe on a machine with one Revit.
'revit_year': None,
# 'preserve' keeps worksets in the detached copy (the copy becomes a
# new central); 'discard' makes a plain non-workshared file. Preserve
# is the default because it loses nothing.
'worksets': 'preserve',
# How long the deferred process waits for Revit to exit before giving
# up and logging a failure. A closing Revit with a 10 GB model takes
# minutes; a Revit that is waiting on a save prompt takes as long as
# the engineer does.
'wait_minutes': 20,
# The pyRevit CLI. None = look where pyRevit installs it.
'pyrevit_exe': None,
# ---- the CYCLE (tools/snapshot_cycle.py), added 2026-09-01 --------
#
# The copy stopped being an archive and became a SANDBOX: the
# pipeline writes it, closes the Revit that was on the last one,
# reopens on the new one, runs a batch inside it and closes it again.
# These are the keys that step needs, and they are all still None or
# empty here for the same reason as everything above -- a path this
# code invents is a path nobody chose.
# Revit itself, for opening the sandbox. `pyrevit run` is not usable
# for this: it plays a journal and quits, which is right for the
# one-shot detach and wrong for a session that has to stay up while a
# sweep drives it. None = derive from `revit_year`, which is a GUESS
# at the install location and is checked before it is used.
'revit_exe': None,
# What to run inside the sandbox once it is up, in order. Each entry
# is a host command line; each gets its own log in the snapshot
# folder; the first non-zero exit stops the rest.
'batch': ['python tools/sweep.py'],
# How long the sandbox gets to start Revit, load pyRevit, open a
# 300 MB model and answer one request. A timeout here is REPORTED,
# never assumed away -- see `snapshot_cycle.wait_for_sandbox`.
'sandbox_wait_minutes': 15,
# WHICH MODEL IS COPIED, for a run that has no Revit to ask.
#
# A cloud model is found by its region and two GUIDs, and nothing on
# the machine knows those except `doc.GetCloudModelPath()` inside a
# session that has the model open. So the identity has to be written
# down once or every run needs somebody to press a button first --
# which is the thing the owner said was too slow to be in the loop.
#
# Normally these stay null and the identity arrives the easy way: the
# button caches it to `<detached_dir>/source.json` on the first press
# and `tools/snapshot_cycle.py` reads it from there ever after. They
# are here for a job that would rather state it outright.
#
# `source_cloud` is {region, project_guid, model_guid}. `source_path`
# is the alternative for a file-based job. Neither is a fallback for
# the other -- a job is one or the other and guessing which was never
# safe.
'source_cloud': None,
'source_path': None,
'source_title': None,
},
# ---- the bridge ---------------------------------------------------------
#
# WHICH MODELS THE ROUTES API REFUSES TO SERVE. This is the owner's
# standing instruction -- 不要碰我的live -- written as code rather than
# carried in an agent's memory.
#
# The hole it closes is a port race. pyRevit takes the configured routes
# port if it is free and walks UP if it is not, so which Revit owns 48884
# is decided by which one started first. Restart the sandbox while the
# live session is up and every request an agent sends afterwards lands in
# the live model -- and it all looks normal, because the snapshot copy
# deliberately carries the SAME TITLE as the model it was copied from.
#
# Which is exactly why the title alone cannot decide it, and why a
# document on the list can still prove itself: `startup.py` lets it
# through when its file is under a snapshot root, or when it is the model
# named by WILLTOOLS_SANDBOX_MODEL in that Revit's own environment (which
# only `tools/snapshot_cycle.py` sets, on the session it launched). All
# of the matching is in `vibe/bridgeguard.py`.
#
# EMPTY BY DEFAULT, and empty means "no model is protected" -- the tool
# does not know which of a machine's models is somebody's live work. The
# VALUE is a model name, which is job information, so it belongs in the
# job (or plant) knowledge layer and never in this file.
'bridge': {
# Document titles the bridge must refuse to serve. A list of names,
# matched without the extension and without regard to case.
'refuse_titles': [],
# Document titles the bridge may RUN ON but never WRITE TO. Reversed
# from refuse_titles on 2026-09-01 -- the owner's own words: "我要把
# revit桥给你。你直接来sample_job。铁律是在live里面只读不写。" A title
# here answers /status, /collect, /params, /selection and any
# /exec snippet that only reads, normally; /exec refuses outright
# when the request carries a transaction or the snippet matches
# known write-capable Revit API surface. See `bridgeguard.py`'s
# `WRITE_PATTERNS` for exactly what that covers, and what it does
# not -- this is a guard rail, not a sandbox. A title on
# refuse_titles is refused outright regardless of this list; the
# two are not layered, refuse_titles simply never reaches here.
# EMPTY BY DEFAULT, same reasoning as refuse_titles below.
'readonly_titles': [],
# Folders whose models are sandboxes whatever their title says.
# `snapshot.detached_dir` is always treated as one; this is for the
# rest.
'allow_roots': [],
# Where `tools/snapshot_cycle.py` starts looking for the routes
# server, and how many ports up it walks. Not the port pyRevit is
# configured with -- that is pyRevit's own setting; this is only the
# range the pipeline scans to work out which session is which.
'port': 48884,
'port_span': 4,
},
'tolerances': {
# Dimensional comparisons, in inches.
'dimension_in': 0.375,
# Strengths and tensions, absolute.
'psi': 1.0,
# How close the ticket's printed jacking force must come to the design
# intent's strand initial tension, in POUNDS. 100 lb, ruled by Will
# 2026-08-30 -- a DECISION, not a measurement, and the exact
# equivalent of the `> 0.1` kips literal that checklist 4.3 carried
# hardcoded before the key was wired. It was 1.0 here when the literal
# was deleted, which would have made the check a hundred times
# stricter than anybody intended and flagged very nearly every ticket.
'it_lb': 100.0,
# 5.9: how far the widest gap between connectors may exceed the
# design's stated on-centre spacing. A TOOL DEFAULT, not a ruling --
# the provenance is written out in
# `connector_spacing_tolerance()`, beside the code that reads it.
'connector_spacing_in': 0.5,
},
'families': {
# Instances of these count as one strand each. Matched as substrings
# against the family name, case-insensitive.
'strand_include': ['STRAND'],
'strand_exclude': ['LOOP', 'GROMMET'],
# Welded wire fabric.
'wwf_prefix': 'WWF',
# Anything matched here is treated as a lifting insert.
'lifter_include': ['LIFT', 'LPA', 'ANCHOR'],
'lifter_exclude': ['SHEAR'],
# Separate families that are cast concrete of the piece itself --
# a slab-seat ledge, a haunch -- not inserts, not voids. Their
# volume is real weight: WP371's ledge carried 8.99 ft3, and
# leaving it out understated the piece by 7.5%.
'cast_include': ['LEDGE'],
},
'legend': {
# A ticket's strand/reinforcing pattern is a legend VIEW on plants
# that draw one. This one does not -- 0 of 204 sheets here carry a
# STRAND/REINFORCING PATTERN view -- so the DEFAULT is empty and the
# legend checks must not fail a ticket for the absence of
# something the plant never draws (docs/ticket-audit-wp1-12.md).
# A plant that does use legend views names its prefixes here and the
# legend checks re-engage.
'pattern_prefixes': [],
# When the legend name ends in a width, it must match the panel.
'width_must_match_panel': True,
# Legend text prints one strand count for a whole design; set true if
# that printed count must equal the strands actually modelled.
'printed_strand_count_is_per_piece': True,
# The view whose text carries the per-piece strand count, printed as
# `(n) 0010001 (E.L.)` inside section A-A on sheet 2. Empty when this
# plant has no such section view; the count is then read from the BOM
# linear-feet row instead (ticketqc.read_ticket).
'section_strand_view': '',
},
# ---- the ticket's written instructions ----------------------------
#
# Checklist 6.5. A ticket carries standing instructions -- how to stack
# it, how to erect it, where to pick and strip it -- each drawn as its
# own small VIEW placed on the sheet. The audit found those views
# containing their own title and nothing under it, which prints as a
# heading over blank paper and reads exactly like an instruction that
# was given.
#
# The names are plant nomenclature, so the DEFAULT IS EMPTY and the
# check reports NOT RUN with that reason rather than passing a ticket it
# never looked at. Naming them here is the whole configuration:
#
# notes:
# instruction_views: [DUNNAGE, ERECTION, PICKING-STRIPPING]
#
# A LIST REPLACES the layer below it -- naming two here removes any
# others, which is the merge rule for every list in this file. A MAP
# DEEP-MERGES, so a job can add one group without restating the rest.
#
# A declared entry is a GROUP OF ALTERNATIVES, because a plant draws
# several variants of the same note and a ticket places ONE of them.
# The group wants at least one of its names placed; only a group with
# none of them reports "not placed", and it reports the group, not
# every variant. Four shapes are accepted (ticketpage._normalise_groups):
#
# instruction_views: [DUNNAGE, ERECTION] one group per name
# instruction_views: [DUNNAGE, [ERECT, ERECT2]] a group of two
# instruction_views: {ERECTION: [ERECT, ERECT2]} the same, named
#
# A fifth shape marks a group the piece carries only SOMETIMES -- a
# note the manufacturer's own standard calls conditional:
#
# instruction_views:
# ROTATE:
# optional: true
# views: [ROTATE-A, ROTATE-B]
#
# Absent, an optional group is SILENCE, not a finding; placed, it is
# judged for content like any other. Declaring a conditional note as
# an ordinary group would report it missing on every ticket that does
# not need it, which is a permanent false alarm. MERGE: the group is
# a MAP so `optional` and `views` DEEP-MERGE independently, but
# `views` is a LIST and REPLACES the layer below -- restate it whole.
#
# and instruction_view_sets, a MAP of product family -> those groups.
# Only the family this ticket actually carries is required of it, so a
# slab ticket is never asked for the panel erection note. When no
# declared family appears on a ticket, that is an undeclared product
# type: NOT RUN with that reason, never a pass and never a finding.
'notes': {
'instruction_views': [],
'instruction_view_sets': {},
},
# ---- which views a model is allowed to be shown ----------------------
#
# Ticket QC escalates a check code cannot compute by rendering ONE VIEW
# and asking a small vision model about it (vibe.ticketvision, and
# docs/ticket-checklist.md section 8bis for what its answer may claim).
#
# Which views carry bar marks is plant nomenclature, exactly like
# notes.instruction_views, so the DEFAULT IS EMPTY and checklist 5.5
# reports NOT RUN naming that -- never a pass on a drawing nobody looked
# at. Naming them is the whole configuration:
#
# vision:
# barmark_views: [BED PLAN, SECTION]
#
# Matched as a case-insensitive name PREFIX, and a list REPLACES the
# layer below it. Nothing here is rendered unless the run-time option
# vision_escalation is also on: naming a view is permission, not a
# decision to spend.
'vision': {
'barmark_views': [],
},
# ---- what the ticket SAYS about debonding --------------------------
#
# Checklist 4.5. 4.7 derives from the geometry WHERE debonding is
# required (strand.debond_below_continuous_ft, which has no default on
# purpose -- it is the plant's number and a wrong one is worse than
# none). 4.5 asks the other half: where the requirement is met, does the
# ticket CALL IT OUT? Owner's ruling 2026-08-30 -- required and not
# called out is a WARN.
#
# These are the words a call-out is printed in, matched as an upper-case
# substring anywhere in a BOM row or a placed view's text. They are
# nomenclature, so THE DEFAULT IS EMPTY and 4.5 reports NOT RUN with
# that reason rather than passing a ticket whose language it does not
# speak. A list of alternatives, because the same instruction is printed
# short on a bed plan and long on a note sheet.
#
# strand:
# debond_callout_tokens: [DEBOND]
#
# NOT the manufacturer's own fixed-length-each-end debond standard,
# which is keyed on strand diameter and is a different rule (QNA, the
# 1000-2000 plank extraction, A4). Do not merge the two.
'strand': {
'debond_callout_tokens': [],
},
'sheet': {
# A ticket's printed length is nominal and legitimately runs over
# the modelled length on stemmed pieces, so this comparison is off
# unless a project turns it on.
'check_tkt_dimensions': False,
},
# ---- the manufacturer catalogue -----------------------------------
#
# Published product data: what a given insert is rated for. It is not
# secret and not job-specific, but it is still DATA rather than code --
# a rated load written into a Python file is a number nobody can correct
# without a release, and it will be wrong eventually.
#
# Two hops, because a plant rarely models the manufacturer's designation
# directly. The plant layer says which catalogue item its own part number
# means; this layer says what that catalogue item can do:
#
# family name in the model -> catalogue_alias -> catalogue entry
#
# so a plant renaming its parts is a two-line edit and the ratings are
# untouched.
'catalogue': {
'inserts': {
# 'SAMPLE_ANCHOR_4T': {'working_load_lb': 8000},
},
# Safety factors on a lifter's rated capacity -- lifter properties,
# so they live with the ratings rather than in a per-job rigging
# block (Will 2026-08-30). They apply ONLY where the catalogue
# quotes an ULTIMATE load; every working_load_lb is a published SWL
# that already contains its factor, and panellift applies these only
# when `capacity_is_ultimate` is set (nothing sets it).
#
# NULL, AND NOT 4.0 / 2.67 AS IT WAS UNTIL 2026-08-31. The 2.67 had
# no source -- no page, no ruling, no transcript -- and the
# manufacturer's own sheet for the parts this reached contradicts
# it: ALP catalog p53 (02/2024) heads the capacity table "Capacity
# in Concrete 4:1 SWL" across BOTH load columns and prints shear
# equal to tension on every row. A factor is a property of the part
# that was tested, so the numbers now live inside each
# `catalogue.inserts` entry with the page that publishes them, and
# `lifter_safety_factor()` resolves them per part rather than
# letting one manufacturer's factor reach another's insert. There
# is no trade-wide value to put here; a part with no entry is
# reported as unjudged, never given a borrowed number.
'safety_factors': {
'tension_fos': None,
'shear_fos': None,
},
# ---- rebar stock ----------------------------------------------
#
# Will, 2026-08-30: "rebars come in 40ft stock. unless overwritten
# by other knowledge, all rebars pieces must be less than 40ft."
# Industry stock, not a plant habit, so the NUMBER is published in
# `knowledge/catalogue.yaml` (40.0) and merges over this null.
#
# Null here on purpose: with no number the check reports NOT RUN
# naming the key, never a pass. A plant that stocks 60 ft, or a job
# spliced differently, overrides it the normal way.
'rebar': {
# THE GENERIC STOCK LENGTH. One number for every size, and it is
# a FALLBACK, not the answer. Real stock is per bar size and two
# sizes are commonly nothing like 40 ft -- a #3 comes in 23'-4"
# and a #4 in 20' at one plant measured 2026-08-30 -- so a check
# that compares every bar against one number passes a 30 ft #4
# that cannot be cut from 20 ft stock. Silently.
#
# Kept because it is a reasonable industry default for the sizes
# nobody has listed, and it is read ONLY when `stock_by_size` has
# no entry for the size in hand. See `rebar_stock()`.
'stock_length_ft': None,
# ---- STOCK LENGTH PER BAR SIZE --------------------------------
#
# `'#4': {stock_length_in: 240, stocked: true, astm: A615}`
#
# PLANT KNOWLEDGE and EMPTY HERE. Owner 2026-08-30: 这个手册里面
# 的东西是厂知识 -- what a producer's manual lists is that
# producer's, not the trade's. Nothing about which sizes are
# stocked, or how long they come, is ever written into this file
# or into catalogue.yaml.
#
# A MAP, keyed by the size as it is printed ('#4'), and that is
# the load-bearing choice. Maps deep-merge: a job that special
# orders one size adds one key and INHERITS the plant's other
# six. A list would replace, so a job adding one bar would delete
# the plant's stock table and every check would go on looking
# entirely normal.
#
# The cost of a map, stated so nobody is surprised: a higher
# layer can override a size but cannot REMOVE one. That is the
# right trade, because removal is not what anyone means -- a size
# the plant has stopped stocking is `stocked: false`, which is a
# fact worth printing, not an absence.
#
# INCHES, and exactly. Two of the lengths measured are not whole
# feet: 23'-4" is 23.3333... ft and no decimal written in a file
# is equal to it, so a bar cut to exactly 23'-4" would fail a
# comparison against its own stock length. Integer inches has no
# such rounding. `rebar_stock()` returns both units so no caller
# ever converts.
#
# `stocked` IS ITS OWN FACT, separate from the length. A size can
# be listed with a length AND marked not stocked -- the length is
# what would arrive if it were ordered, and `stocked: false` is
# why a ticket calling for one deserves a human. Neither is
# modelled as the absence of the other.
'stock_by_size': {},
# ---- WHAT THE STOCK IS MADE OF -------------------------------
#
# PLANT KNOWLEDGE, empty here. `{astm, fy_ksi, basis}` stated
# once for the plant rather than repeated on every size row --
# binding a material to a length is how a stale designation
# outlives the page it came from, and one producer's manual has
# already been found filing its live stock lengths under a
# designation it no longer stocks.
#
# `basis: stated` means somebody asserted this about THIS PLANT.
# It outranks the ASTM default in `astm_grades` below, which is
# only "what that designation usually means". `rebar_fy()`
# reports which of the two it used, every time, so a default can
# never be read as a measurement and a stated fact is never
# quietly downgraded to one.
'stock_material': {},
# ---- the ASTM headings a producer's stock list is written under
#
# PLANT KNOWLEDGE, empty here. PROVENANCE, not a live fact -- a
# record of what a page said, which is why entries carry
# `superseded`. Recorded because a heading with NOTHING UNDER IT
# is a fact, and a different fact from a heading that is not
# there at all:
#
# sizes_listed: true sizes are listed; look them up
# sizes_listed: false NAMED, with no sizes under it. Nothing
# is known about it. This is NOT "the
# plant has none".
# no entry never mentioned.
#
# That middle state earned itself within a day. One manual named
# A706 with the page ending under it; read as "the plant has no
# A706" it would have been WRONG -- the owner's correction was
# that A706 is exactly what the plant stocks and the manual was
# simply out of date. Read as "nothing is known", which is what
# it says, it was right and became answerable.
#
# `rebar_astm()` returns all three distinctly. Collapsing the
# middle one into either neighbour invents a fact.
'astm_designations': {},
# ---- what an ASTM designation says about yield strength -------
#
# PUBLIC, and published in catalogue.yaml: this is what the
# standard says, not what a producer stocks.
#
# A designation DOES NOT DETERMINE fy. Current A615 covers Grades
# 60, 80 and 100 (historically 40 and 75 as well), so "A615" on a
# stock list says nothing at all about strength. Current A706
# defines Grade 60 and Grade 80; 60 is the right default and it
# is a DEFAULT, never an identity.
#
# So `default_fy_ksi` is null for a designation that has no
# defensible default, and `rebar_fy()` reports WHERE its answer
# came from -- stated, or an ASTM default -- so a default can
# never be read as a measurement. A check that needs fy and gets
# None must stand down and say why. Nothing here ever guesses 60.
'astm_grades': {},
# ---- bar grades KNOWN to be on this job -----------------------
#
# JOB KNOWLEDGE, empty here. A human saying "there is a #9 at
# 80 ksi on this job" -- an existence fact about one call-out, NOT
# a statement that every #9 on the job is 80 ksi.
#
# A MAP keyed by a short id, for the same reason `stock_by_size`
# is one: another such fact is another key. As a list, a second
# declaration would replace the first.
'grades_present': {},
# WHICH MARKS ARE ALLOWED TO EXCEED STOCK. Empty by default and
# NEVER filled in here or in catalogue.yaml -- a mark is this
# plant's word for its own part, so the list belongs in the
# plant or job layer, which is not in this repository. The
# catalogue branch owns the stock NUMBER (public); the
# exemptions sit beside it so a reader finds both in one place.
#
# BY MARK, NOT BY FAMILY, and that is a deliberate departure
# from how `_is_reinforcing` identifies a bar. That function
# asks "is this a bar at all", and family is the right grain
# for it. This asks "is this bar allowed to be longer than a
# stick", which is a property of the PART: measured 2026-08-30,
# one bar family at this plant carries both 11 ft pieces and
# 60 ft ones, so exempting by family would exempt every bar at
# the plant and switch the check off.
#
# Matched EXACTLY, case-insensitively -- not as a substring, or
# an entry for `SR1` would silently swallow `SR1101`, the same
# trap `drawings.called_out_on_detail` already documents.
'stock_exempt_marks': [],
},
},
# ---- what the parenthetical suffix on a call-out means -------------
#
# A ticket calls a bar out as `SR324 (F.F.)`, `SR310 (E.L.)`,
# `P76 W/ SR5602 (S.I.F.)`. The suffix is a FACE DESIGNATION: it says
# which face of the piece the bar or embed goes on. Checklist 5.3 asks
# whether the designation printed on the ticket agrees with the side the
# model actually puts the part on.
#
# THE ABBREVIATIONS ARE PUBLIC and identify nobody -- they are the trade's
# -- so the VOCABULARIES ship in `knowledge/catalogue.yaml`, the one layer
# in this repository. This branch is only their shape and its default.
#
# WHICH ONES A PLANT USES IS PLANT KNOWLEDGE (owner, 2026-08-30: 不同的
# plant 对这个东西的称呼不同 -- TIF/BIF is just as common as NF/FF). So
# the plant layer SELECTS by id, in `face_designations.use`, and does not
# copy the tokens. A plant may select more than one: near/far names a
# LAYER POSITION and in-form names a CASTING ORIENTATION, and a ticket
# that prints `(E.L.)` on one line and `(S.I.F.)` on the next is using
# both at once.
#
# WHY EACH VOCABULARY DECLARES ITS FRAME OF REFERENCE, and why the two
# families must not be flattened into one token -> meaning table:
#
# frame: view near / far is measured from THE VIEWING DIRECTION. It
# flips when the view flips. An elevation that looks at
# the panel from the opposite side than was assumed makes
# every N.F./F.F. on the sheet backwards, and each one
# still reads perfectly normal on its own.
# frame: form top-in-form / bottom-in-form is measured from THE
# CASTING BED. It is absolute for the piece and does not
# care which way the view looks.
#
# Resolving a token therefore needs different evidence depending on which
# vocabulary it came from, and a flat map cannot say which. That
# distinction IS the thing that makes 5.3 decidable, so it is structural
# here rather than a comment.
#
# EMPTY `use` MEANS NO VOCABULARY DECLARED. The 5.3 check then stands
# down and says why -- it never guesses a vocabulary, because guessing
# `view` where the plant meant `form` produces confident, wrong verdicts.
# A LIST, so it REPLACES rather than merges: a job saying it uses one
# vocabulary means one, and a leftover second vocabulary silently still
# in force is precisely how a token gets resolved in the wrong frame.
#
# THE MODEL-SIDE HALF, added 2026-08-31. A face designation is printed on
# a ticket AND recorded on the element, and 5.3 asks whether the two
# agree, so it needs both. Everything below is the second one.
#
# model_tokens what the element's field holds -> the printed
# designation. A MAP, so it deep-merges. It is a
# SEPARATE KEY rather than an alias on a token
# because `tokens` is a list and lists replace: a
# plant aliasing a published token would replace the
# whole published vocabulary.
# read_from ['instance'] and NOT ['instance', 'type'].
# Measured over 180 real tickets: the SAME part code
# carries different tokens on different sheets (24
# codes do), so the face is a property of the
# PLACEMENT. A type read is not a weaker version of
# an instance read, it is a wrong one -- it collapses
# distinct facts into one and looks clean doing it.
# modifiers tokens that STACK AFTER a face token in the same
# call-out (`<part> (N.F.) (E.S.)`) and say something
# other than which face. A modifier is not a face and
# has no (frame, side); a reader strips it and
# resolves the face underneath.
# not_face_tokens parenthesised groups that look like a face token
# and are not one. The negative has to be written
# down or it comes back as a false positive.
# attaches_to token -> the member kinds it may attach to
# ('embed', 'bar'). Measured, and a real check: at
# one plant S.I.F. is on embeds 177 times out of 177
# and never on a bar. DATA, not a rule inside a
# check.
# recorded_on the member kinds whose elements record a face at
# all. Empty means NOBODY HAS SAID -- it never means
# "none". It is what tells a blank field on a kind
# that does record faces (unknown) from a blank on a
# kind that never does (does not apply).
# top_in_form where to read the PER-PIECE value saying which
# building face ends up on top in the form. See
# `top_in_form_rule()`; dormant until somebody reads
# it, and loudly so, because a check that resolves a
# form-anchored token to a building face without it
# is exactly backwards on every piece of the opposite
# handedness.
#
# NOTHING HERE EVER READS ABSENCE AS A VALUE. There is no key for what a
# blank means, deliberately: measured on both sides, a blank is
# annotation that was not repeated (219 of 461 unmarked ticket call-outs
# use a code tagged elsewhere on the same sheet) or a kind that does not
# use the field. `same_face()` answering None rather than False is the
# instrument for that and it already does.
'face_designations': {
'use': [], # e.g. [near_far, in_form] -- ids only
'frames': {}, # published in catalogue.yaml
'vocabularies': {}, # published in catalogue.yaml
'model_tokens': {},
'read_from': ['instance'],
'modifiers': {}, # shape published in catalogue.yaml
'not_face_tokens': {},
'attaches_to': {},
'recorded_on': [],
'recorded_on_unverified_because': None,
'top_in_form': {
'label': None,
'source': None,
'values': [],
'verified': False,
'unverified_because': None,
},
},
# ---- THE PREFIX ON A PART NUMBER SAYS WHICH LAYER DEFINES IT ------
#
# Owner, 2026-08-31: "厂知识:P 是 plates,SP 是 special plates,P 能从厂
# 知识里面直接找到是啥。SP 是 job 级别的特殊 plate。" -- P is a standard
# plate, resolvable from plant knowledge; SP is a special plate, made for
# one job.
#
# A LAYER-PLACEMENT RULE EXPRESSED IN THE PART NUMBER ITSELF, which is
# why it is worth a key: everything else in this file has to be told which
# layer owns a fact, and here the name says it.
#
# WHAT IT MAKES CHECKABLE. A part printed on a ticket that nothing can
# look up is TWO DIFFERENT PROBLEMS WITH TWO DIFFERENT OWNERS: a `P` part
# nobody can resolve is a gap in PLANT knowledge, an `SP` part nobody can
# resolve is a gap in JOB knowledge. Reporting both as "unknown part"
# sends half of them to the wrong person.
#
# prefixes prefix -> {means, layer, verified, ...}. A MAP, so a third
# prefix is one added key and a job may add its own.
#
# LONGEST PREFIX WINS -- `SP78` is an SP, not a P that starts with S --
# and that is not a tie-break, it is the safety. Read the other way it
# merges a job special plate into the plant's standard-plate namespace,
# which is exactly what a substring match on a bare part number does and
# exactly what it cost on the face lifter.
#
# `verified: False` is the usual state: a meaning read off a manual is a
# reading, not a ruling, and it says which it is. A prefix nobody can
# describe at all is left OUT rather than given a placeholder -- an entry
# here is a claim about whose desk a missing definition belongs on.
'part_numbers': {
'prefixes': {},
},
# ---- WHAT KIND OF PANEL THIS IS, AND WHY IT IS A KNOWLEDGE KEY -----
#
# A panel type changes WHICH PART PLAYS WHICH ROLE. That sounds like a
# detail and it is not: on 2026-08-31 the owner said the face lifter on an
# insulated wall is a different part from the one on a solid wall, that
# part was named nowhere in any knowledge layer, and so on every insulated
# panel the tool found ZERO face lifters -- and 6.1, 6.2, 6.3, the
# handling result and Panel Lift Check's face stages all passed in
# silence. It is the same failure the plant file's `lifter_include`
# comment already records from an earlier day, in a population nobody had
# thought to separate.
#
# THE SHAPE, so a third panel type is an addition and not a redesign:
#
# panel_types.types id -> whatever identifies that type. A MAP, so
# a job adds one without restating the plant's.
# panel_types.default the type to assume when nothing classifies a
# piece. **null**, and it should stay null:
# reading an unclassified piece as the ordinary
# type is precisely how an insulated panel came
# to be checked for a lifter it does not carry.
# <role>_by_panel_type a map beside any role's family LIST, narrowing
# it. `families_for()` resolves the pair.
#
# `detect_verified: False` IS NOT "there are no panel types". It is
# "nothing can tell them apart yet", and while it is false every role
# resolves to the UNION of every declared type. The union is the safe
# direction for RECOGNISING a part -- a missing family is a silent pass on
# a safety check, an extra one is a visible finding about a real insert --
# and it is NOT safe for COUNTING one, which is why
# `drawings.parity_note_rule` stays dormant rather than taking it.
#
# ---- HOW A PIECE IS RECOGNISED AS A TYPE: `signals`, added 2026-08-31 --
#
# A type may declare `signals`, a MAP of MEASURABLE piece facts. Prose
# (`model_signals`) records what somebody measured and is for a reader;
# `signals` is what `classify_panel()` evaluates, and the two are kept
# apart deliberately -- a sentence a human wrote is not a predicate, and
# a file that let one masquerade as the other would classify a piece off
# a description.
#
# signals:
# body_family_contains: [<token>, ...] # case-insensitive substring
# # of the body family's name
# thickness_in: {min: <n>, max: <n>} # inclusive, INCHES
# member_family_contains: [<token>, ...] # a member only this type has
# min_signals: 2 # per type; `panel_types.min_signals` is the
# # table-wide default, and it is 2
#
# THE RULE, and each half of it was chosen against a specific failure:
#
# * a type is recognised only when EVERY signal that could be evaluated
# AGREES and at least `min_signals` of them could be evaluated. Two,
# not one: the plant file's own note says any single signal could be
# a coincidence of how one job was modelled and that it is three
# mutually independent ones that make it a convention.
# * a fact that was never measured makes its signal UNEVALUABLE, never
# False. Absence is not a value here either.
# * A TYPE THAT DECLARES NO SIGNALS CAN NEVER BE CLASSIFIED INTO.
# Recognising `solid` as "not insulated" is exactly the default this
# branch exists to refuse: it reads an unclassified piece as the
# ordinary type, which is how an insulated panel came to be checked
# for a lifter it does not carry.
# * two types both recognised, or none, is a DECLINE -- said out loud,
# naming every signal and what it said. A piece whose type cannot be
# told gets the FULL checklist.
'panel_types': {
'types': {},
'default': None,
'min_signals': 2,
'detect_verified': False,
'detect_unverified_because': None,
},
# ---- CONNECTORS: what ties an insulated panel's two wythes ---------
#
# The public half -- what a connector IS, and the products -- is in
# `knowledge/catalogue.yaml` under `connectors`. What lives in the PLANT
# layer is this plant's own part numbers and the mapping onto those
# products, exactly as `lifters.catalogue_alias` maps an insert number
# onto `catalogue.inserts`.
#
# RECOGNITION IS BY MARK ON THE TYPE, NOT BY FAMILY NAME, and that is
# measured rather than chosen. Two traps, both live on this job
# (2026-08-31):
#
# 1. THE PART NUMBER IS NOT IN THE FAMILY NAME AT ALL. The pins sit in
# one family whose name says nothing about which connector it is --
# the inverse of the face-lifter lesson, where the family name was
# the only thing that DID carry it. A family-name key finds nothing.
# 2. A DECOY FAMILY EXISTS, literally named for the part number,
# carrying the same mark, WITH ZERO INSTANCES. Matching on family
# name finds the empty decoy and misses every real pin -- a silent
# ZERO on a check whose whole job is to notice zero. That is the
# worst failure this key could have.
#
# So `marks` are matched against the MARK ROLE READ FROM THE TYPE, and
# the check COUNTS INSTANCES. Counting instances is immune to the decoy
# (it contributes none); anything enumerating TYPES would double-count.
#
# `component_gate` is an optional second signal on the same type -- the
# plant's manufacture-component value -- kept because it is what solved
# the census token, and left optional because a plant that does not fill
# it in must not lose the check.
'connectors': {
# {group name: [part number, ...]}. A MAP of groups, because the
# owner named the numbers in two runs and did not say why they are
# two; recording them as one flat list would erase a distinction he
# made, and inventing a reason for it would be worse.
'marks': {},
# Read the mark from the TYPE, the INSTANCE, or both, in order.
# Defaults to the type because that is where it was measured.
'read_from': ['type'],
# Optional: only count a member whose manufacture-component role
# holds this. None means do not gate.
'component_gate': None,
# This plant's part number -> the `catalogue.connectors.products`
# key it really is. EXACT match, not a substring: R8 must never
# swallow R80.
'catalogue_alias': {},
# A plant-wide minimum, SECOND-CLASS on purpose. The published
# per-product number wins; this is used only when there is none, and
# a finding that used it says so -- "6 inches" and "6 inches for
# this product" are different claims.
'min_edge_distance_in': None,
# False means NOTHING HAS CONFIRMED how connectors are recognised in
# the model, and the three connector checks stand down saying so
# rather than reporting a count nobody has validated. Same idiom as
# `drawings.parity_note_rule.family_verified`.
'recognition_verified': False,
'recognition_unverified_because': None,
},
# ---- data for computed checks -------------------------------------
#
# Lifter capacity is a working load in pounds per insert. Keys are matched
# as substrings against the family name, longest match wins.
'lifters': {
# A direct family-name -> pounds table. Checked first, so it always
# wins over the catalogue; use it for a one-off nobody has catalogued.
#
# A value here (or in `lifting_anchors.parts`, the plant's own
# tested chart -- see `lifter_capacity`'s docstring) is a bare
# number for either kind, OR a dict {tension_lb, shear_lb} (or the
# angle-split `tension_<N>_lb` shape) when the two differ. THREE
# STATES a `tension_lb` / `shear_lb` FIELD can hold, added
# 2026-09-04 -- see `_field_rating_state` and `lifter_capacity`'s
# own "THREE STATES" note for the full story:
#
# a number taken as stated, including a real published
# zero -- some inserts genuinely rate zero on one
# axis.
# 0 / None / '' / '-' NOT RATED HERE, not a real zero. A plant
# chart that prints a bare Zero for a face
# (back-cast) lifter is usually stating the
# EDGE-cast figure, not "no capacity" -- reading
# it as a rating once reported a real anchor as
# zero-shear and failed a piece its own hardware
# carries fine. `lifter_capacity()` tries the
# back-cast footnote first (shear only), then the
# next layer (override -> plant chart -> published
# catalogue), before conceding `None`.
# 'forbidden' / false an explicit, deliberate zero -- this part
# may never be loaded that way at all. The ONLY
# way to get a real `0` back out of a dict-shaped
# entry; a bare printed `0` is never one on its
# own any more.
'capacity_lb': {
# 'ANCHOR_FACE_4T': 8000,
},
# This plant's part number -> the catalogue designation it really is.
# Matched as substrings against the family name, longest match wins.
'catalogue_alias': {
# 'P000': 'SAMPLE_ANCHOR_4T',
},
# Global factor applied to the piece weight before comparing.
'dynamic_factor': 1.5,
# A lifter closer than this to an end, in inches, gets flagged.
'min_edge_distance_in': None,
},
'concrete': {
# Release strength must be at least this fraction of f'c.
'min_fci_ratio': None,
},
# Lifting and handling analysis (panellift).
'handling': {
# Where the HTML reports are written. None puts them in a 'handling'
# folder beside the model, with the rest of the QC data. An absolute
# path sends them anywhere -- a job folder on a share, say.
'report_dir': None,
# The load multipliers live on the stages below, one each, because
# that is where they are used. A single pair of keys here could not
# describe a plant that runs four stages, and it matters which number
# is which: another program on the same panel used 1.4 for both
# stripping and erection where PCI Table 8.3.1 gives 1.3 and 1.2.
# The handling tensile limit, as a MULTIPLE OF sqrt(f'c) in psi: the
# allowable is this number times the square root of the concrete
# strength at the stage, so 5.0 on 4000 psi concrete is 316 psi.
#
# There are two schools and both are defensible:
#
# 5.0 the modulus of rupture over a 1.5 safety factor -- "no
# discernible cracking". Conservative, and what this has
# always defaulted to.
# 7.5 the rupture modulus itself: first cracking, no margin.
# PCI-permitted, and what a commercial check of one of these
# panels was set to.
#
# The difference is a factor of 1.5 on EVERY stress verdict, which
# decides whether a piece passes far more often than any refinement
# of the analysis does -- it accounted for most of the disagreement
# between this tool and that commercial check on the same panel.
#
# The owner declined to pick one for everybody, 2026-08-30:
#
# "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,
# 让用户自己填写。I can see it both ways."
#
# So the engineer fills it in per job: Project Config -> Lifting and
# handling -> Stress limit, which writes it to the job layer. This
# 5.0 is only what happens when nobody has said anything, and it is
# the behaviour every existing job file already has. A stage in
# `handling.stages` may state its own, which beats this for that
# stage; the report names which one it used.
#
# THIS 5.0 IS THE SHIPPED DEFAULT, NOT THE ONLY DEFENSIBLE ANSWER,
# and the difference matters because the two numbers are two real
# schools rather than a value and a mistake. A plant layer stating
# 7.5 is not one edit away from wrong -- the owner ruled 7.5 for the
# plant this tool was built against, on 2026-08-19, before the
# 2026-08-30 ruling above made the knob per-job. The later ruling
# decided WHERE THE SETTING LIVES; it did not retract the value, and
# the two stand together. So:
#
# - do not "align" a layer holding 7.5 down to this 5.0;
# - do not raise this 5.0 to 7.5 either. With no ruling from
# anybody, the conservative school is the one to ship.
#
# Both numbers are attributed where they are set. If you are about to
# ask which one applies, search the transcripts first -- CLAUDE.md 7.
# That question has already been asked once after it was answered.
'tension_limit_sqrt_fc': 5.0,
# The compression allowable, ratio*f'c in psi -- the exact sibling of
# `tension_limit_sqrt_fc` above, per the owner's ruling closing the
# question, 2026-09-01: "0.6保持可调" ("0.6 stays adjustable"), same
# per-job field, same place in Project Config.
#
# 0.6 is ACI 318-19 Table 24.5.3.1 ("all other locations", 0.60*f'ci
# at transfer) and Sec. 24.5.4.1 (0.60*f'c in service) -- found in
# `knowledge/library/notes/standards/ACI318-19-ch24.md` at the
# owner's direction, 2026-09-01. ACI Table 24.5.3.1 also permits a
# higher 0.70*f'ci AT MEMBER ENDS; this tool does not implement that
# distinction on the owner's own ruling the same night --
# "先不用实现0.7...别搞这么复杂" ("don't implement 0.7 for now, no
# need to make it that complicated") -- so the flat 0.6 is used
# everywhere on the piece, and that is DELIBERATELY the more
# conservative choice, not an oversight.
#
# THIS 0.6 IS THE SHIPPED DEFAULT, not the only defensible answer, for
# the same reason the tension default above is not: an engineer who
# has a reason to relax it per job sets it in Project Config -> Lifting
# and handling -> Compression limit, which writes it to the job
# layer. A stage in `handling.stages` may state its own, which beats
# this for that stage; the report names which one it used.
'compression_limit_ratio': 0.6,
'sling_angle_deg': 60.0,
# Where inserts and strands are located, and it is not one answer.
#
# 'fast' takes each element's centre off its cheap cached bounding box
# (get_BoundingBox, no geometry solve), which is most of the time
# spent finding the lifters. 'precise' solves the geometry for a solid
# centroid: identical on a symmetric embed, a few inches away on an
# L-shaped one -- and a face lifter's position IS the moment arm.
# An order of magnitude slower.
#
# The two run modes want opposite answers. A batch runs unattended, so
# the time costs nobody anything and it should be right. One piece at
# a time is somebody waiting at the screen, and a piece that lands
# close enough for a few inches to matter is a piece for Eriksson --
# not for a sharper number out of this tool.
'fast_lifter_box': True, # one piece, on demand
'fast_lifter_box_batch': False, # sweep, batch, Ticket QC's own run
# Equalising blocks ('rolling') or fixed-length slings ('static').
# Two different mechanics, not a coefficient: on a three-line pick the
# middle reaction differed by 22%.
'rig_mode': 'rolling',
# How far along the PIECE two inserts of one row may sit and still
# hang from it as one row. Too small splits a staggered row into a
# phantom extra support -- one line goes negative, its neighbour
# overloads; too large merges genuinely separate rows. Set from the
# project config file (decisions.group_tolerance_ft); 1.0 covers
# every stagger measured in the model so far. The Project Config
# template carries a diagram of exactly what it measures.
'group_tolerance_ft': 1.0,
# Where the piece sits on the trailer in the transportation stage.
# Transportation is supports, not a lift, and the plant says where
# the dunnage goes:
#
# mode: null nobody has said -- the stage keeps the old
# lift approximation and the report says so
# mode: lifter_rows the same support lines as the stripping rows
# mode: fraction inset from each end by `fraction` of the span
#
# Nothing here guesses: null is the old, labelled approximation.
'dunnage': {
'mode': None,
'fraction': None,
},
# PCI Table 8.3.1 is indexed by these two. They describe how the piece
# comes out of the form, which is a property of the product, not of
# the panel, so they are stated here rather than guessed per piece.
# product_type: flat | flat_reveal | fluted | sculptured
# finish: smooth | exposed
'product_type': 'flat',
'finish': 'smooth',
# ---- what part each insert plays in a lift ----------------------
#
# 'families' are matched as substrings against the family name.
# 'at' says where the insert acts along the piece:
#
# measured where it is modelled (the default)
# top the top end of the piece as erected
# bottom the bottom end
#
# 'top' is there because an anchor at the panel's top edge acts at the
# edge, while its modelled box centre sits inboard by however long the
# bar welded to it happens to be. On one real piece that was 7 inches,
# and 7 inches of lever arm on the erection pick is not a rounding
# error. Which end is the top is read from the model: the end at the
# higher global elevation.
#
# Empty here: which part number is a face lifter and which is an edge
# anchor is a fact about one plant's hardware.
'lifter_groups': {
# 'face': {'families': ['ANCHOR_FACE_4T'], 'at': 'measured'},
# 'edge_top': {'families': ['ANCHOR_EDGE'], 'at': 'top'},
},
# ---- the handling stages a piece is checked through -------------
#
# Each stage is a load multiplier, the concrete strength at the time,
# and which insert groups carry the piece. 'exclude_top_rows' drops
# that many rows from the top of a group -- a crane that cannot reach
# the top row of face lifters makes erection a different pick from
# stripping, and that is a fact about the site, not the panel.
#
# strength: fci | fc, read off the design summary for the piece.
#
# pci_case says which row of PCI Table 8.3.1 the stage is, and is used
# when multiplier is null. It is one of stripping / transportation /
# yard -- NOT the stage's own name, because a plant may run a stage
# called something else and still be reading the yard row.
# 'rig_mode' on a stage overrides the one above for that stage
# only; None inherits it. Left None everywhere, the piece hangs
# the same way all the way through, which is what a plant running
# one rig wants and is the behaviour this tool has always had.
#
# It is not defaulted to a guess. A commercial check of one real
# piece stripped on fixed slings and erected on an equalising
# block, and its stripping moment was 14.1 k-ft where one rolling
# mode everywhere gives 23.8 -- a factor of 1.7 that follows from
# a plant's rigging practice, not from anything in the model. So
# the engineer sets it, in the project config file where it is
# visible, rather than the tool assuming somebody else's yard.
'stages': [
{'name': 'stripping', 'pci_case': 'stripping', 'multiplier': None,
'strength': 'fci', 'groups': ['face'], 'rig_mode': None},
{'name': 'transportation', 'pci_case': 'transportation',
'multiplier': None, 'strength': 'fc', 'groups': ['face'],
'rig_mode': None},
{'name': 'erection', 'pci_case': 'yard', 'multiplier': None,
'strength': 'fc', 'groups': ['face', 'edge_top'],
'exclude_top_rows': 1, 'rig_mode': None},
],
# CPython 3.9+, which runs the calculation. Revit's IronPython cannot,
# and porting it there would give up the test suite that makes the
# numbers checkable. None searches the usual install locations.
'python_exe': None,
# How far the geometry we read may be from the weight the model
# states before the whole run is refused. This is the gate that
# catches a misread blockout, and it has to be a refusal rather than
# a warning: an opening we did not see makes the section modulus the
# wrong number in the direction that says a panel is fine.
'weight_tolerance': 0.02,
# Open the report when it is finished. On by default -- the gap
# between a report being written and being read is one double-click
# that is easy not to take. Turn it off when working through a lot of
# pieces in a row, or forty browser tabs is the result.
'open_report': True,
# ---- prestress ------------------------------------------------
#
# Everything lost between the jack and the stage being checked, as one
# percentage. It is NOT calculated: a time-dependent loss model needs
# an age, a humidity and a curing schedule that none of this knows,
# and a plausible-looking number derived from guessed inputs is worse
# than a stated assumption an engineer can argue with.
#
# It goes in the project config file so it can be argued with. 8% is
# the default and it is a decision, not a measurement: a commercial
# check of one real piece computed 1.6% at release, so 8% is the
# conservative side of what a job might see once relaxation, creep
# and shrinkage are in. Lower it and every piece gets more
# precompression credited, which is the direction that makes a panel
# look safe.
'prestress_loss_pct': 8.0,
# Where a cut strand has handed its force back to the concrete, in
# inches. None uses ACI 318's f_se * db / 3, computed from the pull
# actually on the design rather than assumed.
'transfer_length_in': None,
},
# Strand section areas by nominal diameter, in inches. Published, and the
# same everywhere; here so the tool can turn '1/2"' on a design summary
# into an area without asking anybody.
'strands': {
'area_in2': {
'0.375': 0.085,
'0.4375': 0.115,
'0.5': 0.153,
'0.5625': 0.192,
'0.6': 0.217,
},
},
# Reinforcing running through other reinforcing, within one assembly.
# Off by default: it is the only check that solves geometry, and on a
# model that draws bars continuously through strands it reports dozens of
# by-design crossings per piece until ignore_family_pairs is filled in.
'interference': {
'check': False,
# Two bodies must overlap by more than this, on every axis, to count.
'tolerance_in': 0.125,
# Confirm every box hit with a real solid intersection. Leave this on:
# a bounding box is the product of three intervals, so a bent bar's
# box is the whole U INCLUDING the hole, and anything passing through
# that hole reads as an interference. On one real piece boxes claimed
# 265 and only 86 were real. Turning it off is faster and wrong.
'exact': True,
# Categories in which two instances of ONE family may legitimately
# overlap. Some hardware is modelled as several instances placed to
# touch on purpose, so that creating the assembly picks them up
# together -- a modelling technique, not a defect. Empty by default:
# which categories those are is a fact about how a plant models, and
# nothing here should assume it.
'same_family_ok_categories': [],
# Only report pairs from the SAME Revit category. Reinforcing meeting
# an embed, or a bar running into a ledge, is how the piece goes
# together; two things of one kind in one place is the defect. Off by
# default -- it does hide real cross-category problems, and that is a
# project's call to make.
'within_category_only': False,
# Families expected to cross, 'A|B', matched as substrings both ways.
'ignore_family_pairs': [],
# Families nothing is ever reported against, matched as substrings.
'exempt_families': [],
# Hardware that arrives as several families but is one part. A
# connection is a plate, its rebar, its sleeve -- modelled to touch
# because they are installed as a unit. Revit records none of that
# ownership, so it is stated here:
#
# - name: TOP OF PANEL TO COLUMN
# families: [SAMPLE_PLATE, SAMPLE_BAR]
# interferes: within_group # may overlap each other, not others
# - name: SOMETHING_SET_OUT_AS_A_UNIT
# families: [SAMPLE_UNIT]
# interferes: false # never reported, with anything
'component_groups': [],
# Mean thickness the shared material must exceed. Defaults to
# tolerance_in. See ticketqc.intersection_depth for why this is a
# thickness and not a bounding box.
'min_thickness_in': None,
# A lifting insert is set out with a bar to either side, and those
# bars belong to the insert rather than to the reinforcing schedule --
# the plant places them and may move them freely, so there is nothing
# to check them against. They are the same family and run the same way
# as the ordinary bars of that size, so the only thing that tells them
# apart is that they hug an insert.
# Found by symmetry, not by proximity: reinforcing comes in mirrored
# layers, so an ordinary bar has a counterpart at the same station in
# another layer and an insert's bar does not. An unpaired bar within
# reach of an insert belongs to it; one that is unpaired and near
# nothing is reported, because the drawing does not explain it.
'insert_bars': {
'insert_families': [], # the insert
'bar_families': [], # what its bars are modelled as
'within_of_insert_in': 5.0, # how far its bars may sit from it
'station_tol_in': 1.0, # two bars at "the same" station
'layer_tol_in': 0.75, # two bars in "the same" layer
},
'max_reported': 10,
},
}
def catalogue_path():
"""The published hardware data that ships with the tool.
The one knowledge layer that IS in this repository, because it is the one
that identifies nobody: manufacturers' catalogue data, the same for
everyone who buys the part. It is still a file rather than code so that
correcting a rating is an edit.
"""
here = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))) # ...\WillTools.extension
return os.path.join(os.path.dirname(here), 'knowledge', 'catalogue.yaml')
def load(doc):
"""Built-in defaults, the catalogue, the plant, the job, the project file.
Five layers, each overriding the one before it key by key:
DEFAULTS what the code assumes
knowledge/catalogue.yaml published data (in the repo)
<plant root>/[<plant>/] what THAT PLANT always does
knowledge.yaml
<model folder>/_ticketqc/ what this job does
knowledge.yaml
the project config file what the engineer set, and where
The job layer sits inside the `_ticketqc` folder beside the model, not
loose beside it -- `designspec.knowledge_path()` is the one place that
spelling lives, and `designspec.root_dir()` is what decides where
`_ticketqc` itself is (WILLTOOLS_JOBS wins; APPDATA is the fallback for a
model with no path yet). Never build this path by hand.
so a plant states its naming conventions and bed layout once and a job
only has to record what is unusual about itself. Only the first two are
part of this repository: between them the other three are the whole
description of one office's template, and that is exactly what must not
be published.
THE PLANT LAYER IS PER PLANT. `job.plant` in the job file names a folder
under the plant root and a second plant is a new folder, not an edit to
the first one's file. A job that names no plant reads the machine-wide
`<root>/knowledge.yaml`, which is what every install had before the key
existed. See `designspec.plant_knowledge_path` for the three cases and
the two-pass note below for why the name has to be read early.
The last layer is the file Project Config writes. It is opt-in -- a model
has one only once somebody has pressed that button -- and it is deliberately
last, because it is the layer a human edited most recently and by hand. It
wins.
pyRevit's YAML reader hands back every scalar as a string -- 1.5 arrives as
'1.5' and null as 'null' -- so values are coerced here rather than at each
call site, where a stray string would turn into an arithmetic error deep
inside a check.
"""
from vibe import designspec
merged = _deep_copy(DEFAULTS)
# Every participating layer is named as it is merged. A file that is
# there and will not parse is skipped -- but it leaves a marker, because
# a layer that fails to load looks exactly like a layer that agrees with
# the defaults. See `_merge_file`.
_merge_file(merged, catalogue_path(), 'catalogue')
# ---- the two-pass, and why it is not an accident ------------------
#
# `job.plant` names WHICH PLANT this job is produced at, and that name
# chooses the plant file. But the job layer merges AFTER the plant layer,
# so by the time the name is in `merged` the plant file has already been
# read -- from the wrong path. The dependency runs backwards through the
# merge order and no ordering of these three lines fixes it.
#
# So the job file (and the config file above it, which may override the
# same key) is READ ONCE, on its own, before anything is merged, purely
# to answer "which plant". `plant_id()` does that and nothing else.
#
# DO NOT "simplify" this into a single pass. Deleting the pre-pass makes
# the tool read the machine-wide plant file while the job file plainly
# says which plant it is for, and every report still looks normal.
# `_plant_layer_stale` below is the tripwire that catches it if someone
# does: the plant the file was chosen by, compared against the plant the
# merged tree ends up naming.
choice = plant_choice(doc)
plant, plant_path = choice['plant'], None
merged['_plant_basis'] = choice['basis']
if choice['path']:
# The project config named the plant knowledge file itself.
plant_path = choice['path']
_merge_file(merged, plant_path, 'plant')
elif plant is None:
# No plant named -- the ordinary case, and the path this function has
# always used. Quiet on purpose: every install that predates the key
# is in exactly this state, and a warning printed on every run of
# every install is a warning nobody reads.
#
# ONE EXCEPTION, and it is not about the plant name: if somebody has
# explicitly set WILLTOOLS_KNOWLEDGE and the file it names is not
# there, that is a definite finding. An operator pointed the tool at
# a file; the tool did not read it; nothing else would ever say so.
plant_path = designspec.plant_knowledge_path()
if (os.environ.get(designspec.PLANT_ENV)
and plant_path and not os.path.isfile(plant_path)):
merged['_plant_env_missing'] = plant_path
_merge_file(merged, plant_path, 'plant')
else:
plant_path = designspec.plant_knowledge_path(plant)
if plant_path and os.path.isfile(plant_path):
_merge_file(merged, plant_path, 'plant')
else:
# Same discipline as `_config_missing` below: NEVER fall back to
# the machine-wide plant file. Somebody named a plant; running on
# a different plant's rules, or on none, while the report reads
# normally is the failure this whole codebase is built against.
merged['_plant_missing'] = (plant, plant_path)
_merge_file(merged, designspec.knowledge_path(doc), 'job')
config = designspec.active_config_path(doc)
if config:
if os.path.isfile(config):
_merge_config_file(merged, config)
else:
# Never silently fall back to the layers underneath. The run would
# use a configuration nobody chose and look completely normal.
merged['_config_missing'] = config
_nest_dotted_parameters(merged)
# Which plant file was actually read, for reports and for Self Check.
merged['_plant_id'] = plant
merged['_plant_path'] = plant_path
# THE TRIPWIRE. If the finished tree names a plant other than the one the
# plant file was chosen by, the pre-pass and the merge have drifted apart
# -- a layer nobody thought to pre-read has set `job.plant`. Recorded, not
# repaired: re-reading here would hide the drift instead of showing it.
final = get(merged, 'job.plant')
final = (as_text(final).strip() or None) if final is not None else None
# A plant DETECTED off the model leaves `job.plant` unset, and that is
# not drift: only a layer that names a different plant is.
if final is not None and final != plant:
merged['_plant_layer_stale'] = (plant, final)
return merged
PRODUCER_NAME_FRAGMENTS = ('precast manufacturer', 'producing plant',
'precast plant', 'precaster', 'producer',
'manufacturer')
def producer_name(doc):
"""The precast producer named on the model's Project Information, read
generically by parameter NAME (one carrying a PRODUCER_NAME_FRAGMENTS
fragment, '_' read as ' '), or None. The value is live model data --
matched at run time, never written anywhere tracked. Never raises."""
try:
pinfo = doc.ProjectInformation
for p in pinfo.Parameters:
try:
name = p.Definition.Name.lower().replace('_', ' ')
except Exception:
continue
if not any(f in name for f in PRODUCER_NAME_FRAGMENTS):
continue
try:
value = p.AsString()
except Exception:
value = None
value = as_text(value).strip() if value else ''
if value:
return value
except Exception:
pass
return None
def plant_aliases(plant):
"""The names a plant folder answers to: its id plus `plant.aliases` from
its own knowledge file, lower-cased, in order. [] when unreadable."""
from vibe import designspec
out = []
try:
path = designspec.plant_knowledge_path(plant)
holder = {}
if path and os.path.isfile(path):
_merge_file(holder, path)
names = [plant] + list(get(holder, 'plant.aliases') or [])
for n in names:
n = as_text(n).strip().lower()
if n and n not in out:
out.append(n)
except Exception:
pass
return out
def match_plant(name, aliases_by_id):
"""(plant id or None, why). Pure, host-testable.
`name` is the producer name off the model; `aliases_by_id` maps each
plant id to its lower-cased aliases. A plant matches when the words of
one of its aliases are all words of the name, or the name's words are
all words of the alias -- whole words, so 'mc' does not hide inside
'mcallister'. Exactly one match resolves; none or several declines,
and the sentence says which."""
text = as_text(name or '').strip().lower()
if not text:
return None, ('the model names no precast producer on Project '
'Information')
words = set(re.findall(r'[a-z0-9]+', text))
hits = []
for plant in sorted(aliases_by_id or {}):
for alias in aliases_by_id.get(plant) or []:
alias_words = set(re.findall(r'[a-z0-9]+', as_text(alias).lower()))
if alias_words and (alias_words <= words or words <= alias_words):
hits.append(plant)
break
if len(hits) == 1:
return hits[0], ('the producer named on Project Information matched '
'plant folder {0!r}'.format(hits[0]))
if not hits:
return None, ('the producer named on Project Information matches none '
'of the plant folders {0}'
.format(sorted(aliases_by_id or {})))
return None, ('the producer named on Project Information matches several '
'plant folders {0}; name one in the job file or the project '
'config'.format(hits))
def detect_plant(doc):
"""(plant id or None, why): the plant folder the model resolves to, in
three levels -- (1) an explicit `job.plant` always wins over all of this,
(2) the producer named on the model's Project Information matched against
each plant folder's aliases, then (3), when level 2 finds nothing, the
names of the model's titleblock families matched against each plant
folder's `plant.titleblock_patterns` (a last resort: titleblock families
are a naming habit, not a declared fact). The `why` names the level that
decided, or both when level 3 follows a level-2 miss. Never raises --
a model that names nothing falls through to the machine-wide plant file
and the sentence says so. Owner, 2026-09-03: entering a new job must not
run on another plant's rules in silence."""
from vibe import plantpath
name = producer_name(doc)
aliases = dict((p, plant_aliases(p)) for p in plantpath.plant_ids())
plant, why2 = match_plant(name, aliases)
if plant is not None:
return plant, why2
family_names = titleblock_family_names(doc)
patterns = dict((p, plant_titleblock_patterns(p))
for p in plantpath.plant_ids())
plant3, why3 = match_titleblocks(family_names, patterns)
if plant3 is not None:
return plant3, why2 + ' -- ' + why3
return None, why2 + '; ' + why3
def plant_titleblock_patterns(plant):
"""The titleblock-family glob patterns a plant folder recognizes: its
`plant.titleblock_patterns` from its own knowledge file, in order. []
when the key is absent or the file unreadable. Revit-free."""
from vibe import designspec
out = []
try:
path = designspec.plant_knowledge_path(plant)
holder = {}
if path and os.path.isfile(path):
_merge_file(holder, path)
for pat in get(holder, 'plant.titleblock_patterns') or []:
pat = as_text(pat).strip()
if pat:
out.append(pat)
except Exception:
pass
return out
def titleblock_family_names(doc):
"""The distinct names of the model's titleblock families, read with a
SCOPED collector (CLAUDE.md forbids unfiltered ones) off
OST_TitleBlocks. A malformed element is skipped, never raised on -- the
answer is the names that were read, possibly []. Revit-only."""
names = []
seen = set()
try:
import Autodesk.Revit.DB as rdb
coll = rdb.FilteredElementCollector(doc)
coll = coll.OfCategory(rdb.BuiltInCategory.OST_TitleBlocks)
coll = coll.WhereElementIsNotElementType()
for el in coll:
try:
name = el.Symbol.Family.Name
except Exception:
continue
name = as_text(name).strip()
if name and name not in seen:
seen.add(name)
names.append(name)
except Exception:
pass
return names
def match_titleblocks(family_names, patterns_by_id):
"""(plant id or None, why). Pure, host-testable.
`family_names` is the model's titleblock family names; `patterns_by_id`
maps each plant id to its glob patterns. Case-insensitive on both sides.
A plant hits when ANY of its patterns matches ANY family name. Exactly
one hitting plant resolves; none or several declines, and the sentence
says which. `[]` family_names is its own sentence."""
names = []
for raw in family_names or []:
n = as_text(raw).strip()
if n:
names.append(n)
if not names:
return None, 'the model has no titleblocks to read'
hits = []
hit_name = None
hit_pattern = None
for plant in sorted(patterns_by_id or {}):
for pattern in patterns_by_id.get(plant) or []:
pattern = as_text(pattern).strip()
if not pattern:
continue
for n in names:
if fnmatch.fnmatchcase(n.lower(), pattern.lower()):
hits.append(plant)
hit_name = n
hit_pattern = pattern
break
else:
continue
break
if len(hits) == 1:
return hits[0], ("titleblock family {0!r} matches {1}'s pattern {2!r}"
.format(hit_name, hits[0], hit_pattern))
if not hits:
return None, ("no titleblock family matches any plant's "
'titleblock_patterns ({0} families seen)'.format(len(names)))
return None, ('titleblock families match several plants: {0}'
.format(', '.join(hits)))
def plant_choice(doc):
"""{'plant', 'path', 'basis', 'missing'}: which plant layer this job
reads, why, and -- when job.plant names a folder with no knowledge
file -- the path that is missing (None otherwise).
Precedence: a plant knowledge FILE chosen in the project config
(`knowledge_sources.plant_file`, the picker in Project Config) > an
explicit `job.plant` (project config over job file) > the plant the
model's producer name detects > nothing named (the machine-wide file,
the old default). `basis` is the sentence a report prints."""
from vibe import designspec
holder = {}
_merge_file(holder, designspec.knowledge_path(doc))
config = designspec.active_config_path(doc)
if config and os.path.isfile(config):
raw = {}
_merge_file(raw, config)
# A config file wraps its knowledge in `knowledge:`; the older,
# undocumented shape is the tree itself.
body = raw.get('knowledge')
_deep_update(holder, body if isinstance(body, dict) else raw)
plant_file = get(holder, 'knowledge_sources.plant_file')
plant_file = as_text(plant_file).strip() if plant_file else ''
if plant_file and os.path.isfile(plant_file):
file_holder = {}
_merge_file(file_holder, plant_file)
pid = get(file_holder, 'plant.id')
pid = (as_text(pid).strip() or None) if pid is not None else None
return {'plant': pid, 'path': plant_file,
'basis': 'the plant knowledge file chosen in the project '
'config', 'missing': None}
plant = get(holder, 'job.plant')
plant = (as_text(plant).strip() or None) if plant is not None else None
if plant:
# `missing`: the named plant's folder has no knowledge file. load()
# refuses that (_plant_missing) -- but a form opening the window
# could not tell "no layer names a plant" from "a plant was chosen
# and its folder has since gone" and could only warn on the next
# save (bridge#2, the company machine, 2026-09-07). Named here so
# the window can say so on open.
from vibe import plantpath
ppath = plantpath.plant_knowledge_path(plant)
missing = None if (ppath and os.path.isfile(ppath)) else (ppath or plant)
return {'plant': plant, 'path': None,
'basis': 'job.plant named in the job file / project config',
'missing': missing}
detected, why = detect_plant(doc)
return {'plant': detected, 'path': None, 'basis': why, 'missing': None}
def plant_file_in_use(know):
"""The plant knowledge file THIS merged tree was actually built from --
the one an error message should tell the user to edit.
`load()` records it as `_plant_path` (per-plant folder when the job names
a plant, the machine-wide file otherwise). Before this existed, two
buttons formatted `designspec.plant_knowledge_path()` with no plant into
their "add it to <file>" messages, which is the machine-wide file by
definition -- so on a job that named a plant the message sent the user
to a file that already had the key (issue #14, 2026-09-05: an hour lost
checking the environment because the file the message named looked
fine). Falls back to the machine-wide path only when the tree carries
no record, e.g. a caller that built `know` by hand."""
from vibe import plantpath # Revit-free; designspec re-exports this
path = None
if isinstance(know, dict):
path = know.get('_plant_path')
if path:
return path
return plantpath.plant_knowledge_path()
# ------------------------------------------------- plant layer completeness
# The data gaps below are found here, at setup time, instead of arriving as a
# per-piece NOT RUN from a button the first time somebody presses it. Each
# finding cites the button that refuses without the key and ends with "-- edit
# <file>" pointing at the plant file actually read (issue #14's discipline:
# naming the machine-wide file on a job that reads a per-plant one sent people
# to a file that already had the key).
# The handling stage names that carry the plant's load-multiplier wording.
_DUNNAGE_MODES = ('lifter_rows', 'fraction')
def plant_layer_gaps(know):
"""Plant-owned configuration gaps in a merged tree, as finding dicts.
Each dict is {'key', 'needed_by', 'severity', 'text'}; `text` ends with
'-- edit <plant file>' so the reader is sent to the file that really was
read. Empty when the tree names everything the buttons need. Pure dict
logic -- no Revit imports, no doc -- so Self Check can run it off the
merged tree and the tests can run it off a hand-built tree.
"""
if not isinstance(know, dict):
know = {}
where = '-- edit {0}'.format(plant_file_in_use(know))
groups = get(know, 'handling.lifter_groups', {})
face = (groups or {}).get('face') or {}
edge = (groups or {}).get('edge_top') or {}
out = []
if not (face.get('families') or []):
out.append({
'key': 'handling.lifter_groups.face.families',
'needed_by': 'Panel Lift Check',
'severity': 'FAIL',
'text': ('handling.lifter_groups.face.families is empty, so no '
'family is known to be a face lifter and Panel Lift '
'Check cannot row the stripping picks. {0}'.format(where)),
})
if not (edge.get('families') or []):
out.append({
'key': 'handling.lifter_groups.edge_top.families',
'needed_by': 'Panel Lift Check',
'severity': 'WARN',
'text': ('handling.lifter_groups.edge_top.families is empty, so '
'no family is known to be an edge anchor; the erection '
'stage hangs off edge anchors and empty means erection '
'uses the face lifters only. {0}'.format(where)),
})
stages = get(know, 'handling.stages', []) or []
if not stages:
out.append({
'key': 'handling.stages',
'needed_by': 'Panel Lift Check',
'severity': 'FAIL',
'text': ('handling.stages is empty, so there is nothing to check '
'a piece through and Panel Lift Check refuses to run. '
'{0}'.format(where)),
})
dunnage = get(know, 'handling.dunnage') or {}
if dunnage.get('mode') not in _DUNNAGE_MODES:
out.append({
'key': 'handling.dunnage.mode',
'needed_by': 'Panel Lift Check',
'severity': 'WARN',
'text': ('handling.dunnage.mode is not one of lifter_rows / '
'fraction, so the transportation stage falls back to the '
'old lift approximation and the report says '
'approximate. {0}'.format(where)),
})
for i, stage in enumerate(stages):
if not isinstance(stage, dict):
continue
# Not `float(value)` in a try: IronPython turns float(None) into a
# .NET NullReferenceException, which surfaces as SystemError and is
# not caught by `except (TypeError, ValueError)` -- issue #17,
# 2026-09-05, Self Check crashed inside Revit while CPython passed.
mult = stage.get('multiplier')
numeric = isinstance(mult, (int, float)) and not isinstance(mult, bool)
if not numeric and isinstance(mult, string_types):
try:
float(mult)
numeric = True
except ValueError:
numeric = False
if not numeric:
name = stage.get('name') or ('handling.stages[{0}]'.format(i))
out.append({
'key': 'handling.stages.{0}.multiplier'.format(name),
'needed_by': 'Panel Lift Check',
'severity': 'WARN',
'text': ('handling.stages.{0}.multiplier is not a number, so '
'the {0} stage has no load factor of its own and '
'panellift reads PCI Table 8.3.1 by pci_case '
'instead. {1}'.format(name, where)),
})
lifters = get(know, 'lifters', {})
if not isinstance(lifters, dict) or 'capacity_lb' not in lifters:
out.append({
'key': 'lifters.capacity_lb',
'needed_by': 'Panel Lift Check / Reinforcing QC',
'severity': 'WARN',
'text': ('lifters.capacity_lb is absent (an empty dict is the '
'"no overrides" shape), so lifter capacities fall back '
'to the published catalogue and a plant-only part is '
'reported unrated. {0}'.format(where)),
})
missing, _not_applicable = unmapped_roles(know)
if missing:
out.append({
'key': 'parameters',
'needed_by': 'every check',
'severity': 'FAIL',
'text': ('{0} parameter role(s) are unmapped ({1}), so the '
'checks that read them cannot. {2}'
.format(len(missing), ', '.join(missing), where)),
})
return out
def plant_id(doc):
"""The plant this job is produced at, read BEFORE any layer is merged --
see `plant_choice` for the precedence (config plant file > job.plant >
detected off the model's producer name > None). A pre-pass, because the
key that chooses the plant layer lives in a layer that merges after it."""
return plant_choice(doc)['plant']
def _merge_config_file(merged, path):
"""Merge a project config file, which wraps its knowledge in a key.
The file also carries `project:`, `output_locations:` and
`design_assumptions:` sections, which describe the configuration rather
than being part of it, so merging the document whole would plant junk
branches in the knowledge tree.
Two of them are not junk though -- they are the sections a person will
actually edit, because they are the ones at the top of the file with
obvious names. So the keys in them that are settings rather than derived
facts are mapped back onto the keys that are really read. A file where
editing the obvious place does nothing is a trap.
"""
holder = {}
# The marker goes on `merged`, not on the holder: the holder is this
# function's scratch space and is thrown away, and an unreadable config
# file is exactly the fact that must survive the call.
_merge_file(holder, path, 'config', notes=merged)
if not holder:
return merged
body = holder.get('knowledge')
body = body if isinstance(body, dict) else holder
_note_capacity_override_emptied(merged, 'config', path, body)
_deep_update(merged, body)
locations = holder.get('output_locations')
if isinstance(locations, dict):
report_dir = locations.get('lifting_check_reports')
if report_dir and not locations.get('lifting_check_reports_is_default'):
merged.setdefault('handling', {})['report_dir'] = report_dir
# Older files carried these two under `design_assumptions:` before the
# decisions block gathered them up. Still read, so a config written by an
# earlier build keeps working.
assumptions = holder.get('design_assumptions')
if isinstance(assumptions, dict):
for key in ('prestress_loss_pct', 'transfer_length_in'):
if assumptions.get(key) is not None:
merged.setdefault('handling', {})[key] = \
_coerce(assumptions[key])
# Mapped back LAST, so the section at the top of the file beats the same
# setting buried in the tree below it. Somebody who edited both meant the
# one they could find.
_merge_decisions(merged, holder.get('decisions'))
return merged
def _note_config_problem(merged, message):
"""Record a definite finding about the config file, for config_problems.
Recorded HERE, where the value is judged, rather than re-read later from
the tree: by the time anyone looks at the tree the bad value is gone --
`_merge_decisions` drops it on purpose and maps the good ones onto
`handling.*`, so there is nothing left to judge. Reporting from the same
branch that does the dropping is what makes the two impossible to drift
apart, and they DID drift: config_problems used to read
`decisions.insert_position` out of the merged tree, a path this merge
never creates, so on a real config file the check could not fire at all.
"""
problems = merged.setdefault('_config_problems', [])
# A file with no `knowledge:` wrapper is merged whole, so its `decisions`
# branch reaches the tree as well and this can be walked twice. One value,
# one sentence.
if message not in problems:
problems.append(message)
return merged
def _merge_decisions(merged, decisions):
"""The `decisions:` block at the top of a project config, mapped back.
Every key here is a friendly name for something read from somewhere else
in the tree. The mapping has to exist in both directions or the section is
decoration: a file where editing the obvious place does nothing is a trap,
and this is the most obvious place in the file.
A value that will not do is dropped, never guessed at, and recorded with
`_note_config_problem` so `config_problems` can say so. A misspelled
setting is not "could not judge" -- it is a definite finding.
"""
if not isinstance(decisions, dict):
return merged
handling = merged.setdefault('handling', {})
if decisions.get('report_dir'):
handling['report_dir'] = decisions['report_dir']
if decisions.get('open_report_when_done') is not None:
handling['open_report'] = _coerce(decisions['open_report_when_done'])
for key in ('prestress_loss_pct', 'transfer_length_in'):
if decisions.get(key) is not None:
handling[key] = _coerce(decisions[key])
# `decisions.dunnage` is written into every config file with its own
# VALID VALUES comment and was the one key here nothing mapped back:
# editing the most obvious place in the file did nothing. Merged key by
# key so editing only `mode` does not wipe `fraction`. An explicit null
# mode is a real setting -- the old, labelled approximation -- so
# membership, not truthiness, decides.
dunnage = decisions.get('dunnage')
if isinstance(dunnage, dict) and dunnage:
target = handling.setdefault('dunnage', {})
for key in ('mode', 'fraction'):
if key in dunnage:
target[key] = _coerce(dunnage[key])
# A value that will not parse, or one at or below zero, is left alone
# here and reported -- never guessed at.
tol = decisions.get('group_tolerance_ft')
if tol is not None:
try:
tol = float(tol)
except (TypeError, ValueError):
tol = None
if tol and tol > 0.0:
handling['group_tolerance_ft'] = tol
else:
_note_config_problem(
merged,
'decisions.group_tolerance_ft is {0!r}; it must be a '
'positive number of feet -- how far along the piece two '
'inserts of one row may sit and still hang from it as one '
'row. It is being ignored, so the run is using the default.'
.format(decisions.get('group_tolerance_ft')))
positions = decisions.get('insert_position')
if isinstance(positions, dict):
for name, target in (('one_piece', 'fast_lifter_box'),
('batch', 'fast_lifter_box_batch')):
value = positions.get(name)
if value is None:
continue
text = str(value).strip().lower()
# A value that is neither is left alone and reported. Guessing
# which one a typo meant is how a setting nobody checked ends up
# deciding a moment arm.
if text in ('fast', 'precise'):
handling[target] = (text == 'fast')
else:
_note_config_problem(
merged,
'decisions.insert_position.{0} is {1!r}; it can only be '
'"fast" (the cached bounding box) or "precise" (a solved '
'centroid). It is being ignored, so the run is using the '
'default for that mode.'.format(name, value))
elif positions is not None:
# `insert_position: fast` -- one value where two are wanted. Silently
# dropped before; it is a definite finding, so it says so.
_note_config_problem(
merged,
'decisions.insert_position is {0!r}; it has to name the two run '
'modes separately (one_piece: and batch:, each "fast" or '
'"precise"). It is being ignored entirely.'.format(positions))
modes = decisions.get('rig_mode')
if not isinstance(modes, dict):
if modes is not None:
_note_config_problem(
merged,
'decisions.rig_mode is {0!r}; it has to be a block with a '
'`default:` in it (and optionally one line per stage). It is '
'being ignored entirely.'.format(modes))
return merged
if modes.get('default'):
handling['rig_mode'] = _checked_rig_mode(
merged, 'default', modes['default'])
# A stage named here overrides the default for that stage only. Left
# blank it inherits, which is what one rig for the whole job looks like.
for stage in handling.get('stages') or []:
name = stage.get('name')
if name in modes and modes[name] is not None:
stage['rig_mode'] = _checked_rig_mode(merged, name, modes[name])
return merged
def _checked_rig_mode(merged, where, value):
"""The value as written, plus a finding if it is not one of the two.
Deliberately still passed through. A rig mode changes the mechanics, not
a coefficient, and the solver already REFUSES an unknown one outright --
substituting a default here would turn that hard stop into a quiet run on
a rig nobody chose. This only makes the refusal say which line of which
file it came from, before any work starts.
"""
text = str(value)
if text not in ('rolling', 'static'):
_note_config_problem(
merged,
'decisions.rig_mode.{0} is {1!r}; it can only be "rolling" '
'(equalising blocks -- every line in a group carries the same '
'force) or "static" (fixed-length slings -- a continuous beam on '
'rigid supports). A misspelled rig mode is refused, not silently '
'inherited.'.format(where, value))
return text
def plant_problems(knowledge):
"""What went wrong choosing the plant layer, as sentences.
Reads the markers `load()` left behind. They cannot be re-derived from
the tree: whether a FILE was there is a fact about the moment it was
looked for, and by the time anyone reads the tree the only evidence that
the plant layer never merged is that its values are missing -- which
looks exactly like a plant that never set them.
A job that names NO plant produces nothing here, on purpose. The
machine-wide plant file is read, exactly as before this key existed, and
that is the state every install is in. It is recorded rather than warned
about: `_plant_id` is None and `sources()` prints the file that was read,
so the fact is on the page for anyone who looks. A warning on every run
of every existing install would be noise, and noise is how a real warning
stops being read.
"""
out = []
if not isinstance(knowledge, dict):
return out
missing = knowledge.get('_plant_missing')
if missing:
plant, path = missing[0], missing[1]
where = (' {0}\n\n'.format(path) if path else
' -- {0!r} is not a usable folder name, so it does not '
'name a file at all.\n\n'.format(plant))
out.append(
'This job says it is produced at plant {0!r}, and that plant has '
'no knowledge file:\n\n{1}Nothing was read in place of it. The '
'machine-wide plant file was NOT used as a fallback -- running '
'one plant\'s job on another plant\'s rules is worse than not '
'running. Create the folder, or correct `job.plant` in the job '
'knowledge file.'.format(plant, where))
env_missing = knowledge.get('_plant_env_missing')
if env_missing:
out.append(
'WILLTOOLS_KNOWLEDGE is set, and the plant knowledge file it '
'names is not there:\n\n {0}\n\nNothing was read in its place, '
'so this run is on built-in defaults plus the job file alone. '
'Point the variable at the file, or at the folder the per-plant '
'folders sit in.'.format(env_missing))
stale = knowledge.get('_plant_layer_stale')
if stale:
out.append(
'The plant knowledge was chosen for plant {0!r} but the finished '
'configuration says the plant is {1!r}. Some layer sets '
'`job.plant` that the plant-resolution pre-pass does not read, so '
'the plant file in force is the wrong one. See the two-pass note '
'in knowledge.load().'.format(stale[0], stale[1]))
return out
# The frames a vocabulary may declare. Not a hard-coded vocabulary: the
# frames themselves are published in catalogue.yaml under
# `face_designations.frames`, and this is only the fallback used when that
# branch is missing, so a file that fails to load cannot make every frame
# look valid.
_FALLBACK_FRAMES = ('view', 'form')
def _frame_sides(frames, frame):
"""The sides a frame can name, or () when the frame does not publish any.
`frames` entries used to be plain sentences and may still be, in a plant
file written against the older shape. A sentence publishes no side list,
and the check that needs one stands down rather than calling every side
invalid -- an older file must not turn into a page of findings.
"""
entry = (frames or {}).get(frame)
if not isinstance(entry, dict):
return ()
sides = entry.get('sides')
if not isinstance(sides, list):
return ()
return tuple([s for s in sides if s is not None])
def face_designation_problems(knowledge):
"""Whether the selected face-designation vocabularies can be resolved.
Derived from the merged tree, so no marker is needed: every fact this
judges -- which ids were selected, which are defined, what frame each
declares -- is still in the tree when anyone looks.
Three ways it goes wrong, and all three are silent otherwise:
* an id in `use` that nothing defines. The plant thinks it declared a
vocabulary; every token in it is unknown to the check.
* a vocabulary declaring a frame that is not a published one. The
check cannot know what evidence to resolve it against.
* two selected vocabularies claiming the SAME token with different
frames. That token is then undecidable -- and it is decided anyway,
by whichever vocabulary happened to be looked at first.
Selecting nothing is not a problem here. It means no vocabulary declared,
the 5.3 check stands down and says so itself, and a plant that never
prints a face designation must not be nagged on every run.
"""
out = []
if not isinstance(knowledge, dict):
return out
branch = get(knowledge, 'face_designations') or {}
use = branch.get('use') or []
if not isinstance(use, list) or not use:
# Selecting nothing is not a problem -- but stating what the MODEL's
# values mean while selecting no vocabulary to resolve them into is,
# and it is the same fault as the one below.
for model, printed in sorted(face_model_tokens(knowledge).items()):
out.append(
'face_designations.model_tokens maps the model value {0!r} '
'to the printed designation {1!r}, and face_designations.use '
'selects no vocabulary at all, so nothing can resolve it.'
.format(model, printed))
return out
vocabularies = branch.get('vocabularies') or {}
frames = branch.get('frames') or {}
known_frames = tuple(frames.keys()) if frames else _FALLBACK_FRAMES
seen = {}
for vocab_id in use:
vocab = vocabularies.get(vocab_id) if isinstance(vocabularies, dict) \
else None
if not isinstance(vocab, dict):
out.append(
'face_designations.use names {0!r}, and no layer defines a '
'vocabulary by that name. Every face designation it was '
'meant to explain is unknown. Defined here: {1}.'
.format(vocab_id,
', '.join(sorted(vocabularies.keys())) or 'none'))
continue
frame = vocab.get('frame')
if frame not in known_frames:
out.append(
'Face-designation vocabulary {0!r} declares frame {1!r}, '
'which is not one of {2}. A vocabulary\'s frame says what its '
'tokens are measured FROM -- the viewing direction or the '
'casting bed -- and nothing can resolve a token without it.'
.format(vocab_id, frame, ', '.join(sorted(known_frames))))
# A token's `side` has to belong to its vocabulary's FRAME. This
# catches the half-edit: somebody who flips a published vocabulary's
# frame from view to form and leaves `side: near` behind gets a
# vocabulary that parses, resolves, and answers nonsense.
allowed = _frame_sides(frames, frame)
if allowed:
for entry in vocab.get('tokens') or []:
if not isinstance(entry, dict):
continue
side = entry.get('side')
if side is not None and side not in allowed:
out.append(
'Face designation {0!r} in vocabulary {1!r} is '
'side {2!r}, which is not a side of frame {3!r} '
'({4}). A side and the frame it is read in have to '
'agree or the token cannot be placed on the piece '
'at all.'
.format(entry.get('token'), vocab_id, side, frame,
', '.join(allowed)))
for entry in vocab.get('tokens') or []:
if not isinstance(entry, dict):
continue
token = entry.get('token')
if token is None:
continue
first = seen.get(token)
if first is None:
seen[token] = (vocab_id, frame)
elif first[1] != frame:
out.append(
'Face designation {0!r} is claimed by two selected '
'vocabularies in different frames of reference: {1!r} '
'({2}) and {3!r} ({4}). One is measured from the view and '
'the other from the form, so the token cannot be resolved '
'at all -- drop one of the two from '
'face_designations.use.'
.format(token, first[0], first[1], vocab_id, frame))
# A FOURTH WAY, added 2026-08-31 with the model-side field: the plant maps
# what its MODEL writes onto what its TICKET prints
# (`face_designations.model_tokens`), and a mapping pointing at a token no
# SELECTED vocabulary carries is dead. Every element carrying that value
# then reads as unresolvable, one element at a time, with nothing saying
# the fault is in the configuration rather than in the model. It is the
# `use`-names-nothing failure wearing a different coat, so it is reported
# in the same place.
for model, printed in sorted(face_model_tokens(knowledge).items()):
if face_designation(knowledge, printed) is not None:
continue
out.append(
'face_designations.model_tokens maps the model value {0!r} to '
'the printed designation {1!r}, and no vocabulary in '
'face_designations.use carries {1!r}. Every element holding '
'{0!r} is unresolvable, and nothing else would say the fault is '
'in the configuration rather than in the model.'
.format(model, printed))
return out
def face_vocabularies(knowledge):
"""The face-designation vocabularies this plant actually uses, in order.
[(id, vocabulary)] for every id in `face_designations.use` that a layer
defines, skipping the ones it does not -- those are reported by
`face_designation_problems`, and returning a half-built vocabulary here
would let a caller resolve tokens as if nothing were wrong.
Empty means NO VOCABULARY DECLARED. It never means "no designations
used": a caller that gets an empty list must stand down and say so, not
fall back to a set it invented.
"""
branch = get(knowledge, 'face_designations') or {}
use = branch.get('use') or []
vocabularies = branch.get('vocabularies') or {}
if not isinstance(use, list) or not isinstance(vocabularies, dict):
return []
out = []
for vocab_id in use:
vocab = vocabularies.get(vocab_id)
if isinstance(vocab, dict):
out.append((vocab_id, vocab))
return out
def face_designation(knowledge, token):
"""What one printed designation means, and WHAT IT IS MEASURED FROM.
Returns a dict -- token, means, side, frame, vocabulary, measured_from --
or None when no selected vocabulary carries the token. None is a real
answer and a caller must print it as one: an unrecognised designation is
"this tool does not know that abbreviation", never "the designation is
wrong".
`frame` comes back with every lookup on purpose. `side` alone is not an
answer -- 'near' is only meaningful once you know it is measured from the
view and therefore flips with it, while 'top' is measured from the bed
and does not. A caller that reads `side` and drops `frame` has thrown
away the half of the answer that makes checklist 5.3 decidable.
Matched case-insensitively on the token as printed, with surrounding
whitespace and any wrapping parentheses already stripped by the caller.
"""
if token is None:
return None
wanted = as_text(token).strip().upper()
if not wanted:
return None
for vocab_id, vocab in face_vocabularies(knowledge):
for entry in vocab.get('tokens') or []:
if not isinstance(entry, dict):
continue
printed = entry.get('token')
if printed is None:
continue
if as_text(printed).strip().upper() != wanted:
continue
return {
'token': printed,
'means': entry.get('means'),
'side': entry.get('side'),
'frame': vocab.get('frame'),
'measured_from': vocab.get('measured_from'),
'vocabulary': vocab_id,
}
return None
# --------------------------------- the face the MODEL records, not the ticket
#
# A face designation has two carriers and they are not the same thing:
#
# the TICKET prints it `P76 W/ SR5602 (S.I.F.)` -- a parenthetical
# suffix, in the vocabulary the plant selected.
# the MODEL holds it a shared parameter on the element, measured
# 2026-08-31: a String, on the INSTANCE, and every
# family TYPE carries exactly one value -- a
# far-face embed and a side-in-form embed are
# different types.
#
# Checklist 5.3 asks whether the two agree, so it needs both, and the model
# half is what was missing. IT IS NOT THE DRAWN LINE. The solid-versus-dashed
# rendering on the sheet is the same fact one layer further from the data
# (it is carried by Object Styles, per subcategory, project-wide), and a view
# also carries hand-drafted detail curves with line styles of their own, which
# are not a rendering of any member at all.
#
# WHY A SEPARATE MAP RATHER THAN AN ALIAS ON THE TOKEN. The model's token is
# not the printed one -- `FF` against `F.F.` -- and the printed tokens live in
# published vocabularies whose `tokens` are a LIST. Lists replace, so a plant
# adding one alias to a published vocabulary would replace the whole thing:
# exactly the half-edit `face_designation_problems` exists to refuse. So the
# plant states a MAP from what its model writes to what its ticket prints, the
# map deep-merges, and the printed token then resolves through the vocabulary
# machinery unchanged. Two facts, two keys.
FACE_ROLE = 'member.face_designation'
def _face_branch(knowledge):
branch = get(knowledge, 'face_designations')
return branch if isinstance(branch, dict) else {}
def face_model_tokens(knowledge):
"""{model value (upper) -> printed token} for this plant, or {}.
Empty means NOBODY HAS STATED THE MAPPING. It never means the model
records no face: a caller that gets {} must stand down and say so.
"""
raw = _face_branch(knowledge).get('model_tokens')
if not isinstance(raw, dict):
return {}
out = {}
for key, value in raw.items():
if key is None or value is None:
continue
model = as_text(key).strip().upper()
printed = as_text(value).strip()
if model and printed:
out[model] = printed
return out
def face_read_from(knowledge):
"""Where the model's face field is read: ['instance'] by default.
IT IS NOT ['instance', 'type'], and that is the one place this differs
from `drawings.reinforcing_rule` -- which really is both, because there
the two are mutually exclusive per element.
Measured over 180 real tickets, 85 unique: the SAME part code carries
DIFFERENT face tokens on different sheets. Twenty-four codes do; one bar
code is `(E.L.)` on one ticket and `(N.F.)` on another. So the face is a
property of the PLACEMENT, not of the part. A type-level read is not a
weaker version of an instance read -- it collapses distinct facts onto one
answer and returns something that looks perfectly clean.
"""
raw = _face_branch(knowledge).get('read_from')
if not isinstance(raw, list) or not raw:
return ['instance']
return [as_text(v).strip().lower() for v in raw if as_text(v).strip()]
def face_modifier(knowledge, token):
"""A trailing group that MODIFIES a face rather than naming one.
Returns the declared entry (with whatever a layer wrote into it -- at
minimum `means`, `means_verified` and `position`) or None.
WHY MODIFIERS ARE A SEPARATE SET. A call-out can stack two parenthesised
groups -- `P106 (S.I.F.) (E.S.)`, measured 35 times across 34 documents,
always after a face token, never instead of one. A vocabulary models a
token as naming a (frame, side); a group that is orthogonal to the face
axis has nowhere to live in that, and a reader expecting exactly one
group MIS-PARSES the pair. So a modifier is declared, strippable, and
carries no side at all.
`means_verified: false` is the ordinary dormant-and-loud flag: a plausible
reading of an abbreviation is a reading, not a fact, and nothing here may
act on an unverified one.
"""
if token is None:
return None
wanted = as_text(token).strip().upper()
if not wanted:
return None
table = _face_branch(knowledge).get('modifiers')
if not isinstance(table, dict):
return None
for key, entry in table.items():
if as_text(key).strip().upper() == wanted:
out = dict(entry) if isinstance(entry, dict) else {}
out['token'] = key
return out
return None
def face_non_token(knowledge, token):
"""A group DECLARED not to be a face designation, or None.
The negative has to be written down. `(O.H.)` is a real trailing group at
one plant -- 15 of them -- that qualifies a DIMENSION and never attaches
to a part call-out, and it pattern-matches a face token exactly. Without
a place to record "this is not one", it comes back as either a false
positive or an unexplained unknown on every sheet that carries it.
"""
if token is None:
return None
wanted = as_text(token).strip().upper()
if not wanted:
return None
table = _face_branch(knowledge).get('not_face_tokens')
if not isinstance(table, dict):
return None
for key, entry in table.items():
if as_text(key).strip().upper() == wanted:
out = {'token': key, 'why': None}
if isinstance(entry, dict):
out.update(entry)
out['token'] = key
elif entry is not None:
out['why'] = as_text(entry)
return out
return None
def split_face_callout(knowledge, groups):
"""Sort one call-out's parenthesised groups into face / modifier / other.
`groups` is the groups as printed, in order -- for
`P106 (S.I.F.) (E.S.)` that is `['S.I.F.', 'E.S.']`.
Returns a dict:
face the `face_designation()` dict for the one face token, or
None
token that token as printed, or None
modifiers the declared modifier entries, in order
not_faces groups DECLARED not to be faces (a dimension qualifier)
unknown groups nothing recognised. NEVER SILENTLY DROPPED.
why one sentence
An unknown group is could-not-judge, not noise. A reader that ignores what
it does not recognise cannot tell "this call-out names no face" from "this
call-out names a face in words nobody wrote down", and those want opposite
actions. Two face tokens in one call-out are likewise refused rather than
resolved by taking the first.
"""
out = {'face': None, 'token': None, 'modifiers': [], 'not_faces': [],
'unknown': [], 'why': None}
faces = []
for group in (groups or []):
text = as_text(group).strip() if group is not None else ''
if not text:
continue
face = face_designation(knowledge, text)
if face is not None:
faces.append(face)
continue
modifier = face_modifier(knowledge, text)
if modifier is not None:
out['modifiers'].append(modifier)
continue
non = face_non_token(knowledge, text)
if non is not None:
out['not_faces'].append(non)
continue
out['unknown'].append(text)
if len(faces) > 1:
out['why'] = ('this call-out carries {0} face designations ({1}) and '
'a part is on one face, so none was chosen. Taking the '
'first would decide it by print order.'
.format(len(faces),
', '.join([f['token'] for f in faces])))
return out
if faces:
out['face'] = faces[0]
out['token'] = faces[0]['token']
tail = []
if out['modifiers']:
tail.append('modifiers stripped: {0}'
.format(', '.join([m.get('token') or '?'
for m in out['modifiers']])))
if out['not_faces']:
tail.append('declared not to be face designations: {0}'
.format(', '.join([n.get('token') or '?'
for n in out['not_faces']])))
if out['unknown']:
tail.append('NOT RECOGNISED, and not ignored: {0}'
.format(', '.join([repr(u) for u in out['unknown']])))
if out['face'] is not None:
head = ('{0} ({1}), read in the {2} frame'
.format(out['token'], out['face'].get('means'),
out['face'].get('frame')))
else:
head = 'no face designation in this call-out'
out['why'] = head + ('. ' + '; '.join(tail) + '.' if tail else '.')
return out
def face_attachment_verdict(knowledge, token, kind):
"""May this face designation attach to this kind of member?
(True / False / None, sentence). `kind` is a short word a caller
classifies the member into -- 'embed', 'bar' -- and the table is
`face_designations.attaches_to`, a MAP of token -> list of kinds, so a job
adds one token's rule without restating the plant's.
This is DATA rather than a rule inside a check because it was measured and
it is exact: at one plant `S.I.F.` attaches to an embed 177 times out of
177 and never to a bar, and `E.L.` attaches only to bars. A `S.I.F.` on a
reinforcing element is, on that evidence, wrong by construction -- which
is a real finding waiting for somebody to write the check, and a fact that
would rot inside one.
None where nothing is declared for the token, or where the caller could
not classify the member. Never False for want of data: "no rule written
down" and "that is not allowed" are opposite answers.
"""
table = _face_branch(knowledge).get('attaches_to')
if not isinstance(table, dict) or not table:
return None, ('face_designations.attaches_to is empty, so nothing '
'says which kinds of member a face designation may '
'attach to.')
wanted = as_text(token).strip().upper() if token is not None else ''
allowed = None
for key, value in table.items():
if as_text(key).strip().upper() == wanted:
allowed = value
break
if not isinstance(allowed, list) or not allowed:
return None, ('face_designations.attaches_to says nothing about '
'{0!r}, so whether it may attach here is not written '
'down.'.format(token))
allowed = [as_text(v).strip().lower() for v in allowed
if as_text(v).strip()]
if kind is None or not as_text(kind).strip():
return None, ('{0!r} attaches to {1}, and this member was not '
'classified, so the two could not be compared.'
.format(token, ', '.join(allowed)))
got = as_text(kind).strip().lower()
if got in allowed:
return True, ('{0!r} attaches to {1}, and this member is classified '
'{2}.'.format(token, ', '.join(allowed), got))
return False, ('{0!r} attaches only to {1}, and this member is classified '
'{2}. Measured over a whole ticket corpus with no '
'exception, so this is wrong by construction rather than '
'unusual.'.format(token, ', '.join(allowed), got))
def face_recorded_on(knowledge):
"""The member kinds whose elements record a face at all, and why not.
Returns (kinds, why). An EMPTY list means nobody has written it down --
never "no kind records one". The distinction is the whole reason this
exists: a blank field on a kind that DOES record faces is unknown, and a
blank on a kind that never does is not applicable, and those two want
different sentences in a report.
"""
branch = _face_branch(knowledge)
kinds = branch.get('recorded_on')
if isinstance(kinds, list) and kinds:
return [as_text(k).strip() for k in kinds if as_text(k).strip()], None
return [], (branch.get('recorded_on_unverified_because')
or 'face_designations.recorded_on is empty: nobody has '
'written down which kinds of member record a face, so a '
'blank field cannot be told from a field that does not '
'apply.')
def face_from_model_token(knowledge, value, component=None, family=None):
"""What face the MODEL's own field names, and where the answer stopped.
Returns a dict, always, never None -- the same discipline as
`rebar_stock`, and for the same reason: a caller that gets nothing back
has to invent the sentence explaining why, and it will invent a worse one.
value what was read off the element, as read
token the printed designation it maps to, or None
face the `face_designation()` dict -- token, means, side, frame,
measured_from, vocabulary -- or None
why one sentence, whatever the outcome
`component` is the member's kind as the plant records it (the
`member.manufacture_component` value) and `family` its family name. Both
optional, and both only ever used to explain an ABSENCE better -- neither
can produce a face.
A BLANK FIELD IS NEVER A FACE. Measured across 282 panels: the field is
blank 34,434 times, and within the kinds that use it at all a face is
written EXPLICITLY (163 near-face values) alongside 1,304 blanks -- if
blank meant near face, near face would never need writing. On the paper
side the same holds: 219 of 461 unmarked call-outs use a code that is
tagged elsewhere on the same sheet, so an unmarked call-out is annotation
that was not repeated. There is no key here for what a blank means, and
there must not be one.
LIFTING HARDWARE IS ITS OWN STAND-DOWN, and it is not a rare corner. The
ticket prints `(N.F.)` more than any other face token, and the most
commonly named part in those call-outs is the face lifter -- whose
instances are blank in the model, every one of them. A part that is on a
face by definition, with a model that does not say which, is a piece of
evidence a check must decline on rather than treat as a disagreement.
WHAT A CHECK CALLS. For 5.3, per member: read the role
`member.face_designation` off the INSTANCE (see `face_read_from` -- never
the type), hand the raw value here with the member's kind and family, and
compare `result['token']` against the ticket's designation with
`same_face()`, which answers True / False / **None**. `None` is not a
disagreement. A `face` of None owes the row a NOT RUN carrying `why`, and
a token whose `face_attachment_verdict` is False is its own finding
independent of any comparison.
"""
out = {'value': value, 'token': None, 'face': None, 'why': None}
raw = '' if value is None else as_text(value).strip()
mapping = face_model_tokens(knowledge)
if not raw:
if family is not None and is_lifter(knowledge, family):
out['why'] = ('This is lifting hardware and it records no face. '
'It is on a face by definition and the model does '
'not say which -- measured, blank on every '
'instance -- so nothing may be concluded from the '
'blank. Stand down and say so.')
return out
kinds, kinds_why = face_recorded_on(knowledge)
if kinds and component is not None:
up = as_text(component).strip().upper()
if any(k.upper() in up for k in kinds):
out['why'] = ('This element records no face, and its kind '
'({0!r}) IS one of the kinds that record one, '
'so the face is UNKNOWN here -- not a default '
'and not near.'.format(component))
else:
out['why'] = ('This element records no face, and its kind '
'({0!r}) is not one of the kinds that record '
'one, so the field does not apply to it.'
.format(component))
return out
out['why'] = ('This element records no face: the parameter behind '
'parameters.{0} is absent or empty on it. That is a '
'fact about the element, not about the mapping, and it '
'is not a face -- {1}'
.format(FACE_ROLE,
kinds_why or 'nothing distinguishes a blank '
'from a field that does not '
'apply.'))
return out
if not mapping:
out['why'] = ('The model records {0!r} on this element and no layer '
'states face_designations.model_tokens, so nothing '
'knows which printed designation that value is. '
'Nothing was assumed.'.format(raw))
return out
printed = mapping.get(raw.upper())
if printed is None:
out['why'] = ('The model records {0!r} on this element and '
'face_designations.model_tokens does not carry it '
'(it carries {1}). It is either a value nobody has '
'measured or a typo, and no face was guessed: a face '
'vocabulary has no "other", so a guess would resolve '
'silently to a face nobody meant.'
.format(raw, ', '.join(sorted(mapping.keys()))))
return out
out['token'] = printed
face = face_designation(knowledge, printed)
if face is None:
out['why'] = ('The model records {0!r}, which face_designations.'
'model_tokens maps to the printed designation {1!r} -- '
'and no vocabulary in face_designations.use carries '
'{1!r}. The mapping and the selection disagree; see '
'config_problems.'.format(raw, printed))
return out
out['face'] = face
out['why'] = ('The model records {0!r}, which this plant prints as {1} '
'({2}), read in the {3} frame -- {4}.'
.format(raw, printed, face.get('means'), face.get('frame'),
face.get('measured_from')))
return out
def top_in_form_rule(knowledge):
"""Where the PER-PIECE "which building face is top in form" value lives.
Returns a dict -- label, source, values, verified, why -- and it is
DORMANT until somebody reads the field, which is the point of landing it.
WHY THIS EXISTS AND WHY IT IS NOT A PLANT CONSTANT. A form-anchored
vocabulary says a part is on the face that ends up on top in the form.
That is absolute for the piece, and it is NOT a fixed building face: the
design output carries the value per panel and it varies panel to panel --
two of three sampled read one way and the third the other.
So a check that resolves a printed face designation to a BUILDING face
(interior / exterior) without reading this per-piece value is exactly
backwards on every piece of the opposite handedness, and every individual
verdict still reads perfectly normal. `building_face_of()` is the guard;
this is where it gets its label from.
It is neither plant nor job knowledge -- it is a value to be READ off each
piece, like a design number -- so what a knowledge layer holds is only the
label to look for. Note the label's exact spelling, space before the colon
included, because that is how it is printed.
"""
rule = _face_branch(knowledge).get('top_in_form')
rule = dict(rule) if isinstance(rule, dict) else {}
out = {'label': rule.get('label'),
'source': rule.get('source'),
'values': rule.get('values') or [],
'verified': bool(rule.get('verified')),
'why': None}
if not out['label']:
out['why'] = (rule.get('unverified_because')
or 'face_designations.top_in_form.label is unset: '
'nobody has written down where a piece states which '
'building face ends up on top in the form.')
return out
if not out['verified']:
out['why'] = ('face_designations.top_in_form names {0!r}{1}, and it '
'is not verified: {2}'
.format(out['label'],
' in ' + as_text(out['source'])
if out['source'] else '',
rule.get('unverified_because')
or 'nobody has said why.'))
return out
out['why'] = ('A piece states which building face is top in form under '
'{0!r}{1}; the values seen are {2}.'
.format(out['label'],
' in ' + as_text(out['source'])
if out['source'] else '',
', '.join([as_text(v) for v in out['values']])
or 'unrecorded'))
return out
def building_face_of(knowledge, token, top_in_form=None):
"""Which BUILDING face a printed designation lands on, or a refusal.
Returns (building_face, why). `building_face` is whatever the piece's own
field says -- typically 'Interior' / 'Exterior' -- or None.
`top_in_form` is that per-piece value, read off the piece. WITHOUT IT THIS
REFUSES, always, and that refusal is the whole function: a form-anchored
token names a casting orientation, the mapping from casting orientation to
building face is per piece and it varies, and a check that assumes one
handedness is not slightly wrong -- it is inverted on half the job, one
normal-looking verdict at a time.
It refuses on a view-framed token too. Near/far measured from the drawing
says nothing about the form until something resolves the view against it.
"""
face = face_designation(knowledge, token)
if face is None:
return None, ('{0!r} is not a designation any selected vocabulary '
'carries, so it names no face at all.'.format(token))
if face.get('frame') != 'form':
return None, ('{0} is read in the {1} frame, not the form, so it '
'says nothing about which building face it lands on '
'until something resolves that frame against the form.'
.format(face['token'], face.get('frame')))
side = face.get('side')
if side == 'both':
return None, ('{0} names every face the vocabulary can name, so it '
'does not pick one building face.'
.format(face['token']))
stated = as_text(top_in_form).strip() if top_in_form is not None else ''
if not stated:
rule = top_in_form_rule(knowledge)
return None, ('{0} is (form, {1}), and which BUILDING face that is '
'varies piece by piece -- so it was not resolved. Read '
'the piece\'s own top-in-form value first: {2}'
.format(face['token'], side, rule['why']))
if side == 'top':
return stated, ('{0} is top in form, and this piece states {1!r} is '
'top in form.'.format(face['token'], stated))
return None, ('{0} is (form, {1}) and this piece states {2!r} is top in '
'form. The OPPOSITE of a stated building face is not '
'derivable from that statement alone -- a piece has more '
'than two faces and nothing here says which one is meant. '
'Read it, do not invert it.'
.format(face['token'], side, stated))
# ------------------------------------ which end of a piece is UP once erected
#
# Owner, 2026-08-30, asked whether to wire or delete the two plant keys that
# had been asking a producer to declare this:
#
# "哪头朝上取决于 assembly origin。也就是 TOT。写入公共知识。
# assembly origin 里面有一个朝上的头。"
#
# Which end is up depends on the ASSEMBLY ORIGIN, also called TOT; write it
# into the PUBLIC knowledge; the assembly origin has an up end in it.
#
# PUBLIC, NOT PLANT. That placement is the ruling, not a filing decision. The
# origin is placed as part of making the assembly, and placing it IS the
# statement of which end goes up -- so there is nothing here for a producer to
# declare, and `orientation.top_is_higher_global_z` / `orientation.
# top_end_families`, the two plant keys that asked one to, were the wrong
# shape rather than merely unread. Both are deleted; the rule ships in
# `knowledge/catalogue.yaml`, which identifies nobody.
#
# WHAT A HIGHER LAYER MAY AND MAY NOT DO. `orientation.up_end` is a MAP, so it
# deep-merges: a producer whose assemblies genuinely carry no usable origin
# corrects one field and inherits the rest. What a layer may NOT do is name a
# different MECHANISM -- that is code, not data, and this function reads
# exactly the fields the catalogue documents.
#
# THE MODEL-READING HALF SHIPPED DORMANT and was answered the same day:
# `AssemblyInstance.GetTransform().BasisY` is the erected up direction,
# measured against geometry unioned from `GetMemberIds()` on 282 assemblies.
# It went live as data -- `origin_up` and `origin_up_verified` in the
# catalogue -- and no code changed to turn it on, which is the whole point of
# having written it as a shape rather than as an `if`.
#
# WHAT DID NEED CODE is the three ways that reading is wrong on real pieces,
# and `up_end_from_origin()` below is exactly those three, in order. One
# assembly in 282 has BasisY pointing sideways; the frame is confirmed for
# HORIZONTAL panels only; and a near-square piece cannot be classified as
# either. All three DECLINE -- they never clamp and never pick the nearest
# axis -- because a wrong answer here is silent: the moments still balance,
# they are just balanced about the wrong end.
UP_END_SOURCE = 'assembly_origin'
# Fallbacks for a catalogue that predates the guards. `1.25` and `0.99` are
# the shipped values and the reasoning for both is in catalogue.yaml.
UP_END_NEAR_SQUARE_RATIO = 1.25
UP_END_UP_TOLERANCE = 0.99
def up_end_rule(knowledge):
"""How to read which end of a piece is up once erected -- and whether it runs.
Returns (source, why) with source one of:
'assembly_origin' the ruled mechanism, and it is READY: a layer has
said what on the origin denotes the up end, and
somebody has verified that against a real model.
None it cannot run, and `why` is the sentence to print.
None NEVER means "this piece has no up end", and a caller must not read it
that way. It means the rule as ruled cannot be executed yet -- which is a
fact about this checkout, not about the piece.
Kept here rather than in `liftexport` on purpose. `liftexport.
erected_orientation()` was the only place in the codebase that knew which
end was up, and it knew it as an unstated implementation detail; the
ruling is knowledge, so it is written down where knowledge lives and where
it can be tested with no model and no licence.
"""
rule = get(knowledge, 'orientation.up_end') or {}
if not isinstance(rule, dict) or not rule:
return None, (
'Nothing says how to tell which end of a piece is up once it is '
'erected: orientation.up_end is not declared in any layer, not '
'even the catalogue that ships with the tool. Nothing was assumed '
'either way.')
source = as_text(rule.get('source') or '').strip()
if not source:
return None, (
'orientation.up_end names no source, so there is nothing to read '
'the up end off. The published rule is {0!r}.'
.format(UP_END_SOURCE))
if source != UP_END_SOURCE:
return None, (
'orientation.up_end.source is {0!r}, and the only mechanism this '
'tool implements is {1!r}. A source nobody implemented is not a '
'different answer, it is an unimplemented one -- so nothing is '
'read off it and nothing is guessed in its place.'
.format(source, UP_END_SOURCE))
origin_up = rule.get('origin_up')
verified = bool(rule.get('origin_up_verified'))
if origin_up and verified:
return UP_END_SOURCE, (
"Which end is up comes from the assembly's origin ({0}): {1}."
.format(rule.get('also_called') or UP_END_SOURCE, origin_up))
stated = rule.get('origin_up_as_stated')
because = rule.get('origin_up_unverified_because')
if origin_up and not verified:
return None, (
"The rule says which end is up comes from the assembly's origin, "
'and orientation.up_end.origin_up says {0!r} -- but '
'origin_up_verified is false, so nobody has checked that against '
'a real assembly. {1}'
.format(origin_up,
because or 'Verify it against a model and set the flag.'))
return None, (
"Which end is up comes from the assembly's origin, but nothing says "
'WHAT on the origin denotes the up end, so the rule cannot be '
'executed{0}. {1}'
.format(' (written as {0!r}, not yet verified against a model)'
.format(stated) if stated else '',
because or 'Read it off a live assembly and set '
'orientation.up_end.origin_up.'))
def up_end_from_origin(knowledge, basis_up, up_axis, long_ft, short_ft,
label=None):
"""Can THIS piece's origin answer which end is up? (True / None, why)
`basis_up` the assembly origin's up basis vector in WORLD coordinates,
as an (x, y, z) triple -- `GetTransform().BasisY`.
`up_axis` which of the piece's own axes that vector lies along:
'length', 'width', 'thickness', or None for none of them.
`long_ft` / `short_ft`
the piece's two in-plane dimensions, longer first.
`label` the mark or id, so a refusal names the piece rather than
describing a shape.
NEVER RETURNS FALSE. The answer is "yes, read it off the origin" or "no
one can tell from the origin, and here is which of the three known
reasons applies". False would have to mean "the origin says the piece is
upside down", and nothing here can mean that.
The three refusals, in order, each measured rather than imagined:
1. `basis_up` is not world up. One assembly in 282 has it pointing
sideways with its FACE NORMAL up instead, and on that piece every
other reading off the frame is wrong too. An assertion, not a
preference -- clamping to the nearest axis is how a single odd piece
becomes a silently mis-erected one.
2. The piece is near square, so long-versus-up cannot classify it. Two
pieces sit at ratio 1.00 and 0.92 while every clean case is 1.7 and
up. Declining a genuinely undecidable band is not timidity; picking
a side there is a coin toss wearing a verdict's clothes.
3. The piece is not horizontal. The frame is confirmed for horizontal
panels; the owner has since confirmed (2026-09-01) that the same
green-arrow reading also holds on a vertical wall panel, but this
function has not been extended to run there -- widening it would
change real verdicts on real pieces, and that is his call. Standing
down until he makes it costs one fallback; assuming it costs an
erection.
"""
rule = get(knowledge, 'orientation.up_end') or {}
named = 'assembly {0}'.format(label) if label else 'this assembly'
tolerance = _number(rule.get('up_tolerance'))
if tolerance is None:
tolerance = UP_END_UP_TOLERANCE
ratio_max = _number(rule.get('near_square_ratio'))
if ratio_max is None or ratio_max < 1.0:
ratio_max = UP_END_NEAR_SQUARE_RATIO
# ---- 1. the origin's up must actually be up -----------------------
try:
x, y, z = [float(v) for v in basis_up]
except (TypeError, ValueError):
return None, ('The origin of {0} gave no up vector, so nothing can be '
'read off it.'.format(named))
size = (x * x + y * y + z * z) ** 0.5
if size <= 0:
return None, ('The origin of {0} gave a zero-length up vector, so '
'nothing can be read off it.'.format(named))
if (z / size) < tolerance:
return None, (
"The origin of {0} does not stand the piece up: its up basis is "
'({1}, {2}, {3}) rather than world up. One assembly in 282 is '
'like this -- its face normal points up instead -- and on such a '
'piece every reading off the frame is wrong, so nothing is read '
'off this one.'
.format(named, round(x, 4), round(y, 4), round(z, 4)))
# ---- 2. near square: no classification is possible ----------------
long_v, short_v = _number(long_ft), _number(short_ft)
if long_v is None or short_v is None or short_v <= 0 or long_v <= 0:
return None, ('{0} has no readable in-plane dimensions, so it cannot '
'be told apart from a vertical piece.'
.format(named[0].upper() + named[1:]))
if long_v < short_v:
long_v, short_v = short_v, long_v
ratio = long_v / short_v
if ratio < ratio_max:
return None, (
'{0} is near square ({1} x {2} ft, ratio {3}), and below {4} the '
'long direction cannot be told from the up direction. Two pieces '
'on this job sit at 1.00 and 0.92 while every clean case is 1.7 '
'and up, so this band is genuinely undecidable rather than '
'merely awkward.'
.format(named[0].upper() + named[1:], round(long_v, 2),
round(short_v, 2), round(ratio, 2), ratio_max))
# ---- 3. the frame is confirmed for horizontal panels only ---------
#
# WORDING FIXED 2026-09-01, VERDICT NOT. This message used to say
# "Vertical panels work differently and nobody has said how yet" -- true
# when it was written, false now: the owner has confirmed (2026-09-01,
# `knowledge/catalogue.yaml` orientation.up_end.also_seen_as.
# vertical_wall_panels) that the SAME green-arrow reading -- BasisY is
# world up -- also holds on a vertical wall panel. What is still open is
# purple and red for that frame, and whether THIS THREE-REFUSAL FUNCTION
# should run there at all -- a decision the owner has not made, so the
# decline stands (see the docstring's "NEVER RETURNS FALSE" note and
# `knowledge/catalogue.yaml`'s own comment on `applies_to`: widening it
# changes real verdicts on real pieces and is his call, not a wording
# fix's).
applies = as_text(rule.get('applies_to') or 'horizontal_panels').strip()
if applies == 'horizontal_panels' and up_axis != 'width':
return None, (
"The origin rule is confirmed for HORIZONTAL panels only, and the "
'origin of {0} stands its {1} up, which is not one. The owner has '
'confirmed the same green-arrow reading also holds on a vertical '
'wall panel (2026-09-01), but this rule has not been extended to '
'run there yet, so the origin is not read on this piece.'
.format(named, up_axis or 'no recognisable direction'))
return True, (
"Read off the origin of {0} ({1}): {2} is the erected up direction, "
'and it stands the piece\'s {3} up.'
.format(named, rule.get('also_called') or UP_END_SOURCE,
rule.get('origin_up'), up_axis))
# ------------------------------------------- which MEMBERS are reinforcing
#
# Checklist 5.6 and 5.7 rest entirely on knowing which modelled members are
# bars, and until now that was an ENUMERATION -- `drawings.reinforcing_
# families`, a list of names. An enumeration goes stale in silence: the plant
# adds a bar family, nobody re-lists it, and the checks report nothing on
# those bars, which reads exactly like nothing being wrong.
#
# So the same fact can now be stated as a RULE. Owner, 2026-08-30: the ones
# with rebar written in them -- "里面写着 rebar 的".
#
# WHAT "IN THEM" MEANS, corrected the same day and worth the space, because
# the first reading of it was wrong and wrong in a way that would have gone
# quiet rather than loud. It was built as a CATEGORY plus a FAMILY-NAME
# token. The census behind that read `FamilySymbol` TYPES only -- and this
# plant writes its categorisation on the INSTANCE, so the census missed the
# entire reinforcing population and the category could never be verified.
# Scoped to real assemblies the bars are not hiding at all: 112 members on
# one piece, 84 on another.
#
# The rule is a PARAMETER and a token in its VALUE, read from the INSTANCE
# first and the TYPE second, and it is not an invention -- the plant has
# already encoded exactly this in the model. Thirteen of its view filters key
# on that one parameter, and every string rule among them is "contains".
#
# The parameter is named here as a ROLE, so the office's own parameter name
# stays in the plant layer where it belongs and never enters this repository.
def _upper_strings(values):
if not isinstance(values, list):
return []
return [as_text(v).strip().upper() for v in values
if v is not None and as_text(v).strip()]
def _reinforcing_family_patterns(knowledge):
"""`drawings.reinforcing_family_patterns`, compiled.
Returns a list of `(raw, compiled)` pairs, in file order. `compiled` is
`None` when the entry does not compile as a regex -- it then simply never
matches, the same silent-inert fate a plain-list token gets when it never
appears in a family name. Matched with `re.search`, case-insensitively,
against the family name AS GIVEN (the plain list upper-cases both sides
first; a regex author writes its own case handling by using this flag,
so the entry is left alone here).
"""
raw = get(knowledge, 'drawings.reinforcing_family_patterns', [])
if not isinstance(raw, list):
return []
out = []
for entry in raw:
text = as_text(entry or '').strip()
if not text:
continue
try:
out.append((text, re.compile(text, re.IGNORECASE)))
except re.error:
out.append((text, None))
return out
def reinforcing_mode(knowledge):
"""Which of the three ways of knowing is actually in force, and why.
Returns (mode, why) with mode one of:
'list' the plant enumerated its families and that enumeration is
authoritative. It WINS where it is set: being overtaken by a
pattern the plant did not write would be worse than the
pattern is worth.
'rule' no enumeration, and the parameter + token rule can run --
which means the parameter role is named, at least one token
is named, and the parameter has been VERIFIED against a
model. All three; a rule that half-runs is worse than none.
None NEITHER. Nothing can be judged, and `why` is the sentence to
print. It is never "no families are bars".
Exposed separately from the per-family verdict because a report wants to
say, once, how it decided -- and because a plant sitting on an
enumeration while a rule is written and inert underneath it should be
able to see that, rather than wondering why the rule changed nothing.
"""
# Default `[]`, spelled out, to agree with ticketchecks' two call
# sites. Two readers disagreeing about what unset means is a
# finding in tools/knowledge_drift.py, and rightly so.
listed = _upper_strings(
get(knowledge, 'drawings.reinforcing_families', []))
patterns = _reinforcing_family_patterns(knowledge)
if listed or patterns:
return 'list', ('{0} reinforcing families and {1} family-name '
'pattern(s) are named in the knowledge files, so the '
'list is used and the parameter rule, if any, is not '
'consulted.'.format(len(listed), len(patterns)))
rule = get(knowledge, 'drawings.reinforcing_rule') or {}
role = as_text(rule.get('parameter') or '').strip()
tokens = _upper_strings(rule.get('value_contains'))
verified = bool(rule.get('parameter_verified'))
because = rule.get('parameter_unverified_because')
if role and tokens and verified:
return 'rule', (
'A member whose {0!r} parameter contains {1} is reinforcing, read '
'from the {2}.'
.format(role, ' or '.join([repr(t) for t in tokens]),
' then the '.join(_read_from(rule))))
if role and tokens and not verified:
return None, (
'The reinforcing rule names the parameter role {0!r} and the '
'token(s) {1}, but parameter_verified is false -- nobody has '
'confirmed that parameter against a model. {2}'
.format(role, ' or '.join([repr(t) for t in tokens]),
because or 'Read it off a live document and set the flag.'))
if tokens and not role:
return None, (
'The reinforcing rule names the token(s) {0} but no PARAMETER to '
'read them out of, so it cannot run. A token with nothing behind '
'it has nothing to match against. {1}'
.format(' or '.join([repr(t) for t in tokens]),
because or 'Name the parameter role and verify it.'))
return None, (
'Nothing says which modelled members are reinforcing: '
'drawings.reinforcing_families and drawings.reinforcing_family_'
'patterns are both empty and drawings.reinforcing_rule names no '
'parameter and token. Nothing was assumed either way.')
def _read_from(rule):
"""Where to look for the parameter, in order. Instance first, then type.
THEY ARE MUTUALLY EXCLUSIVE PER ELEMENT -- measured 2026-08-30: where the
instance carries a value the type is empty, and vice versa. Reading only
one of them therefore finds nothing on half the model and returns a result
that looks perfectly clean, which is exactly how the first census of this
parameter missed every bar in the job.
"""
order = rule.get('read_from')
order = ([as_text(v).strip().lower() for v in order]
if isinstance(order, list) else [])
order = [v for v in order if v in ('instance', 'type')]
return order or ['instance', 'type']
def _scope_for(rule, item):
"""One checklist row's additions and exclusions, as upper-cased tokens."""
scopes = rule.get('scopes')
if not isinstance(scopes, dict) or item is None:
return [], []
entry = scopes.get(as_text(item).strip()) or {}
if not isinstance(entry, dict):
return [], []
return (_upper_strings(entry.get('also')),
_upper_strings(entry.get('except')))
def reinforcing_verdict(knowledge, family, instance_value=None,
type_value=None, item=None):
"""Is this member reinforcing? (True / False / None, why)
NONE IS A REAL ANSWER and the caller must keep it: it means nothing here
can judge, which is not the same as False. Reporting "not a bar" for a
member nobody has described is how 5.6 and 5.7 went quiet in the first
place -- and it is what the old category-plus-family-name shape did to
every bar in the job, because it read TYPES and the answer is on the
INSTANCE.
`instance_value` / `type_value` are the member's and its type's value for
the parameter the rule names. Pass both; they are read in the order
`read_from` gives, because they are mutually exclusive per element.
`item` is the checklist row asking ('5.6', '5.7'). It selects a per-row
scope, and with no scope declared every row sees the same population --
today's behaviour exactly. It exists because the token is NOT a clean
boundary in either direction: some bar-formed hardware carries it (one is
a strand loop, with no bar mark and no stock length), and some genuine
reinforcement does not. Which row wants which is an open question with the
owner, so the lists ship empty rather than guessed.
Resolution order:
1. `drawings.reinforcing_family_overrides` -- an exact, case-insensitive
FAMILY name mapped to true or false. Definitive, and first, so a job
can add or exclude one family without restating a list or a rule.
2. the enumeration, if it is set -- family-name substring match, the
existing semantics unchanged -- then `drawings.
reinforcing_family_patterns`, the same evidence class written as
regexes instead of literal substrings, for a family name that wraps
an open-ended code an enumeration could not keep up with. Either
one being non-empty wins over the rule.
3. the rule -- the parameter's value, then the row's scope, then the
token.
4. otherwise None.
"""
fam = as_text(family or '').strip().upper()
overrides = get(knowledge, 'drawings.reinforcing_family_overrides') or {}
if isinstance(overrides, dict):
for name, value in overrides.items():
if as_text(name).strip().upper() == fam and fam:
return bool(value), (
'Family {0!r} is named in '
'drawings.reinforcing_family_overrides as {1}.'
.format(family, 'a bar' if value else 'not a bar'))
mode, why = reinforcing_mode(knowledge)
if mode is None:
return None, why
if mode == 'list':
for name in _upper_strings(
get(knowledge, 'drawings.reinforcing_families', [])):
if name and name in fam:
return True, ('Family {0!r} contains {1!r}, which is named in '
'drawings.reinforcing_families.'
.format(family, name))
for raw, compiled in _reinforcing_family_patterns(knowledge):
if compiled is not None and compiled.search(as_text(family or '')):
return True, ('Family {0!r} matches {1!r}, a pattern named in '
'drawings.reinforcing_family_patterns.'
.format(family, raw))
return False, ('Family {0!r} matches none of the names in '
'drawings.reinforcing_families nor any pattern in '
'drawings.reinforcing_family_patterns.'.format(family))
rule = get(knowledge, 'drawings.reinforcing_rule') or {}
role = rule.get('parameter')
values = {'instance': instance_value, 'type': type_value}
read, source = None, None
for where in _read_from(rule):
candidate = as_text(values.get(where) or '').strip()
if candidate:
read, source = candidate, where
break
if read is None:
return None, (
'Neither the instance nor the type of {0!r} carries a value for '
'{1!r}, so nothing here can judge it -- which is not the same as '
'it not being a bar.'.format(family, role))
upper = read.upper()
also, excluded = _scope_for(rule, item)
for token in excluded:
if token in upper:
return False, (
'{0!r} says {1!r}, which checklist {2} excludes from '
'reinforcing.'.format(family, read, item))
for token in also:
if token in upper:
return True, (
'{0!r} says {1!r}, which checklist {2} counts as reinforcing '
'even though it carries no token.'.format(family, read, item))
for token in _upper_strings(rule.get('value_contains')):
if token in upper:
return True, ('{0!r} is reinforcing: its {1} {2!r} is {3!r}, '
'which contains {4!r}.'
.format(family, source, role, read, token))
return False, ('{0!r} is not reinforcing: its {1} {2!r} is {3!r}, which '
'carries none of {4}.'
.format(family, source, role, read,
' or '.join([repr(t) for t in _upper_strings(
rule.get('value_contains'))]) or 'any token'))
# ------------------------------------------------------------ rebar stock
#
# Checklist 5.7 asks whether any bar is longer than the stock it is cut from.
# Until `stock_by_size` existed it compared every size against ONE number, and
# a 30 ft #4 at a plant that stocks #4 in 20 ft lengths passed -- silently, on
# a real defect class. Everything below exists so that never happens by
# omission again, and so a size nobody has data for makes the check STAND
# DOWN rather than pass.
# A size, optionally followed by a PARENTHESISED qualifier.
#
# THE PARENTHESES ARE THE WHOLE POINT AND ARE NOT NEGOTIABLE. This pattern
# used to require the entire string to be the size, and a real value in a real
# model -- `'#9 (80 KSI)'` -- came back None, so every bar of the one size the
# owner specifically told us about went unjudged by 5.7. Silently: an
# unreadable size is a stand-down, and a stand-down on five values out of six
# looks like a working check.
#
# The tolerance is deliberately narrow. `(...)` is a qualifier; anything else
# trailing is not a size at all. Widening this to "a size followed by
# whatever" would make `'#4 BENT BAR (17)'` -- a free-text DESCRIPTION -- read
# as a bare size, and a plant that mapped the size role at that field would
# then be silently parsing prose with the strong reader. The description has
# its own reader, with its own basis; see `bar_size_of`.
_SIZE_RE = re.compile(
r'^\s*(?:#|no\.?\s*|number\s*)?0*(\d{1,2})\s*(\([^)]*\))?\s*$', re.I)
# `80 KSI`, `(80 ksi)`, `Grade 80` inside a qualifier. A GRADE, and the only
# place in the tool a grade is ever read off a bar rather than defaulted.
_QUALIFIER_FY_RE = re.compile(
r'(?:^|\W)(?:grade\s*)?(\d{2,3})\s*(?:ksi)?(?:\W|$)', re.I)
def rebar_size_parts(value):
"""(size, qualifier) -- the size, and the parenthesised text after it.
`'#4'` -> `('#4', None)`. `'#9 (80 KSI)'` -> `('#9', '80 KSI')`.
Anything that is not a size -> `(None, None)`.
THE QUALIFIER IS KEPT RATHER THAN DISCARDED because at least one real one
is a GRADE. `rebar_fy()` has a provenance chain -- 'stated', 'plant
stock', 'astm default', None -- whose strongest rung, 'stated', had
nowhere to be filled from: nothing in the model was read as a grade, so
every bar answered from a plant or ASTM default even where the model said
otherwise. A bar whose own size field reads `#9 (80 KSI)` has stated its
grade, and `bar_size_of()` hands the number on as `qualifier_fy_ksi` so a
caller can pass it to `rebar_fy(..., stated_fy_ksi=...)`.
Nothing here decides that a qualifier IS a grade. It returns the text; the
number is offered separately and only when the text reads as one.
"""
if value is None:
return None, None
m = _SIZE_RE.match(as_text(value))
if not m:
return None, None
qualifier = m.group(2)
if qualifier:
qualifier = qualifier.strip('() ').strip() or None
return '#' + m.group(1), qualifier
def rebar_size(value):
"""Anything that names a bar size, as the string the tables are keyed by.
`'#4'`, `'4'`, `4`, `'No. 4'`, `'#04'` all come back `'#4'`, and so does
`'#9 (80 KSI)'` -- a size with a parenthesised qualifier is still that
size, and refusing it meant every bar of that size went unjudged by 5.7
(measured 2026-08-31, and it was the one size the owner had named).
Anything else comes back None, which keeps a junk token out of a table
lookup rather than turning it into a size nobody meant.
The qualifier is DISCARDED here and kept by `rebar_size_parts()`. Callers
that only need a table key stay unchanged; a caller that wants the grade
asks for it.
Keyed by the PRINTED form, `'#4'`, and not by the number 4 on purpose:
the two YAML readers this tool runs on disagree about a bare `4:` -- one
gives the integer, the other the string -- and one field with two types
is a bug this file has already paid for once (see `_coerce`).
"""
return rebar_size_parts(value)[0]
def rebar_size_qualifier_fy(qualifier):
"""A yield strength stated inside a size qualifier, or None.
`'80 KSI'` -> 80.0. `'GRADE 80'` -> 80.0. Anything with no number in it,
or a number outside the range any rebar grade takes, -> None. It never
guesses: a qualifier that is not a grade is not a grade.
"""
if qualifier is None:
return None
m = _QUALIFIER_FY_RE.search(as_text(qualifier))
if not m:
return None
value = _number(m.group(1))
if value is None or not 40 <= value <= 120:
# Outside every published rebar grade (A615: 40/60/75/80/100;
# A706: 60/80). A number outside that band is something else in the
# parentheses and must not be read as a strength.
return None
return value
# The role, exported for callers and tests. THE READ SITES BELOW SPELL THE
# PATH OUT ANYWAY, and that is not redundancy: `tools/knowledge_drift.py`
# pairs declared keys against read keys by scanning the source for the string,
# so a constant HIDES the read and the key reports as "declared but never
# read". `drawings.bed_plan` is the standing example of exactly that.
BAR_SIZE_ROLE = 'member.bar_size'
# The size at the FRONT of a part description, and it insists on the marker
# -- `#4`, `No. 4` -- where `rebar_size` accepts a bare `4`.
#
# THAT DIFFERENCE IS THE SAFETY, not an oversight. `rebar_size` is handed a
# field whose whole job is to hold a size, so a bare number in it is a size. A
# description holds a size AND other things, and a leading bare integer there
# is marked as nothing at all. Reading `4 X 8'-8"` as a #4 bar would be a
# guess wearing a parser's clothes.
_DESC_SIZE_RE = re.compile(r'^\s*(?:#|no\.?\s*|number\s*)0*(\d{1,2})\b', re.I)
def _bar_size_rule(knowledge):
rule = get(knowledge, 'drawings.bar_size_rule')
return rule if isinstance(rule, dict) else {}
def bent_bar_verdict(knowledge, description):
"""Is this a BENT bar? (True / False / None, sentence).
None is a real answer and callers must keep it: it means nothing here can
judge, which is not the same as False. Reporting "not a bent bar" for an
element nobody has described is how a check goes quiet.
IT DOES TWO JOBS AND BOTH MATTER.
* IT GATES THE PARSE. `bar_size_of()` reads a size off the front of a
part description only where this says True, because the shape
`#N BENT BAR (...)` IS the evidence that the leading token is a bar
size. A description that no longer has the shape is no longer
evidence, so the bar goes UNJUDGED rather than mis-sized.
* IT EXPLAINS AN ABSENCE. A bar with no size gets a reason saying "this
is a bent bar" instead of one that sends somebody to re-check a
mapping that is already correct.
The token and the field are both PLANT knowledge
(`drawings.bar_size_rule.bent_bar_tokens`, read out of the description
role). Matched case-insensitively as a SUBSTRING, because that is what
this plant's own thirteen view filters do.
"""
tokens = _upper_strings(_bar_size_rule(knowledge).get('bent_bar_tokens'))
if not tokens:
return None, ('drawings.bar_size_rule.bent_bar_tokens is empty, so '
'nothing here can tell a bent bar from a straight one.')
text = '' if description is None else as_text(description).strip()
if not text:
return None, ('this element records no description, so whether it is '
'a bent bar could not be read.')
up = text.upper()
for token in tokens:
if token in up:
return True, ('its description carries {0!r}, which is how a bent '
'bar is recognised at this plant.'.format(token))
return False, ('its description carries none of {0}, so it does not read '
'as a bent bar.'
.format(', '.join([repr(t) for t in tokens])))
# Owner, verbatim (2026-08-31): "assembly 的 identity description 是我的随
# 笔。family 的不是。" -- the assembly's identity description is his running
# notes; the family's is not.
def bar_size_of(knowledge, parameter_value=None, description=None):
"""A bar's size, and WHICH FIELD ANSWERED.
Returns a dict, always, never None:
size the normalised printed size (`'#4'`), or None
basis 'parameter' the size field behind member.bar_size
'description' the part description behind
member.identity_description, read off the
FAMILY
None nothing could read it. Stand down, print why.
read_from the raw string the answer came out of, or None
qualifier a parenthesised suffix on a size, e.g. `'80 KSI'`, or None
qualifier_fy_ksi that qualifier as a yield strength, when it reads as
one. Pass it to `rebar_fy(..., stated_fy_ksi=)`, whose
strongest provenance rung had nothing to fill it from until
this existed.
why one sentence, whatever the outcome
TWO READERS, TWO POPULATIONS -- NOT A FALLBACK CHAIN. Measured over 24,539
rebar members in 13 families: the size parameter is carried by the one
STRAIGHT family and by none of the twelve bent ones, and the family
description is carried by all twelve bent families (100% on the type) and
by no straight member. The two sets are disjoint. The size parameter is
tried first only because something has to be, and since no bar carries
both, that order has never decided anything -- do not read it as a
ranking, and do not "fix" a bar by looking for the other field.
THE DESCRIPTION IS STRUCTURED DATA, NOT NOTES, and this docstring said the
opposite for part of one day. Owner, 2026-08-31: "the assembly's identity
description is my running notes; the family's is not." The ASSEMBLY's identity
description is his running notes -- the field where `MASTER` sits as a
stable token inside free text. The FAMILY's is the description of the
part. Same parameter name, two kinds of element, opposite reliability, and
`drawings.bar_size_rule.description_read_from` is what says which one to
read. The evidence agrees with him: 7,405 of 7,405 bent members parse, in
one uniform shape, with no exceptions. Prose does not do that.
THE PARSE IS STRICT ON PURPOSE, and the reason is not distrust. The shape
`#N BENT BAR (...)` IS the evidence that the leading token is a bar size;
a description that no longer has that shape is no longer evidence of
anything, so it must leave the bar UNJUDGED rather than be parsed through.
A leading BARE number is refused for the same reason -- nothing marks it
as a size. Both refusals are could-not-judge with a reason.
WHY BENT BARS ARE READ AT ALL. Owner, 2026-08-30: "rebars come in 40ft
stock. unless overwritten by other knowledge, all rebars pieces must be
less than 40ft" -- ALL pieces -- and separately that a bar's length is
read off a parameter, bent bars included. Bent bars are in scope for 5.7
by ruling; only their size was missing.
THE ALTERNATIVE THAT WAS CONSIDERED AND REJECTED: a bar diameter parameter
plus a diameter-to-size map. Measured, it is not universal -- four bent
families, 534 members, carry no diameter on any member, and all four parse
cleanly from the description. The map would have had a hole this route
does not, on top of needing nominal diameters, tolerance bands, and a
ruling for a value landing between two bands. Do not build it.
"""
out = {'size': None, 'basis': None, 'read_from': None,
'qualifier': None, 'qualifier_fy_ksi': None, 'why': None}
rule = _bar_size_rule(knowledge)
raw = '' if parameter_value is None else as_text(parameter_value).strip()
if raw:
size, qualifier = rebar_size_parts(raw)
if size is not None:
out.update({'size': size, 'basis': 'parameter', 'read_from': raw,
'qualifier': qualifier,
'qualifier_fy_ksi': rebar_size_qualifier_fy(qualifier)})
out['why'] = ('{0}, read from the size field behind '
'parameters.{1} ({2}){3}.'
.format(size, BAR_SIZE_ROLE,
', '.join(param_names(
knowledge, 'member.bar_size'))
or 'unmapped',
', qualified {0!r}'.format(qualifier)
if qualifier else ''))
return out
out['why'] = ('the size field behind parameters.{0} holds {1!r}, '
'which does not read as a bar size. Nothing was guessed '
'from it.'.format(BAR_SIZE_ROLE, raw))
return out
mapped = param_names(knowledge, 'member.bar_size')
text = '' if description is None else as_text(description).strip()
bent, bent_why = bent_bar_verdict(knowledge, description)
if not rule.get('description_fallback'):
if not mapped:
out['why'] = ('no parameters.{0} is mapped in any knowledge layer '
'and drawings.bar_size_rule.description_fallback is '
'off, so nothing in the tool can read a bar size -- '
'on any bar.'.format(BAR_SIZE_ROLE))
return out
out['why'] = ('this bar carries no value in the size field behind '
'parameters.{0} ({1}), and the part-description reader '
'is off (drawings.bar_size_rule.description_fallback). '
'The mapping is not what is missing.{2}'
.format(BAR_SIZE_ROLE, ', '.join(mapped),
' This is a bent bar: {0}'.format(bent_why)
if bent else ''))
return out
if not text:
out['why'] = ('this bar carries no size field, and the part '
'description behind {0} (read from {1}) is empty too, '
'so there is nothing left to read a size out of.'
.format(rule.get('description_role')
or 'the description role',
', '.join(rule.get('description_read_from')
or ['type'])))
return out
if bent is not True:
out['why'] = ('this bar carries no size field, and its part '
'description {0!r} does not have the shape a size is '
'read out of -- {1} The shape is the evidence that the '
'leading token is a size, so a description without it '
'leaves the bar unjudged rather than mis-sized.'
.format(text, bent_why))
return out
match = _DESC_SIZE_RE.match(text)
if not match:
out['why'] = ('this bar carries no size field, and its part '
'description {0!r} does not START with a marked bar '
'size (`#4`, `No. 4`). A leading bare number is not '
'accepted: nothing marks it as a size.'.format(text))
return out
size = rebar_size(match.group(1))
out.update({'size': size, 'basis': 'description', 'read_from': text})
out['why'] = ('{0}, read off the front of the part description {1!r}. '
'That is the field the bent-bar families carry their size '
'in; they carry no size field at all, so this is the '
'reader for that population rather than a fallback.'
.format(size, text))
return out
def _no_size_why(knowledge, size):
"""Why a bar reached `rebar_stock` with no usable size.
States that used to share one sentence -- "None does not read as a bar
size" -- and each wants a different action:
* nothing mapped and no fallback on. Nothing can read a size, on any
bar. Fix: map `parameters.member.bar_size`.
* the role IS mapped and this bar came back empty. The field is
ABSENT ON THIS BAR'S FAMILY -- measured 2026-08-31, a plant carries it
on its straight family and on none of its twelve bent ones, which
read their size off the part description instead. Fix:
`drawings.bar_size_rule.description_fallback`, not the mapping.
* something was read and it is not a size. Fix: look at the value.
The middle one is why this exists. Saying "no size parameter is mapped"
to somebody who has just mapped one sends them to re-check a file that is
already right, and hides that the gap is per family. Naming the mapped
parameter is what makes the difference checkable by the reader.
NOTE WHAT IS NOT HERE. It does not say bent bars are out of scope. They
are IN scope by the owner's ruling of 2026-08-30 -- "all rebars pieces
must be less than 40ft" -- so a bent bar with no readable size is an
unanswered question about the size, never a decision about the bar.
"""
if size is not None and as_text(size).strip():
return ('{0!r} does not read as a bar size, so no stock length was '
'looked up.'.format(size))
tail = (' A bar with no resolvable size is not judged against the generic '
'stock length either: "40 ft" and "40 ft for this size" are '
'different claims, and the generic one is wrong in the direction '
'that passes a defect.')
return ('This bar has no size, so no stock length was looked up -- '
+ bar_size_of(knowledge)['why'] + tail)
def rebar_stock(knowledge, size):
"""How long a stick of this bar size is, and whether the plant keeps it.
Returns a dict, always, never None:
size the normalised size, or None if it was not readable
length_in integer inches, exact. None when nothing knows.
length_ft the same length in feet, for printing. None likewise.
basis 'by_size' the per-size table -- the real answer
'generic' the one-number industry fallback
None NOTHING KNOWS. Stand down and say why.
stocked True / False / None. A SEPARATE FACT from the length:
a size can be listed with a length and still be marked
not stocked, and that is neither a pass nor a failure --
it is a reason to ask a person.
astm the designation, from the size row if it names one and
otherwise from the plant's `stock_material`. It does NOT
give a yield strength; see rebar_fy().
astm_basis 'by_size' or 'plant stock', so a size row that overrides
the plant's stock material is distinguishable from one
that inherits it.
why one sentence, for the report, whatever the outcome.
LENGTHS ARE INCHES because two common stock lengths are not whole feet
-- 23'-4" is 23.3333... ft -- and a bar cut to exactly 23'-4" would fail a
comparison against a decimal-foot copy of its own stock length. Feet are
returned too, computed here, so no caller ever does the conversion twice
and differently.
"""
key = rebar_size(size)
out = {'size': key, 'length_in': None, 'length_ft': None, 'basis': None,
'stocked': None, 'astm': None, 'astm_basis': None, 'why': None}
if key is None:
out['why'] = _no_size_why(knowledge, size)
return out
# The designation, if anything says one. A SIZE ROW rarely does and
# should not have to: what the stock is made of is stated once for the
# plant in `stock_material`, because a material repeated on every row is
# a material that goes stale on some of them. `astm_basis` says which
# answered, so a row that DOES override -- a job's odd bar -- is
# distinguishable from the plant default.
material = get(knowledge, 'catalogue.rebar.stock_material') or {}
if isinstance(material, dict) and material.get('astm'):
out['astm'] = material.get('astm')
out['astm_basis'] = 'plant stock'
else:
out['astm_basis'] = None
entry = (get(knowledge, 'catalogue.rebar.stock_by_size') or {}).get(key)
if isinstance(entry, dict):
out['stocked'] = entry.get('stocked')
if entry.get('astm'):
out['astm'] = entry.get('astm')
out['astm_basis'] = 'by_size'
length = _number(entry.get('stock_length_in'))
if length is not None:
out['length_in'] = length
out['length_ft'] = length / 12.0
out['basis'] = 'by_size'
out['why'] = ('{0} stock is {1:g} in ({2:.2f} ft), from the '
'per-size stock table in the plant layer.'
.format(key, length, length / 12.0))
if entry.get('stocked') is False:
out['why'] += (' It is marked NOT STOCKED: that length is '
'what would arrive if it were ordered.')
return out
# Listed, but with no length. `stocked` still means something and is
# returned; the LENGTH falls through to the generic number, because
# an entry that says nothing about length is not a statement that the
# length is unknown.
generic = _number(get(knowledge, 'catalogue.rebar.stock_length_ft'))
if generic is not None:
out['length_ft'] = generic
out['length_in'] = generic * 12.0
out['basis'] = 'generic'
out['why'] = ('No stock length is listed for {0}, so the generic '
'{1:g} ft is used. That number is not size specific '
'and the small sizes are commonly much shorter -- list '
'{0} in the plant layer to check it properly.'
.format(key, generic))
return out
out['why'] = ('No stock length is known for {0}: it is not in the '
'per-size table and no generic length is set either. '
'Nothing was assumed.'.format(key))
return out
def rebar_fy(knowledge, astm=None, stated_fy_ksi=None):
"""The yield strength to use, and WHERE IT CAME FROM.
Returns (fy_ksi, basis, why). `basis` is 'stated' when a grade was given,
'astm default' when it came from the designation's published default, and
None when nothing defensible is known -- in which case fy is None and the
caller must stand down and print `why`.
THE DESIGNATION DOES NOT GIVE A GRADE, and this function exists to stop
anything pretending it does. Current A615 covers Grades 60, 80 and 100
(historically 40 and 75 too), so "A615" is not a strength; its published
`default_fy_ksi` is deliberately null and this returns None for it.
Current A706 covers 60 and 80, with 60 as the default -- a DEFAULT, and
it is reported as one, because a default that reads like a measurement is
how a job with 80 ksi bar gets checked at 60.
"""
stated = _number(stated_fy_ksi)
if stated is not None:
return stated, 'stated', ('{0:g} ksi, as stated.'.format(stated))
# The PLANT'S OWN STATED STOCK MATERIAL, and it comes before the ASTM
# table on purpose: "this plant stocks A706 Grade 60" is an assertion
# about the plant, while "A706 usually means Grade 60" is what the
# designation tends to mean. Same number here, different strength of
# claim, and the basis says which. Only consulted when the caller named
# no designation, or named the one the plant stocks -- a caller asking
# about some other designation is asking a different question.
material = get(knowledge, 'catalogue.rebar.stock_material') or {}
if isinstance(material, dict) and material:
material_fy = _number(material.get('fy_ksi'))
material_astm = material.get('astm')
if material_fy is not None and (not astm or astm == material_astm):
return material_fy, 'plant stock', (
'{0:g} ksi -- the plant states its stock rebar is {1}, '
'{0:g} ksi. A stated fact about this plant, not an assumption '
'about the designation.'
.format(material_fy, material_astm or 'unspecified'))
entry = (get(knowledge, 'catalogue.rebar.astm_grades') or {}).get(astm) \
if astm else None
if isinstance(entry, dict):
default = _number(entry.get('default_fy_ksi'))
grades = entry.get('grades_ksi') or []
if default is not None:
return default, 'astm default', (
'{0:g} ksi, the published DEFAULT for {1} and not a stated '
'grade. {1} also covers {2}.'
.format(default, astm,
', '.join(['%g ksi' % g for g in grades
if _number(g) is not None])
or 'other grades'))
return None, None, (
'No yield strength is known. {0} does not imply one -- it covers '
'{1} -- and no grade was stated, so nothing was assumed.'
.format(astm, ', '.join(['%g ksi' % g for g in grades
if _number(g) is not None])
or 'several grades'))
if astm:
return None, None, (
'No yield strength is known: {0!r} is not in the published ASTM '
'grade table and no grade was stated.'.format(astm))
return None, None, ('No yield strength is known: no grade was stated and '
'no ASTM designation was given.')
def rebar_astm(knowledge, name):
"""What the plant's stock list says about one ASTM heading.
Returns the entry, or None when the heading is not mentioned at all. The
THREE-WAY distinction is the whole point and a caller must keep it:
entry with sizes_listed True sizes are listed; look them up
entry with sizes_listed False NAMED, WITH NOTHING UNDER IT. Nothing
is known. This is NOT "the plant has
none" and must never be printed as
one.
None never mentioned.
The middle case is real: one plant's section 5301 names A706 and the page
ends there. Collapsing it into either neighbour invents a fact.
"""
table = get(knowledge, 'catalogue.rebar.astm_designations') or {}
entry = table.get(name)
return entry if isinstance(entry, dict) else None
def rebar_grades_present(knowledge):
"""Bar grades a human has declared are on this job, as a sorted list.
Existence facts about individual call-outs -- "there is a #9 at 80 ksi on
this job" -- NOT a statement that every bar of that size carries that
grade. Declared in the job layer as a map so a second declaration is a
second key; see the note in DEFAULTS.
"""
table = get(knowledge, 'catalogue.rebar.grades_present') or {}
if not isinstance(table, dict):
return []
out = []
for key in sorted(table):
entry = table[key]
if isinstance(entry, dict):
row = dict(entry)
row['id'] = key
row['size'] = rebar_size(entry.get('size')) or entry.get('size')
out.append(row)
return out
def _number(value):
"""A number, or None. Never a substitute value."""
if value is None or isinstance(value, bool):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def same_face(knowledge, token_a, token_b):
"""Do these two printed designations name THE SAME FACE of the piece?
True / False / None, with None meaning one of them could not be resolved
-- never False, because "I do not know that abbreviation" and "those are
different faces" are opposite answers.
THE IDENTITY OF A FACE IS THE PAIR (frame, side). Two tokens from two
different vocabularies name one face when both are read in the same frame
and land on the same side, and this is not a coincidence of the data
model -- it is what lets a plant whose near/far is anchored to the FORM
say that its `N.F.` and the trade's `T.I.F.` are one face, without either
vocabulary having to mention the other.
Two tokens in DIFFERENT frames are never the same face here, even when
they sound alike. They are measured from different things, so the
question has no answer until something resolves one frame against the
other, and that is a fact about the piece and the view rather than about
the vocabulary.
"""
a = face_designation(knowledge, token_a)
b = face_designation(knowledge, token_b)
if a is None or b is None:
return None
if a.get('frame') is None or a.get('frame') != b.get('frame'):
return False
if a.get('side') is None or b.get('side') is None:
return None
return a['side'] == b['side']
def config_problems(doc, knowledge=None):
"""Things wrong with this model's configuration, as sentences.
Empty when there is nothing to say. Buttons call this before doing any
work: a configuration that is not what the engineer set is worth stopping
for, and it is invisible otherwise.
"""
out = []
# FIRST, because it is the loudest thing that can be wrong with a
# configuration: the file is there, the reader refused it, and the run
# is on the layers underneath it looking entirely normal.
for label, path, message in unreadable_layers(knowledge):
out.append(
'The {0} knowledge layer is on this machine and could not be '
'read, so NONE of it was used:\n\n {1}\n\nThe YAML reader '
'refused it:\n\n {2}\n\nThe layer was skipped whole -- a '
'half-parsed layer is never merged -- so every value that file '
'sets is back to whatever the layer underneath it says, and a '
'layer that fails to load looks exactly like a layer that agrees '
'with the defaults. Fix the file and run again (a key stated '
'twice inside one mapping is the usual cause, and '
'`python tools/knowledgelint.py` names it with a line number); '
'the tool will not guess at what it said.'
.format(label, path, message))
if isinstance(knowledge, dict) and knowledge.get('_config_missing'):
out.append(
'This model is set to run on a project config file that is not '
'there:\n\n {0}\n\nUntil it comes back, or Project Config is '
'run again, everything is using the layered knowledge files '
'instead -- which is NOT the configuration that was set.'
.format(knowledge['_config_missing']))
if isinstance(knowledge, dict):
emptied = knowledge.get('_capacity_override_emptied')
if emptied:
where = '; '.join('{0} layer ({1})'.format(layer, path)
for layer, path in emptied)
out.append(
'lifters.capacity_lb is declared but EMPTY in {0}. The '
'ordinary shape of that table is "no overrides" -- an '
'empty dict, `{{}}` -- and a layer that instead states the '
'key with nothing under it has almost certainly had a '
'VALUE REMOVED from it. This is the exact shape of the '
'2026-08-31 incident where a plant-layer restructure '
'silently deleted the owner\'s hand-entered P76 = 16,000 lb '
'override and every export after it quietly fell back to '
'the catalogue\'s 7,690 with nothing anywhere saying so. '
'If this really is meant to say "no overrides here", write '
'`capacity_lb: {{}}` and this stops firing; if a value used '
'to be here, it needs to come back.'.format(where))
out.extend(plant_problems(knowledge))
out.extend(face_designation_problems(knowledge))
# Everything the `decisions:` block got wrong, judged by _merge_decisions
# at the moment it dropped the value. NOT re-derived from the tree here:
# a dropped value leaves no trace in the tree to re-derive it from, and
# the version of this function that tried read `decisions.insert_position`
# -- a path the merge never creates -- so it never fired on a real file.
if isinstance(knowledge, dict):
for message in knowledge.get('_config_problems') or []:
out.append(message)
dunnage = get(knowledge, 'handling.dunnage') or {}
mode = dunnage.get('mode')
if mode is not None:
if mode not in ('lifter_rows', 'fraction'):
out.append(
'handling.dunnage.mode is {0!r}; it can only be '
'"lifter_rows", "fraction" or blank (the old, labelled lift '
'approximation).'.format(mode))
if mode == 'fraction':
fraction = dunnage.get('fraction')
try:
fraction = float(fraction)
except (TypeError, ValueError):
fraction = None
if fraction is None or not 0.0 < fraction < 0.5:
out.append(
'handling.dunnage.mode is "fraction" but '
'handling.dunnage.fraction must be a number between 0 and '
'0.5, got {0!r}.'.format(dunnage.get('fraction')))
return out
def sources(doc):
"""[(label, path, exists)] for the layers load() reads, newest last.
Resolves the plant layer through the same pre-pass `load()` uses, so this
names the file that is really read. Asking for the plant path without a
plant would print the machine-wide file while the run used a per-plant
one -- a listing of layers that lies about the layers.
"""
from vibe import designspec
plant = plant_id(doc)
rows = [('catalogue', catalogue_path()),
('plant', designspec.plant_knowledge_path(plant)),
('job', designspec.knowledge_path(doc))]
config = designspec.active_config_path(doc)
if config:
rows.append(('project config', config))
# `path` is None where a plant id would not resolve to one (a malformed
# name). Listed as a layer that is not there, which is what it is.
return [(label, path, bool(path) and os.path.isfile(path))
for label, path in rows]
# The layers that are FILES, in merge order: the token the marker key is
# built from, and the label a sentence calls the layer by. The labels match
# `sources()`, so a person reading Self Check sees one name for one layer.
_FILE_LAYERS = (('catalogue', 'catalogue'),
('plant', 'plant'),
('job', 'job'),
('config', 'project config'))
_FILE_LAYER_LABELS = dict(_FILE_LAYERS)
def _unreadable_key(layer):
"""The marker key one layer leaves when its file is there and will not read.
Per layer, the same shape as `_plant_missing` / `_config_missing`, because
they answer the same kind of question: not "what does the tree say" but
"what happened to a file at the moment it was looked for".
"""
return '_{0}_unreadable'.format(layer)
def unreadable_layers(knowledge):
"""[(label, path, message)] for every layer whose file would not parse.
Merge order, so the first row is the deepest layer that failed. Empty
when every file that exists was read, which is the ordinary case.
"""
out = []
if not isinstance(knowledge, dict):
return out
for layer, label in _FILE_LAYERS:
marker = knowledge.get(_unreadable_key(layer))
if marker:
out.append((label, marker[0], marker[1]))
return out
def _note_layer_unreadable(target, layer, path, error):
"""Record that a layer's file exists and the reader refused it."""
if not layer or not isinstance(target, dict):
return target
message = as_text(error).strip() or type(error).__name__
target[_unreadable_key(layer)] = (path, message)
return target
# Commit message, verbatim, private repo `pyrevit-knowledge` `767281e`:
# "总表三层化、厂层收割落库" -- master table split into three layers, plant
# layer harvested and landed into the store.
def _note_capacity_override_emptied(target, layer, path, data):
"""Record when a layer states `lifters.capacity_lb` with nothing in it.
THE INCIDENT THIS EXISTS FOR, 2026-08-31/09-01: a restructure of the
plant layer (private repo `pyrevit-knowledge`, commit `767281e`,
"master table split into three layers, plant layer harvested and
landed into the store") walked into `lifters.capacity_lb` holding the
owner's own hand-entered override -- P76 = 16,000 lb, the correct ALP
with-tension-bar rating -- found it disagreeing with catalogue.yaml's
published 7,690, and emitted the key EMPTY with a comment reading as
though it had always been blank. `lifter_capacity()` checks this table
BEFORE the catalogue, so every export after that quietly fell back to
7,690 and five real panels carried a false FAIL/PLUMB. The sibling key
in the same block, `lifters.min_edge_distance_in: 9`, survived the
identical restructure untouched -- proof this was an accident and not a
policy, and proof the failure mode is silent: nothing anywhere said the
value had gone.
The default SHAPE of `lifters.capacity_lb` is `{}`, an empty dict: "no
overrides declared here". A layer that instead states the key with
nothing under it (bare `capacity_lb:`, or `capacity_lb: null`) merges to
the exact same VALUE as `{}` -- `lifter_capacity` cannot tell them apart
after the merge, because `_longest_substring_match(None or {}, ...)`
behaves identically either way -- but it is a DIFFERENT EVENT: a `null`
is either a value that used to be there, or someone typed the key with
nothing meant by it, and neither is "nobody ever set this here". That
distinction disappears the moment `_deep_update` runs unless it is
caught HERE, at the layer that did it -- which is why this lives beside
`_note_layer_unreadable` rather than being re-derived from the merged
tree later, the same reasoning `_note_config_problem` documents for
`decisions:`.
There is no legitimate reason to write `capacity_lb: null` rather than
`capacity_lb: {}` or leaving the key out entirely -- so if this ever
fires on a genuinely intended blank, the fix is to write the entry-less
dict instead of removing this check.
"""
if not layer or not isinstance(target, dict) or not isinstance(data, dict):
return target
lifters = data.get('lifters')
if isinstance(lifters, dict) and 'capacity_lb' in lifters \
and lifters['capacity_lb'] is None:
target.setdefault('_capacity_override_emptied', []).append(
(layer, path))
return target
def _merge_file(merged, path, layer=None, notes=None):
"""Merge one layer file, and say so out loud when it will not parse.
A file that is not there is not this function's business -- absence is
ordinary (no job file yet, no catalogue on a stripped install) and the
layers that must NOT be absent have their own markers upstream
(`_plant_missing`, `_config_missing`).
A file that IS there and raises is a different animal, and it used to be
swallowed here by a bare `except: return merged`. Measured 2026-09-01: one
duplicate key nested inside one mapping of a plant file -- pyRevit's
reader is strict and raises `Duplicate key` on it -- discarded the ENTIRE
255KB plant layer. Every parameter role mapped to None, the sweep
enumerated 0 assemblies, and every report rendered normally;
`sources()` and `config_problems()` both said healthy, because
A LAYER THAT FAILS TO LOAD LOOKS EXACTLY LIKE A LAYER THAT AGREES WITH
THE DEFAULTS. Nothing downstream can tell the two apart, so the fact has
to be recorded here, at the moment of the failure, or it is gone.
The layer is still SKIPPED -- a half-parsed layer must never be merged,
which is the same rule `yamlkit.YamlKitError` exists for -- but the skip
now leaves a marker that `config_problems()` turns into a refusal.
`layer` is the token from `_FILE_LAYERS`; passing none (the plant-name
pre-pass, which reads files only to answer "which plant" and throws its
holder away) records nothing. `notes` is where the marker goes when that
is not the tree being merged into -- `_merge_config_file` merges into a
holder of its own and the marker belongs on the real tree.
The host-side gate for the same failure is `tools/knowledgelint.py`; the
test double that must stay this strict is `tests/pyrevit_yaml_fake.py`.
"""
if not path or not os.path.isfile(path):
return merged
try:
from pyrevit.coreutils import yaml as pyyaml
data = pyyaml.load_as_dict(path)
except Exception as ex:
_note_layer_unreadable(merged if notes is None else notes,
layer, path, ex)
return merged
if isinstance(data, dict):
coerced = _coerce(data)
# 'config' is excluded here because a project config file may wrap
# its knowledge in a `knowledge:` key -- `_merge_config_file` checks
# the UNWRAPPED body itself, after it has decided which shape this
# file is, rather than double-checking the raw one here.
if layer is not None and layer != 'config':
_note_capacity_override_emptied(
merged if notes is None else notes, layer, path, coerced)
_deep_update(merged, coerced)
return merged
# A digit-only string that leads with a zero: `0010001`, `03`, `00`. The
# author wrote the zero on purpose, so the value is a LABEL and stays a
# string. `0` alone is the number zero.
_LEADING_ZERO_LABEL = re.compile(r'^0\d+$')
_NULLS = ('null', 'none', '~', '')
_TRUE = ('true', 'yes', 'on')
_FALSE = ('false', 'no', 'off')
def _coerce(node):
"""Give YAML scalars their intended types.
pyRevit's YAML reader hands back every scalar as a string -- `1.5`
arrives as `'1.5'` and `null` as `'null'` -- so the typing has to be
put back somewhere, and doing it once here beats a stray string
turning into an arithmetic error deep inside a check.
THE ONE PLACE IT MUST NOT: a LEADING ZERO on an otherwise digit-only
string. `0010001` is a part mark (a blockout former on this job's
details 13/X4.2, 14/X4.2, 28/X4.5), not the number ten thousand and
one, and coercing it silently deleted three characters and changed
its type. Quoting in the YAML does not help -- coercion happens after
parsing, so `'0010001'` and `0010001` reach here identically. The
damage stayed invisible because it is SELF-CONSISTENT: comparisons
inside the knowledge tree still agreed, and only a comparison against
a string read from the model or printed on a drawing would disagree.
It also cost a whole `number` field in the detail catalogue, because
`'03'` arrived as `3` while its companion `'03A'` stayed a string --
one field, two types.
A leading zero is the author saying "this is a label". No genuinely
numeric setting is ever written `007`, so nothing numeric is lost:
`0`, `1.5`, `-3`, `0.5` and `1e3` all still arrive as numbers.
"""
if isinstance(node, dict):
return dict([(k, _coerce(v)) for k, v in node.items()])
if isinstance(node, list):
return [_coerce(v) for v in node]
if not isinstance(node, string_types):
return node
text = node.strip()
low = text.lower()
if low in _NULLS:
return None
if low in _TRUE:
return True
if low in _FALSE:
return False
if _LEADING_ZERO_LABEL.match(text):
return text # a part mark, not a number -- see above
try:
if re.match(r'^[+-]?\d+$', text):
return int(text)
if re.match(r'^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$', text):
return float(text)
except Exception:
pass
return text
def _nest_dotted_parameters(merged):
"""Fold flat `parameters` keys ('panel.design_number: X') into the
nested tree ('parameters.panel.design_number') that `get` and
`param_names` walk.
A job layer sampled off a live model wrote its role map in the flat
dotted form -- the form the role NAMES are spelled in everywhere else
-- and nothing ever read it: `get` walks nested keys only, so every
role in that file stayed unmapped and the plant's None placeholders
won (found 2026-09-03 when a slab's design number came back None
while the piece carried it). Both spellings are now one tree. The
flat entry is the LATER layer's word, so it overwrites whatever the
nested slot holds; the flat key is then dropped so nothing reads it
twice. Never raises.
"""
try:
params = merged.get('parameters')
if not isinstance(params, dict):
return
for key in list(params.keys()):
if not isinstance(key, string_types) or '.' not in key:
continue
value = params.pop(key)
node = params
parts = key.split('.')
for part in parts[:-1]:
child = node.get(part)
if not isinstance(child, dict):
child = {}
node[part] = child
node = child
node[parts[-1]] = value
except Exception:
pass
def get(knowledge, dotted, default=None):
node = knowledge
for part in dotted.split('.'):
if not isinstance(node, dict) or part not in node:
return default
node = node[part]
return default if node is None else node
def _deep_copy(node):
if isinstance(node, dict):
return dict([(k, _deep_copy(v)) for k, v in node.items()])
if isinstance(node, list):
return [_deep_copy(v) for v in node]
return node
def _deep_update(base, override):
for key, value in override.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
_deep_update(base[key], value)
else:
base[key] = value
return base
# ------------------------------------------------------- parameter naming
PARAMETER_ROLES = (
'panel.design_number', 'panel.length', 'panel.width',
'panel.unit_weight', 'panel.weight',
'panel_type.thickness',
'strand_type.diameter', 'strand_type.description',
'wwf_type.description',
'sheet.design_number', 'sheet.length', 'sheet.width', 'sheet.depth',
'sheet.plant',
'sheet.fc', 'sheet.fci', 'sheet.unit_weight',
# AN OPTIONAL ACCELERATOR FOR reinforce's same-elevation tie-breaker,
# never its source of truth -- the source of truth is geometric (a
# thickness-axis coordinate band, computed for every piece regardless;
# see `reinforcecore.elevation_band`). This role names an OPAQUE
# GROUPING KEY a plant may already carry on its walls for an unrelated
# reason: MC's is `Tagged_Sheet`, the drafting sheet a wall's elevation
# is drawn on -- owner, 2026-09-02, "对MC而言,所有的墙应该都有一个参数叫
# Tagged_Sheet。如果俩panel这个参数一样,那么他们就是同一个elevation,
# 不用跑哪个几何引擎。" Two panels sharing a non-empty value here are the
# same elevation with no geometry looked at; unmapped or blank, the
# geometric floor decides instead, and that is the NORMAL state, not a
# gap -- other plants carry no such parameter at all, and even on MC it
# exists only on complex jobs. The parameter NAME is the plant's to
# declare; do not assume it exists on any job that has not mapped it.
'panel.elevation_group',
'member.mark',
# A BAR'S LENGTH, AND THE ONLY SOURCE FOR IT. Will, 2026-08-30: a bar's
# length -- BENT BARS INCLUDED -- is READ off the parameter this role
# maps to and never measured. His words: "钢筋的长度,即使是弯筋的
# 长度,也都不要自己测量" -- never measure it yourself. A bent bar's
# stock consumption is its DEVELOPED length, which a bounding box
# understates and which the parameter carries. He named the parameter;
# the NAME belongs in the plant layer, not in this repository. A bar
# without it is could-not-judge and is named in the finding.
'member.length',
# The free-text identity field a user writes the lateral marker into.
# Declared here so it is settable and visible; OPTIONAL so that a plant
# which never uses Lateral Extract is not nagged about it by every other
# button. Lateral Extract refuses on its own, with its own sentence,
# when this is unmapped -- see latextract.scan().
#
# SECOND READER, added 2026-08-30: Reinforcing Suggestion reads the
# plant's reviewed-template marker (`reinforce.master_token`) out of the
# same field -- the only provenance that feature has. It stands down the
# same way, with its own sentence in its own report, rather than falling
# back to a parameter name written into the code.
'member.identity_description',
# WHAT THE PLANT SAYS A MEMBER IS. A shared parameter carrying a short
# phrase per element, and `drawings.reinforcing_rule` reads it to decide
# which members are reinforcing. Measured 2026-08-30: it exists on BOTH
# the instance and the type and they are MUTUALLY EXCLUSIVE per element,
# which is why the rule reads instance first and falls back to type --
# reading either alone finds nothing on half the model and the result
# looks clean. The plant's own thirteen view filters key on this same
# parameter, all of them with "contains" rather than "equals".
#
# OPTIONAL: a plant that does not carry it is not nagged on every run;
# the reinforcing rule stands down with its own sentence instead, and the
# enumeration is still there for a plant that prefers to name families.
'member.manufacture_component',
# A BAR'S SIZE, as the printed form the stock tables are keyed by --
# '#3', '#4', '#5'. `ticketchecks._bar_size` reads it (instance, then the
# TYPE) and normalises through `rebar_size`, so `catalogue.rebar.
# stock_by_size` can be used per size instead of one generic number.
#
# Declared here 2026-08-31, when a measurement finally answered which
# parameter carries it (open question A3). The NAME still belongs in the
# plant layer -- this is only the role.
#
# THE COVERAGE IS PARTIAL, and that is why this role is only half the
# answer. Measured over two whole assemblies (112 and 100 members, 82 of
# each): STRAIGHT bars carry the parameter; BENT ones -- and a bar-formed
# lifting loop -- do not carry it at all, on any of their types.
#
# BENT BARS ARE IN SCOPE ANYWAY. Owner, 2026-08-30: "rebars come in 40ft
# stock. unless overwritten by other knowledge, all rebars pieces must be
# less than 40ft" -- all pieces -- and separately that a bar's length is
# read off a parameter, bent bars included. So an unreadable size is a gap
# to answer, never a licence to set those bars aside; the second route is
# `drawings.bar_size_rule.description_fallback` and `bar_size_of()` says
# which of the two answered.
#
# OPTIONAL: a plant that carries no size parameter is not nagged on every
# run; 5.7 stands down per bar with its own sentence instead.
'member.bar_size',
# WHICH FACE OF THE PIECE A PART GOES ON, as the model records it.
#
# A face designation is printed on the ticket as a parenthetical suffix
# (`P76 W/ SR5602 (S.I.F.)`) and it is ALSO a property of the element, and
# this role is the second of those. Checklist 5.3 asks whether the two
# agree; reading the model's own field is one layer closer to the data
# than reading the solid-or-dashed line the sheet draws, which is a
# rendering of the same fact.
#
# The raw value is not the printed token -- measured 2026-08-31, this
# plant's parameter holds `FF` where the ticket prints `F.F.` -- so the
# plant also states `face_designations.model_tokens`, and
# `face_from_model_token()` is the reader. See that function.
#
# OPTIONAL: a plant that records no face on its elements is not nagged
# every run; the check stands down and says which of the two halves it
# was missing.
'member.face_designation',
# WHICH PHYSICAL PIECE THIS IS, as against which design it was built to.
#
# A mark names a design: every panel cast to it carries the same one. A
# control number names ONE piece, and it is what a plant's paperwork, its
# yard and its erection sequence all key on -- so a form that shows an
# engineer a mark alone has told them what the piece is but not which
# piece it is.
#
# OPTIONAL, and separate from `member.mark` on purpose. Some plants keep
# both; some keep only one and map their single identifier to
# `member.mark` (this one maps it to the mapped mark parameter,
# which is the mark AND the control number at once). Where the two would
# print the same string, callers show it once. A plant that records no
# control number gets no parenthetical rather than a fabricated one.
'member.control_number',
)
# Roles a check can do without. Everything else unmapped is worth reporting.
# member.mark is optional because the built-in 'Mark' parameter is the
# general fallback; the plant mapping only exists to name its own.
OPTIONAL_ROLES = ('sheet.plant', 'member.mark', 'panel.elevation_group',
'member.identity_description',
'member.manufacture_component',
'member.bar_size',
'member.face_designation',
'member.control_number')
def param_names(knowledge, role):
"""Candidate parameter names for a role, in priority order.
Accepts either a single name or a list, because pyRevit's YAML reader is
happiest with plain scalars and most shops only have one name per role.
"""
value = get(knowledge, 'parameters.' + role)
if value is None:
return []
if isinstance(value, string_types):
value = [value]
return [str(v).strip() for v in value if str(v).strip()]
def unmapped_roles(knowledge, include_optional=False):
"""Roles with no parameter name behind them.
Mapped in the plant file, since they describe the office template rather
than the job; a project file may still override one.
Roles listed under `parameters.not_applicable` are declared NOT TO
EXIST at this plant (its tickets do not print that value; the value
comes from each design's spec instead). They are separated from
genuinely missing mappings -- 'we know it is absent' and 'we never
thought of it' must look different (Will 08-28: unit_weight is
per-design, never a ticket role here).
"""
not_applicable = set(get(knowledge, 'parameters.not_applicable', [])
or [])
missing = []
for role in PARAMETER_ROLES:
if not include_optional and role in OPTIONAL_ROLES:
continue
if role in not_applicable:
continue # declared absent -> not a miss
if not param_names(knowledge, role):
missing.append(role)
return missing, not_applicable
# ------------------------------------------------------------ family tests
def matches(name, includes, excludes):
up = (name or '').upper()
if not any(tok.upper() in up for tok in (includes or [])):
return False
return not any(tok.upper() in up for tok in (excludes or []))
def is_strand(knowledge, family_name):
return matches(family_name,
get(knowledge, 'families.strand_include', ['STRAND']),
get(knowledge, 'families.strand_exclude', []))
def is_lifter(knowledge, family_name):
return matches(family_name,
get(knowledge, 'families.lifter_include', []),
get(knowledge, 'families.lifter_exclude', []))
def _longest_substring_match(table, family_name):
"""The value whose key is the longest substring of the family name."""
up = (family_name or '').upper()
best, best_len = None, -1
for key, value in (table or {}).items():
ku = str(key).upper()
if ku in up and len(ku) > best_len:
best, best_len = value, len(ku)
return best
def _longest_substring_key(table, family_name):
"""(key, value) of the entry whose key is the longest substring of the
family name; (None, None) when nothing matches."""
up = (family_name or '').upper()
best_key, best, best_len = None, None, -1
for key, value in (table or {}).items():
ku = str(key).upper()
if ku in up and len(ku) > best_len:
best_key, best, best_len = key, value, len(ku)
return best_key, best
def is_lpa_family(family_name):
"""Owner ruling (2026-09-05, verbatim): 'LPA8Txxx 在the plant的厂知识里面
没有shear rating,但是你去ALP的知识(公共知识里面),不就能找到shear
rating了吗?' and 'LPA8T当例子,检查fallback机制为啥没能实现,并且修。'
-- restating the owner's 2026-09-02 ruling that an LPA-prefixed family
is a face lifter regardless of whether the plant's whitelist happens to
spell out that exact part number. This is the ONE fallback rule, shared
by `_is_face_lifter` here and `ticketchecks._is_face_stripping_lifter`,
so the two checks can never quietly diverge on which families count."""
return (family_name or '').upper().startswith('LPA')
def _is_face_lifter(knowledge, family_name):
"""Is this family one the plant groups as a FACE (back-cast) lifter --
`handling.lifter_groups.face.families`, matched as substrings the way
the export groups them.
The plant's whitelist is enumerated by hand and does not keep up with
every LPA part number it casts (LPA8T10 was missing; LPA2T338, LPA2T512,
LPA4T414, LPA4T718, LPA8T634 are the next ones due). When the whitelist
misses, fall back to `is_lpa_family` -- the same LPA-prefix rule
`ticketchecks._is_face_stripping_lifter` already used, per the owner's
ruling above. A family that is not LPA-prefixed and not in the
whitelist is still not a face lifter; this fallback only widens what
counts as LPA, it never narrows the whitelist's own hits."""
up = (family_name or '').upper()
for fam in get(knowledge, 'handling.lifter_groups.face.families', []) or []:
if as_text(fam).strip().upper() and as_text(fam).strip().upper() in up:
return True
return is_lpa_family(family_name)
def catalogue_item(knowledge, family_name):
"""The catalogue designation a modelled family stands for, or None.
A plant models its own part number; the catalogue is written against the
manufacturer's. This is the hop between them.
"""
alias = _longest_substring_match(
get(knowledge, 'lifters.catalogue_alias', {}), family_name)
return str(alias) if alias else None
def concrete_derate(entry, fc_psi):
"""How much of a catalogue rating survives at the concrete you have.
A catalogue rating is derived at one strength -- the QuikLift tables are
at 3500 psi. Below that the concrete governs and the capacity follows
sqrt(f'c). ABOVE it nothing is gained, because the anchor's own mechanical
capacity takes over as the governing failure:
factor = min(1, sqrt(f'c actual / basis))
Releasing at 2500 psi on a 3500 psi table therefore costs
sqrt(2500/3500) = 0.845, and releasing at 6000 buys nothing.
Returns 1.0 when either the strength or the basis is unknown -- an
unstated basis must not silently reduce a rating.
"""
if not isinstance(entry, dict) or not fc_psi:
return 1.0
basis = entry.get('basis_fc_psi')
if not basis or basis <= 0 or fc_psi <= 0:
return 1.0
if fc_psi >= basis:
return 1.0
return (float(fc_psi) / float(basis)) ** 0.5
def part_prefixes(knowledge):
"""{prefix (upper) -> entry} for every declared part-number prefix.
Empty means NOBODY HAS DECLARED THE GRAMMAR. It never means a part number
has no prefix, and a caller that gets {} must stand down rather than
reading the letters itself.
"""
table = get(knowledge, 'part_numbers.prefixes')
if not isinstance(table, dict):
return {}
out = {}
for key, entry in table.items():
if key is None:
continue
prefix = as_text(key).strip().upper()
if not prefix:
continue
out[prefix] = dict(entry) if isinstance(entry, dict) else {}
out[prefix]['prefix'] = as_text(key).strip()
return out
def part_prefix(knowledge, part_number):
"""Which declared prefix a part number carries. (prefix, entry, why).
LONGEST PREFIX WINS, and it is the whole point rather than a tie-break:
`SP78` is an SP, not a P that happens to start with an S. Resolved the
short way round, a job's special plate is filed in the plant's
standard-plate namespace -- which is the same merge a substring matcher
makes on a bare part number, and it has already cost once here on a
lifting check.
(None, None, why) when nothing declares the grammar, when the value is
empty, or when no declared prefix matches. The last is a real answer and
not a failure: a part number in a shape nobody has described is a fact
about the corpus, and inventing a prefix for it would file it on somebody's
desk at random.
"""
table = part_prefixes(knowledge)
text = as_text(part_number).strip() if part_number is not None else ''
if not text:
return None, None, ('no part number was given, so no prefix was '
'read.')
if not table:
return None, None, (
'part_numbers.prefixes is empty: no layer states what a part '
'number\'s prefix means, so {0!r} says nothing about which layer '
'defines it.'.format(text))
up = text.upper()
best = None
for prefix in table:
if up.startswith(prefix) and (best is None or len(prefix) > len(best)):
best = prefix
if best is None:
return None, None, (
'{0!r} starts with none of the declared part-number prefixes '
'({1}), so nothing here says which layer defines it. Nothing was '
'guessed.'.format(text, ', '.join(sorted(table))))
entry = table[best]
return best, entry, (
'{0!r} is a {1!r} part -- {2} -- which is {3} knowledge{4}.'
.format(text, entry.get('prefix') or best,
entry.get('means') or 'meaning unrecorded',
entry.get('layer') or 'of an unrecorded layer',
'' if entry.get('verified')
else ', and that reading is UNVERIFIED: {0}'.format(
entry.get('unverified_because') or 'no reason recorded')))
# ============================================================================
# WHICH KIND OF MEMBER THIS IS, AND WHICH CHECKLIST THAT PLANT ASKS OF IT
# ============================================================================
#
# Owner, 2026-08-31: "按照 plant-member 这种级别,给 ticketqc 的 item 重新编号,
# 让 ticketqc 自动识别 plant-member, 并且做正确的 check。"
#
# Three resolutions, in order, and they are separate functions because they
# fail separately and because two of them already existed:
#
# member kind from the MARK PREFIX (`nomenclature.member_kinds`), read
# through `scope.prefix_of` so the scope and the checklist
# can never disagree about what a mark's prefix is.
# panel type from MEASURED SIGNALS (`classify_panel`), and it refines
# WITHIN a member kind -- an insulated wall panel is a wall
# panel first.
# profile selected on the PAIR. A profile that names a panel type is
# more specific than one that names only a member kind, and
# the more specific one wins.
#
# EVERY STEP MAY ANSWER "I COULD NOT TELL", AND EACH TIME THAT RESOLVES
# TOWARDS ASKING MORE. No member kind -> no profile -> the full table under
# its own numbers. That is not a degraded mode; it is the mode this tool ran
# in before any of this existed, and it is the compatibility contract.
def member_kinds(knowledge):
"""{MARK PREFIX (upper): member kind id} -- this plant's naming.
Empty means NOBODY HAS DECLARED THE MAPPING. It never means every piece
is the same kind, and a caller that gets {} must resolve every piece to
an unknown member kind rather than to a default one.
"""
table = get(knowledge, 'nomenclature.member_kinds')
if not isinstance(table, dict):
return {}
out = {}
for prefix in table:
key = as_text(prefix).strip().upper()
kind = table[prefix]
kind = as_text(kind).strip() if kind is not None else ''
if key and kind:
out[key] = kind
return out
def member_kind_of(knowledge, mark):
"""What kind of member this mark names. A dict, and None is a real answer.
member_kind the id, or None -- NOT TOLD APART, never a default
prefix the leading letters `scope.prefix_of` read, or None
why one sentence
THE PREFIX COMES FROM `scope.prefix_of` AND THE LOOKUP IS EXACT. That is
scope.py's own ruling, argued there at length: the prefix is the WHOLE
leading letter run, so a declared `AAA` does not reach a mark `AAAB001`,
whose prefix is `AAAB`. Matching a declared prefix AS a prefix was
considered there and rejected, and re-deciding it here would give the
scope and the checklist two different opinions about the same mark --
silently, and only on the pieces where it matters.
"""
from vibe import scope as scopelib
table = member_kinds(knowledge)
prefix = scopelib.prefix_of(mark)
out = {'member_kind': None, 'prefix': prefix, 'why': ''}
if not table:
out['why'] = (
'nomenclature.member_kinds is empty: no layer says what kind of '
'member a mark prefix names at this plant, so no piece can be '
'placed as one.')
return out
if prefix is None:
out['why'] = (
'{0!r} has no readable letter prefix, so what kind of member it '
'is could not be read. Nothing was assumed.'
.format(as_text(mark) if mark is not None else None))
return out
kind = table.get(prefix)
if not kind:
out['why'] = (
'the mark prefix {0!r} is not one of the declared member-kind '
'prefixes ({1}), so what kind of member this is, is not written '
'down. Nothing was assumed -- and note the prefix is the WHOLE '
'leading letter run, so a prefix that merely STARTS with a '
'declared one is deliberately not a match (scope.py).'
.format(prefix, ', '.join(sorted(table))))
return out
out['member_kind'] = kind
out['why'] = ('the mark prefix {0!r} says this is a {1!r}.'
.format(prefix, kind))
return out
# ------------------------------------------- the checklist this plant asks
UNLISTED_ASK = 'ask'
UNLISTED_NOT_APPLICABLE = 'not_applicable'
UNLISTED_POLICIES = (UNLISTED_ASK, UNLISTED_NOT_APPLICABLE)
def checklist_profiles(knowledge):
"""{profile id: entry} for every declared checklist profile."""
table = get(knowledge, 'checklists.profiles')
if not isinstance(table, dict):
return {}
out = {}
for key in table:
entry = table[key]
name = as_text(key).strip()
if name:
out[name] = dict(entry) if isinstance(entry, dict) else {}
return out
def _profile_row(row, where, problems):
"""One board line as (display number, master id), or None.
TWO SPELLINGS, AND THE FIRST ONE IS THE SAFE ONE:
- '2.1 = 3.3' <- write this
- {number: '2.1', check: '3.3'} <- accepted, and it has a trap
THE TRAP, MEASURED 2026-08-31 AND IT COST A ROW. `_coerce` gives YAML
scalars their intended types, and it runs on VALUES. A number in a value
position is numified -- so `5.10` becomes the FLOAT 5.1, which is the
same value as `5.1`, and two different master ids collide into one. The
board silently lost a question and gained a duplicate.
It is the leading-zero lesson (`0010001` is a part mark, not a number)
with the zero at the other end, and it is worse: a leading zero at least
changes the text, while `5.10` -> `5.1` lands on an id that really
exists. Quoting does not save it either, for the reason `_coerce`'s own
docstring gives -- coercion happens after parsing.
So the `=` form is the recommended one: `'2.1 = 5.10'` never parses as a
number, whether or not anybody quoted it. And the map form REFUSES a
non-string number rather than accepting a value that may already have
been mangled -- there is no way to tell 5.1 from a flattened 5.10 after
the fact, and guessing would put the wrong question on the board.
"""
if isinstance(row, (int, float)) and not isinstance(row, bool):
problems.append(
'{0} is the number {1!r}. A board line must be text -- a bare '
'number is coerced (5.10 becomes 5.1, which is a DIFFERENT '
'master id), so write it as `\'<number> = <master id>\'`.'
.format(where, row))
return None
if isinstance(row, string_types):
if '=' not in row:
problems.append(
'{0} is {1!r}. A board line is `\'<number> = <master id>\'`.'
.format(where, row))
return None
number, check = row.split('=', 1)
return (as_text(number).strip(), as_text(check).strip())
if not isinstance(row, dict):
problems.append('{0} is {1!r}, which is neither a `\'<number> = '
'<master id>\'` line nor a {{number, check}} map. It '
'was dropped from the board.'.format(where, row))
return None
out = []
for field in ('number', 'check'):
value = row.get(field)
if isinstance(value, (int, float)) and not isinstance(value, bool):
problems.append(
'{0}.{1} arrived as the number {2!r} rather than as text, and '
'a number here cannot be trusted: `5.10` is coerced to 5.1, '
'which is a different master id, and nothing can tell the two '
'apart afterwards. It was REFUSED. Write the line as '
'`\'<number> = <master id>\'`.'.format(where, field, value))
return None
out.append(as_text(value).strip() if value is not None else '')
return (out[0], out[1])
def _profile_items(entry, problems):
"""[(display number, master id)] for one profile, in order.
A malformed row is REPORTED and dropped from the board rather than
guessed at -- and dropping it from the BOARD is safe in a way dropping
it from the RUN would not be, because `unlisted` decides what happens to
a check no board names, and it defaults to asking.
"""
rows = entry.get('items')
if rows is None:
return None
if not isinstance(rows, (list, tuple)):
problems.append('items must be an ordered LIST of {number, check} '
'entries; {0!r} is not one, so this profile names no '
'board at all.'.format(rows))
return None
out, seen_ids, seen_numbers = [], {}, {}
for index, row in enumerate(rows):
where = 'items[{0}]'.format(index)
parsed = _profile_row(row, where, problems)
if parsed is None:
continue
number, check = parsed
if not check:
problems.append('{0} names no `check`, so nothing says which '
'question it puts on the board. It was dropped.'
.format(where))
continue
if not number:
# The display number falling back to the stable id is safe and
# visible; inventing a position in somebody's numbering is not.
number = check
problems.append('{0} names check {1!r} with no `number`, so it '
'is shown under its stable id instead of a '
'profile number.'.format(where, check))
if check in seen_ids:
problems.append('{0} puts check {1!r} on the board twice (already '
'at {2}). The second was dropped -- a question '
'asked twice under two numbers is two answers a '
'reader has to reconcile.'
.format(where, check, seen_ids[check]))
continue
if number in seen_numbers:
problems.append('{0} reuses display number {1!r}, which already '
'belongs to check {2!r}. Both are kept and shown, '
'because dropping one would hide a question, but '
'the numbering is ambiguous and should be fixed.'
.format(where, number, seen_numbers[number]))
seen_ids[check] = number
seen_numbers[number] = check
out.append((number, check))
return out
def profile_for(knowledge, member_kind=None, panel_type=None):
"""The checklist profile for this piece. A dict, always every key.
profile the id, or None -- and None means THE FULL TABLE under
its own numbers, which is the compatibility contract
items [(display number, stable check id)] or None
unlisted 'ask' (default) or 'not_applicable'
title/because/since/declared_by as declared
basis 'member_kind+panel_type' | 'member_kind' | 'none'
why one sentence
problem a sentence when a declaration was found and REFUSED
MOST SPECIFIC WINS. A profile naming both a member kind and a panel type
beats one naming only the member kind -- an insulated wall panel is a
wall panel first, and a plant that has said something particular about
the insulated ones has said the more particular thing.
"""
out = {'profile': None, 'items': None, 'unlisted': UNLISTED_ASK,
'unlisted_because': None, 'unlisted_since': None,
'title': None, 'because': None, 'since': None,
'declared_by': None, 'basis': 'none', 'why': '', 'problem': None,
'member_kind': member_kind, 'panel_type': panel_type}
declared = checklist_profiles(knowledge)
if not declared:
out['why'] = (
'checklists.profiles is empty: no layer declares a checklist for '
'any kind of member, so the full checklist runs under its own '
'numbering -- which is what this tool did before profiles '
'existed.')
return out
if not member_kind:
out['why'] = (
'this piece was not placed as a kind of member, so no profile '
'could be selected and the FULL checklist runs under its own '
'numbering. A piece whose kind cannot be told is asked more, '
'never less.')
return out
wanted = as_text(member_kind).strip().lower()
kind_only, both = [], []
for name in sorted(declared):
entry = declared[name]
kind = entry.get('member_kind')
kind = as_text(kind).strip().lower() if kind is not None else ''
if kind != wanted:
continue
want_type = entry.get('panel_type')
want_type = (as_text(want_type).strip()
if want_type is not None else '')
if not want_type:
kind_only.append(name)
elif (panel_type and
as_text(panel_type).strip().lower() == want_type.lower()):
both.append(name)
problems = []
chosen, basis = None, 'none'
if len(both) == 1:
chosen, basis = both[0], 'member_kind+panel_type'
elif len(both) > 1:
problems.append(
'more than one profile ({0}) claims member kind {1!r} AND panel '
'type {2!r}, and a piece cannot have two checklists. NONE was '
'used and the full checklist ran.'
.format(', '.join(both), member_kind, panel_type))
elif len(kind_only) == 1:
chosen, basis = kind_only[0], 'member_kind'
elif len(kind_only) > 1:
problems.append(
'more than one profile ({0}) claims member kind {1!r}, and a '
'piece cannot have two checklists. NONE was used and the full '
'checklist ran.'.format(', '.join(kind_only), member_kind))
if chosen is None:
out['problem'] = ' '.join(problems) or None
out['why'] = out['problem'] or (
'no declared profile claims member kind {0!r} (declared: {1}), so '
'the full checklist runs under its own numbering.'
.format(member_kind, ', '.join(sorted(declared)) or 'none'))
return out
entry = declared[chosen]
items = _profile_items(entry, problems)
policy = entry.get('unlisted')
policy = as_text(policy).strip().lower() if policy is not None else ''
if not policy:
policy = UNLISTED_ASK
elif policy not in UNLISTED_POLICIES:
problems.append(
'unlisted is {0!r}, which is not one of {1}. It was read as {2!r} '
'-- the safe reading, because the other one stops asking '
'questions nobody has decided about.'
.format(policy, ', '.join(UNLISTED_POLICIES), UNLISTED_ASK))
policy = UNLISTED_ASK
if policy == UNLISTED_NOT_APPLICABLE:
reason = _said(entry, 'unlisted_because')
since = _said(entry, 'unlisted_since')
if not reason or not since or not _DATE_RE.match(since):
problems.append(
'unlisted is {0!r}, which is a standing promise about checks '
'nobody has written yet, and it carries no reason and/or no '
'YYYY-MM-DD date. It was REFUSED and read as {1!r}: a check '
'this board does not name is ASKED.'
.format(UNLISTED_NOT_APPLICABLE, UNLISTED_ASK))
policy = UNLISTED_ASK
out['profile'] = chosen
out['items'] = items
out['unlisted'] = policy
out['unlisted_because'] = _said(entry, 'unlisted_because')
out['unlisted_since'] = _said(entry, 'unlisted_since')
out['title'] = _said(entry, 'title')
out['because'] = _said(entry, 'because')
out['since'] = _said(entry, 'since')
out['declared_by'] = _declarer(entry)
out['basis'] = basis
out['problem'] = ' '.join(problems) or None
out['why'] = (
'this piece is a {0!r}{1}, so the {2!r} checklist was used ({3} '
'item(s) on its board; a check it does not name is {4}).'
.format(member_kind,
' and a {0!r}'.format(panel_type)
if basis == 'member_kind+panel_type' else '',
chosen, len(items) if items else 0,
'asked anyway' if policy == UNLISTED_ASK
else 'not applicable'))
return out
# ============================================================================
# HOW A HUMAN NAMES A CHECK, NOW THAT PLANTS RENUMBER THEM
# ============================================================================
#
# Owner, 2026-08-31: "就是得重新编号。以后引用需要前面加 plant-member。"
#
# THE GRAMMAR, and there are exactly two forms:
#
# 3.3 a BARE number is a MASTER ID -- an entry in
# `checklist.master` in the public catalogue.
# Every ruling written before 2026-08-31 is in
# this form and every one of them still means
# exactly what it meant.
#
# sample_plant-wall_panel 3.3 QUALIFIED: <plant>-<member kind> <number>,
# where the number is a position on THAT plant's
# board for THAT kind of member. It resolves to a
# master id through `checklists.profiles`.
#
# HOW IT IS SPLIT, and the two rules are chosen against real ids rather than
# for tidiness:
#
# * the QUALIFIER and the NUMBER are separated by whitespace, because a
# display number contains dots and a member kind contains underscores,
# and a space is the one character neither of them has.
# * the qualifier splits on its LAST hyphen, so a plant whose own id
# carries one (`acme-precast-wall_panel`) still resolves: member kinds
# are single tokens by convention and plants are not.
#
# WHY A QUALIFIED REFERENCE IS RESOLVED AT READ TIME AND STORED AS A MASTER
# ID. This is the whole reason the grammar exists rather than the numbers
# simply changing. A mute written as `sample_plant-wall_panel 3.3` becomes master
# id `3.3` the moment it is read, so when the plant renumbers its board
# tomorrow the mute follows the QUESTION and not the POSITION. A ruling can
# no longer be orphaned by a renumbering, which is the property the whole
# design was built to have -- his three mutes survived one renumbering today
# only because nobody renumbered.
#
# ---- AND A RULE FOR WHOEVER WRITES THE NEXT EXAMPLE -------------------
#
# THE QUALIFIED FORM CONTAINS A PLANT NAME BY CONSTRUCTION, which puts this
# grammar in direct tension with the rule that no client, job or plant name
# appears in a tracked file (PROJECT.md 3.4). The resolution, and it is not
# a workaround:
#
# * a REAL qualified reference is a real plant's business, and it lives
# where that is allowed -- the gitignored plant and job layers, and QNA.
# * every EXAMPLE in tracked code, comments or docs uses a placeholder
# plant (`sample_plant-wall_panel 3.3`).
#
# This was learned the direct way on the day the grammar landed: the first
# examples were written with the real plant's name and took the scrub gate
# from 2 hits to 7 across three tracked files. The tension is permanent --
# every future example writer meets it -- so it is written here, beside the
# grammar, rather than left to the gate to catch each time.
#
# AND A QUALIFIER THAT NAMES ANOTHER PLANT IS REFUSED, not ignored and not
# applied. A mute someone copied from another plant's job file is a
# statement about a board this run is not using, and silently applying it
# would quieten a question nobody here decided about.
def _split_reference(text):
"""('<plant or None>', '<member kind or None>', '<number>') or None."""
parts = as_text(text).strip().split()
if len(parts) == 1:
return (None, None, parts[0])
if len(parts) != 2:
return None
qualifier, number = parts
if '-' in qualifier:
plant, member = qualifier.rsplit('-', 1)
return (plant.strip() or None, member.strip() or None, number)
return (None, qualifier.strip() or None, number)
def resolve_check_reference(knowledge, reference, plant=None, ids=None):
"""A written reference to a check -> its MASTER ID. (id, why, problem).
`ids` is the master universe (`ticketpage.CHECKLIST_IDS`), injected so
this module never imports the page. `plant` is the plant this run is
actually using, so a qualifier naming a different one can be refused.
(None, why, problem) is a real answer: a reference nobody can resolve is
reported, never dropped. A mute that quietly resolved to nothing is a
row the owner believes is quiet and is not -- or worse, a row he
believes is being asked and is not.
"""
ids = tuple(ids or ())
text = as_text(reference).strip() if reference is not None else ''
if not text:
return None, 'no reference was given.', 'an empty check reference.'
parts = _split_reference(text)
if parts is None:
return None, '', (
'{0!r} is not a check reference. Write a master id (`3.3`) or a '
'qualified one (`<plant>-<member kind> <number>`).'.format(text))
ref_plant, ref_member, number = parts
if ref_member is None:
if not ids or number in ids:
return number, (
'{0!r} is a master id.'.format(number)), None
return None, '', (
'{0!r} is not a master checklist id, and it carries no '
'<plant>-<member kind> qualifier, so nothing can place it. The '
'master ids are {1}.'.format(number, ', '.join(ids)))
if ref_plant and plant and \
as_text(ref_plant).strip().lower() != as_text(plant).strip().lower():
return None, '', (
'{0!r} is qualified for plant {1!r} and this run is using plant '
'{2!r}. It was REFUSED rather than applied: a reference to '
'another plant\'s board says nothing about this one.'
.format(text, ref_plant, plant))
profile = profile_for(knowledge, member_kind=ref_member)
items = profile.get('items')
if not profile.get('profile') or items is None:
return None, '', (
'{0!r} names the {1!r} checklist and {2} so the number could not '
'be placed. Write the master id instead, or declare the profile.'
.format(text, ref_member,
(profile.get('why') or 'no such profile is declared,')))
for (display, check) in items:
if as_text(display).strip() == number:
return check, (
'{0!r} is number {1} on the {2!r} board, which is master id '
'{3}.'.format(text, number, profile.get('profile'), check)), None
return None, '', (
'{0!r} names number {1} on the {2!r} board, and that board has no '
'such number (it has {3}). Nothing was matched to it.'
.format(text, number, profile.get('profile'),
', '.join([d for (d, _c) in items]) or 'none'))
def qualified_reference(plant, member_kind, number):
"""How this tool WRITES a reference, so every surface spells it one way.
`sample_plant-wall_panel 3.3`. Falls back to the bare number where there is no
plant or no member kind -- which is exactly the case where a bare number
IS the whole truth, because no board renumbered it.
"""
number = as_text(number).strip()
plant = as_text(plant).strip() if plant else ''
member = as_text(member_kind).strip() if member_kind else ''
if not member:
return number
if not plant:
return '{0} {1}'.format(member, number)
return '{0}-{1} {2}'.format(plant, member, number)
# ---------------------------------- a bar that ships inside another part
#
# THE FACT EXISTED AND ONE CHECK COULD NOT SEE IT (2026-08-31). The owner:
# "SR4996 是 SP29/30 的附属 rebar,我记得我增加过这条知识了,为什么还在 WP117
# 报 warn?" He had recorded it, three times over -- in
# `interference.exempt_families` with his dated comment, in
# `interference.component_groups`, and in `drawings.
# annotation_covered_by_parent`, which the plant file's own
# `rebar_must_appear_on` block states the rule for, nearly verbatim:
#
# "a bar that is PART OF another item on the BOM is covered by that item's
# callout and is never drawn on its own... Looking for SR4996 as an
# independent bar and failing the ticket when it is not found would be the
# checker misreading the BOM, not the ticket being wrong."
#
# 5.2 read the key. The takeoff read the key. **5.6 did not**, so its
# "modelled, never printed" leg reported a bar that is by design never drawn
# on its own. That is the drift detector's oldest category -- LANDED
# KNOWLEDGE WITH NO READER -- live on a check, and it is why this accessor
# exists rather than a third inline `get()`: three readers spelling one fact
# three ways is how the fourth one comes to disagree.
def attached_parents(knowledge):
"""{BAR MARK (upper): [parent part, ...]} -- bars that ship inside a part.
`drawings.annotation_covered_by_parent`, normalised. A MAP, so the layers
deep-merge and a job adds its own SP-series bars without deleting the
plant's hardware ones -- which is the opposite of how
`interference.exempt_families` behaves beside it, and the reason that list
carries a restated block and this map does not.
A single parent may be written as a scalar; it comes back as a one-item
list, so no caller has to test the shape.
"""
source = get(knowledge, 'drawings.annotation_covered_by_parent', {})
if not isinstance(source, dict):
return {}
out = {}
for child in source:
parents = source[child]
if isinstance(parents, (list, tuple)):
named = [as_text(p).strip() for p in parents
if as_text(p).strip()]
elif parents is None:
named = []
else:
named = [as_text(parents).strip()] if as_text(
parents).strip() else []
key = as_text(child).strip().upper()
if key:
out[key] = named
return out
# ------------------------- is a printed token a bar mark or a piece of steel
#
# 5.6 compares the marks printed on a ticket against the bars in the model,
# and until 2026-08-31 it took EVERY mark-shaped token on the sheet for a
# candidate bar mark. Measured on WP115: `P78`, `P114`, `SP29`, `SP30` and
# `SP31` were all reported as "printed bar marks matching no bar in the
# model". They are hardware. They match no bar because they are not bars.
#
# The grammar to tell them apart landed the day before -- `part_numbers.
# prefixes`, longest-prefix-wins -- but it records what a prefix MEANS as
# PROSE ("standard plate", "reinforcing mark"), and prose is not a predicate.
# Parsing `means` to decide would be the same mistake as classifying a panel
# off `model_signals`. So the population is its own declared field, `kind`.
#
# THE TWO ANSWERS ARE NOT SYMMETRIC, and the whole design is in the
# difference:
#
# kind: bar_mark ADDS a token to the comparison. Wrong, it produces a
# visible finding about a token that is not a bar.
# kind: hardware REMOVES a token from the comparison. Wrong, a real
# printed bar mark stops being compared and the row goes
# quiet -- a silent pass on the exact defect 5.6 exists
# to find.
#
# So `kind: hardware` is honoured ONLY on a prefix entry that is
# `verified: true`. An unverified hardware claim is REFUSED and the token
# stays unclassified, which is a finding. A reading is not a ruling, and a
# reading may not switch a comparison off.
#
# AND WHEN NOBODY HAS DECLARED THE GRAMMAR AT ALL, this stands down to
# 'ungoverned' and the caller treats every token as a candidate -- which is
# what 5.6 did before any of this existed. Standing down here has to mean
# "do not narrow", because the other reading would empty the comparison for
# every plant that has not written a grammar yet.
PART_KINDS = ('bar_mark', 'hardware')
def printed_token_population(knowledge, token):
"""Which population a printed token belongs to. (population, why).
'bar_mark' compare it against the modelled bar marks
'hardware' it is a part, not a bar -- not a candidate
'unknown' the grammar cannot place it. A finding, never a drop.
'ungoverned' no layer declares the grammar at all, so nothing was
narrowed and the token stays a candidate.
"""
table = part_prefixes(knowledge)
text = as_text(token).strip() if token is not None else ''
if not table:
return 'ungoverned', (
'part_numbers.prefixes is empty: no layer states what a part '
'number looks like at this plant, so nothing was set aside and '
'every printed token is compared as a bar mark.')
if not text:
return 'unknown', 'no token was given.'
prefix, entry, why = part_prefix(knowledge, text)
if prefix is None:
return 'unknown', why
kind = entry.get('kind')
kind = as_text(kind).strip().lower() if kind is not None else ''
if not kind:
return 'unknown', (
'{0!r} is a {1!r} part number, and part_numbers.prefixes[{1}] '
'declares no `kind`, so nothing says whether a {1} is a bar mark '
'or a piece of hardware. It was NOT set aside.'
.format(text, prefix))
if kind not in PART_KINDS:
return 'unknown', (
'{0!r} is a {1!r} part number whose declared kind is {2!r}, which '
'is not one of {3}. It was NOT set aside.'
.format(text, prefix, kind, ', '.join(PART_KINDS)))
if kind == 'hardware' and not entry.get('verified'):
return 'unknown', (
'{0!r} is a {1!r} part number declared `kind: hardware`, but that '
'prefix is `verified: false` -- {2} Setting a token aside REMOVES '
'it from the comparison, so an unverified reading may not do it: '
'the entry was REFUSED and the token was NOT set aside.'
.format(text, prefix,
entry.get('unverified_because') or
'no reason is recorded for why the reading is '
'unverified.'))
return kind, why
def part_defining_layer(knowledge, part_number):
"""Which knowledge layer should define this part. (layer, why).
'plant', 'job', or None. **None is not "no layer"** -- it is "nothing says
which", and a caller must report it as an unclassifiable part number
rather than picking a layer to blame.
WHAT A CHECK WOULD CALL. For a part printed on a ticket that nothing can
resolve: ask this, and route the finding by the answer. A `plant` part
that cannot be looked up is a gap in the plant's standard-material
knowledge -- one message, one owner. A `job` part that cannot be looked up
is a gap in the job file -- a different message and a different owner.
Today a check reports both as "unknown part", which is right half the
time. Nothing here decides a checklist row; it decides whose question it
is.
"""
prefix, entry, why = part_prefix(knowledge, part_number)
if entry is None:
return None, why
layer = entry.get('layer')
if layer is None or not as_text(layer).strip():
return None, ('{0!r} is a {1!r} part and no layer is recorded for '
'that prefix, so whose knowledge should define it is '
'not written down.'.format(part_number, prefix))
return as_text(layer).strip(), why
def panel_types(knowledge):
"""The panel types a layer declares, as {id: description}.
Empty means NOBODY HAS DECLARED ANY -- never "this plant builds one kind
of panel". A caller that gets {} must treat every piece as unclassified
and say so.
"""
table = get(knowledge, 'panel_types.types')
return dict(table) if isinstance(table, dict) else {}
def panel_type_of(knowledge, stated=None):
"""(panel type id, sentence). None is a real answer and stays one.
`stated` is a type a caller has already established by some other means --
read off the piece, or handed down from a spec. THERE IS NO DETECTION
HERE, deliberately: how a panel type is recognised is plant knowledge and
a model read, and neither belongs in this function. What belongs here is
the refusal to invent one.
`panel_types.default` is consulted and ships **null**. A default is the
tempting fix and it is the one that caused this: reading an unclassified
piece as the ordinary panel type is exactly how an insulated panel came to
be checked for a face lifter it does not carry, on every lifting check at
once, all of them green.
AND A DEFAULT IS REFUSED WHILE `panel_types.detect_verified` IS FALSE.
The two keys only make sense together: a default is "what to assume for
the pieces classification could not place", and while NOTHING can classify
anything it is not a default, it is a blanket assertion that every piece
in the job is one type. That is the failure verbatim, so the default is
reported as unusable rather than applied. Setting `detect_verified: true`
is the deliberate act that turns it on.
"""
declared = panel_types(knowledge)
if stated is not None and as_text(stated).strip():
got = as_text(stated).strip()
for key in declared:
if as_text(key).strip().lower() == got.lower():
return key, ('this piece is stated to be {0!r}.'.format(key))
return None, ('this piece is stated to be {0!r}, which is not one of '
'the declared panel types ({1}). Nothing was matched to '
'it.'.format(got, ', '.join(sorted(declared)) or 'none'))
if not declared:
return None, ('panel_types.types is empty: no layer declares any '
'panel type, so no piece can be classified as one.')
default = get(knowledge, 'panel_types.default')
if default is not None and as_text(default).strip():
if get(knowledge, 'panel_types.detect_verified'):
return as_text(default).strip(), (
'nothing classified this piece, so panel_types.default '
'({0!r}) was used.'.format(default))
return None, (
'panel_types.default is {0!r} but panel_types.detect_verified is '
'false -- nothing can classify a piece at all, so that value '
'would not be a fallback for the unclassified ones, it would make '
'EVERY piece {0!r}. It was not used. {1}'
.format(default,
get(knowledge, 'panel_types.detect_unverified_because')
or 'No reason is recorded for why detection is '
'unverified.'))
return None, (get(knowledge, 'panel_types.detect_unverified_because')
or 'nothing classified this piece and panel_types.default '
'is null, so it is UNCLASSIFIED -- which is not the same '
'as being the ordinary type.')
def families_for(knowledge, base_path, panel_type=None):
"""The families in a role, narrowed to a panel type where one is known.
Returns (families, basis, why).
basis 'panel_type' the piece was classified and this type's list
was used. The real answer.
basis 'union' no classification, so the union of the base
list and EVERY declared type's list is used.
basis 'base' nothing declares a per-type breakdown; the base
list is the whole answer.
`base_path` is the role's family list, e.g.
`handling.lifter_groups.face.families`; the breakdown is read from
`<base_path>_by_panel_type`, a MAP, so a third panel type is one added key
and a job may add one without restating the plant's.
WHY THE UNION AND NOT NOTHING. This resolves "which families play this
role", and on a lifting check the two errors are not symmetric: a family
left OUT means the check finds no lifters and passes in silence, on a
safety check; a family wrongly IN means a visible finding about a real
insert. So an unclassified piece gets everything, and `basis` says so, so
a report can name it rather than a reader having to infer it.
AND WHY A CALLER MUST STILL LOOK AT `basis`. The union is safe for
RECOGNISING a part and is NOT safe for COUNTING one -- a parity taken over
the union of two panel types' lifters is a wrong count with nothing to
show for it. A counting caller must refuse on anything but 'panel_type'.
"""
base = get(knowledge, base_path)
base = [as_text(f).strip() for f in base
if as_text(f).strip()] if isinstance(base, list) else []
table = get(knowledge, base_path + '_by_panel_type')
if not isinstance(table, dict) or not table:
return base, 'base', (
'no {0}_by_panel_type is declared, so {1} is the whole answer and '
'the panel type does not change it.'
.format(base_path, ', '.join(base) or '(nothing)'))
def _listed(key):
value = table.get(key)
return [as_text(f).strip() for f in value
if as_text(f).strip()] if isinstance(value, list) else []
if panel_type is not None and as_text(panel_type).strip():
wanted = as_text(panel_type).strip()
for key in table:
if as_text(key).strip().lower() == wanted.lower():
got = _listed(key)
if got:
return got, 'panel_type', (
'this piece is {0!r}, so {1} is what plays this role '
'on it.'.format(key, ', '.join(got)))
return base, 'base', (
'this piece is {0!r} and {1}_by_panel_type lists nothing '
'for it, so the base list {2} stands.'
.format(key, base_path, ', '.join(base) or '(nothing)'))
return base, 'base', (
'this piece is {0!r}, which {1}_by_panel_type does not cover (it '
'covers {2}), so the base list {3} stands and nothing was '
'narrowed.'.format(wanted, base_path, ', '.join(sorted(table)),
', '.join(base) or '(nothing)'))
union = list(base)
for key in sorted(table):
for family in _listed(key):
if family not in union:
union.append(family)
return union, 'union', (
'this piece was not classified into a panel type, so every family '
'that plays this role on ANY declared type is treated as playing it '
'here: {0}. That is the safe direction for recognising a part and is '
'NOT safe for counting one -- a caller that counts must refuse this '
'basis.'.format(', '.join(union) or '(nothing)'))
# ============================================================================
# CLASSIFYING A PIECE, AND WHAT A PLANT MAY SWITCH OFF BECAUSE OF IT
# ============================================================================
#
# Two halves of one commission (owner, 2026-08-31): which Ticket QC checks
# apply is itself plant knowledge, conditioned on what the piece is. The
# first half recognises the piece; the second reads what the plant declared
# about that kind of piece. They are separate functions because they fail
# separately -- a classifier that cannot tell must not be able to switch
# anything off, and a declaration nobody can parse must not be able to hide
# behind a classification that worked.
#
# BOTH ARE REVIT-FREE AND TAKE PLAIN DATA. `classify_panel` takes a facts
# dict any caller can build -- Ticket QC builds it in `ticketqc.read_ticket`,
# and `liftexport` (which will need "sum both wythes on an insulated body")
# can build the same dict from what it already measures. `panel_facts()` is
# the constructor, so the key names cannot drift between two callers.
FACT_KEYS = ('body_family', 'thickness_in', 'member_families')
# The signals this code knows how to evaluate. A signal name a layer invents
# is NOT ignored: it is reported as unknown, because a plant that believes it
# declared a fourth signal and got silence has been handed a classification
# built on fewer facts than it thinks.
SIGNAL_KEYS = ('body_family_contains', 'thickness_in',
'member_family_contains')
DEFAULT_MIN_SIGNALS = 2
def panel_facts(body_family=None, thickness_in=None, member_families=None):
"""The measured facts a classification is allowed to use, as a dict.
Every value may be None, and None means NOT MEASURED -- never "no". A
caller that has not read a fact leaves it out; the signal that needs it
then reports itself unevaluable, and an unevaluable signal never counts
towards recognising a type.
`thickness_in` is INCHES. The ticket carries Revit's internal feet, so
the caller converts -- the unit is in the key name because a 16 that
meant feet would classify every panel in the job as nothing at all, in
silence.
"""
return {
'body_family': None if body_family is None else as_text(body_family),
'thickness_in': _number(thickness_in),
'member_families': [as_text(f) for f in (member_families or [])
if as_text(f).strip()],
}
def _signal_body_family(want, facts):
got = facts.get('body_family')
if got is None or not as_text(got).strip():
return None, 'the body family name was not read on this piece'
tokens = [as_text(w).strip() for w in _as_list(want)
if as_text(w).strip()]
if not tokens:
return None, 'no token is declared for this signal'
up = as_text(got).upper()
hit = [tk for tk in tokens if tk.upper() in up]
if hit:
return True, ('the body family name contains {0}'
.format(', '.join(hit)))
return False, ('the body family name contains none of {0}'
.format(', '.join(tokens)))
def _signal_thickness(want, facts):
got = facts.get('thickness_in')
if got is None:
return None, 'the piece thickness was not read'
if not isinstance(want, dict):
return None, ('thickness_in must be a map with min and/or max, in '
'inches; {0!r} is not one'.format(want))
low, high = _number(want.get('min')), _number(want.get('max'))
if low is None and high is None:
return None, 'thickness_in declares neither a min nor a max'
if low is not None and got < low:
return False, ('{0} in is under the {1} in this type needs'
.format(_trim(got), _trim(low)))
if high is not None and got > high:
return False, ('{0} in is over the {1} in this type allows'
.format(_trim(got), _trim(high)))
return True, ('{0} in is inside the band this type needs'
.format(_trim(got)))
def _signal_member_family(want, facts):
families = facts.get('member_families')
if families is None:
return None, 'the members of this piece were not read'
tokens = [as_text(w).strip() for w in _as_list(want)
if as_text(w).strip()]
if not tokens:
return None, 'no token is declared for this signal'
ups = [as_text(f).upper() for f in families]
hit = [tk for tk in tokens if any(tk.upper() in u for u in ups)]
if hit:
return True, 'a member family contains {0}'.format(', '.join(hit))
if not families:
return None, ('no member of this piece had a readable family name, '
'so whether it carries one of {0} is unknown'
.format(', '.join(tokens)))
return False, ('no member family contains any of {0}'
.format(', '.join(tokens)))
_SIGNAL_TESTS = {
'body_family_contains': _signal_body_family,
'thickness_in': _signal_thickness,
'member_family_contains': _signal_member_family,
}
def _as_list(value):
if isinstance(value, (list, tuple)):
return list(value)
return [] if value is None else [value]
def _trim(value):
text = '{0:.4f}'.format(float(value)).rstrip('0').rstrip('.')
return text or '0'
def classify_panel(knowledge, facts):
"""What kind of piece this is, as a dict. `None` is a real answer.
Returns, always with every key present:
panel_type the id, or None -- and None means NOT TOLD APART, which
is not the same as being the ordinary type
basis 'signals' | 'undeclared' | 'no_signals' | 'no_match' |
'ambiguous'
why one sentence, naming what every signal said
verified whether `panel_types.detect_verified` is set -- see
below, and note that a classification is REPORTED
whether or not it is verified and is USED to switch a
check off only when it is
signals {type id: {signal: {'agrees': True/False/None,
'why': <sentence>}}}
candidates every type whose signals were satisfied
WHY IT REPORTS WHILE UNVERIFIED. `detect_verified` says a reader exists
AND was checked against the model. The reading itself is cheap and its
answer is evidence -- printing it beside the piece is how the flag ever
gets turned on. What the flag gates is CONSEQUENCE: while it is false no
declaration conditioned on a panel type may switch anything off. See
`check_not_applicable`.
THE ASYMMETRY THIS RESTS ON. Running a check that turns out not to apply
is noise. Skipping a check that did apply is a silent pass. So every
doubt resolves towards classifying nothing and running everything.
"""
facts = facts or {}
declared = panel_types(knowledge)
verified = bool(get(knowledge, 'panel_types.detect_verified'))
table_min = _number(get(knowledge, 'panel_types.min_signals'))
if table_min is None:
table_min = DEFAULT_MIN_SIGNALS
out = {'panel_type': None, 'basis': 'undeclared', 'why': '',
'verified': verified, 'signals': {}, 'candidates': [],
'facts': dict(facts)}
if not declared:
out['why'] = ('panel_types.types is empty: no layer declares any '
'panel type, so no piece can be classified as one.')
return out
stories, candidates, any_signals = [], [], False
for type_id in sorted(declared):
rule = declared.get(type_id)
rule = rule if isinstance(rule, dict) else {}
signals = rule.get('signals')
signals = signals if isinstance(signals, dict) else {}
need = _number(rule.get('min_signals'))
need = int(table_min if need is None else need)
verdicts = {}
agreed, against, unread, unknown = [], [], [], []
for name in sorted(signals):
test = _SIGNAL_TESTS.get(name)
if test is None:
unknown.append(name)
verdicts[name] = {'agrees': None, 'why': (
'this code does not know a signal called {0!r} -- it '
'knows {1} -- so nothing evaluated it'
.format(name, ', '.join(SIGNAL_KEYS)))}
continue
agrees, why = test(signals.get(name), facts)
verdicts[name] = {'agrees': agrees, 'why': why}
(agreed if agrees is True else
against if agrees is False else unread).append(name)
out['signals'][type_id] = verdicts
if signals:
any_signals = True
if not signals:
stories.append('{0} declares no signals, so nothing can recognise '
'a piece as one (and NOT being another type is not '
'evidence of being this one)'.format(type_id))
continue
if against:
stories.append('{0}: {1} disagree(s) ({2})'.format(
type_id, ', '.join(against),
'; '.join([verdicts[n]['why'] for n in against])))
continue
if len(agreed) < need:
stories.append(
'{0}: only {1} of the {2} signals it needs could be '
'evaluated{3}'.format(
type_id, len(agreed), need,
' (' + ', '.join(
[n + ': ' + verdicts[n]['why']
for n in unread + unknown]) + ')'
if (unread or unknown) else ''))
continue
candidates.append(type_id)
stories.append('{0}: {1} signal(s) agree ({2})'.format(
type_id, len(agreed),
'; '.join([verdicts[n]['why'] for n in agreed])))
out['candidates'] = candidates
if len(candidates) == 1:
out['panel_type'] = candidates[0]
out['basis'] = 'signals'
out['why'] = ('this piece is {0}. {1}.'
.format(candidates[0], '. '.join(stories)))
return out
if len(candidates) > 1:
out['basis'] = 'ambiguous'
out['why'] = (
'THE DECLARED SIGNALS CONTRADICT EACH OTHER on this piece: {0} '
'are all satisfied at once, and a piece cannot be two types. '
'Nothing was classified and the FULL checklist runs -- read this '
'as a defect in the signals, not a verdict on the piece. {1}.'
.format(', '.join(candidates), '. '.join(stories)))
return out
out['basis'] = 'no_match' if any_signals else 'no_signals'
out['why'] = (
'this piece was not told apart from any declared panel type, so it '
'is UNCLASSIFIED -- which is NOT the same as being the ordinary '
'type, and every check runs on it. {0}.'.format('. '.join(stories)))
return out
# ------------------------------------- what a plant declares does not apply
NOT_APPLICABLE_ANY = 'any'
_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
def not_applicable_declarations(knowledge):
"""`checks.not_applicable` normalised: {row: {condition: entry}}.
Nothing is dropped and nothing is repaired. An entry this code cannot
use still comes back, so `check_not_applicable` can REPORT it rather
than un-declaring it in silence -- the same ruling as `muted_checks`
keeping a mute whose reason is empty.
"""
raw = get(knowledge, 'checks.not_applicable', {})
if not isinstance(raw, dict):
return {}
out = {}
for item in raw:
branch = raw.get(item)
if not isinstance(branch, dict):
out[as_text(item).strip()] = {}
continue
rows = {}
for condition in branch:
entry = branch.get(condition)
rows[as_text(condition).strip()] = (
dict(entry) if isinstance(entry, dict) else entry)
out[as_text(item).strip()] = rows
return out
def _declaration_problem(item, condition, entry):
"""Why this entry may not be acted on, or None. Refusals only."""
where = 'checks.not_applicable[{0}][{1}]'.format(item, condition)
if not isinstance(entry, dict):
return ('{0} is {1!r}, not a map. It declares nothing and was '
'IGNORED -- the row ran.'.format(where, entry))
if 'applies' not in entry:
return ('{0} does not say `applies`. A declaration that does not say '
'whether the row applies switches nothing off, so the row '
'ran.'.format(where))
applies = entry.get('applies')
if applies not in (True, False):
return ('{0}.applies is {1!r}, and only true or false mean anything. '
'It was IGNORED -- the row ran.'.format(where, applies))
stem = 'applicable' if applies else 'not_applicable'
reason = entry.get(stem + '_because')
reason = '' if reason is None else as_text(reason).strip()
since = entry.get(stem + '_since')
since = '' if since is None else as_text(since).strip()
if not reason:
return ('{0} sets applies: {1} with no `{2}_because`. A reason is '
'REQUIRED -- a safety check switched off (or back on) for a '
'reason nobody wrote down cannot be told apart from a bug. '
'It was IGNORED{3}.'
.format(where, str(applies).lower(), stem,
' -- the row ran' if not applies else ''))
if not since or not _DATE_RE.match(since):
return ('{0} sets applies: {1} with `{2}_since` = {3!r}. A date in '
'YYYY-MM-DD is REQUIRED, so a reader can tell a ruling made '
'against today\'s drawings from one made against a job that '
'closed. It was IGNORED{4}.'
.format(where, str(applies).lower(), stem, since or None,
' -- the row ran' if not applies else ''))
return None
def only_applies_to_declarations(knowledge):
"""`checks.only_applies_to` normalised: {row: {panel type: entry}}."""
raw = get(knowledge, 'checks.only_applies_to', {})
if not isinstance(raw, dict):
return {}
out = {}
for item in raw:
branch = raw.get(item)
if not isinstance(branch, dict):
out[as_text(item).strip()] = {}
continue
rows = {}
for condition in branch:
entry = branch.get(condition)
rows[as_text(condition).strip()] = (
dict(entry) if isinstance(entry, dict) else entry)
out[as_text(item).strip()] = rows
return out
def _scope_problem(item, condition, entry):
"""Why a `only_applies_to` entry may not be acted on, or None."""
where = 'checks.only_applies_to[{0}][{1}]'.format(item, condition)
if condition == NOT_APPLICABLE_ANY:
return ('{0} scopes the row to `any`, which is not a scope -- a row '
'that applies to every kind of piece needs no entry here. It '
'was IGNORED.'.format(where))
if not isinstance(entry, dict):
return ('{0} is {1!r}, not a map. It declares nothing and was '
'IGNORED.'.format(where, entry))
if 'scoped' not in entry:
return ('{0} does not say `scoped`. A declaration that does not say '
'whether the row is scoped to this type scopes nothing, so '
'the row was left applying to every piece.'.format(where))
scoped = entry.get('scoped')
if scoped not in (True, False):
return ('{0}.scoped is {1!r}, and only true or false mean anything. '
'It was IGNORED.'.format(where, scoped))
stem = 'scoped' if scoped else 'unscoped'
reason = entry.get(stem + '_because')
reason = '' if reason is None else as_text(reason).strip()
since = entry.get(stem + '_since')
since = '' if since is None else as_text(since).strip()
if not reason:
return ('{0} sets scoped: {1} with no `{2}_because`. A reason is '
'REQUIRED: a row narrowed to one kind of piece is a row that '
'stops being asked on every other kind, and a narrowing '
'nobody justified cannot be told apart from a bug. It was '
'IGNORED.'.format(where, str(scoped).lower(), stem))
if not since or not _DATE_RE.match(since):
return ('{0} sets scoped: {1} with `{2}_since` = {3!r}. A date in '
'YYYY-MM-DD is REQUIRED. It was IGNORED.'
.format(where, str(scoped).lower(), stem, since or None))
return None
def _scope_of(knowledge, item):
"""([type ids the row is scoped to], {type: entry}, [problems])."""
branch = only_applies_to_declarations(knowledge).get(item) or {}
types, entries, problems = [], {}, []
for condition in sorted(branch):
entry = branch[condition]
problem = _scope_problem(item, condition, entry)
if problem:
problems.append(problem)
continue
if entry.get('scoped') is True:
types.append(condition)
entries[condition] = entry
return types, entries, problems
def check_not_applicable(knowledge, item, panel_type=None, verified=False):
"""Is this checklist row asked on this piece? A dict, never a bare bool.
not_applicable True only when a usable declaration says so
unaskable True when the row is scoped to a panel type and this
piece could not be placed -- NOT RUN, never OK and
never NOT APPLICABLE
only_types the panel types the row is scoped to, when any
condition 'any' or the panel type id that matched
reason the declared sentence, verbatim
since the declared date
declared_by who declared it, when the entry says
declared whether any entry at all was found for this row
problem a sentence when a declaration was found and REFUSED
THE TWO AXES, and the difference is the whole commission. A `any` entry
is the PLANT axis -- "we do not do that here" -- and needs no
classification, so it works on the day it is written. An entry keyed by a
panel type is the PIECE axis and needs one:
* the piece is that type -> the row is not asked
* the piece is another type -> the entry is not about this piece
* the piece was NOT CLASSIFIED -> the entry is REFUSED and said so,
and the row RUNS. "I could not
tell" may only resolve towards
asking the question.
* nothing can classify at all
(`detect_verified` false) -> REFUSED, with the plant's own
reason for why detection is
unverified, and the row RUNS.
A REFUSAL IS LOUD, NOT SILENT. `problem` is a sentence for the report:
a declaration that did not take effect and said nothing would leave the
owner believing a row is off while it is on, which is the mirror image
of the failure this whole table exists to prevent.
"""
item = as_text(item).strip()
table = not_applicable_declarations(knowledge)
branch = table.get(item)
only_types, only_entries, problems = _scope_of(knowledge, item)
out = {'item': item, 'not_applicable': False, 'condition': None,
'reason': None, 'since': None, 'declared_by': None,
'declared': bool(branch) or bool(only_types), 'problem': None,
'unaskable': False, 'only_types': list(only_types)}
# THE SCOPE COMES FIRST. A row that is not about this kind of piece at
# all cannot be switched further off, and it cannot be left on either.
if only_types:
listed = ', '.join(only_types)
told = _said(only_entries[only_types[0]], 'scoped_because')
if not verified:
out['unaskable'] = True
out['reason'] = (
'this row is declared to be about {0} pieces only, and '
'panel_types.detect_verified is false -- nothing has been '
'confirmed to classify a piece, so whether this row is even '
'about this piece could not be established. It was NOT ASKED, '
'and NOT ASKED is not a pass. {1}'
.format(listed,
get(knowledge,
'panel_types.detect_unverified_because') or
'No reason is recorded for why detection is '
'unverified.'))
out['problem'] = out['reason']
elif panel_type is None:
out['unaskable'] = True
out['reason'] = (
'this row is declared to be about {0} pieces only and this '
'piece could not be classified, so nobody could tell whether '
'the question is about it. Running it would answer an {0} '
'question on a piece that may not be one; calling it not '
'applicable would claim it is not {0}, which is precisely '
'what could not be established. It was NOT ASKED.'
.format(listed))
elif as_text(panel_type).strip().lower() not in [
t.lower() for t in only_types]:
out['not_applicable'] = True
out['condition'] = as_text(panel_type).strip()
out['reason'] = (
'this row is about {0} pieces and this piece is {1}. {2}'
.format(listed, as_text(panel_type).strip(),
told or 'No reason is recorded for the scope.'))
out['since'] = _said(only_entries[only_types[0]], 'scoped_since')
out['declared_by'] = _declarer(only_entries[only_types[0]])
if problems:
out['problem'] = ' '.join(
[p for p in [out.get('problem')] + problems if p])
if out['unaskable'] or out['not_applicable'] or not branch:
return out
# `any` first: it needs no classification, so it cannot be blocked by
# one. A type-keyed entry is only consulted when `any` did not settle it.
order = ([NOT_APPLICABLE_ANY] +
sorted([k for k in branch if k != NOT_APPLICABLE_ANY]))
refusals = []
for condition in order:
if condition not in branch:
continue
entry = branch[condition]
problem = _declaration_problem(item, condition, entry)
if problem:
refusals.append(problem)
continue
is_any = (condition == NOT_APPLICABLE_ANY)
if not is_any:
if not verified:
refusals.append(
'checks.not_applicable[{0}][{1}] is conditioned on the '
'PIECE, and panel_types.detect_verified is false -- '
'nothing has been confirmed to classify a piece, so this '
'declaration was REFUSED and the row RAN. {2}'
.format(item, condition,
get(knowledge,
'panel_types.detect_unverified_because') or
'No reason is recorded for why detection is '
'unverified.'))
continue
if panel_type is None:
refusals.append(
'checks.not_applicable[{0}][{1}] applies only to {1} '
'pieces and this piece could not be classified, so it '
'was REFUSED and the row RAN. A piece whose type cannot '
'be told gets the full checklist.'.format(item, condition))
continue
if as_text(panel_type).strip().lower() != condition.lower():
continue
if entry.get('applies') is True:
# An explicit narrowing: somebody turned this row back on for
# this condition, with its own reason. It stops the search --
# `any` is consulted first, so a job that re-enables a row for
# one panel type does not have to restate the plant's `any`.
out['condition'] = condition
out['reason'] = _said(entry, 'applicable_because')
out['since'] = _said(entry, 'applicable_since')
out['declared_by'] = _declarer(entry)
break
out['not_applicable'] = True
out['condition'] = condition
out['reason'] = _said(entry, 'not_applicable_because')
out['since'] = _said(entry, 'not_applicable_since')
out['declared_by'] = _declarer(entry)
break
if refusals:
out['problem'] = ' '.join(
[p for p in [out.get('problem')] + refusals if p])
return out
def _said(entry, key):
"""One declared sentence, or None. `as_text(None)` is the string 'None',
which is how a missing reason becomes a reason that reads like one."""
value = entry.get(key)
if value is None:
return None
return as_text(value).strip() or None
def _declarer(entry):
return _said(entry, 'declared_by')
def declaring_layer(layers, path):
"""Which knowledge layer last wrote `path`, from `configdump.
layer_values(doc)`. None when no layer holds it.
`path` IS A SEQUENCE OF KEYS, not a dotted string, and that is not a
style choice: every key in this table is a CHECKLIST ROW NUMBER, and
`'checks.not_applicable.3.3.any'.split('.')` is five segments where
four were meant. A dotted path cannot address this tree at all.
A COMPUTED ANSWER, not a typed one. `declared_by` in an entry says who
RULED; this says which FILE the tool actually read it from, and the two
are different questions -- a plant rule copied into a job file still
says the plant's engineer ruled it, and the reader needs to know it is
the job file that has to be edited to change it.
"""
found = None
for layer in (layers or []):
label, data = layer[0], (layer[3] if len(layer) > 3 else None)
if not isinstance(data, dict):
continue
node = data
ok = True
for part in (path or []):
if not isinstance(node, dict) or part not in node:
ok = False
break
node = node[part]
if ok:
found = label
return found
def connector_marks(knowledge):
"""Every declared connector part number, flattened and uppercased.
The groups are preserved in the file because the owner named them as
groups; nothing here needs the grouping, so nothing here invents a
meaning for it.
"""
table = get(knowledge, 'connectors.marks')
if not isinstance(table, dict):
return []
out = []
for group in sorted(table):
for mark in (table[group] or []):
text = as_text(mark).strip().upper()
if text and text not in out:
out.append(text)
return out
def connector_product(knowledge, mark):
"""(product key, entry, why) for one connector part number.
EXACT match on the alias table, never a substring: `R8` must not swallow
`R80`, and the standing lesson in this file is that a bare part number
matched loosely files one part under another's name.
(None, None, why) is a real answer -- a part number nobody has paired
with a published product -- and every caller must report it rather than
reaching for another product's numbers.
"""
text = as_text(mark).strip().upper() if mark is not None else ''
if not text:
return None, None, 'no connector mark was given.'
alias = get(knowledge, 'connectors.catalogue_alias', {}) or {}
key = None
if isinstance(alias, dict):
for candidate in alias:
if as_text(candidate).strip().upper() == text:
key = alias[candidate]
break
key = as_text(key).strip() if key else ''
if not key:
return None, None, (
'the plant declares no published product for connector {0!r}, so '
'nothing about its rating or its edge distance can be looked up. '
'Nothing was borrowed from another product.'.format(text))
products = get(knowledge, 'connectors.products', {}) or {}
entry = None
if isinstance(products, dict):
for candidate in products:
if as_text(candidate).strip() == key:
entry = products[candidate]
break
if not isinstance(entry, dict):
return key, None, (
'connector {0!r} is declared to be {1!r}, and the public '
'catalogue has no entry under that name, so nothing about it can '
'be looked up.'.format(text, key))
return key, dict(entry), (
'connector {0!r} is {1!r}.'.format(text, key))
def connector_edge_minimum(knowledge, mark):
"""(inches, source, why) for one connector's minimum edge distance.
PRODUCT FIRST, and a plant-wide fallback is SECOND-CLASS and named:
`source` is 'product' or 'plant', and a finding built on the second says
so, because "6 inches" and "6 inches for this product" are different
claims. That distinction is 6.2's lesson, one part along.
(None, None, why) when neither exists. NEVER another product's number.
"""
key, entry, why = connector_product(knowledge, mark)
if entry:
value = _number(entry.get('min_edge_distance_in'))
if value is not None:
return value, 'product', (
'{0} in is the published minimum for {1}.'.format(value, key))
unknown = entry.get('min_edge_distance_unknown_because')
why = ('{0} publishes no minimum edge distance -- {1}'
.format(key, as_text(unknown).strip() if unknown
else 'and no reason is recorded.'))
fallback = _number(get(knowledge, 'connectors.min_edge_distance_in'))
if fallback is not None:
return fallback, 'plant', (
'{0} No published per-product minimum was found, so the '
'plant-wide {1} in was used -- and that is a WEAKER claim: it is '
'not this product\'s number.'.format(why, fallback))
return None, None, (
'{0} and the plant declares no fallback minimum either, so how close '
'to an edge this connector may sit is not written down anywhere. '
'Nothing was borrowed from another product.'.format(why))
def connector_spacing_requirement(knowledge, requirements):
"""(requirement, why) -- the design-summary line that states this
plant's connector spacing.
ROW 5.9's DESIGN SIDE, and until 2026-08-31 there was none: the check
read `intent['connector_spacing_in']`, nothing ever wrote it, and the
row stood down on every press saying the summary parser could not read
the line. The line was `HK ST100'S @1'-4" O.C.` and it was sitting in
8.1's uninterpreted list the whole time.
`requirements` is `ticketpage.classify_summary_lines`' first return --
typed design-summary lines. This picks the ones that are about A
CONNECTOR, and it does that THROUGH THE PLANT'S OWN ALIAS TABLE rather
than by recognising a product name in code:
the summary says HK ST100'S
the plant says connectors.marks holds R82
connectors.catalogue_alias.R82 is
'HK Composites (ST100)'
so the two are joined on ST100
THE JOIN IS ON A CODE TOKEN, NEVER ON A WORD. A code token is one
carrying both a letter and a digit -- `ST100`, `R82` -- and a match is
EXACT after the summary's possessive `'S` is dropped. Matching on
`HK`, or on any substring, is how one product's requirement gets
applied to another product's connectors: `ST50` and `ST100` are both
'HK Composites' and they are not the same part. The same exact-match
lesson as `connector_product`, one join along.
(None, why) is a real answer and every caller must report it rather
than comparing against nothing. Three shapes of it, told apart because
they have three different repairs:
* the design summary states no spacing at all -> nothing to compare;
* it states one, and no declared connector is named in it -> the
requirement is about something else (a bar, an embed), and reading
a bar's spacing as a connector's would be a confident wrong answer;
* it names a connector and the SPACING IS NOT A NUMBER (a table
variable, a blank on the sheet) -> the requirement is real and
un-valued, which is the one case a reader must not mistake for
agreement.
"""
typed = [r for r in (requirements or []) if isinstance(r, dict)]
spacings = [r for r in typed if r.get('kind') == 'spacing']
if not spacings:
if not typed:
return None, (
'the design summary carries no line this tool could read as '
'a requirement, so it states no connector spacing to compare '
'against')
return None, (
'the design summary states {0} requirement(s) and none of them '
'is a spacing, so it calls for no connector pitch'
.format(len(typed)))
wanted = {}
for mark in connector_marks(knowledge):
for token in _code_tokens(mark):
wanted.setdefault(token, []).append(mark)
key = connector_product(knowledge, mark)[0]
for token in _code_tokens(key):
wanted.setdefault(token, []).append(mark)
if not wanted:
return None, (
'no layer declares a connector part number (or a published '
'product for one), so no design-summary line could be '
'recognised as being about a connector')
for requirement in spacings:
tokens = [as_text(t).strip().upper()
for t in (requirement.get('product_tokens') or [])]
hit = [t for t in tokens if t in wanted]
if not hit:
continue
marks = []
for token in hit:
for mark in wanted[token]:
if mark not in marks:
marks.append(mark)
# `{0}` and not `{0!r}`: the summary line carries apostrophes and
# inch marks, and a repr of it reaches the report as backslashes the
# reader then has to mentally undo before he can find the line on
# the sheet.
why = ('the design summary line "{0}" names {1}, which this plant '
'declares as connector {2}'
.format(requirement.get('text'), ', '.join(hit),
', '.join(marks)))
if requirement.get('spacing_in') is None:
return None, (
'{0} -- but {1}, so there is no number to hold this piece to'
.format(why, requirement.get('spacing_unread_because')
or 'its spacing could not be read'))
return dict(requirement), why
return None, (
'the design summary states {0} spacing requirement(s) and none of '
'them names a part number this plant declares as a connector ({1}), '
'so none of them is about connectors: {2}'
.format(len(spacings), ', '.join(connector_marks(knowledge)),
'; '.join([as_text(r.get('text')) for r in spacings])))
def _code_tokens(text):
"""The tokens of `text` that are a PART CODE -- letters and digits both.
`'HK Composites (ST100)'` gives `['ST100']`: `HK` is a maker and
`Composites` is a word, and neither of them identifies a part. This is
what makes the join in `connector_spacing_requirement` safe to do at
all.
"""
import re as _re
out = []
for raw in _re.split(r'[^A-Za-z0-9]+', as_text(text or '')):
token = raw.strip().upper()
if not token or token in out:
continue
if _re.search(r'[A-Z]', token) and _re.search(r'[0-9]', token):
out.append(token)
return out
def connector_spacing_tolerance(knowledge):
"""(inches, why) -- how far the widest measured gap may sit above the
design's stated on-centre spacing before 5.9 calls it a defect.
WHERE THE NUMBER COMES FROM, and it is not a plant ruling. The one
calibration that exists needed no tolerance at all: measured
2026-08-31 on an insulated piece, the connectors sit at exactly 16.0 in
across the field against a design that states 1'-4", which is 16 in --
the model honours the line to three decimal places. So this allowance
has never been exercised, and 0.5 in is a TOOL DEFAULT chosen to be
smaller than any spacing the summary can state (the finest the corpus
prints is 1/2 in) and larger than the rounding of a printed dimension.
It lives in `tolerances.connector_spacing_in` so a plant can rule on it
without a release, and the finding says which value it used.
"""
value = _number(get(knowledge, 'tolerances.connector_spacing_in'))
if value is None:
return None, (
'tolerances.connector_spacing_in is unset, so how far a gap may '
'exceed the design spacing before it is a defect is not written '
'down anywhere and nothing was assumed')
return value, ('{0} in, from tolerances.connector_spacing_in'
.format(_fmt_tolerance(value)))
def _fmt_tolerance(value):
if abs(value - round(value)) < 1e-9:
return '{0:.0f}'.format(value)
return '{0:g}'.format(value)
##
# ---------------------------------------------------------------- insulation
#
# THE SOLID ZONES, THE RECESSES, AND THE LEGEND THAT SAYS WHAT EACH HATCH
# MEANS. Added 2026-08-31 for master rows 5.11-5.15 and consumed by 5.10 as
# well, because the owner's edge-distance semantics rest on the same parts.
#
# EVERY ONE OF THESE IS A LOOKUP THAT CAN FAIL, AND EVERY ONE OF THEM SAYS
# WHY. `(None, why)` is the shape the whole module uses for "I could not
# read that", and it is the shape here: an unreadable legend, an unmapped
# concept and a plant that has not named its layout view are three different
# silences and the rows built on them print three different sentences.
def insulation_layout_view(knowledge):
"""(view name, why) for the ticket's insulation layout view.
Landed BESIDE `drawings.bed_plan` and read the same way, because it is
the same kind of fact: what one plant's template calls one view. A
literal in the code would be the mistake `bed_plan` was created to
avoid, one view along.
"""
name = as_text(get(knowledge, 'drawings.insulation_layout') or '').strip()
if name:
return name, None
return None, (
'drawings.insulation_layout is unset: no layer says which view on '
'this plant\'s ticket is the insulation layout, so nothing could be '
'looked for on it. That is a missing plant fact, not a finding about '
'this piece')
def solid_zone_rule(knowledge):
"""(family name, why) for the part that is a solid zone at this plant.
The `family_verified` idiom, and it is doing the same work it does for
6.6's parity rule: a family name that matches nothing counts ZERO zones
on every piece, and zero zones would read as "no lifter is missing one".
A permanent invisible pass over a whole defect class is exactly what the
flag exists to prevent, so an unverified name is a stand-down.
"""
family = as_text(get(knowledge, 'insulation.solid_zone.family') or '').strip()
if not family:
return None, (
'insulation.solid_zone.family is unset: no layer says which '
'family is a solid zone at this plant, so none could be '
'recognised')
if not get(knowledge, 'insulation.solid_zone.family_verified'):
return None, (
'insulation.solid_zone.family_verified is false -- the solid-zone '
'family name has not been confirmed against a live document, and '
'a name that matches nothing counts zero zones on every piece, '
'which would read as every lifter being fine. {0}'
.format(as_text(get(
knowledge,
'insulation.solid_zone.family_unverified_because') or
'No reason is recorded.')))
return family, None
def lifter_accessories(knowledge):
"""{child family name (upper) -> {'role': str or None, 'parents': [str,
...]}} from `lifters.accessories` -- REVIT issue #26, 6.13.
Schema landed by the MC plant layer: `<child family>: {parent:
<family or [families]>, role: cup|patch}`. `parent` is accepted as
either a bare string (one parent family) or a list (DB_PATCH nests
under either LPA pin-anchor family) -- normalised to a list here so
every caller sees one shape and never has to branch on it again. An
entry with no `role` keeps `role: None` rather than a fabricated
default; a checker that needs the word for a message has to notice
the gap instead of printing one that was never actually plant fact.
Keyed by the CHILD's own family name (upper-cased, exact match, no
substring folding) -- the same identity `ticketqc._read_nested_children`
reads off `Symbol.Family.Name` and `_read_nested_solid_zones` already
compares by exact string, not `_longest_substring_match`. Empty when
the plant has not named any accessory family; that is a plant fact
(no accessories exist here, or none are written down yet), never a
parsing failure.
"""
raw = get(knowledge, 'lifters.accessories', {})
out = {}
if not isinstance(raw, dict):
return out
for child, entry in raw.items():
if not isinstance(entry, dict):
continue
key = as_text(child).strip().upper()
if not key:
continue
parent = entry.get('parent')
if isinstance(parent, string_types):
parents = [as_text(parent).strip()] if as_text(parent).strip() else []
elif isinstance(parent, (list, tuple)):
parents = [as_text(p).strip() for p in parent if as_text(p).strip()]
else:
parents = []
role = as_text(entry.get('role') or '').strip().lower() or None
out[key] = {'role': role, 'parents': parents}
return out
def legend_swatch_concepts(knowledge):
"""{filled-region type name (upper) -> concept id} from the plant legend.
The legend's own vocabulary. Empty when the plant has not written it
down, and every caller reports that rather than assuming a mapping.
"""
table = get(knowledge, 'insulation.legend.swatch_types')
out = {}
if isinstance(table, dict):
for name in table:
key = as_text(name).strip().upper()
concept = as_text(table[name]).strip()
if key and concept:
out[key] = concept
return out
def recess_region_types(knowledge):
"""The filled-region type names this plant draws a recess with."""
values = get(knowledge, 'insulation.recess.region_types') or []
out = []
for value in values:
text = as_text(value).strip()
if text and text not in out:
out.append(text)
return out
def expected_hatch_pattern(knowledge, concept, legend_patterns=None):
"""(pattern, source, why) -- what a concept's hatch is SUPPOSED to be.
THE LEGEND FIRST, AND THE PLANT MAP SECOND AND NAMED. `legend_patterns`
is what was read off the sheet's own legend on this run; when it holds
the concept, that is the answer and `source` is 'legend'. Otherwise the
plant's `insulation.legend.expected_patterns` answers with source
'plant', and every finding built on it says which -- because "the legend
on this sheet says ANSI35" and "a YAML table written on 2026-08-31 says
ANSI35" are different claims and only the first is self-updating.
(None, None, why) when neither knows the concept. Never a borrowed
pattern from a neighbouring concept: the whole defect this row exists
for is one concept drawn with another's hatch.
"""
ident = as_text(concept).strip()
if not ident:
return None, None, 'no insulation concept was named.'
if isinstance(legend_patterns, dict):
for candidate in legend_patterns:
if as_text(candidate).strip() == ident:
value = as_text(legend_patterns[candidate]).strip()
if value:
return value, 'legend', (
'the legend printed on this ticket draws {0} as {1}'
.format(ident, value))
table = get(knowledge, 'insulation.legend.expected_patterns') or {}
if isinstance(table, dict):
for candidate in table:
if as_text(candidate).strip() == ident:
value = as_text(table[candidate]).strip()
if value:
return value, 'plant', (
'the plant layer records {0} as {1}, measured off the '
'legend on {2} -- a COPY of the legend, not the '
'legend itself, so it can be stale'
.format(ident, value,
as_text(get(
knowledge,
'insulation.legend.'
'expected_patterns_measured_on') or
'a date nobody recorded')))
return None, None, (
'neither this ticket\'s legend nor insulation.legend.'
'expected_patterns says what hatch {0} is drawn with, so what the '
'rendered pattern should have been is not written down anywhere. '
'No neighbouring concept\'s pattern was borrowed.'.format(ident))
_DEPTH_IN_RE = None
def zone_depth_in(description):
"""Inches read out of a solid zone's description, or None.
`3" SOLID ZONE` -> 3.0. THIS IS A READING, NOT A MEASUREMENT, and every
caller marks it inferred. Depth is not a field on these parts; it is
text somebody typed, and a description that states no depth returns
None so the caller can stand down rather than default to either answer.
"""
global _DEPTH_IN_RE
if _DEPTH_IN_RE is None:
_DEPTH_IN_RE = re.compile(r'(\d+(?:\.\d+)?)\s*(?:"|in\b|inch)',
re.IGNORECASE)
text = as_text(description or '')
if not text:
return None
found = _DEPTH_IN_RE.search(text)
if not found:
return None
try:
return float(found.group(1))
except Exception:
return None
# Owner, verbatim (2026-08-31): "如果 insulation 有 solid zone 被 cut
# through 而不只是 recess 的话,那么 edge 就是被 cut 之后的 edge。如果只是
# recess,则按照原来的 edge 来算。"
def zone_cuts_through(knowledge, description, insulation_thickness_in):
"""(True/False/None, why) -- does this solid zone go through the insulation?
THE OWNER'S SEMANTICS, 2026-08-31: "if the insulation has a solid zone
that is cut through, not merely recessed, then the edge is the edge
after the cut. If it is only a recess, calculate using the original
edge." A zone at full insulation depth has
REPLACED the insulation, so its border is an insulation edge; a partial
one leaves insulation running underneath and moves nothing.
None IS THE ANSWER WHENEVER EITHER NUMBER IS UNREADABLE, and the caller
is required to stand the pins near that zone down. Defaulting to False
would quietly measure to the old edge -- the permissive direction --
and defaulting to True would invent an edge that may not exist.
"""
depth = zone_depth_in(description)
if depth is None:
return None, (
'this solid zone\'s description states no depth ({0!r}), so '
'whether it cuts through the insulation could not be read and '
'nothing near it was judged'.format(as_text(description or '')))
thickness = _number(insulation_thickness_in)
if thickness is None:
return None, (
'the insulation thickness is not known, so a {0} in solid zone '
'could not be told from a through-cut, and nothing near it was '
'judged'.format(depth))
through = depth >= thickness - 0.01
return through, (
'INFERRED FROM NAMES, not measured: the zone\'s description states '
'{0} in and the insulation is {1} in, so it reads as {2}'
.format(depth, thickness,
'a through-cut, which moves the insulation edge'
if through else
'partial depth, which leaves the original edge in place'))
def _plant_claims_tension_bar(knowledge, family_name):
"""True when THIS plant's own layer says this family always ships
welded to the tension bar its with-bar rating depends on.
`lifters.parts_shipped_with_tension_bar` names PLANT part codes (here:
'P76'), matched as a substring of the modelled family name -- the same
way `catalogue_alias` is matched, because it is the plant's vocabulary,
not the manufacturer's designation.
Absence of the claim is not a judgement about the hardware either way;
it means this plant has not stated one, and `lifter_capacity` reads the
catalogue's own working_load_lb exactly as published -- UNGATED, same as
the day before this gate existed. That is deliberate (requirement 3 of
the 2026-09-01 exercise this gate was built for): a plant that never
made the claim keeps today's behaviour, because gating it on a claim
nobody made would be inventing plant knowledge that is not written down.
"""
claimed = get(knowledge, 'lifters.parts_shipped_with_tension_bar', [])
if not claimed:
return False
up = as_text(family_name or '').upper()
return any(as_text(token).strip().upper() in up for token in claimed
if as_text(token).strip())
def _tension_bar_corroborated(knowledge, assembly_families):
"""(bool, why) -- was the welded tension bar actually seen on this piece?
`lifters.tension_bar_families` names the SEPARATE family or families
that ARE the welded bar (SR5602, SR6999 at this plant) -- not a property
carried on the embed's own instance. Revit records no link from an
embed instance to a bar welded to it:
`interference.component_groups`'s own docstring says so outright ("no
parameter, no nested family carries it"). So the only corroboration
reachable at all is whether one of those family names shows up as an
OTHER MEMBER of the SAME ASSEMBLY -- `assembly_families`, which the
caller gathers from wherever it can see the whole piece: liftexport's
`boxes` at export time, or ticketqc's `t['bom']` (a family-name ->
count dict; iterating it yields the family names, same as a list).
Nothing here reads the model itself -- the caller does that and hands
the names in.
This is assembly-level, not instance-level: Revit gives no way to say
THIS bar belongs to THIS embed among several on one piece, so a bar
seen anywhere on the assembly corroborates every gated embed on it.
That is a real limitation, not a mistake -- see the exercise notes for
why nothing stronger is available here -- and it is still a genuine
fact about the modelled piece, not a constant.
assembly_families of None means the caller never had that list to hand
(an older code path, or a call site with no assembly in reach) and is
NOT the same as an assembly with no matching members: None can never
corroborate, and the reason says the check was not run rather than
that it failed.
"""
bars = get(knowledge, 'lifters.tension_bar_families', [])
bars = [as_text(b).strip().upper() for b in (bars or [])
if as_text(b).strip()]
if not bars:
return False, ('this plant declares no lifters.tension_bar_families '
'to look for, so the tension bar could not be '
'corroborated at all')
if assembly_families is None:
return False, ('the assembly\'s other members were not available '
'to this call, so the tension bar could not be '
'looked for')
present = [as_text(f).strip().upper() for f in assembly_families
if as_text(f).strip()]
hits = sorted(set(b for b in bars if any(b in f for f in present)))
if hits:
return True, ('{0} modelled among this assembly\'s {1} other '
'member(s)'.format('/'.join(hits), len(present)))
return False, ('none of {0} was found among this assembly\'s {1} other '
'member(s) -- the tension bar this rating depends on was '
'not seen on this piece'.format('/'.join(bars),
len(present)))
_ANGLE_TENSION_KEY_RE = re.compile(r'^tension_(\d+)_lb$')
def _angle_variant_tension_columns(direct):
"""{angle_deg (int): value} for every `tension_<angle>_lb` key present
in a `lifters.capacity_lb` dict entry, skipping any whose value is None.
Empty when the entry carries no angle-split tension at all (the plain
`{tension_lb, shear_lb}` shape, or a bare number, neither of which ever
reach here as a dict with such keys) -- so callers can tell "no
angle-variant shape" from "angle-variant shape, nothing resolves" by
whether this comes back empty.
"""
columns = {}
for key, value in direct.items():
if value is None:
continue
m = _ANGLE_TENSION_KEY_RE.match(as_text(key))
if m:
columns[int(m.group(1))] = value
return columns
def _angle_choice_int(choice):
try:
return int(choice)
except (TypeError, ValueError):
return None
_NOT_RATED_STRINGS = frozenset(('', '-'))
def _field_rating_state(value):
"""Classify one `tension_lb` / `shear_lb` field for `lifter_capacity`.
Returns (state, value) where state is one of:
'forbidden' an explicit `false` or the string `'forbidden'`
(case-insensitive) -- the plant is stating outright
that this kind of load is never permitted on this
part. This IS a real zero, just not for the reason a
blank chart cell is one.
'unrated' `None` (the key is absent), `0` / `0.0` (a chart or
override that prints a bare Zero), or `''` / `'-'`
(a table cell copied as blank or a dash). NOT a real
rating -- see `lifter_capacity`'s 2026-09-04 note.
'rated' anything else numeric. Taken as stated, including a
genuine non-zero published figure.
The raw value comes back unchanged in every case so a caller can still
describe exactly what it saw.
"""
if value is False:
return 'forbidden', value
if isinstance(value, string_types) and value.strip().upper() == 'FORBIDDEN':
return 'forbidden', value
if value is None:
return 'unrated', value
if isinstance(value, string_types) and value.strip() in _NOT_RATED_STRINGS:
return 'unrated', value
number = _number(value)
if number is not None and number == 0:
return 'unrated', value
return 'rated', value
def _back_cast_shear_from_tension(knowledge, family_name, part_key, fc_psi,
assembly_families):
"""(value, basis, why) when this family's shear comes from its tension
per the plant chart's back-cast footnote, or plain `None` when the
footnote does not reach this family at all -- the caller keeps looking
elsewhere (the next table, then the catalogue) in that case.
Three conditions, all the plant's own stated facts, none guessed here:
`lifting_anchors.back_cast_shear_equals_tension` is set, this part's
M8-style designation is named in
`lifting_anchors.back_cast_shear_equals_tension_applies_to`, and this
family is grouped as a FACE (back-cast) lifter
(`handling.lifter_groups.face.families` -- see `_is_face_lifter`). Once
all three match, the footnote IS the answer -- this returns a definite
(value-or-None, basis, why) triple, never plain `None` again, even when
the tension it depends on fails to resolve.
"""
if not get(knowledge, 'lifting_anchors.back_cast_shear_equals_tension'):
return None
applies = [as_text(p).strip().upper() for p in
(get(knowledge, 'lifting_anchors.'
'back_cast_shear_equals_tension_applies_to', []) or [])]
if not part_key or as_text(part_key).strip().upper() not in applies:
return None
if not _is_face_lifter(knowledge, family_name):
return None
tension, _tb, t_why = lifter_capacity(
knowledge, family_name, fc_psi=fc_psi, kind='tension',
assembly_families=assembly_families)
if tension is None:
return None, None, (
'{0} would take its shear from its tension (back-cast, per '
'the plant chart\'s footnote, lifting_anchors.'
'back_cast_shear_equals_tension) but the tension did not '
'resolve: {1}'.format(family_name, t_why))
return tension, 'plant_table', (
'{0} is cast in the back of the panel (a face lifter), so its '
'shear equals its tension per the plant chart\'s footnote '
'(lifting_anchors.back_cast_shear_equals_tension): {1} lb -- {2}'
.format(family_name, tension, t_why))
def _resolve_dict_field(knowledge, family_name, direct, field, kind,
table_name, basis_label):
"""Read one `tension_lb`/`shear_lb`-shaped field out of a single plant
or override dict entry (`lifters.capacity_lb` or `lifting_anchors.
parts`, whichever `table_name` names).
Returns ('return', value, basis, why) for a terminal answer -- forbid,
rated, or a stand-down this table itself explains (an angle mismatch,
an `_unresolved_because`) -- or ('fallthrough', why_fragment) when this
table NAMES the family but prints nothing usable for `kind`, so the
caller should keep looking: the next table, the back-cast footnote, or
the catalogue. See `lifter_capacity`'s 2026-09-04 docstring note for
why a printed `0` is not automatically a terminal answer any more.
"""
value = direct.get(field)
state, _raw = _field_rating_state(value)
if state == 'forbidden':
return ('return', 0, basis_label, (
'{0} is a job- or plant-pinned entry in {1}: {2} is explicitly '
'FORBIDDEN there -- a deliberate zero (this part may never be '
'loaded in {3}), not merely an unpublished rating'
.format(family_name, table_name, field, kind)))
if state == 'rated':
return ('return', value, basis_label, (
'{0} is a job- or plant-pinned {1} in {4}.{2}, '
'taken as stated ({3} lb)'.format(family_name, kind, field,
value, table_name)))
# state == 'unrated' -- the printed/pinned field is 0, blank, a dash,
# or simply absent. Try the angle-split tension shape and the entry's
# own stated reason before conceding there is none here.
if kind != 'shear':
columns = _angle_variant_tension_columns(direct)
if columns:
published = [a for a in
(_angle_choice_int(a) for a in
(get(knowledge,
'lifting_anchors.sling_angles_published',
[]) or []))
if a is not None]
choice_raw = get(knowledge,
'lifting_anchors.sling_angle_choice', None)
if choice_raw is not None:
choice = _angle_choice_int(choice_raw)
if choice is not None and choice in columns:
return ('return', columns[choice], basis_label, (
'{0} publishes tension by sling angle in '
'{4} ({1}); '
'lifting_anchors.sling_angle_choice names {2} '
'deg, so {3} lb applies (per '
'lifting_anchors.sling_angle_choice)'.format(
family_name,
', '.join('{0} deg = {1} lb'.format(a, columns[a])
for a in sorted(columns)),
choice, columns[choice], table_name)))
return ('return', None, None, (
'{0} publishes tension only at {1} deg in '
'{4} (published sling angles: {2}), '
'but lifting_anchors.sling_angle_choice names {3}, '
'which has no matching tension_{3}_lb column here -- '
'standing down rather than guessing'.format(
family_name,
'/'.join(str(a) for a in sorted(columns)),
'/'.join(str(a) for a in sorted(published))
if published else 'none stated',
choice_raw, table_name)))
# sling_angle_choice is null/unset -- fall through to the
# entry's own unresolved_because, exactly as before.
why = direct.get(field + '_unresolved_because')
if why:
return ('return', None, None, why)
if kind != 'shear':
columns = _angle_variant_tension_columns(direct)
if columns:
unknown = get(knowledge,
'lifting_anchors.sling_angle_choice_unknown_because')
return ('return', None, None, (
'{0} publishes tension by sling angle in {1} ({2}) but '
'lifting_anchors.sling_angle_choice is unset -- standing '
'down rather than guessing{3}'.format(
family_name, table_name,
', '.join('{0} deg = {1} lb'.format(a, columns[a])
for a in sorted(columns)),
(': ' + as_text(unknown).strip()) if unknown else '')))
# Genuinely nothing here: no number, no angle-split shape, no stated
# reason. NOT the same as a real zero -- this table is not the last
# word, so the caller keeps looking rather than taking a printed blank
# or zero as the rating.
detail = ('prints a published/pinned zero' if _number(value) == 0
else 'is blank' if value is not None else 'is not set')
return ('fallthrough', (
'{0}.{1} for {2} {3}, not a real {4} rating'.format(
table_name, field, family_name, detail, kind)))
def lifter_capacity(knowledge, family_name, fc_psi=None, kind='tension',
assembly_families=None):
"""(value, basis, why) -- rated working load for a lifter family.
value is None when unknown; basis and why explain what was used and are
never blank when value is not None. Direct table first, then the
catalogue by way of the plant's alias. The direct table wins so that a
job can pin a value without anyone having to edit published product
data -- and it is taken as stated, since whoever typed it knew what
concrete they meant.
basis is one of:
'direct' lifters.capacity_lb had this family and a real
rating for `kind`; taken as stated, no catalogue or
tension-bar question asked.
'plant_table' lifting_anchors.parts (the plant's own tested
chart) had a real rating for `kind`, either printed
directly or via the back-cast-shear-equals-tension
footnote (`_back_cast_shear_from_tension`).
'catalogue' the catalogue's own rating. Reached either
UNCONDITIONALLY (this part carries no tension-bar
gate, or it does and this plant has made no claim
about one -- see `_plant_claims_tension_bar`), or
because a plant override / plant table DID name
this family but had no real rating for `kind` --
see the NOT RATED note below, where `why` names
every layer tried before the catalogue answered.
'with_bar' the catalogue's WITH-tension-bar rating, because the
bar was actually corroborated on THIS piece.
'without_bar' the part requires a tension bar for its published
rating, this plant claims the part ships with one,
but it could not be corroborated on THIS piece -- so
the catalogue's WITHOUT-bar figure applies instead.
NEVER silent: `why` always names both the rating
used and the reason the with-bar one was not.
None value is also None; nothing is known.
fc_psi is the strength AT THE LIFT. Pass f'ci for stripping; the rating is
derated for it (see concrete_derate). Omitting it returns the catalogue
number unadjusted, which is the right answer only when the concrete is at
or above the table's basis.
assembly_families is every OTHER member's family name in the same
assembly as this lifter -- a list, or a BOM dict whose keys are family
names (iterating either yields family-name strings). It is the only
corroboration this function can reach for the tension-bar gate; see
`_tension_bar_corroborated`. A caller with no such list to hand may omit
it, and a tension-bar-gated, plant-claimed part then falls back to the
without-bar rating with `why` saying corroboration was never attempted
-- which is the SAFE direction: an omitted list never earns the 2x
rating by accident.
lifters.capacity_lb[family] is USUALLY a bare number -- the one rating
every existing plant file has ever written, returned here for either
`kind` because nobody who wrote one of those entries meant it to differ
between tension and shear. THAT SHAPE CANNOT SAY "tension and shear are
different numbers", which a real part can need: one plant's own tested
chart for one insert publishes 13,800 lb tension against 3,860 lb shear
-- a different number from the same designation's published catalogue
rating -- so pinning one flat number would silently hand the tension
figure back on a shear query, OVERSTATING it -- the unsafe direction.
Added 2026-09-02 for exactly that case: a dict entry with `tension_lb` /
`shear_lb` keys is read by `kind`. Missing the field `kind` asked for is
not an error -- it means the plant pinned the OTHER kind and said
nothing about this one -- and if `<field>_unresolved_because` is present
that reason is returned instead of guessing (a sibling insert on the
same chart can have a genuinely unresolved tension -- depending on a
sling angle the chart never picked -- while its shear is a real,
published number, so a tension query stands down naming the unresolved
angle rather than silently choosing one over the other, and does so
without disturbing the shear side at all).
A dict entry can ALSO carry the angle-split shape instead of a plain
`tension_lb`: `tension_60_lb` / `tension_75_lb` / ... (one key per
published sling angle) plus a `tension_lb_unresolved_because` explaining
that the chart would not pick one. Added 2026-09-02: when the PLANT has
since answered that question -- `lifting_anchors.sling_angle_choice`
names one of the published angles -- a tension query resolves to that
column instead of standing down, `why` naming the chosen angle. This is
scoped tight on purpose: it only fires for a dict entry that actually has
`tension_<N>_lb` keys, never for the plain `{tension_lb, shear_lb}` shape
or a bare number, and it still stands down -- naming the mismatch rather
than guessing -- when `sling_angle_choice` is unset or names an angle
this entry has no column for.
---- THREE STATES A `tension_lb` / `shear_lb` FIELD CAN HOLD, added
2026-09-04 after a real FAIL that was actually "nobody rated this" ------
A number a plant or job pins is taken as stated, including a genuine
published zero -- some inserts really do carry no rating on one axis.
But `0`, `None`, `''` and `'-'` are NOT reliably "a real zero" on their
own: one plant's own tested chart prints a bare `Zero` in the shear
column of a back-cast (face) lifter not because the anchor carries no
shear, but because that printed cell is the EDGE-cast condition, and a
footnote elsewhere on the same page (`lifting_anchors.
back_cast_shear_equals_tension`) says a BACK-cast one takes its shear
from its tension instead. Reading the printed `0` as the rating --
which this function did until 2026-09-04 -- reported a real face lifter
as having ZERO shear capacity and failed a wall on a load its own
hardware carries just fine.
So a field of `0` / `None` / `''` / `'-'` is read as NOT RATED HERE, not
as a real zero, and is never returned as one. Instead:
1. the back-cast footnote is tried first, for a shear query only
(`_back_cast_shear_from_tension`) -- it is the plant's OWN stated
rule for exactly this printed shape, so it outranks falling
through to a generic number;
2. failing that, the NEXT layer is tried: `lifters.capacity_lb`
(the job/plant override) falls through to `lifting_anchors.parts`
(the plant's own tested chart) before either falls through to the
published catalogue;
3. failing every layer, the answer is `None` with a `why` naming
every layer that was tried and what each one said -- never a
silent 0.
A plant that genuinely WANTS a hard zero -- a part that must never be
loaded on that axis at all, a real published prohibition rather than an
absent rating -- states it as `shear_lb: forbidden` (or `false`), which
this function still returns as `0`, `basis` naming the table that
forbade it and `why` saying it is deliberate. That is now the ONLY way
to get a `0` back out of this function for a dict-shaped entry; a bare
printed `0` never is one on its own any more. See `_field_rating_state`.
"""
field = 'shear_lb' if kind == 'shear' else 'tension_lb'
# The plant's own tested parts chart -- `lifting_anchors.parts`, one
# entry per part number -- read by the part number the family name
# carries, regardless of whether the job/plant override below answers
# first; its part number (`part_key`) is what the back-cast footnote
# keys on, and its own fields are the second layer tried when the
# override's are not rated. Found 2026-09-03: a job whose plant had
# landed the whole chart still reported "no rating" because nothing
# read it.
part_key, chart_part = _longest_substring_key(
get(knowledge, 'lifting_anchors.parts', {}), family_name)
chart_direct = None
if isinstance(chart_part, dict):
chart_direct = dict((k, v) for k, v in chart_part.items()
if k in ('tension_lb', 'shear_lb')
or _ANGLE_TENSION_KEY_RE.match(as_text(k)))
override_direct = _longest_substring_match(
get(knowledge, 'lifters.capacity_lb', {}), family_name)
plant_reasons = []
for candidate, table_name, basis_label in (
(override_direct, 'lifters.capacity_lb', 'direct'),
(chart_direct, 'lifting_anchors.parts', 'plant_table')):
if candidate is None:
continue
if not isinstance(candidate, dict):
# The legacy bare-number override: one figure for either kind,
# taken as stated, no per-kind split possible. Only the
# override can ever be this shape -- `chart_direct` above is
# always built as a dict or None.
return candidate, 'direct', (
'{0} is a job- or plant-pinned rating in '
'lifters.capacity_lb, taken as stated ({1} lb)'
.format(family_name, candidate))
outcome = _resolve_dict_field(knowledge, family_name, candidate,
field, kind, table_name, basis_label)
if outcome[0] == 'return':
return outcome[1], outcome[2], outcome[3]
plant_reasons.append(outcome[1])
if kind == 'shear':
footnote = _back_cast_shear_from_tension(
knowledge, family_name, part_key, fc_psi, assembly_families)
if footnote is not None:
return footnote
plant_reason = '; '.join(plant_reasons) if plant_reasons else None
designation = catalogue_item(knowledge, family_name)
if not designation:
no_alias = (
'no catalogue alias for {0}; lifters.catalogue_alias does not '
'name it'.format(family_name))
return None, None, (
'{0}; {1}'.format(plant_reason, no_alias) if plant_reason
else no_alias)
entry = (get(knowledge, 'catalogue.inserts', {}) or {}).get(designation)
if not isinstance(entry, dict):
if entry is None:
not_published = (
'{0} maps to catalogue designation {1}, which is not '
'published in catalogue.inserts'.format(family_name,
designation))
return None, None, (
'{0}; {1}'.format(plant_reason, not_published)
if plant_reason else not_published)
bare_why = (
'{0} maps to catalogue.inserts.{1}, a bare published number'
.format(family_name, designation))
return entry, 'catalogue', (
'{0}; {1}'.format(plant_reason, bare_why) if plant_reason
else bare_why)
cat_field = 'shear_load_lb' if kind == 'shear' else 'working_load_lb'
rated = entry.get(cat_field)
if rated is None:
# No usable `shear_lb` / `tension_lb` on this catalogue.inserts
# entry -- issue #15: a P78 (alias QL184G) publishes only an
# ultimate_mechanical_tension_lb, nothing in the SWL vocabulary, so
# without this hop it reports LIFT-UNKNOWN. Answer from the
# insulated-panel anchor capacity table when it publishes this part.
from_table = _insulated_panel_anchor_capacity(
knowledge, family_name, designation, kind, fc_psi,
assembly_families, plant_reason)
if from_table is not None:
return from_table
no_field = (
'{0} maps to catalogue.inserts.{1}, which publishes no {2}'
.format(family_name, designation, cat_field))
return None, None, (
'{0}; {1}'.format(plant_reason, no_field) if plant_reason
else no_field)
if (kind == 'tension' and entry.get('requires_tension_bar_for_rating')
and _plant_claims_tension_bar(knowledge, family_name)):
earned, gate_why = _tension_bar_corroborated(knowledge,
assembly_families)
if earned:
with_bar_why = (
'{0} ({1}) is rated {2} lb WITH its tension bar -- {3}'
.format(family_name, designation, rated, gate_why))
return rated * concrete_derate(entry, fc_psi), 'with_bar', (
'{0}; {1}'.format(plant_reason, with_bar_why)
if plant_reason else with_bar_why)
fallback = entry.get('tension_without_tension_bar_lb')
if fallback is None:
no_fallback = (
'{0} ({1}) requires a tension bar for its {2} lb rating, '
'the bar could not be corroborated ({3}), and the '
'catalogue publishes no tension_without_tension_bar_lb to '
'fall back to'.format(family_name, designation, rated,
gate_why))
return None, None, (
'{0}; {1}'.format(plant_reason, no_fallback)
if plant_reason else no_fallback)
without_bar_why = (
'{0} ({1}) requires its tension bar for the {2} lb rating and '
'it could not be corroborated on this piece ({3}), so the '
'WITHOUT-bar rating of {4} lb applies instead'
.format(family_name, designation, rated, gate_why, fallback))
return fallback * concrete_derate(entry, fc_psi), 'without_bar', (
'{0}; {1}'.format(plant_reason, without_bar_why)
if plant_reason else without_bar_why)
catalogue_why = (
'{0} maps to catalogue.inserts.{1}, rated {2} lb {3}'
.format(family_name, designation, rated, kind))
return rated * concrete_derate(entry, fc_psi), 'catalogue', (
'{0}; {1}'.format(plant_reason, catalogue_why) if plant_reason
else catalogue_why)
def _insulated_panel_anchor_capacity(knowledge, family_name, designation,
kind, fc_psi, assembly_families,
plant_reason):
"""(value, basis, why) answered from catalogue.insulated_panel_anchor_
capacities when that table publishes `designation`, or None when it does
not and the caller keeps its existing stand-down.
Issue #15's wire: a P78-style alias that resolves to a
catalogue.inserts entry carrying only an ultimate mechanical rating --
no usable `shear_lb` / `tension_lb` -- must answer from this capacity
table instead of reporting LIFT-UNKNOWN. The table carries the SWL
columns (`shear_4_1_lb`, `reinforced_tension_4_1_lb`) the inserts
entry lacks, per `panel_group`, so the worst (lowest) group of the
rows naming the part is taken -- the catalogue marks its own panel-group
caption semantics uncertain (`panel_group_triple_semantics_uncertain`),
so no one group may be privileged over another.
Shear is taken straight from the table (basis 'catalogue'). Tension is a
WITH-bar rating and runs through the same tension-bar corroboration the
catalogue path applies: when this plant claims the bar and it is
corroborated on the piece the answer is 'with_bar'; when the claim is
made but the bar is not corroborated, and the table prints no without-bar
figure, the answer stands down naming that -- never a guessed number.
"""
table = get(knowledge, 'catalogue.insulated_panel_anchor_capacities', {})
if not isinstance(table, dict):
return None
rows = table.get('rows')
if not isinstance(rows, list):
return None
target = as_text(designation).strip().upper()
part_rows = [row for row in rows
if isinstance(row, dict)
and as_text(row.get('part_number')).strip().upper()
== target]
if not part_rows:
return None
col = ('shear_4_1_lb' if kind == 'shear'
else 'reinforced_tension_4_1_lb')
raws = [row.get(col) for row in part_rows]
values = [_number(raw) for raw in raws]
if any(v is None for v in values):
missing = [row.get('part_number') for row, v in zip(part_rows, values)
if v is None]
no_rating = (
'{0} maps to catalogue.inserts.{1}, but the '
'insulated_panel_anchor_capacities rows for it publish no '
'usable {2} (missing on: {3})'
.format(family_name, designation, col,
', '.join(as_text(m) for m in missing)))
return None, None, (
'{0}; {1}'.format(plant_reason, no_rating) if plant_reason
else no_rating)
basis_fc_psi = table.get('_basis_fc_psi')
basis_entry = {'basis_fc_psi': basis_fc_psi} if basis_fc_psi else {}
factor = concrete_derate(basis_entry, fc_psi)
value = min(values)
valued = value * factor
panel_groups = ', '.join(
as_text(row.get('panel_group')) for row in part_rows)
rebar_note = (
'the table prints rebar_size {0} and the owner states #6 -- both '
'unresolved'.format(
as_text(part_rows[0].get('rebar_size'))))
uncertain = bool(table.get('panel_group_triple_semantics_uncertain'))
lowest_why = (
'the LOWEST group was taken because the catalogue marks its '
'panel-group caption semantics uncertain'
if uncertain else
'the LOWEST group was taken')
if kind == 'shear':
scale_why = ''
if factor != 1.0:
scale_why = ' -- scaled by {0} for f\'c {1} psi against a {2} '
'psi basis'.format(factor, fc_psi, basis_fc_psi)
table_why = (
'{0} maps to catalogue.inserts.{1}, answered from the '
'insulated_panel_anchor_capacities table ({2} rows, panel '
'groups: {3}): shear takes its 4:1 SWL {4} lb as the lowest on '
'the part ({5}); basis f\'c {6} psi{7}. {8}'
.format(family_name, designation, len(part_rows), panel_groups,
valued, lowest_why, basis_fc_psi, scale_why, rebar_note))
return valued, 'catalogue', (
'{0}; {1}'.format(plant_reason, table_why) if plant_reason
else table_why)
# Tension -- a WITH-bar rating, gated exactly like the catalogue's own
# tension bar handling.
claimed = _plant_claims_tension_bar(knowledge, family_name)
if claimed:
earned, gate_why = _tension_bar_corroborated(knowledge,
assembly_families)
if earned:
with_bar_why = (
'{0} maps to catalogue.inserts.{1}, answered from the '
'insulated_panel_anchor_capacities table ({2} rows, panel '
'groups: {3}): reinforced tension {4} lb WITH its bar '
'(the LOWEST on the part, {5}) -- {6}; basis f\'c {7} psi. '
'{8}'
.format(family_name, designation, len(part_rows),
panel_groups, valued, lowest_why, gate_why,
basis_fc_psi, rebar_note))
return valued, 'with_bar', (
'{0}; {1}'.format(plant_reason, with_bar_why)
if plant_reason else with_bar_why)
no_bar = (
'{0} maps to catalogue.inserts.{1}, answered from the '
'insulated_panel_anchor_capacities table, but it publishes '
'only the REINFORCED (with-bar) tension rating ({2} lb) and '
'that bar was not corroborated on this piece ({3}) -- '
'standing down rather than guessing. {4}'
.format(family_name, designation, valued, gate_why, rebar_note))
return None, None, (
'{0}; {1}'.format(plant_reason, no_bar) if plant_reason
else no_bar)
table_why = (
'{0} maps to catalogue.inserts.{1}, answered from the '
'insulated_panel_anchor_capacities table ({2} rows, panel groups: '
'{3}): reinforced tension {4} lb WITH its bar (the LOWEST on the '
'part, {5}); basis f\'c {6} psi. {7}'
.format(family_name, designation, len(part_rows), panel_groups,
valued, lowest_why, basis_fc_psi, rebar_note))
return valued, 'catalogue', (
'{0}; {1}'.format(plant_reason, table_why) if plant_reason
else table_why)
def _catalogue_entry(knowledge, family_name):
designation = catalogue_item(knowledge, family_name)
entry = (get(knowledge, 'catalogue.inserts', {}) or {}).get(designation) \
if designation else None
return entry if isinstance(entry, dict) else None
def lifter_edge_distance(knowledge, family_name):
"""The catalogue's minimum edge distance for a lifter family, or None."""
entry = _catalogue_entry(knowledge, family_name)
return entry.get('min_edge_distance_in') if entry else None
def lifter_safety_factor(knowledge, family_name, kind='tension'):
"""(factor, source, why) for one lifter family, or (None, None, why).
THE PART'S OWN NUMBER OR NOTHING. There is deliberately no fallback:
`catalogue.safety_factors` is null, and until 2026-08-31 it held a
global `shear_fos: 2.67` with no published source that every insert in
the file -- from any manufacturer -- inherited. The page that covers the
parts it actually reached (ALP catalog p53, 02/2024) heads the capacity
table "Capacity in Concrete 4:1 SWL" over BOTH load columns, so 4:1 is
the shear factor there too, and the entries now say so themselves.
Borrowing is the failure this refuses. `QL589SPG` in the same file
publishes 4,010 lb shear against 16,000 lb tension -- nothing like the
1:1 the QuikLift disk anchors show -- so a factor read off one product's
sheet says nothing about another's. (7,690 was that entry's tension
until 2026-09-01, when the with-tension-bar column replaced the
without-bar one; the contrast the paragraph draws is unchanged.)
A global value is still READ if some layer sets one, because a plant
that has its own tested factor must be able to state it; it comes back
as source 'global' and the sentence says it is not this part's number.
"""
field = 'shear_fos' if kind == 'shear' else 'tension_fos'
entry = _catalogue_entry(knowledge, family_name)
if entry:
block = entry.get('safety_factors')
if isinstance(block, dict):
value = _number(block.get(field))
if value:
cite = as_text(block.get('source') or '').strip()
return value, 'published', (
'{0}:1 is {1}\'s own published {2} factor{3}'
.format(value, family_name, kind,
' -- ' + cite if cite else
', and no source is recorded beside it'))
fallback = _number(get(knowledge, 'catalogue.safety_factors.' + field))
if fallback:
return fallback, 'global', (
'{0}:1 comes from the file-wide catalogue.safety_factors, not '
'from {1}\'s own datasheet -- a factor belongs to the part that '
'was tested, so this is a WEAKER claim'.format(fallback,
family_name))
return None, None, (
'no {0} safety factor is published for {1} and the catalogue '
'declares no file-wide one, so an ultimate rating cannot be turned '
'into a working load for it. Nothing was borrowed from another '
'product.'.format(kind, family_name))
def lifter_spacing_minimum(knowledge, family_name):
"""(inches, source, why) for the minimum spacing between two anchors.
ALP catalog p53, 02/2024, footnote: "Minimum anchor spacing is 2x the
published edge distance". Nothing in this tool had ever asked the
question, so two 8-ton anchors 20 in apart passed every check while the
page asked for 28.
Three outcomes, and the middle one is the reason this is not a constant
in a check function:
'published' the entry states `min_spacing_rule.published_in` -- a
sheet that gives a spacing outright OVERRIDES the
multiple, because a rule of thumb never beats a
measured number for the same part.
'derived' the multiple applied to the part's OWN published edge
distance. The finding prints the arithmetic, because
"28 inches" and "2 x 14 inches, the published edge
distance" are different claims and a reader must be
able to check the second.
None no rule, or a rule with no edge distance to double.
That is a could-not-judge and it is named; it is never
satisfied by another part's number.
"""
entry = _catalogue_entry(knowledge, family_name)
rule = entry.get('min_spacing_rule') if entry else None
if not isinstance(rule, dict):
return None, None, (
'no minimum anchor spacing is published for {0}, so how far '
'apart two of them must sit is not written down anywhere'
.format(family_name))
cite = as_text(rule.get('source') or '').strip()
tail = ' -- ' + cite if cite else ''
published = _number(rule.get('published_in'))
if published:
return published, 'published', (
'{0}" is {1}\'s own published minimum anchor spacing{2}'
.format(_trim_number(published), family_name, tail))
multiple = _number(rule.get('multiple_of_edge_distance'))
edge = _number(entry.get('min_edge_distance_in'))
if multiple and edge:
return multiple * edge, 'derived', (
'{0}" is {1:g}x {2}\'s published edge distance of {3}"{4}'
.format(_trim_number(multiple * edge), multiple, family_name,
_trim_number(edge), tail))
if multiple:
return None, None, (
'{0}\'s spacing rule is {1:g}x its published edge distance and '
'the catalogue publishes no edge distance for it, so there is '
'nothing to double'.format(family_name, multiple))
return None, None, (
'the spacing rule recorded for {0} states neither a published '
'spacing nor a multiple of the edge distance'.format(family_name))
def _trim_number(value):
"""9.0 -> '9', 12.5 -> '12.5'. A minimum printed as "18.0 inches" reads
like a measurement; it is a whole number off a table."""
try:
if float(value) == int(float(value)):
return str(int(float(value)))
except (TypeError, ValueError):
pass
return str(value)
def lifter_basis_fc(knowledge, family_name):
"""The concrete strength a lifter's catalogue ratings were derived at.
Handed on rather than applied here when the ratings are going somewhere
that will do its own derating -- a handling report checks one insert at
several concrete strengths, so a single pre-derated number cannot be
right for all of them.
"""
entry = _catalogue_entry(knowledge, family_name)
return entry.get('basis_fc_psi') if entry else None
# ----------------------------------------------------------- starter file
STARTER = u"""# Ticket QC knowledge -- hand written, grows with the job.
#
# Every key here overrides a built-in default; anything you leave out keeps
# the default. Run the Ticket QC button after editing: it reloads this file
# on every run, no Revit restart needed.
#
# This file lives beside the model and is NOT part of the tool's repository.
# It is where your office's naming conventions belong.
#
# It is the LAST of three layers -- defaults, then the plant file, then this.
# Anything true of the plant rather than of this job belongs one layer up:
#
# %APPDATA%\\WillTools\\knowledge.yaml
#
# Naming conventions and the casting bed live there. Override one here only
# when this particular job breaks the plant's own rule.
# ------------------------------------------------------------------- plant
#
# WHICH PLANT THIS JOB IS PRODUCED AT. One folder name, and it is the key
# that CHOOSES the plant layer above: the plant file is read from
# <root>/<plant>/knowledge.yaml.
#
# job:
# plant: northgate
#
# Leave it out and the machine-wide plant file (<root>/knowledge.yaml) is
# read instead, which is how this tool worked before per-plant folders and
# is perfectly normal. Name a plant with no folder and every button says so
# and stops -- it does NOT quietly fall back, because running one plant's job
# on another plant's rules is worse than not running at all.
# Parameter names are NOT here either. They belong to the office's Revit
# template, which outlives any one job, so `parameters:` lives in the plant
# file too. Put a role here only when THIS job departs from the template.
# ------------------------------------------------------------------- scope
#
# WHICH PIECES OF THIS JOB ARE YOURS TO JUDGE. Several engineers work one
# job; the knowledge in these files was validated against the pieces you
# detail and NOT against everybody else's. Naming the prefixes your pieces'
# marks start with stops every button issuing verdicts on work you cannot
# stand behind.
#
# Leave it out and NOTHING is filtered: every report judges every piece and
# says on its own face that no scope was declared. That is the honest
# default, not the safe-looking one -- an empty list here has never meant
# "judge nothing".
#
# Whatever you do declare, every report still prints one line naming how many
# pieces it left alone and of what kind. Out of scope is a state a report
# shows you, never work that quietly disappears. Anything you SELECT
# yourself is judged whatever its prefix, with a line saying the verdict
# rests on unvalidated knowledge.
#
# scope:
# mark_prefixes: [PNL] # the letters your marks start with
# declared_by: <name> # so somebody can argue with it
# note: >
# one sentence on why this is the boundary.
tolerances:
dimension_in: 0.375 # how far a dimension may drift before it fails
psi: 1.0
it_lb: 100.0 # jacking force vs design intent, in POUNDS
# (Will, 2026-08-30)
legend:
# A design's strand-pattern legend prints one strand count. When true, that
# printed count must equal the strands actually modelled on each piece.
# Set false if the printed number is a typical maximum for the design.
printed_strand_count_is_per_piece: true
width_must_match_panel: true
sheet:
# A ticket's printed length is nominal and runs a fraction over the
# modelled length on stemmed pieces by design. Turn on only if this
# project expects the two to agree.
check_tkt_dimensions: false
# ---------------------------------------------------------------- lifters
#
# Working load in pounds, per insert. Keys match as substrings against the
# family name, longest match wins. Fill these in and the lifter capacity
# check starts running; leave a family out and it is reported as unknown
# rather than silently passing.
lifters:
dynamic_factor: 1.5 # piece weight is multiplied by this
min_edge_distance_in: null # flag a lifter closer than this to an end
# `{}` and not a bare key: a bare `capacity_lb:` parses as null, and null
# here is the "a value was deleted" alarm shape `_note_capacity_override_emptied`
# refuses on (owner ruling 2026-09-05: keep refusing). Until 2026-09-05
# this starter wrote the bare key, so EVERY job created from it refused
# Panel Lift Check and Reinforcing QC the moment that check landed (04 Sep).
# To override a rating, replace the braces with entries:
# capacity_lb:
# ANCHOR_2T: 4000 # substring of the insert family name: lb
# ANCHOR_4T: 8000
capacity_lb: {}
concrete:
min_fci_ratio: null # e.g. 0.6 to require f'ci >= 0.6 * f'c
"""
def starter_text(plant=None, plant_basis=None):
"""The text `write_starter` would write. Revit-free.
With a non-empty `plant`, the job block naming it is active (uncommented)
right after the plant commentary, so a new job knows which plant layer it
reads instead of silently falling back to the machine-wide file (issue
#3). `plant_basis` is the short sentence the block cites for why that
plant is named; defaulting to 'Design Extract, from the model's producer
name' when a plant is given and none passed. With no plant, the text is
byte-identical to `STARTER`.
"""
if not plant:
return STARTER
basis = plant_basis if plant_basis else 'Design Extract, from the model\'s producer name'
block = (
'job:\n'
' plant: {0} # named by {1}\n'
'\n'
).format(plant, basis)
anchor = '# ------------------------------------------------------------------- scope'
return STARTER.replace(anchor, block + anchor)
def write_starter(doc, overwrite=False, plant=None, plant_basis=None):
"""Drop a commented knowledge.yaml next to the model. Never clobbers.
`plant`/`plant_basis` are passed to `starter_text`, so a fresh job names
the plant it was created for rather than running un-named.
"""
from vibe import designspec
path = designspec.knowledge_path(doc)
if os.path.isfile(path) and not overwrite:
return path, False
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(starter_text(plant=plant, plant_basis=plant_basis))
return path, True
# Owner, verbatim: "所有的出入都是 bed offset" -- all the discrepancy is
# bed offset.
PLANT_STARTER = u"""# WillTools -- plant standards.
#
# This file is READ BY EVERY PROJECT on this machine. It holds what is true
# of the plant rather than of one job: above all, how the plant names things
# and how its casting beds are laid out. A project's own knowledge.yaml is
# read after this one and overrides it key by key, so put a value here when it
# is the plant's rule and there only when one job breaks it.
#
# Read fresh on every run -- no Revit restart after editing.
#
# Like the project file, this is NOT part of the tool's repository and must
# not be committed to one. To share it across a plant, put it on a file share
# and point the WILLTOOLS_KNOWLEDGE environment variable at the full path --
# at the FILE for one plant, or at the FOLDER the per-plant folders sit in.
#
# ONE FILE PER PLANT. A job says which plant it is produced at with
# `job.plant:` in its own knowledge file, and that name is a FOLDER beside
# this one: <root>/<plant>/knowledge.yaml. A second plant is a new folder,
# never an edit to this file. A job that names no plant reads the
# machine-wide <root>/knowledge.yaml instead, which is this file.
# ------------------------------------------------------ face designations
#
# The parenthetical suffix on a bar or embed call-out -- `SR324 (F.F.)`,
# `SR310 (E.L.)`, `P76 W/ SR5602 (S.I.F.)` -- saying which face of the piece
# the part goes on. The abbreviations themselves are the trade's and are
# published in the tool's own knowledge/catalogue.yaml; WHICH SET this plant
# uses is a house habit and belongs here.
#
# SELECT BY ID, never copy the tokens down. A list, so it REPLACES rather
# than merges: what is named here is exactly what is in force.
#
# face_designations:
# use: [near_far, in_form]
#
# near_far N.F. / F.F. / E.L. measured from the VIEWING DIRECTION
# in_form T.I.F. / B.I.F. / S.I.F. measured from the CASTING BED
#
# Two vocabularies at once is normal: near/far names a layer position and
# in-form names a casting orientation, and one ticket prints both. The frame
# of reference is the whole point -- near/far FLIPS when the view flips and
# in-form does not -- so the two must never be merged into one list of words.
#
# A plant using a token nobody published states its own vocabulary here.
# `vocabularies` is a map, so this is an addition and the published ones stay:
#
# face_designations:
# use: [near_far, house_bed]
# vocabularies:
# house_bed:
# frame: form # or `view`; see catalogue.yaml frames
# measured_from: the casting bed
# tokens:
# - token: U.S.
# means: up side
# side: top
#
# Leave `use` unset and the face-designation check stands down and says so.
# It never guesses: reading a form-relative token as a view-relative one
# produces confident, wrong verdicts.
# ------------------------------------------------------------ nomenclature
#
# Check this section first on a new job. Everything downstream keys off it,
# and a wrong pattern here does not raise an error -- it quietly matches the
# wrong design, which is the one failure this tool cannot catch for you.
#
# THE DESIGN NUMBER
#
# A design is documented by a packet of sheets. Its cover sheet is also the
# design summary, and because it is a cover page it carries a page suffix:
# design PNL1 has cover sheet PNL1.0, then PNL1.A / PNL1.B for the rest of the
# packet. A piece's design number parameter may carry either form. So both
# sheet numbers and piece parameters are reduced through design_number_re
# before anything is compared, and the captured groups are joined to make the
# key.
#
# ^([A-Za-z]+)\\s*(\\d+)
#
# PNL1.0 -> PNL1 SWA1.0M -> SWA1 DBT1 -> DBT1
# PNL1.B1 -> PNL1 RBM9.0A -> RBM9
#
# Suffixes are packet pagination or an added note, not a different design:
# PNL3.A is PNL3 plus a note -- say an erection-only element that is removed
# in the field -- and is checked against PNL3's design. A plant that numbers
# designs 12-345, or W/1.0, or 1234-PNL-1, needs a different pattern here.
#
# WHICH SHEET IS THE DESIGN
#
# design_folder is the project-browser folder the packets live in. Sheets
# outside it are never treated as designs, which is what keeps hundreds of
# shop tickets out of the picker. Leave it empty to search every folder.
#
# VIEW NAMES DO NOT MATTER
#
# A view belongs to the design of the sheet it is placed on, whatever it is
# called -- a view still titled PNL2.0 sitting on sheet PNL1.0 is PNL1.0's.
# There is deliberately no rule about view naming.
nomenclature:
design_number_re: '^([A-Za-z]+)\\s*(\\d+)'
design_folder: '' # e.g. DESIGN PRODUCT
ignore_sheet_prefixes: # dead sheets, skipped outright
- zvoid
- zzvoid
- xxx
stale_view_re: '(\\s+Copy\\s+\\d+|\\s+old)\\s*$' # superseded duplicates
# ------------------------------------------------------------------ rails
#
# A design with strands always dimensions where they sit: a chain across the
# pattern, first segment from a rail to the first strand, then strand to
# strand, last segment back to the far edge. N segments locate N-1 strands
# and the chain total is the piece width.
#
# strand_datum the edge the chain is measured from. 'left_rail' means
# the left-hand edge where the concrete starts.
#
# left_rail_is_fixed whether the left rail is the bed's FIXED rail.
#
# true it is. Tolerance accumulates away from it and
# the dimension chain has a direction.
# false it is not -- and that is the whole statement.
# It does NOT mean the right rail is fixed
# instead: a fixed RIGHT rail does not exist in
# this industry (Will, 2026-08-30). false means
# this bed has no fixed rail to accumulate from.
# null nobody has recorded it. Do not guess.
#
# false does NOT stop the reasoning -- it starts a
# different one. With no fixed rail the piece can sit
# anywhere across the bed, so the strand pattern is
# free to slide off the design chain, and the whole
# difference is bed offset (Will: "all the discrepancy
# is bed offset"). The spacings between strands still have to
# be reproduced exactly; only the distance from the
# rail to the first strand may move, and only as far as
# max_bed_width_in leaves over. So false means: read
# max_bed_width_in and run BED-OFFSET.
#
# null is the one that stops: nobody has said, so
# nothing may be assumed either way.
#
# CLOSED 2026-08-30: _check_bed_offset now reads this
# flag, via ticketpage.bed_offset_status. true no
# longer falls through to the false behaviour -- it
# reports the offset as a WARN and says it was not
# judged, because no fixed-rail tolerance has been
# recorded. STILL OPEN for the engineer: what a
# true plant should actually tolerate. Until that is
# answered a true plant gets a WARN on every ticket
# whose pattern is off the chain at all.
#
# max_bed_width_in clear width of the bed, in inches. The strands are
# stressed across the whole bed, so a piece narrower than
# the bed can sit anywhere in it and the pattern slides
# with it. How far it may slide is not a judgement call:
#
# |offset| <= max_bed_width_in - panel width
#
# A 12'-0" bed leaves an 11'-11" piece exactly 1". Fill
# this in and BED-OFFSET starts checking it; leave it null
# and the spacings are still checked, the slide is not.
rails:
strand_datum: left_rail
left_rail_is_fixed: null
max_bed_width_in: null # e.g. 144 for a 12'-0" bed
# --------------------------------------------------------------- drawings
#
# full_length_bar_fraction
# Checklist 5.2. The standards place reinforcing by
# class: a FULL LENGTH bar is drawn in Section A-1, a
# PARTIAL length bar and any trim / corner bar in the
# plan, a FULL WIDTH transverse bar in Section B-1.
# The manual never says how much of the panel "full"
# is, so this is the plant's number, as a fraction:
#
# 0.9 a bar reaching 90% of the panel counts
# null nobody has said -- 5.2 checks the embeds
# and stands its reinforcing half down,
# saying so, rather than guessing.
#
# Guessing it is the expensive mistake: too high and
# every full-length bar is reported missing from the
# BED PLAN where it must not appear at all.
# reinforcing_families
# Which modelled families are reinforcing bars. Revit's
# own category ('Structural Rebar') answers it for real
# rebar and is used first; a plant that models bars as
# generic-model families must name them here, matched as
# substrings, or nothing in the model says they are bars.
#
# Empty is safe but not free: a bar this does not name
# keeps going through the old residual and can be
# reported as missing from the BED PLAN when it belongs
# in a section instead.
# reinforcing_family_patterns
# THE SAME FACT AS reinforcing_families, written as a
# REGULAR EXPRESSION per entry instead of a literal
# substring -- for a family name that wraps an OPEN-ENDED
# CODE no enumeration could keep up with, e.g. a bend-type
# code inside a fixed wrapper:
#
# reinforcing_family_patterns:
# - '^ACME-.+_\\(SCHEDULED\\)$'
#
# Checked case-insensitively with re.search, AFTER the
# plain substrings above, and counts as the SAME evidence
# class as reinforcing_families -- either one being
# non-empty is 'list' mode and wins over the rule. A
# pattern that fails to compile never matches, silently,
# the same fate a plain-list token gets when it never
# appears in a family name.
# reinforcing_rule THE SAME FACT AS A RULE, AND USUALLY THE BETTER SHAPE.
# A list is an enumeration and it goes stale in silence:
# the day a bar family is added and nobody re-lists it,
# 5.6 and 5.7 stop seeing those bars and report nothing,
# which reads exactly like nothing being wrong.
#
# A rule says it once -- a PARAMETER to read, plus a
# TOKEN to find in its VALUE -- and a family added
# tomorrow is recognised with no edit:
#
# reinforcing_rule:
# parameter: member.manufacture_component
# parameter_verified: true
# read_from: [instance, type]
# value_contains: [rebar]
#
# `parameter` names a ROLE, not a parameter name: map the
# role to your own parameter under `parameters:` below.
# READ_FROM MATTERS. On at least one plant the parameter
# lives on the INSTANCE for some elements and on the TYPE
# for others, never both -- so reading one alone finds
# nothing on half the model and looks entirely clean.
#
# ALL THREE ARE REQUIRED or the rule does not run: the
# role, at least one token, and `parameter_verified`.
# Until somebody has read the parameter off a real model
# leave the flag FALSE and put the reason in
# `parameter_unverified_because` -- the rule is then
# dormant AND SAYS SO. A guessed parameter
# un-recognises every bar at once.
#
# scopes: lets checklist 5.6 and 5.7 see different
# populations, because the token is not a clean boundary
# -- some bar-formed hardware carries it and some real
# reinforcement does not. Empty until somebody rules on
# it; empty means both rows see the same population.
#
# reinforcing_families WINS WHERE IT IS SET. Empty the
# list to hand over to the rule.
# reinforcing_family_overrides
# Per family, exact name, case-insensitive:
# `MY_FAMILY: true` forces one in, `false` forces one
# out. Checked FIRST, so a job corrects one family
# without restating the list or the pattern. A map, so
# the layers deep-merge.
drawings:
full_length_bar_fraction: null # e.g. 0.9
reinforcing_families: [] # e.g. [SR, TRIM_BAR]
reinforcing_family_patterns: [] # e.g. ['^ACME-.+_\\(SCHEDULED\\)$']
reinforcing_rule:
parameter: member.manufacture_component # a ROLE -- map it below
parameter_verified: false # VERIFY IT AGAINST A MODEL FIRST
parameter_unverified_because: null
read_from: [instance, type] # mutually exclusive per element
value_contains: [] # e.g. [rebar]
scopes: {} # e.g. {'5.7': {except: [LIFTING]}}
scopes_unresolved_because: null
reinforcing_family_overrides: {} # e.g. {ODD_BAR_FAMILY: true}
# ------------------------------------------------------------- parameters
#
# What this office's Revit template calls each thing the tool reads. Nothing
# works until this is filled in -- the code knows only roles, never names.
# Self Check lists every role still unmapped.
#
# This belongs to the plant because it belongs to the TEMPLATE, and the
# template outlives any one job. Override a role in a project's own file only
# when that job genuinely departs from the template.
#
# One name, or several tried in order:
# length: PANEL_LENGTH
# length: [PANEL_LENGTH, Length]
#
# parameters:
# panel:
# design_number: PANEL_DESIGN_NO # groups pieces to a design summary
# length: PANEL_LENGTH
# width: PANEL_WIDTH
# unit_weight: PANEL_UNIT_WEIGHT # concrete density, pcf
# weight: PANEL_CAST_WEIGHT # lb
# member:
# mark: MEMBER_MARK
# length: MEMBER_DEV_LENGTH # a bent bar's DEVELOPED length
# manufacture_component: MEMBER_CLASS # what the plant says a member is
# panel_type:
# thickness: PANEL_THICKNESS # on the type, not the instance
# strand_type:
# diameter: STRAND_DIAMETER
# description: STRAND_DESCRIPTION # carries the grade, e.g. '270 KSI'
# wwf_type:
# description: MESH_DESCRIPTION
# sheet:
# design_number: TICKET_DESIGN_NO
# length: TICKET_LENGTH
# width: TICKET_WIDTH
# depth: TICKET_DEPTH
# plant: TICKET_PLANT # optional
# ---------------------------------------------------------------- families
#
# What counts as what, matched as case-insensitive substrings of the family
# name. These are plant-wide: the same catalogue is used on every job.
#
# families:
# strand_include: [STRAND]
# strand_exclude: [LOOP, GROMMET]
# lifter_include: [LIFT, ANCHOR]
# lifter_exclude: [SHEAR]
# wwf_prefix: WWF
# ----------------------------------------------------------------- lifters
#
# A lifter's rating is looked up in TWO hops, because a plant models its own
# part number rather than the manufacturer's designation:
#
# family name in the model
# -> lifters.catalogue_alias (here: what our part number really is)
# -> catalogue.inserts (product data: what it is rated for)
#
# Put the alias here. Put the rating in the catalogue -- it is published
# product data, the same for everyone, and it does not belong to this plant.
#
# lifters:
# dynamic_factor: 1.5
# min_edge_distance_in: null
# catalogue_alias:
# P000: SAMPLE_ANCHOR_4T # our part number -> catalogue item
# capacity_lb:
# # Direct override, checked BEFORE the catalogue. For a one-off that
# # nobody has catalogued yet -- not the place for ordinary hardware.
# ONE_OFF_INSERT: 5000
# # A part whose tension and shear are DIFFERENT numbers needs the dict
# # shape instead of a bare number -- see lifter_capacity()'s docstring
# # for the full three-state rule (a real number / not rated / a
# # deliberate "forbidden"):
# SPLIT_RATING_INSERT:
# tension_lb: 13800
# shear_lb: 3860 # a bare 0 here means NOT RATED, not zero
# # shear_lb: forbidden # this is how to state a REAL zero
# --------------------------------------------------------------- handling
#
# How a piece is picked up and carried. Plant knowledge: the same way every
# job leaves the plant.
#
# handling:
# rig_mode: rolling # valid: rolling | static (NOT static_lifting)
# sling_angle_deg: 60
# dunnage:
# mode: null # where the piece sits on the trailer:
# # null old lift approximation
# # lifter_rows same lines as stripping
# # fraction inset fraction*L each end
# fraction: null # required when mode is fraction, e.g. 0.2
# --------------------------------------------------------------- catalogue
#
# Published product data. Not secret, not job-specific -- but still data, so
# that correcting a rating is an edit rather than a release.
#
# catalogue:
# inserts:
# SAMPLE_ANCHOR_4T:
# working_load_lb: 8000
# ----------------------------------------------------------------- legend
#
# A ticket's strand/reinforcing call-outs live in a pattern-legend VIEW on
# plants that draw one. The DEFAULT prefixes are empty -- a plant that draws
# none must not fail tickets for a view it never draws -- so a plant that
# DOES draw one names its views here and the LGD checks re-engage:
#
# legend:
# pattern_prefixes: [STRAND PATTERN, REINFORCING PATTERN]
# section_strand_view: '' # view whose text prints the per-piece (n)
# ------------------------------------------------------------------ notes
#
# Checklist 6.5. The standing instructions on a ticket -- stacking, erection,
# picking/stripping -- are each their own small view. Name them and the check
# reports any that print a heading with nothing under it. Left empty (the
# default) it reports NOT RUN, never a pass:
#
# notes:
# instruction_views: [DUNNAGE, ERECTION, PICKING-STRIPPING]
#
# A plant that draws SEVERAL VARIANTS of the same note -- a ticket places one
# of them -- names them as a group instead, and the check wants any one:
#
# notes:
# instruction_views:
# ERECTION: [ERECTION, ERECTION (V2)]
#
# A LIST replaces the layer below it; a MAP deep-merges into it. Where the
# note views differ by PRODUCT TYPE, name each family under
# instruction_view_sets (a map, so it deep-merges) and only the family a
# ticket actually carries is required of it.
# ---------------------------------------------------------- nomenclature
#
# Checklist 1.4 reads the design label a VIEW NAME carries ('TICKET NOTE -
# WP2- ...' placed on a WP1 ticket). It derives the pattern from
# design_number_re by dropping the '^' anchor; set this only if that
# derivation is wrong for your view names:
#
# nomenclature:
# view_design_token_re: '([A-Za-z]+)\\s*(\\d+)'
"""
def write_plant_starter(overwrite=False, plant=None):
"""Create a plant knowledge file. Never clobbers an existing one.
With no `plant` this writes the machine-wide file, as it always has.
Naming a plant writes `<root>/<plant>/knowledge.yaml` -- which is how a
SECOND plant is set up: a new folder, not an edit to the first one's
file. An unusable plant id writes nothing and says so.
"""
from vibe import designspec
path = designspec.plant_knowledge_path(plant)
if path is None:
return None, False
folder = os.path.dirname(path)
try:
if folder and not os.path.isdir(folder):
os.makedirs(folder)
except Exception:
pass
if os.path.isfile(path) and not overwrite:
return path, False
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(PLANT_STARTER)
return path, True