Add structural calculation worksheets

Collection of engineering calculation projects (Python + Typst), each with
input, calc script, tests, results, and generated PDF where available.
This commit is contained in:
smillmorel 2026-09-21 12:19:20 -04:00
commit d5ac3fca7e
81 changed files with 18668 additions and 0 deletions

BIN
wood-stud-column/assets/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View file

@ -0,0 +1,82 @@
#let navy = rgb("#1a3a5f")
#let muted = rgb("#626b73")
#let pass = rgb("#1f6b45")
#let fail = rgb("#9b2c2c")
#let calcsheet(
title: "Structural Calculation",
project: "",
prepared-by: "",
body,
) = {
set document(title: title, author: prepared-by)
set page(
paper: "us-letter",
margin: (x: 1in, top: 1.25in, bottom: 1in),
header: context {
grid(
columns: (1fr, 1fr),
align: (left, right),
image("../assets/logo.png", height: 30pt),
[#text(size: 9pt)[Project:] \
#text(size: 10pt, weight: "bold")[#project]],
)
},
footer: context {
set text(size: 8.5pt, fill: muted)
stack(
spacing: 4pt,
line(length: 100%, stroke: 0.5pt + muted),
[#prepared-by],
)
},
)
set text(font: "Libertinus Serif", size: 10pt, lang: "en")
set par(justify: true)
set heading(numbering: none)
show heading.where(level: 1): set text(size: 14pt, weight: "bold", fill: black)
show heading.where(level: 2): set text(size: 11pt, weight: "bold", fill: black)
show heading.where(level: 2): set block(above: 2em, below: 1em)
body
}
#let calcline(formula, note) = grid(
columns: (1.7fr, 1fr),
gutter: 4pt,
align: (left, left),
formula, text(size: 9pt, fill: muted, note),
)
#let check(label, demand, capacity, unit: "", ok: auto, demand-label: "Demand", capacity-label: "Capacity") = {
let utilization = demand / capacity
let passes = if ok == auto { utilization <= 1 } else { ok }
let color = if passes { pass } else { fail }
block(
breakable: false,
width: 100%,
stroke: 0.8pt + black,
inset: 8pt,
radius: 2pt,
)[
#grid(
columns: (1fr, auto),
[#text(weight: "bold")[#label]],
box(stroke: 0.8pt + color, inset: (x: 6pt, y: 2pt))[
#text(weight: "bold", fill: color)[#if passes { "OK" } else { "NOT OK" }]
],
)
#v(4pt)
#grid(
columns: (1fr, auto),
[
#demand-label: #calc.round(demand, digits: 2) #unit #h(14pt)
#capacity-label: #calc.round(capacity, digits: 2) #unit
],
[
D/C: #calc.round(utilization, digits: 2)
],
)
]
}

163
wood-stud-column/calc.py Normal file
View file

