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

78 KiB
Raw Permalink Blame History

Panel Lift Check

Select an assembly, click the inserts that carry each stage, choose where the HTML goes. One report per piece covering stripping, transportation and erection, ending in one recommended position per insert and the delta from where it is drawn.

Revit  ──liftexport──>  <mark>.job.json  ──panellift──>  <mark>.html
(IronPython 2.7)                          (CPython 3.9+)

The job file is kept beside the report. It is the complete input, so any number in the report can be re-run by hand, on a machine with no Revit on it:

python -m panellift WP000.job.json -o WP000.html --json

Three things the Revit side decides

None of them is mechanics, and all three are wrong by default if nobody decides them.

Which way up, and on which axis

x = 0 is the bottom of the piece as erected. Get it backwards and the erection stage drops the wrong row of lifters — tens of thousands of pound-feet, and it looks entirely normal in the output.

Read it off the assembly origin. Every piece carries one, drawn in the model as a red/green/blue triad, and it is a thing somebody placed with a direction rather than something inferred.

Inference is what got this wrong. Comparing the elevation of the two ends of the length axis answers it for a wall panel erected on end and is meaningless for a horizontal one, where both ends are at the same height and the "difference" is whatever the model's rounding happens to be. The anchors got assigned to whichever end won a coin toss.

Every panel direction is asked how much of itself points up, and the largest wins:

erected vertical axis the top of the piece is the anchors
length an end snap in x, pinned along the piece, free across it
width a long edge snap in y, pinned to that edge, free to slide along

Those are two different constraints and they are not the same inserts, so pinned and pinned_y are separate. Collapsing them into one flag gets one case wrong, and the wrong one is a recommendation to put an anchor where the piece has no edge.

If nothing points convincingly up (< 0.7, so more than 45° off), it refuses rather than guessing. So does a piece whose origin stands its thickness up: a beam idealisation spanning in the panel's own plane has no meaning for a piece stood on its face.

Which way the piece spans, per stage

A piece does not always hang the way it was cast, so the span is a property of the stage, not of the panel:

stage spans
stripping, transportation the long direction, always — that is how it lies flat, and how the face lifters are rowed across it
erection whatever the assembly origin stands up

For a wall panel those are the same thing and nothing changes. For a horizontal panel erection spans the width, and the rows have to be taken again in that direction: the anchors along the top edge are one support when the piece is standing and several when it is flat.

The solver is not taught about orientation. The piece is turned instead — cli.transpose swaps length with width, every x with its y, and pinned with pinned_y — so the mechanics stay one implementation checked against published examples. Turning it twice gives back what you started with, which is a test.

The position search sits out any stage that spans the width: it optimises one set of coordinates, and a turned stage's x is the panel's y, so including it would be optimising against two different pieces. It still answers for the stages it can and names the one it left out — silently answering for two of three stages would read as an answer for all three.

Where an insert acts

A face lifter acts at the centre of its solid. An anchor cast into the top edge does not: it is welded to a bar, so its modelled box centre sits inboard by however long that bar is. On one real piece the gap was 7 inches, and 7 inches of lever arm on the erection pick is not a rounding error.

So handling.lifter_groups carries an at field:

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:

  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.


The half of the question that gets acted on. A verdict says whether the pick as drawn works; it does not say where to put the inserts instead, and doing that by hand is a search nobody has time for.

One answer for the piece, not one per stage

An insert is installed once and then has to survive every stage. Optimising the stages separately gives three different answers for the same hole. So the objective is the worst stage's utilisation — peak tension over what that stage allows — and not a stress in psi: stages run at different concrete strengths, and 450 psi at release is a different problem from 450 psi at 28 days.

x: an exhaustive coarse sweep, then refinement

Not PCI Figure 8.3.2's closed form. That holds for two support lines on a prismatic panel, and a panel with door openings is not prismatic — where the openings are, the optimum moves, which is the entire reason to solve it.

The coarse pass tries every combination of stations at 1 ft, and that is not caution. The objective is not convex and not even continuous: with rolling blocks the support lines are split into two cable groups at their widest gap, so moving one line past another's midpoint re-partitions the load in a jump.

Coordinate descent walks straight into that. Measured on a real three-line pick: it settled at 280 psi with two rows collapsed 1.6 ft apart, while a spread layout it never tried reached 130 psi.

Refinement afterwards is local, at 1 in. then 1/4 in., which is safe once the coarse answer has put every line in the right basin. The search never returns anything worse than the layout it was given.

The last several inches of the answer are not determined. The search accepts an improvement at 1e-9 on an objective that is good to perhaps 1 part in 10⁵, and the objective has several near-equal basins — so which basin the coarse sweep hands to the refinement is decided in digits the beam solve cannot resolve. Measured on 19 pieces: a perturbation at the objective's own accuracy moves a recommended station by up to 10 in. (34.75 in. on one piece of a wider sample), and the moved answer is as often better as worse.

Read Δx as "about here" — a direction and a magnitude, not a dimension. The full measurement, what it says about the un-landed banded solver, and what a fix looks like are in Speed → Not done: the banded solver.

y: geometry, and it says so

The beam model has no transverse mechanics at all. Two things are wanted and they conflict:

  • spread — inserts near the edges of the available concrete, because a narrow pair lets the piece roll about the line between them;
  • centred — the row's centroid on the width centreline, so it hangs level.

Spread wins. The as-drawn spacing is kept where the clear strip allows it and opened out to fill the strip where it does not — never pinched inwards, which is what a symmetry-first rule does and it produces pairs a foot apart on a ten foot panel.

