OpenSees Rendering
= "0.0.2" __version__
Synopsis
render.py [<options>] <model-file>
Arpit Nema, Chrystal Chern, and Claudio Perez
This script plots the geometry of a structural model given a SAM JSON file. The SAM JSON structure was developed by the NHERI SimCenter.
Installation
The simplest way to install this script is to run
$ python render.py --install
from a terminal that has python installed, in a directory containing the render.py
file. This will install the following packages:
= """
REQUIREMENTS pyyaml
scipy
numpy
plotly
matplotlib
"""
Matlab
In order to install the Matlab bindings, open Matlab in a directory containing the files render.py
and render.m
, and run the following command in the Matlab interpreter:
render --install
Once this process is complete, the command render
can be called from Matlab, just as described below for the command line.
Usage
This script can be used either as a module, or as a command line utility. When invoked from the command line on Windows, {NAME} should be python -m render
. For example:
python -m render model.json --axes 2 --view elev
The script may be invoked with the following options:
= "render.py"
NAME = f"""
HELP usage: {NAME} <sam-file>
{NAME} --setup ...
{NAME} [options] <sam-file>
{NAME} [options] <sam-file> <res-file>
{NAME} --section <py-file>#<py-object>
Generate a plot of a structural model.
Positional Arguments:
<sam-file> JSON file defining the structural model.
<res-file> JSON or YAML file defining a structural
response.
Options:
DISPLACEMENTS
-s, --scale <scale> Set displacement scale factor.
-d, --disp <node>:<dof>... Apply a unit displacement at node with tag
<node> in direction <dof>.
VIEWING
-V, --view {{elev|plan|sect}} Set camera view.
-a, --axes [<L><T>]<V> Specify model axes. Only <V> is required
--hide <object> Hide <object>; see '--show'.
--show <object> Show <object>; accepts any of:
{{origin|frames|frames.displ|nodes|nodes.displ}}
MISC.
-o, --save <out-file> Save plot to <out-file>.
-c, --conf
--install Install script dependencies.
--setup Run setup operations.
--script {{sam|res}}
--version Print version and exit.
-h, --help Print this message and exit.
<dof> {{long | tran | vert | sect | elev | plan}}
{{ 0 | 1 | 2 | 3 | 4 | 5 }}
<object> {{origin|frames|frames.displ|nodes|nodes.displ}}
"""
="""
EXAMPLESExamples:
Plot the structural model defined in the file `sam.json`:
$ {NAME} sam.json
Plot displaced structure with unit translation at nodes
5, 3 and 2 in direction 2 at scale of 100:
$ {NAME} -d 5:2,3:2,2:2 -s100 --axes 2 sam.json
"""
The remainder of this script is broken into the following sections:
- Data shaping / Misc.
- Kinematics
- Plotting
- Command line processing
Defaults
The following configuration options are available:
= lambda : {
Config "show_objects": ["frames", "frames.displ", "nodes"],
"hide_objects": ["origin"],
"sam_file": None,
"elem_by_type": False,
"res_file": None,
"write_file": None,
"displ": [],
"scale": 100.0,
"orientation": [0,2,1],
"view": "iso",
"plotter": "mpl",
"camera": {
"view": "iso", # iso | plan| elev[ation] | sect[ion]
"projection": "orthographic" # perspective | orthographic
},
"displacements": {"scale": 100, "color": "#660505"},
"objects": {
"origin": {"color": "black"},
"frames" : {
"default": "#000000",
"displaced": {"color": "red", "npoints": 20}
},
"nodes": {
"default": {"size": 2, "color": "#000000"},
"displaced" : {},
"fixed" : {},
},
},"save_options": {
# Options for when writing to an HTML file.
"html": {
"include_plotlyjs": True,
"include_mathjax" : "cdn",
"full_html" : True
}
} }
def apply_config(conf, opts):
for k,v in conf.items():
if isinstance(v,dict):
apply_conf(v, opts[k])else:
= v opts[k]
The following Tcl script can be used to create a results file
= """
EIG_SCRIPT for {set m 1} {$m <= 3} {incr m} {
puts "$m:"
foreach n [getNodeTags] {
puts " $n: \[[join [nodeEigenvector $n $m] {, }]\]";
}
}
"""
import sys, os
try:
import yaml
import numpy as np
= np.ndarray
Array = np.float32
FLOAT from scipy.linalg import block_diag
except:
# prevent undefined variables when run in install mode
= None
yaml = list
Array = float
FLOAT =3 # this script currently assumes ndm=3 NDM
Data shaping / Misc.
The following functions are used for reshaping data and carrying out other miscellaneous operations.
class RenderError(Exception): pass
def wireframe(sam:dict)->dict:
"""
Process OpenSees JSON output and return dict with the form:
{<elem tag>: {"crd": [<coordinates>], ...}}
"""
= sam["geometry"]
geom = np.array([n.pop("crd") for n in geom["nodes"]],dtype=FLOAT)
coord = {n["name"]: {**n, "crd": coord[i]} for i,n in enumerate(geom["nodes"])}
nodes = {t["name"]: t for t in sam["properties"]["crdTransformations"]}
trsfm = {
elems "name"]: dict(
e[**e,
=np.array([nodes[n]["crd"] for n in e["nodes"]], dtype=FLOAT),
crd=trsfm[e["crdTransformation"]] if "crdTransformation" in e else None
trsfmfor e in geom["elements"]
)
}return dict(nodes=nodes, elems=elems, coord=coord)
def read_model(filename:str)->dict:
import json
with open(filename,"r") as f:
= json.load(f)
sam = wireframe(sam["StructuralAnalysisModel"])
model if "RendererConfiguration" in sam:
"config"] = sam["RendererConfiguration"]
model[return model
Kinematics
The following functions implement various kinematic relations for standard frame models.
# Helper functions for extracting rotations in planes
= lambda v: v[[1,2]]
elev_dofs = lambda v: v[[3,4]] plan_dofs
def get_dof_num(dof:str, axes:list):
try: return int(dof)
except: return {
"long": axes[0],
"vert": axes[2],
"tran": axes[1],
"sect": axes[0]+3,
"plan": axes[2]+3,
"elev": axes[1]+3
}[dof]
def elastic_curve(x: Array, v: Array, L:float)->Array:
"compute points along Euler's elastica"
= v
vi, vj = x/L # local coordinates
xi = 1.-3.*xi**2+2.*xi**3
N1 = L*(xi-2.*xi**2+xi**3)
N2 = 3.*xi**2-2*xi**3
N3 = L*(xi**3-xi**2)
N4 = vi*N2+vj*N4
y return y.flatten()
def linear_deformations(u,L):
"""
Compute local frame deformations assuming small displacements
u: 6-vector of displacements in rotated frame
L: element length
"""
= range(6) # Define variables to aid
xi, yi, zi, si, ei, pi = range(6,12) # reading array indices.
xj, yj, zj, sj, ej, pj
= (u[zj]-u[zi]) / L # Chord rotations
elev_chord = (u[yj]-u[yi]) / L
plan_chord return np.array([
- u[xi]], # xi
[u[xj] - elev_chord], # vi_elev
[u[ei] - elev_chord], # vj_elev
[u[ej]
- plan_chord],
[u[pi] - plan_chord],
[u[pj] - u[si]],
[u[sj] =FLOAT) ],dtype
def rotation(xyz: Array, vert=(0,0,-1))->Array:
"Create a rotation matrix between local e and global E"
= xyz[1] - xyz[0]
dx = np.linalg.norm(dx)
L = dx/L
e1 = np.atleast_1d(vert)
v13 = -np.cross(e1,v13)
v2 = v2 / np.linalg.norm(v2)
e2 = np.cross(e1,e2)
v3 = v3 / np.linalg.norm(v3)
e3 return np.stack([e1,e2,e3])
def displaced_profile(
coord: Array,#: Displacements
displ: Array, = None, #: Element orientation vector
vect : Array bool = True, #: Transform to global coordinates
glob : int = 10,
npoints:->Array:
)= npoints
n # (---ndm---)
= 4 if len(coord[0])==3 else 2
rep = rotation(coord, vect)
Q = np.linalg.norm(coord[1] - coord[0])
L = linear_deformations(block_diag(*[Q]*rep)@displ, L)
v = L+v[0,0]
Lnew = np.linspace(0.0, Lnew, n)
xaxis
= elastic_curve(xaxis, plan_dofs(v), Lnew)
plan_curve = elastic_curve(xaxis, elev_dofs(v), Lnew)
elev_curve
= Q@np.linspace(displ[:3], displ[6:9], n).T
dx,dy,dz = np.stack([xaxis+dx[0], plan_curve+dy, elev_curve+dz])
local_curve
if glob:
= Q.T@local_curve + coord[0][None,:].T
global_curve
return global_curve
Plotting
= { # pre-defined plot views
VIEWS "plan": dict(azim= 0, elev= 90),
"sect": dict(azim= 0, elev= 0),
"elev": dict(azim=-90, elev= 0),
"iso": dict(azim= 45, elev= 35)
}
def new_3d_axis():
import matplotlib.pyplot as plt
= plt.subplots(1, 1, subplot_kw={"projection": "3d"})
_, ax True)
ax.set_autoscale_on(
ax.set_axis_off()return ax
def add_origin(ax,scale):
= np.zeros((3,3))
xyz = np.eye(3)*scale
uvw *xyz, *uvw, arrow_length_ratio=0.1, color="black")
ax.quiver(return ax
def set_axis_limits(ax):
"Find and set axes limits"
= [ub - lb for lb, ub in (getattr(ax, f'get_{a}lim')() for a in 'xyz')]
aspect = [max(a,max(aspect)/8) for a in aspect]
aspect ax.set_box_aspect(aspect)
def plot_skeletal(frame, axes=None, conf=None):
if axes is None: axes = [0,2,1]
= conf or {"color": "grey", "alpha": 0.6, "linewidth": 0.5}
props = new_3d_axis()
ax for e in frame["elems"].values():
= e["crd"].T[axes]
x,y,z **props)
ax.plot(x,y,z, return ax
def plot_nodes(frame, displ=None, axes=None, ax=None):
if axes is None: axes = [0,2,1]
= ax or new_3d_axis()
ax = displ or {}
displ = np.zeros(NDM)
Zero = {"color": "black",
props "marker": "s",
"s": 2,
"zorder": 2}
= frame["coord"]
coord for i,n in enumerate(frame["nodes"].values()):
+= displ.get(n["name"],Zero)[:3]
coord[i,:]
= coord.T[axes]
x,y,z **props)
ax.scatter(x, y, z, return ax
def plot_displ(frame:dict, res:dict, ax=None, axes=None):
= {"color": "#660505", "linewidth": 0.5}
props = ax or new_3d_axis()
ax if axes is None: axes = [0,2,1]
for el in frame["elems"].values():
# exclude zero-length elements
if "zero" not in el["type"].lower():
= [
glob_displ for n in el["nodes"]
u # extract displ from node, default to ndf zeros
for u in res.get(n,[0.0]*frame["nodes"][n]["ndf"])
]= el["trsfm"]["vecInLocXZPlane"]
vect = displaced_profile(el["crd"], glob_displ, vect=vect)[axes]
x,y,z **props)
ax.plot(x,y,z, return ax
class Plotter:
def __init__(self,model, opts, axes=None):
self.model = model
if axes is None: axes = [0,2,1]
self.axes = axes
self.opts = opts
class MplPlotter(Plotter):
def __init__(self, **kwds):
pass
def get_section_layers(self, section):
import matplotlib.collections
import matplotlib.lines as lines
= []
collection for layer in section.layers:
if hasattr(layer, "plot_opts"):
= layer.plot_opts
options else:
= dict(linestyle="--", color="k")
options
collection.append(*np.asarray(layer.vertices).T, **options))
lines.Line2D(return collection
def get_section_patches(self, section):
import matplotlib.collections
import matplotlib.patches as mplp
= []
collection for patch in section.patches:
= patch.__class__.__name__.lower()
name if "circ" in name:
if patch.intRad:
= patch.extRad - patch.intRad
width
collection.append(mplp.Annulus(patch.center, patch.extRad, width))else:
collection.append(mplp.Circle(patch.center, patch.extRad))else:
collection.append(mplp.Polygon(patch.vertices))return matplotlib.collections.PatchCollection(
collection,="grey",
facecolor="grey",
edgecolor=0.3
alpha
)
def plot_section(self,
section,=False,
show_properties=True,
plain=True,
show_quad=True,
show_dims=True,
annotate= None,
ax = None,
fig **kwds
):"""Plot a composite cross section."""
import matplotlib.pyplot as plt
if plain:
= show_quad = show_dims = False
show_properties
if show_properties:
= plt.figure(constrained_layout=True)
fig = fig.add_gridspec(1,5)
gs = fig.add_subplot(gs[0,3:-1])
axp = "\n".join(["${}$:\t\t{:0.4}".format(k,v) for k,v in self.properties().items()])
label 0.1, 0.5), xycoords='axes fraction', va='center')
axp.annotate(label, (True)
axp.set_autoscale_on("off")
axp.axis(
= fig.add_subplot(gs[0,:3])
ax else:
= plt.subplots()
fig, ax
if ax is None:
= plt.subplots()
fig, ax True)
ax.set_autoscale_on(1)
ax.set_aspect(
#x_max = 1.01 * max(v[0] for p in self.patches for v in p.vertices if hasattr(p,"vertices"))
#y_max = 1.05 * max(v[1] for p in self.patches for v in p.vertices if hasattr(p,"vertices"))
#y_min = 1.05 * min(v[1] for p in self.patches for v in p.vertices if hasattr(p,"vertices"))
#ax.set_xlim(-x_max, x_max)
#ax.set_ylim( y_min, y_max)
"off")
ax.axis(# add shapes
self.get_section_patches(section,**kwds))
ax.add_collection(for l in self.get_section_layers(section):
ax.add_line(l)# show centroid
#ax.scatter(*section.centroid)
# show origin
0, 0)
ax.scatter(
plt.show()
return fig, ax
class GnuPlotter(Plotter):
def plot_frames(self):
file=sys.stdout
print("""
set term wxt
unset border
unset xtics
unset ytics
unset ztics
set view equal xyz
splot "-" using 1:2:3 with lines
""",file=file)
= self._get_frames()
coords file, coords)
np.savetxt(
def _get_frames(self):
= self.axes
axes = self.model
model = {"color": "#808080", "alpha": 0.6}
props = np.zeros((len(model["elems"])*3,NDM))
coords
coords.fill(np.nan)for i,e in enumerate(model["elems"].values()):
3*i:3*i+2,:] = e["crd"][:,axes]
coords[return coords
class PlotlyPlotter(Plotter):
def plot(x,y,**opts):
pass
def plot_frames():
pass
def _get_displ(self,res:dict):
= self.model
frame = self.axes
axes = {"color": "red"}
props = 10
N = np.zeros((len(frame["elems"])*(N+1),NDM))
coords
coords.fill(np.nan)for i,el in enumerate(frame["elems"].values()):
# exclude zero-length elements
if "zero" not in el["type"].lower():
= [
glob_displ for n in el["nodes"]
u # extract displ from node, default to ndf zeros
for u in res.get(n,[0.0]*frame["nodes"][n]["ndf"])
]= el["trsfm"]["vecInLocXZPlane"]
vect +1)*i:(N+1)*i+N,:] = displaced_profile(el["crd"], glob_displ, vect=vect, npoints=N)[axes].T
coords[(N= coords.T
x,y,z return {
"type": "scatter3d",
"mode": "lines",
"x": x, "y": y, "z": z,
"line": {"color":props["color"]},
"hoverinfo":"skip"
}def make_hover_data(self, data, ln=None):
if ln is None:
= np.array([d.values for d in data])
items = data[0].keys()
keys else:
= np.array([list(data.values())]*ln)
items = data.keys()
keys return {
"hovertemplate": "<br>".join(f"{k}: %{{customdata[{v}]}}" for v,k in enumerate(keys)),
"customdata": list(items),
}
def _get_nodes(self):
= self.model["coord"].T[self.axes]
x,y,z = ["tag",]
keys = np.array(list(self.model["nodes"].keys()),dtype=FLOAT)[:,None]
nodes return {
"name": "nodes",
"x": x, "y": y, "z": z,
"type": "scatter3d","mode": "markers",
"hovertemplate": "<br>".join(f"{k}: %{{customdata[{v}]}}" for v,k in enumerate(keys)),
"customdata": list(nodes),
"marker": {
"symbol": "square",
**self.opts["objects"]["nodes"]["default"]
}
}
def _get_frame_labels(self):
= self._frame_coords.reshape(-1,4,3)[:,-3]
coords = coords.T
x,y,z = ["tag",]
keys = np.array(list(self.model["elems"].keys()),dtype=FLOAT)[:,None]
frames return {
"name": "frames",
"x": x, "y": y, "z": z,
"type": "scatter3d","mode": "markers",
"hovertemplate": "<br>".join(f"{k}: %{{customdata[{v}]}}" for v,k in enumerate(keys)),
"customdata": frames,
"opacity": 0
#"marker": {"opacity": 0.0,"size": 0.0, "line": {"width": 0.0}}
}
def _get_frames(self, elems=None):
= 4
N = self.axes
axes = elems or self.model["elems"]
elems = {"color": "#808080", "alpha": 0.6}
props = np.zeros((len(elems)*N,NDM))
coords
coords.fill(np.nan)for i,e in enumerate(elems.values()):
*i:N*i+N-1,:] = np.linspace(*e["crd"][:,axes], N-1)
coords[Nself._frame_coords = coords
= coords.T
x,y,z return [{
"type": "scatter3d",
"mode": "lines",
"x": x, "y": y, "z": z,
"line": {"color":props["color"]},
"hoverinfo":"skip"
}]
def plot_plotly(model, axes=None, displ=None, opts={}):
import plotly.graph_objects as go
= PlotlyPlotter(model,axes=axes,opts=opts)
plt if opts["elem_by_type"]:
= plt._get_frames()
frames else:
= plt._get_frames()
frames = plt._get_frame_labels()
labels = plt._get_nodes()
nodes = go.Figure(dict(
fig #go.Scatter3d(**plot_skeletal_plotly(model,axes)),
=[*frames, labels, nodes] + ([plt._get_displ(displ)] if displ else []),
data=go.Layout(
layout=dict(aspectmode='data',
scene=False,
xaxis_visible=False,
yaxis_visible=False,
zaxis_visible=dict(
camera={"type": opts["camera"]["projection"]}
projection
)
),=False
showlegend
)
))return fig
Script functions
Argument parsing is implemented manually because in the past I have found the standard library module argparse
to be slow.
def parse_args(argv)->dict:
= Config()
opts if os.path.exists(".render.yaml"):
with open(".render.yaml", "r") as f:
= yaml.load(f, Loader=yaml.Loader)
presets
apply_config(presets,opts)
= iter(argv[1:])
args for arg in args:
try:
if arg == "--help" or arg == "-h":
print(HELP)
sys.exit()
elif arg == "--gnu":
"plotter"] = "gnu"
opts[
elif arg == "--plotly":
"plotter"] = "plotly"
opts[
elif arg == "--install":
try: install_me(next(args))
# if no directory is provided, use default
except StopIteration: install_me()
sys.exit()elif arg == "--version":
print(__version__)
sys.exit()
elif arg[:2] == "-d":
= arg[2:] if len(arg) > 2 else next(args)
node_dof for nd in node_dof.split(","):
= nd.split(":")
node, dof "displ"].append((int(node), get_dof_num(dof, opts["orientation"])))
opts[
elif arg[:2] == "-s":
"scale"] = float(arg[2:]) if len(arg) > 2 else float(next(args))
opts[
elif arg == "--scale":
"scale"] = float(next(args))
opts[
elif arg == "--axes":
= int(next(args))
vert = 2 if vert == 1 else 1
tran "orientation"][1:] = [tran, vert]
opts[
elif arg == "--show":
"show_objects"].extend(next(args).split(","))
opts[
elif arg == "--hide":
"show_objects"].pop(next(args))
opts[
elif arg[:2] == "-V":
"view"] = arg[2:] if len(arg) > 2 else next(args)
opts[elif arg == "--view":
"view"] = next(args)
opts[
elif arg == "--elem-by-type":
"elem_by_type"] = True
opts[
#elif arg[:2] == "-m":
# opts["mode"] = int(arg[2]) if len(arg) > 2 else int(next(args))
elif arg[:2] == "-o":
"write_file"] = arg[2:] if len(arg) > 2 else next(args)
opts[
#elif arg == "--displ-only":
# opts["displ_only"] = True
# Final check on options
elif arg[0] == "-":
raise RenderError(f"ERROR - unknown option '{arg}'")
elif not opts["sam_file"]:
"sam_file"] = arg
opts[
else:
"res_file"] = arg
opts[
except StopIteration:
# `next(args)` was called without successive arg
raise RenderError(f"ERROR -- Argument '{arg}' expected value")
return opts
def install_me(install_opt=None):
import os
import subprocess
if install_opt == "dependencies":
subprocess.check_call(["-m", "pip", "install", *REQUIREMENTS.strip().split("\n")
sys.executable,
])
sys.exit()try:
from setuptools import setup
except ImportError:
from distutils.core import setup
= sys.argv[:1] + ["develop", "--user"]
sys.argv print(sys.argv)
="render",
setup(name=__version__,
version="",
description=HELP,
long_description="", author_email="", url="",
author=["render"],
py_modules=["render.py"],
scripts="",
license=[*REQUIREMENTS.strip().split("\n")],
install_requires )
= [
TESTS False,"{NAME} sam.json -d 2:plan -s"),
(True, "{NAME} sam.json -d 2:plan -s50"),
(True, "{NAME} sam.json -d 2:3 -s50"),
(True, "{NAME} sam.json -d 5:2,3:2,2:2 -s100 --axes 2 sam.json")
( ]
def render(sam_file, res_file=None, **opts):
if sam_file is None:
raise RenderError("ERROR -- expected positional argument <sam-file>")
= read_model(sam_file)
model
if "config" in model:
"config"], opts)
apply_config(model[
= opts["orientation"]
axes
if opts["plotter"] == "gnu":
GnuPlotter(model, axes).plot_frames()
sys.exit()
#
# Handle displacements
#
if res_file is not None:
from urllib.parse import urlparse
= urlparse(res_file)
res_path with open(res_path[2], "r") as f:
= yaml.load(f,Loader=yaml.Loader)
res if res_path[4]: # query parameters passed
= res[int(res_path[4].split("=")[-1])]
res
else:
= {}
res
for n,d in opts["displ"]:
= res.setdefault(n,[0.0]*model["nodes"][n]["ndf"])
v if d < 3: # translational dof
+= 1.0
v[d] else:
+= 0.1
v[d]
# apply scale
= opts["scale"]
scale if scale != 1.0:
for n in res.values():
for i in range(len(n)):
*= scale
n[i]
if opts["write_file"]:
# write plot to file if file name provided
if "html" in opts["write_file"]:
= plot_plotly(model,axes,displ=res,opts=opts)
fig import plotly
print(str(id(model)),file=sys.stderr)
= plotly.io.to_html(fig, div_id=str(id(model)), **opts["save_options"]["html"])
html with open(opts["write_file"],"w+") as f:
f.write(html)else:
"write_file"])
ax.figure.savefig(opts[elif opts["plotter"] == "plotly":
= plot_plotly(model,axes,displ=res,opts=opts)
fig ="browser")
fig.show(renderer
else:
# Matplotlib
= plot_skeletal(model, axes=axes)
ax = plot_nodes(model, res, ax=ax, axes=axes)
ax
if res:
=axes, ax=ax)
plot_displ(model, res, axes
# Handle plot formatting
set_axis_limits(ax)**VIEWS[opts["view"]])
ax.view_init(if "origin" in opts["show_objects"]: add_origin(ax, scale)
import matplotlib.pyplot as plt
plt.show()return ax
Main script
The following code is only executed when the file is invoked as a script.
if __name__ == "__main__":
= parse_args(sys.argv)
opts if "json" in opts["sam_file"]:
# Plot structural model
try:
**opts)
render(except (FileNotFoundError,RenderError) as e:
print(e, file=sys.stderr)
print(f" Run '{NAME} --help' for more information", file=sys.stderr)
sys.exit()else:
# Plot a cross section
from urllib.parse import urlparse
file = urlparse(opts["sam_file"])
with open(file.path, "r") as f:
= f.read()
script = {}
scope exec(script, scope)
= MplPlotter()
plt file.fragment]) plt.plot_section(scope[