Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

419 lines
22 KiB
Markdown

# The takeoff, and the four ways a takeoff lies
What a job contains — hardware by part number, reinforcing by bar mark — per
piece and in total. Read this before changing anything in `takeoffcore.py`,
because the arithmetic is trivial and every hard part is a counting rule
somebody already paid to learn.
## Where it lives
| | |
| --- | --- |
| `lib/vibe/takeoffcore.py` | classification, both totals, nesting detector. No Revit. |
| `lib/vibe/takeoffcount.py` | the control-number multiplier and its sources. No Revit. |
| `lib/vibe/takeoffreport.py` | markdown to read, two CSVs to order from. No Revit. |
| `lib/vibe/takeoffgather.py` | the only file that touches a document. Read only. |
| `WillTools.tab/Setup.panel/Config.pulldown/Takeoff.pushbutton/` | the button — a row in the Config dropdown, see below. |
| `tests/test_takeoff*.py` | 105 tests, host CPython, no model and no licence. |
**The button is behind the Config arrow, not on a panel.** It was off the
ribbon entirely for one day: owner, 2026-08-30, he did not want to work on
this for a while and did not want it on screen in the meantime, so the
`*.pushbutton` folder moved to `WillTools.extension/parked/`, outside every
`*.tab` and therefore never scanned by pyRevit. On 2026-08-30 he asked for it
back inside `Config` ("recon 跟 takeoff 加回来吧, 放到 Config 里面去"), and
`parked/` was deleted with its last resident. A pulldown row costs no panel
space, which is what makes on-the-ribbon-but-out-of-the-way possible at all.
Nothing about the tool ever moved with it — the four library modules, the
tests and the knowledge keys are untouched through both moves, so the takeoff
has stayed callable headless (`tools/rvt.py exec`) throughout and everything
below still describes working code.
**It is not a second model sweep.** `reinforceindex` already walks every
assembly, resolves its members and records `families` — family name to
instance count — on every piece record. Nothing read it. The takeoff's input
is `reinforceindex.read(doc)`, so a press costs a cached read (~1.4 s) plus
one signature pass for the multiplier (~0.3 s). No view is generated and no
geometry is solved.
It deliberately does not refresh the index. A takeoff off a stale index is a
takeoff of the model as it was, which is knowable — `coverage.built_at` says
when. A button that silently spends ten seconds rebuilding is a button that
sometimes freezes Revit and never says why.
## Lie 1 — counting marks instead of pieces
An assembly is a **mark**. A mark is one drawing. The pieces on the floor are
**control numbers**. Identical pieces share a mark, so a takeoff that counts
marks under-orders every material in the job.
Measurable without opening Revit, off the exported ticket filenames
(`<job>-<control>_<mark>`): **151 tickets across 71 distinct marks**, and the
heaviest single mark issued **eighteen** of them — on a job where one ticket
is one piece, which is a declared fact and not the code's to assume; see
[What one ticket covers](#what-one-ticket-covers-and-who-says-so) below. In
the model itself the job reconciliation measured **2480 control-numbered
elements resolving to 1776 pieces, against 755 assemblies**.
The takeoff multiplies, and prints both figures on every row — `per_mark` is
one of each mark, `per_job` is what the job needs — with the multiplier and
its source, because the two answer different questions and a reader will want
to check one against the other.
### The sources, ranked (`takeoffcount.ORDER`)
control_numbers the control numbers on the model's own elements,
grouped by mark. AUTHORITATIVE — this is where they
actually live. Gathered through
`jobreconscan.gather`, the job reconciliation's own
gatherer, so the takeoff and the reconciliation
cannot disagree about how many pieces the job has.
ticket_files the exported tickets. Ground truth for the marks it
covers, silent about the rest, so it is a cross-check
rather than a source. Point `takeoff.ticket_folder`
at the export directory. ONLY A PIECE COUNT WHERE ONE
TICKET IS ONE PIECE — see the next section.
assembly_instances duplicated assembly names. Last, and empty on this
job. See below.
`ORDER` is the full ranking; which of it a given job may actually use is
`takeoffcount.counting_plan()`, off the declared ticket unit. A source the
plan withdraws is **dropped, not demoted** — ranking it last would let it
through on exactly the marks nothing else covers, which is where a wrong
multiplier does its damage.
Anything none of them covers is 1 with basis `assumed_one`, and the report
prints that as an **undercount**, not as a quantity. A multiplier nobody
could source is not a multiplier of one — and that rule is the only reason
the next section exists.
### What one ticket covers, and who says so
`nomenclature.ticket_unit` — **declared in the job layer, read in
`takeoffgather.run`, acted on by `takeoffcount.counting_plan()`.** It is the
one knowledge key the multiplier reads, and it decides exactly one thing:
whether a folder of exported tickets is a count of **pieces** or a count of
**drawings**.
It has to be declared because the plant can do it either way and the manual
shows both. One ticket per control number makes each filename one piece. One
ticket covering a whole mark — one drawing, a `QUANITY` field, several
control numbers printed on its face — exports **one filename per mark**, and
reading that folder as one piece per ticket returns a confident `1` for a
mark that is really seven. That is the `assembly_instances` undercount in
different clothes, which is why the ranking stopped being a constant.
| declared | what happens |
| --- | --- |
| `control_number` | one ticket per control number. All three sources rank exactly as `ORDER` says, and the ticket-against-model cross-check runs. |
| `piece_mark` | one ticket covers the mark. `ticket_files` is **withdrawn** — it is not a piece count — and the cross-check **stands down with a sentence** rather than reporting every mark as a disagreement. |
| *nothing* | the run cannot tell which kind of folder it is reading. `ticket_files` is withdrawn **here too**, and the refusal names the key to set. |
**Undeclared is a refusal and not a fallback, and that was a judgement
call.** Falling back to "one ticket is one piece" can only ever *under*-count,
and it would arrive wearing a source's name (`ticket_files`) instead of
`assumed_one` — a number that says *judged, found one* where the truth is
*could not judge*. Withdrawing the source costs nothing that was known: the
marks it covered fall to `assumed_one`, which the report already prints as an
undercount in those words. It is the same discipline `takeoff.not_ordered` and
`notes.instruction_views` already apply to an unset switch: never fall back
silently. `rails.left_rail_is_fixed` is the near case rather than the same
case — unset there does not withdraw the check, it runs the no-fixed-rail path
and prints "unrecorded" in every message it writes, which is the same rule
answered a different way because a bed-width ceiling holds under either
reading. See `docs/knowledge-override.md`. A value nobody implemented (a typo, say) is a refusal too, for the
same reason: reading it as the default does the opposite of what was asked.
The page says which of the three states it was in — on the multiplier
section's folded header (`ticket unit: …` / `NOT DECLARED`) and in its body —
and the section **opens** only when an undeclared unit actually left marks
with no source at all. A withdrawn source appears in the sources table as
`counted as pieces: NO`, never as a source that happened to find nothing.
### The source that was wrong, and how it was caught
The first version of this took the multiplier from **how many assembly
instances share a name**, on the reasonable-sounding claim that three
identical pieces of one mark are three assemblies. It was written down as
unverified, in the code and in this file, and it was verified through the
bridge:
instances_total 755, distinct_names 755, names_with_more_than_one 0
Every assembly name is unique. That source returns 1 for every mark, so
**every `per_job` figure silently equalled its `per_mark` figure** — a
whole-job undercount wearing the costume of a sourced number, which is the
precise failure this module is written to prevent.
Two things changed, and the second matters more than the first:
- the model's control numbers, off framing elements, are now the first
source, and `assembly_instances` is ranked last;
- **`from_assembly_names` will no longer emit a count of one.** A name that
appears once is the *absence* of evidence, and it must not produce the same
output as evidence of one piece. With no duplicates the source now returns
nothing at all and every mark falls visibly through to `assumed_one`.
There is a test named after that bug so it cannot come back.
### Where the control numbers are
On **structural framing**. The reconciliation swept six categories — walls,
floors, columns, foundations and generic models included, 17,239 elements —
and found control numbers on none of the other five. The takeoff therefore
asks `jobreconscan` for `OST_StructuralFraming` only, which takes its
multiplier from about six seconds to about one.
That is a deliberate narrowing of somebody else's careful default, so it is
**reported rather than assumed**: the page names the categories it searched
and says that a piece modelled in any other category was not seen. Pass
`categories=None` to sweep all six.
A control field may hold a range — `50120-50123` is four pieces, not one
token — and the takeoff calls `jobrecon.expand_control_field` for that rather
than writing a third copy of the rule. Its unreadable tokens come back and
are named on the page.
### `member.control_number` is not mapped yet
The role exists and nothing maps it, so **today the takeoff refuses the
multiplier rather than inventing it**: the multiplier section leads with a
refusal, in those words, and every figure below is `assumed_one`. The same
role is what Job Recon needs, and mapping it once serves both:
```yaml
parameters:
member:
control_number: <whatever the template calls the piece's control number>
```
### The join, and why it is printed
The takeoff keys on the **assembly name**; the multiplier keys on the piece
**mark**. Measured, those are the same string — but measured is not
guaranteed, and if they ever diverge the failure is silent and total: every
mark falls through to `assumed_one` while the source reports hundreds of
marks found. So `join_quality` counts the join both ways and the page shouts
when a source names things the takeoff has never heard of.
It earned its keep immediately. On its first run it reported twelve stray
marks — `X4`, `X4-1`, `X4-2` … — because the export folder also holds the
detail sheets, whose filenames take the identical three-part shape
(`<job>-SHEET_X4-1.pdf` parses out a control number of `SHEET`). A control
number now has to contain at least one digit; letters in one are still fine,
letters alone are not. `unparsed_ticket_files` returns what was skipped.
## Lie 2 — counting a nested family twice
A member whose `SuperComponent` is set is a child and **still appears in
`GetMemberIds()`**, so a family nested inside another is counted once as
itself and once inside its parent.
The takeoff does not guess at this and does not subtract on its own
arithmetic. It does two things:
- a nesting **declared** in `takeoff.nested_in` is honoured — the child
leaves the order list for a section of its own;
- a nesting nobody declared is **detected and printed as a suspicion**.
`suspected_nesting()` looks for family pairs whose counts move in lockstep
across marks *and whose count varies* from mark to mark. The variance
filter is what makes the list readable: two parts that are one each on the
same forty pieces agree perfectly and mean nothing, and without that filter
the list came back forty rows long with the real pairs buried in it.
`agreement` is the fraction of marks where the two counts match exactly, and
it is not required to be 1.0 — the strongest documented nesting on this model
runs 938 against 943, five marks apart for reasons that are somebody's to
explain, and an exact-equality test finds it not at all.
Two pairs the detector produced independently match nestings already written
down elsewhere in this repository, which is the closest thing to a validation
it can get without the bridge.
**The contradiction, now settled.** `docs/revit-bridge-context.md` said
`SuperComponent is not None` marks a CHILD; the comment in
`reinforceindex.extract` said the opposite — that nearly every member carries
one and filtering on it empties the count. Measured through the bridge over
320 sampled members: **`SuperComponent` is populated and points at the
parent**, e.g. an `SP03` member returns a column-shoe assembly family as its
super component. The bridge context is right and the index comment needs
re-examining — it may have been filtering the wrong way round. That is
`reinforceindex.py`'s owner's call, not this module's.
It changes the *explanation* of the nesting suspects and not the takeoff:
this module never reads the field, and the suspects are still a suspicion
list, because knowing that nesting exists is not knowing which of two
lockstep families is the child.
## Lie 3 — silently deciding about attached bars
`drawings.annotation_covered_by_parent` already records which bar marks
arrive welded to which part, and the same bars appear in
`interference.exempt_families` — a bar in one list and not the other means
one of the two is wrong about it.
Those bars are real steel and are **always counted**. Whether they are
**ordered** is a purchasing question, not a geometry one, so it is a switch:
`takeoff.order_attached_bars_separately`, default false. Either way they get
their own section with the parent named. Silently either way is the one thing
not on offer.
## Lie 4 — a tidy total
Two different silences, and the report leads with both rather than closing
with them.
**Coverage.** The index builds only pieces marked reinforced and not on hold.
The rest of the job is not in these quantities, so `population` gives the
report an honest denominator and `marks_not_indexed` names the hole.
**Could not judge.** A piece the index could not measure is not a piece with
no hardware. `features is None` is about geometry and does not stop a
takeoff — the `families` dict survives nearly every failure path — but a
record with no families at all is listed with its reason and contributes
nothing rather than contributing a zero.
**Unclassified.** A family no rule can place is listed **by family name**
with its count and its example pieces. It is never bucketed into "other". An
under-count that looks tidy is worse than a messy honest one.
Every row also carries a `basis` — `declared` when a knowledge key named the
family outright, `rule:*` when a pattern decided it, `none` when nothing did
— and the report totals them, so the inference budget for the run is on the
page.
## The one index field this wants
**Hardware resolves off the family name and reinforcing largely does not.**
At this plant a reinforcing family is a *bend shape*: one shape family
carries one bar mark on one piece and a different one on the next. So the
`families` dict cannot name a bar, and roughly nine tenths of the reinforcing
instances in this model come out as `bar_shape` — counted, real, SKU unknown.
The fix is one field on `reinforceindex.extract`'s record, beside `families`,
and it is a proposal rather than an edit because another agent owns that
file:
```python
# in the member loop, next to the families counter
mark = Q._member_mark(member, know) # ticketqc, already imported
if mark:
marks[mark] = marks.get(mark, 0) + 1
# ... and on every piece_record(...) call that already passes families=
marks=marks,
```
`_member_mark` already returns the mark an engineer says out loud — the bar
mark on a bar, the part number on an anchor. Cost: one parameter read per
member on a pass that already walks every member, no geometry, no view. It
also needs `marks` added to `reinforcecore.piece_record`'s signature and
default (`marks or {}`); `_pack`/`_unpack` copy it for free.
`takeoffcore.piece_lines` **already consumes it** —
`_bar_lines_from_marks`, with a test standing behind it. Nothing else in this
module changes when the field appears. That was the point of writing it
before the field existed.
**Measured, and it is not a clean win — say so before anyone spends the
afternoon on it.** Sampled through the bridge on one column, 40 members: only
**9 resolved a mark**, and the ones that did were hardware and the host —
`SP28`→`SP28`, `SP27`→`SP27`, `SP50`→`SP50`, `COLUMN`→`CL111`. The rest
returned null. So on that sample the field would resolve **hardware marks,
which the family name already gives us, and not the bars, which are the whole
reason to want it.**
That does not kill the idea, but it changes what has to happen first: sample
a piece whose bend-shape families are the bulk of its members and see whether
whatever the `member.mark` role resolves to is populated there. If bars carry no mark parameter in
this model, then bar marks are not in the model at all in a form the index
can read, and the honest answer is that the `bar_shape` rows stay
unresolved — which the report already says plainly.
Two things that path is careful about either way: a mark counter carries
hardware marks too (a hardware family and its mark are the same string), so
only marks that read as a bar and are not already a counted family are taken
from it; and the shape families it replaces are skipped, so nothing is
counted twice.
## Knowledge
Everything job-specific is in the `takeoff:` block of the job layer — prefixes,
shape markers, what is not orderable, declared classes and nestings, the
switch, and `ticket_folder`. `takeoffcore.DEFAULTS` ships only the prefix
patterns.
One key outside that block is read too: **`nomenclature.ticket_unit`**, the
job's declaration of what one exported ticket covers. It is not in
`knowledge.DEFAULTS` and deliberately has no built-in value — see the section
above for why unset is a refusal rather than a default.
`not_ordered` **defaults to empty on purpose.** A default list of things to
ignore is a default under-count. Left empty, the panel itself and the
modelling constructs surface as unclassified, which is the loud version of
the same fact and the one a person can act on.
## What is still open, for the engineer
The unclassified list is the work queue and it is short. Each of these is
counted and none is on the order; classify one by naming it in
`takeoff.family_class`, or exclude it in `takeoff.not_ordered`. Best guesses,
offered as questions and not written into knowledge:
| family | probably | why it was not decided here |
| --- | --- | --- |
| an oval patch former paired 1:1 with the face lifter | a consumable, or a nested part of the lifter | it is the detector's strongest nesting suspect; deciding it as hardware would double-count it |
| `EDGE_PIN` | orderable hardware | ~1,950 instances and no part number in the name; a real purchase line if it is one |
| `FILIGREE_SLAB_TRUSS` | orderable reinforcing | if it is a truss it belongs on the reinforcing order with a unit of length, which this takeoff does not yet compute |
| `DB_PATCH` | a patch or former, paired 1:1 with a part | same suspect list as the oval patch |
| `QL200` | lifting hardware | it is not matched by `families.lifter_include`, so the capacity checks are not seeing it either — worth checking there too |
| `EDGE_INSULATION` | orderable by area, not by count | a count of instances is the wrong unit for it |
| `SLEEVE` | orderable hardware | tracks the column count exactly |
| `REBAR_ASSEMBLY_SINGLE` | reinforcing | may be a container whose contents are already counted |
| two connection assemblies | orderable, one or four of each | too few to matter, listed for completeness |
## What the live model has already answered
`_scratch/claude_takeoff_verify.py` was run through the bridge. All three
questions came back and the answers are folded into the code above; the
script is kept because re-running it is how you check the answers still hold
after a model changes.
```bash
PYTHONIOENCODING=utf-8 <py312> tools/rvt.py exec _scratch/claude_takeoff_verify.py
```
Read only, one narrow question set, no view enumerated, no geometry solved.
It carries a signature key — check `CLAUDE_TAKEOFF_VERIFY` before reading
anything else, because there is one routes server on one port and anyone
working on this model shares it.
| | answer | what changed |
| --- | --- | --- |
| Q1 multiplier | **negative** — 755 instances, 755 distinct names, 0 duplicates | `assembly_instances` demoted to last and made unable to emit a 1; control numbers off framing elements are the source |
| Q2 `SuperComponent` | **populated**, points at the parent, 320 members sampled | the nesting suspects' explanation; the takeoff is unchanged, it never reads the field |
| Q3 member marks | **partial** — 9 of 40 sampled members resolved a mark, all hardware or the host | the proposed index field is no longer presented as a clean win |
### Still worth verifying
- **Once `member.control_number` is mapped**, run the button and read the
join line. `matched` should be most of the 690 indexed marks; a large
`unmatched_source` means the assembly name and the piece mark are not the
same string and the whole per-job column needs rethinking.
- **The multiplier against the exported tickets.** For the 57 marks the
export folder covers, the model's control-number count and the ticket count
should agree. The page prints every disagreement instead of reconciling it,
and that comparison is the only audit the multiplier has — on a job that
declares `ticket_unit: control_number`. Declared the other way, or not
declared, the two are not counts of the same thing and the comparison
stands down rather than reporting every mark as a disagreement.
- **A bar-heavy piece for Q3**, per the section above.
To re-check the counting rules with no Revit at all, against a real index
cache on disk:
```bash
PYTHONIOENCODING=utf-8 <py312> _scratch/claude_takeoff_offline.py \
<index.json> <tickets folder> <out.md> <job knowledge.yaml>
```