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.
419 lines
22 KiB
Markdown
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>
|
|
```
|