feat: named case-result load combinations with full GUI support
Snapshots the current development tree, headlined by proper load combinations (user request): a reusable LoadCombination entity of weighted completed static-case results (e.g. 1.2xDead + 1.6xLive). - core: LoadCombination/LoadCombinationItem entities, Project integration (lookup, unique ids, reference validation) - services: combinations.py (linear superposition + envelope), exported via services __init__ - commands: undoable Add/Delete/Update for combinations - GUI: Load Combinations manager dialog, Run-dialog evaluation, envelope display in Results panel, Combinations tab in Table dock - tests: unit coverage (validation, math, error paths) + integration superposition check vs a single factored run
This commit is contained in:
commit
f361fee969
560 changed files with 178701 additions and 0 deletions
196
examples/space_frame_3d.py
Normal file
196
examples/space_frame_3d.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""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()
|
||||
Loading…
Reference in a new issue