Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""Elastic Frame Example — OpenSees Examples Manual, Example 4.
|
||
|
||
3-story 3-bay 2D elastic moment-resisting frame under gravity
|
||
(distributed beam loads) + a lateral reference pattern (point loads
|
||
at each floor's leftmost joint) + a 5-mode eigenvalue analysis.
|
||
|
||
Matches the Tcl walkthrough at:
|
||
https://opensees.berkeley.edu/wiki/index.php?title=Elastic_Frame_Example
|
||
|
||
Model (kip-in-ksi, ndm=2, ndf=3):
|
||
|
||
Floor 3 (z = 486") 13 ──beam19── 14 ──beam20── 15 ──beam21── 16
|
||
│ │ │ │
|
||
col9 col10 col11 col12
|
||
│ │ │ │
|
||
Floor 2 (z = 324") 9 ──beam16── 10 ─beam17── 11 ──beam18── 12
|
||
│ │ │ │
|
||
col5 col6 col7 col8
|
||
│ │ │ │
|
||
Floor 1 (z = 162") 5 ──beam13── 6 ─beam14── 7 ──beam15── 8
|
||
│ │ │ │
|
||
col1 col2 col3 col4
|
||
│ │ │ │
|
||
Base (z = 0") 1 2 3 4
|
||
(fixed) (fixed) (fixed) (fixed)
|
||
x = 0 360 720 1080
|
||
|
||
Sections (AISC W-shapes):
|
||
- Exterior column (lines 1, 4): W14X257 A=75.6 Iz=3400
|
||
- Interior column (lines 2, 3): W14X311 A=91.4 Iz=4330
|
||
- Floor-1 beam: W33X118 A=34.7 Iz=5900
|
||
- Floor-2 beam: W30X116 A=34.2 Iz=4930
|
||
- Floor-3 beam: W24X68 A=20.1 Iz=1830
|
||
- E = 29000 ksi for all.
|
||
|
||
Columns use the PDelta geometric transformation to capture P-Δ; beams
|
||
use Linear. Gravity is a Constant time series with a uniform
|
||
distributed load per beam (reference tributary intensity Load / 4 /
|
||
bay — matches the Tcl ``eleLoad -type -beamUniform [expr -Load/(4*bay)]``).
|
||
The lateral pattern uses a Linear time series with single-node point
|
||
loads (220 / 180 / 90 kip at floors 1 / 2 / 3 respectively).
|
||
|
||
Expected results (verified against the Tcl reference):
|
||
- Gravity ΣFy at base: ≈ 2505 kip (Σw · Σbeam-length · 3 floors)
|
||
- Gravity+Lateral ΣFx: ≈ -490 kip
|
||
- First five periods (s): 1.0256, 0.3498, 0.1919, 0.1562, 0.1307
|
||
|
||
GUI walkthrough: File → Open → elastic_frame.osmodel → Analyze →
|
||
Run → "Gravity + Lateral" → Display → Show Force Diagram / Modal.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
from otko.core import (
|
||
ConstantTimeSeries,
|
||
CoordinateGridSystem,
|
||
ElasticBeamColumn,
|
||
ElasticSection,
|
||
GridSystem,
|
||
LinearTimeSeries,
|
||
ModalCase,
|
||
NodalLoad,
|
||
Node,
|
||
PlainLoadPattern,
|
||
Project,
|
||
ProjectMeta,
|
||
StaticCase,
|
||
UniformElementLoad,
|
||
UnitSystem,
|
||
make_grid_lines,
|
||
)
|
||
from otko.services import load_project, save_project
|
||
|
||
|
||
# Frame geometry (inches).
|
||
BAY = 360.0 # 30 ft — bay width
|
||
H_STORY = 162.0 # 13.5 ft — story height
|
||
N_BAYS = 3
|
||
N_STORIES = 3
|
||
|
||
# Material + section (kip, in, ksi).
|
||
E = 29000.0
|
||
|
||
# Column sections — exterior W14X257 vs interior W14X311.
|
||
A_COL_EXT, IZ_COL_EXT = 75.6, 3400.0
|
||
A_COL_INT, IZ_COL_INT = 91.4, 4330.0
|
||
|
||
# Beam sections — per floor.
|
||
A_BEAM_F1, IZ_BEAM_F1 = 34.7, 5900.0 # W33X118 (floor 1)
|
||
A_BEAM_F2, IZ_BEAM_F2 = 34.2, 4930.0 # W30X116 (floor 2)
|
||
A_BEAM_F3, IZ_BEAM_F3 = 20.1, 1830.0 # W24X68 (floor 3)
|
||
|
||
# Gravity loading (total weight per floor, kip).
|
||
LOAD_F1 = 1185.0
|
||
LOAD_F2 = 1185.0
|
||
LOAD_F3 = 970.0
|
||
|
||
# Lateral loading (kip, applied at each floor's leftmost joint, +X).
|
||
P_F1 = 220.0
|
||
P_F2 = 180.0
|
||
P_F3 = 90.0
|
||
|
||
# Gravity constant.
|
||
G = 386.4 # in/s²
|
||
|
||
# ─── ID layout ─────────────────────────────────────────────────────
|
||
# Nodes: row-major, starting from (x=0, y=0). 4 columns × 4 rows = 16.
|
||
# row r (0=base, 1=floor-1, 2=floor-2, 3=floor-3), col c (0..3):
|
||
# id = 1 + r*4 + c
|
||
#
|
||
# Elements:
|
||
# cols 1..12 : columns (bottom-to-top, left-to-right within each story)
|
||
# cols 13..21 : beams (bottom-to-top, left-to-right within each floor)
|
||
#
|
||
def _node_id(row: int, col: int) -> int:
|
||
return 1 + row * (N_BAYS + 1) + col
|
||
|
||
|
||
def _col_id(story: int, col: int) -> int:
|
||
# Story 1..3, col 0..3.
|
||
return (story - 1) * (N_BAYS + 1) + col + 1
|
||
|
||
|
||
def _beam_id(floor: int, bay: int) -> int:
|
||
# Floor 1..3, bay 0..(N_BAYS-1).
|
||
n_cols_total = N_STORIES * (N_BAYS + 1) # 12
|
||
return n_cols_total + (floor - 1) * N_BAYS + bay + 1
|
||
|
||
|
||
def build_elastic_frame() -> Project:
|
||
nodes: list[Node] = []
|
||
m_floor = {
|
||
1: LOAD_F1 / ((N_BAYS + 1) * G), # mass per node at floor 1
|
||
2: LOAD_F2 / ((N_BAYS + 1) * G),
|
||
3: LOAD_F3 / ((N_BAYS + 1) * G),
|
||
}
|
||
for r in range(N_STORIES + 1):
|
||
for c in range(N_BAYS + 1):
|
||
nid = _node_id(r, c)
|
||
x = c * BAY
|
||
y = r * H_STORY
|
||
if r == 0:
|
||
# Base nodes: fix tx, ty, rz (the only active DOFs in ndm=2 ndf=3).
|
||
restraint = (True, True, False, False, False, True)
|
||
mass = (0.0,) * 6
|
||
else:
|
||
# Floor nodes: all 6 slots free. The runner's dof_idx
|
||
# picks only (tx, ty, rz) = (0, 1, 5) out of this tuple
|
||
# when emitting 2D. A stray True at index 5 would fix Rz
|
||
# at every floor node and make the frame act rigid-joint.
|
||
restraint = (False,) * 6
|
||
m = m_floor[r]
|
||
mass = (m, m, 0.0, 0.0, 0.0, 0.0)
|
||
nodes.append(Node(
|
||
id=nid, name=f"N{nid}",
|
||
coords=(x, y, 0.0),
|
||
restraint=restraint, mass=mass,
|
||
))
|
||
|
||
# Sections: exterior col, interior col, beam-F1, beam-F2, beam-F3.
|
||
sections = [
|
||
ElasticSection(id=1, name="W14X257-ColExt",
|
||
E=E, A=A_COL_EXT, Iz=IZ_COL_EXT,
|
||
Iy=IZ_COL_EXT, G=11200.0, J=1.0),
|
||
ElasticSection(id=2, name="W14X311-ColInt",
|
||
E=E, A=A_COL_INT, Iz=IZ_COL_INT,
|
||
Iy=IZ_COL_INT, G=11200.0, J=1.0),
|
||
ElasticSection(id=3, name="W33X118-Beam1",
|
||
E=E, A=A_BEAM_F1, Iz=IZ_BEAM_F1,
|
||
Iy=IZ_BEAM_F1, G=11200.0, J=1.0),
|
||
ElasticSection(id=4, name="W30X116-Beam2",
|
||
E=E, A=A_BEAM_F2, Iz=IZ_BEAM_F2,
|
||
Iy=IZ_BEAM_F2, G=11200.0, J=1.0),
|
||
ElasticSection(id=5, name="W24X68-Beam3",
|
||
E=E, A=A_BEAM_F3, Iz=IZ_BEAM_F3,
|
||
Iy=IZ_BEAM_F3, G=11200.0, J=1.0),
|
||
]
|
||
|
||
# Elements — 12 columns (PDelta) + 9 beams (Linear).
|
||
elements: list[ElasticBeamColumn] = []
|
||
for s in range(1, N_STORIES + 1):
|
||
for c in range(N_BAYS + 1):
|
||
sec_id = 1 if c in (0, N_BAYS) else 2 # exterior vs interior
|
||
elements.append(ElasticBeamColumn(
|
||
id=_col_id(s, c), name=f"Col-S{s}-C{c}",
|
||
nodes=(_node_id(s - 1, c), _node_id(s, c)),
|
||
section_id=sec_id, geom_transf="PDelta",
|
||
))
|
||
beam_sec = {1: 3, 2: 4, 3: 5}
|
||
for f in range(1, N_STORIES + 1):
|
||
for b in range(N_BAYS):
|
||
elements.append(ElasticBeamColumn(
|
||
id=_beam_id(f, b), name=f"Beam-F{f}-B{b}",
|
||
nodes=(_node_id(f, b), _node_id(f, b + 1)),
|
||
section_id=beam_sec[f], geom_transf="Linear",
|
||
))
|
||
|
||
# Gravity distributed load per beam: w = -Load / (4 × bay). The Tcl
|
||
# reference divides by 4 (number of column lines), not by the number
|
||
# of bays — so the distributed load represents a *reference* tributary
|
||
# intensity, not the total floor weight spread over all beams.
|
||
# Corresponding reference values: w1 = w2 = -0.8229 kip/in, w3 = -0.6736.
|
||
floor_total = {1: LOAD_F1, 2: LOAD_F2, 3: LOAD_F3}
|
||
gravity_element_loads = [
|
||
UniformElementLoad(
|
||
element_id=_beam_id(f, b),
|
||
wy=-floor_total[f] / ((N_BAYS + 1) * BAY),
|
||
)
|
||
for f in range(1, N_STORIES + 1)
|
||
for b in range(N_BAYS)
|
||
]
|
||
|
||
# Lateral point loads at each floor's leftmost joint (+X).
|
||
lateral_nodes = {
|
||
_node_id(1, 0): P_F1,
|
||
_node_id(2, 0): P_F2,
|
||
_node_id(3, 0): P_F3,
|
||
}
|
||
|
||
return Project(
|
||
meta=ProjectMeta(
|
||
name="Elastic Frame (OpenSees Ex 4)",
|
||
author="OpenSees Examples Manual",
|
||
description=(
|
||
"3-story 3-bay 2D elastic frame, AISC W-shape sections, "
|
||
"gravity (distributed) + lateral (point) + 5-mode eigen."
|
||
),
|
||
units=UnitSystem.US_IN_KIP,
|
||
),
|
||
ndm=2, ndf=3,
|
||
coord_systems=[
|
||
CoordinateGridSystem(
|
||
name="Global",
|
||
grid=GridSystem(
|
||
x_grid_lines=make_grid_lines(
|
||
"X", [c * BAY for c in range(N_BAYS + 1)],
|
||
),
|
||
y_grid_lines=make_grid_lines(
|
||
"Y", [r * H_STORY for r in range(N_STORIES + 1)],
|
||
),
|
||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||
),
|
||
),
|
||
],
|
||
nodes=nodes,
|
||
sections=sections,
|
||
elements=elements,
|
||
time_series=[
|
||
ConstantTimeSeries(id=1, name="Gravity"),
|
||
LinearTimeSeries(id=2, name="Lateral"),
|
||
],
|
||
load_patterns=[
|
||
PlainLoadPattern(
|
||
id=1, name="Gravity",
|
||
time_series_id=1,
|
||
element_loads=gravity_element_loads,
|
||
),
|
||
PlainLoadPattern(
|
||
id=2, name="Lateral",
|
||
time_series_id=2,
|
||
nodal_loads=[
|
||
NodalLoad(node_id=nid, forces=(P, 0, 0, 0, 0, 0))
|
||
for nid, P in lateral_nodes.items()
|
||
],
|
||
),
|
||
],
|
||
analyses=[
|
||
# Gravity alone — ΣFy at base should equal +3340 kip.
|
||
StaticCase(
|
||
id=1, name="Gravity",
|
||
pattern_ids=[1],
|
||
n_steps=1, load_factor_increment=1.0,
|
||
system="BandGeneral", constraints="Transformation",
|
||
integrator="LoadControl", algorithm="Linear",
|
||
test="NormDispIncr", tolerance=1e-10, max_iter=10,
|
||
),
|
||
# Gravity + lateral — ΣFx at base should equal -490 kip.
|
||
StaticCase(
|
||
id=2, name="Gravity+Lateral",
|
||
pattern_ids=[1, 2],
|
||
n_steps=1, load_factor_increment=1.0,
|
||
system="BandGeneral", constraints="Transformation",
|
||
integrator="LoadControl", algorithm="Linear",
|
||
test="NormDispIncr", tolerance=1e-10, max_iter=10,
|
||
),
|
||
# Eigen analysis on the lumped-mass model — 5 modes.
|
||
ModalCase(id=3, name="Modal-5", n_modes=5),
|
||
],
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
project = build_elastic_frame()
|
||
project.validate_references()
|
||
print(f"Built '{project.meta.name}'")
|
||
print(f" ndm={project.ndm}, ndf={project.ndf}, "
|
||
f"units={project.meta.units.value}")
|
||
print(f" {len(project.nodes)} nodes, {len(project.elements)} elements")
|
||
print(f" Total gravity load: {LOAD_F1 + LOAD_F2 + LOAD_F3:.0f} kip")
|
||
print(f" Total lateral load: {P_F1 + P_F2 + P_F3:.0f} kip")
|
||
out_path = Path(__file__).with_suffix(".osmodel")
|
||
save_project(project, out_path)
|
||
print(f"Saved -> {out_path}")
|
||
restored = load_project(out_path)
|
||
restored.validate_references()
|
||
assert restored.model_dump(by_alias=True) == project.model_dump(by_alias=True)
|
||
print("Round-trip OK.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|