25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
1524 lines
78 KiB
Markdown
1524 lines
78 KiB
Markdown
# 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.
|