196 lines
7.1 KiB
Python
196 lines
7.1 KiB
Python
|
|
"""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()
|