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

437 lines
25 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.
# panellift — plan
PCI Chapter 8 handling checks for precast wall panels, fed from the Revit
model that already holds the geometry.
Scope is the "first tier" agreed on 2026-08-18: **stripping and yard handling,
flat**. Transportation (needs dunnage positions the model does not have) and
erection (the panel rotating up, load shedding onto the lowest pick point) are
explicitly out.
---
## Why the calculation does not run inside Revit
The extension runs on IronPython 2.7 on Revit's UI thread. A calculation whose
output goes to a plant has to be verifiable, and verifiable means a test suite
that runs in a second without a Revit licence, a model, or a Windows box.
So: **Revit writes a JSON file, a plain CPython package reads it.** Same
pattern already proven by `vibe.aiextract`. The package has **no
dependencies** — every dependency is something that can be missing at the
moment somebody needs a number.
```
Revit ──liftexport──> <mark>.json ──panellift──> report.html
(IronPython 2.7) (CPython 3.9+)
```
A consequence worth keeping: a panel can be checked by hand-writing the JSON,
with no model at all.
## Decisions taken
| Question | Decision |
|---|---|
| Lifter capacity vs handling multiplier | **Not** stacked. PCI Table 8.3.1 note (a): those factors are for flexural design and are not to be applied to lifting-device safety factors. Inserts are checked against unfactored reactions and against **the published rating of the part itself** — safety factors are per product in `knowledge/catalogue.yaml` and an insert with none published is reported unjudged. *This row used to end "with the manufacturer's 4:1 tension / 2.67:1 shear". No manufacturer published that. The sentence was written the day after the constant it justified and it 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" across both load columns — 4:1 in shear as well. Corrected 2026-08-31, and recorded rather than quietly deleted, because an invented pedigree on a safety factor is the kind of thing that gets re-derived from the same document twice.* |
| Sling angle | Engineer's config file, in degrees. Not inferred from the model. |
| Rolling block vs static lift | Engineer's config file. **Two different mechanics**, not a coefficient — see below. |
| Load cases | Stripping + yard handling. |
| Flexural capacity | Shipped 2026-08-31 as ACI 318-19 approximate `fps` (Eq. 20.3.2.3.1), strand only, in `capacity.py`. **The columns were informational for one day.** Owner's ruling 2026-09-01 — *"M/pMn 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail,我做最后决策。"* — so `M/φMn > 1.0` is a `FLEX-CAP` **FAIL** and `V/φVn > 1.0` a `SHEAR-CAP` FAIL, checked at every station of every stage, with **no development-length exclusion**: the tool may not bless a ratio over 1.0 and the engineer stamps past it. The stress check keeps its own verdict; a station that cannot be judged stays a dash and is neither. Full strain compatibility remains the upgrade: it needs the strand stress-strain curve and mild-steel/WWF input paths that no layer holds yet. `fy` defaults to 60 ksi, `fyt` from the design summary; both overridable in config. |
### Rolling block vs static lift
- **rolling** — equalising sheaves, every line in a cable group carries the
same force. Two groups, two equations of statics, determinate. Reactions do
not depend on stiffness, so a blockout does not move them.
- **static** — fixed-length slings. The panel is a continuous beam on rigid
supports; three or more support lines are indeterminate and the reactions
follow flexural stiffness. A blockout beside a support really does shed load
off it.
Measured on the ASPIRE panel with three support lines at 4 / 16 / 28 ft:
```
rolling 15,167 15,167 15,167 lb
static 13,508 18,484 13,508 lb middle line +22%
```
Picking the wrong one is a 22% error on an insert. Hence: the engineer says.
## The design decision that matters most
**PCI Figure 8.3.2's coefficients are a test, not the engine.**
Those coefficients only hold when the picks sit where positive and negative
moment come out equal. The entire reason to check a real ticket is that the
detailer may not have put them there — so using the closed form as the engine
would assume the answer.
Instead the moment comes from a real beam solve at the *actual* insert
positions, and Figure 8.3.2 becomes the regression test: put the supports at
`(sqrt(2)-1)/2` of the span and the solver must return `0.0214 w a b²`.
It does. That identity is also *derived* in `pci.py` rather than quoted —
solving `w(aL)²/2 = wL²(1-2a)²/8 - w(aL)²/2` gives `4a² + 4a - 1 = 0`, hence
`a = 0.20711` and a coefficient of `0.02145`, which is PCI's 0.0214.
## Known idealisation
The panel is treated as a beam spanning its long direction. This *is* PCI's
own method for `My`, and it is what the ASPIRE examples do. It is not valid
for a near-square panel, where two-way plate action governs — that is
finite-element territory (PanelsPlus). **The code must refuse rather than
answer** when the aspect ratio gets close to 1. Not yet implemented.
The transverse direction `Mx` will use PCI's closed form with the "resisted by
15t or b/2" rule, as a secondary check.
## Status
### Done and tested — 34 tests, all passing
| Module | |
|---|---|
| `model.py` | `Panel`, `Blockout`, `Lifter`, `Rigging`, `Reinforcement`, `Stage`, `Material` |
| `section.py` | `b_eff(x)`, `S(x)`, `I(x)`, `w(x)`, blockout merging, weight reconciliation |
| `pci.py` | Table 8.3.1, `5√f'c`, Figure 8.3.2 closed forms, optimal support fraction |
| `linalg.py` | dense solver, so there are no dependencies |
| `statics.py` | insert clustering → support lines → cable groups; rolling and static solvers; V(x), M(x); sling forces |
Reproduced from ASPIRE Winter 2019 (Brown), which reprints PCI Table 8.3.1 and
Figure 8.3.2:
| | ours | published |
|---|---|---|
| unit weight | 109.4 psf | 109.4 |
| S solid | 1531 in³ | 1531 |
| S, five 6"×24" pockets | 1148 in³ | 1148 |
| unfactored moment | 24,020 lb-ft | 23,973 |
| stripping stress, solid | 245 psi | 244 — OK vs 316 |
| transport stress, solid | 282 psi | 282 — OK vs 354 |
| stripping stress, pocketed | 326 psi | 326 — **no good** vs 316 |
| transport stress, pocketed | 377 psi | 376 — **no good** vs 354 |
The moment differs by 0.2% because the article rounds the coefficient to
0.0214; we carry `(√2−1)²/8`.
### Done since — 76 tests
| | |
|---|---|
| `checks.py` | cracking, insert capacity with sling amplification, edge-anchor shear and plumb-hang tension, per-stage derating, aspect-ratio refusal, unrated inserts as a warning |
| `report.py` | self-contained HTML; `build_many` puts every stage of one piece in one file behind a verdict table |
| `layout.py` | recommended insert positions and the delta from the model |
| `cli.py` | `panellift job.json -o report.html [--json]` |
| `vibe/liftexport.py` | the Revit side of the job file, plus the subprocess |
| `Panel Lift Check` | the button: select an assembly, confirm the picks, choose where the HTML goes |
### Next
1. Strain-compatibility `φMn` (upgrade of `capacity.py`'s approximate-`fps`
columns, shipped 2026-08-31): strand power curve, mild steel and WWF layers
— the input paths for both are still owed before this can be honest.
2. Transverse equilibrium. `layout.py` reports how far a row's centroid sits
off the width centreline, but a beam model cannot say what that costs. On a
panel with large openings the recommended rows can be forced entirely to
one side, and whether the piece twists is currently the engineer's call
with no number attached.
3. Per-piece config overrides (sling angle, lift mode) without editing the
plant knowledge file.
### Answered against the live model (sample_job, 2026-08-18)
Probed on `PNL204` — a `PANEL_VERTICAL::08"`, design PNL1.0,
13'-10" x 4'-0" x 8", 5,533 lb, 51 members.
**Panel-local frame.** `x` is the width, `y` is the thickness, `z` is the
length. On PNL204: `x ∈ [-2.000, 2.000]`, `y ∈ [-0.667, 0.000]`,
`z ∈ [0, 13.917]` ft. So the form face is `y = -0.667` and the exposed face
`y = 0`.
**1. Blockouts are family instances, not `Opening` elements.** The document
contains **zero** `Opening` instances. Voids are a family group under Generic
Models:
| family | count |
| --- | --- |
| `VOID_RECTANGULAR` | 622 |
| `VOID_RECESS_3_SIDES` | 234 |
| `VOID_POUR_STRIP` | 102 |
| `VOID_DT_DAP` | 54 |
| `VOID_ROUND` | 46 |
| `V-PLATE_VOID_RECTANGULAR` | 22 |
| `VOID_TAPER_ROUND` | 17 |
`STEM_BLOCKOUT` (2694) is under Specialty Equipment and belongs to double-tee
stems, not wall panels. So `b_eff(x)` gets populated the same way the lifters
do: instance position transformed to panel-local, size from instance
parameters. Whether these families actually cut the solid, or only mark the
void for the detailer, still needs checking — it decides whether the geometric
weight reconciliation will agree.
**2. Insert role separates cleanly by geometry.** On PNL204:
| family | count | x | y | z |
| --- | --- | --- | --- | --- |
| `ANCHOR_FACE_4T` | 4 | ±1.000 | **-0.667** (form face) | 2.583, 11.417 |
| `LIFT_EDGE_4T` | 2 | ±1.000 | **-0.333** (mid-thickness) | **13.917** (top end) |
Face-mounted and inboard → **stripping**. Mid-thickness and at an end →
**erection**, exactly as suspected of the `LIFT_EDGE_8T` seen at 0'-0".
Two independent signals agree, family prefix and position, so the rule can
check itself rather than trusting a name list.
The real pick is 4 points on 2 support lines at `z/L` = 0.186 and 0.180,
against the PCI optimum of 0.207 — a good first live test case.
**Capacity is encoded in the family name**: `ANCHOR_FACE_4T` → 4T, `LIFT_EDGE_4T`
→ 4T, `LIFT_EDGE_8T` → 8T. That makes `knowledge.yaml`'s `capacity_lb`
table largely derivable. **Open question for the engineer: is `4T` the working
load limit or the ultimate?** It is a factor of four on every insert check.
**3. Strand positions are recoverable, through-thickness included.** Four
`1-2_STRAND` at `y = -0.500` and `y = -0.167` — two layers, 2 in.
from each face of the 8 in. panel. That `y` is precisely the depth strain
compatibility needs. `WWF_MESH_A` sits in the same two layers, confirming
`per_face` in the spec schema.
Unresolved: the four strands sit at only two `x` stations (-1.500 and 0.000)
on a panel spanning -2.000 to 2.000, which is not symmetric. Either the family
insertion point is off-centre or `_point_of` is returning a curve start.
Worth pinning before trusting a lever arm.
**4. Panels are boxes, and there are only two thicknesses.** PNL204's local
bounding box is a clean 4.000 x 0.667 x 13.917. The whole model uses only
`PANEL_VERTICAL` and `PANEL_HORIZONTAL`, each in `08"` and `12"`.
One discrepancy to carry: the bounding box gives 13.917 ft = 13'-11" while
the panel's stated length parameter says 13'-10". Use the stated dimensions
for the calculation and report the delta, the same way a ticket's printed
length runs over the modelled one on stemmed pieces.
### Smaller known gaps
- Three support lines with equal gaps on both sides make the cable grouping
ambiguous; currently resolved to the first. Should warn.
- Partial-depth blockouts are carried but do not reduce `S`.
- Inclined slings: the horizontal component is reported, but the secondary
moment it induces is not analysed. LECWall does not either, and says so.
---
## Insulated (sandwich) panels
Added 2026-08-31. Read this before touching `sandwich.py` or the refusal in
`cli._refuse_sandwich`.
### Why it is a refusal first and mechanics second
Every section property in this package is built on **one prism** of concrete
`panel.thickness_in` deep — `section.NetSection`, `section_modulus_in3`, the
four-corner stress. An insulated body is two concrete wythes with insulation
between them, and idealising it as solid concrete at the envelope depth
**overstates the section modulus and understates the tensile stress**. The
owner, 2026-08-31:
> "把夹芯板当实心算并不 conservative。因为 insulation 并不会 100% composite,
> 你忽视了中间的 composite ratio 的计算和可能出现的问题。这是一个 insulated
> panel design 的时候非常大的一个问题。"
On the dimensions of a real 16 in. piece (4 in. architectural wythe, 4 in.
EPS, 8 in. structural wythe, 64 in. of clear width) the arithmetic is:
| | 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 |
So the solid idealisation overstates the governing section modulus by
**3.56× against the non-composite bound and still 1.18× against the fully
composite one**. Both above 1.0: there is **no reading of a sandwich under
which treating it as solid is the safe direction**, because a sandwich has a
hole in the middle of its depth and is weaker than a solid piece of the same
envelope however well the connectors work. `test_sandwich.py` keeps every one
of those numbers with the arithmetic in a comment.
**Until 2026-08-31 this never fired.** The weight gate refused every insulated
piece for an unrelated reason — the solid sum was one wythe — and that refusal
*accidentally* protected against this. The weight is right now, so the gate
opens, and it opens onto a wrong section. `cli._refuse_sandwich` is what
closes that window, and it is deliberately **upstream-independent**: it does
not care whether the weight gate let the piece through. If a sandwich job file
reaches this package at all, it fires.
### The detection signal travels in the job file
`source.solid_parts` — the per-solid breakdown the Revit side has written
since 2026-08-31. Nothing on this side re-derives it: the file is the
boundary, and a second opinion about a model this process cannot see would be
a guess with a different accent. The ladder is in `sandwich.detect` and the
only rung that may return `False` is *one concrete solid and nothing left
out*. No signal at all returns **None**, never `False`.
### The conventions chosen, and why each is the conservative one
Every one of these is stated on the number it touches, in the report and in
`sandwich.py`, and every one is on the owner-question list below.
| decision | what was chosen | why it is the conservative reading |
| --- | --- | --- |
| **wythe load sharing, non-composite bound** | the wythes are tied by the connectors so they deflect together; each takes the moment in proportion to its own stiffness, `M_i = M·I_i/ΣI`, so `f = M·(t_max/2)/ΣI` | it is the compatible answer, and the extreme fibre lands on the **thickest** wythe rather than being averaged over both. The alternative — sharing by mass — puts less moment on the stiff wythe than compatibility does |
| **is the face wythe structural** | reported **both ways**. The non-composite bound assumes both wythes resist; `structural_wythe_alone` assumes the thickest carries the whole piece and the thinner is dead load | the second is the smallest section modulus of the three (683 in³ against 768), so printing it is the conservative half and printing only it would hide the bound the owner may actually want |
| **which wythe is structural** | the **thickest**, by trade convention | not measured, and it is named as not measured every time it is used. There is no conservative direction available here, only a disclosed one |
| **openings** | assumed to run through **both** wythes, so `b_eff(x)` is shared | the measured plan areas of the two wythes on the real piece differ by ~1%, which is inside the rounding on the volumes. If they diverge on some piece, each wythe needs its own opening set |
| **insulation** | contributes **nothing** to the section | its modulus is three orders below concrete's. This is the standard treatment, not an approximation invented here |
| **modular ratio** | 1, no transformation | both wythes are the same mix on every piece measured. Where the densities differ the report says so rather than quietly scaling one |
| **connector demand distribution** | the mean over a row, at the fully-composite `VQ/I`, **named as a mean and not as a bound** | it is honest about being an assumption. It is *not* conservative — see below — and saying so is the only available treatment until `k` exists |
### The bounds are scaffolding, not the product
The owner's instruction, and it reshapes what this is for. The two-bound
envelope is what can be computed **while the connector data is absent**. The
partial-composite middle is the thing insulated-panel design is actually
about, and the capacities and stiffnesses being hunted are the *inputs to
computing it*, not garnish. The report presents the bounds as an interim
envelope with the missing computation named.
### 可能出现的问题 — what the two bounds do NOT bracket
`sandwich.concerns()` is the enumeration, and reading it is the point.
**Three quantities are bracketed. Everything else about a sandwich panel is
not a bounds question at all**, and a reader who takes a two-bound report as
an envelope over all of it is the failure the list exists to prevent.
Bracketed:
- **wythe flexural tension** — maximal non-composite, monotone in between.
With one trap: the **inner face of a wythe changes sign** between the
bounds (compression non-composite, tension fully composite), so both faces
of both wythes have to be evaluated, not only the envelope faces.
- **interface shear flow** — maximal fully composite, `q = VQ/I`, zero
non-composite. A true upper bound on the **total** interface force, which
is why it is computed even where the plant designs the panel non-composite.
- **deflection** — maximal non-composite. Differential bowing from
temperature and shrinkage is a service problem, not a handling one, and is
**declared out of scope** rather than silently omitted.
Not bracketed:
- **connector force distribution along the span.** Both bounds are
distribution-free — one puts zero on every connector, the other spreads
demand smoothly as `V(x)`. Real partial interaction concentrates slip, and
therefore connector force, **at the ends of each shear span**, decaying
over a length set by the connector stiffness `k` against the wythes' axial
stiffness `EA`. A uniform-demand assumption **understates the end
connectors**. When `k` arrives, the standard partial-interaction treatment
is what goes in here; until then the mean is reported and named as a mean.
- **connector force at discontinuities** — an opening edge or a pick point
restarts the slip profile and the connectors beside it see a local peak
neither bound shows.
- **connector NORMAL (tension) demand.** A flat pick hangs one wythe off the
other through the connectors in direct tension; an erection pick peels the
face wythe as the piece rotates. Not an interface-shear question at all, so
**neither bound speaks to it** — and where the panel is designed
non-composite it is likely the *governing* connector demand. This is why a
capacity is needed **per failure mode**, not as one number.
- **local flexure of the face wythe between connectors** — a slab strip on
discrete supports at the connector spacing. A different mechanism from
anything in this package.
- **percent composite is not one number.** The **stiffness** ratio
(`κ_I = (I_eff − I_nc)/(I_full − I_nc)`, what governs deflection and
bowing) and the **strength** ratio (`η`, the degree of shear connection,
what governs capacity) are different quantities in the trade's own methods.
Any percentage this report prints says which one it is.
### What stands down, and what unblocks it
| stood down | why | unblocked by |
| --- | --- | --- |
| **composite ratio** (both of them) | a computed ratio without connector data is not hard, it is *impossible*. The width of the band is printed; the position in it is not | connector slip stiffness `k`, lb/in per connector (stiffness ratio); connector capacity per failure mode (strength ratio) |
| **connector sufficiency** | no capacity is published in any layer. `verdict` stays `None` — never OK, never a pass | capacity **per failure mode**: interface shear **and** normal/tension, lb per connector |
| **connector recognition** on the gather side | nothing reads the connector instances off the piece. `source.connectors` is written with every field null and the reason beside it, because a *missing* key reads downstream as "this piece has no connectors" | a recognition rule. Measured by the bridge holder, and it is plant knowledge so the parameter names live in the plant layer: identity is a **type-level** parameter, **not** the family name, and a second type-level parameter is the gate. Family-name matching silently finds nothing here — a decoy family with no instances carries the same identity string |
| **the layup on an old job file** | `solid_parts` without `z0_in`/`z1_in` says a sandwich exists and cannot say what its section is. `Layup.measured` is False and `section_bounds` refuses rather than inventing a layup | re-export the piece |
### The plant calls its pins non-composite, and that settles nothing
The insulation family on these pieces carries the description *"EPS with Holes
for Non-composite pins"*. That is **evidence about design intent, carried
verbatim in `source.connectors.declared_action` and quoted in the report**.
It is not a structural ruling, and nothing in the mechanics branches on it:
partial composite action exists physically whatever the label says, and the
connector product has published stiffness data. What the owner's confirmation
would settle is **which bound the pass/fail line is drawn against** — the
report carries both either way.
### Which end is up, and why a disclosed fallback is allowed
`knowledge.up_end_from_origin` is this project's flagship declared-coverage
refusal — three measured conditions, and it never returns `False`. When it
declines, `liftexport.erected_orientation` reads global elevation and answers
anyway, writing the refusal's own reason into `source.up_end_basis_why` —
and **nothing read that key**, partly because `read_panel` looked for
`source` inside `panel` while the exporter writes it at job level, so
`panel.source` came out empty on every real job file.
Both halves are fixed. The job-level `source` is merged onto the panel, and
`report.up_end_disclosure` renders the fallback on the page, in the CLI's
stdout and in the summary's `basis` key — written **only** when the ruled
mechanism did not answer, the same "only when it is not the ordinary case"
rule `searched` and `enabled` follow.
The deliberate decision, because "never fall back" is not the answer either:
- The rule declines on a **vertical wall panel** because it is only confirmed
for horizontal ones. Global elevation is the mechanism that is *sound* for
a piece erected on end — its two ends really are at different heights — so
on that decline the fallback is the right tool for the case the rule handed
over, not a guess.
- It is meaningless for a piece erected long-ways, and that case never
reaches here: `MIN_VERTICAL_SHARE` refuses any piece with no axis
convincingly up, and the aspect-ratio gate refuses a near-square one.
- **It reaches one stage.** Stripping and transportation are analysed flat
and are invariant under mirroring `x` — reverse the piece end for end and
the beam problem is its own mirror image. Only erection depends on it:
which row `exclude_top_rows` drops, and which edge the anchors are on.
So the fallback answers, and it is stated on every report it touched, naming
the rule's own reason for standing down. `default_stages` gets the same
treatment one step further on: an absent `source.erected_vertical_axis` used
to be read as `or 'length'`, and now says so in `problems`.
### Owner questions
Nothing below is inferred and nothing below is guessed at. Numbered so they
can be answered out of order.
1. **Are the insulated panels designed, and to be checked, as
non-composite?** The plant's own family description says the pins are
non-composite. Confirming it picks which bound the pass/fail line is drawn
against; the report carries both either way.
2. **Which wythe is structural?** The thickest is assumed, by trade
convention, and named as an assumption every time. A ruling makes it a
fact.
3. **Is the architectural wythe to be relied on in flexure at all**, or
carried by the connectors and resisting nothing? The two readings differ
by 768 in³ against 683 in³ on the reference piece.
4. **What depth should the beam use once the refusal is lifted** — the
envelope, one wythe, or a bounded pair? This is the question
`docs/lift-check.md` has been carrying as open since 2026-08-31, and the
bounds are now computed for all three so it can be answered against
numbers.
5. **Connector capacity per failure mode** — interface shear, and
normal/tension — in lb per connector, and the concrete strength the
ratings were derived at.
6. **Connector slip stiffness `k`**, lb/in per connector. Without it there is
no ratio and no end-concentration factor, and both stay stood down.
7. **Do the openings run through both wythes on every piece?** Assumed yes;
the measured plan areas on the reference piece agree to ~1%, which is
inside the rounding.
8. **Is the local thickness axis' minimum face the form face?** The layup is
measured from the lowest face of any solid the body draws, which is the
same convention the strand and insert `z_in` already use, and which face
that is has never been established. A symmetric layup is the same either
way; an asymmetric one may be the mirror of what was cast.