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 modelling capability that produces something worth analysing: three-dimensional two-node beams 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 two ways in. Build a structure member by member, as the example
below does; or draw a shape as a surface mesh and let from_geometry put a
member along every edge of it and share the mass over its nodes. The second
is the shorter road from any geometry — imported or built — to a set of
modes, and visualdynamics.demo.drone is built that way throughout.
What it is not: a general finite element code. There are no shells, no solids, no constraints beyond fixing degrees of freedom, and no static solution. A plate is modelled the way a frame is, as a grillage of beams, which is a real modelling choice with a known cost rather than an approximation hidden inside an element. Wiring a surface mesh's edges is the same bargain: 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 millimetres 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
aluminium = fem.Material('aluminium', 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), aluminium, 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.
Classes:
| Name | Description |
|---|---|
Material |
An isotropic elastic material. |
Section |
A beam cross section, as the four numbers the element needs. |
Beam |
One two-node beam element. |
Plate |
One four-node rectangular plate-bending element. |
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 |
|---|---|
connected_pieces |
The connected components of an adjacency map, largest first. |
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 fibre 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.
Section
dataclass
¶
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.
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 |
|---|---|
round_tube |
A circular tube, given its outside diameter and wall thickness. |
rod |
A solid circular rod. |
rectangle |
A solid rectangle, |
square_tube |
A square tube, given its outside width and wall thickness. |
Methods:¶
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
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
square_tube
classmethod
¶
square_tube(
name: str, width: float, wall: float
) -> Section
A square tube, given its outside width and wall thickness.
Source code in src/visualdynamics/core/fem.py
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.
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.
Assembly is dense. The matrices are (6 x nodes) square, so a three-hundred-node model is a 1800 x 1800 pair — 26 MB and a couple of seconds to solve — and a thousand nodes is 290 MB and a minute or two. That is the working ceiling, and it is a deliberate one: a sparse assembly and a subspace solver would raise it by an order of magnitude and cost more code than the models this is here to build need. Measure before assuming it is the problem.
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.
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. |
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. |
rigid_body_vectors |
The six rigid-body motions, as columns over the model's DOFs. |
eigensolution |
Real normal modes, mass-normalized, as a ShapeSet. |
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
from_geometry
classmethod
¶
from_geometry(
geometry: Geometry,
material: Material,
section: Section,
total_mass: float | None = None,
tracelines: bool = False,
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.
tracelines wires the display polylines too, which is what a
wireframe geometry with no elements 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
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 | |
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 tracelines, 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 modeller'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.
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
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 recognised 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] = (),
) -> 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. The remaining problem is the
symmetric generalized one, K phi = lambda M phi, solved by
factoring M (Cholesky), reducing to a standard symmetric problem
and transforming back — which is what makes the shapes come out
mass-normalized to machine precision rather than normalized and
then rescaled.
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.
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
Functions:¶
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.