# CivilPy
# Copyright (C) 2019-2026 Dane Parks
#
# SPDX-License-Identifier: MIT
# See the LICENSE file in the project root for full license text.
"""
ACI 318-19 Chapter 17 - Anchoring to Concrete
All lengths in inches, forces in pounds (lb), stresses in psi.
"""
import functools
import math
from dataclasses import dataclass, field
from typing import Literal, Optional
# ──────────────────────────────────────────────────────────────────────────────
# Anchor-rod thread lookup: UNC threads per inch → effective stress area (in²)
# Formula: A_se = π/4 * (d_a - 0.9743/n_t)²
# ──────────────────────────────────────────────────────────────────────────────
_UNC_TPI = {
0.50: 13, 0.625: 11, 0.75: 10, 0.875: 9,
1.00: 8, 1.25: 7, 1.50: 6, 1.75: 5,
2.00: 4.5, 2.50: 4, 3.00: 4,
}
def _unc_stress_area(d_a: float) -> float:
"""Effective tensile stress area from UNC thread formula (in²)."""
# Find nearest nominal diameter
candidates = sorted(_UNC_TPI.keys(), key=lambda d: abs(d - d_a))
n_t = _UNC_TPI[candidates[0]]
return math.pi / 4 * (d_a - 0.9743 / n_t) ** 2
# ──────────────────────────────────────────────────────────────────────────────
# Results dataclass
# ──────────────────────────────────────────────────────────────────────────────
[docs]
@dataclass
class AnchorCheckResult:
"""Result for a single limit-state check."""
label: str # human-readable name
reference: str # ACI 318 section or equation number
N_n: float # nominal strength (lb), 0 if not a tension check
V_n: float # nominal strength (lb), 0 if not a shear check
phi: float # strength reduction factor
phi_Sn: float # design strength = phi * nominal (lb), or unitless limit if is_ratio
demand: float # factored demand (lb), or unitless ratio if is_ratio
is_ratio: bool = False # True for unitless interaction checks (don't divide by 1000)
@property
def dcr(self) -> float:
"""Demand-to-capacity ratio."""
if self.phi_Sn <= 0:
return float("inf")
return self.demand / self.phi_Sn
@property
def ok(self) -> bool:
return self.dcr <= 1.0
def __repr__(self) -> str:
status = "OK" if self.ok else "NG"
if self.is_ratio:
return (
f"{self.label} [{self.reference}]: "
f"limit={self.phi_Sn:.2f}, "
f"demand={self.demand:.3f}, "
f"DCR={self.dcr:.3f} [{status}]"
)
return (
f"{self.label} [{self.reference}]: "
f"φSn={self.phi_Sn/1000:.2f} kips, "
f"demand={self.demand/1000:.2f} kips, "
f"DCR={self.dcr:.3f} [{status}]"
)
# ──────────────────────────────────────────────────────────────────────────────
# Main class
# ──────────────────────────────────────────────────────────────────────────────
[docs]
class AnchorBolts:
"""
ACI 318-19 Chapter 17 anchor design checks for cast-in and post-installed anchors.
All inputs in US customary units (inches, pounds, psi).
Parameters
----------
f_c : float
Specified concrete compressive strength (psi). Capped at 10,000 psi
for cast-in anchors per ACI 318 Section 17.3.1.
h_a : float
Thickness of concrete member measured parallel to anchor axis (in).
d_a : float
Nominal anchor diameter (in).
h_ef : float
Effective embedment depth (in).
f_ya : float
Anchor yield strength (psi). e.g. ASTM F1554 Gr 36 → 36,000.
f_uta : float
Anchor tensile strength (psi). e.g. ASTM F1554 Gr 36 → 58,000.
Used value is capped at min(1.9*f_ya, 125,000) per ACI 318-19 17.6.1.2.
n_x : int
Number of anchors in the x-direction (direction of applied shear).
n_y : int
Number of anchors in the y-direction.
s_x : float
Anchor spacing in x-direction (in). Use 0 if n_x == 1.
s_y : float
Anchor spacing in y-direction (in). Use 0 if n_y == 1.
c_a1 : float
Edge distance from anchor to free edge in direction of applied shear (in).
Use a large value (e.g. 100) if no near edge in shear direction.
c_a2 : float
Edge distance from anchor to free edge perpendicular to shear (in).
Use a large value if no near edge perpendicular to shear.
c_a_min : float, optional
Minimum edge distance in any direction (in). Defaults to min(c_a1, c_a2).
A_se_N : float, optional
Effective cross-sectional area of anchor in tension (in²). Computed
from UNC thread formula if not provided.
A_se_V : float, optional
Effective cross-sectional area in shear (in²). Defaults to A_se_N.
A_brg : float, optional
Net bearing area of anchor head (in²). Required for headed-bolt pullout
per 17.6.3.2.2a. If None, pullout check uses the hooked-bolt formula
with e_h, or is skipped.
e_h : float, optional
Hook length (in) for J- or L-bolts; from inside of hook to bearing
surface. Required for hooked-bolt pullout per 17.6.3.2.2b.
lambda_a : float
Lightweight concrete modification factor per ACI 318 Table 17.2.4.1.
Use 1.0 for normalweight concrete (default).
is_cracked : bool
True if concrete is assumed cracked at service load levels (conservative
default). False for uncracked.
has_supp_reinf : bool
True if supplementary reinforcement conforming to ACI 318 17.5.2.1
is provided (Condition A → higher φ for concrete limit states).
is_ductile : bool
True if anchor material meets ductility requirements (elongation ≥ 14%,
reduction ≥ 30%). ASTM F1554 Gr 36 and Gr 55 qualify.
grout_pad : bool
True if anchor is used with a built-up grout pad. Reduces V_sa by 0.80
per ACI 318 Section 17.7.1.2.1.
e_N_prime : float
Eccentricity of resultant tensile force from centroid of anchor group (in).
Use 0 for concentric loading.
e_V_prime : float
Eccentricity of shear force from centroid of anchor group (in).
Use 0 for concentric shear.
A_Nc : float, optional
Projected concrete failure area for tension (in²). Computed automatically
if not provided.
A_Vc : float, optional
Projected concrete failure area for shear (in²). Computed automatically
if not provided.
N_ua : float
Total factored tensile demand on the anchor group (lb).
V_ua : float
Total factored shear demand on the anchor group (lb).
shear_direction : str
Direction of applied shear relative to the nearest edge: "perpendicular"
(default), "parallel" (ACI 17.7.2.1(c) — doubles V_cb, sets psi_ed=1.0),
or "away" (shear directed away from edge — no edge breakout check).
anchor_type : str
"cast_in" (default), "post_installed", or "adhesive".
Affects k_c in basic breakout (24 vs 17) and enables bond check.
tau_uncr : float, optional
Characteristic bond stress in uncracked concrete (psi), from ACI 355.4
evaluation report. Required for adhesive bond strength check.
tau_cr : float, optional
Characteristic bond stress in cracked concrete (psi). Required for
adhesive bond strength check when is_cracked=True.
sdc : str
Seismic Design Category ("A"–"F"). For SDC C–F, concrete failure mode
design strengths are multiplied by 0.75 per ACI 318-19 Section 17.10.5.3.
anchor_reinf_tension : float, optional
Design strength (φ*Rn, lb) of properly developed anchor reinforcement
in tension per ACI 17.5.2.1. When provided, replaces concrete breakout
in the tension limit-state check.
anchor_reinf_shear : float, optional
Design strength (φ*Rn, lb) of anchor reinforcement in shear per
ACI 17.5.2.1. When provided, replaces concrete breakout in the shear
limit-state check.
bolt_circle_radius : float, optional
Radius of the bolt circle (in) for circular anchor patterns (e.g. highway
poles, luminaires, sign structures). When provided, `_A_Nc` and `_A_Vc`
use annular/circular geometry instead of the rectangular array formulas.
Use `from_circular()` to construct typical highway pole base patterns.
shaft_radius : float, optional
Outer radius of a drilled shaft (in). When provided together with
`bolt_circle_radius`, automatically sets c_a1 = shaft_radius −
bolt_circle_radius (edge distance from bolt to shaft perimeter).
coupler_depth : float, optional
Depth (in) of a heavy-hex coupling nut used to extend a damaged anchor rod
via field repour. Per AISC Design Guide 1 and ACI 318-19 Section 17.10.5.3,
the coupler must be frictionally isolated from the new concrete (e.g., with
polyethylene tape or PVC sleeving) to preserve the anchor's stretch length
and maintain ductile behavior. If the coupler is NOT isolated, it acts as
the bearing surface, reducing effective embedment: h_ef_eff = h_ef − coupler_depth.
c_ac : float, optional
Critical edge distance for post-installed anchors (in). Used in the splitting
factor ψ_cp,N per ACI 318-19 Eq. 17.6.2.6.1b. Defaults to 2.5·h_ef per
ACI Table 17.9.5 if not provided. Only used when anchor_type='post_installed'
and is_cracked=False.
"""
def __init__(
self,
# Concrete
f_c: float,
h_a: float,
# Anchor geometry
d_a: float,
h_ef: float,
# Anchor material
f_ya: float,
f_uta: float,
# Group layout
n_x: int = 1,
n_y: int = 1,
s_x: float = 0.0,
s_y: float = 0.0,
# Edge distances
c_a1: float = float("inf"),
c_a2: float = float("inf"),
c_a_min: Optional[float] = None,
# Anchor cross-sections
A_se_N: Optional[float] = None,
A_se_V: Optional[float] = None,
A_brg: Optional[float] = None,
e_h: Optional[float] = None,
# Concrete & reinforcement modifiers
lambda_a: float = 1.0,
is_cracked: bool = True,
has_supp_reinf: bool = False,
is_ductile: bool = True,
grout_pad: bool = False,
# Eccentricities
e_N_prime: float = 0.0,
e_V_prime: float = 0.0,
# Override projected areas
A_Nc: Optional[float] = None,
A_Vc: Optional[float] = None,
# Applied demands
N_ua: float = 0.0,
V_ua: float = 0.0,
# Shear direction
shear_direction: Literal["perpendicular", "parallel",
"away"] = "perpendicular",
# Anchor type & adhesive bond
anchor_type: Literal["cast_in", "post_installed",
"adhesive"] = "cast_in",
tau_uncr: Optional[float] = None,
tau_cr: Optional[float] = None,
# Seismic
sdc: str = "A",
# Anchor reinforcement overrides
anchor_reinf_tension: Optional[float] = None,
anchor_reinf_shear: Optional[float] = None,
# AASHTO / highway structures
bolt_circle_radius: Optional[float] = None,
shaft_radius: Optional[float] = None,
coupler_depth: Optional[float] = None,
c_ac: Optional[float] = None,
):
# ── Material limits ─────────────────────────────────────────────────
self.f_c = min(f_c, 10_000) # ACI 318 17.3.1
self.h_a = h_a
self.d_a = d_a
self.f_ya = f_ya
self.f_uta = min(f_uta, min(1.9 * f_ya, 125_000)) # 17.6.1.2 cap
# ── Coupler / cold-joint depth ───────────────────────────────────────
self.coupler_depth = coupler_depth
if coupler_depth is not None:
if coupler_depth <= 0 or coupler_depth >= h_ef:
raise ValueError(
f"coupler_depth={coupler_depth} must be between 0 and h_ef={h_ef}"
)
self.h_ef = h_ef - coupler_depth
else:
self.h_ef = h_ef
# c_ac stored after h_ef is set so default can reference it
self._c_ac_input = c_ac
# ── Group geometry ───────────────────────────────────────────────────
self.n_x = max(1, n_x)
self.n_y = max(1, n_y)
self.n = self.n_x * self.n_y
self.s_x = s_x
self.s_y = s_y
# ── Circular pattern / drilled shaft geometry ────────────────────────
self._bolt_circle_radius = bolt_circle_radius
self.shaft_radius = shaft_radius
# If shaft geometry is provided and c_a1 wasn't explicitly set, derive it.
if shaft_radius is not None and bolt_circle_radius is not None:
derived = shaft_radius - bolt_circle_radius
if derived <= 0:
raise ValueError(
f"shaft_radius={shaft_radius} must be > bolt_circle_radius={bolt_circle_radius}"
)
if c_a1 == float("inf"):
c_a1 = derived
if c_a2 == float("inf"):
c_a2 = derived # round shaft: same edge distance on all sides
# ── Edge distances ───────────────────────────────────────────────────
self.c_a1 = c_a1
self.c_a2 = c_a2
self.c_a_min = c_a_min if c_a_min is not None else min(c_a1, c_a2)
# ── Cross-sectional areas ────────────────────────────────────────────
self.A_se_N = A_se_N if A_se_N is not None else _unc_stress_area(d_a)
self.A_se_V = A_se_V if A_se_V is not None else self.A_se_N
self.A_brg = A_brg
self.e_h = e_h
# ── Modifiers ────────────────────────────────────────────────────────
self.lambda_a = lambda_a
self.is_cracked = is_cracked
self.has_supp_reinf = has_supp_reinf
self.is_ductile = is_ductile
self.grout_pad = grout_pad
# ── Eccentricities ───────────────────────────────────────────────────
self.e_N_prime = e_N_prime
self.e_V_prime = e_V_prime
# ── Projected areas (computed or user-supplied) ──────────────────────
self._A_Nc_override = A_Nc
self._A_Vc_override = A_Vc
# ── Demands ─────────────────────────────────────────────────────────
self.N_ua = N_ua
self.V_ua = V_ua
# ── Shear direction & anchor type ────────────────────────────────────
if shear_direction not in ("perpendicular", "parallel", "away"):
raise ValueError(f"shear_direction must be 'perpendicular', 'parallel', or 'away', got {shear_direction!r}")
self.shear_direction = shear_direction
if anchor_type not in ("cast_in", "post_installed", "adhesive"):
raise ValueError(f"anchor_type must be 'cast_in', 'post_installed', or 'adhesive', got {anchor_type!r}")
self.anchor_type = anchor_type
self.tau_uncr = tau_uncr
self.tau_cr = tau_cr
# ── Seismic & anchor reinforcement ───────────────────────────────────
self.sdc = sdc.upper()
self.anchor_reinf_tension = anchor_reinf_tension
self.anchor_reinf_shear = anchor_reinf_shear
# ── Phi factors ─────────────────────────────────────────────────────
# Table 17.5.3(a) - steel
if is_ductile:
self.phi_steel_tension = 0.75
self.phi_steel_shear = 0.65
else:
self.phi_steel_tension = 0.65
self.phi_steel_shear = 0.60
# Table 17.5.3(b) - concrete breakout, bond, side-face blowout
if has_supp_reinf:
self.phi_concrete = 0.75 # Condition A
else:
self.phi_concrete = 0.70 # Condition B
# Table 17.5.3(c) - pullout and pryout (cast-in anchors: fixed at 0.70)
self.phi_pullout = 0.70
# ─────────────────────────────────────────────────────────────────────────
# Seismic reduction (17.10.5.3)
# ─────────────────────────────────────────────────────────────────────────
@property
def _phi_seismic(self) -> float:
"""0.75 reduction on concrete failure modes for SDC C–F (ACI 17.10.5.3)."""
return 0.75 if self.sdc in ("C", "D", "E", "F") else 1.0
@property
def c_ac(self) -> float:
"""Critical edge distance for post-installed anchors (in) — ACI Table 17.9.5."""
if self._c_ac_input is not None:
return self._c_ac_input
return 2.5 * self.h_ef
# ─────────────────────────────────────────────────────────────────────────
# Projected failure areas (17.6.2.1, 17.7.2.1)
# ─────────────────────────────────────────────────────────────────────────
def _A_Nco(self) -> float:
"""A_Nco: projected area of single anchor far from edges (17.6.2.1.4)."""
return 9.0 * self.h_ef ** 2
@functools.cached_property
def _A_Nc(self) -> float:
"""
A_Nc: projected concrete failure area for tension (17.6.2.1.1).
Rectangular group: rectangle from projecting 1.5*h_ef from outermost anchors.
Circular group: annular ring — π*(R_out² − R_in²) where
R_out = r_bc + min(c_a_min, 1.5*h_ef),
R_in = max(0, r_bc − 1.5*h_ef) (non-zero when bolts are far apart).
"""
if self._A_Nc_override is not None:
return self._A_Nc_override
cone = 1.5 * self.h_ef
if self._bolt_circle_radius is not None:
r_bc = self._bolt_circle_radius
R_out = r_bc + min(self.c_a_min, cone)
R_in = max(0.0, r_bc - cone)
return math.pi * (R_out ** 2 - R_in ** 2)
# Rectangular group
x_extent = min(self.c_a_min, cone) + (self.n_x - 1) * self.s_x + min(self.c_a_min, cone)
y_extent = min(self.c_a_min, cone) + (self.n_y - 1) * self.s_y + min(self.c_a_min, cone)
return x_extent * y_extent
def _A_Vco(self) -> float:
"""A_Vco: projected area of single anchor in deep member (17.7.2.1.3)."""
return 4.5 * self.c_a1 ** 2
def _a_vc_for(self, c_a1: float, c_a2: float, n_perp: int, s_perp: float) -> float:
"""A_Vc for arbitrary edge distances and group layout — used for corner checks."""
height = min(1.5 * c_a1, self.h_a)
c_a2_eff = min(c_a2, 1.5 * c_a1)
return height * (c_a2_eff + (n_perp - 1) * s_perp + c_a2_eff)
@functools.cached_property
def _A_Vc(self) -> float:
"""
A_Vc: projected concrete failure area for shear (17.7.2.1.1).
Rectangular group: height × (c_a2_eff + group_width + c_a2_eff).
Circular group: height × (c_a2_eff + 2*r_bc + c_a2_eff), where the
bolt circle diameter replaces the rectangular group extent.
"""
if self._A_Vc_override is not None:
return self._A_Vc_override
if self._bolt_circle_radius is not None:
r_bc = self._bolt_circle_radius
height = min(1.5 * self.c_a1, self.h_a)
c_a2_eff = min(self.c_a2, 1.5 * self.c_a1)
return height * (c_a2_eff + 2.0 * r_bc + c_a2_eff)
return self._a_vc_for(self.c_a1, self.c_a2, self.n_y, self.s_y)
# ─────────────────────────────────────────────────────────────────────────
# 17.6 - Tensile strength
# ─────────────────────────────────────────────────────────────────────────
[docs]
def steel_tension_strength(self) -> AnchorCheckResult:
"""
Steel strength of anchors in tension — ACI 318-19 Eq. 17.6.1.2.
N_sa = A_se,N * f_uta (per anchor)
Total group design strength = n * phi * N_sa
"""
N_sa_each = self.A_se_N * self.f_uta
N_sa_total = self.n * N_sa_each
phi_Nn = self.phi_steel_tension * N_sa_total
return AnchorCheckResult(
label="Steel strength (tension)",
reference="ACI 318-19 Eq. 17.6.1.2",
N_n=N_sa_total,
V_n=0.0,
phi=self.phi_steel_tension,
phi_Sn=phi_Nn,
demand=self.N_ua,
)
def _psi_ec_N(self) -> float:
"""Eccentricity factor for tension — ACI 318-19 Eq. 17.6.2.3.1."""
return min(1.0, 1.0 / (1.0 + self.e_N_prime / (1.5 * self.h_ef)))
def _psi_ed_N(self) -> float:
"""Edge effect factor for tension — ACI 318-19 Eq. 17.6.2.4.1."""
if self.c_a_min >= 1.5 * self.h_ef:
return 1.0
return 0.7 + 0.3 * self.c_a_min / (1.5 * self.h_ef)
def _psi_c_N(self) -> float:
"""Cracking factor for tension — ACI 318-19 Section 17.6.2.5.1."""
if self.is_cracked:
return 1.0
# Uncracked concrete: 1.25 for cast-in, 1.40 for post-installed or adhesive (17.6.2.5.1)
return 1.40 if self.anchor_type in ("post_installed", "adhesive") else 1.25
def _psi_cp_N(self) -> float:
"""Splitting factor for tension — ACI 318-19 Eq. 17.6.2.6.1."""
# Cast-in anchors: always 1.0 per 17.6.2.6.2
if self.anchor_type != "post_installed":
return 1.0
# Post-installed in cracked concrete: 1.0 per 17.6.2.6.1a
if self.is_cracked:
return 1.0
# Post-installed in uncracked concrete: Eq. 17.6.2.6.1b
ratio = self.c_a_min / self.c_ac
return min(1.0, max(ratio, 1.0 / 1.5))
def _N_b(self) -> float:
"""
Basic concrete breakout strength of a single anchor in tension (lb).
Cast-in: Eq. 17.6.2.2.3 (11 ≤ h_ef ≤ 25) else Eq. 17.6.2.2.1 (k_c=24).
Post-installed: Eq. 17.6.2.2.1 with k_c=17 per 17.6.2.2.2.
"""
if self.anchor_type == "cast_in" and 11.0 <= self.h_ef <= 25.0:
return 16.0 * self.lambda_a * math.sqrt(self.f_c) * self.h_ef ** (5.0 / 3.0)
k_c = 24.0 if self.anchor_type == "cast_in" else 17.0
return k_c * self.lambda_a * math.sqrt(self.f_c) * self.h_ef ** 1.5
[docs]
def breakout_strength_tension(self) -> AnchorCheckResult:
"""
Concrete breakout strength of anchor group in tension.
Single anchor: Eq. 17.6.2.1a
Anchor group: Eq. 17.6.2.1b (includes psi_ec_N)
"""
A_Nc = self._A_Nc
A_Nco = self._A_Nco()
N_b = self._N_b()
psi_ec = self._psi_ec_N()
psi_ed = self._psi_ed_N()
psi_c = self._psi_c_N()
psi_cp = self._psi_cp_N()
if self.n == 1:
# Eq. 17.6.2.1a — single anchor
N_cb = (A_Nc / A_Nco) * psi_ed * psi_c * psi_cp * N_b
ref = "ACI 318-19 Eq. 17.6.2.1a"
else:
# Eq. 17.6.2.1b — anchor group
N_cb = (A_Nc / A_Nco) * psi_ec * psi_ed * psi_c * psi_cp * N_b
ref = "ACI 318-19 Eq. 17.6.2.1b"
phi_Nn = self.phi_concrete * self._phi_seismic * N_cb
return AnchorCheckResult(
label="Concrete breakout (tension)",
reference=ref,
N_n=N_cb,
V_n=0.0,
phi=self.phi_concrete,
phi_Sn=phi_Nn,
demand=self.N_ua,
)
def _psi_c_P(self) -> float:
"""Pullout cracking factor — ACI 318-19 Section 17.6.3.3.1."""
return 1.0 if self.is_cracked else 1.4
[docs]
def pullout_strength(self) -> Optional[AnchorCheckResult]:
"""
Pullout strength of cast-in anchors — ACI 318-19 Section 17.6.3.
Returns None if insufficient geometry is provided (no A_brg or e_h).
For headed studs/bolts: N_p = 8 * A_brg * f_c' (Eq. 17.6.3.2.2a)
For J- or L-bolts: N_p = 0.9 * f_c' * e_h * d_a (Eq. 17.6.3.2.2b)
N_pn = psi_c,P * N_p (Eq. 17.6.3.1)
Note: Pullout is evaluated per anchor; total = n * phi * N_pn.
"""
if self.A_brg is not None:
# Eq. 17.6.3.2.2a — headed studs and headed bolts
N_p = 8.0 * self.A_brg * self.f_c
ref = "ACI 318-19 Eq. 17.6.3.2.2a (headed)"
elif self.e_h is not None:
# Eq. 17.6.3.2.2b — J- or L-bolts
if not (3.0 * self.d_a <= self.e_h <= 4.5 * self.d_a):
raise ValueError(
f"Hook length e_h={self.e_h:.3f} must satisfy "
f"3*d_a={3*self.d_a:.3f} ≤ e_h ≤ 4.5*d_a={4.5*self.d_a:.3f}"
)
N_p = 0.9 * self.f_c * self.e_h * self.d_a
ref = "ACI 318-19 Eq. 17.6.3.2.2b (hooked)"
else:
return None
N_pn_each = self._psi_c_P() * N_p
N_pn_total = self.n * N_pn_each
phi_Nn = self.phi_pullout * self._phi_seismic * N_pn_total
return AnchorCheckResult(
label="Pullout (tension)",
reference=ref,
N_n=N_pn_total,
V_n=0.0,
phi=self.phi_pullout,
phi_Sn=phi_Nn,
demand=self.N_ua,
)
[docs]
def side_face_blowout(self) -> Optional[AnchorCheckResult]:
"""
Side-face blowout strength of headed anchors — ACI 318-19 Section 17.6.4.
Applicable only when h_ef > 2.5 * c_a1 (deep anchor near edge).
Returns None if condition not met or A_brg not provided.
N_sb = 160 * c_a1 * sqrt(A_brg) * lambda_a * sqrt(f_c') (Eq. 17.6.4.1)
Group: N_sbg = (1 + s/6/c_a1) * N_sb (Eq. 17.6.4.2, simplified)
"""
if self.A_brg is None:
return None
if self.h_ef <= 2.5 * self.c_a1:
return None # condition not triggered
N_sb = 160.0 * self.c_a1 * math.sqrt(self.A_brg) * self.lambda_a * math.sqrt(self.f_c)
# Perpendicular edge modifier (17.6.4.1.1)
if self.c_a2 < 3.0 * self.c_a1:
ratio = max(1.0, min(3.0, self.c_a2 / self.c_a1))
N_sb *= (1.0 + ratio) / 4.0
# Group effect (17.6.4.2): if spacing < 6*c_a1, average per anchor
if self.n > 1 and self.s_x < 6.0 * self.c_a1:
N_sbg = N_sb * (1.0 + self.s_x / (6.0 * self.c_a1))
else:
N_sbg = self.n * N_sb
phi_Nn = self.phi_concrete * self._phi_seismic * N_sbg
return AnchorCheckResult(
label="Side-face blowout",
reference="ACI 318-19 Eq. 17.6.4.1",
N_n=N_sbg,
V_n=0.0,
phi=self.phi_concrete,
phi_Sn=phi_Nn,
demand=self.N_ua,
)
[docs]
def bond_strength_tension(self) -> Optional[AnchorCheckResult]:
"""
Bond strength of adhesive anchors in tension — ACI 318-19 Section 17.6.5.
Requires anchor_type="adhesive" and tau_uncr (or tau_cr when is_cracked).
Returns None for non-adhesive anchors or missing bond stress data.
N_ba = tau * π * d_a * h_ef (Eq. 17.6.5.2.1)
c_Na = 10 * d_a * sqrt(tau / 1100) (Eq. 17.6.5.1.5, influence distance)
"""
if self.anchor_type != "adhesive":
return None
tau = self.tau_cr if self.is_cracked else self.tau_uncr
if tau is None:
return None
# Characteristic influence distance
c_Na = 10.0 * self.d_a * math.sqrt(tau / 1100.0)
# Reference influence area (single anchor, free of edges)
A_Nao = (2.0 * c_Na) ** 2
# Group influence area (bounded by edges)
x_ext = min(self.c_a_min, c_Na) + (self.n_x - 1) * self.s_x + min(self.c_a_min, c_Na)
y_ext = min(self.c_a_min, c_Na) + (self.n_y - 1) * self.s_y + min(self.c_a_min, c_Na)
A_Na = x_ext * y_ext
# Basic bond strength per anchor
N_ba = tau * math.pi * self.d_a * self.h_ef
# Edge and eccentricity factors
psi_ed_Na = 1.0 if self.c_a_min >= c_Na else (0.7 + 0.3 * self.c_a_min / c_Na)
psi_ec_Na = min(1.0, 1.0 / (1.0 + self.e_N_prime / c_Na))
if self.n == 1:
N_ag = (A_Na / A_Nao) * psi_ed_Na * N_ba
ref = "ACI 318-19 Eq. 17.6.5.1.1a"
else:
N_ag = (A_Na / A_Nao) * psi_ec_Na * psi_ed_Na * N_ba
ref = "ACI 318-19 Eq. 17.6.5.1.1b"
phi_Nn = self.phi_concrete * self._phi_seismic * N_ag
return AnchorCheckResult(
label="Bond strength (tension)",
reference=ref,
N_n=N_ag,
V_n=0.0,
phi=self.phi_concrete,
phi_Sn=phi_Nn,
demand=self.N_ua,
)
# ─────────────────────────────────────────────────────────────────────────
# 17.7 - Shear strength
# ─────────────────────────────────────────────────────────────────────────
[docs]
def steel_shear_strength(self) -> AnchorCheckResult:
"""
Steel strength of anchors in shear — ACI 318-19 Eq. 17.7.1.2b.
For cast-in headed bolts (threads may be in shear plane)::
V_sa = 0.6 * A_se,V * f_uta (per anchor)
Grout pad reduces by 0.80 (Section 17.7.1.2.1).
"""
V_sa_each = 0.6 * self.A_se_V * self.f_uta
if self.grout_pad:
V_sa_each *= 0.80 # 17.7.1.2.1
V_sa_total = self.n * V_sa_each
phi_Vn = self.phi_steel_shear * V_sa_total
return AnchorCheckResult(
label="Steel strength (shear)",
reference="ACI 318-19 Eq. 17.7.1.2b",
N_n=0.0,
V_n=V_sa_total,
phi=self.phi_steel_shear,
phi_Sn=phi_Vn,
demand=self.V_ua,
)
def _psi_ec_V(self) -> float:
"""Eccentricity factor for shear — ACI 318-19 Eq. 17.7.2.3.1."""
return min(1.0, 1.0 / (1.0 + self.e_V_prime / (1.5 * self.c_a1)))
def _psi_ed_V(self) -> float:
"""Edge effect factor for shear — ACI 318-19 Eq. 17.7.2.4.1."""
if self.c_a2 >= 1.5 * self.c_a1:
return 1.0
return 0.7 + 0.3 * self.c_a2 / (1.5 * self.c_a1)
def _psi_c_V(self) -> float:
"""
Cracking factor for shear — ACI 318-19 Table 17.7.2.5.1.
1.4 if uncracked and no supplementary reinforcement
1.2 if cracked with supplementary reinforcement ≥ No. 4 bar
1.0 if cracked with no adequate supplementary reinforcement
"""
if not self.is_cracked:
return 1.4
if self.has_supp_reinf:
return 1.2
return 1.0
def _psi_h_V(self) -> float:
"""
Member thickness factor for shear — ACI 318-19 Eq. 17.7.2.6.1.
Applied when h_a < 1.5 * c_a1.
"""
if self.h_a >= 1.5 * self.c_a1:
return 1.0
return math.sqrt(1.5 * self.c_a1 / self.h_a)
def _v_b_for(self, c_a1: float) -> float:
"""V_b for arbitrary c_a1 — used for corner checks and parallel shear."""
l_e = min(self.h_ef, 8.0 * self.d_a)
V_b_a = (7.0 * (l_e / self.d_a) ** 0.2 * math.sqrt(self.d_a)) * \
self.lambda_a * math.sqrt(self.f_c) * c_a1 ** 1.5
V_b_b = 9.0 * self.lambda_a * math.sqrt(self.f_c) * c_a1 ** 1.5
return min(V_b_a, V_b_b)
@functools.cached_property
def _V_b(self) -> float:
"""
Basic concrete breakout strength of a single anchor in shear (lb).
V_b per Eq. 17.7.2.2.1a (geometry-based), capped by Eq. 17.7.2.2.1b.
l_e = h_ef for anchors with constant stiffness (headed rods).
"""
return self._v_b_for(self.c_a1)
def _vcb_perp(self, c_a1: float, c_a2: float, n_perp: int, s_perp: float,
override_psi_ed: Optional[float] = None) -> float:
"""
V_cb for shear perpendicular to an edge — parametric helper for corner/parallel.
n_perp / s_perp are the anchor count and spacing in the direction
*perpendicular* to shear (i.e. parallel to the edge being checked).
"""
A_Vc = self._a_vc_for(c_a1, c_a2, n_perp, s_perp)
A_Vco = 4.5 * c_a1 ** 2
V_b = self._v_b_for(c_a1)
psi_ec = min(1.0, 1.0 / (1.0 + self.e_V_prime / (1.5 * c_a1)))
if override_psi_ed is not None:
psi_ed = override_psi_ed
elif c_a2 >= 1.5 * c_a1:
psi_ed = 1.0
else:
psi_ed = 0.7 + 0.3 * c_a2 / (1.5 * c_a1)
psi_c = self._psi_c_V()
psi_h = 1.0 if self.h_a >= 1.5 * c_a1 else math.sqrt(1.5 * c_a1 / self.h_a)
if self.n == 1:
return (A_Vc / A_Vco) * psi_ed * psi_c * psi_h * V_b
return (A_Vc / A_Vco) * psi_ec * psi_ed * psi_c * psi_h * V_b
[docs]
def breakout_strength_shear(self) -> Optional[AnchorCheckResult]:
"""
Concrete breakout strength of anchor group in shear — ACI 318-19 17.7.2.
``shear_direction="perpendicular"``: standard edge breakout
(17.7.2.1a/b). Corner condition (both c_a1 and c_a2 finite):
automatically computes both edges and uses the minimum per ACI
17.7.2.1(d). ``shear_direction="parallel"``: doubles V_cb with
psi_ed=1.0 (17.7.2.1(c)). ``shear_direction="away"``: returns
None (no edge breakout).
Returns None when c_a1 is infinite (no near edge in shear direction).
"""
if self.shear_direction == "away" or not math.isfinite(self.c_a1):
return None
psi_c = self._psi_c_V()
if self.shear_direction == "parallel":
# ACI 17.7.2.1(c): double the perpendicular result, psi_ed = 1.0
V_cb = 2.0 * self._vcb_perp(self.c_a1, self.c_a2, self.n_y, self.s_y,
override_psi_ed=1.0)
ref_suffix = " (parallel×2)"
else:
# "perpendicular"
V_cb = self._vcb_perp(self.c_a1, self.c_a2, self.n_y, self.s_y)
# Corner check — ACI 17.7.2.1(d): when c_a2 < 1.5*c_a1 the second
# edge can produce a lower breakout capacity; check both and use min.
if math.isfinite(self.c_a2) and self.c_a2 < 1.5 * self.c_a1:
V_cb_2 = self._vcb_perp(self.c_a2, self.c_a1, self.n_x, self.s_x)
if V_cb_2 < V_cb:
V_cb = V_cb_2
ref_suffix = " (corner, edge 2 governs)"
else:
ref_suffix = " (corner, edge 1 governs)"
else:
ref_suffix = ""
eq = "17.7.2.1a" if self.n == 1 else "17.7.2.1b"
ref = f"ACI 318-19 Eq. {eq}{ref_suffix}"
phi_Vn = self.phi_concrete * self._phi_seismic * V_cb
return AnchorCheckResult(
label="Concrete breakout (shear)",
reference=ref,
N_n=0.0,
V_n=V_cb,
phi=self.phi_concrete,
phi_Sn=phi_Vn,
demand=self.V_ua,
)
[docs]
def pryout_strength(self) -> AnchorCheckResult:
"""
Concrete pryout strength of anchors in shear — ACI 318-19 Eq. 17.7.3.1.
V_cp = k_cp * N_cp (single anchor)
V_cpg = k_cp * N_cpg (group)
k_cp = 1.0 for h_ef < 2.5 in, else 2.0.
N_cp / N_cpg taken as concrete breakout strength in tension.
"""
k_cp = 1.0 if self.h_ef < 2.5 else 2.0
# Use nominal tension breakout (no phi) as N_cp
result_N = self.breakout_strength_tension()
N_cp = result_N.N_n # nominal (unfactored)
V_cp = k_cp * N_cp
phi_Vn = self.phi_pullout * self._phi_seismic * V_cp
return AnchorCheckResult(
label="Pryout (shear)",
reference="ACI 318-19 Eq. 17.7.3.1",
N_n=0.0,
V_n=V_cp,
phi=self.phi_pullout,
phi_Sn=phi_Vn,
demand=self.V_ua,
)
# ─────────────────────────────────────────────────────────────────────────
# 17.8 - Tension-shear interaction
# ─────────────────────────────────────────────────────────────────────────
[docs]
def tension_shear_interaction(self) -> AnchorCheckResult:
"""
Tension-shear interaction — ACI 318-19 Section 17.8.
If N_ua/(φN_n) ≤ 0.2, shear governs and the full shear capacity is
available. If V_ua/(φV_n) ≤ 0.2, tension governs and the full
tension capacity is available. Otherwise (Eq. 17.8.3)::
N_ua/(φN_n) + V_ua/(φV_n) ≤ 1.2
"""
phi_Nn = min(r.phi_Sn for r in self._tension_results() if r is not None)
phi_Vn = min(r.phi_Sn for r in self._shear_results() if r is not None)
ratio_N = self.N_ua / phi_Nn if phi_Nn > 0 else float("inf")
ratio_V = self.V_ua / phi_Vn if phi_Vn > 0 else float("inf")
if ratio_N <= 0.2:
# Only shear: demand = 0 equivalent (tension neglected)
interaction = ratio_V
elif ratio_V <= 0.2:
# Only tension
interaction = ratio_N
else:
interaction = ratio_N + ratio_V
return AnchorCheckResult(
label="Tension-shear interaction",
reference="ACI 318-19 Eq. 17.8.3",
N_n=0.0,
V_n=0.0,
phi=1.0,
phi_Sn=1.2,
demand=interaction,
is_ratio=True,
)
# ─────────────────────────────────────────────────────────────────────────
# Helpers & summary
# ─────────────────────────────────────────────────────────────────────────
def _tension_results(self) -> list:
results = [self.steel_tension_strength()]
# Anchor reinforcement replaces concrete breakout in tension (17.5.2.1)
if self.anchor_reinf_tension is not None:
results.append(AnchorCheckResult(
label="Anchor reinforcement (tension)",
reference="ACI 318-19 Section 17.5.2.1",
N_n=self.anchor_reinf_tension,
V_n=0.0,
phi=1.0,
phi_Sn=self.anchor_reinf_tension,
demand=self.N_ua,
))
else:
results.append(self.breakout_strength_tension())
pu = self.pullout_strength()
if pu is not None:
results.append(pu)
sb = self.side_face_blowout()
if sb is not None:
results.append(sb)
bond = self.bond_strength_tension()
if bond is not None:
results.append(bond)
return results
def _shear_results(self) -> list:
results = [self.steel_shear_strength()]
# Anchor reinforcement replaces concrete breakout in shear (17.5.2.1)
if self.anchor_reinf_shear is not None:
results.append(AnchorCheckResult(
label="Anchor reinforcement (shear)",
reference="ACI 318-19 Section 17.5.2.1",
N_n=0.0,
V_n=self.anchor_reinf_shear,
phi=1.0,
phi_Sn=self.anchor_reinf_shear,
demand=self.V_ua,
))
else:
cb = self.breakout_strength_shear()
if cb is not None:
results.append(cb)
results.append(self.pryout_strength())
return results
[docs]
def governing_tension_strength(self) -> AnchorCheckResult:
"""Return the governing (minimum φSn) tension limit state."""
return min(self._tension_results(), key=lambda r: r.phi_Sn)
[docs]
def governing_shear_strength(self) -> AnchorCheckResult:
"""Return the governing (minimum φSn) shear limit state."""
return min(self._shear_results(), key=lambda r: r.phi_Sn)
[docs]
def stretch_length_check(self) -> Optional[AnchorCheckResult]:
"""
Stretch length adequacy for seismic ductile designs — ACI 318-19 Section 17.10.5.3.
For SDC C–F where anchor yielding is relied upon for ductility, the
frictionally isolated length must be ≥ 8·d_a. Returns None if not in
seismic SDC C–F. coupler_depth is used as the isolated stretch length when
provided; otherwise h_ef is used as a conservative proxy.
"""
if self.sdc not in ("C", "D", "E", "F"):
return None
L_stretch = self.coupler_depth if self.coupler_depth is not None else self.h_ef
L_min = 8.0 * self.d_a
# phi_Sn = available length (capacity), demand = required minimum
# OK when L_stretch >= L_min (DCR = L_min/L_stretch <= 1.0)
return AnchorCheckResult(
label="Stretch length (seismic)",
reference="ACI 318-19 Section 17.10.5.3",
N_n=0.0,
V_n=0.0,
phi=1.0,
phi_Sn=L_stretch,
demand=L_min,
is_ratio=True,
)
[docs]
def check_all(self) -> dict:
"""
Run all applicable limit-state checks.
Returns a dict mapping check label to AnchorCheckResult.
Interaction check is included only when both N_ua and V_ua are nonzero.
Stretch length check is included for SDC C–F.
"""
results = {}
for r in self._tension_results():
results[r.label] = r
for r in self._shear_results():
results[r.label] = r
if self.N_ua > 0 and self.V_ua > 0:
r = self.tension_shear_interaction()
results[r.label] = r
sl = self.stretch_length_check()
if sl is not None:
results[sl.label] = sl
return results
[docs]
def summary(self) -> str:
"""Print a formatted table of all limit-state checks."""
header = f"{'Limit State':<35} {'Ref':<28} {'φSn (kip)':>10} {'Demand':>10} {'DCR':>7} {'Status':>6}"
separator = "-" * len(header)
lines = [separator, header, separator]
for r in self.check_all().values():
if r.is_ratio:
phi_str = f"{'≤1.20':>10}"
dem_str = f"{r.demand:>10.3f}"
else:
phi_str = f"{r.phi_Sn/1000:>10.2f}"
dem_str = f"{r.demand/1000:>10.2f}"
lines.append(
f"{r.label:<35} {r.reference:<28} "
f"{phi_str} {dem_str} "
f"{r.dcr:>7.3f} {'OK' if r.ok else 'NG':>6}"
)
lines.append(separator)
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────────────────
# AASHTO / highway structure helpers
# ─────────────────────────────────────────────────────────────────────────
[docs]
@classmethod
def from_circular(
cls,
n_bolts: int,
bolt_circle_radius: float,
f_c: float,
h_a: float,
d_a: float,
h_ef: float,
f_ya: float,
f_uta: float,
M_u: Optional[float] = None,
P_u: float = 0.0,
V_u: Optional[float] = None,
T_u: float = 0.0,
**kwargs,
) -> "AnchorBolts":
"""
Constructor for circular bolt patterns — highway poles, luminaires, signs.
Parameters
----------
n_bolts : int
Total number of anchor rods on the bolt circle.
bolt_circle_radius : float
Radius of the bolt circle (in), measured to bolt centerlines.
M_u : float, optional
Factored overturning moment at pole base (lb·in). When provided,
bolt demands are computed automatically via bolt_demands_from_pole().
P_u : float, optional
Factored axial load; positive = uplift/tension (lb). Used with M_u.
V_u : float, optional
Factored base shear at pole base (lb). When provided, shear demands
are computed automatically via bolt_demands_from_pole().
T_u : float, optional
Factored torsional moment at pole base (lb·in). Used with V_u.
**kwargs
Any additional AnchorBolts parameters (shaft_radius, sdc, N_ua, …).
Explicit N_ua/V_ua in kwargs take precedence over computed values.
Notes
-----
`n` is stored as n_y=n_bolts, n_x=1. Projected areas _A_Nc and _A_Vc
use annular/circular geometry automatically when bolt_circle_radius is set.
Supply shaft_radius to auto-derive c_a1 for drilled shaft applications.
"""
if M_u is not None or V_u is not None:
n_ua_auto, v_ua_auto = cls.bolt_demands_from_pole(
n_bolts=n_bolts,
bolt_circle_radius=bolt_circle_radius,
M_u=M_u or 0.0,
P_u=P_u,
V_u=V_u or 0.0,
T_u=T_u,
)
kwargs.setdefault("N_ua", n_ua_auto)
kwargs.setdefault("V_ua", v_ua_auto)
return cls(
f_c=f_c,
h_a=h_a,
d_a=d_a,
h_ef=h_ef,
f_ya=f_ya,
f_uta=f_uta,
n_x=1,
n_y=n_bolts,
bolt_circle_radius=bolt_circle_radius,
**kwargs,
)
[docs]
@staticmethod
def bolt_demands_from_pole(
n_bolts: int,
bolt_circle_radius: float,
M_u: float,
P_u: float = 0.0,
V_u: float = 0.0,
T_u: float = 0.0,
) -> tuple:
"""
Max individual bolt demands from global AASHTO pole reactions.
Uses the elastic circular bolt group method (AASHTO LTS-1 / AISC Design
Guide 1). All inputs in consistent units (lb and in).
Parameters
----------
n_bolts : int
Number of anchor rods on the bolt circle.
bolt_circle_radius : float
Radius from pole centroid to bolt centerlines (in).
M_u : float
Factored overturning moment at base (lb·in).
P_u : float
Factored axial load; positive = uplift/tension (lb).
V_u : float
Factored in-plane shear at base (lb).
T_u : float
Factored torsional moment at base (lb·in).
Returns
-------
(N_ua_max, V_ua_max) : tuple[float, float]
Maximum tension demand (lb) and shear demand (lb) on a single bolt.
Notes
-----
::
Tension from moment: N_M = 2·M_u / (n·r) [elastic, Σy² = n·r²/2]
Axial share: N_P = P_u / n
Shear from V_u: V_v = V_u / n
Shear from torsion: V_t = T_u / (n·r) [all bolts at equal radius]
Final: N_ua_max = max(0, N_M + N_P)
V_ua_max = V_v + V_t (conservative, additive)
"""
r = bolt_circle_radius
n = n_bolts
N_moment = 2.0 * M_u / (n * r)
N_axial = P_u / n
N_ua_max = max(0.0, N_moment + N_axial)
V_direct = V_u / n
V_torsion = T_u / (n * r)
V_ua_max = V_direct + V_torsion
return N_ua_max, V_ua_max
# ─────────────────────────────────────────────────────────────────────────
# Visualisation & digital inputs
# ─────────────────────────────────────────────────────────────────────────
[docs]
def plot(self, title: str = "", figsize: tuple = (8, 7)):
"""
2D plan-view diagram of the anchor group.
Draws the concrete boundary, bolt locations, the 1.5·h_ef tension breakout
zone (dashed red), and demand arrows for V_ua and N_ua.
Parameters
----------
title : str
Optional plot title; defaults to repr(self).
figsize : tuple
Matplotlib figure size in inches, default (8, 7).
Returns
-------
(fig, ax) : tuple
Matplotlib Figure and Axes objects for further customisation or saving.
"""
try:
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
except ImportError:
raise ImportError(
"matplotlib is required for plot(). Install with: pip install matplotlib"
)
fig, ax = plt.subplots(figsize=figsize)
cone = 1.5 * self.h_ef
r_bc = self._bolt_circle_radius
# ── Display extents ──────────────────────────────────────────────────
base = r_bc + cone if r_bc else max(
cone,
(self.n_x - 1) * self.s_x / 2 + cone,
(self.n_y - 1) * self.s_y / 2 + cone,
)
c1 = self.c_a1 if math.isfinite(self.c_a1) else base
c2 = self.c_a2 if math.isfinite(self.c_a2) else base
far = max(base, c1) # extent on the "far" (non-edge) side of the group
# ── Concrete boundary ────────────────────────────────────────────────
conc = mpatches.Rectangle(
(-c1, -c2), c1 + far, 2 * c2,
linewidth=2, edgecolor="#555555", facecolor="#e8e8e8", zorder=1,
)
ax.add_patch(conc)
# ── Bolt locations ───────────────────────────────────────────────────
bolt_r = max(self.d_a / 2, cone * 0.03) # visible even for tiny diameters
if r_bc is not None:
for k in range(self.n):
theta = 2 * math.pi * k / self.n
bx, by = r_bc * math.cos(theta), r_bc * math.sin(theta)
ax.add_patch(mpatches.Circle((bx, by), bolt_r,
color="steelblue", zorder=5))
# Bolt circle reference (thin dashed)
ax.add_patch(mpatches.Circle((0, 0), r_bc, fill=False,
linestyle=":", edgecolor="steelblue",
linewidth=0.8, zorder=2))
else:
for i in range(self.n_x):
for j in range(self.n_y):
bx = (i - (self.n_x - 1) / 2.0) * self.s_x
by = (j - (self.n_y - 1) / 2.0) * self.s_y
ax.add_patch(mpatches.Circle((bx, by), bolt_r,
color="steelblue", zorder=5))
# ── Tension breakout zone (1.5·h_ef) ────────────────────────────────
if r_bc is not None:
ext = min(self.c_a_min if math.isfinite(self.c_a_min) else cone, cone)
R_out = r_bc + ext
R_in = max(0.0, r_bc - cone)
ax.add_patch(mpatches.Circle((0, 0), R_out, fill=False,
linestyle="--", edgecolor="crimson",
linewidth=1.5, zorder=3))
if R_in > 0:
ax.add_patch(mpatches.Circle((0, 0), R_in, fill=False,
linestyle="--", edgecolor="crimson",
linewidth=1.5, zorder=3))
else:
ext = min(self.c_a_min if math.isfinite(self.c_a_min) else cone, cone)
xh = (self.n_x - 1) * self.s_x / 2.0 + ext
yh = (self.n_y - 1) * self.s_y / 2.0 + ext
ax.add_patch(mpatches.Rectangle(
(-xh, -yh), 2 * xh, 2 * yh,
fill=False, linestyle="--", edgecolor="crimson",
linewidth=1.5, zorder=3,
))
# ── Near-edge lines and labels ───────────────────────────────────────
if math.isfinite(self.c_a1):
ax.axvline(x=-c1, color="#333333", linewidth=2.0, zorder=4)
ax.text(-c1 - cone * 0.05, -c2 * 0.85,
f'c_a1 = {self.c_a1:.1f}"',
ha="right", fontsize=8, color="#333333")
if math.isfinite(self.c_a2):
for sign in (+1, -1):
ax.axhline(y=sign * c2, color="#333333", linewidth=2.0, zorder=4)
ax.text(far * 0.7, c2 + cone * 0.05,
f'c_a2 = {self.c_a2:.1f}"', fontsize=8, color="#333333")
# ── Shear demand arrow (→) ───────────────────────────────────────────
if self.V_ua > 0:
arrow_len = far * 0.35
ax.annotate(
"", xy=(arrow_len, 0), xytext=(0, 0),
arrowprops=dict(arrowstyle="->", color="darkorange",
lw=2.2, mutation_scale=16),
zorder=6,
)
ax.text(arrow_len + cone * 0.05, bolt_r * 2.5,
f"Vu = {self.V_ua / 1000:.1f} k",
color="darkorange", fontsize=8)
# ── Tension demand arrow (↑ out of plane, shown as ⊕ label) ─────────
if self.N_ua > 0:
ax.text(0, 0, "⊕", ha="center", va="center",
fontsize=14, color="green", fontweight="bold", zorder=7)
ax.text(bolt_r * 3, c2 * 0.5,
f"Nu = {self.N_ua / 1000:.1f} k",
color="green", fontsize=8)
# ── Axis / legend ────────────────────────────────────────────────────
pad = cone * 0.25
ax.set_xlim(-c1 - pad, far + pad)
ax.set_ylim(-c2 - pad, c2 + pad)
ax.set_aspect("equal")
ax.set_xlabel('x — shear direction (in)')
ax.set_ylabel('y — perpendicular (in)')
ax.set_title(title or repr(self))
ax.grid(True, alpha=0.3)
legend_handles = [
mpatches.Patch(facecolor="#e8e8e8", edgecolor="#555555", label="Concrete"),
mpatches.Patch(facecolor="steelblue", label="Anchor rod"),
mpatches.Patch(fill=False, edgecolor="crimson", linestyle="--",
label=f'1.5·h_ef = {cone:.1f}"'),
]
ax.legend(handles=legend_handles, loc="upper left", fontsize=8)
fig.tight_layout()
return fig, ax
[docs]
@classmethod
def from_dxf(
cls,
path: str,
f_c: float,
h_a: float,
h_ef: float,
f_ya: float,
f_uta: float,
bolts_layer: str = "BOLTS",
concrete_layer: str = "CONCRETE",
**kwargs,
) -> "AnchorBolts":
"""
Build an AnchorBolts instance by parsing geometry from a DXF file.
Reads two layers from the DXF modelspace:
* ``bolts_layer`` (default ``"BOLTS"``) — CIRCLE entities whose centres
define bolt locations and whose radius gives anchor_radius = d_a / 2.
* ``concrete_layer`` (default ``"CONCRETE"``) — one LWPOLYLINE (or
POLYLINE) whose vertices define the concrete boundary. Edge distances
c_a1 (shear direction) and c_a2 (perpendicular) are derived from the
minimum distances from the bolt-group centroid to the boundary edges,
classified by edge orientation.
Bolt pattern detection:
* **Circular** — all bolts lie within 10 % of the same radius from the
centroid. Delegates to ``from_circular()``.
* **Rectangular** — grid spacing is inferred from the sorted unique
x/y coordinates of the bolt positions.
Parameters
----------
path : str
Absolute or relative path to the ``.dxf`` file.
f_c, h_a, h_ef, f_ya, f_uta : float
Material and geometry properties not derivable from CAD.
bolts_layer, concrete_layer : str
Layer names to query (case-sensitive, DXF convention is UPPERCASE).
**kwargs
Additional ``AnchorBolts`` keyword arguments (N_ua, V_ua, sdc, …).
Returns
-------
AnchorBolts
"""
try:
import ezdxf
except ImportError:
raise ImportError(
"ezdxf is required for from_dxf(). Install with: pip install ezdxf"
)
doc = ezdxf.readfile(path)
msp = doc.modelspace()
# ── Extract bolt positions (CIRCLE entities) ─────────────────────────
bolt_centers: list = []
bolt_radius: Optional[float] = None
for ent in msp.query(f'CIRCLE[layer=="{bolts_layer}"]'):
bolt_centers.append((ent.dxf.center.x, ent.dxf.center.y))
if bolt_radius is None:
bolt_radius = ent.dxf.radius
if not bolt_centers:
raise ValueError(
f"No CIRCLE entities found on layer '{bolts_layer}' in {path!r}"
)
n = len(bolt_centers)
d_a = (bolt_radius or 0.5) * 2.0
# Centroid of bolt group
cx = sum(p[0] for p in bolt_centers) / n
cy = sum(p[1] for p in bolt_centers) / n
rel = [(x - cx, y - cy) for x, y in bolt_centers]
# ── Detect circular vs rectangular ───────────────────────────────────
radii = [math.hypot(x, y) for x, y in rel]
r_mean = sum(radii) / len(radii) if radii else 0.0
r_std = math.sqrt(sum((r - r_mean) ** 2 for r in radii) / len(radii)) \
if len(radii) > 1 else 0.0
is_circular = (r_mean > 1e-6) and (r_std < 0.10 * r_mean)
# ── Extract concrete boundary (LWPOLYLINE) ───────────────────────────
c_a1 = float("inf")
c_a2 = float("inf")
for ent in msp.query(f'LWPOLYLINE[layer=="{concrete_layer}"]'):
pts = [(v[0], v[1]) for v in ent.get_points()]
if len(pts) < 3:
continue
for i in range(len(pts)):
p1 = pts[i]
p2 = pts[(i + 1) % len(pts)]
dx, dy = p2[0] - p1[0], p2[1] - p1[1]
seg_len = math.hypot(dx, dy)
if seg_len < 1e-9:
continue
t = max(0.0, min(1.0, ((cx - p1[0]) * dx + (cy - p1[1]) * dy)
/ seg_len ** 2))
px, py = p1[0] + t * dx, p1[1] + t * dy
dist = math.hypot(cx - px, cy - py)
# Classify by edge orientation: horizontal edge → c_a2 (y-normal)
# vertical edge → c_a1 (x-normal)
if abs(dx) > abs(dy):
c_a2 = min(c_a2, dist)
else:
c_a1 = min(c_a1, dist)
# ── Build instance ───────────────────────────────────────────────────
if is_circular:
return cls.from_circular(
n_bolts=n,
bolt_circle_radius=r_mean,
f_c=f_c, h_a=h_a, d_a=d_a,
h_ef=h_ef, f_ya=f_ya, f_uta=f_uta,
c_a1=c_a1, c_a2=c_a2,
**kwargs,
)
# Rectangular: reconstruct grid from sorted unique positions
ROUND = 2 # decimal places for deduplication
xs = sorted({round(x, ROUND) for x, _ in rel})
ys = sorted({round(y, ROUND) for _, y in rel})
n_x = len(xs)
n_y = len(ys)
s_x = (xs[-1] - xs[0]) / (n_x - 1) if n_x > 1 else 0.0
s_y = (ys[-1] - ys[0]) / (n_y - 1) if n_y > 1 else 0.0
return cls(
f_c=f_c, h_a=h_a, d_a=d_a, h_ef=h_ef,
f_ya=f_ya, f_uta=f_uta,
n_x=n_x, n_y=n_y, s_x=s_x, s_y=s_y,
c_a1=c_a1, c_a2=c_a2,
**kwargs,
)
def __repr__(self) -> str:
return (
f"AnchorBolts(n={self.n}, d_a={self.d_a}in, h_ef={self.h_ef}in, "
f"f_c={self.f_c:.0f}psi, f_uta={self.f_uta:.0f}psi)"
)
# ──────────────────────────────────────────────────────────────────────────────
# ACI 318-19 Section 17.11 — Shear Lug Checks
# ──────────────────────────────────────────────────────────────────────────────
[docs]
class ShearLugCheck:
"""
ACI 318-19 Section 17.11 shear lug design checks.
Shear lugs transfer in-plane shear by bearing against concrete rather than
through anchor rod bending. Two limit states are checked:
1. Bearing strength of lug (17.11.2.1):
V_brg,sl = n_sl × 1.7 × λ_a × f_c × A_ef,sl
2. Concrete breakout of lug (17.11.3.2.1):
V_b,sl = 3.5 × λ_a × √f_c × A_ef,sl^0.5 × h_sl^1.5
Full breakout: V_cb,sl = (A_Vc,sl / A_Vco,sl) × psi_ed × V_b,sl
All lengths in inches, forces in pounds (lb), stresses in psi.
Parameters
----------
f_c : float
Specified concrete compressive strength (psi), capped at 10,000 psi.
h_a : float
Member thickness in the direction of applied shear (in).
n_sl : int
Number of shear lugs.
A_ef_sl : float
Effective bearing area of a single shear lug (in²). This is the
projected bearing face area perpendicular to applied shear, excluding
any grout pocket area.
h_sl : float
Height of shear lug above the concrete or grout surface (in).
c_a1 : float
Edge distance from the lug to the nearest free edge in the direction
of applied shear (in). Use float('inf') if no near edge.
c_a2 : float
Edge distance perpendicular to applied shear (in).
Use float('inf') if no near edge.
lambda_a : float
Lightweight concrete factor (1.0 for normalweight).
has_supp_reinf : bool
True if supplementary reinforcement is provided (Condition A, φ=0.75).
V_ua : float
Factored shear demand on the lug group (lb).
sdc : str
Seismic Design Category; applies 0.75 seismic reduction for SDC C–F.
"""
def __init__(
self,
f_c: float,
h_a: float,
n_sl: int,
A_ef_sl: float,
h_sl: float,
c_a1: float = float("inf"),
c_a2: float = float("inf"),
lambda_a: float = 1.0,
has_supp_reinf: bool = False,
V_ua: float = 0.0,
sdc: str = "A",
):
self.f_c = min(f_c, 10_000)
self.h_a = h_a
self.n_sl = max(1, n_sl)
self.A_ef_sl = A_ef_sl
self.h_sl = h_sl
self.c_a1 = c_a1
self.c_a2 = c_a2
self.lambda_a = lambda_a
self.has_supp_reinf = has_supp_reinf
self.V_ua = V_ua
self.sdc = sdc.upper()
self.phi = 0.75 if has_supp_reinf else 0.70
@property
def _phi_seismic(self) -> float:
return 0.75 if self.sdc in ("C", "D", "E", "F") else 1.0
[docs]
def bearing_strength(self) -> AnchorCheckResult:
"""
Bearing strength of shear lugs — ACI 318-19 Eq. 17.11.2.1.
V_brg,sl = n_sl × 1.7 × λ_a × f_c × A_ef,sl
"""
V_brg = self.n_sl * 1.7 * self.lambda_a * self.f_c * self.A_ef_sl
phi_Vn = self.phi * self._phi_seismic * V_brg
return AnchorCheckResult(
label="Shear lug bearing",
reference="ACI 318-19 Eq. 17.11.2.1",
N_n=0.0,
V_n=V_brg,
phi=self.phi,
phi_Sn=phi_Vn,
demand=self.V_ua,
)
[docs]
def breakout_strength(self) -> Optional[AnchorCheckResult]:
"""
Concrete breakout strength of shear lug — ACI 318-19 Section 17.11.3.
V_b,sl = 3.5 × λ_a × √f_c × A_ef,sl^0.5 × h_sl^1.5 (Eq. 17.11.3.2.1)
Returns None when c_a1 is infinite (no near edge).
"""
if not math.isfinite(self.c_a1):
return None
# Basic breakout strength (Eq. 17.11.3.2.1)
V_b_sl = (3.5 * self.lambda_a * math.sqrt(self.f_c)
* math.sqrt(self.A_ef_sl) * self.h_sl ** 1.5)
# Reference area (single lug, deep member) — same form as anchor
A_Vco_sl = 4.5 * self.c_a1 ** 2
# Projected area
height = min(1.5 * self.c_a1, self.h_a)
c_a2_eff = min(self.c_a2, 1.5 * self.c_a1) if math.isfinite(self.c_a2) else 1.5 * self.c_a1
A_Vc_sl = height * 2.0 * c_a2_eff
# Edge factor
if math.isfinite(self.c_a2) and self.c_a2 < 1.5 * self.c_a1:
psi_ed = 0.7 + 0.3 * self.c_a2 / (1.5 * self.c_a1)
else:
psi_ed = 1.0
V_cb_sl = (A_Vc_sl / A_Vco_sl) * psi_ed * V_b_sl
phi_Vn = self.phi * self._phi_seismic * V_cb_sl
return AnchorCheckResult(
label="Shear lug breakout",
reference="ACI 318-19 Eq. 17.11.3.2.1",
N_n=0.0,
V_n=V_cb_sl,
phi=self.phi,
phi_Sn=phi_Vn,
demand=self.V_ua,
)
[docs]
def check_all(self) -> dict:
"""Run bearing and breakout checks; return dict of label → AnchorCheckResult."""
results = {}
for r in [self.bearing_strength(), self.breakout_strength()]:
if r is not None:
results[r.label] = r
return results
[docs]
def summary(self) -> str:
"""Formatted table of shear lug limit-state checks."""
header = f"{'Limit State':<25} {'Ref':<32} {'φVn (kip)':>10} {'Demand':>10} {'DCR':>7} {'Status':>6}"
sep = "-" * len(header)
lines = [sep, header, sep]
for r in self.check_all().values():
lines.append(
f"{r.label:<25} {r.reference:<32} "
f"{r.phi_Sn/1000:>10.2f} {r.demand/1000:>10.2f} "
f"{r.dcr:>7.3f} {'OK' if r.ok else 'NG':>6}"
)
lines.append(sep)
return "\n".join(lines)
def __repr__(self) -> str:
return (
f"ShearLugCheck(n_sl={self.n_sl}, A_ef={self.A_ef_sl}in², "
f"h_sl={self.h_sl}in, f_c={self.f_c:.0f}psi)"
)