# 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 out-of-process pattern the rest of the tools use. The package has **no dependencies** — every dependency is something that can be missing at the moment somebody needs a number. ``` Revit ──liftexport──> .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.