52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
7724 lines
376 KiB
Python
7724 lines
376 KiB
Python
# -*- 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 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,
|
||
},
|
||
|
||
# ---- 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.
|
||
'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.
|
||
plant, plant_path = plant_id(doc), None
|
||
if 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
|
||
|
||
# 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
|
||
if final != plant:
|
||
merged['_plant_layer_stale'] = (plant, final)
|
||
return merged
|
||
|
||
|
||
def plant_id(doc):
|
||
"""The plant this job is produced at, read BEFORE any layer is merged.
|
||
|
||
A pre-pass, and the only reason it exists is the ordering trap documented
|
||
in `load()`: the key that chooses the plant layer lives in a layer that
|
||
merges after it.
|
||
|
||
Reads the two layers that may legitimately set it, in the same order and
|
||
with the same precedence the real merge gives them -- the job file, then
|
||
the project config on top -- so the plant chosen here is the plant the
|
||
finished tree will name. Neither file existing, the answer is None, which
|
||
means "no plant named" and is not an error.
|
||
"""
|
||
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. `_merge_config_file` knows
|
||
# this and so must anything that reads a key out of one.
|
||
body = raw.get('knowledge')
|
||
_deep_update(holder, body if isinstance(body, dict) else raw)
|
||
|
||
plant = get(holder, 'job.plant')
|
||
if plant is None:
|
||
return None
|
||
plant = as_text(plant).strip()
|
||
return plant or None
|
||
|
||
|
||
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 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 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 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
|
||
|
||
|
||
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; taken as
|
||
stated, no catalogue or tension-bar question asked.
|
||
'catalogue' the catalogue's own rating, UNCONDITIONALLY -- either
|
||
this part carries no tension-bar gate, or it does and
|
||
this plant has made no claim about one. See
|
||
`_plant_claims_tension_bar`.
|
||
'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 shear pinned at 0 while tension depends on a sling
|
||
angle the chart never picked, so a tension query stands down naming it
|
||
rather than silently choosing one angle over the other).
|
||
|
||
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.
|
||
"""
|
||
direct = _longest_substring_match(
|
||
get(knowledge, 'lifters.capacity_lb', {}), family_name)
|
||
if isinstance(direct, dict):
|
||
field = 'shear_lb' if kind == 'shear' else 'tension_lb'
|
||
value = direct.get(field)
|
||
if value is not None:
|
||
return value, 'direct', (
|
||
'{0} is a job- or plant-pinned {1} in lifters.capacity_lb.{2}, '
|
||
'taken as stated ({3} lb)'.format(family_name, kind, field,
|
||
value))
|
||
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 columns[choice], 'direct', (
|
||
'{0} publishes tension by sling angle in '
|
||
'lifters.capacity_lb ({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]))
|
||
return None, None, (
|
||
'{0} publishes tension only at {1} deg in '
|
||
'lifters.capacity_lb (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))
|
||
# 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 None, None, why
|
||
return None, None, (
|
||
'{0} is a job- or plant-pinned entry in lifters.capacity_lb but '
|
||
'publishes no {1}'.format(family_name, field))
|
||
if direct is not None:
|
||
return direct, 'direct', (
|
||
'{0} is a job- or plant-pinned rating in lifters.capacity_lb, '
|
||
'taken as stated ({1} lb)'.format(family_name, direct))
|
||
|
||
designation = catalogue_item(knowledge, family_name)
|
||
if not designation:
|
||
return None, None, (
|
||
'no catalogue alias for {0}; lifters.catalogue_alias does not '
|
||
'name it'.format(family_name))
|
||
entry = (get(knowledge, 'catalogue.inserts', {}) or {}).get(designation)
|
||
if not isinstance(entry, dict):
|
||
if entry is None:
|
||
return None, None, (
|
||
'{0} maps to catalogue designation {1}, which is not '
|
||
'published in catalogue.inserts'.format(family_name,
|
||
designation))
|
||
return entry, 'catalogue', (
|
||
'{0} maps to catalogue.inserts.{1}, a bare published number'
|
||
.format(family_name, designation))
|
||
field = 'shear_load_lb' if kind == 'shear' else 'working_load_lb'
|
||
rated = entry.get(field)
|
||
if rated is None:
|
||
return None, None, (
|
||
'{0} maps to catalogue.inserts.{1}, which publishes no {2}'
|
||
.format(family_name, designation, 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:
|
||
return rated * concrete_derate(entry, fc_psi), 'with_bar', (
|
||
'{0} ({1}) is rated {2} lb WITH its tension bar -- {3}'
|
||
.format(family_name, designation, rated, gate_why))
|
||
fallback = entry.get('tension_without_tension_bar_lb')
|
||
if fallback is None:
|
||
return None, None, (
|
||
'{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 fallback * concrete_derate(entry, fc_psi), 'without_bar', (
|
||
'{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 rated * concrete_derate(entry, fc_psi), 'catalogue', (
|
||
'{0} maps to catalogue.inserts.{1}, rated {2} lb {3}'
|
||
.format(family_name, designation, rated, kind))
|
||
|
||
|
||
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
|
||
capacity_lb:
|
||
# Substring of the insert family name: rated load in pounds.
|
||
# ANCHOR_2T: 4000
|
||
# ANCHOR_4T: 8000
|
||
|
||
concrete:
|
||
min_fci_ratio: null # e.g. 0.6 to require f'ci >= 0.6 * f'c
|
||
"""
|
||
|
||
|
||
def write_starter(doc, overwrite=False):
|
||
"""Drop a commented knowledge.yaml next to the model. Never clobbers."""
|
||
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)
|
||
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
|
||
|
||
# --------------------------------------------------------------- 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
|