Where the openings leave no strip straddling the centreline, the row goes where the concrete is and the eccentricity is reported in inches. That is a statement, not a calculation. Whether the piece twists, and whether it needs rigging for it, this model cannot answer.

Installability first

Every offered position is checked against the openings and the catalogue's minimum edge distance, with the clearance applied to blockout edges as well as panel edges — an insert near a hole has less concrete behind it than its rating assumes. A recommendation that cannot be installed is not a recommendation.


A stage that was not analysed

A stage can be switched off — "enabled": false on the stage in the job file, enabled: false on the spec in handling.stages — and it still appears in the report. That is the whole reason the flag exists. Deleting the stage from the list instead leaves no record that anybody meant to check it, and a stage missing from a report is read as a stage that passed. "Could not be checked" must never be mistaken for "checked and fine".

What it looks like:

  • In the summary, which is the one part of the page that is always open: a box in the same row as the stages that ran, dashed rather than colour-striped, with the sentence "Erection analysis not performed." and the reason if the job file gave one (disabled_reason, or the stage's note). No ratio — not a percentage, not a zero, not even a dash where the others print 96%. A dash in a column of numbers gets scored as a number that came out small.
  • In the summary table, a row saying not analysed across the numeric columns and NOT RUN in the result column.
  • In the detail, its own collapsed disclosure in its place among the others, spelling out that no load was applied, no stress computed and no check run — so there is no result to pass or fail.

The overall verdict:

  • A skipped stage never becomes a verdict, and never changes one. Nothing was measured; it cannot pass and it cannot fail. OK/WARN/FAIL still mean what they meant, and the CLI exit code is unchanged.
  • What does change is the claim. "All checks satisfied" is a statement about the panel and it is false the moment a handling condition was not looked at. So the banner reads "All checks satisfied for the 2 stages analysed — 1 stage (erection) was not analysed. This report does not say whether it would pass", and carries a PARTIAL tag so the shape of the banner differs too, for the reader who takes in the colour and moves on. The JSON summary says the same thing in complete: false and not_run: [].

Two refusals around it:

  • enabled must be a JSON true or false. "false" is truthy in Python, so coercing it would run the stage the author meant to switch off and never tell them; 0/1 are refused in the same spirit.
  • A job where every stage is disabled is refused. A page of "not analysed" boxes and no numbers is not a report.

A disabled stage is otherwise not validated — its multiplier, f'c and pick list are never read. Refusing the job over a field nobody uses would make switching a stage off harder than deleting it, which pushes authors straight back to the thing that leaves no record.

The key is absent from every job file written before it existed, and absent means enabled: all 184 job files under a real sweep render byte-for-byte identically with the flag in place. The writer follows the same rule and emits enabled only when it is false.


When it will not answer

  • Fewer than two support lines — not a pick.
  • Every insert pinned — nothing to recommend.
  • A stage naming an insert the panel does not have — a hard error, never a quietly smaller pick group. Analysing three picks where four were meant is unconservative and looks entirely normal.
  • A stage with no multiplier and no pci_case — refused rather than defaulted to 1.0.
  • Short/long above 0.75 — the panel spans two ways and a beam idealisation cannot represent it. It refuses instead of answering, because answering would look ordinary and be wrong. Short over long, not width over length: transpose swaps the two, and reading it as width/length meant a turned piece came back above 1.00 and was refused as "close to square" at 2.33 to 1. Turning a piece cannot change whether it is square.

Speed

This section is where the batch timings live, and there are two separate measurements below that are easy to mistake for one. Both are on the same 184-piece reference sweep and both are real; they measure different paths, so they do not have to agree:

what ran where the figures are
the CPython half alone python -m panellift <job> -o out.html --json on the saved job files, one after another, no Revit the table immediately below
the real batch path the Panel Lift button, one CPython subprocess per piece, measured end to end "What was done about it", further down

Quote the second when the question is "how long does a batch take", because that is the path a user is on. Quote the first only when the question is about the package on its own.

Two figures elsewhere in the tree restate these and are looser than what is here: panellift/src/panellift/cli.py's docstring says a 184-piece batch takes "41 minutes" with the search and "40 seconds" without, which pairs the sequential with-search run against the eight-at-a-time without-search one — the like-for-like sequential figure is 2.5 minutes. PanelLiftCheck/script.py restates the batch-path row correctly. Neither is the record; this section is. Correct the docstring when something else takes you into that file.

Measured 2026-08-30 across the 184-piece reference sweep, CPython half only (python -m panellift <job> -o out.html --json on real job files, run one after another on one core):

s %
the position search (layout.recommend) 12.6 93
everything else — read, analyse, every check, render, write, summarise 1.0 7
one piece, end to end 13.6

Worst piece 45–49 s. The whole sweep one after another: about 40 minutes, 39 of which is the search. With the search skipped, the same 184 pieces — full analysis, every check, every verdict, every report written — take 2 minutes in total, and 0.7 s of that is the median piece.

The two numbers this document used to give were wrong

It said a run was "about two seconds", and the comment on _MAX_COMBOS costed a candidate layout at about 1 ms. Both were left behind by the search itself: recommend now sweeps every stage of the piece at once, and one candidate layout costs 15 ms per stage, fifteen times the figure the budget was reasoned about with. On that arithmetic the _MAX_COMBOS ceiling of 40,000 layouts is not "a few seconds at worst", it is ten minutes a stage.

The ceiling is left where it is on purpose. It decides which pieces get a full search rather than a local improvement, which is an engineering judgement and not a performance one; what changes is that the comment beside it now says what a layout actually costs. Both figures are corrected in place.

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.