WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
# panellift — plan
|
||||
|
||||
PCI Chapter 8 handling checks for precast wall panels, fed from the Revit
|
||||
model that already holds the geometry.
|
||||
|
||||
Scope is the "first tier" agreed on 2026-08-18: **stripping and yard handling,
|
||||
flat**. Transportation (needs dunnage positions the model does not have) and
|
||||
erection (the panel rotating up, load shedding onto the lowest pick point) are
|
||||
explicitly out.
|
||||
|
||||
---
|
||||
|
||||
## Why the calculation does not run inside Revit
|
||||
|
||||
The extension runs on IronPython 2.7 on Revit's UI thread. A calculation whose
|
||||
output goes to a plant has to be verifiable, and verifiable means a test suite
|
||||
that runs in a second without a Revit licence, a model, or a Windows box.
|
||||
|
||||
So: **Revit writes a JSON file, a plain CPython package reads it.** Same
|
||||
pattern already proven by `vibe.aiextract`. The package has **no
|
||||
dependencies** — every dependency is something that can be missing at the
|
||||
moment somebody needs a number.
|
||||
|
||||
```
|
||||
Revit ──liftexport──> <mark>.json ──panellift──> report.html
|
||||
(IronPython 2.7) (CPython 3.9+)
|
||||
```
|
||||
|
||||
A consequence worth keeping: a panel can be checked by hand-writing the JSON,
|
||||
with no model at all.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Lifter capacity vs handling multiplier | **Not** stacked. PCI Table 8.3.1 note (a): those factors are for flexural design and are not to be applied to lifting-device safety factors. Inserts are checked against unfactored reactions and against **the published rating of the part itself** — safety factors are per product in `knowledge/catalogue.yaml` and an insert with none published is reported unjudged. *This row used to end "with the manufacturer's 4:1 tension / 2.67:1 shear". No manufacturer published that. The sentence was written the day after the constant it justified and it cited nothing; the one datasheet in this repository for the parts it reached (ALP catalog p53, 02/2024) heads its capacity table "Capacity in Concrete 4:1 SWL" across both load columns — 4:1 in shear as well. Corrected 2026-08-31, and recorded rather than quietly deleted, because an invented pedigree on a safety factor is the kind of thing that gets re-derived from the same document twice.* |
|
||||
| Sling angle | Engineer's config file, in degrees. Not inferred from the model. |
|
||||
| Rolling block vs static lift | Engineer's config file. **Two different mechanics**, not a coefficient — see below. |
|
||||
| Load cases | Stripping + yard handling. |
|
||||
| Flexural capacity | Shipped 2026-08-31 as ACI 318-19 approximate `fps` (Eq. 20.3.2.3.1), strand only, in `capacity.py`. **The columns were informational for one day.** Owner's ruling 2026-09-01 — *"M/pMn 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail,我做最后决策。"* — so `M/φMn > 1.0` is a `FLEX-CAP` **FAIL** and `V/φVn > 1.0` a `SHEAR-CAP` FAIL, checked at every station of every stage, with **no development-length exclusion**: the tool may not bless a ratio over 1.0 and the engineer stamps past it. The stress check keeps its own verdict; a station that cannot be judged stays a dash and is neither. Full strain compatibility remains the upgrade: it needs the strand stress-strain curve and mild-steel/WWF input paths that no layer holds yet. `fy` defaults to 60 ksi, `fyt` from the design summary; both overridable in config. |
|
||||
|
||||
### Rolling block vs static lift
|
||||
|
||||
- **rolling** — equalising sheaves, every line in a cable group carries the
|
||||
same force. Two groups, two equations of statics, determinate. Reactions do
|
||||
not depend on stiffness, so a blockout does not move them.
|
||||
- **static** — fixed-length slings. The panel is a continuous beam on rigid
|
||||
supports; three or more support lines are indeterminate and the reactions
|
||||
follow flexural stiffness. A blockout beside a support really does shed load
|
||||
off it.
|
||||
|
||||
Measured on the ASPIRE panel with three support lines at 4 / 16 / 28 ft:
|
||||
|
||||
```
|
||||
rolling 15,167 15,167 15,167 lb
|
||||
static 13,508 18,484 13,508 lb middle line +22%
|
||||
```
|
||||
|
||||
Picking the wrong one is a 22% error on an insert. Hence: the engineer says.
|
||||
|
||||
## The design decision that matters most
|
||||
|
||||
**PCI Figure 8.3.2's coefficients are a test, not the engine.**
|
||||
|
||||
Those coefficients only hold when the picks sit where positive and negative
|
||||
moment come out equal. The entire reason to check a real ticket is that the
|
||||
detailer may not have put them there — so using the closed form as the engine
|
||||
would assume the answer.
|
||||
|
||||
Instead the moment comes from a real beam solve at the *actual* insert
|
||||
positions, and Figure 8.3.2 becomes the regression test: put the supports at
|
||||
`(sqrt(2)-1)/2` of the span and the solver must return `0.0214 w a b²`.
|
||||
|
||||
It does. That identity is also *derived* in `pci.py` rather than quoted —
|
||||
solving `w(aL)²/2 = wL²(1-2a)²/8 - w(aL)²/2` gives `4a² + 4a - 1 = 0`, hence
|
||||
`a = 0.20711` and a coefficient of `0.02145`, which is PCI's 0.0214.
|
||||
|
||||
## Known idealisation
|
||||
|
||||
The panel is treated as a beam spanning its long direction. This *is* PCI's
|
||||
own method for `My`, and it is what the ASPIRE examples do. It is not valid
|
||||
for a near-square panel, where two-way plate action governs — that is
|
||||
finite-element territory (PanelsPlus). **The code must refuse rather than
|
||||
answer** when the aspect ratio gets close to 1. Not yet implemented.
|
||||
|
||||
The transverse direction `Mx` will use PCI's closed form with the "resisted by
|
||||
15t or b/2" rule, as a secondary check.
|
||||
|
||||
## Status
|
||||
|
||||
### Done and tested — 34 tests, all passing
|
||||
|
||||
| Module | |
|
||||
|---|---|
|
||||
| `model.py` | `Panel`, `Blockout`, `Lifter`, `Rigging`, `Reinforcement`, `Stage`, `Material` |
|
||||
| `section.py` | `b_eff(x)`, `S(x)`, `I(x)`, `w(x)`, blockout merging, weight reconciliation |
|
||||
| `pci.py` | Table 8.3.1, `5√f'c`, Figure 8.3.2 closed forms, optimal support fraction |
|
||||
| `linalg.py` | dense solver, so there are no dependencies |
|
||||
| `statics.py` | insert clustering → support lines → cable groups; rolling and static solvers; V(x), M(x); sling forces |
|
||||
|
||||
Reproduced from ASPIRE Winter 2019 (Brown), which reprints PCI Table 8.3.1 and
|
||||
Figure 8.3.2:
|
||||
|
||||
| | ours | published |
|
||||
|---|---|---|
|
||||
| unit weight | 109.4 psf | 109.4 |
|
||||
| S solid | 1531 in³ | 1531 |
|
||||
| S, five 6"×24" pockets | 1148 in³ | 1148 |
|
||||
| unfactored moment | 24,020 lb-ft | 23,973 |
|
||||
| stripping stress, solid | 245 psi | 244 — OK vs 316 |
|
||||
| transport stress, solid | 282 psi | 282 — OK vs 354 |
|
||||
| stripping stress, pocketed | 326 psi | 326 — **no good** vs 316 |
|
||||
| transport stress, pocketed | 377 psi | 376 — **no good** vs 354 |
|
||||
|
||||
The moment differs by 0.2% because the article rounds the coefficient to
|
||||
0.0214; we carry `(√2−1)²/8`.
|
||||
|
||||
### Done since — 76 tests
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `checks.py` | cracking, insert capacity with sling amplification, edge-anchor shear and plumb-hang tension, per-stage derating, aspect-ratio refusal, unrated inserts as a warning |
|
||||
| `report.py` | self-contained HTML; `build_many` puts every stage of one piece in one file behind a verdict table |
|
||||
| `layout.py` | recommended insert positions and the delta from the model |
|
||||
| `cli.py` | `panellift job.json -o report.html [--json]` |
|
||||
| `vibe/liftexport.py` | the Revit side of the job file, plus the subprocess |
|
||||
| `Panel Lift Check` | the button: select an assembly, confirm the picks, choose where the HTML goes |
|
||||
|
||||
### Next
|
||||
|
||||
1. Strain-compatibility `φMn` (upgrade of `capacity.py`'s approximate-`fps`
|
||||
columns, shipped 2026-08-31): strand power curve, mild steel and WWF layers
|
||||
— the input paths for both are still owed before this can be honest.
|
||||
2. Transverse equilibrium. `layout.py` reports how far a row's centroid sits
|
||||
off the width centreline, but a beam model cannot say what that costs. On a
|
||||
panel with large openings the recommended rows can be forced entirely to
|
||||
one side, and whether the piece twists is currently the engineer's call
|
||||
with no number attached.
|
||||
3. Per-piece config overrides (sling angle, lift mode) without editing the
|
||||
plant knowledge file.
|
||||
|
||||
### Answered against the live model (sample_job, 2026-08-18)
|
||||
|
||||
Probed on `PNL204` — a `PANEL_VERTICAL::08"`, design PNL1.0,
|
||||
13'-10" x 4'-0" x 8", 5,533 lb, 51 members.
|
||||
|
||||
**Panel-local frame.** `x` is the width, `y` is the thickness, `z` is the
|
||||
length. On PNL204: `x ∈ [-2.000, 2.000]`, `y ∈ [-0.667, 0.000]`,
|
||||
`z ∈ [0, 13.917]` ft. So the form face is `y = -0.667` and the exposed face
|
||||
`y = 0`.
|
||||
|
||||
**1. Blockouts are family instances, not `Opening` elements.** The document
|
||||
contains **zero** `Opening` instances. Voids are a family group under Generic
|
||||
Models:
|
||||
|
||||
| family | count |
|
||||
| --- | --- |
|
||||
| `VOID_RECTANGULAR` | 622 |
|
||||
| `VOID_RECESS_3_SIDES` | 234 |
|
||||
| `VOID_POUR_STRIP` | 102 |
|
||||
| `VOID_DT_DAP` | 54 |
|
||||
| `VOID_ROUND` | 46 |
|
||||
| `V-PLATE_VOID_RECTANGULAR` | 22 |
|
||||
| `VOID_TAPER_ROUND` | 17 |
|
||||
|
||||
`STEM_BLOCKOUT` (2694) is under Specialty Equipment and belongs to double-tee
|
||||
stems, not wall panels. So `b_eff(x)` gets populated the same way the lifters
|
||||
do: instance position transformed to panel-local, size from instance
|
||||
parameters. Whether these families actually cut the solid, or only mark the
|
||||
void for the detailer, still needs checking — it decides whether the geometric
|
||||
weight reconciliation will agree.
|
||||
|
||||
**2. Insert role separates cleanly by geometry.** On PNL204:
|
||||
|
||||
| family | count | x | y | z |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `ANCHOR_FACE_4T` | 4 | ±1.000 | **-0.667** (form face) | 2.583, 11.417 |
|
||||
| `LIFT_EDGE_4T` | 2 | ±1.000 | **-0.333** (mid-thickness) | **13.917** (top end) |
|
||||
|
||||
Face-mounted and inboard → **stripping**. Mid-thickness and at an end →
|
||||
**erection**, exactly as suspected of the `LIFT_EDGE_8T` seen at 0'-0".
|
||||
Two independent signals agree, family prefix and position, so the rule can
|
||||
check itself rather than trusting a name list.
|
||||
|
||||
The real pick is 4 points on 2 support lines at `z/L` = 0.186 and 0.180,
|
||||
against the PCI optimum of 0.207 — a good first live test case.
|
||||
|
||||
**Capacity is encoded in the family name**: `ANCHOR_FACE_4T` → 4T, `LIFT_EDGE_4T`
|
||||
→ 4T, `LIFT_EDGE_8T` → 8T. That makes `knowledge.yaml`'s `capacity_lb`
|
||||
table largely derivable. **Open question for the engineer: is `4T` the working
|
||||
load limit or the ultimate?** It is a factor of four on every insert check.
|
||||
|
||||
**3. Strand positions are recoverable, through-thickness included.** Four
|
||||
`1-2_STRAND` at `y = -0.500` and `y = -0.167` — two layers, 2 in.
|
||||
from each face of the 8 in. panel. That `y` is precisely the depth strain
|
||||
compatibility needs. `WWF_MESH_A` sits in the same two layers, confirming
|
||||
`per_face` in the spec schema.
|
||||
|
||||
Unresolved: the four strands sit at only two `x` stations (-1.500 and 0.000)
|
||||
on a panel spanning -2.000 to 2.000, which is not symmetric. Either the family
|
||||
insertion point is off-centre or `_point_of` is returning a curve start.
|
||||
Worth pinning before trusting a lever arm.
|
||||
|
||||
**4. Panels are boxes, and there are only two thicknesses.** PNL204's local
|
||||
bounding box is a clean 4.000 x 0.667 x 13.917. The whole model uses only
|
||||
`PANEL_VERTICAL` and `PANEL_HORIZONTAL`, each in `08"` and `12"`.
|
||||
|
||||
One discrepancy to carry: the bounding box gives 13.917 ft = 13'-11" while
|
||||
the panel's stated length parameter says 13'-10". Use the stated dimensions
|
||||
for the calculation and report the delta, the same way a ticket's printed
|
||||
length runs over the modelled one on stemmed pieces.
|
||||
|
||||
### Smaller known gaps
|
||||
|
||||
- Three support lines with equal gaps on both sides make the cable grouping
|
||||
ambiguous; currently resolved to the first. Should warn.
|
||||
- Partial-depth blockouts are carried but do not reduce `S`.
|
||||
- Inclined slings: the horizontal component is reported, but the secondary
|
||||
moment it induces is not analysed. LECWall does not either, and says so.
|
||||
|
||||
---
|
||||
|
||||
## Insulated (sandwich) panels
|
||||
|
||||
Added 2026-08-31. Read this before touching `sandwich.py` or the refusal in
|
||||
`cli._refuse_sandwich`.
|
||||
|
||||
### Why it is a refusal first and mechanics second
|
||||
|
||||
Every section property in this package is built on **one prism** of concrete
|
||||
`panel.thickness_in` deep — `section.NetSection`, `section_modulus_in3`, the
|
||||
four-corner stress. An insulated body is two concrete wythes with insulation
|
||||
between them, and idealising it as solid concrete at the envelope depth
|
||||
**overstates the section modulus and understates the tensile stress**. The
|
||||
owner, 2026-08-31:
|
||||
|
||||
> "把夹芯板当实心算并不 conservative。因为 insulation 并不会 100% composite,
|
||||
> 你忽视了中间的 composite ratio 的计算和可能出现的问题。这是一个 insulated
|
||||
> panel design 的时候非常大的一个问题。"
|
||||
|
||||
On the dimensions of a real 16 in. piece (4 in. architectural wythe, 4 in.
|
||||
EPS, 8 in. structural wythe, 64 in. of clear width) the arithmetic is:
|
||||
|
||||
| | I, in⁴ | governing S, in³ |
|
||||
| --- | --- | --- |
|
||||
| solid at the 16 in. envelope | 21,845 | 2,731 |
|
||||
| fully composite | 20,139 | 2,324 |
|
||||
| non-composite | 3,072 | 768 |
|
||||
| structural wythe alone | 2,731 | 683 |
|
||||
|
||||
So the solid idealisation overstates the governing section modulus by
|
||||
**3.56× against the non-composite bound and still 1.18× against the fully
|
||||
composite one**. Both above 1.0: there is **no reading of a sandwich under
|
||||
which treating it as solid is the safe direction**, because a sandwich has a
|
||||
hole in the middle of its depth and is weaker than a solid piece of the same
|
||||
envelope however well the connectors work. `test_sandwich.py` keeps every one
|
||||
of those numbers with the arithmetic in a comment.
|
||||
|
||||
**Until 2026-08-31 this never fired.** The weight gate refused every insulated
|
||||
piece for an unrelated reason — the solid sum was one wythe — and that refusal
|
||||
*accidentally* protected against this. The weight is right now, so the gate
|
||||
opens, and it opens onto a wrong section. `cli._refuse_sandwich` is what
|
||||
closes that window, and it is deliberately **upstream-independent**: it does
|
||||
not care whether the weight gate let the piece through. If a sandwich job file
|
||||
reaches this package at all, it fires.
|
||||
|
||||
### The detection signal travels in the job file
|
||||
|
||||
`source.solid_parts` — the per-solid breakdown the Revit side has written
|
||||
since 2026-08-31. Nothing on this side re-derives it: the file is the
|
||||
boundary, and a second opinion about a model this process cannot see would be
|
||||
a guess with a different accent. The ladder is in `sandwich.detect` and the
|
||||
only rung that may return `False` is *one concrete solid and nothing left
|
||||
out*. No signal at all returns **None**, never `False`.
|
||||
|
||||
### The conventions chosen, and why each is the conservative one
|
||||
|
||||
Every one of these is stated on the number it touches, in the report and in
|
||||
`sandwich.py`, and every one is on the owner-question list below.
|
||||
|
||||
| decision | what was chosen | why it is the conservative reading |
|
||||
| --- | --- | --- |
|
||||
| **wythe load sharing, non-composite bound** | the wythes are tied by the connectors so they deflect together; each takes the moment in proportion to its own stiffness, `M_i = M·I_i/ΣI`, so `f = M·(t_max/2)/ΣI` | it is the compatible answer, and the extreme fibre lands on the **thickest** wythe rather than being averaged over both. The alternative — sharing by mass — puts less moment on the stiff wythe than compatibility does |
|
||||
| **is the face wythe structural** | reported **both ways**. The non-composite bound assumes both wythes resist; `structural_wythe_alone` assumes the thickest carries the whole piece and the thinner is dead load | the second is the smallest section modulus of the three (683 in³ against 768), so printing it is the conservative half and printing only it would hide the bound the owner may actually want |
|
||||
| **which wythe is structural** | the **thickest**, by trade convention | not measured, and it is named as not measured every time it is used. There is no conservative direction available here, only a disclosed one |
|
||||
| **openings** | assumed to run through **both** wythes, so `b_eff(x)` is shared | the measured plan areas of the two wythes on the real piece differ by ~1%, which is inside the rounding on the volumes. If they diverge on some piece, each wythe needs its own opening set |
|
||||
| **insulation** | contributes **nothing** to the section | its modulus is three orders below concrete's. This is the standard treatment, not an approximation invented here |
|
||||
| **modular ratio** | 1, no transformation | both wythes are the same mix on every piece measured. Where the densities differ the report says so rather than quietly scaling one |
|
||||
| **connector demand distribution** | the mean over a row, at the fully-composite `VQ/I`, **named as a mean and not as a bound** | it is honest about being an assumption. It is *not* conservative — see below — and saying so is the only available treatment until `k` exists |
|
||||
|
||||
### The bounds are scaffolding, not the product
|
||||
|
||||
The owner's instruction, and it reshapes what this is for. The two-bound
|
||||
envelope is what can be computed **while the connector data is absent**. The
|
||||
partial-composite middle is the thing insulated-panel design is actually
|
||||
about, and the capacities and stiffnesses being hunted are the *inputs to
|
||||
computing it*, not garnish. The report presents the bounds as an interim
|
||||
envelope with the missing computation named.
|
||||
|
||||
### 可能出现的问题 — what the two bounds do NOT bracket
|
||||
|
||||
`sandwich.concerns()` is the enumeration, and reading it is the point.
|
||||
**Three quantities are bracketed. Everything else about a sandwich panel is
|
||||
not a bounds question at all**, and a reader who takes a two-bound report as
|
||||
an envelope over all of it is the failure the list exists to prevent.
|
||||
|
||||
Bracketed:
|
||||
|
||||
- **wythe flexural tension** — maximal non-composite, monotone in between.
|
||||
With one trap: the **inner face of a wythe changes sign** between the
|
||||
bounds (compression non-composite, tension fully composite), so both faces
|
||||
of both wythes have to be evaluated, not only the envelope faces.
|
||||
- **interface shear flow** — maximal fully composite, `q = VQ/I`, zero
|
||||
non-composite. A true upper bound on the **total** interface force, which
|
||||
is why it is computed even where the plant designs the panel non-composite.
|
||||
- **deflection** — maximal non-composite. Differential bowing from
|
||||
temperature and shrinkage is a service problem, not a handling one, and is
|
||||
**declared out of scope** rather than silently omitted.
|
||||
|
||||
Not bracketed:
|
||||
|
||||
- **connector force distribution along the span.** Both bounds are
|
||||
distribution-free — one puts zero on every connector, the other spreads
|
||||
demand smoothly as `V(x)`. Real partial interaction concentrates slip, and
|
||||
therefore connector force, **at the ends of each shear span**, decaying
|
||||
over a length set by the connector stiffness `k` against the wythes' axial
|
||||
stiffness `EA`. A uniform-demand assumption **understates the end
|
||||
connectors**. When `k` arrives, the standard partial-interaction treatment
|
||||
is what goes in here; until then the mean is reported and named as a mean.
|
||||
- **connector force at discontinuities** — an opening edge or a pick point
|
||||
restarts the slip profile and the connectors beside it see a local peak
|
||||
neither bound shows.
|
||||
- **connector NORMAL (tension) demand.** A flat pick hangs one wythe off the
|
||||
other through the connectors in direct tension; an erection pick peels the
|
||||
face wythe as the piece rotates. Not an interface-shear question at all, so
|
||||
**neither bound speaks to it** — and where the panel is designed
|
||||
non-composite it is likely the *governing* connector demand. This is why a
|
||||
capacity is needed **per failure mode**, not as one number.
|
||||
- **local flexure of the face wythe between connectors** — a slab strip on
|
||||
discrete supports at the connector spacing. A different mechanism from
|
||||
anything in this package.
|
||||
- **percent composite is not one number.** The **stiffness** ratio
|
||||
(`κ_I = (I_eff − I_nc)/(I_full − I_nc)`, what governs deflection and
|
||||
bowing) and the **strength** ratio (`η`, the degree of shear connection,
|
||||
what governs capacity) are different quantities in the trade's own methods.
|
||||
Any percentage this report prints says which one it is.
|
||||
|
||||
### What stands down, and what unblocks it
|
||||
|
||||
| stood down | why | unblocked by |
|
||||
| --- | --- | --- |
|
||||
| **composite ratio** (both of them) | a computed ratio without connector data is not hard, it is *impossible*. The width of the band is printed; the position in it is not | connector slip stiffness `k`, lb/in per connector (stiffness ratio); connector capacity per failure mode (strength ratio) |
|
||||
| **connector sufficiency** | no capacity is published in any layer. `verdict` stays `None` — never OK, never a pass | capacity **per failure mode**: interface shear **and** normal/tension, lb per connector |
|
||||
| **connector recognition** on the gather side | nothing reads the connector instances off the piece. `source.connectors` is written with every field null and the reason beside it, because a *missing* key reads downstream as "this piece has no connectors" | a recognition rule. Measured by the bridge holder, and it is plant knowledge so the parameter names live in the plant layer: identity is a **type-level** parameter, **not** the family name, and a second type-level parameter is the gate. Family-name matching silently finds nothing here — a decoy family with no instances carries the same identity string |
|
||||
| **the layup on an old job file** | `solid_parts` without `z0_in`/`z1_in` says a sandwich exists and cannot say what its section is. `Layup.measured` is False and `section_bounds` refuses rather than inventing a layup | re-export the piece |
|
||||
|
||||
### The plant calls its pins non-composite, and that settles nothing
|
||||
|
||||
The insulation family on these pieces carries the description *"EPS with Holes
|
||||
for Non-composite pins"*. That is **evidence about design intent, carried
|
||||
verbatim in `source.connectors.declared_action` and quoted in the report**.
|
||||
It is not a structural ruling, and nothing in the mechanics branches on it:
|
||||
partial composite action exists physically whatever the label says, and the
|
||||
connector product has published stiffness data. What the owner's confirmation
|
||||
would settle is **which bound the pass/fail line is drawn against** — the
|
||||
report carries both either way.
|
||||
|
||||
### Which end is up, and why a disclosed fallback is allowed
|
||||
|
||||
`knowledge.up_end_from_origin` is this project's flagship declared-coverage
|
||||
refusal — three measured conditions, and it never returns `False`. When it
|
||||
declines, `liftexport.erected_orientation` reads global elevation and answers
|
||||
anyway, writing the refusal's own reason into `source.up_end_basis_why` —
|
||||
and **nothing read that key**, partly because `read_panel` looked for
|
||||
`source` inside `panel` while the exporter writes it at job level, so
|
||||
`panel.source` came out empty on every real job file.
|
||||
|
||||
Both halves are fixed. The job-level `source` is merged onto the panel, and
|
||||
`report.up_end_disclosure` renders the fallback on the page, in the CLI's
|
||||
stdout and in the summary's `basis` key — written **only** when the ruled
|
||||
mechanism did not answer, the same "only when it is not the ordinary case"
|
||||
rule `searched` and `enabled` follow.
|
||||
|
||||
The deliberate decision, because "never fall back" is not the answer either:
|
||||
|
||||
- The rule declines on a **vertical wall panel** because it is only confirmed
|
||||
for horizontal ones. Global elevation is the mechanism that is *sound* for
|
||||
a piece erected on end — its two ends really are at different heights — so
|
||||
on that decline the fallback is the right tool for the case the rule handed
|
||||
over, not a guess.
|
||||
- It is meaningless for a piece erected long-ways, and that case never
|
||||
reaches here: `MIN_VERTICAL_SHARE` refuses any piece with no axis
|
||||
convincingly up, and the aspect-ratio gate refuses a near-square one.
|
||||
- **It reaches one stage.** Stripping and transportation are analysed flat
|
||||
and are invariant under mirroring `x` — reverse the piece end for end and
|
||||
the beam problem is its own mirror image. Only erection depends on it:
|
||||
which row `exclude_top_rows` drops, and which edge the anchors are on.
|
||||
|
||||
So the fallback answers, and it is stated on every report it touched, naming
|
||||
the rule's own reason for standing down. `default_stages` gets the same
|
||||
treatment one step further on: an absent `source.erected_vertical_axis` used
|
||||
to be read as `or 'length'`, and now says so in `problems`.
|
||||
|
||||
### Owner questions
|
||||
|
||||
Nothing below is inferred and nothing below is guessed at. Numbered so they
|
||||
can be answered out of order.
|
||||
|
||||
1. **Are the insulated panels designed, and to be checked, as
|
||||
non-composite?** The plant's own family description says the pins are
|
||||
non-composite. Confirming it picks which bound the pass/fail line is drawn
|
||||
against; the report carries both either way.
|
||||
2. **Which wythe is structural?** The thickest is assumed, by trade
|
||||
convention, and named as an assumption every time. A ruling makes it a
|
||||
fact.
|
||||
3. **Is the architectural wythe to be relied on in flexure at all**, or
|
||||
carried by the connectors and resisting nothing? The two readings differ
|
||||
by 768 in³ against 683 in³ on the reference piece.
|
||||
4. **What depth should the beam use once the refusal is lifted** — the
|
||||
envelope, one wythe, or a bounded pair? This is the question
|
||||
`docs/lift-check.md` has been carrying as open since 2026-08-31, and the
|
||||
bounds are now computed for all three so it can be answered against
|
||||
numbers.
|
||||
5. **Connector capacity per failure mode** — interface shear, and
|
||||
normal/tension — in lb per connector, and the concrete strength the
|
||||
ratings were derived at.
|
||||
6. **Connector slip stiffness `k`**, lb/in per connector. Without it there is
|
||||
no ratio and no end-concentration factor, and both stay stood down.
|
||||
7. **Do the openings run through both wythes on every piece?** Assumed yes;
|
||||
the measured plan areas on the reference piece agree to ~1%, which is
|
||||
inside the rounding.
|
||||
8. **Is the local thickness axis' minimum face the form face?** The layup is
|
||||
measured from the lowest face of any solid the body draws, which is the
|
||||
same convention the strand and insert `z_in` already use, and which face
|
||||
that is has never been established. A symmetric layup is the same either
|
||||
way; an asymmetric one may be the mirror of what was cast.
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "panellift"
|
||||
version = "0.1.0"
|
||||
description = "PCI Chapter 8 handling checks for precast wall panels"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [] # deliberately none -- see PLAN.md
|
||||
|
||||
[project.scripts]
|
||||
panellift = "panellift.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PCI Chapter 8 handling checks for precast wall panels.
|
||||
|
||||
The calculation knows nothing about Revit. It takes a Panel, a Stage and a
|
||||
Rigging, and returns numbers that can be checked against the published
|
||||
examples in the PCI Design Handbook. Everything Revit-shaped lives in the
|
||||
pyRevit extension that writes the input file.
|
||||
"""
|
||||
from .model import ( # noqa: F401
|
||||
Blockout, Lifter, Material, Panel, Reinforcement, Rigging, Stage,
|
||||
)
|
||||
|
||||
__version__ = '0.1.0'
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`python -m panellift` -- how Revit calls this, since it cannot rely on the
|
||||
console script being on PATH."""
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,787 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Where the inserts should have gone, and how far the model is from it.
|
||||
|
||||
A pass/fail on the pick as drawn answers half the question. The other half --
|
||||
"then where?" -- is the half somebody has to answer before they can move
|
||||
anything, and answering it by hand is a search over positions that a detailer
|
||||
does not have time to do properly.
|
||||
|
||||
Two things are recommended, and they are recommended for different reasons:
|
||||
|
||||
along the piece (x) minimise the peak tensile stress. This is a real
|
||||
search, not PCI Figure 8.3.2's closed form: the
|
||||
closed form holds only 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 exactly why it is worth solving.
|
||||
|
||||
across the piece (y) hang the piece level. The beam model has no
|
||||
transverse mechanics at all, so this is geometry and
|
||||
is stated as such: a pair placed symmetrically about
|
||||
the width centreline lifts level, and one that
|
||||
cannot be (because the openings are in the way) is
|
||||
reported as eccentric rather than quietly centred.
|
||||
|
||||
Every recommended position is checked for installability first. A position
|
||||
inside a blockout, or closer to an edge than the catalogue allows, is not a
|
||||
recommendation -- it is a drawing somebody has to reject later.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from . import section, statics
|
||||
from .model import Lifter, Panel, Rigging, Stage
|
||||
|
||||
# Search resolution, in feet. A coarse sweep over EVERY combination of
|
||||
# stations, then local refinement around the winner.
|
||||
#
|
||||
# The coarse pass has to be exhaustive, 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 line's midpoint re-partitions the load in a jump. Coordinate descent
|
||||
# walks straight into that wall -- 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.
|
||||
_COARSE_FT = 1.0
|
||||
_COARSE_FT_MANY = 1.5 # 4+ free lines: the grid grows as n^4
|
||||
_REFINE = (1.0 / 12.0, 0.25 / 12.0)
|
||||
_SEARCH_STATIONS = 400 # for the search; the answer is re-checked full
|
||||
_SWEEPS = 3
|
||||
|
||||
# The exhaustive sweep is C(stations, free rows), and that is a cliff, not a
|
||||
# slope. MEASURED 2026-08-30 on the 184-job reference sweep: one layout costs
|
||||
# **15 ms per stage**, not the ~1 ms this comment used to imply -- 76% of it a
|
||||
# dense linalg.solve on the static path. So three free rows on a 26 ft piece is
|
||||
# 2,024 layouts and about half a minute per stage, and eleven free rows on a
|
||||
# 32 ft piece -- a horizontal panel whose erection anchors sit in their own row
|
||||
# each -- is 352,716 layouts and an hour and a half, which is indistinguishable
|
||||
# from a hang.
|
||||
#
|
||||
# So the sweep is costed BEFORE it is run. Coarsen until it fits; if even a
|
||||
# coarse grid does not, do not run it at all and say so, because a search that
|
||||
# takes long enough to look broken will be killed and then nobody gets an
|
||||
# answer.
|
||||
#
|
||||
# The budget is a COUNT and stays one. It was set against the old timing and is
|
||||
# left where it is on purpose: raising or lowering it changes which pieces get
|
||||
# a full search, which is an engineering decision and not a performance one.
|
||||
# What the corrected cost buys is a caller who can turn the search OFF -- see
|
||||
# `not_searched` below and `--no-recommend`.
|
||||
_MAX_COMBOS = 40000
|
||||
_FALLBACK_STEPS = (2.0, 3.0, 4.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- feasibility
|
||||
|
||||
def _merge(intervals: Sequence[Tuple[float, float]]
|
||||
) -> List[Tuple[float, float]]:
|
||||
out = []
|
||||
for lo, hi in sorted(intervals):
|
||||
if out and lo <= out[-1][1]:
|
||||
out[-1] = (out[-1][0], max(out[-1][1], hi))
|
||||
else:
|
||||
out.append((lo, hi))
|
||||
return out
|
||||
|
||||
|
||||
def clear_y_intervals(panel: Panel, x: float,
|
||||
edge_in: float = 0.0) -> List[Tuple[float, float]]:
|
||||
"""Where across the width an insert may sit at station x, in feet.
|
||||
|
||||
The panel's own edges and every blockout spanning x are pushed in by the
|
||||
minimum edge distance. An insert nearer than that to a hole has less
|
||||
concrete behind it than the rating assumes.
|
||||
"""
|
||||
clear_ft = edge_in / 12.0
|
||||
lo, hi = clear_ft, panel.width_ft - clear_ft
|
||||
if hi <= lo:
|
||||
return []
|
||||
blocked = _merge([(b.y0 - clear_ft, b.y0 + b.width + clear_ft)
|
||||
for b in panel.blockouts if b.spans(x)])
|
||||
out = []
|
||||
cursor = lo
|
||||
for start, end in blocked:
|
||||
if start > cursor:
|
||||
out.append((cursor, min(hi, start)))
|
||||
cursor = max(cursor, end)
|
||||
if cursor >= hi:
|
||||
break
|
||||
if cursor < hi:
|
||||
out.append((cursor, hi))
|
||||
return [(a, b) for a, b in out if b > a]
|
||||
|
||||
|
||||
def _fits(intervals: Sequence[Tuple[float, float]], y: float) -> bool:
|
||||
return any(lo <= y <= hi for lo, hi in intervals)
|
||||
|
||||
|
||||
def _widest(intervals: Sequence[Tuple[float, float]]
|
||||
) -> Optional[Tuple[float, float]]:
|
||||
return max(intervals, key=lambda iv: iv[1] - iv[0]) if intervals else None
|
||||
|
||||
|
||||
def station_is_placeable(panel: Panel, x: float, count: int,
|
||||
edge_in: float = 0.0) -> bool:
|
||||
"""Can a row of `count` inserts be installed at this station at all?
|
||||
|
||||
A station whose only clear strip is narrower than the inserts need is not
|
||||
a candidate, however good the moment there would be.
|
||||
"""
|
||||
if x < edge_in / 12.0 or x > panel.length_ft - edge_in / 12.0:
|
||||
return False
|
||||
widest = _widest(clear_y_intervals(panel, x, edge_in))
|
||||
if widest is None:
|
||||
return False
|
||||
if count <= 1:
|
||||
return True
|
||||
# Inserts in one row need to be at least an edge distance apart, or the
|
||||
# cones of concrete they rely on overlap.
|
||||
return (widest[1] - widest[0]) >= (count - 1) * edge_in / 12.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the x search
|
||||
|
||||
@dataclass
|
||||
class _Frame:
|
||||
"""Everything about a panel that does not change as the picks move."""
|
||||
panel: Panel
|
||||
stage: Stage
|
||||
rigging: Rigging
|
||||
profile: statics.LoadProfile
|
||||
jumps: List[float]
|
||||
counts: List[int]
|
||||
# 1/S at every station the diagram will report, in that order. The section
|
||||
# is a property of the panel and the picks do not touch it, so solving it
|
||||
# once here rather than per candidate takes three million calls out of a
|
||||
# three-line search. Zero where the section has vanished into a blockout.
|
||||
inv_s: List[float] = field(default_factory=list)
|
||||
# Answers already worked out, keyed by the stations that produced them.
|
||||
# `_peak_psi` is a pure function of (this frame, these stations) -- see the
|
||||
# note there -- and 17% of the calls in a real search are exact repeats:
|
||||
# the coarse sweep and the refinement pass over the same layout, and the
|
||||
# refinement's three sweeps re-try a line's current position every time.
|
||||
# Measured on the 184-job reference sweep, this is ~1.16x on a single press
|
||||
# and it cannot change an answer, because a memo hit returns the number the
|
||||
# recomputation would have produced.
|
||||
memo: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# Layouts one frame will remember. Well past what a real search asks for (884
|
||||
# objective evaluations on the median job); it exists so that a pathological
|
||||
# piece cannot turn a cache into a memory problem. Overflowing costs a
|
||||
# recomputation, never a different answer.
|
||||
_MEMO_MAX = 200000
|
||||
|
||||
|
||||
def _peak_psi(frame: _Frame, xs: Sequence[float]) -> float:
|
||||
"""Peak tensile stress for support lines at these stations.
|
||||
|
||||
Pure: for one frame this depends on nothing but `xs`. The frame holds the
|
||||
load profile, the section and the station list, and none of the three moves
|
||||
when a pick moves -- which is what makes the memo above safe.
|
||||
"""
|
||||
key = tuple(xs)
|
||||
hit = frame.memo.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
|
||||
lifters = []
|
||||
for i, (x, n) in enumerate(zip(xs, frame.counts)):
|
||||
for j in range(max(1, n)):
|
||||
lifters.append(Lifter(mark='r{0}.{1}'.format(i, j), family='?',
|
||||
x=x, y=0.0, role='strip'))
|
||||
staged = dataclasses.replace(frame.panel, lifters=lifters)
|
||||
try:
|
||||
rig = statics.group_lifters(staged, frame.rigging, role='strip')
|
||||
except ValueError:
|
||||
if len(frame.memo) < _MEMO_MAX:
|
||||
frame.memo[key] = float('inf')
|
||||
return float('inf')
|
||||
if frame.rigging.mode == 'static':
|
||||
statics.solve_static(rig, frame.profile, staged, frame.stage.fc_psi)
|
||||
else:
|
||||
statics.solve_rolling(rig, frame.profile)
|
||||
dia = statics.diagram(rig, frame.profile, extra_stations=frame.jumps)
|
||||
|
||||
# `frame.inv_s` was solved on exactly the station list `diagram()` builds
|
||||
# -- `_make_frame` writes the same expression out of the same profile and
|
||||
# the same jumps -- so the two can never differ in length. The guard that
|
||||
# used to re-solve the section here could not fire, and a dead branch in
|
||||
# the hottest loop in the package is a branch nobody can test.
|
||||
worst = 0.0
|
||||
for m, inv in zip(dia.moment_lbft, frame.inv_s):
|
||||
if inv:
|
||||
stress = abs(m) * inv
|
||||
if stress > worst:
|
||||
worst = stress
|
||||
if len(frame.memo) < _MEMO_MAX:
|
||||
frame.memo[key] = worst
|
||||
return worst
|
||||
|
||||
|
||||
def _inverse_section(panel: Panel, xs: Sequence[float]) -> List[float]:
|
||||
"""12/S at each station, so the inner loop is one multiply.
|
||||
|
||||
Zero stands for "no section here", which is the same thing the caller did
|
||||
with a `continue` and cheaper to test.
|
||||
"""
|
||||
out = []
|
||||
for x in xs:
|
||||
s = section.section_modulus_in3(panel, x)
|
||||
out.append(12.0 / s if s > 0.0 else 0.0)
|
||||
return out
|
||||
|
||||
|
||||
def _candidates(panel: Panel, centre: float, step: float, span: float,
|
||||
count: int, edge_in: float) -> List[float]:
|
||||
lo = max(edge_in / 12.0, centre - span)
|
||||
hi = min(panel.length_ft - edge_in / 12.0, centre + span)
|
||||
out, x = [], lo
|
||||
while x <= hi + 1e-9:
|
||||
if station_is_placeable(panel, x, count, edge_in):
|
||||
out.append(round(x, 4))
|
||||
x += step
|
||||
return out
|
||||
|
||||
|
||||
def _combinations(sizes: Sequence[int]) -> float:
|
||||
"""How many layouts a sweep over these per-row grids would try.
|
||||
|
||||
Multiplied rather than combined, so it over-counts the orderings the sweep
|
||||
prunes -- which is the right way round for a budget: it can refuse a sweep
|
||||
that would have been affordable, and never accepts one that is not.
|
||||
Capped so a huge product does not itself become a number nobody can hold.
|
||||
"""
|
||||
total = 1.0
|
||||
for size in sizes:
|
||||
total *= max(1, size)
|
||||
if total > 1e12:
|
||||
return 1e12
|
||||
return total
|
||||
|
||||
|
||||
def affordable_grids(panel: Panel, xs: Sequence[float], counts: Sequence[int],
|
||||
free: Sequence[bool], edge_in: float, coarse: float):
|
||||
"""Per-row candidate stations at the finest step the budget allows.
|
||||
|
||||
Returns (grids, step, ok). ok False means even the coarsest grid is too
|
||||
big, and the caller should not sweep at all.
|
||||
"""
|
||||
for step in (coarse,) + _FALLBACK_STEPS:
|
||||
grids = [_candidates(panel, panel.length_ft / 2.0, step,
|
||||
panel.length_ft, counts[i], edge_in)
|
||||
if free[i] else [xs[i]] for i in range(len(xs))]
|
||||
if _combinations([len(g) for g in grids]) <= _MAX_COMBOS:
|
||||
return grids, step, True
|
||||
return None, None, False
|
||||
|
||||
|
||||
def _ordered(xs: Sequence[float], min_gap: float) -> bool:
|
||||
"""Lines must stay in order and stay distinct.
|
||||
|
||||
Two lines closer together than the rigging's own grouping tolerance are
|
||||
one line, so a layout that put them there would have fewer support points
|
||||
than the report claims it has.
|
||||
"""
|
||||
for a, b in zip(xs, xs[1:]):
|
||||
if b - a < min_gap:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _sweep_objective(objective, xs: List[float], best: float,
|
||||
free: Sequence[bool], grids: Sequence[List[float]],
|
||||
min_gap: float) -> Tuple[List[float], float]:
|
||||
"""Every combination of the given per-line candidate stations.
|
||||
|
||||
Exhaustive by construction, so the coarse answer does not depend on where
|
||||
the search started -- which is the whole point of doing it this way.
|
||||
"""
|
||||
def walk(i: int, chosen: List[float]):
|
||||
if i == len(xs):
|
||||
yield list(chosen)
|
||||
return
|
||||
if not free[i]:
|
||||
chosen.append(xs[i])
|
||||
if _ordered(chosen, min_gap):
|
||||
for row in walk(i + 1, chosen):
|
||||
yield row
|
||||
chosen.pop()
|
||||
return
|
||||
for cand in grids[i]:
|
||||
chosen.append(cand)
|
||||
if _ordered(chosen, min_gap):
|
||||
for row in walk(i + 1, chosen):
|
||||
yield row
|
||||
chosen.pop()
|
||||
|
||||
for trial in walk(0, []):
|
||||
value = objective(trial)
|
||||
if value < best - 1e-9:
|
||||
best, xs = value, trial
|
||||
return xs, best
|
||||
|
||||
|
||||
def _make_frame(panel: Panel, stage: Stage, rigging: Rigging,
|
||||
counts: Sequence[int]) -> _Frame:
|
||||
"""The fixed part of the problem, solved once.
|
||||
|
||||
The load profile does not depend on where the picks are, and it is the
|
||||
expensive part -- rebuilding it per candidate would make the search a
|
||||
hundred times slower for the same answer. It is also what makes the
|
||||
as-built and the recommended peak comparable: both must be measured with
|
||||
the same load, including the reconciliation to the model's stated weight.
|
||||
"""
|
||||
profile = statics.load_profile(panel, stage.multiplier,
|
||||
stations=_SEARCH_STATIONS)
|
||||
if panel.weight_lb:
|
||||
profile, _ = statics.scale_to_stated_weight(panel, profile,
|
||||
stage.multiplier)
|
||||
jumps = section.discontinuities(panel)
|
||||
# The stations the diagram will use, worked out the same way it does. They
|
||||
# do not depend on where the picks are, so neither does the section.
|
||||
stations = sorted(set(list(profile.xs) + [0.0] + list(jumps) +
|
||||
[profile.xs[-1] + profile.dx / 2.0]))
|
||||
return _Frame(panel=panel, stage=stage, rigging=rigging, profile=profile,
|
||||
jumps=jumps, counts=list(counts),
|
||||
inv_s=_inverse_section(panel, stations))
|
||||
|
||||
|
||||
def best_stations(panel: Panel, stage: Stage, rigging: Rigging,
|
||||
start: Sequence[float], counts: Sequence[int],
|
||||
free: Sequence[bool], edge_in: float = 0.0,
|
||||
frame: Optional[_Frame] = None
|
||||
) -> Tuple[List[float], float]:
|
||||
"""Stations that minimise the peak tensile stress.
|
||||
|
||||
An exhaustive coarse sweep, then local refinement. The coarse pass costs a
|
||||
few thousand beam solves and buys independence from the starting layout;
|
||||
the refinement then walks one line at a time, which is safe once the
|
||||
coarse answer has put every line in the right basin.
|
||||
|
||||
Never returns something worse than the layout it was given.
|
||||
"""
|
||||
if frame is None:
|
||||
frame = _make_frame(panel, stage, rigging, counts)
|
||||
|
||||
xs = [float(x) for x in start]
|
||||
best = _peak_psi(frame, xs)
|
||||
# A recommended pair of lines closer together than group_tolerance_ft
|
||||
# would be read back as ONE row -- a recommendation the solve itself
|
||||
# would contradict on re-read. min_gap must therefore stay ABOVE the
|
||||
# tolerance, with margin; it tracks that setting on purpose, so a plant
|
||||
# raising the tolerance raises this floor with it.
|
||||
min_gap = max(rigging.group_tolerance_ft * 1.5, 0.5)
|
||||
|
||||
n_free = sum(1 for f in free if f)
|
||||
coarse = _COARSE_FT if n_free <= 3 else _COARSE_FT_MANY
|
||||
grids = [_candidates(panel, panel.length_ft / 2.0, coarse,
|
||||
panel.length_ft, frame.counts[i], edge_in)
|
||||
if free[i] else [xs[i]] for i in range(len(xs))]
|
||||
xs, best = _sweep_objective(lambda t: _peak_psi(frame, t), xs, best,
|
||||
free, grids, min_gap)
|
||||
|
||||
for step in _REFINE:
|
||||
for _ in range(_SWEEPS):
|
||||
grids = [_candidates(panel, xs[i], step, coarse if step ==
|
||||
_REFINE[0] else _REFINE[0], frame.counts[i],
|
||||
edge_in) if free[i] else [xs[i]]
|
||||
for i in range(len(xs))]
|
||||
moved = False
|
||||
for i, is_free in enumerate(free):
|
||||
if not is_free:
|
||||
continue
|
||||
for cand in grids[i]:
|
||||
trial = list(xs)
|
||||
trial[i] = cand
|
||||
if not _ordered(trial, min_gap):
|
||||
continue
|
||||
value = _peak_psi(frame, trial)
|
||||
if value < best - 1e-6:
|
||||
best, xs, moved = value, trial, True
|
||||
if not moved:
|
||||
break
|
||||
return xs, best
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the y answer
|
||||
|
||||
def best_ys(panel: Panel, x: float, count: int, as_built: Sequence[float],
|
||||
edge_in: float = 0.0) -> Tuple[List[float], str]:
|
||||
"""Where the row's inserts should sit across the width, and why.
|
||||
|
||||
Two things are wanted and they can 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, because that is
|
||||
what makes the piece hang level.
|
||||
|
||||
Spread wins. The as-drawn spacing is kept when the clear strip allows it,
|
||||
otherwise the row opens out to fill the strip -- 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
|
||||
the honest answer: the beam model has no transverse mechanics, so this is
|
||||
the one thing about the y direction the report can actually say.
|
||||
"""
|
||||
intervals = clear_y_intervals(panel, x, edge_in)
|
||||
if not intervals:
|
||||
return list(as_built), 'no clear strip at this station'
|
||||
mid = panel.width_ft / 2.0
|
||||
widest = _widest(intervals)
|
||||
room = widest[1] - widest[0]
|
||||
|
||||
if count == 1:
|
||||
if _fits(intervals, mid):
|
||||
return [mid], ''
|
||||
return ([0.5 * (widest[0] + widest[1])],
|
||||
'nothing clears the centreline at this station')
|
||||
|
||||
ordered = sorted(as_built)
|
||||
span = min(ordered[-1] - ordered[0], room)
|
||||
lo = mid - span / 2.0
|
||||
if lo < widest[0]:
|
||||
lo = widest[0]
|
||||
elif lo + span > widest[1]:
|
||||
lo = widest[1] - span
|
||||
ys = [lo + i * span / (count - 1.0) for i in range(count)]
|
||||
|
||||
notes = []
|
||||
pulled = (ordered[-1] - ordered[0]) - span
|
||||
if pulled > 1.0 / 24.0:
|
||||
notes.append('spacing closed up {0:.1f} in. -- the openings leave '
|
||||
'only {1:.2f} ft of clear concrete here'
|
||||
.format(pulled * 12.0, room))
|
||||
offset = (sum(ys) / len(ys) - mid) * 12.0
|
||||
if abs(offset) > 0.5:
|
||||
notes.append('row centroid is {0:.1f} in. off the width centreline, '
|
||||
'so the piece hangs out of level unless it is rigged '
|
||||
'for it'.format(abs(offset)))
|
||||
return ys, '; '.join(notes)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the comparison
|
||||
|
||||
@dataclass
|
||||
class Move:
|
||||
"""One modelled insert and where it is being asked to go."""
|
||||
mark: str
|
||||
family: str
|
||||
from_x: float
|
||||
from_y: float
|
||||
to_x: float
|
||||
to_y: float
|
||||
pinned: bool = False
|
||||
|
||||
@property
|
||||
def dx_in(self) -> float:
|
||||
return (self.to_x - self.from_x) * 12.0
|
||||
|
||||
@property
|
||||
def dy_in(self) -> float:
|
||||
return (self.to_y - self.from_y) * 12.0
|
||||
|
||||
@property
|
||||
def moved(self) -> bool:
|
||||
return max(abs(self.dx_in), abs(self.dy_in)) >= 0.125
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
x: float
|
||||
to_x: float
|
||||
count: int
|
||||
note: str = ''
|
||||
pinned: bool = False # cannot move along the piece
|
||||
pinned_y: bool = False # cannot come off its edge, across it
|
||||
used_by: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def fixed(self) -> bool:
|
||||
return self.pinned and self.pinned_y
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageResult:
|
||||
name: str
|
||||
allowable_psi: float
|
||||
as_built_psi: float
|
||||
recommended_psi: float
|
||||
|
||||
def _ratio(self, psi: float) -> float:
|
||||
return psi / self.allowable_psi if self.allowable_psi else 0.0
|
||||
|
||||
@property
|
||||
def as_built_ratio(self) -> float:
|
||||
return self._ratio(self.as_built_psi)
|
||||
|
||||
@property
|
||||
def recommended_ratio(self) -> float:
|
||||
return self._ratio(self.recommended_psi)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recommendation:
|
||||
rows: List[Row] = field(default_factory=list)
|
||||
moves: List[Move] = field(default_factory=list)
|
||||
stages: List[StageResult] = field(default_factory=list)
|
||||
note: str = ''
|
||||
# False means NOBODY LOOKED -- see `not_searched`. It is not the same fact
|
||||
# as an empty `rows`/`moves`, which mean the search ran and found nothing
|
||||
# to move, and the difference has to survive all the way to the page.
|
||||
searched: bool = True
|
||||
|
||||
@property
|
||||
def as_built_ratio(self) -> float:
|
||||
return max([s.as_built_ratio for s in self.stages] or [0.0])
|
||||
|
||||
@property
|
||||
def recommended_ratio(self) -> float:
|
||||
return max([s.recommended_ratio for s in self.stages] or [0.0])
|
||||
|
||||
@property
|
||||
def improvement(self) -> float:
|
||||
"""Fraction of the governing utilisation the move takes off."""
|
||||
if not self.as_built_ratio:
|
||||
return 0.0
|
||||
return (self.as_built_ratio - self.recommended_ratio) \
|
||||
/ self.as_built_ratio
|
||||
|
||||
@property
|
||||
def worth_moving(self) -> bool:
|
||||
return any(m.moved for m in self.moves)
|
||||
|
||||
|
||||
def _edge_distance(lifters: Sequence[Lifter]) -> float:
|
||||
"""The strictest minimum edge distance any insert in the pick asks for."""
|
||||
stated = [l.min_edge_distance_in for l in lifters
|
||||
if l.min_edge_distance_in]
|
||||
return max(stated) if stated else 0.0
|
||||
|
||||
|
||||
def _rows_across_stages(cases, tolerance: float):
|
||||
"""Every physical insert row on the piece, and which stages hang off it.
|
||||
|
||||
Rows are clustered from the union of what the stages pick, not from one
|
||||
stage's rig, because a row that only erection uses still has to be
|
||||
installed somewhere -- and it is the same hole in the same panel.
|
||||
"""
|
||||
seen = {}
|
||||
for analysis, _rig, _f in cases:
|
||||
for line in analysis.rig.lines:
|
||||
for lifter in line.lifters:
|
||||
seen.setdefault(lifter.mark, lifter)
|
||||
lifters = sorted(seen.values(), key=lambda l: (l.x, l.y))
|
||||
|
||||
rows = []
|
||||
for lifter in lifters:
|
||||
if rows and abs(lifter.x - rows[-1]['x']) <= tolerance:
|
||||
rows[-1]['lifters'].append(lifter)
|
||||
rows[-1]['x'] = sum(l.x for l in rows[-1]['lifters']) \
|
||||
/ len(rows[-1]['lifters'])
|
||||
else:
|
||||
rows.append({'x': lifter.x, 'lifters': [lifter]})
|
||||
|
||||
index = {}
|
||||
for i, row in enumerate(rows):
|
||||
for lifter in row['lifters']:
|
||||
index[lifter.mark] = i
|
||||
per_stage = []
|
||||
for analysis, _rig, _f in cases:
|
||||
used = sorted(set(index[l.mark] for line in analysis.rig.lines
|
||||
for l in line.lifters))
|
||||
per_stage.append((analysis, used))
|
||||
return rows, per_stage
|
||||
|
||||
|
||||
NOT_SEARCHED_REASON = (
|
||||
'the position search was switched off for this run, so no insert '
|
||||
'positions were computed and nothing here says the ones as drawn are '
|
||||
'the best available')
|
||||
|
||||
|
||||
def not_searched(reason: str = NOT_SEARCHED_REASON) -> Recommendation:
|
||||
"""The answer for a run that did not look: no positions, and says so.
|
||||
|
||||
The search is 93% of a run -- 12.6 s of a 13.6 s median on the reference
|
||||
sweep -- and a batch of 184 pieces spends 41 minutes of its 42 in here.
|
||||
A caller that wants the verdicts and not the positions can say so, and
|
||||
what it gets back is this: an empty recommendation that is FLAGGED empty.
|
||||
|
||||
Returning a plain `Recommendation()` instead would be the one thing this
|
||||
package refuses to do anywhere else. It reads as "the search ran and found
|
||||
nothing to move" -- the report's own headline for that case is "the
|
||||
inserts as drawn are already at the best position this search can find" --
|
||||
and that sentence about a search nobody ran is a lie the reader has no way
|
||||
to catch. Everything downstream keys off `searched`, so the page, the
|
||||
stdout line and the JSON summary all say NOT COMPUTED instead.
|
||||
"""
|
||||
return Recommendation(note=reason, searched=False)
|
||||
|
||||
|
||||
def recommend(cases) -> Recommendation:
|
||||
"""Where this piece's inserts would be better placed, and by how far.
|
||||
|
||||
Solved for **every stage at once**, which is the only formulation that
|
||||
produces an answer somebody can act on. An insert gets installed in one
|
||||
place and then has to survive stripping, the yard and erection; optimising
|
||||
the stages separately gives three different answers for the same hole, and
|
||||
the report would be asking for a panel that cannot be built.
|
||||
|
||||
So the objective is the worst stage 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.
|
||||
|
||||
Rows whose inserts are pinned stay exactly where they are, both axes.
|
||||
Everything else is searched. Returns positions AND the delta from the
|
||||
model, because the delta is the number that gets acted on.
|
||||
"""
|
||||
cases = list(cases)
|
||||
if not cases:
|
||||
return Recommendation(note='nothing to recommend')
|
||||
|
||||
# A stage the piece spans the other way in has been transposed: its x is
|
||||
# the panel's y. Optimising a position against both frames at once would
|
||||
# be optimising against two different pieces, so those stages sit the
|
||||
# search out and are said to have.
|
||||
turned = [a.stage.name for a, _r, _f in cases if a.stage.span != 'length']
|
||||
supported = [a.stage.name for a, _r, _f in cases
|
||||
if a.stage.supports is not None]
|
||||
cases = [c for c in cases if c[0].stage.span == 'length'
|
||||
and c[0].stage.supports is None]
|
||||
if not cases:
|
||||
notes = []
|
||||
if turned:
|
||||
notes.append(
|
||||
'{0} spans across the width, and so does every other stage '
|
||||
'on this piece, which the position search does not cover yet'
|
||||
.format(', '.join(turned)))
|
||||
if supported:
|
||||
notes.append(
|
||||
'{0} carries the piece on dunnage supports, so there are no '
|
||||
'insert positions to recommend there'.format(
|
||||
', '.join(supported)))
|
||||
return Recommendation(note=' '.join(notes))
|
||||
|
||||
panel = cases[0][0].panel
|
||||
rigging = cases[0][0].rigging
|
||||
|
||||
rows, per_stage = _rows_across_stages(cases, rigging.group_tolerance_ft)
|
||||
if len(rows) < 2:
|
||||
return Recommendation(note='a single support line is not a pick')
|
||||
|
||||
all_lifters = [l for r in rows for l in r['lifters']]
|
||||
edge_in = _edge_distance(all_lifters)
|
||||
free = [not any(l.pinned for l in r['lifters']) for r in rows]
|
||||
free_y = [not any(l.pinned_y for l in r['lifters']) for r in rows]
|
||||
if not any(free) and not any(free_y):
|
||||
return Recommendation(
|
||||
note='every insert on this piece is pinned, so there is nothing '
|
||||
'to recommend')
|
||||
|
||||
counts = [len(r['lifters']) for r in rows]
|
||||
start = [r['x'] for r in rows]
|
||||
# Same coupling as the single-stage search above: a recommendation closer
|
||||
# than group_tolerance_ft would read back as one row. See that comment.
|
||||
min_gap = max(rigging.group_tolerance_ft * 1.5, 0.5)
|
||||
|
||||
frames = []
|
||||
for analysis, used in per_stage:
|
||||
frame = _make_frame(panel, analysis.stage, rigging,
|
||||
[counts[i] for i in used])
|
||||
frames.append((frame, used, analysis.stage.allowable_tension_psi,
|
||||
analysis.stage.name))
|
||||
|
||||
def worst(xs):
|
||||
"""Governing utilisation over every stage, for one set of stations."""
|
||||
out = 0.0
|
||||
for frame, used, allowable, _name in frames:
|
||||
psi = _peak_psi(frame, [xs[i] for i in used])
|
||||
out = max(out, psi / allowable if allowable else psi)
|
||||
return out
|
||||
|
||||
def per_stage_psi(xs):
|
||||
return [(name, _peak_psi(frame, [xs[i] for i in used]), allowable)
|
||||
for frame, used, allowable, name in frames]
|
||||
|
||||
xs = [float(x) for x in start]
|
||||
best = worst(xs)
|
||||
rec_note = ''
|
||||
|
||||
n_free = sum(1 for f in free if f)
|
||||
coarse = _COARSE_FT if n_free <= 3 else _COARSE_FT_MANY
|
||||
grids, coarse, swept = affordable_grids(panel, xs, counts, free, edge_in,
|
||||
coarse)
|
||||
if swept:
|
||||
xs, best = _sweep_objective(worst, xs, best, free, grids, min_gap)
|
||||
else:
|
||||
coarse = _FALLBACK_STEPS[-1]
|
||||
rec_note = (
|
||||
'{0} rows can move, and trying every combination of their '
|
||||
'positions is too many layouts to search. What follows is a local '
|
||||
'improvement on the layout as drawn, not the best one available -- '
|
||||
'move fewer rows, or pin the ones whose position is already '
|
||||
'settled, to get the full search.'.format(n_free))
|
||||
|
||||
for step in _REFINE:
|
||||
for _ in range(_SWEEPS):
|
||||
moved = False
|
||||
for i, is_free in enumerate(free):
|
||||
if not is_free:
|
||||
continue
|
||||
for cand in _candidates(panel, xs[i], step,
|
||||
coarse if step == _REFINE[0]
|
||||
else _REFINE[0], counts[i], edge_in):
|
||||
trial = list(xs)
|
||||
trial[i] = cand
|
||||
if not _ordered(trial, min_gap):
|
||||
continue
|
||||
value = worst(trial)
|
||||
if value < best - 1e-9:
|
||||
best, xs, moved = value, trial, True
|
||||
if not moved:
|
||||
break
|
||||
|
||||
rec = Recommendation()
|
||||
notes = []
|
||||
if rec_note:
|
||||
notes.append(rec_note)
|
||||
if turned:
|
||||
notes.append('{0} spans across the width and was not part of this '
|
||||
'search; check the positions below against it by hand'
|
||||
.format(', '.join(turned)))
|
||||
rec.note = ' '.join(notes)
|
||||
before, after = per_stage_psi(start), per_stage_psi(xs)
|
||||
for (name, was, allowable), (_n2, now, _a2) in zip(before, after):
|
||||
rec.stages.append(StageResult(name=name, allowable_psi=allowable,
|
||||
as_built_psi=was, recommended_psi=now))
|
||||
|
||||
for i, (row, to_x, is_free) in enumerate(zip(rows, xs, free)):
|
||||
lifters = sorted(row['lifters'], key=lambda l: l.y)
|
||||
names = [a.stage.name for a, used in per_stage if i in used]
|
||||
final_x = to_x if is_free else row['x']
|
||||
if free_y[i]:
|
||||
ys, note = best_ys(panel, final_x, len(lifters),
|
||||
[l.y for l in lifters], edge_in)
|
||||
else:
|
||||
# On its edge and staying there. Spreading it across the width
|
||||
# would be recommending an insert somewhere the piece has no edge.
|
||||
ys, note = [l.y for l in lifters], ''
|
||||
rec.rows.append(Row(x=row['x'], to_x=final_x,
|
||||
count=len(lifters), note=note,
|
||||
pinned=not is_free, pinned_y=not free_y[i],
|
||||
used_by=names))
|
||||
for lifter, to_y in zip(lifters, sorted(ys)):
|
||||
rec.moves.append(Move(mark=lifter.mark, family=lifter.family,
|
||||
from_x=lifter.x, from_y=lifter.y,
|
||||
to_x=final_x if is_free else lifter.x,
|
||||
to_y=to_y,
|
||||
pinned=not (is_free or free_y[i])))
|
||||
return rec
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""A dense linear solver, so the package needs no dependencies.
|
||||
|
||||
panellift ships to a Windows machine with whatever Python happens to be
|
||||
installed, and gets run from inside a Revit workflow. Every dependency is a
|
||||
thing that can be missing at the moment someone needs a number. The largest
|
||||
system here is a few hundred equations, which Gaussian elimination handles in
|
||||
milliseconds, so numpy would buy nothing but an install step.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
Matrix = List[List[float]]
|
||||
Vector = List[float]
|
||||
|
||||
|
||||
def solve(matrix: Matrix, rhs: Vector) -> Vector:
|
||||
"""Solve A x = b by Gaussian elimination with partial pivoting.
|
||||
|
||||
A and b are copied, not modified. Raises ValueError on a singular system,
|
||||
which for a beam means the supports do not restrain it -- worth saying
|
||||
plainly rather than returning quiet nonsense.
|
||||
"""
|
||||
n = len(rhs)
|
||||
if any(len(row) != n for row in matrix) or len(matrix) != n:
|
||||
raise ValueError('matrix must be square and match the right-hand side')
|
||||
|
||||
a = [list(row) + [rhs[i]] for i, row in enumerate(matrix)]
|
||||
|
||||
for col in range(n):
|
||||
pivot = max(range(col, n), key=lambda r: abs(a[r][col]))
|
||||
if abs(a[pivot][col]) < 1e-12:
|
||||
raise ValueError(
|
||||
'singular system at column {0}: the beam is not restrained '
|
||||
'(check that at least two support lines were found)'.format(col))
|
||||
if pivot != col:
|
||||
a[col], a[pivot] = a[pivot], a[col]
|
||||
|
||||
inv = 1.0 / a[col][col]
|
||||
for row in range(col + 1, n):
|
||||
factor = a[row][col] * inv
|
||||
if factor == 0.0:
|
||||
continue
|
||||
for k in range(col, n + 1):
|
||||
a[row][k] -= factor * a[col][k]
|
||||
|
||||
x = [0.0] * n
|
||||
for row in range(n - 1, -1, -1):
|
||||
total = a[row][n] - sum(a[row][k] * x[k] for k in range(row + 1, n))
|
||||
x[row] = total / a[row][row]
|
||||
return x
|
||||
|
||||
|
||||
def matvec(matrix: Matrix, vector: Vector) -> Vector:
|
||||
return [sum(m * v for m, v in zip(row, vector)) for row in matrix]
|
||||
@@ -0,0 +1,595 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The input a handling check needs, and nothing about where it came from.
|
||||
|
||||
Everything here is plain data. Revit fills it in on one machine; a test fills
|
||||
it in from a textbook example on another. The calculation cannot tell the
|
||||
difference, which is the point: the engineering can be verified against
|
||||
published examples without a model, a licence, or a Windows box.
|
||||
|
||||
Units are the ones a precast engineer writes on a drawing, not Revit's:
|
||||
|
||||
length, width, positions feet
|
||||
thickness, depths, cover inches
|
||||
stress psi
|
||||
force pounds
|
||||
moment pound-feet unless a name says otherwise
|
||||
|
||||
Panel axes: x runs along the length (0 at one end), y across the width, z
|
||||
through the thickness measured **up from the form face** (the face cast
|
||||
against the bed). A panel is checked lying flat, which is how it is stripped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# --------------------------------------------------------------- geometry
|
||||
|
||||
@dataclass
|
||||
class Blockout:
|
||||
"""A void through the panel. Full depth unless depth_in says otherwise.
|
||||
|
||||
Only full-depth blockouts reduce the section modulus in this version; a
|
||||
partial-depth one is carried so the report can show it and so the weight
|
||||
reconciliation stays honest.
|
||||
"""
|
||||
name: str
|
||||
x0: float
|
||||
y0: float
|
||||
length: float
|
||||
width: float
|
||||
depth_in: Optional[float] = None
|
||||
|
||||
@property
|
||||
def x1(self) -> float:
|
||||
return self.x0 + self.length
|
||||
|
||||
@property
|
||||
def full_depth(self) -> bool:
|
||||
return self.depth_in is None
|
||||
|
||||
def spans(self, x: float) -> bool:
|
||||
return self.x0 <= x <= self.x1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recess:
|
||||
"""A blind pocket in ONE face. Concrete behind it, so nothing is cut.
|
||||
|
||||
A `Blockout` is a hole: the section loses that width and every strand
|
||||
crossing it is severed. A recess is not that, and the two arrived here as
|
||||
one thing until 2026-09-01 -- the job writer emitted every rectangle it
|
||||
read off the side faces of the solid as a blockout, including 8-inch
|
||||
corner pockets with 3.5 in of concrete behind them. On one real piece that
|
||||
phantom hole restarted strand development a foot from the end and produced
|
||||
a flexural-capacity FAIL on a panel whose model has no opening in it.
|
||||
|
||||
So recesses are carried and counted and NOT used. Nothing in `section.py`,
|
||||
`prestress.py` or `capacity.py` reads this list: for every number in the
|
||||
report a recess is solid concrete, which is an approximation in the
|
||||
unconservative direction for section modulus and the report says so in
|
||||
those words. Making it real means depth-aware section properties, and
|
||||
that is a piece of engineering, not a wiring change.
|
||||
|
||||
`depth_in` is how deep the pocket goes, `concrete_in` what is left behind
|
||||
it; either may be None where the job writer could not measure it.
|
||||
"""
|
||||
name: str
|
||||
x0: float
|
||||
y0: float
|
||||
length: float
|
||||
width: float
|
||||
concrete_in: Optional[float] = None
|
||||
depth_in: Optional[float] = None
|
||||
|
||||
@property
|
||||
def x1(self) -> float:
|
||||
return self.x0 + self.length
|
||||
|
||||
def spans(self, x: float) -> bool:
|
||||
return self.x0 <= x <= self.x1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- rigging
|
||||
|
||||
@dataclass
|
||||
class Lifter:
|
||||
"""One lifting insert, located in panel coordinates.
|
||||
|
||||
capacity_lb is the manufacturer's rated working load. None means unknown,
|
||||
which must surface as a warning rather than a silent pass -- an unrated
|
||||
insert is the failure mode this whole exercise exists to catch.
|
||||
"""
|
||||
mark: str
|
||||
family: str
|
||||
x: float
|
||||
y: float
|
||||
role: str = 'strip' # strip | erect | brace
|
||||
capacity_lb: Optional[float] = None
|
||||
# Where capacity_lb came from -- 'direct' (a job/plant-pinned override),
|
||||
# 'catalogue' (the manufacturer's number, unconditionally), 'with_bar' /
|
||||
# 'without_bar' (a part whose rating depends on a tension bar, and
|
||||
# whether that bar was corroborated on THIS piece). None on an older job
|
||||
# file written before this was tracked. capacity_why is the sentence
|
||||
# `knowledge.lifter_capacity` wrote at export time explaining it -- the
|
||||
# only place that explanation exists, since panellift never re-reads the
|
||||
# catalogue. See check_plumb_hang / check_insert_capacity, which put
|
||||
# this in the finding text rather than letting a with_bar/without_bar
|
||||
# swing go unremarked the way the bare 7,690-vs-16,000 number once did.
|
||||
capacity_basis: Optional[str] = None
|
||||
capacity_why: Optional[str] = None
|
||||
shear_capacity_lb: Optional[float] = None
|
||||
shear_capacity_basis: Optional[str] = None
|
||||
shear_capacity_why: Optional[str] = None
|
||||
# The concrete strength the catalogue derived those ratings at. Capacity
|
||||
# follows sqrt(f'c) BELOW it and does not move above it, so an insert is
|
||||
# worth less at release than at 28 days and the same insert has to be
|
||||
# checked at both. None means the ratings are taken as stated.
|
||||
basis_fc_psi: Optional[float] = None
|
||||
# The manufacturer's minimum distance to a free edge. Used when a better
|
||||
# position is being looked for: a recommendation that cannot be installed
|
||||
# is worse than none.
|
||||
min_edge_distance_in: Optional[float] = None
|
||||
# The factors that turn an ULTIMATE rating into a working load, carried
|
||||
# PER INSERT because that is what they are: a number off the sheet of the
|
||||
# part that was tested. They were one pair for the whole job until
|
||||
# 2026-08-31, and the shear half of that pair was 2.67 with no published
|
||||
# source -- while the manufacturer's page for the parts it reached (ALP
|
||||
# catalog p53, 02/2024) heads its capacity table "Capacity in Concrete
|
||||
# 4:1 SWL" across BOTH load columns. A global default is what let one
|
||||
# product's factor decide another product's allowed load, so there is no
|
||||
# longer one: None here means the insert is NOT JUDGED against an
|
||||
# ultimate rating, and `check_insert_capacity` says so by name.
|
||||
tension_fos: Optional[float] = None
|
||||
shear_fos: Optional[float] = None
|
||||
# Some inserts cannot be moved along the piece -- an anchor in the end of
|
||||
# a panel that stands on that end is at the end, and that is the detail,
|
||||
# not a choice.
|
||||
pinned: bool = False
|
||||
# And some cannot be moved ACROSS it, which is a different constraint and
|
||||
# not the same inserts. A panel erected on its long edge carries its
|
||||
# anchors along that edge: they may slide freely down the length, and
|
||||
# they may not come off the edge. Pinning both would forbid the one
|
||||
# degree of freedom that layout is there to use.
|
||||
pinned_y: bool = False
|
||||
# Installed in an edge of the panel rather than its face, and therefore
|
||||
# loaded across its axis while the piece is being stood up. Stated, not
|
||||
# inferred from having a shear rating: face lifters have one of those too,
|
||||
# and guessing would run the wrong check on them without saying so.
|
||||
on_edge: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rigging:
|
||||
"""How the panel hangs. The engineer sets this; it is not in the model.
|
||||
|
||||
mode is the load path, and it changes the mechanics, not just a number:
|
||||
|
||||
'rolling' equalising blocks, so every line in a group carries the same
|
||||
force. Two groups, statically determinate. LECWall's
|
||||
assumption.
|
||||
'static' fixed-length slings. The panel is a continuous beam on rigid
|
||||
supports and the reactions follow stiffness, not statics.
|
||||
|
||||
sling_angle_deg is measured from the plane of the panel: 90 is a vertical
|
||||
line off a spreader beam, smaller angles amplify the line force by
|
||||
1/sin(theta) and push the panel into compression.
|
||||
"""
|
||||
sling_angle_deg: float = 90.0
|
||||
mode: str = 'rolling'
|
||||
group_tolerance_ft: float = 0.25 # lifters within this share a line
|
||||
# A JOB-WIDE fallback for the ultimate-to-working factors, and None on
|
||||
# purpose. It read 4.0 / 2.67 until 2026-08-31; the 2.67 had no source
|
||||
# and the manufacturer's page for the parts it reached publishes 4:1 in
|
||||
# shear, so a single pair here was both wrong and unable to be right --
|
||||
# one number cannot be the answer for two manufacturers' hardware.
|
||||
# `Lifter.tension_fos` / `Lifter.shear_fos` are the real home; these are
|
||||
# read only when an insert carries none of its own, and when BOTH are
|
||||
# absent the insert is reported unjudged rather than given a default.
|
||||
tension_fos: Optional[float] = None
|
||||
shear_fos: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------- reinforcement
|
||||
|
||||
@dataclass
|
||||
class Strand:
|
||||
"""One pretensioned strand, running the length of the piece.
|
||||
|
||||
Held one at a time rather than as a layer with a count, because what
|
||||
matters most about a strand here is WHERE ACROSS the piece it sits: a
|
||||
blockout cuts the strands it passes over and leaves the rest alone, and
|
||||
that both drops the force and moves the line of action sideways. A layer
|
||||
with a count cannot say which of its strands survived.
|
||||
|
||||
y is across the width in feet, z_in is up from the form face in inches,
|
||||
and initial_pull_lb is the jacking force for THIS strand -- the "I.T."
|
||||
figure on the design summary, which is quoted per strand.
|
||||
"""
|
||||
y: float
|
||||
z_in: float
|
||||
area_in2: float = 0.153 # 1/2 in. 270k low-relaxation
|
||||
initial_pull_lb: float = 0.0
|
||||
label: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prestress:
|
||||
"""The pretensioning, as the handling check needs to see it.
|
||||
|
||||
loss_pct is a single number covering every loss between jacking and the
|
||||
stage being checked. It is not calculated: a time-dependent loss model
|
||||
needs an age, a humidity and a schedule that none of this knows, and a
|
||||
plausible-looking calculation from guessed inputs is worse than a stated
|
||||
assumption an engineer can argue with. It is set in the project config
|
||||
file for exactly that reason.
|
||||
|
||||
transfer_length_in is where a strand's force has fully entered the
|
||||
concrete, measured from wherever it ends -- the end of the piece, or the
|
||||
edge of a blockout that cut it. None means the ACI 318 estimate,
|
||||
f_se * db / 3.
|
||||
"""
|
||||
strands: List['Strand'] = field(default_factory=list)
|
||||
loss_pct: float = 8.0
|
||||
diameter_in: float = 0.5
|
||||
transfer_length_in: Optional[float] = None
|
||||
# The strand's ULTIMATE strength, f_pu, in ksi. Needed by nothing in the
|
||||
# stress path -- the precompression comes from the jacking force, which is
|
||||
# measured -- and needed by every capacity number: ACI's approximate f_ps
|
||||
# is written in terms of f_pu, and the equation is only permitted where
|
||||
# f_se >= 0.5 f_pu.
|
||||
#
|
||||
# 270 is the default and it carries provenance, exactly the way
|
||||
# `Stage.tension_limit_sqrt_fc` does, and for the same reason: a page that
|
||||
# states a capacity and no way back to the grade behind it cannot be
|
||||
# audited. ASTM A416 defines Grade 250 AND Grade 270, so this is a choice
|
||||
# and not a constant, and `fpu_source` is what says who made it.
|
||||
#
|
||||
# It is also SELF-CHECKING, which is why a default is defensible here at
|
||||
# all: f_pi/f_pu is computed from the measured jacking force and printed
|
||||
# beside it, so a piece whose strand really is Grade 250 shows a jacking
|
||||
# ratio above the 0.75 f_pu limit and the assumption announces itself
|
||||
# instead of hiding. See `report.capacity_basis`.
|
||||
fpu_ksi: float = 270.0
|
||||
# 'default' -- nobody said, this package's 270.
|
||||
# 'job' -- the job file stated it. The routing for that is
|
||||
# `panel.prestress.fpu_ksi`, written by `liftexport` from
|
||||
# `spec.reinforcing.strand_groups[].grade_ksi`, which
|
||||
# `designspec` already reads off the design summary. Nothing
|
||||
# writes it yet.
|
||||
fpu_source: str = 'default'
|
||||
|
||||
@property
|
||||
def total_area_in2(self) -> float:
|
||||
return sum(s.area_in2 for s in self.strands)
|
||||
|
||||
@property
|
||||
def jacking_stress_ksi(self) -> Optional[float]:
|
||||
"""f_pi -- the measured pull over the measured area, in ksi.
|
||||
|
||||
None where there is nothing to divide. This is the number that audits
|
||||
`fpu_ksi`: on the reference job it comes out at 189.0 ksi against an
|
||||
assumed 270, i.e. 0.700 f_pu, which is what the ticket legend says.
|
||||
"""
|
||||
area = self.total_area_in2
|
||||
if area <= 0.0:
|
||||
return None
|
||||
return sum(s.initial_pull_lb for s in self.strands) / area / 1000.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reinforcement:
|
||||
"""One layer of steel, as strain compatibility needs to see it.
|
||||
|
||||
z_in is the depth of the layer measured up from the form face, so it can
|
||||
be compared directly with a neutral axis depth. initial_pull_lb is the
|
||||
jacking force per strand from the design summary; it is None for mild
|
||||
steel and welded wire.
|
||||
|
||||
`from_ft` / `to_ft` are the layer's own run along the panel length --
|
||||
None (the default, and the whole of the pre-existing behaviour) means
|
||||
"present at every station", the only thing a layer with no range ever
|
||||
meant before these fields existed. A bar carrying an explicit range (the
|
||||
Panel Analysis `reinforcement.mild_bars` deck block, PANELANALYSIS-4)
|
||||
contributes to `capacity.mild_flexure_at` only where the station falls
|
||||
inside [from_ft, to_ft]; a station outside it sees no area from this
|
||||
layer, the same as if the bar were not there at all -- never a fabricated
|
||||
capacity past the bar's own physical run.
|
||||
"""
|
||||
kind: str # strand | bar | wwf
|
||||
label: str
|
||||
area_in2: float
|
||||
z_in: float
|
||||
grade_ksi: float
|
||||
count: int = 1
|
||||
initial_pull_lb: Optional[float] = None
|
||||
from_ft: Optional[float] = None
|
||||
to_ft: Optional[float] = None
|
||||
|
||||
@property
|
||||
def total_area_in2(self) -> float:
|
||||
return self.area_in2 * self.count
|
||||
|
||||
@property
|
||||
def prestressed(self) -> bool:
|
||||
return self.kind == 'strand' and self.initial_pull_lb is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ panel
|
||||
|
||||
@dataclass
|
||||
class Panel:
|
||||
mark: str
|
||||
length_ft: float
|
||||
width_ft: float
|
||||
thickness_in: float
|
||||
unit_weight_pcf: float = 150.0
|
||||
fci_psi: Optional[float] = None # at stripping
|
||||
fc_psi: Optional[float] = None # at 28 days / yard handling
|
||||
weight_lb: Optional[float] = None # as the model reports it
|
||||
finish: str = 'smooth' # smooth | exposed (Table 8.3.1)
|
||||
product_type: str = 'flat' # flat | flat_reveal | fluted | sculptured
|
||||
blockouts: List[Blockout] = field(default_factory=list)
|
||||
# Blind pockets: recorded, shown, and taken into NO calculation. See
|
||||
# `Recess`. An empty list is the ordinary case and is not the same claim
|
||||
# as a job file that never mentioned recesses at all -- the report only
|
||||
# speaks about them when there are some.
|
||||
recesses: List[Recess] = field(default_factory=list)
|
||||
# Concrete of the cast piece that is not the panel rectangle -- a
|
||||
# slab-seat ledge, a haunch -- measured off the model as its own family.
|
||||
# Real weight: on one real piece it was 7.5% of the total. It rides at no
|
||||
# station, so the statics rescale to the stated weight and this only has
|
||||
# to make the reconciliation honest.
|
||||
extra_cast_ft3: float = 0.0
|
||||
lifters: List[Lifter] = field(default_factory=list)
|
||||
reinforcement: List[Reinforcement] = field(default_factory=list)
|
||||
# None means a piece with no pretensioning. It does NOT mean "ignore the
|
||||
# prestress": a panel that has strands and arrives here without them is
|
||||
# refused upstream, because leaving them out understates nothing and
|
||||
# overstates the tension by the whole of P/A -- 350 psi on a real piece,
|
||||
# against a limit of 444.
|
||||
prestress: Optional['Prestress'] = None
|
||||
design_number: Optional[str] = None
|
||||
source: dict = field(default_factory=dict)
|
||||
# Drop the in-plane (horizontal) prestress-eccentricity term from the
|
||||
# fibre stresses -- what the plant's reference tool does with its
|
||||
# "Ignore Horizontal Strand Eccentricity" option on. Off by default:
|
||||
# the term is real, and only a plant that runs its reference that way
|
||||
# switches it off (handling.ignore_lateral_prestress, 2026-09-03).
|
||||
ignore_lateral_prestress: bool = False
|
||||
|
||||
@property
|
||||
def thickness_ft(self) -> float:
|
||||
return self.thickness_in / 12.0
|
||||
|
||||
def lifters_for(self, role: str) -> List[Lifter]:
|
||||
return [l for l in self.lifters if l.role == role]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- stages
|
||||
|
||||
# The words the report and the findings use for `Stage.tension_limit_source`.
|
||||
# Short, because they are printed inline beside a psi figure on every line
|
||||
# that states an allowable tension, and one word is read where a clause is
|
||||
# skipped. The full explanation lives in the report's basis list.
|
||||
TENSION_LIMIT_SOURCES = {
|
||||
'default': 'default',
|
||||
'job': 'job',
|
||||
'stage': 'stage',
|
||||
}
|
||||
|
||||
# The same three words, for `Stage.compression_limit_source`. Kept as its own
|
||||
# dict rather than reusing `TENSION_LIMIT_SOURCES` for the same reason the
|
||||
# tension side has one at all: an unrecognised word must not print as though
|
||||
# it were recognised, and the two provenance fields are validated separately
|
||||
# end to end, so a bug in one must not read the other's dict and pass by
|
||||
# accident.
|
||||
COMPRESSION_LIMIT_SOURCES = {
|
||||
'default': 'default',
|
||||
'job': 'job',
|
||||
'stage': 'stage',
|
||||
}
|
||||
|
||||
|
||||
def trim_number(value: float) -> str:
|
||||
"""`5.0 -> '5'`, `7.5 -> '7.5'`. One decimal, no trailing zero.
|
||||
|
||||
A limit printed as `5.0*sqrt(f'c)` reads like a computed quantity; the
|
||||
two values people argue about are written `5` and `7.5` everywhere the
|
||||
argument is had, and the report should use the same words.
|
||||
"""
|
||||
return '{0:.1f}'.format(float(value)).rstrip('0').rstrip('.') or '0'
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stage:
|
||||
"""One handling condition: a load multiplier and the strength at the time.
|
||||
|
||||
PCI Table 8.3.1 gives the multiplier; the concrete strength is whatever
|
||||
the panel has reached by then, which is why stripping and yard handling
|
||||
are different checks and not one check with a worse number.
|
||||
"""
|
||||
name: str
|
||||
multiplier: float
|
||||
fc_psi: float
|
||||
note: str = ''
|
||||
# Which row of PCI Table 8.3.1 the stage is: stripping / transportation /
|
||||
# yard. Carried separately from `name` because a plant may call a stage
|
||||
# anything; the report's honesty notes key off this, not off a display
|
||||
# name that can be renamed away.
|
||||
pci_case: Optional[str] = None
|
||||
# Tensile limit as a multiple of sqrt(f'c), and it is a real decision.
|
||||
#
|
||||
# 5.0 the modulus of rupture over a 1.5 safety factor -- "no
|
||||
# discernible cracking", and the default here.
|
||||
# 7.5 the modulus of rupture itself: first cracking, no margin.
|
||||
#
|
||||
# The difference is a factor of 1.5 on every stress check, so it decides
|
||||
# whether a piece passes far more often than any refinement of the
|
||||
# analysis does. Another program checking the same panel was set to 7.5
|
||||
# while this defaulted to 5.0, and that alone accounted for most of the
|
||||
# disagreement between them.
|
||||
#
|
||||
# The owner would not pick a doctrine for everybody, 2026-08-30:
|
||||
#
|
||||
# "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用
|
||||
# 户自己填写。I can see it both ways."
|
||||
#
|
||||
# So it is a field in the Project Config window's lifting & handling
|
||||
# module, resolved through the knowledge layers as
|
||||
# `handling.tension_limit_sqrt_fc` and written into every stage of the
|
||||
# job file. 5.0 stays the value when nobody has said otherwise.
|
||||
tension_limit_sqrt_fc: float = 5.0
|
||||
# WHO chose that coefficient, which the report has to print beside it.
|
||||
#
|
||||
# 'default' nobody said. panellift's own 5.0.
|
||||
# 'job' the job file stated it -- what an export always does,
|
||||
# carrying whatever the layers (panellift default -> plant
|
||||
# -> job -> project config) resolved
|
||||
# handling.tension_limit_sqrt_fc to.
|
||||
# 'stage' a handling.stages entry carried its own limit and beat
|
||||
# the job-wide one.
|
||||
#
|
||||
# Without this the page shows a psi number and no way back to it: two
|
||||
# reports on the same piece, one at 5.0 and one at 7.5, differ only in
|
||||
# an allowable nobody can trace -- and 'stage' exists because an
|
||||
# engineer who types 5.0 into Project Config and gets a 7.5 report
|
||||
# otherwise has nothing on the page that explains it.
|
||||
tension_limit_source: str = 'default'
|
||||
# How the piece hangs in THIS stage, when it does not hang the way it
|
||||
# does in the others. None inherits the job's rigging mode, which is
|
||||
# what a plant running one rig for everything wants.
|
||||
#
|
||||
# It is a real per-stage decision and not a refinement: a commercial
|
||||
# check of the same panel on the same inserts used fixed slings to
|
||||
# strip and an equalising block to erect, and got 14.1 k-ft of
|
||||
# stripping moment where one rolling mode everywhere gives 23.8. The
|
||||
# tool will not guess which is right -- the engineer sets it.
|
||||
rig_mode: Optional[str] = None
|
||||
# Support lines along the span instead of a pick -- transportation on
|
||||
# dunnage. None means the stage is a lift. When present, the reactions are
|
||||
# a continuous beam on rigid supports: no cable groups, no sling angle and
|
||||
# no insert capacity check, because the piece is not hanging from inserts.
|
||||
supports: Optional[List[float]] = None
|
||||
# Which way the piece spans in this stage: 'length' as it was cast and as
|
||||
# it lies flat, or 'width' once a horizontal panel has been stood up on
|
||||
# its long edge and gravity runs the other way.
|
||||
span: str = 'length'
|
||||
# Compression only becomes a question once there is prestress: without it
|
||||
# a handling stress of a few hundred psi is nowhere near anything. With
|
||||
# 350 psi of precompression already in the section it is worth asking.
|
||||
# 0.6 f'c is the usual handling allowable, and it is now the same kind of
|
||||
# per-job decision `tension_limit_sqrt_fc` above is:
|
||||
#
|
||||
# 0.6 ACI 318-19 Table 24.5.3.1 ("all other locations", 0.60*f'ci at
|
||||
# transfer) and Sec. 24.5.4.1 (0.60*f'c in service). The default
|
||||
# here, unchanged from what this tool always did.
|
||||
#
|
||||
# ACI Table 24.5.3.1 also permits 0.70*f'ci AT MEMBER ENDS, a higher
|
||||
# allowable this tool DELIBERATELY does not implement -- the owner's
|
||||
# ruling, 2026-09-01: "先不用实现0.7...别搞这么复杂" ("don't implement
|
||||
# 0.7 for now, no need to make it that complicated"). One flat ratio
|
||||
# everywhere on the piece is the conservative choice, not an oversight.
|
||||
#
|
||||
# Resolved through the knowledge layers as
|
||||
# `handling.compression_limit_ratio` and written into every stage of the
|
||||
# job file, exactly as `tension_limit_sqrt_fc` is -- see that field's own
|
||||
# comment above for why a per-job number is a field and not a doctrine.
|
||||
compression_limit_ratio: float = 0.6
|
||||
# WHO chose that ratio. Same three words and the same reason as
|
||||
# `tension_limit_source`: a psi figure with no way back to who set the
|
||||
# ratio behind it cannot be told apart from the same panel checked under
|
||||
# a different one.
|
||||
compression_limit_source: str = 'default'
|
||||
|
||||
@property
|
||||
def allowable_tension_psi(self) -> float:
|
||||
return self.tension_limit_sqrt_fc * (self.fc_psi ** 0.5)
|
||||
|
||||
@property
|
||||
def allowable_compression_psi(self) -> float:
|
||||
return self.compression_limit_ratio * self.fc_psi
|
||||
|
||||
@property
|
||||
def tension_limit_text(self) -> str:
|
||||
"""`5*sqrt(f'c)` -- the coefficient as it is spoken, without a psi.
|
||||
|
||||
ASCII on purpose: this string goes into findings that are printed to
|
||||
whatever console the run was launched from, and a radical sign there
|
||||
is a crash on a cp1252 code page rather than a nicer report.
|
||||
"""
|
||||
return "{0}*sqrt(f'c)".format(trim_number(self.tension_limit_sqrt_fc))
|
||||
|
||||
@property
|
||||
def tension_limit_source_word(self) -> str:
|
||||
"""One word for who set the coefficient. Never blank, never a guess."""
|
||||
return TENSION_LIMIT_SOURCES.get(self.tension_limit_source, 'stated')
|
||||
|
||||
@property
|
||||
def tension_basis(self) -> str:
|
||||
"""`5*sqrt(f'c), job` -- the limit and its provenance, for one line.
|
||||
|
||||
Callers put it in brackets after the psi figure, so that no page
|
||||
anywhere states an allowable tension without saying which of the two
|
||||
schools produced it.
|
||||
"""
|
||||
return '{0}, {1}'.format(self.tension_limit_text,
|
||||
self.tension_limit_source_word)
|
||||
|
||||
@property
|
||||
def compression_limit_text(self) -> str:
|
||||
"""`0.6*f'c` -- the ratio as it is spoken, mirroring
|
||||
`tension_limit_text`."""
|
||||
return "{0}*f'c".format(trim_number(self.compression_limit_ratio))
|
||||
|
||||
@property
|
||||
def compression_limit_source_word(self) -> str:
|
||||
"""One word for who set the ratio. Never blank, never a guess."""
|
||||
return COMPRESSION_LIMIT_SOURCES.get(
|
||||
self.compression_limit_source, 'stated')
|
||||
|
||||
@property
|
||||
def compression_basis(self) -> str:
|
||||
"""`0.6*f'c, default` -- the ratio and its provenance, for one line.
|
||||
|
||||
Mirrors `tension_basis` exactly, for the same reason: no page may
|
||||
state an allowable compression without saying which ratio produced
|
||||
it and who chose that ratio.
|
||||
"""
|
||||
return '{0}, {1}'.format(self.compression_limit_text,
|
||||
self.compression_limit_source_word)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Material:
|
||||
"""Values the engineer confirms rather than the model asserting them."""
|
||||
fy_ksi: float = 60.0 # mild bar, unless stated otherwise
|
||||
fyt_ksi: float = 65.0 # welded wire / transverse
|
||||
strand_fpu_ksi: float = 270.0
|
||||
strand_area_in2: float = 0.153 # 1/2 in. 270k low-relaxation
|
||||
prestress_loss_at_strip: float = 0.10 # ASPIRE example uses 10%
|
||||
prestress_loss_at_yard: float = 0.15
|
||||
Ec_ksi: Optional[float] = None # None -> 57000*sqrt(f'c)
|
||||
phi_flexure: float = 0.9
|
||||
handling_load_factor: float = 1.4 # LECWall's ultimate check
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkippedStage:
|
||||
"""A stage the job file switched off: `"enabled": false`.
|
||||
|
||||
It is NOT a Stage with the numbers left blank, and that is deliberate. A
|
||||
Stage carries a multiplier, an f'c and a tension limit, and every one of
|
||||
those would have to be invented here. An invented number is the exact
|
||||
failure this package exists to prevent: a reader who sees 0 psi against
|
||||
a 444 psi allowable reads "miles of margin", not "nobody looked".
|
||||
|
||||
So this carries only what is true -- which stage it was, where it sat in
|
||||
the list so the report can print it in its place, and why it was switched
|
||||
off if anybody said. Everything else is absent, and the report prints its
|
||||
absence as a sentence rather than as a dash in a numeric column.
|
||||
"""
|
||||
name: str
|
||||
index: int = 0
|
||||
reason: str = ''
|
||||
@@ -0,0 +1,452 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Published PCI Chapter 8 values and closed forms.
|
||||
|
||||
Everything here comes from PCI Design Handbook section 8.3, as reproduced in
|
||||
the public ASPIRE Winter 2019 article "Calculations for Handling and Shipping
|
||||
Precast Concrete Deck Panels" (Brown).
|
||||
|
||||
It is kept separate from the analysis so the two can be compared: the closed
|
||||
forms below are used as a *check* on the beam solver, never as the solver.
|
||||
That distinction is the whole design. Figure 8.3.2 gives moments for pick
|
||||
points placed where positive and negative moment come out equal, and the
|
||||
reason to check a real ticket at all is that the detailer may not have put
|
||||
them there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
|
||||
# Table 8.3.1 -- equivalent static load multipliers for stripping and dynamic
|
||||
# forces, by product type and finish. PCI note (a): these are for flexural
|
||||
# design of the panel and are NOT to be applied to the safety factors on
|
||||
# lifting devices.
|
||||
TABLE_8_3_1: Dict[str, Dict[str, float]] = {
|
||||
'flat': {'exposed': 1.2, 'smooth': 1.3},
|
||||
'flat_reveal': {'exposed': 1.3, 'smooth': 1.4},
|
||||
'fluted': {'exposed': 1.4, 'smooth': 1.6},
|
||||
'sculptured': {'exposed': 1.5, 'smooth': 1.7},
|
||||
}
|
||||
|
||||
YARD_AND_ERECTION_MULTIPLIER = 1.2
|
||||
TRANSPORTATION_MULTIPLIER = 1.5
|
||||
|
||||
PRODUCT_TYPE_LABELS = {
|
||||
'flat': 'Flat, removable side forms, no false joints or reveals',
|
||||
'flat_reveal': 'Flat, with false joints and/or reveals',
|
||||
'fluted': 'Fluted, with proper draft',
|
||||
'sculptured': 'Sculptured and other conditions',
|
||||
}
|
||||
|
||||
FINISH_LABELS = {
|
||||
'exposed': 'Exposed aggregate with retarder',
|
||||
'smooth': 'Smooth form (release agent only)',
|
||||
}
|
||||
|
||||
|
||||
def stripping_multiplier(product_type: str, finish: str) -> float:
|
||||
try:
|
||||
return TABLE_8_3_1[product_type][finish]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
'no Table 8.3.1 entry for product_type={0!r} finish={1!r}; '
|
||||
'expected one of {2} and one of {3}'
|
||||
.format(product_type, finish, sorted(TABLE_8_3_1),
|
||||
sorted(FINISH_LABELS)))
|
||||
|
||||
|
||||
# ------------------------------------------------------------- allowables
|
||||
|
||||
# The CODE rupture modulus, ACI 318-19 Eq. 19.2.3.1: f_r = 7.5*lambda*sqrt(f'c).
|
||||
# It is the stress at which the extreme fibre of plain concrete cracks, and it
|
||||
# is what M_cr is built on.
|
||||
#
|
||||
# READ THIS BEFORE TOUCHING EITHER NUMBER. There is a second 7.5 in this
|
||||
# package and they are NOT the same quantity:
|
||||
#
|
||||
# * 7.5 HERE is ACI 318-19 19.2.3.1, the rupture modulus. It is a material
|
||||
# property, no safety factor in it, and it appears in M_cr.
|
||||
# * 7.5 in `Stage.tension_limit_sqrt_fc` on this job is the HANDLING TENSILE
|
||||
# ALLOWABLE -- a limit the owner ruled on 2026-08-19 (PROJECT.md 3.8 has
|
||||
# his sentence), resolved through `handling.tension_limit_sqrt_fc`, and it
|
||||
# is a *choice
|
||||
# between two schools*: 5 = the rupture modulus over a 1.5 safety factor
|
||||
# ("no discernible cracking"), 7.5 = the rupture modulus itself, no margin.
|
||||
#
|
||||
# They coincide numerically on this plant and only because the owner picked the
|
||||
# no-margin school. A plant that picks 5 leaves this constant at 7.5, and any
|
||||
# edit that "unifies" them silently changes M_cr on every panel it touches.
|
||||
MODULUS_OF_RUPTURE_SQRT_FC = 7.5
|
||||
|
||||
# ACI 318-19 Table 19.2.4.1(a) -- lambda from equilibrium density. Normalweight
|
||||
# concrete (w_c above 135 pcf) is 1.0; below that the concrete carries less
|
||||
# tension than sqrt(f'c) alone predicts, because lightweight aggregate splits
|
||||
# rather than the crack going round it.
|
||||
LAMBDA_NORMALWEIGHT_MIN_PCF = 135.0
|
||||
LAMBDA_LIGHTWEIGHT_MAX_PCF = 100.0
|
||||
LAMBDA_ALL_LIGHTWEIGHT = 0.75
|
||||
LAMBDA_PER_PCF = 0.0075
|
||||
|
||||
|
||||
def lambda_factor(unit_weight_pcf: Optional[float]) -> float:
|
||||
"""ACI 318-19 Table 19.2.4.1(a): lambda from the concrete's own density.
|
||||
|
||||
Not hardcoded to 1.0. The panel states its unit weight and 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, not because the code says so,
|
||||
and a lightweight piece arriving here must not silently be credited with
|
||||
normalweight tension.
|
||||
|
||||
None returns 1.0: `Panel.unit_weight_pcf` is not optional and defaults to
|
||||
150.0, so None here means a caller passed nothing, never a model that
|
||||
could not be read.
|
||||
"""
|
||||
if unit_weight_pcf is None:
|
||||
return 1.0
|
||||
w = float(unit_weight_pcf)
|
||||
if w > LAMBDA_NORMALWEIGHT_MIN_PCF:
|
||||
return 1.0
|
||||
if w <= LAMBDA_LIGHTWEIGHT_MAX_PCF:
|
||||
return LAMBDA_ALL_LIGHTWEIGHT
|
||||
return min(1.0, LAMBDA_PER_PCF * w)
|
||||
|
||||
|
||||
def modulus_of_rupture_psi(fc_psi: float, lam: float = 1.0) -> float:
|
||||
"""ACI 318-19 19.2.3.1. `lam` is ACI's lambda -- see `lambda_factor`.
|
||||
|
||||
The default of 1.0 keeps the one-argument call this has always had, and a
|
||||
caller holding a lightweight piece passes the factor.
|
||||
"""
|
||||
return MODULUS_OF_RUPTURE_SQRT_FC * lam * math.sqrt(fc_psi)
|
||||
|
||||
|
||||
def allowable_tension_psi(fc_psi: float) -> float:
|
||||
"""No-discernible-cracking limit: rupture modulus over a 1.5 safety factor.
|
||||
|
||||
f_allowable = 7.5*sqrt(f'c) / 1.5 = 5*sqrt(f'c)
|
||||
|
||||
This is the HANDLING ALLOWABLE, not the rupture modulus -- see the comment
|
||||
on MODULUS_OF_RUPTURE_SQRT_FC above. `Stage.tension_limit_sqrt_fc` is what
|
||||
a real run uses; this function is the 5.0 school written out.
|
||||
"""
|
||||
return 5.0 * math.sqrt(fc_psi)
|
||||
|
||||
|
||||
# ------------------------------------------------- strength-reduction factors
|
||||
#
|
||||
# ACI 318-19 Table 21.2.1 and Table 21.2.2. These are code values -- industry
|
||||
# wide, published, identifying nobody -- and they are named constants rather
|
||||
# than digits in an expression because a bare 0.9 in a capacity calculation is
|
||||
# indistinguishable from a fudge factor six months later.
|
||||
#
|
||||
# PROPOSED FOR THE PUBLIC LAYER, not landed. They belong in
|
||||
# `knowledge/catalogue.yaml` beside the other published facts, 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
|
||||
# the file already uses (the table or section number). That is a
|
||||
# `knowledge.py` + `liftexport` change -- panellift has no YAML reader and is
|
||||
# fed only by the job file -- so the keys are NAMED here for routing and the
|
||||
# constants live here until somebody wires them.
|
||||
|
||||
# ACI 318 Table 21.2.2, tension-controlled sections.
|
||||
PHI_FLEXURE_TENSION_CONTROLLED = 0.90
|
||||
|
||||
# ACI 318-14 Table 21.2.2 / 21.2.2.2 -- phi for flexure is NOT a flat 0.90. It
|
||||
# is the STRAIN-based transition: compression-controlled at the floor below the
|
||||
# steel yield strain, tension-controlled 0.90 at a net tensile strain of 0.005,
|
||||
# and linear between. The floor differs by steel:
|
||||
#
|
||||
# * PRESTRESSED strand: 0.65 (slope 0.25 across the transition).
|
||||
# * MILD reinforcement: 0.75 (slope 0.15) -- carried for completeness; the
|
||||
# handling flexure path here is strand only.
|
||||
#
|
||||
# The along-length "0.75 -> 0.90 down the panel" that the reference tool's
|
||||
# reports show is an EMERGENT effect of the section's net tensile strain
|
||||
# changing with the developed strand, NOT an ACI 318-19 21.2.3
|
||||
# transfer->development interpolation (which the reference QC document contains
|
||||
# nowhere). So phi is computed from the strain at each section, and
|
||||
# `capacity.phi_flexure_from_strain` is that function. See
|
||||
# docs/eriksson-wall-algorithms.md sec.2.
|
||||
PHI_FLEXURE_COMPRESSION_CONTROLLED = 0.65 # prestressed floor
|
||||
PHI_FLEXURE_COMPRESSION_CONTROLLED_MILD = 0.75 # mild floor (not on this path)
|
||||
|
||||
# ACI 318-14 21.2.2.2: the steel strain at which the compression-controlled
|
||||
# floor ends. 0.002 for Grade-60 mild and for prestressed strand alike (the
|
||||
# reference QC doc uses 0.002 for both). The tension-controlled limit 0.005 is
|
||||
# `TENSION_CONTROLLED_STRAIN` below.
|
||||
STRAIN_COMPRESSION_CONTROLLED = 0.002
|
||||
|
||||
# A separate factor entirely -- ACI's phi for a tension force in strand that is
|
||||
# still WITHIN its transfer length (the reference tool's "tension,
|
||||
# transferring" resistance factor). It is NOT the flexure floor above and is
|
||||
# not applied on the flexure path here; whether the reference tool caps
|
||||
# near-end flexural phi at this value is flagged unresolved in
|
||||
# docs/eriksson-wall-algorithms.md sec.2.3 and is deliberately NOT invented.
|
||||
PHI_TENSION_TRANSFERRING = 0.75
|
||||
|
||||
# ACI 318-19 Table 21.2.1, shear.
|
||||
PHI_SHEAR = 0.75
|
||||
|
||||
# ACI 318-19 21.2.2: a section is tension-controlled once the net tensile
|
||||
# strain in the extreme layer of tension reinforcement reaches 0.005.
|
||||
TENSION_CONTROLLED_STRAIN = 0.005
|
||||
|
||||
# ACI 318-19 22.2.2.1: assumed strain at the extreme compression fibre.
|
||||
CONCRETE_CRUSHING_STRAIN = 0.003
|
||||
|
||||
# ACI 318-19 22.2.2.4.1: equivalent rectangular stress block intensity.
|
||||
WHITNEY_STRESS_INTENSITY = 0.85
|
||||
|
||||
|
||||
def beta1(fc_psi: float) -> float:
|
||||
"""ACI 318-19 Table 22.2.2.4.3 -- the Whitney block depth ratio.
|
||||
|
||||
f'c <= 4000 0.85
|
||||
4000 < f'c < 8000 0.85 - 0.05*(f'c - 4000)/1000
|
||||
f'c >= 8000 0.65
|
||||
"""
|
||||
if fc_psi <= 4000.0:
|
||||
return 0.85
|
||||
if fc_psi >= 8000.0:
|
||||
return 0.65
|
||||
return 0.85 - 0.05 * (fc_psi - 4000.0) / 1000.0
|
||||
|
||||
|
||||
# ACI 318-19 Table 20.3.2.3.1: gamma_p, keyed on f_py/f_pu. Low-relaxation
|
||||
# strand (f_py/f_pu >= 0.90) is 0.28; stress-relieved is 0.40.
|
||||
GAMMA_P_LOW_RELAXATION = 0.28
|
||||
|
||||
# ACI 318-19 20.3.2.3.1 permits the approximate f_ps equation only where the
|
||||
# effective prestress is at least half the ultimate strength. Below that the
|
||||
# equation is outside its own stated range and this package will not use it.
|
||||
MIN_FSE_OVER_FPU_FOR_APPROXIMATE_FPS = 0.5
|
||||
|
||||
|
||||
def fps_approximate_ksi(fpu_ksi: float, rho_p: float, fc_psi: float,
|
||||
gamma_p: float = GAMMA_P_LOW_RELAXATION) -> float:
|
||||
"""ACI 318-19 Eq. 20.3.2.3.1, bonded strand with no mild steel.
|
||||
|
||||
f_ps = f_pu * (1 - (gamma_p/beta1) * (rho_p * f_pu / f'c))
|
||||
|
||||
The full equation carries a `+ (d/d_p)*(omega - omega')` term for mild
|
||||
tension and compression steel. It is dropped here because there is none:
|
||||
`model.Reinforcement` is instantiated nowhere in this package and the job
|
||||
file has no field that could carry a bar layer, so a panel arriving here
|
||||
has strand and nothing else. That is a fact about the input, checked, not
|
||||
an assumption -- and the day a bar layer can be expressed, this signature
|
||||
grows an omega and the report's footnote changes with it.
|
||||
|
||||
Never above f_pu, which the equation cannot exceed for a positive rho_p
|
||||
but which a caller passing nonsense could.
|
||||
"""
|
||||
fpu_psi = fpu_ksi * 1000.0
|
||||
reduction = (gamma_p / beta1(fc_psi)) * (rho_p * fpu_psi / fc_psi)
|
||||
return max(0.0, min(fpu_ksi, fpu_ksi * (1.0 - reduction)))
|
||||
|
||||
|
||||
# ---------------------------------------------- strand stress-strain curve
|
||||
#
|
||||
# The PCI Design Aid 15.2.3 POWER CURVE for 270 ksi low-relaxation strand --
|
||||
# the strand's own stress-strain law, used by the reference tool (Eriksson
|
||||
# Wall) to get f_ps by strain compatibility instead of the ACI approximate
|
||||
# equation. Transcribed from `docs/eriksson-wall-algorithms.md` sec.4.2
|
||||
# (QC p.33/p.140), verbatim:
|
||||
#
|
||||
# f_ps = Ep * eps_ps if eps_ps <= 0.0085 (elastic)
|
||||
# f_ps = f_pu - 0.04 / (eps_ps - 0.007) if eps_ps > 0.0085 (power law)
|
||||
#
|
||||
# with f_ps in ksi and the 0.04 a ksi constant. The two branches are designed
|
||||
# to meet within ~1 ksi at eps_ps = 0.0085 (Ep*0.0085 = 242.3 vs
|
||||
# 270 - 0.04/0.0015 = 243.3 for the sample strand), so the curve reads as one
|
||||
# smooth law, not a step. It is capped at f_pu (the power branch approaches
|
||||
# f_pu asymptotically from below and cannot reach it for any finite strain, but
|
||||
# a caller passing a strain below the elastic limit with a large Ep could, so
|
||||
# the cap is real). See docs sec.4.2 / correction 12.
|
||||
#
|
||||
# WHY THIS EXISTS NOW, when `capacity.py`'s docstring says strain compatibility
|
||||
# was deliberately NOT used "because no layer holds the strand's stress-strain
|
||||
# curve": the curve's constants ARE the three below, and the reference QC
|
||||
# document is the layer that holds them. The ACI approximate f_ps
|
||||
# (`fps_approximate_ksi`) stays -- it is what the shared `capacity_at` /
|
||||
# Panel Lift Check use, reviewer-reproducible on paper -- and this is the more
|
||||
# exact law the additive Panel Analysis flexure path uses to match Eriksson.
|
||||
STRAND_ELASTIC_MODULUS_KSI = 28500.0 # Ep, PCI value for 7-wire strand (sec.10)
|
||||
FPS_POWER_CURVE_STRAIN_LIMIT = 0.0085 # eps_ps below which f_ps is elastic
|
||||
FPS_POWER_CURVE_COEFF_KSI = 0.04 # the 0.04 ksi numerator, PCI 15.2.3
|
||||
FPS_POWER_CURVE_STRAIN_OFFSET = 0.007 # the 0.007 strain offset, PCI 15.2.3
|
||||
|
||||
|
||||
def fps_power_curve_ksi(eps_ps: float, fpu_ksi: float,
|
||||
ep_ksi: float = STRAND_ELASTIC_MODULUS_KSI) -> float:
|
||||
"""PCI Design Aid 15.2.3 power curve: strand stress from total strain, ksi.
|
||||
|
||||
`eps_ps` is the TOTAL strain in the strand (the effective prestrain
|
||||
`eps_se = f_se/Ep` plus the strain change from the section curvature), per
|
||||
docs sec.4.2. Below `FPS_POWER_CURVE_STRAIN_LIMIT` the strand is elastic
|
||||
(`Ep*eps`); above it the power law `f_pu - 0.04/(eps - 0.007)` applies.
|
||||
Capped at f_pu, floored at 0.
|
||||
|
||||
Hand check (270 ksi strand at eps_ps = 0.010):
|
||||
0.010 > 0.0085 -> 270 - 0.04/(0.010 - 0.007) = 270 - 13.33 = 256.67 ksi.
|
||||
At the elastic limit eps_ps = 0.0085: Ep*0.0085 = 28500*0.0085 = 242.25 ksi.
|
||||
"""
|
||||
if eps_ps <= 0.0:
|
||||
return 0.0
|
||||
if eps_ps <= FPS_POWER_CURVE_STRAIN_LIMIT:
|
||||
fps = ep_ksi * eps_ps
|
||||
else:
|
||||
fps = fpu_ksi - FPS_POWER_CURVE_COEFF_KSI / (eps_ps
|
||||
- FPS_POWER_CURVE_STRAIN_OFFSET)
|
||||
return max(0.0, min(fpu_ksi, fps))
|
||||
|
||||
|
||||
def development_length_in(lt_in: float, fps_ksi: float, fse_ksi: float,
|
||||
db_in: float) -> float:
|
||||
"""ACI 318-19 25.4.8.1: l_d = (f_se/3)*d_b + (f_ps - f_se)*d_b.
|
||||
|
||||
The FIRST term is the transfer length, and this deliberately takes it as
|
||||
an argument rather than recomputing it: `prestress.transfer_length_in`
|
||||
already answers that question, honours a job file that states one, and is
|
||||
the same length the stress path uses. Two places computing a transfer
|
||||
length is how they come to disagree.
|
||||
"""
|
||||
return max(0.0, lt_in) + max(0.0, fps_ksi - fse_ksi) * db_in
|
||||
|
||||
|
||||
# ACI 318-19 22.5.5.1, the simplified method: V_c = 2*lambda*sqrt(f'c)*b_w*d
|
||||
# for a member without shear reinforcement. Wall panels carry no stirrups in
|
||||
# the handling direction, so this is the whole of V_n.
|
||||
CONCRETE_SHEAR_SQRT_FC = 2.0
|
||||
|
||||
# ACI 318-19 22.5.2.2: for a prestressed member, d need not be taken less than
|
||||
# 0.80h. That matters here -- a strand 1.75 in. up an 8 in. panel gives
|
||||
# d_p = 6.25 in. sagging but only 1.75 in. hogging, and 0.80h = 6.4 in. is the
|
||||
# code's own floor under it.
|
||||
MIN_D_OVER_H_PRESTRESSED = 0.80
|
||||
|
||||
|
||||
def concrete_shear_capacity_lb(fc_psi: float, bw_in: float, d_in: float,
|
||||
lam: float = 1.0) -> float:
|
||||
"""ACI 318-19 22.5.5.1 simplified: V_c = 2*lambda*sqrt(f'c)*b_w*d, in lb."""
|
||||
return (CONCRETE_SHEAR_SQRT_FC * lam * math.sqrt(fc_psi)
|
||||
* bw_in * d_in)
|
||||
|
||||
|
||||
# ---------------------------------------------- detailed shear (Vci / Vcw)
|
||||
#
|
||||
# The PCI detailed method the reference tool (Eriksson Wall) uses for a
|
||||
# prestressed section, and the non-prestressed one-way path it uses otherwise.
|
||||
# These are the coefficients from `docs/eriksson-wall-algorithms.md` sec.3,
|
||||
# transcribed from that tool's own QC document (QC p.37 prestressed, QC p.76
|
||||
# mild) and cross-cited to PCI Design Handbook 8th Ed. / ACI 318-14. They are
|
||||
# named constants for the same reason every other coefficient in this file is:
|
||||
# a bare 3.5 or 0.3 in a shear expression is indistinguishable from a fudge
|
||||
# factor six months on. The function that assembles them is
|
||||
# `capacity.detailed_shear_at`; it is ADDITIVE and does not touch the
|
||||
# simplified `concrete_shear_capacity_lb` above, which Panel Lift Check still
|
||||
# reads.
|
||||
#
|
||||
# PRESTRESSED -- min(Vci, Vcw), each a force in lb:
|
||||
#
|
||||
# Vci = max( 0.6*sqrt(f'c)*b*d + Vu*Mcre/Mmax [PCI 5-25],
|
||||
# 1.7*sqrt(f'c)*b*d [PCI 5-27, a FLOOR] )
|
||||
# Mcre = St*(6*sqrt(f'c) + fpe) [PCI 5-26]
|
||||
# Vcw = (3.5*sqrt(f'c) + 0.3*fpc)*b*d [PCI 5-28]
|
||||
# Vc = min(Vci, Vcw); phi*Vn = 0.75*Vc
|
||||
# b*d = W*dv, dv = 0.8*th (ACI 318-14 22.5.8.3.1)
|
||||
#
|
||||
# At a zero-moment station Mmax -> 0, so Vci.1 -> +inf and Vci = +inf, which is
|
||||
# the reference tool's printed "inf" in the Vci column and makes Vcw govern
|
||||
# there (docs sec.3.1, QC p.15/p.18).
|
||||
VCI_LOWER_SQRT_FC = 0.6 # PCI 5-25, the 0.6*sqrt(f'c) shear-flexure term
|
||||
VCI_FLOOR_SQRT_FC = 1.7 # PCI 5-27, the floor Vci may not fall below
|
||||
MCRE_SHEAR_MODULUS_SQRT_FC = 6.0 # PCI 5-26, the 6*sqrt(f'c) in the cracking
|
||||
# moment for the shear check. This is NOT the 7.5 rupture modulus
|
||||
# (MODULUS_OF_RUPTURE_SQRT_FC) nor the handling tensile allowable -- it is
|
||||
# the value PCI 5-26 uses for the SHEAR-cracking moment specifically.
|
||||
VCW_SQRT_FC = 3.5 # PCI 5-28, the 3.5*sqrt(f'c) web-shear term
|
||||
VCW_FPC_COEFF = 0.3 # PCI 5-28, the 0.3*fpc precompression term
|
||||
DV_OVER_H_SHEAR = 0.80 # ACI 318-14 22.5.8.3.1: dv = 0.8*th (shear)
|
||||
|
||||
# NON-PRESTRESSED -- max(Vc, Vc.min), a MAX and not a cap (a genuine surprise,
|
||||
# flagged in docs sec.3.2 correction 5 / QC p.76):
|
||||
#
|
||||
# Vc = 2*lambda*(1 + Nu/(2000*Ag))*sqrt(f'c)*bw*d [ACI 318-14 22.5.6.1]
|
||||
# Vc.min = (4/3)*lambda*sqrt(f'c)*Ag
|
||||
# phi*Vn = 0.75*max(Vc, Vc.min)
|
||||
#
|
||||
# Nu is factored axial COMPRESSION (raises Vc); it is 0 for a piece hanging
|
||||
# flat in handling. The 2.0 coefficient is `CONCRETE_SHEAR_SQRT_FC` above.
|
||||
NONPRESTRESSED_AXIAL_DIVISOR = 2000.0 # the 2000*Ag in the (1 + Nu/2000Ag) term
|
||||
NONPRESTRESSED_VC_MIN_SQRT_FC = 4.0 / 3.0 # the (4/3)*sqrt(f'c)*Ag floor term
|
||||
|
||||
|
||||
def insert_derate(basis_fc_psi, fc_psi) -> float:
|
||||
"""How much of a catalogue rating survives at the concrete you have.
|
||||
|
||||
A rating is derived at one strength -- an anchor table quoted at 3500 psi,
|
||||
say -- and the concrete around it fails in a cone, so the capacity follows
|
||||
sqrt(f'c) below that. It does NOT rise above it: past the basis the
|
||||
anchor's own steel governs and the concrete has stopped being the weak
|
||||
part.
|
||||
|
||||
factor = min(1, sqrt(f'c actual / basis))
|
||||
|
||||
Releasing at 2500 psi on a 3500 psi table therefore costs 15%, and
|
||||
releasing at 6000 buys nothing. Returns 1.0 when either number is unknown:
|
||||
an unstated basis must not silently reduce a rating.
|
||||
"""
|
||||
if not basis_fc_psi or not fc_psi or basis_fc_psi <= 0 or fc_psi <= 0:
|
||||
return 1.0
|
||||
if fc_psi >= basis_fc_psi:
|
||||
return 1.0
|
||||
return math.sqrt(float(fc_psi) / float(basis_fc_psi))
|
||||
|
||||
|
||||
def elastic_modulus_psi(fc_psi: float) -> float:
|
||||
"""ACI 318 normalweight: Ec = 57000*sqrt(f'c)."""
|
||||
return 57000.0 * math.sqrt(fc_psi)
|
||||
|
||||
|
||||
# ------------------------------------------------ Figure 8.3.2 closed forms
|
||||
|
||||
# Supports at this fraction of the span from each end equalise the positive
|
||||
# and negative moment of a uniformly loaded beam with two overhangs. Setting
|
||||
# w(aL)^2/2 = wL^2(1-2a)^2/8 - w(aL)^2/2 gives 4a^2 + 4a - 1 = 0, so
|
||||
# a = (sqrt(2)-1)/2 and the moment coefficient works out at 0.02145 -- which
|
||||
# is where PCI's 0.0214 comes from, and what the beam solver must reproduce.
|
||||
OPTIMAL_TWO_LINE_FRACTION = (math.sqrt(2.0) - 1.0) / 2.0 # 0.20711
|
||||
|
||||
|
||||
def optimal_two_line_supports(length_ft: float) -> Tuple[float, float]:
|
||||
"""Support positions that equalise +M and -M on a uniformly loaded panel."""
|
||||
a = OPTIMAL_TWO_LINE_FRACTION * length_ft
|
||||
return a, length_ft - a
|
||||
|
||||
|
||||
def fig_832_four_point(w_psf: float, a_ft: float, b_ft: float
|
||||
) -> Tuple[float, float]:
|
||||
"""Four-point pick moments in lb-ft. a is the short side, b the long side.
|
||||
|
||||
Mx is resisted by a width of 15t or b/2, whichever is less; My by width a.
|
||||
"""
|
||||
mx = 0.0107 * w_psf * a_ft ** 2 * b_ft
|
||||
my = 0.0214 * w_psf * a_ft * b_ft ** 2
|
||||
return mx, my
|
||||
|
||||
|
||||
def fig_832_eight_point(w_psf: float, a_ft: float, b_ft: float
|
||||
) -> Tuple[float, float]:
|
||||
"""Eight-point pick moments in lb-ft. Mx is resisted by 15t or b/4."""
|
||||
mx = 0.0054 * w_psf * a_ft ** 2 * b_ft
|
||||
my = 0.0054 * w_psf * a_ft * b_ft ** 2
|
||||
return mx, my
|
||||
|
||||
|
||||
def transverse_resisting_width_ft(long_side_ft: float, thickness_in: float,
|
||||
points: int = 4) -> float:
|
||||
"""PCI: Mx is resisted by 15t, or b/2 (four-point) / b/4 (eight-point)."""
|
||||
share = long_side_ft / (2.0 if points <= 4 else 4.0)
|
||||
return min(15.0 * thickness_in / 12.0, share)
|
||||
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""What the strands are doing at each station along the piece.
|
||||
|
||||
A pretensioned strand does not carry its full force everywhere. It carries
|
||||
nothing at the end where it stops, and picks its force up over the TRANSFER
|
||||
LENGTH -- the distance the bond needs to drag it back in. That matters twice
|
||||
on a handling check:
|
||||
|
||||
* at the ends of the piece, where the lifters closest to the end sit inside
|
||||
the transfer length and the precompression that would have helped them is
|
||||
only partly there;
|
||||
|
||||
* at a blockout, which CUTS every strand it passes over. Those strands stop
|
||||
at the opening, so they lose their force approaching it and pick it up
|
||||
again on the far side. The ones that miss the opening carry on untouched.
|
||||
|
||||
The second is the one that bites, and not only through the force. The strands
|
||||
a blockout cuts are the ones over the opening; the survivors are off to one
|
||||
side of it. So the line of action of the prestress moves sideways at exactly
|
||||
the station where the section's own centroid has moved the other way, and the
|
||||
piece gets in-plane bending nobody applied. On a real panel that was 146 psi
|
||||
of tension at a corner -- a third of what the check had to spend.
|
||||
|
||||
Everything is per strand, because a blockout cuts strands and not layers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .model import Panel, Prestress, Strand
|
||||
|
||||
|
||||
class StrandState:
|
||||
"""The prestress resultant at one station."""
|
||||
|
||||
__slots__ = ('force_lb', 'area_in2', 'cy_in', 'cz_in', 'count')
|
||||
|
||||
def __init__(self, force_lb: float, area_in2: float, cy_in: float,
|
||||
cz_in: float, count: float):
|
||||
self.force_lb = force_lb
|
||||
self.area_in2 = area_in2
|
||||
self.cy_in = cy_in
|
||||
self.cz_in = cz_in
|
||||
self.count = count
|
||||
|
||||
|
||||
def effective_lb(strand: Strand, pre: Prestress) -> float:
|
||||
"""Jacking force less every loss between the bed and now."""
|
||||
return strand.initial_pull_lb * (1.0 - pre.loss_pct / 100.0)
|
||||
|
||||
|
||||
def transfer_length_in(pre: Prestress) -> float:
|
||||
"""Where a strand has fully handed its force to the concrete.
|
||||
|
||||
ACI 318 puts it at f_se * db / 3 with f_se in ksi and db in inches. f_se
|
||||
is taken from the strands themselves rather than assumed, so a design
|
||||
pulled to 70% and one pulled to 75% do not share a transfer length.
|
||||
"""
|
||||
if pre.transfer_length_in is not None:
|
||||
return max(0.0, pre.transfer_length_in)
|
||||
area = sum(s.area_in2 for s in pre.strands)
|
||||
if area <= 0.0:
|
||||
return 0.0
|
||||
force = sum(effective_lb(s, pre) for s in pre.strands)
|
||||
f_se_ksi = force / area / 1000.0
|
||||
return f_se_ksi * pre.diameter_in / 3.0
|
||||
|
||||
|
||||
def cuts_for(strand: Strand, panel: Panel) -> List[Tuple[float, float]]:
|
||||
"""The x intervals, in feet, where a blockout has severed this strand.
|
||||
|
||||
Only full-depth blockouts cut: a recess in one face leaves the strand in
|
||||
the concrete behind it. Overlapping openings are merged, otherwise a
|
||||
strand that crosses two of them would be given back a length of bond
|
||||
between them that does not exist.
|
||||
"""
|
||||
spans = []
|
||||
for b in panel.blockouts:
|
||||
if not b.full_depth:
|
||||
continue
|
||||
if b.y0 - 1e-9 <= strand.y <= b.y0 + b.width + 1e-9:
|
||||
spans.append((b.x0, b.x1))
|
||||
if not spans:
|
||||
return []
|
||||
spans.sort()
|
||||
merged = [list(spans[0])]
|
||||
for lo, hi in spans[1:]:
|
||||
if lo > merged[-1][1] + 1e-9:
|
||||
merged.append([lo, hi])
|
||||
else:
|
||||
merged[-1][1] = max(merged[-1][1], hi)
|
||||
return [(lo, hi) for lo, hi in merged]
|
||||
|
||||
|
||||
def bonded_run(strand: Strand, panel: Panel,
|
||||
x: float) -> Optional[Tuple[float, float]]:
|
||||
"""The unbroken length of strand containing x, or None if x is in a cut."""
|
||||
lo = 0.0
|
||||
for c0, c1 in cuts_for(strand, panel):
|
||||
if x < c0:
|
||||
return lo, c0
|
||||
if x <= c1:
|
||||
return None # standing in the opening
|
||||
lo = c1
|
||||
return lo, panel.length_ft
|
||||
|
||||
|
||||
def developed(strand: Strand, panel: Panel, x: float, lt_ft: float,
|
||||
ld_ft: Optional[float] = None) -> float:
|
||||
"""Fraction of this strand's force present at x, between 0 and 1.
|
||||
|
||||
Linear from each end of the run it belongs to. A run shorter than two
|
||||
ramp lengths never reaches full force anywhere in it, which is the right
|
||||
answer and not an edge case: a strip of panel between two openings
|
||||
genuinely is not fully prestressed.
|
||||
|
||||
`ld_ft` is the reference tool's (Eriksson Wall) §1.3 refinement and is
|
||||
OFF by default (None), so `state_at` -- and through it Panel Lift Check's
|
||||
cracking/compression -- keeps the physically-correct transfer-length force
|
||||
model, validated against Eriksson on a clean solid panel to ~1%. When a
|
||||
caller passes `ld_ft` (the handling STRESS path does), a run boundary that
|
||||
is an OPENING EDGE re-develops over the DEVELOPMENT length rather than the
|
||||
transfer length -- QC p.136's "strand not fully developed on both sides" of
|
||||
an opening, where the tool credits only the developed strands. A boundary
|
||||
that is the PIECE END still uses the transfer length: releasing at the bed
|
||||
end transfers f_se over `l_t`, and it is only an opening's saw-cut that has
|
||||
to rebuild bond over the longer `l_d`. So a clean panel (no openings) is
|
||||
untouched whether `ld_ft` is passed or not. See
|
||||
docs/eriksson-wall-algorithms.md sec.1.3 / sec.9.
|
||||
"""
|
||||
run = bonded_run(strand, panel, x)
|
||||
if run is None:
|
||||
return 0.0
|
||||
lo, hi = run
|
||||
if lt_ft <= 0.0:
|
||||
return 1.0
|
||||
lo_len = lt_ft if (ld_ft is None or lo <= 1e-9) else ld_ft
|
||||
hi_len = lt_ft if (ld_ft is None or hi >= panel.length_ft - 1e-9) else ld_ft
|
||||
fr_lo = 1.0 if lo_len <= 0.0 else (x - lo) / lo_len
|
||||
fr_hi = 1.0 if hi_len <= 0.0 else (hi - x) / hi_len
|
||||
return max(0.0, min(1.0, fr_lo, fr_hi))
|
||||
|
||||
|
||||
def state_at(panel: Panel, x: float,
|
||||
ld_ft: Optional[float] = None) -> Optional[StrandState]:
|
||||
"""Force, area and line of action of the prestress at station x.
|
||||
|
||||
`ld_ft` is passed straight to `developed` and is None by default, so the
|
||||
shared callers (Panel Lift Check's cracking/compression via
|
||||
`checks.stress_profile`) keep the transfer-length force model. The handling
|
||||
STRESS path (`capacity.handling_stress_at`) passes a development length so
|
||||
a strand a blockout cut re-develops over `l_d` rather than `l_t` -- the
|
||||
reference tool's §1.3 developed-strand-at-opening rule. See `developed`.
|
||||
"""
|
||||
pre = panel.prestress
|
||||
if pre is None or not pre.strands:
|
||||
return None
|
||||
lt_ft = transfer_length_in(pre) / 12.0
|
||||
force = area = my = mz = count = 0.0
|
||||
for strand in pre.strands:
|
||||
share = developed(strand, panel, x, lt_ft, ld_ft)
|
||||
if share <= 0.0:
|
||||
continue
|
||||
f = effective_lb(strand, pre) * share
|
||||
force += f
|
||||
area += strand.area_in2 * share
|
||||
count += share
|
||||
my += f * strand.y * 12.0
|
||||
mz += f * strand.z_in
|
||||
if force <= 0.0:
|
||||
return StrandState(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
return StrandState(force, area, my / force, mz / force, count)
|
||||
|
||||
|
||||
def cut_strand_count(panel: Panel) -> int:
|
||||
"""How many strands a blockout severs anywhere along the piece."""
|
||||
pre = panel.prestress
|
||||
if pre is None:
|
||||
return 0
|
||||
return sum(1 for s in pre.strands if cuts_for(s, panel))
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Section properties along the panel, with blockouts taken out.
|
||||
|
||||
A panel stripped flat bends about its weak axis: the resisting section at any
|
||||
station is the remaining width times the thickness. So every property here is
|
||||
a function of x, and a blockout is simply width that is not there.
|
||||
|
||||
S = b_eff * t^2 / 6
|
||||
|
||||
That is the whole of it, and it reproduces both worked examples in the ASPIRE
|
||||
article exactly -- 1531 in^3 solid, 1148 in^3 with five 6 in. pockets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
from . import sandwich
|
||||
from .model import Panel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sandwich
|
||||
#
|
||||
# An insulated (sandwich) body is two (or more) concrete wythes with an
|
||||
# insulation gap between them. Everything in this module used to assume ONE
|
||||
# prism of concrete `panel.thickness_in` deep, and on a sandwich that is the
|
||||
# `cli._refuse_sandwich` defect: the insulation carries next to nothing, so the
|
||||
# section (b*d, I, S) and the shear depth d_v must come from the CONCRETE
|
||||
# WYTHES with the insulation EXCLUDED, never from the full envelope.
|
||||
#
|
||||
# WHICH treatment, and why THIS one (stated because a psi that comes out of it
|
||||
# is conditional on it): the section here is the FULLY-COMPOSITE transformed
|
||||
# section of the concrete wythes about their common centroid, insulation
|
||||
# excluded. That is what Eriksson Wall's own section table prints for these
|
||||
# pieces -- hand-checked against the WPB3.0 golden data (4" + 4" insulation +
|
||||
# 3", 288 in clear): Area 2016 in2 (= both wythes, insulation out), Cy 5.79 in,
|
||||
# Izz 29955 in4, Sb 5177 / St 5745 in3 all reproduce to the reported figure.
|
||||
# Eriksson then applies a %composite factor (95% for stress/strength on the WPB
|
||||
# runs, 35% for in-place deflection); that partial-composite knock-down and the
|
||||
# strand development-at-opening layer are OUT OF SCOPE here and are flagged, not
|
||||
# faked -- see `sandwich.py`'s `composite_ratio`/`concerns`. So this section is
|
||||
# the fully-composite bound (Eriksson's ~95% sits just under it); the more
|
||||
# conservative non-composite and structural-wythe-alone bounds live in
|
||||
# `sandwich.py` and are the owner's to draw a pass/fail line against.
|
||||
#
|
||||
# NOTE the task framed Eriksson's wall runs as NON-composite (structural wythe
|
||||
# alone). The WPB extracted samples do not bear that out: the reported section
|
||||
# geometry is fully-composite (both wythes) and the %composite setting is 95%,
|
||||
# not 0. The shear residual is the tell -- it closes at -36% (= 1 - 7/11) only
|
||||
# when d_v uses the SUM of the wythe thicknesses (7 in here), which is the
|
||||
# composite concrete depth, not one wythe. So this implements the composite
|
||||
# concrete section; a structural-wythe-alone default would over-refuse the
|
||||
# stress by ~13x on Izz and re-open the "over-conservative" half of the defect.
|
||||
|
||||
|
||||
def _sandwich_layup(panel: Panel):
|
||||
"""The concrete layup to analyse as a composite section, or None.
|
||||
|
||||
None means "treat as one solid prism" -- either the body is genuinely
|
||||
solid, or nothing in the job file measured a layup (in which case
|
||||
`cli._refuse_sandwich` has already refused before any station is reached,
|
||||
so a None here is only ever a real solid). Returns the `sandwich.Layup`
|
||||
only for a body the file says is a sandwich AND whose layers are all
|
||||
positioned through the thickness (`layup.complete`) with two or more
|
||||
concrete wythes -- the exact makeup a composite section CAN be built from.
|
||||
A contiguous two-concrete body (an architectural face cast monolithically
|
||||
on a structural wythe, no insulation gap) satisfies this too and its
|
||||
composite section is simply the merged solid, which is the right answer.
|
||||
"""
|
||||
src = getattr(panel, 'source', None) or {}
|
||||
det = sandwich.detect(src)
|
||||
if det.sandwich is not True:
|
||||
return None
|
||||
layup = det.layup
|
||||
if layup is None or not layup.complete:
|
||||
return None
|
||||
if len([w for w in layup.wythes if w.thickness_in]) < 2:
|
||||
return None
|
||||
return layup
|
||||
|
||||
|
||||
class _Wythes:
|
||||
"""Through-thickness properties of the concrete, PER INCH of plan width.
|
||||
|
||||
`a_per` in2/in (= concrete depth), `i_per` in4/in, `s_per` in3/in are the
|
||||
section constants that scale linearly with the width actually present at a
|
||||
station, so an opening reduces every one of them together exactly as it
|
||||
does for a solid. `zbar_in` is the concrete centroid up from the form face,
|
||||
`c_form`/`c_far` the distances to the two extreme concrete fibres (equal,
|
||||
= t/2, for a solid), and `shear_t` the concrete depth d_v is built on
|
||||
(0.8*shear_t), which is the sum of the wythe thicknesses for a sandwich and
|
||||
the envelope for a solid.
|
||||
"""
|
||||
|
||||
__slots__ = ('a_per', 'i_per', 's_per', 'zbar_in', 'c_form', 'c_far',
|
||||
'shear_t', 'is_sandwich')
|
||||
|
||||
def __init__(self, panel: Panel):
|
||||
layup = _sandwich_layup(panel)
|
||||
if layup is None:
|
||||
t = panel.thickness_in
|
||||
self.a_per = t
|
||||
self.i_per = t ** 3 / 12.0
|
||||
self.s_per = t ** 2 / 6.0 if t else 0.0
|
||||
self.zbar_in = t / 2.0
|
||||
self.c_form = self.c_far = t / 2.0
|
||||
self.shear_t = t
|
||||
self.is_sandwich = False
|
||||
return
|
||||
wythes = [w for w in layup.wythes if w.thickness_in]
|
||||
t_conc = sum(w.thickness_in for w in wythes)
|
||||
zbar = (sum(w.thickness_in * w.mid_in for w in wythes) / t_conc
|
||||
if t_conc else 0.0)
|
||||
i_per = sum(w.thickness_in ** 3 / 12.0
|
||||
+ w.thickness_in * (w.mid_in - zbar) ** 2 for w in wythes)
|
||||
z_lo = min(w.z0_in for w in wythes)
|
||||
z_hi = max(w.z1_in for w in wythes)
|
||||
c_form = zbar - z_lo
|
||||
c_far = z_hi - zbar
|
||||
c_gov = max(c_form, c_far)
|
||||
self.a_per = t_conc
|
||||
self.i_per = i_per
|
||||
self.s_per = (i_per / c_gov) if c_gov else 0.0
|
||||
self.zbar_in = zbar
|
||||
self.c_form = c_form
|
||||
self.c_far = c_far
|
||||
self.shear_t = t_conc
|
||||
self.is_sandwich = True
|
||||
|
||||
|
||||
# How close to the panel edge an opening has to land before it is taken as
|
||||
# running out to that edge. A sixteenth of an inch: the model is drawn to
|
||||
# real dimensions and this is the noise floor, not an engineering judgement.
|
||||
#
|
||||
# It exists because the alternative is worse than it sounds. An opening that
|
||||
# stops 0.001 in short of the edge leaves a strip of concrete 0.001 in wide,
|
||||
# and that strip is then the OUTERMOST concrete in the section -- so every
|
||||
# in-plane stress gets reported at a fibre 110 in from the centroid instead of
|
||||
# 23 in, on a sliver a bar could not be cast into. On one real piece that was
|
||||
# the whole difference between 432 psi and a commercial check's 162 psi, and
|
||||
# it was the reason two of the 194 pieces in a full sweep failed.
|
||||
EDGE_SNAP_FT = (1.0 / 16.0) / 12.0
|
||||
|
||||
|
||||
def voids_at(panel: Panel, x: float) -> List[Tuple[float, float]]:
|
||||
"""Merged (y0, y1) intervals of missing width at station x, in feet.
|
||||
|
||||
An opening within EDGE_SNAP_FT of an edge is taken to reach it, so that a
|
||||
drafting-tolerance sliver never becomes the section's extreme fibre.
|
||||
"""
|
||||
intervals = []
|
||||
for b in panel.blockouts:
|
||||
if not (b.full_depth and b.spans(x)):
|
||||
continue
|
||||
lo, hi = b.y0, b.y0 + b.width
|
||||
if lo <= EDGE_SNAP_FT:
|
||||
lo = 0.0
|
||||
if hi >= panel.width_ft - EDGE_SNAP_FT:
|
||||
hi = panel.width_ft
|
||||
intervals.append((lo, hi))
|
||||
if not intervals:
|
||||
return []
|
||||
intervals.sort()
|
||||
merged = [list(intervals[0])]
|
||||
for start, end in intervals[1:]:
|
||||
if start > merged[-1][1]:
|
||||
merged.append([start, end])
|
||||
else:
|
||||
merged[-1][1] = max(merged[-1][1], end)
|
||||
return [(lo, hi) for lo, hi in merged]
|
||||
|
||||
|
||||
def strips_at(panel: Panel, x: float) -> List[Tuple[float, float]]:
|
||||
"""The concrete that IS there at station x, as (y0, y1) in feet.
|
||||
|
||||
An opening does not just make the section narrower, it makes it two
|
||||
strips of unequal size, and where those strips sit decides where the
|
||||
section's centroid is. On a panel whose only opening is off to one side
|
||||
the centroid moves several inches, and the prestress -- which has not
|
||||
moved -- becomes eccentric about it.
|
||||
"""
|
||||
out = []
|
||||
edge = 0.0
|
||||
for lo, hi in voids_at(panel, x):
|
||||
lo = max(0.0, min(lo, panel.width_ft))
|
||||
hi = max(0.0, min(hi, panel.width_ft))
|
||||
if lo > edge:
|
||||
out.append((edge, lo))
|
||||
edge = max(edge, hi)
|
||||
if edge < panel.width_ft:
|
||||
out.append((edge, panel.width_ft))
|
||||
return [(a, b) for a, b in out if b - a > 1e-9]
|
||||
|
||||
|
||||
class NetSection:
|
||||
"""Properties of the concrete remaining at one station.
|
||||
|
||||
Two bending axes, because a prestressed panel needs both:
|
||||
|
||||
z through the thickness -- the axis the piece bends about while it
|
||||
hangs, and the only one the beam model loads.
|
||||
y across the width -- nothing loads it, but a prestress that is
|
||||
off-centre across a section bends it anyway, and that stress is
|
||||
real and lands on the corners.
|
||||
"""
|
||||
|
||||
__slots__ = ('area_in2', 'cy_in', 'iy_in4', 'iz_in4', 'width_in',
|
||||
'thickness_in', 'y_min_in', 'y_max_in',
|
||||
'zbar_in', 'c_form_in', 'c_far_in', 'shear_thickness_in',
|
||||
'is_sandwich')
|
||||
|
||||
def __init__(self, panel: Panel, x: float):
|
||||
strips = strips_at(panel, x)
|
||||
# Through-thickness constants come from the concrete only: for a solid
|
||||
# `w.a_per` is the full thickness, for a sandwich it is the summed
|
||||
# wythe depth with the insulation excluded and the composite inertia.
|
||||
w = _Wythes(panel)
|
||||
t_area = w.a_per # concrete depth carrying area
|
||||
area = 0.0
|
||||
first = 0.0
|
||||
for a, b in strips:
|
||||
wid = (b - a) * 12.0
|
||||
area += wid * t_area
|
||||
first += wid * t_area * ((a + b) / 2.0 * 12.0)
|
||||
self.area_in2 = area
|
||||
self.cy_in = (first / area) if area > 0.0 else 0.0
|
||||
iy = 0.0
|
||||
for a, b in strips:
|
||||
wid = (b - a) * 12.0
|
||||
mid = (a + b) / 2.0 * 12.0
|
||||
iy += t_area * wid ** 3 / 12.0 + wid * t_area * (mid - self.cy_in) ** 2
|
||||
self.iy_in4 = iy
|
||||
self.width_in = area / t_area if t_area else 0.0
|
||||
# thickness_in stays the ENVELOPE (what the piece is drawn as); the
|
||||
# through-thickness section uses the concrete constants below.
|
||||
self.thickness_in = panel.thickness_in
|
||||
self.iz_in4 = self.width_in * w.i_per
|
||||
self.zbar_in = w.zbar_in
|
||||
self.c_form_in = w.c_form
|
||||
self.c_far_in = w.c_far
|
||||
self.shear_thickness_in = w.shear_t
|
||||
self.is_sandwich = w.is_sandwich
|
||||
# The outermost CONCRETE, which is not the outermost panel: an opening
|
||||
# that runs out to an edge takes that edge with it, and a stress
|
||||
# reported at a fibre that is not there is a stress nobody has.
|
||||
self.y_min_in = strips[0][0] * 12.0 if strips else 0.0
|
||||
self.y_max_in = strips[-1][1] * 12.0 if strips else 0.0
|
||||
|
||||
@property
|
||||
def sz_in3(self) -> float:
|
||||
"""The GOVERNING through-thickness section modulus, I_z / c_max.
|
||||
|
||||
For a solid `c_form == c_far == t/2`, so this is the familiar
|
||||
`b*t^2/6`. For a sandwich the two concrete faces sit at different
|
||||
distances from the composite centroid (Sb != St), and the smaller
|
||||
modulus -- the larger lever -- is the one that decides the peak stress,
|
||||
so it is the one returned.
|
||||
"""
|
||||
c = max(self.c_form_in, self.c_far_in)
|
||||
return (self.iz_in4 / c) if c else 0.0
|
||||
|
||||
@property
|
||||
def y_extremes_in(self) -> Tuple[float, float]:
|
||||
"""Signed distance from the centroid to the outermost concrete."""
|
||||
return self.y_min_in - self.cy_in, self.y_max_in - self.cy_in
|
||||
|
||||
|
||||
def blockout_width_at(panel: Panel, x: float) -> float:
|
||||
"""Total width, in feet, removed by full-depth blockouts at station x.
|
||||
|
||||
Overlapping blockouts are merged rather than summed. Double-counting an
|
||||
overlap would make the section look weaker than it is, which sounds
|
||||
conservative but is really just wrong, and wrong in a way that sends
|
||||
someone chasing a failure that is not there.
|
||||
"""
|
||||
intervals = [(b.y0, b.y0 + b.width)
|
||||
for b in panel.blockouts
|
||||
if b.full_depth and b.spans(x)]
|
||||
if not intervals:
|
||||
return 0.0
|
||||
intervals.sort()
|
||||
total = 0.0
|
||||
lo, hi = intervals[0]
|
||||
for start, end in intervals[1:]:
|
||||
if start > hi:
|
||||
total += hi - lo
|
||||
lo, hi = start, end
|
||||
else:
|
||||
hi = max(hi, end)
|
||||
return total + (hi - lo)
|
||||
|
||||
|
||||
def b_eff_ft(panel: Panel, x: float) -> float:
|
||||
"""Effective width at station x, never below zero."""
|
||||
return max(0.0, panel.width_ft - blockout_width_at(panel, x))
|
||||
|
||||
|
||||
def area_in2(panel: Panel, x: float) -> float:
|
||||
return b_eff_ft(panel, x) * 12.0 * _Wythes(panel).a_per
|
||||
|
||||
|
||||
def section_modulus_in3(panel: Panel, x: float) -> float:
|
||||
return b_eff_ft(panel, x) * 12.0 * _Wythes(panel).s_per
|
||||
|
||||
|
||||
def inertia_in4(panel: Panel, x: float) -> float:
|
||||
return b_eff_ft(panel, x) * 12.0 * _Wythes(panel).i_per
|
||||
|
||||
|
||||
def unit_weight_psf(panel: Panel) -> float:
|
||||
return panel.unit_weight_pcf * panel.thickness_ft
|
||||
|
||||
|
||||
def line_load_plf(panel: Panel, x: float) -> float:
|
||||
"""Unfactored self weight per foot of length at station x."""
|
||||
return unit_weight_psf(panel) * b_eff_ft(panel, x)
|
||||
|
||||
|
||||
def geometric_weight_lb(panel: Panel, stations: int = 2001) -> float:
|
||||
"""Self weight from the geometry we think we have.
|
||||
|
||||
extra_cast_ft3 is concrete outside the rectangle this rebuilds -- a
|
||||
ledge, a haunch. It belongs to no station, so it is added as the lump
|
||||
it is; the distributed load is rescaled to the stated weight anyway,
|
||||
and this is what makes that reconciliation come out honest.
|
||||
"""
|
||||
xs, dx = grid(panel.length_ft, stations)
|
||||
rectangle = sum(line_load_plf(panel, x) for x in xs) * dx
|
||||
return rectangle + panel.extra_cast_ft3 * panel.unit_weight_pcf
|
||||
|
||||
|
||||
def weight_reconciliation(panel: Panel) -> Tuple[float, float, float]:
|
||||
"""Our geometric weight against the weight the model reports.
|
||||
|
||||
A mismatch is the cheapest available signal that a blockout was missed or
|
||||
the thickness is wrong. It is reported rather than silently corrected,
|
||||
because we do not know which of the two numbers is the liar.
|
||||
|
||||
Returns (geometric_lb, stated_lb, relative_error). stated is 0.0 when the
|
||||
model did not give one.
|
||||
"""
|
||||
geometric = geometric_weight_lb(panel)
|
||||
stated = panel.weight_lb
|
||||
if not stated:
|
||||
return geometric, 0.0, 0.0
|
||||
return geometric, stated, (geometric - stated) / stated
|
||||
|
||||
|
||||
def grid(length: float, stations: int) -> Tuple[List[float], float]:
|
||||
"""Midpoint stations, so a blockout edge never lands exactly on a sample."""
|
||||
dx = length / stations
|
||||
return [(i + 0.5) * dx for i in range(stations)], dx
|
||||
|
||||
|
||||
def jump_stations(panel: Panel) -> List[float]:
|
||||
"""The x where the section really changes -- a full-depth blockout edge.
|
||||
|
||||
`discontinuities` below samples a hair either side of each of these,
|
||||
because that is what a station table wants. A MESH wants the jump itself:
|
||||
a node offset 1e-4 ft from the edge leaves a finite element 1e-4 ft long
|
||||
beside one 0.19 ft long, and a stiffness ratio of 6e9 across two adjacent
|
||||
elements is what a dense Gaussian solve loses its digits to. Both callers
|
||||
read the same edges from here so they cannot drift apart.
|
||||
"""
|
||||
edges = set()
|
||||
for b in panel.blockouts:
|
||||
if not b.full_depth:
|
||||
continue
|
||||
for x in (b.x0, b.x1):
|
||||
if 0.0 < x < panel.length_ft:
|
||||
edges.add(x)
|
||||
return sorted(edges)
|
||||
|
||||
|
||||
def discontinuities(panel: Panel) -> List[float]:
|
||||
"""Stations where the section jumps -- blockout edges, always checked.
|
||||
|
||||
Sampled a hair inside each edge on both sides: the moment is continuous
|
||||
there but S is not, and the reduced side is the one that governs.
|
||||
"""
|
||||
eps = 1e-4
|
||||
out = set()
|
||||
for x in jump_stations(panel):
|
||||
out.add(min(panel.length_ft, x + eps))
|
||||
out.add(max(0.0, x - eps))
|
||||
return sorted(out)
|
||||
@@ -0,0 +1,686 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Reactions and the moment diagram for a panel hanging from its inserts.
|
||||
|
||||
The panel is idealised as a beam spanning its long direction, supported at the
|
||||
lifter lines, carrying its own factored weight. That is not a simplification
|
||||
bolted on for convenience -- it is what PCI Figure 8.3.2 itself is. Putting
|
||||
two supports at (sqrt(2)-1)/2 of the span and solving this beam gives
|
||||
0.02145 w a b^2, which is PCI's 0.0214. test_pci_fig832 asserts exactly that.
|
||||
|
||||
Two load paths, because the engineer chooses between them and they are
|
||||
different mechanics, not different numbers:
|
||||
|
||||
rolling block equalising sheaves, so every line in a cable group carries
|
||||
the same force. Two groups, two equations, determinate --
|
||||
and independent of stiffness, so blockouts do not move the
|
||||
reactions around.
|
||||
static lift fixed-length slings. The panel is a continuous beam on rigid
|
||||
supports; the reactions follow flexural stiffness and a
|
||||
blockout near a support genuinely changes them.
|
||||
|
||||
Sign conventions, fixed once here: x runs from one end of the panel, load acts
|
||||
downward and is positive, reactions act upward and are positive, and sagging
|
||||
moment (tension on the form face) is positive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List, Optional, Sequence, Tuple
|
||||
|
||||
from . import section
|
||||
from .linalg import solve
|
||||
from .model import Lifter, Panel, Rigging, Stage
|
||||
from .pci import elastic_modulus_psi
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ layout
|
||||
|
||||
@dataclass
|
||||
class SupportLine:
|
||||
"""One line of lifters across the panel, acting as a single beam support."""
|
||||
x: float
|
||||
lifters: List[Lifter]
|
||||
group: int = 0
|
||||
reaction_lb: float = 0.0
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.lifters)
|
||||
|
||||
@property
|
||||
def per_lifter_lb(self) -> float:
|
||||
return self.reaction_lb / self.count if self.count else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rig:
|
||||
"""The pick, as inferred from where the inserts actually are."""
|
||||
lines: List[SupportLine]
|
||||
points: int
|
||||
mode: str
|
||||
grouped_at: Optional[float] = None # gap the cable groups split across
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return '{0}-point pick on {1} support line{2} ({3})'.format(
|
||||
self.points, len(self.lines), '' if len(self.lines) == 1 else 's',
|
||||
'rolling blocks' if self.mode == 'rolling' else 'static lift')
|
||||
|
||||
|
||||
def group_lifters(panel: Panel, rigging: Rigging,
|
||||
role: str = 'strip') -> Rig:
|
||||
"""Cluster inserts into support lines, then into two cable groups.
|
||||
|
||||
Lifters within group_tolerance_ft of each other along the span are one
|
||||
line -- they hang from the same point of the rigging, so the beam sees one
|
||||
support. The lines are then split into a left and a right cable group at
|
||||
the widest gap between them, which is how a two-, three- or four-point
|
||||
pick is actually rigged: one gap per side, or none.
|
||||
"""
|
||||
picked = sorted(panel.lifters_for(role), key=lambda l: l.x)
|
||||
if len(picked) < 2:
|
||||
raise ValueError(
|
||||
'need at least two {0!r} lifters to analyse a pick, found {1}'
|
||||
.format(role, len(picked)))
|
||||
|
||||
lines: List[SupportLine] = []
|
||||
for lifter in picked:
|
||||
if lines and abs(lifter.x - lines[-1].x) <= rigging.group_tolerance_ft:
|
||||
line = lines[-1]
|
||||
line.lifters.append(lifter)
|
||||
line.x = sum(l.x for l in line.lifters) / len(line.lifters)
|
||||
else:
|
||||
lines.append(SupportLine(x=lifter.x, lifters=[lifter]))
|
||||
|
||||
if len(lines) < 2:
|
||||
raise ValueError(
|
||||
'all {0} lifters fall on one line at x={1:.2f} ft; a single '
|
||||
'support line cannot be analysed as a pick'
|
||||
.format(len(picked), lines[0].x))
|
||||
|
||||
gap_at = None
|
||||
if len(lines) == 2:
|
||||
lines[0].group, lines[1].group = 0, 1
|
||||
else:
|
||||
gaps = [(lines[i + 1].x - lines[i].x, i) for i in range(len(lines) - 1)]
|
||||
_, split = max(gaps)
|
||||
gap_at = 0.5 * (lines[split].x + lines[split + 1].x)
|
||||
for i, line in enumerate(lines):
|
||||
line.group = 0 if i <= split else 1
|
||||
|
||||
return Rig(lines=lines, points=len(picked), mode=rigging.mode,
|
||||
grouped_at=gap_at)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ loading
|
||||
|
||||
@dataclass
|
||||
class LoadProfile:
|
||||
"""Factored self weight sampled on a uniform grid, plus its integrals.
|
||||
|
||||
W(x) = integral of w, and Q(x) = integral of x*w, both cumulative. Holding
|
||||
them lets the moment at any station come out of a closed form instead of a
|
||||
nested integral, which keeps the diagram O(n) and exact for a piecewise
|
||||
constant load.
|
||||
"""
|
||||
xs: List[float]
|
||||
w: List[float]
|
||||
W: List[float]
|
||||
Q: List[float]
|
||||
dx: float
|
||||
|
||||
@property
|
||||
def total_lb(self) -> float:
|
||||
return self.W[-1]
|
||||
|
||||
@property
|
||||
def centroid_ft(self) -> float:
|
||||
return self.Q[-1] / self.W[-1] if self.W[-1] else 0.0
|
||||
|
||||
|
||||
def load_profile(panel: Panel, multiplier: float,
|
||||
stations: int = 2000) -> LoadProfile:
|
||||
xs, dx = section.grid(panel.length_ft, stations)
|
||||
w = [section.line_load_plf(panel, x) * multiplier for x in xs]
|
||||
|
||||
W, Q = [], []
|
||||
running_w = running_q = 0.0
|
||||
for x, wi in zip(xs, w):
|
||||
running_w += wi * dx
|
||||
running_q += wi * dx * x
|
||||
W.append(running_w)
|
||||
Q.append(running_q)
|
||||
return LoadProfile(xs=xs, w=w, W=W, Q=Q, dx=dx)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointMoment:
|
||||
"""A concentrated external moment applied to the beam at station x.
|
||||
|
||||
ADDITIVE -- a proper FE point-moment load, not two opposed point forces a
|
||||
small distance apart (that "couple" trick is used only in the derivation
|
||||
below and in `test_a_point_moment_matches_the_hand_solution`, never at
|
||||
runtime). The whole of `moment_lbft` is assembled onto the ROTATION
|
||||
degree of freedom of the node forced exactly at `x` (see `_nodes`'
|
||||
`point_moment_xs` and `solve_static`'s force-vector assembly), so an
|
||||
indeterminate multi-support beam redistributes its reactions around it
|
||||
exactly the way it would around a real applied couple.
|
||||
|
||||
Sign convention matches the module's own sagging-positive M(x) (see the
|
||||
module docstring: "sagging moment (tension on the form face) is
|
||||
positive"), the same convention `capacity._cracking_moment` /
|
||||
`extreme_fibre_stresses` use for an eccentric axial force (`+P*e` adds
|
||||
directly to Mg): a positive `moment_lbft` behaves, for every station
|
||||
beyond x, exactly like extra sagging moment added there and held -- the
|
||||
diagram picks up a PERMANENT STEP of +moment_lbft crossing x, the same way
|
||||
a real external couple's moment about any point is independent of where
|
||||
that point is measured from.
|
||||
|
||||
Derived (limit of two opposite point loads eps apart, worked in full in
|
||||
the test) for a 2-support span of length L with the moment at x0:
|
||||
|
||||
R0 = -moment_lbft / L (the support BEHIND x0 loses reaction)
|
||||
RL = +moment_lbft / L
|
||||
M(x) = -moment_lbft * x / L for x < x0
|
||||
M(x) = moment_lbft * (1 - x / L) for x > x0
|
||||
|
||||
The FE assembly forces `force[rotation_dof] += -moment_lbft` (the
|
||||
element stiffness/load-vector convention already in this module is the
|
||||
standard Euler-Bernoulli (v up-positive, theta CCW-positive) DOF pair --
|
||||
verified against the module's own UDL consistent-load-vector comment,
|
||||
`[L/2, L^2/12, L/2, -L^2/12]` -- so a CCW-positive applied moment adds
|
||||
directly to the theta DOF's force entry, and the sagging-M(x) convention
|
||||
is the NEGATIVE of that CCW-about-the-cut sense; see the test for the
|
||||
numeric check this pins).
|
||||
"""
|
||||
x: float
|
||||
moment_lbft: float
|
||||
|
||||
|
||||
def scale_to_stated_weight(panel: Panel, profile: LoadProfile,
|
||||
multiplier: float) -> Tuple[LoadProfile, float]:
|
||||
"""Rescale the load so it integrates to the weight the model reports.
|
||||
|
||||
The geometry we scraped is a reconstruction; the model's own cast-weight
|
||||
parameter is what the piece actually weighs. Where they differ the model wins for magnitude,
|
||||
but the *shape* of w(x) still comes from the geometry, so a blockout still
|
||||
lands in the right place. The discrepancy is returned so it can be
|
||||
reported rather than buried.
|
||||
|
||||
The returned error is (geometric - stated) / stated, the same definition
|
||||
and the same sign as section.weight_reconciliation: negative means our
|
||||
geometry came out lighter than the piece really is, so something -- an
|
||||
extra blockout, a thin thickness -- is missing from what we read.
|
||||
"""
|
||||
if not panel.weight_lb:
|
||||
return profile, 0.0
|
||||
target = panel.weight_lb * multiplier
|
||||
if profile.total_lb <= 0.0:
|
||||
return profile, 0.0
|
||||
ratio = target / profile.total_lb
|
||||
error = 1.0 / ratio - 1.0
|
||||
if abs(ratio - 1.0) < 1e-9:
|
||||
return profile, 0.0
|
||||
scaled = LoadProfile(
|
||||
xs=profile.xs,
|
||||
w=[wi * ratio for wi in profile.w],
|
||||
W=[v * ratio for v in profile.W],
|
||||
Q=[v * ratio for v in profile.Q],
|
||||
dx=profile.dx)
|
||||
return scaled, error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reactions
|
||||
|
||||
def solve_rolling(rig: Rig, profile: LoadProfile) -> None:
|
||||
"""Two cable groups, equal force within each. Statics, no stiffness.
|
||||
|
||||
Resolving about x=0: the resultant of a group acts at the mean of its
|
||||
line positions, because the lines in it carry equal force by construction.
|
||||
"""
|
||||
left = [l for l in rig.lines if l.group == 0]
|
||||
right = [l for l in rig.lines if l.group == 1]
|
||||
if not left or not right:
|
||||
raise ValueError('cable grouping produced an empty group')
|
||||
|
||||
xl = sum(l.x for l in left) / len(left)
|
||||
xr = sum(l.x for l in right) / len(right)
|
||||
if abs(xr - xl) < 1e-9:
|
||||
raise ValueError('both cable groups resolve to the same position')
|
||||
|
||||
total = profile.total_lb
|
||||
moment = profile.Q[-1]
|
||||
# total = Rl + Rr ; moment = Rl*xl + Rr*xr
|
||||
rr = (moment - total * xl) / (xr - xl)
|
||||
rl = total - rr
|
||||
|
||||
for line in left:
|
||||
line.reaction_lb = rl / len(left)
|
||||
for line in right:
|
||||
line.reaction_lb = rr / len(right)
|
||||
|
||||
|
||||
def solve_static(rig: Rig, profile: LoadProfile, panel: Panel,
|
||||
fc_psi: float,
|
||||
point_moments: Optional[Sequence[PointMoment]] = None) -> None:
|
||||
"""Continuous beam on rigid supports: Euler-Bernoulli, variable EI.
|
||||
|
||||
Two supports give the same answer as statics, which test_statics checks.
|
||||
Three or more are indeterminate and this is the only way to get them
|
||||
right; a blockout beside a support really does shed load off it. The same
|
||||
is true of a `PointMoment` -- see its own docstring -- which is why it is
|
||||
assembled INTO this solve (a node forced at its x, the moment added to
|
||||
that node's rotation-DOF force entry) rather than bolted on afterward:
|
||||
only the FE solve sees how an indeterminate beam's reactions redistribute
|
||||
around it.
|
||||
|
||||
The mesh and the element loads both come off the SAME station profile the
|
||||
diagram integrates -- see `_nodes` and `_element_load`. Until 2026-09-01
|
||||
they did not: the loads were one sample of w per element and the mesh put
|
||||
1e-4 ft slivers beside 0.19 ft elements, so the reactions missed the load
|
||||
by up to 4.05% and M at the free end came back at 70% of peak M instead of
|
||||
zero. `test_statics` now asserts the closure on every fixture.
|
||||
"""
|
||||
point_moments = list(point_moments or [])
|
||||
xs_nodes = _nodes(panel, rig,
|
||||
point_moment_xs=[pm.x for pm in point_moments])
|
||||
n_nodes = len(xs_nodes)
|
||||
n_dof = 2 * n_nodes
|
||||
|
||||
e_psi = elastic_modulus_psi(fc_psi)
|
||||
stiff = [[0.0] * n_dof for _ in range(n_dof)]
|
||||
force = [0.0] * n_dof
|
||||
|
||||
for i in range(n_nodes - 1):
|
||||
x0, x1 = xs_nodes[i], xs_nodes[i + 1]
|
||||
length = x1 - x0
|
||||
if length <= 0.0:
|
||||
continue
|
||||
mid = 0.5 * (x0 + x1)
|
||||
# lb-ft^2: inertia in^4 -> ft^4 by /12^4, modulus psi -> psf by *144
|
||||
ei = e_psi * 144.0 * section.inertia_in4(panel, mid) / 20736.0
|
||||
|
||||
k = _element_stiffness(ei, length)
|
||||
f = _element_load(profile, x0, x1)
|
||||
dofs = [2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 3]
|
||||
for a in range(4):
|
||||
force[dofs[a]] += f[a]
|
||||
for b in range(4):
|
||||
stiff[dofs[a]][dofs[b]] += k[a][b]
|
||||
|
||||
for pm in point_moments:
|
||||
node = min(range(n_nodes), key=lambda j: abs(xs_nodes[j] - pm.x))
|
||||
if abs(xs_nodes[node] - pm.x) > 1e-9:
|
||||
# `_nodes` forces a node at every point-moment x the same way it
|
||||
# does for a support line; this can only fire for a moment off
|
||||
# the piece.
|
||||
raise ValueError(
|
||||
'point moment at x={0:.4f} ft is outside the piece (0 to '
|
||||
'{1:.4f} ft), so it cannot be placed'
|
||||
.format(pm.x, panel.length_ft))
|
||||
# CCW-positive rotation DOF; the module's sagging convention is the
|
||||
# negative of that sense about the cut -- see `PointMoment`.
|
||||
force[2 * node + 1] += -pm.moment_lbft
|
||||
|
||||
fixed = []
|
||||
for line in rig.lines:
|
||||
node = min(range(n_nodes), key=lambda j: abs(xs_nodes[j] - line.x))
|
||||
if abs(xs_nodes[node] - line.x) > 1e-9:
|
||||
# `_nodes` puts a node on every support line, so this can only
|
||||
# happen when the line is off the piece. Refuse rather than
|
||||
# extract the reaction at one x while `diagram` applies it at
|
||||
# another: the two would disagree, M(L) would stop closing, and
|
||||
# every number on the page would still look ordinary.
|
||||
raise ValueError(
|
||||
'support line at x={0:.4f} ft is outside the piece (0 to '
|
||||
'{1:.4f} ft), so its reaction cannot be placed'
|
||||
.format(line.x, panel.length_ft))
|
||||
fixed.append(2 * node)
|
||||
fixed_set = set(fixed)
|
||||
free = [d for d in range(n_dof) if d not in fixed_set]
|
||||
|
||||
reduced = [[stiff[r][c] for c in free] for r in free]
|
||||
rhs = [force[r] for r in free]
|
||||
solved = solve(reduced, rhs)
|
||||
|
||||
disp = [0.0] * n_dof
|
||||
for dof, value in zip(free, solved):
|
||||
disp[dof] = value
|
||||
|
||||
for line, dof in zip(rig.lines, fixed):
|
||||
internal = sum(stiff[dof][c] * disp[c] for c in range(n_dof))
|
||||
line.reaction_lb = internal - force[dof]
|
||||
|
||||
|
||||
# The shortest element the mesh is allowed to make, as a fraction of the
|
||||
# uniform step. An element's stiffness goes as 1/L^3, so two neighbours whose
|
||||
# lengths differ by a factor r hand the dense solve a matrix with a spread of
|
||||
# r^3, and Gaussian elimination gives up roughly that many digits. At a tenth
|
||||
# of the step the spread is 1e3 and the reactions close to twelve figures. The
|
||||
# 1e-4 ft nodes this used to take from `section.discontinuities` made a spread
|
||||
# of 6e9 -- which is how WP313's transportation reactions came out 4.05% over
|
||||
# the load they carry while every element load was right to five decimals.
|
||||
MIN_ELEMENT_FRACTION = 0.1
|
||||
|
||||
|
||||
def _place(nodes: List[float], x: float, floor: float) -> None:
|
||||
"""Add x to a sorted node list unless it would leave a sliver element."""
|
||||
i = bisect.bisect_left(nodes, x)
|
||||
if i > 0 and x - nodes[i - 1] <= floor:
|
||||
return
|
||||
if i < len(nodes) and nodes[i] - x <= floor:
|
||||
return
|
||||
nodes.insert(i, x)
|
||||
|
||||
|
||||
def _nodes(panel: Panel, rig: Rig, target: int = 120,
|
||||
point_moment_xs: Sequence[float] = ()) -> List[float]:
|
||||
"""Uniform nodes, with a node forced onto every support and section jump.
|
||||
|
||||
Three rules, in the order they are applied, and each of the last two is
|
||||
there because breaking it was measured:
|
||||
|
||||
* a node AT every support line, never merely near one. `solve_static`
|
||||
reads the reaction off that degree of freedom and `diagram` applies it
|
||||
at `line.x`; a node a hair away puts the reaction somewhere the
|
||||
diagram does not, and M(L) stops closing. A `PointMoment`'s x gets the
|
||||
SAME exact-placement treatment, for the same reason: `solve_static`
|
||||
assembles it onto one node's rotation DOF and a node a hair away would
|
||||
put the couple somewhere the diagram does not.
|
||||
* a node on every full-depth blockout edge, at the edge itself -- so no
|
||||
element straddles a jump and has neither section.
|
||||
* nothing else within `MIN_ELEMENT_FRACTION` of the uniform step of a
|
||||
node already placed. A blockout edge that lands on a support is the
|
||||
one dropped, because refining the mesh is optional and putting the
|
||||
reaction in the right place is not.
|
||||
"""
|
||||
span = panel.length_ft
|
||||
step = span / target
|
||||
floor = step * MIN_ELEMENT_FRACTION
|
||||
|
||||
nodes = [0.0, span]
|
||||
for x in list(line.x for line in rig.lines) + list(point_moment_xs):
|
||||
if not (0.0 <= x <= span):
|
||||
continue # solve_static refuses it by name
|
||||
i = bisect.bisect_left(nodes, x)
|
||||
if i < len(nodes) and abs(nodes[i] - x) <= 1e-12:
|
||||
continue
|
||||
if i > 0 and abs(nodes[i - 1] - x) <= 1e-12:
|
||||
continue
|
||||
nodes.insert(i, x)
|
||||
for x in section.jump_stations(panel):
|
||||
_place(nodes, x, floor)
|
||||
for i in range(1, target):
|
||||
_place(nodes, i * step, floor)
|
||||
return nodes
|
||||
|
||||
|
||||
def _element_stiffness(ei: float, length: float) -> List[List[float]]:
|
||||
l, l2, l3 = length, length ** 2, length ** 3
|
||||
c = ei / l3
|
||||
return [
|
||||
[12 * c, 6 * l * c, -12 * c, 6 * l * c],
|
||||
[6 * l * c, 4 * l2 * c, -6 * l * c, 2 * l2 * c],
|
||||
[-12 * c, -6 * l * c, 12 * c, -6 * l * c],
|
||||
[6 * l * c, 2 * l2 * c, -6 * l * c, 4 * l2 * c],
|
||||
]
|
||||
|
||||
|
||||
def _shape_integrals(length: float, t0: float, t1: float) -> List[float]:
|
||||
"""Integrals of the four Hermite shape functions over t in [t0, t1].
|
||||
|
||||
t is the natural coordinate (x - x0)/length, and the results are in x, so
|
||||
over a whole element they come out [L/2, L^2/12, L/2, -L^2/12] -- the udl
|
||||
consistent load vector, which is the check `test_statics` makes on them.
|
||||
"""
|
||||
def at(t):
|
||||
t2, t3, t4 = t * t, t ** 3, t ** 4
|
||||
return (t - t3 + 0.5 * t4,
|
||||
length * (0.5 * t2 - 2.0 * t3 / 3.0 + 0.25 * t4),
|
||||
t3 - 0.5 * t4,
|
||||
length * (0.25 * t4 - t3 / 3.0))
|
||||
hi, lo = at(t1), at(t0)
|
||||
return [length * (hi[k] - lo[k]) for k in range(4)]
|
||||
|
||||
|
||||
def _element_load(profile: LoadProfile, x0: float, x1: float) -> List[float]:
|
||||
"""Consistent nodal loads over one element, in the up-positive frame.
|
||||
|
||||
ONE load representation. The diagram integrates the station profile; so
|
||||
does this, over the same piecewise-constant cells, rather than taking a
|
||||
single sample of w at the element midpoint and multiplying by the length.
|
||||
That is the whole of the closure fix: the Hermite shape functions
|
||||
reproduce a linear field exactly, so f = -integral(w * N) preserves both
|
||||
the total load and its first moment about any point, and the reactions the
|
||||
solve returns therefore satisfy sum(R) = integral(w) and
|
||||
sum(R*x) = integral(x*w) to numerical precision -- which is exactly what
|
||||
M(L) = 0 asks of them.
|
||||
|
||||
The midpoint rule it replaces preserved neither wherever w was not
|
||||
constant over the element, and w is never constant over a panel with a
|
||||
blockout in it.
|
||||
"""
|
||||
length = x1 - x0
|
||||
if length <= 0.0:
|
||||
return [0.0, 0.0, 0.0, 0.0]
|
||||
dx = profile.dx
|
||||
last = len(profile.w) - 1
|
||||
lo = min(last, max(0, int(x0 / dx)))
|
||||
hi = min(last, max(0, int(x1 / dx)))
|
||||
out = [0.0, 0.0, 0.0, 0.0]
|
||||
for j in range(lo, hi + 1):
|
||||
w = profile.w[j]
|
||||
if w == 0.0:
|
||||
continue
|
||||
a = max(x0, j * dx)
|
||||
b = min(x1, (j + 1) * dx)
|
||||
if b <= a:
|
||||
continue
|
||||
parts = _shape_integrals(length, (a - x0) / length, (b - x0) / length)
|
||||
for k in range(4):
|
||||
out[k] -= w * parts[k]
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the diagram
|
||||
|
||||
@dataclass
|
||||
class Diagram:
|
||||
xs: List[float]
|
||||
shear_lb: List[float]
|
||||
moment_lbft: List[float]
|
||||
|
||||
def moment_at(self, x: float) -> float:
|
||||
return _interp(self.xs, self.moment_lbft, x)
|
||||
|
||||
def shear_at(self, x: float) -> float:
|
||||
return _interp(self.xs, self.shear_lb, x)
|
||||
|
||||
@property
|
||||
def max_positive(self) -> Tuple[float, float]:
|
||||
i = max(range(len(self.xs)), key=lambda j: self.moment_lbft[j])
|
||||
return self.xs[i], self.moment_lbft[i]
|
||||
|
||||
@property
|
||||
def max_negative(self) -> Tuple[float, float]:
|
||||
i = min(range(len(self.xs)), key=lambda j: self.moment_lbft[j])
|
||||
return self.xs[i], self.moment_lbft[i]
|
||||
|
||||
|
||||
def diagram(rig: Rig, profile: LoadProfile,
|
||||
extra_stations: Sequence[float] = (),
|
||||
point_moments: Optional[Sequence[PointMoment]] = None) -> Diagram:
|
||||
"""V and M from the reactions, whichever way they were found.
|
||||
|
||||
V(x) = sum(R_i for x_i <= x) - integral_0^x w
|
||||
M(x) = sum(R_i (x - x_i)) - [ x*W(x) - Q(x) ] + sum(M0_j for x_j <= x)
|
||||
|
||||
Deriving the diagram from equilibrium rather than from the element forces
|
||||
means the rolling and static paths share one routine, and M(L) coming back
|
||||
to zero is then a real check on the reactions rather than a tautology. The
|
||||
last term is `PointMoment`'s own contribution: a real external couple's
|
||||
moment about any point is independent of where that point is measured
|
||||
from, so M(x) picks up the WHOLE of `moment_lbft`, undiminished, the
|
||||
instant x passes it -- no lever arm, unlike a reaction or a distributed
|
||||
load. `rig.lines`' reactions already carry the point moments' effect on
|
||||
THEM (from `solve_static`'s assembly), so this is additive on top, not a
|
||||
substitute.
|
||||
"""
|
||||
point_moments = list(point_moments or [])
|
||||
xs = sorted(set(list(profile.xs) + [0.0] + list(extra_stations) +
|
||||
[pm.x for pm in point_moments] +
|
||||
[profile.xs[-1] + profile.dx / 2.0]))
|
||||
|
||||
# Nearly every station in xs IS one of the profile's own stations, where W
|
||||
# and Q are already known by index. Interpolating there is a binary search
|
||||
# for a number we are holding. It costs nothing on one diagram and it cost
|
||||
# nine million calls on one position search, so both lists are walked with
|
||||
# a single pointer instead: exact where they coincide, interpolated only
|
||||
# at the handful of blockout edges that fall between.
|
||||
shear, moment = [], []
|
||||
j, n = 0, len(profile.xs)
|
||||
for x in xs:
|
||||
while j < n and profile.xs[j] < x:
|
||||
j += 1
|
||||
if j < n and profile.xs[j] == x:
|
||||
w_int, q_int = profile.W[j], profile.Q[j]
|
||||
else:
|
||||
w_int = _interp_cumulative(profile.W, profile, x)
|
||||
q_int = _interp_cumulative(profile.Q, profile, x)
|
||||
v = -w_int
|
||||
m = -(x * w_int - q_int)
|
||||
for line in rig.lines:
|
||||
if line.x <= x:
|
||||
v += line.reaction_lb
|
||||
m += line.reaction_lb * (x - line.x)
|
||||
for pm in point_moments:
|
||||
if pm.x <= x + 1e-9:
|
||||
m += pm.moment_lbft
|
||||
shear.append(v)
|
||||
moment.append(m)
|
||||
return Diagram(xs=xs, shear_lb=shear, moment_lbft=moment)
|
||||
|
||||
|
||||
def _interp_cumulative(values: List[float], profile: LoadProfile,
|
||||
x: float) -> float:
|
||||
if x <= profile.xs[0]:
|
||||
return values[0] * (x / profile.xs[0]) if profile.xs[0] else 0.0
|
||||
if x >= profile.xs[-1]:
|
||||
return values[-1]
|
||||
return _interp(profile.xs, values, x)
|
||||
|
||||
|
||||
def _interp(xs: Sequence[float], ys: Sequence[float], x: float) -> float:
|
||||
if x <= xs[0]:
|
||||
return ys[0]
|
||||
if x >= xs[-1]:
|
||||
return ys[-1]
|
||||
lo, hi = 0, len(xs) - 1
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if xs[mid] <= x:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
span = xs[hi] - xs[lo]
|
||||
if span <= 0.0:
|
||||
return ys[lo]
|
||||
t = (x - xs[lo]) / span
|
||||
return ys[lo] + t * (ys[hi] - ys[lo])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ sling
|
||||
|
||||
@dataclass
|
||||
class SlingForces:
|
||||
angle_deg: float
|
||||
amplification: float
|
||||
line_force_lb: float
|
||||
horizontal_lb: float
|
||||
|
||||
|
||||
def sling_forces(reaction_lb: float, rigging: Rigging) -> SlingForces:
|
||||
"""Force in an inclined line, and the horizontal it drags into the panel.
|
||||
|
||||
LECWall states outright that it does not account for secondary moments
|
||||
from inclined lines. We cannot fix that without a second-order analysis,
|
||||
but we can at least put the horizontal component in front of the engineer
|
||||
instead of leaving it invisible.
|
||||
"""
|
||||
theta = math.radians(rigging.sling_angle_deg)
|
||||
sin_t = math.sin(theta)
|
||||
if sin_t <= 1e-6:
|
||||
raise ValueError('sling angle must be greater than zero degrees')
|
||||
amplification = 1.0 / sin_t
|
||||
line = reaction_lb * amplification
|
||||
return SlingForces(angle_deg=rigging.sling_angle_deg,
|
||||
amplification=amplification,
|
||||
line_force_lb=line,
|
||||
horizontal_lb=line * math.cos(theta))
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the analysis
|
||||
|
||||
@dataclass
|
||||
class Analysis:
|
||||
panel: Panel
|
||||
stage: Stage
|
||||
rigging: Rigging
|
||||
rig: Rig
|
||||
profile: LoadProfile
|
||||
diagram: Diagram
|
||||
weight_error: float = 0.0
|
||||
stations: List[float] = field(default_factory=list)
|
||||
|
||||
# Filled by `capacity.profile_for`, which the verdict check and the
|
||||
# station table both read so the two cannot disagree. Declared here and
|
||||
# not attached ad hoc so it is visible on the object it belongs to;
|
||||
# `capacity_stamp` is a fingerprint of the diagram it was measured on, and
|
||||
# a profile whose stamp no longer matches is recomputed rather than
|
||||
# returned. `statics` does not import `capacity` -- nothing here computes
|
||||
# these, and the annotation stays untyped for exactly that reason.
|
||||
capacity_points: Optional[List[Any]] = None
|
||||
capacity_stamp: Optional[tuple] = None
|
||||
|
||||
|
||||
|
||||
def _support_rig(supports: Sequence[float]) -> Rig:
|
||||
"""Support lines with no lifters behind them -- dunnage, not a pick."""
|
||||
lines = [SupportLine(x=float(x), lifters=[], group=0) for x in supports]
|
||||
return Rig(lines=lines, points=0, mode='static')
|
||||
|
||||
def analyse(panel: Panel, stage: Stage, rigging: Rigging,
|
||||
role: str = 'strip', reconcile_weight: bool = True) -> Analysis:
|
||||
if stage.supports is not None:
|
||||
if len(stage.supports) < 2:
|
||||
raise ValueError('a supported stage needs at least two supports')
|
||||
rig = _support_rig(stage.supports)
|
||||
else:
|
||||
rig = group_lifters(panel, rigging, role=role)
|
||||
profile = load_profile(panel, stage.multiplier)
|
||||
error = 0.0
|
||||
if reconcile_weight:
|
||||
profile, error = scale_to_stated_weight(panel, profile, stage.multiplier)
|
||||
|
||||
if stage.supports is not None:
|
||||
# Dunnage is a continuous beam on rigid supports. With two lines the
|
||||
# reactions are statically determinate; with more they follow
|
||||
# stiffness, exactly like solve_static -- which is what a trailer
|
||||
# actually is, and never an equalising rig.
|
||||
solve_static(rig, profile, panel, stage.fc_psi)
|
||||
elif rigging.mode == 'rolling':
|
||||
solve_rolling(rig, profile)
|
||||
elif rigging.mode == 'static':
|
||||
solve_static(rig, profile, panel, stage.fc_psi)
|
||||
else:
|
||||
raise ValueError(
|
||||
"rigging mode must be 'rolling' or 'static', got {0!r}"
|
||||
.format(rigging.mode))
|
||||
|
||||
jumps = section.discontinuities(panel)
|
||||
dia = diagram(rig, profile, extra_stations=jumps)
|
||||
return Analysis(panel=panel, stage=stage, rigging=rigging, rig=rig,
|
||||
profile=profile, diagram=dia, weight_error=error,
|
||||
stations=jumps)
|
||||
@@ -0,0 +1,238 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Feet-inches formatting and parsing, shared by every 前端/后端 in this repo.
|
||||
|
||||
Owner ruling, 2026-09-05: every length shown on any input page or results
|
||||
page -- in `panellift`'s CPython half and in the pyRevit/IronPython half --
|
||||
prints as `8'-4 1/2"`, rounded to the nearest 1/8", never as a decimal.
|
||||
Internal storage and every calculation keep using decimal feet or decimal
|
||||
inches exactly as before; this module is only the edge where a number
|
||||
becomes text a person reads, and the edge where text a person typed becomes
|
||||
a number again.
|
||||
|
||||
Deliberately written in the INTERSECTION of Python 2 and 3 syntax -- no
|
||||
f-strings, no type annotations, no `from __future__ import annotations` --
|
||||
because this exact file is meant to be importable from the IronPython 2.7
|
||||
side of the project too (see `WillTools.extension/lib/vibe/pa_extents.py`,
|
||||
which has its own `_feet_str` today and is expected to switch to this module
|
||||
later). Do not add anything here that only CPython 3 can parse.
|
||||
|
||||
Four functions:
|
||||
|
||||
fmt_ft_in(feet, precision=8) -> "8'-4 1/2\"" (a length, in feet)
|
||||
fmt_in(inches, precision=8) -> "6 3/8\"" (a length, in inches)
|
||||
parse_length(text, default_unit='ft') -> float feet
|
||||
parse_length_in(text, default_unit='in') -> float inches
|
||||
|
||||
`parse_length` / `parse_length_in` accept every form the owner named:
|
||||
"8'-4 1/2\"", "8'4\"", "8' 4-1/2\"", "4 1/2\"", "100\"", "-0'-6\"", and a
|
||||
bare number (read in `default_unit` -- feet unless the caller says
|
||||
otherwise). A string that matches none of those raises `ValueError` naming
|
||||
the offending text.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
try: # Python 2 (IronPython 2.7)
|
||||
_string_types = (str, unicode) # noqa: F821
|
||||
except NameError: # Python 3
|
||||
_string_types = (str,)
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
|
||||
# --------------------------------------------------------------- formatting
|
||||
|
||||
def fmt_ft_in(feet, precision=8):
|
||||
"""Decimal feet -> "8'-4 1/2\"", rounded to the nearest 1/precision inch.
|
||||
|
||||
`None` -> '-'. Negative values keep the sign in front of the whole
|
||||
string ("-0'-6\""), never inside it. A remainder that rounds up to a
|
||||
full inch carries into the inch count, and 12 inches carries into the
|
||||
foot count.
|
||||
"""
|
||||
if feet is None:
|
||||
return '-'
|
||||
sign = '-' if feet < 0 else ''
|
||||
value = abs(feet)
|
||||
whole_ft = int(value)
|
||||
# work in units of 1/precision inch so the carry logic is exact integer
|
||||
# arithmetic, never float comparison against 12.0 or 1.0.
|
||||
units_total = int(round((value - whole_ft) * 12.0 * precision))
|
||||
inch, rem = divmod(units_total, precision)
|
||||
if inch >= 12:
|
||||
whole_ft += inch // 12
|
||||
inch = inch % 12
|
||||
out = "{0}'-{1}".format(whole_ft, inch)
|
||||
if rem:
|
||||
out += ' {0}'.format(Fraction(rem, precision))
|
||||
return sign + out + '"'
|
||||
|
||||
|
||||
def fmt_in(inches, precision=8):
|
||||
"""Decimal inches -> "6 3/8\"" -- for thicknesses, depths, offsets,
|
||||
diameters and anything else that is already spoken of in inches, never
|
||||
feet, across the codebase.
|
||||
|
||||
`None` -> '-'.
|
||||
"""
|
||||
if inches is None:
|
||||
return '-'
|
||||
sign = '-' if inches < 0 else ''
|
||||
value = abs(inches)
|
||||
whole_in = int(value)
|
||||
units = int(round((value - whole_in) * precision))
|
||||
if units >= precision:
|
||||
whole_in += 1
|
||||
units = 0
|
||||
out = '{0}'.format(whole_in)
|
||||
if units:
|
||||
out += ' {0}'.format(Fraction(units, precision))
|
||||
return sign + out + '"'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ parsing
|
||||
|
||||
# A leading sign, held separately from the feet/inch markers below because a
|
||||
# hyphen INSIDE the string (the "8'-4" separator, not a minus) must never be
|
||||
# read as negation -- only a hyphen before the first digit is a sign.
|
||||
_SIGN_RE = re.compile(r'^-\s*')
|
||||
|
||||
# "8", optionally decimal, followed by a foot mark, followed by whatever is
|
||||
# left (which may be empty, or an inch remainder with its own quote mark).
|
||||
_FEET_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*'\s*(.*)$")
|
||||
|
||||
# A hyphen used as the visual separator between a whole inch and a fraction
|
||||
# ("4-1/2") rather than as a sign -- only recognised with a digit already on
|
||||
# its left, so a leading "-" (from "8'-6\"", the separator right after the
|
||||
# foot mark) is stripped as its own case, not caught here.
|
||||
_HYPHEN_FRAC_RE = re.compile(r'(\d)\s*-\s*(\d+\s*/\s*\d+)')
|
||||
|
||||
_MIXED_RE = re.compile(r'^(\d+)\s+(\d+)\s*/\s*(\d+)$')
|
||||
_FRAC_RE = re.compile(r'^(\d+)\s*/\s*(\d+)$')
|
||||
_DEC_RE = re.compile(r'^(\d+(?:\.\d+)?)$')
|
||||
|
||||
|
||||
def _safe_div(num, den, orig):
|
||||
"""num/den, or ValueError(orig) for a 0 denominator -- a typed "1/0" is
|
||||
bad input, not a licence for this module to raise ZeroDivisionError
|
||||
instead of the ValueError every other bad string raises here.
|
||||
"""
|
||||
if den == 0:
|
||||
raise ValueError(orig)
|
||||
return num / den
|
||||
|
||||
|
||||
def _normalize_quotes(s):
|
||||
s = s.replace(u'’', "'").replace(u'‘', "'")
|
||||
s = s.replace(u'”', '"').replace(u'“', '"')
|
||||
return s
|
||||
|
||||
|
||||
def _parse_inch_remainder(rest, orig, allow_empty):
|
||||
"""Parse what follows a foot mark (or the whole string, for a bare inch
|
||||
value): an optional leading '-' separator, a whole number and/or a
|
||||
fraction, an optional trailing '"'. Returns inches as a float.
|
||||
"""
|
||||
rest = rest.strip()
|
||||
if rest.endswith('"'):
|
||||
rest = rest[:-1].strip()
|
||||
if rest.startswith('-'):
|
||||
# the "8'-6" separator, not a sign -- the sign (if any) was already
|
||||
# consumed by the caller before the foot mark was even found.
|
||||
rest = rest[1:].strip()
|
||||
if rest == '':
|
||||
if allow_empty:
|
||||
return 0.0
|
||||
raise ValueError(orig)
|
||||
rest = _HYPHEN_FRAC_RE.sub(r'\1 \2', rest)
|
||||
m = _MIXED_RE.match(rest)
|
||||
if m:
|
||||
return float(m.group(1)) + _safe_div(
|
||||
float(m.group(2)), float(m.group(3)), orig)
|
||||
m = _FRAC_RE.match(rest)
|
||||
if m:
|
||||
return _safe_div(float(m.group(1)), float(m.group(2)), orig)
|
||||
m = _DEC_RE.match(rest)
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
raise ValueError(orig)
|
||||
|
||||
|
||||
def _parse_bare_number(s, orig, default_unit):
|
||||
"""No `'` and no trailing `"` anywhere -- a plain number, or a bare
|
||||
fraction / mixed number, read in `default_unit`.
|
||||
"""
|
||||
m = _DEC_RE.match(s)
|
||||
if m:
|
||||
value = float(m.group(1))
|
||||
else:
|
||||
s2 = _HYPHEN_FRAC_RE.sub(r'\1 \2', s)
|
||||
m = _MIXED_RE.match(s2)
|
||||
if m:
|
||||
value = float(m.group(1)) + _safe_div(
|
||||
float(m.group(2)), float(m.group(3)), orig)
|
||||
else:
|
||||
m = _FRAC_RE.match(s2)
|
||||
if m:
|
||||
value = _safe_div(float(m.group(1)), float(m.group(2)), orig)
|
||||
else:
|
||||
raise ValueError(orig)
|
||||
return value * 12.0 if default_unit == 'ft' else value
|
||||
|
||||
|
||||
def _parse_core_in(text, default_unit):
|
||||
"""The shared parser. Returns inches, given how a bare unmarked number
|
||||
should be read ('ft' or 'in').
|
||||
"""
|
||||
if text is None:
|
||||
raise ValueError(repr(text))
|
||||
if isinstance(text, (int, float)):
|
||||
return float(text) * 12.0 if default_unit == 'ft' else float(text)
|
||||
if not isinstance(text, _string_types):
|
||||
text = str(text)
|
||||
orig = text
|
||||
s = text.strip()
|
||||
if s == '':
|
||||
raise ValueError(orig)
|
||||
s = _normalize_quotes(s)
|
||||
|
||||
negative = False
|
||||
m_sign = _SIGN_RE.match(s)
|
||||
if m_sign:
|
||||
negative = True
|
||||
s = s[m_sign.end():].strip()
|
||||
if s == '':
|
||||
raise ValueError(orig)
|
||||
|
||||
m_feet = _FEET_RE.match(s)
|
||||
if m_feet:
|
||||
feet = float(m_feet.group(1))
|
||||
rest = m_feet.group(2)
|
||||
total_in = feet * 12.0 + _parse_inch_remainder(rest, orig, True)
|
||||
elif s.endswith('"'):
|
||||
total_in = _parse_inch_remainder(s, orig, False)
|
||||
else:
|
||||
total_in = _parse_bare_number(s, orig, default_unit)
|
||||
|
||||
return -total_in if negative else total_in
|
||||
|
||||
|
||||
def parse_length_in(text, default_unit='in'):
|
||||
"""Parse any of the accepted forms and return inches.
|
||||
|
||||
A bare number with no `'` or `"` anywhere is read in `default_unit`
|
||||
('in' by default here, since most callers of the inches-flavoured parser
|
||||
already speak inches -- pass default_unit='ft' to read a bare number as
|
||||
feet instead). Raises ValueError(text) when nothing matches.
|
||||
"""
|
||||
return _parse_core_in(text, default_unit)
|
||||
|
||||
|
||||
def parse_length(text, default_unit='ft'):
|
||||
"""Parse any of the accepted forms and return decimal feet.
|
||||
|
||||
A bare number with no `'` or `"` anywhere is read in `default_unit`
|
||||
('ft' by default, per the owner's ruling). Raises ValueError(text) when
|
||||
nothing matches.
|
||||
"""
|
||||
return _parse_core_in(text, default_unit) / 12.0
|
||||
@@ -0,0 +1,541 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ASCE 7 Components-and-Cladding (Chapter 30) WALL wind-pressure engine.
|
||||
|
||||
This computes the design wind pressure that Panel Analysis's In-Place model
|
||||
currently takes as a *user-typed* input (the W1..W4 out-of-plane line loads on a
|
||||
precast wall panel come from these psf pressures times the panel width). It is a
|
||||
standalone CPython module -- no Revit, no licence, host-testable -- so the
|
||||
engineering can be checked the same way `capacity.py` and `prestress.py` are.
|
||||
|
||||
WHAT IT IS. The Components & Cladding (C&C) path of ASCE 7, Chapter 30, Part 1
|
||||
(low-rise / h <= 60 ft). Given the wind inputs the owner's engineer specifies --
|
||||
design wind speed Vult, Risk Category, Exposure, Enclosure classification, ASCE
|
||||
code version, mean roof height h, and the wall's effective wind area -- it walks
|
||||
the velocity-pressure chain of Chapter 26 and the wall external-pressure figure
|
||||
of Chapter 30 to the Zone 4 and Zone 5 design pressures, positive and negative.
|
||||
This is the governing wind for a wall panel's out-of-plane bending.
|
||||
|
||||
EVERY NUMBER IS GROUNDED, NOTHING IS INVENTED. The tables and equations below
|
||||
are transcribed from the mined ASCE notes under
|
||||
`knowledge/library/notes/standards/` -- ASCE7-16-ch26.md / ASCE7-22-ch26.md for
|
||||
the qz chain and GCpi, ASCE7-16-ch30.md / ASCE7-22-ch30.md (+ the figure-read
|
||||
sibling ASCE7-22-ch30-figGCp.md) for the wall GCp. Each value carries its cite.
|
||||
The wall GCp are FIGURE-READ (image content, not text) and carry +/-0.05; every
|
||||
pressure that rests on one is stamped `figure_read=True`. If a needed value is
|
||||
not in the notes for the selected version, the engine returns a loud NOT_JUDGED
|
||||
naming the figure/page rather than fabricating.
|
||||
|
||||
THE VERSION DIFFERENCE THAT MATTERS (Kd placement). The wind directionality
|
||||
factor Kd moved between editions:
|
||||
|
||||
7-16 Eq. (26.10-1): qz = 0.00256 * Kz * Kzt * Kd * Ke * V^2 (Kd IN qz)
|
||||
Eq. (30.3-1): p = qh * [(GCp) - (GCpi)] (no Kd here)
|
||||
|
||||
7-22 Eq. (26.10-1): qz = 0.00256 * Kz * Kzt * Ke * V^2 (no Kd in qz)
|
||||
Eq. (30.3-1): p = qh * Kd * [(GCp) - (GCpi)] (Kd HERE)
|
||||
|
||||
The *final* pressure is algebraically identical either way (Kd multiplies once),
|
||||
but the intermediate qh differs by the factor Kd (0.85 for C&C). The engine
|
||||
keys off ASCE_CODE_VERSION and never cross-applies: 7-16 puts Kd in qh; 7-22
|
||||
keeps it out of qh and applies it in the pressure equation. A caller that reads
|
||||
the reported qh gets the value ASCE prints for that edition, not a blend.
|
||||
|
||||
SCOPE -- WHY h <= 60 ONLY. The brief scopes this to Chapter 30 Part 1 (the
|
||||
low-rise wall figure, Fig. 30.3-1, Eq. 30.3-1), which is the precast wall-panel
|
||||
case and the one the Eriksson samples exercise. For h > 60 ft the C&C wall path
|
||||
switches to the Directional Procedure (7-16 Eq. 30.5-1 Fig. 30.5-1; 7-22
|
||||
Eq. 30.4-1 Fig. 30.4-1), where the positive (windward) pressure uses qz at the
|
||||
height z of the point on the wall -- q != qh -- and that needs a windward-z
|
||||
evaluation policy this module does not own. So h > 60 returns NOT_JUDGED naming
|
||||
that figure, rather than silently reusing the low-rise coefficients.
|
||||
"""
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 0. Constants and small value objects
|
||||
# ============================================================================
|
||||
|
||||
#: The two editions this engine knows. Anything else -> NOT_JUDGED.
|
||||
CODE_VERSIONS = ("7-16", "7-22")
|
||||
|
||||
#: The C&C chapter. The engine only walks Chapter 30; a caller naming any other
|
||||
#: chapter is out of scope (Chapter 27/28/29 are MWFRS/other-structure paths).
|
||||
CC_CHAPTER = 30
|
||||
|
||||
#: Velocity-pressure constant, US customary (V in mph, q in psf).
|
||||
#: 7-16 Eq. (26.10-1) / 7-22 Eq. (26.10-1).
|
||||
Q_CONST_US = 0.00256
|
||||
|
||||
#: Wind directionality factor for Components and Cladding of buildings.
|
||||
#: Table 26.6-1 (7-16 p.266 / 7-22 p.274): "Components and cladding" = 0.85.
|
||||
KD_CC = 0.85
|
||||
|
||||
#: Figure-read uncertainty band carried on every wall GCp (image content, not
|
||||
#: text). ASCE7-16-ch30.md Fig. 30.3-1 note / ASCE7-22-ch30-figGCp.md: +/-0.05.
|
||||
GCP_PM = 0.05
|
||||
|
||||
#: Chapter 30 minimum net design pressure: 16 psf in either direction.
|
||||
#: 7-16 Sec. 30.2.2 (p.333) / 7-22 Sec. 30.2.2 (p.315).
|
||||
MIN_NET_PRESSURE_PSF = 16.0
|
||||
|
||||
|
||||
class WindNotJudged(Exception):
|
||||
"""Raised for a hard input error a caller must fix (unknown version, etc.).
|
||||
|
||||
Distinct from the *scoped-out* NOT_JUDGED that :func:`compute` returns on a
|
||||
``WindResult`` (e.g. h > 60): that is a legitimate answer of "outside this
|
||||
module's low-rise path", carried as data so a form can show the reason.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cited:
|
||||
"""A value with the ASCE clause it came from."""
|
||||
value: float
|
||||
cite: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZonePressure:
|
||||
"""One wall zone's positive and negative design pressures (psf).
|
||||
|
||||
``positive`` acts toward the wall (pressure), ``negative`` away (suction);
|
||||
both are the C&C design values for out-of-plane bending. Each pressure is
|
||||
reported with the GCp that produced it, and ``figure_read`` marks that the
|
||||
GCp is figure-read (image content) so the pressure inherits +/-0.05 of GCp
|
||||
uncertainty -- reported as ``uncertainty_psf``.
|
||||
"""
|
||||
zone: int
|
||||
positive_psf: float
|
||||
negative_psf: float
|
||||
gcp_positive: float
|
||||
gcp_negative: float
|
||||
figure_read: bool = True
|
||||
gcp_pm: float = GCP_PM
|
||||
#: +/- band on each pressure from the +/-0.05 GCp read, = q_eff * gcp_pm.
|
||||
uncertainty_psf: float = 0.0
|
||||
#: True where |p| < 16 psf, so Sec. 30.2.2's 16-psf floor governs design.
|
||||
min_governs_positive: bool = False
|
||||
min_governs_negative: bool = False
|
||||
basis: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WindResult:
|
||||
"""The full output: the qz chain intermediates and the zone pressures.
|
||||
|
||||
``not_judged`` is None on success; a sentence (naming the missing/mismatched
|
||||
page) when the request falls outside the low-rise path. When set, ``zones``
|
||||
is empty and the chain fields may be partially filled.
|
||||
"""
|
||||
code_version: str
|
||||
not_judged: Optional[str] = None
|
||||
|
||||
# -- inputs echoed (provenance) --
|
||||
v_ult_mph: float = 0.0
|
||||
risk_category: str = ""
|
||||
exposure: str = ""
|
||||
enclosure: str = ""
|
||||
mean_roof_height_ft: float = 0.0
|
||||
effective_wind_area_ft2: float = 0.0
|
||||
|
||||
# -- velocity-pressure chain, each cited --
|
||||
kh: Optional[Cited] = None
|
||||
kzt: Optional[Cited] = None
|
||||
ke: Optional[Cited] = None
|
||||
kd: Optional[Cited] = None
|
||||
gcpi: Optional[Cited] = None
|
||||
qh_psf: Optional[Cited] = None
|
||||
#: The equation string actually used for qh, keyed to the edition.
|
||||
qh_equation: str = ""
|
||||
#: Kd's placement for this edition: 'in_qz' (7-16) or 'in_pressure' (7-22).
|
||||
kd_placement: str = ""
|
||||
|
||||
# -- results --
|
||||
zones: List[ZonePressure] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 1. Chapter 26 -- the velocity-pressure chain
|
||||
# ============================================================================
|
||||
|
||||
# --- Table 26.10-1, Velocity Pressure Exposure Coefficients Kh/Kz -------------
|
||||
# Rows are (height_ft, Kz_B, Kz_C, Kz_D). The 0-15 ft band is keyed at 15.
|
||||
# The Exposure B column was REVISED between editions (7-22 lowered it at and
|
||||
# above 40 ft); C and D are identical. This is a real version difference beyond
|
||||
# Kd placement, so both tables are carried verbatim and the engine picks by
|
||||
# edition. Values transcribed from:
|
||||
# 7-16: ASCE7-16-ch26.md Table 26.10-1 (p.268)
|
||||
# 7-22: ASCE7-22-ch26.md Table 26.10-1 (p.277)
|
||||
_KZ_TABLE_716 = [
|
||||
(15, 0.57, 0.85, 1.03), (20, 0.62, 0.90, 1.08), (25, 0.66, 0.94, 1.12),
|
||||
(30, 0.70, 0.98, 1.16), (40, 0.76, 1.04, 1.22), (50, 0.81, 1.09, 1.27),
|
||||
(60, 0.85, 1.13, 1.31), (70, 0.89, 1.17, 1.34), (80, 0.93, 1.21, 1.38),
|
||||
(90, 0.96, 1.24, 1.40), (100, 0.99, 1.26, 1.43), (120, 1.04, 1.31, 1.48),
|
||||
(140, 1.09, 1.36, 1.52), (160, 1.13, 1.39, 1.55), (180, 1.17, 1.43, 1.58),
|
||||
(200, 1.20, 1.46, 1.61), (250, 1.28, 1.53, 1.68), (300, 1.35, 1.59, 1.73),
|
||||
(350, 1.41, 1.64, 1.78), (400, 1.47, 1.69, 1.82), (450, 1.52, 1.73, 1.86),
|
||||
(500, 1.56, 1.77, 1.89),
|
||||
]
|
||||
_KZ_TABLE_722 = [
|
||||
(15, 0.57, 0.85, 1.03), (20, 0.62, 0.90, 1.08), (25, 0.66, 0.94, 1.12),
|
||||
(30, 0.70, 0.98, 1.16), (40, 0.74, 1.04, 1.22), (50, 0.79, 1.09, 1.27),
|
||||
(60, 0.83, 1.13, 1.31), (70, 0.86, 1.17, 1.34), (80, 0.90, 1.21, 1.38),
|
||||
(90, 0.92, 1.24, 1.40), (100, 0.95, 1.26, 1.43), (120, 1.00, 1.31, 1.48),
|
||||
(140, 1.04, 1.34, 1.52), (160, 1.08, 1.39, 1.55), (180, 1.11, 1.41, 1.58),
|
||||
(200, 1.14, 1.44, 1.61), (250, 1.21, 1.51, 1.68), (300, 1.27, 1.57, 1.73),
|
||||
(350, 1.33, 1.62, 1.78), (400, 1.38, 1.66, 1.82), (450, 1.42, 1.70, 1.86),
|
||||
(500, 1.46, 1.74, 1.89),
|
||||
]
|
||||
_KZ_TABLE = {"7-16": _KZ_TABLE_716, "7-22": _KZ_TABLE_722}
|
||||
_KZ_COL = {"B": 1, "C": 2, "D": 3}
|
||||
_KZ_TABLE_CITE = {
|
||||
"7-16": "ASCE 7-16 Table 26.10-1 (p.268)",
|
||||
"7-22": "ASCE 7-22 Table 26.10-1 (p.277)",
|
||||
}
|
||||
|
||||
|
||||
def velocity_pressure_coefficient(height_ft, exposure, code_version):
|
||||
"""Kh (= Kz at mean roof height h) from Table 26.10-1, linearly interpolated.
|
||||
|
||||
Table 26.10-1 Note 3 permits linear interpolation between heights; below the
|
||||
0-15 ft band the 15 ft value is used (the table's first row). Above 500 ft
|
||||
is out of table and out of scope here.
|
||||
"""
|
||||
col = _KZ_COL[exposure]
|
||||
rows = _KZ_TABLE[code_version]
|
||||
if height_ft <= rows[0][0]:
|
||||
return rows[0][col]
|
||||
if height_ft >= rows[-1][0]:
|
||||
return rows[-1][col]
|
||||
for i in range(len(rows) - 1):
|
||||
h0, h1 = rows[i][0], rows[i + 1][0]
|
||||
if h0 <= height_ft <= h1:
|
||||
v0, v1 = rows[i][col], rows[i + 1][col]
|
||||
frac = (height_ft - h0) / (h1 - h0)
|
||||
return v0 + (v1 - v0) * frac
|
||||
return rows[-1][col] # unreachable
|
||||
|
||||
|
||||
def ground_elevation_factor(elevation_ft):
|
||||
"""Ke from Sec. 26.9 / Table 26.9-1 (same both editions).
|
||||
|
||||
``None`` -> 1.00, which Table 26.9-1 Note 1/3 permits ("conservative
|
||||
approximation Ke = 1.00 is permitted in all cases"). A supplied elevation
|
||||
uses the closed form Ke = exp(-0.0000362 * z_ft) (Table 26.9-1 Note 2).
|
||||
Cite: ASCE 7-16 Table 26.9-1 (p.268) / ASCE 7-22 Table 26.9-1 (p.275).
|
||||
"""
|
||||
if elevation_ft is None:
|
||||
return 1.0
|
||||
return math.exp(-0.0000362 * elevation_ft)
|
||||
|
||||
|
||||
# --- Table 26.13-1, Internal Pressure Coefficient (GCpi) ----------------------
|
||||
# Keyed by enclosure classification; the value stored is the MAGNITUDE (used as
|
||||
# +/- in the two load cases of Table 26.13-1 Note 3).
|
||||
# 7-16: ASCE7-16-ch26.md Table 26.13-1 (p.271)
|
||||
# 7-22: ASCE7-22-ch26.md Table 26.13-1 (p.280)
|
||||
_GCPI = {
|
||||
"enclosed": 0.18,
|
||||
"partially_enclosed": 0.55,
|
||||
"partially_open": 0.18,
|
||||
"open": 0.00,
|
||||
}
|
||||
_GCPI_CITE = {
|
||||
"7-16": "ASCE 7-16 Table 26.13-1 (p.271)",
|
||||
"7-22": "ASCE 7-22 Table 26.13-1 (p.280)",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. Chapter 30 -- the wall external-pressure figure (Part 1, h <= 60)
|
||||
# ============================================================================
|
||||
|
||||
# --- Fig. 30.3-1, wall (GCp), h <= 60 ft --------------------------------------
|
||||
# FIGURE-READ values (image content; each carries +/-0.05). The figure is the
|
||||
# same across editions; both cites are recorded. Each curve is given by its two
|
||||
# printed plateau anchors -- the small-area value (A <= 10 ft^2) and the
|
||||
# large-area value (A >= 500 ft^2) -- and is a straight log-linear line between
|
||||
# them. Interpolating on log10(A) between these anchors reproduces the notes'
|
||||
# own derived intermediate table (checked: A=20/50/100/200 land on the note).
|
||||
# 7-16: ASCE7-16-ch30.md Fig. 30.3-1 (p.335)
|
||||
# 7-22: ASCE7-22-ch30-figGCp.md Sec. 1 / Fig. 30.3-1 (p.317)
|
||||
#
|
||||
# (small_area_anchor, large_area_anchor):
|
||||
_WALL_GCP_HLE60 = {
|
||||
"zone4_neg": (-1.1, -0.8), # interior wall, suction
|
||||
"zone5_neg": (-1.4, -0.8), # edge strips (width a), suction
|
||||
"pos": (+1.0, +0.7), # single combined Zones 4 & 5 positive curve
|
||||
}
|
||||
_WALL_GCP_CITE = {
|
||||
"7-16": "ASCE 7-16 Fig. 30.3-1, walls h<=60 ft (p.335); figure-read +/-0.05",
|
||||
"7-22": "ASCE 7-22 Fig. 30.3-1, walls h<=60 ft (p.317, ASCE7-22-ch30-figGCp.md); figure-read +/-0.05",
|
||||
}
|
||||
|
||||
_A_SMALL = 10.0 # small-area plateau ends here (log axis knee)
|
||||
_A_LARGE = 500.0 # large-area plateau reached here, flat to 1000
|
||||
|
||||
|
||||
def wall_gcp(area_ft2, curve):
|
||||
"""Figure-read (GCp) for a wall curve at an effective wind area, log-linear.
|
||||
|
||||
``curve`` is one of 'zone4_neg', 'zone5_neg', 'pos'. Below A=10 ft^2 the
|
||||
small-area plateau holds; above A=500 ft^2 the large-area plateau holds;
|
||||
between, a straight line on log10(A) between the two anchors (Fig. 30.3-1).
|
||||
"""
|
||||
g_small, g_large = _WALL_GCP_HLE60[curve]
|
||||
a = max(area_ft2, 1.0)
|
||||
if a <= _A_SMALL:
|
||||
return g_small
|
||||
if a >= _A_LARGE:
|
||||
return g_large
|
||||
frac = (math.log10(a) - math.log10(_A_SMALL)) / (
|
||||
math.log10(_A_LARGE) - math.log10(_A_SMALL))
|
||||
return g_small + (g_large - g_small) * frac
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. The engine
|
||||
# ============================================================================
|
||||
|
||||
def _normalize_enclosure(enclosure):
|
||||
"""Accept a few spellings; return the canonical key or None."""
|
||||
if enclosure is None:
|
||||
return None
|
||||
key = enclosure.strip().lower().replace("-", "_").replace(" ", "_")
|
||||
aliases = {
|
||||
"enclosed": "enclosed",
|
||||
"partially_enclosed": "partially_enclosed",
|
||||
"partial_enclosed": "partially_enclosed",
|
||||
"partially_open": "partially_open",
|
||||
"partial_open": "partially_open",
|
||||
"open": "open",
|
||||
}
|
||||
return aliases.get(key)
|
||||
|
||||
|
||||
def compute(
|
||||
v_ult_mph,
|
||||
risk_category,
|
||||
exposure,
|
||||
enclosure,
|
||||
code_version,
|
||||
mean_roof_height_ft,
|
||||
effective_wind_area_ft2,
|
||||
cc_chapter=CC_CHAPTER,
|
||||
kzt=1.0,
|
||||
ground_elevation_ft=None,
|
||||
ke=None,
|
||||
roof_angle_deg=None,
|
||||
):
|
||||
"""Compute the C&C wall design pressures (Zone 4 & 5, positive & negative).
|
||||
|
||||
Parameters mirror what the owner's engineer specifies (see module docstring).
|
||||
``kzt`` defaults to 1.0 (Sec. 26.8.2: Kzt = 1.0 where the speed-up
|
||||
conditions are not all met). ``ke`` may be given directly, else it is taken
|
||||
from ``ground_elevation_ft`` (None -> 1.0). ``roof_angle_deg`` is optional:
|
||||
when <= 10 deg, Fig. 30.3-1 Note 5 reduces the wall (GCp) by 10%; when None
|
||||
the reduction is NOT applied and a note says so (roof angle unknown).
|
||||
|
||||
Returns a :class:`WindResult`. On an out-of-scope request (h > 60, unknown
|
||||
version/exposure/enclosure, wrong chapter, non-positive area) it returns a
|
||||
result whose ``not_judged`` is a sentence naming the reason/figure, rather
|
||||
than guessing.
|
||||
"""
|
||||
res = WindResult(
|
||||
code_version=str(code_version),
|
||||
v_ult_mph=v_ult_mph,
|
||||
risk_category=str(risk_category),
|
||||
exposure=str(exposure).strip().upper() if exposure else "",
|
||||
enclosure=str(enclosure),
|
||||
mean_roof_height_ft=mean_roof_height_ft,
|
||||
effective_wind_area_ft2=effective_wind_area_ft2,
|
||||
)
|
||||
|
||||
# ---- gate the inputs; every failure is a loud, named NOT_JUDGED ----
|
||||
if code_version not in CODE_VERSIONS:
|
||||
res.not_judged = (
|
||||
"NOT JUDGED: ASCE_CODE_VERSION {0!r} not in {1}. The qz-chain "
|
||||
"arrangement (Kd placement) and the GCpi/GCp source are selected by "
|
||||
"edition; this engine only carries 7-16 and 7-22.".format(
|
||||
code_version, CODE_VERSIONS))
|
||||
return res
|
||||
|
||||
if int(cc_chapter) != CC_CHAPTER:
|
||||
res.not_judged = (
|
||||
"NOT JUDGED: C&C chapter {0} is out of scope. This engine walks "
|
||||
"Chapter 30 (Components & Cladding) only; Chapters 27/28/29 are "
|
||||
"MWFRS / other-structure paths.".format(cc_chapter))
|
||||
return res
|
||||
|
||||
exp = res.exposure
|
||||
if exp not in ("B", "C", "D"):
|
||||
res.not_judged = (
|
||||
"NOT JUDGED: Exposure {0!r} not in (B, C, D). Table 26.10-1 tabulates "
|
||||
"Kh only for these categories.".format(exposure))
|
||||
return res
|
||||
|
||||
encl = _normalize_enclosure(enclosure)
|
||||
if encl is None:
|
||||
res.not_judged = (
|
||||
"NOT JUDGED: enclosure {0!r} not recognized. Table 26.13-1 defines "
|
||||
"enclosed / partially_enclosed / partially_open / open.".format(
|
||||
enclosure))
|
||||
return res
|
||||
|
||||
if effective_wind_area_ft2 is None or effective_wind_area_ft2 <= 0:
|
||||
res.not_judged = (
|
||||
"NOT JUDGED: effective wind area must be > 0 ft^2 (got {0!r}).".format(
|
||||
effective_wind_area_ft2))
|
||||
return res
|
||||
|
||||
if mean_roof_height_ft is None or mean_roof_height_ft <= 0:
|
||||
res.not_judged = (
|
||||
"NOT JUDGED: mean roof height must be > 0 ft (got {0!r}).".format(
|
||||
mean_roof_height_ft))
|
||||
return res
|
||||
|
||||
if mean_roof_height_ft > 60.0:
|
||||
fig = ("Fig. 30.5-1 (ASCE 7-16 p.363)" if code_version == "7-16"
|
||||
else "Fig. 30.4-1 (ASCE 7-22 p.334)")
|
||||
res.not_judged = (
|
||||
"NOT JUDGED: mean roof height {0:.1f} ft > 60 ft is outside this "
|
||||
"engine's Chapter 30 Part 1 (low-rise) scope. The C&C wall path for "
|
||||
"h > 60 ft is the Directional Procedure ({1}, "
|
||||
"{2}), where the positive (windward) pressure uses qz at the point's "
|
||||
"height z (q != qh) and needs a windward-z evaluation policy this "
|
||||
"module does not own. The wall GCp for that figure ARE in the notes; "
|
||||
"the equation arrangement is not implemented here.".format(
|
||||
mean_roof_height_ft,
|
||||
"Eq. 30.5-1" if code_version == "7-16" else "Eq. 30.4-1",
|
||||
fig))
|
||||
return res
|
||||
|
||||
# ---- the velocity-pressure chain ----
|
||||
kh = velocity_pressure_coefficient(mean_roof_height_ft, exp, code_version)
|
||||
ke_val = ke if ke is not None else ground_elevation_factor(ground_elevation_ft)
|
||||
gcpi_mag = _GCPI[encl]
|
||||
|
||||
res.kh = Cited(kh, _KZ_TABLE_CITE[code_version])
|
||||
res.kzt = Cited(kzt, "ASCE 7 Sec. 26.8.2 (default 1.0 where speed-up "
|
||||
"conditions not all met)")
|
||||
res.ke = Cited(ke_val, "ASCE 7-16 Table 26.9-1 (p.268)" if code_version == "7-16"
|
||||
else "ASCE 7-22 Table 26.9-1 (p.275)")
|
||||
res.kd = Cited(KD_CC, "ASCE 7-16 Table 26.6-1 (p.266)" if code_version == "7-16"
|
||||
else "ASCE 7-22 Table 26.6-1 (p.274)")
|
||||
res.gcpi = Cited(gcpi_mag, _GCPI_CITE[code_version])
|
||||
|
||||
v2 = float(v_ult_mph) ** 2
|
||||
|
||||
# THE version fork: Kd placement.
|
||||
if code_version == "7-16":
|
||||
# Eq. (26.10-1): Kd INSIDE qz.
|
||||
qh = Q_CONST_US * kh * kzt * KD_CC * ke_val * v2
|
||||
res.qh_equation = ("qh = 0.00256 * Kh * Kzt * Kd * Ke * V^2 "
|
||||
"[ASCE 7-16 Eq. 26.10-1, p.268]")
|
||||
res.kd_placement = "in_qz"
|
||||
# Eq. (30.3-1): p = qh * [(GCp) - (GCpi)] (Kd already in qh)
|
||||
q_pressure = qh
|
||||
pressure_equation = ("p = qh * [(GCp) - (GCpi)] "
|
||||
"[ASCE 7-16 Eq. 30.3-1, p.334]")
|
||||
else: # "7-22"
|
||||
# Eq. (26.10-1): Kd NOT in qz.
|
||||
qh = Q_CONST_US * kh * kzt * ke_val * v2
|
||||
res.qh_equation = ("qh = 0.00256 * Kh * Kzt * Ke * V^2 "
|
||||
"[ASCE 7-22 Eq. 26.10-1, p.277]")
|
||||
res.kd_placement = "in_pressure"
|
||||
# Eq. (30.3-1): p = qh * Kd * [(GCp) - (GCpi)] (Kd applied here)
|
||||
q_pressure = qh * KD_CC
|
||||
pressure_equation = ("p = qh * Kd * [(GCp) - (GCpi)] "
|
||||
"[ASCE 7-22 Eq. 30.3-1, p.316]")
|
||||
|
||||
res.qh_psf = Cited(qh, res.qh_equation)
|
||||
res.notes.append(pressure_equation)
|
||||
# `q_pressure` is the effective multiplier on [(GCp)-(GCpi)] and is identical
|
||||
# across the two editions (Kd multiplies exactly once either way).
|
||||
|
||||
# ---- Fig. 30.3-1 Note 5: 10% wall-GCp reduction for theta <= 10 deg ----
|
||||
gcp_factor = 1.0
|
||||
if roof_angle_deg is not None and roof_angle_deg <= 10.0:
|
||||
gcp_factor = 0.90
|
||||
res.notes.append(
|
||||
"Fig. 30.3-1 Note 5 applied: wall (GCp) reduced 10% for roof angle "
|
||||
"theta = {0:.1f} deg <= 10 deg.".format(roof_angle_deg))
|
||||
elif roof_angle_deg is None:
|
||||
res.notes.append(
|
||||
"Fig. 30.3-1 Note 5 (10% wall-GCp reduction for theta <= 10 deg) NOT "
|
||||
"applied: roof angle not supplied. Supply roof_angle_deg to apply it.")
|
||||
|
||||
# ---- the zone pressures ----
|
||||
a = effective_wind_area_ft2
|
||||
gcp_pos = wall_gcp(a, "pos") * gcp_factor
|
||||
for zone, neg_curve in ((4, "zone4_neg"), (5, "zone5_neg")):
|
||||
gcp_neg = wall_gcp(a, neg_curve) * gcp_factor
|
||||
# Max positive: +GCp with negative internal (-GCpi).
|
||||
p_pos = q_pressure * (gcp_pos - (-gcpi_mag))
|
||||
# Max negative (suction): -GCp with positive internal (+GCpi).
|
||||
p_neg = q_pressure * (gcp_neg - (+gcpi_mag))
|
||||
unc = abs(q_pressure) * GCP_PM # +/-0.05 on GCp -> +/- this many psf
|
||||
zp = ZonePressure(
|
||||
zone=zone,
|
||||
positive_psf=p_pos,
|
||||
negative_psf=p_neg,
|
||||
gcp_positive=gcp_pos,
|
||||
gcp_negative=gcp_neg,
|
||||
figure_read=True,
|
||||
gcp_pm=GCP_PM,
|
||||
uncertainty_psf=unc,
|
||||
min_governs_positive=abs(p_pos) < MIN_NET_PRESSURE_PSF,
|
||||
min_governs_negative=abs(p_neg) < MIN_NET_PRESSURE_PSF,
|
||||
basis=_WALL_GCP_CITE[code_version],
|
||||
)
|
||||
res.zones.append(zp)
|
||||
|
||||
if any(z.min_governs_positive or z.min_governs_negative for z in res.zones):
|
||||
res.notes.append(
|
||||
"Sec. 30.2.2 minimum net C&C pressure 16 psf governs where a "
|
||||
"computed |p| < 16 psf (flagged per zone).")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 4. A tiny human-readable dump (host convenience; not used by Revit)
|
||||
# ============================================================================
|
||||
|
||||
def format_result(res):
|
||||
"""Render a WindResult as plain text for a host print / debug."""
|
||||
lines = []
|
||||
lines.append("ASCE {0} C&C wall pressures (Chapter 30, Part 1, h<=60)".format(
|
||||
res.code_version))
|
||||
if res.not_judged:
|
||||
lines.append(" " + res.not_judged)
|
||||
return "\n".join(lines)
|
||||
lines.append(" inputs: Vult={0} mph, Risk {1}, Exp {2}, {3}, h={4} ft, "
|
||||
"A={5} ft^2".format(res.v_ult_mph, res.risk_category,
|
||||
res.exposure, res.enclosure,
|
||||
res.mean_roof_height_ft,
|
||||
res.effective_wind_area_ft2))
|
||||
lines.append(" Kh = {0:.3f} [{1}]".format(res.kh.value, res.kh.cite))
|
||||
lines.append(" Kzt = {0:.3f} [{1}]".format(res.kzt.value, res.kzt.cite))
|
||||
lines.append(" Ke = {0:.3f} [{1}]".format(res.ke.value, res.ke.cite))
|
||||
lines.append(" Kd = {0:.3f} [{1}] (placement: {2})".format(
|
||||
res.kd.value, res.kd.cite, res.kd_placement))
|
||||
lines.append(" GCpi = +/-{0:.2f} [{1}]".format(res.gcpi.value, res.gcpi.cite))
|
||||
lines.append(" qh = {0:.2f} psf [{1}]".format(res.qh_psf.value,
|
||||
res.qh_equation))
|
||||
for z in res.zones:
|
||||
lines.append(
|
||||
" Zone {0}: +{1:.1f} / {2:.1f} psf (+/-{3:.1f}; GCp {4:+.2f}/{5:+.2f}; "
|
||||
"figure-read)".format(z.zone, z.positive_psf, z.negative_psf,
|
||||
z.uncertainty_psf, z.gcp_positive,
|
||||
z.gcp_negative))
|
||||
if z.min_governs_positive or z.min_governs_negative:
|
||||
lines.append(" (Sec. 30.2.2 16-psf minimum governs)")
|
||||
for n in res.notes:
|
||||
lines.append(" note: " + n)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shared fixtures, built from the ASPIRE Winter 2019 worked examples.
|
||||
|
||||
Brown, K. "Calculations for Handling and Shipping Precast Concrete Deck
|
||||
Panels." ASPIRE, Winter 2019, pp. 24-27. The article reproduces PCI Design
|
||||
Handbook Table 8.3.1 and Figure 8.3.2 and works two examples end to end, which
|
||||
makes it the one published check available without a PCI licence.
|
||||
|
||||
Panel: 10 ft x 32 ft x 8.75 in., normalweight, f'ci = 4000, f'c = 5000,
|
||||
four-point pick at the Figure 8.3.2 locations.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
|
||||
|
||||
from panellift.model import Blockout, Lifter, Panel, Rigging, Stage # noqa: E402
|
||||
from panellift import pci # noqa: E402
|
||||
|
||||
|
||||
WIDTH_FT = 10.0
|
||||
LENGTH_FT = 32.0
|
||||
THICKNESS_IN = 8.75
|
||||
|
||||
# The article's rounded values, quoted so a failure names the source number.
|
||||
ASPIRE = {
|
||||
'w_psf': 109.4,
|
||||
'moment_lbft': 23973.0,
|
||||
's_solid_in3': 1531.0,
|
||||
's_pocketed_in3': 1148.0,
|
||||
'strip_stress_psi': 244.0,
|
||||
'strip_allowable_psi': 316.0,
|
||||
'ship_stress_psi': 282.0,
|
||||
'ship_allowable_psi': 354.0,
|
||||
'pocketed_strip_stress_psi': 326.0,
|
||||
'pocketed_ship_stress_psi': 376.0,
|
||||
}
|
||||
|
||||
|
||||
def four_point_lifters(length_ft=LENGTH_FT, width_ft=WIDTH_FT):
|
||||
"""Two lines of two, at the Figure 8.3.2 equalising positions."""
|
||||
left, right = pci.optimal_two_line_supports(length_ft)
|
||||
ys = (0.25 * width_ft, 0.75 * width_ft)
|
||||
return [Lifter(mark='L{0}'.format(i + 1), family='TEST_LIFTER',
|
||||
x=x, y=y, role='strip', capacity_lb=8000.0)
|
||||
for i, (x, y) in enumerate([(left, ys[0]), (left, ys[1]),
|
||||
(right, ys[0]), (right, ys[1])])]
|
||||
|
||||
|
||||
def make_panel(mark='ASPIRE-1', blockouts=None):
|
||||
"""A fresh panel every time.
|
||||
|
||||
Built by a plain function rather than by one fixture mutating another:
|
||||
pytest caches a fixture per test, so a test asking for both the solid and
|
||||
the pocketed panel would otherwise be handed the same object twice and
|
||||
quietly compare it with itself.
|
||||
"""
|
||||
return Panel(
|
||||
mark=mark,
|
||||
length_ft=LENGTH_FT,
|
||||
width_ft=WIDTH_FT,
|
||||
thickness_in=THICKNESS_IN,
|
||||
unit_weight_pcf=150.0,
|
||||
fci_psi=4000.0,
|
||||
fc_psi=5000.0,
|
||||
finish='smooth',
|
||||
product_type='flat',
|
||||
lifters=four_point_lifters(),
|
||||
blockouts=list(blockouts or []),
|
||||
)
|
||||
|
||||
|
||||
def aspire_pockets():
|
||||
"""Five 6 in. x 24 in. full-depth pockets in one row across the width.
|
||||
|
||||
The article puts them 'at or near the lifting points' and notes that is
|
||||
not good practice -- it is the location of maximum moment. Five pockets
|
||||
six inches wide take 2.5 ft out of a 10 ft width, giving S = 1148 in^3.
|
||||
"""
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
return [Blockout(name='P{0}'.format(i + 1),
|
||||
x0=left - 1.0, y0=1.0 + i * 1.5,
|
||||
length=2.0, width=0.5)
|
||||
for i in range(5)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def solid_panel():
|
||||
return make_panel()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pocketed_panel():
|
||||
return make_panel(mark='ASPIRE-2', blockouts=aspire_pockets())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stripping():
|
||||
return Stage(name='stripping',
|
||||
multiplier=pci.stripping_multiplier('flat', 'smooth'),
|
||||
fc_psi=4000.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shipping():
|
||||
return Stage(name='transportation',
|
||||
multiplier=pci.TRANSPORTATION_MULTIPLIER,
|
||||
fc_psi=5000.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rolling():
|
||||
return Rigging(mode='rolling', sling_angle_deg=90.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def static_lift():
|
||||
return Rigging(mode='static', sling_angle_deg=90.0)
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The two worked examples from ASPIRE Winter 2019, end to end.
|
||||
|
||||
Every number asserted here appears in print in the article. Where our model is
|
||||
deliberately more refined than the article -- it reduces the self weight for
|
||||
the pockets, which the article says it neglects -- the difference is asserted
|
||||
in the direction it must go, rather than papered over with a loose tolerance.
|
||||
"""
|
||||
from panellift import pci, section, statics
|
||||
from panellift.model import Stage
|
||||
|
||||
from conftest import ASPIRE
|
||||
|
||||
|
||||
def _stress_psi(moment_lbft, s_in3):
|
||||
return moment_lbft * 12.0 / s_in3
|
||||
|
||||
|
||||
# ------------------------------------------------------- section properties
|
||||
|
||||
def test_solid_section_modulus(solid_panel):
|
||||
s = section.section_modulus_in3(solid_panel, 16.0)
|
||||
assert abs(s - ASPIRE['s_solid_in3']) < 1.0
|
||||
|
||||
|
||||
def test_pocketed_section_modulus(pocketed_panel):
|
||||
"""Five 6 in. pockets in one row: 1148 in^3, printed in the article."""
|
||||
x = pocketed_panel.blockouts[0].x0 + 1.0
|
||||
s = section.section_modulus_in3(pocketed_panel, x)
|
||||
assert abs(s - ASPIRE['s_pocketed_in3']) < 1.0
|
||||
|
||||
|
||||
def test_pockets_only_reduce_the_section_where_they_are(pocketed_panel):
|
||||
away = section.section_modulus_in3(pocketed_panel, 16.0)
|
||||
assert abs(away - ASPIRE['s_solid_in3']) < 1.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------- allowables
|
||||
|
||||
def test_allowable_tension_at_stripping():
|
||||
assert abs(pci.allowable_tension_psi(4000.0) -
|
||||
ASPIRE['strip_allowable_psi']) < 1.0
|
||||
|
||||
|
||||
def test_allowable_tension_at_shipping():
|
||||
assert abs(pci.allowable_tension_psi(5000.0) -
|
||||
ASPIRE['ship_allowable_psi']) < 1.0
|
||||
|
||||
|
||||
def test_table_831_multipliers():
|
||||
assert pci.stripping_multiplier('flat', 'smooth') == 1.3
|
||||
assert pci.stripping_multiplier('flat', 'exposed') == 1.2
|
||||
assert pci.stripping_multiplier('sculptured', 'smooth') == 1.7
|
||||
assert pci.TRANSPORTATION_MULTIPLIER == 1.5
|
||||
assert pci.YARD_AND_ERECTION_MULTIPLIER == 1.2
|
||||
|
||||
|
||||
# --------------------------------------------------- example 1, solid panel
|
||||
|
||||
def test_example_1_stripping(solid_panel, stripping, rolling):
|
||||
"""1.3 x 23,973 lb-ft over S = 1531 in^3 gives 244 psi, under 316 psi."""
|
||||
result = statics.analyse(solid_panel, stripping, rolling,
|
||||
reconcile_weight=False)
|
||||
_, moment = result.diagram.max_positive
|
||||
stress = _stress_psi(moment, ASPIRE['s_solid_in3'])
|
||||
|
||||
assert abs(stress - ASPIRE['strip_stress_psi']) < 3.0
|
||||
assert stress < pci.allowable_tension_psi(4000.0)
|
||||
|
||||
|
||||
def test_example_1_shipping(solid_panel, shipping, rolling):
|
||||
"""1.5 x 23,973 lb-ft over S = 1531 in^3 gives 282 psi, under 354 psi."""
|
||||
result = statics.analyse(solid_panel, shipping, rolling,
|
||||
reconcile_weight=False)
|
||||
_, moment = result.diagram.max_positive
|
||||
stress = _stress_psi(moment, ASPIRE['s_solid_in3'])
|
||||
|
||||
assert abs(stress - ASPIRE['ship_stress_psi']) < 3.0
|
||||
assert stress < pci.allowable_tension_psi(5000.0)
|
||||
|
||||
|
||||
# ------------------------------------------------ example 2, with pockets
|
||||
|
||||
def test_example_2_stripping_fails_as_printed(solid_panel, stripping, rolling):
|
||||
"""326 psi against 316 psi allowable. No good, says the article.
|
||||
|
||||
Computed the article's way: the solid-panel moment against the reduced
|
||||
section, because the article explicitly neglects the weight the pockets
|
||||
remove.
|
||||
"""
|
||||
result = statics.analyse(solid_panel, stripping, rolling,
|
||||
reconcile_weight=False)
|
||||
_, moment = result.diagram.max_positive
|
||||
stress = _stress_psi(moment, ASPIRE['s_pocketed_in3'])
|
||||
|
||||
assert abs(stress - ASPIRE['pocketed_strip_stress_psi']) < 3.0
|
||||
assert stress > pci.allowable_tension_psi(4000.0)
|
||||
|
||||
|
||||
def test_example_2_shipping_fails_as_printed(solid_panel, shipping, rolling):
|
||||
"""376 psi against 354 psi allowable."""
|
||||
result = statics.analyse(solid_panel, shipping, rolling,
|
||||
reconcile_weight=False)
|
||||
_, moment = result.diagram.max_positive
|
||||
stress = _stress_psi(moment, ASPIRE['s_pocketed_in3'])
|
||||
|
||||
assert abs(stress - ASPIRE['pocketed_ship_stress_psi']) < 3.0
|
||||
assert stress > pci.allowable_tension_psi(5000.0)
|
||||
|
||||
|
||||
def test_modelling_the_pocket_weight_relieves_the_moment_slightly(
|
||||
pocketed_panel, solid_panel, stripping, rolling):
|
||||
"""Our full model is less conservative than the article, and must say so.
|
||||
|
||||
The article neglects the 1.6% of self weight the pockets remove. We do
|
||||
not, so our moment comes out slightly lower -- a few percent at most. If
|
||||
that gap were ever large it would mean the blockouts were misread, so the
|
||||
test pins the size of the difference, not just its sign.
|
||||
"""
|
||||
full = statics.analyse(pocketed_panel, stripping, rolling,
|
||||
reconcile_weight=False)
|
||||
article = statics.analyse(solid_panel, stripping, rolling,
|
||||
reconcile_weight=False)
|
||||
_, m_full = full.diagram.max_positive
|
||||
_, m_article = article.diagram.max_positive
|
||||
|
||||
assert m_full < m_article
|
||||
assert (m_article - m_full) / m_article < 0.05
|
||||
|
||||
|
||||
def test_pocketed_panel_still_fails_when_modelled_fully(pocketed_panel,
|
||||
stripping, rolling):
|
||||
"""The refinement must not turn a printed failure into a pass."""
|
||||
result = statics.analyse(pocketed_panel, stripping, rolling,
|
||||
reconcile_weight=False)
|
||||
x0 = pocketed_panel.blockouts[0].x0
|
||||
x = x0 + 1.0
|
||||
moment = abs(result.diagram.moment_at(x))
|
||||
s = section.section_modulus_in3(pocketed_panel, x)
|
||||
|
||||
assert _stress_psi(moment, s) > pci.allowable_tension_psi(4000.0)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,482 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The checks, and that the report they feed actually builds.
|
||||
|
||||
The ASPIRE examples are re-used here as verdict tests rather than number
|
||||
tests: example 1 must come back OK and the pocketed example 2 must come back
|
||||
FAIL. A refactor that quietly turns a printed failure into a pass is the
|
||||
regression worth catching.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import checks, pci, report, statics
|
||||
from panellift.model import Blockout, Lifter, Panel, Prestress, Rigging, Stage, Strand
|
||||
|
||||
from conftest import make_panel, aspire_pockets
|
||||
|
||||
|
||||
def _analyse(panel, stage, rigging):
|
||||
return statics.analyse(panel, stage, rigging, reconcile_weight=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ verdicts
|
||||
|
||||
def test_solid_panel_passes_stripping(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
found = checks.check_cracking(a, stripping)
|
||||
assert [f.severity for f in found] == ['OK']
|
||||
|
||||
|
||||
def test_pocketed_panel_fails_stripping(stripping, rolling):
|
||||
"""Same panel, five pockets at the pick, and the answer must flip."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
found = checks.check_cracking(a, stripping)
|
||||
assert found[0].severity == 'FAIL'
|
||||
assert 'psi' in found[0].message
|
||||
|
||||
|
||||
def test_the_governing_station_is_at_a_pocket(stripping, rolling):
|
||||
"""The worst stress must be found at the blockout, not on the grid."""
|
||||
panel = make_panel(blockouts=aspire_pockets())
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
found = checks.check_cracking(a, stripping)
|
||||
edge = panel.blockouts[0]
|
||||
assert edge.x0 - 0.1 <= found[0].at_ft <= edge.x1 + 0.1
|
||||
|
||||
|
||||
# --------------------------------------------------- prestress loss note
|
||||
|
||||
PULL_LB = 0.153 * 270_000 * 0.75 # 75% pull, 1/2 in. 270k strand
|
||||
|
||||
|
||||
def _near_miss_analysis(loss_pct):
|
||||
"""A hand-built Analysis, bypassing the beam solve entirely.
|
||||
|
||||
Station and moment are chosen so the worst corner lands at 95% of the
|
||||
tensile limit -- squarely inside the WARN band rather than balanced on
|
||||
its edge -- for a 4 x 20 ft, 6 in. solid panel. loss_pct=None gives a
|
||||
panel with no prestress at all, at a moment tuned to land in the same
|
||||
band on bending alone.
|
||||
"""
|
||||
panel = Panel(mark='NM', length_ft=20.0, width_ft=4.0, thickness_in=6.0,
|
||||
fc_psi=5000.0, fci_psi=3500.0)
|
||||
if loss_pct is not None:
|
||||
# y and z at the section's own centroid, so neither eccentricity
|
||||
# term fires and the axial term alone does the work of getting this
|
||||
# close to the limit.
|
||||
strand = Strand(y=2.0, z_in=3.0, area_in2=0.153,
|
||||
initial_pull_lb=PULL_LB)
|
||||
panel.prestress = Prestress(strands=[strand], loss_pct=loss_pct)
|
||||
moment_lbft = 10_320.0
|
||||
else:
|
||||
moment_lbft = 8_061.0
|
||||
diagram = statics.Diagram(xs=[10.0], shear_lb=[0.0],
|
||||
moment_lbft=[moment_lbft])
|
||||
stage = Stage(name='test', multiplier=1.0, fc_psi=5000.0)
|
||||
analysis = statics.Analysis(panel=panel, stage=stage, rigging=Rigging(),
|
||||
rig=None, profile=None, diagram=diagram)
|
||||
return analysis, stage
|
||||
|
||||
|
||||
def test_near_miss_with_prestress_names_the_loss_assumption():
|
||||
analysis, stage = _near_miss_analysis(loss_pct=12.5)
|
||||
found = checks.check_cracking(analysis, stage)
|
||||
crack = found[0]
|
||||
assert crack.severity == 'WARN' # verdict is unchanged
|
||||
assert 'within 10% of the limit' in crack.message
|
||||
assert '12.5%' in crack.message # read from the config,
|
||||
assert '8.0%' not in crack.message # not the 8% default
|
||||
assert 'stated assumption, not a calculated one' in crack.message
|
||||
assert 'computed over time' in crack.message
|
||||
|
||||
|
||||
def test_near_miss_without_prestress_has_no_loss_note():
|
||||
"""No prestress credited, so there is no loss assumption to question."""
|
||||
analysis, stage = _near_miss_analysis(loss_pct=None)
|
||||
found = checks.check_cracking(analysis, stage)
|
||||
crack = found[0]
|
||||
assert crack.severity == 'WARN'
|
||||
assert 'within 10% of the limit' in crack.message
|
||||
assert 'loss' not in crack.message
|
||||
|
||||
|
||||
# ------------------------------------------------------------- inserts
|
||||
|
||||
def test_insert_check_defactors_the_reaction(solid_panel, stripping, rolling):
|
||||
"""PCI Table 8.3.1 note (a): the handling multiplier is not for inserts.
|
||||
|
||||
Two stages differing only in multiplier must give the same line force.
|
||||
"""
|
||||
def line_force(mult):
|
||||
stage = Stage('s', mult, 4000.0)
|
||||
a = _analyse(make_panel(), stage, rolling)
|
||||
found = [f for f in checks.check_insert_capacity(a, rolling)
|
||||
if f.code == 'INSERT']
|
||||
return found[0].message
|
||||
|
||||
assert line_force(1.0) == line_force(1.7)
|
||||
|
||||
|
||||
def test_shallow_sling_raises_the_line_force(solid_panel, stripping):
|
||||
vertical = Rigging(mode='rolling', sling_angle_deg=90.0)
|
||||
shallow = Rigging(mode='rolling', sling_angle_deg=30.0)
|
||||
a = _analyse(make_panel(), stripping, vertical)
|
||||
|
||||
v = checks.check_insert_capacity(a, vertical)
|
||||
s = checks.check_insert_capacity(a, shallow)
|
||||
assert not any(f.code == 'SLING-ANGLE' for f in v)
|
||||
assert any(f.code == 'SLING-ANGLE' for f in s)
|
||||
|
||||
|
||||
def test_unrated_insert_warns_rather_than_passing(stripping, rolling):
|
||||
panel = make_panel()
|
||||
for lifter in panel.lifters:
|
||||
lifter.capacity_lb = None
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
found = checks.check_insert_capacity(a, rolling)
|
||||
assert found[0].code == 'INSERT-UNRATED'
|
||||
assert found[0].severity == 'WARN'
|
||||
|
||||
|
||||
def test_an_overloaded_insert_fails(stripping, rolling):
|
||||
panel = make_panel()
|
||||
for lifter in panel.lifters:
|
||||
lifter.capacity_lb = 100.0
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
found = checks.check_insert_capacity(a, rolling)
|
||||
assert any(f.severity == 'FAIL' for f in found)
|
||||
|
||||
|
||||
# --------------------------------- owner's ruling, 2026-09-02: decomposition
|
||||
#
|
||||
# "panel lift 里面,现在如果有sling angle的话,是直接给tension放大。不要直接给
|
||||
# tension放大,把力分解成tension跟shear,并且依次检查lifter的tension和shear
|
||||
# capacity。举例来说P136 at 18.83 ft: line force 7,609 lb (service 6,589 lb /
|
||||
# sin 60°) vs 8,000 lb tension (95%),就是tension部分6589/8000,shear部分拆
|
||||
# 出来单独算,并且跟shear capacity进行对比。二者都小于1即可,不考虑interaction."
|
||||
#
|
||||
# The wrong arithmetic compared the WHOLE amplified line force (7,609 lb)
|
||||
# to the tension rating and printed 95% -- a near-WARN that isn't real. The
|
||||
# decomposition the owner asked for compares only the vertical share (the
|
||||
# service reaction itself, 6,589 lb) to tension, and separately the
|
||||
# horizontal share (6,589 * cot(60deg) = 3,804 lb) to shear.
|
||||
|
||||
def _p136_analysis(sling_angle_deg=60.0, capacity_lb=8000.0,
|
||||
shear_capacity_lb=8000.0, service=6589.0):
|
||||
lifter = Lifter(mark='P136', family='P136', x=18.83, y=0.0,
|
||||
capacity_lb=capacity_lb,
|
||||
shear_capacity_lb=shear_capacity_lb)
|
||||
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=service)
|
||||
rig = statics.Rig(lines=[line], points=1, mode='rolling')
|
||||
stage = Stage('erect', 1.0, 5000.0)
|
||||
rigging = Rigging(mode='rolling', sling_angle_deg=sling_angle_deg)
|
||||
analysis = statics.Analysis(panel=make_panel(), stage=stage,
|
||||
rigging=rigging, rig=rig, profile=None,
|
||||
diagram=None)
|
||||
return analysis, rigging
|
||||
|
||||
|
||||
def test_owner_worked_example_p136_decomposes_tension_and_shear():
|
||||
"""The pinned numbers from the ruling: 82% tension (not the old 95% on
|
||||
the amplified line force), and a shear share of ~3,804 lb checked on
|
||||
its own against the shear column -- both under 1, no interaction."""
|
||||
analysis, rigging = _p136_analysis()
|
||||
found = [f for f in checks.check_insert_capacity(analysis, rigging)
|
||||
if f.code == 'INSERT']
|
||||
assert found
|
||||
msg = found[0].message
|
||||
assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg
|
||||
assert 'shear 3,804 lb vs 8,000 lb (48%)' in msg
|
||||
# The old (wrong) arithmetic's 95% verdict does not survive.
|
||||
assert '95%' not in msg
|
||||
# 82% and 48% are each under 1 -- the finding must read as OK, not the
|
||||
# WARN the old single-ratio comparison against the amplified 7,609 lb
|
||||
# line force gave.
|
||||
assert found[0].severity == 'OK'
|
||||
|
||||
|
||||
def test_vertical_rigging_degrades_the_shear_share_to_zero():
|
||||
"""theta = 90 (a vertical line, sling_angle_deg's own convention for
|
||||
'straight up') must fall back to the old plumb-vertical arithmetic
|
||||
exactly: cot(90) = 0, so the shear share is zero and only tension is a
|
||||
real question."""
|
||||
analysis, rigging = _p136_analysis(sling_angle_deg=90.0)
|
||||
found = [f for f in checks.check_insert_capacity(analysis, rigging)
|
||||
if f.code == 'INSERT']
|
||||
assert found
|
||||
msg = found[0].message
|
||||
assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg
|
||||
assert 'shear 0 lb vs 8,000 lb (0%)' in msg
|
||||
|
||||
|
||||
def test_a_missing_shear_capacity_still_judges_the_tension_half():
|
||||
"""Some lifters carry no shear rating. That is a could-not-judge on the
|
||||
shear half, named inline -- never a silent pass -- and it must not stop
|
||||
the tension half, which has everything it needs, from judging."""
|
||||
analysis, rigging = _p136_analysis(shear_capacity_lb=None)
|
||||
found = [f for f in checks.check_insert_capacity(analysis, rigging)
|
||||
if f.code == 'INSERT']
|
||||
assert found
|
||||
msg = found[0].message
|
||||
assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg
|
||||
assert 'no shear rating' in msg and 'NOT JUDGED' in msg
|
||||
codes = [f.code for f in checks.check_insert_capacity(analysis, rigging)]
|
||||
assert 'INSERT-UNRATED' in codes
|
||||
|
||||
|
||||
# --------------------- MC dict-shaped capacities: cli.py resolves, checks.py
|
||||
# judges what comes out (a number, or None + a reason)
|
||||
#
|
||||
# `cli._resolve_capacity` is what turns MC's richer `capacity_lb` /
|
||||
# `shear_capacity_lb` dicts into the (float-or-None, reason) pair
|
||||
# `check_insert_capacity` has always consumed -- these exercise the CHECK
|
||||
# side of that split: a None tension carrying a reason must surface as
|
||||
# NOT JUDGED naming it, and a real published zero on the other axis (MC's
|
||||
# LPA4T512 pin: `shear_lb: 0`) must still be judged, not treated as absent.
|
||||
|
||||
def _mc_lifter(tension_reason=None, capacity_lb=8000.0,
|
||||
shear_capacity_lb=0.0):
|
||||
"""`tension_reason` given makes tension itself unrated (None) with that
|
||||
reason -- the angle-unresolved shape. Left None, tension carries a real
|
||||
`capacity_lb` instead, so a shear-only test is not also incidentally an
|
||||
unrated-tension test."""
|
||||
return Lifter(mark='PIN', family='LPA4T512', x=18.83, y=0.0,
|
||||
capacity_lb=(None if tension_reason else capacity_lb),
|
||||
capacity_why=tension_reason,
|
||||
shear_capacity_lb=shear_capacity_lb)
|
||||
|
||||
|
||||
def test_an_unresolved_tension_dict_is_not_judged_carrying_its_reason():
|
||||
"""The angle-unresolved shape -- tension resolves to None upstream in
|
||||
cli.py, with `capacity_why` carrying the specific "sling angle not
|
||||
confirmed" sentence rather than the generic "no rating" one. The check
|
||||
must print that reason on the tension clause, not swallow it."""
|
||||
reason = ('M8 publishes LPA4T512 tension by sling angle and the plant '
|
||||
'has not confirmed which column applies')
|
||||
lifter = _mc_lifter(tension_reason=reason)
|
||||
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0)
|
||||
rig = statics.Rig(lines=[line], points=1, mode='rolling')
|
||||
stage = Stage('erect', 1.0, 5000.0)
|
||||
rigging = Rigging(mode='rolling', sling_angle_deg=60.0)
|
||||
analysis = statics.Analysis(panel=make_panel(), stage=stage,
|
||||
rigging=rigging, rig=rig, profile=None,
|
||||
diagram=None)
|
||||
found = [f for f in checks.check_insert_capacity(analysis, rigging)
|
||||
if f.code == 'INSERT']
|
||||
assert found
|
||||
msg = found[0].message
|
||||
assert 'no tension rating' in msg and 'NOT JUDGED' in msg
|
||||
assert reason in msg
|
||||
|
||||
|
||||
def test_a_published_zero_shear_capacity_is_judged_not_skipped():
|
||||
"""MC's LPA4T512 publishes `shear_lb: 0` -- a real rating, not a
|
||||
could-not-judge -- so a real shear demand against it must come back a
|
||||
genuine (very bad) ratio, never an INSERT-UNRATED could-not-judge and
|
||||
never a ZeroDivisionError crash."""
|
||||
lifter = _mc_lifter(shear_capacity_lb=0.0)
|
||||
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0)
|
||||
rig = statics.Rig(lines=[line], points=1, mode='rolling')
|
||||
stage = Stage('erect', 1.0, 5000.0)
|
||||
rigging = Rigging(mode='rolling', sling_angle_deg=60.0) # nonzero shear
|
||||
analysis = statics.Analysis(panel=make_panel(), stage=stage,
|
||||
rigging=rigging, rig=rig, profile=None,
|
||||
diagram=None)
|
||||
found = [f for f in checks.check_insert_capacity(analysis, rigging)
|
||||
if f.code == 'INSERT']
|
||||
assert found
|
||||
msg = found[0].message
|
||||
assert 'no shear rating' not in msg # judged, not could-not-judge
|
||||
assert 'vs 0 lb' in msg
|
||||
assert found[0].severity == 'FAIL' # any real demand over 0 fails
|
||||
codes = [f.code for f in checks.check_insert_capacity(analysis, rigging)]
|
||||
assert 'INSERT-UNRATED' not in codes
|
||||
|
||||
|
||||
def test_a_published_zero_shear_capacity_with_no_shear_demand_is_ok():
|
||||
"""0/0 -- a vertical sling puts no shear share on this axis at all, and
|
||||
an axis with no capacity that also carries no demand is OK, not a
|
||||
fabricated FAIL."""
|
||||
lifter = _mc_lifter(shear_capacity_lb=0.0)
|
||||
line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0)
|
||||
rig = statics.Rig(lines=[line], points=1, mode='rolling')
|
||||
stage = Stage('erect', 1.0, 5000.0)
|
||||
rigging = Rigging(mode='rolling', sling_angle_deg=90.0) # cot(90) = 0
|
||||
analysis = statics.Analysis(panel=make_panel(), stage=stage,
|
||||
rigging=rigging, rig=rig, profile=None,
|
||||
diagram=None)
|
||||
found = [f for f in checks.check_insert_capacity(analysis, rigging)
|
||||
if f.code == 'INSERT']
|
||||
assert found
|
||||
assert 'shear 0 lb vs 0 lb (0%)' in found[0].message
|
||||
|
||||
|
||||
# -------------------------------------------------------- aspect ratio
|
||||
|
||||
@pytest.mark.parametrize('width,expected', [(4.0, 'OK'), (20.0, 'WARN'),
|
||||
(28.0, 'FAIL')])
|
||||
def test_near_square_panels_are_refused(width, expected):
|
||||
panel = make_panel()
|
||||
panel.width_ft = width
|
||||
assert checks.check_aspect_ratio(panel)[0].severity == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('width', [4.0, 20.0, 28.0])
|
||||
def test_turning_a_piece_does_not_change_whether_it_is_square(width):
|
||||
"""transpose() stands a horizontal panel up by swapping length and width.
|
||||
|
||||
A 26.8 x 11.5 panel is 0.43 either way round. When this check read
|
||||
width/length it came back 2.33 turned, failed, and reported the piece as
|
||||
"close to square" -- which is how all 184 horizontal panels in a real
|
||||
model failed erection and no genuine overstress could be seen behind it.
|
||||
"""
|
||||
flat = make_panel()
|
||||
flat.width_ft = width
|
||||
stood_up = make_panel()
|
||||
stood_up.length_ft, stood_up.width_ft = width, flat.length_ft
|
||||
assert (checks.check_aspect_ratio(flat)[0].severity ==
|
||||
checks.check_aspect_ratio(stood_up)[0].severity)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- dunnage
|
||||
|
||||
def _dunnage_stage(supports):
|
||||
return Stage(name='transportation', multiplier=1.4, fc_psi=5000.0,
|
||||
pci_case='transportation', supports=supports)
|
||||
|
||||
|
||||
def test_support_under_a_big_opening_is_named(rolling):
|
||||
"""One real piece: the plant's fraction-of-span rule 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. Here an 8 ft wide, full-depth opening removes 80% of a
|
||||
10 ft panel's width at x=5, which is well past 'most of the width'."""
|
||||
panel = make_panel()
|
||||
panel.blockouts = [Blockout(name='DOOR', x0=3.0, y0=0.0,
|
||||
length=4.0, width=8.0)]
|
||||
stage = _dunnage_stage([5.0, 25.0])
|
||||
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
|
||||
found = checks.check_dunnage_position(a)
|
||||
|
||||
assert len(found) == 1 # only the 5.0 ft support
|
||||
f = found[0]
|
||||
assert f.severity == 'WARN'
|
||||
assert f.code == 'DUNNAGE-OPENING'
|
||||
assert f.at_ft == 5.0
|
||||
assert '5.00 ft' in f.message
|
||||
assert 'DOOR' in f.message
|
||||
assert '3.00 to 7.00 ft' in f.message
|
||||
assert '80%' in f.message
|
||||
|
||||
|
||||
def test_support_clear_of_any_opening_is_not_flagged(rolling):
|
||||
panel = make_panel() # no blockouts at all
|
||||
stage = _dunnage_stage([5.0, 25.0])
|
||||
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
|
||||
assert checks.check_dunnage_position(a) == []
|
||||
|
||||
|
||||
def test_a_small_opening_under_a_support_does_not_trigger(rolling):
|
||||
"""A opening that leaves most of the width intact is a different case
|
||||
from a support bearing mostly on a hole -- below the fraction this draws
|
||||
the line at, it is left alone."""
|
||||
panel = make_panel()
|
||||
panel.blockouts = [Blockout(name='SMALL', x0=3.0, y0=0.0,
|
||||
length=4.0, width=2.5)] # 25% of 10 ft
|
||||
stage = _dunnage_stage([5.0, 25.0])
|
||||
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
|
||||
assert checks.check_dunnage_position(a) == []
|
||||
|
||||
|
||||
def test_dunnage_check_does_not_run_on_a_lifted_stage(stripping, rolling):
|
||||
"""supports is None on a normal pick -- nothing to ask voids_at about."""
|
||||
a = statics.analyse(make_panel(), stripping, rolling,
|
||||
reconcile_weight=False)
|
||||
assert checks.check_dunnage_position(a) == []
|
||||
|
||||
|
||||
def test_run_all_includes_the_dunnage_check_on_a_supported_stage(rolling):
|
||||
panel = make_panel()
|
||||
panel.blockouts = [Blockout(name='DOOR', x0=3.0, y0=0.0,
|
||||
length=4.0, width=8.0)]
|
||||
stage = _dunnage_stage([5.0, 25.0])
|
||||
a = statics.analyse(panel, stage, rolling, reconcile_weight=False)
|
||||
found = checks.run_all(a, rolling)
|
||||
assert any(f.code == 'DUNNAGE-OPENING' for f in found)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- report
|
||||
|
||||
def test_report_builds_and_is_self_contained(stripping, rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
found = checks.run_all(a, rolling)
|
||||
page = report.build(a, rolling, found)
|
||||
|
||||
assert page.count('<svg') == page.count('</svg>') == 5
|
||||
assert '</table>' in page
|
||||
# A calc report has to open from disk on a machine with no network.
|
||||
assert 'http://' not in page and 'https://' not in page
|
||||
assert 'ASPIRE-1' in page or 'ASPIRE' in page
|
||||
|
||||
|
||||
def test_report_states_the_verdict(stripping, rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
found = checks.run_all(a, rolling)
|
||||
assert checks.worst_severity(found) == 'FAIL'
|
||||
assert 'not satisfied' in report.build(a, rolling, found)
|
||||
|
||||
|
||||
def test_transportation_report_says_it_was_checked_as_a_lift(stripping,
|
||||
rolling):
|
||||
"""Transportation sits on dunnage, not the inserts. Until the support
|
||||
model exists, the report must say which model produced the numbers."""
|
||||
strip = _analyse(make_panel(), stripping, rolling)
|
||||
strip_page = report.build(strip, rolling,
|
||||
checks.run_all(strip, rolling),
|
||||
recommendation=False)
|
||||
assert 'dunnage' not in strip_page
|
||||
|
||||
haul = Stage(name='haul', multiplier=1.4, fc_psi=5000.0,
|
||||
pci_case='transportation')
|
||||
haul_a = _analyse(make_panel(), haul, rolling)
|
||||
haul_page = report.build(haul_a, rolling,
|
||||
checks.run_all(haul_a, rolling),
|
||||
recommendation=False)
|
||||
assert 'dunnage' in haul_page
|
||||
assert 'checked as a <b>lift</b>' in haul_page
|
||||
|
||||
|
||||
def test_transportation_on_dunnage_reports_supports_not_a_pick(rolling):
|
||||
panel = make_panel()
|
||||
haul = Stage(name='transportation', multiplier=1.4, fc_psi=5000.0,
|
||||
pci_case='transportation',
|
||||
supports=[0.2 * panel.length_ft, 0.8 * panel.length_ft])
|
||||
a = _analyse(panel, haul, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
assert 'dunnage line' in page
|
||||
assert 'checked as a <b>lift</b>' not in page
|
||||
assert 'no insert check applies' in page
|
||||
|
||||
|
||||
# ------------------------------------------------------ demo-safe: no CJK
|
||||
#
|
||||
# Owner, 2026-09-01: no Chinese anywhere in a report or a UI surface, even
|
||||
# when quoting him -- he is demoing this to colleagues before the weekend.
|
||||
# The owner's own rulings behind these findings (`capacity.py`'s M/phi-Mn
|
||||
# ruling, `sandwich.py`'s "not conservative" ruling, both quoted again in
|
||||
# `checks.py` and `cli.py`) now live in `#` comments only, never in a
|
||||
# `Finding.message`. This renders the real HTML page -- `report.build`,
|
||||
# the surface a colleague actually sees -- off a pocketed panel forced
|
||||
# into a FAIL, so the capacity and cracking findings are both live.
|
||||
def test_the_rendered_findings_page_carries_no_chinese_text_or_html(
|
||||
stripping, rolling):
|
||||
panel = make_panel(blockouts=aspire_pockets())
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
findings = checks.run_all(a, rolling)
|
||||
assert any(f.severity == 'FAIL' for f in findings) # the FAIL path ran
|
||||
cjk = re.compile(u'[一-鿿]')
|
||||
for f in findings:
|
||||
assert not cjk.search(f.message), f.message
|
||||
page = report.build(a, rolling, findings, recommendation=False)
|
||||
assert not cjk.search(page), page
|
||||
@@ -0,0 +1,607 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The job file is a contract, so it is tested like one.
|
||||
|
||||
Revit cannot import this package and this package cannot import Revit; the
|
||||
JSON between them is the only place the two agree. Every way of getting it
|
||||
wrong that would still produce a plausible-looking report is worth a test,
|
||||
because a handling report that looks ordinary and is wrong is the failure this
|
||||
whole package exists to prevent.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import checks, cli
|
||||
from conftest import ASPIRE, LENGTH_FT, THICKNESS_IN, WIDTH_FT
|
||||
from panellift import pci
|
||||
|
||||
|
||||
def aspire_job(**overrides):
|
||||
"""The ASPIRE panel, written the way Revit would write it."""
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
job = {
|
||||
'schema': 'panellift/1',
|
||||
'panel': {
|
||||
'mark': 'ASPIRE-1',
|
||||
'length_ft': LENGTH_FT,
|
||||
'width_ft': WIDTH_FT,
|
||||
'thickness_in': THICKNESS_IN,
|
||||
'unit_weight_pcf': 150.0,
|
||||
'fci_psi': 4000.0,
|
||||
'fc_psi': 5000.0,
|
||||
'lifters': [
|
||||
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
|
||||
'y': 2.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
|
||||
'y': 7.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
|
||||
'y': 2.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
|
||||
'y': 7.5, 'capacity_lb': 12000.0},
|
||||
],
|
||||
},
|
||||
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
|
||||
'stages': [
|
||||
{'name': 'stripping',
|
||||
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
|
||||
'fc_psi': 4000.0},
|
||||
],
|
||||
}
|
||||
job.update(overrides)
|
||||
return job
|
||||
|
||||
|
||||
# --------------------------------------------------------------- it computes
|
||||
|
||||
def test_job_reproduces_the_published_stripping_stress():
|
||||
"""A job file must reach the same number the fixtures do, or it is a
|
||||
second implementation rather than a second front door."""
|
||||
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
||||
summary = cli.summarise(cases)
|
||||
peak = summary['stages'][0]['peak_tension_psi']
|
||||
assert peak == pytest.approx(ASPIRE['strip_stress_psi'], abs=1.5)
|
||||
|
||||
|
||||
def test_stage_without_lifters_key_uses_every_insert():
|
||||
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
||||
assert cases[0][0].rig.points == 4
|
||||
|
||||
|
||||
def test_stage_may_carry_the_piece_on_a_subset():
|
||||
"""The whole reason stages name their inserts: a crane that cannot reach
|
||||
the top row makes erection a different pick from stripping."""
|
||||
job = aspire_job()
|
||||
job['stages'][0]['lifters'] = ['L1', 'L2', 'L3']
|
||||
_panel, _rig, cases = cli.analyse_job(job)
|
||||
assert cases[0][0].rig.points == 3
|
||||
|
||||
|
||||
# ------------------------------------------------------------ it refuses well
|
||||
|
||||
def test_unknown_lifter_mark_is_an_error_not_a_smaller_pick():
|
||||
"""Silently analysing three picks where four were meant is
|
||||
unconservative and looks entirely normal in the report."""
|
||||
job = aspire_job()
|
||||
job['stages'][0]['lifters'] = ['L1', 'L2', 'L9']
|
||||
with pytest.raises(cli.JobError) as err:
|
||||
cli.analyse_job(job)
|
||||
assert 'L9' in str(err.value)
|
||||
|
||||
|
||||
def test_duplicate_lifter_marks_are_refused():
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][1]['mark'] = 'L1'
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(job)
|
||||
|
||||
|
||||
# --------------------------------------------- capacity_lb: the dict shape
|
||||
#
|
||||
# `knowledge.lifter_capacity()` (private repo `pyrevit-knowledge`, week of
|
||||
# 2026-09-01) grew a richer shape for MC hardware: `capacity_lb` and
|
||||
# `shear_capacity_lb` can now arrive as a dict instead of a bare number, and
|
||||
# `liftexport.export()` started writing that dict straight through. A real
|
||||
# MC dry-run (WPA594) hit `read_panel` doing `float({...})` and died with
|
||||
# `"capacity_lb" is not a number` -- no report, exit 2, before a single
|
||||
# check ran. These pin the fix: `cli._resolve_capacity` reading both the
|
||||
# split-number shape and the angle-unresolved stand-down shape, with the
|
||||
# existing plain-number shape passing through byte-for-byte unchanged.
|
||||
|
||||
def test_a_plain_number_capacity_is_unchanged_the_regression_case():
|
||||
"""The critical backward-compat: a plain-number deck carries a bare
|
||||
number and must read identically to before the dict shape ever
|
||||
existed."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][0]['capacity_lb'] = 12000.0
|
||||
job['panel']['lifters'][0]['shear_capacity_lb'] = 9000.0
|
||||
panel = cli.read_panel(job['panel'])
|
||||
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
||||
assert l1.capacity_lb == 12000.0
|
||||
assert l1.shear_capacity_lb == 9000.0
|
||||
assert l1.capacity_why is None
|
||||
assert l1.shear_capacity_why is None
|
||||
|
||||
|
||||
def test_a_split_tension_shear_dict_reads_both_capacities():
|
||||
"""{'tension_lb': N, 'shear_lb': M} -- MC's QL589-shaped rating."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][0]['capacity_lb'] = {
|
||||
'tension_lb': 13800, 'shear_lb': 3860}
|
||||
job['panel']['lifters'][0]['shear_capacity_lb'] = {
|
||||
'tension_lb': 13800, 'shear_lb': 3860}
|
||||
panel = cli.read_panel(job['panel'])
|
||||
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
||||
assert l1.capacity_lb == 13800.0
|
||||
assert l1.shear_capacity_lb == 3860.0
|
||||
# A dict that resolved to a real number carries no could-not-judge
|
||||
# reason of its own.
|
||||
assert l1.capacity_why is None
|
||||
assert l1.shear_capacity_why is None
|
||||
|
||||
|
||||
def test_a_zero_capacity_inside_a_dict_is_a_real_rating_not_a_missing_one():
|
||||
"""MC's LPA4T512 publishes `shear_lb: 0` -- the pin genuinely takes no
|
||||
shear. That is a judgeable 0.0, not the same fact as "no rating"
|
||||
(which reads as None): a dict carrying the key at all, even as 0, must
|
||||
resolve to the number, never fall through to the reason branch."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][0]['shear_capacity_lb'] = {
|
||||
'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680,
|
||||
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
||||
panel = cli.read_panel(job['panel'])
|
||||
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
||||
assert l1.shear_capacity_lb == 0.0
|
||||
assert l1.shear_capacity_why is None
|
||||
|
||||
|
||||
def test_an_angle_unresolved_dict_leaves_tension_none_with_its_reason():
|
||||
"""The MC LPA4T512 shape: no settled `tension_lb`, only 60/75-degree
|
||||
columns and a stated reason nobody has confirmed which applies. This
|
||||
must NOT pick 60 or 75 -- tension resolves to None, carrying the exact
|
||||
reason string, never a silently chosen number."""
|
||||
job = aspire_job()
|
||||
reason = ('M8 (manufacturer Lifting Anchor Chart published 12.02.22, '
|
||||
'footnote b) publishes LPA4T512 tension by sling angle -- '
|
||||
'5,990 lb @ 60 deg, 6,680 lb @ 75 deg -- and asks the plant '
|
||||
'to confirm which column applies. lifting_anchors.'
|
||||
'sling_angle_choice is still null: nobody has confirmed it.')
|
||||
job['panel']['lifters'][0]['capacity_lb'] = {
|
||||
'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680,
|
||||
'tension_lb_unresolved_because': reason}
|
||||
panel = cli.read_panel(job['panel'])
|
||||
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
||||
assert l1.capacity_lb is None
|
||||
assert l1.capacity_why == reason
|
||||
# Neither 60 nor 75 was silently picked.
|
||||
assert '5990' not in (l1.capacity_why or '')
|
||||
assert '6680' not in (l1.capacity_why or '')
|
||||
|
||||
|
||||
def test_a_dict_reason_beats_the_generic_top_level_why():
|
||||
"""The exporter also writes a generic top-level `capacity_why`
|
||||
("taken as stated (<dict repr>)") beside the dict. The SPECIFIC
|
||||
per-axis reason inside the dict is what a reader needs, and must win
|
||||
over that generic wrapper rather than being shadowed by it."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][0]['capacity_lb'] = {
|
||||
'shear_lb': 0, 'tension_60_lb': 5990,
|
||||
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
||||
job['panel']['lifters'][0]['capacity_why'] = (
|
||||
'TEST_LIFTER is a job- or plant-pinned rating, taken as stated')
|
||||
panel = cli.read_panel(job['panel'])
|
||||
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
||||
assert l1.capacity_why == 'sling angle not confirmed'
|
||||
|
||||
|
||||
def test_a_dict_with_no_number_and_no_reason_is_not_judged_not_crashed():
|
||||
"""A malformed dict -- neither a settled number nor a stated reason for
|
||||
the axis being read -- must still refuse to guess, but as a named
|
||||
could-not-judge rather than `float(dict)` raising TypeError."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][0]['capacity_lb'] = {'something_else': 1}
|
||||
panel = cli.read_panel(job['panel'])
|
||||
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
||||
assert l1.capacity_lb is None
|
||||
assert l1.capacity_why is not None
|
||||
assert 'NOT JUDGED' in l1.capacity_why
|
||||
|
||||
|
||||
def test_a_wpa594_shaped_deck_with_empty_stages_refuses_legibly():
|
||||
"""The real dry-run failure this whole fix is about: a deck with MC's
|
||||
dict-shaped capacities and zero stages (no `handling.lifter_groups`
|
||||
mapping yet). Before the fix this died in `read_panel` on
|
||||
`float({...})`, exit 2, before the empty-stage guard ever ran. After
|
||||
it, `read_panel` succeeds and the ordinary empty-stage refusal fires
|
||||
instead -- a clean JobError, not a crash, and not a report."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][0]['capacity_lb'] = {
|
||||
'shear_lb': 0, 'tension_60_lb': 5990,
|
||||
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
||||
job['stages'] = []
|
||||
with pytest.raises(cli.JobError) as err:
|
||||
cli.analyse_job(job)
|
||||
assert 'not a number' not in str(err.value)
|
||||
|
||||
|
||||
def test_a_wpa594_shaped_deck_with_empty_stages_exits_clean_via_main(
|
||||
tmp_path, capsys):
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'][0]['capacity_lb'] = {
|
||||
'shear_lb': 0, 'tension_60_lb': 5990,
|
||||
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
||||
job['stages'] = []
|
||||
job_file = tmp_path / 'job.json'
|
||||
out = tmp_path / 'report.html'
|
||||
job_file.write_text(json.dumps(job), encoding='utf-8')
|
||||
|
||||
assert cli.main([str(job_file), '-o', str(out)]) == 2
|
||||
assert not out.exists()
|
||||
captured = capsys.readouterr()
|
||||
assert 'not a number' not in captured.err
|
||||
assert 'Traceback' not in captured.err
|
||||
|
||||
|
||||
def test_a_job_with_no_stages_checks_nothing_and_says_so():
|
||||
job = aspire_job()
|
||||
job['stages'] = []
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(job)
|
||||
|
||||
|
||||
# ------------------------------------------- zero lifters: NOT JUDGED, loudly
|
||||
#
|
||||
# `families.lifter_include` matching zero families on the Revit side is
|
||||
# CLAUDE.md's own named cardinal sin, and it has bitten twice already: the
|
||||
# shipped defaults matching no real family, and P78 on insulated panels --
|
||||
# both times a silent degrade into a report that completed and looked like a
|
||||
# clean pass. `liftexport.export` now refuses before writing a deck shaped
|
||||
# like this at all, but a HAND-WRITTEN job file skips the exporter entirely,
|
||||
# so this package has to refuse on its own too -- defense in depth. See
|
||||
# `cli._refuse_zero_lifters`.
|
||||
|
||||
def test_zero_lifters_is_refused_not_judged():
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'] = []
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(job)
|
||||
assert 'NOT JUDGED' in str(caught.value)
|
||||
assert 'panel.lifters' in str(caught.value)
|
||||
|
||||
|
||||
def test_zero_lifters_names_the_panel_wide_fact_not_one_stages_pick_list():
|
||||
"""Without the panel-wide guard, the per-stage 'carries the piece on no
|
||||
inserts' message would fire first -- once statics reached that stage --
|
||||
and read as one stage's narrower pick problem rather than the fact that
|
||||
the piece has no lifters modelled at all."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'] = []
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(job)
|
||||
assert 'carries the piece on no inserts' not in str(caught.value)
|
||||
|
||||
|
||||
def test_zero_lifters_with_an_explicit_empty_stage_list_is_also_refused():
|
||||
"""The exporter writes an explicit `"lifters": []` on a stage, not an
|
||||
absent key (see default_stages() on the Revit side) -- the guard must
|
||||
catch that shape too, not just a stage with no 'lifters' key at all."""
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'] = []
|
||||
job['stages'][0]['lifters'] = []
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(job)
|
||||
assert 'NOT JUDGED' in str(caught.value)
|
||||
|
||||
|
||||
def test_zero_lifters_on_dunnage_only_stages_still_computes():
|
||||
"""A piece genuinely carried on dunnage for every stage that runs needs
|
||||
no insert at all -- the guard must not refuse a legitimate dunnage-only
|
||||
deck just because panel.lifters happens to be empty."""
|
||||
job = _supported_job()
|
||||
job['panel']['lifters'] = []
|
||||
_panel, _rig, cases = cli.analyse_job(job)
|
||||
assert cases[0][0].rig.points == 0
|
||||
|
||||
|
||||
def test_zero_lifters_with_every_lift_stage_disabled_computes_the_rest():
|
||||
"""A lift stage that is switched off is not going to run, so it does not
|
||||
need lifters either -- only an ENABLED, non-dunnage stage does."""
|
||||
dunnage_stage = _supported_job()['stages'][0]
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'] = []
|
||||
job['stages'] = [
|
||||
dict(job['stages'][0], enabled=False,
|
||||
disabled_reason='no crane on this site yet'),
|
||||
dunnage_stage,
|
||||
]
|
||||
_panel, _rig, cases = cli.analyse_job(job)
|
||||
assert len(cases) == 1
|
||||
assert cases[0][0].stage.supports == dunnage_stage['supports']
|
||||
|
||||
|
||||
def test_a_normal_deck_with_lifters_is_unaffected_by_the_guard():
|
||||
"""The other side of the same rule: a piece that DOES have lifters must
|
||||
not trip on a guard meant for the piece that has none."""
|
||||
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
||||
assert len(cases) == 1
|
||||
|
||||
|
||||
def test_main_reports_not_judged_without_a_traceback(tmp_path, capsys):
|
||||
job = aspire_job()
|
||||
job['panel']['lifters'] = []
|
||||
job_file = tmp_path / 'job.json'
|
||||
out = tmp_path / 'report.html'
|
||||
job_file.write_text(json.dumps(job), encoding='utf-8')
|
||||
|
||||
assert cli.main([str(job_file), '-o', str(out)]) == 2
|
||||
assert not out.exists()
|
||||
captured = capsys.readouterr()
|
||||
assert 'NOT JUDGED' in captured.err
|
||||
assert 'Traceback' not in captured.err
|
||||
|
||||
|
||||
def test_missing_thickness_names_the_field():
|
||||
job = aspire_job()
|
||||
del job['panel']['thickness_in']
|
||||
with pytest.raises(cli.JobError) as err:
|
||||
cli.analyse_job(job)
|
||||
assert 'thickness_in' in str(err.value)
|
||||
|
||||
|
||||
def test_a_future_schema_is_refused_rather_than_guessed_at():
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(aspire_job(schema='panellift/99'))
|
||||
|
||||
|
||||
def test_unknown_rigging_mode_is_refused():
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(aspire_job(rigging={'mode': 'magic'}))
|
||||
|
||||
|
||||
def test_a_stage_with_neither_multiplier_nor_pci_case_is_refused():
|
||||
"""Defaulting a missing load factor to 1.0 would produce a report that
|
||||
looks complete and is 30% light."""
|
||||
job = aspire_job()
|
||||
del job['stages'][0]['multiplier']
|
||||
with pytest.raises(cli.JobError) as err:
|
||||
cli.analyse_job(job)
|
||||
assert 'pci_case' in str(err.value)
|
||||
|
||||
|
||||
# --------------------------------------------------------- Table 8.3.1 lookup
|
||||
|
||||
def test_pci_case_reads_the_table_for_this_panel():
|
||||
job = aspire_job()
|
||||
job['stages'][0]['multiplier'] = None
|
||||
job['stages'][0]['pci_case'] = 'stripping'
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
assert cases[0][0].stage.multiplier == pytest.approx(1.3) # flat, smooth
|
||||
|
||||
|
||||
def test_pci_case_follows_the_finish():
|
||||
job = aspire_job()
|
||||
job['panel']['product_type'] = 'fluted'
|
||||
job['stages'][0]['multiplier'] = None
|
||||
job['stages'][0]['pci_case'] = 'stripping'
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
assert cases[0][0].stage.multiplier == pytest.approx(1.6) # fluted, smooth
|
||||
|
||||
|
||||
def test_a_stated_multiplier_beats_the_table():
|
||||
"""A plant working to its own number is not overruled by the handbook."""
|
||||
job = aspire_job()
|
||||
job['stages'][0]['multiplier'] = 1.4
|
||||
job['stages'][0]['pci_case'] = 'stripping'
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
assert cases[0][0].stage.multiplier == pytest.approx(1.4)
|
||||
|
||||
|
||||
def test_a_product_type_not_in_the_table_is_refused():
|
||||
job = aspire_job()
|
||||
job['panel']['product_type'] = 'corrugated'
|
||||
job['stages'][0]['multiplier'] = None
|
||||
job['stages'][0]['pci_case'] = 'stripping'
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(job)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the main
|
||||
|
||||
def test_main_writes_one_file_covering_every_stage(tmp_path):
|
||||
job = aspire_job()
|
||||
job['stages'].append({'name': 'transportation',
|
||||
'multiplier': pci.TRANSPORTATION_MULTIPLIER,
|
||||
'fc_psi': 5000.0})
|
||||
job_file = tmp_path / 'job.json'
|
||||
out = tmp_path / 'report.html'
|
||||
job_file.write_text(json.dumps(job), encoding='utf-8')
|
||||
|
||||
assert cli.main([str(job_file), '-o', str(out)]) == 0
|
||||
html = io.open(str(out), encoding='utf-8').read()
|
||||
assert html.count('<!doctype html>') <= 1
|
||||
assert 'stripping' in html and 'transportation' in html
|
||||
|
||||
|
||||
def test_main_exits_nonzero_when_a_stage_fails(tmp_path):
|
||||
"""The exit code is what a caller checks when it is not going to read the
|
||||
HTML -- it has to mean something."""
|
||||
job = aspire_job()
|
||||
job['stages'][0]['fc_psi'] = 500.0 # nothing passes at 500 psi
|
||||
job_file = tmp_path / 'job.json'
|
||||
job_file.write_text(json.dumps(job), encoding='utf-8')
|
||||
assert cli.main([str(job_file), '-o', str(tmp_path / 'r.html')]) == 1
|
||||
|
||||
|
||||
def test_main_rejects_a_bad_job_file_without_writing_a_report(tmp_path):
|
||||
job_file = tmp_path / 'job.json'
|
||||
out = tmp_path / 'report.html'
|
||||
job_file.write_text('{"schema": "panellift/1"}', encoding='utf-8')
|
||||
assert cli.main([str(job_file), '-o', str(out)]) == 2
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
# ------------------------------------------------- a stage may rig its own way
|
||||
|
||||
def _peak(analysis):
|
||||
points = checks.stress_profile(analysis)
|
||||
return max(abs(p.stress_psi) for p in points)
|
||||
|
||||
|
||||
def _three_line_job(**stage_extra):
|
||||
"""Three support lines, where rolling and static actually disagree.
|
||||
|
||||
On two lines the two modes are the same problem: statics alone fixes both
|
||||
reactions. It takes a third line before stiffness has anything to say.
|
||||
"""
|
||||
job = aspire_job()
|
||||
mid = LENGTH_FT / 2.0
|
||||
job['panel']['lifters'].extend([
|
||||
{'mark': 'L5', 'family': 'TEST_LIFTER', 'x': mid, 'y': 2.5,
|
||||
'capacity_lb': 12000.0},
|
||||
{'mark': 'L6', 'family': 'TEST_LIFTER', 'x': mid, 'y': 7.5,
|
||||
'capacity_lb': 12000.0},
|
||||
])
|
||||
job['stages'][0].update(stage_extra)
|
||||
return job
|
||||
|
||||
|
||||
def test_a_stage_without_a_rig_mode_inherits_the_job():
|
||||
_p, _r, cases = cli.analyse_job(_three_line_job())
|
||||
_analysis, rigging, _f = cases[0]
|
||||
assert rigging.mode == 'rolling'
|
||||
|
||||
|
||||
def test_a_stage_can_rig_differently_from_the_job():
|
||||
"""The setting has to reach the mechanics, not just be stored.
|
||||
|
||||
This is the whole point of it: a commercial check of one real piece
|
||||
stripped on fixed slings and erected on an equalising block, and the
|
||||
stripping moment came out 14.1 k-ft against 23.8 for one mode everywhere.
|
||||
Whichever is right for a given plant, the two must not produce the same
|
||||
answer -- if they do, the setting is decorative.
|
||||
"""
|
||||
rolling = cli.analyse_job(_three_line_job(rig_mode='rolling'))[2][0]
|
||||
static = cli.analyse_job(_three_line_job(rig_mode='static'))[2][0]
|
||||
assert rolling[1].mode == 'rolling'
|
||||
assert static[1].mode == 'static'
|
||||
assert _peak(rolling[0]) != pytest.approx(_peak(static[0]), rel=1e-6)
|
||||
|
||||
|
||||
def test_the_job_rigging_is_left_alone_by_a_stage_override():
|
||||
"""One stage overriding must not re-rig the stages after it."""
|
||||
job = _three_line_job()
|
||||
job['stages'] = [dict(job['stages'][0], name='stripping',
|
||||
rig_mode='static'),
|
||||
dict(job['stages'][0], name='erection')]
|
||||
_p, rigging, cases = cli.analyse_job(job)
|
||||
assert [c[1].mode for c in cases] == ['static', 'rolling']
|
||||
assert rigging.mode == 'rolling'
|
||||
|
||||
|
||||
def test_a_misspelt_rig_mode_is_refused_rather_than_inherited():
|
||||
"""'Static' silently inheriting 'rolling' would change every moment in the
|
||||
stage and look exactly like a stage that meant to inherit."""
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(_three_line_job(rig_mode='fixed'))
|
||||
assert 'rig_mode' in str(caught.value)
|
||||
|
||||
|
||||
def test_the_summary_says_which_mode_each_stage_used():
|
||||
"""A per-stage setting the output does not show is one nobody can check."""
|
||||
# The job rigs one way and the stage the other, so reporting the stage's
|
||||
# mode is the only way to pass: printing job['rigging']['mode'] would say
|
||||
# 'static' here and nobody reading the report would know the difference.
|
||||
job = aspire_job(rigging={'mode': 'static', 'sling_angle_deg': 90.0})
|
||||
job['stages'][0]['rig_mode'] = 'rolling'
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
assert cli.summarise(cases)['stages'][0]['rig_mode'] == 'rolling'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- supports
|
||||
|
||||
def _supported_job():
|
||||
job = aspire_job()
|
||||
job['stages'][0] = {
|
||||
'name': 'transportation',
|
||||
'pci_case': 'transportation',
|
||||
'multiplier': 1.4,
|
||||
'fc_psi': 5000.0,
|
||||
'supports': [0.2 * LENGTH_FT, 0.8 * LENGTH_FT],
|
||||
}
|
||||
return job
|
||||
|
||||
|
||||
def test_a_stage_with_supports_is_solved_without_a_pick():
|
||||
"""Dunnage is not rigging: no lifters, no sling, no insert findings."""
|
||||
_p, _r, cases = cli.analyse_job(_supported_job())
|
||||
analysis, rigging, findings = cases[0]
|
||||
assert analysis.rig.points == 0
|
||||
assert analysis.stage.supports == [0.2 * LENGTH_FT, 0.8 * LENGTH_FT]
|
||||
assert analysis.rig.lines[0].lifters == []
|
||||
codes = set(f.code for f in findings)
|
||||
assert 'INSERT' not in codes and 'INSERT-UNRATED' not in codes
|
||||
assert 'PLUMB' not in codes
|
||||
assert 'PICK' not in codes
|
||||
|
||||
|
||||
def test_two_symmetrical_supports_share_the_piece_evenly():
|
||||
_p, _r, cases = cli.analyse_job(_supported_job())
|
||||
total = cases[0][0].profile.total_lb
|
||||
reactions = [line.reaction_lb for line in cases[0][0].rig.lines]
|
||||
assert reactions[0] == pytest.approx(total / 2.0, rel=1e-6)
|
||||
assert reactions[1] == pytest.approx(total / 2.0, rel=1e-6)
|
||||
|
||||
|
||||
def test_the_summary_carries_the_support_positions():
|
||||
_p, _r, cases = cli.analyse_job(_supported_job())
|
||||
summary = cli.summarise(cases)
|
||||
assert summary['stages'][0]['supports'] == \
|
||||
[0.2 * LENGTH_FT, 0.8 * LENGTH_FT]
|
||||
|
||||
|
||||
def test_a_stage_cannot_name_both_supports_and_lifters():
|
||||
job = _supported_job()
|
||||
job['stages'][0]['lifters'] = ['L1', 'L2']
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(job)
|
||||
assert 'both supports and lifters' in str(caught.value)
|
||||
|
||||
|
||||
def test_one_support_line_is_refused():
|
||||
job = _supported_job()
|
||||
job['stages'][0]['supports'] = [0.2 * LENGTH_FT]
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(job)
|
||||
assert 'at least two supports' in str(caught.value)
|
||||
|
||||
|
||||
def test_a_support_outside_the_piece_is_refused():
|
||||
job = _supported_job()
|
||||
job['stages'][0]['supports'] = [0.2 * LENGTH_FT, LENGTH_FT + 1.0]
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(job)
|
||||
assert 'outside the piece' in str(caught.value)
|
||||
|
||||
|
||||
def test_an_empty_lifter_list_does_not_contradict_a_support_line():
|
||||
"""The Revit exporter writes `lifters: []` on a stage it turned into
|
||||
supports, and an empty list names no insert. Refusing it made every job
|
||||
file a plant with dunnage configured could write unreadable -- which no
|
||||
test caught, because the shape came from the other side of the language
|
||||
boundary and the tests here built their own."""
|
||||
job = aspire_job()
|
||||
haul = dict(job['stages'][0], name='transportation', lifters=[],
|
||||
supports=[8.0, 24.0])
|
||||
job['stages'] = [job['stages'][0], haul]
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
assert cases[1][0].stage.supports == [8.0, 24.0]
|
||||
|
||||
|
||||
def test_a_support_line_and_a_named_insert_are_still_refused():
|
||||
job = aspire_job()
|
||||
haul = dict(job['stages'][0], name='transportation', lifters=['L1'],
|
||||
supports=[8.0, 24.0])
|
||||
job['stages'] = [haul]
|
||||
with pytest.raises(cli.JobError) as caught:
|
||||
cli.analyse_job(job)
|
||||
assert 'one or the other' in str(caught.value)
|
||||
@@ -0,0 +1,285 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The compression limit is a job decision too, following the tensile one.
|
||||
|
||||
`compression_limit_ratio` multiplies f'c (f'ci at stripping, since the ratio
|
||||
reads whatever `Stage.fc_psi` the stage was built with) to give the
|
||||
compression allowable in psi. Unlike the tensile limit, the owner did not
|
||||
leave two schools of thought open here -- he closed the question,
|
||||
2026-09-01:
|
||||
|
||||
"0.6保持可调" ("0.6 stays adjustable")
|
||||
|
||||
0.6 remains the shipped default (ACI 318-19 Table 24.5.3.1, "all other
|
||||
locations", transfer; Sec. 24.5.4.1, service) and becomes a per-job,
|
||||
per-stage field exactly like `tension_limit_sqrt_fc`: a job file may state
|
||||
it, a `stages[]` entry may override it for that stage, and
|
||||
`compression_limit_source` says which. ACI's higher 0.70*f'ci at member ends
|
||||
is deliberately NOT implemented -- "先不用实现0.7...别搞这么复杂" -- so
|
||||
there is one ratio for the whole piece, and nothing here tests a location
|
||||
split.
|
||||
|
||||
Mirrors `test_tension_limit.py` structurally; see that file's docstring for
|
||||
the reasoning behind the pattern.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from panellift import checks, cli, layout, report
|
||||
from panellift.checks import Finding, StressPoint
|
||||
from panellift.model import COMPRESSION_LIMIT_SOURCES, Panel, Stage
|
||||
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT
|
||||
from panellift import pci
|
||||
|
||||
|
||||
# --------------------------------------------------- the model-level ratio
|
||||
|
||||
def test_the_default_ratio_and_source():
|
||||
"""Byte-identity when nobody sets it -- the same contract
|
||||
`tension_limit_sqrt_fc` gives at 5.0."""
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
|
||||
assert stage.compression_limit_ratio == 0.6
|
||||
assert stage.compression_limit_source == 'default'
|
||||
|
||||
|
||||
def test_the_ratio_multiplies_the_stages_own_strength():
|
||||
"""The requirement this whole feature turns on: the ratio multiplies
|
||||
THIS STAGE'S f'c/f'ci, not the job's f'c or a strength borrowed from
|
||||
another stage. Two stages with the same ratio and different strengths
|
||||
(f'ci at stripping vs f'c at yard, say) must scale independently."""
|
||||
stripping = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
|
||||
compression_limit_ratio=0.6)
|
||||
yard = Stage(name='yard', multiplier=1.0, fc_psi=5000.0,
|
||||
compression_limit_ratio=0.6)
|
||||
assert stripping.allowable_compression_psi == pytest.approx(1800.0)
|
||||
assert yard.allowable_compression_psi == pytest.approx(3000.0)
|
||||
# Same ratio, different psi allowable, because the strength that changed
|
||||
# was the STAGE's, not the ratio's.
|
||||
assert stripping.allowable_compression_psi != yard.allowable_compression_psi
|
||||
|
||||
|
||||
def test_a_different_ratio_moves_the_same_stages_allowable():
|
||||
"""The other half of the same requirement, isolating the ratio instead
|
||||
of the strength: hold f'ci fixed and change only the ratio."""
|
||||
base = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
|
||||
compression_limit_ratio=0.6)
|
||||
relaxed = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0,
|
||||
compression_limit_ratio=0.7)
|
||||
assert base.allowable_compression_psi == pytest.approx(1800.0)
|
||||
assert relaxed.allowable_compression_psi == pytest.approx(2100.0)
|
||||
|
||||
|
||||
def test_compression_basis_names_ratio_and_source():
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
|
||||
compression_limit_ratio=0.7, compression_limit_source='job')
|
||||
assert stage.compression_basis == "0.7*f'c, job"
|
||||
|
||||
|
||||
def test_the_basis_string_is_ascii():
|
||||
"""Findings are printed to whatever console launched the run -- no
|
||||
character here may be outside cp1252."""
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
|
||||
stage.compression_basis.encode('ascii')
|
||||
|
||||
|
||||
def test_an_unrecognised_source_word_prints_stated_not_a_guess():
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
|
||||
compression_limit_source='typo')
|
||||
assert stage.compression_limit_source_word == 'stated'
|
||||
|
||||
|
||||
# -------------------------------------------------- the check actually uses it
|
||||
|
||||
def _points(compression_psi, prestress_lb=1000.0):
|
||||
return [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0,
|
||||
stress_psi=0.0, compression_psi=compression_psi,
|
||||
prestress_lb=prestress_lb)]
|
||||
|
||||
|
||||
def test_no_prestress_means_no_compression_finding():
|
||||
"""Compression only becomes a question once there is prestress -- see
|
||||
`check_compression`'s own docstring. Unaffected by this field."""
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
|
||||
points = [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0, stress_psi=0.0,
|
||||
compression_psi=500.0, prestress_lb=0.0)]
|
||||
assert checks.check_compression(points, stage) == []
|
||||
|
||||
|
||||
def test_the_ratio_decides_the_verdict():
|
||||
"""0.6*4000 = 2400 psi allowable. 2000 psi passes comfortably; 2500
|
||||
fails; the stage's OWN ratio is what makes the difference, not a
|
||||
hard-coded 0.6 inside the check."""
|
||||
stage_default = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
|
||||
assert checks.check_compression(_points(2000.0), stage_default)[0] \
|
||||
.severity == 'OK'
|
||||
assert checks.check_compression(_points(2500.0), stage_default)[0] \
|
||||
.severity == 'FAIL'
|
||||
# The same 2500 psi passes once the job relaxes the ratio: proof the
|
||||
# number that moved the verdict was the ratio, not the stress.
|
||||
stage_relaxed = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
|
||||
compression_limit_ratio=0.7,
|
||||
compression_limit_source='job')
|
||||
assert checks.check_compression(_points(2500.0), stage_relaxed)[0] \
|
||||
.severity == 'OK'
|
||||
|
||||
|
||||
def test_every_compression_finding_states_the_basis():
|
||||
"""CRUSH is the check the ratio decides, and its line has to carry the
|
||||
ratio and who chose it beside the psi -- exactly as CRACK does for
|
||||
tension."""
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0,
|
||||
compression_limit_ratio=0.7, compression_limit_source='job')
|
||||
found = checks.check_compression(_points(2500.0), stage)
|
||||
assert found
|
||||
assert "0.7*f'c, job" in found[0].message
|
||||
|
||||
|
||||
# ------------------------------------------------------- the deck round trip
|
||||
|
||||
def _panel():
|
||||
return Panel(mark='p', length_ft=1.0, width_ft=1.0, thickness_in=1.0)
|
||||
|
||||
|
||||
def test_absent_from_the_deck_is_the_default():
|
||||
"""A job file written before this field existed leaves both keys out,
|
||||
and that has to read exactly as it always has: 0.6, default."""
|
||||
stage, _marks = cli.read_stage(
|
||||
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0}, 0, _panel())
|
||||
assert stage.compression_limit_ratio == 0.6
|
||||
assert stage.compression_limit_source == 'default'
|
||||
|
||||
|
||||
def test_a_stated_ratio_is_read_as_job():
|
||||
stage, _marks = cli.read_stage(
|
||||
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
|
||||
'compression_limit_ratio': 0.7}, 0, _panel())
|
||||
assert stage.compression_limit_ratio == 0.7
|
||||
assert stage.compression_limit_source == 'job'
|
||||
|
||||
|
||||
def test_the_exporter_can_say_a_stage_overrode_the_job():
|
||||
stage, _marks = cli.read_stage(
|
||||
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
|
||||
'compression_limit_ratio': 0.7,
|
||||
'compression_limit_source': 'stage'}, 0, _panel())
|
||||
assert stage.compression_limit_source == 'stage'
|
||||
|
||||
|
||||
def test_an_unknown_provenance_word_is_refused_not_displayed():
|
||||
with pytest.raises(cli.JobError) as exc:
|
||||
cli.read_stage(
|
||||
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
|
||||
'compression_limit_ratio': 0.7,
|
||||
'compression_limit_source': 'jobb'}, 0, _panel())
|
||||
assert 'compression_limit_source' in str(exc.value)
|
||||
|
||||
|
||||
def test_a_non_numeric_ratio_is_refused():
|
||||
"""Mirrors `tension_limit_sqrt_fc`'s own refusal shape: garbage is
|
||||
refused loudly, never silently coerced to the 0.6 default."""
|
||||
with pytest.raises(cli.JobError) as exc:
|
||||
cli.read_stage(
|
||||
{'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0,
|
||||
'compression_limit_ratio': 'static_lift'}, 0, _panel())
|
||||
assert 'compression_limit_ratio' in str(exc.value)
|
||||
|
||||
|
||||
def test_the_provenance_words_are_the_ones_the_model_knows():
|
||||
assert set(COMPRESSION_LIMIT_SOURCES) == set(['default', 'job', 'stage'])
|
||||
|
||||
|
||||
# ------------------------------------------------ end to end, with a real pick
|
||||
|
||||
def _job(compression_limit_ratio=None, compression_limit_source=None):
|
||||
"""The ASPIRE panel, prestressed at mid-depth (e=0) so the only thing
|
||||
the strands add is uniform P/A compression -- enough to exercise
|
||||
`check_compression` without pulling in the eccentricity mechanics
|
||||
`test_prestress.py` already covers on its own piece."""
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
pull = 0.153 * 270_000.0 * 0.75
|
||||
mid_z = THICKNESS_IN / 2.0
|
||||
stage = {'name': 'stripping',
|
||||
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
|
||||
'fc_psi': 4000.0}
|
||||
if compression_limit_ratio is not None:
|
||||
stage['compression_limit_ratio'] = compression_limit_ratio
|
||||
if compression_limit_source is not None:
|
||||
stage['compression_limit_source'] = compression_limit_source
|
||||
return {
|
||||
'schema': 'panellift/1',
|
||||
'panel': {
|
||||
'mark': 'ASPIRE-1',
|
||||
'length_ft': LENGTH_FT,
|
||||
'width_ft': WIDTH_FT,
|
||||
'thickness_in': THICKNESS_IN,
|
||||
'unit_weight_pcf': 150.0,
|
||||
'fci_psi': 4000.0,
|
||||
'fc_psi': 5000.0,
|
||||
'lifters': [
|
||||
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
|
||||
'y': 2.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
|
||||
'y': 7.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
|
||||
'y': 2.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
|
||||
'y': 7.5, 'capacity_lb': 12000.0},
|
||||
],
|
||||
'prestress': {
|
||||
'strands': [
|
||||
{'y': 2.5, 'z_in': mid_z, 'initial_pull_lb': pull},
|
||||
{'y': 7.5, 'z_in': mid_z, 'initial_pull_lb': pull},
|
||||
],
|
||||
'loss_pct': 8.0,
|
||||
},
|
||||
},
|
||||
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
|
||||
'stages': [stage],
|
||||
}
|
||||
|
||||
|
||||
def _analysed(**kw):
|
||||
_panel, _rigging, cases = cli.analyse_job(_job(**kw))
|
||||
return cases[0]
|
||||
|
||||
|
||||
def test_the_stage_carries_the_ratio_through_analyse_job():
|
||||
stage = _analysed(compression_limit_ratio=0.7,
|
||||
compression_limit_source='job')[0].stage
|
||||
assert stage.compression_limit_ratio == 0.7
|
||||
assert stage.compression_limit_source == 'job'
|
||||
assert stage.allowable_compression_psi == pytest.approx(0.7 * 4000.0)
|
||||
|
||||
|
||||
def test_a_real_pick_produces_a_named_crush_finding():
|
||||
"""End to end: a prestressed piece really does report compression, and
|
||||
the line names the ratio and who chose it -- the report-side
|
||||
requirement, exercised through the actual arithmetic rather than a
|
||||
synthetic StressPoint."""
|
||||
findings = _analysed(compression_limit_ratio=0.6,
|
||||
compression_limit_source='default')[2]
|
||||
crush = [f for f in findings if f.code == 'CRUSH']
|
||||
assert crush
|
||||
assert "0.6*f'c, default" in crush[0].message
|
||||
|
||||
|
||||
def test_the_default_note_cites_aci_and_the_conservative_choice():
|
||||
"""The report's note beside the compression allowable has to name the
|
||||
source -- and when that source is the untouched default, cite ACI and
|
||||
say plainly that the flat ratio (vs. ACI's 0.70 at member ends) is a
|
||||
deliberate, conservative simplification."""
|
||||
page = report.build_many(
|
||||
[_analysed(compression_limit_ratio=0.6,
|
||||
compression_limit_source='default')],
|
||||
recommendation=layout.not_searched())
|
||||
assert '24.5.3.1' in page or '24.5.4.1' in page
|
||||
assert '0.70' in page
|
||||
assert 'ACI' in page
|
||||
|
||||
|
||||
def test_a_job_set_ratio_does_not_get_the_citation():
|
||||
"""The citation belongs to the number nobody set. A job that chose its
|
||||
own ratio does not need ACI quoted back at it."""
|
||||
page = report.build_many(
|
||||
[_analysed(compression_limit_ratio=0.7,
|
||||
compression_limit_source='job')],
|
||||
recommendation=layout.not_searched())
|
||||
assert 'Compression allowable:' not in page
|
||||
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""An insert is worth less at release than at 28 days.
|
||||
|
||||
A catalogue rating is derived at one concrete strength and the anchor pulls a
|
||||
cone of concrete out, so below that strength the capacity follows sqrt(f'c).
|
||||
One handling report checks the same insert at stripping AND at erection, which
|
||||
is why the rating cannot be derated once on the way in: it has to be done per
|
||||
stage, at the strength that stage actually has.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import make_panel
|
||||
from panellift import checks, pci, statics
|
||||
from panellift.model import Stage
|
||||
|
||||
|
||||
def rated_panel(basis=3500.0, capacity=8000.0):
|
||||
panel = make_panel()
|
||||
return dataclasses.replace(panel, lifters=[
|
||||
dataclasses.replace(l, capacity_lb=capacity, basis_fc_psi=basis)
|
||||
for l in panel.lifters])
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the factor
|
||||
|
||||
def test_below_the_basis_it_follows_the_square_root():
|
||||
assert pci.insert_derate(3500.0, 2500.0) == pytest.approx(0.8452, abs=1e-4)
|
||||
|
||||
|
||||
def test_at_or_above_the_basis_nothing_is_gained():
|
||||
"""Past the basis the anchor's own steel governs; stronger concrete around
|
||||
it does not make the anchor stronger."""
|
||||
assert pci.insert_derate(3500.0, 3500.0) == 1.0
|
||||
assert pci.insert_derate(3500.0, 6000.0) == 1.0
|
||||
|
||||
|
||||
def test_an_unstated_basis_does_not_silently_reduce_a_rating():
|
||||
assert pci.insert_derate(None, 2500.0) == 1.0
|
||||
assert pci.insert_derate(3500.0, None) == 1.0
|
||||
|
||||
|
||||
# --------------------------------------------------------- through a check
|
||||
|
||||
def test_the_same_insert_is_rated_differently_at_two_stages():
|
||||
panel = rated_panel()
|
||||
lifter = panel.lifters[0]
|
||||
at_release = Stage('stripping', 1.3, 2500.0)
|
||||
at_28 = Stage('erection', 1.2, 6000.0)
|
||||
assert checks.rated(lifter, at_release) == pytest.approx(6761, abs=1)
|
||||
assert checks.rated(lifter, at_28) == pytest.approx(8000, abs=1)
|
||||
|
||||
|
||||
def test_the_capacity_check_uses_the_derated_number(rolling):
|
||||
panel = rated_panel()
|
||||
stage = Stage('stripping', 1.3, 2500.0)
|
||||
a = statics.analyse(panel, stage, rolling, role='strip')
|
||||
inserts = [f for f in checks.check_insert_capacity(a, rolling)
|
||||
if f.code == 'INSERT']
|
||||
assert inserts
|
||||
assert 'derated from 8,000 lb' in inserts[0].message
|
||||
|
||||
|
||||
def test_no_basis_means_the_rating_is_taken_as_stated(rolling):
|
||||
"""A plant that types a number into capacity_lb meant that number."""
|
||||
panel = rated_panel(basis=None)
|
||||
stage = Stage('stripping', 1.3, 2500.0)
|
||||
a = statics.analyse(panel, stage, rolling, role='strip')
|
||||
inserts = [f for f in checks.check_insert_capacity(a, rolling)
|
||||
if f.code == 'INSERT']
|
||||
assert inserts and 'derated' not in inserts[0].message
|
||||
@@ -0,0 +1,233 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The detailed Vci/Vcw shear method: `capacity.detailed_shear_at`.
|
||||
|
||||
Parity gap #6 -- our simplified V_c = 2*sqrt(f'c)*bw*d does not reproduce the
|
||||
reference tool's (Eriksson Wall) PCI detailed min(Vci, Vcw), which left the
|
||||
handling shear utilisation low by -35% (MWV1.0) to -54% (WPB3.0) on the parity
|
||||
sweep. The formulas are `docs/eriksson-wall-algorithms.md` sec.3, transcribed
|
||||
from that tool's QC document (QC p.37 prestressed, QC p.76 non-prestressed).
|
||||
|
||||
Every number below is HAND COMPUTED, the arithmetic written into the comment
|
||||
beside it, on a rectangular prestressed section chosen so the closed forms are
|
||||
exact -- the same discipline `test_capacity.py` uses:
|
||||
|
||||
b = 48 in. t = 8 in. L = 32 ft f'c = 4000 psi w_c = 150 pcf
|
||||
4 strands, 0.153 in.^2 each, all at z = 2.0 in. above the form face
|
||||
jacked at 30,000 lb each, 10% loss, l_t stated at 30 in.
|
||||
|
||||
f'c = 4000 puts sqrt(f'c) = 63.24555..., w_c = 150 puts lambda = 1.0, and a
|
||||
mid-span station (x = 16 ft, no blockout) develops every strand fully, so:
|
||||
|
||||
Pi = 4 * 30000 * (1 - 0.10) = 108,000 lb
|
||||
A = 48 * 8 = 384 in^2
|
||||
fpc = Pi/A = 281.25 psi (centroid, Vcw)
|
||||
dv = 0.8 * 8 = 6.40 in (ACI 22.5.8.3.1)
|
||||
b*d = 48 * 6.40 = 307.2 in^2
|
||||
I = 48 * 8^3 / 12 = 2048 in^4
|
||||
St = I / (t/2) = 2048 / 4 = 512 in^3
|
||||
e = z_strand - t/2 = 2.0 - 4.0 = -2.0 in (low strand)
|
||||
fpe = Pi/A + Pi*|e|*(t/2)/I (compression at
|
||||
= 281.25 + 108000*2.0*4/2048 = 703.125 psi the sagging tension
|
||||
fibre, for Mcre)
|
||||
Mcre = St*(6*sqrt(f'c) + fpe) (PCI 5-26)
|
||||
= 512*(6*63.24555 + 703.125) = 554,290.4 lb-in
|
||||
= 46,190.86 lb-ft
|
||||
Vcw = (3.5*sqrt(f'c) + 0.3*fpc)*b*d (PCI 5-28)
|
||||
= (3.5*63.24555 + 0.3*281.25)*307.2
|
||||
= (221.3594 + 84.375)*307.2 = 93,921.62 lb
|
||||
|
||||
The pins are WRITTEN-DOWN goldens, not recomputations. A test that rebuilds
|
||||
both sides agrees with any refactor, including a wrong one -- PROJECT.md 3.7.
|
||||
"""
|
||||
import math
|
||||
|
||||
from panellift import capacity, pci
|
||||
from panellift.model import Blockout, Panel, Prestress, Stage, Strand
|
||||
|
||||
|
||||
SQRT_4000 = math.sqrt(4000.0) # 63.245553...
|
||||
MID_X = 16.0 # mid-span: every strand fully developed
|
||||
|
||||
|
||||
def _pre_panel(blockouts=None):
|
||||
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
|
||||
initial_pull_lb=30000.0)
|
||||
for i in range(4)]
|
||||
return Panel(
|
||||
mark='HS-PRE', length_ft=32.0, width_ft=48.0 / 12.0, thickness_in=8.0,
|
||||
unit_weight_pcf=150.0, fci_psi=4000.0, fc_psi=4000.0,
|
||||
blockouts=list(blockouts or []),
|
||||
prestress=Prestress(strands=strands, loss_pct=10.0, diameter_in=0.5,
|
||||
transfer_length_in=30.0))
|
||||
|
||||
|
||||
def _stage():
|
||||
return Stage(name='strip', multiplier=1.4, fc_psi=4000.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------- the shared terms
|
||||
|
||||
def test_section_terms_match_the_hand_calc():
|
||||
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0,
|
||||
8000.0)
|
||||
assert d.prestressed is True
|
||||
assert abs(d.dv_in - 6.40) < 1e-9 # 0.8 * 8
|
||||
assert abs(d.bd_in2 - 307.2) < 1e-9 # 48 * 6.40
|
||||
assert abs(d.fpc_psi - 281.25) < 1e-6 # Pi/A = 108000/384
|
||||
assert abs(d.fpe_psi - 703.125) < 1e-6 # 281.25 + 108000*2*4/2048
|
||||
assert abs(d.mcre_lbft - 46190.8616) < 1e-2 # St*(6 sqrt f'c + fpe)/12
|
||||
assert abs(d.vcw_lb - 93921.6188) < 1e-2 # (3.5 sqrt f'c + 0.3 fpc)bd
|
||||
|
||||
|
||||
# --------------------------------------------------------- Vci.1 governs
|
||||
|
||||
def test_vci_flexure_shear_term_governs_above_the_floor():
|
||||
"""M = 10,000 lb-ft, V = 8,000 lb -- Vci.1 clears the 1.7 floor.
|
||||
|
||||
Vci.1 = 0.6*sqrt(f'c)*b*d + Vu*Mcre/Mmax (PCI 5-25)
|
||||
= 0.6*63.24555*307.2 + 8000*46190.86/10000
|
||||
= 11,657.36 + 36,952.69 = 48,610.11 lb
|
||||
Vci.2 = 1.7*sqrt(f'c)*b*d = 1.7*63.24555*307.2 = 33,029.36 lb [floor]
|
||||
Vci = max(48,610.11, 33,029.36) = 48,610.11 lb
|
||||
Vcw = 93,921.62 lb ; Vc = min = 48,610.11 lb
|
||||
phiVn = 0.75 * 48,610.11 = 36,457.58 lb
|
||||
util = 8000 / 36,457.58 = 0.219433
|
||||
"""
|
||||
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0,
|
||||
8000.0)
|
||||
assert d.governed_by == 'Vci'
|
||||
assert abs(d.vci_lb - 48610.1097) < 1e-2
|
||||
assert abs(d.vc_lb - 48610.1097) < 1e-2
|
||||
assert abs(d.phi_vn_lb - 36457.5822) < 1e-2
|
||||
assert abs(d.v_over_phi_vn - 0.2194331) < 1e-6
|
||||
|
||||
|
||||
# --------------------------------------------------------- the 1.7 floor
|
||||
|
||||
def test_vci_floor_governs_when_the_flexure_shear_term_is_small():
|
||||
"""M = 20,000 lb-ft, V = 8,000 lb -- Vci.1 falls under the floor.
|
||||
|
||||
Vci.1 = 11,657.36 + 8000*46190.86/20000
|
||||
= 11,657.36 + 18,476.34 = 30,133.70 lb
|
||||
Vci.2 = 33,029.36 lb [floor wins]
|
||||
Vci = max(30,133.70, 33,029.36) = 33,029.36 lb
|
||||
Vc = min(33,029.36, 93,921.62) = 33,029.36 lb
|
||||
phiVn = 0.75 * 33,029.36 = 24,772.02 lb
|
||||
util = 8000 / 24,772.02 = 0.322945
|
||||
|
||||
This is the 1.7*sqrt(f'c)*bw*d FLOOR (PCI 5-27), a max and not a cap -- the
|
||||
correction #4 in docs/eriksson-wall-algorithms.md. It is exactly this floor
|
||||
that lands MWV1.0 on Eriksson's 0.19 in the parity harness (the reduced
|
||||
end section where Vci.1 sits below it); dropping the floor overshoots.
|
||||
"""
|
||||
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 20000.0,
|
||||
8000.0)
|
||||
assert d.governed_by == 'Vci'
|
||||
assert abs(d.vci_lb - 33029.3577) < 1e-2 # the floor, 1.7 sqrt f'c bd
|
||||
assert abs(d.vc_lb - 33029.3577) < 1e-2
|
||||
assert abs(d.v_over_phi_vn - 0.3229450) < 1e-6
|
||||
|
||||
|
||||
# ---------------------------------------------- zero moment -> Vcw governs
|
||||
|
||||
def test_zero_moment_station_sends_vci_to_infinity_and_vcw_governs():
|
||||
"""M -> 0 : Vci.1 -> +inf, so Vci = +inf and Vc = Vcw (docs sec.3.1).
|
||||
|
||||
This is the reference tool's printed "inf" in the Vci column at a
|
||||
zero-moment station (QC p.15/p.18). Vcw is independent of the moment:
|
||||
|
||||
Vc = Vcw = 93,921.62 lb
|
||||
phiVn = 0.75 * 93,921.62 = 70,441.21 lb
|
||||
util = 8000 / 70,441.21 = 0.113570
|
||||
"""
|
||||
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 0.0, 8000.0)
|
||||
assert d.vci_lb == float('inf')
|
||||
assert d.governed_by == 'Vcw'
|
||||
assert abs(d.vc_lb - 93921.6188) < 1e-2
|
||||
assert abs(d.phi_vn_lb - 70441.2141) < 1e-2
|
||||
assert abs(d.v_over_phi_vn - 0.1135699) < 1e-6
|
||||
|
||||
|
||||
def test_a_moment_just_under_one_lbft_is_treated_as_zero():
|
||||
"""The Mmax->0 guard fires at the same 1 lb-ft floor the ratios use."""
|
||||
d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 0.5, 8000.0)
|
||||
assert d.vci_lb == float('inf')
|
||||
assert d.governed_by == 'Vcw'
|
||||
|
||||
|
||||
# --------------------------------------------------- non-prestressed path
|
||||
|
||||
def test_non_prestressed_uses_the_aci_max_not_a_cap():
|
||||
"""ACI 318-14 22.5.6.1, phi*Vn = 0.75*max(Vc, Vc.min) -- QC p.76.
|
||||
|
||||
Same rectangular section, no strand, no axial (Nu = 0):
|
||||
|
||||
Vc = 2*lambda*(1 + 0)*sqrt(f'c)*bw*d
|
||||
= 2*63.24555*307.2 = 38,858.07 lb
|
||||
Vc.min = (4/3)*lambda*sqrt(f'c)*Ag
|
||||
= 1.33333*63.24555*384 = 32,381.72 lb
|
||||
Vc = max(38,858.07, 32,381.72) = 38,858.07 lb (Vc governs)
|
||||
phiVn = 0.75 * 38,858.07 = 29,143.55 lb
|
||||
|
||||
It is a MAX, not a cap (docs correction #5, a genuine surprise). With
|
||||
dv = 0.8h the flexure term always clears the (4/3)Ag floor for a solid
|
||||
rectangle at Nu = 0, so Vc governs here; the branch is still a `max`, which
|
||||
is asserted directly below so a future edit to a `min`/cap is caught.
|
||||
"""
|
||||
panel = Panel(mark='HS-NP', length_ft=32.0, width_ft=48.0 / 12.0,
|
||||
thickness_in=8.0, unit_weight_pcf=150.0,
|
||||
fci_psi=4000.0, fc_psi=4000.0)
|
||||
d = capacity.detailed_shear_at(panel, _stage(), MID_X, 5000.0, 8000.0)
|
||||
assert d.prestressed is False
|
||||
assert d.governed_by == 'Vc'
|
||||
vc_flex = 2.0 * SQRT_4000 * 0.8 * 384.0 # 38,858.07
|
||||
vc_min = (4.0 / 3.0) * SQRT_4000 * 384.0 # 32,381.72
|
||||
assert abs(d.vc_lb - vc_flex) < 1e-2
|
||||
assert abs(d.vc_lb - max(vc_flex, vc_min)) < 1e-9 # a MAX, never a cap
|
||||
assert d.vc_lb > vc_min # the floor did not win
|
||||
assert abs(d.phi_vn_lb - 29143.5509) < 1e-2
|
||||
|
||||
|
||||
# -------------------------------------------------- additive: nothing moved
|
||||
|
||||
def test_simplified_shear_capacity_is_untouched():
|
||||
"""`capacity_at` and its simplified V_c are what Panel Lift Check reads;
|
||||
the detailed path must not change them.
|
||||
|
||||
V_c(simplified) = 2*lambda*sqrt(f'c)*bw*d, d = max(dp, 0.8h)
|
||||
Here dp(sagging) = t - z = 6.0 in, floored at 0.8h = 6.4 in, so d = 6.4:
|
||||
V_c = 2*63.24555*48*6.4 = 38,858.07 lb
|
||||
phiVn = 0.75 * 38,858.07 = 29,143.55 lb
|
||||
"""
|
||||
cap = capacity.capacity_at(_pre_panel(), _stage(), MID_X, 10000.0, 8000.0)
|
||||
assert abs(cap.phi_vn_lb - 29143.5509) < 1e-2
|
||||
assert abs(cap.d_shear_in - 6.4) < 1e-9 # 0.8h floor, dp = 6.0 < 6.4
|
||||
|
||||
|
||||
def test_detailed_and_simplified_disagree_by_design():
|
||||
"""The whole point of the gap: the two methods give different V_c, and the
|
||||
detailed one is what matches Eriksson. At this station Vci (48,610) beats
|
||||
the simplified V_c (38,858), so the detailed util is LOWER here -- but at a
|
||||
reduced section where the 1.7 floor bites (a blockout), the detailed util
|
||||
runs HIGHER, which is what closes MWV1.0's -35%."""
|
||||
stage = _stage()
|
||||
det = capacity.detailed_shear_at(_pre_panel(), stage, MID_X, 10000.0, 8000.0)
|
||||
old = capacity.capacity_at(_pre_panel(), stage, MID_X, 10000.0, 8000.0)
|
||||
assert det.phi_vn_lb != old.phi_vn_lb
|
||||
|
||||
|
||||
# ------------------------------------------ prestress reduced at a blockout
|
||||
|
||||
def test_undeveloped_strand_lowers_fpc_and_fpe():
|
||||
"""A blockout that cuts strands drops Pi at the station, so fpc and fpe
|
||||
fall with it -- the detailed shear reads the SAME developed prestress the
|
||||
stress and flexure paths do (prestress.state_at), never the full jacking
|
||||
force. A full-depth blockout over the whole width from x=10 to x=14 cuts
|
||||
every strand; just past it at x=15 the strands are still redeveloping."""
|
||||
blocked = _pre_panel(blockouts=[Blockout(
|
||||
name='O', x0=10.0, y0=0.0, length=4.0, width=48.0 / 12.0)])
|
||||
near = capacity.detailed_shear_at(blocked, _stage(), 15.0, 10000.0, 8000.0)
|
||||
far = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0,
|
||||
8000.0)
|
||||
assert near.fpc_psi < far.fpc_psi # less strand developed here
|
||||
assert near.vcw_lb < far.vcw_lb # so Vcw is smaller too
|
||||
@@ -0,0 +1,130 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The reference tool's (Eriksson Wall) §1.3 developed-strand-at-opening rule:
|
||||
a strand a blockout SAW-CUT re-develops over the DEVELOPMENT length from that
|
||||
edge, while a strand's transfer from the PIECE END is unchanged.
|
||||
|
||||
docs/eriksson-wall-algorithms.md sec.1.3 / sec.9. ADDITIVE and OPT-IN: it fires
|
||||
only when a caller passes `ld_ft` (the handling STRESS path,
|
||||
`capacity.handling_stress_at`). `prestress.state_at` with no `ld_ft` -- which is
|
||||
what Panel Lift Check's `checks.stress_profile` uses -- keeps the physically
|
||||
correct transfer-length force model, so a clean solid panel is byte-for-byte
|
||||
what it was (validated against Eriksson to ~1%).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from panellift import capacity, prestress
|
||||
from panellift.model import Blockout, Panel, Prestress, Stage, Strand
|
||||
|
||||
|
||||
def _panel_with_one_opening():
|
||||
"""20 ft panel, a full-depth opening x = 8..12 across the strand's y band.
|
||||
|
||||
The single strand at y = 2 is cut by it, so it has two bonded runs,
|
||||
[0, 8] and [12, 20]. l_t = 2 ft (stated), and the tests pass l_d = 5 ft.
|
||||
"""
|
||||
strand = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0)
|
||||
pre = Prestress(strands=[strand], loss_pct=0.0, diameter_in=0.5,
|
||||
fpu_ksi=270.0, transfer_length_in=24.0) # l_t = 2 ft
|
||||
panel = Panel(mark='O', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
|
||||
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
|
||||
prestress=pre)
|
||||
panel.blockouts = [Blockout(name='O1', x0=8.0, y0=0.0, length=4.0,
|
||||
width=4.0)]
|
||||
return panel, strand
|
||||
|
||||
|
||||
def test_developed_uses_development_length_from_an_opening_edge():
|
||||
"""At x = 6, 2 ft short of the opening edge at x = 8, over l_d = 5:
|
||||
fraction = (8 - 6)/5 = 0.4 -- against 1.0 with transfer only.
|
||||
The piece-end side (x = 6, l_t = 2) is already full, so the opening edge
|
||||
governs.
|
||||
"""
|
||||
panel, strand = _panel_with_one_opening()
|
||||
# transfer only: (8-6)/2 = 1.0 on the opening side -> full
|
||||
assert prestress.developed(strand, panel, 6.0, lt_ft=2.0) == pytest.approx(1.0)
|
||||
# development length from the opening edge: (8-6)/5 = 0.4
|
||||
assert prestress.developed(strand, panel, 6.0, lt_ft=2.0,
|
||||
ld_ft=5.0) == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_developed_keeps_transfer_length_from_the_piece_end():
|
||||
"""At x = 1, near the piece end (x = 0), the transfer length still governs
|
||||
even when l_d is supplied: (1 - 0)/2 = 0.5, the same both ways. Releasing
|
||||
at the bed transfers f_se over l_t; only a saw-cut rebuilds over l_d."""
|
||||
panel, strand = _panel_with_one_opening()
|
||||
assert prestress.developed(strand, panel, 1.0, lt_ft=2.0) == pytest.approx(0.5)
|
||||
assert prestress.developed(strand, panel, 1.0, lt_ft=2.0,
|
||||
ld_ft=5.0) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_developed_on_the_far_side_of_the_opening():
|
||||
"""In the second run [12, 20], the near edge x = 12 is an OPENING edge, so
|
||||
at x = 14: (14 - 12)/5 = 0.4 with l_d, 1.0 with transfer only."""
|
||||
panel, strand = _panel_with_one_opening()
|
||||
assert prestress.developed(strand, panel, 14.0, lt_ft=2.0) == pytest.approx(1.0)
|
||||
assert prestress.developed(strand, panel, 14.0, lt_ft=2.0,
|
||||
ld_ft=5.0) == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_state_at_ld_reduces_pi_near_an_opening():
|
||||
"""`state_at(..., ld_ft=5)` credits less prestress force approaching an
|
||||
opening than the transfer-only default. Pi = 30000 * developed."""
|
||||
panel, _ = _panel_with_one_opening()
|
||||
s_transfer = prestress.state_at(panel, 6.0)
|
||||
s_dev = prestress.state_at(panel, 6.0, ld_ft=5.0)
|
||||
assert s_transfer.force_lb == pytest.approx(30000.0) # full
|
||||
assert s_dev.force_lb == pytest.approx(30000.0 * 0.4) # 12000
|
||||
|
||||
|
||||
def test_clean_panel_is_untouched_by_the_ld_rule():
|
||||
"""A panel with NO openings has only piece-end boundaries, so passing l_d
|
||||
changes nothing -- the WPA2.0 clean-panel match is preserved. Checked at
|
||||
several stations, near end and midspan alike."""
|
||||
strand = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0)
|
||||
pre = Prestress(strands=[strand], loss_pct=0.0, diameter_in=0.5,
|
||||
fpu_ksi=270.0, transfer_length_in=24.0)
|
||||
panel = Panel(mark='C', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
|
||||
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
|
||||
prestress=pre) # no blockouts
|
||||
for x in (0.5, 1.5, 5.0, 10.0, 18.5, 19.5):
|
||||
a = prestress.state_at(panel, x)
|
||||
b = prestress.state_at(panel, x, ld_ft=5.0)
|
||||
assert a.force_lb == pytest.approx(b.force_lb)
|
||||
assert a.cy_in == pytest.approx(b.cy_in)
|
||||
|
||||
|
||||
def test_stress_development_length_is_built_on_the_transfer_length():
|
||||
"""The representative l_d handling_stress_at uses is l_t + (f_ps - f_se) d_b
|
||||
-- the ACI 25.4.8.1 form on the SAME transfer length the force model uses,
|
||||
so it is always at least the transfer length."""
|
||||
panel, _ = _panel_with_one_opening()
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
|
||||
ld = capacity.stress_development_length_ft(panel, stage)
|
||||
lt = prestress.transfer_length_in(panel.prestress) / 12.0
|
||||
assert ld >= lt
|
||||
assert ld == pytest.approx(2.0, abs=1e-9) or ld > 2.0 # l_t was 2.0 ft
|
||||
|
||||
|
||||
def test_handling_stress_solid_panel_unchanged_by_the_rule():
|
||||
"""On a solid panel the §1.3 rule is a no-op, so the extreme-fibre stress
|
||||
is exactly the transfer-model value -- the guard that the clean-panel
|
||||
parity match cannot regress."""
|
||||
strand_b = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0)
|
||||
strand_t = Strand(y=2.0, z_in=6.5, area_in2=0.153, initial_pull_lb=30000.0)
|
||||
pre = Prestress(strands=[strand_b, strand_t], loss_pct=0.0, diameter_in=0.5,
|
||||
fpu_ksi=270.0)
|
||||
panel = Panel(mark='C', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
|
||||
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
|
||||
prestress=pre)
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0,
|
||||
tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6)
|
||||
sp = capacity.handling_stress_at(panel, stage, 8.0, 12000.0)
|
||||
# rebuild with the plain transfer-model state, no ld gating
|
||||
from panellift import section
|
||||
net = section.NetSection(panel, 8.0)
|
||||
st = prestress.state_at(panel, 8.0) # no ld_ft
|
||||
f_form, f_far = capacity.extreme_fibre_stresses(
|
||||
st.force_lb, net.area_in2, 0.0, panel.thickness_in / 2.0,
|
||||
net.iz_in4, 12000.0)
|
||||
assert sp.f_form_psi == pytest.approx(f_form)
|
||||
assert sp.f_far_psi == pytest.approx(f_far)
|
||||
@@ -0,0 +1,302 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Erection is two conditions, and they use two different ratings.
|
||||
|
||||
the pick panel flat, face lifters and edge anchors together. Face
|
||||
lifters are pulled out of the face (tension); the anchors are
|
||||
edge-on to the line, which pulls across them (shear).
|
||||
|
||||
standing panel plumb. The face lifters have let go and carry nothing.
|
||||
The whole piece hangs from the anchors alone, in tension.
|
||||
|
||||
Checking the anchors against tension in the first condition, or running the
|
||||
second one through the beam model, both produce a report that looks complete
|
||||
and answers a question the panel never asks.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import LENGTH_FT, WIDTH_FT, make_panel
|
||||
from panellift import checks, statics
|
||||
from panellift.model import Lifter
|
||||
|
||||
|
||||
def erection_panel(shear_lb=4010.0, tension_lb=7690.0, n=4, weight=35000.0):
|
||||
"""The ASPIRE panel picked on its lower two lines plus a top edge row."""
|
||||
panel = make_panel()
|
||||
face = [dataclasses.replace(l, capacity_lb=12000.0)
|
||||
for l in panel.lifters if l.x < 0.5 * LENGTH_FT]
|
||||
edge = [Lifter(mark='E{0}'.format(i + 1), family='EDGE_ANCHOR',
|
||||
x=LENGTH_FT, y=WIDTH_FT * (i + 1) / (n + 1.0),
|
||||
role='strip', capacity_lb=tension_lb,
|
||||
shear_capacity_lb=shear_lb, on_edge=True)
|
||||
for i in range(n)]
|
||||
return dataclasses.replace(panel, lifters=face + edge, weight_lb=weight)
|
||||
|
||||
|
||||
def analyse(panel, stage, rolling):
|
||||
return statics.analyse(panel, stage, rolling, role='strip')
|
||||
|
||||
|
||||
def codes(findings):
|
||||
return [f.code for f in findings]
|
||||
|
||||
|
||||
def insert_lines(findings, family):
|
||||
return [f for f in findings if f.code == 'INSERT' and family in f.message]
|
||||
|
||||
|
||||
# --------------------------------------------- condition 1: the flat pick
|
||||
|
||||
def test_an_edge_anchor_is_checked_in_both_tension_and_shear(stripping, rolling):
|
||||
"""2026-09-02 owner's ruling: the sling's line force is decomposed into
|
||||
a vertical (tension) and a horizontal (shear) share and BOTH are
|
||||
checked against the lifter's own capacity in that column -- `on_edge`
|
||||
no longer selects a single axis to judge here (it still matters to
|
||||
check_plumb_hang). An edge anchor carries a tension rating same as a
|
||||
face lifter, and it is checked too; at this fixture's vertical
|
||||
(90deg) rigging the shear share is zero, so the shear ratio comes back
|
||||
a trivial 0% rather than being skipped."""
|
||||
a = analyse(erection_panel(), stripping, rolling)
|
||||
found = insert_lines(checks.check_insert_capacity(a, rolling),
|
||||
'EDGE_ANCHOR')
|
||||
assert found
|
||||
assert 'shear' in found[0].message and 'tension' in found[0].message
|
||||
assert '4,010' in found[0].message # the shear rating
|
||||
assert '7,690' in found[0].message # the tension rating
|
||||
|
||||
|
||||
def test_a_face_lifter_is_checked_in_tension(stripping, rolling):
|
||||
a = analyse(erection_panel(), stripping, rolling)
|
||||
found = insert_lines(checks.check_insert_capacity(a, rolling),
|
||||
'TEST_LIFTER')
|
||||
assert found
|
||||
assert 'tension' in found[0].message
|
||||
|
||||
|
||||
def test_an_edge_anchor_with_no_shear_rating_still_judges_tension(stripping,
|
||||
rolling):
|
||||
"""No shear rating means the shear half of the decomposition can't be
|
||||
judged -- but the tension half is a separate question with its own
|
||||
capacity, and it still judges rather than the whole lifter vanishing
|
||||
from the report. The missing half is named, not silently dropped."""
|
||||
a = analyse(erection_panel(shear_lb=None), stripping, rolling)
|
||||
found = checks.check_insert_capacity(a, rolling)
|
||||
assert 'INSERT-UNRATED' in codes(found)
|
||||
lines = insert_lines(found, 'EDGE_ANCHOR')
|
||||
assert lines
|
||||
assert 'tension' in lines[0].message
|
||||
assert 'NOT JUDGED' in lines[0].message
|
||||
assert '7,690' in lines[0].message # the tension rating still judged
|
||||
|
||||
|
||||
# ------------------------------------------ condition 2: standing plumb
|
||||
|
||||
def test_standing_plumb_hangs_the_whole_piece_on_the_anchors(stripping,
|
||||
rolling):
|
||||
a = analyse(erection_panel(n=4, weight=35000.0), stripping, rolling)
|
||||
found = [f for f in checks.check_plumb_hang(a) if f.code == 'PLUMB']
|
||||
assert len(found) == 1
|
||||
assert '8,750' in found[0].message # 35,000 / 4
|
||||
assert '7,690' in found[0].message # the tension rating
|
||||
|
||||
|
||||
def test_standing_plumb_ignores_the_handling_multiplier_and_the_sling(rolling):
|
||||
"""It is a piece hanging straight down off four anchors. There is no
|
||||
distribution question in it, so nothing about the pick can change it."""
|
||||
from panellift.model import Stage
|
||||
panel = erection_panel()
|
||||
gentle = checks.check_plumb_hang(analyse(panel, Stage('a', 1.0, 4000.0),
|
||||
rolling))
|
||||
harsh = checks.check_plumb_hang(analyse(panel, Stage('b', 1.7, 4000.0),
|
||||
rolling))
|
||||
assert gentle[0].message == harsh[0].message
|
||||
|
||||
|
||||
def test_a_row_too_light_for_the_piece_fails(stripping, rolling):
|
||||
a = analyse(erection_panel(tension_lb=2000.0), stripping, rolling)
|
||||
found = [f for f in checks.check_plumb_hang(a) if f.code == 'PLUMB']
|
||||
assert found and found[0].severity == 'FAIL'
|
||||
|
||||
|
||||
def test_an_anchor_with_no_tension_rating_says_so(stripping, rolling):
|
||||
a = analyse(erection_panel(tension_lb=None), stripping, rolling)
|
||||
assert 'PLUMB-UNRATED' in codes(checks.check_plumb_hang(a))
|
||||
|
||||
|
||||
def test_a_stage_with_no_edge_anchors_is_not_stood_up(stripping, rolling):
|
||||
"""Stripping and transportation never put the piece on end, so there is
|
||||
no plumb condition to report and no warning to give about it."""
|
||||
a = analyse(make_panel(), stripping, rolling)
|
||||
assert checks.check_plumb_hang(a) == []
|
||||
|
||||
|
||||
def test_face_lifters_are_not_used_to_stand_the_piece_up(stripping, rolling):
|
||||
"""They have a tension rating too. Using it here would report a condition
|
||||
the panel never sees."""
|
||||
panel = erection_panel()
|
||||
panel = dataclasses.replace(panel, lifters=[
|
||||
dataclasses.replace(l, on_edge=False) for l in panel.lifters])
|
||||
a = analyse(panel, stripping, rolling)
|
||||
assert checks.check_plumb_hang(a) == []
|
||||
|
||||
|
||||
def test_run_all_reports_both_conditions(stripping, rolling):
|
||||
a = analyse(erection_panel(), stripping, rolling)
|
||||
found = codes(checks.run_all(a, rolling))
|
||||
assert 'INSERT' in found and 'PLUMB' in found
|
||||
|
||||
|
||||
|
||||
# ----------------------------------- transverse edge-anchor path
|
||||
|
||||
def _transverse_panel(opening=False):
|
||||
"""A horizontal panel erected on its long edge, top-row anchors along the
|
||||
edge, in TRANSPOSED shape: length = erected span (11.5 ft), width = long
|
||||
dimension (26.83 ft)."""
|
||||
from panellift.model import Blockout, Panel
|
||||
panel = Panel(mark='H', length_ft=11.5, width_ft=26.83, thickness_in=8.0,
|
||||
weight_lb=30800.0, fci_psi=3500.0, fc_psi=6000.0)
|
||||
for i, y in enumerate([3.5, 10.5, 16.33, 24.08]):
|
||||
panel.lifters.append(Lifter(
|
||||
mark='E{0}'.format(i + 1), family='EDGE_ANCHOR', x=11.5, y=y,
|
||||
role='strip', capacity_lb=16000.0, shear_capacity_lb=4010.0,
|
||||
on_edge=True))
|
||||
if opening:
|
||||
# Reaches the anchored edge (x0 + length = 11.5) inside the gap
|
||||
# between the anchors at y = 10.5 and 16.33.
|
||||
panel.blockouts.append(Blockout(name='BIG', x0=7.0, y0=14.0,
|
||||
length=4.5, width=3.0))
|
||||
return panel
|
||||
|
||||
|
||||
def _edge_path(panel):
|
||||
from panellift.model import Stage
|
||||
return checks.check_edge_anchor_path(
|
||||
panel, Stage('erection', 1.4, 6000.0, span='width'))
|
||||
|
||||
|
||||
def _f_t_psi(finding):
|
||||
m = __import__('re').search(r'f_t ([0-9.]+) psi', finding.message)
|
||||
return float(m.group(1))
|
||||
|
||||
|
||||
def test_transverse_edge_path_is_tiny_on_a_solid_horizontal_panel():
|
||||
found = _edge_path(_transverse_panel())
|
||||
ok = [f for f in found if f.code == 'EDGE-PATH']
|
||||
assert ok and ok[0].severity == 'OK'
|
||||
assert 'psi' in ok[0].message
|
||||
assert _f_t_psi(ok[0]) < 20.0 # a few pounds per square inch
|
||||
|
||||
|
||||
def test_transverse_edge_path_respects_an_opening_across_the_anchor_line():
|
||||
solid = _edge_path(_transverse_panel())[0]
|
||||
with_opening = _edge_path(_transverse_panel(opening=True))[0]
|
||||
assert solid.code == 'EDGE-PATH'
|
||||
assert with_opening.code == 'EDGE-PATH'
|
||||
assert 'opening' in with_opening.message.lower()
|
||||
assert _f_t_psi(with_opening) > _f_t_psi(solid)
|
||||
|
||||
|
||||
def test_transverse_edge_path_ignores_length_stages_and_few_anchors():
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Stage
|
||||
panel = _transverse_panel()
|
||||
assert checks.check_edge_anchor_path(
|
||||
panel, Stage('s', 1.4, 6000.0, span='length')) == []
|
||||
few = _dc.replace(panel, lifters=panel.lifters[:1])
|
||||
assert checks.check_edge_anchor_path(
|
||||
few, Stage('s', 1.4, 6000.0, span='width')) == []
|
||||
|
||||
|
||||
# ----------------------- the anchored edge is read, not assumed
|
||||
|
||||
def _mirrored_panel():
|
||||
"""The same piece with the anchor row on the OTHER long edge -- x == 0
|
||||
after transposing -- and the opening cutting in from that edge.
|
||||
|
||||
Which of a horizontal panel's two long edges ends up on top is a fact
|
||||
about the model. Assuming it is always x == length does not give a wrong
|
||||
number here, it gives a clean OK: no opening is ever seen to reach the
|
||||
anchor line, so the strip reports its full depth and the one thing this
|
||||
check exists to catch goes unreported.
|
||||
"""
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Blockout
|
||||
panel = _transverse_panel()
|
||||
panel = _dc.replace(panel, lifters=[_dc.replace(l, x=0.0)
|
||||
for l in panel.lifters])
|
||||
panel.blockouts.append(Blockout(name='BIG', x0=0.0, y0=14.0,
|
||||
length=4.5, width=3.0))
|
||||
return panel
|
||||
|
||||
|
||||
def test_the_anchored_edge_is_taken_from_the_anchors():
|
||||
found = _edge_path(_mirrored_panel())[0]
|
||||
assert 'opening BIG' in found.message
|
||||
# 11.5 ft of span less the 4.5 ft the opening eats = 7.0 ft of web.
|
||||
assert '84.0 in' in found.message
|
||||
|
||||
|
||||
def test_mirrored_and_normal_read_the_same_strip():
|
||||
"""Same geometry, mirrored. The stress must come out the same."""
|
||||
normal = _f_t_psi(_edge_path(_transverse_panel(opening=True))[0])
|
||||
mirrored = _f_t_psi(_edge_path(_mirrored_panel())[0])
|
||||
assert abs(normal - mirrored) < 0.05
|
||||
|
||||
|
||||
# ------------------------------ the load and the ends are not understated
|
||||
|
||||
def test_the_handling_multiplier_is_on_the_load():
|
||||
"""This compares a concrete stress to a concrete allowable, so it is the
|
||||
factored load -- the insert checks divide the multiplier back out because
|
||||
an insert is rated against service load, and that is a different question.
|
||||
"""
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Stage
|
||||
panel = _transverse_panel()
|
||||
gentle = checks.check_edge_anchor_path(
|
||||
panel, Stage('a', 1.0, 6000.0, span='width'))[0]
|
||||
harsh = checks.check_edge_anchor_path(
|
||||
panel, Stage('b', 2.0, 6000.0, span='width'))[0]
|
||||
# the message rounds to a tenth of a psi, so compare at that grain
|
||||
assert abs(_f_t_psi(harsh) - 2.0 * _f_t_psi(gentle)) <= 0.15
|
||||
|
||||
|
||||
def test_the_strip_past_the_last_anchor_is_a_cantilever():
|
||||
"""It hangs off the end anchor; there is nothing beyond it to lean on.
|
||||
Calling it a simple span understates that moment four times over."""
|
||||
import dataclasses as _dc
|
||||
panel = _transverse_panel()
|
||||
# One long overhang, and the two anchors close together so no interior
|
||||
# gap can win: 26.83 ft of piece with anchors at 3.5 and 5.0.
|
||||
panel = _dc.replace(panel, lifters=[
|
||||
_dc.replace(panel.lifters[0], y=3.5),
|
||||
_dc.replace(panel.lifters[1], y=5.0)])
|
||||
found = _edge_path(panel)[0]
|
||||
assert 'overhang' in found.message
|
||||
w = 30800.0 * 1.4 / 26.83
|
||||
gap = 26.83 - 5.0
|
||||
s_in3 = 8.0 * (11.5 * 12.0) ** 2 / 6.0
|
||||
assert abs(_f_t_psi(found) - w * gap ** 2 / 2.0 * 12.0 / s_in3) < 0.05
|
||||
|
||||
|
||||
# --------------------------------- nothing checked never reads as a pass
|
||||
|
||||
def test_no_concrete_strength_is_not_a_pass():
|
||||
from panellift.model import Stage
|
||||
found = checks.check_edge_anchor_path(
|
||||
_transverse_panel(), Stage('e', 1.4, 0.0, span='width'))
|
||||
assert codes(found) == ['EDGE-PATH-UNRATED']
|
||||
assert found[0].severity == 'WARN'
|
||||
|
||||
|
||||
def test_an_opening_that_eats_the_whole_strip_fails():
|
||||
import dataclasses as _dc
|
||||
from panellift.model import Blockout
|
||||
panel = _transverse_panel()
|
||||
panel.blockouts.append(Blockout(name='SLOT', x0=0.0, y0=12.0,
|
||||
length=11.5, width=6.0))
|
||||
found = _edge_path(panel)[0]
|
||||
assert found.severity == 'FAIL'
|
||||
assert 'no section at all' in found.message
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""An opening that all but reaches an edge must not leave a fibre behind.
|
||||
|
||||
A real piece had an opening drawn to 11.1041 ft on a panel 11.1042 ft wide.
|
||||
The 0.001 in of concrete left over was, arithmetically, the outermost material
|
||||
in the section -- so every in-plane stress was reported at a fibre 110 in from
|
||||
the centroid instead of 23 in, on a sliver nothing could be cast into. The
|
||||
piece came back at 432 psi where a commercial check of the same piece said
|
||||
162, and it was one of only two failures in a 194-piece sweep.
|
||||
|
||||
The stress at a fibre that is not really there is a stress nobody has.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from panellift import section
|
||||
from panellift.model import Blockout
|
||||
|
||||
from conftest import make_panel
|
||||
|
||||
|
||||
def _panel_with_opening(gap_ft):
|
||||
"""A panel whose only opening stops `gap_ft` short of the far edge."""
|
||||
panel = make_panel()
|
||||
panel.blockouts = [Blockout(
|
||||
name='O1', x0=10.0, y0=3.0, length=4.0,
|
||||
width=panel.width_ft - 3.0 - gap_ft)]
|
||||
return panel
|
||||
|
||||
|
||||
@pytest.mark.parametrize('gap_ft', [0.0, 1e-4, 1e-3, 0.004])
|
||||
def test_a_drafting_sliver_is_not_the_extreme_fibre(gap_ft):
|
||||
"""Whether the opening lands exactly on the edge or a thousandth short,
|
||||
the section has to be the same piece of concrete."""
|
||||
exact = section.NetSection(_panel_with_opening(0.0), 12.0)
|
||||
nearly = section.NetSection(_panel_with_opening(gap_ft), 12.0)
|
||||
|
||||
assert nearly.width_in == pytest.approx(exact.width_in, abs=0.1)
|
||||
assert nearly.y_extremes_in[1] == pytest.approx(exact.y_extremes_in[1],
|
||||
abs=0.1)
|
||||
assert nearly.iy_in4 == pytest.approx(exact.iy_in4, rel=0.01)
|
||||
|
||||
|
||||
def test_the_extreme_fibre_is_inside_the_concrete():
|
||||
"""The outermost fibre cannot be further from the centroid than the
|
||||
material is. This is what the sliver broke: 110 in of lever arm on a
|
||||
section only 46 in wide."""
|
||||
net = section.NetSection(_panel_with_opening(1e-3), 12.0)
|
||||
reach = max(abs(v) for v in net.y_extremes_in)
|
||||
assert reach <= net.width_in, (
|
||||
'extreme fibre %.1f in from the centroid, but the section is only '
|
||||
'%.1f in wide' % (reach, net.width_in))
|
||||
|
||||
|
||||
def test_an_opening_well_short_of_the_edge_keeps_its_strip():
|
||||
"""The snap is a noise floor, not a licence to delete real concrete. A
|
||||
strip an inch and a half wide is still a strip."""
|
||||
panel = _panel_with_opening(0.125) # 1.5 in of concrete left
|
||||
net = section.NetSection(panel, 12.0)
|
||||
strips = section.strips_at(panel, 12.0)
|
||||
assert len(strips) == 2
|
||||
assert net.y_extremes_in[1] > 0.5 * panel.width_ft * 12.0
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Concrete of the cast piece that is not the panel rectangle.
|
||||
|
||||
A slab-seat ledge is modelled as its own family inside the assembly. It is
|
||||
real concrete -- on WP371 it carried 8.99 ft3 = 1,349 lb, 7.5% of the piece
|
||||
-- and leaving it out made the weight reconciliation accuse a correct model.
|
||||
The lump rides at no station, so it is added to the geometric weight whole;
|
||||
the statics rescale to the stated weight regardless, and this is what makes
|
||||
that reconciliation honest.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from panellift import cli, section
|
||||
|
||||
from conftest import make_panel
|
||||
|
||||
|
||||
LEDGE_FT3 = 8.992 # what WP371's LEDGE family actually measured
|
||||
LEDGE_LB = LEDGE_FT3 * 150.0
|
||||
|
||||
|
||||
def test_extra_cast_is_added_to_the_geometric_weight():
|
||||
"""The ledge is weight whether or not it sits at a station."""
|
||||
bare = make_panel()
|
||||
ledged = make_panel()
|
||||
ledged.extra_cast_ft3 = LEDGE_FT3
|
||||
|
||||
expected = section.geometric_weight_lb(bare) + LEDGE_LB
|
||||
assert section.geometric_weight_lb(ledged) == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_reconciliation_clears_when_the_ledge_is_counted():
|
||||
"""A panel whose stated weight includes the ledge must not be accused."""
|
||||
panel = make_panel()
|
||||
panel.extra_cast_ft3 = LEDGE_FT3
|
||||
panel.weight_lb = section.geometric_weight_lb(panel)
|
||||
|
||||
_geom, _stated, error = section.weight_reconciliation(panel)
|
||||
assert abs(error) < 0.001
|
||||
|
||||
|
||||
def test_reconciliation_still_catches_a_missing_ledge():
|
||||
"""Without the field, the old wrong answer is still visible.
|
||||
|
||||
The ledge here is sized as a share of THIS panel -- on the smaller
|
||||
WP371 it was 7.5% of the piece, and check_weight warns past 5%.
|
||||
"""
|
||||
panel = make_panel()
|
||||
ledge_ft3 = 0.075 * (panel.length_ft * panel.width_ft *
|
||||
panel.thickness_ft)
|
||||
panel.weight_lb = (section.geometric_weight_lb(panel)
|
||||
+ ledge_ft3 * panel.unit_weight_pcf)
|
||||
|
||||
_geom, _stated, error = section.weight_reconciliation(panel)
|
||||
assert error < -0.05 # we are light by more than 5%
|
||||
|
||||
|
||||
def test_cli_reads_extra_cast_ft3():
|
||||
"""The job file carries the ledge, and the reader keeps it."""
|
||||
import json
|
||||
|
||||
panel = make_panel()
|
||||
panel.extra_cast_ft3 = LEDGE_FT3
|
||||
data = json.loads(json.dumps({
|
||||
'mark': panel.mark,
|
||||
'length_ft': panel.length_ft,
|
||||
'width_ft': panel.width_ft,
|
||||
'thickness_in': panel.thickness_in,
|
||||
'unit_weight_pcf': panel.unit_weight_pcf,
|
||||
'extra_cast_ft3': LEDGE_FT3,
|
||||
}))
|
||||
|
||||
assert cli.read_panel(data).extra_cast_ft3 == pytest.approx(LEDGE_FT3)
|
||||
|
||||
|
||||
def test_cli_treats_a_missing_field_as_no_ledge():
|
||||
"""Old job files without the key keep working, as zero."""
|
||||
import json
|
||||
|
||||
data = json.loads(json.dumps({
|
||||
'mark': 'OLD-1',
|
||||
'length_ft': 32.0,
|
||||
'width_ft': 10.0,
|
||||
'thickness_in': 8.75,
|
||||
}))
|
||||
|
||||
assert cli.read_panel(data).extra_cast_ft3 == 0.0
|
||||
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The PCI Design Aid 15.2.3 strand power curve and the strain-compatibility
|
||||
flexure it feeds -- docs/eriksson-wall-algorithms.md sec.4.2.
|
||||
|
||||
ADDITIVE. `pci.fps_approximate_ksi` (the ACI approximate f_ps that the shared
|
||||
`capacity_at`/`phi_mn_lbft` and Panel Lift Check keep) is untouched; these pin
|
||||
the more exact law the reference tool (Eriksson Wall) uses and that the additive
|
||||
`handling_flexure_at` (Panel Analysis) now prefers.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from panellift import capacity, pci
|
||||
from panellift.model import Panel, Prestress, Stage, Strand
|
||||
|
||||
|
||||
# ------------------------------------------------------- the pure power curve
|
||||
|
||||
def test_fps_power_curve_elastic_branch():
|
||||
"""eps_ps <= 0.0085 is elastic: f_ps = Ep*eps_ps.
|
||||
|
||||
0.005 * 28500 = 142.5 ksi. At the limit itself, 0.0085 * 28500 = 242.25.
|
||||
"""
|
||||
assert pci.fps_power_curve_ksi(0.005, 270.0) == pytest.approx(142.5)
|
||||
assert pci.fps_power_curve_ksi(0.0085, 270.0) == pytest.approx(242.25)
|
||||
|
||||
|
||||
def test_fps_power_curve_power_branch():
|
||||
"""eps_ps > 0.0085: f_ps = f_pu - 0.04/(eps_ps - 0.007).
|
||||
|
||||
At 0.010: 270 - 0.04/(0.010 - 0.007) = 270 - 13.3333 = 256.6667 ksi.
|
||||
At 0.020: 270 - 0.04/0.013 = 270 - 3.0769 = 266.9231 ksi.
|
||||
"""
|
||||
assert pci.fps_power_curve_ksi(0.010, 270.0) == pytest.approx(256.6667,
|
||||
abs=1e-3)
|
||||
assert pci.fps_power_curve_ksi(0.020, 270.0) == pytest.approx(266.9231,
|
||||
abs=1e-3)
|
||||
|
||||
|
||||
def test_fps_power_curve_is_nearly_continuous_at_the_knee():
|
||||
"""The two branches meet within ~1 ksi at eps_ps = 0.0085 -- one smooth
|
||||
law, not a step. Elastic 242.25; power 270 - 0.04/0.0015 = 243.33."""
|
||||
elastic = pci.fps_power_curve_ksi(0.0085, 270.0)
|
||||
power = 270.0 - 0.04 / (0.0085 - 0.007)
|
||||
assert abs(power - elastic) < 1.1
|
||||
|
||||
|
||||
def test_fps_power_curve_caps_at_fpu_and_floors_at_zero():
|
||||
# The power branch approaches f_pu from below but never reaches it: at
|
||||
# eps = 1.0, 270 - 0.04/0.993 = 269.96 ksi, under f_pu.
|
||||
assert pci.fps_power_curve_ksi(1.0, 270.0) == pytest.approx(269.96, abs=0.01)
|
||||
assert pci.fps_power_curve_ksi(1.0, 270.0) < 270.0
|
||||
# The cap DOES bind if the elastic branch is pushed past f_pu (an out-of-
|
||||
# range Ep): 0.005 * 100000 = 500 ksi -> capped to f_pu = 270.
|
||||
assert pci.fps_power_curve_ksi(0.005, 270.0, ep_ksi=100000.0) \
|
||||
== pytest.approx(270.0)
|
||||
# Non-positive strain is no stress, not a negative one.
|
||||
assert pci.fps_power_curve_ksi(0.0, 270.0) == 0.0
|
||||
assert pci.fps_power_curve_ksi(-0.001, 270.0) == 0.0
|
||||
|
||||
|
||||
# --------------------------------------- strain-compat Mn on a solid panel
|
||||
|
||||
def _solid_prestressed_panel():
|
||||
"""A plain solid panel, one bottom strand row, no openings -- so the
|
||||
power-curve solve has a single fully-developed tension layer and can be
|
||||
reproduced by an independent bisection in the test."""
|
||||
strands = [Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=28900.0),
|
||||
Strand(y=6.0, z_in=1.5, area_in2=0.153, initial_pull_lb=28900.0)]
|
||||
pre = Prestress(strands=strands, loss_pct=0.0, diameter_in=0.5,
|
||||
fpu_ksi=270.0)
|
||||
return Panel(mark='S', length_ft=24.0, width_ft=8.0, thickness_in=8.0,
|
||||
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
|
||||
prestress=pre)
|
||||
|
||||
|
||||
def test_power_curve_mn_matches_an_independent_strain_compat_solve():
|
||||
"""`capacity_at` stores a power-curve M_n; reproduce it from the same law.
|
||||
|
||||
Midspan, fully developed: every strand sees the full power-curve f_ps at
|
||||
its own strain, and equilibrium 0.85 f'ci beta1 c b = sum(A f_ps) fixes c.
|
||||
The test re-solves that balance independently and checks the stored M_n.
|
||||
"""
|
||||
panel = _solid_prestressed_panel()
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
|
||||
x = 12.0
|
||||
cap = capacity.capacity_at(panel, stage, x, moment_lbft=1.0, shear_lb=0.0)
|
||||
assert cap.mn_powercurve_lbft is not None
|
||||
|
||||
# Independent solve, same inputs: two identical strands at d = 8 - 1.5 =
|
||||
# 6.5 in, f_se = 28900/0.153/1000 = 188.9 ksi, eps_se = f_se/28500.
|
||||
import math
|
||||
fc = 3500.0
|
||||
b = panel.thickness_in and (panel.width_ft * 12.0) # solid, full width
|
||||
beta1 = pci.beta1(fc)
|
||||
ep = pci.STRAND_ELASTIC_MODULUS_KSI
|
||||
d = 8.0 - 1.5
|
||||
area = 0.153 * 2
|
||||
fse = 28900.0 / 0.153 / 1000.0
|
||||
eps_se = fse / ep
|
||||
|
||||
def tension(c):
|
||||
eps = eps_se + 0.003 * (d - c) / c
|
||||
return area * pci.fps_power_curve_ksi(eps, 270.0, ep) * 1000.0
|
||||
|
||||
lo, hi = 1e-4, d
|
||||
for _ in range(80):
|
||||
mid = 0.5 * (lo + hi)
|
||||
if 0.85 * fc * beta1 * mid * b - tension(mid) > 0.0:
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
c = 0.5 * (lo + hi)
|
||||
a = beta1 * c
|
||||
mn = tension(c) * (d - a / 2.0) / 12.0
|
||||
assert cap.mn_powercurve_lbft == pytest.approx(mn, rel=1e-3)
|
||||
|
||||
|
||||
def test_handling_flexure_prefers_the_power_curve_mn():
|
||||
"""handling_flexure_at reads the power-curve M_n, not the ACI-approximate
|
||||
one, when the point carries it -- and applies the strain-based phi to it."""
|
||||
panel = _solid_prestressed_panel()
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
|
||||
x = 12.0
|
||||
cap = capacity.capacity_at(panel, stage, x, moment_lbft=4000.0, shear_lb=0.0)
|
||||
phi, phi_mn, ratio = capacity.handling_flexure_at(cap, 4000.0)
|
||||
# Fully developed, low rho -> tension-controlled -> phi 0.90, and phi*M_n
|
||||
# is 0.90 times the stored power-curve M_n, NOT the approximate phi_mn_lbft.
|
||||
assert phi == pytest.approx(0.90)
|
||||
assert phi_mn == pytest.approx(0.90 * cap.mn_powercurve_lbft)
|
||||
assert phi_mn != pytest.approx(cap.phi_mn_lbft) # differs from approximate
|
||||
assert ratio == pytest.approx(4000.0 / phi_mn)
|
||||
|
||||
|
||||
def test_power_curve_mn_is_none_without_developed_strand():
|
||||
"""A free end has no developed area, so no power-curve M_n is stored."""
|
||||
panel = _solid_prestressed_panel()
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
|
||||
cap = capacity.capacity_at(panel, stage, x=0.0, moment_lbft=1.0,
|
||||
shear_lb=0.0)
|
||||
assert cap.mn_powercurve_lbft is None
|
||||
|
||||
|
||||
def test_power_curve_free_end_flexure_is_not_a_full_phi_mn():
|
||||
"""The development cap keeps a station near a free end from claiming the
|
||||
full midspan phi*M_n: at x just inside the transfer length the strand has
|
||||
only begun to develop, so phi*M_n there is well below the midspan value."""
|
||||
panel = _solid_prestressed_panel()
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
|
||||
mid = capacity.capacity_at(panel, stage, 12.0, 1.0, 0.0)
|
||||
near = capacity.capacity_at(panel, stage, 0.5, 1.0, 0.0)
|
||||
assert mid.mn_powercurve_lbft is not None
|
||||
if near.mn_powercurve_lbft is not None:
|
||||
assert near.mn_powercurve_lbft < mid.mn_powercurve_lbft
|
||||
@@ -0,0 +1,203 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The Tier-1 handling additions to `capacity`: the extreme-fibre STRESS check
|
||||
and the phi development RAMP.
|
||||
|
||||
Both are ADDITIVE -- `capacity_at` and every type it returns are untouched, and
|
||||
their tests (test_capacity.py, test_station_table.py, ...) stand as they were.
|
||||
These pin the new functions on numbers a reviewer can reproduce on paper:
|
||||
|
||||
* `extreme_fibre_stresses` is the task's Pi/A -/+ (Mg + Pi*e)*c/I, hand
|
||||
checked to the psi.
|
||||
* `handling_stress_at` builds those from a real panel/stage and divides by
|
||||
the STAGE's own allowables (tension_limit_sqrt_fc*sqrt(f'c),
|
||||
compression_limit_ratio*f'c) -- the settings that were read by nothing
|
||||
until this landed. A stage set to 5 vs 7.5 sqrt(f'c) moves the tension
|
||||
utilisation by exactly 1.5.
|
||||
* `phi_flexure_ramped` / `ramped_phi_mn` are the 0.75->0.90 ramp across the
|
||||
transfer/development length.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import capacity, prestress
|
||||
from panellift.model import Panel, Prestress, Stage, Strand
|
||||
|
||||
|
||||
# --------------------------------------------------- the pure stress formula
|
||||
|
||||
def test_extreme_fibre_stresses_hand_check():
|
||||
"""Pi=100000, A=1000, e=0, c=4, I=2000, Mg=10000 -> form +140, far -340.
|
||||
|
||||
S = I/c = 500 in3; Pi/A = 100 psi (compression, negative); Mg/S = 240 psi.
|
||||
Form face (z=0): -100 + 240 = +140 (tension). Far face: -100 - 240 = -340.
|
||||
"""
|
||||
f_form, f_far = capacity.extreme_fibre_stresses(
|
||||
pi_lb=100000.0, area_in2=1000.0, e_in=0.0, c_in=4.0,
|
||||
i_in4=2000.0, moment_lbft=10000.0)
|
||||
assert f_form == pytest.approx(140.0)
|
||||
assert f_far == pytest.approx(-340.0)
|
||||
|
||||
|
||||
def test_extreme_fibre_stresses_eccentric_prestress():
|
||||
"""A low strand (e<0) hogs the piece: it ADDS compression on the form face.
|
||||
|
||||
e = -2 in, Pi=120000, so Pi*e = -240000 lb-in. With A=1000, c=4, I=2000
|
||||
(S=500) and Mg=0: form = -120 + (-240000/500) = -120 - 480 = -600;
|
||||
far = -120 + 480 = +360 (tension on the far face, which is the hogging a
|
||||
low strand causes).
|
||||
"""
|
||||
f_form, f_far = capacity.extreme_fibre_stresses(
|
||||
pi_lb=120000.0, area_in2=1000.0, e_in=-2.0, c_in=4.0,
|
||||
i_in4=2000.0, moment_lbft=0.0)
|
||||
assert f_form == pytest.approx(-600.0)
|
||||
assert f_far == pytest.approx(360.0)
|
||||
|
||||
|
||||
def test_extreme_fibre_stresses_degenerate_returns_none():
|
||||
assert capacity.extreme_fibre_stresses(1.0, 0.0, 0.0, 4.0, 2000.0, 1.0) \
|
||||
== (None, None)
|
||||
assert capacity.extreme_fibre_stresses(1.0, 1000.0, 0.0, 4.0, 0.0, 1.0) \
|
||||
== (None, None)
|
||||
|
||||
|
||||
# ------------------------------------------------ handling_stress_at on panel
|
||||
|
||||
def _solid_panel():
|
||||
"""A plain solid panel with two symmetric strands -- e_z = 0, no openings.
|
||||
|
||||
t=8 in, so mid-depth is 4 in; strands at z=1.5 and z=6.5 (= 8-1.5) with
|
||||
equal force put the group centroid at mid-depth (e_z=0) and, being full
|
||||
width, leave the in-plane term e_y=0 too. So the fibre stress is exactly
|
||||
the 1-D Pi/A -/+ Mg*c/I with no prestress bending -- hand-reproducible.
|
||||
"""
|
||||
strands = [Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0),
|
||||
Strand(y=2.0, z_in=6.5, area_in2=0.153, initial_pull_lb=30000.0)]
|
||||
pre = Prestress(strands=strands, loss_pct=0.0, diameter_in=0.5,
|
||||
fpu_ksi=270.0)
|
||||
return Panel(mark='S', length_ft=20.0, width_ft=4.0, thickness_in=8.0,
|
||||
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
|
||||
prestress=pre)
|
||||
|
||||
|
||||
def test_handling_stress_at_matches_the_pure_formula():
|
||||
panel = _solid_panel()
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0,
|
||||
tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6)
|
||||
x = 8.0
|
||||
m = 12000.0
|
||||
sp = capacity.handling_stress_at(panel, stage, x, m)
|
||||
|
||||
# Rebuild the ingredients the way the function does, independently.
|
||||
from panellift import section
|
||||
net = section.NetSection(panel, x)
|
||||
state = prestress.state_at(panel, x)
|
||||
pi = state.force_lb
|
||||
assert pi > 0
|
||||
assert sp.e_in == pytest.approx(0.0, abs=1e-9) # symmetric -> e_z = 0
|
||||
f_form, f_far = capacity.extreme_fibre_stresses(
|
||||
pi, net.area_in2, 0.0, panel.thickness_in / 2.0, net.iz_in4, m)
|
||||
assert sp.f_form_psi == pytest.approx(f_form)
|
||||
assert sp.f_far_psi == pytest.approx(f_far)
|
||||
assert sp.in_plane_psi == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_handling_stress_utils_use_the_stages_allowables():
|
||||
"""The utilisation divides by the STAGE's own allowables -- the settings
|
||||
that nothing read before. tension over tension_limit_sqrt_fc*sqrt(f'c),
|
||||
compression over compression_limit_ratio*f'c."""
|
||||
panel = _solid_panel()
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0,
|
||||
tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6)
|
||||
sp = capacity.handling_stress_at(panel, stage, 10.0, 15000.0)
|
||||
allow_t = 7.5 * math.sqrt(3500.0)
|
||||
allow_c = 0.6 * 3500.0
|
||||
assert sp.tension_util == pytest.approx(sp.tension_psi / allow_t)
|
||||
assert sp.compression_util == pytest.approx(sp.compression_psi / allow_c)
|
||||
|
||||
|
||||
def test_handling_stress_limit_coefficient_moves_the_tension_util_by_1p5():
|
||||
"""5 vs 7.5 sqrt(f'c) is the whole 1.5x that the two schools argue about,
|
||||
and it lands on the tension utilisation and nowhere else."""
|
||||
panel = _solid_panel()
|
||||
common = dict(multiplier=1.4, fc_psi=3500.0, compression_limit_ratio=0.6)
|
||||
five = Stage(name='a', tension_limit_sqrt_fc=5.0, **common)
|
||||
seven = Stage(name='b', tension_limit_sqrt_fc=7.5, **common)
|
||||
a = capacity.handling_stress_at(panel, five, 10.0, 15000.0)
|
||||
b = capacity.handling_stress_at(panel, seven, 10.0, 15000.0)
|
||||
assert a.tension_psi == pytest.approx(b.tension_psi) # same stress
|
||||
# 5 is the stricter school -> a higher utilisation, by exactly 1.5.
|
||||
assert a.tension_util == pytest.approx(b.tension_util * 1.5)
|
||||
|
||||
|
||||
def test_handling_stress_no_allowable_is_a_dash_not_a_zero():
|
||||
"""A stage stating no strength has no allowable to divide by -- util is
|
||||
None with a reason, never a fabricated 0, the same could-not-judge
|
||||
discipline as every ratio in this module."""
|
||||
panel = _solid_panel()
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=0.0)
|
||||
sp = capacity.handling_stress_at(panel, stage, 10.0, 15000.0)
|
||||
assert sp.tension_util is None and sp.compression_util is None
|
||||
assert sp.why
|
||||
|
||||
|
||||
# --------------------------------- phi from net tensile strain (Table 21.2.2)
|
||||
|
||||
def test_phi_flexure_from_strain_boundaries_and_slope():
|
||||
"""ACI 318-14 Table 21.2.2, prestressed floor 0.65, eps_ty 0.002."""
|
||||
assert capacity.phi_flexure_from_strain(0.001) == pytest.approx(0.65)
|
||||
assert capacity.phi_flexure_from_strain(0.002) == pytest.approx(0.65)
|
||||
# midway: 0.65 + 0.25*(0.0035-0.002)/(0.005-0.002) = 0.65 + 0.125 = 0.775
|
||||
assert capacity.phi_flexure_from_strain(0.0035) == pytest.approx(0.775)
|
||||
assert capacity.phi_flexure_from_strain(0.005) == pytest.approx(0.90)
|
||||
assert capacity.phi_flexure_from_strain(0.010) == pytest.approx(0.90)
|
||||
|
||||
|
||||
def test_phi_flexure_from_strain_none_is_the_flat_value():
|
||||
"""No strain to judge on is not a reason to drop to the floor."""
|
||||
assert capacity.phi_flexure_from_strain(None) == pytest.approx(0.90)
|
||||
|
||||
|
||||
def test_handling_flexure_at_applies_the_strain_phi_to_the_same_mn():
|
||||
"""M_n does not change; only phi moves, from the section's strain."""
|
||||
pt = capacity.CapacityPoint(x=5.0)
|
||||
pt.phi = 0.90
|
||||
pt.phi_mn_lbft = 9000.0 # M_n = 10000 lb-ft
|
||||
pt.strain_t = 0.0035 # transition -> phi 0.775
|
||||
phi, phi_mn, ratio = capacity.handling_flexure_at(pt, moment_lbft=3100.0)
|
||||
assert phi == pytest.approx(0.775)
|
||||
assert phi_mn == pytest.approx(10000.0 * 0.775) # = 7750
|
||||
assert ratio == pytest.approx(3100.0 / 7750.0)
|
||||
|
||||
|
||||
def test_handling_flexure_at_tension_controlled_is_flat_0p90():
|
||||
pt = capacity.CapacityPoint(x=5.0)
|
||||
pt.phi = 0.90
|
||||
pt.phi_mn_lbft = 9000.0
|
||||
pt.strain_t = 0.020 # well tension-controlled
|
||||
phi, phi_mn, _ = capacity.handling_flexure_at(pt, moment_lbft=3000.0)
|
||||
assert phi == pytest.approx(0.90)
|
||||
assert phi_mn == pytest.approx(9000.0) # unchanged from flat
|
||||
|
||||
|
||||
def test_handling_flexure_at_rebuilds_mn_in_the_transition_region():
|
||||
"""Where capacity_at bailed (no phi*M_n) but left the equilibrium geometry,
|
||||
M_n is rebuilt from C = 0.85 f'c a b at lever arm (d_p - a/2)."""
|
||||
pt = capacity.CapacityPoint(x=5.0) # phi_mn_lbft stays None
|
||||
pt.a_in = 1.0
|
||||
pt.b_in = 100.0
|
||||
pt.dp_in = 6.0
|
||||
pt.fc_psi = 3500.0
|
||||
pt.strain_t = 0.003 # transition
|
||||
phi, phi_mn, ratio = capacity.handling_flexure_at(pt, moment_lbft=5000.0)
|
||||
tension = 0.85 * 3500.0 * 1.0 * 100.0
|
||||
mn = tension * (6.0 - 0.5) / 12.0
|
||||
assert phi == pytest.approx(0.65 + 0.25 * (0.003 - 0.002) / 0.003)
|
||||
assert phi_mn == pytest.approx(phi * mn)
|
||||
assert ratio == pytest.approx(5000.0 / (phi * mn))
|
||||
|
||||
|
||||
def test_handling_flexure_at_no_strength_is_none_not_zero():
|
||||
pt = capacity.CapacityPoint(x=5.0) # no phi_mn, no geometry, no eps
|
||||
assert capacity.handling_flexure_at(pt, moment_lbft=1000.0) \
|
||||
== (None, None, None)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""`Panel.ignore_lateral_prestress` drops the in-plane prestress term from
|
||||
the corner stresses -- the plant's reference-tool convention -- and is off
|
||||
by default."""
|
||||
from panellift.model import Panel
|
||||
|
||||
|
||||
def test_default_is_off():
|
||||
assert Panel(mark='X', length_ft=20.0, width_ft=10.0,
|
||||
thickness_in=8.0).ignore_lateral_prestress is False
|
||||
|
||||
|
||||
def test_flag_is_a_plain_field():
|
||||
p = Panel(mark='X', length_ft=20.0, width_ft=10.0, thickness_in=8.0,
|
||||
ignore_lateral_prestress=True)
|
||||
assert p.ignore_lateral_prestress is True
|
||||
@@ -0,0 +1,552 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The In-Place strength model: load cases, combinations, and the checks.
|
||||
|
||||
Neutral fixtures only -- no client geometry, no client numbers. The parity
|
||||
against the reference tool's real pieces lives in the scratchpad harness; these
|
||||
are the identities any correct in-place answer must satisfy:
|
||||
|
||||
* a cantilever under a uniform wind gives w*L^2/2 at the support (statics);
|
||||
* a load combination assembles exactly sum(factor * case) and nothing else;
|
||||
* the flexure util is |Mu| / (phi*Mn from capacity_at), reused not rebuilt;
|
||||
* axial phi*Pn(C) is 0.85 f'c Ag * 0.80 * 0.65 for a plain/mild section, and
|
||||
carries the strand reduction where strand is developed;
|
||||
* fewer than two restraints refuses loudly, the engine's own words.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import capacity, inplace, section
|
||||
from panellift.model import Panel, Prestress, Strand
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fixtures
|
||||
|
||||
def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
|
||||
fc_psi=5000.0):
|
||||
"""A solid, non-prestressed wall -- the mild-wall shape (no strand)."""
|
||||
return Panel(mark='TEST-WALL', length_ft=length_ft, width_ft=width_ft,
|
||||
thickness_in=thickness_in, unit_weight_pcf=150.0,
|
||||
fc_psi=fc_psi, fci_psi=3500.0, prestress=None)
|
||||
|
||||
|
||||
def strand_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
|
||||
fc_psi=5000.0):
|
||||
"""A solid wall carrying two straight strands near the tension face."""
|
||||
strands = [Strand(y=2.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0,
|
||||
label='S1'),
|
||||
Strand(y=8.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0,
|
||||
label='S2')]
|
||||
pre = Prestress(strands=strands, loss_pct=8.0, diameter_in=0.5,
|
||||
fpu_ksi=270.0)
|
||||
return Panel(mark='TEST-STRAND', length_ft=length_ft, width_ft=width_ft,
|
||||
thickness_in=thickness_in, unit_weight_pcf=150.0,
|
||||
fc_psi=fc_psi, fci_psi=3500.0, prestress=pre)
|
||||
|
||||
|
||||
def cantilever_inputs(pressure_psf=100.0):
|
||||
"""Restraints at 0 and 20 ft, a 10 ft cantilever above with wind on it."""
|
||||
return inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0],
|
||||
wind_zones=[inplace.LateralPressureZone(20.0, 30.0, pressure_psf,
|
||||
'W cantilever')])
|
||||
|
||||
|
||||
# ------------------------------------------------------- cantilever statics
|
||||
|
||||
def test_cantilever_moment_is_w_l_squared_over_two():
|
||||
"""A uniform wind on a cantilever gives w*L^2/2 at the support.
|
||||
|
||||
This is the whole reason the beam reuse is trusted: the reference tool's
|
||||
own governing WPA1.0 flexure moment IS a cantilever moment, and the closed
|
||||
form is unarguable.
|
||||
"""
|
||||
panel = plain_wall()
|
||||
inputs = cantilever_inputs(pressure_psf=100.0)
|
||||
dia, rig = inplace.solve_lateral(panel, inputs.restraint_xs,
|
||||
inputs.wind_zones, panel.width_ft,
|
||||
panel.fc_psi)
|
||||
w_plf = 100.0 * panel.width_ft # 1000 plf
|
||||
cantilever_ft = 10.0
|
||||
expected = w_plf * cantilever_ft ** 2 / 2.0 # 50,000 lb-ft
|
||||
at_support = dia.moment_at(20.0)
|
||||
assert abs(abs(at_support) - expected) / expected < 0.01
|
||||
# and the free top closes to zero
|
||||
assert abs(dia.moment_at(30.0)) < 0.02 * expected
|
||||
|
||||
|
||||
# --------------------------------------------------- load-case superposition
|
||||
|
||||
def test_load_case_fields_split_dead_and_wind():
|
||||
"""Dead is axial only; wind is moment/shear only. No crosstalk."""
|
||||
panel = plain_wall()
|
||||
inputs = cantilever_inputs()
|
||||
xs = inplace.key_stations(panel, inputs)
|
||||
cases = inplace.build_load_cases(panel, inputs, xs)
|
||||
|
||||
d = cases['D']
|
||||
w = cases['W']
|
||||
# Dead carries axial, no moment/shear.
|
||||
assert max(abs(v) for v in d.axial_lb) > 0.0
|
||||
assert all(v == 0.0 for v in d.moment_lbft)
|
||||
assert all(v == 0.0 for v in d.shear_lb)
|
||||
# Wind carries moment/shear, no axial.
|
||||
assert max(abs(v) for v in w.moment_lbft) > 0.0
|
||||
assert all(v == 0.0 for v in w.axial_lb)
|
||||
# Dead axial at the base is the whole self weight; at the top it is zero.
|
||||
assert d.axial_at(0.0) > d.axial_at(20.0) > 0.0
|
||||
assert abs(d.axial_at(panel.length_ft)) < 1.0
|
||||
|
||||
|
||||
def test_combination_demand_is_pure_superposition():
|
||||
"""Mu, Vu, Pu are exactly sum(factor * case) -- LC7 = 0.9D + 1.0W."""
|
||||
panel = plain_wall()
|
||||
inputs = cantilever_inputs()
|
||||
xs = inplace.key_stations(panel, inputs)
|
||||
cases = inplace.build_load_cases(panel, inputs, xs)
|
||||
|
||||
lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7')
|
||||
x = 20.0
|
||||
pu, mu, vu = inplace.combination_demand(cases, lc7, x)
|
||||
# 0.9 * dead axial, 1.0 * wind moment, and T/H are zero cases.
|
||||
assert pu == pytest.approx(0.9 * cases['D'].axial_at(x), rel=1e-9)
|
||||
assert mu == pytest.approx(1.0 * cases['W'].moment_at(x), rel=1e-9)
|
||||
assert vu == pytest.approx(1.0 * cases['W'].shear_at(x), rel=1e-9)
|
||||
|
||||
|
||||
def test_known_aci_combo_assembles_the_right_factored_demand():
|
||||
"""LC5 = 1.2D + 1.0L + 0.5R + 1.0W applies each factor to its own case.
|
||||
|
||||
Built from a hand-set case table so the arithmetic is unarguable: dead 100k
|
||||
axial, live 20k axial, wind 10 k-ft moment -> LC5 Pu = 1.2*100 + 1.0*20 =
|
||||
140 k and Mu = 1.0*10 = 10 k-ft.
|
||||
"""
|
||||
xs = [0.0, 1.0]
|
||||
zeros = [0.0, 0.0]
|
||||
cases = {
|
||||
'D': inplace.LoadField('D', 'D', xs, [100000.0, 100000.0], list(zeros),
|
||||
list(zeros)),
|
||||
'L': inplace.LoadField('L', 'L', xs, [20000.0, 20000.0], list(zeros),
|
||||
list(zeros)),
|
||||
'R': inplace.LoadField('R', 'R', xs, [5000.0, 5000.0], list(zeros),
|
||||
list(zeros)),
|
||||
'W': inplace.LoadField('W', 'W', xs, list(zeros),
|
||||
[10000.0, 10000.0], [3000.0, 3000.0]),
|
||||
}
|
||||
lc5 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC5')
|
||||
pu, mu, vu = inplace.combination_demand(cases, lc5, 0.0)
|
||||
assert pu == pytest.approx(1.2 * 100000 + 1.0 * 20000 + 0.5 * 5000)
|
||||
assert mu == pytest.approx(1.0 * 10000)
|
||||
assert vu == pytest.approx(1.0 * 3000)
|
||||
|
||||
|
||||
# ----------------------------------------------------- axial capacity (new)
|
||||
|
||||
def test_axial_phi_pn_c_plain_section():
|
||||
"""phi*Pn(C) = 0.65 * 0.80 * 0.85 f'c Ag for a plain/mild section."""
|
||||
panel = plain_wall(width_ft=10.0, thickness_in=12.0, fc_psi=5000.0)
|
||||
stage = inplace.in_place_stage(panel, cantilever_inputs())
|
||||
ag = section.NetSection(panel, 5.0).area_in2 # 120 in * 12 in
|
||||
ax = inplace.axial_capacity(panel, stage, 5.0, pu_lb=100000.0,
|
||||
restraint_xs=[0.0, 20.0])
|
||||
expected = (inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR
|
||||
* 0.85 * 5000.0 * ag)
|
||||
assert ax.phi_pn_c_lb == pytest.approx(expected, rel=1e-9)
|
||||
assert ax.util == pytest.approx(100000.0 / expected, rel=1e-9)
|
||||
|
||||
|
||||
def test_axial_strand_term_reduces_po():
|
||||
"""A developed strand REDUCES Po by (f_se - 0.003 Ep) Aps (docs sec.5.1)."""
|
||||
panel = strand_wall()
|
||||
stage = inplace.in_place_stage(panel, cantilever_inputs())
|
||||
# A mid-span station where both strands are fully developed.
|
||||
x = 15.0
|
||||
ax = inplace.axial_capacity(panel, stage, x, pu_lb=50000.0,
|
||||
restraint_xs=[0.0, 20.0])
|
||||
plain = inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR * \
|
||||
0.85 * panel.fc_psi * section.NetSection(panel, x).area_in2
|
||||
# With strand present phi*Pn(C) is LOWER than the plain 0.85 f'c Ag figure.
|
||||
assert ax.aps_in2 > 0.0
|
||||
assert ax.phi_pn_c_lb < plain
|
||||
|
||||
|
||||
def test_axial_reports_pcrit():
|
||||
"""phi*Pcrit is computed (Euler on EI_eff) and flagged approximate."""
|
||||
panel = plain_wall()
|
||||
stage = inplace.in_place_stage(panel, cantilever_inputs())
|
||||
ax = inplace.axial_capacity(panel, stage, 10.0, pu_lb=100000.0,
|
||||
restraint_xs=[0.0, 20.0])
|
||||
assert ax.phi_pcrit_lb is not None and ax.phi_pcrit_lb > 0.0
|
||||
assert ax.pcrit_note and 'approximate' in ax.pcrit_note
|
||||
|
||||
|
||||
# ------------------------------------------------- strength util from capacity
|
||||
|
||||
def test_flexure_util_comes_from_capacity_at():
|
||||
"""The in-place flexure util is a P-M-aware capacity fed the combo's Mu/Pu.
|
||||
|
||||
A pure-strand piece (no mild reinforcement) is checked through the
|
||||
additive `capacity.strand_pm_flexure_at` (Eriksson parity gap #2), NOT
|
||||
`capacity_at`'s flat pure-bending phi*Mn -- `_check_station` overwrites
|
||||
`sd.cap` with the P-M numbers when they can be judged. This pins that the
|
||||
util the run reports is that same P-M number, at the SAME station's own
|
||||
Pu, not the flat one.
|
||||
"""
|
||||
panel = strand_wall()
|
||||
inputs = cantilever_inputs(pressure_psf=300.0) # enough to bend it
|
||||
res = inplace.run(panel, inputs)
|
||||
assert res.refusal is None
|
||||
# find a combo/station where flexure was judged
|
||||
found = False
|
||||
for cr in res.combos:
|
||||
for sd in cr.stations:
|
||||
if sd.flex_util is not None:
|
||||
direct = capacity.strand_pm_flexure_at(
|
||||
panel, res.stage, sd.x, sd.mu_lbft, pu_lb=sd.pu_lb)
|
||||
assert sd.flex_util == pytest.approx(direct.m_over_phi_mn)
|
||||
found = True
|
||||
assert found, 'no station produced a flexural util to check'
|
||||
|
||||
|
||||
def test_mild_wall_flexure_dashes_not_passes():
|
||||
"""A no-strand wall dashes phi*Mn with a reason -- never a fabricated pass.
|
||||
|
||||
capacity_at is strand-only; the mild flexural capacity is the concurrent
|
||||
capacity layer's job. The in-place check must surface that as a
|
||||
could-not-judge, exactly like every other dash in the engine.
|
||||
"""
|
||||
panel = plain_wall()
|
||||
inputs = cantilever_inputs(pressure_psf=300.0)
|
||||
res = inplace.run(panel, inputs)
|
||||
combo = res.combos[0]
|
||||
sd = combo.stations[len(combo.stations) // 2]
|
||||
assert sd.flex_util is None
|
||||
assert sd.cap.reason(capacity.PHI_MN) # a reason is present
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- refusal
|
||||
|
||||
def test_fewer_than_two_restraints_refuses_loudly():
|
||||
"""One restraint is not a span -- the engine refuses, in its own words."""
|
||||
panel = plain_wall()
|
||||
inputs = inplace.InPlaceInputs(restraint_xs=[0.0], wind_zones=[])
|
||||
res = inplace.run(panel, inputs)
|
||||
assert res.refusal is not None
|
||||
assert 'two supports' in res.refusal
|
||||
assert not res.combos
|
||||
|
||||
|
||||
def test_run_finds_an_overall_governing_check():
|
||||
"""A wired run names an overall governing combo/check/util/x."""
|
||||
panel = strand_wall()
|
||||
res = inplace.run(panel, cantilever_inputs(pressure_psf=300.0))
|
||||
assert res.gov_combo and res.gov_check and res.gov_util is not None
|
||||
assert res.gov_util > 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------- combination set
|
||||
|
||||
def test_asce_strength_set_matches_the_printed_combinations():
|
||||
"""The strength set is the ASCE 7-16 2.3 combos with Eriksson's T/H."""
|
||||
names = [c.name for c in inplace.ASCE_7_16_STRENGTH]
|
||||
assert names == ['LC1', 'LC2', 'LC3', 'LC4', 'LC5', 'LC6', 'LC7', 'LC8']
|
||||
lc1 = inplace.ASCE_7_16_STRENGTH[0]
|
||||
assert lc1.factors['D'] == 1.4
|
||||
lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7')
|
||||
assert lc7.factors['D'] == 0.9 and lc7.factors['W'] == 1.0
|
||||
# every strength combo folds in 1.0T and 1.6H, per docs sec.7
|
||||
for c in inplace.ASCE_7_16_STRENGTH:
|
||||
assert c.factors.get('T') == 1.0
|
||||
assert c.factors.get('H') == 1.6
|
||||
|
||||
|
||||
# ------------------------------------------- eccentric bearing loads (gap #1)
|
||||
|
||||
def test_eccentric_dead_load_puts_a_real_moment_on_the_d_case():
|
||||
"""`AppliedAxial.ecc_in` turns a dead bearing load into a D-case moment.
|
||||
|
||||
Before this field existed, `dead_axial_field`/`live_axial_field` only
|
||||
ever populated `axial_lb` -- `D`'s `moment_lbft` was all zeros
|
||||
(Eriksson in-place parity gap #1, `_scratch/eriksson_parity_2026-09-04.md`
|
||||
root cause #1). A load with `ecc_in == 0.0` (the default) must still
|
||||
leave D's moment at all zeros -- a deck with no eccentric loads is
|
||||
byte-for-byte what this engine reported before `ecc_in` existed.
|
||||
"""
|
||||
panel = plain_wall(length_ft=40.0)
|
||||
concentric = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0, 40.0],
|
||||
applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0,
|
||||
label='P1')])
|
||||
xs = inplace.key_stations(panel, concentric)
|
||||
cases = inplace.build_load_cases(panel, concentric, xs)
|
||||
assert all(v == 0.0 for v in cases['D'].moment_lbft)
|
||||
|
||||
eccentric = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0, 40.0],
|
||||
applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0,
|
||||
ecc_in=6.0, label='P1')])
|
||||
cases_ecc = inplace.build_load_cases(panel, eccentric, xs)
|
||||
assert max(abs(v) for v in cases_ecc['D'].moment_lbft) > 0.0
|
||||
# axial is unchanged by the eccentricity -- same P(x), a real moment on top.
|
||||
assert cases_ecc['D'].axial_lb == cases['D'].axial_lb
|
||||
|
||||
|
||||
def test_eccentric_moment_matches_the_point_moment_hand_solution():
|
||||
"""The D-case moment field IS the `PointMoment` beam solve, hand-checked.
|
||||
|
||||
Two restraints only (determinate), so `solve_point_moments`'s answer must
|
||||
match `PointMoment`'s own closed form exactly, on `eccentric_moments`' OWN
|
||||
`moment_lbft = -(P*ecc_in/12)` (the minus is the golden-pinned sign, see
|
||||
`eccentric_moments`' docstring): with `m0 = -(P*ecc_in/12)` at x=10 ft on
|
||||
a 2-support 20 ft span, R0=-m0/L, RL=+m0/L, and M(x) = -m0*x/L for x<10,
|
||||
m0*(1-x/20) for x>10.
|
||||
"""
|
||||
panel = plain_wall(length_ft=20.0)
|
||||
p_lb, ecc_in = 40000.0, 6.0
|
||||
m0 = -(p_lb * ecc_in / 12.0)
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0],
|
||||
applied_axial=[inplace.AppliedAxial(x_ft=10.0, dead_lb=p_lb,
|
||||
ecc_in=ecc_in, label='P1')])
|
||||
xs = sorted(set([2.0, 5.0, 8.0, 12.0, 15.0, 18.0]))
|
||||
cases = inplace.build_load_cases(panel, inputs, xs)
|
||||
d = cases['D']
|
||||
for x in xs:
|
||||
expected = (-m0 * x / 20.0 if x < 10.0
|
||||
else m0 * (1.0 - x / 20.0))
|
||||
assert d.moment_at(x) == pytest.approx(expected, abs=1.0), x
|
||||
|
||||
|
||||
def test_eccentric_moment_sign_matches_the_golden_p1_p6_jumps():
|
||||
"""The golden's own Mu jump at P1 and P6 (WPA2.0/A27 pp.30-31), reproduced.
|
||||
|
||||
P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k, L=12.25k) jumps
|
||||
+38.9 k-ft crossing its own station in the golden's table (14.87 ft:
|
||||
-16.6 -> 16.35 ft: +22.3) -- close to the LC2-factored P*e/12 =
|
||||
(1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft, POSITIVE for a NEGATIVE offset.
|
||||
P1 (x=11'-1.5"=11.125 ft, ecc_in=+10 in, D=9.62k, L=6.01k) jumps -24.2
|
||||
k-ft (10.41 ft: +6.5 -> 11.12 ft: -17.7) -- close to
|
||||
-(1.2*9.62+1.6*6.01)*10/12 = -17.63 k-ft (plus the linear slope over the
|
||||
0.7 ft gap to golden's own pre-jump row), NEGATIVE for a POSITIVE offset.
|
||||
This is what pins `eccentric_moments`' `-(P*ecc_in/12)` sign, checked
|
||||
directly on THIS engine's own D-case (LC2's D factor only, isolating the
|
||||
mechanism from L/W): the D-only jump at each station must have the sign
|
||||
the golden's own jump has.
|
||||
"""
|
||||
panel = plain_wall(length_ft=74.0 + 4.0 / 12.0)
|
||||
p1_x, p1_ecc, p1_d = 11.0 + 1.5 / 12.0, 10.0, 9.62e3
|
||||
p6_x, p6_ecc, p6_d = 15.0 + 11.25 / 12.0, -10.0, 20.18e3
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, panel.length_ft],
|
||||
applied_axial=[
|
||||
inplace.AppliedAxial(x_ft=p1_x, dead_lb=p1_d, ecc_in=p1_ecc,
|
||||
label='P1'),
|
||||
inplace.AppliedAxial(x_ft=p6_x, dead_lb=p6_d, ecc_in=p6_ecc,
|
||||
label='P6')])
|
||||
xs = [0.0, p1_x - 0.5, p1_x + 0.001, p6_x - 0.5, p6_x + 0.001,
|
||||
panel.length_ft]
|
||||
cases = inplace.build_load_cases(panel, inputs, sorted(xs))
|
||||
d = cases['D']
|
||||
# P6: negative offset -> POSITIVE jump.
|
||||
assert d.moment_at(p6_x + 0.001) > d.moment_at(p6_x - 0.5)
|
||||
# P1: positive offset -> NEGATIVE jump.
|
||||
assert d.moment_at(p1_x + 0.001) < d.moment_at(p1_x - 0.5)
|
||||
|
||||
|
||||
# ------------------------------------------------------ wind envelope (gap #3)
|
||||
|
||||
def test_wind_envelope_picks_the_worse_of_pressure_and_suction():
|
||||
"""Every check is enveloped over BOTH wind directions (Eriksson gap #3).
|
||||
|
||||
`ASCE_7_16_STRENGTH` only ever carries a POSITIVE W factor, so without
|
||||
the envelope a station only ever sees pressure. With an eccentric dead
|
||||
bearing load ALSO putting a real moment into the same stations, pressure
|
||||
and suction combine with it differently station to station -- this pins
|
||||
that `inplace.run`'s reported per-station flex/shear utils are exactly
|
||||
the max of a hand-run of BOTH `wind_suction_cases` directions, and that
|
||||
the two directions really do disagree somewhere (otherwise the envelope
|
||||
would be a no-op and this would not be testing anything).
|
||||
"""
|
||||
panel = strand_wall(length_ft=40.0)
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0, 40.0],
|
||||
wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')],
|
||||
applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0,
|
||||
ecc_in=4.0, label='P1')])
|
||||
res = inplace.run(panel, inputs)
|
||||
assert res.refusal is None
|
||||
cases_suction = inplace.wind_suction_cases(res.cases)
|
||||
assert cases_suction is not None
|
||||
|
||||
checked = 0
|
||||
disagreed = False
|
||||
for cr in res.combos:
|
||||
if not cr.combo.factors.get('W', 0.0):
|
||||
continue
|
||||
for sd in cr.stations:
|
||||
a = inplace._check_station(panel, res.stage, res.cases, cr.combo,
|
||||
sd.x, inputs.restraint_xs)
|
||||
b = inplace._check_station(panel, res.stage, cases_suction,
|
||||
cr.combo, sd.x, inputs.restraint_xs)
|
||||
want_flex = max(a.flex_util if a.flex_util is not None else -1.0,
|
||||
b.flex_util if b.flex_util is not None else -1.0)
|
||||
got_flex = sd.flex_util if sd.flex_util is not None else -1.0
|
||||
assert got_flex == pytest.approx(want_flex, abs=1e-9)
|
||||
if a.mu_lbft != pytest.approx(b.mu_lbft):
|
||||
disagreed = True
|
||||
checked += 1
|
||||
assert checked > 0
|
||||
assert disagreed, ('pressure and suction produced the same Mu everywhere '
|
||||
'-- the fixture is not exercising the envelope')
|
||||
|
||||
|
||||
# --------------------------------------------- LC2 magnitude at a full jump
|
||||
|
||||
def test_lc2_reproduces_the_golden_p6_jump_at_close_to_full_magnitude():
|
||||
"""`key_stations` forcing a node at (and just before) each load is what
|
||||
lets a combo's factored jump reach close to its full magnitude.
|
||||
|
||||
WPA2.0/A27's own P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k,
|
||||
L=12.25k) jumps +38.9 k-ft crossing its station under the golden's own
|
||||
LC2 (1.2D+1.6L) -- close to (1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft.
|
||||
Before `key_stations` forced a node onto every eccentric load's own x
|
||||
(and a hair before it), the coarse D/L `LoadField`s only sampled the
|
||||
beam solve's fine diagram at the L/20 grid, and a jump landing INSIDE
|
||||
one of those coarse intervals got smoothed into a gentle ramp across the
|
||||
whole interval instead of a sharp step -- Mu magnitudes read ~50-60% of
|
||||
the golden's own printed jumps. This runs `inplace.run` end to end (not
|
||||
a hand-built `xs` list) and checks the jump `key_stations` itself
|
||||
produces is within 10% of the LC2-factored P*e/12 (a looser tolerance
|
||||
than the hand-solution tests because this station's own backoff point
|
||||
is a genuine finite distance from the load, not an infinitesimal limit).
|
||||
"""
|
||||
panel = plain_wall(length_ft=74.0 + 4.0 / 12.0, width_ft=4.0,
|
||||
thickness_in=8.0)
|
||||
p6_x = 15.0 + 11.25 / 12.0
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, panel.length_ft],
|
||||
applied_axial=[inplace.AppliedAxial(x_ft=p6_x, dead_lb=20.18e3,
|
||||
live_lb=12.25e3, ecc_in=-10.0,
|
||||
label='P6')])
|
||||
xs = inplace.key_stations(panel, inputs)
|
||||
cases = inplace.build_load_cases(panel, inputs, xs)
|
||||
lc2 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC2')
|
||||
|
||||
before = max(x for x in xs if x < p6_x)
|
||||
after = min(x for x in xs if x >= p6_x)
|
||||
_pu_a, mu_a, _vu_a = inplace.combination_demand(cases, lc2, before)
|
||||
_pu_b, mu_b, _vu_b = inplace.combination_demand(cases, lc2, after)
|
||||
jump = mu_b - mu_a
|
||||
expected = (1.2 * 20.18e3 + 1.6 * 12.25e3) * 10.0 / 12.0
|
||||
assert jump == pytest.approx(expected, rel=0.10), (
|
||||
'jump {0:.1f} lb-ft vs the LC2-factored P*e/12 {1:.1f} lb-ft'
|
||||
.format(jump, expected))
|
||||
|
||||
|
||||
# ------------------------------------------------- manual per-zone wind (PANELANALYSIS-4)
|
||||
|
||||
def test_manual_zones_pressure_and_suction_give_different_w_fields():
|
||||
"""A zone with pressure != suction gives W+ != W- (not the old negate).
|
||||
|
||||
`wind_zone_mode='manual'` plus a zone carrying DIFFERENT `pressure_psf`
|
||||
and `suction_psf` magnitudes must NOT reproduce `wind_suction_cases`'s
|
||||
"negate the pressure field" shortcut -- that shortcut is exact only when
|
||||
the two magnitudes match, which this fixture deliberately violates
|
||||
(10 psf pressure, 30 psf suction) so a bug that silently falls back to
|
||||
the legacy path is caught by the moments actually disagreeing by more
|
||||
than a sign flip.
|
||||
"""
|
||||
panel = plain_wall(length_ft=40.0)
|
||||
zone = inplace.LateralPressureZone(
|
||||
x0_ft=0.0, x1_ft=40.0, pressure_psf=10.0, suction_psf=30.0,
|
||||
label='W1')
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone],
|
||||
wind_zone_mode='manual')
|
||||
res = inplace.run(panel, inputs)
|
||||
assert res.refusal is None
|
||||
key_xs = res.cases['D'].xs
|
||||
cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs,
|
||||
res.cases)
|
||||
assert cases_suction is not None
|
||||
w_plus = res.cases['W']
|
||||
w_minus = cases_suction['W']
|
||||
|
||||
# A plain negate would give w_minus.moment == -w_plus.moment everywhere;
|
||||
# the 3x magnitude difference (30 vs 10 psf) must break that exactly.
|
||||
ratios = [abs(m) for m in w_minus.moment_lbft if abs(m) > 1e-6]
|
||||
assert ratios, 'suction case carries no moment at all'
|
||||
peak_plus = max(abs(m) for m in w_plus.moment_lbft)
|
||||
peak_minus = max(abs(m) for m in w_minus.moment_lbft)
|
||||
assert peak_minus == pytest.approx(peak_plus * 3.0, rel=0.02), (
|
||||
'suction (30 psf) should peak at 3x the pressure (10 psf) case, '
|
||||
'got {0:.1f} vs {1:.1f}'.format(peak_minus, peak_plus))
|
||||
|
||||
|
||||
def test_manual_zone_with_pressure_only_contributes_nothing_to_suction():
|
||||
"""A zone with pressure_psf and no suction_psf: W- has nothing from it.
|
||||
|
||||
PANELANALYSIS-4: "a zone with pressure only contributes nothing to W-".
|
||||
With every zone pressure-only, the whole deck has no suction data at all,
|
||||
so `build_wind_suction_cases` returns None (no envelope pass) rather than
|
||||
inventing a suction field off the pressure magnitude.
|
||||
"""
|
||||
panel = plain_wall(length_ft=40.0)
|
||||
zone = inplace.LateralPressureZone(
|
||||
x0_ft=0.0, x1_ft=40.0, pressure_psf=15.0, suction_psf=None,
|
||||
label='W1')
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone],
|
||||
wind_zone_mode='manual')
|
||||
res = inplace.run(panel, inputs)
|
||||
assert res.refusal is None
|
||||
key_xs = res.cases['D'].xs
|
||||
cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs,
|
||||
res.cases)
|
||||
assert cases_suction is None
|
||||
|
||||
|
||||
def test_manual_zone_per_zone_width_scales_the_line_load():
|
||||
"""A zone's OWN `width_ft` (not the panel width) sets its line load.
|
||||
|
||||
Two otherwise-identical single-zone runs, one at the panel's width and
|
||||
one at double it via the zone's own `width_ft`, must differ by exactly
|
||||
that factor -- pins that `lateral_line_load_plf` reads the zone's width
|
||||
ahead of the panel-wide fallback.
|
||||
"""
|
||||
panel = plain_wall(length_ft=40.0, width_ft=8.0)
|
||||
narrow = inplace.LateralPressureZone(
|
||||
x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=None, label='W1')
|
||||
wide = inplace.LateralPressureZone(
|
||||
x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=16.0, label='W1')
|
||||
dia_narrow, _r = inplace.solve_lateral(panel, [0.0, 20.0, 40.0],
|
||||
[narrow], panel.width_ft,
|
||||
panel.fc_psi)
|
||||
dia_wide, _r2 = inplace.solve_lateral(panel, [0.0, 20.0, 40.0],
|
||||
[wide], panel.width_ft,
|
||||
panel.fc_psi)
|
||||
m_narrow = max(abs(dia_narrow.moment_at(x)) for x in
|
||||
[i * 40.0 / 40.0 for i in range(41)])
|
||||
m_wide = max(abs(dia_wide.moment_at(x)) for x in
|
||||
[i * 40.0 / 40.0 for i in range(41)])
|
||||
assert m_wide == pytest.approx(m_narrow * 2.0, rel=0.02)
|
||||
|
||||
|
||||
def test_legacy_zones_still_use_the_negate_shortcut():
|
||||
"""A deck with NO manual mode (every zone's `suction_psf` left at its
|
||||
dataclass default None) keeps the exact old `wind_suction_cases` path --
|
||||
`build_wind_suction_cases` must dispatch to it byte for byte when
|
||||
`wind_zone_mode` is left at its own default 'legacy'.
|
||||
"""
|
||||
panel = strand_wall(length_ft=40.0)
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0, 40.0],
|
||||
wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')])
|
||||
assert inputs.wind_zone_mode == 'legacy'
|
||||
res = inplace.run(panel, inputs)
|
||||
key_xs = res.cases['D'].xs
|
||||
dispatched = inplace.build_wind_suction_cases(panel, inputs, key_xs,
|
||||
res.cases)
|
||||
direct = inplace.wind_suction_cases(res.cases)
|
||||
assert dispatched['W'].moment_lbft == direct['W'].moment_lbft
|
||||
assert dispatched['W'].shear_lb == direct['W'].shear_lb
|
||||
@@ -0,0 +1,202 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The In-Place SERVICE slice: ASCE 2.4 combos, extreme-fibre stress vs the
|
||||
cracking / compression limits, deflection, and the P-Delta magnifier.
|
||||
|
||||
Neutral fixtures only -- the parity against the reference tool's real pieces
|
||||
lives in the scratchpad harness (parity_service.py). These pin the identities
|
||||
any correct service answer must satisfy:
|
||||
|
||||
* the service set is ASCE 7-16 2.4.1(1)-(5), T at 0.75, no 0.6D+0.6W;
|
||||
* SC5 = 1.0D + 0.6W assembles exactly 1.0*P_D and 0.6*M_W;
|
||||
* extreme-fibre tension = M/S - P/A (mild), util = tension / 7.5 sqrt(f'c),
|
||||
and a >1 util is REPORTED (the tool flags it, docs 6.1);
|
||||
* the P-Delta magnifier is 1/(1-gamma), gamma = Pu L^2 / (8 EI_eff), and an
|
||||
instability is NOT JUDGED, named -- never a fabricated number;
|
||||
* a deflection with no L/n limit is computed but its LIMIT check is NOT
|
||||
JUDGED, named.
|
||||
"""
|
||||
import math
|
||||
|
||||
from panellift import capacity, inplace, section
|
||||
from panellift.model import Panel
|
||||
|
||||
|
||||
def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
|
||||
fc_psi=6000.0):
|
||||
return Panel(mark='SVC-WALL', length_ft=length_ft, width_ft=width_ft,
|
||||
thickness_in=thickness_in, unit_weight_pcf=150.0,
|
||||
fc_psi=fc_psi, fci_psi=3500.0, prestress=None)
|
||||
|
||||
|
||||
def _service_cases(x_lo, x_hi, p_lb, m_lbft, wind_factor=0.6):
|
||||
"""A cases dict where SC5 (1.0D + 0.6W) assembles to (p_lb, m_lbft): D
|
||||
carries the axial, W carries m/0.6 so 0.6*W = m."""
|
||||
xs = [x_lo, x_hi]
|
||||
return {
|
||||
'D': inplace.LoadField('D', 'Dead', xs, [p_lb, p_lb], [0.0, 0.0],
|
||||
[0.0, 0.0]),
|
||||
'W': inplace.LoadField('W', 'Wind', xs, [0.0, 0.0],
|
||||
[m_lbft / wind_factor, m_lbft / wind_factor],
|
||||
[0.0, 0.0]),
|
||||
}
|
||||
|
||||
|
||||
def _stage(panel):
|
||||
return inplace.in_place_stage(panel, inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, panel.length_ft]))
|
||||
|
||||
|
||||
def _sc5():
|
||||
return next(c for c in inplace.ASCE_7_16_SERVICE if c.name == 'SC5')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the combos
|
||||
|
||||
def test_service_set_is_the_asce_2_4_basic_combinations():
|
||||
names = [c.name for c in inplace.ASCE_7_16_SERVICE]
|
||||
assert names == ['SC1', 'SC2', 'SC3', 'SC4', 'SC5']
|
||||
for c in inplace.ASCE_7_16_SERVICE:
|
||||
assert c.kind == 'service'
|
||||
assert c.factors.get('T') == 0.75
|
||||
sc5 = _sc5()
|
||||
assert sc5.factors['D'] == 1.0 and sc5.factors['W'] == 0.6
|
||||
# combination (7) 0.6D+0.6W is deliberately NOT in the reference set.
|
||||
assert not any(c.factors.get('D') == 0.6 for c in inplace.ASCE_7_16_SERVICE)
|
||||
|
||||
|
||||
def test_service_combo_assembles_the_right_demand():
|
||||
"""SC5 = 1.0D + 0.6W puts 1.0*P_D and 0.6*M_W, nothing else."""
|
||||
panel = plain_wall()
|
||||
cases = _service_cases(0.0, panel.length_ft, p_lb=40000.0, m_lbft=100000.0)
|
||||
pu, mu, vu = inplace.combination_demand(cases, _sc5(), 12.0)
|
||||
assert abs(pu - 40000.0) < 1e-6
|
||||
assert abs(mu - 100000.0) < 1e-6
|
||||
assert abs(vu) < 1e-9
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the stress
|
||||
|
||||
def test_service_tension_matches_hand_stress_and_cracking_util():
|
||||
"""WPA1.0's governing service tension row: a 12 in x 164 in solid mild wall
|
||||
at M = 260.99 k-ft, P = 36.9 k gives 776.9 psi vs 580.9 (7.5 sqrt 6000)."""
|
||||
panel = Panel(mark='SVC', length_ft=43.08, width_ft=164.0 / 12.0,
|
||||
thickness_in=12.0, unit_weight_pcf=150.0, fci_psi=3500.0,
|
||||
fc_psi=6000.0, prestress=None)
|
||||
cases = _service_cases(0.0, panel.length_ft, p_lb=36900.0, m_lbft=260990.0)
|
||||
st = inplace.service_station(panel, _stage(panel), cases, [_sc5()], 25.1)
|
||||
assert st.why is None
|
||||
assert abs(st.cracking_limit_psi - 580.9) < 0.5
|
||||
assert abs(st.tension_psi - 776.9) < 1.0
|
||||
assert abs(st.compression_psi - 814.4) < 1.0
|
||||
# util = tension / cracking; over 1.0 here -- the tool FLAGS it, we report it
|
||||
assert st.tension_util > 1.0
|
||||
assert abs(st.tension_util - 776.9 / 580.9) < 0.01
|
||||
|
||||
|
||||
def test_prestress_precompression_term_is_the_reused_primitive():
|
||||
"""WPA5.0's developed-strand compression row: Pi = 123.3 k, M = 17.22 k-ft,
|
||||
P = 19.7 k -> 815.6 psi through capacity.extreme_fibre_stresses + (-P/A)."""
|
||||
A, i_in4, c_in = 313.5, 3161.0, 5.5
|
||||
f_form, f_far = capacity.extreme_fibre_stresses(
|
||||
123300.0, A, 0.0, c_in, i_in4, 17220.0)
|
||||
f_form -= 19700.0 / A
|
||||
f_far -= 19700.0 / A
|
||||
comp = max(0.0, -f_form, -f_far)
|
||||
assert abs(comp - 815.6) < 1.0
|
||||
|
||||
|
||||
def test_no_section_dashes_with_a_reason_not_a_zero():
|
||||
"""A station where NetSection cannot be built is None+why, never a fake 0."""
|
||||
panel = plain_wall(thickness_in=0.0)
|
||||
cases = _service_cases(0.0, panel.length_ft, 1000.0, 1000.0)
|
||||
st = inplace.service_station(panel, _stage(panel), cases, [_sc5()], 5.0)
|
||||
assert st.why is not None
|
||||
assert st.tension_util is None and st.compression_util is None
|
||||
|
||||
|
||||
def test_cracking_class_t_raises_the_limit_to_12_sqrt_fc():
|
||||
stage = _stage(plain_wall(fc_psi=6000.0))
|
||||
assert abs(inplace.cracking_limit_psi(stage, class_t=False)
|
||||
- 7.5 * math.sqrt(6000.0)) < 0.1
|
||||
assert abs(inplace.cracking_limit_psi(stage, class_t=True)
|
||||
- 12.0 * math.sqrt(6000.0)) < 0.1
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the P-Delta
|
||||
|
||||
def test_pdelta_magnifier_on_a_hand_case():
|
||||
"""delta = 1/(1-gamma), gamma = Pu L^2 / (8 EI_eff), EI_eff = 0.875 Ec Ig."""
|
||||
panel = plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
|
||||
fc_psi=6000.0)
|
||||
pu, span_ft = 50000.0, 30.0
|
||||
m = inplace.pdelta_magnifier(panel, _stage(panel), span_ft, pu, 15.0,
|
||||
beta_d=0.0)
|
||||
ec = inplace.ec_aci_psi(6000.0, 150.0)
|
||||
ig = section.NetSection(panel, 15.0).iz_in4
|
||||
ei = 0.875 * ec * ig
|
||||
gamma = pu * (span_ft * 12.0) ** 2 / (8.0 * ei)
|
||||
assert abs(m.gamma - gamma) < 1e-9
|
||||
assert abs(m.magnifier - 1.0 / (1.0 - gamma)) < 1e-9
|
||||
assert m.magnifier > 1.0
|
||||
|
||||
|
||||
def test_pdelta_reports_instability_not_a_fake_number():
|
||||
panel = plain_wall(length_ft=60.0, width_ft=4.0, thickness_in=6.0,
|
||||
fc_psi=5000.0)
|
||||
m = inplace.pdelta_magnifier(panel, _stage(panel), 60.0, 5.0e6, 30.0)
|
||||
assert m.magnifier is None
|
||||
assert m.why and 'stability' in m.why
|
||||
|
||||
|
||||
def test_tension_axial_does_not_magnify():
|
||||
panel = plain_wall(fc_psi=6000.0)
|
||||
m = inplace.pdelta_magnifier(panel, _stage(panel), panel.length_ft,
|
||||
-30000.0, 15.0)
|
||||
assert m.magnifier == 1.0 and m.gamma == 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------ deflection/run
|
||||
|
||||
def test_run_populates_service_with_deflection_limit():
|
||||
panel = plain_wall(length_ft=30.0, width_ft=10.0, fc_psi=6000.0)
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 30.0],
|
||||
wind_zones=[inplace.LateralPressureZone(0.0, 30.0, 50.0, 'W')],
|
||||
deflection_limit_ratio=150.0)
|
||||
res = inplace.run(panel, inputs)
|
||||
assert res.service is not None and res.service.stations
|
||||
d = res.service.deflection
|
||||
assert d.combo == 'SC5'
|
||||
assert d.delta_wind_in > 0.0
|
||||
assert d.delta_total_in is not None and d.delta_total_in >= d.delta_i_in
|
||||
assert abs(d.limit_in - 30.0 * 12.0 / 150.0) < 1e-6
|
||||
assert d.util is not None and d.util > 0.0
|
||||
|
||||
|
||||
def test_deflection_limit_not_supplied_is_not_judged_not_faked():
|
||||
panel = plain_wall(length_ft=30.0, width_ft=10.0, fc_psi=6000.0)
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 30.0],
|
||||
wind_zones=[inplace.LateralPressureZone(0.0, 30.0, 50.0, 'W')])
|
||||
res = inplace.run(panel, inputs)
|
||||
d = res.service.deflection
|
||||
assert d.delta_total_in is not None # deflection still computed
|
||||
assert d.util is None # but not judged against a limit
|
||||
assert d.why and 'deflection limit' in d.why
|
||||
|
||||
|
||||
def test_deflection_recovers_the_simple_beam_closed_form():
|
||||
"""Uniform service wind, pinned-pinned: our double integration of the
|
||||
moment field gives 5 w L^4 / (384 EI_eff) at midspan."""
|
||||
panel = plain_wall(length_ft=20.0, width_ft=8.0, thickness_in=10.0,
|
||||
fc_psi=6000.0)
|
||||
press = 40.0
|
||||
inputs = inplace.InPlaceInputs(
|
||||
restraint_xs=[0.0, 20.0],
|
||||
wind_zones=[inplace.LateralPressureZone(0.0, 20.0, press, 'W')],
|
||||
deflection_limit_ratio=240.0)
|
||||
d = inplace.run(panel, inputs).service.deflection
|
||||
w_lb_per_in = (0.6 * press * panel.width_ft) / 12.0
|
||||
l_in = 20.0 * 12.0
|
||||
closed = 5.0 * w_lb_per_in * l_in ** 4 / (384.0 * d.ei_eff_lbin2)
|
||||
assert abs(d.delta_wind_in - closed) / closed < 0.01
|
||||
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The search remembers layouts it has already costed, and two dead branches.
|
||||
|
||||
`_peak_psi` is the inner loop of the whole package -- 884 calls on the median
|
||||
job of the reference sweep, 15 ms each, 93% of the run. 17% of those calls ask
|
||||
for a layout the search has already costed: the coarse sweep and the first
|
||||
refinement pass over the same stations, and each of the three refinement sweeps
|
||||
re-tries every line's CURRENT position before deciding it cannot improve on it.
|
||||
|
||||
Memoising is only safe because the function is pure, and it is pure for a
|
||||
reason that has to stay true: everything it reads apart from `xs` lives on the
|
||||
frame, and the frame holds the parts of the problem that DO NOT MOVE when a
|
||||
pick moves -- the load profile, the section, the station list. The first test
|
||||
below is the one that matters; it is what catches somebody later making the
|
||||
frame depend on the positions.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import layout, section, statics
|
||||
from conftest import LENGTH_FT, aspire_pockets, make_panel
|
||||
|
||||
|
||||
def _frame(panel, stage, rigging, counts=(2, 2)):
|
||||
return layout._make_frame(panel, stage, rigging, list(counts))
|
||||
|
||||
|
||||
# ------------------------------------------------------------- it is pure
|
||||
|
||||
def test_the_frame_does_not_depend_on_where_the_picks_are(solid_panel,
|
||||
stripping, rolling):
|
||||
"""The memo's whole licence. If costing a layout could change the frame,
|
||||
a remembered answer would be an answer to a different question."""
|
||||
frame = _frame(solid_panel, stripping, rolling)
|
||||
before = (list(frame.inv_s), list(frame.jumps), list(frame.counts),
|
||||
list(frame.profile.w), frame.profile.dx)
|
||||
for xs in ([4.0, 28.0], [8.0, 24.0], [2.0, 30.0], [15.0, 16.0]):
|
||||
layout._peak_psi(frame, xs)
|
||||
after = (list(frame.inv_s), list(frame.jumps), list(frame.counts),
|
||||
list(frame.profile.w), frame.profile.dx)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_a_remembered_answer_is_the_answer(solid_panel, stripping, rolling):
|
||||
"""Bit-identical, not merely close. The search compares improvements at
|
||||
1e-9, so a memo that returned a differently-rounded number could pick a
|
||||
different layout."""
|
||||
frame = _frame(solid_panel, stripping, rolling)
|
||||
first = layout._peak_psi(frame, [6.0, 26.0])
|
||||
again = layout._peak_psi(frame, [6.0, 26.0])
|
||||
assert again == first
|
||||
|
||||
fresh = _frame(solid_panel, stripping, rolling)
|
||||
assert layout._peak_psi(fresh, [6.0, 26.0]) == first
|
||||
|
||||
|
||||
def test_the_memo_actually_hits(solid_panel, stripping, rolling):
|
||||
"""A cache nobody hits is a dict that costs memory and buys nothing."""
|
||||
frame = _frame(solid_panel, stripping, rolling)
|
||||
layout._peak_psi(frame, [6.0, 26.0])
|
||||
assert len(frame.memo) == 1
|
||||
layout._peak_psi(frame, [6.0, 26.0])
|
||||
assert len(frame.memo) == 1 # hit, not a second entry
|
||||
layout._peak_psi(frame, [7.0, 26.0])
|
||||
assert len(frame.memo) == 2
|
||||
|
||||
|
||||
def test_a_real_search_repeats_itself_enough_to_be_worth_it(solid_panel,
|
||||
stripping,
|
||||
rolling):
|
||||
"""Measured 17% repeats on the reference sweep; this pins that the
|
||||
repetition is structural and not a property of one job file."""
|
||||
calls = []
|
||||
real = layout._peak_psi
|
||||
|
||||
def counting(frame, xs):
|
||||
calls.append(tuple(xs))
|
||||
return real(frame, xs)
|
||||
|
||||
layout._peak_psi = counting
|
||||
try:
|
||||
layout.best_stations(solid_panel, stripping, rolling,
|
||||
start=[6.0, 26.0], counts=[2, 2],
|
||||
free=[True, True])
|
||||
finally:
|
||||
layout._peak_psi = real
|
||||
assert len(calls) > len(set(calls))
|
||||
|
||||
|
||||
def test_an_unbuildable_layout_is_remembered_too(solid_panel, stripping,
|
||||
rolling):
|
||||
"""Two lines on one station raise out of group_lifters and cost inf. That
|
||||
is an answer like any other and re-deriving it is the same wasted solve."""
|
||||
frame = _frame(solid_panel, stripping, rolling)
|
||||
value = layout._peak_psi(frame, [10.0, 10.0])
|
||||
assert value == float('inf')
|
||||
assert frame.memo[(10.0, 10.0)] == float('inf')
|
||||
|
||||
|
||||
def test_the_memo_is_bounded(solid_panel, stripping, rolling, monkeypatch):
|
||||
"""A pathological piece must not turn a cache into a memory problem.
|
||||
Overflowing costs a recomputation, never a different answer."""
|
||||
monkeypatch.setattr(layout, '_MEMO_MAX', 2)
|
||||
frame = _frame(solid_panel, stripping, rolling)
|
||||
for x in (4.0, 5.0, 6.0, 7.0, 8.0):
|
||||
layout._peak_psi(frame, [x, 26.0])
|
||||
assert len(frame.memo) == 2
|
||||
# and the answers are still right past the cap
|
||||
assert layout._peak_psi(frame, [8.0, 26.0]) == \
|
||||
layout._peak_psi(_frame(solid_panel, stripping, rolling), [8.0, 26.0])
|
||||
|
||||
|
||||
# --------------------------------------------- the search answer is unchanged
|
||||
|
||||
@pytest.mark.parametrize('with_pockets', [False, True])
|
||||
def test_the_recommendation_is_what_it_was_without_the_memo(
|
||||
with_pockets, stripping, rolling, monkeypatch):
|
||||
"""The memo must be invisible in the answer. Run the same search with the
|
||||
cache disabled (cap 0) and demand the same stations to the last bit."""
|
||||
panel = make_panel(blockouts=aspire_pockets() if with_pockets else None)
|
||||
|
||||
def search():
|
||||
return layout.best_stations(panel, stripping, rolling,
|
||||
start=[6.0, 26.0], counts=[2, 2],
|
||||
free=[True, True])
|
||||
|
||||
monkeypatch.setattr(layout, '_MEMO_MAX', 0)
|
||||
xs_off, best_off = search()
|
||||
monkeypatch.setattr(layout, '_MEMO_MAX', 200000)
|
||||
xs_on, best_on = search()
|
||||
|
||||
assert xs_on == xs_off
|
||||
assert best_on == best_off
|
||||
|
||||
|
||||
# ------------------------------------------------- the branch that was deleted
|
||||
|
||||
def test_the_section_is_solved_on_exactly_the_diagram_stations(solid_panel,
|
||||
stripping,
|
||||
rolling):
|
||||
"""`_peak_psi` used to carry a guard re-solving the section when the
|
||||
station list "changed under us". It could not fire: `_make_frame` builds
|
||||
the station list out of the same profile and the same discontinuities that
|
||||
`diagram()` does, from the same expression. The guard is gone; this is
|
||||
what it was guarding, asserted where it can actually fail.
|
||||
|
||||
A dead branch in the hottest loop in the package is a branch nobody can
|
||||
test, and the comment on it claimed a thing that could not happen.
|
||||
"""
|
||||
for panel in (solid_panel, make_panel(blockouts=aspire_pockets())):
|
||||
frame = _frame(panel, stripping, rolling)
|
||||
for xs in ([6.0, 26.0], [4.0, 28.0], [10.0, 22.0]):
|
||||
lifters = []
|
||||
for i, (x, n) in enumerate(zip(xs, frame.counts)):
|
||||
for j in range(n):
|
||||
lifters.append(layout.Lifter(
|
||||
mark='r{0}.{1}'.format(i, j), family='?',
|
||||
x=x, y=0.0, role='strip'))
|
||||
staged = dataclasses.replace(panel, lifters=lifters)
|
||||
rig = statics.group_lifters(staged, rolling, role='strip')
|
||||
statics.solve_rolling(rig, frame.profile)
|
||||
dia = statics.diagram(rig, frame.profile,
|
||||
extra_stations=frame.jumps)
|
||||
assert len(dia.xs) == len(frame.inv_s)
|
||||
|
||||
|
||||
def test_the_frame_carries_no_spare_lifters():
|
||||
"""`_Frame._spares` claimed a reuse optimisation that did not exist --
|
||||
nothing ever read it or wrote it. A field whose comment describes code
|
||||
that is not there is worse than no field."""
|
||||
names = [f.name for f in dataclasses.fields(layout._Frame)]
|
||||
assert '_spares' not in names
|
||||
assert 'memo' in names
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Two constraints, not one, and they are not the same inserts.
|
||||
|
||||
A panel erected on its END carries its anchors in that end: they cannot move
|
||||
along the piece, and where they sit across it is still a choice.
|
||||
|
||||
A panel erected on its long EDGE carries them along that edge: they may slide
|
||||
freely down the length, and they may not come off the edge.
|
||||
|
||||
Collapsing both into one "pinned" flag gets one of the two wrong, and the
|
||||
wrong one is a recommendation to put an anchor where the piece has no edge.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import LENGTH_FT, WIDTH_FT, make_panel
|
||||
from panellift import cli, layout, statics
|
||||
from panellift.model import Lifter, Rigging, Stage
|
||||
|
||||
|
||||
def build(anchor_kwargs, anchor_xs=(LENGTH_FT,), anchor_ys=None):
|
||||
panel = make_panel()
|
||||
face = [dataclasses.replace(l, capacity_lb=12000.0)
|
||||
for l in panel.lifters if l.x < 0.5 * LENGTH_FT]
|
||||
anchors = []
|
||||
for n, x in enumerate(anchor_xs):
|
||||
for m, y in enumerate(anchor_ys or (0.25 * WIDTH_FT, 0.75 * WIDTH_FT)):
|
||||
anchors.append(Lifter(
|
||||
mark='E{0}{1}'.format(n, m), family='EDGE_ANCHOR', x=x, y=y,
|
||||
role='strip', capacity_lb=7690.0, shear_capacity_lb=4010.0,
|
||||
on_edge=True, **anchor_kwargs))
|
||||
return dataclasses.replace(panel, lifters=face + anchors,
|
||||
weight_lb=35000.0)
|
||||
|
||||
|
||||
def recommend(panel, stage, rigging):
|
||||
a = statics.analyse(panel, stage, rigging, role='strip')
|
||||
return layout.recommend([(a, rigging, [])])
|
||||
|
||||
|
||||
def row_at(rec, x, tol=0.5):
|
||||
return [r for r in rec.rows if abs(r.x - x) < tol][0]
|
||||
|
||||
|
||||
# ------------------------------------------------- erected on its end
|
||||
|
||||
def test_an_end_anchor_row_holds_its_station(stripping, rolling):
|
||||
rec = recommend(build({'pinned': True}), stripping, rolling)
|
||||
row = row_at(rec, LENGTH_FT)
|
||||
assert row.to_x == pytest.approx(LENGTH_FT)
|
||||
assert row.pinned and not row.pinned_y
|
||||
|
||||
|
||||
def test_an_end_anchor_row_may_still_be_moved_across_the_width(stripping,
|
||||
rolling):
|
||||
"""Being in the end says nothing about where across the piece it sits.
|
||||
|
||||
Drawn hard against one side, so there is something to move: the pair keeps
|
||||
its spacing and is brought back onto the centreline.
|
||||
"""
|
||||
rec = recommend(build({'pinned': True}, anchor_ys=(1.0, 2.0)),
|
||||
stripping, rolling)
|
||||
moves = [m for m in rec.moves if m.family == 'EDGE_ANCHOR']
|
||||
assert all(m.dx_in == pytest.approx(0.0) for m in moves)
|
||||
assert all(abs(m.dy_in) > 0.5 for m in moves)
|
||||
assert sum(m.to_y for m in moves) / len(moves) == pytest.approx(
|
||||
WIDTH_FT / 2.0)
|
||||
|
||||
|
||||
# ------------------------------------------- erected on its long edge
|
||||
|
||||
def test_an_edge_anchor_row_is_not_lifted_off_its_edge(stripping, rolling):
|
||||
"""The one that was broken: a horizontal panel's anchors live on the top
|
||||
edge, and recommending a y in the middle of the panel asks for an anchor
|
||||
where there is no edge to cast it into."""
|
||||
rec = recommend(build({'pinned_y': True},
|
||||
anchor_xs=(8.0, 24.0),
|
||||
anchor_ys=(WIDTH_FT, WIDTH_FT)),
|
||||
stripping, rolling)
|
||||
for move in [m for m in rec.moves if m.family == 'EDGE_ANCHOR']:
|
||||
assert move.to_y == pytest.approx(WIDTH_FT)
|
||||
assert move.dy_in == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_an_edge_anchor_row_may_still_slide_along_the_piece(stripping,
|
||||
rolling):
|
||||
"""Sliding along the edge is the one degree of freedom it has, and it is
|
||||
exactly the one the search exists to use."""
|
||||
rec = recommend(build({'pinned_y': True},
|
||||
anchor_xs=(1.0,), anchor_ys=(WIDTH_FT, WIDTH_FT)),
|
||||
stripping, rolling)
|
||||
row = row_at(rec, 1.0)
|
||||
assert not row.pinned and row.pinned_y
|
||||
assert row.to_x != pytest.approx(1.0)
|
||||
|
||||
|
||||
# ------------------------------------------------------ the job file
|
||||
|
||||
def test_pinned_y_survives_the_job_file():
|
||||
"""It is written by the Revit side; if the reader drops it the constraint
|
||||
is silently gone and only a horizontal panel would ever show it."""
|
||||
job = {
|
||||
'schema': cli.SCHEMA,
|
||||
'panel': {
|
||||
'mark': 'H1', 'length_ft': 30.0, 'width_ft': 8.0,
|
||||
'thickness_in': 8.0, 'weight_lb': 20000.0,
|
||||
'lifters': [
|
||||
{'mark': 'A', 'family': 'F', 'x': 5.0, 'y': 8.0,
|
||||
'pinned_y': True, 'on_edge': True, 'capacity_lb': 7690.0},
|
||||
{'mark': 'B', 'family': 'F', 'x': 25.0, 'y': 8.0,
|
||||
'pinned_y': True, 'on_edge': True, 'capacity_lb': 7690.0},
|
||||
],
|
||||
},
|
||||
'stages': [{'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0}],
|
||||
}
|
||||
panel, _rig, _cases = cli.analyse_job(job)
|
||||
assert all(l.pinned_y and not l.pinned for l in panel.lifters)
|
||||
@@ -0,0 +1,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The mild-reinforced flexural path: capacity.mild_flexure_at.
|
||||
|
||||
The reference tool (Eriksson Wall) computes a Whitney-block flexural capacity
|
||||
for a NON-prestressed (mild-steel) wall panel -- and for a COMBINED piece --
|
||||
which `capacity_at` dashes ('no prestressing on this piece'). This pins that
|
||||
path, which is additive: `capacity_at` and the strand-only `handling_flexure_at`
|
||||
are untouched, and a pure-strand piece never reaches the mild solver.
|
||||
|
||||
Every number in the hand-check section is HAND COMPUTED, with the arithmetic in
|
||||
the comment beside it, on a rectangular section chosen so the closed forms are
|
||||
exact:
|
||||
|
||||
b = 48 in. t = 8 in. f'c = 4000 psi w_c = 150 pcf fy = 60 ksi
|
||||
|
||||
f'c = 4000 is the corner of ACI Table 22.2.2.4.3, so beta1 = 0.85 with no
|
||||
interpolation; w_c = 150 puts lambda at 1.0. Es = 29000 ksi (mild).
|
||||
|
||||
The parity against the reference tool's own golden phi*Mn (SWA1.0 172.1 k-ft,
|
||||
WPA1.0-12in 480.1 k-ft) lives in scratchpad/parity_check.py, off the client
|
||||
decks; this file is the reproducible-on-paper unit check.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, 'src'))
|
||||
|
||||
from panellift import capacity, pci # noqa: E402
|
||||
from panellift.model import (Panel, Prestress, Reinforcement, # noqa: E402
|
||||
Stage, Strand)
|
||||
|
||||
|
||||
B_IN = 48.0
|
||||
T_IN = 8.0
|
||||
FC = 4000.0
|
||||
|
||||
|
||||
def mild_panel(layers, width_ft=B_IN / 12.0, thickness_in=T_IN):
|
||||
return Panel(mark='MILD-1', length_ft=32.0, width_ft=width_ft,
|
||||
thickness_in=thickness_in, unit_weight_pcf=150.0,
|
||||
fci_psi=FC, fc_psi=FC, reinforcement=layers)
|
||||
|
||||
|
||||
def one_layer(area_in2, z_in, fy=60.0, count=1):
|
||||
return Reinforcement(kind='bar', label='M', area_in2=area_in2, z_in=z_in,
|
||||
grade_ksi=fy, count=count)
|
||||
|
||||
|
||||
STAGE = Stage(name='mild', multiplier=1.0, fc_psi=FC)
|
||||
|
||||
|
||||
# ---------------------------------------------------- 1. hand calculations
|
||||
|
||||
def test_mild_mn_no_axial_matches_the_hand_calculation():
|
||||
"""One layer As = 2.0 in^2 at d = 6.0 in (z = t - d = 2.0), fy = 60, Pu = 0.
|
||||
|
||||
T = As*fy = 2.0 * 60,000 = 120,000 lb (yields)
|
||||
a = T/(0.85 f'c b) = 120,000/(0.85*4000*48) = 0.735294 in
|
||||
c = a/beta1 = 0.735294/0.85 = 0.865052 in
|
||||
eps_t = 0.003*(6 - 0.865052)/0.865052 = 0.017808 -> phi 0.90
|
||||
M_n = T*(d - a/2) = 120,000*(6 - 0.367647) = 675,882.4 lb-in
|
||||
= 56,323.53 lb-ft
|
||||
phi*M_n = 0.9 * 56,323.53 = 50,691.18 lb-ft
|
||||
"""
|
||||
# sagging (+M): compression on the z=t face, so d = t - z = 8 - 2 = 6.
|
||||
mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0)
|
||||
assert abs(mp.as_in2 - 2.0) < 1e-9
|
||||
assert abs(mp.a_in - 0.735294) < 1e-4
|
||||
assert abs(mp.c_in - 0.865052) < 1e-4
|
||||
assert abs(mp.strain_t - 0.017808) < 1e-5
|
||||
assert mp.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED == 0.90
|
||||
assert abs(mp.mn_lbft - 56323.53) < 1.0
|
||||
assert abs(mp.phi_mn_lbft - 50691.18) < 1.0
|
||||
assert abs(mp.m_over_phi_mn - 10000.0 / 50691.18) < 1e-6
|
||||
|
||||
|
||||
def test_mild_mn_with_axial_shifts_the_neutral_axis_and_raises_mn():
|
||||
"""Same layer, now with Pu = 50,000 lb COMPRESSION on the P-M surface.
|
||||
|
||||
Equilibrium C - Pu - T = 0, steel still yields, so
|
||||
C = Pu + T = 50,000 + 120,000 = 170,000 lb
|
||||
a = C/(0.85 f'c b) = 170,000/163,200 = 1.041667 in
|
||||
c = a/beta1 = 1.041667/0.85 = 1.225490 in
|
||||
M_n = C*(c - a/2) - T*(c - d) - Pu*(c - Cy), Cy = t/2 = 4
|
||||
= 170,000*0.704657 - 120,000*(-4.774510) - 50,000*(-2.774510)
|
||||
= 119,791.7 + 572,941.2 + 138,725.5 = 831,458.4 lb-in
|
||||
= 69,288.2 lb-ft
|
||||
phi*M_n = 0.9 * 69,288.2 = 62,359.4 lb-ft
|
||||
|
||||
The axial compression deepens the block and raises M_n -- the P-M coupling
|
||||
(docs sec.4.2). A tool that ignored Pu would report the 56.3 k-ft above.
|
||||
"""
|
||||
mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0,
|
||||
pu_lb=50000.0)
|
||||
assert abs(mp.a_in - 1.041667) < 1e-4
|
||||
assert abs(mp.c_in - 1.225490) < 1e-4
|
||||
assert abs(mp.mn_lbft - 69288.2) < 1.0
|
||||
assert abs(mp.phi_mn_lbft - 62359.4) < 1.0
|
||||
# and it is strictly larger than the no-axial capacity
|
||||
base = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0)
|
||||
assert mp.mn_lbft > base.mn_lbft
|
||||
|
||||
|
||||
def test_two_symmetric_layers_reproduce_the_SWA_style_couple():
|
||||
"""(2)#11 at d-CG 3 and 9 in a 12 in x 14 ft solid section, f'c = 6 ksi,
|
||||
Pu = 21.1 k hogging -- the SWA1.0 shape. Golden phi*Mn = 172.1 k-ft, and
|
||||
the strain-compat couple here lands at 171.1 k-ft (-0.6%). Kept as a
|
||||
reproducible echo of the parity harness inside the suite."""
|
||||
panel = Panel(mark='SWA1.0', length_ft=32.0, width_ft=14.0,
|
||||
thickness_in=12.0, unit_weight_pcf=150.0,
|
||||
fci_psi=2500.0, fc_psi=6000.0,
|
||||
reinforcement=[one_layer(1.56, z_in=3.0, count=2),
|
||||
one_layer(1.56, z_in=9.0, count=2)])
|
||||
stage = Stage(name='inplace', multiplier=1.0, fc_psi=6000.0)
|
||||
mp = capacity.mild_flexure_at(panel, stage, x=16.0, moment_lbft=-100300.0,
|
||||
pu_lb=21100.0)
|
||||
assert abs(mp.as_in2 - 6.24) < 1e-9
|
||||
assert mp.phi == 0.90
|
||||
assert abs(mp.phi_mn_lbft / 1000.0 - 172.1) / 172.1 < 0.03 # within 3%
|
||||
assert abs(mp.m_over_phi_mn - 0.58) < 0.02
|
||||
|
||||
|
||||
# ------------------------------------------------ 2. the strain-based phi
|
||||
|
||||
def test_phi_uses_the_MILD_075_floor_not_the_prestressed_065():
|
||||
"""A heavily reinforced layer drops eps_t into the transition band, so phi
|
||||
is between the floor and 0.90 -- and the floor is the MILD 0.75, read
|
||||
through the SAME phi_flexure_from_strain helper (docs sec.2.2)."""
|
||||
mp = capacity.mild_flexure_at(mild_panel([one_layer(8.0, z_in=2.0)]),
|
||||
STAGE, x=16.0, moment_lbft=+10000.0)
|
||||
assert pci.PHI_FLEXURE_COMPRESSION_CONTROLLED_MILD == 0.75
|
||||
assert capacity.PHI_FLEXURE_MILD_FLOOR == 0.75
|
||||
# eps_t is in the 0.002..0.005 transition, so phi is strictly between
|
||||
assert 0.002 < mp.strain_t < 0.005
|
||||
assert 0.75 < mp.phi < 0.90
|
||||
# and it is exactly the helper's value on the mild floor
|
||||
assert abs(mp.phi - capacity.phi_flexure_from_strain(
|
||||
mp.strain_t, phi_min=0.75)) < 1e-12
|
||||
# the prestressed 0.65 floor would give a strictly lower phi here
|
||||
assert capacity.phi_flexure_from_strain(mp.strain_t, phi_min=0.65) < mp.phi
|
||||
|
||||
|
||||
# ------------------------------------------------------- 3. combined piece
|
||||
|
||||
def test_combined_strand_plus_mild_credits_both():
|
||||
"""A piece with BOTH strand and mild steel carries them on one P-M surface;
|
||||
adding a strand group to a mild section raises M_n and flags combined."""
|
||||
mild_only = mild_panel([one_layer(2.0, z_in=2.0)])
|
||||
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
|
||||
initial_pull_lb=30000.0) for i in range(4)]
|
||||
combined = mild_panel([one_layer(2.0, z_in=2.0)])
|
||||
combined.prestress = Prestress(strands=strands, loss_pct=10.0,
|
||||
diameter_in=0.5, transfer_length_in=30.0)
|
||||
mp_mild = capacity.mild_flexure_at(mild_only, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
mp_comb = capacity.mild_flexure_at(combined, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert mp_mild.combined is False
|
||||
assert mp_comb.combined is True
|
||||
# both bars are on the tension side (z=2 -> d=6 sagging), so the extra
|
||||
# strand tension raises the couple
|
||||
assert mp_comb.mn_lbft > mp_mild.mn_lbft
|
||||
# as_in2 counts MILD only; the strand rides its own layer
|
||||
assert abs(mp_comb.as_in2 - 2.0) < 1e-9
|
||||
|
||||
|
||||
# ----------------------------------------------- 4. could-not-judge, dashes
|
||||
|
||||
def test_a_piece_with_no_mild_steel_says_so_rather_than_computing_zero():
|
||||
mp = capacity.mild_flexure_at(mild_panel([]), STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert mp.phi_mn_lbft is None
|
||||
assert mp.mn_lbft is None
|
||||
assert 'no mild reinforcement' in mp.why
|
||||
|
||||
|
||||
def test_has_mild_reinforcement_predicate():
|
||||
assert capacity.has_mild_reinforcement(
|
||||
mild_panel([one_layer(2.0, z_in=2.0)])) is True
|
||||
assert capacity.has_mild_reinforcement(mild_panel([])) is False
|
||||
# a strand layer is not mild
|
||||
strand_layer = Reinforcement(kind='strand', label='S', area_in2=0.153,
|
||||
z_in=2.0, grade_ksi=270.0, count=4,
|
||||
initial_pull_lb=30000.0)
|
||||
assert capacity.has_mild_reinforcement(mild_panel([strand_layer])) is False
|
||||
|
||||
|
||||
# ------------------------------- 5. the strand-only path is byte-unchanged
|
||||
|
||||
def test_flexure_at_leaves_a_pure_strand_piece_on_the_existing_path():
|
||||
"""flexure_at must not route a strand-only piece through the mild solver.
|
||||
On such a piece it returns EXACTLY handling_flexure_at(capacity_at(...)) --
|
||||
the additive path the task requires to stay byte-for-byte unchanged."""
|
||||
strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153,
|
||||
initial_pull_lb=30000.0) for i in range(4)]
|
||||
panel = Panel(mark='STRAND-1', length_ft=32.0, width_ft=B_IN / 12.0,
|
||||
thickness_in=T_IN, unit_weight_pcf=150.0,
|
||||
fci_psi=FC, fc_psi=FC,
|
||||
prestress=Prestress(strands=strands, loss_pct=10.0,
|
||||
diameter_in=0.5, transfer_length_in=30.0))
|
||||
assert capacity.has_mild_reinforcement(panel) is False
|
||||
cap = capacity.capacity_at(panel, STAGE, 16.0, 10000.0, 0.0)
|
||||
want = capacity.handling_flexure_at(cap, 10000.0)
|
||||
got = capacity.flexure_at(panel, STAGE, 16.0, 10000.0, cap=cap)
|
||||
assert got == want
|
||||
assert got[1] is not None # it really did compute one
|
||||
|
||||
|
||||
# --------------------- 6. from_ft/to_ft range (PANELANALYSIS-4 mild_bars)
|
||||
|
||||
def test_layer_with_a_range_contributes_only_inside_it():
|
||||
"""A `Reinforcement` carrying `from_ft`/`to_ft` is present only there --
|
||||
phi*Mn INSIDE the run must exceed phi*Mn at a station OUTSIDE it (the
|
||||
outside station falls back to whatever other steel is on the panel, here
|
||||
none at all, so it dashes with 'no mild reinforcement').
|
||||
"""
|
||||
layer = Reinforcement(kind='bar', label='M', area_in2=2.0, z_in=2.0,
|
||||
grade_ksi=60.0, count=1, from_ft=10.0, to_ft=20.0)
|
||||
panel = mild_panel([layer], width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
|
||||
inside = capacity.mild_flexure_at(panel, STAGE, x=15.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert inside.why is None
|
||||
assert inside.phi_mn_lbft is not None
|
||||
assert inside.as_in2 == pytest.approx(2.0)
|
||||
|
||||
outside = capacity.mild_flexure_at(panel, STAGE, x=25.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert outside.why == 'no mild reinforcement on this piece'
|
||||
assert outside.phi_mn_lbft is None
|
||||
|
||||
|
||||
def test_layer_with_no_range_is_unchanged_everywhere():
|
||||
"""`from_ft`/`to_ft` both None (every layer before this field existed)
|
||||
behaves identically at two different stations -- the byte-identical
|
||||
guarantee for every deck that never sets a range."""
|
||||
layer = Reinforcement(kind='bar', label='M', area_in2=2.0, z_in=2.0,
|
||||
grade_ksi=60.0, count=1)
|
||||
panel = mild_panel([layer], width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
a = capacity.mild_flexure_at(panel, STAGE, x=4.0, moment_lbft=+10000.0)
|
||||
b = capacity.mild_flexure_at(panel, STAGE, x=28.0, moment_lbft=+10000.0)
|
||||
assert a.phi_mn_lbft == pytest.approx(b.phi_mn_lbft)
|
||||
assert a.as_in2 == pytest.approx(b.as_in2) == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_mild_bars_with_depth_raise_phi_mn_over_bars_without():
|
||||
"""A depth-bearing mild-bar layer measurably raises phi*Mn over a bare
|
||||
section (no reinforcement at all) at the same station -- pins that a
|
||||
`mild_bars` row with a real depth genuinely enters the capacity, not
|
||||
just parses without error."""
|
||||
bare = mild_panel([], width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
reinforced = mild_panel(
|
||||
[Reinforcement(kind='bar', label='#4', area_in2=4.8, z_in=2.0,
|
||||
grade_ksi=60.0, count=1)],
|
||||
width_ft=B_IN / 12.0, thickness_in=T_IN)
|
||||
|
||||
bare_pt = capacity.mild_flexure_at(bare, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
reinforced_pt = capacity.mild_flexure_at(reinforced, STAGE, x=16.0,
|
||||
moment_lbft=+10000.0)
|
||||
assert bare_pt.why == 'no mild reinforcement on this piece'
|
||||
assert bare_pt.phi_mn_lbft is None
|
||||
assert reinforced_pt.phi_mn_lbft is not None
|
||||
assert reinforced_pt.phi_mn_lbft > 0.0
|
||||
@@ -0,0 +1,192 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Switching the position search off, and saying so.
|
||||
|
||||
The search is 93% of a run -- measured 2026-08-30 across the 184-piece
|
||||
reference sweep: 12.6 s of a 13.6 s median, and 41 minutes of a 42 minute
|
||||
batch. `--no-recommend` is what makes a batch usable.
|
||||
|
||||
Everything here is about the same one risk. A report whose "Recommended
|
||||
positions" section had simply gone missing reads exactly like a report on a
|
||||
piece with nothing to recommend -- and the report's own words for that case
|
||||
are "the inserts as drawn are already at the best position this search can
|
||||
find", which is a sentence about a search nobody ran. Same rule as a stage
|
||||
with `enabled: false`: NOT RUN is never silent.
|
||||
|
||||
The other half is that the default must not move. `searched` is written into
|
||||
the JSON summary only when the search was skipped, the same way the job writer
|
||||
emits `enabled` only when it is false, so a run with no flag produces the same
|
||||
bytes it did before the flag existed.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
|
||||
from panellift import cli, layout, report
|
||||
from test_cli import aspire_job
|
||||
|
||||
|
||||
def _run(tmp_path, *flags):
|
||||
"""The CLI, end to end, on a real job file. Returns (code, out, html)."""
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
job_path = tmp_path / 'job.json'
|
||||
out_path = tmp_path / 'report.html'
|
||||
with io.open(str(job_path), 'w', encoding='utf-8') as fh:
|
||||
fh.write(json.dumps(aspire_job()))
|
||||
code = cli.main([str(job_path), '-o', str(out_path)] + list(flags))
|
||||
with io.open(str(out_path), 'r', encoding='utf-8') as fh:
|
||||
return code, out_path, fh.read()
|
||||
|
||||
|
||||
# ----------------------------------------------------- the flag does its job
|
||||
|
||||
def test_no_recommend_leaves_every_verdict_alone(tmp_path, capsys):
|
||||
"""The flag drops an ANSWER, never a CHECK. If it could change a verdict
|
||||
it would be a different calculation, not a faster one."""
|
||||
with_search = _run(tmp_path / 'a', )
|
||||
capsys.readouterr()
|
||||
without = _run(tmp_path / 'b', '--no-recommend')
|
||||
capsys.readouterr()
|
||||
assert with_search[0] == without[0]
|
||||
|
||||
job = aspire_job()
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
full = cli.summarise(cases, layout.recommend(cases))
|
||||
skipped = cli.summarise(cases, layout.not_searched())
|
||||
assert full['verdict'] == skipped['verdict']
|
||||
assert [s['verdict'] for s in full['stages']] == \
|
||||
[s['verdict'] for s in skipped['stages']]
|
||||
assert [s['peak_tension_psi'] for s in full['stages']] == \
|
||||
[s['peak_tension_psi'] for s in skipped['stages']]
|
||||
|
||||
|
||||
def test_no_recommend_is_actually_faster(tmp_path, capsys):
|
||||
"""Not a behaviour test -- a claim test. The flag exists for one reason
|
||||
and a version of it that still searched would pass every other test
|
||||
here."""
|
||||
import time
|
||||
job = aspire_job()
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
layout.recommend(cases)
|
||||
searched = time.perf_counter() - t0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
layout.not_searched()
|
||||
skipped = time.perf_counter() - t0
|
||||
|
||||
assert skipped < searched / 10.0
|
||||
|
||||
|
||||
# ------------------------------------------------------- and says it did it
|
||||
|
||||
def test_the_report_says_the_positions_were_not_computed(tmp_path, capsys):
|
||||
"""Present, closed, flagged NOT RUN, honest when opened.
|
||||
|
||||
Skipping the search is the ordinary way this runs now, so the section is
|
||||
no longer a warning box on an open heading -- it is a collapsed
|
||||
disclosure like every other section on the page, carrying NOT RUN on the
|
||||
line you can read without opening it.
|
||||
"""
|
||||
_code, _path, html = _run(tmp_path, '--no-recommend')
|
||||
capsys.readouterr()
|
||||
assert 'Recommended positions' in html # the section stays
|
||||
assert 'Not computed' in html
|
||||
# the closed summary line says so, and nothing shouts
|
||||
assert '<b class="notrun-tag">not run</b>' in html
|
||||
assert 'class="caveat"' not in html
|
||||
# and none of the things a search that RAN would have said
|
||||
assert 'already at the best position' not in html
|
||||
assert 'recommended x' not in html
|
||||
|
||||
|
||||
def test_the_not_run_section_is_a_collapsed_details(tmp_path, capsys):
|
||||
"""The engineer's own words: it is always there, and closed by default.
|
||||
|
||||
Both halves matter. A section that vanishes on a skipped search reads as
|
||||
a piece with nothing to move; a section that opens itself puts a notice
|
||||
nobody asked for above the numbers they did.
|
||||
"""
|
||||
_code, _path, skipped_html = _run(tmp_path / 'skipped', '--no-recommend')
|
||||
capsys.readouterr()
|
||||
_code, _path, ran_html = _run(tmp_path / 'ran')
|
||||
capsys.readouterr()
|
||||
|
||||
for html, tagged in ((skipped_html, True), (ran_html, False)):
|
||||
i = html.index('Recommended positions')
|
||||
head = html[max(0, i - 200):i]
|
||||
# inside a <details class="section">, and never an open one
|
||||
assert '<details class="section">' in head
|
||||
assert '<details class="section" open' not in html
|
||||
assert '<h2>Recommended positions</h2>' not in html
|
||||
assert ('notrun-tag">not run' in html) is tagged
|
||||
|
||||
|
||||
def test_a_skipped_search_never_reads_as_nothing_to_move():
|
||||
"""The exact confusion this is all here to prevent."""
|
||||
ran = report._recommendation(layout.Recommendation())
|
||||
skipped = report._recommendation(layout.not_searched())
|
||||
assert 'already at the best position' in ran
|
||||
assert 'already at the best position' not in skipped
|
||||
assert 'did not run' in skipped
|
||||
|
||||
|
||||
def test_the_stdout_line_says_so_too(tmp_path, capsys):
|
||||
"""A batch scrolling past has to be visible without opening a report."""
|
||||
_run(tmp_path, '--no-recommend')
|
||||
out = capsys.readouterr().out
|
||||
assert 'NOT COMPUTED' in out
|
||||
|
||||
_run(tmp_path / 'again')
|
||||
out = capsys.readouterr().out
|
||||
assert 'NOT COMPUTED' not in out
|
||||
|
||||
|
||||
def test_the_summary_carries_searched_false(tmp_path, capsys):
|
||||
job = aspire_job()
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
skipped = cli.summarise(cases, layout.not_searched())
|
||||
assert skipped['recommendation']['searched'] is False
|
||||
assert skipped['recommendation']['note']
|
||||
assert skipped['recommendation']['moves'] == []
|
||||
|
||||
|
||||
def test_searched_is_absent_when_the_search_ran():
|
||||
"""Written only when it was skipped -- the same rule the job writer
|
||||
follows for `enabled`. Absent means it ran, which is what every summary
|
||||
written before this flag existed means.
|
||||
"""
|
||||
job = aspire_job()
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
full = cli.summarise(cases, layout.recommend(cases))
|
||||
assert 'searched' not in full['recommendation']
|
||||
assert full['recommendation'].get('searched', True) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------- nothing else moved
|
||||
|
||||
def test_a_default_run_is_byte_for_byte_what_it_was(tmp_path, capsys):
|
||||
"""The flag is additive. With nothing asked for, every byte of the
|
||||
report and of the JSON summary is what it was before the flag existed --
|
||||
which is checked here on one job and, out of band, on all 184 of the
|
||||
reference sweep with the clock frozen."""
|
||||
job = aspire_job()
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
rec = layout.recommend(cases)
|
||||
|
||||
html_default = report.build_many(cases, recommendation=rec)
|
||||
summary_default = json.dumps(cli.summarise(cases, rec), sort_keys=True)
|
||||
|
||||
# the same call the pre-flag code made, with no recommendation passed in
|
||||
_p2, _r2, cases2 = cli.analyse_job(aspire_job())
|
||||
html_implicit = report.build_many(cases2)
|
||||
summary_implicit = json.dumps(cli.summarise(cases2), sort_keys=True)
|
||||
|
||||
assert html_default == html_implicit
|
||||
assert summary_default == summary_implicit
|
||||
|
||||
|
||||
def test_the_flag_is_off_unless_asked_for(tmp_path, capsys):
|
||||
"""A default that has to be asked for is a default nobody gets."""
|
||||
_code, _path, html = _run(tmp_path)
|
||||
capsys.readouterr()
|
||||
assert 'Not computed' not in html
|
||||
@@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Real typography in the HTML report, and why the source strings stay ASCII.
|
||||
|
||||
`report.notate()` and `report.notate_plain()` are the ONE place engineering
|
||||
shorthand -- "phi-Mn", "5*sqrt(f'c)", "f_se", "60deg" -- becomes real
|
||||
subscripts, a real radical, a real degree sign. Everywhere else in this
|
||||
package, in `checks.py`, `capacity.py` and `model.py`, that text stays plain
|
||||
ASCII on purpose: the SAME strings are written to sweep/problems.txt, printed
|
||||
to the console, exported into the job/problem JSON, and printed inside
|
||||
Revit's pyRevit output window under IronPython 2.7 (see
|
||||
`WillTools.extension/lib/vibe/liftexport.py`). A radical sign or a raw
|
||||
`<sub>` tag in any of those is a crash or mojibake, not a nicer report --
|
||||
this project has already been bitten by exactly that shape of bug once, in
|
||||
the Discord bridge.
|
||||
|
||||
So these tests do two things: pin the rendering (given a known ASCII string,
|
||||
the HTML/plain output is exactly this), and guard the boundary (the
|
||||
producers stay ASCII; nothing here should ever need to change if `notate()`
|
||||
changes).
|
||||
|
||||
Every string tested against notate()/notate_plain() below is copied VERBATIM
|
||||
from `checks.py`, `capacity.py` or `model.py` -- not invented -- because an
|
||||
untested token is exactly the "regex over prose" failure this was built to
|
||||
avoid (`psi` the unit must never become `ψ`; there is no rule for it at
|
||||
all, on purpose -- see test_units_and_ratios_pass_through_untouched below).
|
||||
"""
|
||||
import re
|
||||
|
||||
from conftest import aspire_pockets, make_panel
|
||||
from panellift import checks, report, statics
|
||||
from panellift.model import Blockout, Prestress, Rigging, Stage, Strand
|
||||
|
||||
|
||||
# --------------------------------------------------------- notate(): HTML
|
||||
|
||||
def test_the_radical_is_real_mathml_with_a_plain_fallback_beside_it():
|
||||
"""A bare √ glued to a CSS overline is the old half-measure: the
|
||||
glyph and the border never actually join. RADICAL_FC now has to carry a
|
||||
real <msqrt> -- drawn as one shape by the browser's own MathML layout --
|
||||
AND, right beside it, the plain-Unicode reading a non-MathML viewer gets
|
||||
instead, because <math> has no fallback of its own."""
|
||||
assert report.RADICAL_FC == (
|
||||
'<math><msqrt><mrow><msub><mrow><mi>f</mi><mo>′</mo></mrow>'
|
||||
'<mi>c</mi></msub></mrow></msqrt></math>'
|
||||
'<span class="radical-fallback">√f′c</span>')
|
||||
assert report.RADICAL_FCI == (
|
||||
'<math><msqrt><mrow><msub><mrow><mi>f</mi><mo>′</mo></mrow>'
|
||||
'<mi>ci</mi></msub></mrow></msqrt></math>'
|
||||
'<span class="radical-fallback">√f′ci</span>')
|
||||
# The CSS-overline technique is gone; nothing should still reference it.
|
||||
assert '.rt{' not in report.CSS
|
||||
assert 'class="rt"' not in report.RADICAL_FC
|
||||
# The fallback switch: hidden by default, shown only behind a real
|
||||
# MathML capability test, never behind a guess or a browser sniff.
|
||||
assert 'math{display:none}' in report.CSS
|
||||
assert '@supports (math-depth:0)' in report.CSS
|
||||
assert '.radical-fallback{display:none}' in report.CSS
|
||||
|
||||
|
||||
def test_fc_prime_gets_a_real_prime_and_a_real_subscript():
|
||||
# Copied from Stage.tension_limit_text's shape: "5*sqrt(f'c)".
|
||||
assert report.notate("5*sqrt(f'c)") == '5' + report.RADICAL_FC
|
||||
assert report.notate("7.5*sqrt(f'c)") == '7.5' + report.RADICAL_FC
|
||||
# f'ci (stripping strength) is a DIFFERENT subscript and must not be
|
||||
# truncated to f'c's -- "f'ci" contains "f'c" as a substring.
|
||||
assert report.notate("f'ci") == 'f′<sub>ci</sub>'
|
||||
assert report.notate("f'c") == 'f′<sub>c</sub>'
|
||||
# Bare, mid sentence -- from capacity.py's `point.why[col] = "stage
|
||||
# states no f'c"`.
|
||||
assert report.notate("stage states no f'c") == (
|
||||
'stage states no f′<sub>c</sub>')
|
||||
|
||||
|
||||
def test_phi_mn_and_phi_vn_are_real_subscripts_not_hyphenated_ascii():
|
||||
# From capacity.py's _FLEX_BASIS / _SHEAR_BASIS and checks.py's RATIO_WORD.
|
||||
assert report.notate('phi-Mn') == 'φM<sub>n</sub>'
|
||||
assert report.notate('phi-Vn') == 'φV<sub>n</sub>'
|
||||
assert report.notate('M/phi-Mn') == 'M/φM<sub>n</sub>'
|
||||
assert report.notate('V/phi-Vn') == 'V/φV<sub>n</sub>'
|
||||
# Bare phi, e.g. checks.py's `detail += ' (phi {0:.2f})'`.
|
||||
assert report.notate('(phi 0.90)') == '(φ 0.90)'
|
||||
|
||||
|
||||
def test_a_real_finding_message_renders_with_real_markup():
|
||||
"""Not a synthetic string -- an actual CapacityPoint.why /
|
||||
development_note() sentence, copied from capacity.py, run through the
|
||||
same function the Checks table uses."""
|
||||
why = ('within a strand\'s development length of a free end (36.0 in. '
|
||||
'from the end of the piece; l_d 63.7 in.) -- f_ps is 187.9 of '
|
||||
'243.4 ksi here, so phi-Mn, which is strand only, tends to zero '
|
||||
'while M_cr does not')
|
||||
out = report.notate(why)
|
||||
assert 'ℓ<sub>d</sub> 63.7 in.' in out
|
||||
assert 'f<sub>ps</sub> is 187.9 of 243.4 ksi' in out
|
||||
assert 'φM<sub>n</sub>, which is strand only' in out
|
||||
assert 'M<sub>cr</sub> does not' in out
|
||||
# And nothing here should ever come back out as raw ASCII shorthand.
|
||||
assert 'phi-Mn' not in out and 'M_cr' not in out and 'l_d' not in out
|
||||
|
||||
|
||||
def test_the_shear_basis_sentence_renders_v_n_equals_v_c():
|
||||
# capacity.py's _SHEAR_BASIS, verbatim.
|
||||
basis = ('phi-Vn here is CONCRETE ALONE -- ACI 318-19 22.5.5.1 '
|
||||
'simplified, no shear reinforcement in the handling direction, '
|
||||
'so V_n = V_c')
|
||||
out = report.notate(basis)
|
||||
assert 'φV<sub>n</sub> here is CONCRETE ALONE' in out
|
||||
assert 'V<sub>n</sub> = V<sub>c</sub>' in out
|
||||
|
||||
|
||||
def test_aps_in2_in3_and_degrees():
|
||||
# checks.py: 'P {0} lb over Aps {1:.2f} in2 ...'
|
||||
assert report.notate('Aps 0.61 in2') == 'A<sub>ps</sub> 0.61 in²'
|
||||
# checks.py: 'S {6} in3'
|
||||
assert report.notate('S 1,472 in3') == 'S 1,472 in³'
|
||||
# checks.py: 'sin {4:.0f}deg' and 'the 60deg usually taken as ...'
|
||||
assert report.notate('sin 60deg') == 'sin 60°'
|
||||
assert report.notate('below the 60deg usually') == (
|
||||
'below the 60° usually')
|
||||
|
||||
|
||||
def test_the_multiplication_idiom_not_the_coordinate_one():
|
||||
# checks.py SLING-ANGLE: 'line force is {1:.2f}x the vertical reaction'
|
||||
assert report.notate('1.03x the vertical reaction') == (
|
||||
'1.03× the vertical reaction')
|
||||
# A coordinate is always written "x=1.23 ft" in this package -- no digit
|
||||
# immediately in front of the letter -- so it must NOT be touched.
|
||||
assert report.notate('the pick line at x=1.23 ft') == (
|
||||
'the pick line at x=1.23 ft')
|
||||
|
||||
|
||||
def test_units_and_ratios_pass_through_untouched():
|
||||
"""The owner's own example: psi/ksi/plf/pli/lb/kips are already correct
|
||||
and must never be touched, and a ratio like 4:1 stays exactly as typed.
|
||||
There is no rule for any of these in _NOTATION_RULES -- this asserts the
|
||||
omission, not an exception that could rot."""
|
||||
for s in ('444 psi', '270 ksi', '109.4 plf', '80 pli', '34,638 lb',
|
||||
'8 kips', 'Capacity in Concrete 4:1 SWL', '0.207'):
|
||||
assert report.notate(s) == report.esc(s)
|
||||
|
||||
|
||||
def test_data_is_still_escaped_around_the_markup():
|
||||
"""A panel mark or a job-authored reason can carry HTML-significant
|
||||
characters; notate() has to escape those exactly like esc() does, in the
|
||||
SAME string that also carries real notation."""
|
||||
out = report.notate("Tom & Jerry's <panel> f'c 4000 psi")
|
||||
assert '&' in out
|
||||
assert '<panel>' in out
|
||||
assert "Jerry's" in out
|
||||
assert 'f′<sub>c</sub>' in out
|
||||
# And never literal angle brackets from the data itself.
|
||||
assert '<panel>' not in out
|
||||
|
||||
|
||||
def test_le_ge_and_none_input():
|
||||
assert report.notate('a <= b') == 'a ≤ b'
|
||||
assert report.notate('a >= b') == 'a ≥ b'
|
||||
assert report.notate(None) == ''
|
||||
|
||||
|
||||
# ---------------------------------------------- notate_plain(): SVG / JS text
|
||||
|
||||
def test_plain_mode_never_emits_a_tag():
|
||||
"""<sub> in an <svg> <text> node or in JS textContent renders as four
|
||||
literal characters, not a subscript -- notate_plain() must never emit
|
||||
one."""
|
||||
samples = ["5*sqrt(f'c)", 'phi-Mn', 'f_t', 'f_se', 'M_cr', 'Aps 0.61 in2',
|
||||
'sin 60deg', '1.03x the vertical reaction', '(phi 0.90)']
|
||||
for s in samples:
|
||||
out = report.notate_plain(s)
|
||||
assert '<' not in out and '>' not in out, (s, out)
|
||||
assert '&' not in out, (s, out)
|
||||
|
||||
|
||||
def test_plain_mode_uses_unicode_subscripts_where_they_exist():
|
||||
assert report.notate_plain('f_t') == 'fₜ' # fₜ
|
||||
assert report.notate_plain('phi-Mn') == 'φMₙ' # φMₙ
|
||||
assert report.notate_plain("5*sqrt(f'c)") == "5√f′c"
|
||||
# No Unicode subscript 'c' exists -- f_c degrades to plain "fc", not a
|
||||
# fabricated subscript and not the bare underscore.
|
||||
assert report.notate_plain('f_c') == 'fc'
|
||||
assert '_' not in report.notate_plain('f_c')
|
||||
|
||||
|
||||
# ---------------------------------------------------- the architecture edge
|
||||
|
||||
def test_the_source_strings_stay_ascii():
|
||||
"""The producers -- Stage.tension_limit_text, and a real finding message
|
||||
-- must still be pure ASCII after this change. Only report.py's renderer
|
||||
may carry Unicode or HTML; console/sweep/JSON/IronPython read this."""
|
||||
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
|
||||
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
|
||||
text = stage.tension_limit_text
|
||||
assert text == "7.5*sqrt(f'c)"
|
||||
text.encode('ascii') # raises if it is not
|
||||
|
||||
panel = make_panel(blockouts=aspire_pockets())
|
||||
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
|
||||
findings = checks.run_all(analysis, Rigging(mode='rolling'))
|
||||
for f in findings:
|
||||
f.message.encode('ascii')
|
||||
|
||||
|
||||
def test_a_rendered_report_carries_no_raw_ascii_shorthand_in_its_body():
|
||||
"""End to end: build a real report and check the BODY (not the CSS
|
||||
comments, which are developer prose, not rendered text) for the ASCII
|
||||
forms this whole module exists to replace."""
|
||||
panel = make_panel(blockouts=aspire_pockets())
|
||||
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
|
||||
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
|
||||
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
|
||||
findings = checks.run_all(analysis, Rigging(mode='rolling'))
|
||||
page = report.build(analysis, Rigging(mode='rolling'), findings,
|
||||
recommendation=False)
|
||||
body = page[page.index('</style>'):]
|
||||
for pattern in (r"\bphi-Mn\b", r"\bphi-Vn\b", r"f'c\b", r"f'ci\b",
|
||||
r"\bf_t\b", r"\bf_c\b", r"\bf_se\b", r"\bf_pu\b",
|
||||
r"\bf_ps\b", r"\bl_d\b", r"\bl_t\b", r"\bM_cr\b",
|
||||
r"\bV_n\b", r"\bV_c\b", r"\bAps\b", r"\bin2\b",
|
||||
r"\bin3\b", r"\bd_b\b", r"\beps_t\b"):
|
||||
assert not re.search(pattern, body), pattern
|
||||
# And the positive side of the same assertion: the markup is actually
|
||||
# there, not just absent.
|
||||
assert '<sub>' in body and report.RADICAL_FC in body
|
||||
|
||||
|
||||
# --------------------------------------- _prestress_line() vs. _rows()/notate()
|
||||
|
||||
def _panel_with_a_cut_strand():
|
||||
"""A panel with real prestress and a blockout that severs the one
|
||||
strand -- enough to exercise every clause of `_prestress_line()`,
|
||||
including the `<strong>...cut by a blockout</strong>` branch."""
|
||||
panel = make_panel(blockouts=[
|
||||
Blockout(name='OPENING', x0=15.0, y0=1.5, length=2.0, width=1.0)])
|
||||
panel.prestress = Prestress(
|
||||
strands=[Strand(y=2.0, z_in=4.0, area_in2=0.153,
|
||||
initial_pull_lb=0.153 * 270_000 * 0.75)],
|
||||
loss_pct=8.0, diameter_in=0.5)
|
||||
return panel
|
||||
|
||||
|
||||
def test_prestress_line_returns_real_markup_never_escaped():
|
||||
"""`_prestress_line()` builds its own <br>/<strong>/<sub> markup and
|
||||
escapes only the free-form pieces itself (see its own docstring) -- this
|
||||
pins that raw output. See the end-to-end test below for the regression
|
||||
it was written to catch: `_rows()` running this same markup through
|
||||
notate() a second time, printing the literal text "<br>" on the page."""
|
||||
line = report._prestress_line(_panel_with_a_cut_strand())
|
||||
assert '<br>' in line
|
||||
assert '<sub>ps</sub>' in line and '²' in line
|
||||
assert '<strong>1 cut by a blockout</strong>' in line
|
||||
assert '<' not in line and '>' not in line
|
||||
|
||||
|
||||
def test_the_rendered_piece_table_prestress_row_is_not_double_escaped():
|
||||
"""Regression: the Piece table used to build the Prestress row with
|
||||
`_rows()`, which runs every value through `notate()` -- safe for the
|
||||
other rows' plain prose, but `_prestress_line()`'s value is already real
|
||||
HTML it built and escaped itself, so notate() escaped it a SECOND time
|
||||
and the report printed the literal text "<br>" / "<strong>" instead of a
|
||||
line break and bold. Build a whole report and check the actual row."""
|
||||
panel = _panel_with_a_cut_strand()
|
||||
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
|
||||
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
|
||||
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
|
||||
findings = checks.run_all(analysis, Rigging(mode='rolling'))
|
||||
page = report.build(analysis, Rigging(mode='rolling'), findings,
|
||||
recommendation=False)
|
||||
|
||||
assert '<br>' not in page and '<strong>' not in page
|
||||
|
||||
assert '<tr><th>Prestress</th><td>' in page
|
||||
row = page[page.index('<tr><th>Prestress</th><td>'):]
|
||||
row = row[:row.index('</td></tr>') + len('</td></tr>')]
|
||||
assert '<br>' in row
|
||||
assert '<strong>' in row and 'cut by a blockout' in row
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The beam solver must reproduce PCI Figure 8.3.2 from first principles.
|
||||
|
||||
This is the test the whole design rests on. Figure 8.3.2's coefficients are
|
||||
not a separate method -- they are the closed-form solution of a uniformly
|
||||
loaded beam with two overhangs, with the supports placed where the positive
|
||||
and negative moments come out equal. If our solver cannot land on 0.0214 with
|
||||
the supports there, it cannot be trusted with the supports somewhere else,
|
||||
which is the only case anyone actually needs it for.
|
||||
"""
|
||||
import math
|
||||
|
||||
from panellift import pci, section, statics
|
||||
from panellift.model import Stage
|
||||
|
||||
from conftest import ASPIRE, LENGTH_FT, WIDTH_FT
|
||||
|
||||
|
||||
def test_optimal_support_fraction_is_the_root_of_the_quadratic():
|
||||
a = pci.OPTIMAL_TWO_LINE_FRACTION
|
||||
assert abs(4 * a ** 2 + 4 * a - 1) < 1e-12
|
||||
assert abs(a - 0.20710678) < 1e-7
|
||||
|
||||
|
||||
def test_closed_form_coefficient_is_pcis_0_0214():
|
||||
"""M = w*(aL)^2/2 with a = (sqrt(2)-1)/2 gives 0.02145, printed as 0.0214."""
|
||||
a = pci.OPTIMAL_TWO_LINE_FRACTION
|
||||
coefficient = a ** 2 / 2.0
|
||||
assert abs(coefficient - 0.0214) < 0.0001
|
||||
|
||||
|
||||
def test_solver_reproduces_my_coefficient(solid_panel, rolling):
|
||||
"""The numerical diagram must match 0.0214 w a b^2 to within 0.5%."""
|
||||
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
|
||||
result = statics.analyse(solid_panel, stage, rolling,
|
||||
reconcile_weight=False)
|
||||
|
||||
w_psf = section.unit_weight_psf(solid_panel)
|
||||
_, expected = pci.fig_832_four_point(w_psf, WIDTH_FT, LENGTH_FT)
|
||||
|
||||
_, m_pos = result.diagram.max_positive
|
||||
_, m_neg = result.diagram.max_negative
|
||||
|
||||
assert abs(m_pos - expected) / expected < 0.005
|
||||
assert abs(abs(m_neg) - expected) / expected < 0.005
|
||||
|
||||
|
||||
def test_positive_and_negative_moments_are_equal_at_the_optimum(solid_panel,
|
||||
rolling):
|
||||
"""That equality is the definition of the Figure 8.3.2 layout."""
|
||||
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
|
||||
result = statics.analyse(solid_panel, stage, rolling,
|
||||
reconcile_weight=False)
|
||||
_, m_pos = result.diagram.max_positive
|
||||
_, m_neg = result.diagram.max_negative
|
||||
assert abs(m_pos - abs(m_neg)) / m_pos < 0.01
|
||||
|
||||
|
||||
def test_unit_weight_matches_the_article(solid_panel):
|
||||
assert abs(section.unit_weight_psf(solid_panel) -
|
||||
ASPIRE['w_psf']) < 0.1
|
||||
|
||||
|
||||
def _peak_abs_moment(result):
|
||||
"""The governing moment, whichever sign it has.
|
||||
|
||||
Bunching the picks inboard turns the panel into two long cantilevers, and
|
||||
then the whole interior hogs -- the largest positive moment is zero, at
|
||||
the ends. Comparing only sagging would read that as an improvement.
|
||||
"""
|
||||
_, positive = result.diagram.max_positive
|
||||
_, negative = result.diagram.max_negative
|
||||
return max(abs(positive), abs(negative))
|
||||
|
||||
|
||||
def test_moving_the_supports_inboard_raises_the_governing_moment(solid_panel,
|
||||
rolling):
|
||||
"""A sanity check that the solver is solving, not returning a constant."""
|
||||
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
|
||||
optimum = _peak_abs_moment(
|
||||
statics.analyse(solid_panel, stage, rolling, reconcile_weight=False))
|
||||
|
||||
for lifter in solid_panel.lifters:
|
||||
lifter.x = LENGTH_FT / 2.0 + math.copysign(
|
||||
4.0, lifter.x - LENGTH_FT / 2.0)
|
||||
moved = _peak_abs_moment(
|
||||
statics.analyse(solid_panel, stage, rolling, reconcile_weight=False))
|
||||
|
||||
assert moved > optimum * 1.5
|
||||
|
||||
|
||||
def test_the_optimum_layout_really_is_the_optimum(solid_panel, rolling):
|
||||
"""No other support position beats (sqrt(2)-1)/2. Worth proving once.
|
||||
|
||||
This is what makes the PCI layout a benchmark the report can measure a
|
||||
detailer's actual layout against, rather than just another arrangement.
|
||||
"""
|
||||
stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0)
|
||||
best = _peak_abs_moment(
|
||||
statics.analyse(solid_panel, stage, rolling, reconcile_weight=False))
|
||||
|
||||
for fraction in (0.10, 0.15, 0.18, 0.23, 0.26, 0.30):
|
||||
left = fraction * LENGTH_FT
|
||||
for lifter in solid_panel.lifters:
|
||||
lifter.x = left if lifter.x < LENGTH_FT / 2.0 else LENGTH_FT - left
|
||||
trial = _peak_abs_moment(
|
||||
statics.analyse(solid_panel, stage, rolling,
|
||||
reconcile_weight=False))
|
||||
assert trial >= best * 0.999, (
|
||||
'fraction {0} beat the supposed optimum'.format(fraction))
|
||||
@@ -0,0 +1,237 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The figures have to be the data, and the data has to be readable.
|
||||
|
||||
Two complaints from the engineer using this, both about the same thing --
|
||||
a picture that does not answer a question is decoration on a calculation:
|
||||
|
||||
1. The free body's load was the same drawing on every piece. It was: the
|
||||
tall stroke at each station came out of `beam_y - 30 - h + h`, so the
|
||||
load height cancelled itself out and the one stroke that DID scale was
|
||||
drawn inside the constant one. A panel with a run of openings looked
|
||||
exactly like a solid slab. `test_two_load_profiles_draw_differently` is
|
||||
the guard.
|
||||
|
||||
2. Shear, moment and stress were curves with a peak label and nothing else,
|
||||
so reading a value at a station meant measuring the page with your eye.
|
||||
They now carry their own samples and the cursor reads them off, in the
|
||||
units the engineer states them in: kip, kip-ft, psi.
|
||||
|
||||
The rule underneath both: the report never shows a number the engine did not
|
||||
compute. The hover values are the plotted samples, converted once at the point
|
||||
of display, written onto the SVG -- not recalculated in the browser.
|
||||
"""
|
||||
import re
|
||||
|
||||
from conftest import aspire_pockets, make_panel
|
||||
from panellift import checks, layout, report, statics
|
||||
from panellift.model import Blockout
|
||||
|
||||
|
||||
def _analyse(panel, stage, rigging):
|
||||
return statics.analyse(panel, stage, rigging)
|
||||
|
||||
|
||||
def _svg_of(page, title_fragment):
|
||||
"""The one <svg> whose title contains this fragment."""
|
||||
i = page.index(title_fragment)
|
||||
return page[page.rindex('<svg', 0, i):page.index('</svg>', i)]
|
||||
|
||||
|
||||
# --------------------------------------------------- 1. the free body is data
|
||||
|
||||
def test_the_load_band_steps_where_the_section_steps(stripping, rolling):
|
||||
"""A blockout takes width out of the piece, so w drops over it. The
|
||||
drawing has to drop over it too, or it is not the free body of anything.
|
||||
"""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
svg = _svg_of(report.free_body(a), 'Free body')
|
||||
band = re.search(r'<path class="loadband" d="([^"]+)"', svg).group(1)
|
||||
ys = set(re.findall(r',([0-9.]+)', band))
|
||||
assert len(ys) >= 3 # beam line, full load, reduced load
|
||||
|
||||
|
||||
def test_two_load_profiles_draw_differently(stripping, rolling):
|
||||
"""The complaint, as a test. Same piece, same pick, same everything but
|
||||
the openings -- and before this the two free bodies were identical."""
|
||||
solid = _analyse(make_panel(), stripping, rolling)
|
||||
pocketed = _analyse(make_panel(blockouts=aspire_pockets()),
|
||||
stripping, rolling)
|
||||
|
||||
a = _svg_of(report.free_body(solid), 'Free body')
|
||||
b = _svg_of(report.free_body(pocketed), 'Free body')
|
||||
|
||||
band_a = re.search(r'<path class="loadband" d="([^"]+)"', a).group(1)
|
||||
band_b = re.search(r'<path class="loadband" d="([^"]+)"', b).group(1)
|
||||
assert band_a != band_b
|
||||
|
||||
# and the arrows, which are what makes the band read as a load
|
||||
arrows_a = re.findall(r'<path class="load" d="([^"]+)"', a)
|
||||
arrows_b = re.findall(r'<path class="load" d="([^"]+)"', b)
|
||||
assert arrows_a != arrows_b
|
||||
|
||||
|
||||
def test_a_uniform_piece_draws_one_flat_band(stripping, rolling):
|
||||
"""The other half of the same guarantee: an unbroken piece must not be
|
||||
given a shape it does not have."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
segs = report.load_segments(a.profile)
|
||||
assert len(segs) == 1
|
||||
svg = _svg_of(report.free_body(a), 'Free body')
|
||||
assert 'plf factored' in svg
|
||||
assert ' to ' not in re.search(r'<text class="note"[^>]*>([^<]*)<',
|
||||
svg).group(1)
|
||||
|
||||
|
||||
def test_the_segments_carry_their_own_magnitudes(stripping, rolling):
|
||||
"""Per-segment labels, in the unit the profile is in -- plf, factored."""
|
||||
panel = make_panel(blockouts=[
|
||||
Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)])
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
svg = _svg_of(report.free_body(a), 'Free body')
|
||||
labels = re.findall(r'<text class="loadlab"[^>]*>([^<]*)</text>', svg)
|
||||
assert len(labels) >= 2
|
||||
assert len(set(labels)) >= 2 # not the same number twice
|
||||
note = re.search(r'<text class="note"[^>]*>([^<]*)<', svg).group(1)
|
||||
assert 'plf factored' in note and ' to ' in note
|
||||
|
||||
|
||||
def test_the_load_segments_are_the_profile_not_an_average(stripping, rolling):
|
||||
"""load_segments must return the real w in each run, and cover the piece
|
||||
end to end -- the free body's numbers are quoted, so they are exact."""
|
||||
panel = make_panel(blockouts=[
|
||||
Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)])
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
segs = report.load_segments(a.profile)
|
||||
assert len(segs) == 3
|
||||
assert segs[0][0] == 0.0
|
||||
assert abs(segs[-1][1] - panel.length_ft) < 1e-9
|
||||
for i in range(len(segs) - 1):
|
||||
assert segs[i][1] == segs[i + 1][0]
|
||||
assert segs[1][2] < segs[0][2] # the opening lightens the middle
|
||||
# every value is one the profile actually holds
|
||||
held = set(a.profile.w)
|
||||
assert all(w in held for _x0, _x1, w in segs)
|
||||
|
||||
|
||||
# ------------------------------------------------- 2 and 3. hover, and units
|
||||
|
||||
def test_the_diagrams_carry_their_samples_in_display_units(stripping,
|
||||
rolling):
|
||||
"""Shear in kip, moment in kip-ft, stress in psi, x in feet. Converted
|
||||
once, here, and written onto the plot -- nothing converts in the browser.
|
||||
"""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
|
||||
units = re.findall(r'data-yu="([^"]*)" data-yl="([^"]*)"', page)
|
||||
# 'fₜ' (real Unicode subscript), not 'f_t': this label lands in an <svg>
|
||||
# <text> node and in the hover script's textContent, which parse a
|
||||
# <sub> tag as four literal characters rather than a subscript -- see
|
||||
# report.notate_plain().
|
||||
assert units == [('kip', 'V'), ('kip-ft', 'M'), ('psi', 'fₜ')]
|
||||
assert page.count('data-xu="ft"') == 3
|
||||
|
||||
shear_svg = _svg_of(page, 'Shear V(x)')
|
||||
hy = re.search(r'data-hy="([^"]*)"', shear_svg).group(1).split()
|
||||
peak_kip = max(abs(float(v)) for v in hy)
|
||||
peak_lb = max(abs(v) for v in a.diagram.shear_lb)
|
||||
assert abs(peak_kip - peak_lb / report.LB_PER_KIP) < 0.02
|
||||
assert peak_kip < peak_lb # it really was divided
|
||||
|
||||
moment_svg = _svg_of(page, 'Moment M(x)')
|
||||
hy = re.search(r'data-hy="([^"]*)"', moment_svg).group(1).split()
|
||||
peak_kipft = max(abs(float(v)) for v in hy)
|
||||
assert abs(peak_kipft -
|
||||
max(abs(m) for m in a.diagram.moment_lbft)
|
||||
/ report.LB_PER_KIP) < 0.2
|
||||
assert all(len(v.split('.')[1]) == 1 for v in hy) # 1 dp, consistent
|
||||
|
||||
|
||||
def test_every_hover_sample_has_a_pixel_and_a_value(stripping, rolling):
|
||||
"""Four arrays of the same length, or the lookup is off by one somewhere
|
||||
and the tooltip quietly reports the wrong station."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
for svg in re.findall(r'<svg[^>]*class="hoverplot"[^>]*>', page):
|
||||
got = [len(re.search(k + r'="([^"]*)"', svg).group(1).split())
|
||||
for k in ('data-hx', 'data-hy', 'data-px', 'data-py')]
|
||||
assert len(set(got)) == 1 and got[0] > 10
|
||||
assert re.search(r'data-t="([-0-9.]+)"', svg)
|
||||
assert re.search(r'data-b="([-0-9.]+)"', svg)
|
||||
|
||||
|
||||
def test_the_stations_are_feet_to_two_decimals(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
svg = _svg_of(report.stress_plot(a), 'Tensile stress')
|
||||
hx = re.search(r'data-hx="([^"]*)"', svg).group(1).split()
|
||||
assert all(len(v.split('.')[1]) == 2 for v in hx)
|
||||
assert float(hx[0]) >= 0.0
|
||||
assert float(hx[-1]) <= round(a.panel.length_ft, 2) + 0.01
|
||||
|
||||
|
||||
def test_the_script_is_present_exactly_once(stripping, rolling):
|
||||
"""One document, one listener. build() nested inside build_many() must
|
||||
not bring a second copy with it."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
found = checks.run_all(a, rolling)
|
||||
|
||||
one = report.build(a, rolling, found, recommendation=False)
|
||||
assert one.count('<script>') == one.count('</script>') == 1
|
||||
|
||||
nested = report.build(a, rolling, found, recommendation=False,
|
||||
standalone=False)
|
||||
assert '<script>' not in nested
|
||||
|
||||
many = report.build_many([(a, rolling, found)],
|
||||
recommendation=layout.not_searched())
|
||||
assert many.count('<script>') == many.count('</script>') == 1
|
||||
assert many.count("querySelectorAll('svg.hoverplot')") == 1
|
||||
|
||||
|
||||
def test_a_page_with_no_script_still_shows_every_curve(stripping, rolling):
|
||||
"""Hover is additive. Strip the script out and the report is exactly the
|
||||
report it was -- same curves, same peak rules, same disclosures, and the
|
||||
print overrides untouched. That is the no-JS and the printed case."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
without = page[:page.index('<script>')]
|
||||
|
||||
assert without.count('<svg') == without.count('</svg>') == 5
|
||||
assert without.count('class="series"') == 3
|
||||
assert 'kip-ft' in without and 'allowable' in without
|
||||
assert 'details::details-content' in without # print override intact
|
||||
assert '<details class="section">' in without # closed by default
|
||||
assert '<details class="section" open' not in without
|
||||
assert '.hoverlayer{display:none !important}' in without
|
||||
|
||||
|
||||
def test_hover_values_are_the_values_that_were_plotted(stripping, rolling):
|
||||
"""The samples on the <svg> are the samples the path was drawn from --
|
||||
the same decimation, the same points. A tooltip reading off a different
|
||||
series is a second, unreviewed calculation."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
svg = _svg_of(report.stress_plot(a), 'Tensile stress')
|
||||
px = re.search(r'data-px="([^"]*)"', svg).group(1).split()
|
||||
py = re.search(r'data-py="([^"]*)"', svg).group(1).split()
|
||||
drawn = re.search(r'<path class="series" d="M ([^"]+)"', svg).group(1)
|
||||
pairs = [seg.strip().split(',') for seg in drawn.split(' L ')]
|
||||
assert len(pairs) == len(px)
|
||||
for (dx, dy), hx, hy in zip(pairs, px, py):
|
||||
assert abs(float(dx) - float(hx)) < 0.06
|
||||
assert abs(float(dy) - float(hy)) < 0.06
|
||||
|
||||
|
||||
def test_the_stations_are_monotonic_so_the_lookup_can_bisect(stripping,
|
||||
rolling):
|
||||
"""The script binary-searches data-px. It may do that only because the
|
||||
stations come out of a sorted diagram."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
for svg in re.findall(r'<svg[^>]*class="hoverplot"[^>]*>', page):
|
||||
px = [float(v) for v in
|
||||
re.search(r'data-px="([^"]*)"', svg).group(1).split()]
|
||||
assert px == sorted(px)
|
||||
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Prestress, transfer length, and what a blockout does to both.
|
||||
|
||||
The numbers in `test_matches_a_commercial_check` are from an independent
|
||||
run of the same piece through Eriksson Wall v5.2.0. That program was not
|
||||
consulted while this was written -- its inputs were read off its own file and
|
||||
its outputs off its own report -- so agreement to the last printed digit is
|
||||
worth having, and disagreement is worth stopping for.
|
||||
|
||||
The piece: 38'-7.5" x 10'-7.5" x 8", one opening 111.25 x 40.25 set 16 in.
|
||||
from one edge, twelve 1/2 in. 270k strands in two rows.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from panellift import checks, prestress, section
|
||||
from panellift.model import Blockout, Panel, Prestress, Strand
|
||||
|
||||
|
||||
PULL = 0.153 * 270_000 * 0.75 # 75% pull on a 1/2 in. 270k strand
|
||||
POSITIONS_IN = [3.5, 16.5, 40.5, 59.5, 87.5, 109.5]
|
||||
ROWS_IN = [1.75, 6.25]
|
||||
|
||||
|
||||
def sample_panel(loss_pct=1.58, blockout=True):
|
||||
strands = [Strand(y=y / 12.0, z_in=z, area_in2=0.153,
|
||||
initial_pull_lb=PULL)
|
||||
for z in ROWS_IN for y in POSITIONS_IN]
|
||||
blockouts = []
|
||||
if blockout:
|
||||
blockouts.append(Blockout(name='OPENING 1', x0=330.0 / 12.0,
|
||||
y0=16.0 / 12.0, length=111.25 / 12.0,
|
||||
width=40.25 / 12.0))
|
||||
return Panel(
|
||||
mark='sample', length_ft=463.5 / 12.0, width_ft=127.5 / 12.0,
|
||||
thickness_in=8.0, fc_psi=6000.0, fci_psi=3500.0,
|
||||
blockouts=blockouts,
|
||||
prestress=Prestress(strands=strands, loss_pct=loss_pct,
|
||||
diameter_in=0.5))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ transfer
|
||||
|
||||
def test_transfer_length_follows_the_effective_stress():
|
||||
panel = sample_panel()
|
||||
# f_se = 0.75 * 270 * (1 - 0.0158) = 199.3 ksi; lt = f_se * db / 3
|
||||
assert prestress.transfer_length_in(panel.prestress) == \
|
||||
pytest.approx(33.2, abs=0.1)
|
||||
|
||||
|
||||
def test_a_strand_picks_its_force_up_over_the_transfer_length():
|
||||
panel = sample_panel()
|
||||
lt = prestress.transfer_length_in(panel.prestress) / 12.0
|
||||
strand = panel.prestress.strands[0] # y = 3.5 in, never cut
|
||||
assert prestress.developed(strand, panel, 0.0, lt) == 0.0
|
||||
assert prestress.developed(strand, panel, lt / 2, lt) == pytest.approx(0.5)
|
||||
assert prestress.developed(strand, panel, lt, lt) == pytest.approx(1.0)
|
||||
assert prestress.developed(strand, panel, 10.0, lt) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_the_far_end_is_a_free_end_too():
|
||||
panel = sample_panel()
|
||||
lt = prestress.transfer_length_in(panel.prestress) / 12.0
|
||||
strand = panel.prestress.strands[0]
|
||||
assert prestress.developed(strand, panel, panel.length_ft, lt) == 0.0
|
||||
assert prestress.developed(
|
||||
strand, panel, panel.length_ft - lt / 2, lt) == pytest.approx(0.5)
|
||||
|
||||
|
||||
# ------------------------------------------------------- blockouts cut strands
|
||||
|
||||
def test_a_blockout_cuts_only_the_strands_it_passes_over():
|
||||
panel = sample_panel()
|
||||
cut = [s for s in panel.prestress.strands if prestress.cuts_for(s, panel)]
|
||||
# the opening spans y 16 -> 56.25 in, which is 16.5 and 40.5
|
||||
assert len(cut) == 4
|
||||
assert sorted(set(round(s.y * 12.0, 2) for s in cut)) == [16.5, 40.5]
|
||||
|
||||
|
||||
def test_area_inside_the_opening_is_the_uncut_strands_only():
|
||||
panel = sample_panel()
|
||||
state = prestress.state_at(panel, 28.97) # mid-opening
|
||||
assert state.area_in2 == pytest.approx(1.22, abs=0.01)
|
||||
|
||||
|
||||
def test_area_ramps_down_approaching_the_cut():
|
||||
"""Eriksson reports 1.84 / 1.75 / 1.33 / 1.22 at these four stations."""
|
||||
panel = sample_panel()
|
||||
for x_ft, expected in ((22.62, 1.84), (25.11, 1.75),
|
||||
(27.04, 1.33), (27.50, 1.22)):
|
||||
state = prestress.state_at(panel, x_ft)
|
||||
assert state.area_in2 == pytest.approx(expected, abs=0.01), x_ft
|
||||
|
||||
|
||||
def test_a_strand_the_opening_misses_keeps_its_force_through_it():
|
||||
panel = sample_panel()
|
||||
lt = prestress.transfer_length_in(panel.prestress) / 12.0
|
||||
outer = [s for s in panel.prestress.strands
|
||||
if round(s.y * 12.0, 2) == 109.5][0]
|
||||
assert prestress.developed(outer, panel, 28.97, lt) == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- net section
|
||||
|
||||
def test_an_off_centre_opening_moves_the_centroid():
|
||||
panel = sample_panel()
|
||||
net = section.NetSection(panel, 28.97)
|
||||
assert net.area_in2 == pytest.approx(698.0, abs=1.0)
|
||||
assert net.cy_in == pytest.approx(76.49, abs=0.05)
|
||||
assert net.sz_in3 == pytest.approx(930.7, abs=1.0)
|
||||
assert net.iy_in4 == pytest.approx(979_000, rel=0.005)
|
||||
|
||||
|
||||
def test_a_solid_section_is_centred():
|
||||
panel = sample_panel(blockout=False)
|
||||
net = section.NetSection(panel, 19.0)
|
||||
assert net.cy_in == pytest.approx(63.75, abs=0.01)
|
||||
assert net.area_in2 == pytest.approx(1020.0, abs=0.5)
|
||||
|
||||
|
||||
# ------------------------------------------------- the number that matters
|
||||
|
||||
def _corners(panel, x, moment_lbft):
|
||||
net = section.NetSection(panel, x)
|
||||
return [c[0] for c in checks._corner_stresses(panel, x, moment_lbft, net)]
|
||||
|
||||
|
||||
def _corner_max(panel, x, moment_lbft):
|
||||
return max(_corners(panel, x, moment_lbft))
|
||||
|
||||
|
||||
def test_matches_a_commercial_check():
|
||||
"""417.6 psi at 28.97 ft, from Eriksson Wall on the same piece.
|
||||
|
||||
Their factored service moment there is 48.17 k-ft. Feed the same moment
|
||||
into the same section and the answer has to be the same, or one of us is
|
||||
wrong about the mechanics rather than about the panel.
|
||||
"""
|
||||
panel = sample_panel(loss_pct=1.58)
|
||||
got = _corner_max(panel, 28.97, 48_170.0)
|
||||
assert got == pytest.approx(417.6, abs=2.0)
|
||||
|
||||
|
||||
def test_without_prestress_the_same_piece_reads_far_higher():
|
||||
"""The bug this whole feature exists to fix, kept as a number."""
|
||||
panel = sample_panel()
|
||||
bare = Panel(**{**panel.__dict__, 'prestress': None})
|
||||
assert _corner_max(bare, 28.97, 48_170.0) == pytest.approx(621.0, abs=2.0)
|
||||
|
||||
|
||||
def test_precompression_wipes_out_a_light_stage():
|
||||
"""Our own stripping moment on this piece, which is larger than theirs.
|
||||
|
||||
23.83 k-ft of bending on the solid section against ~359 psi of
|
||||
precompression: 210 psi of it goes, and the piece comes back well under
|
||||
the limit instead of at 47% of it.
|
||||
"""
|
||||
panel = sample_panel()
|
||||
assert _corner_max(panel, 10.94, 23_830.0) < 40.0
|
||||
|
||||
|
||||
def test_precompression_and_compression_both_match_at_stripping():
|
||||
"""Eriksson's stripping at 0.25L: tension 0.0 psi, compression 667.4.
|
||||
|
||||
Their moment there is 14.10 k-ft, not ours -- they idealise a static
|
||||
lift and this idealises rolling blocks, so the two disagree about the
|
||||
reactions before either says anything about stress. Feeding THEIR moment
|
||||
in isolates the section from the rigging, and both faces then have to
|
||||
agree, which is a stronger check than the tension alone: the tension is
|
||||
zero either way once there is enough precompression, but the compression
|
||||
is not, and it pins P/A and the eccentricity together.
|
||||
"""
|
||||
panel = sample_panel(loss_pct=1.58)
|
||||
corners = _corners(panel, 9.66, 14_100.0)
|
||||
assert max(corners) <= 0.0
|
||||
assert -min(corners) == pytest.approx(667.4, abs=2.0)
|
||||
|
||||
|
||||
def test_a_bigger_loss_gives_back_tension():
|
||||
"""The loss assumption is a real lever, so it has to move the answer."""
|
||||
lean = _corner_max(sample_panel(loss_pct=1.58), 28.97, 48_170.0)
|
||||
fat = _corner_max(sample_panel(loss_pct=8.0), 28.97, 48_170.0)
|
||||
assert fat > lean + 10.0
|
||||
|
||||
|
||||
# --------------------------------------------------------- sign conventions
|
||||
|
||||
def _one_row_panel(z_in):
|
||||
strands = [Strand(y=y / 12.0, z_in=z_in, initial_pull_lb=PULL)
|
||||
for y in [15.0, 45.0, 75.0, 105.0]]
|
||||
return Panel(mark='s', length_ft=30.0, width_ft=10.0, thickness_in=8.0,
|
||||
fc_psi=6000.0,
|
||||
prestress=Prestress(strands=strands, loss_pct=0.0))
|
||||
|
||||
|
||||
def test_a_low_strand_helps_the_face_that_sagging_puts_in_tension():
|
||||
"""Sagging puts tension on the form face, z = 0. A strand below mid-depth
|
||||
hogs the piece and takes tension off that face; the same strand above
|
||||
mid-depth adds to it.
|
||||
|
||||
Nothing in the commercial comparison can catch this: that piece has two
|
||||
symmetric rows, so its eccentricity is exactly zero and the term this
|
||||
tests does not exist. It was written wrong the first time and this is
|
||||
what found it.
|
||||
"""
|
||||
low = _corners(_one_row_panel(1.0), 15.0, 20_000.0)[:2] # cz = -t/2
|
||||
mid = _corners(_one_row_panel(4.0), 15.0, 20_000.0)[:2]
|
||||
high = _corners(_one_row_panel(7.0), 15.0, 20_000.0)[:2]
|
||||
assert max(low) < max(mid) < max(high)
|
||||
@@ -0,0 +1,261 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Blind recesses: carried through the job file, and taken into nothing.
|
||||
|
||||
THE DEFECT, fixed on the Revit side 2026-09-01
|
||||
-----------------------------------------------
|
||||
`liftexport._openings` reads rectangles off the side faces of the concrete
|
||||
solid, and a blind pocket has side faces exactly like a hole does. The
|
||||
exporter emitted every one of them as a `blockout`, and this package reads a
|
||||
blockout as a void through the thickness: it takes that width out of the
|
||||
section, CUTS every strand crossing it, and restarts development at its edges.
|
||||
|
||||
So a corner pocket 0.29 x 0.67 ft with 3.5 in of concrete behind it, a foot
|
||||
from the end of a piece, severed fourteen strands that pass a clear 3.5 in
|
||||
below and above it. `test_the_pocket_as_a_blockout_is_the_defect_itself`
|
||||
measures what that did, and it is not subtle.
|
||||
|
||||
The exporter now splits them: `blockouts` are the through ones, `recesses` are
|
||||
the blind ones with `concrete_in` and `depth_in`. This side reads the new key,
|
||||
carries it on the model, counts it in the report -- and uses it in no
|
||||
calculation whatever. That is an approximation and it is unconservative over
|
||||
the pocket, so the report says so in a line of its own rather than leaving a
|
||||
reader to assume the pocket was accounted for.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from panellift import capacity, cli, report
|
||||
from panellift.model import Recess
|
||||
|
||||
|
||||
RECT = {'x0': 11.2083, 'y0': 5.0, 'length': 0.2917, 'width': 0.6667}
|
||||
|
||||
|
||||
# --------------------------------------------------------- reading the key
|
||||
|
||||
def test_a_recess_round_trips_through_the_job_file():
|
||||
"""THE CONTRACT, from this side. The other half is
|
||||
`WillTools.extension/tests/test_liftexport.py::
|
||||
test_the_recess_keys_are_the_ones_panellift_reads` -- neither package can
|
||||
import the other, so a rename under either is caught only by a pin at
|
||||
each end."""
|
||||
panel = cli.read_panel({
|
||||
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
|
||||
'thickness_in': 8.0,
|
||||
'recesses': [dict(RECT, name='RECESS 1', concrete_in=3.5,
|
||||
depth_in=4.5)],
|
||||
})
|
||||
assert panel.recesses == [Recess(name='RECESS 1', x0=11.2083, y0=5.0,
|
||||
length=0.2917, width=0.6667,
|
||||
concrete_in=3.5, depth_in=4.5)]
|
||||
assert panel.blockouts == []
|
||||
|
||||
|
||||
def test_a_recess_may_arrive_with_neither_depth_measured():
|
||||
"""The exporter writes None where the geometry did not answer, and None
|
||||
has to survive as None -- a 0 would read as a pocket of no depth."""
|
||||
panel = cli.read_panel({
|
||||
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
|
||||
'thickness_in': 8.0,
|
||||
'recesses': [dict(RECT, name='RECESS 1')],
|
||||
})
|
||||
assert panel.recesses[0].concrete_in is None
|
||||
assert panel.recesses[0].depth_in is None
|
||||
|
||||
|
||||
def test_a_job_file_from_before_the_split_still_reads():
|
||||
"""Every job file written to date has no `recesses` key at all, including
|
||||
the 184 of the reference sweep. Absent is an empty list here, and the
|
||||
report says nothing about recesses on such a piece -- absence is never
|
||||
rendered as a claim that it was looked for."""
|
||||
panel = cli.read_panel({
|
||||
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
|
||||
'thickness_in': 8.0,
|
||||
'blockouts': [dict(RECT, name='OPENING 1')],
|
||||
})
|
||||
assert panel.recesses == []
|
||||
assert len(panel.blockouts) == 1
|
||||
|
||||
|
||||
def test_a_recess_missing_a_dimension_is_refused_by_name():
|
||||
"""Same treatment a blockout gets. A rectangle with no x0 is not a
|
||||
rectangle at 0."""
|
||||
with pytest.raises(cli.JobError) as excinfo:
|
||||
cli.read_panel({
|
||||
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
|
||||
'thickness_in': 8.0,
|
||||
'recesses': [{'name': 'RECESS 1', 'y0': 5.0, 'length': 0.3,
|
||||
'width': 0.7}],
|
||||
})
|
||||
assert 'panel.recesses[0]' in str(excinfo.value)
|
||||
assert 'x0' in str(excinfo.value)
|
||||
|
||||
|
||||
def test_a_recess_is_turned_with_the_piece():
|
||||
"""A panel erected on its long edge is transposed rather than taught
|
||||
about orientation. A pocket left un-turned would be printed where the
|
||||
piece does not have one."""
|
||||
panel = cli.read_panel({
|
||||
'mark': 'PNL101', 'length_ft': 11.5, 'width_ft': 10.6667,
|
||||
'thickness_in': 8.0,
|
||||
'recesses': [dict(RECT, name='RECESS 1', concrete_in=3.5,
|
||||
depth_in=4.5)],
|
||||
})
|
||||
turned = cli.transpose(panel)
|
||||
assert (turned.recesses[0].x0, turned.recesses[0].y0) == (5.0, 11.2083)
|
||||
assert (turned.recesses[0].length, turned.recesses[0].width) == \
|
||||
(0.6667, 0.2917)
|
||||
# Depth is through the panel either way, like the thickness.
|
||||
assert turned.recesses[0].depth_in == 4.5
|
||||
|
||||
|
||||
# --------------------------------------------------- WP128, the real shape
|
||||
#
|
||||
# 11.5 x 10.667 ft x 8 in, one 0.2917 x 0.6667 ft pocket at x0 11.2083 with
|
||||
# 3.5 in of concrete behind it, fourteen 1/2 in strands in two layers at
|
||||
# z 1.75 and 6.25 -- so the pocket is clear of every one of them. The piece is
|
||||
# nearly square, so it trips ASPECT whatever else is true; that finding is
|
||||
# unrelated and none of these tests reads the overall verdict.
|
||||
|
||||
def wp128_job(**panel_keys):
|
||||
strands = ([{'y': 5.3333, 'z_in': 1.75, 'area_in2': 0.153,
|
||||
'initial_pull_lb': 28920.0}] * 7 +
|
||||
[{'y': 5.3333, 'z_in': 6.25, 'area_in2': 0.153,
|
||||
'initial_pull_lb': 28920.0}] * 7)
|
||||
|
||||
def face(mark, x, y):
|
||||
return {'mark': mark, 'family': 'TEST_LIFTER', 'x': x, 'y': y,
|
||||
'capacity_lb': 8000.0, 'shear_capacity_lb': 8000.0,
|
||||
'tension_fos': 4.0, 'shear_fos': 4.0}
|
||||
|
||||
def edge(mark, y):
|
||||
return {'mark': mark, 'family': 'TEST_EDGE_ANCHOR', 'x': 11.5, 'y': y,
|
||||
'capacity_lb': 16000.0, 'shear_capacity_lb': 16000.0,
|
||||
'on_edge': True, 'pinned': True,
|
||||
'tension_fos': 4.0, 'shear_fos': 4.0}
|
||||
|
||||
panel = {
|
||||
'mark': 'WP128', 'length_ft': 11.5, 'width_ft': 10.6667,
|
||||
'thickness_in': 8.0, 'unit_weight_pcf': 150.0, 'weight_lb': 12267.0,
|
||||
'fc_psi': 6000.0, 'fci_psi': 3500.0,
|
||||
'finish': 'smooth', 'product_type': 'flat',
|
||||
'lifters': [face('L1', 2.1667, 2.0012), face('L2', 2.1667, 8.6678),
|
||||
face('L3', 9.3333, 2.0012), face('L4', 9.3333, 8.6678),
|
||||
edge('E1', 2.0), edge('E2', 8.6667)],
|
||||
'prestress': {'diameter_in': 0.5, 'loss_pct': 8,
|
||||
'transfer_length_in': None, 'strands': strands},
|
||||
}
|
||||
panel.update(panel_keys)
|
||||
return {
|
||||
'schema': 'panellift/1',
|
||||
'panel': panel,
|
||||
'rigging': {'mode': 'rolling', 'sling_angle_deg': 60.0,
|
||||
'group_tolerance_ft': 0.4},
|
||||
# Erection: the bottom face pair plus the two edge anchors in the end
|
||||
# the piece stands on. This is the stage the phantom opening failed.
|
||||
'stages': [{'name': 'erection', 'multiplier': 1.4, 'fc_psi': 6000.0,
|
||||
'lifters': ['L1', 'L2', 'E1', 'E2']}],
|
||||
}
|
||||
|
||||
|
||||
THE_POCKET = dict(RECT, name='RECESS 1', concrete_in=3.5, depth_in=4.5)
|
||||
THE_POCKET_AS_A_HOLE = dict(RECT, name='OPENING 1')
|
||||
|
||||
|
||||
def erection(job):
|
||||
"""(panel, capacity points, findings) for the one stage."""
|
||||
panel, _rigging, cases = cli.analyse_job(job)
|
||||
analysis, _rig, findings = cases[0]
|
||||
return panel, capacity.profile_for(analysis), findings
|
||||
|
||||
|
||||
def worst_flexural(points):
|
||||
return max([p.m_over_phi_mn for p in points
|
||||
if p.m_over_phi_mn is not None] or [None])
|
||||
|
||||
|
||||
def test_the_pocket_carries_no_blockout_at_all():
|
||||
panel, _points, _findings = erection(
|
||||
wp128_job(recesses=[THE_POCKET]))
|
||||
assert panel.blockouts == []
|
||||
assert len(panel.recesses) == 1
|
||||
|
||||
|
||||
def test_a_recess_restarts_no_strand_development():
|
||||
"""The assertion this whole fixture exists for. Development restarts at a
|
||||
free end, and a blockout edge is one; a pocket is not, and no station may
|
||||
be measured from it."""
|
||||
_panel, points, _findings = erection(wp128_job(recesses=[THE_POCKET]))
|
||||
ends = sorted(set(p.free_end for p in points if p.free_end))
|
||||
assert ends == ['the end of the piece']
|
||||
|
||||
|
||||
def test_a_recess_is_arithmetically_identical_to_solid_concrete():
|
||||
"""Not merely "does not fail" -- the same numbers, station for station.
|
||||
That is what "carried and used in nothing" has to mean, and a check that
|
||||
only looked at the verdict would pass while the section quietly moved."""
|
||||
_panel, with_pocket, _f = erection(wp128_job(recesses=[THE_POCKET]))
|
||||
_panel2, solid, _f2 = erection(wp128_job())
|
||||
assert [p.x for p in with_pocket] == [p.x for p in solid]
|
||||
assert [p.b_in for p in with_pocket] == [p.b_in for p in solid]
|
||||
assert [p.m_over_phi_mn for p in with_pocket] == \
|
||||
[p.m_over_phi_mn for p in solid]
|
||||
|
||||
|
||||
def test_no_station_exceeds_its_flexural_capacity_on_erection():
|
||||
"""The piece as its model really is: solid, with a pocket in a corner.
|
||||
The reference sweep reported M/phi-Mn 1.04 on this piece and it was the
|
||||
phantom hole, not the panel."""
|
||||
_panel, points, findings = erection(wp128_job(recesses=[THE_POCKET]))
|
||||
assert worst_flexural(points) < 1.0
|
||||
assert [f for f in findings if f.code == 'FLEX-CAP' and
|
||||
f.severity == 'FAIL'] == []
|
||||
|
||||
|
||||
def test_the_pocket_as_a_blockout_is_the_defect_itself():
|
||||
"""The before picture, kept so the fixture above is proving something.
|
||||
One key moved and the same 0.29 x 0.67 ft rectangle severs every strand
|
||||
it stands over, restarts their development at its edges a foot from the
|
||||
end of the piece, and produces a flexural-capacity FAIL on a panel whose
|
||||
model has no opening in it."""
|
||||
_panel, points, findings = erection(
|
||||
wp128_job(blockouts=[THE_POCKET_AS_A_HOLE]))
|
||||
assert 'an opening edge, which cuts every strand it crosses' in \
|
||||
[p.free_end for p in points]
|
||||
assert worst_flexural(points) > 1.0
|
||||
assert [f.code for f in findings if f.severity == 'FAIL'
|
||||
and f.code == 'FLEX-CAP'] == ['FLEX-CAP']
|
||||
|
||||
|
||||
# ------------------------------------------------------- what the page says
|
||||
|
||||
def one_page(job):
|
||||
panel, rigging, cases = cli.analyse_job(job)
|
||||
analysis, rig, findings = cases[0]
|
||||
return report.build(analysis, rig, findings, recommendation=False)
|
||||
|
||||
|
||||
def test_the_piece_summary_says_the_recess_is_not_in_the_numbers():
|
||||
"""A stated approximation. The alternative is a page that counts the
|
||||
pocket nowhere and mentions it nowhere, which is the silent omission this
|
||||
codebase treats as the defect rather than the shortcut."""
|
||||
page = one_page(wp128_job(recesses=[THE_POCKET]))
|
||||
assert 'Recesses' in page
|
||||
assert '1 blind recess recorded' in page
|
||||
assert 'NOT reflected in the section properties or in strand development' \
|
||||
in page
|
||||
assert 'treats the piece as solid concrete there' in page
|
||||
|
||||
|
||||
def test_the_count_is_the_count():
|
||||
page = one_page(wp128_job(recesses=[
|
||||
THE_POCKET, dict(THE_POCKET, name='RECESS 2', x0=0.5)]))
|
||||
assert '2 blind recesses recorded' in page
|
||||
|
||||
|
||||
def test_a_piece_with_no_recesses_says_nothing_about_them():
|
||||
"""Absence is not a claim, and a row saying "Recesses: none" would be one
|
||||
-- it would read as "we looked", on every job file written before the key
|
||||
existed."""
|
||||
page = one_page(wp128_job())
|
||||
assert 'Recesses' not in page
|
||||
assert 'blind recess' not in page
|
||||
@@ -0,0 +1,395 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The strip ACROSS the width, between the inserts of one pick row.
|
||||
|
||||
The beam solve calls each support line rigid. It is not: the inserts in a
|
||||
row are discrete points, and the panel between two of them carries its
|
||||
share sideways to reach one. These tests hold the ROW-FLEX arithmetic to
|
||||
hand numbers on the ASPIRE panel -- 10 ft wide, 8.75 in thick, two lines
|
||||
of two at the Figure 8.3.2 positions -- where every value can be written
|
||||
on one line.
|
||||
"""
|
||||
import dataclasses
|
||||
import re
|
||||
|
||||
from conftest import LENGTH_FT, WIDTH_FT, THICKNESS_IN, make_panel
|
||||
from panellift import checks, pci, statics
|
||||
from panellift.model import Blockout, Lifter, Rigging, Stage
|
||||
|
||||
|
||||
WEIGHT_LB = 35000.0 # 32 ft x 10 ft x 8.75 in of 150 pcf concrete
|
||||
|
||||
|
||||
def _panel():
|
||||
return dataclasses.replace(make_panel(), weight_lb=WEIGHT_LB)
|
||||
|
||||
|
||||
def _findings(panel, stage, rolling):
|
||||
a = statics.analyse(panel, stage, rolling, role='strip')
|
||||
return checks.check_row_transverse_flexure(a)
|
||||
|
||||
|
||||
def _codes(findings):
|
||||
return [f.code for f in findings]
|
||||
|
||||
|
||||
def _f_t_psi(finding):
|
||||
m = re.search(r'f_t ([0-9.]+) psi', finding.message)
|
||||
return float(m.group(1))
|
||||
|
||||
|
||||
def _expected_stress(stripping, gap_ft, depth_ft):
|
||||
"""Hand arithmetic for the ASPIRE pick, straight from the definition.
|
||||
|
||||
Two symmetric lines under an equalising rig: each carries half the
|
||||
factored weight. Spread over the width that is w; the strip between or
|
||||
past the inserts is w*gap^2/8 inside, /2 at an overhang; the web depth
|
||||
is the longitudinal concrete beside the row.
|
||||
"""
|
||||
w_lbft = WEIGHT_LB * stripping.multiplier / 2.0 / WIDTH_FT
|
||||
m_lbft = w_lbft * gap_ft ** 2 / 8.0
|
||||
s_in3 = THICKNESS_IN * (depth_ft * 12.0) ** 2 / 6.0
|
||||
return m_lbft * 12.0 / s_in3
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- solid
|
||||
|
||||
def test_the_strip_is_noise_on_a_solid_panel(stripping, rolling):
|
||||
"""Same deep-beam stiffness that makes EDGE-PATH ~1% of allowable."""
|
||||
found = _findings(_panel(), stripping, rolling)
|
||||
assert len(found) == 1
|
||||
assert found[0].severity == 'OK'
|
||||
assert _f_t_psi(found[0]) < 5.0
|
||||
|
||||
|
||||
def test_the_arithmetic_is_locked_to_a_hand_number(stripping, rolling):
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
# Interior gap is WIDTH/2; the web beside either row is the inner span.
|
||||
expected = _expected_stress(stripping, WIDTH_FT / 2.0, right - left)
|
||||
found = _findings(_panel(), stripping, rolling)[0]
|
||||
assert abs(_f_t_psi(found) - expected) < 0.05
|
||||
|
||||
|
||||
def test_the_multiplier_is_on_the_load():
|
||||
"""Concrete stress against concrete allowable: factored, unlike INSERT."""
|
||||
roll = Rigging(mode='rolling', sling_angle_deg=90.0)
|
||||
gentle = _findings(_panel(), Stage('a', 1.0, 4000.0), roll)[0]
|
||||
harsh = _findings(_panel(), Stage('b', 2.0, 4000.0), roll)[0]
|
||||
assert abs(_f_t_psi(harsh) - 2.0 * _f_t_psi(gentle)) <= 0.15
|
||||
|
||||
|
||||
def test_an_overhang_past_the_end_insert_governs_when_it_must(stripping,
|
||||
rolling):
|
||||
"""Both inserts of each row pushed to mid-width: the strips hanging off
|
||||
the ends are 4.9 ft cantilevers against a 0.2 ft interior span."""
|
||||
panel = _panel()
|
||||
panel = dataclasses.replace(panel, lifters=[
|
||||
dataclasses.replace(l, y=4.9 + (i % 2) * 0.2)
|
||||
for i, l in enumerate(panel.lifters)])
|
||||
found = _findings(panel, stripping, rolling)[0]
|
||||
assert 'overhang' in found.message
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
w_lbft = WEIGHT_LB * stripping.multiplier / 2.0 / WIDTH_FT
|
||||
m_lbft = w_lbft * 4.9 ** 2 / 2.0
|
||||
s_in3 = THICKNESS_IN * ((right - left) * 12.0) ** 2 / 6.0
|
||||
assert abs(_f_t_psi(found) - m_lbft * 12.0 / s_in3) < 0.05
|
||||
|
||||
|
||||
# ------------------------------------------------------- openings cut the web
|
||||
|
||||
def _row_opening(depth_in=None):
|
||||
"""Straddles the left pick row and reaches into the insert-to-insert
|
||||
strip, leaving 1.0 ft of web on each side of the row."""
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
return Blockout(name='BIG', x0=left - 1.0, y0=4.0, length=2.0,
|
||||
width=3.0, depth_in=depth_in)
|
||||
|
||||
|
||||
def _side_opening(offset_ft, length_ft=4.0, depth_in=None):
|
||||
"""A full-depth opening wholly on one side of the left pick row.
|
||||
|
||||
offset_ft is the clear distance from the row to the opening's near face:
|
||||
positive puts it inboard (to the right of the row), negative outboard.
|
||||
"""
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
x0 = (left + offset_ft) if offset_ft > 0 else (left + offset_ft - length_ft)
|
||||
return Blockout(name='SIDE', x0=x0, y0=3.0, length=length_ft, width=4.0,
|
||||
depth_in=depth_in)
|
||||
|
||||
|
||||
def test_an_opening_on_one_side_leaves_the_other_side_untouched(stripping,
|
||||
rolling):
|
||||
"""The web is the concrete BESIDE the row, not the part of the hole that
|
||||
happens to lie on each side of it. An opening entirely outboard of the
|
||||
row does not touch the concrete inboard of it, and the deeper side is
|
||||
what the strip spans on."""
|
||||
solid = _findings(_panel(), stripping, rolling)[0]
|
||||
panel = dataclasses.replace(_panel(), blockouts=[_side_opening(-0.25)])
|
||||
found = _findings(panel, stripping, rolling)[0]
|
||||
assert found.severity == 'OK'
|
||||
assert abs(_f_t_psi(found) - _f_t_psi(solid)) < 0.05
|
||||
|
||||
|
||||
def test_an_opening_inboard_of_the_row_leaves_only_the_outboard_end(
|
||||
stripping, rolling):
|
||||
"""Mirror of the above: the inboard web is starved to 3 in, so the strip
|
||||
spans on the outboard end of the piece instead."""
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
panel = dataclasses.replace(_panel(), blockouts=[_side_opening(0.25)])
|
||||
found = _findings(panel, stripping, rolling)[0]
|
||||
assert 'opening SIDE' in found.message
|
||||
assert '{0:.1f} in'.format(left * 12.0) in found.message
|
||||
|
||||
|
||||
def test_a_bigger_hole_through_the_row_never_reads_as_a_deeper_web(
|
||||
stripping, rolling):
|
||||
"""The failure this replaces: the web was measured to the opening's FAR
|
||||
face, so lengthening a hole that runs through the pick row made the strip
|
||||
deeper and the stress lower -- monotonically backwards."""
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
depths = []
|
||||
for length in (1.0, 2.0, 4.0, 8.0):
|
||||
panel = dataclasses.replace(_panel(), blockouts=[Blockout(
|
||||
name='GROW', x0=left - length / 2.0, y0=3.0, length=length,
|
||||
width=4.0)])
|
||||
found = _findings(panel, stripping, rolling)
|
||||
codes = _codes(found)
|
||||
assert 'ROW-FLEX-INTERRUPTED' in codes
|
||||
depths.append(codes)
|
||||
# Every one of them is interrupted; none of them is rated as a beam that
|
||||
# got deeper. The old code produced 12, 24, 48 and 96 in of "web".
|
||||
assert all('ROW-FLEX-INTERRUPTED' in c for c in depths)
|
||||
|
||||
|
||||
def test_a_row_running_through_an_opening_is_not_rated_at_all(stripping,
|
||||
rolling):
|
||||
"""No concrete beside the row means no strip. Rating it would invent a
|
||||
section; failing it would condemn a piece this model cannot speak about."""
|
||||
panel = dataclasses.replace(_panel(), blockouts=[_row_opening()])
|
||||
found = _findings(panel, stripping, rolling)
|
||||
notes = [f for f in found if f.code == 'ROW-FLEX-INTERRUPTED']
|
||||
assert len(notes) == 1 # one per row and opening, not gap
|
||||
assert notes[0].severity == 'WARN'
|
||||
assert 'runs through opening BIG' in notes[0].message
|
||||
assert 'cannot rate it' in notes[0].message
|
||||
# The other row is solid and is still rated.
|
||||
rated = [f for f in found if f.code == 'ROW-FLEX']
|
||||
assert len(rated) == 1 and rated[0].severity == 'OK'
|
||||
|
||||
|
||||
def test_an_opening_clear_of_the_strip_across_the_width_does_not_sever_it(
|
||||
stripping, rolling):
|
||||
"""Six inches clear is clear. A tolerance that says otherwise turns
|
||||
concrete that is there into a finding, 184 times over."""
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
solid = _findings(_panel(), stripping, rolling)[0]
|
||||
# Inserts at y = 2.5 and 7.5; this sits between 7.9 and 9.9, clear of the
|
||||
# 2.5-7.5 strip by 0.4 ft, and it straddles the row longitudinally.
|
||||
panel = dataclasses.replace(_panel(), blockouts=[Blockout(
|
||||
name='CLEAR', x0=left - 1.0, y0=7.9, length=2.0, width=2.0)])
|
||||
found = _findings(panel, stripping, rolling)
|
||||
interrupting = [f for f in found if f.code == 'ROW-FLEX-INTERRUPTED']
|
||||
# It interrupts only the overhang it actually crosses, and says where.
|
||||
assert len(interrupting) == 1
|
||||
assert '7.90 to 9.90 ft' in interrupting[0].message
|
||||
# The strip it is 0.4 ft clear of is not severed: what is still rated
|
||||
# stays down at solid-panel noise rather than reporting a cut web.
|
||||
rated = [f for f in found if f.code == 'ROW-FLEX']
|
||||
assert len(rated) == 1 and rated[0].severity == 'OK'
|
||||
assert _f_t_psi(rated[0]) < 2.0 * _f_t_psi(solid)
|
||||
|
||||
|
||||
def test_a_partial_depth_void_is_not_a_web_cut(stripping, rolling):
|
||||
"""House rule: partial-depth blockouts do not reduce section properties.
|
||||
The finding must come out numerically identical to the solid panel's."""
|
||||
solid = _findings(_panel(), stripping, rolling)[0]
|
||||
panel = dataclasses.replace(_panel(),
|
||||
blockouts=[_row_opening(depth_in=4.0)])
|
||||
found = _findings(panel, stripping, rolling)[0]
|
||||
assert 'opening' not in found.message
|
||||
assert abs(_f_t_psi(found) - _f_t_psi(solid)) < 0.001
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ guards
|
||||
|
||||
def test_a_stage_standing_on_its_edge_belongs_to_edge_path(stripping,
|
||||
rolling):
|
||||
"""Within-row gaps along the anchored top edge are EDGE-PATH's concrete;
|
||||
checking them here too would report the same strip under two codes."""
|
||||
found = _findings(_panel(), Stage('e', 1.4, 4000.0, span='width'),
|
||||
rolling)
|
||||
assert found == []
|
||||
|
||||
|
||||
def test_a_dunnage_stage_has_no_pick_rows(shipping, rolling):
|
||||
panel = dataclasses.replace(_panel())
|
||||
stage = Stage(name='transportation', multiplier=1.35, fc_psi=5000.0,
|
||||
supports=[5.0, LENGTH_FT - 5.0])
|
||||
assert _findings(panel, stage, rolling) == []
|
||||
|
||||
|
||||
def test_no_weight_says_so_instead_of_passing(stripping, rolling):
|
||||
found = _findings(make_panel(), stripping, rolling) # weight_lb=None
|
||||
assert _codes(found) == ['ROW-FLEX']
|
||||
assert found[0].severity == 'WARN'
|
||||
|
||||
|
||||
def test_no_concrete_strength_is_not_a_pass(rolling):
|
||||
found = _findings(_panel(), Stage('e', 1.52, 0.0), rolling)
|
||||
assert _codes(found) == ['ROW-FLEX-UNRATED']
|
||||
assert found[0].severity == 'WARN'
|
||||
|
||||
|
||||
def test_run_all_carries_the_check(stripping, rolling):
|
||||
a = statics.analyse(_panel(), stripping, rolling, role='strip')
|
||||
assert 'ROW-FLEX' in _codes(checks.run_all(a, rolling))
|
||||
|
||||
|
||||
# ------------------------------- a line that is not a row is not rated
|
||||
|
||||
def _staggered_panel():
|
||||
"""A real shape out of the model: the two inserts of one row sit 0.5 ft
|
||||
apart along the piece, which is more than handling.group_tolerance_ft, so
|
||||
the solve reads them as two support lines each holding one insert."""
|
||||
panel = _panel()
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
return dataclasses.replace(panel, lifters=[
|
||||
dataclasses.replace(panel.lifters[0], x=left, y=2.5),
|
||||
dataclasses.replace(panel.lifters[1], x=left + 0.5, y=7.5),
|
||||
dataclasses.replace(panel.lifters[2], x=right, y=2.5),
|
||||
dataclasses.replace(panel.lifters[3], x=right, y=7.5)])
|
||||
|
||||
|
||||
def test_a_line_holding_one_insert_is_not_rated_as_a_row(stripping, rolling):
|
||||
"""One insert has no strip BETWEEN inserts -- the width hangs off a
|
||||
point. Rating it cantilevered the whole half-width off that point and,
|
||||
with an opening beside it, reported 33,491 psi on a piece that ships."""
|
||||
found = _findings(_staggered_panel(), stripping, rolling)
|
||||
notes = [f for f in found if f.code == 'ROW-FLEX-NOT-A-ROW']
|
||||
assert notes and all(f.severity == 'WARN' for f in notes)
|
||||
assert 'one insert' in notes[0].message
|
||||
assert 'group_tolerance_ft' in notes[0].message
|
||||
assert not [f for f in found if f.severity == 'FAIL']
|
||||
|
||||
|
||||
def test_a_line_being_pulled_up_is_not_rated_either(stripping):
|
||||
"""Two support lines half a foot apart make the static solve lift one of
|
||||
them: -12,621 lb against +38,138 lb next to it. Taking abs() of that and
|
||||
rating a strip against the result is arithmetic on a number that means
|
||||
the opposite of what it says.
|
||||
|
||||
Static, not rolling, because that is the mode that produces it: an
|
||||
equalising rig shares the load and never lifts a line, so under rolling
|
||||
this case cannot arise and a test written on it would pass without
|
||||
running the code it names.
|
||||
"""
|
||||
static = Rigging(mode='static')
|
||||
a = statics.analyse(_staggered_panel(), stripping, static, role='strip')
|
||||
lifted = [l for l in a.rig.lines if l.reaction_lb <= 0.0]
|
||||
assert lifted, 'the fixture stopped producing an uplift reaction'
|
||||
found = checks.check_row_transverse_flexure(a)
|
||||
assert not [f for f in found if f.severity == 'FAIL']
|
||||
# This geometry trips 'one insert' first, so force a two-insert line to
|
||||
# be the one lifted and check the uplift branch on its own terms.
|
||||
panel = _panel()
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
panel = dataclasses.replace(panel, lifters=[
|
||||
dataclasses.replace(panel.lifters[0], x=left, y=2.5),
|
||||
dataclasses.replace(panel.lifters[1], x=left, y=7.5),
|
||||
dataclasses.replace(panel.lifters[2], x=left + 0.5, y=2.5),
|
||||
dataclasses.replace(panel.lifters[3], x=left + 0.5, y=7.5)])
|
||||
a = statics.analyse(panel, stripping, static, role='strip')
|
||||
assert any(l.reaction_lb <= 0.0 and len(l.lifters) == 2
|
||||
for l in a.rig.lines), 'no two-insert line was lifted'
|
||||
found = checks.check_row_transverse_flexure(a)
|
||||
assert any('pulled UP' in f.message for f in found)
|
||||
assert not [f for f in found if f.severity == 'FAIL']
|
||||
|
||||
|
||||
def test_a_severed_band_does_not_become_a_zero_depth_failure(stripping,
|
||||
rolling):
|
||||
"""WP156's shape: one opening crosses the row inside the strip, another
|
||||
sits far away on the same strip. The crossing one severs a band -- the
|
||||
load goes around it -- and the far one must not turn that into a
|
||||
'no section at all' FAIL naming the wrong opening."""
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
panel = dataclasses.replace(_panel(), blockouts=[
|
||||
# Squeeze the concrete beside the row to 6 in on both sides...
|
||||
Blockout(name='OUTBOARD', x0=left - 1.17, y0=4.0, length=0.67,
|
||||
width=3.0),
|
||||
Blockout(name='INBOARD', x0=left + 0.5, y0=4.0, length=0.67,
|
||||
width=3.0),
|
||||
# ...and cut the strip itself with a third that crosses the row.
|
||||
Blockout(name='CROSSING', x0=left - 0.3, y0=4.0, length=0.67,
|
||||
width=3.0)])
|
||||
found = _findings(panel, stripping, rolling)
|
||||
notes = [f for f in found if f.code == 'ROW-FLEX-INTERRUPTED']
|
||||
assert len(notes) == 1
|
||||
assert 'opening CROSSING' in notes[0].message
|
||||
assert 'INBOARD' not in notes[0].message
|
||||
# The severed strip must not ALSO be rated behind the note. Carrying on
|
||||
# with the two squeezing openings rates that row on 6 in of web and
|
||||
# reports 1,750 psi against 296 -- a FAIL invented for a strip the model
|
||||
# has just said it cannot speak about.
|
||||
assert not [f for f in found if f.severity == 'FAIL']
|
||||
rated = [f for f in found if f.code == 'ROW-FLEX']
|
||||
assert len(rated) == 1
|
||||
assert abs(rated[0].at_ft - right) < 0.01
|
||||
|
||||
|
||||
# --------------------------- where the pinch sits is most of the answer
|
||||
|
||||
def _pinched(y0, y1):
|
||||
"""Two openings, one each side of the left pick row, squeezing the web to
|
||||
6 in over the band y0..y1 of the strip between the inserts at 2.5 and 7.5.
|
||||
"""
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
return dataclasses.replace(_panel(), blockouts=[
|
||||
Blockout(name='L', x0=left - 1.17, y0=y0, length=0.67, width=y1 - y0),
|
||||
Blockout(name='R', x0=left + 0.5, y0=y0, length=0.67, width=y1 - y0)])
|
||||
|
||||
|
||||
def test_a_pinch_beside_an_insert_costs_less_than_one_at_midspan(stripping,
|
||||
rolling):
|
||||
"""A strip between two inserts carries its largest moment at midspan and
|
||||
none at all at the inserts. Concrete missing where the moment is zero is
|
||||
not the same as concrete missing where it peaks -- and taking the
|
||||
smallest web anywhere and applying it to the whole strip said it was,
|
||||
reporting 522 psi against 444 on a piece whose midspan is solid.
|
||||
"""
|
||||
at_end = _f_t_psi(_findings(_pinched(2.6, 3.1), stripping, rolling)[0])
|
||||
at_mid = _f_t_psi(_findings(_pinched(4.75, 5.25), stripping, rolling)[0])
|
||||
assert at_mid > 2.0 * at_end
|
||||
|
||||
|
||||
def test_the_pinched_band_is_rated_at_its_own_moment(stripping, rolling):
|
||||
"""Closed form for the band beside the insert: the strip spans 2.5 to
|
||||
7.5, the band ends at 3.1, and a simply supported span carries
|
||||
w*(y-a)*(b-y)/2 there -- not w*L^2/8, which belongs to midspan.
|
||||
|
||||
w comes off the solved line rather than half the weight: the section is
|
||||
6 in deep, so a per-cent of reaction is tens of psi here and an
|
||||
approximate w would make this test about the approximation.
|
||||
"""
|
||||
panel = _pinched(2.6, 3.1)
|
||||
a = statics.analyse(panel, stripping, rolling, role='strip')
|
||||
line = min(a.rig.lines, key=lambda l: l.x)
|
||||
w_lbft = line.reaction_lb / WIDTH_FT
|
||||
m_lbft = w_lbft * (3.1 - 2.5) * (7.5 - 3.1) / 2.0
|
||||
s_in3 = THICKNESS_IN * 6.0 ** 2 / 6.0 # 6 in of web
|
||||
found = checks.check_row_transverse_flexure(a)[0]
|
||||
assert abs(_f_t_psi(found) - m_lbft * 12.0 / s_in3) < 0.2
|
||||
|
||||
|
||||
def test_a_pinch_over_the_whole_strip_still_governs_at_midspan(stripping,
|
||||
rolling):
|
||||
"""The uniform case has to survive the change: squeeze the whole strip
|
||||
and the answer goes back to w*L^2/8 on the reduced section."""
|
||||
panel = _pinched(2.5, 7.5) # exactly the strip, not the overhangs
|
||||
a = statics.analyse(panel, stripping, rolling, role='strip')
|
||||
line = min(a.rig.lines, key=lambda l: l.x)
|
||||
w_lbft = line.reaction_lb / WIDTH_FT
|
||||
m_lbft = w_lbft * (WIDTH_FT / 2.0) ** 2 / 8.0
|
||||
s_in3 = THICKNESS_IN * 6.0 ** 2 / 6.0
|
||||
found = checks.check_row_transverse_flexure(a)[0]
|
||||
assert 'overhang' not in found.message
|
||||
assert abs(_f_t_psi(found) - m_lbft * 12.0 / s_in3) < 0.2
|
||||
@@ -0,0 +1,244 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tension_fos / shear_fos: a number that had no source, and its removal.
|
||||
|
||||
They divide a lifter's rated capacity -- `shear_fos` when the lifter sits on
|
||||
an edge, `tension_fos` otherwise -- and only when `capacity_is_ultimate` is
|
||||
true, i.e. only where the catalogue quotes an ultimate rather than a working
|
||||
load. Nothing in this repository sets that flag, so the arithmetic is not
|
||||
reached on any shipped path; it is reachable, it is public API, and what it
|
||||
would have computed was wrong.
|
||||
|
||||
WHAT WAS WRONG. `shear_fos` was 2.67, as a `Rigging` default, as a
|
||||
`read_rigging` default, as a `catalogue.safety_factors` entry and as what
|
||||
`liftexport` wrote into every job file. No page published it, nobody typed
|
||||
it, and the comment justifying it as "the manufacturer's 4:1 tension /
|
||||
2.67:1 shear" was written 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" over BOTH
|
||||
load columns and prints shear equal to tension on all seven QLPD rows.
|
||||
|
||||
WHAT IT WOULD HAVE DONE. 32,000 lb ultimate / 2.67 = 11,985 lb allowed,
|
||||
against a published SWL of 8,000. Half again as much load on an anchor as
|
||||
the manufacturer permits, in the direction that passes one which would fail.
|
||||
|
||||
SO THE GLOBAL IS GONE, and it was not replaced by a global 4.0 -- that is
|
||||
the same defect wearing the right number, because the next manufacturer's
|
||||
insert would inherit a factor read off ALP's page. The factors are per
|
||||
product in the catalogue and per insert in `Lifter`, and an insert with none
|
||||
is NOT JUDGED: `INSERT-NO-FOS`, named, never defaulted.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import checks, cli, statics
|
||||
from panellift.model import Lifter, Rigging, Stage
|
||||
from conftest import make_panel
|
||||
|
||||
CATALOGUE = (Path(__file__).resolve().parents[2]
|
||||
/ 'knowledge' / 'catalogue.yaml')
|
||||
|
||||
# This is the one file in this suite that is NOT self-contained -- it pins
|
||||
# published catalogue rows against the real, tracked `knowledge/catalogue.yaml`
|
||||
# rather than a fixture, on purpose (see the module docstring: this is a
|
||||
# golden-value check against a page, not a recomputation). A knowledge-less
|
||||
# distribution (tools/make_dist.py) ships no catalogue.yaml at all, so the
|
||||
# question this file asks -- "does the catalogue publish X" -- has no
|
||||
# catalogue to ask it of. SKIP, not FAIL: the same "could not judge" vs.
|
||||
# "judged, found nothing" discipline the rest of this project uses, applied
|
||||
# to a test file rather than a knowledge lookup.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not CATALOGUE.is_file(),
|
||||
reason='knowledge/catalogue.yaml is not shipped in this checkout '
|
||||
'(see knowledge/README.md) -- nothing to pin these golden values '
|
||||
'against')
|
||||
|
||||
# The seven QuikLift disk-anchor rows of ALP catalog p53, 02/2024, typed in
|
||||
# from the page: (part, tension SWL, shear SWL, ultimate tension).
|
||||
# A pin against a written-down golden, not a recomputation.
|
||||
PAGE_53 = [
|
||||
('QLPD2T3WG', 4000, 4000, 16000),
|
||||
('QLPD4T3WG', 6257, 6257, 32000),
|
||||
('QLPD4T4WG', 7200, 7200, 32000),
|
||||
('QLPD4T5WG', 8000, 8000, 32000),
|
||||
('QLPD8T6WG', 11019, 11019, 64000),
|
||||
('QLPD8T7WG', 14640, 14640, 64000),
|
||||
('QLPD8T8WG', 16000, 16000, 64000),
|
||||
]
|
||||
|
||||
|
||||
def catalogue_text():
|
||||
return CATALOGUE.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def entry_text(part):
|
||||
"""One `catalogue.inserts` entry, as raw YAML text.
|
||||
|
||||
Read with a regex rather than a parser: this package has no YAML
|
||||
dependency and is not going to grow one to check a handful of numbers.
|
||||
"""
|
||||
block = re.search(r'\n %s:\n((?: \S.*\n| .*\n)+)' % part,
|
||||
catalogue_text())
|
||||
assert block, 'catalogue.yaml has no entry for {0}'.format(part)
|
||||
return block.group(1)
|
||||
|
||||
|
||||
def field(part, name):
|
||||
found = re.search(r'^\s+%s:\s*([-\d.]+)\s*$' % name, entry_text(part),
|
||||
re.M)
|
||||
return float(found.group(1)) if found else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the page
|
||||
|
||||
@pytest.mark.parametrize('part,tension,shear,_ultimate', PAGE_53)
|
||||
def test_the_catalogue_publishes_four_to_one_in_shear_for_every_qlpd_row(
|
||||
part, tension, shear, _ultimate):
|
||||
"""The page's own heading, landed as data on the part it belongs to."""
|
||||
text = entry_text(part)
|
||||
assert 'safety_factors:' in text, (
|
||||
'{0} carries no safety_factors block, so an ultimate rating for it '
|
||||
'has no published factor and it will be reported unjudged'
|
||||
.format(part))
|
||||
assert field(part, 'tension_fos') == 4.0
|
||||
assert field(part, 'shear_fos') == 4.0, (
|
||||
'ALP catalog p53 heads the capacity table "Capacity in Concrete 4:1 '
|
||||
'SWL" across BOTH load columns, so shear is 4:1 here too. 2.67 is '
|
||||
'the number this test exists to keep out.')
|
||||
assert 'p53' in text and '02/2024' in text, (
|
||||
'a safety factor with no page beside it is how 2.67 survived for '
|
||||
'twelve days')
|
||||
# ...and the working loads it is a factor on are still the page's.
|
||||
assert field(part, 'working_load_lb') == tension
|
||||
assert field(part, 'shear_load_lb') == shear
|
||||
|
||||
|
||||
def test_no_global_safety_factor_survives_anywhere():
|
||||
"""A tombstone. The failure was not the value 2.67 but the SHAPE that
|
||||
let one number apply to every manufacturer's insert, so replacing it
|
||||
with a global 4.0 would have been the same defect with a better number.
|
||||
"""
|
||||
block = re.search(r'\n safety_factors:\n((?: \w+:.*\n| .*\n)+)',
|
||||
catalogue_text())
|
||||
assert block, 'catalogue.yaml has no catalogue.safety_factors block'
|
||||
assert re.search(r'^ tension_fos: null\s*$', block.group(1), re.M)
|
||||
assert re.search(r'^ shear_fos: null\s*$', block.group(1), re.M)
|
||||
assert Rigging().tension_fos is None
|
||||
assert Rigging().shear_fos is None
|
||||
assert cli.read_rigging({}).tension_fos is None
|
||||
assert cli.read_rigging({}).shear_fos is None
|
||||
assert Lifter(mark='m', family='f', x=0.0, y=0.0).shear_fos is None
|
||||
# 2.67 may still be NAMED in the catalogue -- the comment that records
|
||||
# what it was and why it went is the provenance, and deleting the number
|
||||
# would leave the next reader to rediscover it from the same page. What
|
||||
# may not come back is 2.67 as a VALUE.
|
||||
live = re.findall(r'^\s*\w*fos:\s*2\.67\s*$', catalogue_text(), re.M)
|
||||
assert not live, live
|
||||
|
||||
|
||||
def test_the_ultimate_route_overshoots_the_published_swl_even_at_four_to_one():
|
||||
"""Why `capacity_is_ultimate` stays false for these parts.
|
||||
|
||||
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 ultimate/4 and what the concrete holds. Reading an
|
||||
ultimate through the right factor is still unconservative on those four,
|
||||
which is a fact about the route and not about the factor, and it is here
|
||||
so that nobody "fixes" the factor and then turns the flag on.
|
||||
"""
|
||||
over = [(part, ultimate / 4.0, tension)
|
||||
for (part, tension, _shear, ultimate) in PAGE_53
|
||||
if ultimate / 4.0 > tension + 0.5]
|
||||
assert [p for (p, _a, _b) in over] == [
|
||||
'QLPD4T3WG', 'QLPD4T4WG', 'QLPD8T6WG', 'QLPD8T7WG']
|
||||
assert max(a / b for (_p, a, b) in over) > 1.4 # QLPD8T6WG, +45%
|
||||
|
||||
|
||||
# ------------------------------------------------------- what the check does
|
||||
|
||||
def _panel_with(factor_kind, factor):
|
||||
"""A rating and a factor on ONLY the axis under test.
|
||||
|
||||
2026-09-02: check_insert_capacity now decomposes the sling's line force
|
||||
into a tension and a shear share and judges both, for every lifter --
|
||||
`on_edge` no longer picks a single axis to check. These tests are about
|
||||
factor resolution on one column, not about the decomposition, so the
|
||||
OTHER column is left with no capacity at all (rated() returns None,
|
||||
joining INSERT-UNRATED, never INSERT-NO-FOS) rather than a rating with
|
||||
no factor of its own, which would otherwise also land in
|
||||
INSERT-NO-FOS and be mistaken for the thing under test.
|
||||
"""
|
||||
panel = make_panel()
|
||||
lifters = [
|
||||
Lifter(mark=l.mark, family=l.family, x=l.x, y=l.y, role=l.role,
|
||||
capacity_lb=32000.0 if factor_kind == 'tension' else None,
|
||||
shear_capacity_lb=32000.0 if factor_kind == 'shear' else None,
|
||||
on_edge=(factor_kind == 'shear'),
|
||||
**{factor_kind + '_fos': factor})
|
||||
for l in panel.lifters]
|
||||
return dataclass_replace_lifters(panel, lifters)
|
||||
|
||||
|
||||
def dataclass_replace_lifters(panel, lifters):
|
||||
import dataclasses
|
||||
return dataclasses.replace(panel, lifters=lifters)
|
||||
|
||||
|
||||
def _findings(panel, stage, ultimate=True):
|
||||
rigging = Rigging(mode='rolling', sling_angle_deg=60.0)
|
||||
analysis = statics.analyse(panel, stage, rigging, reconcile_weight=False)
|
||||
return checks.check_insert_capacity(analysis, rigging, ultimate)
|
||||
|
||||
|
||||
def test_an_insert_with_no_published_factor_is_named_not_defaulted(stripping):
|
||||
"""The whole point. An ultimate rating and no factor used to become an
|
||||
allowed load through a constant; it now becomes a sentence."""
|
||||
out = _findings(_panel_with('shear', None), stripping)
|
||||
codes = [f.code for f in out]
|
||||
assert 'INSERT-NO-FOS' in codes
|
||||
note = [f for f in out if f.code == 'INSERT-NO-FOS'][0]
|
||||
assert note.severity == 'WARN'
|
||||
assert 'NOT judged' in note.message
|
||||
assert 'borrowed' in note.message
|
||||
# and no insert was judged behind its back
|
||||
assert not [f for f in out if f.code == 'INSERT']
|
||||
|
||||
|
||||
def test_the_page_factor_gives_exactly_the_published_working_load(stripping):
|
||||
"""32,000 / 4 = 8,000, which is what p53 prints for QLPD4T5WG. The old
|
||||
2.67 gave 11,985 for the same insert -- allowing nearly half again as
|
||||
much shear as the manufacturer's own page. Both numbers appear in the
|
||||
message (2026-09-02: on the SHEAR clause specifically, since the check
|
||||
now decomposes the line force rather than comparing it whole), so this
|
||||
compares them."""
|
||||
with_page = _findings(_panel_with('shear', 4.0), stripping)
|
||||
with_2_67 = _findings(_panel_with('shear', 2.67), stripping)
|
||||
page_msg = ' '.join(f.message for f in with_page if f.code == 'INSERT')
|
||||
old_msg = ' '.join(f.message for f in with_2_67 if f.code == 'INSERT')
|
||||
assert 'shear' in page_msg
|
||||
assert 'vs 8,000 lb' in page_msg or 'vs 8000 lb' in page_msg
|
||||
assert page_msg != old_msg
|
||||
assert '11,98' in old_msg or '11985' in old_msg
|
||||
|
||||
|
||||
def test_the_insert_s_own_factor_beats_the_job_wide_one(stripping):
|
||||
"""Resolution order, and the direction that matters: a job file naming
|
||||
2.67 must not override what the part's datasheet publishes."""
|
||||
panel = _panel_with('shear', 4.0)
|
||||
rigging = Rigging(mode='rolling', sling_angle_deg=60.0, shear_fos=2.67)
|
||||
analysis = statics.analyse(panel, stripping, rigging,
|
||||
reconcile_weight=False)
|
||||
out = checks.check_insert_capacity(analysis, rigging, True)
|
||||
assert not [f for f in out if f.code == 'INSERT-NO-FOS']
|
||||
msg = ' '.join(f.message for f in out if f.code == 'INSERT')
|
||||
assert '11,98' not in msg and '11985' not in msg
|
||||
|
||||
|
||||
def test_a_working_load_reading_never_touches_a_factor(stripping):
|
||||
"""The default path, which is every shipped path: with the ratings read
|
||||
as working loads the factors are not applied at all, so an insert with
|
||||
none is judged normally and no INSERT-NO-FOS is raised."""
|
||||
out = _findings(_panel_with('shear', None), stripping, ultimate=False)
|
||||
assert not [f for f in out if f.code == 'INSERT-NO-FOS']
|
||||
assert [f for f in out if f.code == 'INSERT']
|
||||
@@ -0,0 +1,594 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Insulated (sandwich) panels: the refusal, and the section it refuses over.
|
||||
|
||||
Two kinds of test here and they are kept apart on purpose.
|
||||
|
||||
**The mechanics** are checked against arithmetic done by hand, written out in
|
||||
a comment beside every number, on the dimensions of a real 16 in. insulated
|
||||
piece. A symmetric-enough sandwich has closed-form section properties and
|
||||
there is no excuse for a test that recomputes the code's own expression --
|
||||
a pin that recomputes both sides pins nothing and agrees with any refactor,
|
||||
including a wrong one.
|
||||
|
||||
**The refusal** is pinned as behaviour: which job files trip it, which do not,
|
||||
that it produces no report, and that it never returns one of OK/WARN/FAIL.
|
||||
|
||||
THE PIECE THE FIXTURE IS. Measured on the live model 2026-08-31: envelope
|
||||
16.0 in, 4 in. EPS between the wythes, two concrete solids of 79.885 ft3 and
|
||||
39.557 ft3, both at 150.28 pcf, length 27.94 ft, width across 5.33 ft.
|
||||
|
||||
the wythes are in the ratio 79.885 / 39.557 = 2.0195
|
||||
they share 16 - 4 = 12 in. of concrete
|
||||
12 * 2.0195 / 3.0195 = 8.025 -> 8 in. structural
|
||||
12 - 8 = 4 -> 4 in. architectural
|
||||
|
||||
so the layup is 4 in. concrete + 4 in. EPS + 8 in. concrete = 16 in., which
|
||||
is a textbook layup and is what the fixture below is built from. Everything
|
||||
is worked at b = 64 in. (the 5.33 ft width, no opening), because a clean
|
||||
width makes the hand arithmetic checkable by eye.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import cli, report, sandwich
|
||||
|
||||
|
||||
# The layup of the real piece, and the width it is worked at.
|
||||
T_ARCH = 4.0
|
||||
T_STRUCT = 8.0
|
||||
ENVELOPE = 16.0
|
||||
B = 64.0
|
||||
|
||||
|
||||
def wp_layup():
|
||||
"""4 in. concrete, 4 in. EPS, 8 in. concrete, form face at z = 0."""
|
||||
return sandwich.Layup(
|
||||
layers=[
|
||||
sandwich.Layer(sandwich.CONCRETE, 0.0, 4.0, 'ARCH WYTHE',
|
||||
150.28, 39.557),
|
||||
sandwich.Layer(sandwich.NOT_CONCRETE, 4.0, 8.0, 'EPS', 1.5, 39.8),
|
||||
sandwich.Layer(sandwich.CONCRETE, 8.0, 16.0, 'STRUCT WYTHE',
|
||||
150.28, 79.885),
|
||||
],
|
||||
envelope_in=ENVELOPE, measured=True, why='test fixture')
|
||||
|
||||
|
||||
def sandwich_source(positioned=True):
|
||||
"""`source` as the exporter writes it for this piece."""
|
||||
parts = [
|
||||
{'volume_ft3': 79.885, 'material': 'STRUCT WYTHE',
|
||||
'density_pcf': 150.28, 'counted': True},
|
||||
{'volume_ft3': 39.557, 'material': 'ARCH WYTHE',
|
||||
'density_pcf': 150.28, 'counted': True},
|
||||
{'volume_ft3': 39.8, 'material': 'EPS', 'density_pcf': 1.5,
|
||||
'counted': False},
|
||||
]
|
||||
if positioned:
|
||||
for part, (z0, z1) in zip(parts, [(8.0, 16.0), (0.0, 4.0),
|
||||
(4.0, 8.0)]):
|
||||
part['z0_in'], part['z1_in'] = z0, z1
|
||||
return {'solid_parts': parts, 'envelope_thickness_in': ENVELOPE,
|
||||
'solid_basis': 'material density'}
|
||||
|
||||
|
||||
# ==================================================================== section
|
||||
#
|
||||
# Every expected number below is arithmetic, written out, on b = 64 in.
|
||||
|
||||
def test_non_composite_inertia_is_the_sum_about_each_wythes_own_axis():
|
||||
# I = b*t1^3/12 + b*t2^3/12
|
||||
# = 64*4^3/12 + 64*8^3/12
|
||||
# = 64*64/12 + 64*512/12 = 341.3333 + 2730.6667 = 3072.0 in^4
|
||||
bound = sandwich.non_composite(B, wp_layup())
|
||||
assert bound.i_in4 == pytest.approx(3072.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_non_composite_section_modulus_is_governed_by_the_thicker_wythe():
|
||||
# Wythes tied by the connectors deflect together, so each takes the
|
||||
# moment in proportion to its own stiffness:
|
||||
# M_i = M * I_i / sum(I), f_i = M_i*(t_i/2)/I_i = M*(t_i/2)/sum(I)
|
||||
# so the extreme fibre stress is set by the THICKEST wythe:
|
||||
# S = sum(I) / (t_max/2) = 3072 / 4 = 768.0 in^3
|
||||
# and the 4 in. wythe's own fibres are at 3072/2 = 1536 in^3.
|
||||
bound = sandwich.non_composite(B, wp_layup())
|
||||
assert bound.s_governing_in3 == pytest.approx(768.0, abs=1e-9)
|
||||
moduli = sorted(set(round(f.s_in3, 6) for f in bound.fibres))
|
||||
assert moduli == [768.0, 1536.0]
|
||||
|
||||
|
||||
def test_fully_composite_neutral_axis_and_inertia():
|
||||
# A1 = 64*4 = 256 in^2 at z = 2.0; A2 = 64*8 = 512 in^2 at z = 12.0
|
||||
# zbar = (256*2 + 512*12)/768 = (512 + 6144)/768 = 8.666667 in
|
||||
# I = 64*4^3/12 + 256*(2 - 8.666667)^2
|
||||
# + 64*8^3/12 + 512*(12 - 8.666667)^2
|
||||
# = 341.3333 + 11377.7778 + 2730.6667 + 5688.8889 = 20138.6667 in^4
|
||||
bound = sandwich.fully_composite(B, wp_layup())
|
||||
assert bound.axis_z_in == pytest.approx(8.6666667, abs=1e-6)
|
||||
assert bound.i_in4 == pytest.approx(20138.6667, abs=1e-3)
|
||||
|
||||
|
||||
def test_fully_composite_section_moduli_at_both_faces():
|
||||
# form face c = 8.666667 in -> S = 20138.6667/8.666667 = 2323.6923
|
||||
# far face c = 7.333333 in -> S = 20138.6667/7.333333 = 2746.1818
|
||||
bound = sandwich.fully_composite(B, wp_layup())
|
||||
by_label = dict((f.label, f.s_in3) for f in bound.fibres)
|
||||
assert by_label['form face'] == pytest.approx(2323.6923, abs=1e-3)
|
||||
assert by_label['far face'] == pytest.approx(2746.1818, abs=1e-3)
|
||||
assert bound.s_governing_in3 == pytest.approx(2323.6923, abs=1e-3)
|
||||
|
||||
|
||||
def test_structural_wythe_alone_is_the_smallest_section_of_the_three():
|
||||
# The conservative reading of "is the face wythe structural": the 8 in.
|
||||
# wythe alone, S = b*t^2/6 = 64*64/6 = 682.6667 in^3.
|
||||
bound = sandwich.structural_wythe_alone(B, wp_layup())
|
||||
assert bound.i_in4 == pytest.approx(2730.6667, abs=1e-3)
|
||||
assert bound.s_governing_in3 == pytest.approx(682.6667, abs=1e-3)
|
||||
|
||||
|
||||
def test_the_solid_idealisation_is_unconservative_against_BOTH_bounds():
|
||||
"""The whole reason this work exists, as a number.
|
||||
|
||||
solid at 16 in.: I = 64*16^3/12 = 21845.333, S = 64*256/6 = 2730.667
|
||||
against fully composite 2730.667/2323.692 = 1.1751
|
||||
against non-composite 2730.667/768.0 = 3.5556
|
||||
|
||||
Both above 1.0 -- so there is no reading of a sandwich under which
|
||||
treating it as solid concrete at the envelope depth is the safe
|
||||
direction. That is the owner's "把夹芯板当实心算并不 conservative", and
|
||||
if this assertion ever flips the refusal has lost its argument.
|
||||
"""
|
||||
bounds = sandwich.section_bounds(wp_layup(), B)
|
||||
assert bounds.solid.i_in4 == pytest.approx(21845.3333, abs=1e-3)
|
||||
assert bounds.solid.s_governing_in3 == pytest.approx(2730.6667, abs=1e-3)
|
||||
against_composite, against_non = bounds.solid_overstates_by
|
||||
assert against_composite == pytest.approx(1.1751, abs=1e-4)
|
||||
assert against_non == pytest.approx(3.5556, abs=1e-4)
|
||||
assert against_composite > 1.0 and against_non > 1.0
|
||||
|
||||
|
||||
def test_shear_flow_is_the_same_from_either_wythe_as_equilibrium_requires():
|
||||
# Q(bottom) = 256*(8.666667 - 2.0) = 256*6.666667 = 1706.6667 in^3
|
||||
# Q(top) = 512*(12.0 - 8.666667) = 512*3.333333 = 1706.6667 in^3
|
||||
# q/V = Q/I = 1706.6667/20138.6667 = 0.0847458 per inch
|
||||
layup = wp_layup()
|
||||
comp = sandwich.fully_composite(B, layup)
|
||||
flow = sandwich.shear_flow(comp, layup, B)
|
||||
assert flow.q_in3 == pytest.approx(1706.6667, abs=1e-3)
|
||||
assert flow.per_lb_of_shear == pytest.approx(0.0847458, abs=1e-7)
|
||||
top = B * T_STRUCT * abs(comp.axis_z_in - 12.0)
|
||||
assert top == pytest.approx(flow.q_in3, abs=1e-6)
|
||||
|
||||
|
||||
def test_connector_demand_at_the_measured_spacing():
|
||||
# The design summary says HK ST100'S @ 1'-4" o.c., and the model has
|
||||
# 5 rows across the width. Per connector, per pound of shear:
|
||||
# 0.0847458 * 16.0 / 5 = 0.271186 lb
|
||||
layup = wp_layup()
|
||||
flow = sandwich.shear_flow(sandwich.fully_composite(B, layup), layup, B,
|
||||
v_lb=10000.0)
|
||||
demand = sandwich.connector_demand(flow, spacing_in=16.0, per_row=5)
|
||||
# q = 0.0847458 * 10000 = 847.458 lb/in; *16/5 = 2711.86 lb
|
||||
assert demand.per_connector_lb == pytest.approx(2711.864, abs=1e-2)
|
||||
# And with no capacity anywhere it is a DEMAND, never a verdict.
|
||||
assert demand.capacity_lb is None
|
||||
assert demand.verdict is None
|
||||
assert 'NOT KNOWN' in demand.why
|
||||
|
||||
|
||||
def test_a_demand_with_no_flow_stands_down_rather_than_reading_zero():
|
||||
demand = sandwich.connector_demand(None, spacing_in=16.0, per_row=5)
|
||||
assert demand.per_connector_lb is None
|
||||
assert demand.verdict is None
|
||||
|
||||
|
||||
# ================================================================= detection
|
||||
|
||||
def test_no_solid_parts_means_could_not_tell_and_never_solid():
|
||||
said = sandwich.detect({})
|
||||
assert said.sandwich is None
|
||||
assert said.sandwich is not False
|
||||
assert 'solid_parts' in said.why
|
||||
|
||||
|
||||
def test_one_concrete_solid_and_nothing_left_out_is_a_solid_panel():
|
||||
said = sandwich.detect({'solid_parts': [
|
||||
{'volume_ft3': 20.0, 'material': 'CONC', 'density_pcf': 150.0,
|
||||
'counted': True}]})
|
||||
assert said.sandwich is False
|
||||
|
||||
|
||||
def test_a_solid_left_out_as_not_concrete_is_a_sandwich():
|
||||
said = sandwich.detect(sandwich_source())
|
||||
assert said.sandwich is True
|
||||
assert said.basis == 'solid_parts'
|
||||
assert 'EPS' in said.why
|
||||
|
||||
|
||||
def test_two_concrete_solids_with_nothing_left_out_still_refuses():
|
||||
"""Over-refusing costs an answer; under-refusing prints a third of the
|
||||
real stress. Nothing in the file can tell a wythe pair from a panel and a
|
||||
ledge, so the safe direction is taken and said out loud."""
|
||||
said = sandwich.detect({'solid_parts': [
|
||||
{'volume_ft3': 79.9, 'material': 'CONC', 'counted': True},
|
||||
{'volume_ft3': 39.6, 'material': 'CONC', 'counted': True}]})
|
||||
assert said.sandwich is True
|
||||
assert 'ledge' in said.why
|
||||
|
||||
|
||||
def test_solid_parts_without_positions_knows_it_is_a_sandwich_and_no_more():
|
||||
said = sandwich.detect(sandwich_source(positioned=False))
|
||||
assert said.sandwich is True
|
||||
assert said.layup.measured is False
|
||||
assert said.layup.complete is False
|
||||
bounds = sandwich.section_bounds(said.layup, B)
|
||||
assert bounds.usable is False
|
||||
assert 'z0_in' in bounds.why
|
||||
|
||||
|
||||
def test_a_layup_that_was_not_measured_computes_no_section():
|
||||
"""The state a job file written before the exporter measured positions is
|
||||
in. It must refuse, not invent a layup that would look ordinary."""
|
||||
bounds = sandwich.section_bounds(
|
||||
sandwich.layup_from_source(sandwich_source(positioned=False)), B)
|
||||
assert bounds.usable is False
|
||||
assert bounds.non_composite is None
|
||||
assert bounds.composite is None
|
||||
|
||||
|
||||
# ========================================================== composite ratio
|
||||
|
||||
def test_the_composite_ratio_stands_down_and_names_both_definitions():
|
||||
"""A computed ratio is impossible without connector data -- not hard,
|
||||
impossible -- and an unlabelled percentage is a number nobody can check.
|
||||
Two definitions, both named, both None."""
|
||||
ratio = sandwich.composite_ratio(
|
||||
sandwich.non_composite(B, wp_layup()),
|
||||
sandwich.fully_composite(B, wp_layup()))
|
||||
assert ratio.stiffness is None
|
||||
assert ratio.strength is None
|
||||
assert 'I_eff' in ratio.definition_stiffness
|
||||
assert 'DEFLECTION' in ratio.definition_stiffness
|
||||
assert 'STRENGTH' in ratio.definition_strength
|
||||
# The two bounding inertias ARE printed: the width of the band is on the
|
||||
# page even though the position in it is not.
|
||||
assert ratio.i_non_composite_in4 == pytest.approx(3072.0)
|
||||
assert ratio.i_full_composite_in4 == pytest.approx(20138.6667, abs=1e-3)
|
||||
# And what would unblock each is stated per number, not as one shrug.
|
||||
joined = ' '.join(ratio.unblocked_by)
|
||||
assert 'stiffness' in joined and 'failure mode' in joined
|
||||
|
||||
|
||||
def test_the_ratio_never_prints_a_percentage_anywhere_in_its_own_text():
|
||||
ratio = sandwich.composite_ratio()
|
||||
assert '%' not in ratio.why
|
||||
|
||||
|
||||
# ================================================================== concerns
|
||||
|
||||
def test_the_two_bounds_bracket_flexure_and_shear_flow_and_nothing_else():
|
||||
"""可能出现的问题, enumerated. Exactly three quantities are bracketed;
|
||||
everything else about a sandwich panel is not a bounds question, and a
|
||||
reader who takes the two-bound report as an envelope over all of it is
|
||||
the failure this list exists to prevent."""
|
||||
items = sandwich.concerns(wp_layup())
|
||||
bracketed = [c.title for c in items if c.bracketed]
|
||||
assert bracketed == ['wythe flexural tension', 'interface shear flow',
|
||||
'deflection and bowing']
|
||||
not_bracketed = [c.title for c in items if not c.bracketed]
|
||||
assert 'connector force distribution along the span' in not_bracketed
|
||||
assert 'connector NORMAL (tension) demand' in not_bracketed
|
||||
assert ('local flexure of the face wythe between connectors'
|
||||
in not_bracketed)
|
||||
|
||||
|
||||
def test_unequal_wythes_get_their_own_concern():
|
||||
items = [c.title for c in sandwich.concerns(wp_layup())]
|
||||
assert 'the wythes are unequal' in items
|
||||
|
||||
|
||||
def test_equal_wythes_do_not():
|
||||
even = sandwich.Layup(
|
||||
layers=[sandwich.Layer(sandwich.CONCRETE, 0.0, 6.0),
|
||||
sandwich.Layer(sandwich.NOT_CONCRETE, 6.0, 8.0),
|
||||
sandwich.Layer(sandwich.CONCRETE, 8.0, 14.0)],
|
||||
envelope_in=14.0, measured=True)
|
||||
assert 'the wythes are unequal' not in [c.title
|
||||
for c in sandwich.concerns(even)]
|
||||
|
||||
|
||||
def test_the_sign_flip_at_the_inner_face_is_written_down():
|
||||
"""The trap a two-fibre check misses: the inner face of the outer wythe
|
||||
is in compression non-composite and in tension fully composite, so it
|
||||
changes sign between the bounds."""
|
||||
layup = wp_layup()
|
||||
nc = sandwich.non_composite(B, layup)
|
||||
fc = sandwich.fully_composite(B, layup)
|
||||
# M = 1,000,000 lb-in, sagging.
|
||||
m = 1.0e6
|
||||
# non-composite, the 4 in. wythe's own top fibre at z = 4:
|
||||
# f = -M*(+2.0)/3072 = -651.0 psi, COMPRESSION
|
||||
inner_nc = -m * (T_ARCH / 2.0) / nc.i_in4
|
||||
# fully composite at z = 4, which is 4.6667 in BELOW the axis:
|
||||
# f = -M*(4 - 8.666667)/20138.667 = +231.7 psi, TENSION
|
||||
inner_fc = -m * (4.0 - fc.axis_z_in) / fc.i_in4
|
||||
assert inner_nc < 0.0 and inner_fc > 0.0
|
||||
said = [c.sentence for c in sandwich.concerns(layup)
|
||||
if c.title == 'wythe flexural tension'][0]
|
||||
assert 'changes sign between the bounds' in said
|
||||
|
||||
|
||||
# ================================================================== refusal
|
||||
|
||||
def sandwich_job():
|
||||
"""A real-shaped job file for an insulated piece, layup MEASURED.
|
||||
|
||||
Deliberately an OTHERWISE VALID job: everything here analyses fine, on the
|
||||
CONCRETE-WYTHE composite section (insulation excluded) that `section.py`
|
||||
now builds. Before 2026-09-02 this was refused outright; the point of the
|
||||
refusal was never "a sandwich cannot be analysed", it was "a sandwich must
|
||||
not be analysed as one SOLID prism". Now that it is not, a measured
|
||||
sandwich runs.
|
||||
"""
|
||||
from test_cli import aspire_job
|
||||
job = aspire_job()
|
||||
job['source'] = sandwich_source()
|
||||
job['panel']['thickness_in'] = ENVELOPE
|
||||
return job
|
||||
|
||||
|
||||
def unmeasured_sandwich_job():
|
||||
"""The same piece, but the exporter could not measure the layup positions.
|
||||
|
||||
This is the makeup that STILL refuses: without z0/z1 there is no centroid,
|
||||
no I and no d_v, so there is no section to build and no solid guess is
|
||||
allowed to stand in for one.
|
||||
"""
|
||||
from test_cli import aspire_job
|
||||
job = aspire_job()
|
||||
job['source'] = sandwich_source(positioned=False)
|
||||
job['panel']['thickness_in'] = ENVELOPE
|
||||
return job
|
||||
|
||||
|
||||
def test_a_measured_sandwich_now_analyses_instead_of_being_refused():
|
||||
"""The defect this closes: a measured sandwich must be analysed, and on
|
||||
the concrete-wythe composite section, not refused and not treated solid."""
|
||||
from panellift import section
|
||||
panel, _rig, cases = cli.analyse_job(sandwich_job())
|
||||
assert len(cases) >= 1
|
||||
net = section.NetSection(cases[0][0].panel, panel.length_ft / 2.0)
|
||||
assert net.is_sandwich is True
|
||||
# the section is the concrete only: 4 + 8 = 12 in of concrete, NOT the
|
||||
# 16 in envelope, and the shear depth follows the concrete.
|
||||
assert net.shear_thickness_in == pytest.approx(T_ARCH + T_STRUCT)
|
||||
assert net.thickness_in == pytest.approx(ENVELOPE) # envelope still drawn
|
||||
# composite centroid off mid-depth, so the two faces have unequal S.
|
||||
assert net.zbar_in == pytest.approx(8.6666667, abs=1e-6)
|
||||
assert net.c_form_in != pytest.approx(net.c_far_in)
|
||||
# and it is NOT the solid idealisation: area is both wythes, insulation out
|
||||
b_in = panel.width_ft * 12.0
|
||||
assert net.area_in2 == pytest.approx(b_in * (T_ARCH + T_STRUCT), abs=1e-6)
|
||||
|
||||
|
||||
def test_a_measured_sandwich_writes_a_report_and_does_not_exit_two(tmp_path):
|
||||
path = tmp_path / 'job.json'
|
||||
out = tmp_path / 'out.html'
|
||||
path.write_text(json.dumps(sandwich_job()), encoding='utf-8')
|
||||
code = cli.main([str(path), '-o', str(out)])
|
||||
assert code in (0, 1) # a verdict, not a refusal (2)
|
||||
assert out.exists()
|
||||
|
||||
|
||||
def test_an_unmeasured_sandwich_still_refuses_before_anything_is_calculated():
|
||||
with pytest.raises(cli.SandwichSection) as caught:
|
||||
cli.analyse_job(unmeasured_sandwich_job())
|
||||
message = str(caught.value)
|
||||
assert sandwich.REFUSAL_CODE in message
|
||||
assert 'overstates the section modulus' in message
|
||||
|
||||
|
||||
def test_the_refusal_is_a_joberror_so_every_existing_caller_still_refuses():
|
||||
"""`tools/sweep.py`, the button and the CLI all catch JobError. A new
|
||||
exception class they have never heard of would escape as a traceback."""
|
||||
assert issubclass(cli.SandwichSection, cli.JobError)
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(unmeasured_sandwich_job())
|
||||
|
||||
|
||||
def test_the_refusal_carries_the_evidence_it_can_still_give(tmp_path):
|
||||
"""An unmeasured layup cannot be bounded, but the refusal still says WHY
|
||||
-- the missing positions -- rather than one bare word."""
|
||||
with pytest.raises(cli.SandwichSection) as caught:
|
||||
cli.analyse_job(unmeasured_sandwich_job())
|
||||
bounds = caught.value.bounds
|
||||
assert bounds.usable is False
|
||||
assert 'z0_in' in bounds.why
|
||||
|
||||
|
||||
def test_the_cli_writes_no_report_and_exits_two_on_an_unmeasured_sandwich(
|
||||
tmp_path):
|
||||
path = tmp_path / 'job.json'
|
||||
out = tmp_path / 'out.html'
|
||||
path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8')
|
||||
code = cli.main([str(path), '-o', str(out)])
|
||||
assert code == 2
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
def test_the_refusal_json_never_says_ok_warn_or_fail(tmp_path, capsys):
|
||||
path = tmp_path / 'job.json'
|
||||
out = tmp_path / 'out.html'
|
||||
path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8')
|
||||
assert cli.main([str(path), '-o', str(out), '--json']) == 2
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload['verdict'] == 'REFUSED'
|
||||
assert payload['verdict'] not in ('OK', 'WARN', 'FAIL')
|
||||
assert payload['complete'] is False
|
||||
assert payload['composite_ratio']['stiffness'] is None
|
||||
assert payload['connector_sufficiency']['verdict'] is None
|
||||
|
||||
|
||||
def test_the_refusal_text_prints_the_evidence_not_just_the_word_no():
|
||||
detection = sandwich.detect(sandwich_source())
|
||||
bounds = sandwich.section_bounds(detection.layup, B)
|
||||
text = sandwich.refusal_text(bounds, detection, 'PNL115', 16.0, 5)
|
||||
assert 'NO STAGE WAS ANALYSED' in text
|
||||
assert 'non-composite' in text and 'fully composite' in text
|
||||
assert 'NOT COMPUTED' in text # the composite ratio
|
||||
assert 'NOT CHECKED' in text # connector sufficiency
|
||||
assert 'NOT BRACKETED' in text
|
||||
# every bound's assumption travels with its numbers
|
||||
assert 'no shear is transferred across the insulation' in text
|
||||
|
||||
|
||||
def test_the_refusal_text_survives_a_layup_it_could_not_measure():
|
||||
detection = sandwich.detect(sandwich_source(positioned=False))
|
||||
bounds = sandwich.section_bounds(detection.layup, B)
|
||||
text = sandwich.refusal_text(bounds, detection, 'PNL115')
|
||||
assert 'could not be bounded' in text
|
||||
assert 'NOT BRACKETED' in text
|
||||
|
||||
|
||||
# ============================================== the solid path does not move
|
||||
|
||||
SOLID_GOLDEN = {
|
||||
# Typed in, not recomputed. A pin that recomputes both sides agrees with
|
||||
# any refactor, including a wrong one -- the rule the takeoff count was
|
||||
# pinned under. These are the ASPIRE four-point pick as it stood before
|
||||
# any of the sandwich work.
|
||||
'verdict': 'WARN',
|
||||
'complete': True,
|
||||
'multiplier': 1.3,
|
||||
'fc_psi': 4000.0,
|
||||
'peak_tension_psi': 244.71194093343502,
|
||||
'peak_at_ft': 15.992,
|
||||
'allowable_psi': 316.2277660168379,
|
||||
'ratio': 0.7738471039902456,
|
||||
'as_built_ratio': 0.7738471039902419,
|
||||
'recommended_ratio': 0.7682552280488836,
|
||||
}
|
||||
|
||||
|
||||
def test_a_solid_panel_is_byte_for_byte_the_number_it_always_was():
|
||||
from test_cli import aspire_job
|
||||
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
||||
summary = cli.summarise(cases)
|
||||
stage = summary['stages'][0]
|
||||
assert summary['verdict'] == SOLID_GOLDEN['verdict']
|
||||
assert summary['complete'] is SOLID_GOLDEN['complete']
|
||||
for key in ('multiplier', 'fc_psi', 'peak_tension_psi', 'peak_at_ft',
|
||||
'allowable_psi', 'ratio'):
|
||||
assert repr(stage[key]) == repr(SOLID_GOLDEN[key]), key
|
||||
rec = summary['recommendation']
|
||||
assert repr(rec['as_built_ratio']) == repr(SOLID_GOLDEN['as_built_ratio'])
|
||||
assert repr(rec['recommended_ratio']) == \
|
||||
repr(SOLID_GOLDEN['recommended_ratio'])
|
||||
|
||||
|
||||
def test_a_solid_job_gains_no_new_summary_keys():
|
||||
"""`basis` is written only when the up-end rule did NOT answer, the same
|
||||
"only when it is not the ordinary case" rule `searched` and `enabled`
|
||||
follow. A job file with no source block must summarise exactly as it did
|
||||
before this existed."""
|
||||
from test_cli import aspire_job
|
||||
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
||||
assert sorted(cli.summarise(cases)) == [
|
||||
'complete', 'not_run', 'recommendation', 'stages', 'verdict']
|
||||
|
||||
|
||||
# ==================================================== which end is up (G2)
|
||||
|
||||
def test_the_up_end_fallback_is_disclosed_on_the_page_and_in_the_summary():
|
||||
"""The refusal `knowledge.up_end_from_origin` makes is the project's
|
||||
flagship declared-coverage refusal, and until now `erected_orientation`
|
||||
could override it with a fallback whose reason nothing read. If the
|
||||
best-argued refusal in the tree can be undone by an `or` in its caller,
|
||||
the idiom is a comment."""
|
||||
from test_cli import aspire_job
|
||||
job = aspire_job()
|
||||
job['source'] = {
|
||||
'up_end_basis': 'global elevation',
|
||||
'up_end_basis_why': 'assembly X is not horizontal, so the rule '
|
||||
'stands down. Which end is up was therefore read '
|
||||
'from GLOBAL ELEVATION instead.'}
|
||||
_panel, _rig, cases = cli.analyse_job(job)
|
||||
summary = cli.summarise(cases)
|
||||
assert summary['basis']['basis'] == 'global elevation'
|
||||
assert 'stands down' in summary['basis']['why']
|
||||
html = report.build_many(cases)
|
||||
# Shortened 2026-09-01 (owner: "把这一段从lift的结果里面删掉,或者缩短
|
||||
# ...太长了压根不会看的") -- the always-visible line states the fact in
|
||||
# a few words; the producer's own reasoning (which still names GLOBAL
|
||||
# ELEVATION) is folded, not deleted.
|
||||
assert 'read from global elevation, not the assembly origin' in html
|
||||
assert 'GLOBAL ELEVATION' in html
|
||||
# and it says which stage it actually decides
|
||||
assert 'erection' in html
|
||||
|
||||
|
||||
def test_the_ruled_basis_is_not_shouted_about():
|
||||
"""A page that shouts about a rule that worked teaches the reader to skip
|
||||
the block on the day it did not."""
|
||||
from test_cli import aspire_job
|
||||
job = aspire_job()
|
||||
job['source'] = {'up_end_basis': report.RULED_UP_END_BASIS,
|
||||
'up_end_basis_why': 'the origin answered'}
|
||||
_panel, _rig, cases = cli.analyse_job(job)
|
||||
assert 'basis' not in cli.summarise(cases)
|
||||
assert 'came from a fallback' not in report.build_many(cases)
|
||||
|
||||
|
||||
def test_the_job_level_source_actually_reaches_the_panel():
|
||||
"""It did not, for the whole life of the exporter: `read_panel` looks for
|
||||
`source` inside `panel` and the exporter writes it at job level, so
|
||||
`panel.source` came out empty on every real job file and everything the
|
||||
Revit side recorded was unreadable from here."""
|
||||
from test_cli import aspire_job
|
||||
job = aspire_job()
|
||||
job['source'] = {'up_end_basis': 'global elevation', 'model': 'sample_job'}
|
||||
panel, _rig, _cases = cli.analyse_job(job)
|
||||
assert panel.source['up_end_basis'] == 'global elevation'
|
||||
|
||||
|
||||
def test_transpose_still_wins_over_the_job_source_for_its_own_key():
|
||||
"""`transpose` writes `prestress_not_credited` about the TURNED copy;
|
||||
a job-level key of the same name must not overwrite it."""
|
||||
from panellift.model import Panel, Prestress, Strand
|
||||
panel = Panel(mark='T', length_ft=20.0, width_ft=8.0, thickness_in=8.0,
|
||||
prestress=Prestress(strands=[Strand(y=1.0, z_in=4.0,
|
||||
initial_pull_lb=1000.0)]),
|
||||
source={'model': 'sample_job'})
|
||||
turned = cli.transpose(panel)
|
||||
assert 'prestress_not_credited' in turned.source
|
||||
assert turned.source['model'] == 'sample_job'
|
||||
|
||||
|
||||
def test_the_solid_parts_keys_are_the_ones_the_exporter_writes():
|
||||
"""THE CONTRACT, pinned from this side.
|
||||
|
||||
`vibe.liftexport._solid_parts` writes exactly these keys on each entry of
|
||||
`source.solid_parts`. Neither package can import the other -- that is the
|
||||
whole design -- so nothing but a pin at each end stops one being renamed
|
||||
under the other. The far half is `test_liftexport.py::
|
||||
test_the_solid_parts_keys_are_the_ones_panellift_reads`.
|
||||
"""
|
||||
part = sandwich_source()['solid_parts'][0]
|
||||
assert sorted(part) == ['counted', 'density_pcf', 'material',
|
||||
'volume_ft3', 'z0_in', 'z1_in']
|
||||
# and every one of them is actually consumed
|
||||
layer = sandwich._layer_from_part(part)
|
||||
assert layer.kind == sandwich.CONCRETE
|
||||
assert layer.z0_in == 8.0 and layer.z1_in == 16.0
|
||||
assert layer.material == 'STRUCT WYTHE'
|
||||
assert layer.density_pcf == 150.28
|
||||
assert layer.volume_ft3 == 79.885
|
||||
|
||||
|
||||
def test_the_layup_block_the_exporter_writes_is_not_needed_to_detect():
|
||||
"""`source.layup` is the same facts arranged for the section; the
|
||||
DETECTION runs off `solid_parts` alone, so a job file from an exporter
|
||||
that could not build a layup still refuses."""
|
||||
source = sandwich_source(positioned=False)
|
||||
source['layup'] = {'measured': False, 'layers': [], 'why': 'x'}
|
||||
assert sandwich.detect(source).sandwich is True
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The insulated-sandwich SECTION path in `section.py` / `capacity.py`.
|
||||
|
||||
Every expected number here is arithmetic done by hand, written out beside it,
|
||||
and -- where a golden figure exists -- pinned to the value Eriksson Wall's own
|
||||
section table prints, NOT to the code's own expression. A pin that recomputes
|
||||
both sides agrees with any refactor, including a wrong one.
|
||||
|
||||
THE GOLDEN PIECE is WPB3.0 Run 1 (docs/eriksson-wall-algorithms.md sec.12 and
|
||||
the extracted `WPB3.0.md`): a horizontal insulated wall panel, layup
|
||||
|
||||
3 in. concrete + 4 in. insulation + 4 in. concrete = 11 in. envelope
|
||||
|
||||
at a clear width of 24 ft = 288 in (no opening at the checked station).
|
||||
Eriksson reports, for this section (p.11 of that packet):
|
||||
|
||||
Area 2016.00 in2 Cy 5.79 in Izz 29955.43 in4 Sb 5177.48 St 5744.88
|
||||
|
||||
which is the FULLY-COMPOSITE transformed section of the two concrete wythes
|
||||
about their common centroid, with the insulation EXCLUDED. That is what
|
||||
`section.NetSection` must reproduce for a sandwich, and it is what closes the
|
||||
"treated as a solid 11 in prism" defect: the solid idealisation would give
|
||||
Izz = 288*11^3/12 = 31944 and S = 288*11^2/6 = 5808, both larger than the real
|
||||
section (unconservative), and its shear b*d would be 288*0.8*11, a third too
|
||||
large -- see `test_shear_bd_drops_by_the_36_percent_the_parity_run_found`.
|
||||
|
||||
The task framed Eriksson's treatment as NON-composite; the golden figures do
|
||||
not bear that out (Area 2016 is BOTH wythes, and Izz 29955 is their composite
|
||||
inertia, not one wythe's), so this implements and pins the composite section
|
||||
Eriksson actually reports. The %composite knock-down Eriksson then applies
|
||||
(95% for stress/strength, 35% for in-place deflection) and the strand
|
||||
development layer are out of scope here; the more conservative non-composite /
|
||||
structural-wythe-alone bounds live in `sandwich.py`.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import capacity, section
|
||||
from panellift.model import Panel, Stage
|
||||
|
||||
|
||||
ENVELOPE = 11.0
|
||||
WIDTH_FT = 24.0
|
||||
B_IN = WIDTH_FT * 12.0 # 288 in
|
||||
|
||||
|
||||
def wpb3_source():
|
||||
"""`source` as the exporter writes it for the WPB3.0 layup.
|
||||
|
||||
3 in wythe at the form face (z 0-3), 4 in insulation (z 3-7), 4 in wythe
|
||||
(z 7-11). The order does not change Area / Izz / the governing S; it only
|
||||
labels which face is Sb and which is St.
|
||||
"""
|
||||
return {
|
||||
'solid_parts': [
|
||||
{'counted': True, 'z0_in': 0.0, 'z1_in': 3.0,
|
||||
'material': 'WYTHE 3IN', 'density_pcf': 150.0, 'volume_ft3': 1.0},
|
||||
{'counted': False, 'z0_in': 3.0, 'z1_in': 7.0,
|
||||
'material': 'INSULATION', 'density_pcf': 2.0, 'volume_ft3': 1.0},
|
||||
{'counted': True, 'z0_in': 7.0, 'z1_in': 11.0,
|
||||
'material': 'WYTHE 4IN', 'density_pcf': 150.0, 'volume_ft3': 1.0},
|
||||
],
|
||||
'envelope_thickness_in': ENVELOPE,
|
||||
}
|
||||
|
||||
|
||||
def wpb3_panel():
|
||||
return Panel(mark='WPB3', length_ft=24.0, width_ft=WIDTH_FT,
|
||||
thickness_in=ENVELOPE, unit_weight_pcf=150.0,
|
||||
fci_psi=3500.0, fc_psi=6000.0, source=wpb3_source())
|
||||
|
||||
|
||||
def _mid_net(panel):
|
||||
return section.NetSection(panel, panel.length_ft / 2.0)
|
||||
|
||||
|
||||
# ------------------------------------------------ the section, vs the golden
|
||||
|
||||
def test_area_is_both_wythes_with_the_insulation_excluded():
|
||||
# A = b * (t1 + t2) = 288 * (3 + 4) = 2016.00 in2, NOT 288*11 = 3168.
|
||||
net = _mid_net(wpb3_panel())
|
||||
assert net.is_sandwich is True
|
||||
assert net.area_in2 == pytest.approx(2016.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_centroid_is_the_composite_neutral_axis():
|
||||
# zbar = (A1*z1 + A2*z2)/A, wythes at z=1.5 and z=9:
|
||||
# = (3*1.5 + 4*9)/7 = (4.5 + 36)/7 = 5.785714 in -> Eriksson Cy 5.79
|
||||
net = _mid_net(wpb3_panel())
|
||||
assert net.zbar_in == pytest.approx(5.7857143, abs=1e-6)
|
||||
|
||||
|
||||
def test_through_thickness_inertia_matches_the_reported_izz():
|
||||
# Iz = b*[ t1^3/12 + t1*(z1 - zbar)^2 + t2^3/12 + t2*(z2 - zbar)^2 ]
|
||||
# = 288*[ 3^3/12 + 3*(1.5-5.785714)^2 + 4^3/12 + 4*(9-5.785714)^2 ]
|
||||
# = 288*[ 2.25 + 55.1020 + 5.3333 + 41.3265 ] = 288*104.0119 = 29955.4
|
||||
net = _mid_net(wpb3_panel())
|
||||
assert net.iz_in4 == pytest.approx(29955.43, abs=0.5) # reported 29955.43
|
||||
|
||||
|
||||
def test_the_two_faces_have_the_unequal_moduli_eriksson_prints():
|
||||
# c_form = 5.785714 (to z=0), c_far = 11 - 5.785714 = 5.214286 (to z=11)
|
||||
# Sb = 29955.43 / 5.785714 = 5177.5 (Eriksson 5177.48)
|
||||
# St = 29955.43 / 5.214286 = 5744.9 (Eriksson 5744.88)
|
||||
net = _mid_net(wpb3_panel())
|
||||
assert net.c_form_in == pytest.approx(5.7857143, abs=1e-6)
|
||||
assert net.c_far_in == pytest.approx(5.2142857, abs=1e-6)
|
||||
sb = net.iz_in4 / net.c_form_in
|
||||
st = net.iz_in4 / net.c_far_in
|
||||
assert sb == pytest.approx(5177.48, abs=1.0)
|
||||
assert st == pytest.approx(5744.88, abs=1.0)
|
||||
# sz_in3 is the GOVERNING (smaller) modulus, the one that decides tension.
|
||||
assert net.sz_in3 == pytest.approx(min(sb, st), abs=1e-6)
|
||||
|
||||
|
||||
def test_the_solid_idealisation_would_have_overstated_this_section():
|
||||
"""The number the refusal was built on: the solid 11 in prism is bigger
|
||||
than the real composite section, both in I and S -- unconservative."""
|
||||
net = _mid_net(wpb3_panel())
|
||||
solid_iz = B_IN * ENVELOPE ** 3 / 12.0 # 31944.0
|
||||
solid_sz = B_IN * ENVELOPE ** 2 / 6.0 # 5808.0
|
||||
assert solid_iz > net.iz_in4 # 31944 > 29955
|
||||
assert solid_sz > net.sz_in3 # 5808 > 5177
|
||||
|
||||
|
||||
# --------------------------------------------------------------- shear b*d
|
||||
|
||||
def test_shear_bd_drops_by_the_36_percent_the_parity_run_found():
|
||||
"""docs sec.3.1: for a sandwich d_v = 0.8*th uses the CONCRETE depth (the
|
||||
summed wythes), not the envelope. Here that is 0.8*7 vs 0.8*11, so the
|
||||
shear area is 7/11 of the solid one -- a 36.4% drop, which is exactly the
|
||||
WPB3.0 shear residual the harness traced to b*d not excluding insulation.
|
||||
"""
|
||||
net = _mid_net(wpb3_panel())
|
||||
assert net.shear_thickness_in == pytest.approx(7.0) # concrete, not 11
|
||||
stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0)
|
||||
pt = capacity.detailed_shear_at(wpb3_panel(), stage,
|
||||
x=12.0, moment_lbft=5000.0, shear_lb=8000.0)
|
||||
solid_bd = net.width_in * capacity.pci.DV_OVER_H_SHEAR * ENVELOPE
|
||||
assert pt.bd_in2 == pytest.approx(solid_bd * 7.0 / 11.0, rel=1e-9)
|
||||
drop = 1.0 - pt.bd_in2 / solid_bd
|
||||
assert drop == pytest.approx(4.0 / 11.0, abs=1e-6) # 0.3636...
|
||||
|
||||
|
||||
# --------------------------------------- contiguous two-layer -> merged solid
|
||||
|
||||
def test_a_contiguous_two_concrete_body_is_the_merged_solid():
|
||||
"""An architectural face cast monolithically on a structural wythe (no
|
||||
insulation gap) is not a hole in the middle -- its composite section is
|
||||
simply the solid over the whole concrete span, and that is what comes
|
||||
out. 4 in (z0-4) + 12 in (z4-16) contiguous -> a solid 16 in section."""
|
||||
src = {'solid_parts': [
|
||||
{'counted': True, 'z0_in': 4.0, 'z1_in': 16.0, 'material': 'STRUCT',
|
||||
'density_pcf': 150.0, 'volume_ft3': 1.0},
|
||||
{'counted': True, 'z0_in': 0.0, 'z1_in': 4.0, 'material': 'ARCH',
|
||||
'density_pcf': 150.0, 'volume_ft3': 1.0}],
|
||||
'envelope_thickness_in': 16.0}
|
||||
panel = Panel(mark='C', length_ft=20.0, width_ft=5.0, thickness_in=12.0,
|
||||
source=src)
|
||||
net = section.NetSection(panel, 10.0)
|
||||
b = 5.0 * 12.0
|
||||
assert net.area_in2 == pytest.approx(b * 16.0) # all 16 in concrete
|
||||
assert net.zbar_in == pytest.approx(8.0) # mid-depth: solid
|
||||
assert net.iz_in4 == pytest.approx(b * 16.0 ** 3 / 12.0)
|
||||
assert net.shear_thickness_in == pytest.approx(16.0)
|
||||
|
||||
|
||||
# --------------------------------------------- the solid path does not move
|
||||
|
||||
def test_a_solid_panel_is_untouched():
|
||||
"""No source -> not a sandwich -> the familiar b*t, b*t^2/6, b*t^3/12,
|
||||
d_v = 0.8*t, centroid t/2. Byte-for-byte what it always was."""
|
||||
panel = Panel(mark='S', length_ft=20.0, width_ft=5.0, thickness_in=8.0)
|
||||
net = section.NetSection(panel, 10.0)
|
||||
b = 5.0 * 12.0
|
||||
assert net.is_sandwich is False
|
||||
assert net.area_in2 == pytest.approx(b * 8.0)
|
||||
assert net.zbar_in == pytest.approx(4.0)
|
||||
assert net.c_form_in == pytest.approx(4.0)
|
||||
assert net.c_far_in == pytest.approx(4.0)
|
||||
assert net.iz_in4 == pytest.approx(b * 8.0 ** 3 / 12.0)
|
||||
assert net.sz_in3 == pytest.approx(b * 8.0 ** 2 / 6.0)
|
||||
assert net.shear_thickness_in == pytest.approx(8.0)
|
||||
|
||||
|
||||
def test_the_free_functions_follow_the_same_section():
|
||||
"""`section.area_in2` / `section_modulus_in3` / `inertia_in4` are what
|
||||
`statics` (EI) and `layout` read; they must be the sandwich section too."""
|
||||
panel = wpb3_panel()
|
||||
x = 12.0
|
||||
net = section.NetSection(panel, x)
|
||||
assert section.area_in2(panel, x) == pytest.approx(net.area_in2, abs=1e-6)
|
||||
assert section.inertia_in4(panel, x) == pytest.approx(net.iz_in4, abs=1e-6)
|
||||
assert section.section_modulus_in3(panel, x) == pytest.approx(net.sz_in3,
|
||||
abs=1e-6)
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""A piece does not always hang the way it was cast.
|
||||
|
||||
A horizontal panel is stripped flat, spanning its long direction like
|
||||
everything else, and then stood up on its long EDGE. Gravity then runs across
|
||||
what used to be the width, and that is the direction it spans.
|
||||
|
||||
The solver is not taught about orientation. The piece is turned instead, so
|
||||
the mechanics stay one implementation checked against published examples.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT, make_panel
|
||||
from panellift import cli, statics
|
||||
from panellift.model import Blockout, Lifter
|
||||
|
||||
|
||||
def horizontal_job(span):
|
||||
"""Anchors along one long edge, face lifters inboard of it."""
|
||||
return {
|
||||
'schema': cli.SCHEMA,
|
||||
'panel': {
|
||||
'mark': 'H1', 'length_ft': 30.0, 'width_ft': 8.0,
|
||||
'thickness_in': 8.0, 'weight_lb': 24000.0, 'fc_psi': 5000.0,
|
||||
'blockouts': [{'name': 'O', 'x0': 10.0, 'y0': 2.0,
|
||||
'length': 6.0, 'width': 3.0}],
|
||||
'lifters': [
|
||||
{'mark': 'A', 'family': 'ANCHOR', 'x': 6.0, 'y': 8.0,
|
||||
'on_edge': True, 'pinned_y': True, 'capacity_lb': 7690.0,
|
||||
'shear_capacity_lb': 4010.0},
|
||||
{'mark': 'B', 'family': 'ANCHOR', 'x': 24.0, 'y': 8.0,
|
||||
'on_edge': True, 'pinned_y': True, 'capacity_lb': 7690.0,
|
||||
'shear_capacity_lb': 4010.0},
|
||||
{'mark': 'C', 'family': 'FACE', 'x': 6.0, 'y': 1.5,
|
||||
'capacity_lb': 8000.0},
|
||||
{'mark': 'D', 'family': 'FACE', 'x': 24.0, 'y': 1.5,
|
||||
'capacity_lb': 8000.0},
|
||||
],
|
||||
},
|
||||
'stages': [{'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0,
|
||||
'span': span}],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------- the transposition
|
||||
|
||||
def test_turning_the_piece_swaps_its_two_in_plane_dimensions():
|
||||
panel = make_panel()
|
||||
turned = cli.transpose(panel)
|
||||
assert turned.length_ft == pytest.approx(WIDTH_FT)
|
||||
assert turned.width_ft == pytest.approx(LENGTH_FT)
|
||||
assert turned.thickness_in == pytest.approx(THICKNESS_IN)
|
||||
|
||||
|
||||
def test_turning_the_piece_takes_the_blockouts_with_it():
|
||||
panel = dataclasses.replace(make_panel(), blockouts=[
|
||||
Blockout(name='O', x0=4.0, y0=1.0, length=6.0, width=2.0)])
|
||||
hole = cli.transpose(panel).blockouts[0]
|
||||
assert (hole.x0, hole.y0, hole.length, hole.width) == (1.0, 4.0, 2.0, 6.0)
|
||||
|
||||
|
||||
def test_turning_the_piece_swaps_which_way_an_insert_is_pinned():
|
||||
"""An anchor pinned to a long edge is pinned across the piece as cast and
|
||||
ALONG it once the piece has been stood up on that edge. Carrying the flag
|
||||
through unchanged would free it to slide off its own edge."""
|
||||
panel = dataclasses.replace(make_panel(), lifters=[
|
||||
Lifter(mark='A', family='F', x=2.0, y=9.0, pinned_y=True)])
|
||||
turned = cli.transpose(panel).lifters[0]
|
||||
assert (turned.x, turned.y) == (9.0, 2.0)
|
||||
assert turned.pinned and not turned.pinned_y
|
||||
|
||||
|
||||
def test_turning_twice_is_the_piece_you_started_with():
|
||||
panel = make_panel()
|
||||
there_and_back = cli.transpose(cli.transpose(panel))
|
||||
assert there_and_back.length_ft == pytest.approx(panel.length_ft)
|
||||
assert [(l.x, l.y) for l in there_and_back.lifters] == \
|
||||
[(l.x, l.y) for l in panel.lifters]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ through a job
|
||||
|
||||
def test_a_stage_spanning_the_width_really_spans_the_width():
|
||||
_p, _r, cases = cli.analyse_job(horizontal_job('width'))
|
||||
assert cases[0][0].panel.length_ft == pytest.approx(8.0)
|
||||
assert cases[0][0].stage.span == 'width'
|
||||
|
||||
|
||||
def test_the_same_inserts_group_differently_when_the_piece_turns():
|
||||
"""The anchors along the top edge are ONE support when the piece is
|
||||
standing and TWO when it is flat. That is the whole reason to turn it."""
|
||||
_p, _r, flat = cli.analyse_job(horizontal_job('length'))
|
||||
_p, _r, stood = cli.analyse_job(horizontal_job('width'))
|
||||
assert len(flat[0][0].rig.lines) == 2 # by x: 6 ft and 24 ft
|
||||
assert len(stood[0][0].rig.lines) == 2 # by y: 1.5 ft and 8 ft
|
||||
stood_line = max(stood[0][0].rig.lines, key=lambda l: l.x)
|
||||
assert stood_line.count == 2 # both anchors, one support
|
||||
|
||||
|
||||
def test_a_stage_defaults_to_spanning_its_length():
|
||||
_p, _r, cases = cli.analyse_job(horizontal_job(None))
|
||||
assert cases[0][0].stage.span == 'length'
|
||||
assert cases[0][0].panel.length_ft == pytest.approx(30.0)
|
||||
|
||||
|
||||
def test_an_unknown_span_is_refused():
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(horizontal_job('diagonally'))
|
||||
|
||||
|
||||
def test_the_position_search_stands_aside_for_a_turned_stage():
|
||||
"""It optimises one set of coordinates. 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 says which one it left out --
|
||||
silently optimising against two of three stages would read as an answer
|
||||
for all three.
|
||||
"""
|
||||
from panellift import layout
|
||||
job = horizontal_job('width')
|
||||
job['stages'].insert(0, {'name': 'stripping', 'multiplier': 1.3,
|
||||
'fc_psi': 4000.0})
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
rec = layout.recommend(cases)
|
||||
assert rec.moves # stripping still answered
|
||||
assert [s.name for s in rec.stages] == ['stripping']
|
||||
assert 'erection' in rec.note
|
||||
|
||||
|
||||
def test_the_search_says_so_when_every_stage_is_turned():
|
||||
from panellift import layout
|
||||
_p, _r, cases = cli.analyse_job(horizontal_job('width'))
|
||||
rec = layout.recommend(cases)
|
||||
assert not rec.moves and 'erection' in rec.note
|
||||
@@ -0,0 +1,259 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""A stage that was not analysed has to say so, in its own box.
|
||||
|
||||
The failure being guarded against is not a wrong number. It is silence: a
|
||||
stage that nobody ran, missing from the report, read by an engineer as a
|
||||
stage that passed. "Could not be checked" must never be mistaken for
|
||||
"checked and fine", and a stage that simply is not on the page is exactly
|
||||
that mistake with nothing on the page to correct it.
|
||||
|
||||
So these tests are mostly about what the OUTPUT says, not about what the
|
||||
calculation computes -- there is no calculation for a stage that was
|
||||
switched off, and that is the point.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import cli, layout, report
|
||||
from test_cli import aspire_job
|
||||
|
||||
|
||||
def two_stage_job(enabled=False, reason=None, **extra):
|
||||
"""The ASPIRE panel with a second stage that is switched off."""
|
||||
job = aspire_job()
|
||||
second = {'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0}
|
||||
if enabled is not None:
|
||||
second['enabled'] = enabled
|
||||
if reason is not None:
|
||||
second['disabled_reason'] = reason
|
||||
second.update(extra)
|
||||
job['stages'] = [job['stages'][0], second]
|
||||
return job
|
||||
|
||||
|
||||
# ------------------------------------------------- the flag itself is additive
|
||||
|
||||
def test_a_job_with_no_enabled_key_runs_every_stage():
|
||||
"""The 184 job files on disk have no `enabled` anywhere. Absent means
|
||||
enabled, or adding the flag would have changed every one of them."""
|
||||
_p, _r, cases = cli.analyse_job(two_stage_job(enabled=None))
|
||||
assert len(cases) == 2
|
||||
assert list(cases.skipped) == []
|
||||
|
||||
|
||||
def test_enabled_true_is_the_same_as_no_key_at_all():
|
||||
a = cli.analyse_job(two_stage_job(enabled=None))[2]
|
||||
b = cli.analyse_job(two_stage_job(enabled=True))[2]
|
||||
assert len(a) == len(b) == 2
|
||||
assert not b.skipped
|
||||
|
||||
|
||||
def test_a_disabled_stage_is_not_analysed():
|
||||
_p, _r, cases = cli.analyse_job(two_stage_job())
|
||||
assert [a.stage.name for a, _r, _f in cases] == ['stripping']
|
||||
|
||||
|
||||
def test_a_disabled_stage_is_still_carried():
|
||||
"""Not analysed is not the same as not there. If it were, the flag would
|
||||
be an elaborate way of deleting the stage."""
|
||||
_p, _r, cases = cli.analyse_job(two_stage_job(reason='crane cannot reach'))
|
||||
assert [s.name for s in cases.skipped] == ['erection']
|
||||
assert cases.skipped[0].reason == 'crane cannot reach'
|
||||
assert cases.skipped[0].index == 1
|
||||
|
||||
|
||||
def test_the_stage_note_serves_as_the_reason_when_none_is_given():
|
||||
_p, _r, cases = cli.analyse_job(
|
||||
two_stage_job(note='checked by hand off the rigging drawing'))
|
||||
assert cases.skipped[0].reason == 'checked by hand off the rigging drawing'
|
||||
|
||||
|
||||
def test_cases_is_still_an_ordinary_list_of_triples():
|
||||
"""Every existing caller unpacks (panel, rigging, cases) and then
|
||||
(analysis, rigging, findings) out of cases. That has to keep working."""
|
||||
_p, _r, cases = cli.analyse_job(two_stage_job())
|
||||
assert isinstance(cases, list)
|
||||
analysis, rigging, findings = cases[0]
|
||||
assert analysis.stage.name == 'stripping'
|
||||
assert rigging is not None and findings is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------- refusals worth having
|
||||
|
||||
def test_a_disabled_stage_is_not_validated():
|
||||
"""It has no multiplier and no f'c, which would be fatal in a stage that
|
||||
ran. Refusing the job over fields nobody reads would push authors back to
|
||||
deleting the stage, which is the thing that leaves no record."""
|
||||
job = aspire_job()
|
||||
job['stages'] = [job['stages'][0],
|
||||
{'name': 'erection', 'enabled': False}]
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
assert [s.name for s in cases.skipped] == ['erection']
|
||||
|
||||
|
||||
def test_a_disabled_stage_still_needs_a_name():
|
||||
"""Something has to appear in the box."""
|
||||
job = aspire_job()
|
||||
job['stages'] = [job['stages'][0], {'enabled': False}]
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(job)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', ['false', 'no', 0, 1, []])
|
||||
def test_enabled_refuses_anything_that_is_not_a_boolean(value):
|
||||
"""`"enabled": "false"` is truthy in Python. Coercing it would run the
|
||||
stage the author meant to switch off and never tell them."""
|
||||
with pytest.raises(cli.JobError):
|
||||
cli.analyse_job(two_stage_job(enabled=value))
|
||||
|
||||
|
||||
def test_a_job_with_every_stage_disabled_is_refused():
|
||||
job = aspire_job()
|
||||
job['stages'] = [dict(job['stages'][0], enabled=False)]
|
||||
with pytest.raises(cli.JobError) as exc:
|
||||
cli.analyse_job(job)
|
||||
assert 'disabled' in str(exc.value)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the summary json
|
||||
|
||||
def test_the_summary_carries_the_stage_with_no_numbers():
|
||||
_p, _r, cases = cli.analyse_job(two_stage_job(reason='crane cannot reach'))
|
||||
summary = cli.summarise(cases)
|
||||
rows = dict((s['name'], s) for s in summary['stages'])
|
||||
assert rows['erection']['analysed'] is False
|
||||
assert rows['erection']['verdict'] == 'NOT RUN'
|
||||
assert rows['erection']['reason'] == 'crane cannot reach'
|
||||
# Never a zero. A zero peak against a 350 psi allowable reads as the best
|
||||
# result on the piece.
|
||||
for key in ('peak_tension_psi', 'allowable_psi', 'ratio', 'multiplier',
|
||||
'fc_psi'):
|
||||
assert rows['erection'][key] is None
|
||||
|
||||
|
||||
def test_the_skipped_stage_keeps_its_place_in_the_list():
|
||||
job = aspire_job()
|
||||
job['stages'] = [{'name': 'erection', 'enabled': False},
|
||||
job['stages'][0]]
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
assert [s['name'] for s in cli.summarise(cases)['stages']] == \
|
||||
['erection', 'stripping']
|
||||
|
||||
|
||||
def test_the_summary_says_it_is_incomplete():
|
||||
_p, _r, cases = cli.analyse_job(two_stage_job())
|
||||
summary = cli.summarise(cases)
|
||||
assert summary['complete'] is False
|
||||
assert [s['name'] for s in summary['not_run']] == ['erection']
|
||||
|
||||
|
||||
def test_a_job_with_nothing_skipped_is_complete():
|
||||
_p, _r, cases = cli.analyse_job(aspire_job())
|
||||
summary = cli.summarise(cases)
|
||||
assert summary['complete'] is True
|
||||
assert summary['not_run'] == []
|
||||
|
||||
|
||||
def test_a_skipped_stage_does_not_change_the_verdict():
|
||||
"""It cannot: nothing was measured. What it changes is the CLAIM, and
|
||||
that is `complete`, not `verdict`."""
|
||||
ran = cli.summarise(cli.analyse_job(aspire_job())[2])
|
||||
partial = cli.summarise(cli.analyse_job(two_stage_job())[2])
|
||||
assert partial['verdict'] == ran['verdict']
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the report
|
||||
|
||||
def _html(job):
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
return report.build_many(cases, recommendation=layout.recommend(cases))
|
||||
|
||||
|
||||
def test_the_report_prints_the_sentence_not_a_number():
|
||||
html = _html(two_stage_job(reason='the crane cannot reach the top row'))
|
||||
assert 'Erection analysis not performed.' in html
|
||||
assert 'the crane cannot reach the top row' in html
|
||||
assert 'not analysed' in html
|
||||
|
||||
|
||||
def test_the_skipped_box_carries_no_ratio():
|
||||
"""The other boxes print '96% of allowable tension'. This one must not
|
||||
print a percentage at all -- not even '-', which the eye scores as the
|
||||
best number on the page."""
|
||||
html = _html(two_stage_job())
|
||||
box = html.split('<div class="stagebox NOTRUN">')[1].split('</div>')[0] + \
|
||||
html.split('<div class="stagebox NOTRUN">')[1].split('</div>')[1]
|
||||
assert 'of allowable tension' not in box
|
||||
assert '%' not in box
|
||||
|
||||
|
||||
def test_the_skipped_box_sits_in_the_open_summary():
|
||||
"""Beside the stages that ran, above the first collapsed disclosure --
|
||||
Eriksson's arrangement, and the reason the summary was left open."""
|
||||
html = _html(two_stage_job())
|
||||
assert html.index('stagebox NOTRUN') < html.index('<details class="stage"')
|
||||
|
||||
|
||||
def test_the_skipped_box_keeps_its_place_among_the_others():
|
||||
job = aspire_job()
|
||||
job['stages'] = [{'name': 'erection', 'enabled': False},
|
||||
job['stages'][0]]
|
||||
html = _html(job)
|
||||
order = [m.group(1) for m in
|
||||
re.finditer(r'<div class="stagebox (\w+)"', html)]
|
||||
assert order[0] == 'NOTRUN' and len(order) == 2
|
||||
|
||||
|
||||
def test_the_overall_banner_says_what_it_covers():
|
||||
html = _html(two_stage_job())
|
||||
banner = html.split('<div class="verdict">')[1].split('</div>')[0]
|
||||
assert 'not analysed' in banner
|
||||
assert 'partial' in banner
|
||||
|
||||
|
||||
def test_the_banner_does_not_claim_the_piece_when_a_stage_was_skipped():
|
||||
"""'All checks satisfied' is a statement about the panel. It is false the
|
||||
moment a handling condition was not looked at."""
|
||||
html = _html(two_stage_job())
|
||||
banner = html.split('<div class="verdict">')[1].split('</div>')[0]
|
||||
assert 'All checks satisfied<' not in banner
|
||||
assert 'analysed' in banner
|
||||
|
||||
|
||||
def test_the_banner_is_untouched_when_every_stage_ran():
|
||||
html = _html(aspire_job())
|
||||
banner = html.split('<div class="verdict">')[1].split('</div>')[0]
|
||||
assert 'partial' not in banner
|
||||
assert 'not analysed' not in banner
|
||||
|
||||
|
||||
def test_the_skipped_stage_gets_its_own_disclosure_and_it_is_closed():
|
||||
html = _html(two_stage_job())
|
||||
assert '<details class="stage NOTRUN">' in html
|
||||
assert '<details class="stage NOTRUN" open' not in html
|
||||
|
||||
|
||||
def test_the_detail_says_the_verdict_does_not_cover_it():
|
||||
html = _html(two_stage_job())
|
||||
assert 'does not cover this one' in html
|
||||
|
||||
|
||||
def test_a_report_with_nothing_skipped_is_byte_for_byte_unchanged():
|
||||
"""The flag is additive at the output end too: a job file that does not
|
||||
use it must render exactly what it rendered before, or 184 reports on
|
||||
disk stop matching what the tool produces today."""
|
||||
job = aspire_job()
|
||||
_p, _r, cases = cli.analyse_job(job)
|
||||
rec = layout.recommend(cases)
|
||||
assert report.build_many(cases, recommendation=rec) == \
|
||||
report.build_many(list(cases), recommendation=rec, skipped=[])
|
||||
|
||||
|
||||
def test_write_many_takes_the_skipped_stages_off_the_cases(tmp_path):
|
||||
"""A caller that just passes `cases` along gets it right without knowing
|
||||
the attribute exists."""
|
||||
_p, _r, cases = cli.analyse_job(two_stage_job())
|
||||
out = str(tmp_path / 'r.html')
|
||||
report.write_many(out, cases, recommendation=layout.recommend(cases))
|
||||
assert 'analysis not performed' in open(out, encoding='utf-8').read()
|
||||
@@ -0,0 +1,431 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Equilibrium identities, and the two load paths against each other.
|
||||
|
||||
None of these need a textbook: they are things that must be true of any
|
||||
correct answer. They are the tests that catch a sign error in a case nobody
|
||||
wrote a worked example for.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from conftest import LENGTH_FT, aspire_pockets, make_panel
|
||||
|
||||
from panellift import pci, statics
|
||||
from panellift.model import Blockout, Lifter, Panel, Rigging, Stage
|
||||
|
||||
|
||||
def _lifters(xs, width=10.0):
|
||||
out = []
|
||||
for i, x in enumerate(xs):
|
||||
for j, y in enumerate((0.25 * width, 0.75 * width)):
|
||||
out.append(Lifter(mark='L{0}{1}'.format(i, j), family='T',
|
||||
x=x, y=y, role='strip', capacity_lb=8000.0))
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------ equilibrium
|
||||
|
||||
@pytest.mark.parametrize('mode', ['rolling', 'static'])
|
||||
def test_reactions_sum_to_the_factored_weight(solid_panel, stripping,
|
||||
rolling, static_lift, mode):
|
||||
rigging = rolling if mode == 'rolling' else static_lift
|
||||
result = statics.analyse(solid_panel, stripping, rigging,
|
||||
reconcile_weight=False)
|
||||
total = sum(line.reaction_lb for line in result.rig.lines)
|
||||
assert abs(total - result.profile.total_lb) / result.profile.total_lb < 1e-6
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', ['rolling', 'static'])
|
||||
def test_moment_closes_at_both_ends(solid_panel, stripping, rolling,
|
||||
static_lift, mode):
|
||||
rigging = rolling if mode == 'rolling' else static_lift
|
||||
result = statics.analyse(solid_panel, stripping, rigging,
|
||||
reconcile_weight=False)
|
||||
_, peak = result.diagram.max_positive
|
||||
assert abs(result.diagram.moment_at(0.0)) < peak * 1e-3
|
||||
assert abs(result.diagram.moment_at(solid_panel.length_ft)) < peak * 1e-3
|
||||
|
||||
|
||||
# ------------------------------------------------------- the closure invariant
|
||||
#
|
||||
# `statics.diagram`'s docstring makes a promise about itself: deriving V and M
|
||||
# from equilibrium rather than from the element forces means "M(L) coming back
|
||||
# to zero is then a real check on the reactions rather than a tautology". It
|
||||
# was failing that check. `solve_static` sampled the load once per finite
|
||||
# element while the diagram integrated the 2,000-station profile, and
|
||||
# `_nodes` put 1e-4 ft sliver elements at every blockout edge, so the reactions
|
||||
# missed the load by up to 4.05% and M at the free right end came back at up to
|
||||
# 70% of peak M. 280 of 549 stage-cases on the 184-file reference sweep closed
|
||||
# worse than 0.1%; the `rolling` path, which is pure statics, always closed
|
||||
# exactly. Measured 2026-09-01, fixed the same day.
|
||||
#
|
||||
# The tolerance below is deliberately tight. This is an identity, not a
|
||||
# convergence criterion: the reactions either sum to the load the diagram
|
||||
# integrates or they do not. 1e-6 of peak M is roughly a hundred times the
|
||||
# residual the sweep's worst case now shows (5.8e-8) and about four orders
|
||||
# tighter than anything the old code could have passed.
|
||||
CLOSURE_TOL = 1e-6
|
||||
|
||||
|
||||
def _closure(result):
|
||||
"""(sum(R) - integral(w))/integral(w), and |M(L)|/peak|M|."""
|
||||
total = result.profile.total_lb
|
||||
reactions = sum(line.reaction_lb for line in result.rig.lines)
|
||||
peak = max(abs(m) for m in result.diagram.moment_lbft)
|
||||
return ((reactions - total) / total if total else 0.0,
|
||||
abs(result.diagram.moment_lbft[-1]) / peak if peak else 0.0)
|
||||
|
||||
|
||||
def _closure_cases():
|
||||
"""One case per (panel shape, stage, load path) worth distinguishing.
|
||||
|
||||
Named rather than generated so a failure says which shape broke: it is the
|
||||
blockout cases that used to fail, and a solid panel closes even under the
|
||||
old midpoint sampling because w is constant and the one sample is right.
|
||||
"""
|
||||
cases = []
|
||||
for panel_name, blockouts in (('solid', None),
|
||||
('pocketed', 'aspire'),
|
||||
('one big opening', 'big'),
|
||||
('opening off a support', 'at support')):
|
||||
for stage in (Stage('stripping', 1.4, 4000.0),
|
||||
Stage('transportation', 1.5, 5000.0)):
|
||||
for mode in ('rolling', 'static'):
|
||||
cases.append(pytest.param(
|
||||
blockouts, stage, mode, None,
|
||||
id='{0}-{1}-{2}'.format(panel_name, stage.name, mode)))
|
||||
cases.append(pytest.param(
|
||||
blockouts, Stage('transportation', 1.5, 5000.0), 'static',
|
||||
[4.0, 27.0],
|
||||
id='{0}-on dunnage'.format(panel_name)))
|
||||
return cases
|
||||
|
||||
|
||||
def _closure_panel(kind):
|
||||
if kind is None:
|
||||
return make_panel()
|
||||
if kind == 'aspire':
|
||||
return make_panel(blockouts=aspire_pockets())
|
||||
if kind == 'big':
|
||||
# Most of the width gone over an eighth of the span, so w(x) drops by
|
||||
# 60% at two stations that no uniform mesh would land on.
|
||||
return make_panel(blockouts=[Blockout(name='O1', x0=5.3333, y0=0.0,
|
||||
length=4.3542, width=6.0)])
|
||||
# An edge landing on top of a support line is the case where the mesh has
|
||||
# to choose between refining and putting the reaction in the right place.
|
||||
left, _ = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
return make_panel(blockouts=[Blockout(name='O1', x0=left, y0=0.0,
|
||||
length=3.0, width=6.0)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('blockouts,stage,mode,supports', _closure_cases())
|
||||
def test_every_stage_case_closes_the_moment_at_the_free_end(blockouts, stage,
|
||||
mode, supports):
|
||||
panel = _closure_panel(blockouts)
|
||||
if supports is not None:
|
||||
stage = Stage(stage.name, stage.multiplier, stage.fc_psi,
|
||||
supports=supports)
|
||||
rigging = Rigging(mode=mode, sling_angle_deg=90.0)
|
||||
result = statics.analyse(panel, stage, rigging, reconcile_weight=False)
|
||||
|
||||
reaction_error, end_moment = _closure(result)
|
||||
assert abs(reaction_error) < CLOSURE_TOL, (
|
||||
'sum(R) misses the load the diagram integrates by {0:.4%}'
|
||||
.format(reaction_error))
|
||||
assert end_moment < CLOSURE_TOL, (
|
||||
'M at the free end is {0:.4%} of peak M; it should be zero'
|
||||
.format(end_moment))
|
||||
|
||||
|
||||
def _wp313_transportation():
|
||||
"""The worst-closing case on the reference sweep, rebuilt from its job file.
|
||||
|
||||
WP313 on dunnage: a 22.5 ft piece with an opening that takes 61% of the
|
||||
width out over 3.35 ft, starting 1.33 ft in -- so the load drops by nearly
|
||||
two thirds a foot and a half OUTSIDE the left support and comes back three
|
||||
feet inside it. Both edges fall between uniform mesh stations, and the
|
||||
smaller opening puts a third and fourth edge in mid-span.
|
||||
|
||||
That shape is what turned a one-sample-per-element load into a 4.05%
|
||||
reaction error and left M(22.5 ft) at 19,914 lb-ft against a peak of
|
||||
28,580 -- 69.7% of peak, on the stage that carries 106 of the 147 capacity
|
||||
exceedances the free-end stations produce.
|
||||
"""
|
||||
panel = Panel(
|
||||
mark='WP313', length_ft=22.5, width_ft=11.9167, thickness_in=8.0,
|
||||
unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0,
|
||||
finish='smooth', product_type='flat', weight_lb=25711.0,
|
||||
lifters=[Lifter(mark='L1', family='T', x=5.1863, y=9.6667,
|
||||
role='strip', capacity_lb=8000.0),
|
||||
Lifter(mark='L2', family='T', x=17.3, y=2.4167,
|
||||
role='strip', capacity_lb=8000.0)],
|
||||
blockouts=[Blockout(name='OPENING 1', x0=1.3333, y0=0.0,
|
||||
length=3.3542, width=7.2708),
|
||||
Blockout(name='OPENING 2', x0=8.75, y0=10.3333,
|
||||
length=1.0, width=0.5)])
|
||||
stage = Stage('transportation', 1.5, 6000.0, supports=[4.725, 17.775])
|
||||
return panel, stage
|
||||
|
||||
|
||||
def test_the_worst_case_on_the_sweep_closes():
|
||||
"""Regression on the shape that measured 4.05%, with the weight scaling on.
|
||||
|
||||
`reconcile_weight` is left ON here, unlike the parametrised cases above,
|
||||
because that is how the sweep runs it: the profile is rescaled to the
|
||||
model's own cast weight and the FE loads have to follow the SAME rescaled
|
||||
profile. Two representations of the load is the defect; a scaling applied
|
||||
to one of them and not the other would be the same defect again.
|
||||
"""
|
||||
panel, stage = _wp313_transportation()
|
||||
result = statics.analyse(panel, stage, Rigging(mode='rolling',
|
||||
sling_angle_deg=60.0),
|
||||
reconcile_weight=True)
|
||||
reaction_error, end_moment = _closure(result)
|
||||
assert abs(reaction_error) < CLOSURE_TOL, (
|
||||
'{0:.4%}; this case measured 4.0477% before 2026-09-01'
|
||||
.format(reaction_error))
|
||||
assert end_moment < CLOSURE_TOL, (
|
||||
'{0:.4%} of peak M; this case measured 69.67% before 2026-09-01'
|
||||
.format(end_moment))
|
||||
|
||||
|
||||
def test_the_element_loads_integrate_the_profile_the_diagram_integrates():
|
||||
"""The invariant one layer down, so a failure says WHICH half moved.
|
||||
|
||||
The closure tests above can only report that the reactions and the load
|
||||
disagree. This one asks the element loads directly: summed over the mesh
|
||||
they must reproduce the profile's own total and first moment, because
|
||||
those two numbers are the whole of what `diagram` needs to bring M(L) back
|
||||
to zero.
|
||||
"""
|
||||
panel, stage = _wp313_transportation()
|
||||
profile = statics.load_profile(panel, stage.multiplier)
|
||||
rig = statics._support_rig(stage.supports)
|
||||
nodes = statics._nodes(panel, rig)
|
||||
|
||||
force = moment = 0.0
|
||||
for i in range(len(nodes) - 1):
|
||||
x0, x1 = nodes[i], nodes[i + 1]
|
||||
f = statics._element_load(profile, x0, x1)
|
||||
force -= f[0] + f[2]
|
||||
moment -= f[0] * x0 + f[2] * x1 + f[1] + f[3]
|
||||
|
||||
assert abs(force - profile.total_lb) < 1e-6 * profile.total_lb
|
||||
assert abs(moment - profile.Q[-1]) < 1e-6 * profile.Q[-1]
|
||||
|
||||
|
||||
def test_the_mesh_never_leaves_a_sliver_element():
|
||||
"""What made the reactions drift even where the loads were right.
|
||||
|
||||
A node 1e-4 ft from its neighbour beside elements 0.19 ft long is a
|
||||
stiffness ratio of 6e9 across two adjacent rows of the matrix, and a dense
|
||||
Gaussian solve gives up about that many digits. The mesh is allowed a node
|
||||
exactly on a support and exactly on a section jump; it is not allowed one
|
||||
a hair away from either.
|
||||
"""
|
||||
panel, stage = _wp313_transportation()
|
||||
rig = statics._support_rig(stage.supports)
|
||||
nodes = statics._nodes(panel, rig)
|
||||
|
||||
for x in stage.supports:
|
||||
assert min(abs(n - x) for n in nodes) < 1e-12, (
|
||||
'no node on the support at x={0}'.format(x))
|
||||
floor = (panel.length_ft / 120.0) * statics.MIN_ELEMENT_FRACTION
|
||||
shortest = min(nodes[i + 1] - nodes[i] for i in range(len(nodes) - 1))
|
||||
assert shortest >= floor * (1.0 - 1e-9), (
|
||||
'shortest element {0:.3e} ft against a floor of {1:.3e}'
|
||||
.format(shortest, floor))
|
||||
|
||||
|
||||
# --------------------------------------------------------- point moments
|
||||
|
||||
def test_a_point_moment_matches_the_hand_solution(solid_panel):
|
||||
"""A concentrated moment on a 2-support span against a hand solution.
|
||||
|
||||
Derived (limit of two opposite point loads a vanishing distance apart,
|
||||
`PointMoment`'s own docstring): span L, moment M0 (sagging convention) at
|
||||
x0, on 2 supports (0, L):
|
||||
|
||||
R0 = -M0/L, RL = +M0/L
|
||||
M(x) = -M0*x/L for x < x0
|
||||
M(x) = M0*(1 - x/L) for x > x0
|
||||
|
||||
With 2 supports the beam is determinate, so EI (the panel's own) cannot
|
||||
matter -- same reasoning `test_two_supports_agree_between_the_two_load_
|
||||
paths` relies on.
|
||||
"""
|
||||
L = solid_panel.length_ft
|
||||
x0 = 0.3 * L
|
||||
m0 = 10000.0
|
||||
rig = statics._support_rig([0.0, L])
|
||||
profile = statics.load_profile(solid_panel, 0.0) # w = 0 everywhere
|
||||
pm = [statics.PointMoment(x=x0, moment_lbft=m0)]
|
||||
statics.solve_static(rig, profile, solid_panel, 5000.0, point_moments=pm)
|
||||
|
||||
r0 = rig.lines[0].reaction_lb
|
||||
rl = rig.lines[1].reaction_lb
|
||||
assert abs(r0 - (-m0 / L)) < 1e-6 * m0
|
||||
assert abs(rl - (m0 / L)) < 1e-6 * m0
|
||||
|
||||
dia = statics.diagram(rig, profile, point_moments=pm)
|
||||
for x_check, expected in ((0.1 * L, -m0 * 0.1 * L / L),
|
||||
(0.9 * L, m0 * (1.0 - 0.9 * L / L))):
|
||||
got = dia.moment_at(x_check)
|
||||
assert abs(got - expected) < 1e-6 * m0, (
|
||||
'M({0:.2f})={1:.2f}, expected {2:.2f}'
|
||||
.format(x_check, got, expected))
|
||||
|
||||
# M(0) = M(L) = 0 -- a pin at the very end of the piece with no other
|
||||
# load carries no moment, same closure identity the other tests assert.
|
||||
assert abs(dia.moment_at(0.0)) < 1e-6 * m0
|
||||
assert abs(dia.moment_at(L)) < 1e-6 * m0
|
||||
|
||||
# The jump crossing x0 is exactly +moment_lbft (sagging convention).
|
||||
# Offsets are a few grid steps (profile.xs is a 2000-station uniform
|
||||
# grid, ~0.016 ft here) so the probe lands cleanly on one side of x0
|
||||
# rather than interpolating across the single node that carries the
|
||||
# post-jump value (`diagram`'s `pm.x <= x + 1e-9` folds the jump into
|
||||
# x0's own entry).
|
||||
# A little short of exactly m0: M(x) also ramps (slope R0) across the
|
||||
# 10-step gap the probe straddles, which is real linear variation, not
|
||||
# slop -- the two closed-form checks above already pin the exact shape.
|
||||
step = solid_panel.length_ft / 2000.0
|
||||
just_below = dia.moment_at(x0 - 5.0 * step)
|
||||
just_above = dia.moment_at(x0 + 5.0 * step)
|
||||
ramp = abs(r0) * 10.0 * step
|
||||
assert abs((just_above - just_below) - m0) < ramp + 1.0
|
||||
|
||||
|
||||
def test_point_moment_forces_a_node_at_its_own_x(solid_panel):
|
||||
rig = statics._support_rig([0.0, solid_panel.length_ft])
|
||||
nodes = statics._nodes(solid_panel, rig, point_moment_xs=[7.7])
|
||||
assert min(abs(n - 7.7) for n in nodes) < 1e-12
|
||||
|
||||
|
||||
def test_point_moment_off_the_piece_is_refused(solid_panel):
|
||||
rig = statics._support_rig([0.0, solid_panel.length_ft])
|
||||
profile = statics.load_profile(solid_panel, 0.0)
|
||||
pm = [statics.PointMoment(x=solid_panel.length_ft + 5.0,
|
||||
moment_lbft=1000.0)]
|
||||
with pytest.raises(ValueError, match='outside the piece'):
|
||||
statics.solve_static(rig, profile, solid_panel, 5000.0,
|
||||
point_moments=pm)
|
||||
|
||||
|
||||
def test_factored_weight_scales_with_the_multiplier(solid_panel, rolling):
|
||||
light = statics.analyse(solid_panel, Stage('a', 1.0, 4000.0), rolling,
|
||||
reconcile_weight=False)
|
||||
heavy = statics.analyse(solid_panel, Stage('b', 1.3, 4000.0), rolling,
|
||||
reconcile_weight=False)
|
||||
_, m_light = light.diagram.max_positive
|
||||
_, m_heavy = heavy.diagram.max_positive
|
||||
assert abs(m_heavy / m_light - 1.3) < 1e-6
|
||||
|
||||
|
||||
# ------------------------------------------------- rolling versus static
|
||||
|
||||
def test_two_supports_agree_between_the_two_load_paths(solid_panel, stripping,
|
||||
rolling, static_lift):
|
||||
"""With two support lines the beam is determinate, so stiffness cannot matter."""
|
||||
a = statics.analyse(solid_panel, stripping, rolling, reconcile_weight=False)
|
||||
b = statics.analyse(solid_panel, stripping, static_lift,
|
||||
reconcile_weight=False)
|
||||
for la, lb in zip(a.rig.lines, b.rig.lines):
|
||||
assert abs(la.reaction_lb - lb.reaction_lb) / la.reaction_lb < 1e-3
|
||||
|
||||
|
||||
def test_three_lines_are_indeterminate_and_the_paths_diverge(solid_panel,
|
||||
stripping,
|
||||
rolling,
|
||||
static_lift):
|
||||
"""A middle support takes far more load than equalised sheaves would give it."""
|
||||
solid_panel.lifters = _lifters([4.0, 16.0, 28.0])
|
||||
|
||||
equalised = statics.analyse(solid_panel, stripping, rolling,
|
||||
reconcile_weight=False)
|
||||
continuous = statics.analyse(solid_panel, stripping, static_lift,
|
||||
reconcile_weight=False)
|
||||
|
||||
middle_equalised = equalised.rig.lines[1].reaction_lb
|
||||
middle_continuous = continuous.rig.lines[1].reaction_lb
|
||||
assert middle_continuous > middle_equalised * 1.2
|
||||
|
||||
|
||||
# --------------------------------------------------------------- grouping
|
||||
|
||||
def test_four_lines_split_into_two_cable_groups(solid_panel, rolling):
|
||||
solid_panel.lifters = _lifters([3.0, 8.0, 24.0, 29.0])
|
||||
rig = statics.group_lifters(solid_panel, rolling)
|
||||
assert len(rig.lines) == 4
|
||||
assert rig.points == 8
|
||||
assert [line.group for line in rig.lines] == [0, 0, 1, 1]
|
||||
assert 8.0 < rig.grouped_at < 24.0
|
||||
|
||||
|
||||
def test_three_lines_pair_the_two_that_are_close(solid_panel, rolling):
|
||||
solid_panel.lifters = _lifters([4.0, 24.0, 28.0])
|
||||
rig = statics.group_lifters(solid_panel, rolling)
|
||||
assert [line.group for line in rig.lines] == [0, 1, 1]
|
||||
|
||||
|
||||
def test_lifters_at_the_same_station_collapse_to_one_line(solid_panel, rolling):
|
||||
rig = statics.group_lifters(solid_panel, rolling)
|
||||
assert len(rig.lines) == 2
|
||||
assert rig.points == 4
|
||||
assert all(line.count == 2 for line in rig.lines)
|
||||
|
||||
|
||||
def test_one_support_line_is_refused(solid_panel, rolling):
|
||||
solid_panel.lifters = _lifters([16.0])
|
||||
with pytest.raises(ValueError, match='one line'):
|
||||
statics.group_lifters(solid_panel, rolling)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ sling angle
|
||||
|
||||
def test_vertical_sling_does_not_amplify(rolling):
|
||||
forces = statics.sling_forces(10000.0, rolling)
|
||||
assert abs(forces.amplification - 1.0) < 1e-9
|
||||
assert abs(forces.horizontal_lb) < 1e-6
|
||||
|
||||
|
||||
def test_sixty_degree_sling_amplifies_by_two_over_root_three(rolling):
|
||||
rolling.sling_angle_deg = 60.0
|
||||
forces = statics.sling_forces(10000.0, rolling)
|
||||
assert abs(forces.line_force_lb - 10000.0 / 0.8660254) < 0.1
|
||||
assert abs(forces.horizontal_lb - 5773.5) < 1.0
|
||||
|
||||
|
||||
def test_shallow_sling_is_much_worse(rolling):
|
||||
rolling.sling_angle_deg = 30.0
|
||||
forces = statics.sling_forces(10000.0, rolling)
|
||||
assert abs(forces.line_force_lb - 20000.0) < 1.0
|
||||
|
||||
|
||||
def test_a_zero_sling_angle_refuses_loudly_rather_than_dividing_by_zero(
|
||||
rolling):
|
||||
rolling.sling_angle_deg = 0.0
|
||||
with pytest.raises(ValueError, match='greater than zero'):
|
||||
statics.sling_forces(10000.0, rolling)
|
||||
|
||||
|
||||
def test_a_negative_sling_angle_refuses_loudly(rolling):
|
||||
"""Not physically meaningful, and math.sin of it is negative -- caught
|
||||
by the same guard as zero, not a NaN or a silently flipped sign."""
|
||||
rolling.sling_angle_deg = -30.0
|
||||
with pytest.raises(ValueError, match='greater than zero'):
|
||||
statics.sling_forces(10000.0, rolling)
|
||||
|
||||
|
||||
# --------------------------------------------- weight reconciliation
|
||||
|
||||
def test_reconciliation_scales_the_load_to_the_stated_weight(solid_panel,
|
||||
stripping,
|
||||
rolling):
|
||||
geometric = statics.load_profile(solid_panel, 1.0).total_lb
|
||||
solid_panel.weight_lb = geometric * 1.05
|
||||
|
||||
result = statics.analyse(solid_panel, stripping, rolling,
|
||||
reconcile_weight=True)
|
||||
expected = solid_panel.weight_lb * stripping.multiplier
|
||||
assert abs(result.profile.total_lb - expected) / expected < 1e-6
|
||||
assert abs(result.weight_error + 0.047619) < 1e-4
|
||||
@@ -0,0 +1,372 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The numbers behind the curves, station by station.
|
||||
|
||||
Eriksson Wall prints, after each stage's plots, a summary table of the same
|
||||
quantities at engineering stations with the governing row bolded. This is our
|
||||
version of it, and these are the properties that make it worth printing:
|
||||
|
||||
1. It carries the columns we actually compute, in the units the engineer
|
||||
states them in -- ft, kip, kip-ft, psi -- and does NOT carry the ones we
|
||||
don't. A zero in a capacity column is worse than no column at all,
|
||||
because somebody would divide by it. Six capacity columns landed
|
||||
2026-08-31 (M_cr, phi, phi-Mn, M/phi-Mn, phi-Vn, V/phi-Vn); the rest are
|
||||
still named as absent in the footnote, and a station whose capacity
|
||||
cannot be computed prints a dash and a REASON -- never a zero, never the
|
||||
neighbouring station's number.
|
||||
2. Its rows are the analysis's own stations. Thinning may drop a station
|
||||
that is only there because the grid is uniform; it may never drop an
|
||||
opening edge, a pick line, a peak, or the governing station.
|
||||
3. The governing row is marked once. Not zero times, and not twice --
|
||||
"which row is the answer" has exactly one answer.
|
||||
4. It is closed on load like every other section, and it survives the
|
||||
print overrides, because a printed report that hides its numbers is a
|
||||
report that says nothing.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import aspire_pockets, make_panel
|
||||
from panellift import checks, layout, report, statics
|
||||
from panellift.model import Blockout, Stage
|
||||
from panellift.units import parse_length
|
||||
|
||||
|
||||
def _analyse(panel, stage, rigging):
|
||||
return statics.analyse(panel, stage, rigging)
|
||||
|
||||
|
||||
def _table_of(page, after='Station table'):
|
||||
"""The one <table class="stations"> that follows this heading."""
|
||||
i = page.index(after)
|
||||
j = page.index('<table class="stations"', i)
|
||||
return page[j:page.index('</table>', j) + 8]
|
||||
|
||||
|
||||
def _rows(table):
|
||||
return re.findall(r'<tr[^>]*>(.*?)</tr>', table[table.index('<tbody>'):])
|
||||
|
||||
|
||||
def _cells(row):
|
||||
return [re.sub(r'<[^>]+>', '', c)
|
||||
for c in re.findall(r'<td[^>]*>(.*?)</td>', row)]
|
||||
|
||||
|
||||
# ------------------------------------------------ 1. the columns, converted
|
||||
|
||||
def test_the_columns_are_the_ones_we_compute_in_display_units(stripping,
|
||||
rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
head = re.sub(r'<[^>]+>', '|', table[:table.index('</thead>')])
|
||||
for column in ('x, ft', 'V, kip', 'M, kip-ft', 'S, in', 'allow, psi',
|
||||
'/allow', 'note'):
|
||||
assert column in head, column
|
||||
assert head.count(', psi') == 3 # f_t, f_c, allow
|
||||
|
||||
|
||||
def test_the_capacity_columns_are_carried_and_the_rest_are_named_absent(
|
||||
stripping, rolling):
|
||||
"""The load-bearing honesty of the whole table, in its 2026-08-31 form.
|
||||
|
||||
Six capacity columns are computed and carried. The ones that are still
|
||||
not computed are NAMED in the footnote rather than being quietly missing,
|
||||
and the sentence that forbids zero-filling them survives.
|
||||
"""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
html = report.station_table(a)
|
||||
table = _table_of(html, '<table')
|
||||
head = table[:table.index('</thead>')]
|
||||
for carried in ('M<sub>cr</sub>', 'φM<sub>n</sub>',
|
||||
'M/φM<sub>n</sub>', 'φV<sub>n</sub>',
|
||||
'V/φV<sub>n</sub>', 'φ'):
|
||||
assert carried in head, carried
|
||||
footnote = html[html.index('<ul class="basis">'):]
|
||||
for still_absent in ('V<sub>ci</sub>', 'V<sub>cw</sub>', 'deflection',
|
||||
'A<sub>v</sub>'):
|
||||
assert still_absent in footnote, still_absent
|
||||
assert 'left out rather than filled with zeros' in footnote
|
||||
# and the footnote does not claim the new ones are missing any more
|
||||
assert 'This revision computes none of those' not in footnote
|
||||
|
||||
|
||||
def test_every_value_is_the_engines_value_converted_once(stripping, rolling):
|
||||
"""A row's V and M are diagram.shear_lb / moment_lbft at that station
|
||||
over LB_PER_KIP -- not a re-solve, not an interpolation."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
points = dict((p.x, p) for p in checks.stress_profile(a))
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
xs = a.diagram.xs
|
||||
|
||||
# Column order since the 2026-09-01 reorder (f_se, added later the same
|
||||
# day, sits after f_c): x=0, M/phi-Mn=1, V/phi-Vn=2, M=3, phi-Mn=4,
|
||||
# M_cr=5, V=6, phi-Vn=7, phi=8, S=9, f_c=10, f_se=11, f_t/allow=12,
|
||||
# f_t=13, allow=14, note=15.
|
||||
for row in _rows(table):
|
||||
c = _cells(row)
|
||||
# Column 0 is now feet-inches ("8'-4 1/2\"") per the owner's
|
||||
# 2026-09-05 ruling, not a bare decimal -- parse it back rather than
|
||||
# `float()`.
|
||||
x = parse_length(c[0])
|
||||
i = min(range(len(xs)), key=lambda j: abs(xs[j] - x))
|
||||
assert abs(float(c[6].replace(',', ''))
|
||||
- a.diagram.shear_lb[i] / report.LB_PER_KIP) < 0.01
|
||||
assert abs(float(c[3].replace(',', ''))
|
||||
- a.diagram.moment_lbft[i] / report.LB_PER_KIP) < 0.1
|
||||
# and the stress column is the same profile the plot was drawn from
|
||||
gov = [r for r in _rows(table) if 'gov' in r][0]
|
||||
worst = max(points.values(), key=lambda p: p.stress_psi)
|
||||
assert abs(float(_cells(gov)[13]) - worst.stress_psi) < 0.05
|
||||
assert _cells(gov)[12] == '{0:.0%}'.format(
|
||||
worst.stress_psi / stripping.allowable_tension_psi)
|
||||
|
||||
|
||||
def test_a_negative_zero_never_reaches_a_cell(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
for row in _rows(table):
|
||||
for cell in _cells(row):
|
||||
assert not re.match(r'^-0(\.0*)?$', cell), cell
|
||||
|
||||
|
||||
# --------------------------------- 2. what thinning may and may not drop
|
||||
|
||||
def test_the_table_is_thinned_but_keeps_every_jump_station(stripping,
|
||||
rolling):
|
||||
"""Five pockets in a row: ten edge stations, every one of them a row,
|
||||
out of two thousand computed."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
assert len(a.stations) >= 2
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
rows = _rows(table)
|
||||
assert len(rows) < len(a.diagram.xs) / 10 # it really is thinned
|
||||
shown = [parse_length(_cells(r)[0]) for r in rows]
|
||||
# 0.01 ft, not 0.005 -- column 0 is now rounded to the nearest 1/8" for
|
||||
# display, so a round trip through the printed string can be off by up
|
||||
# to 1/16" (~0.0052 ft) even for the exact same station.
|
||||
for x in a.stations:
|
||||
assert any(abs(x - s) < 0.01 for s in shown), x
|
||||
assert sum('opening edge' in r for r in rows) == len(a.stations)
|
||||
|
||||
|
||||
def test_an_opening_edge_is_shown_from_both_sides(stripping, rolling):
|
||||
"""The moment is continuous across the edge and S is not. Two rows at the
|
||||
same station with different S is the section jumping -- the one thing a
|
||||
uniform grid would step over."""
|
||||
panel = make_panel(blockouts=[
|
||||
Blockout(name='B1', x0=10.0, y0=0.0, length=6.0, width=4.0)])
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
rows = _rows(_table_of(report.station_table(a), '<table'))
|
||||
edges = [r for r in rows if 'opening edge' in r]
|
||||
assert len(edges) == 4 # two edges, both sides
|
||||
pair = [_cells(r) for r in edges[:2]]
|
||||
assert pair[0][0] == pair[1][0] # same station
|
||||
assert pair[0][9] != pair[1][9] # different S (col 9)
|
||||
|
||||
|
||||
def test_the_peaks_and_the_pick_lines_survive_thinning(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
rows = _rows(_table_of(report.station_table(a), '<table'))
|
||||
text = ''.join(rows)
|
||||
for tag in ('pick line', 'max V', 'min V', 'max M', 'min M'):
|
||||
assert tag in text, tag
|
||||
shown = [parse_length(_cells(r)[0]) for r in rows]
|
||||
for line in a.rig.lines:
|
||||
assert any(abs(line.x - s) < 0.02 for s in shown), line.x
|
||||
# the ends of the piece are both there
|
||||
assert shown[0] == 0.0
|
||||
assert abs(shown[-1] - a.panel.length_ft) < 0.01
|
||||
|
||||
|
||||
def test_a_supported_stage_names_its_supports_not_pick_lines(stripping,
|
||||
rolling):
|
||||
"""Transportation on dunnage is not a lift, and the note column says the
|
||||
word the rest of that stage uses."""
|
||||
stage = Stage(name='transportation', multiplier=1.5, fc_psi=5000.0,
|
||||
supports=[6.0, 26.0])
|
||||
a = _analyse(make_panel(), stage, rolling)
|
||||
html = report.station_table(a)
|
||||
rows = _rows(_table_of(html, '<table'))
|
||||
assert sum('support' in r for r in rows) == 2
|
||||
assert 'pick line' not in ''.join(rows)
|
||||
assert 'every support' in html
|
||||
|
||||
|
||||
def test_the_rule_that_was_applied_is_printed(stripping, rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
html = report.station_table(a)
|
||||
kept = len(_rows(_table_of(html, '<table')))
|
||||
assert '{0} rows out of {1}'.format(kept, len(a.diagram.xs)) in html
|
||||
assert 'thinned' in html and 'never drop' in html
|
||||
|
||||
|
||||
def test_a_short_diagram_is_not_thinned_at_all(stripping, rolling):
|
||||
"""The stride is a cap, not a rule: nothing is dropped that there was
|
||||
room for."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
a.diagram.xs = a.diagram.xs[:8]
|
||||
a.diagram.shear_lb = a.diagram.shear_lb[:8]
|
||||
a.diagram.moment_lbft = a.diagram.moment_lbft[:8]
|
||||
a.stations = []
|
||||
rows, _gov = report.station_indices(a, ())
|
||||
assert [i for i, _tags in rows] == list(range(8))
|
||||
|
||||
|
||||
# --------------------------------------- 3. the governing row, marked once
|
||||
|
||||
def test_the_governing_row_is_marked_exactly_once(stripping, rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
for html in (report.station_table(a),
|
||||
report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)):
|
||||
table = _table_of(html, '<table class="stations"')
|
||||
assert table.count('<tr class="gov">') == 1
|
||||
assert table.count('governing') == 1
|
||||
gov = [r for r in _rows(table) if 'gov' in r][0]
|
||||
assert 'governing' in gov
|
||||
|
||||
|
||||
def test_the_governing_row_is_the_worst_ratio_on_the_page(stripping, rolling):
|
||||
"""It has to be the same station the verdict, the red dot on the stress
|
||||
plot and the CRACK finding all point at."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
points = checks.stress_profile(a)
|
||||
worst = max(points, key=lambda p: p.stress_psi)
|
||||
table = _table_of(report.station_table(a, points), '<table')
|
||||
gov = _cells([r for r in _rows(table) if 'gov' in r][0])
|
||||
# 0.01 ft, not 0.005 -- see the note in
|
||||
# test_the_table_is_thinned_but_keeps_every_jump_station above.
|
||||
assert abs(parse_length(gov[0]) - worst.x) < 0.01
|
||||
# f_t/allow is column 12 since the 2026-09-01 reorder, f_se included (see
|
||||
# the column map at the top of this file's test_every_value_is_the_
|
||||
# engines_value...)
|
||||
ratios = [int(_cells(r)[12].rstrip('%')) for r in _rows(table)
|
||||
if _cells(r)[12].endswith('%')]
|
||||
assert int(gov[12].rstrip('%')) == max(ratios)
|
||||
|
||||
|
||||
def test_the_mark_is_bold_and_tinted_and_also_a_word(stripping, rolling):
|
||||
"""Three ways, because a print drops the tint and a monochrome print
|
||||
drops the colour. The word in the note column survives both."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
assert ('table.stations tr.gov td'
|
||||
'{background:var(--fillc);font-weight:700}') in page
|
||||
gov = [r for r in _rows(_table_of(page)) if 'gov' in r][0]
|
||||
assert 'governing' in gov
|
||||
|
||||
|
||||
def test_every_stage_of_a_multi_stage_report_gets_its_own_table(stripping,
|
||||
shipping,
|
||||
rolling):
|
||||
panel = make_panel(blockouts=aspire_pockets())
|
||||
cases = [(_analyse(panel, stage, rolling), rolling, [])
|
||||
for stage in (stripping, shipping)]
|
||||
for a, rig, _f in cases:
|
||||
_f.extend(checks.run_all(a, rig))
|
||||
page = report.build_many(cases, recommendation=layout.not_searched())
|
||||
assert page.count('<table class="stations"') == 2
|
||||
assert page.count('<tr class="gov">') == 2
|
||||
assert page.count('>Station table</summary>') == 2
|
||||
|
||||
|
||||
# ------------------------------------------- 4. collapsed, and it prints
|
||||
|
||||
def test_the_section_is_closed_on_load_like_every_other(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
assert ('<details class="section stationsec"><summary>Station table'
|
||||
'</summary>') in page
|
||||
assert '<details class="section stationsec" open' not in page
|
||||
# nested inside the stage's own disclosure, not beside it
|
||||
stage_body = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False, standalone=False)
|
||||
assert 'stationsec' in stage_body
|
||||
|
||||
|
||||
def test_the_table_survives_the_print_overrides(stripping, rolling):
|
||||
"""A closed <details> is hidden on screen by two different mechanisms in
|
||||
two generations of engine, and the print block overrides both. The table
|
||||
must be inside that, and its scroll box must open up on paper."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
printed = page[page.index('@media print'):]
|
||||
printed = printed[:printed.index('</style>')]
|
||||
assert 'details>*:not(summary){display:block !important}' in printed
|
||||
assert 'details::details-content' in printed
|
||||
assert '.tablewrap{overflow:visible !important}' in printed
|
||||
assert 'table.stations thead{display:table-header-group}' in printed
|
||||
assert 'details.stationsec{break-inside:auto}' in printed
|
||||
# and the table is inside a details, so those overrides reach it
|
||||
assert '<details class="section stationsec">' in page
|
||||
without = page[:page.index('<script>')]
|
||||
assert '<table class="stations"' in without # no JS needed at all
|
||||
|
||||
|
||||
# ----------------------------------------------------- 5. a real piece
|
||||
|
||||
def _sweep_jobs():
|
||||
root = Path(__file__).resolve().parents[2] / 'knowledge' / 'job'
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(root.glob('*/sweep/jobs/*.job.json'))
|
||||
|
||||
|
||||
def _is_sandwich_job(job):
|
||||
"""Two or more concrete solids in the layup -- an insulated (or
|
||||
two-wythe) body, the makeup that used to be refused outright."""
|
||||
parts = ((job.get('source') or {}).get('solid_parts')) or []
|
||||
return sum(1 for p in parts if p.get('counted')) >= 2
|
||||
|
||||
|
||||
def test_a_real_job_renders_a_station_table():
|
||||
"""The fixtures are a published worked example; a real piece is a real
|
||||
piece. Skipped where the local job library is not checked out.
|
||||
|
||||
Renders the first real jobs that ANALYSE, and requires at least one of
|
||||
them to be an insulated/two-wythe sandwich -- the makeup that was refused
|
||||
before 2026-09-02 and now runs on the concrete-wythe composite section.
|
||||
A job that cannot be LIFT-analysed for a reason unrelated to its section
|
||||
(e.g. an erection stage whose two picked inserts collapse onto one line
|
||||
across the span -- a rigging-geometry refusal, `statics` ValueError, that
|
||||
fires on WP100-102 whether the piece is solid or sandwich) is skipped,
|
||||
not counted as a rendering failure: this test is about the station table,
|
||||
not about those pieces' rigging.
|
||||
"""
|
||||
from panellift import cli
|
||||
jobs = _sweep_jobs()
|
||||
if not jobs:
|
||||
pytest.skip('no local job library')
|
||||
import json
|
||||
rendered = 0
|
||||
sandwich_rendered = 0
|
||||
for path in jobs:
|
||||
with open(str(path), 'r', encoding='utf-8') as fh:
|
||||
job = json.load(fh)
|
||||
try:
|
||||
_panel, _rig, cases = cli.analyse_job(job)
|
||||
except (cli.JobError, ValueError):
|
||||
# Refused (an unmeasured sandwich) or un-riggable (WP100-style
|
||||
# one-line erection pick). Neither is a station-table failure.
|
||||
continue
|
||||
page = report.build_many(cases, recommendation=layout.not_searched())
|
||||
assert page.count('<table class="stations"') == len(cases)
|
||||
assert page.count('<tr class="gov">') == len(cases)
|
||||
for table in re.findall(
|
||||
r'<table class="stations".*?</table>', page, re.S):
|
||||
rows = _rows(table)
|
||||
assert 8 <= len(rows) <= 200
|
||||
assert all(len(_cells(r)) == 16 for r in rows)
|
||||
rendered += 1
|
||||
if _is_sandwich_job(job):
|
||||
sandwich_rendered += 1
|
||||
if rendered >= 3 and sandwich_rendered >= 1:
|
||||
break
|
||||
assert rendered >= 3
|
||||
# the sandwich path is the point of this work: at least one real
|
||||
# insulated/two-wythe piece must render its table, not be refused.
|
||||
assert sandwich_rendered >= 1
|
||||
@@ -0,0 +1,239 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The tensile limit is a job decision, and the report has to say whose.
|
||||
|
||||
`tension_limit_sqrt_fc` multiplies sqrt(f'c) to give the allowable tension in
|
||||
psi. 5.0 is the modulus of rupture over a 1.5 safety factor -- "no
|
||||
discernible cracking"; 7.5 is the rupture modulus itself, first cracking with
|
||||
no margin. The two are a factor of 1.5 apart on EVERY stress verdict, which
|
||||
is more than any refinement of the analysis moves, and a commercial check of
|
||||
one of these panels ran at 7.5 while this defaulted to 5.0 -- that difference
|
||||
alone accounted for most of the disagreement between the two.
|
||||
|
||||
The owner declined to settle it, 2026-08-30:
|
||||
|
||||
"应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自己
|
||||
填写。I can see it both ways."
|
||||
|
||||
So the engineer fills it in per job, and these tests hold the two halves of
|
||||
that: the number the job states is the number the check uses, and the page
|
||||
says which number that was and who chose it. A report that states 666 psi
|
||||
with nothing else on it cannot be told apart from the same panel at 444 psi
|
||||
by anybody reading it later, which is the whole failure being prevented.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import cli, layout, report
|
||||
from panellift.model import Stage
|
||||
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT
|
||||
from panellift import pci
|
||||
|
||||
|
||||
def job(limit=None, source=None, stage_extra=None):
|
||||
"""The ASPIRE panel as the exporter writes it, at whatever limit.
|
||||
|
||||
`limit=None` leaves the key out entirely -- a job file written before
|
||||
anybody could set this, which must behave exactly as it always did.
|
||||
"""
|
||||
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
||||
stage = {'name': 'stripping',
|
||||
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
|
||||
'fc_psi': 4000.0}
|
||||
if limit is not None:
|
||||
stage['tension_limit_sqrt_fc'] = limit
|
||||
if source is not None:
|
||||
stage['tension_limit_source'] = source
|
||||
stage.update(stage_extra or {})
|
||||
return {
|
||||
'schema': 'panellift/1',
|
||||
'panel': {
|
||||
'mark': 'ASPIRE-1',
|
||||
'length_ft': LENGTH_FT,
|
||||
'width_ft': WIDTH_FT,
|
||||
'thickness_in': THICKNESS_IN,
|
||||
'unit_weight_pcf': 150.0,
|
||||
'fci_psi': 4000.0,
|
||||
'fc_psi': 5000.0,
|
||||
'lifters': [
|
||||
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
|
||||
'y': 2.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
|
||||
'y': 7.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
|
||||
'y': 2.5, 'capacity_lb': 12000.0},
|
||||
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
|
||||
'y': 7.5, 'capacity_lb': 12000.0},
|
||||
],
|
||||
},
|
||||
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
|
||||
'stages': [stage],
|
||||
}
|
||||
|
||||
|
||||
def analysed(**kw):
|
||||
"""(analysis, rigging, findings) for the one stage of that job."""
|
||||
_panel, _rigging, cases = cli.analyse_job(job(**kw))
|
||||
return cases[0]
|
||||
|
||||
|
||||
def rendered(**kw):
|
||||
"""One stage of one piece, all the way to HTML."""
|
||||
return report.build_many([analysed(**kw)],
|
||||
recommendation=layout.not_searched())
|
||||
|
||||
|
||||
# ------------------------------------------------- the value reaches the sum
|
||||
|
||||
def test_the_job_files_limit_is_the_limit_the_check_uses():
|
||||
"""The whole chain in one assertion: knowledge -> job file -> Stage ->
|
||||
allowable. A field that writes a key nothing reads is the drift this
|
||||
repository keeps finding, so the number has to move the arithmetic."""
|
||||
five = analysed(limit=5.0)[0].stage
|
||||
seven = analysed(limit=7.5)[0].stage
|
||||
assert five.allowable_tension_psi == pytest.approx(5.0 * 4000.0 ** 0.5)
|
||||
assert seven.allowable_tension_psi == pytest.approx(7.5 * 4000.0 ** 0.5)
|
||||
# 1.5x, exactly, which is the entire argument.
|
||||
assert (seven.allowable_tension_psi /
|
||||
five.allowable_tension_psi) == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_the_limit_changes_the_number_printed_on_a_real_report():
|
||||
"""Not just the model object -- the rendered page. Two reports of the
|
||||
same piece under the two schools must not be able to look alike."""
|
||||
five = rendered(limit=5.0)
|
||||
seven = rendered(limit=7.5)
|
||||
assert '316 psi' in five and '474 psi' in seven
|
||||
assert '474 psi' not in five and '316 psi' not in seven
|
||||
|
||||
|
||||
def test_a_permissive_limit_can_turn_a_fail_into_a_pass():
|
||||
"""Why this is the largest single source of disagreement: the verdict
|
||||
itself moves, on the same panel, with the same geometry and loads."""
|
||||
def verdict(limit):
|
||||
# Same panel, same picks, same loads. Only f'ci is dropped to 1500
|
||||
# psi, which puts the stripping stress between the two allowables:
|
||||
# 5*sqrt(1500) = 194 psi and 7.5*sqrt(1500) = 290 psi.
|
||||
found = [f for f in analysed(limit=limit,
|
||||
stage_extra={'fc_psi': 1500.0})[2]
|
||||
if f.code == 'CRACK']
|
||||
return found[0].severity if found else None
|
||||
|
||||
assert verdict(5.0) == 'FAIL'
|
||||
assert verdict(7.5) in ('OK', 'WARN')
|
||||
|
||||
|
||||
# ----------------------------------------------------- unset is unchanged
|
||||
|
||||
def test_a_job_file_with_no_limit_still_gets_five():
|
||||
"""Byte-identity for everything already on disk. Every job file written
|
||||
before this was settable leaves the key out, and absent has to mean the
|
||||
5.0 it has always meant -- not "ask", not "inherit something"."""
|
||||
assert analysed()[0].stage.tension_limit_sqrt_fc == 5.0
|
||||
assert Stage(name='s', multiplier=1.0, fc_psi=4000.0) \
|
||||
.tension_limit_sqrt_fc == 5.0
|
||||
|
||||
|
||||
def test_absent_and_explicit_five_compute_identically():
|
||||
"""The default is the same code path as a stated 5.0, down to the
|
||||
findings text -- apart from the one word saying who chose it."""
|
||||
absent, stated = analysed(), analysed(limit=5.0)
|
||||
a = [(f.severity, f.code) for f in absent[2]]
|
||||
b = [(f.severity, f.code) for f in stated[2]]
|
||||
assert a == b
|
||||
assert (absent[0].stage.allowable_tension_psi ==
|
||||
stated[0].stage.allowable_tension_psi)
|
||||
|
||||
|
||||
# --------------------------------------------------------- who chose it
|
||||
|
||||
def test_provenance_is_derived_when_the_file_does_not_say():
|
||||
"""A stage that states the limit is a stage somebody set; one that does
|
||||
not is running panellift's own number. Neither needs a second key."""
|
||||
assert analysed()[0].stage.tension_limit_source == 'default'
|
||||
assert analysed(limit=7.5)[0].stage.tension_limit_source == 'job'
|
||||
|
||||
|
||||
def test_the_exporter_can_say_a_stage_overrode_the_job():
|
||||
"""The one thing this side cannot work out for itself: a handling.stages
|
||||
entry that beat the job-wide Project Config field. Without it, the
|
||||
engineer who typed 5.0 into that field and got a 7.5 report has nothing
|
||||
on the page that explains the difference."""
|
||||
stage = analysed(limit=7.5, source='stage')[0].stage
|
||||
assert stage.tension_limit_source == 'stage'
|
||||
assert 'stage' in stage.tension_basis
|
||||
|
||||
|
||||
def test_an_unknown_provenance_word_is_refused_not_displayed():
|
||||
"""A label that can be quietly wrong is worse than no label: 'jobb' read
|
||||
as 'job' would print a confident sentence about the wrong layer."""
|
||||
with pytest.raises(cli.JobError) as exc:
|
||||
cli.analyse_job(job(limit=7.5, source='jobb'))
|
||||
assert 'tension_limit_source' in str(exc.value)
|
||||
|
||||
|
||||
def test_the_basis_string_is_ascii():
|
||||
"""Findings are printed to whatever console launched the run. A radical
|
||||
sign there is a crash on a cp1252 code page, not a nicer report."""
|
||||
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
|
||||
stage.tension_basis.encode('ascii')
|
||||
assert stage.tension_limit_text == "5*sqrt(f'c)"
|
||||
assert Stage(name='s', multiplier=1.0, fc_psi=4000.0,
|
||||
tension_limit_sqrt_fc=7.5).tension_limit_text == \
|
||||
"7.5*sqrt(f'c)"
|
||||
|
||||
|
||||
# ------------------------------------------------ the report cannot be mute
|
||||
|
||||
def test_every_finding_that_states_an_allowable_states_the_limit():
|
||||
"""CRACK is the check the limit decides, and its line has to carry the
|
||||
coefficient beside the psi -- 444 and 666 on the same concrete are two
|
||||
doctrines, and the psi alone does not say which."""
|
||||
found = [f for f in analysed(limit=7.5)[2] if f.code == 'CRACK']
|
||||
assert found
|
||||
assert "7.5*sqrt(f'c), job" in found[0].message
|
||||
|
||||
|
||||
def test_the_stress_plot_title_names_the_limit_it_drew():
|
||||
"""It used to say 5*sqrt(f'c) whatever the stage ran at -- the one place
|
||||
a 7.5 job was labelled as a 5.0 one."""
|
||||
svg = report.stress_plot(analysed(limit=7.5)[0])
|
||||
assert '7.5' in svg
|
||||
assert 'against 5' not in svg
|
||||
|
||||
|
||||
def test_the_basis_list_says_where_the_coefficient_came_from():
|
||||
page = rendered(limit=7.5)
|
||||
assert 'as the job file states it' in page
|
||||
# notate() dresses "7.5*sqrt(f'c)" up as a real radical over a real
|
||||
# f-prime-sub-c, not the ASCII the underlying strings still carry.
|
||||
assert '7.5' + report.RADICAL_FC in page
|
||||
|
||||
|
||||
def test_the_default_is_named_as_a_default_on_the_page():
|
||||
"""The report must not present panellift's own fallback as a decision
|
||||
somebody made."""
|
||||
page = rendered()
|
||||
assert "panellift's own default" in page or \
|
||||
"panellift's own default" in page
|
||||
|
||||
|
||||
def test_the_stage_table_carries_the_limit_under_the_allowable():
|
||||
"""Two stages of one piece may run at different limits, so it is per row
|
||||
rather than a footnote."""
|
||||
page = rendered(limit=7.5)
|
||||
assert 'class="limbasis"' in page
|
||||
# notate() renders "7.5*sqrt(f'c)" as a real radical, so the cell now
|
||||
# carries nested tags -- match to the closing </span> of the limbasis
|
||||
# element itself, not to the first '<' inside it.
|
||||
cell = re.search(r'<span class="limbasis">(.*?)</span></td>', page)
|
||||
assert cell and '7.5' in cell.group(1) and 'job' in cell.group(1)
|
||||
assert report.RADICAL_FC in cell.group(1)
|
||||
|
||||
|
||||
def test_the_stage_box_shows_the_limit_beside_the_ratio():
|
||||
"""The boxes are what a reader takes in and moves on from."""
|
||||
page = rendered(limit=7.5)
|
||||
box = page[page.index('<div class="stagebox-detail">'):][:260]
|
||||
# notate() renders "7.5*sqrt(f'c), job" as a real radical + f-prime-sub-c.
|
||||
assert '7.5' + report.RADICAL_FC + ', job' in box
|
||||
@@ -0,0 +1,203 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`panellift.units` -- the feet-inches formatter/parser every 前端 and 后端
|
||||
in this repo now goes through (owner's ruling, 2026-09-05: every displayed
|
||||
length is feet-inches, rounded to the nearest 1/8", never a decimal).
|
||||
|
||||
This module is written in the intersection of Python 2 and 3 syntax on
|
||||
purpose (see its own docstring) because the pyRevit/IronPython side is meant
|
||||
to import this exact file later; nothing here exercises anything Python-3
|
||||
-only, so a green run here says nothing about IronPython compatibility that
|
||||
reading the source doesn't already say, but it is still the same file that
|
||||
will make that trip.
|
||||
"""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift.units import fmt_ft_in, fmt_in, parse_length, parse_length_in
|
||||
|
||||
|
||||
# ------------------------------------------------------------- fmt_ft_in
|
||||
|
||||
def test_canonical_form():
|
||||
assert fmt_ft_in(8 + 4.5 / 12.0) == '8\'-4 1/2"'
|
||||
|
||||
|
||||
def test_zero_feet_still_prints_the_foot_mark():
|
||||
# 6 3/8 in = 0.53125 ft
|
||||
assert fmt_ft_in(0.53125) == '0\'-6 3/8"'
|
||||
|
||||
|
||||
def test_negative_keeps_the_sign_out_front_not_inside():
|
||||
assert fmt_ft_in(-0.5) == '-0\'-6"'
|
||||
|
||||
|
||||
def test_none_is_a_dash():
|
||||
assert fmt_ft_in(None) == '-'
|
||||
|
||||
|
||||
def test_a_whole_number_of_feet_has_no_fraction_cell():
|
||||
assert fmt_ft_in(5.0) == '5\'-0"'
|
||||
|
||||
|
||||
def test_twelve_inches_carries_into_the_foot_count():
|
||||
# 7 ft + 11.96 in rounds to 8 in at 1/8" precision, but 7 ft + 11.98 in
|
||||
# rounds UP to a full 12 in, which must carry to the next foot.
|
||||
almost_eight_feet = 7.0 + 11.98 / 12.0
|
||||
assert fmt_ft_in(almost_eight_feet) == '8\'-0"'
|
||||
|
||||
|
||||
def test_rounds_to_the_nearest_eighth_by_default():
|
||||
# 4 5/16 in -> nearest 1/8" is 4 1/4 or 4 3/8; 5/16 = 0.3125,
|
||||
# which is exactly between 1/4 (0.25) and 3/8 (0.375) at 1/8" steps of
|
||||
# 0.125 -- round(0.3125 / 0.125) = round(2.5) = 2 (round-half-to-even),
|
||||
# i.e. 2/8 = 1/4".
|
||||
four_and_5_16 = 4.3125 / 12.0
|
||||
assert fmt_ft_in(four_and_5_16) == '0\'-4 1/4"'
|
||||
|
||||
|
||||
def test_a_finer_precision_is_honoured():
|
||||
# 1/16" precision: 6 1/16 in.
|
||||
six_and_1_16 = (6.0 + 1.0 / 16.0) / 12.0
|
||||
assert fmt_ft_in(six_and_1_16, precision=16) == '0\'-6 1/16"'
|
||||
# at the default 1/8" precision the same value rounds down to 6".
|
||||
assert fmt_ft_in(six_and_1_16) == '0\'-6"'
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- fmt_in
|
||||
|
||||
def test_fmt_in_basic():
|
||||
assert fmt_in(6.375) == '6 3/8"'
|
||||
|
||||
|
||||
def test_fmt_in_whole_number():
|
||||
assert fmt_in(12.0) == '12"'
|
||||
|
||||
|
||||
def test_fmt_in_none_is_a_dash():
|
||||
assert fmt_in(None) == '-'
|
||||
|
||||
|
||||
def test_fmt_in_carries_a_full_inch():
|
||||
# 5.98 in at 1/8" precision rounds up to a full 6 in, not "5 8/8".
|
||||
assert fmt_in(5.98) == '6"'
|
||||
|
||||
|
||||
def test_fmt_in_negative():
|
||||
assert fmt_in(-0.5) == '-0 1/2"'
|
||||
|
||||
|
||||
# ------------------------------------------------------- rounding boundary
|
||||
|
||||
@pytest.mark.parametrize('eighths, expected', [
|
||||
(0, '0"'), (1, '0 1/8"'), (2, '0 1/4"'), (3, '0 3/8"'),
|
||||
(4, '0 1/2"'), (5, '0 5/8"'), (6, '0 3/4"'), (7, '0 7/8"'),
|
||||
])
|
||||
def test_every_eighth_prints_its_own_reduced_fraction(eighths, expected):
|
||||
assert fmt_in(eighths / 8.0) == expected
|
||||
|
||||
|
||||
def test_exactly_half_a_sixteenth_rounds_to_even_at_default_precision():
|
||||
"""1/16" (0.0625 in) sits exactly halfway between 0 and 1/8" at the
|
||||
default precision -- Python's own `round()` (banker's rounding) sends an
|
||||
exact .5 to the nearest EVEN integer, so 0.5 eighths rounds to 0, not 1.
|
||||
This test pins that behaviour so a future change to the rounding rule
|
||||
shows up here rather than as a silently different printed fraction.
|
||||
"""
|
||||
assert fmt_in(1.0 / 16.0) == '0"'
|
||||
# 3/16" -> 1.5 eighths -> rounds to 2 (even) -> 1/4"
|
||||
assert fmt_in(3.0 / 16.0) == '0 1/4"'
|
||||
|
||||
|
||||
# --------------------------------------------------------------- parsing
|
||||
|
||||
@pytest.mark.parametrize('text, expected_ft', [
|
||||
('8\'-4 1/2"', 8 + 4.5 / 12.0),
|
||||
('8\'4"', 8 + 4.0 / 12.0),
|
||||
("8' 4-1/2\"", 8 + 4.5 / 12.0),
|
||||
('4 1/2"', 4.5 / 12.0),
|
||||
('100"', 100.0 / 12.0),
|
||||
("-0'-6\"", -0.5),
|
||||
('8\'', 8.0),
|
||||
])
|
||||
def test_parse_length_accepts_every_named_form(text, expected_ft):
|
||||
assert parse_length(text) == pytest.approx(expected_ft, abs=1e-9)
|
||||
|
||||
|
||||
def test_a_bare_number_is_feet_by_default():
|
||||
assert parse_length('5') == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_a_bare_number_can_be_read_as_inches():
|
||||
assert parse_length('6', default_unit='in') == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_parse_length_in_reads_a_bare_number_as_inches_by_default():
|
||||
assert parse_length_in('6') == pytest.approx(6.0)
|
||||
|
||||
|
||||
def test_parse_length_in_can_read_a_bare_number_as_feet():
|
||||
assert parse_length_in('1', default_unit='ft') == pytest.approx(12.0)
|
||||
|
||||
|
||||
def test_parse_length_in_on_a_feet_inches_string():
|
||||
assert parse_length_in('6 3/8"') == pytest.approx(6.375)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [
|
||||
'garbage', '', ' ', "8'garbage", '1/0"', None,
|
||||
])
|
||||
def test_unparseable_text_raises_value_error_naming_the_text(bad):
|
||||
with pytest.raises(ValueError):
|
||||
parse_length(bad)
|
||||
|
||||
|
||||
def test_the_error_names_the_offending_text():
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_length('nonsense')
|
||||
assert 'nonsense' in str(exc.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------- negative forms
|
||||
|
||||
def test_negative_feet_and_inches_together():
|
||||
assert parse_length("-8'-4 1/2\"") == pytest.approx(-(8 + 4.5 / 12.0))
|
||||
|
||||
|
||||
def test_negative_bare_inches():
|
||||
assert parse_length('-6"') == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_negative_bare_number():
|
||||
assert parse_length('-5') == pytest.approx(-5.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- round-trip
|
||||
|
||||
def test_round_trip_property_across_a_range():
|
||||
"""parse_length(fmt_ft_in(x)) == x within 1/16", the max error a value
|
||||
can pick up from rounding to the nearest 1/8" for display.
|
||||
"""
|
||||
rng = random.Random(20260905)
|
||||
max_err_ft = (1.0 / 16.0) / 12.0
|
||||
for _ in range(5000):
|
||||
x = rng.uniform(-60.0, 60.0)
|
||||
rendered = fmt_ft_in(x)
|
||||
back = parse_length(rendered)
|
||||
assert abs(back - x) <= max_err_ft + 1e-12, (x, rendered, back)
|
||||
|
||||
|
||||
def test_round_trip_property_holds_at_zero_and_small_values():
|
||||
for x in (0.0, 1e-9, -1e-9, 1.0 / 12.0, -1.0 / 12.0):
|
||||
back = parse_length(fmt_ft_in(x))
|
||||
assert abs(back - x) <= (1.0 / 16.0) / 12.0 + 1e-9
|
||||
|
||||
|
||||
def test_round_trip_property_for_fmt_in_and_parse_length_in():
|
||||
rng = random.Random(20260905)
|
||||
max_err_in = 1.0 / 16.0
|
||||
for _ in range(5000):
|
||||
x = rng.uniform(-200.0, 200.0)
|
||||
rendered = fmt_in(x)
|
||||
back = parse_length_in(rendered)
|
||||
assert abs(back - x) <= max_err_in + 1e-9, (x, rendered, back)
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Which end of the piece is up, when it came from a fallback -- and the red
|
||||
line telling the reader to go check TOT on the model.
|
||||
|
||||
`report.up_end_disclosure()` / `report._up_end_block()` already print the
|
||||
fallback's own explanation, unconditionally, whenever `source.up_end_basis`
|
||||
is anything but the ruled mechanism (`RULED_UP_END_BASIS`). What is new here
|
||||
is `report._up_end_is_origin_defect()`: of the three reasons the ruled
|
||||
mechanism can decline (see `up_end_from_origin()` in
|
||||
`WillTools.extension/lib/vibe/knowledge.py`), only the FIRST -- the origin's
|
||||
own up vector does not point up -- is a defect on the assembly's origin
|
||||
itself. The other two (near-square; not yet confirmed for this piece's
|
||||
orientation) are gaps in what the rule covers, not something wrong with the
|
||||
model, and must never carry the same red call to action.
|
||||
"""
|
||||
import re
|
||||
|
||||
from conftest import make_panel
|
||||
|
||||
from panellift import report
|
||||
|
||||
|
||||
# Verbatim from `up_end_from_origin()`'s FIRST refusal
|
||||
# (WillTools.extension/lib/vibe/knowledge.py) -- the exact shape
|
||||
# `liftexport` writes into `source.up_end_basis_why`, and the real text on
|
||||
# WP401 (2026-09-01).
|
||||
ORIGIN_NOT_UP_WHY = (
|
||||
"The origin of assembly WP401 does not stand the piece up: its up "
|
||||
"basis is (0.0, 1.0, 0.0) rather than world up. One assembly in 282 is "
|
||||
"like this -- its face normal points up instead -- and on such a "
|
||||
"piece every reading off the frame is wrong, so nothing is read off "
|
||||
"this one. Which end is up was therefore read from GLOBAL ELEVATION "
|
||||
"instead -- the end of the piece that is higher in the model. That is "
|
||||
"a fallback, not the rule.")
|
||||
|
||||
# Verbatim shape of the SECOND refusal (near square) -- must NOT flag.
|
||||
NEAR_SQUARE_WHY = (
|
||||
"Assembly PNL127 is near square (11.63 x 11.58 ft, ratio 1.00), and "
|
||||
"below 1.25 the long direction cannot be told from the up direction.")
|
||||
|
||||
# Verbatim shape of the THIRD refusal (not yet confirmed for this
|
||||
# orientation) -- must NOT flag either; this is a coverage gap, not a
|
||||
# defect on the model. Matches `up_end_from_origin()`'s wording as fixed
|
||||
# 2026-09-01: green (BasisY/world-up) is now confirmed for a vertical wall
|
||||
# panel too, so the message no longer claims nobody has said how vertical
|
||||
# panels work -- only that this function has not been extended to run
|
||||
# there, which is a coverage gap and NOT a defect on the origin.
|
||||
NOT_HORIZONTAL_WHY = (
|
||||
"The origin rule is confirmed for HORIZONTAL panels only, and the "
|
||||
"origin of assembly PNL1 stands its length up, which is not one. The "
|
||||
"owner has confirmed the same green-arrow reading also holds on a "
|
||||
"vertical wall panel (2026-09-01), but this rule has not been "
|
||||
"extended to run there yet, so the origin is not read on this piece.")
|
||||
|
||||
|
||||
def _panel_with_fallback(why, basis='global elevation'):
|
||||
panel = make_panel()
|
||||
panel.source = {
|
||||
'up_end_basis': basis,
|
||||
'up_end_basis_why': why,
|
||||
'up_end_affects': 'erection -- which row of face lifters the crane '
|
||||
'cannot reach, and which edge the anchors are on',
|
||||
}
|
||||
return panel
|
||||
|
||||
|
||||
# ------------------------------------------------- _up_end_is_origin_defect
|
||||
|
||||
def test_the_origin_not_up_refusal_is_flagged():
|
||||
assert report._up_end_is_origin_defect(ORIGIN_NOT_UP_WHY) is True
|
||||
|
||||
|
||||
def test_the_near_square_refusal_is_not_flagged():
|
||||
assert report._up_end_is_origin_defect(NEAR_SQUARE_WHY) is False
|
||||
|
||||
|
||||
def test_the_not_yet_confirmed_orientation_refusal_is_not_flagged():
|
||||
assert report._up_end_is_origin_defect(NOT_HORIZONTAL_WHY) is False
|
||||
|
||||
|
||||
def test_empty_or_none_why_is_not_flagged():
|
||||
assert report._up_end_is_origin_defect('') is False
|
||||
assert report._up_end_is_origin_defect(None) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------- _up_end_block()
|
||||
|
||||
def test_the_red_line_appears_when_the_origin_itself_is_wrong():
|
||||
"""WP401's real shape: the origin does not point up at all, so this is
|
||||
a defect on the assembly's own TOT, not a caveat about the analysis."""
|
||||
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
|
||||
block = report._up_end_block(panel)
|
||||
assert 'tot-flag' in block
|
||||
assert 'TOT' in block
|
||||
# The short fact is always visible; the measured reasoning ("does not
|
||||
# stand the piece up") is inside the fold, not repeated at top level.
|
||||
assert 'read from global elevation, not the assembly origin' in block
|
||||
assert 'does not stand the piece up' in block
|
||||
|
||||
|
||||
def test_the_block_is_short_a_few_lines_not_a_wall_of_text():
|
||||
"""The owner's own words, 2026-09-01: '把这一段从lift的结果里面删掉,
|
||||
或者缩短...太长了压根不会看的' -- delete this section or shorten it, it
|
||||
is so long nobody will read it at all. The block he cut was ~700
|
||||
characters; the always-visible text (outside the fold) must be a small
|
||||
fraction of that."""
|
||||
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
|
||||
block = report._up_end_block(panel)
|
||||
# Everything inside the fold is closed by default and does not count
|
||||
# against "what a reader sees at a glance" -- strip it out before
|
||||
# measuring, the same way a browser would hide it on load. Tags do not
|
||||
# count either: they are never visible text, only the characters
|
||||
# between them are.
|
||||
fold_start = block.index('<details')
|
||||
fold_end = block.index('</details>') + len('</details>')
|
||||
always_visible = block[:fold_start] + block[fold_end:]
|
||||
visible_text = re.sub(r'<[^>]+>', '', always_visible)
|
||||
assert len(visible_text) < 250, (len(visible_text), visible_text)
|
||||
|
||||
|
||||
def test_fallback_not_the_rule_appears_at_most_once():
|
||||
"""The old block said this exact clause TWICE in one paragraph -- once
|
||||
as the report's own intro sentence, once again from the end of the
|
||||
producer string it quoted. That repetition, not the length alone, was
|
||||
what the owner named as the reason he stopped reading."""
|
||||
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
|
||||
block = report._up_end_block(panel)
|
||||
assert block.count('fallback, not the rule') <= 1
|
||||
|
||||
|
||||
def test_the_full_reasoning_is_folded_using_the_report_s_own_mechanism():
|
||||
"""Not deleted, not moved to a different wall of visible text -- folded
|
||||
behind the SAME <details>/_sec() idiom every other section already
|
||||
uses, closed by default."""
|
||||
panel = _panel_with_fallback(ORIGIN_NOT_UP_WHY)
|
||||
block = report._up_end_block(panel)
|
||||
assert '<details class="section upend-why">' in block
|
||||
assert '<summary>Why</summary>' in block
|
||||
assert 'open' not in block.split('<details')[1].split('>')[0]
|
||||
# The full producer reasoning is still there -- just inside the fold.
|
||||
assert 'One assembly in 282' in block
|
||||
assert 'That is a fallback, not the rule.' in block
|
||||
|
||||
|
||||
def test_the_red_line_is_absent_for_the_near_square_refusal():
|
||||
"""A near-square piece is genuinely undecidable, not a broken origin --
|
||||
the red 'go check TOT' line would send the reader to fix a model that
|
||||
is not wrong."""
|
||||
panel = _panel_with_fallback(NEAR_SQUARE_WHY)
|
||||
block = report._up_end_block(panel)
|
||||
assert block # the disclosure itself still prints
|
||||
assert 'tot-flag' not in block
|
||||
assert 'TOT' not in block
|
||||
|
||||
|
||||
def test_the_red_line_is_absent_for_the_not_yet_confirmed_orientation():
|
||||
"""A vertical panel the rule has not been extended to cover yet is a
|
||||
known gap, not a defect -- same non-flag as the near-square case."""
|
||||
panel = _panel_with_fallback(NOT_HORIZONTAL_WHY)
|
||||
block = report._up_end_block(panel)
|
||||
assert block
|
||||
assert 'tot-flag' not in block
|
||||
assert 'TOT' not in block
|
||||
|
||||
|
||||
def test_no_block_at_all_when_the_ruled_mechanism_answered():
|
||||
"""`up_end_basis` == the ruled mechanism (`RULED_UP_END_BASIS`) means
|
||||
there is nothing to disclose -- and therefore nothing to flag."""
|
||||
panel = _panel_with_fallback('assembly origin should never get here',
|
||||
basis=report.RULED_UP_END_BASIS)
|
||||
assert report._up_end_block(panel) == ''
|
||||
|
||||
|
||||
def test_no_block_at_all_when_the_panel_has_no_source_disclosure():
|
||||
panel = make_panel()
|
||||
panel.source = {}
|
||||
assert report._up_end_block(panel) == ''
|
||||
|
||||
|
||||
def test_the_flag_uses_the_same_red_every_fail_on_the_page_already_uses():
|
||||
"""Match the report's existing severity styling rather than inventing a
|
||||
new colour -- var(--fail), the same variable `.FAIL` uses."""
|
||||
assert '.tot-flag{color:var(--fail)' in report.CSS
|
||||
|
||||
|
||||
def test_the_flag_is_small_text_not_a_banner():
|
||||
"""It sits inside the existing warn-styled .upend box; it must not carry
|
||||
its own background, border or oversized font that would read as a
|
||||
second banner on the page."""
|
||||
css = report.CSS
|
||||
start = css.index('.tot-flag{')
|
||||
end = css.index('}', start)
|
||||
rule = css[start:end]
|
||||
assert 'font-size:12px' in rule
|
||||
assert 'background' not in rule
|
||||
assert 'border' not in rule
|
||||
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Host tests for the ASCE 7 C&C wall wind-pressure engine (`windload.py`).
|
||||
|
||||
Two things are pinned here, both HAND COMPUTED with the arithmetic written into
|
||||
the test so a failure names the number that moved (PROJECT.md 3.7 discipline,
|
||||
same as test_capacity.py):
|
||||
|
||||
1. The velocity-pressure (qz) chain, and the 7-16 vs 7-22 Kd-placement
|
||||
difference: qh differs between editions by the factor Kd, but the final
|
||||
design pressure is identical.
|
||||
2. The GCp -> pressure step, with the figure-read +/-0.05 uncertainty carried
|
||||
onto the pressure.
|
||||
|
||||
NO CLIENT NUMBERS. Every input below is a generic, round ASCE example chosen so
|
||||
the closed-form arithmetic is reproducible on paper. The validation against the
|
||||
Eriksson golden sample (which carries client/job context) lives in scratchpad,
|
||||
not here -- see the module report.
|
||||
|
||||
Worked example used throughout (a textbook-style low-rise wall):
|
||||
|
||||
Vult = 115 mph Risk Category II Exposure C
|
||||
Enclosed Kzt = 1.0 Ke = 1.0
|
||||
mean roof height h = 30 ft (so Kh = Kz at 30 ft, Exp C = 0.98, both editions)
|
||||
Kd (C&C) = 0.85 effective wind area A = 100 ft^2
|
||||
|
||||
Chain arithmetic (Q_CONST = 0.00256, V^2 = 115^2 = 13225):
|
||||
7-22 qh = 0.00256 * 0.98 * 1.0 * 1.0 * 13225 = 33.17888 psf (no Kd)
|
||||
7-16 qh = 0.00256 * 0.98 * 1.0 * 0.85 * 1.0 * 13225 = 28.20205 psf (Kd in qh)
|
||||
ratio = 28.20205 / 33.17888 = 0.85 = Kd. <-- the version fork
|
||||
effective pressure multiplier q_eff = 28.20205 psf in BOTH editions
|
||||
(7-16: q_eff = qh; 7-22: q_eff = qh * Kd = 33.17888 * 0.85 = 28.20205)
|
||||
|
||||
GCp at A = 100 ft^2 (Fig. 30.3-1, log-linear between anchors A=10 and A=500):
|
||||
log10(100) = 2 ; log10(10) = 1 ; log10(500) = 2.698970 ; frac = 1/1.698970
|
||||
= 0.588593
|
||||
Zone 4 neg: -1.1 + (-0.8-(-1.1))*0.588593 = -1.1 + 0.176578 = -0.923422
|
||||
Zone 5 neg: -1.4 + ( 0.6)*0.588593 = -1.4 + 0.353156 = -1.046844
|
||||
positive : +1.0 + (-0.3)*0.588593 = +1.0 - 0.176578 = +0.823422
|
||||
|
||||
Zone 4 design pressures (GCpi = 0.18, enclosed), q_eff = 28.20205 psf:
|
||||
p_pos = q_eff * ( +0.823422 - (-0.18) ) = 28.20205 * 1.003422 = 28.2986 psf
|
||||
p_neg = q_eff * ( -0.923422 - (+0.18) ) = 28.20205 * -1.103422 = -31.117 psf
|
||||
uncertainty band = q_eff * 0.05 = 28.20205 * 0.05 = +/-1.4101 psf
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from panellift import windload
|
||||
from panellift.windload import compute
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ constants
|
||||
V = 115.0
|
||||
V2 = V * V # 13225
|
||||
Q = windload.Q_CONST_US # 0.00256
|
||||
KH_30_C = 0.98 # Table 26.10-1, 30 ft, Exposure C (both editions)
|
||||
KD = 0.85
|
||||
|
||||
|
||||
def _base(code_version, **kw):
|
||||
kwargs = dict(
|
||||
v_ult_mph=V, risk_category="II", exposure="C", enclosure="enclosed",
|
||||
code_version=code_version, mean_roof_height_ft=30.0,
|
||||
effective_wind_area_ft2=100.0,
|
||||
)
|
||||
kwargs.update(kw)
|
||||
return compute(**kwargs)
|
||||
|
||||
|
||||
# ============================================================ the qz chain
|
||||
def test_kh_table_lookup_and_version_difference():
|
||||
# 30 ft Exposure C is 0.98 in both editions...
|
||||
assert windload.velocity_pressure_coefficient(30.0, "C", "7-16") == 0.98
|
||||
assert windload.velocity_pressure_coefficient(30.0, "C", "7-22") == 0.98
|
||||
# ...but the Exposure B column was revised: 40 ft B is 0.76 in 7-16, 0.74 in
|
||||
# 7-22. This is a real version difference the engine must not smear.
|
||||
assert windload.velocity_pressure_coefficient(40.0, "B", "7-16") == 0.76
|
||||
assert windload.velocity_pressure_coefficient(40.0, "B", "7-22") == 0.74
|
||||
|
||||
|
||||
def test_kh_linear_interpolation():
|
||||
# 35 ft, Exp C, 7-16: halfway between 0.98 (30) and 1.04 (40) = 1.01.
|
||||
kh = windload.velocity_pressure_coefficient(35.0, "C", "7-16")
|
||||
assert kh == pytest.approx(1.01, abs=1e-9)
|
||||
|
||||
|
||||
def test_qh_chain_hand_computed_both_editions():
|
||||
r16 = _base("7-16")
|
||||
r22 = _base("7-22")
|
||||
|
||||
qh_722 = Q * KH_30_C * 1.0 * 1.0 * V2 # 33.17888
|
||||
qh_716 = Q * KH_30_C * 1.0 * KD * 1.0 * V2 # 28.20205
|
||||
assert r22.qh_psf.value == pytest.approx(qh_722, rel=1e-9)
|
||||
assert r16.qh_psf.value == pytest.approx(qh_716, rel=1e-9)
|
||||
|
||||
# The Kd-placement fork, stated as an invariant: 7-16 qh is smaller by Kd.
|
||||
assert r16.qh_psf.value == pytest.approx(r22.qh_psf.value * KD, rel=1e-12)
|
||||
assert r16.kd_placement == "in_qz"
|
||||
assert r22.kd_placement == "in_pressure"
|
||||
# qh values are genuinely different numbers (not the same reported twice).
|
||||
assert abs(r16.qh_psf.value - r22.qh_psf.value) > 4.0
|
||||
|
||||
|
||||
def test_ke_from_elevation_and_default():
|
||||
assert windload.ground_elevation_factor(None) == 1.0
|
||||
# 5000 ft -> exp(-0.0000362*5000) = exp(-0.181) = 0.83446 (Table 26.9-1 ~0.83)
|
||||
assert windload.ground_elevation_factor(5000.0) == pytest.approx(0.83446, abs=1e-4)
|
||||
|
||||
|
||||
# ==================================================== GCp -> pressure step
|
||||
def test_wall_gcp_log_linear_matches_note_intermediates():
|
||||
# The engine's log-linear read must reproduce the mined notes' derived
|
||||
# intermediate table (ASCE7-22-ch30-figGCp.md Sec 1.2).
|
||||
assert windload.wall_gcp(20.0, "zone4_neg") == pytest.approx(-1.05, abs=0.005)
|
||||
assert windload.wall_gcp(50.0, "zone4_neg") == pytest.approx(-0.98, abs=0.005)
|
||||
assert windload.wall_gcp(100.0, "zone4_neg") == pytest.approx(-0.92, abs=0.005)
|
||||
assert windload.wall_gcp(200.0, "zone4_neg") == pytest.approx(-0.87, abs=0.005)
|
||||
assert windload.wall_gcp(100.0, "zone5_neg") == pytest.approx(-1.05, abs=0.005)
|
||||
assert windload.wall_gcp(100.0, "pos") == pytest.approx(+0.82, abs=0.005)
|
||||
# plateaus hold outside [10, 500]
|
||||
assert windload.wall_gcp(5.0, "zone5_neg") == -1.4
|
||||
assert windload.wall_gcp(800.0, "pos") == +0.7
|
||||
|
||||
|
||||
def test_zone4_pressures_hand_computed():
|
||||
r = _base("7-16")
|
||||
z4 = next(z for z in r.zones if z.zone == 4)
|
||||
|
||||
q_eff = 28.20205
|
||||
gcp_pos = 0.823422
|
||||
gcp_neg = -0.923422
|
||||
p_pos = q_eff * (gcp_pos + 0.18) # 28.2986
|
||||
p_neg = q_eff * (gcp_neg - 0.18) # -31.117
|
||||
|
||||
assert z4.gcp_positive == pytest.approx(gcp_pos, abs=1e-4)
|
||||
assert z4.gcp_negative == pytest.approx(gcp_neg, abs=1e-4)
|
||||
assert z4.positive_psf == pytest.approx(p_pos, rel=1e-4)
|
||||
assert z4.negative_psf == pytest.approx(p_neg, rel=1e-4)
|
||||
|
||||
|
||||
def test_zone5_suction_exceeds_zone4():
|
||||
# Zone 5 (edge/corner strips) is the higher-magnitude suction than Zone 4.
|
||||
r = _base("7-22")
|
||||
z4 = next(z for z in r.zones if z.zone == 4)
|
||||
z5 = next(z for z in r.zones if z.zone == 5)
|
||||
assert abs(z5.negative_psf) > abs(z4.negative_psf)
|
||||
# positive is the single combined 4&5 curve -> same for both zones.
|
||||
assert z4.positive_psf == pytest.approx(z5.positive_psf, rel=1e-12)
|
||||
|
||||
|
||||
def test_final_pressure_identical_across_editions():
|
||||
# THE headline invariant: Kd sits in a different equation in each edition,
|
||||
# but the design pressure a caller receives is the same to the last psf.
|
||||
r16 = _base("7-16")
|
||||
r22 = _base("7-22")
|
||||
for zone in (4, 5):
|
||||
a = next(z for z in r16.zones if z.zone == zone)
|
||||
b = next(z for z in r22.zones if z.zone == zone)
|
||||
assert a.positive_psf == pytest.approx(b.positive_psf, rel=1e-12)
|
||||
assert a.negative_psf == pytest.approx(b.negative_psf, rel=1e-12)
|
||||
|
||||
|
||||
# ==================================================== figure-read uncertainty
|
||||
def test_figure_read_flag_and_uncertainty_carried():
|
||||
r = _base("7-16")
|
||||
q_eff = 28.20205
|
||||
for z in r.zones:
|
||||
assert z.figure_read is True
|
||||
assert z.gcp_pm == 0.05
|
||||
# +/-0.05 on a figure-read GCp propagates to +/- q_eff*0.05 psf.
|
||||
assert z.uncertainty_psf == pytest.approx(q_eff * 0.05, rel=1e-6)
|
||||
assert z.uncertainty_psf == pytest.approx(1.4101, abs=1e-3)
|
||||
|
||||
|
||||
def test_pressures_carry_a_cite():
|
||||
r = _base("7-22")
|
||||
assert "Fig. 30.3-1" in r.zones[0].basis
|
||||
assert "figure-read" in r.zones[0].basis
|
||||
assert "Eq. 26.10-1" in r.qh_psf.cite
|
||||
assert "Table 26.10-1" in r.kh.cite
|
||||
assert "Table 26.13-1" in r.gcpi.cite
|
||||
|
||||
|
||||
# ==================================================== enclosure / GCpi
|
||||
def test_partially_enclosed_uses_larger_gcpi():
|
||||
enc = _base("7-16", enclosure="enclosed")
|
||||
par = _base("7-16", enclosure="partially_enclosed")
|
||||
assert enc.gcpi.value == 0.18
|
||||
assert par.gcpi.value == 0.55
|
||||
# larger internal pressure -> larger design pressures (magnitudes).
|
||||
ze = next(z for z in enc.zones if z.zone == 4)
|
||||
zp = next(z for z in par.zones if z.zone == 4)
|
||||
assert abs(zp.negative_psf) > abs(ze.negative_psf)
|
||||
|
||||
|
||||
def test_open_building_gcpi_zero():
|
||||
r = _base("7-22", enclosure="open")
|
||||
assert r.gcpi.value == 0.0
|
||||
|
||||
|
||||
# ==================================================== roof-angle 10% reduction
|
||||
def test_roof_angle_reduction_applied_only_when_supplied():
|
||||
no_angle = _base("7-16") # roof_angle_deg None
|
||||
low_slope = _base("7-16", roof_angle_deg=5.0) # <= 10 deg -> 10% reduction
|
||||
z_none = next(z for z in no_angle.zones if z.zone == 4)
|
||||
z_low = next(z for z in low_slope.zones if z.zone == 4)
|
||||
# GCp reduced 10% when theta <= 10 deg (Fig. 30.3-1 Note 5).
|
||||
assert z_low.gcp_negative == pytest.approx(z_none.gcp_negative * 0.90, rel=1e-9)
|
||||
assert any("Note 5 applied" in n for n in low_slope.notes)
|
||||
assert any("NOT applied" in n for n in no_angle.notes)
|
||||
|
||||
|
||||
# ==================================================== minimum pressure floor
|
||||
def test_min_16psf_flag():
|
||||
# A very low wind + interior zone can fall under the 16 psf net floor.
|
||||
r = compute(v_ult_mph=70.0, risk_category="II", exposure="B",
|
||||
enclosure="enclosed", code_version="7-22",
|
||||
mean_roof_height_ft=15.0, effective_wind_area_ft2=500.0)
|
||||
z4 = next(z for z in r.zones if z.zone == 4)
|
||||
# positive at low speed / large area should trip the 16-psf floor flag.
|
||||
assert z4.min_governs_positive == (abs(z4.positive_psf) < 16.0)
|
||||
if z4.min_governs_positive:
|
||||
assert any("16 psf" in n for n in r.notes)
|
||||
|
||||
|
||||
# ==================================================== NOT-JUDGED paths
|
||||
def test_h_over_60_not_judged_names_the_figure():
|
||||
r16 = _base("7-16", mean_roof_height_ft=77.4)
|
||||
r22 = _base("7-22", mean_roof_height_ft=77.4)
|
||||
assert r16.not_judged and "NOT JUDGED" in r16.not_judged
|
||||
assert r16.zones == []
|
||||
assert "Fig. 30.5-1" in r16.not_judged and "Eq. 30.5-1" in r16.not_judged
|
||||
assert "Fig. 30.4-1" in r22.not_judged and "Eq. 30.4-1" in r22.not_judged
|
||||
|
||||
|
||||
def test_unknown_version_not_judged():
|
||||
r = _base("7-10")
|
||||
assert r.not_judged and "ASCE_CODE_VERSION" in r.not_judged
|
||||
assert r.zones == []
|
||||
|
||||
|
||||
def test_wrong_chapter_not_judged():
|
||||
r = _base("7-16", cc_chapter=27)
|
||||
assert r.not_judged and "Chapter 30" in r.not_judged
|
||||
|
||||
|
||||
def test_bad_exposure_and_enclosure_not_judged():
|
||||
assert _base("7-16", exposure="A").not_judged is not None
|
||||
assert _base("7-16", enclosure="mostly_shut").not_judged is not None
|
||||
|
||||
|
||||
def test_nonpositive_area_not_judged():
|
||||
assert _base("7-16", effective_wind_area_ft2=0).not_judged is not None
|
||||
|
||||
|
||||
# ==================================================== format smoke test
|
||||
def test_format_result_runs():
|
||||
txt = windload.format_result(_base("7-22"))
|
||||
assert "C&C wall pressures" in txt
|
||||
assert "Zone 4" in txt and "Zone 5" in txt
|
||||
txt2 = windload.format_result(_base("7-16", mean_roof_height_ft=100.0))
|
||||
assert "NOT JUDGED" in txt2
|
||||
Reference in New Issue
Block a user