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,363 @@
|
||||
{
|
||||
"schema": "beamcalc/1",
|
||||
"mark": "RBA1.0",
|
||||
"description": "8\" x 48-1/4\" transfer beam, 14'-6\" long, mild reinforced Grade 80, uniform transfer wall load (reference package RBA1 run 1, Eriksson Beam v5.9.1; the package's own f'ci was typed as 3500 ksi and is kept so its limits reproduce)",
|
||||
"materials": {
|
||||
"fc_ksi": 8,
|
||||
"fci_ksi": 3500,
|
||||
"wc_pcf": 150,
|
||||
"ec_ksi": 5422,
|
||||
"eci_ksi": 113419,
|
||||
"type": "normalweight",
|
||||
"lambda": 1.0,
|
||||
"rebar": {
|
||||
"fy_ksi": 80,
|
||||
"es_ksi": 29000
|
||||
},
|
||||
"trans": {
|
||||
"fy_ksi": 60,
|
||||
"es_ksi": 29000
|
||||
}
|
||||
},
|
||||
"extents": {
|
||||
"length_ft": 14.5,
|
||||
"section": {
|
||||
"type": "rectangular",
|
||||
"width_in": 8,
|
||||
"height_in": 48.25
|
||||
}
|
||||
},
|
||||
"supports": {
|
||||
"start": {
|
||||
"type": "pin",
|
||||
"from_start_in": 8,
|
||||
"length_in": 17,
|
||||
"width_in": 7
|
||||
},
|
||||
"end": {
|
||||
"type": "pin",
|
||||
"from_end_in": 8,
|
||||
"length_in": 17,
|
||||
"width_in": 7
|
||||
}
|
||||
},
|
||||
"bearing": {
|
||||
"analyse": true,
|
||||
"mu": 1.4,
|
||||
"nu_ratio": 0.2,
|
||||
"cr": 1.0
|
||||
},
|
||||
"combos": {
|
||||
"strength": [
|
||||
{
|
||||
"D": 1.4,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 0,
|
||||
"E": 0,
|
||||
"include": true
|
||||
},
|
||||
{
|
||||
"D": 1.2,
|
||||
"L": 1.6,
|
||||
"R": 0.5,
|
||||
"W": 0,
|
||||
"E": 0,
|
||||
"include": true
|
||||
},
|
||||
{
|
||||
"D": 1.2,
|
||||
"L": 1.0,
|
||||
"R": 1.6,
|
||||
"W": 0,
|
||||
"E": 0,
|
||||
"include": true
|
||||
},
|
||||
{
|
||||
"D": 1.2,
|
||||
"L": 0,
|
||||
"R": 1.6,
|
||||
"W": 0.5,
|
||||
"E": 0,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 1.2,
|
||||
"L": 1.0,
|
||||
"R": 0.5,
|
||||
"W": 1.0,
|
||||
"E": 0,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 1.2,
|
||||
"L": 1.0,
|
||||
"R": 0.2,
|
||||
"W": 0,
|
||||
"E": 1.0,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 0.9,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 1.0,
|
||||
"E": 0,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 0.9,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 0,
|
||||
"E": 1.0,
|
||||
"include": false
|
||||
}
|
||||
],
|
||||
"service": [
|
||||
{
|
||||
"D": 1,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 0,
|
||||
"E": 0,
|
||||
"include": true
|
||||
},
|
||||
{
|
||||
"D": 1,
|
||||
"L": 1,
|
||||
"R": 0,
|
||||
"W": 0,
|
||||
"E": 0,
|
||||
"include": true
|
||||
},
|
||||
{
|
||||
"D": 1,
|
||||
"L": 0,
|
||||
"R": 1,
|
||||
"W": 0,
|
||||
"E": 0,
|
||||
"include": true
|
||||
},
|
||||
{
|
||||
"D": 1,
|
||||
"L": 0.75,
|
||||
"R": 0.75,
|
||||
"W": 0,
|
||||
"E": 0,
|
||||
"include": true
|
||||
},
|
||||
{
|
||||
"D": 1,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 0.6,
|
||||
"E": 0,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 1,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 0,
|
||||
"E": 0.7,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 1,
|
||||
"L": 0.75,
|
||||
"R": 0.75,
|
||||
"W": 0.45,
|
||||
"E": 0,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 1,
|
||||
"L": 0.75,
|
||||
"R": 0.75,
|
||||
"W": 0,
|
||||
"E": 0.525,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 0.6,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 0.6,
|
||||
"E": 0,
|
||||
"include": false
|
||||
},
|
||||
{
|
||||
"D": 0.6,
|
||||
"L": 0,
|
||||
"R": 0,
|
||||
"W": 0,
|
||||
"E": 0.7,
|
||||
"include": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"deflection": {
|
||||
"total": "L/480",
|
||||
"live": "L/360",
|
||||
"ec_for_sw": "Eci",
|
||||
"multipliers": {
|
||||
"self_weight": {
|
||||
"erection": 1,
|
||||
"construction": 1.3,
|
||||
"end": 2
|
||||
},
|
||||
"addl_dead": {
|
||||
"construction": 0,
|
||||
"end": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"shear": {
|
||||
"composite": "varying_strength",
|
||||
"dv_method": "cg_tension",
|
||||
"min_method": "beam_9.6.3"
|
||||
},
|
||||
"flexure": {
|
||||
"method": "whitney",
|
||||
"ignored_distance_in": 0,
|
||||
"strain_limit": 0.004
|
||||
},
|
||||
"transfer_limits": {
|
||||
"comp_ends": 0.7,
|
||||
"comp_other": 0.7,
|
||||
"tens_ends_sqrt": 6,
|
||||
"tens_other_sqrt": 6
|
||||
},
|
||||
"service_limits": {
|
||||
"comp_sustained": 0.45,
|
||||
"comp_total": 0.6
|
||||
},
|
||||
"phi": {
|
||||
"tension": 0.9,
|
||||
"shear": 0.75,
|
||||
"compression": 0.65
|
||||
},
|
||||
"development": {
|
||||
"ktr": 0,
|
||||
"mild_mult": 1
|
||||
},
|
||||
"section_properties": {
|
||||
"transform": true
|
||||
}
|
||||
},
|
||||
"loads": {
|
||||
"self_weight_mult": 1,
|
||||
"pattern_live": true,
|
||||
"point": [],
|
||||
"line": [
|
||||
{
|
||||
"id": "L1",
|
||||
"x0_in": 0,
|
||||
"x0_from": "start",
|
||||
"x1_in": 0,
|
||||
"x1_from": "end",
|
||||
"ecc_in": 0,
|
||||
"deadnc": 45.7,
|
||||
"live": 7.0,
|
||||
"roof": 0.53
|
||||
}
|
||||
]
|
||||
},
|
||||
"handling": {
|
||||
"min_sling_deg": 60,
|
||||
"stripping": {
|
||||
"multiplier": 1.4,
|
||||
"face_down": "bottom",
|
||||
"strength": "initial",
|
||||
"limits": "transfer",
|
||||
"pick_points": 2,
|
||||
"lift_from_start_in": 24,
|
||||
"lift_from_end_in": 24
|
||||
},
|
||||
"handling": {
|
||||
"multiplier": 1.5,
|
||||
"dunnage_from_start_in": 24,
|
||||
"dunnage_from_end_in": 24
|
||||
},
|
||||
"erection": {
|
||||
"multiplier": 1.2,
|
||||
"lift_from_start_in": 24,
|
||||
"lift_from_end_in": 24
|
||||
}
|
||||
},
|
||||
"reinforcement": {
|
||||
"bars": [
|
||||
{
|
||||
"size": "#11",
|
||||
"rows": 1,
|
||||
"cols": 2,
|
||||
"left_in": 3,
|
||||
"right_in": 3,
|
||||
"top_in": 44.25,
|
||||
"bottom_in": 4,
|
||||
"start_in": 2,
|
||||
"end_in": 2
|
||||
},
|
||||
{
|
||||
"size": "#9",
|
||||
"rows": 1,
|
||||
"cols": 2,
|
||||
"left_in": 2.5,
|
||||
"right_in": 2.5,
|
||||
"top_in": 41.25,
|
||||
"bottom_in": 7,
|
||||
"start_in": 2,
|
||||
"end_in": 2
|
||||
},
|
||||
{
|
||||
"size": "#9",
|
||||
"rows": 1,
|
||||
"cols": 2,
|
||||
"left_in": 2.5,
|
||||
"right_in": 2.5,
|
||||
"top_in": 38.25,
|
||||
"bottom_in": 10,
|
||||
"start_in": 2,
|
||||
"end_in": 2
|
||||
},
|
||||
{
|
||||
"size": "#7",
|
||||
"rows": 1,
|
||||
"cols": 2,
|
||||
"left_in": 2,
|
||||
"right_in": 2,
|
||||
"top_in": 2,
|
||||
"bottom_in": 46.25,
|
||||
"start_in": 2,
|
||||
"end_in": 2
|
||||
}
|
||||
],
|
||||
"strands": [],
|
||||
"transverse": [
|
||||
{
|
||||
"use": "Av",
|
||||
"size": "#4",
|
||||
"legs": 2,
|
||||
"first_in": 2,
|
||||
"from": "start",
|
||||
"spacing_in": 4,
|
||||
"length_in": 60,
|
||||
"qty": 16,
|
||||
"symmetric": true
|
||||
},
|
||||
{
|
||||
"use": "Av",
|
||||
"size": "#4",
|
||||
"legs": 2,
|
||||
"first_in": 68,
|
||||
"from": "start",
|
||||
"spacing_in": 6,
|
||||
"length_in": 20,
|
||||
"qty": 4,
|
||||
"symmetric": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"schema": "beamcalc/1",
|
||||
"mark": "RBA5.0",
|
||||
"description": "16RB22 beam, 17'-11\" long, mild reinforced, transfer loads from a flat slab (reference package RBA5, Eriksson Beam v5.9.1)",
|
||||
"materials": {"fc_ksi": 5, "fci_ksi": 2.5, "wc_pcf": 150, "ec_ksi": 4287, "eci_ksi": 3031,
|
||||
"type": "normalweight", "lambda": 1.0,
|
||||
"rebar": {"fy_ksi": 60, "es_ksi": 29000}, "trans": {"fy_ksi": 60, "es_ksi": 29000}},
|
||||
"extents": {"length_ft": 17.916667, "section": {"type": "rectangular", "width_in": 16, "height_in": 22}},
|
||||
"supports": {"start": {"type": "pin", "from_start_in": 2, "length_in": 4, "width_in": 10},
|
||||
"end": {"type": "pin", "from_end_in": 2, "length_in": 4, "width_in": 10}},
|
||||
"bearing": {"analyse": true, "mu": 1.4, "nu_ratio": 0.2},
|
||||
"combos": {
|
||||
"strength": [
|
||||
{"D": 1.4, "L": 0, "R": 0, "W": 0, "E": 0, "include": true},
|
||||
{"D": 1.2, "L": 1.6, "R": 0.5, "W": 0, "E": 0, "include": true},
|
||||
{"D": 1.2, "L": 1.0, "R": 1.6, "W": 0, "E": 0, "include": true},
|
||||
{"D": 1.2, "L": 0, "R": 1.6, "W": 0.5, "E": 0, "include": false},
|
||||
{"D": 1.2, "L": 1.0, "R": 0.5, "W": 1.0, "E": 0, "include": false},
|
||||
{"D": 1.2, "L": 1.0, "R": 0.2, "W": 0, "E": 1.0, "include": false},
|
||||
{"D": 0.9, "L": 0, "R": 0, "W": 1.0, "E": 0, "include": false},
|
||||
{"D": 0.9, "L": 0, "R": 0, "W": 0, "E": 1.0, "include": false}],
|
||||
"service": [
|
||||
{"D": 1, "L": 0, "R": 0, "W": 0, "E": 0, "include": true},
|
||||
{"D": 1, "L": 1, "R": 0, "W": 0, "E": 0, "include": true},
|
||||
{"D": 1, "L": 0, "R": 1, "W": 0, "E": 0, "include": true},
|
||||
{"D": 1, "L": 0.75, "R": 0.75, "W": 0, "E": 0, "include": true},
|
||||
{"D": 1, "L": 0, "R": 0, "W": 0.6, "E": 0, "include": false},
|
||||
{"D": 1, "L": 0, "R": 0, "W": 0, "E": 0.7, "include": false},
|
||||
{"D": 1, "L": 0.75, "R": 0.75, "W": 0.45, "E": 0, "include": false},
|
||||
{"D": 1, "L": 0.75, "R": 0.75, "W": 0, "E": 0.525, "include": false},
|
||||
{"D": 0.6, "L": 0, "R": 0, "W": 0.6, "E": 0, "include": false},
|
||||
{"D": 0.6, "L": 0, "R": 0, "W": 0, "E": 0.7, "include": false}]},
|
||||
"settings": {
|
||||
"deflection": {"total": "L/240", "live": "L/360", "ec_for_sw": "Eci",
|
||||
"multipliers": {"self_weight": {"erection": 1, "construction": 1.3, "end": 2},
|
||||
"addl_dead": {"construction": 0, "end": 2}}},
|
||||
"shear": {"composite": "varying_strength", "dv_method": "cg_tension", "min_method": "beam_9.6.3"},
|
||||
"flexure": {"method": "whitney", "ignored_distance_in": 0, "strain_limit": 0.004},
|
||||
"transfer_limits": {"comp_ends": 0.7, "comp_other": 0.7, "tens_ends_sqrt": 6, "tens_other_sqrt": 6},
|
||||
"service_limits": {"comp_sustained": 0.45, "comp_total": 0.6},
|
||||
"phi": {"tension": 0.9, "shear": 0.75, "compression": 0.65},
|
||||
"development": {"ktr": 0, "mild_mult": 1},
|
||||
"section_properties": {"transform": true}},
|
||||
"loads": {
|
||||
"self_weight_mult": 1, "pattern_live": true,
|
||||
"point": [{"id": "P1", "x_in": 110, "from": "end", "ecc_in": 0, "deadc": 8.3}],
|
||||
"line": [{"id": "L1", "x0_in": 0, "x0_from": "start", "x1_in": 110, "x1_from": "end",
|
||||
"ecc_in": 0, "deadc": 3.05, "live": 1.1}]},
|
||||
"handling": {"min_sling_deg": 60,
|
||||
"stripping": {"multiplier": 1.4, "face_down": "bottom", "strength": "initial",
|
||||
"limits": "transfer", "pick_points": 2,
|
||||
"lift_from_start_in": 6, "lift_from_end_in": 6}},
|
||||
"reinforcement": {
|
||||
"bars": [
|
||||
{"size": "#6", "rows": 1, "cols": 4, "left_in": 2, "right_in": 2, "top_in": 2, "bottom_in": 20, "start_in": 2, "end_in": 2},
|
||||
{"size": "#6", "rows": 1, "cols": 6, "left_in": 2, "right_in": 2, "top_in": 20, "bottom_in": 2, "start_in": 2, "end_in": 2}],
|
||||
"strands": [],
|
||||
"transverse": [
|
||||
{"use": "Av", "size": "#4", "legs": 2, "first_in": 2, "from": "start", "spacing_in": 4, "length_in": 36, "qty": 10, "symmetric": true},
|
||||
{"use": "Av", "size": "#4", "legs": 2, "first_in": 46, "from": "start", "spacing_in": 10, "length_in": 60, "qty": 7, "symmetric": true},
|
||||
{"use": "Av", "size": "#4", "legs": 2, "first_in": 2, "from": "start", "spacing_in": 4, "length_in": 36, "qty": 10, "symmetric": true},
|
||||
{"use": "Av", "size": "#4", "legs": 2, "first_in": 46, "from": "start", "spacing_in": 10, "length_in": 60, "qty": 7, "symmetric": true}]}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "beamcalc"
|
||||
version = "0.1.0"
|
||||
description = "ACI 318-19 / PCI 8th precast beam checks -- the CPython engine behind Beam Analysis"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [] # deliberately none, same rule as panellift
|
||||
|
||||
[project.scripts]
|
||||
beamcalc = "beamcalc.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""beamcalc -- the CPython engine behind WillTools' Beam Analysis.
|
||||
|
||||
A precast beam (rectangular, L / spandrel, inverted-T; mild and/or
|
||||
prestressed) on two bearings with optional overhangs, checked the way the
|
||||
reference tool (Eriksson Beam v5.9) reports it: stripping stresses, in-place
|
||||
strength (flexure, shear, torsion), in-place service (stresses, deflection)
|
||||
and bearing. Input is one JSON job file (`beamcalc/1`, see `model.py`);
|
||||
output is an `Analysis` and an HTML report. Nothing here imports Revit.
|
||||
|
||||
Units inside the engine: inches, kips, ksi. Feet appear only at the
|
||||
boundary (the job file, the report).
|
||||
"""
|
||||
SCHEMA = 'beamcalc/1'
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
import sys
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`python -m beamcalc job.json [-o report.html] [--json summary.json]`
|
||||
|
||||
Reads one `beamcalc/1` job, runs every check, writes the HTML report and
|
||||
prints a one-line verdict. Exit 0 on PASS, 1 on FAIL, 2 when the job could
|
||||
not be read (the message names the key)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from . import model, report, solve
|
||||
|
||||
|
||||
def summary(a: solve.Analysis) -> dict:
|
||||
worst_f = max(a.flex, key=lambda f: f.util) if a.flex else None
|
||||
worst_s = max(a.shr, key=lambda s: s.r.util) if a.shr else None
|
||||
out = {
|
||||
'mark': a.beam.mark,
|
||||
'ok': a.ok,
|
||||
'stages': [{'name': s.name, 'ok': s.ok, 'util': s.util} for s in a.stages],
|
||||
'flexure': {'util': worst_f.util, 'mu': worst_f.mu, 'phi_mn': worst_f.phi_mn,
|
||||
'x_ft': worst_f.x_ft, 'combo': worst_f.combo} if worst_f else None,
|
||||
'shear': {'util': worst_s.r.util, 'vu': worst_s.r.vu, 'phi_vn': worst_s.r.phi_vn,
|
||||
'vc': worst_s.r.vc, 'x_ft': worst_s.x_ft,
|
||||
'av_req_max': max(s.r.av_req for s in a.shr)} if worst_s else None,
|
||||
'deflection': {k: a.deflection[k] for k in ('total_down', 'total_up', 'total_limit',
|
||||
'live_down', 'live_up', 'live_limit')},
|
||||
'reactions': {k: list(v) for k, v in a.reactions.items()},
|
||||
'bearing': {k: {kk: vv for kk, vv in v.items() if kk != 'note'}
|
||||
for k, v in a.bearing.items()},
|
||||
'stripping': ({k: v for k, v in a.stripping.items() if k != 'result'}
|
||||
if a.stripping else None),
|
||||
'stations': [{'x_ft': f.x_ft, 'mu': f.mu, 'phi_mn': f.phi_mn, 'mcr12': f.mcr12,
|
||||
'util': f.util, 'c': f.c, 'as': f.as_t, 'as_c': f.as_c, 'd': f.d,
|
||||
'vu': s.r.vu, 'phi_vn': s.r.phi_vn, 'vc': s.r.vc, 'vs': s.r.vs_prov,
|
||||
'bwd': s.r.bwd, 'av_req': s.r.av_req, 'av_prov': s.r.av_prov,
|
||||
'f_act': s.r.f_act, 'f_alw': s.r.f_alw}
|
||||
for f, s in zip(a.flex, a.shr)],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(prog='beamcalc')
|
||||
ap.add_argument('job')
|
||||
ap.add_argument('-o', '--out', help='HTML report path')
|
||||
ap.add_argument('--json', help='write a JSON summary here')
|
||||
args = ap.parse_args(argv)
|
||||
try:
|
||||
beam = model.load_job(args.job)
|
||||
except (model.JobError, OSError, ValueError) as e:
|
||||
print('COULD NOT READ JOB: {0}'.format(e), file=sys.stderr)
|
||||
return 2
|
||||
a = solve.analyse(beam)
|
||||
if args.out:
|
||||
with open(args.out, 'w', encoding='utf-8') as fh:
|
||||
fh.write(report.render(a))
|
||||
if args.json:
|
||||
with open(args.json, 'w', encoding='utf-8') as fh:
|
||||
json.dump(summary(a), fh, indent=1)
|
||||
print('{0}: {1}'.format(beam.mark or 'beam', 'PASS' if a.ok else 'FAIL'))
|
||||
for s in a.stages:
|
||||
print(' {0:<20} {1} {2:.0%}'.format(s.name, 'PASS' if s.ok else 'FAIL', s.util))
|
||||
return 0 if a.ok else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,273 @@
|
||||
# -*- 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,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Development of bars and strands along the beam.
|
||||
|
||||
A bar contributes only the stress it can develop at a station: fy times
|
||||
(embedment / ld), capped at fy -- ACI 318-19 25.4.2.1 read as a linear
|
||||
build-up, which is how the reference tool prints a partial `As` near the
|
||||
ends (RBA5 p.12: As = 0.28 in² at 4" from the end for 4.4 in² of #6).
|
||||
|
||||
`ld` is Table 25.4.2.3 with ψt = ψe = ψg = 1. The tool's own setting "Bar
|
||||
Confinement Provided" picks the column: unchecked (its default in every
|
||||
package read) is the longer "other cases" length -- for a #6 at f'c = 5 ksi
|
||||
that is 31.8", which is what RBA5's As build-up implies (2" of embedment ->
|
||||
0.063 of the bar). Top-bar ψt = 1.3 is NOT applied: the printed d at the
|
||||
end of RBA5 (12.80") is the area-weighted centroid of top and bottom bars
|
||||
at EQUAL development, which ψt = 1.3 on the top bars would have broken.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from .model import Beam, BarRow, Materials
|
||||
|
||||
PSI_T_TOP = 1.0 # see module docstring; the reference tool's choice
|
||||
|
||||
|
||||
def ld_bar(row: BarRow, mats: Materials, confined: bool = False,
|
||||
mult: float = 1.0, top: bool = False) -> float:
|
||||
"""Straight-bar TENSION development length (in) as the reference tool
|
||||
prints it in its `As` column: fy·ψt·ψe·db / (20·λ·√f'c) for EVERY bar
|
||||
size -- measured on RBA5 (#6, f'c 5 ksi: 31.8") and RBA7 (#7, f'c
|
||||
8 ksi: 29.4"), both of which the size-split Table 25.4.2.3 would have
|
||||
put in a different column. `confined` is kept for the day the tool's
|
||||
"Bar Confinement Provided" box is understood; today it changes nothing.
|
||||
"""
|
||||
fy = mats.rebar.fy_ksi * 1000.0
|
||||
fc = mats.fc_ksi * 1000.0
|
||||
lam = mats.lam if mats.normalweight else 0.75
|
||||
psi_t = PSI_T_TOP if top else 1.0
|
||||
psi_e = 1.0
|
||||
psi_g = psi_g_for(mats.rebar.fy_ksi)
|
||||
ld = fy * psi_t * psi_e * psi_g / (20.0 * lam * math.sqrt(fc)) * row.db
|
||||
return max(12.0, ld * mult)
|
||||
|
||||
|
||||
def psi_g_for(fy_ksi: float) -> float:
|
||||
"""ACI 318-19 Table 25.4.2.5 reinforcement grade factor: 1.0 for
|
||||
Grade 40/60, 1.15 for Grade 80, 1.3 for Grade 100. Owner, 2026-09-06,
|
||||
on how to treat Grade 80 development: "参考ACI吧". On RBA1 (Grade 80)
|
||||
this closes part of the +72 % gap in the reported As against the
|
||||
reference tool; the remaining ~x1.5 has no ACI text behind it and is
|
||||
left as a recorded difference, not fitted (docs/beam-analysis-state.md)."""
|
||||
if fy_ksi >= 100.0 - 1e-9:
|
||||
return 1.3
|
||||
if fy_ksi >= 80.0 - 1e-9:
|
||||
return 1.15
|
||||
return 1.0
|
||||
|
||||
|
||||
def ld_capacity(row: BarRow, mats: Materials, mult: float = 1.0) -> float:
|
||||
"""The length over which the reference tool lets a bar's FORCE build up
|
||||
in the strength solve -- distinct from `ld_bar`, which only drives its
|
||||
`As` column. Measured on RBA5: φMn = 36.7 k-ft at 2" of embedment and
|
||||
148.3 at 8.8" fit a bar force rising linearly to fy over about 13.5",
|
||||
which is ACI 318-19 25.4.9.2's COMPRESSION development length
|
||||
max(0.02·fy·db/(λ√f'c), 0.0003·fy·db) = max(12.7, 13.5) for a #6 at
|
||||
5 ksi. Whether that is the tool's intent or its accident is not known;
|
||||
it is what its numbers do."""
|
||||
fy = mats.rebar.fy_ksi * 1000.0
|
||||
fc = mats.fc_ksi * 1000.0
|
||||
lam = mats.lam if mats.normalweight else 0.75
|
||||
db = row.db
|
||||
ldc = max(0.02 * fy * db / (lam * math.sqrt(fc)), 0.0003 * fy * db)
|
||||
return max(8.0, ldc * mult)
|
||||
|
||||
|
||||
def ldh_bar(row: BarRow, mats: Materials, mult: float = 1.0) -> float:
|
||||
"""Hooked-bar development length (in), ACI 318-19 25.4.3.1 with every
|
||||
ψ = 1 (ψr, ψo, ψc, ψe) -- the conservative reading; a hooked bar end is
|
||||
'developed' once it has ldh of embedment from the hook's outside."""
|
||||
fy = mats.rebar.fy_ksi * 1000.0
|
||||
fc = mats.fc_ksi * 1000.0
|
||||
lam = mats.lam if mats.normalweight else 0.75
|
||||
db = row.db
|
||||
ldh = fy / (55.0 * lam * math.sqrt(fc)) * db ** 1.5
|
||||
return max(8.0 * db, 6.0, ldh * mult)
|
||||
|
||||
|
||||
def bar_fraction(embed_start: float, embed_end: float, row: BarRow,
|
||||
mats: Materials, confined: bool = False, mult: float = 1.0,
|
||||
top: bool = False, for_capacity: bool = False) -> float:
|
||||
"""Developed fraction (0..1) of a bar at a station with the given
|
||||
embedment towards each end. `for_capacity` picks the strength-solve
|
||||
length (`ld_capacity`); otherwise the reported-`As` length."""
|
||||
ld = ld_capacity(row, mats, mult) if for_capacity else \
|
||||
ld_bar(row, mats, confined, mult, top)
|
||||
f_start = embed_start / (ldh_bar(row, mats, mult)
|
||||
if row.start_cond == 'hooked' else ld)
|
||||
f_end = embed_end / (ldh_bar(row, mats, mult)
|
||||
if row.end_cond == 'hooked' else ld)
|
||||
return max(0.0, min(1.0, f_start, f_end))
|
||||
|
||||
|
||||
def transfer_length_in(db: float) -> float:
|
||||
"""ACI 318-19 25.4.8.1 / 22.5.9.1: lt = 50 db."""
|
||||
return 50.0 * db
|
||||
|
||||
|
||||
def strand_dev_length_in(db: float, fse_ksi: float, fps_ksi: float) -> float:
|
||||
"""ACI 318-19 25.4.8.1: ld = (fse/3000) db + ((fps - fse)/1000) db (psi)."""
|
||||
return (fse_ksi * 1000.0 / 3000.0) * db + ((fps_ksi - fse_ksi) * 1000.0
|
||||
/ 1000.0) * db
|
||||
|
||||
|
||||
def strand_available_ksi(embed: float, db: float, fse_ksi: float,
|
||||
fps_ksi: float) -> float:
|
||||
"""The stress a strand can carry at `embed` from its (bonded) end:
|
||||
linear to fse over the transfer length, then linear to fps at ld."""
|
||||
lt = transfer_length_in(db)
|
||||
if embed <= 0:
|
||||
return 0.0
|
||||
if embed <= lt:
|
||||
return fse_ksi * embed / lt
|
||||
ld = strand_dev_length_in(db, fse_ksi, fps_ksi)
|
||||
if embed >= ld:
|
||||
return fps_ksi
|
||||
return fse_ksi + (fps_ksi - fse_ksi) * (embed - lt) / (ld - lt)
|
||||
@@ -0,0 +1,246 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Flexural strength by strain compatibility with a Whitney stress block.
|
||||
|
||||
At a station every bar and strand present is a layer with a developed
|
||||
stress limit (`develop.py`); the neutral-axis depth c is found so that the
|
||||
concrete block plus every compression layer equals every tension layer,
|
||||
then Mn is the moment of all of it about the compression face. Positive
|
||||
bending puts the block at the top; negative bending at the bottom -- the
|
||||
same routine on the flipped section.
|
||||
|
||||
What is reported per station mirrors the reference tool's Flexure Summary
|
||||
Table: c, εt, φ, φMn, the developed tension area `As`, the compression
|
||||
area `A's`, `Aps`, and `d` (developed-area-weighted centroid of the
|
||||
tension layers from the compression face).
|
||||
|
||||
Strand stress: PCI Design Handbook 8th Ed. power formula for 270 ksi
|
||||
low-relaxation strand, εps = εse + εce(≈0) + 0.003·(dp - c)/c.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from . import develop
|
||||
from .model import Beam, Materials
|
||||
|
||||
|
||||
@dataclass
|
||||
class Layer:
|
||||
depth: float # from the compression face (in)
|
||||
area: float # in²
|
||||
kind: str # 'bar' | 'strand'
|
||||
f_limit: float # developable stress, ksi (bars: f·fy; strands: available fps)
|
||||
es: float # modulus, ksi
|
||||
fy: float = 60.0 # bars only
|
||||
fse: float = 0.0 # strands only: effective prestress after losses
|
||||
fpu: float = 270.0
|
||||
dev: float = 1.0 # developed fraction (bars)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlexureResult:
|
||||
phi_mn: float # kip-ft
|
||||
mn: float # kip-ft
|
||||
phi: float
|
||||
c: float # in
|
||||
eps_t: float
|
||||
as_tension: float # developed tension bar area, in²
|
||||
as_comp: float # bar area in compression, in²
|
||||
aps: float # strand area, in²
|
||||
d: float # in, from the compression face
|
||||
controls: str # 'T' tension-controlled, 'C' compression, 'TR' transition
|
||||
strain_ok: bool = True
|
||||
note: str = ''
|
||||
|
||||
|
||||
def strand_stress_ksi(eps: float, fpu: float = 270.0,
|
||||
low_relaxation: bool = True) -> float:
|
||||
"""PCI 8th Ed. Design Aid 15.3.3 power formula (270 ksi strand)."""
|
||||
if eps <= 0:
|
||||
return 0.0
|
||||
if low_relaxation:
|
||||
f = eps * (887.0 + 27613.0 / (1.0 + (112.4 * eps) ** 7.36) ** (1.0 / 7.36))
|
||||
else:
|
||||
f = eps * (885.0 + 27645.0 / (1.0 + (118.0 * eps) ** 6.0) ** (1.0 / 6.0))
|
||||
return min(f, fpu)
|
||||
|
||||
|
||||
def _forces(layers: List[Layer], c: float, section_block, beta1: float,
|
||||
fc: float, ecu: float = 0.003) -> Tuple[float, float, list]:
|
||||
"""(net force = C - T, moment about compression face of all forces,
|
||||
per-layer (force, stress)) for a trial c. Compression positive."""
|
||||
a = beta1 * c
|
||||
ac, ybar = section_block(a)
|
||||
cc = 0.85 * fc * ac
|
||||
net = cc
|
||||
mom = -cc * ybar # compression above → negative lever from face
|
||||
per = []
|
||||
for L in layers:
|
||||
eps = ecu * (c - L.depth) / c
|
||||
if L.kind == 'bar':
|
||||
fs = max(-L.f_limit, min(L.f_limit, L.es * eps))
|
||||
if fs > 0 and L.depth < c:
|
||||
# displaced concrete: bar within the neutral-axis depth c
|
||||
# (the reference tool's convention; the Whitney block is a
|
||||
# resultant device, not the bar-side boundary)
|
||||
fs -= 0.85 * fc
|
||||
force = fs * L.area # + compression, - tension
|
||||
else:
|
||||
# eps is the concrete strain at the strand (+ compression);
|
||||
# the strand's own strain is its effective prestrain minus that
|
||||
eps_ps = L.fse / L.es - eps
|
||||
fps = min(strand_stress_ksi(max(eps_ps, 0.0), L.fpu), L.f_limit)
|
||||
force = -fps * L.area
|
||||
net += force
|
||||
mom += -force * L.depth # tension (negative force) below → positive moment
|
||||
per.append((force, force / L.area if L.area else 0.0))
|
||||
return net, mom, per
|
||||
|
||||
|
||||
def solve_section(layers: List[Layer], section_block, height: float,
|
||||
beta1: float, fc: float, phi_t: float, phi_c: float,
|
||||
prestressed: bool, fy: float = 60.0, es: float = 29000.0,
|
||||
strain_limit: float = 0.004) -> FlexureResult:
|
||||
"""Find c by bisection so the section is in equilibrium; report."""
|
||||
tension = [L for L in layers if L.f_limit > 0]
|
||||
if not tension:
|
||||
return FlexureResult(0.0, 0.0, phi_t, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
|
||||
'NC', True, 'no developed reinforcement')
|
||||
lo, hi = 1e-4, height
|
||||
net_lo, _, _ = _forces(layers, lo, section_block, beta1, fc)
|
||||
net_hi, _, _ = _forces(layers, hi, section_block, beta1, fc)
|
||||
if net_lo > 0:
|
||||
# even a hairline block out-pulls the steel: no tension capacity
|
||||
c = lo
|
||||
elif net_hi < 0:
|
||||
c = hi
|
||||
else:
|
||||
for _ in range(80):
|
||||
mid = (lo + hi) / 2.0
|
||||
net_mid, _, _ = _forces(layers, mid, section_block, beta1, fc)
|
||||
if net_mid > 0:
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
if hi - lo < 1e-7:
|
||||
break
|
||||
c = (lo + hi) / 2.0
|
||||
net, mom, per = _forces(layers, c, section_block, beta1, fc)
|
||||
mn_kin = mom
|
||||
# extreme tension steel strain
|
||||
d_t = max(L.depth for L in layers)
|
||||
eps_t = 0.003 * (d_t - c) / c
|
||||
eps_ty = 0.002 if prestressed else fy / es
|
||||
if eps_t >= 0.005:
|
||||
phi = phi_t
|
||||
ctl = 'T'
|
||||
elif eps_t <= eps_ty:
|
||||
phi = phi_c
|
||||
ctl = 'C'
|
||||
else:
|
||||
phi = phi_c + (phi_t - phi_c) * (eps_t - eps_ty) / (0.005 - eps_ty)
|
||||
ctl = 'TR'
|
||||
as_t = sum(L.area * L.dev for (L, (f, s)) in zip(layers, per)
|
||||
if L.kind == 'bar' and f < 0)
|
||||
as_c = sum(L.area for (L, (f, s)) in zip(layers, per)
|
||||
if L.kind == 'bar' and f >= 0)
|
||||
aps = sum(L.area for L in layers if L.kind == 'strand')
|
||||
# Reported d: "computed to the center of tension force in bars" (the
|
||||
# reference tool's own footnote). Each tension layer weighs in with its
|
||||
# developed AREA times the STRAIN-COMPATIBILITY stress at the solved c,
|
||||
# capped at yield only -- not at the development stress cap the
|
||||
# equilibrium solve uses. With the cap, two layers developing together
|
||||
# both sit at the cap and d collapses to their plain area centroid
|
||||
# (12.80 flat on RBA5); Eriksson's d ramps 12.80 -> 14.53 -> 19.49 ->
|
||||
# 20.00 as the top layer's strain dies off approaching c. Probed on both
|
||||
# fixtures 2026-09-05: As, c and phiMn unchanged bit for bit.
|
||||
wt = []
|
||||
for L, (f, s) in zip(layers, per):
|
||||
if f < 0 and c > 0:
|
||||
stress = L.es * 0.003 * (L.depth - c) / c
|
||||
cap = L.fy if L.kind == 'bar' else L.f_limit
|
||||
stress = max(-cap, min(cap, stress))
|
||||
wt.append((L.area * L.dev * stress, L.depth))
|
||||
d = sum(a * y for a, y in wt) / sum(a for a, _ in wt) if wt and sum(a for a, _ in wt) > 0 else 0.0
|
||||
strain_ok = (eps_t >= strain_limit) if (not prestressed) else True
|
||||
return FlexureResult(phi * mn_kin / 12.0, mn_kin / 12.0, phi, c, eps_t,
|
||||
as_t, as_c, aps, d, ctl, strain_ok)
|
||||
|
||||
|
||||
def _bar_layers(beam: Beam, x_in: float, positive: bool,
|
||||
confined: bool = False) -> List[Layer]:
|
||||
h = beam.section.height_in
|
||||
mats = beam.materials
|
||||
fy = mats.rebar.fy_ksi
|
||||
out = []
|
||||
for bx, by, area, db, e0, e1, row in beam.bars_at(x_in):
|
||||
depth = (h - by) if positive else by
|
||||
top = (by > h / 2.0)
|
||||
f_cap = develop.bar_fraction(e0, e1, row, mats, confined,
|
||||
beam.settings.mild_dev_mult, top=top,
|
||||
for_capacity=True)
|
||||
f_rep = develop.bar_fraction(e0, e1, row, mats, confined,
|
||||
beam.settings.mild_dev_mult, top=top)
|
||||
out.append(Layer(depth, area, 'bar', f_cap * fy, mats.rebar.es_ksi,
|
||||
fy, dev=f_rep))
|
||||
return out
|
||||
|
||||
|
||||
def _strand_layers(beam: Beam, x_in: float, positive: bool,
|
||||
fse_ksi: float, fps_nominal: float) -> List[Layer]:
|
||||
h = beam.section.height_in
|
||||
mats = beam.materials
|
||||
out = []
|
||||
for g in beam.strands:
|
||||
db = _strand_db(g.size)
|
||||
for sx, sy, area in g.strands(beam.section):
|
||||
depth = (h - sy) if positive else sy
|
||||
embed = min(x_in, beam.length_in - x_in)
|
||||
avail = develop.strand_available_ksi(embed, db, fse_ksi, fps_nominal)
|
||||
out.append(Layer(depth, area, 'strand', avail, mats.strand.ep_ksi,
|
||||
fse=min(fse_ksi, avail), fpu=mats.strand.fpu_ksi))
|
||||
return out
|
||||
|
||||
|
||||
def _strand_db(size: str) -> float:
|
||||
s = size.replace(' Sp', '').replace('"', '').strip()
|
||||
return {'3/8': 0.375, '7/16': 0.4375, '1/2': 0.5, '9/16': 0.5625,
|
||||
'0.6': 0.6}.get(s, 0.5)
|
||||
|
||||
|
||||
def capacity_at(beam: Beam, x_in: float, positive: bool = True,
|
||||
fse_ksi: float = 0.0, fc_ksi: Optional[float] = None
|
||||
) -> FlexureResult:
|
||||
"""φMn at x for positive (sagging) or negative bending."""
|
||||
mats = beam.materials
|
||||
fc = fc_ksi if fc_ksi is not None else mats.fc_ksi
|
||||
beta1 = mats.beta1_at(fc)
|
||||
sec = beam.section
|
||||
layers = _bar_layers(beam, x_in, positive)
|
||||
if beam.strands:
|
||||
fps_nom = 0.98 * mats.strand.fpu_ksi
|
||||
layers += _strand_layers(beam, x_in, positive, fse_ksi, fps_nom)
|
||||
block = sec.compression_area if positive else sec.compression_area_from_bottom
|
||||
return solve_section(layers, block, sec.height_in, beta1, fc,
|
||||
beam.settings.phi_tension, beam.settings.phi_compression,
|
||||
bool(beam.strands), mats.rebar.fy_ksi,
|
||||
mats.rebar.es_ksi, beam.settings.strain_limit)
|
||||
|
||||
|
||||
def cracking_moment(beam: Beam, x_in: float, positive: bool = True,
|
||||
p_eff_k: float = 0.0, e_in: float = 0.0,
|
||||
fc_ksi: Optional[float] = None) -> float:
|
||||
"""Mcr (kip-ft) on the transformed section: (fr + P/A + P·e/S)·S with
|
||||
fr = 7.5·λ·√f'c (ACI 19.2.3.1). `e_in` is the strand eccentricity
|
||||
below the centroid (positive = below, which helps positive bending)."""
|
||||
from . import section as sec_mod
|
||||
mats = beam.materials
|
||||
fc = fc_ksi if fc_ksi is not None else mats.fc_ksi
|
||||
fr = 7.5 * mats.lam * math.sqrt(fc * 1000.0) / 1000.0 # ksi
|
||||
p = sec_mod.props_at(beam, x_in)
|
||||
s = p.sb if positive else p.st
|
||||
sign = 1.0 if positive else -1.0
|
||||
f_pre = p_eff_k / p.area + sign * p_eff_k * e_in / s if p_eff_k else 0.0
|
||||
return (fr + f_pre) * s / 12.0
|
||||
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Load cases, patterns and combinations.
|
||||
|
||||
The reference tool carries nine load-case columns on every load row
|
||||
(`model.CASES`) and combines them as D / L / R / W / E:
|
||||
|
||||
D = self weight + deadnc + topping + deadc
|
||||
L = live (and sus_live rides inside live for strength; it is its own
|
||||
case only for the sustained-stress and long-term checks)
|
||||
R = roof
|
||||
W = pressure or suction, whichever governs, each sign tried
|
||||
E = seismic
|
||||
|
||||
`case_loads(beam, case)` returns the `statics.Load` list for one case.
|
||||
`patterns(beam)` returns the live-load patterns: with "Pattern Live Loads"
|
||||
on, live load is applied to each of the beam's three regions (left
|
||||
overhang, span, right overhang) in every on/off combination, and every
|
||||
station reports the worst of them. With it off, live is all-on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Dict, List, Sequence, Tuple
|
||||
|
||||
from .model import Beam, CASES
|
||||
from .statics import Load
|
||||
|
||||
SELF = 'self'
|
||||
DEAD_CASES = ('deadnc', 'topping', 'deadc')
|
||||
|
||||
|
||||
def self_weight(beam: Beam) -> List[Load]:
|
||||
w = beam.self_weight_klf / 12.0 # k/in
|
||||
return [Load('line', 0.0, beam.length_in, w0=w, w1=w, tag='self weight')]
|
||||
|
||||
|
||||
def case_loads(beam: Beam, case: str) -> List[Load]:
|
||||
"""Every load in one case, in kips / kip-in / k-in."""
|
||||
if case == SELF:
|
||||
return self_weight(beam)
|
||||
out = []
|
||||
for p in beam.point_loads:
|
||||
v = p.cases.get(case, 0.0)
|
||||
if v:
|
||||
out.append(Load('point', p.x_in, p=v, tag=p.id))
|
||||
for l in beam.line_loads:
|
||||
w0, w1 = l.cases.get(case, (0.0, 0.0))
|
||||
if w0 or w1:
|
||||
out.append(Load('line', l.x0_in, l.x1_in, w0=w0 / 12.0,
|
||||
w1=w1 / 12.0, tag=l.id))
|
||||
for m in beam.point_moments:
|
||||
v = m.cases.get(case, 0.0)
|
||||
if v:
|
||||
out.append(Load('moment', m.x_in, m=v * 12.0, tag=m.id))
|
||||
return out
|
||||
|
||||
|
||||
def clip(loads: Sequence[Load], x0: float, x1: float) -> List[Load]:
|
||||
"""The part of each load that lies within [x0, x1] -- how a pattern
|
||||
keeps only the live load on one region of the beam."""
|
||||
out = []
|
||||
for l in loads:
|
||||
if l.kind in ('point', 'moment'):
|
||||
if x0 - 1e-9 <= l.x0 <= x1 + 1e-9:
|
||||
out.append(l)
|
||||
else:
|
||||
a = max(l.x0, x0)
|
||||
b = min(l.x1, x1)
|
||||
if b > a + 1e-9:
|
||||
L = l.x1 - l.x0
|
||||
wa = l.w0 + (l.w1 - l.w0) * (a - l.x0) / L if L else l.w0
|
||||
wb = l.w0 + (l.w1 - l.w0) * (b - l.x0) / L if L else l.w1
|
||||
out.append(Load('line', a, b, w0=wa, w1=wb, tag=l.tag))
|
||||
return out
|
||||
|
||||
|
||||
def regions(beam: Beam) -> List[Tuple[float, float]]:
|
||||
"""The three pattern regions, dropping any of zero length."""
|
||||
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
|
||||
regs = [(0.0, xa), (xa, xb), (xb, beam.length_in)]
|
||||
return [(a, b) for a, b in regs if b - a > 1e-6]
|
||||
|
||||
|
||||
def patterns(beam: Beam) -> List[Tuple[Tuple[float, float], ...]]:
|
||||
"""Every non-empty on/off pattern of the regions (all-on first), or just
|
||||
all-on when patterning is off. The all-on pattern is always included so
|
||||
a result can never be less than the un-patterned answer."""
|
||||
regs = regions(beam)
|
||||
if not beam.pattern_live or len(regs) == 1:
|
||||
return [tuple(regs)]
|
||||
out = [tuple(regs)]
|
||||
for n in range(1, len(regs)):
|
||||
for combo in itertools.combinations(regs, n):
|
||||
out.append(tuple(combo))
|
||||
return out
|
||||
|
||||
|
||||
def live_loads_for(beam: Beam, pattern: Sequence[Tuple[float, float]],
|
||||
case: str = 'live') -> List[Load]:
|
||||
full = case_loads(beam, case)
|
||||
out = []
|
||||
for a, b in pattern:
|
||||
out.extend(clip(full, a, b))
|
||||
return out
|
||||
|
||||
|
||||
def dead_loads(beam: Beam, include_self: bool = True) -> List[Load]:
|
||||
out = []
|
||||
if include_self:
|
||||
out.extend(self_weight(beam))
|
||||
for c in DEAD_CASES:
|
||||
out.extend(case_loads(beam, c))
|
||||
return out
|
||||
|
||||
|
||||
def scaled(loads: Sequence[Load], f: float) -> List[Load]:
|
||||
if f == 1.0:
|
||||
return list(loads)
|
||||
if f == 0.0:
|
||||
return []
|
||||
return [Load(l.kind, l.x0, l.x1, l.p * f, l.w0 * f, l.w1 * f, l.m * f, l.tag)
|
||||
for l in loads]
|
||||
|
||||
|
||||
def has_case(beam: Beam, case: str) -> bool:
|
||||
return bool(case_loads(beam, case))
|
||||
|
||||
|
||||
def wind_variants(beam: Beam) -> List[Tuple[str, List[Load]]]:
|
||||
"""W as the reference tool takes it: pressure and suction are separate
|
||||
cases and either may govern; each is tried with both signs."""
|
||||
out = []
|
||||
for c in ('pressure', 'suction'):
|
||||
loads = case_loads(beam, c)
|
||||
if loads:
|
||||
out.append((c, loads))
|
||||
out.append((c + ' (reversed)', scaled(loads, -1.0)))
|
||||
return out
|
||||
@@ -0,0 +1,771 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The job file (`beamcalc/1`) and the dataclasses it becomes.
|
||||
|
||||
The job file is the whole input: a beam re-run from it on a machine with no
|
||||
Revit and no licence gives the same numbers. Its blocks mirror the reference
|
||||
tool's own input pages one for one (Design Criteria, Concrete Extents,
|
||||
Structural Model, Stripping & Handling, Reinforcement) so a package printed
|
||||
by that tool can be transcribed into a job by hand -- that is exactly how the
|
||||
parity fixtures in `beamcalc/fixtures/` were made.
|
||||
|
||||
Lengths in the job file are INCHES unless the key says otherwise
|
||||
(`length_ft` is the one exception, because everyone says a beam length in
|
||||
feet). Forces are kips, moments kip-ft, stresses ksi, line loads klf, area
|
||||
loads psf. `x` positions are measured from the START of the beam unless a
|
||||
`from: "end"` accompanies them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Sequence
|
||||
|
||||
SCHEMA = 'beamcalc/1'
|
||||
|
||||
# Load-case columns, in the reference tool's own order and naming. Every load
|
||||
# row carries one value per case (a line load carries a start and end value
|
||||
# per case, so it can taper).
|
||||
CASES = ('deadnc', 'topping', 'deadc', 'live', 'sus_live', 'roof',
|
||||
'pressure', 'suction', 'seismic')
|
||||
|
||||
# Bar areas (in²) and diameters (in) by size, ACI 318 Appendix A.
|
||||
BAR_AREA = {'#3': 0.11, '#4': 0.20, '#5': 0.31, '#6': 0.44, '#7': 0.60,
|
||||
'#8': 0.79, '#9': 1.00, '#10': 1.27, '#11': 1.56, '#14': 2.25,
|
||||
'#18': 4.00}
|
||||
BAR_DIA = {'#3': 0.375, '#4': 0.500, '#5': 0.625, '#6': 0.750, '#7': 0.875,
|
||||
'#8': 1.000, '#9': 1.128, '#10': 1.270, '#11': 1.410,
|
||||
'#14': 1.693, '#18': 2.257}
|
||||
|
||||
# Strand areas (in²) by nominal size (7-wire, ASTM A416).
|
||||
STRAND_AREA = {'3/8': 0.085, '7/16': 0.115, '1/2': 0.153, '1/2 Sp': 0.167,
|
||||
'9/16': 0.192, '0.6': 0.217}
|
||||
|
||||
|
||||
class JobError(ValueError):
|
||||
"""The job file cannot be read into a beam. The message names the key."""
|
||||
|
||||
|
||||
def _num(d, key, default=None, required=False):
|
||||
v = d.get(key, default)
|
||||
if v is None or v == '':
|
||||
if required:
|
||||
raise JobError('missing required value: {0}'.format(key))
|
||||
return default
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
raise JobError('{0} is not a number: {1!r}'.format(key, v))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rebar:
|
||||
fy_ksi: float = 60.0
|
||||
es_ksi: float = 29000.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrandMaterial:
|
||||
fpu_ksi: float = 270.0
|
||||
ep_ksi: float = 28500.0
|
||||
low_relaxation: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Materials:
|
||||
fc_ksi: float
|
||||
fci_ksi: float
|
||||
wc_pcf: float = 150.0
|
||||
ec_ksi: Optional[float] = None # None -> ACI 19.2.2.1(a)
|
||||
eci_ksi: Optional[float] = None
|
||||
normalweight: bool = True
|
||||
lam: float = 1.0
|
||||
rebar: Rebar = field(default_factory=Rebar)
|
||||
trans: Rebar = field(default_factory=Rebar)
|
||||
strand: StrandMaterial = field(default_factory=StrandMaterial)
|
||||
|
||||
@staticmethod
|
||||
def modulus_ksi(wc_pcf: float, fc_ksi: float) -> float:
|
||||
"""ACI 318-19 19.2.2.1(a): Ec = wc^1.5 * 33 * sqrt(f'c) (psi)."""
|
||||
return wc_pcf ** 1.5 * 33.0 * math.sqrt(fc_ksi * 1000.0) / 1000.0
|
||||
|
||||
@property
|
||||
def Ec(self) -> float:
|
||||
return self.ec_ksi or self.modulus_ksi(self.wc_pcf, self.fc_ksi)
|
||||
|
||||
@property
|
||||
def Eci(self) -> float:
|
||||
return self.eci_ksi or self.modulus_ksi(self.wc_pcf, self.fci_ksi)
|
||||
|
||||
@property
|
||||
def beta1(self) -> float:
|
||||
"""ACI 318-19 Table 22.2.2.4.3."""
|
||||
fc = self.fc_ksi * 1000.0
|
||||
if fc <= 4000.0:
|
||||
return 0.85
|
||||
if fc >= 8000.0:
|
||||
return 0.65
|
||||
return 0.85 - 0.05 * (fc - 4000.0) / 1000.0
|
||||
|
||||
def beta1_at(self, fc_ksi: float) -> float:
|
||||
fc = fc_ksi * 1000.0
|
||||
if fc <= 4000.0:
|
||||
return 0.85
|
||||
if fc >= 8000.0:
|
||||
return 0.65
|
||||
return 0.85 - 0.05 * (fc - 4000.0) / 1000.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Combo:
|
||||
D: float
|
||||
L: float
|
||||
R: float
|
||||
W: float
|
||||
E: float
|
||||
include: bool = True
|
||||
label: str = ''
|
||||
|
||||
def name(self) -> str:
|
||||
if self.label:
|
||||
return self.label
|
||||
parts = []
|
||||
for f, s in ((self.D, 'D'), (self.L, 'L'), (self.R, 'R'),
|
||||
(self.W, 'W'), (self.E, 'E')):
|
||||
if f:
|
||||
parts.append('{0:g}{1}'.format(f, s))
|
||||
return ' + '.join(parts) or '0'
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
total_defl_ratio: float = 240.0 # L/240
|
||||
live_defl_ratio: float = 360.0
|
||||
ec_for_sw_defl: str = 'Eci' # 'Ec' | 'Eci'
|
||||
sw_mult: Dict[str, float] = field(default_factory=lambda: {
|
||||
'erection': 1.0, 'construction': 1.3, 'end': 2.0})
|
||||
addl_dead_mult: Dict[str, float] = field(default_factory=lambda: {
|
||||
'construction': 0.0, 'end': 2.0})
|
||||
composite_shear: str = 'varying_strength'
|
||||
dv_method: str = 'cg_tension'
|
||||
min_shear_method: str = 'beam_9.6.3'
|
||||
flexure_method: str = 'whitney'
|
||||
ignored_distance_in: float = 0.0
|
||||
strain_limit: float = 0.004
|
||||
transfer_comp_ends: float = 0.7 # x f'ci
|
||||
transfer_comp_other: float = 0.7
|
||||
transfer_tens_ends_sqrt: float = 6.0 # x sqrt(f'ci) psi
|
||||
transfer_tens_other_sqrt: float = 6.0
|
||||
service_comp_sustained: float = 0.45 # x f'c
|
||||
service_comp_total: float = 0.60
|
||||
phi_tension: float = 0.9
|
||||
phi_shear: float = 0.75
|
||||
phi_compression: float = 0.65
|
||||
ktr: float = 0.0
|
||||
mild_dev_mult: float = 1.0
|
||||
limit_shear_near_supports: bool = True
|
||||
transform_reinforcement: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Section:
|
||||
"""A section built of axis-aligned rectangles. `kind` is what the user
|
||||
chose; `rects` is what the engine uses -- (x0, y0, w, h) in inches with
|
||||
y measured UP from the bottom of the section and x from its left edge."""
|
||||
kind: str # 'rectangular' | 'L' | 'inverted_t'
|
||||
width_in: float # overall bounding width
|
||||
height_in: float # overall height
|
||||
rects: List[tuple]
|
||||
stem_width_in: Optional[float] = None
|
||||
stem_height_in: Optional[float] = None
|
||||
flange_width_in: Optional[float] = None
|
||||
flange_thickness_in: Optional[float] = None
|
||||
side: str = 'left'
|
||||
|
||||
@classmethod
|
||||
def rectangular(cls, b: float, h: float) -> 'Section':
|
||||
return cls('rectangular', b, h, [(0.0, 0.0, b, h)])
|
||||
|
||||
@classmethod
|
||||
def l_beam(cls, stem_w: float, stem_h: float, flange_w: float,
|
||||
flange_t: float, side: str = 'left') -> 'Section':
|
||||
"""Spandrel / L-beam the way the reference tool defines it: a stem of
|
||||
`stem_w` x (full height) with a ledge `flange_w` wide and `flange_t`
|
||||
tall at the bottom on one side. `stem_h` is the height ABOVE the
|
||||
ledge, so overall height is stem_h + flange_t."""
|
||||
h = stem_h + flange_t
|
||||
b = stem_w + flange_w
|
||||
if side == 'left':
|
||||
rects = [(flange_w, 0.0, stem_w, h), (0.0, 0.0, flange_w, flange_t)]
|
||||
else:
|
||||
rects = [(0.0, 0.0, stem_w, h), (stem_w, 0.0, flange_w, flange_t)]
|
||||
s = cls('L', b, h, rects, stem_w, stem_h, flange_w, flange_t, side)
|
||||
return s
|
||||
|
||||
@classmethod
|
||||
def inverted_t(cls, stem_w: float, stem_h: float, flange_w: float,
|
||||
flange_t: float) -> 'Section':
|
||||
"""Inverted tee: a ledge `flange_w` wide each side of the stem."""
|
||||
h = stem_h + flange_t
|
||||
b = stem_w + 2 * flange_w
|
||||
rects = [(flange_w, 0.0, stem_w, h), (0.0, 0.0, b, flange_t)]
|
||||
# the bottom rect overlaps the stem; split it to avoid double count
|
||||
rects = [(flange_w, 0.0, stem_w, h),
|
||||
(0.0, 0.0, flange_w, flange_t),
|
||||
(flange_w + stem_w, 0.0, flange_w, flange_t)]
|
||||
return cls('inverted_t', b, h, rects, stem_w, stem_h, flange_w,
|
||||
flange_t, 'both')
|
||||
|
||||
# -- gross properties -----------------------------------------------
|
||||
@property
|
||||
def area(self) -> float:
|
||||
return sum(w * h for _, _, w, h in self.rects)
|
||||
|
||||
@property
|
||||
def cy(self) -> float:
|
||||
"""Centroid height above the bottom."""
|
||||
return sum(w * h * (y + h / 2.0) for _, y, w, h in self.rects) / self.area
|
||||
|
||||
@property
|
||||
def cx(self) -> float:
|
||||
return sum(w * h * (x + w / 2.0) for x, _, w, h in self.rects) / self.area
|
||||
|
||||
@property
|
||||
def ixx(self) -> float:
|
||||
c = self.cy
|
||||
return sum(w * h ** 3 / 12.0 + w * h * (y + h / 2.0 - c) ** 2
|
||||
for _, y, w, h in self.rects)
|
||||
|
||||
@property
|
||||
def iyy(self) -> float:
|
||||
c = self.cx
|
||||
return sum(h * w ** 3 / 12.0 + w * h * (x + w / 2.0 - c) ** 2
|
||||
for x, _, w, h in self.rects)
|
||||
|
||||
@property
|
||||
def st(self) -> float:
|
||||
return self.ixx / (self.height_in - self.cy)
|
||||
|
||||
@property
|
||||
def sb(self) -> float:
|
||||
return self.ixx / self.cy
|
||||
|
||||
def width_at(self, y: float) -> float:
|
||||
"""Total concrete width at height y above the bottom."""
|
||||
return sum(w for _, y0, w, h in self.rects if y0 <= y < y0 + h)
|
||||
|
||||
def web_width(self) -> float:
|
||||
"""bw for shear: the stem width (the narrowest full-height part)."""
|
||||
if self.stem_width_in:
|
||||
return self.stem_width_in
|
||||
return self.width_in
|
||||
|
||||
def compression_area(self, depth_from_top: float) -> tuple:
|
||||
"""(area, centroid depth from top) of the concrete within
|
||||
`depth_from_top` of the top face -- the Whitney block for positive
|
||||
bending. Handles a flange at the bottom by just not being there."""
|
||||
top = self.height_in
|
||||
a_tot = 0.0
|
||||
m_tot = 0.0
|
||||
for _, y0, w, h in self.rects:
|
||||
y1 = y0 + h
|
||||
lo = max(y0, top - depth_from_top)
|
||||
hi = min(y1, top)
|
||||
if hi > lo:
|
||||
a = w * (hi - lo)
|
||||
a_tot += a
|
||||
m_tot += a * (top - (lo + hi) / 2.0)
|
||||
return a_tot, (m_tot / a_tot if a_tot else 0.0)
|
||||
|
||||
def compression_area_from_bottom(self, depth_from_bottom: float) -> tuple:
|
||||
"""Same, for negative bending (block at the bottom)."""
|
||||
a_tot = 0.0
|
||||
m_tot = 0.0
|
||||
for _, y0, w, h in self.rects:
|
||||
y1 = y0 + h
|
||||
lo = max(y0, 0.0)
|
||||
hi = min(y1, depth_from_bottom)
|
||||
if hi > lo:
|
||||
a = w * (hi - lo)
|
||||
a_tot += a
|
||||
m_tot += a * ((lo + hi) / 2.0)
|
||||
return a_tot, (m_tot / a_tot if a_tot else 0.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Support:
|
||||
x_in: float # centreline of bearing, from the start
|
||||
length_in: float # along the beam
|
||||
width_in: float # across the beam
|
||||
kind: str = 'pin'
|
||||
|
||||
@property
|
||||
def face_start_in(self) -> float:
|
||||
return self.x_in - self.length_in / 2.0
|
||||
|
||||
@property
|
||||
def face_end_in(self) -> float:
|
||||
return self.x_in + self.length_in / 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointLoad:
|
||||
id: str
|
||||
x_in: float
|
||||
ecc_in: float
|
||||
cases: Dict[str, float]
|
||||
description: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineLoad:
|
||||
id: str
|
||||
x0_in: float
|
||||
x1_in: float
|
||||
ecc0_in: float
|
||||
ecc1_in: float
|
||||
cases: Dict[str, tuple] # case -> (w0, w1) klf
|
||||
description: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointMoment:
|
||||
id: str
|
||||
x_in: float
|
||||
cases: Dict[str, float] # kip-ft
|
||||
|
||||
|
||||
@dataclass
|
||||
class BarRow:
|
||||
"""One row of the reference tool's Rebar table: `cols` bars of `size`
|
||||
in `rows` rows, the group's edges `left`/`right` from the section's
|
||||
left/right faces, `top`/`bottom` the distance from the top/bottom faces
|
||||
to the group's outermost bar CENTRES, running from `start_in` after the
|
||||
beam start to `end_in` before the beam end."""
|
||||
size: str
|
||||
rows: int
|
||||
cols: int
|
||||
left_in: float
|
||||
right_in: float
|
||||
top_in: float
|
||||
bottom_in: float
|
||||
start_in: float = 0.0
|
||||
end_in: float = 0.0
|
||||
start_cond: str = 'straight'
|
||||
end_cond: str = 'straight'
|
||||
area_in2: Optional[float] = None # override; else BAR_AREA[size]
|
||||
|
||||
@property
|
||||
def bar_area(self) -> float:
|
||||
return self.area_in2 if self.area_in2 else BAR_AREA[self.size]
|
||||
|
||||
@property
|
||||
def db(self) -> float:
|
||||
return BAR_DIA.get(self.size, math.sqrt(4 * self.bar_area / math.pi))
|
||||
|
||||
def bars(self, section: Section) -> List[tuple]:
|
||||
"""[(x, y, area, db), ...] every bar centre, y up from the bottom."""
|
||||
out = []
|
||||
h = section.height_in
|
||||
b = section.width_in
|
||||
ys = ([h - self.top_in] if self.rows == 1 else
|
||||
[h - self.top_in - i * (h - self.top_in - self.bottom_in)
|
||||
/ (self.rows - 1) for i in range(self.rows)])
|
||||
xs = ([self.left_in] if self.cols == 1 else
|
||||
[self.left_in + j * (b - self.left_in - self.right_in)
|
||||
/ (self.cols - 1) for j in range(self.cols)])
|
||||
for y in ys:
|
||||
for x in xs:
|
||||
out.append((x, y, self.bar_area, self.db))
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrandGroup:
|
||||
"""One row of the reference tool's Prestress table."""
|
||||
ids: str
|
||||
size: str # '1/2', '1/2 Sp', '0.6', ...
|
||||
pull_pct: float # % of fpu jacked
|
||||
rows: int
|
||||
cols: int
|
||||
left_in: float
|
||||
right_in: float
|
||||
first_row_in: float # bottom row height above the bottom face
|
||||
spacing_in: float # row spacing
|
||||
force_k: Optional[float] = None # printed total jacking force, if known
|
||||
|
||||
@property
|
||||
def strand_area(self) -> float:
|
||||
return STRAND_AREA[self.size]
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return self.rows * self.cols
|
||||
|
||||
def strands(self, section: Section) -> List[tuple]:
|
||||
"""[(x, y, area), ...] every strand centre, y up from the bottom."""
|
||||
out = []
|
||||
b = section.width_in
|
||||
xs = ([self.left_in] if self.cols == 1 else
|
||||
[self.left_in + j * (b - self.left_in - self.right_in)
|
||||
/ (self.cols - 1) for j in range(self.cols)])
|
||||
for i in range(self.rows):
|
||||
y = self.first_row_in + i * self.spacing_in
|
||||
for x in xs:
|
||||
out.append((x, y, self.strand_area))
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransverseZone:
|
||||
size: str
|
||||
legs: int
|
||||
first_in: float # first bar from the start (or end)
|
||||
from_end: bool
|
||||
spacing_in: float
|
||||
length_in: float
|
||||
qty: int
|
||||
symmetric: bool = True
|
||||
use: str = 'Av'
|
||||
area_in2: Optional[float] = None # override total area per set
|
||||
|
||||
@property
|
||||
def area(self) -> float:
|
||||
if self.area_in2:
|
||||
return self.area_in2
|
||||
return self.legs * BAR_AREA[self.size]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stripping:
|
||||
multiplier: float = 1.4
|
||||
face_down: str = 'bottom'
|
||||
strength: str = 'initial' # 'initial' | 'final'
|
||||
limits: str = 'transfer'
|
||||
lift_from_start_in: float = 0.0
|
||||
lift_from_end_in: float = 0.0
|
||||
pick_points: int = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class Beam:
|
||||
mark: str
|
||||
length_in: float
|
||||
section: Section
|
||||
materials: Materials
|
||||
supports: List[Support]
|
||||
settings: Settings
|
||||
strength_combos: List[Combo]
|
||||
service_combos: List[Combo]
|
||||
point_loads: List[PointLoad] = field(default_factory=list)
|
||||
line_loads: List[LineLoad] = field(default_factory=list)
|
||||
point_moments: List[PointMoment] = field(default_factory=list)
|
||||
bars: List[BarRow] = field(default_factory=list)
|
||||
strands: List[StrandGroup] = field(default_factory=list)
|
||||
transverse: List[TransverseZone] = field(default_factory=list)
|
||||
stripping: Optional[Stripping] = None
|
||||
self_weight_mult: float = 1.0
|
||||
pattern_live: bool = True
|
||||
bearing_analysis: bool = True
|
||||
bearing_mu: float = 1.4
|
||||
bearing_nu_ratio: float = 0.2
|
||||
description: str = ''
|
||||
|
||||
# -- derived ---------------------------------------------------------
|
||||
@property
|
||||
def length_ft(self) -> float:
|
||||
return self.length_in / 12.0
|
||||
|
||||
@property
|
||||
def self_weight_klf(self) -> float:
|
||||
return (self.section.area / 144.0) * self.materials.wc_pcf / 1000.0 \
|
||||
* self.self_weight_mult
|
||||
|
||||
@property
|
||||
def weight_k(self) -> float:
|
||||
return self.self_weight_klf * self.length_ft
|
||||
|
||||
@property
|
||||
def prestressed(self) -> bool:
|
||||
return bool(self.strands)
|
||||
|
||||
@property
|
||||
def span_in(self) -> float:
|
||||
a, b = self.supports[0].x_in, self.supports[1].x_in
|
||||
return b - a
|
||||
|
||||
def bars_at(self, x_in: float) -> List[tuple]:
|
||||
"""Every bar present at x: [(x, y, area, db, embed_start, embed_end,
|
||||
row), ...] with the embedment lengths from x to each bar end."""
|
||||
out = []
|
||||
for row in self.bars:
|
||||
x0 = row.start_in
|
||||
x1 = self.length_in - row.end_in
|
||||
if x0 - 1e-9 <= x_in <= x1 + 1e-9:
|
||||
for bx, by, a, db in row.bars(self.section):
|
||||
out.append((bx, by, a, db, x_in - x0, x1 - x_in, row))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsing
|
||||
|
||||
def _x_from(d, key_x, key_from, length_in, default_from='start'):
|
||||
x = _num(d, key_x, 0.0)
|
||||
frm = (d.get(key_from) or default_from).lower()
|
||||
if frm == 'end':
|
||||
return length_in - x
|
||||
return x
|
||||
|
||||
|
||||
def _combos(rows, default_rows) -> List[Combo]:
|
||||
rows = rows if rows is not None else default_rows
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append(Combo(D=float(r.get('D', 0)), L=float(r.get('L', 0)),
|
||||
R=float(r.get('R', 0)), W=float(r.get('W', 0)),
|
||||
E=float(r.get('E', 0)),
|
||||
include=bool(r.get('include', True)),
|
||||
label=r.get('label', '')))
|
||||
return out
|
||||
|
||||
|
||||
# ACI 318-19 5.3.1 as the reference tool prints them (Roof as its own case).
|
||||
DEFAULT_STRENGTH = [
|
||||
dict(D=1.4, L=0, R=0, W=0, E=0, include=True),
|
||||
dict(D=1.2, L=1.6, R=0.5, W=0, E=0, include=True),
|
||||
dict(D=1.2, L=1.0, R=1.6, W=0, E=0, include=True),
|
||||
dict(D=1.2, L=0, R=1.6, W=0.5, E=0, include=False),
|
||||
dict(D=1.2, L=1.0, R=0.5, W=1.0, E=0, include=False),
|
||||
dict(D=1.2, L=1.0, R=0.2, W=0, E=1.0, include=False),
|
||||
dict(D=0.9, L=0, R=0, W=1.0, E=0, include=False),
|
||||
dict(D=0.9, L=0, R=0, W=0, E=1.0, include=False),
|
||||
]
|
||||
DEFAULT_SERVICE = [
|
||||
dict(D=1, L=0, R=0, W=0, E=0, include=True),
|
||||
dict(D=1, L=1, R=0, W=0, E=0, include=True),
|
||||
dict(D=1, L=0, R=1, W=0, E=0, include=True),
|
||||
dict(D=1, L=0.75, R=0.75, W=0, E=0, include=True),
|
||||
dict(D=1, L=0, R=0, W=0.6, E=0, include=False),
|
||||
dict(D=1, L=0, R=0, W=0, E=0.7, include=False),
|
||||
dict(D=1, L=0.75, R=0.75, W=0.45, E=0, include=False),
|
||||
dict(D=1, L=0.75, R=0.75, W=0, E=0.525, include=False),
|
||||
dict(D=0.6, L=0, R=0, W=0.6, E=0, include=False),
|
||||
dict(D=0.6, L=0, R=0, W=0, E=0.7, include=False),
|
||||
]
|
||||
|
||||
|
||||
def _section(d: dict) -> Section:
|
||||
kind = (d.get('type') or 'rectangular').lower().replace(' ', '_')
|
||||
if kind in ('rectangular', 'rect'):
|
||||
return Section.rectangular(_num(d, 'width_in', required=True),
|
||||
_num(d, 'height_in', required=True))
|
||||
if kind in ('l', 'l_beam', 'spandrel', 'spandrel_/_l-beam'):
|
||||
return Section.l_beam(_num(d, 'stem_width_in', required=True),
|
||||
_num(d, 'stem_height_in', required=True),
|
||||
_num(d, 'flange_width_in', required=True),
|
||||
_num(d, 'flange_thickness_in', required=True),
|
||||
(d.get('side') or 'left').lower())
|
||||
if kind in ('inverted_t', 'it', 'inverted_tee'):
|
||||
return Section.inverted_t(_num(d, 'stem_width_in', required=True),
|
||||
_num(d, 'stem_height_in', required=True),
|
||||
_num(d, 'flange_width_in', required=True),
|
||||
_num(d, 'flange_thickness_in', required=True))
|
||||
raise JobError('unknown section type: {0!r}'.format(kind))
|
||||
|
||||
|
||||
def _materials(d: dict) -> Materials:
|
||||
rb = d.get('rebar') or {}
|
||||
tr = d.get('trans') or rb
|
||||
st = d.get('strand') or {}
|
||||
return Materials(
|
||||
fc_ksi=_num(d, 'fc_ksi', required=True),
|
||||
fci_ksi=_num(d, 'fci_ksi', _num(d, 'fc_ksi')),
|
||||
wc_pcf=_num(d, 'wc_pcf', 150.0),
|
||||
ec_ksi=_num(d, 'ec_ksi'),
|
||||
eci_ksi=_num(d, 'eci_ksi'),
|
||||
normalweight=(d.get('type') or 'normalweight').lower().startswith('normal'),
|
||||
lam=_num(d, 'lambda', 1.0),
|
||||
rebar=Rebar(_num(rb, 'fy_ksi', 60.0), _num(rb, 'es_ksi', 29000.0)),
|
||||
trans=Rebar(_num(tr, 'fy_ksi', 60.0), _num(tr, 'es_ksi', 29000.0)),
|
||||
strand=StrandMaterial(_num(st, 'fpu_ksi', 270.0),
|
||||
_num(st, 'ep_ksi', 28500.0),
|
||||
bool(st.get('low_relaxation', True))))
|
||||
|
||||
|
||||
def _settings(d: dict) -> Settings:
|
||||
s = Settings()
|
||||
if not d:
|
||||
return s
|
||||
defl = d.get('deflection') or {}
|
||||
for key, attr in (('total', 'total_defl_ratio'), ('live', 'live_defl_ratio')):
|
||||
v = defl.get(key)
|
||||
if v is not None:
|
||||
if isinstance(v, str) and '/' in v:
|
||||
v = float(v.split('/')[1])
|
||||
setattr(s, attr, float(v))
|
||||
if defl.get('ec_for_sw'):
|
||||
s.ec_for_sw_defl = defl['ec_for_sw']
|
||||
mult = defl.get('multipliers') or {}
|
||||
if mult.get('self_weight'):
|
||||
s.sw_mult.update({k: float(v) for k, v in mult['self_weight'].items()
|
||||
if v is not None})
|
||||
if mult.get('addl_dead'):
|
||||
s.addl_dead_mult.update({k: float(v) for k, v in mult['addl_dead'].items()
|
||||
if v is not None})
|
||||
phi = d.get('phi') or {}
|
||||
s.phi_tension = _num(phi, 'tension', s.phi_tension)
|
||||
s.phi_shear = _num(phi, 'shear', s.phi_shear)
|
||||
s.phi_compression = _num(phi, 'compression', s.phi_compression)
|
||||
tl = d.get('transfer_limits') or {}
|
||||
s.transfer_comp_ends = _num(tl, 'comp_ends', s.transfer_comp_ends)
|
||||
s.transfer_comp_other = _num(tl, 'comp_other', s.transfer_comp_other)
|
||||
s.transfer_tens_ends_sqrt = _num(tl, 'tens_ends_sqrt', s.transfer_tens_ends_sqrt)
|
||||
s.transfer_tens_other_sqrt = _num(tl, 'tens_other_sqrt', s.transfer_tens_other_sqrt)
|
||||
sl = d.get('service_limits') or {}
|
||||
s.service_comp_sustained = _num(sl, 'comp_sustained', s.service_comp_sustained)
|
||||
s.service_comp_total = _num(sl, 'comp_total', s.service_comp_total)
|
||||
fx = d.get('flexure') or {}
|
||||
s.ignored_distance_in = _num(fx, 'ignored_distance_in', 0.0)
|
||||
s.strain_limit = _num(fx, 'strain_limit', 0.004)
|
||||
sh = d.get('shear') or {}
|
||||
s.dv_method = sh.get('dv_method', s.dv_method)
|
||||
s.min_shear_method = sh.get('min_method', s.min_shear_method)
|
||||
dv = d.get('development') or {}
|
||||
s.ktr = _num(dv, 'ktr', 0.0)
|
||||
s.mild_dev_mult = _num(dv, 'mild_mult', 1.0)
|
||||
s.transform_reinforcement = bool(
|
||||
(d.get('section_properties') or {}).get('transform', True))
|
||||
return s
|
||||
|
||||
|
||||
def _cases(d: dict, pair=False) -> dict:
|
||||
out = {}
|
||||
for c in CASES:
|
||||
v = d.get(c, 0)
|
||||
if pair:
|
||||
if isinstance(v, (list, tuple)):
|
||||
out[c] = (float(v[0]), float(v[-1]))
|
||||
else:
|
||||
out[c] = (float(v or 0), float(v or 0))
|
||||
else:
|
||||
out[c] = float(v or 0)
|
||||
return out
|
||||
|
||||
|
||||
def from_job(job: dict) -> Beam:
|
||||
"""Build a `Beam` from a parsed `beamcalc/1` job dict. Raises JobError
|
||||
naming the first thing wrong; never returns a half-read beam."""
|
||||
if job.get('schema') != SCHEMA:
|
||||
raise JobError('schema is {0!r}, this engine reads {1!r}'
|
||||
.format(job.get('schema'), SCHEMA))
|
||||
ext = job.get('extents') or {}
|
||||
length_in = _num(ext, 'length_in')
|
||||
if length_in is None:
|
||||
length_ft = _num(ext, 'length_ft', required=True)
|
||||
length_in = length_ft * 12.0
|
||||
section = _section(ext.get('section') or {})
|
||||
mats = _materials(job.get('materials') or {})
|
||||
settings = _settings(job.get('settings') or {})
|
||||
|
||||
sup = job.get('supports') or {}
|
||||
s0 = sup.get('start') or {}
|
||||
s1 = sup.get('end') or {}
|
||||
supports = [
|
||||
Support(_num(s0, 'from_start_in', 0.0), _num(s0, 'length_in', 0.0),
|
||||
_num(s0, 'width_in', section.width_in), s0.get('type', 'pin')),
|
||||
Support(length_in - _num(s1, 'from_end_in', 0.0),
|
||||
_num(s1, 'length_in', 0.0),
|
||||
_num(s1, 'width_in', section.width_in), s1.get('type', 'pin')),
|
||||
]
|
||||
if supports[1].x_in <= supports[0].x_in:
|
||||
raise JobError('supports cross: start at {0}", end at {1}"'
|
||||
.format(supports[0].x_in, supports[1].x_in))
|
||||
|
||||
combos = job.get('combos') or {}
|
||||
strength = _combos(combos.get('strength'), DEFAULT_STRENGTH)
|
||||
service = _combos(combos.get('service'), DEFAULT_SERVICE)
|
||||
|
||||
loads = job.get('loads') or {}
|
||||
pls = []
|
||||
for i, p in enumerate(loads.get('point') or []):
|
||||
pls.append(PointLoad(p.get('id') or 'P{0}'.format(i + 1),
|
||||
_x_from(p, 'x_in', 'from', length_in),
|
||||
_num(p, 'ecc_in', 0.0), _cases(p),
|
||||
p.get('description', '')))
|
||||
lls = []
|
||||
for i, l in enumerate(loads.get('line') or []):
|
||||
x0 = _x_from(l, 'x0_in', 'x0_from', length_in, 'start')
|
||||
x1 = _x_from(l, 'x1_in', 'x1_from', length_in, 'end')
|
||||
if x1 < x0:
|
||||
x0, x1 = x1, x0
|
||||
lls.append(LineLoad(l.get('id') or 'L{0}'.format(i + 1), x0, x1,
|
||||
_num(l, 'ecc0_in', _num(l, 'ecc_in', 0.0)),
|
||||
_num(l, 'ecc1_in', _num(l, 'ecc_in', 0.0)),
|
||||
_cases(l, pair=True), l.get('description', '')))
|
||||
pms = []
|
||||
for i, m in enumerate(loads.get('moments') or []):
|
||||
pms.append(PointMoment(m.get('id') or 'M{0}'.format(i + 1),
|
||||
_x_from(m, 'x_in', 'from', length_in),
|
||||
_cases(m)))
|
||||
|
||||
reinf = job.get('reinforcement') or {}
|
||||
bars = []
|
||||
for r in reinf.get('bars') or []:
|
||||
bars.append(BarRow(r['size'], int(r.get('rows', 1)), int(r.get('cols', 1)),
|
||||
_num(r, 'left_in', 2.0), _num(r, 'right_in', 2.0),
|
||||
_num(r, 'top_in', 2.0), _num(r, 'bottom_in', 2.0),
|
||||
_num(r, 'start_in', 0.0), _num(r, 'end_in', 0.0),
|
||||
r.get('start_cond', 'straight'),
|
||||
r.get('end_cond', 'straight'),
|
||||
_num(r, 'area_in2')))
|
||||
strands = []
|
||||
for g in reinf.get('strands') or []:
|
||||
strands.append(StrandGroup(g.get('ids', ''), g['size'],
|
||||
_num(g, 'pull_pct', 70.0),
|
||||
int(g.get('rows', 1)), int(g.get('cols', 1)),
|
||||
_num(g, 'left_in', 2.0), _num(g, 'right_in', 2.0),
|
||||
_num(g, 'first_row_in', 2.0),
|
||||
_num(g, 'spacing_in', 2.0),
|
||||
_num(g, 'force_k')))
|
||||
trans = []
|
||||
for z in reinf.get('transverse') or []:
|
||||
trans.append(TransverseZone(z['size'], int(z.get('legs', 2)),
|
||||
_num(z, 'first_in', 0.0),
|
||||
(z.get('from') or 'start').lower() == 'end',
|
||||
_num(z, 'spacing_in', required=True),
|
||||
_num(z, 'length_in', 0.0),
|
||||
int(z.get('qty', 0)),
|
||||
bool(z.get('symmetric', True)),
|
||||
z.get('use', 'Av'), _num(z, 'area_in2')))
|
||||
|
||||
hd = job.get('handling') or {}
|
||||
st = hd.get('stripping')
|
||||
stripping = None
|
||||
if st:
|
||||
stripping = Stripping(_num(st, 'multiplier', 1.4),
|
||||
st.get('face_down', 'bottom'),
|
||||
st.get('strength', 'initial'),
|
||||
st.get('limits', 'transfer'),
|
||||
_num(st, 'lift_from_start_in', 0.0),
|
||||
_num(st, 'lift_from_end_in', 0.0),
|
||||
int(st.get('pick_points', 2)))
|
||||
|
||||
br = job.get('bearing') or {}
|
||||
return Beam(
|
||||
mark=str(job.get('mark') or ''),
|
||||
length_in=length_in, section=section, materials=mats,
|
||||
supports=supports, settings=settings,
|
||||
strength_combos=strength, service_combos=service,
|
||||
point_loads=pls, line_loads=lls, point_moments=pms,
|
||||
bars=bars, strands=strands, transverse=trans, stripping=stripping,
|
||||
self_weight_mult=_num(loads, 'self_weight_mult', 1.0),
|
||||
pattern_live=bool(loads.get('pattern_live', True)),
|
||||
bearing_analysis=bool(br.get('analyse', True)),
|
||||
bearing_mu=_num(br, 'mu', 1.4),
|
||||
bearing_nu_ratio=_num(br, 'nu_ratio', 0.2),
|
||||
description=str(job.get('description') or ''))
|
||||
|
||||
|
||||
def load_job(path: str) -> Beam:
|
||||
with open(path, 'r', encoding='utf-8') as fh:
|
||||
return from_job(json.load(fh))
|
||||
@@ -0,0 +1,383 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The results HTML (the Beam Analysis 后端 page).
|
||||
|
||||
Same shape as the Panel Analysis results page: verdict banner, one card per
|
||||
stage, then collapsible sections that echo the inputs and print every
|
||||
per-station table the reference tool prints, with SVG diagrams drawn by
|
||||
`panellift.report.Plot` so the two reports read alike. Lengths print as
|
||||
feet-inches through `panellift.units`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import html
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PANELLIFT = os.path.abspath(os.path.join(_HERE, '..', '..', '..', 'panellift', 'src'))
|
||||
if _PANELLIFT not in sys.path:
|
||||
sys.path.insert(0, _PANELLIFT)
|
||||
|
||||
from panellift.report import Plot, CSS as _PL_CSS, hover_script # noqa: E402
|
||||
from panellift.units import fmt_ft_in, fmt_in # noqa: E402
|
||||
|
||||
from .solve import Analysis
|
||||
from .model import Beam, CASES
|
||||
|
||||
|
||||
def esc(s) -> str:
|
||||
return html.escape(str(s), quote=True)
|
||||
|
||||
|
||||
def n(v, d=1) -> str:
|
||||
if v is None:
|
||||
return '-'
|
||||
if isinstance(v, float) and (v != v or v in (float('inf'), float('-inf'))):
|
||||
return 'NC'
|
||||
return ('{0:,.%df}' % d).format(v)
|
||||
|
||||
|
||||
def ratio(v) -> str:
|
||||
if v is None or v != v:
|
||||
return '-'
|
||||
if v == float('inf'):
|
||||
return 'inf'
|
||||
cls = 'fail' if v > 1.0 + 1e-9 else 'ok'
|
||||
return '<span class="{0}">{1}</span>'.format(cls, n(v, 2))
|
||||
|
||||
|
||||
CSS = _PL_CSS + """
|
||||
.cards{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}
|
||||
.card{border:1px solid #d8dde3;border-left:5px solid #2a7;border-radius:6px;padding:8px 12px;min-width:180px;background:#fff}
|
||||
.card.bad{border-left-color:#c33}
|
||||
.card .t{font-weight:600;font-size:13px}.card .u{font-size:22px;font-weight:700;margin:2px 0}
|
||||
.card .s{font-size:12px;color:#555}
|
||||
.verdict{padding:8px 12px;border-radius:6px;margin:10px 0;font-weight:600}
|
||||
.verdict.ok{background:#e6f5ea;color:#1a6b34}.verdict.bad{background:#fbe6e6;color:#8a1c1c}
|
||||
span.fail{color:#b00;font-weight:600}span.ok{color:#1a6b34}
|
||||
table.st{font-size:12px}table.st td,table.st th{padding:2px 6px;white-space:nowrap}
|
||||
.note{font-size:12px;color:#555;margin:4px 0 8px}
|
||||
details{margin:8px 0}summary{cursor:pointer;font-weight:600;padding:6px 8px;background:#f2f4f6;border-radius:4px}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pieces
|
||||
|
||||
def _sec(title: str, body: str, open_: bool = False, tag: str = '') -> str:
|
||||
return ('<details{o}><summary>{t}{g}</summary><div class="body">{b}</div>'
|
||||
'</details>').format(o=' open' if open_ else '', t=esc(title),
|
||||
g=(' <span class="tag">' + tag + '</span>') if tag else '',
|
||||
b=body)
|
||||
|
||||
|
||||
def _table(headers: List[str], rows: List[List[str]], cls='st') -> str:
|
||||
h = ''.join('<th>{0}</th>'.format(x) for x in headers)
|
||||
b = ''.join('<tr>' + ''.join('<td>{0}</td>'.format(c) for c in r) + '</tr>'
|
||||
for r in rows)
|
||||
return '<div class="scroll"><table class="{0}"><thead><tr>{1}</tr></thead>' \
|
||||
'<tbody>{2}</tbody></table></div>'.format(cls, h, b)
|
||||
|
||||
|
||||
def _kv(pairs) -> str:
|
||||
return '<table class="kv">' + ''.join(
|
||||
'<tr><th>{0}</th><td>{1}</td></tr>'.format(esc(k), v) for k, v in pairs
|
||||
) + '</table>'
|
||||
|
||||
|
||||
def _cards(a: Analysis) -> str:
|
||||
out = []
|
||||
for st in a.stages:
|
||||
bad = not st.ok
|
||||
sub = ''
|
||||
if st.name == 'In-Place Strength':
|
||||
f = st.data.get('flexure')
|
||||
s = st.data.get('shear')
|
||||
bits = []
|
||||
if f:
|
||||
bits.append('flexure {0} at {1}'.format(n(f.util, 2), fmt_ft_in(f.x_ft)))
|
||||
if s:
|
||||
bits.append('shear {0} at {1}'.format(n(s.r.util, 2), fmt_ft_in(s.x_ft)))
|
||||
sub = '; '.join(bits)
|
||||
elif st.name == 'In-Place Service':
|
||||
d = a.deflection
|
||||
sub = 'total {0}" / {1}", live {2}" / {3}"'.format(
|
||||
n(d['total_down'], 3), n(d['total_limit'], 3),
|
||||
n(d['live_down'], 3), n(d['live_limit'], 3))
|
||||
elif st.name == 'Stripping':
|
||||
sub = '; '.join(st.lines[1:])
|
||||
out.append('<div class="card{b}"><div class="t">{t} <span class="{c}">{v}</span></div>'
|
||||
'<div class="u">{u}</div><div class="s">{s}</div></div>'.format(
|
||||
b=' bad' if bad else '', t=esc(st.name),
|
||||
c='fail' if bad else 'ok', v='FAIL' if bad else 'PASS',
|
||||
u='{0:.0%} utilised'.format(st.util) if st.util < 10 else 'NC',
|
||||
s=esc(sub)))
|
||||
return '<div class="cards">' + ''.join(out) + '</div>'
|
||||
|
||||
|
||||
def _inputs(b: Beam) -> str:
|
||||
m = b.materials
|
||||
sec = b.section
|
||||
mat = _kv([
|
||||
("f'c / f'ci", '{0} / {1} ksi'.format(n(m.fc_ksi, 2), n(m.fci_ksi, 2))),
|
||||
('Ec / Eci', '{0} / {1} ksi'.format(n(m.Ec, 0), n(m.Eci, 0))),
|
||||
('unit weight', '{0} pcf, {1}'.format(n(m.wc_pcf, 0),
|
||||
'normalweight' if m.normalweight else 'lightweight')),
|
||||
('rebar fy / Es', '{0} / {1} ksi'.format(n(m.rebar.fy_ksi, 0), n(m.rebar.es_ksi, 0))),
|
||||
('strand fpu / Ep', '{0} / {1} ksi'.format(n(m.strand.fpu_ksi, 0), n(m.strand.ep_ksi, 0))
|
||||
if b.strands else '-'),
|
||||
])
|
||||
geo = _kv([
|
||||
('length', fmt_ft_in(b.length_ft)),
|
||||
('section', '{0}, {1} wide x {2} high'.format(sec.kind, fmt_in(sec.width_in),
|
||||
fmt_in(sec.height_in))),
|
||||
('area / Ixx', '{0} in² / {1} in⁴'.format(n(sec.area, 1), n(sec.ixx, 0))),
|
||||
('St / Sb (gross)', '{0} / {1} in³'.format(n(sec.st, 0), n(sec.sb, 0))),
|
||||
('self weight', '{0} klf, {1} kips total'.format(n(b.self_weight_klf, 3),
|
||||
n(b.weight_k, 2))),
|
||||
('bearings', 'start CL at {0} ({1} long), end CL at {2} from the end ({3} long)'
|
||||
.format(fmt_in(b.supports[0].x_in), fmt_in(b.supports[0].length_in),
|
||||
fmt_in(b.length_in - b.supports[1].x_in), fmt_in(b.supports[1].length_in))),
|
||||
('span (CL to CL)', fmt_ft_in(b.span_in / 12.0)),
|
||||
])
|
||||
load_rows = []
|
||||
for p in b.point_loads:
|
||||
load_rows.append([esc(p.id), 'point', fmt_ft_in(p.x_in / 12.0), '-'] +
|
||||
[n(p.cases.get(c, 0.0), 2) for c in CASES])
|
||||
for l in b.line_loads:
|
||||
load_rows.append([esc(l.id), 'line', fmt_ft_in(l.x0_in / 12.0),
|
||||
fmt_ft_in(l.x1_in / 12.0)] +
|
||||
['{0}'.format(n(l.cases.get(c, (0, 0))[0], 2)) for c in CASES])
|
||||
loads = _table(['id', 'kind', 'from', 'to'] + list(CASES), load_rows) if load_rows \
|
||||
else '<p class="note">no applied loads</p>'
|
||||
combos = _table(['#', 'D', 'L', 'R', 'W', 'E', 'include'],
|
||||
[[str(i + 1), n(c.D, 2), n(c.L, 2), n(c.R, 2), n(c.W, 2), n(c.E, 2),
|
||||
'yes' if c.include else 'no'] for i, c in enumerate(b.strength_combos)])
|
||||
bars = _table(['size', 'rows x cols', 'from top', 'from bottom', 'left', 'right',
|
||||
'starts', 'ends', 'area'],
|
||||
[[esc(r.size), '{0} x {1}'.format(r.rows, r.cols), fmt_in(r.top_in),
|
||||
fmt_in(r.bottom_in), fmt_in(r.left_in), fmt_in(r.right_in),
|
||||
fmt_in(r.start_in) + ' from start', fmt_in(r.end_in) + ' from end',
|
||||
n(r.bar_area * r.rows * r.cols, 2) + ' in²'] for r in b.bars]) \
|
||||
if b.bars else '<p class="note">no mild bars</p>'
|
||||
strands = _table(['ids', 'size', 'pull', 'rows x cols', 'first row', 'spacing'],
|
||||
[[esc(g.ids), esc(g.size), n(g.pull_pct, 0) + '%',
|
||||
'{0} x {1}'.format(g.rows, g.cols), fmt_in(g.first_row_in),
|
||||
fmt_in(g.spacing_in)] for g in b.strands]) \
|
||||
if b.strands else '<p class="note">no strands</p>'
|
||||
trans = _table(['size', 'legs', 'area', 'first', 'spacing', 'zone length', 'qty', 'symmetric'],
|
||||
[[esc(z.size), str(z.legs), n(z.area, 2), fmt_in(z.first_in) +
|
||||
(' from end' if z.from_end else ' from start'), fmt_in(z.spacing_in),
|
||||
fmt_in(z.length_in), str(z.qty), 'yes' if z.symmetric else 'no']
|
||||
for z in b.transverse]) if b.transverse else '<p class="note">no transverse steel</p>'
|
||||
return (_sec('Materials', mat) + _sec('Concrete extents', geo) +
|
||||
_sec('Loads (kips, klf)', loads) + _sec('Strength combinations', combos) +
|
||||
_sec('Mild reinforcement', bars) + _sec('Prestress', strands) +
|
||||
_sec('Transverse reinforcement', trans))
|
||||
|
||||
|
||||
def _diagram(a: Analysis) -> str:
|
||||
"""Shear and moment envelopes over the grid, plus deflection."""
|
||||
b = a.beam
|
||||
xs = [x / 12.0 for x in a.grid]
|
||||
vmax = [0.0] * len(xs)
|
||||
vmin = [0.0] * len(xs)
|
||||
mmax = [0.0] * len(xs)
|
||||
mmin = [0.0] * len(xs)
|
||||
for tag, res in a.diagrams.items():
|
||||
for i in range(len(xs)):
|
||||
vmax[i] = max(vmax[i], res.shear[i])
|
||||
vmin[i] = min(vmin[i], res.shear[i])
|
||||
mmax[i] = max(mmax[i], res.moment[i] / 12.0)
|
||||
mmin[i] = min(mmin[i], res.moment[i] / 12.0)
|
||||
out = []
|
||||
for title, hi, lo, unit in (('Shear envelope Vu (kips)', vmax, vmin, 'k'),
|
||||
('Moment envelope Mu (kip-ft)', mmax, mmin, 'k-ft')):
|
||||
p = Plot(width=760, height=200)
|
||||
y0 = min(lo) if lo else 0.0
|
||||
y1 = max(hi) if hi else 1.0
|
||||
pad = (y1 - y0) * 0.08 or 1.0
|
||||
p.domain(0.0, b.length_ft, y0 - pad, y1 + pad)
|
||||
p.area(list(zip(xs, hi)), cls='fill')
|
||||
p.area(list(zip(xs, lo)), cls='fill')
|
||||
p.path(list(zip(xs, hi)))
|
||||
p.path(list(zip(xs, lo)))
|
||||
p.hline(0.0)
|
||||
for s in b.supports:
|
||||
p.vline(s.x_in / 12.0, cls='support')
|
||||
p.axis_x()
|
||||
p.hline(y1, label='{0} {1}'.format(n(y1, 1), unit))
|
||||
p.hline(y0, label='{0} {1}'.format(n(y0, 1), unit))
|
||||
out.append(p.render(title))
|
||||
d = a.deflection
|
||||
p = Plot(width=760, height=200)
|
||||
tot = d['total']
|
||||
live = d['live']
|
||||
y1 = max(max(tot), max(live), 0.0)
|
||||
y0 = min(min(tot), min(live), 0.0)
|
||||
pad = (y1 - y0) * 0.1 or 0.1
|
||||
p.domain(0.0, b.length_ft, -(y1 + pad), -(y0 - pad))
|
||||
p.area(list(zip(xs, [-t for t in tot])), cls='fill')
|
||||
p.path(list(zip(xs, [-t for t in tot])))
|
||||
p.path(list(zip(xs, [-l for l in live])), cls='series2')
|
||||
p.hline(0.0)
|
||||
p.hline(-d['total_limit'], cls='limit', label='L/{0:.0f}'.format(b.settings.total_defl_ratio))
|
||||
for s in b.supports:
|
||||
p.vline(s.x_in / 12.0, cls='support')
|
||||
p.axis_x()
|
||||
out.append(p.render('Deflection, total (filled) and live (line), inches, down is down'))
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def _flexure_table(a: Analysis) -> str:
|
||||
rows = []
|
||||
for f in a.flex:
|
||||
rows.append([fmt_ft_in(f.x_ft), ratio(f.util), n(f.mu, 2),
|
||||
'{0} {1}'.format(n(f.phi, 2), f.controls), n(f.phi_mn, 2),
|
||||
n(f.mcr12, 2), n(f.c, 2), n(f.as_t, 2), n(f.as_c, 2), n(f.aps, 3),
|
||||
n(f.d, 2), esc(f.combo)])
|
||||
return _table(['station', 'util', 'Mu (k-ft)', 'φ', 'φMn (k-ft)', '1.2 Mcr', 'c (in)',
|
||||
'As (in²)', "A's (in²)", 'Aps (in²)', 'd (in)', 'combination'], rows)
|
||||
|
||||
|
||||
def _shear_table(a: Analysis) -> str:
|
||||
rows = []
|
||||
for s in a.shr:
|
||||
r = s.r
|
||||
rows.append([fmt_ft_in(s.x_ft), ratio(r.util), n(r.vu, 1), n(r.phi_vn, 1), n(r.vc, 1),
|
||||
n(r.vs_prov, 1), n(r.bwd, 1), n(r.av_min, 3), n(r.av_prov, 3),
|
||||
n(r.av_req, 3), n(r.s, 2) if r.s else '-', n(r.s_max, 2),
|
||||
n(r.f_act, 1), n(r.f_alw, 1), esc(r.note)])
|
||||
return _table(['station', 'util', 'Vu (k)', 'φVn (k)', 'Vc (k)', 'Vs prov (k)', 'bw·d (in²)',
|
||||
'Av min', 'Av prov', 'Av req (in²/ft)', 's (in)', 's max', 'f act (psi)',
|
||||
'f alw (psi)', ''], rows)
|
||||
|
||||
|
||||
def _service_table(a: Analysis) -> str:
|
||||
b = a.beam
|
||||
fc = b.materials.fc_ksi * 1000.0
|
||||
lim_tot = b.settings.service_comp_total * fc
|
||||
lim_sus = b.settings.service_comp_sustained * fc
|
||||
rows = []
|
||||
for r in a.service:
|
||||
rows.append([fmt_ft_in(r.x_ft), n(r.m, 2), n(r.f_top, 0), n(r.f_bot, 0),
|
||||
n(r.f_top_sus, 0), n(r.f_bot_sus, 0),
|
||||
ratio(max(r.f_top, r.f_bot) / lim_tot),
|
||||
ratio(max(r.f_top_sus, r.f_bot_sus) / lim_sus), esc(r.combo)])
|
||||
head = ('<p class="note">Stresses on the transformed section, psi, compression positive. '
|
||||
'Limits: total {0} psi (0.{1:.0f} f\'c), sustained {2} psi.</p>'
|
||||
.format(n(lim_tot, 0), b.settings.service_comp_total * 100, n(lim_sus, 0)))
|
||||
return head + _table(['station', 'M (k-ft)', 'f top', 'f bot', 'f top sus', 'f bot sus',
|
||||
'comp / total', 'comp / sustained', 'combination'], rows)
|
||||
|
||||
|
||||
def _deflection(a: Analysis) -> str:
|
||||
d = a.deflection
|
||||
return _kv([
|
||||
('span', fmt_ft_in(d['span_in'] / 12.0)),
|
||||
('total deflection down / up', '{0}" / {1}" (limit {2}")'.format(
|
||||
n(d['total_down'], 3), n(d['total_up'], 3), n(d['total_limit'], 3))),
|
||||
('live deflection down / up', '{0}" / {1}" (limit {2}")'.format(
|
||||
n(d['live_down'], 3), n(d['live_up'], 3), n(d['live_limit'], 3))),
|
||||
('multipliers', 'self weight x {0}, additional dead x {1}, live x 1'.format(
|
||||
n(d['multipliers']['self_weight'], 2), n(d['multipliers']['addl_dead'], 2))),
|
||||
('stiffness', 'E {0} ksi (self weight on {1} ksi), I {2} in⁴ (uncracked, transformed)'
|
||||
.format(n(d['e'], 0), n(d['e_sw'], 0), n(d['i'], 0))),
|
||||
])
|
||||
|
||||
|
||||
def _reactions(a: Analysis) -> str:
|
||||
rows = []
|
||||
for case, (ra, rb) in a.reactions.items():
|
||||
if ra or rb:
|
||||
rows.append([esc(case), n(ra, 2), n(rb, 2)])
|
||||
br = a.bearing
|
||||
bear = _table(['bearing', 'Vu (k)', 'A1 (in²)', 'plain φVn (k)', 'note'],
|
||||
[[k, n(v['vu'], 1), n(v['a1'], 1), n(v['phi_vn_plain'], 1), esc(v['note'])]
|
||||
for k, v in br.items()])
|
||||
return (_table(['case', 'start (k)', 'end (k)'], rows) +
|
||||
'<h4>Bearing</h4>' + bear)
|
||||
|
||||
|
||||
def _stripping(a: Analysis) -> str:
|
||||
s = a.stripping
|
||||
if not s:
|
||||
return '<p class="note">stripping not analysed</p>'
|
||||
b = a.beam
|
||||
body = _kv([
|
||||
('lift points', '{0} and {1} from the start'.format(
|
||||
fmt_in(b.stripping.lift_from_start_in),
|
||||
fmt_in(b.length_in - b.stripping.lift_from_end_in))),
|
||||
('factored weight', '{0} kips ({1} x {2})'.format(n(s['factored_weight_k'], 2),
|
||||
n(s['weight_k'], 2), b.stripping.multiplier)),
|
||||
('per pick point', '{0} kips vertical; sling tension {1} k, shear {2} k at 60°'.format(
|
||||
n(s['per_point_k'], 2), n(s['sling_tension_k'], 2), n(s['sling_shear_k'], 2))),
|
||||
("strength used", "f'{0} = {1} ksi".format('ci' if b.stripping.strength == 'initial' else 'c',
|
||||
n(s['fc_ksi'], 2))),
|
||||
('tension', '{0} / {1} psi at {2}'.format(n(s['f_tension'], 0), n(s['lim_tension'], 0),
|
||||
fmt_ft_in(s['f_tension_x'] / 12.0))),
|
||||
('compression', '{0} / {1} psi at {2}'.format(n(s['f_comp'], 0), n(s['lim_comp'], 0),
|
||||
fmt_ft_in(s['f_comp_x'] / 12.0))),
|
||||
('flexure', 'Mu {0} vs φMn {1} k-ft, utilisation {2}'.format(
|
||||
n(s['mu'], 1), n(s['phi_mn'], 1), ratio(s['util_flex']))),
|
||||
])
|
||||
res = s['result']
|
||||
p = Plot(width=760, height=180)
|
||||
xs = [x / 12.0 for x in res.xs]
|
||||
ms = [m / 12.0 for m in res.moment]
|
||||
y0, y1 = min(ms), max(ms)
|
||||
pad = (y1 - y0) * 0.1 or 1.0
|
||||
p.domain(0.0, b.length_ft, y0 - pad, y1 + pad)
|
||||
p.area(list(zip(xs, ms)))
|
||||
p.path(list(zip(xs, ms)))
|
||||
p.hline(0.0)
|
||||
p.axis_x()
|
||||
return body + p.render('Stripping moment (kip-ft), factored self weight')
|
||||
|
||||
|
||||
def render(a: Analysis, title: Optional[str] = None) -> str:
|
||||
b = a.beam
|
||||
ok = a.ok
|
||||
title = title or '{0} — beam analysis'.format(b.mark or 'beam')
|
||||
verdict = ('<div class="verdict {0}">{1}</div>'.format(
|
||||
'ok' if ok else 'bad',
|
||||
'PASS — every solved check satisfied' if ok else 'FAIL — one or more solved checks not satisfied'))
|
||||
worst_f = max(a.flex, key=lambda f: f.util) if a.flex else None
|
||||
worst_s = max(a.shr, key=lambda s: s.r.util) if a.shr else None
|
||||
summary = _kv([
|
||||
('flexure', '{0}: Mu {1} vs φMn {2} k-ft at {3} ({4})'.format(
|
||||
ratio(worst_f.util), n(worst_f.mu, 1), n(worst_f.phi_mn, 1),
|
||||
fmt_ft_in(worst_f.x_ft), esc(worst_f.combo)) if worst_f else '-'),
|
||||
('shear', '{0}: Vu {1} vs φVn {2} k at {3}; Av/s required {4} in²/ft'.format(
|
||||
ratio(worst_s.r.util), n(worst_s.r.vu, 1), n(worst_s.r.phi_vn, 1),
|
||||
fmt_ft_in(worst_s.x_ft),
|
||||
n(max(s.r.av_req for s in a.shr), 3)) if worst_s else '-'),
|
||||
('service compression', '{0} psi total, {1} psi sustained'.format(
|
||||
n(max(max(r.f_top, r.f_bot) for r in a.service), 0),
|
||||
n(max(max(r.f_top_sus, r.f_bot_sus) for r in a.service), 0)) if a.service else '-'),
|
||||
('service tension', '{0} psi (Class U limit {1}, Class T limit {2})'.format(
|
||||
n(max(max(-r.f_top, -r.f_bot) for r in a.service), 0),
|
||||
n(7.5 * (b.materials.fc_ksi * 1000.0) ** 0.5, 0),
|
||||
n(12.0 * (b.materials.fc_ksi * 1000.0) ** 0.5, 0)) if a.service else '-'),
|
||||
])
|
||||
body = [
|
||||
'<h1>{0}</h1>'.format(esc(title)),
|
||||
'<p class="sub">{0} · generated {1} · beamcalc/1</p>'.format(
|
||||
esc(b.description), datetime.datetime.now().strftime('%Y-%m-%d %H:%M')),
|
||||
verdict, _cards(a),
|
||||
_sec('Summary', summary, open_=True),
|
||||
_sec('Inputs', _inputs(b)),
|
||||
_sec('Stripping', _stripping(a), open_=bool(a.stripping)),
|
||||
_sec('In-place strength — diagrams', _diagram(a), open_=True),
|
||||
_sec('In-place strength — flexure, per station', _flexure_table(a), open_=True),
|
||||
_sec('In-place strength — shear, per station', _shear_table(a), open_=True),
|
||||
_sec('In-place service — stresses', _service_table(a)),
|
||||
_sec('In-place service — deflection', _deflection(a), open_=True),
|
||||
_sec('Reactions and bearing', _reactions(a)),
|
||||
]
|
||||
if a.notes:
|
||||
body.append(_sec('Notes', '<ul>' + ''.join('<li>{0}</li>'.format(esc(x)) for x in a.notes) + '</ul>'))
|
||||
return ('<!doctype html>\n<html><head><meta charset="utf-8"><title>{t}</title>'
|
||||
'<style>{css}</style></head><body><main>{b}</main>{js}</body></html>'
|
||||
.format(t=esc(title), css=CSS, b=''.join(body), js=hover_script()))
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Section properties: gross, and transformed for the reinforcement.
|
||||
|
||||
The reference tool's "Transform Reinforcement: All" setting puts (n - 1)·A
|
||||
of every bar and strand into the section before it computes the stresses
|
||||
and the cracking moment -- measured on RBA5 (16" x 22", 6-#6 bottom,
|
||||
4-#6 top): gross Sb = 1291 in³ gives 1.2·Mcr = 68.5 k-ft, transformed
|
||||
Sb = 1493 in³ gives the printed 79.2 k-ft. So `props_at(x)` is the one to
|
||||
use for anything the reference tool computes from a section modulus.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .model import Beam, Section
|
||||
|
||||
|
||||
@dataclass
|
||||
class Props:
|
||||
area: float
|
||||
cy: float # centroid above the bottom
|
||||
ixx: float
|
||||
height: float
|
||||
|
||||
@property
|
||||
def st(self) -> float:
|
||||
return self.ixx / (self.height - self.cy)
|
||||
|
||||
@property
|
||||
def sb(self) -> float:
|
||||
return self.ixx / self.cy
|
||||
|
||||
@property
|
||||
def ct(self) -> float:
|
||||
return self.height - self.cy
|
||||
|
||||
|
||||
def gross(section: Section) -> Props:
|
||||
return Props(section.area, section.cy, section.ixx, section.height_in)
|
||||
|
||||
|
||||
def transformed(section: Section, steel: List[Tuple[float, float, float]],
|
||||
) -> Props:
|
||||
"""`steel` is [(y_above_bottom, area, n), ...]; each contributes
|
||||
(n - 1)·A at its position. With no steel this is the gross section."""
|
||||
a = section.area
|
||||
q = a * section.cy
|
||||
for y, area, n in steel:
|
||||
a_eq = (n - 1.0) * area
|
||||
a += a_eq
|
||||
q += a_eq * y
|
||||
cy = q / a
|
||||
ixx = 0.0
|
||||
for x0, y0, w, h in section.rects:
|
||||
ixx += w * h ** 3 / 12.0 + w * h * (y0 + h / 2.0 - cy) ** 2
|
||||
for y, area, n in steel:
|
||||
ixx += (n - 1.0) * area * (y - cy) ** 2
|
||||
return Props(a, cy, ixx, section.height_in)
|
||||
|
||||
|
||||
def props_at(beam: Beam, x_in: float, ec_ksi: Optional[float] = None,
|
||||
transform: Optional[bool] = None) -> Props:
|
||||
"""Section properties at x: transformed for every bar and strand
|
||||
present there (a bar counts fully once it exists at x -- the reference
|
||||
tool does not scale the transformed area by development), else gross."""
|
||||
if transform is None:
|
||||
transform = beam.settings.transform_reinforcement
|
||||
if not transform:
|
||||
return gross(beam.section)
|
||||
ec = ec_ksi or beam.materials.Ec
|
||||
steel = []
|
||||
n_bar = beam.materials.rebar.es_ksi / ec
|
||||
n_strand = beam.materials.strand.ep_ksi / ec
|
||||
for bx, by, area, db, e0, e1, row in beam.bars_at(x_in):
|
||||
steel.append((by, area, n_bar))
|
||||
for g in beam.strands:
|
||||
for sx, sy, area in g.strands(beam.section):
|
||||
steel.append((sy, area, n_strand))
|
||||
return transformed(beam.section, steel)
|
||||
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""One-way shear, ACI 318-19 Chapter 22.5, reported per station the way
|
||||
the reference tool's Shear Summary Table does: Vu, φVn, Vc, Vs provided,
|
||||
bw·d, Av,min, Av provided, Av required (in²/ft), s, s max, and the shear
|
||||
stresses `fact = Vu / (bw·d)` and `falw = φ·(Vc + 8·√f'c·bw·d) / (bw·d)`
|
||||
(the last two reverse-engineered from RBA5 p.12: 44.1 k / 147.2 in² =
|
||||
299.6 psi; 0.75·(25.8 + 8·√5000·147.2/1000) / 147.2 = 555.9 psi).
|
||||
|
||||
Vc:
|
||||
non-prestressed, Av ≥ Av,min: Table 22.5.5.1(a): 2·λ·√f'c·bw·d
|
||||
prestressed: the lesser of Vci and Vcw (22.5.6.3), with the
|
||||
approximate 22.5.6.2 as the reported fallback when the detailed data
|
||||
are not available.
|
||||
Vs = Av·fyt·d / s, capped at 8·√f'c·bw·d (22.5.1.2).
|
||||
Av,min (9.6.3.4): max(0.75·√f'c, 50)·bw / fyt.
|
||||
s max (9.7.6.2.2): d/2 and 24", halved when Vs > 4·√f'c·bw·d.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .model import Beam, TransverseZone
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShearResult:
|
||||
vu: float # kips (factored demand, absolute)
|
||||
phi_vn: float # kips
|
||||
vc: float # kips
|
||||
vs_prov: float # kips
|
||||
bwd: float # in²
|
||||
d: float # in
|
||||
av_min: float # in²/ft
|
||||
av_prov: float # in²/ft
|
||||
av_req: float # in²/ft
|
||||
s: Optional[float] # in, provided spacing at x
|
||||
s_max: float # in
|
||||
f_act: float # psi
|
||||
f_alw: float # psi
|
||||
util: float
|
||||
note: str = ''
|
||||
vs_over_max: bool = False # raw Vs > 8·√f'c·bw·d (22.5.1.2)
|
||||
|
||||
|
||||
def zone_at(beam: Beam, x_in: float) -> Tuple[Optional[TransverseZone], float]:
|
||||
"""(zone, spacing) of the transverse steel covering x, or (None, 0)."""
|
||||
L = beam.length_in
|
||||
best = None
|
||||
for z in beam.transverse:
|
||||
if z.use.lower() != 'av':
|
||||
continue
|
||||
spans = []
|
||||
x0 = (L - z.first_in) if z.from_end else z.first_in
|
||||
length = z.length_in if z.length_in else (z.qty - 1) * z.spacing_in
|
||||
if z.from_end:
|
||||
spans.append((x0 - length - z.spacing_in / 2.0, x0 + z.spacing_in / 2.0))
|
||||
else:
|
||||
spans.append((x0 - z.spacing_in / 2.0, x0 + length + z.spacing_in / 2.0))
|
||||
if z.symmetric:
|
||||
a, b = spans[0]
|
||||
spans.append((L - b, L - a))
|
||||
# a zone counts once at x if x lies in ANY of its spans (its own
|
||||
# direct span or the mirrored span -- a symmetric zone at midspan
|
||||
# where the two overlap must not be counted twice)
|
||||
if any(a - 1e-9 <= x_in <= b + 1e-9 for a, b in spans):
|
||||
if best is None or z.spacing_in < best.spacing_in:
|
||||
best = z
|
||||
return (best, best.spacing_in) if best else (None, 0.0)
|
||||
|
||||
|
||||
def av_provided_per_ft(beam: Beam, x_in: float) -> Tuple[float, Optional[float]]:
|
||||
"""Total Av at x (in²/ft) summing every zone that covers x -- two
|
||||
overlapping zones (the 'overlap stirrups' detail) add up -- and the
|
||||
tightest spacing among them."""
|
||||
L = beam.length_in
|
||||
total = 0.0
|
||||
s_min = None
|
||||
for z in beam.transverse:
|
||||
if z.use.lower() != 'av':
|
||||
continue
|
||||
x0 = (L - z.first_in) if z.from_end else z.first_in
|
||||
length = z.length_in if z.length_in else (z.qty - 1) * z.spacing_in
|
||||
spans = []
|
||||
if z.from_end:
|
||||
spans.append((x0 - length - z.spacing_in / 2.0, x0 + z.spacing_in / 2.0))
|
||||
else:
|
||||
spans.append((x0 - z.spacing_in / 2.0, x0 + length + z.spacing_in / 2.0))
|
||||
if z.symmetric:
|
||||
a, b = spans[0]
|
||||
spans.append((L - b, L - a))
|
||||
# a zone counts once at x if x lies in ANY of its spans (its own
|
||||
# direct span or the mirrored span -- a symmetric zone at midspan
|
||||
# where the two overlap must not be counted twice). Distinct zones
|
||||
# stay additive: an inner/outer doubled stirrup is two zone rows
|
||||
# and each counts once.
|
||||
if any(a - 1e-9 <= x_in <= b + 1e-9 for a, b in spans):
|
||||
total += z.area * 12.0 / z.spacing_in
|
||||
s_min = z.spacing_in if s_min is None else min(s_min, z.spacing_in)
|
||||
return total, s_min
|
||||
|
||||
|
||||
def vc_nonprestressed(beam: Beam, bw: float, d: float, fc_ksi: float,
|
||||
nu_k: float = 0.0) -> float:
|
||||
lam = beam.materials.lam
|
||||
fc_psi = fc_ksi * 1000.0
|
||||
vc = 2.0 * lam * math.sqrt(fc_psi) / 1000.0 * bw * d
|
||||
if nu_k:
|
||||
vc += nu_k / (6.0 * beam.section.area) * bw * d
|
||||
return max(0.0, min(vc, 5.0 * lam * math.sqrt(fc_psi) / 1000.0 * bw * d))
|
||||
|
||||
|
||||
def vc_prestressed(beam: Beam, bw: float, d: float, dp: float, fc_ksi: float,
|
||||
vu: float, mu_kin: float, mmax_kin: float, vd: float,
|
||||
fpe_psi_bot: float, fd_psi_bot: float, fpc_psi: float,
|
||||
yt: float, i_in4: float, vp: float = 0.0) -> Tuple[float, str]:
|
||||
"""Detailed Vci / Vcw (22.5.6.3.1 / 22.5.6.3.2); returns (Vc, which)."""
|
||||
lam = beam.materials.lam
|
||||
sq = math.sqrt(fc_ksi * 1000.0)
|
||||
dv = max(dp, 0.8 * beam.section.height_in)
|
||||
mcre = (i_in4 / yt) * (6.0 * lam * sq + fpe_psi_bot - fd_psi_bot) / 1000.0
|
||||
if abs(mmax_kin) > 1e-9:
|
||||
vci = 0.6 * lam * sq / 1000.0 * bw * dv + vd + vu * mcre / abs(mmax_kin)
|
||||
else:
|
||||
vci = float('inf')
|
||||
vci = max(vci, 1.7 * lam * sq / 1000.0 * bw * dv)
|
||||
vcw = (3.5 * lam * sq + 0.3 * fpc_psi) / 1000.0 * bw * dv + vp
|
||||
if vci <= vcw:
|
||||
return vci, 'Vci'
|
||||
return vcw, 'Vcw'
|
||||
|
||||
|
||||
def check(beam: Beam, x_in: float, vu: float, d: float, fc_ksi: float,
|
||||
prestress: Optional[dict] = None, mu_kin: float = 0.0,
|
||||
mmax_kin: float = 0.0, vd: float = 0.0) -> ShearResult:
|
||||
mats = beam.materials
|
||||
lam = mats.lam
|
||||
fc_psi = fc_ksi * 1000.0
|
||||
sq = math.sqrt(fc_psi)
|
||||
bw = beam.section.web_width()
|
||||
fyt = mats.trans.fy_ksi
|
||||
phi = beam.settings.phi_shear
|
||||
d = max(d, 1e-6)
|
||||
bwd = bw * d
|
||||
av_prov, s = av_provided_per_ft(beam, x_in)
|
||||
av_min = max(0.75 * sq, 50.0) * bw / (fyt * 1000.0) * 12.0
|
||||
if prestress:
|
||||
vc, which = vc_prestressed(beam, bw, d, prestress['dp'], fc_ksi,
|
||||
abs(vu), mu_kin, mmax_kin, vd,
|
||||
prestress['fpe_bot'], prestress['fd_bot'],
|
||||
prestress['fpc'], prestress['yt'],
|
||||
prestress['i'], prestress.get('vp', 0.0))
|
||||
note = which
|
||||
else:
|
||||
vc = vc_nonprestressed(beam, bw, d, fc_ksi)
|
||||
note = ''
|
||||
vs_cap = 8.0 * lam * sq / 1000.0 * bwd
|
||||
vs_prov = av_prov / 12.0 * fyt * d if av_prov else 0.0
|
||||
vs_over_max = vs_prov > vs_cap
|
||||
if vs_over_max:
|
||||
note = (note + '; ' if note else '') + "Vs exceeds 8*sqrt(f'c)*bw*d (22.5.1.2)"
|
||||
phi_vn = phi * (vc + vs_prov)
|
||||
vu_a = abs(vu)
|
||||
# required Av/s (in²/ft): (Vu/φ - Vc)/(fyt d) * 12, not below min when
|
||||
# Vu > φVc/2 (9.6.3.1), zero when Vu <= φVc/2
|
||||
vs_req = max(0.0, vu_a / phi - vc)
|
||||
av_req = vs_req / (fyt * d) * 12.0 if vs_req > 0 else 0.0
|
||||
if vu_a > 0.5 * phi * vc:
|
||||
av_req = max(av_req, av_min)
|
||||
s_max = min(d / 2.0, 24.0)
|
||||
if vs_prov > 4.0 * lam * sq / 1000.0 * bwd:
|
||||
s_max = min(d / 4.0, 12.0)
|
||||
f_act = vu_a * 1000.0 / bwd if bwd else 0.0
|
||||
f_alw = phi * (vc + vs_cap) * 1000.0 / bwd if bwd else 0.0
|
||||
util = vu_a / phi_vn if phi_vn > 0 else (0.0 if vu_a == 0 else float('inf'))
|
||||
return ShearResult(vu_a, phi_vn, vc, vs_prov, bwd, d, av_min, av_prov,
|
||||
av_req, s, s_max, f_act, f_alw, util, note, vs_over_max)
|
||||
@@ -0,0 +1,573 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Run every check on a beam and return one `Analysis`.
|
||||
|
||||
Order, and what feeds what:
|
||||
|
||||
1. stations -- L/20 grid + supports, support faces, critical sections,
|
||||
lift points, load and bar ends, the maximum-moment location.
|
||||
2. envelope -- every included strength combo × every live pattern × every
|
||||
wind variant, solved with `statics.solve`; per station the max sagging
|
||||
Mu, max hogging Mu and max |Vu| with the combo that made each.
|
||||
3. flexure per station (`flexure.capacity_at`) for both signs.
|
||||
4. shear per station (`shear.check`) with d from the flexure solve.
|
||||
5. service -- every service combo, stresses on the transformed section.
|
||||
6. deflection -- elastic per case, then the reference tool's multipliers.
|
||||
7. stripping -- a two-point pick on the initial strength.
|
||||
8. reactions, bearing.
|
||||
|
||||
Every number in the result carries the station it belongs to so the report
|
||||
and the parity tests can address it by `(station_ft, column)`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from . import deflection as defl_mod, flexure, loads as ld, shear, section as sec_mod, statics, develop
|
||||
from .model import Beam, Combo
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationStrength:
|
||||
x_in: float
|
||||
mu_pos: float = 0.0 # kip-ft, max sagging
|
||||
mu_neg: float = 0.0 # kip-ft, max hogging (negative number)
|
||||
vu: float = 0.0 # kips, max |V|
|
||||
vu_signed: float = 0.0
|
||||
combo_pos: str = ''
|
||||
combo_neg: str = ''
|
||||
combo_v: str = ''
|
||||
mu_at_vmax: float = 0.0 # moment coincident with the governing shear
|
||||
vd_at_vmax: float = 0.0 # unfactored dead shear (prestressed Vci)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationFlexure:
|
||||
x_in: float
|
||||
x_ft: float
|
||||
mu: float
|
||||
phi_mn: float
|
||||
mcr12: float
|
||||
util: float
|
||||
phi: float
|
||||
c: float
|
||||
eps_t: float
|
||||
as_t: float
|
||||
as_c: float
|
||||
aps: float
|
||||
d: float
|
||||
controls: str
|
||||
positive: bool
|
||||
combo: str
|
||||
strain_ok: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationShear:
|
||||
x_in: float
|
||||
x_ft: float
|
||||
r: shear.ShearResult
|
||||
combo: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationService:
|
||||
x_in: float
|
||||
x_ft: float
|
||||
f_top: float # psi, + compression
|
||||
f_bot: float
|
||||
f_top_sus: float
|
||||
f_bot_sus: float
|
||||
combo: str
|
||||
m: float # kip-ft governing
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stage:
|
||||
name: str
|
||||
ok: bool
|
||||
util: float
|
||||
lines: List[str] = field(default_factory=list)
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Analysis:
|
||||
beam: Beam
|
||||
stations_in: List[float]
|
||||
grid: List[float]
|
||||
strength: List[StationStrength]
|
||||
flex: List[StationFlexure]
|
||||
shr: List[StationShear]
|
||||
service: List[StationService]
|
||||
stages: List[Stage]
|
||||
reactions: dict
|
||||
deflection: dict
|
||||
stripping: Optional[dict]
|
||||
bearing: dict
|
||||
diagrams: dict # combo name -> statics.Result (for plots)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return all(s.ok for s in self.stages)
|
||||
|
||||
def flex_at(self, x_ft: float) -> Optional[StationFlexure]:
|
||||
return _nearest(self.flex, x_ft)
|
||||
|
||||
def shear_at(self, x_ft: float) -> Optional[StationShear]:
|
||||
return _nearest(self.shr, x_ft)
|
||||
|
||||
|
||||
def _nearest(rows, x_ft, tol=0.02):
|
||||
best = None
|
||||
for r in rows:
|
||||
if abs(r.x_ft - x_ft) <= tol and (best is None or
|
||||
abs(r.x_ft - x_ft) < abs(best.x_ft - x_ft)):
|
||||
best = r
|
||||
return best
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- stations
|
||||
|
||||
def stations(beam: Beam, d_at_face: Optional[Dict[str, float]] = None
|
||||
) -> List[float]:
|
||||
L = beam.length_in
|
||||
pts = set()
|
||||
n = 20
|
||||
for i in range(n + 1):
|
||||
pts.add(L * i / n)
|
||||
for s in beam.supports:
|
||||
pts.add(s.x_in)
|
||||
pts.add(s.face_start_in)
|
||||
pts.add(s.face_end_in)
|
||||
if d_at_face:
|
||||
pts.add(beam.supports[0].face_end_in + d_at_face.get('start', 0.0))
|
||||
pts.add(beam.supports[1].face_start_in - d_at_face.get('end', 0.0))
|
||||
for p in beam.point_loads:
|
||||
pts.add(p.x_in)
|
||||
for l in beam.line_loads:
|
||||
pts.add(l.x0_in)
|
||||
pts.add(l.x1_in)
|
||||
for row in beam.bars:
|
||||
pts.add(row.start_in)
|
||||
pts.add(L - row.end_in)
|
||||
if beam.stripping:
|
||||
pts.add(beam.stripping.lift_from_start_in)
|
||||
pts.add(L - beam.stripping.lift_from_end_in)
|
||||
return sorted(round(p, 4) for p in pts if 0.0 <= p <= L)
|
||||
|
||||
|
||||
def breakpoints(beam: Beam) -> List[float]:
|
||||
pts = [s.x_in for s in beam.supports]
|
||||
pts += [p.x_in for p in beam.point_loads]
|
||||
for l in beam.line_loads:
|
||||
pts += [l.x0_in, l.x1_in]
|
||||
pts += [m.x_in for m in beam.point_moments]
|
||||
return pts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- envelope
|
||||
|
||||
def _combo_loadsets(beam: Beam, combo: Combo):
|
||||
"""Every (label, loads) the combo expands to over patterns and wind."""
|
||||
dead = ld.dead_loads(beam)
|
||||
out = []
|
||||
pats = ld.patterns(beam) if combo.L or combo.R else [tuple(ld.regions(beam))]
|
||||
winds = ld.wind_variants(beam) if combo.W else [('', [])]
|
||||
for pat in pats:
|
||||
live = ld.live_loads_for(beam, pat, 'live') if combo.L else []
|
||||
roof = ld.live_loads_for(beam, pat, 'roof') if combo.R else []
|
||||
for wname, wl in winds:
|
||||
loads = (ld.scaled(dead, combo.D) + ld.scaled(live, combo.L)
|
||||
+ ld.scaled(roof, combo.R) + ld.scaled(wl, combo.W)
|
||||
+ ld.scaled(ld.case_loads(beam, 'seismic'), combo.E))
|
||||
tag = combo.name()
|
||||
if len(pats) > 1:
|
||||
tag += ' [pattern {0}]'.format(len(pat))
|
||||
if wname:
|
||||
tag += ' ' + wname
|
||||
out.append((tag, loads))
|
||||
return out
|
||||
|
||||
|
||||
def envelope(beam: Beam, combos: List[Combo], xs: List[float]
|
||||
) -> Tuple[List[StationStrength], Dict[str, statics.Result]]:
|
||||
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
|
||||
rows = [StationStrength(x) for x in xs]
|
||||
diagrams = {}
|
||||
dead_only = statics.solve(ld.dead_loads(beam), xa, xb, xs)
|
||||
for combo in combos:
|
||||
if not combo.include:
|
||||
continue
|
||||
for tag, loads in _combo_loadsets(beam, combo):
|
||||
res = statics.solve(loads, xa, xb, xs)
|
||||
diagrams[tag] = res
|
||||
for i, x in enumerate(xs):
|
||||
m = res.moment[i] / 12.0
|
||||
vl, vr = res.shear_jump(x)
|
||||
v = _station_shear(beam, x, vl, vr)
|
||||
r = rows[i]
|
||||
if m > r.mu_pos:
|
||||
r.mu_pos, r.combo_pos = m, tag
|
||||
if m < r.mu_neg:
|
||||
r.mu_neg, r.combo_neg = m, tag
|
||||
if abs(v) > r.vu:
|
||||
r.vu, r.vu_signed, r.combo_v = abs(v), v, tag
|
||||
r.mu_at_vmax = m
|
||||
dl, dr = dead_only.shear_jump(x)
|
||||
r.vd_at_vmax = dl if abs(vl) >= abs(vr) else dr
|
||||
return rows, diagrams
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- solve
|
||||
|
||||
def analyse(beam: Beam, extra_stations_in: Optional[List[float]] = None) -> Analysis:
|
||||
"""`extra_stations_in`: more x (in) to report at -- the comparison tool passes
|
||||
the reference package's printed stations so every row lines up."""
|
||||
notes: List[str] = []
|
||||
mats = beam.materials
|
||||
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
|
||||
L = beam.length_in
|
||||
|
||||
# --- effective depth at the support faces, for the critical sections
|
||||
d_face = {}
|
||||
for key, x in (('start', beam.supports[0].face_end_in),
|
||||
('end', beam.supports[1].face_start_in)):
|
||||
fr = flexure.capacity_at(beam, x, positive=True)
|
||||
d_face[key] = fr.d if fr.d > 0 else 0.8 * beam.section.height_in
|
||||
xs = stations(beam, d_face)
|
||||
if extra_stations_in:
|
||||
xs = sorted(set(xs) | {round(x, 4) for x in extra_stations_in if 0.0 <= x <= L})
|
||||
grid = statics.grid(L, breakpoints(beam) + xs, step=0.5)
|
||||
|
||||
# --- strength envelope on the grid (so plots are smooth), sampled at xs
|
||||
strength_grid, diagrams = envelope(beam, beam.strength_combos, grid)
|
||||
by_x = {round(r.x_in, 4): r for r in strength_grid}
|
||||
strength = [by_x[round(x, 4)] for x in xs]
|
||||
|
||||
# --- flexure per station
|
||||
flex_rows: List[StationFlexure] = []
|
||||
fse = _effective_prestress_ksi(beam) if beam.strands else 0.0
|
||||
for r in strength:
|
||||
x = r.x_in
|
||||
best = None
|
||||
for positive, mu, combo in ((True, r.mu_pos, r.combo_pos),
|
||||
(False, r.mu_neg, r.combo_neg)):
|
||||
cap = flexure.capacity_at(beam, x, positive=positive, fse_ksi=fse)
|
||||
mcr = flexure.cracking_moment(beam, x, positive=positive)
|
||||
if cap.phi_mn > 1e-9:
|
||||
util = abs(mu) / cap.phi_mn
|
||||
elif abs(mu) <= max(0.1, 0.01 * abs(1.2 * mcr)):
|
||||
# no developed steel and next to no moment: the reference
|
||||
# tool prints NC here (the beam ends, the bearing zone)
|
||||
util = 0.0
|
||||
else:
|
||||
util = float('inf')
|
||||
row = StationFlexure(x, x / 12.0, mu, cap.phi_mn if positive else -cap.phi_mn,
|
||||
1.2 * mcr if positive else -1.2 * mcr, util,
|
||||
cap.phi, cap.c, cap.eps_t, cap.as_tension,
|
||||
cap.as_comp, cap.aps, cap.d, cap.controls,
|
||||
positive, combo, cap.strain_ok)
|
||||
if best is None or util > best.util or (
|
||||
util == best.util and positive):
|
||||
best = row
|
||||
flex_rows.append(best)
|
||||
|
||||
# --- shear per station
|
||||
shr_rows: List[StationShear] = []
|
||||
for r, f in zip(strength, flex_rows):
|
||||
x = r.x_in
|
||||
d = _shear_depth(beam, x, f)
|
||||
# The reference tool prints the actual Vu at every station and
|
||||
# lets the critical section (face + d) be one more station; it
|
||||
# does not clip the end-region demand to it. Same here.
|
||||
vu = r.vu
|
||||
res = shear.check(beam, x, vu, d, mats.fc_ksi)
|
||||
shr_rows.append(StationShear(x, x / 12.0, res, r.combo_v))
|
||||
|
||||
# --- service stresses
|
||||
service_rows = _service(beam, xs, grid)
|
||||
|
||||
# --- deflections
|
||||
defl = defl_mod.analyse(beam, grid)
|
||||
|
||||
# --- stripping
|
||||
strip = _stripping(beam) if beam.stripping else None
|
||||
|
||||
# --- reactions per case (unfactored)
|
||||
reactions = _reactions(beam)
|
||||
|
||||
# --- bearing
|
||||
bearing = _bearing(beam, diagrams)
|
||||
|
||||
# --- stage summaries
|
||||
stages = []
|
||||
if strip:
|
||||
stages.append(Stage('Stripping', strip['ok'], strip['util'],
|
||||
strip['lines'], strip))
|
||||
worst_f = max(flex_rows, key=lambda f: f.util) if flex_rows else None
|
||||
worst_s = max(shr_rows, key=lambda s: s.r.util) if shr_rows else None
|
||||
flex_ok = all(f.util <= 1.0 + 1e-9 and f.strain_ok for f in flex_rows)
|
||||
mcr_ok = all(abs(f.phi_mn) >= abs(f.mcr12) - 1e-9 or abs(f.mu) < 1e-9
|
||||
for f in flex_rows)
|
||||
stages.append(Stage('In-Place Strength',
|
||||
flex_ok and (worst_s is None or worst_s.r.util <= 1.0 + 1e-9),
|
||||
max(worst_f.util if worst_f else 0.0,
|
||||
worst_s.r.util if worst_s else 0.0),
|
||||
[], {'flexure': worst_f, 'shear': worst_s, 'mcr_ok': mcr_ok}))
|
||||
svc_ok = defl['total_ok'] and defl['live_ok'] and _service_ok(beam, service_rows)
|
||||
stages.append(Stage('In-Place Service', svc_ok,
|
||||
max(defl['total_util'], defl['live_util']), [], {}))
|
||||
|
||||
return Analysis(beam, xs, grid, strength, flex_rows, shr_rows, service_rows,
|
||||
stages, reactions, defl, strip, bearing, diagrams, notes)
|
||||
|
||||
|
||||
def _station_shear(beam: Beam, x: float, vl: float, vr: float) -> float:
|
||||
"""Which side of a shear jump a station reports. The reference tool
|
||||
prints the OVERHANG side at a bearing centreline (RBA5 p.12: -0.9 k at
|
||||
the 2" support, not the 43.9 k just inside it) -- the span-side value
|
||||
belongs to the next station in. Everywhere else the larger magnitude."""
|
||||
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
|
||||
if abs(x - xa) < 1e-6:
|
||||
return vl
|
||||
if abs(x - xb) < 1e-6:
|
||||
return vr
|
||||
return vl if abs(vl) >= abs(vr) else vr
|
||||
|
||||
|
||||
def _vu_at(rows: List[StationStrength], x: float) -> float:
|
||||
best = min(rows, key=lambda r: abs(r.x_in - x))
|
||||
return best.vu
|
||||
|
||||
|
||||
def _shear_depth(beam: Beam, x: float, f: StationFlexure) -> float:
|
||||
"""d for shear: the reference tool's 'CG of Tension Rein.' -- the
|
||||
flexure solve's d (from the compression face) but not less than
|
||||
0.8h for a prestressed member (22.5.2.1). Where no bar has any
|
||||
development yet (the bearing centreline, As = 0) the reference tool
|
||||
still prints bw·d with d at the plain area centroid of every bar
|
||||
present, measured from the compression face of the governing moment's
|
||||
sign at that station -- RBA5 p.12: bwd = 204.8 = 16 × 12.80 at x = 0
|
||||
(from the top), 147.2 = 16 × 9.20 at x = 2.04 in just inside the
|
||||
bearing where the hogging overhang moment governs (from the bottom),
|
||||
never 16 × 0.8h. The bars are taken where they exist (at x, else at
|
||||
midspan: at x = 0 the bars have not started yet and the tool still
|
||||
prints 12.80); 0.8h only when the beam has no bars at all."""
|
||||
h = beam.section.height_in
|
||||
d = f.d
|
||||
if d <= 0:
|
||||
bars = beam.bars_at(x) or beam.bars_at(beam.length_in / 2.0)
|
||||
tot = sum(b[2] for b in bars)
|
||||
if tot > 0:
|
||||
depth = (lambda by: (h - by) if f.positive else by)
|
||||
d = sum(b[2] * depth(b[1]) for b in bars) / tot
|
||||
else:
|
||||
d = 0.8 * h
|
||||
if beam.strands:
|
||||
d = max(d, 0.8 * h)
|
||||
return d
|
||||
|
||||
|
||||
def _effective_prestress_ksi(beam: Beam) -> float:
|
||||
"""Effective strand stress after losses. Until the PCI loss routine
|
||||
lands, a flat 18 % of the jacking stress (PCI 8th Ed. typical for
|
||||
pretensioned members), applied to the highest pull."""
|
||||
if not beam.strands:
|
||||
return 0.0
|
||||
fpu = beam.materials.strand.fpu_ksi
|
||||
pull = max(g.pull_pct for g in beam.strands) / 100.0
|
||||
return fpu * pull * 0.82
|
||||
|
||||
|
||||
def _service(beam: Beam, xs: List[float], grid: List[float]
|
||||
) -> List[StationService]:
|
||||
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
|
||||
rows = []
|
||||
dead = ld.dead_loads(beam)
|
||||
sus = ld.case_loads(beam, 'sus_live')
|
||||
results = []
|
||||
for combo in beam.service_combos:
|
||||
if not combo.include:
|
||||
continue
|
||||
for tag, loads in _combo_loadsets(beam, combo):
|
||||
results.append((tag, statics.solve(loads, xa, xb, xs)))
|
||||
sus_res = statics.solve(dead + sus, xa, xb, xs)
|
||||
for i, x in enumerate(xs):
|
||||
p = sec_mod.props_at(beam, x)
|
||||
best = None
|
||||
for tag, res in results:
|
||||
m = res.moment[i] # kip-in
|
||||
f_top = m / p.st * 1000.0 # psi, + compression at top for M>0
|
||||
f_bot = -m / p.sb * 1000.0
|
||||
key = max(-f_top, -f_bot, f_top, f_bot)
|
||||
if best is None or key > best[0]:
|
||||
best = (key, tag, m, f_top, f_bot)
|
||||
m_s = sus_res.moment[i]
|
||||
f_top_s = m_s / p.st * 1000.0
|
||||
f_bot_s = -m_s / p.sb * 1000.0
|
||||
if best is None:
|
||||
best = (0.0, '', 0.0, 0.0, 0.0)
|
||||
rows.append(StationService(x, x / 12.0, best[3], best[4], f_top_s,
|
||||
f_bot_s, best[1], best[2] / 12.0))
|
||||
return rows
|
||||
|
||||
|
||||
def _service_ok(beam: Beam, rows: List[StationService]) -> bool:
|
||||
fc = beam.materials.fc_ksi * 1000.0
|
||||
lim_tot = beam.settings.service_comp_total * fc
|
||||
lim_sus = beam.settings.service_comp_sustained * fc
|
||||
for r in rows:
|
||||
if max(r.f_top, r.f_bot) > lim_tot + 1e-6:
|
||||
return False
|
||||
if max(r.f_top_sus, r.f_bot_sus) > lim_sus + 1e-6:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _deflections(beam: Beam, grid: List[float]) -> dict:
|
||||
"""Elastic deflection per case on the gross (transformed) section,
|
||||
combined with the reference tool's multipliers:
|
||||
|
||||
total = SW·m_sw + (deadnc + topping + deadc)·m_dead + live + roof
|
||||
live = live (+ roof)
|
||||
|
||||
Self weight uses Eci when the setting says so; everything else Ec.
|
||||
Limits are span / ratio with span = distance between bearings."""
|
||||
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
|
||||
s = beam.settings
|
||||
mats = beam.materials
|
||||
span = xb - xa
|
||||
p = sec_mod.props_at(beam, (xa + xb) / 2.0)
|
||||
ei_c = [mats.Ec * p.ixx] * len(grid)
|
||||
e_sw = mats.Eci if s.ec_for_sw_defl.lower() == 'eci' else mats.Ec
|
||||
ei_i = [e_sw * p.ixx] * len(grid)
|
||||
|
||||
def defl(loads, ei):
|
||||
if not loads:
|
||||
return [0.0] * len(grid)
|
||||
res = statics.solve(loads, xa, xb, grid)
|
||||
return statics.deflect(res, ei, xa, xb)
|
||||
|
||||
d_sw = defl(ld.self_weight(beam), ei_i)
|
||||
d_dead = defl(sum((ld.case_loads(beam, c) for c in ld.DEAD_CASES), []), ei_c)
|
||||
d_live = defl(ld.case_loads(beam, 'live'), ei_c)
|
||||
d_roof = defl(ld.case_loads(beam, 'roof'), ei_c)
|
||||
m_sw = s.sw_mult.get('end', 2.0)
|
||||
m_dd = s.addl_dead_mult.get('end', 2.0)
|
||||
total = [d_sw[i] * m_sw + d_dead[i] * m_dd + d_live[i] + d_roof[i]
|
||||
for i in range(len(grid))]
|
||||
live = [d_live[i] + d_roof[i] for i in range(len(grid))]
|
||||
# report within the span (overhang tips separately)
|
||||
idx = [i for i, x in enumerate(grid) if xa <= x <= xb]
|
||||
t_down = max(total[i] for i in idx)
|
||||
t_up = min(total[i] for i in idx)
|
||||
l_down = max(live[i] for i in idx)
|
||||
l_up = min(live[i] for i in idx)
|
||||
lim_t = span / s.total_defl_ratio
|
||||
lim_l = span / s.live_defl_ratio
|
||||
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,
|
||||
'elastic': {'self_weight': d_sw, 'dead': d_dead, 'live': d_live,
|
||||
'roof': d_roof},
|
||||
'total': total, 'live': live,
|
||||
'multipliers': {'self_weight': m_sw, 'addl_dead': m_dd},
|
||||
'e_sw': e_sw, 'e': mats.Ec, 'i': p.ixx,
|
||||
}
|
||||
|
||||
|
||||
def _stripping(beam: Beam) -> dict:
|
||||
st = beam.stripping
|
||||
mats = beam.materials
|
||||
L = beam.length_in
|
||||
xa = st.lift_from_start_in
|
||||
xb = L - st.lift_from_end_in
|
||||
grid = statics.grid(L, [xa, xb], step=0.5)
|
||||
loads = ld.scaled(ld.self_weight(beam), st.multiplier)
|
||||
res = statics.solve(loads, xa, xb, grid)
|
||||
fc = mats.fci_ksi if st.strength == 'initial' else mats.fc_ksi
|
||||
fc_psi = fc * 1000.0
|
||||
s = beam.settings
|
||||
lim_c = s.transfer_comp_other * fc_psi
|
||||
lim_t = s.transfer_tens_other_sqrt * math.sqrt(fc_psi)
|
||||
worst_t = (0.0, 0.0)
|
||||
worst_c = (0.0, 0.0)
|
||||
for i, x in enumerate(grid):
|
||||
p = sec_mod.props_at(beam, x)
|
||||
m = res.moment[i]
|
||||
f_top = m / p.st * 1000.0
|
||||
f_bot = -m / p.sb * 1000.0
|
||||
for f in (f_top, f_bot):
|
||||
if f > worst_c[0]:
|
||||
worst_c = (f, x)
|
||||
if -f > worst_t[0]:
|
||||
worst_t = (-f, x)
|
||||
# flexure at the max-moment station with the initial strength
|
||||
xm, mm = res.max_abs_moment()
|
||||
cap = flexure.capacity_at(beam, xm, positive=(mm >= 0), fc_ksi=fc)
|
||||
mu = abs(mm) / 12.0
|
||||
util_f = mu / cap.phi_mn if cap.phi_mn > 1e-9 else float('inf')
|
||||
w = beam.weight_k * st.multiplier
|
||||
per_point = w / st.pick_points
|
||||
sling = math.radians(60.0)
|
||||
tension = per_point
|
||||
shear_k = per_point / math.tan(sling)
|
||||
util = max(worst_t[0] / lim_t if lim_t else 0.0,
|
||||
worst_c[0] / lim_c if lim_c else 0.0, util_f)
|
||||
lines = [
|
||||
'Lifted at {0:.1f}" and {1:.1f}" from the start, self weight x {2:g}'
|
||||
.format(xa, xb, st.multiplier),
|
||||
'tension {0:.0f} / {1:.0f} psi, compression {2:.0f} / {3:.0f} psi'
|
||||
.format(worst_t[0], lim_t, worst_c[0], lim_c),
|
||||
'flexure Mu {0:.1f} vs phiMn {1:.1f} k-ft'.format(mu, cap.phi_mn),
|
||||
]
|
||||
return {
|
||||
'ok': util <= 1.0 + 1e-9, 'util': util, 'lines': lines,
|
||||
'f_tension': worst_t[0], 'f_tension_x': worst_t[1], 'lim_tension': lim_t,
|
||||
'f_comp': worst_c[0], 'f_comp_x': worst_c[1], 'lim_comp': lim_c,
|
||||
'mu': mu, 'phi_mn': cap.phi_mn, 'util_flex': util_f, 'x_max': xm,
|
||||
'weight_k': beam.weight_k, 'factored_weight_k': w,
|
||||
'per_point_k': per_point, 'sling_tension_k': tension,
|
||||
'sling_shear_k': shear_k, 'result': res, 'fc_ksi': fc,
|
||||
}
|
||||
|
||||
|
||||
def _reactions(beam: Beam) -> dict:
|
||||
xa, xb = beam.supports[0].x_in, beam.supports[1].x_in
|
||||
out = {}
|
||||
for case in (ld.SELF,) + ld.CASES:
|
||||
loads = ld.case_loads(beam, case)
|
||||
if not loads:
|
||||
out[case] = (0.0, 0.0)
|
||||
continue
|
||||
ra, rb = statics.reactions(loads, xa, xb)
|
||||
out[case] = (ra, rb)
|
||||
return out
|
||||
|
||||
|
||||
def _bearing(beam: Beam, diagrams: dict) -> dict:
|
||||
"""Factored reactions (max over the strength combos) and the PCI
|
||||
plain-bearing capacity. Vu is the REACTION, read off each combo's own
|
||||
diagram (`Result.r_start` / `r_end`), never the station shear at the
|
||||
bearing centreline: that station reports the overhang side of the jump
|
||||
(`_station_shear`), which is ~0 -- RBA5 prints 45.1 k here against a
|
||||
-0.9 k shear row at the same x. The Cr reduction is not yet reproduced;
|
||||
`phi_vn_plain` is the unreduced φ·0.85·f'c·A1 and is flagged as such."""
|
||||
vu_l = max((abs(res.r_start) for res in diagrams.values()), default=0.0)
|
||||
vu_r = max((abs(res.r_end) for res in diagrams.values()), default=0.0)
|
||||
out = {'left': {}, 'right': {}}
|
||||
for key, sup, vu in (('left', beam.supports[0], vu_l),
|
||||
('right', beam.supports[1], vu_r)):
|
||||
a1 = sup.length_in * sup.width_in
|
||||
phi_vn = beam.settings.phi_compression * 0.85 * beam.materials.fc_ksi * a1
|
||||
out[key] = {'vu': vu, 'a1': a1, 'phi_vn_plain': phi_vn,
|
||||
'note': 'unreduced (Cr = 1); PCI 5.2.1 reduction pending'}
|
||||
return out
|
||||
@@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Statics of a beam on two supports with overhangs.
|
||||
|
||||
Determinate, so reactions come from equilibrium and V(x), M(x) from a single
|
||||
pass along a fine grid. Deflection is a double integration of M/EI with
|
||||
w = 0 at both supports, so it is right for any load shape and for a
|
||||
varying EI (the cracked-section case in `deflection.py`).
|
||||
|
||||
Sign convention: loads are positive DOWN, shear V(x) is the sum of the
|
||||
upward forces to the left of x, moment M(x) is positive SAGGING (bottom in
|
||||
tension). Deflection is positive DOWN. All lengths inches, forces kips,
|
||||
moments kip-in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class Load:
|
||||
"""One load on the beam in one case: a point force P at x, a linearly
|
||||
varying line load (w0 at x0, w1 at x1, kips/in), or a point moment M
|
||||
at x (kip-in, positive clockwise = sagging to the right)."""
|
||||
kind: str # 'point' | 'line' | 'moment'
|
||||
x0: float
|
||||
x1: float = 0.0
|
||||
p: float = 0.0 # kips (point)
|
||||
w0: float = 0.0 # k/in (line)
|
||||
w1: float = 0.0
|
||||
m: float = 0.0 # kip-in (moment)
|
||||
tag: str = ''
|
||||
|
||||
def total(self) -> float:
|
||||
if self.kind == 'point':
|
||||
return self.p
|
||||
if self.kind == 'line':
|
||||
return (self.w0 + self.w1) / 2.0 * (self.x1 - self.x0)
|
||||
return 0.0
|
||||
|
||||
def centroid(self) -> float:
|
||||
if self.kind == 'point':
|
||||
return self.x0
|
||||
if self.kind == 'line':
|
||||
L = self.x1 - self.x0
|
||||
if L <= 0:
|
||||
return self.x0
|
||||
tot = self.total()
|
||||
if abs(tot) < 1e-12:
|
||||
return (self.x0 + self.x1) / 2.0
|
||||
# first moment of a trapezoid about x0
|
||||
m1 = self.w0 * L * L / 2.0 + (self.w1 - self.w0) * L * L / 3.0
|
||||
return self.x0 + m1 / tot
|
||||
return self.x0
|
||||
|
||||
def moment_about(self, x: float) -> float:
|
||||
"""Moment of this load about x (kip-in, clockwise positive for a
|
||||
downward load to the right of x)."""
|
||||
if self.kind == 'moment':
|
||||
return self.m
|
||||
return self.total() * (self.centroid() - x)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
xs: List[float]
|
||||
shear: List[float]
|
||||
moment: List[float]
|
||||
r_start: float
|
||||
r_end: float
|
||||
deflection: Optional[List[float]] = None
|
||||
loads: Optional[List] = None
|
||||
xa: float = 0.0
|
||||
xb: float = 0.0
|
||||
|
||||
def at(self, x: float, series: Sequence[float]) -> float:
|
||||
return interp(self.xs, series, x)
|
||||
|
||||
def moment_at(self, x: float) -> float:
|
||||
return self.at(x, self.moment)
|
||||
|
||||
def shear_at(self, x: float) -> float:
|
||||
return self.at(x, self.shear)
|
||||
|
||||
def shear_jump(self, x: float) -> Tuple[float, float]:
|
||||
"""(V just left, V just right) -- a point load or support makes V
|
||||
discontinuous and the printed 'V at x' is whichever side governs."""
|
||||
if self.loads is not None:
|
||||
return (_vm_at(self.loads, self.xa, self.xb, self.r_start,
|
||||
self.r_end, x, inclusive=False)[0],
|
||||
_vm_at(self.loads, self.xa, self.xb, self.r_start,
|
||||
self.r_end, x, inclusive=True)[0])
|
||||
# Fall back to the neighbouring grid nodes for a Result that was not
|
||||
# built by `solve` and carries no loads to recompute from.
|
||||
i = bisect.bisect_left(self.xs, x - 1e-9)
|
||||
j = bisect.bisect_right(self.xs, x + 1e-9)
|
||||
left = self.shear[max(i - 1, 0)]
|
||||
right = self.shear[min(j, len(self.xs) - 1)]
|
||||
return left, right
|
||||
|
||||
def deflection_at(self, x: float) -> float:
|
||||
if self.deflection is None:
|
||||
return 0.0
|
||||
return self.at(x, self.deflection)
|
||||
|
||||
def max_abs_moment(self) -> Tuple[float, float]:
|
||||
i = max(range(len(self.xs)), key=lambda k: abs(self.moment[k]))
|
||||
return self.xs[i], self.moment[i]
|
||||
|
||||
|
||||
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]
|
||||
i = bisect.bisect_right(xs, x)
|
||||
x0, x1 = xs[i - 1], xs[i]
|
||||
if x1 == x0:
|
||||
return ys[i]
|
||||
t = (x - x0) / (x1 - x0)
|
||||
return ys[i - 1] + t * (ys[i] - ys[i - 1])
|
||||
|
||||
|
||||
def grid(length: float, breakpoints: Sequence[float], step: float = 0.5
|
||||
) -> List[float]:
|
||||
"""A fine grid from 0 to `length` that lands exactly on every
|
||||
breakpoint (supports, load ends, bar ends), so V and M kinks are
|
||||
represented and nothing is interpolated across a jump."""
|
||||
pts = set([0.0, length])
|
||||
for b in breakpoints:
|
||||
if 0.0 <= b <= length:
|
||||
pts.add(round(b, 6))
|
||||
n = int(math.ceil(length / step))
|
||||
for i in range(n + 1):
|
||||
pts.add(round(min(length, i * step), 6))
|
||||
return sorted(pts)
|
||||
|
||||
|
||||
def reactions(loads: Sequence[Load], xa: float, xb: float) -> Tuple[float, float]:
|
||||
"""Upward reactions at supports xa < xb for downward loads."""
|
||||
tot = sum(l.total() for l in loads)
|
||||
m_about_a = sum(l.moment_about(xa) for l in loads)
|
||||
rb = m_about_a / (xb - xa)
|
||||
ra = tot - rb
|
||||
return ra, rb
|
||||
|
||||
|
||||
def _line_w_at(l: Load, x: float) -> float:
|
||||
if x < l.x0 or x > l.x1 or l.x1 <= l.x0:
|
||||
return 0.0
|
||||
t = (x - l.x0) / (l.x1 - l.x0)
|
||||
return l.w0 + t * (l.w1 - l.w0)
|
||||
|
||||
|
||||
def _vm_at(loads: Sequence[Load], xa: float, xb: float, ra: float, rb: float,
|
||||
x: float, inclusive: bool) -> Tuple[float, float]:
|
||||
"""Closed-form (V, M) at x for the loads and reactions ra, rb.
|
||||
|
||||
`inclusive=True` includes a reaction, point load or applied moment
|
||||
located exactly at x (V taken just to the RIGHT of x); `inclusive=False`
|
||||
excludes it, giving the LEFT limit. Line loads are continuous and are
|
||||
treated the same either way."""
|
||||
v = 0.0
|
||||
m = 0.0
|
||||
# reactions (upward)
|
||||
if (xa <= x + 1e-9) if inclusive else (xa < x - 1e-9):
|
||||
v += ra
|
||||
m += ra * (x - xa)
|
||||
if (xb <= x + 1e-9) if inclusive else (xb < x - 1e-9):
|
||||
v += rb
|
||||
m += rb * (x - xb)
|
||||
for l in loads:
|
||||
if l.kind == 'point':
|
||||
if (l.x0 <= x + 1e-9) if inclusive else (l.x0 < x - 1e-9):
|
||||
v -= l.p
|
||||
m -= l.p * (x - l.x0)
|
||||
elif l.kind == 'line':
|
||||
if x > l.x0:
|
||||
xe = min(x, l.x1)
|
||||
L = xe - l.x0
|
||||
if L > 0:
|
||||
we = _line_w_at(l, xe)
|
||||
tot = (l.w0 + we) / 2.0 * L
|
||||
# centroid of the trapezoid from x0 to xe
|
||||
if abs(tot) > 1e-12:
|
||||
m1 = l.w0 * L * L / 2.0 + (we - l.w0) * L * L / 3.0
|
||||
c = l.x0 + m1 / tot
|
||||
else:
|
||||
c = (l.x0 + xe) / 2.0
|
||||
v -= tot
|
||||
m -= tot * (x - c)
|
||||
elif l.kind == 'moment':
|
||||
if (l.x0 <= x + 1e-9) if inclusive else (l.x0 < x - 1e-9):
|
||||
m -= l.m
|
||||
return v, m
|
||||
|
||||
|
||||
def solve(loads: Sequence[Load], xa: float, xb: float, xs: Sequence[float]
|
||||
) -> Result:
|
||||
"""V(x) and M(x) on the grid `xs` for the given loads and supports.
|
||||
|
||||
Both are evaluated exactly (closed form per load) at every grid point
|
||||
-- the grid is for reporting, not for accuracy -- with V taken just to
|
||||
the RIGHT of x (so at a support or point load the grid point carries
|
||||
the post-jump value; `shear_jump` gives both sides)."""
|
||||
ra, rb = reactions(loads, xa, xb)
|
||||
shear = []
|
||||
moment = []
|
||||
for x in xs:
|
||||
v, m = _vm_at(loads, xa, xb, ra, rb, x, inclusive=True)
|
||||
shear.append(v)
|
||||
moment.append(m)
|
||||
return Result(list(xs), shear, moment, ra, rb, loads=list(loads),
|
||||
xa=xa, xb=xb)
|
||||
|
||||
|
||||
def deflect(result: Result, ei: Sequence[float], xa: float, xb: float
|
||||
) -> List[float]:
|
||||
"""Deflection (positive DOWN, inches) by double integration of M/EI
|
||||
with w(xa) = w(xb) = 0. `ei` is EI (kip-in²) at every grid point --
|
||||
constant, or varying for a cracked section."""
|
||||
xs = result.xs
|
||||
n = len(xs)
|
||||
curv = [result.moment[i] / ei[i] if ei[i] else 0.0 for i in range(n)]
|
||||
# theta = integral of curvature (trapezoid); w = integral of theta
|
||||
theta = [0.0] * n
|
||||
for i in range(1, n):
|
||||
h = xs[i] - xs[i - 1]
|
||||
theta[i] = theta[i - 1] + h * (curv[i] + curv[i - 1]) / 2.0
|
||||
w = [0.0] * n
|
||||
for i in range(1, n):
|
||||
h = xs[i] - xs[i - 1]
|
||||
w[i] = w[i - 1] + h * (theta[i] + theta[i - 1]) / 2.0
|
||||
# w_true = w + c1*x + c2 ; impose zero at supports
|
||||
wa = interp(xs, w, xa)
|
||||
wb = interp(xs, w, xb)
|
||||
c1 = -(wb - wa) / (xb - xa)
|
||||
c2 = -wa - c1 * xa
|
||||
# sagging curvature (M>0) -> downward deflection; the integration above
|
||||
# gives upward-positive for positive M, so flip the sign.
|
||||
return [-(w[i] + c1 * xs[i] + c2) for i in range(n)]
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Put beamcalc/src first on sys.path. Run from the repo root, the bare
|
||||
`beamcalc/` directory (which holds fixtures/, tools/, tests/ and src/) is
|
||||
what `import beamcalc` finds -- an implicit namespace package with no
|
||||
modules in it -- and every test here fails to import. `pip install -e
|
||||
beamcalc` would fix it too, but the suite must run on a fresh checkout
|
||||
with nothing installed, same as the rest of this repo."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
_mod = sys.modules.get('beamcalc')
|
||||
if _mod is not None and getattr(_mod, '__file__', None) is None:
|
||||
del sys.modules['beamcalc'] # the namespace-package placeholder
|
||||
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shear-zone accounting: `av_provided_per_ft` sums each covering zone once
|
||||
(a symmetric zone overlapping its own mirror at midspan is not doubled, but
|
||||
two identical zone rows -- the doubled-stirrup detail -- each count), the raw
|
||||
Vs reported per station vs the 8·√f'c·bw·d cap of 22.5.1.2 (`vs_over_max` and
|
||||
its note), the displaced-concrete flexure station, and a full RBA1 run."""
|
||||
import inspect
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from beamcalc import model, solve, shear
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
RBA5 = os.path.join(HERE, '..', 'fixtures', 'rba5_16x22.json')
|
||||
RBA1 = os.path.join(HERE, '..', 'fixtures', 'rba1_8x48.json')
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def rba5():
|
||||
return model.load_job(RBA5)
|
||||
|
||||
|
||||
def test_av_provided_per_ft_counts_each_zone_once(rba5):
|
||||
# (beam, x_in) -> (total in^2/ft, tightest spacing or None)
|
||||
assert list(inspect.signature(shear.av_provided_per_ft).parameters) \
|
||||
== ['beam', 'x_in']
|
||||
# 107.5" is the beam midpoint: the symmetric outer zone's mirrored half
|
||||
# overlaps its own direct span there, and must count only once.
|
||||
mid, s_mid = shear.av_provided_per_ft(rba5, 107.5)
|
||||
off_mid, s_off = shear.av_provided_per_ft(rba5, 100.0)
|
||||
assert mid == pytest.approx(off_mid, abs=1e-9)
|
||||
assert s_mid == s_off
|
||||
|
||||
|
||||
def test_av_provided_per_ft_doubles_the_tight_zone(rba5):
|
||||
# RBA5's transverse list carries the tight end zone (first 2", s = 4")
|
||||
# twice: the doubled-stirrup detail, and each identical row counts once.
|
||||
tight = [z for z in rba5.transverse if z.spacing_in == 4.0]
|
||||
assert len(tight) == 2
|
||||
expected = 2 * (tight[0].area * 12.0 / tight[0].spacing_in)
|
||||
av_prov, s_min = shear.av_provided_per_ft(rba5, 6.0)
|
||||
assert av_prov == pytest.approx(expected, abs=1e-9)
|
||||
assert s_min == 4.0
|
||||
|
||||
|
||||
def test_raw_vs_and_22_5_1_2_flag(rba5):
|
||||
a = solve.analyse(rba5, extra_stations_in=[0.33 * 12, 0.90 * 12,
|
||||
8.96 * 12])
|
||||
cases = [(0.33 * 12, 153.6, True), (0.90 * 12, 233.9, True),
|
||||
(8.96 * 12, 96.0, False)]
|
||||
for x, vs_exp, over in cases:
|
||||
row = min(a.shr, key=lambda s: abs(s.x_in - x))
|
||||
assert abs(row.x_in - x) < 1e-6, 'no station at x = {}'.format(x)
|
||||
msg = ('at x={0:.2f}: vs_prov={1:.4f} (expect ~{2}), '
|
||||
'vs_over_max={3}, note={4!r}').format(
|
||||
row.x_in, row.r.vs_prov, vs_exp, row.r.vs_over_max,
|
||||
row.r.note)
|
||||
assert row.r.vs_prov == pytest.approx(vs_exp, rel=0.01), msg
|
||||
assert row.r.vs_over_max is over, msg
|
||||
assert ('22.5.1.2' in row.r.note) is over, msg
|
||||
|
||||
|
||||
def test_displaced_concrete_flexure_row(rba5):
|
||||
a = solve.analyse(rba5, extra_stations_in=[1.43 * 12])
|
||||
row = min(a.flex, key=lambda f: abs(f.x_ft - 1.43))
|
||||
msg = ('at x_ft={0}: c={1:.4f} (expect ~2.67), '
|
||||
'phi_mn={2:.4f} (expect ~224.0)').format(
|
||||
row.x_ft, row.c, row.phi_mn)
|
||||
# phi_mn reproduces the package's 224.0. c does NOT yet: the package
|
||||
# prints 2.67 here and the engine gives 2.49 (2.42 before the
|
||||
# displaced-concrete trigger moved from a to c). This pins the current
|
||||
# value as a regression guard so a change to the section solve is
|
||||
# noticed -- it is not a claim of agreement. When c reaches 2.67 within
|
||||
# 3%, replace the pin with that. Open item, PROJECT.md §4 / RBA5 notes.
|
||||
assert row.c == pytest.approx(2.4928, rel=0.02), msg
|
||||
assert abs(row.phi_mn) == pytest.approx(224.0, rel=0.01), msg
|
||||
|
||||
|
||||
def test_rba1_analysis_runs():
|
||||
a = solve.analyse(model.load_job(RBA1))
|
||||
assert len(a.shr) > 10
|
||||
|
||||
|
||||
def test_grade_factor_psi_g_follows_aci_table_25_4_2_5():
|
||||
from beamcalc import develop
|
||||
assert develop.psi_g_for(60.0) == 1.0
|
||||
assert develop.psi_g_for(80.0) == 1.15
|
||||
assert develop.psi_g_for(100.0) == 1.3
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`Result.shear_jump` returns the exact one-sided shear limits at x --
|
||||
grid-independent and right even when x sits just before a support or point
|
||||
load (the old code reported the neighbouring grid node's post-jump value)."""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from beamcalc import model, solve, statics
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
JOB = os.path.join(HERE, '..', 'fixtures', 'rba5_16x22.json')
|
||||
EXTRA = [1.90, 1.92, 2.0, 2.04, 212.92, 213.0, 213.08]
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def job():
|
||||
return model.load_job(JOB)
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def analysis(job):
|
||||
return solve.analyse(job, extra_stations_in=EXTRA)
|
||||
|
||||
|
||||
def _row(analysis, x_in):
|
||||
return next(r for r in analysis.strength if abs(r.x_in - x_in) < 1e-6)
|
||||
|
||||
|
||||
def test_strength_rows_see_the_overhang_side_before_a_support(analysis):
|
||||
# 1.92" is 0.08" before the left support centreline at 2.0": both
|
||||
# limits there are the small overhang shear, not the 44 kip span side.
|
||||
assert _row(analysis, 1.92).vu < 2.0
|
||||
assert _row(analysis, 2.04).vu > 40.0
|
||||
# right support at 213.0": 212.92" is span-side, 213.08" is overhang.
|
||||
assert _row(analysis, 212.92).vu > 18.0
|
||||
assert _row(analysis, 213.08).vu < 1.0
|
||||
|
||||
|
||||
def test_diagram_shear_jump_at_support():
|
||||
a = solve.analyse(model.load_job(JOB), extra_stations_in=EXTRA)
|
||||
# The governing combo puts 44.1 kip on the span side of the left support.
|
||||
d = a.diagrams['1.2D + 1.6L + 0.5R [pattern 3]']
|
||||
left, right = d.shear_jump(2.0)
|
||||
assert abs(left) < 2.0
|
||||
assert abs(right) > 40.0
|
||||
|
||||
|
||||
def test_diagram_shear_jump_is_continuous_off_loads(analysis):
|
||||
d = analysis.diagrams['1.4D']
|
||||
left, right = d.shear_jump(100.0) # no load or support sits at 100.0
|
||||
assert left == pytest.approx(right, abs=1e-9)
|
||||
assert left == pytest.approx(d.shear_at(100.0), abs=1e-6)
|
||||
|
||||
|
||||
def test_right_limit_at_a_node_is_the_stored_value(analysis):
|
||||
d = analysis.diagrams['1.2D + 1.6L + 0.5R [pattern 3]']
|
||||
for x, v in zip(d.xs, d.shear):
|
||||
assert d.shear_jump(x)[1] == pytest.approx(v, abs=1e-9)
|
||||
@@ -0,0 +1,224 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Deviation table: beamcalc vs an Eriksson Beam package's printed results.
|
||||
|
||||
python beamcalc/tools/compare_eriksson.py <job.json> <truth.json> [-o report.md]
|
||||
|
||||
`truth.json` comes from eriksson_extract.py. Engine stations are matched to
|
||||
the printed stations by nearest x within TOL ft; where the engine has no
|
||||
station within TOL, the engine is re-run with that station added, so every
|
||||
printed row gets a comparison. Prints one Markdown block per table with the
|
||||
largest deviations, and a one-line scoreboard (max |dev| per quantity)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
from beamcalc import model, solve # noqa: E402
|
||||
|
||||
TOL = 0.03
|
||||
|
||||
|
||||
def pct(a, b):
|
||||
if a is None or b is None:
|
||||
return None
|
||||
if abs(b) < 1e-9:
|
||||
return 0.0 if abs(a) < 1e-6 else None
|
||||
return 100.0 * (a - b) / abs(b)
|
||||
|
||||
|
||||
def fmt(v, nd=2):
|
||||
return '-' if v is None else ('{0:.%df}' % nd).format(v)
|
||||
|
||||
|
||||
def nearest(rows, x, key=lambda r: r.x_ft):
|
||||
best = None
|
||||
for r in rows:
|
||||
if abs(key(r) - x) <= TOL and (best is None or abs(key(r) - x) < abs(key(best) - x)):
|
||||
best = r
|
||||
return best
|
||||
|
||||
|
||||
def compare(job_path, truth_path):
|
||||
truth = json.load(open(truth_path))
|
||||
beam = model.load_job(job_path)
|
||||
extra = sorted({r['x_ft'] * 12.0 for t in ('flexure', 'shear', 'tension') for r in truth[t]})
|
||||
a = solve.analyse(beam, extra_stations_in=extra) if 'extra_stations_in' in solve.analyse.__code__.co_varnames else solve.analyse(beam)
|
||||
out = {'tables': {}, 'score': {}}
|
||||
|
||||
def table(name, rows, pairs):
|
||||
# pairs: list of (label, truth_key, engine_fn, nd, is_abs_tol)
|
||||
lines = ['| x ft | ' + ' | '.join('%s (engine / Eriksson / dev)' % p[0] for p in pairs) + ' |',
|
||||
'|---|' + '---|' * len(pairs)]
|
||||
worst = {p[0]: (0.0, None) for p in pairs}
|
||||
n = 0
|
||||
for tr in rows:
|
||||
x = tr['x_ft']
|
||||
eng = nearest_engine(name, a, x)
|
||||
if eng is None:
|
||||
continue
|
||||
n += 1
|
||||
cells = []
|
||||
for label, tkey, efn, nd, atol in pairs:
|
||||
tv = tr.get(tkey)
|
||||
ev = efn(eng)
|
||||
if tv is None:
|
||||
cells.append('%s / NC / -' % fmt(ev, nd)); continue
|
||||
if name in ('shear',) or tkey == 'mcr12':
|
||||
tv, ev = abs(tv), (abs(ev) if ev is not None else None) # the tool prints signed V
|
||||
d = pct(ev, tv)
|
||||
if tv is not None and ev is not None and atol is not None and abs(ev - tv) <= atol:
|
||||
d = 0.0
|
||||
if d is not None and abs(d) > worst[label][0]:
|
||||
worst[label] = (abs(d), x)
|
||||
cells.append('%s / %s / %s' % (fmt(ev, nd), fmt(tv, nd), ('%+.1f%%' % d) if d is not None else '-'))
|
||||
lines.append('| %.2f | %s |' % (x, ' | '.join(cells)))
|
||||
out['tables'][name] = {'md': '\n'.join(lines), 'n': n, 'worst': worst}
|
||||
for label, (w, x) in worst.items():
|
||||
out['score']['%s.%s' % (name, label)] = (w, x)
|
||||
|
||||
table('flexure', truth['flexure'], [
|
||||
('Mu', 'mu', lambda e: e.mu, 2, 0.05),
|
||||
('phiMn', 'phi_mn', lambda e: e.phi_mn, 2, 0.05),
|
||||
('1.2Mcr', 'mcr12', lambda e: e.mcr12, 2, 0.05),
|
||||
('c', 'c', lambda e: e.c, 2, 0.01),
|
||||
('As', 'as', lambda e: e.as_t, 2, 0.01),
|
||||
('d', 'd', lambda e: e.d, 2, 0.01),
|
||||
])
|
||||
table('shear', truth['shear'], [
|
||||
('Vu', 'vu', lambda e: e.r.vu, 1, 0.1),
|
||||
('phiVn', 'phi_vn', lambda e: e.r.phi_vn, 1, 0.1),
|
||||
('Vc', 'vc', lambda e: e.r.vc, 1, 0.1),
|
||||
('Vs prov', 'vs_prov', lambda e: e.r.vs_prov, 1, 0.1),
|
||||
('bwd', 'bwd', lambda e: e.r.bwd, 1, 0.1),
|
||||
('Av req', 'av_req', lambda e: e.r.av_req, 3, 0.002),
|
||||
('f act', 'f_act', lambda e: e.r.f_act, 1, 0.5),
|
||||
])
|
||||
table('tension', truth['tension'], [
|
||||
('f bot', 'f', lambda e: -min(e.f_bot, e.f_top, 0.0), 1, 1.0),
|
||||
('Mz', 'mz', lambda e: e.m, 2, 0.05),
|
||||
])
|
||||
table('comp_total', truth['comp_total'], [
|
||||
('f comp', 'f', lambda e: max(e.f_top, e.f_bot, 0.0), 1, 1.0),
|
||||
])
|
||||
table('comp_sust', truth['comp_sust'], [
|
||||
('f comp sus', 'f', lambda e: max(e.f_top_sus, e.f_bot_sus, 0.0), 1, 1.0),
|
||||
])
|
||||
# deflection rows
|
||||
d = a.deflection
|
||||
xs = truth.get('defl_x') or []
|
||||
dl = ['| stage | case | mult E/T | max E | max T | dev |', '|---|---|---|---|---|---|']
|
||||
for tr in truth['deflection']:
|
||||
case = tr['case']
|
||||
if tr['stage'] == 'Total' or case == 'Total':
|
||||
ev = d['total'] if tr['stage'] == 'Total' else None
|
||||
m_e = None
|
||||
elif tr['stage'].startswith('Start'):
|
||||
if case != 'SW':
|
||||
continue
|
||||
ev = [v / d['start_sw_multiplier'] * d['start_sw_multiplier'] for v in d['start_total']]
|
||||
m_e = d['start_sw_multiplier']
|
||||
else:
|
||||
row = d['rows'].get(case)
|
||||
if not row:
|
||||
continue
|
||||
ev = [row['multiplier'] * v for v in row['elastic']]
|
||||
m_e = row['multiplier']
|
||||
if ev is None:
|
||||
continue
|
||||
# engine values are on the engine grid; sample at the printed stations
|
||||
ge = _sample(a.grid, ev, [x * 12.0 for x in xs]) if xs else ev
|
||||
tv = tr['values']
|
||||
te = -max(abs(v) for v in tv) if tv else 0.0
|
||||
ee = -max(abs(v) for v in ge) if ge else 0.0
|
||||
dev = pct(ee, te)
|
||||
dl.append('| %s | %s | %s / %.2f | %.3f | %.3f | %s |' % (
|
||||
tr['stage'], case, fmt(m_e), tr['mult'], ee, te, ('%+.1f%%' % dev) if dev is not None else '-'))
|
||||
if dev is not None and abs(te) >= 0.01:
|
||||
out['score']['defl.%s.%s' % (tr['stage'], case)] = (abs(dev), None)
|
||||
out['tables']['deflection'] = {'md': '\n'.join(dl), 'n': len(dl) - 2, 'worst': {}}
|
||||
# summary
|
||||
s = truth['summary']
|
||||
sm = ['| item | engine | Eriksson | dev |', '|---|---|---|---|']
|
||||
worst_f = max(a.flex, key=lambda f: f.util) if a.flex else None
|
||||
items = [
|
||||
('flexure util', worst_f.util if worst_f else None, s.get('flex_util')),
|
||||
('phiMn governing', worst_f.phi_mn if worst_f else None, s.get('flex_phi_mn')),
|
||||
('Mu governing', worst_f.mu if worst_f else None, s.get('flex_mu')),
|
||||
('Av/s max', max(x.r.av_req for x in a.shr) if a.shr else None, s.get('av_s')),
|
||||
('defl total down', d['total_down'], s.get('defl_total_down')),
|
||||
('defl total up', d['total_up'], s.get('defl_total_up')),
|
||||
('defl live down', d['live_down'], s.get('defl_live_down')),
|
||||
('comp total max', max(max(r.f_top, r.f_bot) for r in a.service) if a.service else None, s.get('comp_tot_max')),
|
||||
('comp sust max', max(max(r.f_top_sus, r.f_bot_sus) for r in a.service) if a.service else None, s.get('comp_sust_max')),
|
||||
('tension max', max(-min(r.f_top, r.f_bot) for r in a.service) if a.service else None, s.get('tension_max')),
|
||||
]
|
||||
for k, side in (('left', 0), ('right', 1)):
|
||||
for case in ('deadnc', 'deadc', 'live', 'roof'):
|
||||
tv = (s.get('reactions') or {}).get(case)
|
||||
ev = a.reactions.get(case)
|
||||
if case == 'deadnc' and ev: # the tool's Deadnc includes self weight
|
||||
sw = a.reactions.get('self') or [0.0, 0.0]
|
||||
ev = [ev[0] + sw[0], ev[1] + sw[1]]
|
||||
items.append(('R %s %s' % (case, k), ev[side] if ev else None, tv[side] if tv else None))
|
||||
for k in ('left', 'right'):
|
||||
b = (s.get('bearing') or {}).get(k) or {}
|
||||
items.append(('bearing Vu %s' % k, (a.bearing.get(k) or {}).get('vu'), b.get('vu')))
|
||||
items.append(('bearing plain phiVn %s' % k, (a.bearing.get(k) or {}).get('phi_vn_plain'), b.get('phi_vn_plain')))
|
||||
st = s.get('stripping') or {}
|
||||
if a.stripping:
|
||||
items += [('strip Mu', a.stripping.get('mu'), st.get('mu')),
|
||||
('strip phiMn', a.stripping.get('phi_mn'), st.get('phi_mn')),
|
||||
('strip f tens', a.stripping.get('f_tens'), (st.get('tens') or [None])[0]),
|
||||
('strip f comp', a.stripping.get('f_comp'), (st.get('comp') or [None])[0])]
|
||||
for label, ev, tv in items:
|
||||
dev = pct(ev, tv)
|
||||
sm.append('| %s | %s | %s | %s |' % (label, fmt(ev, 3), fmt(tv, 3), ('%+.1f%%' % dev) if dev is not None else '-'))
|
||||
if dev is not None:
|
||||
out['score']['summary.%s' % label] = (abs(dev), None)
|
||||
out['tables']['summary'] = {'md': '\n'.join(sm), 'n': len(items), 'worst': {}}
|
||||
return out, a
|
||||
|
||||
|
||||
def _sample(grid, vals, xs):
|
||||
out = []
|
||||
for x in xs:
|
||||
i = min(range(len(grid)), key=lambda k: abs(grid[k] - x))
|
||||
out.append(vals[i])
|
||||
return out
|
||||
|
||||
|
||||
def nearest_engine(name, a, x):
|
||||
if name == 'flexure':
|
||||
return nearest(a.flex, x)
|
||||
if name == 'shear':
|
||||
return nearest(a.shr, x)
|
||||
return nearest(a.service, x)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('job'); ap.add_argument('truth'); ap.add_argument('-o', '--out')
|
||||
ap.add_argument('--full', action='store_true', help='print every row, not just the scoreboard')
|
||||
args = ap.parse_args(argv)
|
||||
out, a = compare(args.job, args.truth)
|
||||
md = ['# %s vs Eriksson' % a.beam.mark, '']
|
||||
md.append('## Scoreboard (max |dev|)'); md.append('')
|
||||
md.append('| quantity | max dev | at x ft |'); md.append('|---|---|---|')
|
||||
for k, (w, x) in sorted(out['score'].items(), key=lambda kv: -kv[1][0]):
|
||||
md.append('| %s | %.1f%% | %s |' % (k, w, fmt(x)))
|
||||
for name, t in out['tables'].items():
|
||||
md += ['', '## %s (%d rows)' % (name, t['n']), '', t['md']]
|
||||
text = '\n'.join(md)
|
||||
if args.out:
|
||||
open(args.out, 'w', encoding='utf-8').write(text)
|
||||
if args.full:
|
||||
print(text)
|
||||
else:
|
||||
print('\n'.join(md[:md.index('') + 40]) if len(md) > 40 else text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Pull the result tables out of an Eriksson Beam v5.9 PDF, deterministically.
|
||||
|
||||
python beamcalc/tools/eriksson_extract.py <package.pdf> <out.json>
|
||||
|
||||
Uses pymupdf word coordinates: every printed table row is one text line, so
|
||||
words are grouped by their y position and split on the known column count.
|
||||
Nothing is inferred -- a row that does not have the expected number of
|
||||
numeric cells is skipped and counted in `skipped`. Only numbers are kept;
|
||||
the title block (client / job / engineer) is not copied out.
|
||||
|
||||
Tables recognised (first run in the document only -- the "END CHECK" strut
|
||||
and tie re-run that some packages append is ignored on purpose):
|
||||
summary -- the "Summary" page numbers (reactions, bearing, strength, service)
|
||||
flexure -- In-Place Strength / Flexure Summary Table (14 columns)
|
||||
shear -- In-Place Strength / Shear Summary Table (15 columns)
|
||||
tension -- In-Place Service / Tension: station, psi, cracking psi, Mz
|
||||
comp_total -- Total Compression: station, psi, Mz
|
||||
comp_sust -- Sustained Compression: station, psi, Mz
|
||||
deflection -- Camber and Deflections: rows {case, stage, mult, values[11]}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pymupdf
|
||||
|
||||
NUM = re.compile(r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
def _num(t):
|
||||
if t == 'NC':
|
||||
return None
|
||||
return float(t)
|
||||
|
||||
|
||||
def lines_of(page):
|
||||
rows = {}
|
||||
for x0, y0, x1, y1, word, b, l, n in page.get_text('words'):
|
||||
rows.setdefault(round(y0), []).append((x0, word))
|
||||
out = []
|
||||
for y in sorted(rows):
|
||||
out.append([w for x, w in sorted(rows[y])])
|
||||
return out
|
||||
|
||||
|
||||
FLEX_COLS = ['x_ft', 'util', 'mu', 'phi', 'tc', 'phi_mn', 'mcr12', 'c', 'theta',
|
||||
'as', 'as_c', 'aps', 'loss', 'd']
|
||||
SHEAR_COLS = ['x_ft', 'util', 'vu', 'phi_vn', 'vc', 'vs_prov', 'bwd', 'legs',
|
||||
'av_min', 'av_prov', 'av_req', 's', 's_max', 'f_act', 'f_alw']
|
||||
|
||||
|
||||
def parse_row(words, cols):
|
||||
if len(words) != len(cols):
|
||||
return None
|
||||
row = {}
|
||||
for c, w in zip(cols, words):
|
||||
if c == 'tc':
|
||||
row[c] = w
|
||||
continue
|
||||
if not (NUM.match(w) or w == 'NC'):
|
||||
return None
|
||||
row[c] = _num(w)
|
||||
return row
|
||||
|
||||
|
||||
def extract(pdf_path):
|
||||
doc = pymupdf.open(pdf_path)
|
||||
out = {'flexure': [], 'shear': [], 'tension': [], 'comp_total': [], 'comp_sust': [],
|
||||
'deflection': [], 'summary': {}, 'skipped': 0, 'pages': len(doc)}
|
||||
mode = None # which table the following numeric rows belong to
|
||||
stage = None # 'strength' / 'service' / 'stripping' ... from page headers
|
||||
defl_stage = None
|
||||
defl_case = None
|
||||
seen_summary = False
|
||||
seen_run_end = False
|
||||
for pno, page in enumerate(doc, 1):
|
||||
text = page.get_text()
|
||||
if 'Project Information' in text and seen_summary:
|
||||
# a second Eriksson run begins (END CHECK etc.) -- stop
|
||||
seen_run_end = True
|
||||
if seen_run_end:
|
||||
break
|
||||
for words in lines_of(page):
|
||||
line = ' '.join(words)
|
||||
# --- section headers drive the mode
|
||||
if line.startswith('In-Place Strength Summary'):
|
||||
stage = 'strength'; mode = None
|
||||
elif line.startswith('In-Place Service Summary'):
|
||||
stage = 'service'; mode = None
|
||||
elif line.startswith('Camber and Deflections') and 'Note' not in line:
|
||||
stage = 'deflection'; mode = 'defl'
|
||||
elif line.split(' ')[0] in ('Stripping', 'Handling', 'Erection', 'Transfer') and 'Summary' in line:
|
||||
stage = 'handling'; mode = None
|
||||
elif line == 'Flexure' and stage == 'strength':
|
||||
mode = 'flexure'
|
||||
elif line.startswith('Pure Shear') and stage == 'strength':
|
||||
mode = 'shear'
|
||||
elif line.startswith('Loc. Util Vu') and stage == 'strength':
|
||||
mode = 'shear'
|
||||
elif line.startswith('Loc. Util.') and stage == 'strength':
|
||||
mode = 'flexure' if mode != 'shear' else mode
|
||||
elif line.startswith('Precast') and stage == 'service':
|
||||
pass
|
||||
elif line.startswith('Location Tension Cracking Mz'):
|
||||
mode = 'tension'
|
||||
elif line.startswith('Total Compression'):
|
||||
mode = 'comp_total'
|
||||
elif line.startswith('Sustained Compression'):
|
||||
mode = 'comp_sust'
|
||||
elif line.startswith('Servicability Class') or line.startswith('Location Tension Class'):
|
||||
mode = 'svc_class'
|
||||
elif line.startswith('Torsion') and stage == 'strength':
|
||||
mode = None
|
||||
if stage == 'deflection':
|
||||
if line in ('Start of Construction', 'End of Construction'):
|
||||
defl_stage = line
|
||||
elif line.startswith('Final') and len(words) == 1:
|
||||
defl_stage = 'Final'
|
||||
elif line.startswith('Deflections Occuring Prior'):
|
||||
defl_stage = 'Final prior'
|
||||
elif line.startswith('Deflections Occuring After'):
|
||||
defl_stage = 'Final after'
|
||||
elif line == 'Total Deflections':
|
||||
defl_stage = 'Total'
|
||||
elif len(words) == 1 and not NUM.match(words[0]):
|
||||
if words[0] in ('SW', 'Dnc', 'Dt', 'Dc', 'Live', 'Roof', 'Wind', 'Seismic', 'PS', 'Total'):
|
||||
defl_case = words[0]
|
||||
elif len(words) == 2 and words[0] == 'Sust.' and words[1] == 'Live':
|
||||
defl_case = 'Sust. Live'
|
||||
elif len(words) == 12 and all(NUM.match(w) for w in words) and defl_case:
|
||||
out['deflection'].append({'stage': defl_stage, 'case': defl_case,
|
||||
'mult': float(words[0]),
|
||||
'values': [float(w) for w in words[1:]],
|
||||
'page': pno})
|
||||
defl_case = None
|
||||
elif len(words) == 11 and all(NUM.match(w) for w in words) and defl_case is None and not out.get('defl_x'):
|
||||
out['defl_x'] = [float(w) for w in words]
|
||||
continue
|
||||
if mode == 'flexure':
|
||||
r = parse_row(words, FLEX_COLS)
|
||||
if r:
|
||||
r['page'] = pno; out['flexure'].append(r)
|
||||
elif words and NUM.match(words[0]) and len(words) > 8:
|
||||
out['skipped'] += 1
|
||||
elif mode == 'shear':
|
||||
r = parse_row(words, SHEAR_COLS)
|
||||
if r:
|
||||
r['page'] = pno; out['shear'].append(r)
|
||||
elif words and NUM.match(words[0]) and len(words) > 8:
|
||||
out['skipped'] += 1
|
||||
elif mode == 'tension' and len(words) == 4 and all(NUM.match(w) for w in words):
|
||||
out['tension'].append({'x_ft': float(words[0]), 'f': float(words[1]),
|
||||
'f_cr': float(words[2]), 'mz': float(words[3]), 'page': pno})
|
||||
elif mode in ('comp_total', 'comp_sust') and len(words) == 3 and all(NUM.match(w) for w in words):
|
||||
out[mode].append({'x_ft': float(words[0]), 'f': float(words[1]),
|
||||
'mz': float(words[2]), 'page': pno})
|
||||
# --- the one-page Summary block (regex over the page text)
|
||||
if 'Stage: In-Place Strength' in text and 'Stage: In-Place Service' in text and not seen_summary:
|
||||
seen_summary = True
|
||||
s = out['summary']
|
||||
flat = text.replace('\n', ' ')
|
||||
|
||||
def grab(pattern, cast=float):
|
||||
m = re.search(pattern, flat)
|
||||
return cast(m.group(1)) if m else None
|
||||
s['flex_util'] = grab(r'Stage: In-Place Strength.*?Utilization\s*:\s*([\d.]+)')
|
||||
s['flex_phi_mn'] = grab(r'Stage: In-Place Strength.*?ϕMn \(k-ft\):\s*([\d.-]+)')
|
||||
s['flex_mu'] = grab(r'Stage: In-Place Strength.*?Mu \(k-ft\):\s*([\d.-]+)')
|
||||
s['av_s'] = grab(r'Av/s \(in²/ft\):\s*([\d.]+)')
|
||||
s['vc'] = grab(r'Vc \(k\)\s*:\s*([\d.]+)')
|
||||
s['vu'] = grab(r'Vu \(k\)\s*:\s*([\d.]+)')
|
||||
s['defl_total_up'] = grab(r'Defl\. Total\s*↑ \(in\):\s*([\d.-]+)')
|
||||
s['defl_total_down'] = grab(r'Defl\. Total.*?↓ \(in\):\s*([\d.-]+)')
|
||||
s['defl_total_limit'] = grab(r'Defl\. Total.*?Limit \(in\):\s*([\d.]+)')
|
||||
m = re.search(r'LL\s*↑ \(in\):\s*([\d.-]+)\s*↓ \(in\):\s*([\d.-]+)\s*Limit \(in\):\s*([\d.]+)', flat)
|
||||
if m:
|
||||
s['defl_live_up'], s['defl_live_down'], s['defl_live_limit'] = (float(v) for v in m.groups())
|
||||
m = re.search(r'Defl\. Total\s*↑ \(in\):\s*([\d.-]+)\s*↓ \(in\):\s*([\d.-]+)\s*Limit \(in\):\s*([\d.]+)', flat)
|
||||
if m:
|
||||
s['defl_total_up'], s['defl_total_down'], s['defl_total_limit'] = (float(v) for v in m.groups())
|
||||
s['comp_tot_max'] = grab(r'Comp\. Tot\. Max \(psi\):\s*([\d.]+)')
|
||||
s['comp_sust_max'] = grab(r'Comp\. Sust\.Max \(psi\):\s*([\d.]+)')
|
||||
s['tension_max'] = grab(r'Tension\s*Max \(psi\):\s*([\d.]+)')
|
||||
s['class_u_limit'] = grab(r'Class U Limit \(psi\):\s*([\d.]+)')
|
||||
s['class_t_limit'] = grab(r'Class T Limit \(psi\):\s*([\d.]+)')
|
||||
m = re.search(r'Fy\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)', flat)
|
||||
if m:
|
||||
g = [float(v) for v in m.groups()]
|
||||
s['reactions'] = {'deadnc': g[0:2], 'deadc': g[2:4], 'live': g[4:6], 'roof': g[6:8]}
|
||||
m = re.search(r'Left\s+([\d.]+)\s+([\d.]+)\s+.*?Right\s+([\d.]+)\s+([\d.]+)', flat)
|
||||
if m:
|
||||
s['bearing'] = {'left': {'vu': float(m.group(1)), 'phi_vn_plain': float(m.group(2))},
|
||||
'right': {'vu': float(m.group(3)), 'phi_vn_plain': float(m.group(4))}}
|
||||
m = re.search(r'Stage: Stripping.*?Max Shear \(k\)\s*:\s*([\d.]+)\s*Max Tension \(k\):\s*([\d.]+).*?Compression \(psi\):\s*(\d+)/(\d+)\s*Tension \(psi\)\s*:\s*(\d+) / (\d+).*?Utilization:\s*([\d.]+)\s*ϕMn \(k-ft\):\s*([\d.]+)\s*Mu \(k-ft\):\s*([\d.]+)', flat)
|
||||
if m:
|
||||
g = m.groups()
|
||||
s['stripping'] = {'max_shear': float(g[0]), 'max_tension': float(g[1]),
|
||||
'comp': [float(g[2]), float(g[3])], 'tens': [float(g[4]), float(g[5])],
|
||||
'util': float(g[6]), 'phi_mn': float(g[7]), 'mu': float(g[8])}
|
||||
s['page'] = pno
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
data = extract(src)
|
||||
with open(dst, 'w', encoding='utf-8') as fh:
|
||||
json.dump(data, fh, indent=1)
|
||||
print('%s: flexure %d rows, shear %d, tension %d, comp %d/%d, defl rows %d, skipped %d, summary keys %d'
|
||||
% (dst, len(data['flexure']), len(data['shear']), len(data['tension']),
|
||||
len(data['comp_total']), len(data['comp_sust']), len(data['deflection']),
|
||||
data['skipped'], len(data['summary'])))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user