Files
willtools-demo/beamcalc/src/beamcalc/deflection.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

274 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Camber and deflections the way the reference tool tabulates them.
Reverse-engineered from RBA5 p.14 ("Camber and Deflections"), which prints
one row per load case per stage with a multiplier, then the total:
* **Multipliers are ACI 318-19 24.2.4.1's long-term factor, not PCI's.**
Each row's multiplier is 1 + ξ / (1 + 50·ρ') with ρ' = A's / (b·d) at
midspan. RBA5 (ρ' = 1.76 / (16 × 20) = 0.0055): ξ = 2.0 gives 2.57 and
ξ = 1.0 gives 1.78 -- exactly the printed 2.57 (SW, Dc, sustained live,
final) and 1.78 (SW at start of construction; topping, final). The
settings page's "Self Weight 1 / 1.3 / 2" and "Add'l Dead - / 0 / 2" are
therefore ξ per stage (erection / construction / end), and live load
always carries 1.00.
* **Stiffness is the effective moment of inertia per station**, ACI 318-19
24.2.3.5 (Bischoff): Ie = Icr / (1 - (2/3·Mcr/Ma)²·(1 - Icr/Ig)) where
Ma > 2/3·Mcr, else Ig -- with Ig and Icr both TRANSFORMED (the tool's
"Transform Reinforcement: All"), Mcr = fr·Sb on the transformed section,
and Ma the service moment of the STAGE's total load at that station.
At the start of construction only self weight is on, nothing cracks, and
SW deflects on the uncracked section with Eci (printed 0.029 / 1.78 =
0.0163" -- ours 0.0160"). At the final stage the same self weight
deflects on the cracked stiffness of the FULL service load (0.113 / 2.57
= 0.044").
* Self weight uses Eci when the setting says so; every other case Ec.
* Live load is patterned; the reported live deflection is the worst
pattern's, and the total takes the worst pattern too.
"""
from __future__ import annotations
import math
from typing import Dict, List, Optional, Sequence, Tuple
from . import loads as ld, section as sec_mod, statics
from .model import Beam
def cracked_inertia(beam: Beam, x_in: float, positive: bool = True,
ec_ksi: Optional[float] = None) -> Tuple[float, float, float]:
"""(Icr, kd, A_cr) of the cracked transformed section at x, elastic
(n·As tension, (n-1)·A's compression), solving the NA depth kd from
first moments. Positive bending: compression at the top."""
mats = beam.materials
ec = ec_ksi or mats.Ec
n_bar = mats.rebar.es_ksi / ec
n_str = mats.strand.ep_ksi / ec
sec = beam.section
h = sec.height_in
layers = [] # (depth from compression face, area, n)
for bx, by, area, db, e0, e1, row in beam.bars_at(x_in):
depth = (h - by) if positive else by
layers.append((depth, area, n_bar))
for g in beam.strands:
for sx, sy, area in g.strands(sec):
depth = (h - sy) if positive else sy
layers.append((depth, area, n_str))
if not any(d > 0 for d, a, nn in layers):
return sec.ixx, h, sec.area
block = sec.compression_area if positive else sec.compression_area_from_bottom
def first_moment(kd):
a, ybar = block(kd)
q = a * (kd - ybar) # concrete about the NA (above it)
for depth, area, nn in layers:
eq = (nn - 1.0) * area if depth < kd else nn * area
q += eq * (kd - depth) # positive above the NA
return q
lo, hi = 1e-4, h
for _ in range(100):
mid = (lo + hi) / 2.0
if first_moment(mid) > 0:
hi = mid
else:
lo = mid
kd = (lo + hi) / 2.0
a_c, ybar = block(kd)
# concrete: I about its own centroid + shift to the NA
icr = 0.0
top = h if positive else 0.0
for x0, y0, w, hh in sec.rects:
# part of this rect within the compression block
if positive:
lo_y = max(y0, h - kd)
hi_y = min(y0 + hh, h)
else:
lo_y = max(y0, 0.0)
hi_y = min(y0 + hh, kd)
if hi_y > lo_y:
d_h = hi_y - lo_y
centre = (lo_y + hi_y) / 2.0
depth_c = (h - centre) if positive else centre
icr += w * d_h ** 3 / 12.0 + w * d_h * (kd - depth_c) ** 2
a_cr = a_c
for depth, area, nn in layers:
eq = (nn - 1.0) * area if depth < kd else nn * area
icr += eq * (depth - kd) ** 2
a_cr += eq
return icr, kd, a_cr
def cracking_moment_kin(beam: Beam, x_in: float, positive: bool = True) -> float:
mats = beam.materials
fr = 7.5 * mats.lam * math.sqrt(mats.fc_ksi * 1000.0) / 1000.0
p = sec_mod.props_at(beam, x_in)
return fr * (p.sb if positive else p.st)
def effective_inertia(beam: Beam, x_in: float, ma_kin: float) -> float:
"""ACI 318-19 24.2.3.5 at one station for the service moment `ma`."""
positive = ma_kin >= 0
ig = sec_mod.props_at(beam, x_in).ixx
mcr = cracking_moment_kin(beam, x_in, positive)
ma = abs(ma_kin)
if ma <= (2.0 / 3.0) * mcr or mcr <= 0:
return ig
icr, kd, a_cr = cracked_inertia(beam, x_in, positive)
if icr >= ig:
return ig
denom = 1.0 - ((2.0 / 3.0) * mcr / ma) ** 2 * (1.0 - icr / ig)
return min(ig, icr / denom) if denom > 0 else ig
def long_term_multiplier(beam: Beam, xi: float) -> float:
"""1 + ξ / (1 + 50·ρ'), ρ' = compression steel ratio at midspan
(ACI 318-19 24.2.4.1.1 / 24.2.4.1.3)."""
if xi <= 0:
return 1.0
x_mid = beam.length_in / 2.0
h = beam.section.height_in
bars = beam.bars_at(x_mid)
# compression steel for positive bending = bars in the top half
as_c = sum(a for bx, by, a, db, e0, e1, row in bars if by > h / 2.0)
tens = [(a, h - by) for bx, by, a, db, e0, e1, row in bars if by <= h / 2.0]
d = (sum(a * dd for a, dd in tens) / sum(a for a, _ in tens)) if tens else 0.9 * h
b = beam.section.width_at(h - 1e-6) or beam.section.width_in
rho_c = as_c / (b * d) if b * d > 0 else 0.0
return 1.0 + xi / (1.0 + 50.0 * rho_c)
def _deflect(beam: Beam, loads, grid, ei) -> List[float]:
if not loads:
return [0.0] * len(grid)
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
res = statics.solve(loads, xa, xb, grid)
return statics.deflect(res, ei, xa, xb)
def analyse(beam: Beam, grid: Sequence[float]) -> dict:
s = beam.settings
mats = beam.materials
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
span = xb - xa
grid = list(grid)
n = len(grid)
e_sw = mats.Eci if s.ec_for_sw_defl.lower() == 'eci' else mats.Ec
ec = mats.Ec
sw = ld.self_weight(beam)
dnc = ld.case_loads(beam, 'deadnc')
dt = ld.case_loads(beam, 'topping')
dc = ld.case_loads(beam, 'deadc')
sus = ld.case_loads(beam, 'sus_live')
roof = ld.case_loads(beam, 'roof')
live_full = ld.case_loads(beam, 'live')
# multipliers (ξ per stage from the settings)
m_sw_start = long_term_multiplier(beam, s.sw_mult.get('erection', 1.0))
m_sw_final = long_term_multiplier(beam, s.sw_mult.get('end', 2.0))
m_dead_final = long_term_multiplier(beam, s.addl_dead_mult.get('end', 2.0))
m_top_final = long_term_multiplier(beam, s.sw_mult.get('erection', 1.0))
m_sus_final = m_dead_final
# --- start of construction: self weight (+ Dnc + Dt) on the uncracked
# section, judged for cracking under that load alone
start_loads = sw + dnc + dt
ma_start = statics.solve(start_loads, xa, xb, grid).moment
ie_start = [effective_inertia(beam, x, ma_start[i]) for i, x in enumerate(grid)]
d_sw_start = _deflect(beam, sw, grid, [e_sw * ie for ie in ie_start])
d_dnc_start = _deflect(beam, dnc, grid, [ec * ie for ie in ie_start])
d_dt_start = _deflect(beam, dt, grid, [ec * ie for ie in ie_start])
start_total = [m_sw_start * d_sw_start[i] + d_dnc_start[i] + d_dt_start[i]
for i in range(n)]
# --- final: worst live pattern for the total and for the live row
best = None
for pat in ld.patterns(beam):
live = ld.live_loads_for(beam, pat, 'live')
roof_p = ld.live_loads_for(beam, pat, 'roof')
# Two stiffnesses: the SUSTAINED rows (self weight, dead, topping,
# sustained live) deflect on Ie judged under the sustained load
# alone; the transient rows (live, roof) on Ie under the full
# service load. Measured on RBA5: with one Ie for everything the
# dead rows came out 5 % too soft (0.519 vs 0.495) while the live
# row was exact; Ie from the dead-only moment closes that gap.
sust_loads = sw + dnc + dt + dc + sus
final_loads = sust_loads + live + roof_p
ma_s = statics.solve(sust_loads, xa, xb, grid).moment
ma = statics.solve(final_loads, xa, xb, grid).moment
ie_s = [effective_inertia(beam, x, ma_s[i]) for i, x in enumerate(grid)]
ie = [effective_inertia(beam, x, ma[i]) for i, x in enumerate(grid)]
ei_sw = [e_sw * v for v in ie_s]
ei_s = [ec * v for v in ie_s]
ei_c = [ec * v for v in ie]
rows = {
'SW': (m_sw_final, _deflect(beam, sw, grid, ei_sw)),
'Dnc': (m_dead_final, _deflect(beam, dnc, grid, ei_s)),
'Dt': (m_top_final, _deflect(beam, dt, grid, ei_s)),
'Dc': (m_dead_final, _deflect(beam, dc, grid, ei_s)),
'Live': (1.0, _deflect(beam, live, grid, ei_c)),
'Sust. Live': (m_sus_final, _deflect(beam, sus, grid, ei_s)),
'Roof': (1.0, _deflect(beam, roof_p, grid, ei_c)),
}
total = [sum(m * d[i] for m, d in rows.values()) for i in range(n)]
sustained = [sum(m * d[i] for k, (m, d) in rows.items()
if k not in ('Live', 'Roof')) for i in range(n)]
live_row = [rows['Live'][1][i] + rows['Roof'][1][i] for i in range(n)]
key = max(abs(t) for t in total)
if best is None or key > best[0]:
best = (key, rows, total, sustained, live_row, ie, pat)
_, rows, total, sustained, live_row, ie, pat = best
idx = list(range(n)) # the tool reports over the whole length
# --- "prior / after installation of non-structural elements" split.
# RBA1/2/3/7 print the final stage in two blocks -- SW + Dnc (with their
# long-term multipliers) as "prior", Dt + Dc + live + sustained live +
# roof as "after" -- and judge the AFTER block against the total-load
# limit (RBA1: table total 0.423", reported "Defl. Total" 0.023" = the
# after block, limit L/480 -> PASS). RBA5 (composite, no Dnc load) and
# RBA6 ("Precast Only", Dnc present) print one block and judge the
# whole total. Inferred rule from those six packages: the split is
# made when the section behaves compositely AND a non-composite dead
# load exists. ACI 318-19 Table 24.2.2's "part of the total deflection
# occurring after attachment of nonstructural elements" is the idea.
composite = (s.composite_shear or '').lower() not in ('precast_only', 'precast only', 'none')
split = composite and any(abs(v) > 1e-12 for v in rows['Dnc'][1])
prior = [rows['SW'][0] * rows['SW'][1][i] + rows['Dnc'][0] * rows['Dnc'][1][i]
for i in range(n)]
after = [total[i] - prior[i] for i in range(n)]
judged = after if split else total
t_down = max(judged[i] for i in idx)
t_up = min(judged[i] for i in idx)
l_down = max(live_row[i] for i in idx)
l_up = min(live_row[i] for i in idx)
lim_t = span / s.total_defl_ratio
lim_l = span / s.live_defl_ratio
cracked = sum(1 for i in idx if ie[i] < sec_mod.props_at(beam, grid[i]).ixx - 1e-6)
return {
'span_in': span,
'total_down': t_down, 'total_up': -t_up, 'total_limit': lim_t,
'live_down': l_down, 'live_up': -l_up, 'live_limit': lim_l,
'total_util': max(t_down, -t_up) / lim_t if lim_t else 0.0,
'live_util': max(l_down, -l_up) / lim_l if lim_l else 0.0,
'total_ok': max(t_down, -t_up) <= lim_t + 1e-9,
'live_ok': max(l_down, -l_up) <= lim_l + 1e-9,
'rows': {k: {'multiplier': m, 'elastic': d} for k, (m, d) in rows.items()},
'total': total, 'sustained': sustained, 'live': live_row,
'prior': prior, 'after': after, 'split': split, 'judged': judged,
'table_total_down': max(total), 'table_total_up': -min(total),
'start_total': start_total, 'start_sw_multiplier': m_sw_start,
'ie': ie, 'pct_cracked': 100.0 * cracked / n if n else 0.0,
'multipliers': {'self_weight': m_sw_final, 'addl_dead': m_dead_final,
'topping': m_top_final, 'live': 1.0},
'e_sw': e_sw, 'e': ec,
'i': sec_mod.props_at(beam, (xa + xb) / 2.0).ixx,
'icr_mid': cracked_inertia(beam, (xa + xb) / 2.0)[0],
'pattern': pat,
'max_start': min(start_total) if start_total else 0.0,
'max_sustained': min(sustained) if sustained else 0.0,
}