visualdynamics.core.fem¶
fem
¶
A beam finite element model, and the eigensolution it gives.
visualdynamics is an analysis toolset, so this is deliberately the smallest modeling capability that produces something worth analyzing: three-dimensional two-node beams, flat-shell plates — four-node rectangles (MITC4) and three-node triangles (MITC3) — and lumped masses, assembled into mass and stiffness matrices and solved for real normal modes. It exists because a demonstration needs a truth model — a dense analytical answer the measured one can be compared against — and because building one should not require reaching for another package.
There are three ways in. Build a structure member by member, as the
example below does. Give a geometry's element groups their properties — a material
and a thickness for an element group of plates, a material and a section for an
element group of beams (GroupProperties) — and let from_geometry build every
element as the element it is, which is how an exodus file means a
structure and the way a user builds a model of a simple article. Or draw
a shape as a surface mesh and let from_geometry, given one material and
one section, put a member along every edge of it and share the mass over
its nodes: the shorter road from any geometry to some set of modes, and
how visualdynamics.demo.drone is built throughout.
What it is not: a general finite element code. The plate is rectangular
only — a skewed or warped quad is refused rather than solved badly — and
there are no curved shells, no solids, no constraints beyond fixing degrees
of freedom, and no static solution. Wiring a surface mesh's edges with
from_geometry makes a grillage of beams, not plates, which is a real
modeling choice with a known cost rather than an approximation hidden
inside an element: it answers what order of mode density and what mode
families a shape has, and it does not pretend to be shell theory.
Everything here is SI, because the mass and stiffness matrices are the one
place in visualdynamics where several dimensions have to be consistent with each
other at once — a length in millimeters beside a modulus in pascals is not
wrong in any single entry, it is wrong only in the answer. The objects that
come out (Geometry, ShapeSet) carry their units declared, so the
conversion happens once, at the boundary, as it does everywhere else.
from visualdynamics import fem
aluminum = fem.Material('aluminum', youngs_modulus=70e9, density=2700)
tube = fem.Section.round_tube('16 mm tube', outer=0.016, wall=0.001)
model = fem.Model('cantilever')
for i in range(11):
model.add_node(100 + i, i * 0.1, 0.0, 0.0)
model.add_chain(range(100, 111), aluminum, tube)
shapes = model.eigensolution(maximum_frequency=2000,
fixed=['100'], damping=0.01)
The element formulation is Euler-Bernoulli with a consistent mass matrix: no shear flexibility and no section rotary inertia, which is right while a member is slender (length more than about ten times its depth) and progressively optimistic when it is not. A drone arm at 180 mm long and 16 mm deep is comfortably inside that; a stubby mounting stub is not, and frequencies there read high.
References
The Euler-Bernoulli beam element and its consistent mass matrix are textbook; these are where they are set out.
- Przemieniecki, J. S. (1968). Theory of Matrix Structural Analysis. McGraw-Hill.
- Cook, R. D., Malkus, D. S., Plesha, M. E., & Witt, R. J. (2002). Concepts and Applications of Finite Element Analysis, 4th ed. Wiley. The cubic Hermite shape functions the consistent mass follows from, and why consistent rather than lumped changes the frequencies returned.
- Craig, R. R., & Kurdila, A. J. (2006). Fundamentals of Structural Dynamics, 2nd ed. Wiley. The generalized symmetric eigenproblem this solves, and normalization to unit modal mass.
Classes:
| Name | Description |
|---|---|
Material |
An isotropic elastic material. |
LibraryMaterial |
A material the library offers, with where its numbers came from. |
Section |
A beam cross section, as the four numbers the element needs. |
GroupProperties |
What an element group of a geometry is made of, for |
Beam |
One two-node beam element. |
Plate |
One four-node rectangular plate-bending element. |
Triangle |
One three-node triangular plate-bending element. |
Solid |
One solid element: a hexahedron, a wedge or a tetrahedron, told |
RigidLink |
Two nodes held rigidly together, with no mass of their own: the |
Spring |
A discrete spring between two degrees of freedom, or from one to |
LumpedMass |
A rigid item carried at a node: a motor, a battery, a camera. |
Face |
A cosmetic face: shading, not stiffness. |
Model |
Nodes, beams and lumped masses, and the modes they imply. |
Functions:
| Name | Description |
|---|---|
ground_axes |
A ground's directions as |
material |
A library material by name — |
with_article |
'a channel', 'an I-beam', 'an angle' — a shape named in a |
angle_major_axis |
Where an angle's major principal axis lies — the direction its |
connected_pieces |
The connected components of an adjacency map, largest first. |
polish_modes |
Refine a block of approximate modes until they are converged. |
Classes¶
Material
dataclass
¶
Material(name: str, youngs_modulus: float, density: float, poissons_ratio: float = 0.3, modulus_of_rigidity: float | None = None)
An isotropic elastic material.
Shear modulus is derived from the modulus and Poisson's ratio unless it is given: for carbon fiber laminates the isotropic relation is a poor guess and the torsional stiffness it implies can be out by a factor of two, so the door is left open to state it.
Attributes:
| Name | Type | Description |
|---|---|---|
is_rigid |
bool
|
Whether this is |
LibraryMaterial
dataclass
¶
LibraryMaterial(material: Material, note: str)
A material the library offers, with where its numbers came from.
The note names the kind of source and what the values are typical of, because a handbook's typical modulus and density are what a modal solution wants and also not what a particular heat of a particular alloy measures: a person with the part's certification in hand types those numbers over the library's.
Section
dataclass
¶
Section(name: str, area: float, iy: float, iz: float, j: float, shape: str = '', dimensions: tuple[float, ...] = ())
A beam cross section, as the four numbers the element needs.
iy and iz are second moments of area about the element's own local
y and z axes, and j is the torsion constant — St Venant's, which
equals the polar second moment only for a circular section. For
anything else it is smaller, and using the polar value overstates
torsional stiffness; the constructors below carry the right formula for
the shapes they build.
The shapes (SHAPES): a section built from one remembers it —
shape and its dimensions in meters, in the order the constructor
takes them — so a table can show the dimensions and a file or a
session script rebuilds the section from them. One rule for which way
a shape faces: its width or flanges run along local y and its
depth or height along local z, so the orientation vector (which
names local y) points across the flanges, and iy is the
strong-axis bending of an I-beam or a channel. An angle is the
exception, because its leg axes are not principal and the element has
no product term: its iy and iz are its principal moments, and the
orientation vector points along the major principal axis
(angle_major_axis says where that is relative to the legs).
The element assumes the shear center is the centroid. For the symmetric shapes it is; a channel's and an angle's are not, and the twisting a load through their centroid causes is not in the model.
polar is the inertia term, always the true polar second moment
(iy + iz), because rotary inertia about the axis is a property of where
the material is and has nothing to do with warping.
Methods:
| Name | Description |
|---|---|
of_shape |
The section of a named shape ( |
round_tube |
A circular tube, given its outside diameter and wall thickness. |
rod |
A solid circular rod. |
rectangle |
A solid rectangle, |
rectangular_tube |
A rectangular tube of one wall thickness, |
square_tube |
A square tube, given its outside width and wall thickness — a |
i_beam |
A doubly symmetric I-beam: overall |
channel |
A channel: overall |
angle |
An angle of one thickness. Its leg axes are not principal and |
Methods:¶
of_shape
classmethod
¶
of_shape(name: str, shape: str, dimensions: Sequence[float]) -> Section
The section of a named shape (SHAPES), given its dimensions
in meters in the order the shape's constructor takes them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
What to call the section. |
required |
shape
|
str
|
A key of |
required |
dimensions
|
sequence of float
|
The shape's dimensions, in meters. |
required |
Returns:
| Type | Description |
|---|---|
Section
|
|
Source code in src/visualdynamics/core/fem.py
round_tube
classmethod
¶
round_tube(name: str, outer: float, wall: float) -> Section
A circular tube, given its outside diameter and wall thickness.
Source code in src/visualdynamics/core/fem.py
rod
classmethod
¶
rod(name: str, diameter: float) -> Section
A solid circular rod.
Source code in src/visualdynamics/core/fem.py
rectangle
classmethod
¶
rectangle(name: str, width: float, height: float) -> Section
A solid rectangle, width along local y and height along z.
Source code in src/visualdynamics/core/fem.py
rectangular_tube
classmethod
¶
rectangular_tube(name: str, width: float, height: float, wall: float) -> Section
A rectangular tube of one wall thickness, width along local y
and height along z, both outside dimensions.
Source code in src/visualdynamics/core/fem.py
square_tube
classmethod
¶
square_tube(name: str, width: float, wall: float) -> Section
A square tube, given its outside width and wall thickness — a rectangular tube of equal sides.
Source code in src/visualdynamics/core/fem.py
i_beam
classmethod
¶
i_beam(name: str, depth: float, flange_width: float, flange_thickness: float, web_thickness: float) -> Section
A doubly symmetric I-beam: overall depth along local z,
flanges flange_width wide along y. Fillets are left out, which
puts area and torsion a few percent under a rolled shape's table
values (a W8x31: 8.99 in² against 9.13, J 0.50 in⁴ against 0.54).
Source code in src/visualdynamics/core/fem.py
channel
classmethod
¶
channel(name: str, depth: float, flange_width: float, flange_thickness: float, web_thickness: float) -> Section
A channel: overall depth along local z, the flanges
flange_width wide (web included) running along +y from the web.
iz is about the centroid, which sits off the web; the shear
center sits further off it the other way, and the element does
not know. The flanges are of one thickness: a rolled channel's
taper toward their tips, and its table flange thickness is an
average, so this iz runs high for one (a C6x10.5: 1.06 in⁴
against the table's 0.86) — a bent-plate channel it gives
exactly.
Source code in src/visualdynamics/core/fem.py
angle
classmethod
¶
angle(name: str, long_leg: float, short_leg: float, thickness: float) -> Section
An angle of one thickness. Its leg axes are not principal and
the element has no product term, so iy and iz are the
principal moments — major and minor — and the orientation vector
is to point along the major principal axis (angle_major_axis).
Source code in src/visualdynamics/core/fem.py
GroupProperties
dataclass
¶
GroupProperties(material: Material | None = None, thickness: float | None = None, section: Section | None = None, orientation: tuple[float, float, float] | None = None, mass: float | None = None, stiffness: tuple[float | None, ...] | None = None, ground: tuple[str, ...] | bool = ())
What an element group of a geometry is made of, for Model.from_geometry.
One property set per element group of one element type, the way every
finite element format states a structure: a material for any
element group, plus a thickness for an element group of plates (triangles or
quads) or a section — and an orientation vector for the roll, as
Model.add_beam takes it — for an element group of beams; an element group of
solids takes the material alone. An element group given both, or plates or
beams given neither, is refused when the model is built, by name.
An element group of point elements is a set of lumped masses and takes a
mass alone, no material (2026-10-07): GroupProperties(mass=m)
puts m kilograms at the node of every element in the element group —
a bolt, a sensor, a fitting too small to mesh — which is how a
finite element deck states one, a CONM2 or a point-mass element group.
Springs and ground (2026-10-08, Brandon: a spring is a kind of line
element, and ground a kind of point). A group given a stiffness,
six numbers in the global X, Y, Z, RX, RY and RZ directions with None
for free, is a group of springs: every two-node line in it a spring
between its two nodes in each direction given (Model.add_spring),
the nodes free to coincide, and every point element a spring from its
node to ground — a Nastran CBUSH, or CELAS cards one direction at a
time. A group given ground holds points, and each one's node is
held in each direction given (Model.add_ground): all six with
ground=True, the translations alone with ground=('X', 'Y',
'Z') — a support, or the far end of a spring to ground drawn as a
line. A Nastran SPC1 reads as one, its components the directions.
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
'plate', 'beam', 'solid', 'rigid', 'mass', or what is wrong |
Attributes¶
kind
property
¶
'plate', 'beam', 'solid', 'rigid', 'mass', or what is wrong with it. A rigid element group takes no thickness and no section, and any left from before the material was picked are ignored; a material alone is an element group of solids (2026-09-30), which take nothing else — an element group of plates or beams given only a material is refused where the elements are built, by what they are. A mass makes an element group of point masses whatever else is set: the mass is the one number such an element group can use; ground and a stiffness likewise make a group of supports and of springs.
Beam
dataclass
¶
Beam(node_a: int, node_b: int, material: Material, section: Section, orientation: tuple[float, float, float] | None = None, color: int = 1, group: str = '')
One two-node beam element.
Plate
dataclass
¶
Plate(nodes: tuple[int, int, int, int], material: Material, thickness: float, color: int = 1, group: str = '')
One four-node rectangular plate-bending element.
A flat shell: plane-stress membrane action in its own plane and Mindlin bending out of it, with the transverse shear tied at the edge midpoints (MITC4). The tying is not optional finesse — a plain bilinear Mindlin element locks in shear as the plate gets thin, and a 12x12 mesh of the locked element puts the first elastic mode of a thin free plate several times too high.
Rectangles only, and refused otherwise rather than silently mis-integrated: the tying directions assume the natural axes align with the sides, which is exactly true for a rectangle and only approximately for anything else. The models this module exists to build mesh rectangular panels; a skewed general quad earns its place when something needs it, with the covariant transforms and the validation that come with it.
Nodes run around the perimeter: 1-2 is the first edge, 1-4 the second, corner 3 opposite corner 1.
Triangle
dataclass
¶
Triangle(nodes: tuple[int, int, int], material: Material, thickness: float, color: int = 1, group: str = '')
One three-node triangular plate-bending element.
The quad's sibling, so a mesh of triangles and a mesh of rectangles are one theory: a flat shell with plane-stress membrane action in its own plane and Mindlin bending out of it, the transverse shear tied along the three edges (MITC3, Lee & Bathe 2004). The tying is what keeps a linear triangle from locking in shear as the plate gets thin — a plain linear Mindlin triangle is the worst locker there is. Any flat triangle is a valid element; only a degenerate one (zero area) is refused.
Nodes run around the perimeter, counterclockwise about the normal the element takes as its own +z.
Solid
dataclass
¶
Solid(nodes: tuple[int, ...], material: Material, color: int = 1, group: str = '')
One solid element: a hexahedron, a wedge or a tetrahedron, told apart by how many nodes it names (8, 6 or 4).
Three translations per node and no rotations — a solid has no
rotational stiffness, and the rotations of a node only solids touch
are grounded by the eigensolution rather than left as degrees of
freedom with nothing on them (Model.dangling_rotations).
The hexahedron is trilinear with Wilson's incompatible bending
modes, Taylor's form (the extra modes' strains taken from the
centroid's Jacobian, so a distorted brick still passes the patch
test): a plain trilinear brick is far too stiff in bending, and a
part meshed a few elements through its thickness would come out a
third high. With the modes, one layer of bricks bends like a beam.
The wedge is the linear six-node element and the tetrahedron the
constant-strain one — transition and imported shapes, not what a
part is meshed with here (mesh.block makes bricks); a linear
tetrahedron locks in bending and a mesh of them is trusted only
where it is fine.
Nodes run around the bottom face and then the top, the same way round (UFF 2412 and Nastran's CHEXA/CPENTA/CTETRA order).
RigidLink
dataclass
¶
Two nodes held rigidly together, with no mass of their own: the
second moves as the first does, translated by the first's rotation
about it (RIGID).
Spring
dataclass
¶
Spring(node_a: int, direction_a: int, node_b: int | None, direction_b: int, stiffness: float, name: str = '')
A discrete spring between two degrees of freedom, or from one to
ground: the flexible counterpart of a RigidLink, and what a
Nastran CELAS states (2026-10-08, proposed for two-beam
substructuring cases, where a test article meets its fixture at
coincident nodes with no length between them for a beam).
Each end is a node and a signed direction code (direction_code:
1-3 the translations, 4-6 the rotations); the spring resists the
difference of the two motions, each taken along its own sign, so
'X+' to 'X+' is the ordinary axial spring. node_b None grounds it.
LumpedMass
dataclass
¶
LumpedMass(node: int, mass: float, inertia: tuple[float, float, float] = (0.0, 0.0, 0.0), name: str = '')
A rigid item carried at a node: a motor, a battery, a camera.
The inertias are about the global axes through the node. A point mass leaves them zero, which is honest for something small against the members carrying it and wrong for a battery the size of the structure — a mass with no inertia cannot rock, so a rocking mode simply will not appear.
Face
dataclass
¶
A cosmetic face: shading, not stiffness.
A grillage of beams reads as a wireframe, and a wireframe of a drone deck reads as nothing much. Faces spanning nodes that are already there give the renderer something to shade and the animation something to deform, while contributing nothing to the matrices — which is exactly the truth about them, and is why they are a separate kind rather than a zero-stiffness element.
Model
¶
Nodes, beams and lumped masses, and the modes they imply.
Two assemblies of one set of element matrices. Dense (matrices):
(6 x nodes) square, every mode solved whole — exact, and the path for a
model up to SPARSE_ABOVE degrees of freedom. Sparse
(sparse_matrices): only the nonzeros, the lowest modes by
shift-invert Lanczos. The dense path was the only one, and its ceiling
(~1000 nodes) deliberate, until a plate model of a small real
structure met it: the BARC at a quarter inch, 1,500 nodes, is ~13 GB
dense; sparse, its eighth-inch mesh of 6,150 nodes solves in three
seconds in under half a gigabyte (2026-09-26).
Attributes:
name: What the model is called; it becomes the geometry's name
and the shape set's comment.
length_unit: What the coordinates are in. Everything inside is
SI; this is what the Geometry is told on the way out.
beams: Every member, as Beam records naming two nodes, a
material and a section.
masses: Lumped masses, as LumpedMass records at a node.
springs: Discrete springs between two degrees of freedom or to
ground, as Spring records (add_spring).
grounds: The nodes held, and in which directions, {node: axes
0-5} (add_ground).
faces: Surfaces, as Face records naming three or four nodes.
They carry no stiffness — a face is drawn, and its edges
are what carry members.
Methods:
| Name | Description |
|---|---|
add_chain |
A run of beams through consecutive nodes — a member, in one call. |
add_plate |
One rectangular plate element over four existing nodes. |
add_triangle |
One triangular plate element over three existing nodes. |
add_solid |
One solid element over eight, six or four existing nodes: a |
add_rigid_link |
Join two nodes rigidly, adding no mass. |
add_spring |
A spring between two degrees of freedom, or from one to ground. |
add_ground |
Hold a node in some or all of the six directions: a support, |
rigid_bodies |
The groups of nodes the rigid links join, each in the model's |
from_geometry |
A structure from a drawn shape: members on its edges, mass at its |
pieces |
The structure's disconnected parts, largest first. |
wire_faces |
Put a beam along every edge of every face, and return how many. |
distribute_mass |
Share |
group |
What this node was added as part of — 'arm 2', 'top deck'. |
dof_strings |
'101X+', '101Y+', … in the matrices' own order. |
matrices |
Assemble the global mass and stiffness matrices, dense. |
sparse_matrices |
The same mass and stiffness matrices as |
rigid_body_vectors |
The six rigid-body motions, as columns over the model's DOFs. |
eigensolution |
Real normal modes, mass-normalized, as a ShapeSet. |
scaled_system |
The sparse eigenproblem as the sparse solver poses it. |
constraint_transform |
T, with u = T q: every degree of freedom of the model written |
dangling_rotations |
The nodes whose rotations nothing acts on: touched by solids or |
idle_lead_rotations |
(lead node, axis 0-2) for each rotation of a rigid body's lead |
loose_nodes |
The nodes nothing touches: no element, no rigid link, no |
geometry |
The model as a Geometry: nodes, beams as elements, faces as faces. |
Attributes:
| Name | Type | Description |
|---|---|---|
node_ids |
list[int]
|
In insertion order: the matrices' row order follows this. |
structural_mass |
float
|
What the members weigh, before anything is hung on them. |
Source code in src/visualdynamics/core/fem.py
Attributes¶
structural_mass
property
¶
What the members weigh, before anything is hung on them.
Methods:¶
add_chain
¶
add_chain(nodes: Sequence[int], material: Material, section: Section, orientation: Sequence[float] | None = None, color: int = 1, group: str = '') -> list[Beam]
A run of beams through consecutive nodes — a member, in one call.
Source code in src/visualdynamics/core/fem.py
add_plate
¶
add_plate(nodes: Sequence[int], material: Material, thickness: float, color: int = 1, group: str = '') -> Plate
One rectangular plate element over four existing nodes.
Source code in src/visualdynamics/core/fem.py
add_triangle
¶
add_triangle(nodes: Sequence[int], material: Material, thickness: float, color: int = 1, group: str = '') -> Triangle
One triangular plate element over three existing nodes.
Source code in src/visualdynamics/core/fem.py
add_solid
¶
One solid element over eight, six or four existing nodes: a hexahedron, a wedge or a tetrahedron.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nodes
|
sequence of int
|
The corners, bottom face then top, the same way round. |
required |
material
|
Material
|
What it is made of. |
required |
color
|
optional
|
As for a plate. |
1
|
group
|
optional
|
As for a plate. |
1
|
Returns:
| Type | Description |
|---|---|
Solid
|
|
Source code in src/visualdynamics/core/fem.py
add_rigid_link
¶
add_rigid_link(node_a: int, node_b: int, group: str = '') -> RigidLink
Join two nodes rigidly, adding no mass.
Links that share nodes join into one rigid body, however they are chained; each body moves as its first node does (the one added to the model first), and the others follow it exactly. The eigensolution eliminates the followers' degrees of freedom rather than stiffening anything, so the answer is the limit of an infinitely stiff member and the matrices stay well conditioned.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
node_a
|
int
|
The nodes, both already in the model, and different. |
required |
node_b
|
int
|
The nodes, both already in the model, and different. |
required |
group
|
str
|
The part the link belongs to — its element group, from a geometry. |
''
|
Returns:
| Type | Description |
|---|---|
RigidLink
|
|
Source code in src/visualdynamics/core/fem.py
add_spring
¶
add_spring(dof_a: str, dof_b: str | None, stiffness: float, name: str = '') -> Spring
A spring between two degrees of freedom, or from one to ground.
The ends are written the way fixed writes them, a node and a
direction: model.add_spring('101Z+', '201Z+', 5e5) joins two
nodes' Z translations, model.add_spring('101RY+', '201RY+',
2e3) their rotations about Y, and model.add_spring('1Z+',
None, 1e4) grounds node 1 in Z. The nodes may coincide — a
joint between two parts meshed to the same point, which no beam
can be — and a joint stiff in more than one direction is one
spring per direction. It adds no mass. A spring to a node
nothing else touches is a spring to ground, since that node is
grounded whole (loose_nodes).
A spring far stiffer than the structure costs the solve digits:
two beams joined by springs ten orders over their EI/L read
0.2 % low on the dense solver (2026-10-08), where five orders
were within 3e-5 of the one beam. A joint meant to be rigid is
a rigid link (add_rigid_link), which costs nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dof_a
|
str
|
The first end, as ' |
required |
dof_b
|
str or None
|
The second end, the same way, or None for ground. Both ends are translations or both rotations. |
required |
stiffness
|
float
|
Positive: N/m between translations, N m/rad between rotations. |
required |
name
|
str
|
What the spring is called — its element group, from a geometry. |
''
|
Returns:
| Type | Description |
|---|---|
Spring
|
|
Source code in src/visualdynamics/core/fem.py
add_ground
¶
Hold a node in some or all of the six directions: a support,
solved for as fixed would hold it, but carried by the model —
what a ground point in a geometry builds. A node nothing else
touches is held whole already (loose_nodes); this holds one the
structure touches too. Held twice, a node is held in both sets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
node
|
int
|
The node, already in the model. |
required |
directions
|
bool or sequence of str
|
The directions held, as |
True
|
Returns:
| Type | Description |
|---|---|
int
|
The node. |
Source code in src/visualdynamics/core/fem.py
rigid_bodies
¶
The groups of nodes the rigid links join, each in the model's node order — its first node is the one the others follow.
Source code in src/visualdynamics/core/fem.py
from_geometry
classmethod
¶
from_geometry(geometry: Geometry, material: Material | None = None, section: Section | None = None, total_mass: float | None = None, name: str = '', groups: dict[int, str] | None = None, sections: dict[str, Section] | None = None) -> Model
A structure from a drawn shape: members on its edges, mass at its nodes.
The shortest route from any geometry to a set of modes. Every element contributes its own edges as beams — a face gives its perimeter, a line element gives its run — and the mass is shared equally over the nodes. What comes back is a model that can be solved like any other.
This is a sanity-check tool, not a mesher. The members are a stand-in for whatever the real structure is: one section for the whole model, chosen to put the modes where they are wanted, and the answer scales as its square root. Shell bending, membrane action and any real thickness are simply not represented. What it is good for is looking at a shape and asking what order of mode density and what mode families it has — which is the question a display model usually raises first.
A geometry whose element groups carry properties builds itself. When
geometry.group_properties names what each element group is made of
(GroupProperties), every element becomes the element it is:
a quad a plate, a triangle a triangle, a two-node line a beam,
each with its element group's material and thickness or section, and a
point element in an element group given a mass a lumped mass at its
node. That
is the model an exodus file means, one property set per element group
of one element type (Brandon, 2026-09-25), and the demonstration
plate rebuilt from its own geometry this way is the same model
to the last digit (tests/test_block_model.py). An element group with no
properties, or an element type the solver has no element for,
is refused by name; material and section are not consulted.
Without element group properties the grillage below is built, and
material and section are required for it.
A drawn line — an element group of two-node line elements with no
properties, what a traceline was — is an element like any other
here and gives its run, which is what a wireframe geometry
needs to hold together at all.
groups labels the nodes by the part they belong to; a Geometry
does not carry that, and the first question asked of any result is
which part of the structure a mode lives in. sections then gives
one part a section of its own — {'prop': stiffer} — applied where
both ends of an edge belong to it, which is how a part is made
stiffer or softer than the rest without redrawing anything.
Source code in src/visualdynamics/core/fem.py
1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 | |
pieces
¶
The structure's disconnected parts, largest first.
One piece is a structure; more than one is that many free bodies, each bringing its own six zero-frequency modes. It is the first thing to ask of any model that comes back too floppy, and the answer is almost never what was intended — the old airplane fixture, meshed and wired through its own drawn lines, turned out to be three: the fuselage, a wing and the tail, none joined.
Source code in src/visualdynamics/core/fem.py
wire_faces
¶
Put a beam along every edge of every face, and return how many.
This is the shortest road from a shape to a structure: draw the thing as a surface mesh, and let its own edges be its members. The geometry then is the model, with nothing derived, nothing tied, and no second set of nodes that only exist to be looked at.
Beams, not axial springs. A spring on each edge leaves a quad free to shear and a flat sheet free to fold — the edges never change length, so nothing resists it — and the model comes back a mechanism with as many zero-frequency modes as it has panels. A beam carries moment, so a wireframe of them is a space frame and stands up. It is the same element the rest of this module uses.
Shared edges are wired once. An edge that already has a beam is left alone, so explicit members (a truss strut, a standoff) can be placed first and keep their own section.
Source code in src/visualdynamics/core/fem.py
distribute_mass
¶
Share total over the nodes by the members meeting at each.
A node's share is proportional to the length of member it carries — half of every member that reaches it — so mass follows the material rather than the mesh. Returns {node: mass}.
Sharing it equally is the obvious thing and it is a trap, because a node count is a statement about how finely something was drawn rather than about how much of it there is. On the demonstration airframe that put 42% of the mass into the propellers — they need the finest mesh to look right, so they collect the most nodes — and 117 g on each rotor buried every airframe mode below 1.8 kHz under blade motion, while the battery, the heaviest real item on the aircraft, was left with 51 g.
Each node also gets a rotary inertia, without which the three
rotational degrees of freedom carry nothing, the mass matrix is
singular and the Cholesky factorization fails outright. It is
taken as spin * m * L^2 with L the mean length of the members
meeting there — the patch of structure the node stands for, spun
about its own middle. The default 1/12 is a uniform rod's.
Source code in src/visualdynamics/core/fem.py
group
¶
What this node was added as part of — 'arm 2', 'top deck'.
A label for the modeler's own use: nothing here reads it, but working out which part of a structure a mode lives in is the first question asked of any result, and reconstructing it from coordinates afterwards is guesswork.
Source code in src/visualdynamics/core/fem.py
dof_strings
¶
matrices
¶
Assemble the global mass and stiffness matrices, dense.
Rows and columns run structural node by structural node in
insertion order, six per node in DIRECTIONS order, which is what
dof_strings() spells out. Display nodes are absent: they carry
nothing, so there is nothing of theirs to assemble.
Source code in src/visualdynamics/core/fem.py
sparse_matrices
¶
The same mass and stiffness matrices as matrices, stored
sparse (scipy CSR): each node couples only to the nodes of the
elements it touches, so a row holds a few dozen entries of
thousands, and the storage grows with the nodes rather than
their square.
Returns:
| Type | Description |
|---|---|
tuple of scipy.sparse.csr_matrix
|
(mass, stiffness), symmetric. |
Source code in src/visualdynamics/core/fem.py
rigid_body_vectors
¶
The six rigid-body motions, as columns over the model's DOFs.
Written down from the node positions rather than found from the matrices: they are what the null space of an unconstrained stiffness matrix is, and knowing them in advance is what lets a rigid mode be recognized as rigid rather than as a very soft one.
Source code in src/visualdynamics/core/fem.py
eigensolution
¶
eigensolution(maximum_frequency: float | None = None, num_modes: int | None = None, damping: float = 0.0, fixed: Sequence[str] = (), solver: str = 'auto', progress: Callable[[int, int], None] | None = None) -> ShapeSet
Real normal modes, mass-normalized, as a ShapeSet.
fixed names degrees of freedom to ground: '101X+' fixes one,
'101' fixes all six of that node. Rigid links are eliminated
exactly (constraint_transform).
Two solvers, one answer. Dense (a model of up to
SPARSE_ABOVE degrees of freedom): the symmetric generalized
problem K phi = lambda M phi solved whole, by factoring M
(Cholesky), reducing to a standard symmetric problem and
transforming back — every mode, mass-normalized to machine
precision. Sparse (larger models): the matrices stored as
their nonzeros (sparse_matrices) and the lowest modes found by
shift-invert Lanczos (ARPACK, scipy.sparse.linalg.eigsh), the
family the large finite element codes use; it finds the lowest
num_modes, or every mode up to maximum_frequency, and one of
the two must be said. Memory grows with the nodes instead of
their square: a plate model of 1,500 nodes, ~13 GB dense, is tens
of megabytes sparse (Brandon, 2026-09-26, for the BARC example —
the dense ceiling, deliberate until then, measured and met).
damping is a fraction of critical, applied uniformly. A model
has no damping of its own; it is stated so the modes can
synthesize an FRF that looks like a measurement.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
maximum_frequency
|
float
|
Keep every mode up to this frequency, in Hz. |
None
|
num_modes
|
int
|
Keep this many, the lowest, rigid ones included. |
None
|
damping
|
float
|
The fraction of critical damping every mode is given. |
0.0
|
fixed
|
sequence of str
|
Degrees of freedom to ground. |
()
|
solver
|
('auto', 'dense', 'sparse')
|
Which solver; 'auto' is dense up to |
'auto'
|
progress
|
callable
|
Told |
None
|
Returns:
| Type | Description |
|---|---|
ShapeSet
|
|
Source code in src/visualdynamics/core/fem.py
1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 | |
scaled_system
¶
The sparse eigenproblem as the sparse solver poses it.
Returns (K, M, T, S, sigma, stiffness): the constrained, symmetrically scaled stiffness and mass in CSC form, the constraint transform T and the scaling S that carry a solution back to every degree of freedom as T (S phi), the shift sigma, and the unconstrained sparse stiffness. Public so a test can hand the polish a start of its own choosing.
The shift is just below zero: the rigid-body modes (eigenvalue 0) are nearest it, so they come first, and K - sigma M = K + |sigma| M is positive definite even when K alone is singular (free-free). The scaling is symmetric and diagonal, S K S and S M S with S = 1/sqrt of the shifted diagonal: the same eigenvalues, the modes S phi'. A model of plates mixes meters with radians, and rigid links fold lever arms into the rotations — the rigid-link test model's shifted matrix had a condition number of 4e12, and Lanczos, which converges no better than its linear solves, left residuals of 1e-2. Scaled it is 4e9 (2026-09-26).
Source code in src/visualdynamics/core/fem.py
constraint_transform
¶
T, with u = T q: every degree of freedom of the model written in terms of those that remain free — grounded ones gone, and each rigid body's followers written through its first node, u_b = u_a + θ_a × (x_b − x_a) and θ_b = θ_a. The identity's columns when there is nothing to constrain.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fixed
|
sequence of str
|
Degrees of freedom to ground, as |
()
|
sparse
|
bool
|
Return it as a scipy CSR matrix, for the sparse solver. |
False
|
Returns:
| Type | Description |
|---|---|
ndarray or csr_matrix
|
(num_dof, remaining) and real. |
Source code in src/visualdynamics/core/fem.py
2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 | |
dangling_rotations
¶
The nodes whose rotations nothing acts on: touched by solids or translational springs and by nothing that carries a rotation — no beam, plate or triangle, and no rigid link, whose lead's rotation moves its followers. Neither a solid nor a spring along an axis gives a rotation stiffness, so these rotations are grounded by the eigensolution; left free they would be degrees of freedom with neither mass nor stiffness, which no factorization survives. A mass on springs is the newer case (2026-10-08): a tuned mass hung from a structure by spring lines, its node no beam's.
Returns:
| Type | Description |
|---|---|
list of int
|
The nodes, in the model's order. |
Source code in src/visualdynamics/core/fem.py
idle_lead_rotations
¶
(lead node, axis 0-2) for each rotation of a rigid body's lead that nothing acts on: a body only solids touch, whose nodes lie on one line, cannot feel a rotation about that line — it moves no follower, and a solid has no rotational stiffness — so that rotation is a degree of freedom with neither mass nor stiffness.
Found tying a beam of bricks to the channels under it (the BARC
in hexes, 2026-10-07): the two meshes' columns line up, so each
tied channel node leads only the beam nodes straight above it,
and the app's Tie on any two conforming brick meshes does the
same. Grounded like the rotations of a node only solids touch
(dangling_rotations): the rotation about the line where the
line runs along a global axis, all three where the body's nodes
coincide. A line at a slant is left to the factorization's own
refusal, which names it; grounding an oblique axis would need a
rotated basis, and no model here has needed one.
Returns:
| Type | Description |
|---|---|
list of (int, int)
|
The lead's node and the rotation's axis, in the model's order. |
Source code in src/visualdynamics/core/fem.py
loose_nodes
¶
The nodes nothing touches: no element, no rigid link, no lumped mass. A finite element deck carries them routinely — a reference point, a constraint's own grid — and a model built from its element groups grounds them whole rather than refusing the deck (2026-09-30); the grillage path still refuses, since there a loose node is a drawing that was never wired.
Returns:
| Type | Description |
|---|---|
list of int
|
The nodes, in the model's order. |
Source code in src/visualdynamics/core/fem.py
geometry
¶
geometry(beams: bool = True) -> Geometry
The model as a Geometry: nodes, beams as elements, faces as faces.
Beams become element type 21 (beam2) rather than tracelines, because they are elements — a traceline is a line drawn through nodes to make a display readable, and confusing the two would make the model's own connectivity indistinguishable from a drawing aid the moment anything edited it.
beams=False leaves them out, for a model whose members are all
wrapped in surfaces: there the beams run inside the shells, so
drawing them puts a wireframe over the thing it is the skeleton of.
Source code in src/visualdynamics/core/fem.py
2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 | |
Functions:¶
ground_axes
¶
A ground's directions as AXES names, in their order: True for
all six, False or None or nothing for none, else names ('X', 'RY'),
a sign or case making no difference — 'x+' is 'X'.
Source code in src/visualdynamics/core/fem.py
material
¶
material(name: str) -> Material
A library material by name — material('6061-T6') — the same
entry the Element Groups table's Material drop-down fills a row from.
Raises KeyError naming the library when the name is not in it,
so a typo reads as one rather than as a missing material.
Source code in src/visualdynamics/core/fem.py
with_article
¶
'a channel', 'an I-beam', 'an angle' — a shape named in a sentence.
angle_major_axis
¶
Where an angle's major principal axis lies — the direction its orientation vector points — in degrees from the long leg, turning toward the short leg. 45 for an equal angle.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
long_leg
|
float
|
The angle's dimensions, in any one unit. |
required |
short_leg
|
float
|
The angle's dimensions, in any one unit. |
required |
thickness
|
float
|
The angle's dimensions, in any one unit. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Degrees from the long leg, 0 to 180. |
Source code in src/visualdynamics/core/fem.py
connected_pieces
¶
The connected components of an adjacency map, largest first.
One piece is a structure; more than one is that many free bodies, each bringing its own six zero-frequency modes. The flood fill is shared by the model (asking over its beams and plates) and the drone's drawing (asking over its face edges, before a model exists), so the two cannot disagree about what "joined" means.
Source code in src/visualdynamics/core/fem.py
polish_modes
¶
polish_modes(stiffness: Any, mass: Any, sigma: float, vectors: ndarray, wanted: int, factor: Any = None, ticker: Any = None) -> tuple[ndarray, ndarray, ndarray]
Refine a block of approximate modes until they are converged.
Block inverse iteration with the shifted factor (K - sigma M), each
step followed by Rayleigh-Ritz on the block — the small projected
generalized problem solved densely — repeated until every one of the
first wanted modes has a relative residual below POLISH_RESIDUAL,
or a step no longer halves the worst of them (the floor the
conditioning sets), or POLISH_STEPS are spent. The residual of a mode is
|K v - lambda M v| over |(K - sigma M) v|, which is defined for a
rigid-body mode too (its numerator and |K v| are both zero). The
block is polished whole: the Ritz step resolves the modes inside
it exactly, and iteration removes what leaks in from outside, at
a rate set by the gap between the last wanted mode and the first
beyond the block, which is why the caller hands over a guard band.
Returns the eigenvalues, the mass-normal vectors, both for the whole
block in ascending order, and the residual of each after the last
step. Sparse stiffness and mass in CSC form.