Files
willtools-demo/docs/lift-check.md
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

1524 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Panel Lift Check
Select an assembly, click the inserts that carry each stage, choose where the
HTML goes. One report per piece covering stripping, transportation and
erection, ending in one recommended position per insert and the delta from
where it is drawn.
```
Revit ──liftexport──> <mark>.job.json ──panellift──> <mark>.html
(IronPython 2.7) (CPython 3.9+)
```
The job file is kept beside the report. It is the complete input, so any
number in the report can be re-run by hand, on a machine with no Revit on it:
```
python -m panellift WP000.job.json -o WP000.html --json
```
---
## Three things the Revit side decides
None of them is mechanics, and all three are wrong by default if nobody
decides them.
### Which way up, and on which axis
`x = 0` is the **bottom of the piece as erected**. Get it backwards and the
erection stage drops the wrong row of lifters — tens of thousands of
pound-feet, and it looks entirely normal in the output.
Read it off the **assembly origin**. Every piece carries one, drawn in the
model as a red/green/blue triad, and it is a thing somebody placed with a
direction rather than something inferred.
Inference is what got this wrong. Comparing the elevation of the two ends of
the length axis answers it for a wall panel erected on end and is
**meaningless for a horizontal one**, where both ends are at the same height
and the "difference" is whatever the model's rounding happens to be. The
anchors got assigned to whichever end won a coin toss.
Every panel direction is asked how much of itself points up, and the largest
wins:
| erected vertical axis | the top of the piece is | the anchors |
| --- | --- | --- |
| length | an **end** | snap in x, pinned along the piece, free across it |
| width | a long **edge** | snap in y, pinned to that edge, free to slide along |
Those are two different constraints and they are not the same inserts, so
`pinned` and `pinned_y` are separate. Collapsing them into one flag gets one
case wrong, and the wrong one is a recommendation to put an anchor where the
piece has no edge.
If nothing points convincingly up (< 0.7, so more than 45° off), it
**refuses** rather than guessing. So does a piece whose origin stands its
*thickness* up: a beam idealisation spanning in the panel's own plane has no
meaning for a piece stood on its face.
### Which way the piece spans, per stage
A piece does not always hang the way it was cast, so the span is a property of
the **stage**, not of the panel:
| stage | spans |
| --- | --- |
| stripping, transportation | the **long** direction, always — that is how it lies flat, and how the face lifters are rowed across it |
| erection | whatever the assembly origin stands **up** |
For a wall panel those are the same thing and nothing changes. For a
horizontal panel erection spans the width, and **the rows have to be taken
again in that direction**: the anchors along the top edge are *one* support
when the piece is standing and *several* when it is flat.
The solver is not taught about orientation. The piece is **turned** instead —
`cli.transpose` swaps length with width, every `x` with its `y`, and `pinned`
with `pinned_y` — so the mechanics stay one implementation checked against
published examples. Turning it twice gives back what you started with, which
is a test.
The position search sits out any stage that spans the width: it optimises one
set of coordinates, and a turned stage's x is the panel's y, so including it
would be optimising against two different pieces. It still answers for the
stages it can and **names the one it left out** — silently answering for two
of three stages would read as an answer for all three.
### Where an insert acts
A face lifter acts at the centre of its solid. An anchor cast into the top
edge does **not**: it is welded to a bar, so its modelled box centre sits
inboard by however long that bar is. On one real piece the gap was 7 inches,
and 7 inches of lever arm on the erection pick is not a rounding error.
So `handling.lifter_groups` carries an `at` field:
```yaml
handling:
lifter_groups:
face:
families: [ANCHOR_FACE_4T]
at: measured # where it is modelled
edge_top:
families: [ANCHOR_EDGE]
at: top # acts at the end of the piece
```
`at: top` also implies the insert is `pinned` — it cannot be moved along the
piece, because being at the top edge is the detail — and `on_edge`, which is
what turns on the shear check below.
### Which insert is what
Nothing in the code knows a part number. `lifter_groups` maps family-name
substrings to roles, and the stages are written in terms of roles:
```yaml
stages:
- {name: stripping, pci_case: stripping, strength: fci, groups: [face]}
- {name: transportation, pci_case: transportation, strength: fc, groups: [face]}
- {name: erection, pci_case: yard, strength: fc,
groups: [face, edge_top], exclude_top_rows: 1}
```
`exclude_top_rows: 1` is the crane, not the panel: it cannot reach the topmost
row of face lifters once the piece is being stood up, so erection hangs off
the rows below plus the anchors at the top edge.
These are **defaults**, and only defaults. What a crane can actually reach on
the day is not in the model.
### Picking the inserts
The button temporarily isolates the piece **in whatever view you are already
in** — not a view it picks for you, and never a new one — and asks you to
**click the inserts** for each stage in turn. The defaults arrive pre-selected,
so the usual answer is still one click, and only this piece's inserts are
selectable, so a stray click cannot become a support line.
It used to be a checklist of rows, and that was wrong twice over. A row is the
tool's *guess* about which inserts share a hook, and the label named one family
when the row held two: one edge anchor and two face lifters an inch apart at
the same station read as three edge anchors, the whole row was filtered out of
stripping by the anchor's group, and the piece was analysed on six of its eight
face lifters. Nobody can audit a checkbox that says `5'-8"`. Everybody can
audit three inserts lit up on the piece in front of them.
`stage_audit` still reconciles the defaults against what the groups hold, but
it now *says* rather than *refuses*: with a human clicking every insert, a bad
default costs clicks instead of an answer. What it is telling you is that the
highlighted defaults are not to be trusted on this piece.
The stage table in the output is followed by **what was actually picked** —
families counted, and the stations they stand at. The numbers mean nothing
without it, and it is the one part of the run that came from a person.
---
## Prestress
A pretensioned panel is not the same piece as a reinforced one, and for a long
time this checked it as though it were. On a real 8 in. panel the strands put
**359 psi of precompression** into the section against a stripping limit of
444 — leaving them out overstated every handling stress by that much, and
turned a piece that passes at 70% into a FAIL at 102%.
The stress at a station is now four terms, not one:
```
f = -P/A precompression, everywhere
-(M + P·ez)·cz / Iz bending through the thickness
-P·ey·cy / Iy bending ACROSS the piece
```
evaluated at all four corners of the net section, tension positive.
The third term is the one that gets forgotten. Nothing applies it — it exists
because the prestress is off-centre **across** the piece, which happens as soon
as an opening cuts some strands and moves the section's own centroid the other
way. On the panel above it is worth 146 psi of tension at one corner, a third
of the budget.
### Where the numbers come from
| | |
| --- | --- |
| where the strands are | the **model** — this piece, and which ones a blockout crosses is a fact about geometry |
| how hard they were pulled | the **design summary**, as the per-strand I.T. figure Extract Design already reads into `reinforcing.strand_groups[].it_lb` |
| loss | `design_assumptions.prestress_loss_pct` in the project config, default **8%** |
Loss is a **stated assumption, not a calculation**. A time-dependent model
needs an age, a humidity and a curing schedule none of this has, and a
plausible-looking number from guessed inputs is worse than a figure an
engineer can argue with. It sits at the top of the config file with its own
comment because every point of it is worth about 4 psi of credited
precompression — in the direction that makes a piece look safe.
### When that assumption is the reason a piece passes
Cracking already warns when a stage lands within 10% of its tensile limit.
When the piece also has prestress credited, that warning now says why it is
worth a second look: the number rests on the assumed loss, not a calculated
one, so a piece that only just squeaks by on an 8% guess is exactly the piece
where the guess matters most. Re-running it somewhere loss is computed over
time — the plant uses Eriksson Wall for that — has moved the answer before:
one real piece came back 1.64% initial and 8.24% final, and 0.23% at the
station that governed. No prestress credited, no such note — there is no loss
assumption behind a bare-concrete number.
The band is the same 10% the cracking check already uses; this only adds
words to a finding that already exists, and never changes OK to WARN or WARN
to FAIL on its own.
A piece with strands in the model and no initial pull on its design is
**refused**, not warned about. That is the whole point: the failure mode being
fixed here was silent.
### Transfer, and what a blockout does to a strand
A strand carries nothing where it stops and picks its force up over the
**transfer length**, ACI 318's `f_se·db/3`, worked out from the pull actually
on the design. That matters at both ends of the piece — the lifters nearest an
end sit inside it — and at every opening, because **an opening cuts every
strand it passes over**.
On the sample panel the opening spans 16 → 56.25 in. across, which is two of
the six strand lines, so four of twelve strands are severed:
```
Aps, in²
22.62 ft 1.84 full
25.11 ft 1.75 inside the transfer length of the cut
27.04 ft 1.33
27.50 ft 1.22 the opening starts; the four are gone
```
A strand that misses the opening carries straight through it untouched. A run
shorter than two transfer lengths never reaches full force anywhere along it,
which is the right answer and not an edge case: a strip of panel between two
openings genuinely is not fully prestressed.
#### A pocket is not an opening — `recesses`, added 2026-09-01
All of the above is what a `blockout` means, and until 2026-09-01 the exporter
called every rectangle it read a blockout. It reads them off the side faces of
the concrete solid, and a **blind pocket has side faces exactly like a hole
does** — so an 8 in corner pocket with 3.5 in of concrete behind it arrived
here as a void through the thickness and cut fourteen strands passing a clear
3.5 in above and below it. That is where the last genuine-looking capacity FAIL
on the reference sweep came from: `M/φMn` 1.04 a foot from the end of a piece
whose model has no opening in it, and the owner's answer to the drawing was
*"那不是穿孔。。。没穿"*.
The through test had existed on the Revit side the whole time
(`liftexport.opening_penetration` — shoot a line down the thickness axis
through the rectangle's centre, intersect it with the solid, `through` when
under 0.05 in of concrete is left) and simply was not wired into the export.
It is now, in `liftexport.split_openings`:
| | |
| --- | --- |
| `blockouts` | the **through** ones. Unchanged shape, unchanged meaning. |
| `recesses` | the **blind** ones — the same rectangle fields plus `concrete_in` (what is left along the centreline) and `depth_in` (thickness − that). |
A rectangle whose penetration could not be solved stays in `blockouts` and the
export says so in a problem line: *could not tell* is not *blind*, and the
through reading is the one that takes section and strand away.
What this package does with a recess is **nothing**. It is read, carried on
`Panel.recesses`, and counted in the piece summary — no section property and no
development length moves for it, so every number on the page treats the pocket
as solid concrete. That is unconservative over the pocket, and the report says
so in a line of its own rather than leaving it to be discovered. Making it real
means depth-aware section properties; that is engineering, not wiring.
The weight gate does read `depth_in`, because it has to: the reconstruction
subtracts a pocket at its own depth now, where it used to subtract it at the
full thickness and, for one day between the two, not at all.
### When the piece spans its width
Strands are pulled along the bed, so they run the **length**. Stand a
horizontal panel on its long edge and they are now *across* the span, where
they precompress nothing that resists that bending. `transpose` drops them and
the report says so. Rotating them into the new x would credit the piece with a
compression it does not have — the one error here that makes a panel look
safer than it is.
The check uses the **full** panel width for the section while the piece hangs
from a handful of discrete inserts. That was worth checking and it holds: the
path sideways to an anchor is a beam whose depth is the span dimension, so on
a real 26.8 × 11.5 ft piece with 8 ft between anchors it costs 4.3 psi against
444 allowable. The arithmetic, and the one geometry where it needs redoing —
a large opening cutting across the anchor line — are in `docs/handoff.md`.
### Compression
Only worth checking once there is prestress, and then it is: `0.6 f'c`, at all
four corners, reported as `CRUSH`.
### Checked against something else
Every number above is reproduced from an independent run of the same piece
through a commercial wall-panel program — its inputs read off its own file, its
outputs off its own report, neither consulted while this was written. Feeding
**its** moment into **this** section gives 417.4 psi where it printed 417.6,
and 667.5 psi of compression where it printed 667.4. `test_prestress.py` keeps
those numbers.
What still differs, and deliberately: that program idealises stripping and
transportation as a **static lift** (rigid supports, unequal reactions) while
`rig_mode` here is per stage and the plant sets it. Transportation is now
plant-configured dunnage when the plant says so: `handling.dunnage.mode` is
`lifter_rows` or `fraction`, the stage carries `supports` instead of
`lifters`, and the solve is a continuous beam on rigid supports with no sling
or insert check. A plant that has not configured dunnage still gets the old
lift path, and the report says that out loud.
### A dunnage line under an opening
The plant's `fraction` rule places a support a fraction of the span in from
each end, and it is blind to geometry. On one real piece that put a support
at 5.23 ft on a panel whose opening ran 3.23 to 12.52 ft — a support line
under a hole: the section modulus there was 464 in³ against roughly 1900 for
the full section, and that stage came back at 1.14 of its limit.
The beam solve already uses the real section at every station, opening or
not, so the arithmetic is not wrong. What is wrong is that nobody reading a
line force or a stress ratio can tell a support is bearing where the concrete
mostly is not, so every support on a dunnage stage is checked against
`section.voids_at()` at its own station. When the missing width there is at
least half the panel's full width — most of it, not a sliver — the finding
names the support's position and the opening it falls inside. Severity WARN:
the piece may genuinely be carried that way, and this does not say otherwise.
It only says so.
---
## Capacity, printed beside the demand
Added 2026-08-31, on the owner's instruction: *"panellift, station table,
现在加入 pVn, pMn, Mcr, phi, 等等的数字。"* Six columns on every station table,
after the stress columns and before the note:
```
... f_t | f_c | allow | f_t/allow | Mcr, kip-ft | φ | φMn, kip-ft | M/φMn | φVn, kip | V/φVn | note
```
The first eight columns did not move, in position or in value. Measured: the
40 real job files rendered before and after this work produce pages that are
**byte-for-byte identical outside the station table**, clock frozen.
### A ratio over 1.0 is a FAIL, and the engineer stamps past it
They were informational for **one day**. On 2026-09-01 the owner ruled:
> "M/pMn 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail,
> 我做最后决策。"
*(A ratio over 1.0 is acceptable to him — he can stamp it. Not to this tool:
over 1.0, report a FAIL. He makes the final decision.)*
So `checks.check_capacity_ratios` now imports `capacity.py` — the test that
used to assert it could not is inverted in place — and raises:
| | |
| --- | --- |
| `FLEX-CAP` | `M/φMn > 1.0` → **FAIL**, naming the piece, the stage, the station, the ratio, and that φMn is strand only |
| `SHEAR-CAP` | `V/φVn > 1.0` → **FAIL**, same rule |
The doctrine is narrow and worth stating exactly: **the tool may not bless a
ratio over 1.0.** It may not weigh the exceedance, discount it, or decide it
does not count. Reporting is the whole of its job; the stamp is the
engineer's.
Four things follow, and none of them is negotiable in the code:
- **No development-length exclusion.** Every exceedance measured sits within a
strand's development length of a free end or an opening edge, where φMn —
strand only — falls towards zero while Mcr does not. The owner was told that
and ruled FAIL anyway. The finding still **carries** the sentence, with the
embedment, the free end it is measured from and `l_d`, because that is what
he decides on; it is context on a FAIL, never a reason to withhold one.
- **The demand did not move.** M and V are the analysis's own factored
handling numbers — self weight times the PCI Table 8.3.1 multiplier and
nothing else. Whether a strength-design load factor stacks on top was **not**
answered and is still in **Open**.
- **A dash is still not a verdict.** A station that could not be judged prints
its own reason and takes no part; where *no* station of a stage could be
judged — a piece with no strand, so there is no φMn to be over — the check
returns nothing at all, the way `check_compression` does when there is no
prestress. That is the one silence it has, and the station table prints the
reason at every row it applies to.
- **The stress check is untouched** and still carries its own verdict.
Cracking and flexural capacity are two questions and neither replaces the
other. On the reference sweep the stress-only verdicts are unchanged piece
for piece: 111 OK, 59 WARN, 13 FAIL, 1 refused.
**Every station, not the table's rows.** The check scans all ~2000 stations of
a stage, not the ~25 the station table prints. A verdict that moved with a
display stride would be no verdict, and the difference is not academic:
scanning every station finds a ratio over 1.0 on **112** of the 184 reference
pieces where the printed rows alone find one on **40**. Where a station over
1.0 would have been thinned away it is **forced into the table** with a
`worst M/phi-Mn` tag — a verdict whose evidence is not on the page is a verdict
nobody can check.
#### What it costs, and what it moved
Measured 2026-09-01 on the 184-piece reference sweep, one core, read + solve +
every check, no position search:
| | |
| --- | --- |
| before | 37.0 s |
| after | 79.3 s |
The analysis half roughly doubles — about **+0.23 s a piece**, three stages of
flexural capacity at every station — which is roughly +5 s on the
eight-at-a-time batch path that measures ~31 s. `profile_for` computes the
profile **once** per stage and the station table reads the same list, behind a
fingerprint of the diagram it was measured on, so the page and the Checks table
cannot disagree and a cache cannot outlive its numbers.
And the verdicts, on the same sweep:
| | OK | WARN | FAIL | refused |
| --- | --- | --- | --- | --- |
| stress checks alone | 111 | 59 | 13 | 1 |
| with the capacity ruling | 53 | 13 | **117** | 1 |
**104 pieces move to FAIL, and every one of them fails at an undeveloped
strand.** That is the ruling working as written, not a defect — but it is a
much larger number than the four stations on two pieces the owner was shown
when he made it (see the note under **Open**), and it is worth his eye before
a batch goes out.
### Mcr, and the two sevens that are not the same seven
```
Mcr = S·(fr + P/A) ∓ P·ez fr = 7.5·λ·√f'c
```
`fr` is the **code modulus of rupture**, ACI 318-19 19.2.3.1. The handling
tensile **allowable** on this plant is also 7.5√f'c — but that is a different
quantity that happens to share a number:
| | |
| --- | --- |
| `pci.MODULUS_OF_RUPTURE_SQRT_FC` = 7.5 | a material property, ACI 19.2.3.1, no safety factor in it. It is what cracking *means*. |
| `handling.tension_limit_sqrt_fc` = 7.5 here | a **choice between two schools** the owner made on 2026-08-19: 5 = the rupture modulus over a 1.5 safety factor, 7.5 = the rupture modulus itself, no margin. A plant that picks 5 does not move the constant above it. |
The constant carries that warning in `pci.py`, the report prints it under the
table, and `test_capacity.py` fails if either half of the sentence goes away.
The failure it is guarding against is somebody "unifying" them, which would
silently move Mcr on every panel.
The sign follows the sense of M at the station. Strand below mid-depth **raises**
the sagging Mcr and **lowers** the hogging one — on the test fixture that is
50,239 lb-ft against 14,239, a factor of 3.5. Absoluting it would have printed
the sagging number at a hogging station.
**What Mcr deliberately leaves out:** the lateral prestress term
(`-P·ey·cy/Iy`) that the four-corner stress carries. Mcr is a section quantity
about one axis, which is how Eriksson prints it. So where a blockout has moved
the prestress across the piece, a station can show `f_t` over the allowable
with `M` under `Mcr` — two different questions, and the report says so rather
than reconciling them into one number.
### φMn is strand only, and that is checked rather than assumed
ACI 318-19 Eq. 20.3.2.3.1 — the approximate `fps` — with the equivalent
rectangular stress block. The equation's `(ω − ω')` term for mild tension and
compression steel is dropped, and the justification is not a belief about the
concrete: **`model.Reinforcement` is instantiated nowhere in this package,
`cli.read_panel` has no field that could fill it, and the job file has no key
for it.** A panel reaching here has strand and nothing else. `test_capacity.py`
asserts that, so the day `liftexport` can express a bar layer the test fails
and the footnote has to be rewritten with it.
**Why not strain compatibility**, which `PLAN.md` has listed as the plan since
2026-08-18: it needs the strand's stress-strain curve, and no layer in this
repository holds one. A curve assumed to make the answer look exact is a
plausible-looking number from a guessed input — the failure this package
exists to prevent. The approximate equation is code-sanctioned wherever
`fse ≥ 0.5 fpu` (checked, and a station outside that range **stands down with
the reason** rather than using the equation anyway), needs only what the job
file already carries, and a reviewer can reproduce it on paper. Strain
compatibility stays on `PLAN.md` as the upgrade.
`φ` is 0.90 where the section is tension-controlled (`εt ≥ 0.005`, ACI Table
21.2.2), computed from `c` at the station rather than stored as a constant
somebody trusts. A station that is **not** tension-controlled prints a dash
and its `εt`: ACI's transition between compression- and tension-controlled is
not built, and 0.90 borrowed from the tension-controlled row would overstate
the capacity of exactly the sections where it matters.
### Transfer and development: one set of lengths, not two
Inside a strand's transfer and development length `fps` is reduced, and it is
reduced **from the lengths this package already had**:
- `lt` is `prestress.transfer_length_in` — the same function the P/A columns
use, honouring a job file that states one.
- `ld = lt + (fps − fse)·db`, ACI 318-19 25.4.8.1, **whose first term is that
transfer length**. `pci.development_length_in` takes `lt` as an argument
rather than recomputing `fse·db/3`, because two places computing a transfer
length is how they come to disagree.
Available strand stress ramps linearly from zero at the free end to `fse` at
`lt` and on to `fps` at `ld`. The free end is not only the end of the piece: a
full-depth blockout **cuts every strand it passes over**, so the run restarts
at each opening edge, reached through the same `prestress.bonded_run` the
stress path uses. A strand standing in an opening carries nothing.
`fse` and `fps` saturate at **different** lengths and that is not an
inconsistency. `fse` — the effective prestress — is fully in the concrete at
`lt`, which is what a transfer length means, so `Mcr` stops growing there.
`fps` — the stress at flexural failure — needs the longer `ld`. On the test
fixture, at 40 in. from the end `Mcr` is already at its full value while `φMn`
is still climbing.
**A ratio that blows up near an end or an opening edge is the strand, not the
piece.** φMn is strand only, so inside a development length it falls towards
zero while Mcr does not. On WP128's erection stage a station 3½ in. from a
free end reads M = 1.9 kip-ft against φMn = 0.005 kip-ft — a ratio of 40,000%.
That is arithmetic and it is true, and it is a statement about an undeveloped
strand rather than about a section with no capacity. Such a row says so in its
note, and φMn prints `<0.1` rather than `0.0`, because a zero in a capacity
column is a number somebody divides by.
**And since 2026-09-01 it is also a FAIL.** The sentence above is the reason
the owner needs the note; it is not a reason the tool may skip the verdict. He
was shown exactly this row and ruled that the tool reports and he decides.
### φVn is concrete alone
```
φVn = φ · 2·λ·√f'c · bw · d φ = 0.75, ACI 318-19 Table 21.2.1
```
ACI 318-19 22.5.5.1 simplified. **These panels carry no shear reinforcement in
the handling direction**, so `Vn = Vc` and there is no `Vs` to add — which is
also why Eriksson's `Vs Req` and `Av Min` columns stay absent here rather than
being printed as zero.
`bw` is the effective width at the station — the concrete actually there,
openings removed, the same width `S` is taken on. `d` is `dp`, floored at
`0.80h` by ACI 318-19 22.5.2.2, which is the code's own provision for a
prestressed member and is what carries a station whose strand is on the
compression side or not yet developed. On an 8 in. panel with strand 1.75 in.
up, `dp` is 6.25 in. sagging and 1.75 in. hogging; 0.80h = 6.4 in. is the
floor under both.
### Where every number's assumption is printed
`λ` is not hardcoded to 1.0. It comes off the concrete's own density, ACI
318-19 Table 19.2.4.1(a) — 1.0 above 135 pcf, 0.75 at or below 100, `0.0075·wc`
between. Every wall panel on the reference job is 150 pcf, so the answer *is*
1.0, but it is 1.0 because the concrete is normalweight and not because the
code says so, and a lightweight piece is credited with less tension in `Mcr`
and in `φVn` both.
The φ values, λ, β1, γp, the rupture coefficient and the shear coefficient are
**named constants in `pci.py` with their ACI clause beside each**, never digits
inside an expression. `test_capacity.py` walks `capacity.py`'s AST and fails if
any of them reappears as a literal. **Proposed, not landed:** they are code
values, industry-wide, identifying nobody, so they belong in
`knowledge/catalogue.yaml` as `codes.aci318_19.phi.flexure_tension_controlled`,
`codes.aci318_19.phi.shear`, `codes.aci318_19.modulus_of_rupture_sqrt_fc` and
`codes.aci318_19.concrete_shear_sqrt_fc`, each with the `source:` shape that
file already uses. Landing them there needs a `knowledge.py` and a
`liftexport` change — panellift has no YAML reader and is fed only by the job
file — so the keys are named for routing and the constants stay in `pci.py`
until somebody wires them.
Under the table the report prints, per stage: the `fps` method by clause, the
transfer and development lengths and where they came from, the loss and that
it is a **stated assumption** (`design_assumptions.prestress_loss_pct`, 8% by
default), **this stage's** f'c — f'ci at release and stripping, f'c later —
`λ` and the density it came from, `fpu` and its provenance, and the sentence
that the ratio columns use the table's own M and V with **no further
strength-design load factor** beyond the stage's PCI Table 8.3.1 multiplier.
### fpu carries provenance, and audits itself
`fps` is written in terms of `fpu`, which nothing in the stress path needs —
the precompression comes from the measured jacking force. The job file has no
key for it today, so `panel.prestress.fpu_ksi` defaults to **270** and the
report says, in words, that it is panellift's own default and that no layer
stated one. That is the same treatment `Stage.tension_limit_sqrt_fc` gets, and
for the same reason: a page that states a capacity with no way back to the
grade behind it cannot be audited.
It is defensible as a default only because it is **self-checking**. The
measured jacking stress `fpi` is printed beside it as a fraction of `fpu` — on
the reference job that is 189.0 ksi, i.e. **0.700 fpu**, which is exactly what
the ticket legend says (`0.70 fpu = 28.9 kips`). A piece whose strand is really
Grade 250 would show a jacking ratio above the 0.75 fpu limit and announce
itself rather than hiding behind a plausible capacity.
**The routing when somebody wires it:** `liftexport` writes
`panel.prestress.fpu_ksi` from `spec.reinforcing.strand_groups[].grade_ksi`,
which `designspec` already reads off the design summary. Nothing writes it yet;
`cli.read_prestress` reads it, and stamps `fpu_source: 'job'` when it is there.
### A sandwich panel gains nothing from this
Insulated pieces **refuse** at `cli._refuse_sandwich` before any analysis runs,
so they never reach a station table and there is nothing here for them. The
sandwich report prints bounds, not stations, and no capacity column was added
to it. That is not an oversight: a capacity for a partially composite section
needs the connector data that `PLAN.md`'s owner questions 5 and 6 are about,
and the two geometric bounds do not bracket it. When the refusal is lifted, the
capacity columns are one of the things that has to be answered for, not
inherited.
---
## The weight gate
Before anything is calculated, three weights have to agree within
`handling.weight_tolerance` (2%). If they do not, the run **refuses** — no
report, no numbers.
| | |
| --- | --- |
| stated | the panel's own weight parameter |
| solid | every **concrete** solid the body draws × unit weight |
| reconstructed | (length × width × thickness − blockouts − recesses at their own depth) × unit weight |
Which pair disagrees says what is wrong, and that is the point of carrying all
three:
- **stated vs solid** — the model. A weight parameter that has not been
recalculated, or a unit weight that is not what the piece is cast from.
- **solid vs reconstructed** — *us*. Those two describe the same concrete and
differ only when the blockouts were read wrong.
That second one is why the gate is a refusal and not a warning. A missed
opening makes the piece heavier, which sounds conservative and is not: the
section modulus is then computed as if a door were not there, and that is the
wrong number in the direction that says a panel is fine.
It has already caught one. Openings are read off the faces of the concrete
solid, and any face reaching the panel boundary used to be discarded as "the
outside of the piece". A door running down to the bottom edge has **jambs that
reach that edge** — they were thrown away, the head clustered into a line, and
the panel came back weighing what a solid one would. The test is now about
shape rather than position: an outer face lies flat *in* one of the four
bounding planes, so it is degenerate along one in-plane direction *and* sits
at that direction's extreme. A jamb at some x strictly inside the piece is not
that, however far it runs in y.
A void modelled as a separate family, a recess that is not full depth, and a
non-rectangular opening will all still be read wrong — and will all still be
caught here rather than reaching a report.
### The solid was one solid, and a sandwich panel is not one solid
**Measured 2026-08-31, and it is why this section changed.** On the job's
insulated panels the modelled-solid weight came out at about **65% of the
printed weight**, consistently:
| piece | printed | the model's own weight parameter | modelled solid |
| --- | --- | --- | --- |
| WP115 | 18,573 lb | 18,376 lb | **11,983 lb** |
| WP109 | 4,189 lb | — | **2,880 lb** |
Two independent statements about the piece agreed with each other to about 1%
and the solid sum was the outlier. The gate refused, correctly, on a number
that was wrong — and because it refused, **the entire handling analysis
computed nothing on 27 pieces**, 15 of them with issued tickets. Two rows of
noise, 2.2 and 6.3, out of one cause.
The cause was one word. `concrete_solid` took the **largest** solid the body
draws and called it the piece. That is right for an 8 in. solid panel, whose
body draws one. A 16 in. insulated body draws several — concrete wythes with
an insulation layer between them — and the largest of those is one wythe.
So the shape and the weight stopped being the same object:
- `concrete_solid` still returns the largest solid, and still answers the two
questions that are about **one prism**: where the openings are (they are its
side faces) and what the piece's extents are.
- `concrete_volume` answers the weight, by **summing** the solids — and by
saying which ones it summed.
**Which of them are concrete is decided by density, not by a product name.**
Structural concrete is 145–150 pcf normalweight and 110+ even lightweight; the
board between the wythes is under 5. Nothing a wall panel is cast from lands
in between, so a threshold anywhere in the middle separates them without
anybody having to know what the plant buys. `materials.concrete_min_pcf`,
default **50**, and the density comes off the material's own structural asset
rather than out of the code.
`choose_concrete` is the whole decision and it is **pure** — plain dicts in,
plain dict out — so it is tested on host CPython with no model and no licence.
The ladder, in order, and what each rung refuses to do:
| what the body draws | what happens |
| --- | --- |
| one solid | that solid, unchanged — this is every solid panel in the job |
| several solids, all one material (or all with no material at all) | **all** of them. Nothing distinguishes them, so nothing may drop one. Where the material is unread the sentence says the weight is too heavy by whatever of it is not concrete |
| several materials, every one publishing a density | the ones at or above the threshold; the rest are named, with their volume, as left out |
| several materials, no density, but `materials.concrete_names` declared | classified by name — and it still **refuses** if any solid has no readable material name, because a solid nobody can name is not a solid that is not concrete |
| several materials, no density, no names declared | **refuses**, and names both wrong answers it declined: summing them all weighs the insulation, taking the largest drops a wythe |
Nothing here asks what kind of panel it is. The classifier in
`knowledge.classify_panel` is not consulted and does not need to be — the bug
was never that the tool failed to recognise an insulated panel, it was that it
counted one solid where the body drew several, which is a question about the
geometry in front of it and not about the piece's type.
**And the number never travels alone.** `source.solid_note` in the job file
says what went into the sum, `source.solid_parts` lists every solid with its
material and volume and whether it was counted, and the gate's own report
prints the sentence under the number: *"summed 2 of the 3 solids the body
draws as concrete: … Left out 6.20 ft³ — … — as not concrete (under 50 pcf)."*
Both wythes with the insulation left out is a different claim from 18,500 lb,
and only one of the two can be argued with.
**Whether the printed weight includes the insulation is not established here**
and is not guessed at. The sum's convention is stated rather than assumed: it
is **concrete only**, and the insulation's volume and density are both printed
beside it, so the difference is arithmetic anybody can do. At sandwich-board
densities it is worth a fraction of a percent of the piece — inside the gate's
2% either way — so it does not decide whether a panel reconciles; it decides
what the report is claiming.
### No measured leg is no gate
A missing `solid` weight used to leave a silent hole: the gate fell back to
`stated` against `reconstructed`, those two agreed — **they are both built out
of the same dimensions** — and the piece passed a check that never ran.
`solid` is the only one of the three read off the piece's own geometry, and
solid-vs-reconstructed is the comparison that catches a blockout read wrong,
which is the entire reason this is a refusal and not a warning. So a run that
could not weigh the concrete now **refuses and says which of the three was
never read**, rather than passing on the two that cannot disagree.
## An insert is worth less at release
A catalogue rating is derived at one concrete strength, and the anchor pulls a
cone of concrete out, so below that the capacity follows `sqrt(f'c)`:
```
factor = min(1, sqrt(f'c actual / basis))
```
It does not rise above the basis — past there the anchor's own steel governs.
The consequence for the job file: ratings go out **underated**, with
`basis_fc_psi` beside them. One report checks the same insert at release
(stripping) and at 28 days (everything else), so a single pre-derated number
would be wrong for all but one stage. `panellift.checks.rated()` applies it
per stage, where there are tests.
On a 3500 psi basis, releasing at 2500 costs 15%. That is the difference
between a lifter reading 72% and 85%.
---
## The safety factor is the insert's, and 2.67 was nobody's
**What a rating means, first.** Every `working_load_lb` and `shear_load_lb` in
`knowledge/catalogue.yaml` is a published *safe working load* — the factor is
already inside the number — so the default reading applies no factor at all and
dividing again would be a double count. `tension_fos` / `shear_fos` come into
force only where a rating is an **ultimate**, which the caller declares with
`capacity_is_ultimate`. **Nothing in this repository sets that flag**, so on
every shipped path today the factors are not reached.
**They were still wrong, and in the direction that passes an anchor.** Until
2026-08-31 `shear_fos` was **2.67** — as a `Rigging` default, as a
`read_rigging` default, as one file-wide `catalogue.safety_factors` entry, and
as what `liftexport` wrote into every job file. No page published it. The
comment justifying it as *"the manufacturer's 4:1 tension / 2.67:1 shear"* was
written the day **after** the constant and cited nothing.
The one datasheet in this repository for the parts it reached — **ALP catalog
p53, 02/2024** — heads its capacity table **"Capacity in Concrete 4:1 SWL"**,
*one heading spanning both the shear and the tension columns*, and prints shear
equal to tension on all seven QLPD rows. The manufacturer's shear factor is
**4:1**. What 2.67 would have computed:
```
QLPD4T5WG 32,000 lb ultimate / 2.67 = 11,985 lb allowed
published SWL = 8,000 lb
```
Half again the load the manufacturer permits, on an edge anchor, in shear.
**The fix is per part, not a better global.** Replacing 2.67 with 4.0 would be
the same defect wearing the right number: the next manufacturer's insert would
inherit a factor read off ALP's page. Evidence that this is not hypothetical is
in the same file — `QL589SPG` publishes 4,010 lb shear against 7,690 lb
tension, nothing like the 1:1 the QuikLift rows show. So:
* each ALP entry carries its own `safety_factors: {tension_fos: 4.0,
shear_fos: 4.0}` with the page cited beside it;
* `catalogue.safety_factors` is **null**, with a written reason, and is read
only as a fallback a plant may fill for hardware the catalogue does not
cover;
* `Lifter.tension_fos` / `Lifter.shear_fos` carry the resolved number into the
job file, per insert, and `Rigging`'s pair is the fallback;
* an insert with **no** factor is **not judged** against an ultimate rating.
`check_insert_capacity` raises `INSERT-NO-FOS` naming it, and never
substitutes a number.
**And the ultimate route is unconservative here even at 4:1.** The page shades
four of its seven rows and says the shading marks where the anchor's own
mechanical capacity governs — so the printed SWL is the *lesser* of the 4:1
mechanical figure and what the concrete holds:
| part | ultimate / 4 | published SWL | |
| --- | --- | --- | --- |
| QLPD4T3WG | 8,000 | 6,257 | +28% |
| QLPD4T4WG | 8,000 | 7,200 | +11% |
| QLPD8T6WG | 16,000 | 11,019 | +45% |
| QLPD8T7WG | 16,000 | 14,640 | +9% |
The other three agree exactly. For these parts the **published working load is
the answer** and the ultimate is provenance, not an input — which is a fact
about the route, not about the factor, and it is why `capacity_is_ultimate`
stays false. `panellift/tests/test_safety_factors.py` pins all of it.
**Open, and the owner's:** whether an insert from a manufacturer whose sheet
this repository does not hold should keep *any* default factor, or stand down
without one. The code stands down today. Nobody has asked him.
---
## Erection is two conditions
They are separate questions and they share nothing, so they are asked
separately.
### 1. The pick — panel flat
Face lifters and edge anchors together, load shared out by the beam solve.
Which rating applies follows from how each insert is loaded in that position,
not from what the insert is:
| | |
| --- | --- |
| face lifter | the line pulls straight out of the face → **tension** |
| edge anchor | the panel is edge-on, the line pulls across the anchor → **shear** |
On the hardware this was built for those two ratings differ by nearly a factor
of two, in the direction that matters: checking an edge anchor against its
tension rating here passes an anchor that would shear out. Reported as
`INSERT`, with the rating named in every line.
### 2. Standing plumb — panel upright, anchors alone
The face lifters have let go. They are pointing sideways and carrying nothing.
The whole piece hangs from the edge anchors, straight down, in **tension**.
So this ignores the rig, the sling angle, the beam and the load multiplier
entirely: it is piece weight ÷ anchors, against the tension rating. **There is
no distribution question in a piece hanging straight down**, which is exactly
why putting it through the beam model would be the wrong answer dressed up as
the right one. Reported as `PLUMB`.
A stage with no edge anchors — stripping, transportation — never stands the
piece up, so it gets neither the shear treatment nor a plumb check, and no
warning about their absence.
`on_edge` is **stated by the model, not inferred from having a shear rating** —
face lifters have one of those too, and guessing would quietly run both wrong
checks on them. A missing rating gets `INSERT-UNRATED` or `PLUMB-UNRATED`
rather than silence.
---
## Recommended positions
The half of the question that gets acted on. A verdict says whether the pick
as drawn works; it does not say where to put the inserts instead, and doing
that by hand is a search nobody has time for.
### One answer for the piece, not one per stage
An insert is installed once and then has to survive every stage. Optimising
the stages separately gives three different answers for the same hole. So the
objective is the **worst stage's utilisation** — peak tension over what that
stage allows — and not a stress in psi: stages run at different concrete
strengths, and 450 psi at release is a different problem from 450 psi at
28 days.
### x: an exhaustive coarse sweep, then refinement
Not PCI Figure 8.3.2's closed form. That holds for two support lines on a
prismatic panel, and a panel with door openings is not prismatic — where the
openings are, the optimum moves, which is the entire reason to solve it.
The coarse pass tries **every combination** of stations at 1 ft, and that is
not caution. The objective is not convex and not even continuous: with rolling
blocks the support lines are split into two cable groups at their widest gap,
so moving one line past another's midpoint re-partitions the load in a jump.
Coordinate descent walks straight into that. Measured on a real three-line
pick: it settled at 280 psi with two rows collapsed 1.6 ft apart, while a
spread layout it never tried reached 130 psi.
Refinement afterwards is local, at 1 in. then 1/4 in., which is safe once the
coarse answer has put every line in the right basin. The search never returns
anything worse than the layout it was given.
**The last several inches of the answer are not determined.** The search
accepts an improvement at 1e-9 on an objective that is good to perhaps 1 part
in 10⁵, and the objective has several near-equal basins — so which basin the
coarse sweep hands to the refinement is decided in digits the beam solve
cannot resolve. Measured on 19 pieces: a perturbation at the objective's own
accuracy moves a recommended station by up to 10 in. (34.75 in. on one piece
of a wider sample), and the moved answer is as often *better* as worse.
Read `Δx` as "about here" — a direction and a magnitude, not a dimension. The
full measurement, what it says about the un-landed banded solver, and what a
fix looks like are in **Speed → Not done: the banded solver**.
### y: geometry, and it says so
The beam model has no transverse mechanics at all. Two things are wanted and
they conflict:
- **spread** — inserts near the edges of the available concrete, because a
narrow pair lets the piece roll about the line between them;
- **centred** — the row's centroid on the width centreline, so it hangs level.
Spread wins. The as-drawn spacing is kept where the clear strip allows it and
opened out to fill the strip where it does not — never pinched inwards, which
is what a symmetry-first rule does and it produces pairs a foot apart on a ten
foot panel.
Where the openings leave no strip straddling the centreline, the row goes
where the concrete is and the eccentricity is reported in inches. **That is a
statement, not a calculation.** Whether the piece twists, and whether it needs
rigging for it, this model cannot answer.
### Installability first
Every offered position is checked against the openings and the catalogue's
minimum edge distance, with the clearance applied to blockout edges as well as
panel edges — an insert near a hole has less concrete behind it than its
rating assumes. A recommendation that cannot be installed is not a
recommendation.
---
## A stage that was not analysed
A stage can be switched off — `"enabled": false` on the stage in the job file,
`enabled: false` on the spec in `handling.stages` — and it still appears in the
report. That is the whole reason the flag exists. Deleting the stage from the
list instead leaves **no record that anybody meant to check it**, and a stage
missing from a report is read as a stage that passed. "Could not be checked"
must never be mistaken for "checked and fine".
What it looks like:
- **In the summary**, which is the one part of the page that is always open: a
box in the same row as the stages that ran, dashed rather than colour-striped,
with the sentence *"Erection analysis not performed."* and the reason if the
job file gave one (`disabled_reason`, or the stage's `note`). **No ratio** —
not a percentage, not a zero, not even a dash where the others print `96%`.
A dash in a column of numbers gets scored as a number that came out small.
- **In the summary table**, a row saying `not analysed` across the numeric
columns and `NOT RUN` in the result column.
- **In the detail**, its own collapsed disclosure in its place among the
others, spelling out that no load was applied, no stress computed and no
check run — so there is no result to pass or fail.
The overall verdict:
- A skipped stage **never becomes a verdict**, and never changes one. Nothing
was measured; it cannot pass and it cannot fail. `OK`/`WARN`/`FAIL` still
mean what they meant, and the CLI exit code is unchanged.
- What does change is the **claim**. "All checks satisfied" is a statement
about the panel and it is false the moment a handling condition was not
looked at. So the banner reads "All checks satisfied for the 2 stages
analysed — 1 stage (erection) was not analysed. This report does not say
whether it would pass", and carries a `PARTIAL` tag so the shape of the
banner differs too, for the reader who takes in the colour and moves on.
The JSON summary says the same thing in `complete: false` and `not_run: []`.
Two refusals around it:
- `enabled` must be a JSON `true` or `false`. `"false"` is truthy in Python,
so coercing it would run the stage the author meant to switch off and never
tell them; `0`/`1` are refused in the same spirit.
- A job where **every** stage is disabled is refused. A page of "not analysed"
boxes and no numbers is not a report.
A disabled stage is otherwise **not validated** — its multiplier, `f'c` and
pick list are never read. Refusing the job over a field nobody uses would make
switching a stage off harder than deleting it, which pushes authors straight
back to the thing that leaves no record.
The key is absent from every job file written before it existed, and absent
means enabled: all 184 job files under a real sweep render byte-for-byte
identically with the flag in place. The writer follows the same rule and emits
`enabled` **only when it is false**.
---
## When it will not answer
- **Fewer than two support lines** — not a pick.
- **Every insert pinned** — nothing to recommend.
- **A stage naming an insert the panel does not have** — a hard error, never a
quietly smaller pick group. Analysing three picks where four were meant is
unconservative and looks entirely normal.
- **A stage with no multiplier and no `pci_case`** — refused rather than
defaulted to 1.0.
- **Short/long above 0.75** — the panel spans two ways and a beam
idealisation cannot represent it. It refuses instead of answering, because
answering would look ordinary and be wrong. Short over long, not width over
length: `transpose` swaps the two, and reading it as width/length meant a
turned piece came back above 1.00 and was refused as "close to square" at
2.33 to 1. Turning a piece cannot change whether it is square.
## Speed
**This section is where the batch timings live, and there are two separate
measurements below that are easy to mistake for one.** Both are on the same
184-piece reference sweep and both are real; they measure different paths, so
they do not have to agree:
| | what ran | where the figures are |
| --- | --- | --- |
| **the CPython half alone** | `python -m panellift <job> -o out.html --json` on the saved job files, one after another, no Revit | the table immediately below |
| **the real batch path** | the Panel Lift button, one CPython subprocess per piece, measured end to end | ["What was done about it"](#what-was-done-about-it), further down |
Quote the second when the question is "how long does a batch take", because
that is the path a user is on. Quote the first only when the question is about
the package on its own.
Two figures elsewhere in the tree restate these and are looser than what is
here: `panellift/src/panellift/cli.py`'s docstring says a 184-piece batch
takes "41 minutes" with the search and "40 seconds" without, which pairs the
sequential with-search run against the eight-at-a-time without-search one —
the like-for-like sequential figure is 2.5 minutes. `PanelLiftCheck/script.py`
restates the batch-path row correctly. **Neither is the record; this section
is.** Correct the docstring when something else takes you into that file.
Measured 2026-08-30 across the 184-piece reference sweep, CPython half only
(`python -m panellift <job> -o out.html --json` on real job files, run one
after another on one core):
| | s | % |
| --- | --- | --- |
| **the position search** (`layout.recommend`) | **12.6** | **93** |
| everything else — read, analyse, every check, render, write, summarise | 1.0 | 7 |
| **one piece, end to end** | **13.6** | |
Worst piece 45–49 s. The whole sweep one after another: **about 40 minutes**,
39 of which is the search. With the search skipped, the same 184 pieces — full
analysis, every check, every verdict, every report written — take **2 minutes**
in total, and 0.7 s of that is the median piece.
### The two numbers this document used to give were wrong
It said a run was "about two seconds", and the comment on `_MAX_COMBOS` costed
a candidate layout at about 1 ms. Both were left behind by the search itself:
`recommend` now sweeps every stage of the piece at once, and one candidate
layout costs **15 ms per stage**, fifteen times the figure the budget was
reasoned about with. On that arithmetic the `_MAX_COMBOS` ceiling of 40,000
layouts is not "a few seconds at worst", it is **ten minutes a stage**.
The ceiling is left where it is on purpose. It decides which pieces get a full
search rather than a local improvement, which is an engineering judgement and
not a performance one; what changes is that the comment beside it now says what
a layout actually costs. Both figures are corrected in place.
### Where the time goes inside the search
884 objective evaluations on the median piece, 15 ms each. About **76% of one
evaluation is a single `linalg.solve`** — dense Gaussian elimination, no
dependencies, deliberately (see `linalg.py`). Every piece in the reference
model has a stage with `rig_mode: static`, so that solve is in the inner loop
for all 184 of them:
| | |
| --- | --- |
| reduced system | 248–268 dof (266 typical) |
| true half-bandwidth | **3** — it is a beam, two dof a node, and only neighbouring nodes are coupled |
| diagonal spread, unscaled | 3.5 × 10¹⁰ to 1.2 × 10¹¹ |
A dense O(n³) elimination on a matrix that is 98% zeros is the whole cost.
### What was done about it
**The search became optional, and a batch skips it.** `--no-recommend` on the
CLI, `recommend_positions` in the run-time options for the single-piece button
(default ON — the recommendation is what the button is for), and always off in
a batch. `tools/sweep.py` passes it too: that sweep reads the verdict, the
per-stage ratios and the findings and never looked at a recommendation, so it
was computing one and throwing it away 184 times.
Every stage is still analysed and every check still runs: the flag drops an
*answer*, never a *check*. Measured across the whole reference sweep, the
verdicts are unchanged piece for piece — 111 OK, 59 WARN, 13 FAIL, 1 refused,
with and without.
Measured end to end through the real batch path (one CPython subprocess per
piece, 184 pieces). **These are the numbers to quote about a batch**, and they
are a few percent off the CPython-half table above because this path also pays
for process start-up and file I/O per piece:
| | one at a time | eight at a time |
| --- | --- | --- |
| search on (what shipped) | **38 min** | 8.9 min |
| search off | 2.5 min | **~31 s** |
The `~40 min` / `2 min` in the table at the top of this section is the same
comparison measured on the package alone. Nothing disagrees; two paths were
timed.
A report whose search did not run says so, in the warning style, under the
heading the positions would have filled — on the HTML page, on the button's
own output, and as `"searched": false` in the JSON summary. The section is
never simply absent. It cannot be: the report's words for a search that ran
and found nothing are "the inserts as drawn are already at the best position
this search can find", and a page missing the section reads as exactly that.
Same rule as a stage with `enabled: false`.
`"searched"` is written **only when the search was skipped**, the way the job
writer emits `enabled` only when it is false. A summary without the key is a
summary from a run that searched, which is what every summary written before
the flag existed means.
**A batch runs on several cores.** Each job was already an isolated CPython
subprocess reading one job file and writing its own report, log and summary —
nothing shared, so running eight at once is a scheduling change and not a
numerical one. `min(cores − 2, 8)`, two cores left alone because Revit is open
and the user is still working in it. Worth about 4.8× on the real batch path,
as the table above shows; it is the belt to the flag's braces, and it is what
keeps a batch tolerable for anyone who does want the positions.
The coordination is the risk, not the arithmetic, and `batchrun.py` is where it
lives for a reason — read its header before touching it. The queue, the lock
and the thread factory are created on the UI thread while the defining scope is
alive, and every function a worker thread runs has its names bound as default
parameters. There are now N threads that can kill Revit instead of one, so
`pump` carries its own outer guard.
**The search remembers layouts it has already costed.** `_peak_psi` is pure
for one frame — the frame holds the load profile, the section and the station
list, none of which move when a pick moves — and about 17% of the calls in a
real search are exact repeats: the coarse sweep and the first refinement pass
cross the same stations, and each refinement sweep re-tries every line's
current position before concluding it cannot improve on it. Measured 1.13×
over the whole sweep (3717 s → 3300 s of CPU), and it cannot change an answer,
because a memo hit returns the number the recomputation would have produced.
All 184 reference reports render **byte for byte identically** with the memo in
and the flag absent, HTML and JSON summary both, clock frozen. That is the
standard the `enabled` work set and it is the standard for anything that
touches this search.
Two dead things came out with it: `_Frame._spares`, a field whose comment
claimed a reuse optimisation that was never written, and a guard in `_peak_psi`
that re-solved the section if "the station list changed under us". It could not
fire — `_make_frame` builds that list from the same expression, the same
profile and the same discontinuities `diagram()` does. A dead branch in the
hottest loop in the package is a branch nobody can test.
### Not done: the banded solver, and the reason it is not a speed question
The obvious next step is to stop running a dense O(n³) elimination on a matrix
of half-bandwidth 3. A banded LDLᵀ measures **3.3× as a drop-in** and about
**7× with banded assembly**, which would take a run from 13.6 s to near 4 s.
The kernel is real and it works.
It is **not landed**, and the reason is not the kernel. Swapping solvers moved
recommended stations by up to 2.25 in., and that was read as the banded
solver being suspect. It is the wrong reading, and the measurement that shows
why is below.
#### What was measured
Perturb the objective by a relative epsilon — a deterministic one, so it models
*a different but equally valid solver*, one that rounds each layout the same
way every time — then re-run the search, and judge every answer it produces
with the **unperturbed** objective. Judging is one solve per layout, not a
search, so it is cheap. 19 pieces of the reference sweep:
| perturbation | pieces whose stations moved | worst move | the moved answer was |
| --- | --- | --- | --- |
| 1e-9 | **0 of 19** | — | — |
| 1e-6 | 4 of 19 | 9.75 in. | **better on 2, worse on 2** |
| 1e-5 | 4 of 19 | 10.0 in. | better on 2, worse on 2 |
| 1e-4 | 6 of 19 | 10.0 in. | better on 4, worse on 2 |
On a wider sample one piece moved **34.75 in.** — nearly three feet — under a
1e-6 perturbation, and the layout it moved to was **12% better** in governing
utilisation than the one the shipped search returns.
#### What that means
**The search is not noisy at its own tolerance.** At 1e-9 — the threshold
`_sweep_objective` accepts an improvement at — nothing moves at all, on any
piece. The tolerance is not the problem.
**It is unstable at the accuracy the objective actually has.** The static solve
is a dense elimination on an *unscaled* system: translational and rotational
degrees of freedom sit in the same matrix, and its diagonal spans 8.4 × 10¹⁰.
Elimination error goes as machine epsilon times the condition number, and the
diagonal spread is a floor under the condition number, so the objective is good
to something like 1 part in 10⁵ — squarely inside the band where the table
above says the answer wanders.
**And the wandering is not noise around one optimum.** If it were, the moved
answers would be equally good and the choice would not matter. They are not:
some are better and some are worse, by up to 0.010 of utilisation. The
objective has **several near-equal basins**, the exhaustive coarse sweep picks
between them on digits the solve cannot resolve, and the local refinement then
descends whichever one it was handed. Two pieces in nineteen have a layout
about ten inches from the recommended one that is 7–11% better, and the search
does not find it.
#### So the banded solver question is the wrong question
"The banded solver moves stations by 2.25 in." is not evidence that the banded
solver is wrong. Half of such moves are improvements. The test that settles it
is the one above and it costs a second a piece: **run both solvers, then score
both recommended layouts with one agreed objective.** Whichever layout has the
lower true utilisation is the better recommendation, whatever produced it.
Nobody had run that, which is why the 2.25 in. looked like a reason to stop.
Landing the banded solver as a speed change would have buried all of this under
a 3× and left the real finding — **the recommendations as shipped today are not
determined to better than several inches, and on some pieces are not the best
available at all** — undiscovered. That is a call for the owner, not a
refactor to slip in beside a cache.
#### What a fix looks like
1. **Scale the system before solving** (non-dimensionalise the rotational dof,
or solve in a normalised basis). This is worth doing on its own: it costs
nothing and it is what makes the objective trustworthy enough to compare
solvers at all.
2. **Then** land the banded solver, judged on the true objective rather than on
whether it reproduces today's stations.
3. Give the refinement more than one starting basin, or keep the best few
coarse layouts rather than only the winner — that is where the 7–11% on
those two pieces is being lost.
4. Add a tie-break that prefers the station nearest the layout as drawn, so two
layouts the model cannot tell apart never produce a move order. On a drawing
this is the one that matters: an insert should not be asked to move ten
inches for a stress difference nobody can measure.
Until then, read `Δx` as "about here". It is a good direction and a good
magnitude; it is not a dimension.
### The three redundancies that came out earlier
All of one kind — recomputing something that had not changed. Kept here
because the same shape is where the next win will be:
| | |
| --- | --- |
| `diagram()` | interpolated `W` and `Q` at stations where it already held them by index. 9.4M binary searches. Both lists are sorted, so they are now walked with one pointer. |
| `stress_profile()` | called `moment_at(x)` for every `x` in `diagram.xs` — a binary search for the number in the list beside the one being iterated. Now a `zip`. |
| the search | recomputed `S(x)` per candidate layout. The section belongs to the panel and the picks do not touch it, so it is solved once per stage. |
The first two are in the shipped checks, not just the search: the test suite
went from 6.3 s to 1.2 s on the same change.
## The strips the beam pretends are rigid
Two places where the one-way beam solve leans on a support being a line when
it is really a handful of discrete points, and the panel between those points
has to carry its load sideways to reach one:
- **EDGE-PATH** — erection. A horizontal piece stands on its long edge and
hangs from anchors dotted along the top; the strip between two of them is a
beam whose depth is the erected span.
- **ROW-FLEX** — any flat pick (`span == 'length'`). Each support row is a few
inserts spread across the width; the strip between two of them is a beam
whose depth is the longitudinal concrete beside that row — the longer of the
two adjacent segments. The load is the row's factored reaction spread over
the width, uniform because uniform is what the idealisation it corrects
assumed too.
Both are near-noise on solid panels — deep beams, so stiff the answer is not
close — and both exist for the geometry that breaks them: an opening reaching
across the support line and eating the web. EDGE-PATH loses its depth where an
opening cuts in from the anchored edge; ROW-FLEX loses it where an opening
sits beside the pick row, leaving a foot or two of web where nineteen had been.
Same arithmetic rotated 90 degrees, same severity bands, same honesty rules:
nothing to rate against says UNRATED instead of passing, and no section left
never reads as a pass.
The web is the concrete **beside** the row, which is not the same question as
how much of a hole lies on each side of it. An opening wholly outboard of the
row leaves the inboard concrete untouched, and the strip spans on whichever
side is deeper. Measuring instead to the hole's far face made a *bigger* hole
through the row come out as a *deeper* web — monotonically backwards, and the
reason `test_a_bigger_hole_through_the_row_never_reads_as_a_deeper_web` walks
a hole from 1 ft to 8 ft and refuses to see the stress fall.
A strip is not one depth either. An opening pinches it over its own band of
the width and leaves the rest at full depth, and *where* the pinch sits decides
most of the answer: a strip between two inserts carries its largest moment at
midspan and none at all at the inserts, so concrete missing beside an insert
costs almost nothing. The strip is therefore cut at every opening edge and each
band rated where its own moment peaks — midspan for a strip between inserts,
the root for an overhang. Taking the smallest web anywhere and applying it to
the whole strip reported 522 psi against 444 on a piece whose midspan is solid.
Three shapes are reported rather than rated, because rating them means
inventing a number:
- **ROW-FLEX-INTERRUPTED** — an opening crosses the row inside the strip.
There is no concrete beside the row in that band at all; the load travels
around the hole and comes back, which is not a beam of any depth. Calling it
a zero-depth FAIL would condemn a piece the model cannot speak about. 18 of
the 184 pieces in the reference model hit this.
- **ROW-FLEX-NOT-A-ROW, one insert** — a support line holding a single insert
has no strip *between* inserts: the width hangs off a point. Every instance
in the reference model is one staggered row read as two, because its inserts
sit further apart along the piece than `handling.group_tolerance_ft`.
- **ROW-FLEX-NOT-A-ROW, uplift** — a line whose reaction is negative is being
pulled up, not carrying the piece down into its inserts. Taking `abs()` of
it and rating a strip against the result is arithmetic on a number that
means the opposite; it is also a signal about the beam solve, not about this
check.
Those three guards took the reference model from 28 ROW-FLEX failures — the
worst reporting 33,491 psi against 444 — to none, with the worst rated strip
at 62% of allowable.
What neither answers: a piece squarish enough for the two directions to share
the load — aspect ratio warns above short/long 0.50 and refuses above 0.75 —
and partial-depth voids, which reduce no section property in this package.
## An insulated panel is refused, and the refusal says how far wrong it would be
**Landed 2026-08-31, and it closes a window that had been open for a few
hours.** Until that morning `panellift` had never once run on an insulated
piece: the weight gate refused every one of them because the solid sum was a
single wythe. That refusal was wrong about the weight and *accidentally right*
about everything else, because the analysis behind it models the section as
**solid concrete at the envelope thickness** — and a sandwich is not that.
So when the weight was fixed, the gate stopped refusing and started letting
insulated pieces through to a section idealisation that is wrong **in the
optimistic direction**. The owner:
> "把夹芯板当实心算并不 conservative。因为 insulation 并不会 100% composite,
> 你忽视了中间的 composite ratio 的计算和可能出现的问题。这是一个 insulated
> panel design 的时候非常大的一个问题。"
`panellift` now **refuses** an insulated job — `SANDWICH-SECTION`, no report,
no verdict, exit 2 — and it refuses *upstream-independently*: whether or not
the weight gate let the piece past is not its business. If a sandwich job file
reaches the package at all, this fires.
### How it knows
`source.solid_parts` in the job file, which the exporter has written since the
weight repair. **The signal travels in the file and is never re-derived on the
CPython side** — the file is the boundary, and a second opinion about a model
that process cannot see is a guess with a different accent. The ladder, and
the only rung that may answer "solid":
| what the file says | the answer |
| --- | --- |
| no `solid_parts` at all | **could not tell.** Never "solid". A hand-written file is the author's own statement and is taken as written; an exported one has carried the key since the repair, so a file without it belongs to a piece the weight gate refused anyway |
| one concrete solid, nothing left out | solid. Every solid panel in the reference job |
| anything left out as **not concrete** | sandwich — that is what the insulation is |
| two or more concrete solids, nothing left out | sandwich, **conservatively**. Nothing in the file can tell a wythe pair from a panel and a separately drawn ledge; over-refusing costs an answer, and the other way round prints a third of the real stress |
### What the refusal prints
A refusal that says only "no" is a refusal nobody can act on, so the
section-property comparison — which **is** the reason — goes out with it. On
the real 16 in. piece (4 in. architectural wythe, 4 in. EPS, 8 in. structural
wythe, 64 in. of clear width):
| | I, in⁴ | governing S, in³ |
| --- | --- | --- |
| solid at the 16 in. envelope | 21,845 | 2,731 |
| fully composite | 20,139 | 2,324 |
| non-composite | 3,072 | 768 |
| structural wythe alone | 2,731 | 683 |
The solid idealisation overstates the governing section modulus **3.56×
against the non-composite bound and 1.18× against the fully composite one**.
Both above 1.0, which is the finding: a sandwich has a hole in the middle of
its depth, so it is weaker than a solid piece of the same envelope **however
well the connectors work**, and there is no reading under which treating it as
solid is safe.
The page also carries the shear flow at the connector plane (`q = VQ/I`,
0.0847 lb per inch of span per pound of shear on this piece), each bound's
assumption beside its numbers, what stands down, and the enumeration below.
### The bounds are scaffolding, not the product
Two section states are computable **from geometry alone**, with no connector
data at all: non-composite (ΣI of the wythes about their own axes) and fully
composite (the transformed section about the sandwich's neutral axis). They
bracket the real panel, and they are what can be said **while the connector
data is absent**.
The partially composite middle is what insulated-panel design is actually
about. It needs per-connector **slip stiffness** and **capacity per failure
mode**, neither of which exists in any layer, so the report presents the
bounds as an interim envelope with the missing computation *named* — never as
the method.
### What the two bounds do NOT bracket
Three quantities are bracketed. Everything else about a sandwich panel is not
a bounds question, and reading a two-bound report as an envelope over all of
it is the trap this list exists for.
- **Bracketed:** wythe flexural tension (worst non-composite) — with the trap
that the **inner face of a wythe changes sign** between the bounds, so both
faces of both wythes have to be read, not only the envelope faces;
interface shear flow (worst fully composite); deflection (worst
non-composite). Differential bowing is a service problem and is **declared
out of scope**, not silently omitted.
- **Not bracketed — connector force distribution.** Both bounds are
distribution-free: one puts zero on every connector, the other spreads
demand smoothly as `V(x)`. Partial interaction concentrates slip at the
**ends of each shear span**, so a uniform-demand assumption *understates*
the end connectors. The mean is reported and named as a mean.
- **Not bracketed — connector normal (tension) demand.** A flat pick hangs one
wythe off the other in direct tension. Not an interface-shear question at
all, and where the panel is designed non-composite it is likely the
*governing* demand — which is why a capacity is wanted **per failure mode**
and not as one number.
- **Not bracketed — local flexure of the face wythe between connectors**, and
connector force at openings and pick points.
- **Percent composite is not one number.** The **stiffness** ratio and the
**strength** ratio are different quantities in the trade's own methods, so
any percentage printed says which one it is. Both stand down today: without
connector stiffness a computed ratio is not hard, it is impossible.
The plant's own family description calls these pins non-composite. That is
carried verbatim as **evidence about design intent** and nothing in the
mechanics branches on it — partial action exists whatever the label says. It
is with the owner, and his answer picks which bound the pass/fail line is
drawn against; the report carries both either way.
Conventions chosen, why each is the conservative reading, and the
owner-question list are in `panellift/PLAN.md`.
## Open
- **What depth the beam should use once the refusal is lifted.** The weight
counts both wythes and the *section* now knows they are two — but only well
enough to refuse. `thickness_in` and the openings are still taken from the
largest solid's extents (`ticketqc.concrete_extents`), which on a body
drawing several solids is **one wythe**; the job file now carries
`source.envelope_thickness_in` measured over every solid beside it, so the
two are on the page rather than one being mistaken for the other. What the
extent actually comes out as on a real insulated piece is still a live read
nobody has taken, and whether a sandwich acting through its connectors
should be idealised on the envelope, on one wythe, or on a bounded pair is
the owner's. Neither is inferred and neither is guessed at.
**Still unmeasured as of 2026-08-31; the bounds are now computed for all
three so it can be answered against numbers.**
- Transverse equilibrium *between* the strips: EDGE-PATH and ROW-FLEX rate
each path separately with full sections. A piece squarish enough that both
paths carry load together is exactly what the aspect-ratio gate refuses or
warns on; it is not solved here.
- **Whether a capacity ratio should become a verdict — ANSWERED 2026-09-01,
and one third of it is still open.** The question was put in three parts.
(a) *Should a ratio ≥ 1.0 raise anything at all?* and (b) *should stations
inside a development length be excluded?* were both answered by the owner,
by DM, verbatim:
> "M/pMn 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail,
> 我做最后决策。"
**FAIL, and no exclusion.** He was told first that all four known
exceedances sat within a strand's development length of a free end or an
opening edge, and ruled anyway: over 1.0 the tool reports a FAIL and he
makes the final call. What shipped the same day: `checks.check_capacity_ratios`,
`FLEX-CAP` and `SHEAR-CAP` findings at every station of every stage, the
development-length sentence carried on the finding as context, the offending
station forced into the station table, and the whole of it written up under
**Capacity, printed beside the demand** above.
**(c) is still open.** Whether the demand should carry a strength-design
load factor — LECWall's 1.4, which `Material.handling_load_factor` holds and
nothing applies — *on top of* the PCI Table 8.3.1 multiplier, or whether the
multiplier is the whole of it, was **not** answered. The demand is therefore
exactly what it was before the ruling, and the report says so on the page.
Nothing is guessed at until he answers.
**One measurement to put in front of him.** The three-part question was
asked against "`M/φMn` reached 1.0 at **4 stations on 2 pieces**" over 120
job files, with `V/φVn` never over 0.31. Re-measured on 2026-09-01 across
the 184-file reference sweep, that does not reproduce: the ~25 printed rows
per stage carry **70 exceedances on 40 pieces**, and every station of every
stage carries **323 on 112 pieces** — every one of them at an undeveloped
strand, and `V/φVn` still never over **0.35**. The ruling is unchanged by
that and was implemented as given; but the sweep now returns 117 FAIL where
it returned 13, and he ruled on a number two orders of magnitude smaller.
Which of the two counts is right has not been chased down — the earlier one
is not reproducible from anything in the tree.
- **Strain compatibility for `φMn`**, which `PLAN.md` has listed since
2026-08-18. What landed is ACI's approximate `fps` (20.3.2.3.1), which is
strand-only and code-limited to `fse ≥ 0.5 fpu`. The upgrade needs a strand
stress-strain curve no layer holds, and mild steel the job file cannot
express.
- **`fpu` is defaulted, not read.** 270 ksi with its provenance printed, and
the routing key named above. One `liftexport` line closes it.
- Partial-depth blockouts are carried but do not reduce `S`, and so are the
blind `recesses` the exporter now separates out — the page states that
approximation, and closing it means depth-aware section properties.
- **The 184 sweep job files predate the through-vs-blind split** and were
written from a model that has since diverged from the one open (see the
`source.model` guard in `tools/sweep.py`). Every number in them that rests
on a blockout has to be re-exported before it means anything; 164 of the 184
carry blockouts at all, and that is the upper bound on how many moved.