# 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──> .job.json ──panellift──> .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 -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 -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.