otko/examples/space_frame_3d.py

196 lines
7.1 KiB
Python
Raw Permalink Normal View History

2026-09-08 02:12:15 -04:00
"""Two-story 3D space frame — exercises full 3D rendering and dynamics.
Floor plan (each story):
N3 ──── N4 z (up)
│ │ │
│ │ │
N1 ──── N2 └──── x y → into page
Two stories @ 3 m, two-bay @ 5 m. 12 nodes, 20 elements
(8 columns + 8 floor beams + 4 stiffening braces in the bottom story).
Run from the repository root:
python examples/space_frame_3d.py
Produces ``examples/space_frame_3d.osmodel``.
Open in the GUI, run the cases, then exercise:
Display → Show Force Diagram → N (axial) → braces in tension/compression
Display → Show Force Diagram → M3 → moment distribution at columns
Display → Animate Mode Shape → 1st = sway, 2nd = perpendicular sway
Display → Time-History Plot → roof-corner displacement vs time
"""
from __future__ import annotations
import math
from pathlib import Path
from otko.core import (
ElasticBeamColumn,
ElasticSection,
LinearTimeSeries,
ModalCase,
NodalLoad,
Node,
PathTimeSeries,
PlainLoadPattern,
Project,
ProjectMeta,
ResponseSpectrum,
ResponseSpectrumCase,
StaticCase,
TransientCase,
UnitSystem,
)
from otko.services import load_project, save_project
def _earthquake_pulse(n_steps: int, dt: float) -> list[float]:
"""4-cycle damped sinusoid (toy 'ground motion')."""
f0 = 2.0 # Hz — close to the building's first period
zeta = 0.05
out = []
for i in range(n_steps):
t = i * dt
amp = math.exp(-2.0 * math.pi * f0 * zeta * t)
out.append(amp * math.sin(2.0 * math.pi * f0 * t))
return out
def build_space_frame() -> Project:
# ── geometry ──
bay_x, bay_y, story_z = 5.0, 5.0, 3.0
nodes = []
nid = 1
# 4 base nodes (z=0) — fully fixed.
for x in (0.0, bay_x):
for y in (0.0, bay_y):
nodes.append(Node(id=nid, name=f"Base{nid}",
coords=(x, y, 0.0), restraint=(True,) * 6))
nid += 1
# 4 first-floor + 4 roof nodes — free, with mass.
for story in (1, 2):
for x in (0.0, bay_x):
for y in (0.0, bay_y):
nodes.append(Node(
id=nid, name=f"L{story}N{nid}",
coords=(x, y, story * story_z),
mass=(2_500.0, 2_500.0, 2_500.0, 0.0, 0.0, 0.0),
))
nid += 1
# ── elements ──
elements: list[ElasticBeamColumn] = []
eid = 1
def add_el(ni: int, nj: int, sec: int, name: str) -> None:
nonlocal eid
elements.append(ElasticBeamColumn(id=eid, name=name,
nodes=(ni, nj), section_id=sec))
eid += 1
# Columns: bases (1-4) → 1st floor (5-8); 1st floor → roof (9-12).
for i in range(4):
add_el(i + 1, i + 5, sec=1, name=f"Col-G{i+1}")
add_el(i + 5, i + 9, sec=1, name=f"Col-1{i+1}")
# Floor beams at each story (5-8 and 9-12). Connect 4 nodes around perimeter.
for story_base in (5, 9):
a, b, c, d = story_base, story_base + 1, story_base + 2, story_base + 3
add_el(a, b, sec=2, name=f"Beam-{story_base}-X1")
add_el(c, d, sec=2, name=f"Beam-{story_base}-X2")
add_el(a, c, sec=2, name=f"Beam-{story_base}-Y1")
add_el(b, d, sec=2, name=f"Beam-{story_base}-Y2")
return Project(
meta=ProjectMeta(name="Space Frame 3D", author="Ozan", units=UnitSystem.SI_M_N),
ndm=3, ndf=6,
nodes=nodes,
sections=[
ElasticSection(id=1, name="HSS-Column",
E=200e9, A=0.012, Iz=2.5e-4, Iy=2.5e-4,
G=80e9, J=4.0e-4),
ElasticSection(id=2, name="W-Beam",
E=200e9, A=0.009, Iz=3.0e-4, Iy=8.0e-5,
G=80e9, J=1.0e-6),
],
elements=elements,
time_series=[
LinearTimeSeries(id=1, name="Ramp"),
PathTimeSeries(id=2, name="EQGround",
values=_earthquake_pulse(400, 0.01),
dt=0.01),
],
load_patterns=[
# Lateral push at the 4 roof nodes (X direction) for Static.
PlainLoadPattern(
id=1, name="StaticPush",
time_series_id=1,
nodal_loads=[
NodalLoad(node_id=9, forces=(25_000.0, 0, 0, 0, 0, 0)),
NodalLoad(node_id=10, forces=(25_000.0, 0, 0, 0, 0, 0)),
NodalLoad(node_id=11, forces=(25_000.0, 0, 0, 0, 0, 0)),
NodalLoad(node_id=12, forces=(25_000.0, 0, 0, 0, 0, 0)),
],
),
# Earthquake-style horizontal load on roof corner for Transient.
PlainLoadPattern(
id=2, name="EQRoofLoad",
time_series_id=2,
nodal_loads=[
NodalLoad(node_id=12, forces=(50_000.0, 0, 0, 0, 0, 0)),
],
),
],
analyses=[
StaticCase(id=1, name="Lateral-Push", pattern_ids=[1]),
ModalCase(id=2, name="Modal-6", n_modes=6),
TransientCase(id=3, name="EQ-4s", pattern_ids=[2],
dt=0.01, n_steps=400,
# ~5% damping at the first two modes (assuming
# f1 ≈ 2.5 Hz, f2 ≈ 5.0 Hz from typical 2-story
# steel frames). Solve 2x2 Rayleigh:
# α = 4π · f1·f2 · ζ / (f1 + f2)
# β = ζ / (π · (f1 + f2))
rayleigh_alpha_m=0.524,
rayleigh_beta_k=0.00106),
ResponseSpectrumCase(
id=4, name="RS-X-SRSS",
modal_case_id=2, spectrum_id=1, direction=1,
combination="SRSS",
),
],
spectra=[
# Approximated EC8 Type-1 elastic spectrum, soil class B,
# ag = 0.30g, S = 1.20, TB = 0.15s, TC = 0.50s, TD = 2.0s.
# Sa(T) values precomputed at a sparse grid; in real use
# you'd load these from a CSV or compute on the fly.
ResponseSpectrum(
id=1, name="EC8 Type-1 / Soil B",
periods=[0.01, 0.15, 0.50, 1.0, 2.0, 4.0],
accelerations=[3.53, 8.83, 8.83, 4.42, 2.21, 1.10],
damping_ratio=0.05,
),
],
)
def main() -> None:
project = build_space_frame()
project.validate_references()
print(f"Built '{project.meta.name}' — {len(project.nodes)} nodes, "
f"{len(project.elements)} elements, {len(project.analyses)} cases.")
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()