@ -0,0 +1,163 @@
from __future__ import annotations
import json
import math
import sys
import argparse
from pathlib import Path
try:
import yaml
except ImportError:
raise SystemExit("Install dependencies: python -m pip install -r requirements.txt")
try:
from pint import DimensionalityError, UndefinedUnitError, UnitRegistry
except ImportError:
raise SystemExit("Install dependencies: python -m pip install -r requirements.txt")
HERE = Path(__file__).resolve().parent
ureg = UnitRegistry()
ureg.define("kip = 1000 * force_pound")
ureg.define("ksi = kip / inch ** 2")
ureg.define("psi = force_pound / inch ** 2")
def quantity(value, unit: str, name: str) -> float:
try:
q = ureg.Quantity(value).to(unit)
except (DimensionalityError, UndefinedUnitError, TypeError, ValueError) as exc:
raise ValueError(f"{name}: expected {unit}, got {value!r}") from exc
magnitude = float(q.magnitude)
if magnitude <= 0:
raise ValueError(f"{name} must be positive")
return magnitude
def factor(value, name: str) -> float:
magnitude = float(value)
if magnitude <= 0:
raise ValueError(f"{name} must be positive")
return magnitude
# NDS 2018 constants for sawn-lumber columns (NDS 3.7.1.5, Eqs. 3.7-1 and 3.7-2).
KC_EULER = 0.822 # Euler buckling coefficient for a rectangular section about its
# weak axis (pi^2 / 12); appears directly in Eq. 3.7-2.
C_SAWN = 0.8 # Column-stability denominator coefficient c for sawn lumber
# (Eq. 3.7-1). c = 0.85 for round timber, 0.9 for glulam/SCL/CLT.
def _column_stability_factor(Fc_star: float, Emin_prime: float, Le: float, d: float):
"""NDS 3.7.1.5, Eq. 3.7-1 — column stability factor Cp for one buckling axis."""
FcE = KC_EULER * Emin_prime / (Le / d) ** 2
ratio = FcE / Fc_star
Cp = (1.0 + ratio) / (2.0 * C_SAWN) - math.sqrt(((1.0 + ratio) / (2.0 * C_SAWN)) ** 2 - ratio / C_SAWN)
if not math.isfinite(Cp) or Cp <= 0:
raise ValueError(f"Cp = {Cp:.4f} is not finite/positive; column stability invalid (NDS 3.7.1.4)")
return min(Cp, 1.0), FcE, ratio
def compute(inp: dict) -> dict:
# Geometry
b_in = quantity(inp["width"], "in", "width")
d_in = quantity(inp["depth"], "in", "depth")
L_in = quantity(inp["length"], "in", "length")
Ke = factor(inp["Ke"], "Ke")
# Material (NDS Supplement reference values)
Fc_psi = quantity(inp["Fc"], "psi", "Fc")
E_psi = quantity(inp["E"], "psi", "E")
Emin_psi = quantity(inp["E_min"], "psi", "E_min")
species_grade = str(inp["species_grade"])
# Adjustment factors (NDS Table 4.3.1)
CD = factor(inp["CD"], "CD")
CM = factor(inp["CM"], "CM")
Ct = factor(inp["Ct"], "Ct")
CF = factor(inp["CF"], "CF")
Ci = factor(inp["Ci"], "Ci")
# Checked axial demand (ASD)
P_lbf = quantity(inp["P"], "lbf", "P")
A_in2 = b_in * d_in
Le_in = Ke * L_in
d_least = min(b_in, d_in)
d_great = max(b_in, d_in)
# Adjusted reference compression (without Cp), NDS 3.7.1.5
Fc_star = Fc_psi * CD * CM * Ct * CF * Ci
Emin_prime = Emin_psi * CM * Ct * Ci
# Column stability factor about each axis; the weak axis governs.
Cp_weak, FcE_weak, ratio_weak = _column_stability_factor(Fc_star, Emin_prime, Le_in, d_least)
Cp_strong, FcE_strong, ratio_strong = _column_stability_factor(Fc_star, Emin_prime, Le_in, d_great)
governing_axis = "weak" if Cp_weak <= Cp_strong else "strong"
Cp = min(Cp_weak, Cp_strong)
FcE = FcE_weak if governing_axis == "weak" else FcE_strong
# Adjusted compression design value, NDS Table 4.3.1
Fc_prime = Fc_star * Cp
fc_psi = P_lbf / A_in2
Pa_lbf = Fc_prime * A_in2
def q(value: float) -> float:
return round(value, 6)
values = {
"P_lbf": q(P_lbf),
"L_in": q(L_in), "b_in": q(b_in), "d_in": q(d_in),
"A_in2": q(A_in2), "Ke": q(Ke), "Le_in": q(Le_in),
"d_least_in": q(d_least), "d_great_in": q(d_great),
"Le_d_weak": q(Le_in / d_least), "Le_d_strong": q(Le_in / d_great),
"species_grade": species_grade,
"Fc_psi": q(Fc_psi), "E_psi": q(E_psi), "Emin_psi": q(Emin_psi),
"CD": q(CD), "CM": q(CM), "Ct": q(Ct), "CF": q(CF), "Ci": q(Ci),
"kc_euler": q(KC_EULER), "c_sawn": q(C_SAWN),
"Fc_star_psi": q(Fc_star), "Emin_prime_psi": q(Emin_prime),
"FcE_weak_psi": q(FcE_weak), "FcE_strong_psi": q(FcE_strong),
"ratio_weak": q(ratio_weak), "ratio_strong": q(ratio_strong),
"Cp_weak": q(Cp_weak), "Cp_strong": q(Cp_strong),
"Cp": q(Cp), "governing_axis": governing_axis,
"Fc_prime_psi": q(Fc_prime), "fc_psi": q(fc_psi), "Pa_lbf": q(Pa_lbf),
}
checks = {
"axial": {"demand": q(P_lbf), "capacity": q(Pa_lbf), "ok": P_lbf <= Pa_lbf},
}
return {
"tool": "wood_stud_column",
"version": "0.1",
"project": inp.get("project", ""),
"prepared_by": inp.get("prepared_by", ""),
"values": values,
"checks": checks,
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Calculate the wood stud column axial capacity from a YAML input file.")
parser.add_argument("--input", "-i", type=Path, default=HERE / "input.yaml", help="YAML input path")
parser.add_argument("--output", "-o", type=Path, help="JSON output path; defaults beside the input")
parser.add_argument("--stdout", action="store_true", help="Write the complete JSON result to stdout instead of a file")
args = parser.parse_args(argv)
input_path = args.input
output_path = args.output or input_path.with_name("results.json")
with input_path.open(encoding="utf-8") as handle:
result = compute(yaml.safe_load(handle))
serialized = json.dumps(result, indent=2) + "\n"
if args.stdout:
sys.stdout.write(serialized)
else:
output_path.write_text(serialized, encoding="utf-8")
print(output_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,29 @@
# Project name shown in the Typst report header.
project: "Interior Bearing Wall"
# Person or organization shown in the Typst report footer.
prepared_by: "Conemco Engineering"
# Axial load (ASD) checked against the allowable column capacity.
P: "400 lbf"
# Stud geometry (actual dimensions of a nominal 2x4).
length: "6 ft" # L, total stud length
width: "1.5 in" # b, actual 2x4 width
depth: "3.5 in" # d, actual 2x4 depth
Ke: 1.0 # effective-length factor, pinned-pinned (NDS 3.7.1.2 / Appendix G)
# Species and grade label shown in the report. "SP" is read as Southern Pine
# (SYP) per NDS Supplement Table 4B; confirm if SPF or SPF-S was intended.
species_grade: "SP (Southern Pine) No. 2"
# NDS Supplement reference design values for Southern Pine (SYP) No. 2, dimension lumber.
Fc: "1450 psi" # compression parallel to grain (F_c)
E: "1400000 psi" # modulus of elasticity (E)
E_min: "510000 psi" # 5th-percentile modulus (E_min)
# Adjustment factors (NDS Table 4.3.1). All 1.0 except size factor for this example.
CD: 1 # load duration factor (1.0 = normal/occupancy duration, NDS 2.3.2)
CM: 1 # wet service factor (dry, moisture < 19%, NDS 4.3.3)
Ct: 1 # temperature factor (< 100 deg F, NDS 4.3.4)
CF: 1.15 # size factor for compression, 2x4 (NDS 4.3.6)
Ci: 1 # incising factor (NDS 4.3.8)

View file

@ -0,0 +1,50 @@
{
"tool": "wood_stud_column",
"version": "0.1",
"project": "Interior Bearing Wall",
"prepared_by": "Conemco Engineering",
"values": {
"P_lbf": 400.0,
"L_in": 72.0,
"b_in": 1.5,
"d_in": 3.5,
"A_in2": 5.25,
"Ke": 1.0,
"Le_in": 72.0,
"d_least_in": 1.5,
"d_great_in": 3.5,
"Le_d_weak": 48.0,
"Le_d_strong": 20.571429,
"species_grade": "SP (Southern Pine) No. 2",
"Fc_psi": 1450.0,
"E_psi": 1400000.0,
"Emin_psi": 510000.0,
"CD": 1.0,
"CM": 1.0,
"Ct": 1.0,
"CF": 1.15,
"Ci": 1.0,
"kc_euler": 0.822,
"c_sawn": 0.8,
"Fc_star_psi": 1667.5,
"Emin_prime_psi": 510000.0,
"FcE_weak_psi": 181.953125,
"FcE_strong_psi": 990.633681,
"ratio_weak": 0.109117,
"ratio_strong": 0.594083,
"Cp_weak": 0.106575,
"Cp_strong": 0.496288,
"Cp": 0.106575,
"governing_axis": "weak",
"Fc_prime_psi": 177.71332,
"fc_psi": 76.190476,
"Pa_lbf": 932.994931
},
"checks": {
"axial": {
"demand": 400.0,
"capacity": 932.994931,
"ok": true
}
}
}

View file

@ -0,0 +1,65 @@
from pathlib import Path
import importlib.util
import json
import subprocess
import sys
import pytest
import yaml
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("wood_stud_column_calc", HERE / "calc.py")
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
def result():
with (HERE / "input.yaml").open(encoding="utf-8") as handle:
return module.compute(yaml.safe_load(handle))
def test_typst_loads_match_checked_inputs():
subprocess.run([sys.executable, str(HERE / "calc.py")], check=True, cwd=HERE.parents[1])
query = subprocess.run(
[
"typst", "eval", "query(<wood-stud-column-loads>)", "--root", ".",
"--in", "calcs/wood-stud-column/beam.typ", "--format", "json",
],
check=True,
capture_output=True,
text=True,
cwd=HERE.parents[1],
)
published = json.loads(query.stdout)
assert len(published) == 1
derived = published[0]["value"]
values = result()["values"]
assert derived["P_lbf"] == pytest.approx(values["P_lbf"], abs=0.001)
assert derived["A_in2"] == pytest.approx(values["A_in2"], abs=0.001)
def test_ground_truth_column_stability():
output = result()
values = output["values"]
assert values["A_in2"] == pytest.approx(5.25)
assert values["Le_in"] == pytest.approx(72.0)
assert values["Le_d_weak"] == pytest.approx(48.0, abs=0.01)
assert values["FcE_weak_psi"] == pytest.approx(181.9, abs=0.5)
assert values["Cp_weak"] == pytest.approx(0.107, abs=0.003)
assert values["Cp_strong"] == pytest.approx(0.497, abs=0.005)
assert values["Fc_prime_psi"] == pytest.approx(177.6, abs=0.5)
assert values["Pa_lbf"] == pytest.approx(932.0, abs=3.0)
assert output["checks"]["axial"]["ok"] is True
def test_cli_can_write_json_to_stdout():
completed = subprocess.run(
[sys.executable, str(HERE / "calc.py"), "--input", str(HERE / "input.yaml"), "--stdout"],
check=True,
capture_output=True,
text=True,
)
output = json.loads(completed.stdout)
assert output["values"]["P_lbf"] == pytest.approx(400)
assert completed.stderr == ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
#import "assets/sheet.typ": calcline, calcsheet, check
#let data = json("results.json")
#let n = data.values
#let checks = data.checks
#let round(value, digits: 2) = calc.round(value, digits: digits)
#show: calcsheet.with(
title: "Wood Stud Column — Axial Capacity",
project: data.project,
prepared-by: data.prepared_by,
)
= Wood Stud Column — Axial Capacity
Concentrically loaded 2x4 wood stud (SP / Southern Pine No. 2) analyzed for
axial compression parallel to grain per NDS 2018 ASD. Pint parses `input.yaml`;
`calc.py` computes the column stability factor $C_p$ (NDS 3.7.1.5, Eq. 3.7-1)
and the adjusted compression capacity. The governing limit state is compression
with buckling about the weak axis.
== Geometry And Axial Load
#let P = 400
#let L_ft = 6
#let b = 1.5
#let d = 3.5
#let A = b * d
#metadata((P_lbf: P, A_in2: A)) <wood-stud-column-loads>
#calcline([$P = #P " lbf"$], [Axial load, compression (ASD)])
#calcline([$L = #L_ft " ft" = #n.L_in " in"$], [Total stud length])
#calcline([$b = #b " in"$, $d = #d " in"$], [Actual dimensions of a nominal 2x4])
#calcline([$A = b d = #round(n.A_in2, digits: 2) " in"^2$], [Cross-sectional area])
#calcline([$K_e = #n.Ke$], [Effective-length factor, pinned-pinned (NDS 3.7.1.2)])
#calcline([$L_e = K_e L = #round(n.Le_in, digits: 1) " in"$], [Effective length])
#calcline([$d_"min" = min(b, d) = #n.d_least_in " in"$], [Least dimension; weak-axis buckling governs])
#calcline([$L_e / d_"min" = #round(n.Le_in / n.d_least_in, digits: 1) <= 50$], [Slenderness ratio, weak axis; cap $L_e/d <= 50$ (NDS 3.7.1.4)])
== Material And Adjustment Factors
#calcline([$"Species/grade" = #n.species_grade$], [NDS Supplement reference values])
#calcline([$F_c = #n.Fc_psi " psi"$], [Reference compression parallel to grain])
#calcline([$E = #n.E_psi " psi"$, $E'_"min" = #n.Emin_psi " psi"$], [Moduli of elasticity])
#calcline([$C_D = #n.CD$, $C_M = #n.CM$, $C_t = #n.Ct$, $C_F = #n.CF$, $C_i = #n.Ci$], [Load duration ($C_D=1.0$ normal/occupancy, NDS 2.3.2), wet service, temperature, size ($C_F=1.15$ for 2x4 compression, NDS 4.3.6), incising (NDS Table 4.3.1)])
== Column Stability Factor (NDS 3.7.1.4)
#calcline([$F^*_c = F_c C_D C_M C_t C_F C_i = #round(n.Fc_star_psi, digits: 1) " psi"$], [Adjusted reference compression, NDS 3.7.1.5])
#calcline([$E'_"min" = E_"min" C_M C_t C_i = #round(n.Emin_prime_psi, digits: 0) " psi"$], [Adjusted 5th-percentile modulus])
#calcline([$F_("cE") = 0.822 E'_"min" / (L_e / d_"min")^2 = #round(n.FcE_weak_psi, digits: 2) " psi"$], [Critical buckling stress, NDS Eq. 3.7-2 (coefficient 0.822)])
#calcline([$0.822$ (Euler coeff., NDS Eq. 3.7-2), $c = #n.c_sawn$], [Sawn-lumber column coefficient, NDS Eq. 3.7-1])
#calcline([$C_p = (1 + F_("cE")/F^*_c)/(2c) - sqrt(...) = #round(n.Cp, digits: 4)$], [Column stability factor, NDS Eq. 3.7-1 (3.7.1.5); weak axis governs])
== Adjusted Compression Capacity
#calcline([$F'_c = F^*_c C_p = #round(n.Fc_prime_psi, digits: 2) " psi"$], [Adjusted compression design value, NDS 3.7.1.5])
#calcline([$f_c = P / A = #round(n.fc_psi, digits: 2) " psi"$], [Acting compression stress])
#calcline([$P_a = F'_c A = #round(n.Pa_lbf, digits: 1) " lbf"$], [Allowable axial load])
#v(7pt)
#check("Axial Compression", checks.axial.demand, checks.axial.capacity, unit: "lbf", ok: checks.axial.ok, demand-label: [$P$], capacity-label: [$P_a$])