otko/examples/space_frame_3d.py
smillmorel 612936a00b
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
feat: initial otko import
2026-09-08 02:12:15 -04:00

196 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()