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

22 KiB

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 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:

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:

# 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.

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:

PYTHONIOENCODING=utf-8 <py312> _scratch/claude_takeoff_offline.py \
    <index.json> <tickets folder> <out.md> <job knowledge.yaml>