API reference#
This page enumerates every public class, function, and module
femorph-solver ships. Every symbol referenced from the
Reference chapters resolves to one of the entries
below — so a or cross-reference in
prose links to the right place in the rendered docs.
Model#
- class femorph_solver.model.Model(grid: UnstructuredGrid | None = None, *, unit_system: UnitSystem | str = UnitSystem.UNSPECIFIED)[source]#
Bases:
object- apply_force(node: int, *, fx: float = 0.0, fy: float = 0.0, fz: float = 0.0, mx: float = 0.0, my: float = 0.0, mz: float = 0.0, accumulate: bool = False) None[source]#
Apply a keyword-driven nodal force / moment vector.
Sparse by construction — zero-valued components are skipped. Any of the six
ForceLabelentries can be supplied as keyword arguments; unused ones stay zero (and unregistered on the Model).- Parameters:
node – Node number the load is applied at.
fx – Translational force components (units inherited from the Model’s
unit_system).fy – Translational force components (units inherited from the Model’s
unit_system).fz – Translational force components (units inherited from the Model’s
unit_system).mx – Moment components. Relevant for elements with rotational DOFs (
BEAM2/QUAD4_SHELL/ etc.).my – Moment components. Relevant for elements with rotational DOFs (
BEAM2/QUAD4_SHELL/ etc.).mz – Moment components. Relevant for elements with rotational DOFs (
BEAM2/QUAD4_SHELL/ etc.).accumulate –
False(default): the supplied components overwrite any previously-applied values at this(node, label)pair (mirrors the APDLFsemantics + the typical “build-and-replace” workflow).True: the components add to the existing values — the natural semantics for distributing a traction or pressure across multiple element segments that share nodes (multiple NASTRANFORCEcards on the same GRID, multiple Abaqus*CLOADrows, segmented integration of a traction-edge integral).
Examples
>>> model.apply_force(tip_node, fz=-100.0) # 100 N down >>> model.apply_force(pin_node, fx=200.0, mz=5.0)
Lump per-segment contributions on shared nodes:
>>> for seg in segments: ... for n, contribution in seg.lumped_force(node_traction).items(): ... model.apply_force(n, fx=contribution, accumulate=True)
- assert_unit_system(expected: UnitSystem | str) None[source]#
Raise
ValueErrorif this Model’sunit_systemis notexpected.Use this in scripts that require a specific unit convention — a mismatch between the caller’s assumption and the Model’s stamp usually means numbers will be silently wrong by a factor of \(10^n\).
UnitSystem.UNSPECIFIEDalways passes (no assertion possible on an unstamped Model).
- assign(element, material: dict[str, float] | None = None, *, et_id: int = 1, mat_id: int = 1, real_id: int | None = None, real: ndarray | tuple[float, ...] | None = None) None[source]#
Declare an element kernel and a material in one call.
- Parameters:
element – An
ELEMENTSspec class (default-configured shorthand, e.g.ELEMENTS.HEX8) or instance with named, typed formulation knobs (ELEMENTS.HEX8(integration="enhanced_strain")). Strings andElementTypemembers are not accepted; reach forAPDLif you need the string-form preprocessor verbs at the APDL-deck interop boundary.material – Mapping of material properties (
{"EX": ..., "PRXY": ..., "DENS": ...}). Values are stamped undermat_id(default1). Accepts the output offemorph_solver.materials.fetch()directly.et_id – Numeric ids under which to register the kernel / material / real constants. Only relevant when a model mixes multiple element types or materials; most single-physics demos use the defaults.
mat_id – Numeric ids under which to register the kernel / material / real constants. Only relevant when a model mixes multiple element types or materials; most single-physics demos use the defaults.
real_id – Numeric ids under which to register the kernel / material / real constants. Only relevant when a model mixes multiple element types or materials; most single-physics demos use the defaults.
real – Real-constant values (array-like of floats) to stamp under
real_id.Noneskips the real-constant declaration.
- property bcs: BoundaryConditions#
Typed view of the Model’s Dirichlet boundary-condition state.
Zero-copy view over the internal
(node, dof) → valuestorage. Provides clean inspection (bcs.fixed,bcs.n_pinned,len(bcs),bcs.is_fixed(node, dof)), mutation helpers (bcs.pin(...),bcs.clear()), and abcs.to_table()for REPL printing.Every
fix()/d()call on the Model shows up here immediately; the view doesn’t duplicate state.
- cyclic_modal_solve(*, low_face: np.ndarray, high_face: np.ndarray, n_sectors: int, n_modes: int = 10, sector_angle: float | None = None, pair_rotation: np.ndarray | None = None, harmonic_indices: Sequence[int] | None = None, lumped: bool = False, linear_solver: str = 'auto') list[CyclicModalResult][source]#
Solve the cyclic-symmetry modal problem on a single base sector.
Wraps
femorph_solver.solvers.cyclic.solve_cyclic_modal()with the Model’sK/Mand a node-array → DOF-array translation, so the caller works in node space (the same space asfix()/apply_force()).- Parameters:
low_face (ndarray) – Nodes on the rotor’s two cyclic faces. Either node numbers (1-D int array) or boolean masks of length
Model.n_nodesindexed in grid-point order — the same forms accepted byfix(). Both faces must list the same number of paired nodes in matching order.high_face (ndarray) – Nodes on the rotor’s two cyclic faces. Either node numbers (1-D int array) or boolean masks of length
Model.n_nodesindexed in grid-point order — the same forms accepted byfix(). Both faces must list the same number of paired nodes in matching order.n_sectors (int) – Number of repetitions that close the rotor (
N).n_modes (int, default 10) – Number of lowest modes per harmonic index.
sector_angle (float, optional) – Sector opening angle in radians (defaults to
2π / n_sectors). Used only to derivepair_rotationwhen the latter is omitted. Override on a non-uniform sector or when low/high faces already sit in a per-sector local frame (then passpair_rotation=Noneandsector_angle=None).pair_rotation ((3, 3) array, optional) – Spatial rotation R(α) mapping sector j’s local frame to sector j+1’s. When
None, derived fromsector_angleas a +Z-axis rotation; pass explicitRfor off-axis rotors.harmonic_indices (sequence of int, optional) – Harmonic indices to solve. Defaults to
0, 1, …, N//2.lumped (bool, default False) – Use a row-summed lumped mass instead of consistent mass.
linear_solver (str, default
"auto") – Linear-solver backend identifier registered infemorph_solver.solvers.linear.
- Returns:
One result per harmonic index, each carrying
omega_sq,frequency,mode_shapes, and the harmonic indexk.- Return type:
- dof_map() ndarray[source]#
Return an
(N, 2)array of(mapdl_node_num, local_dof_idx).Row
iof K/M corresponds todof_map()[i].local_dof_idxis the 0-based position in the canonical DOF order(UX, UY, UZ, ROTX, ROTY, ROTZ). Nodes only get rows for the DOFs the elements at them actually need, so a truss model stays 3-DOF while a beam model gets 6.
- eel(u: ndarray, *, nodal_avg: bool = True) ndarray | dict[int, ndarray][source]#
Elastic strain from a displacement vector.
Strain is evaluated at each element’s own nodes by ε(ξ_node) = B(ξ_node)·u_e — direct nodal evaluation rather than Gauss extrapolation, but the two agree exactly for uniform-strain fields and converge on mesh refinement. Output is 6-component Voigt with engineering shears
[εxx, εyy, εzz, γxy, γyz, γxz].- Parameters:
u ((N,) float64) – Global displacement vector indexed by
dof_map()— i.e. theStaticResult.displacementfromsolve()or a mode shape frommodal_solve().nodal_avg (bool, default True) – If
True(the default), values at shared nodes are averaged across every element touching them — returns a(n_nodes_global, 6)array indexed bynode_numbers. IfFalse, returns the per-element dict{elem_num: (n_nodes_in_elem, 6)}— unaveraged, likePLESOL.
- element_info(elem_num: int) tuple[list[int], int, int, int][source]#
Return
(node_nums, et_id, mat_id, real_id)for an element.Convenience accessor for the stress-recovery / strain-export paths that previously reached into
model._elements[en]. O(log n) lookup; liftgrid.cell_data/grid.cell_connectivityyourself for bulk iteration.
- estimate_solve(n_modes: int = 10, *, linear_solver: str = 'auto', eigen_solver: str = 'arpack', ooc: bool = False, host_spec=None)[source]#
Predicted
(wall_s, peak_rss_mb)for a modal solve on this model and machine, without running it.Wraps
Estimator— reads training rows from anyfemorph-benchmark-*.jsonfiles in the current working directory and fits per-(host, backend) log-log power laws on the fly. No training data → falls back to a shape-of-universe prior tuned from the repo’s own 14900KF benchmarks; the returnedp95band then sits at 2×p50so callers can tell they’re looking at a prior, not a calibrated prediction.- Parameters:
n_modes (int) – Modes requested (matches
modal_solve()).linear_solver (str) – Backend names — picks which per-bucket fit applies.
eigen_solver (str) – Backend names — picks which per-bucket fit applies.
ooc (bool, default False) –
Trueapplies the measured OOC multipliers (+3.5×wall,−22 %peak) on top of the in-core prediction; useful whenmodal_solvewould otherwise exceed RAM.host_spec (HostSpec or None) – Override host detection (useful for “what would this take on a different box?” queries).
None→ auto- detect.
- Returns:
p50/p95bands + the training-bucket diagnostic theEstimatorpicked.- Return type:
- fix(nodes: ndarray | list[int] | tuple[int, ...] | int | None = None, *, where: ndarray | None = None, dof: str | DOFLabel | list[str | DOFLabel] | tuple[str | DOFLabel, ...] = 'ALL', value: float = 0.0) None[source]#
Pin nodal DOFs to a prescribed value (Dirichlet boundary condition).
- Parameters:
nodes – Single node number, or an array / iterable of node numbers. Mutually exclusive with
where.where – Boolean mask of length
Model.n_nodesselecting grid points to pin. Indexed by VTK point order — the same order used bygrid.points. Mutually exclusive withnodes.dof (str,
DOFLabel, or sequence thereof,) – default"ALL"DOF (or DOFs) to pin."ALL"clamps every DOF active at the selected node(s); a sequence clamps each listed DOF in turn.value (float, default
0.0) – Value to prescribe (the right-hand side inK u = F). Zero is the lossless case for every analysis type; non-zero values only round-trip cleanly throughsolve_static().
Examples
Clamp everything at the
x = 0face of a bar:model.fix(where=grid.points[:, 0] < 1e-9)
Pin a single node’s UZ:
model.fix(nodes=42, dof="UZ")
Pin multiple DOFs at once:
model.fix(where=base_mask, dof=["UX", "UY", "UZ"])
- classmethod from_chain(points: ndarray) Model[source]#
Build a 1-D chain Model — sequential 2-node line cells.
Convenience wrapper around
from_lines()for the common case of N points connected sequentially:(0,1), (1,2), …, (N-2, N-1).- Parameters:
points (numpy.ndarray) –
(n_points, 3)nodal coordinates along the chain.- Returns:
Model with
n_points - 1line cells.- Return type:
- classmethod from_grid(grid: UnstructuredGrid) Model[source]#
Wrap an existing
pyvista.UnstructuredGridas a Model.The grid is expected to carry the structured cell/point data arrays (
ansys_node_num,ansys_elem_num,ansys_elem_type_num,ansys_material_type,ansys_real_constant). Missing arrays are auto-filled with sequential 1-based ids so meshes built directly in pyvista (e.g. aStructuredGridcast to unstructured) are usable without the caller stamping every field themselves.The grid becomes the Model’s sole source of truth for geometry and connectivity — the staging dicts stay empty for the lifetime of the Model unless the caller later mutates via
n()/e(). Two incidental passes happen here:cell_connectivity/offsetare coerced to int32 if they came in as int64 (VTK’s default on 64-bit builds) — halves the cell-structure footprint and removes a copy-cast the hot path would otherwise do on every assembly call.Points and cells are reordered so
ansys_node_numandansys_elem_numare strictly increasing. Every downstream hot path assumes node-number-sorted order to produce a deterministic DOF layout; canonicalising here keeps the invariant local to Model construction rather than leaking into every consumer.
- classmethod from_lines(nodes: ndarray, elements: ndarray) Model[source]#
Build a Model from explicit nodes + 2-node line connectivity.
Produces an
pyvista.UnstructuredGridwith VTK_LINE cells — the canonical cell type for TRUSS2 / BEAM2 / SPRING. After construction, attach an element kernel viamodel.assign(...).- Parameters:
nodes (numpy.ndarray) –
(n_points, 3)float64 nodal coordinates.elements (numpy.ndarray) –
(n_elements, 2)int connectivity — each row is(node_i, node_j)indexing intonodes(0-based).
- Returns:
Native Model with
ansys_node_numandansys_elem_numstamped 1-based, ready forassign()/fix()/apply_force().- Return type:
Examples
Two-link axial truss:
>>> model = Model.from_lines( ... nodes=np.array([[0, 0, 0], [1, 0, 0], [2, 0, 0]], dtype=float), ... elements=np.array([[0, 1], [1, 2]], dtype=int), ... ) >>> model.assign(ELEMENTS.TRUSS2, {"EX": 2.0e11}, real=(1e-4,))
- classmethod from_points(nodes: ndarray) Model[source]#
Build a Model from explicit nodes as 0-D VTK_VERTEX cells.
Each row of
nodesbecomes one single-node cell — the canonical layout for POINT_MASS (POINT_MASS). Node ids and element ids are stamped 1-based byfrom_grid().- Parameters:
nodes (numpy.ndarray) –
(n_points, 3)nodal coordinates.- Returns:
Model with
n_pointsvertex cells.- Return type:
- classmethod from_pv(path: str | Path) Model[source]#
Load a single-file
.pvModel — alias ofload().Mirrors the
from_<format>family (from_grid(),from_lines(),from_chain(),from_points()) for callers who prefer thefrom_pvspelling for the native single-file formatsave()writes.
- harmonic_solve(*, frequencies: np.ndarray | Sequence[float], n_modes: int = 10, load: np.ndarray | None = None, modal_damping_ratio: float | Sequence[float] | np.ndarray | None = None, rayleigh: tuple[float, float] | None = None, lumped: bool = False, eigen_solver: str = 'arpack', linear_solver: str = 'auto', tol: float = 1e-12, modal_result: ModalResult | None = None) HarmonicResult[source]#
Run a frequency-response (harmonic) analysis by modal superposition.
Solves
(K - ω² M + i ω C) u(ω) = Fat each excitation frequency by projecting onto the lowestn_modesmass- orthonormal eigenmodes of the undamped system.Cis introduced through per-mode damping ratios — seefemorph_solver.solvers.harmonic.solve_harmonic_modal()for the full superposition / damping math.- Parameters:
frequencies –
(n_freq,)excitation frequencies [Hz].n_modes – Number of modes retained in the superposition. Ignored when
modal_resultis supplied.load –
(N,)complex (or real) DOF-indexed load. Defaults to the F-staged nodal forces fromapply_force().modal_damping_ratio – Scalar (uniform) or per-mode damping ratio ζ_n ∈ [0, 1).
rayleigh –
(α, β)Rayleigh constants such thatζ_n = α/(2 ω_n) + β ω_n / 2. May be combined withmodal_damping_ratio.lumped – Forwarded to the underlying
modal_solve()whenmodal_resultis not supplied.eigen_solver – Forwarded to the underlying
modal_solve()whenmodal_resultis not supplied.linear_solver – Forwarded to the underlying
modal_solve()whenmodal_resultis not supplied.tol – Forwarded to the underlying
modal_solve()whenmodal_resultis not supplied.modal_result – Reuse a precomputed
ModalResultinstead of recomputing — useful for parametric forcing sweeps where the eigenproblem doesn’t change.
- Returns:
Frequency-domain response with
.displacementshaped(n_freq, N)complex.- Return type:
HarmonicResult
- classmethod load(path: str | Path) Model[source]#
Inverse of
save()— read a.pvback into a Model.- Parameters:
path (str or pathlib.Path) – Path to a file previously written by
save().- Returns:
Fresh Model with the saved geometry, etypes, materials, real constants, BCs, loads, and unit system applied.
- Return type:
- Raises:
FileNotFoundError – If the file does not exist.
ValueError – If the file is missing the solver-internal state blob (i.e. it was not written by
save()).
- property loads: Loads#
Typed view of the Model’s applied nodal forces / moments.
Mirror of
bcsforapply_force()/f()state. Supportsloads.forces,loads.n_applied,loads.apply(node, fz=...),loads.clear(), andloads.to_table().
- mass_matrix(lumped: bool = False) csr_array[source]#
Assemble the global mass matrix as a
scipy.sparse.csr_array.Same caching semantics as
stiffness_matrix()— consistent and lumped variants are cached independently.
- modal_solve(n_modes: int = 10, lumped: bool = False, *, eigen_solver: str = 'arpack', linear_solver: str = 'auto', tol: float = 1e-12, release_inputs: bool = True, mixed_precision: bool | None = False, progress: bool = False) ModalResult[source]#
Run a linear modal analysis.
Solves
K φ = ω² M φwith D-staged Dirichlet BCs applied. Returns the lowestn_modesmodes, ordered by frequency.eigen_solverpicks the sparse eigenbackend ("arpack","lobpcg","primme","dense");linear_solverpicks the inner shift-invert factorizer. The default"auto"picks the fastest SPD direct solver available (pardiso → cholmod → umfpack → superlu); pass an explicit name ("superlu","cholmod", …) to override. Seefemorph_solver.solvers.eigen.list_eigen_solvers()andfemorph_solver.solvers.linear.list_linear_solvers().release_inputs=True(the default) drops the Model’s cached full K / M the moment the BC-reducedK_ff/M_ffare built, and before the Pardiso factor allocates. On a 1.2 M-DOF HEX20 problem that saves ~2 GB of peak RSS (the full K and M each run ~1 GB + scaling with density).Time cost: negligible on the first call — CPython’s refcount frees the full K / M immediately when the BC-reduce helper returns (no
gc.collectinvolved; the sparse-matrix graph has no cycles so a full GC would be pure overhead). On repeat calls on the same Model however the full K / M must be re-assembled from elements (~1-3 s at large sizes) because the cache was released; parametric sweeps should passrelease_inputs=Falseto keep the warm-assembly cache.mixed_precisioncontrols the inner Pardiso factor precision (ignored by non-Pardiso backends):False(default) — factor and solve in double precision. Identical frequencies to established commercial solvers at machine-epsilon.True— factor in single precision and refine with double-precision residuals. Halves the factor’s permanent footprint when the linked MKL honours the request; the refinement typically recovers ≥13-digit eigenvalue accuracy on well-conditioned SPD stiffness matrices.None— let femorph-solver decide: opts in for Pardiso SPD factorisation above ~50 k free DOFs, where the memory saving matters most. Not recommended for production models that are known to be ill-conditioned (e.g. highly anisotropic composites, degenerate meshes with near-zero-Jacobian cells).
Note
Mixed-precision Pardiso depends on MKL exposing
iparm(28)through pypardiso; some MKL builds silently no-op the request. Checklu._solver.get_iparm(28)after a factorize if you need to confirm the request was honoured.
- node_coord(node_num: int) tuple[float, float, float][source]#
Return
(x, y, z)for the given node number.The grid is the only permanent storage, so single-node lookups aren’t O(1) the way the old
_nodes[nn]dict lookup was — but callers that do this in a hot loop should lift the fullgrid.pointsarray viaModel.grid()instead.
- release_matrix_cache() None[source]#
Drop the cached assembled K / M matrices.
Each cached matrix is roughly
nnz × 12 bytes— on a 1.2 M-DOF plate with ~100 M nnz that’s ~1.2 GB each, so releasing both saves ~2.4 GB. Call after amodal_solve/static_solvefinishes and the caller is done with further analyses on this Model. Subsequentstiffness_matrix()ormass_matrix()calls will re-assemble.
- save(path: str | Path) Path[source]#
Serialise this Model to a single
.pvfile (TA-15 / #265).The mesh, cell- / point-data, materials, real constants, boundary conditions, force loads, and unit-system stamp all round-trip through
Model.load(). Solver-internal state is stamped intofield_dataunder keys prefixed with__solver_key_; a vanilla pyvista reader can recognise those as opaque and skip them.- Parameters:
path (str or pathlib.Path) – Destination file (
.pvextension conventional).- Returns:
Canonicalised absolute path of the written file.
- Return type:
Examples
Build, save, restore, verify the round-trip is exact:
>>> model = fs.Model.from_grid(grid) >>> model.assign(ELEMENTS.HEX8, {"EX": 2.0e11, "PRXY": 0.30}) >>> model.save("/tmp/cantilever.pv") >>> restored = fs.Model.load("/tmp/cantilever.pv") >>> (model.stiffness_matrix() != restored.stiffness_matrix()).nnz == 0 True
- set_unit_system(unit_system: UnitSystem | str) None[source]#
Declare the
UnitSystemthis Model’s numeric inputs follow.Bookkeeping only — the solver’s math is dimensionless. The stamp travels with the Model through assembly, solve, and the result serialiser so downstream readers can interpret the numbers.
- solve(*, linear_solver: str = 'auto', progress: bool = False) StaticResult[source]#
Run a linear static analysis.
Uses the K assembled from current elements, the D-staged Dirichlet BCs, and the F-staged nodal forces. Returns a
StaticResultwhosedisplacement/reactionarrays are indexed bydof_map().linear_solverpicks the sparse backend (seefemorph_solver.solvers.linear.list_linear_solvers()).- Parameters:
linear_solver (str, default
"auto") – Sparse linear backend name.progress (bool, default
False) – Show a per-stage progress display (rich→tqdm→ logger fallback). Off by default for scripted use; the CLI front-end flips it on.
- stiffness_matrix() csr_array[source]#
Assemble the global stiffness matrix as a
scipy.sparse.csr_array.Row/column
icorresponds todof_map()rowi.Cached on the Model: repeat calls on an unchanged Model skip the ~2 s assembly phase entirely. The cache is invalidated by any structural (
n/e/et) or material (mp/r) mutation.
- strain(displacement: ndarray) dict[int, ndarray][source]#
Compute per-element elastic strain from a global displacement vector.
Returns a dict mapping element number →
(n_nodes, 6)engineering-shear Voigt strain sampled at each element node. Shares the kernel path withwrite_static_result()/write_modal_result()but stays in memory — nothing hits disk. Use this to recover strain from any displacement field (static result, one modal shape, a morphed guess) without round-tripping through.pv.displacementmust be indexed bydof_map()(the same layout the solvers produce). Elements whose kernel returnsNonefromeel_batchare skipped.
- transient_solve(*, dt: float, n_steps: int, load: Callable[[float], np.ndarray] | np.ndarray | None = None, lumped: bool = False, damping: _sp.csr_array | None = None, u0: np.ndarray | None = None, v0: np.ndarray | None = None, beta: float = 0.25, gamma: float = 0.5) TransientResult[source]#
Run a linear transient analysis.
Integrates
M ü + C u̇ + K u = F(t)with Newmark-β. The loadF(t)defaults to the F-staged nodal forces held constant over the interval; pass a callablet -> (N,)for time-varying loads or a fixed array to override the staged values.
- property unit_system: UnitSystem#
Current
UnitSystemstamp.Defaults to
UnitSystem.UNSPECIFIEDfor legacy Models that pre-date this attribute. Set viaset_unit_system(), pass via the constructor, or letModel.from_grid()/femorph_solver.mapdl_api.from_cdb()detect it from the input.
Solver orchestration#
Linear static solve.
Partitions the global system K u = F into free / fixed DOF blocks:
[ K_ff K_fc ] [ u_f ] [ F_f ]
[ K_cf K_cc ] [ u_c ] = [ R_c ]
with u_c prescribed. u_f is recovered with a pluggable linear
backend from femorph_solver.solvers.linear. Reaction forces at
constrained DOFs are recovered as
R_c = K_cf · u_f + K_cc · u_c - F_c (sign convention: R_c is the
external reaction needed to hold u_c).
References
Zienkiewicz, O. C., Taylor, R. L., & Zhu, J. Z. The Finite Element Method: Its Basis and Fundamentals, 7th ed., §1 and §9. [the DOF partitioning / master-slave reduction formulated above]
Cook, R. D., Malkus, D. S., Plesha, M. E., & Witt, R. J. Concepts and Applications of Finite Element Analysis, 4th ed., §2. [recovery of reaction forces from the constrained rows]
Bathe, K.-J. Finite Element Procedures, 2nd ed., §3.4. [direct elimination of prescribed DOFs — the form used here, as opposed to penalty or Lagrange-multiplier alternatives]
- class femorph_solver.solvers.static.StaticResult(displacement: ndarray, reaction: ndarray, free_mask: ndarray)[source]#
Bases:
objectResult of a linear static solve.
- displacement#
(N,)DOF-indexed displacement produced by the solve.- Type:
- reaction#
(N,)reaction force; nonzero only at constrained DOFs.- Type:
- free_mask#
(N,)bool —Trueat solved-for DOFs.- Type:
- save(path: str | Path, model: Model, *, unit_system: str = 'SI', extra_field_data: dict[str, np.ndarray] | None = None) Path[source]#
Serialise this static result to a disk-backed
.pvfile.Re-projects the DOF-indexed
displacement(andreactionas the force field) onto per-node(n_points, n_dof_per_node)arrays using the model’sdof_map(), then hands off towrite_static_result().- Parameters:
path (str or pathlib.Path) – Destination file (
.pvextension conventional).model (femorph_solver.model.Model) – Model whose K matrix this result was computed from; supplies the mesh + DOF layout.
extra_field_data (mapping, optional) – Extra
field_dataentries (e.g. solver statistics).
- Returns:
Canonicalised absolute path to the written file.
- Return type:
- femorph_solver.solvers.static.solve_static(K: csr_array, F: ndarray, prescribed: dict[int, float], *, linear_solver: str = 'auto', thread_limit: int | None = None) StaticResult[source]#
Solve
K u = Fwith Dirichlet BCs at the indices inprescribed.- Parameters:
K ((N, N) scipy.sparse.csr_array)
F ((N,) float64 load vector)
prescribed (mapping
{global_dof_index: prescribed_value})linear_solver (str) – Name of a registered linear backend — see
femorph_solver.solvers.linear.list_linear_solvers().thread_limit (int or None, optional) – Cap on BLAS / OpenMP threads used inside the linear solve.
Nonedefers to the global limit (seefemorph_solver.set_thread_limit()).
Generalized-eigenvalue modal solve.
Solves K φ = ω² M φ for the lowest n_modes free-vibration modes
with Dirichlet BCs partitioned out. DOFs with zero diagonal stiffness (for
example the transverse DOFs of a pure axial truss) are dropped as
rigid-body modes — the canonical free-vibration default.
For n_free <= DENSE_CUTOFF we always use the dense LAPACK path
(scipy.linalg.eigh()). For larger systems the eigen backend is
configurable via the eigen_solver / linear_solver kwargs — see
femorph_solver.solvers.eigen and femorph_solver.solvers.linear for the
registered options.
References
Bathe, K.-J. Finite Element Procedures, 2nd ed., Prentice-Hall (2014), §11. [structural eigenproblem
K φ = ω² M φand discussion of subspace / Lanczos methods]Hughes, T. J. R. The Finite Element Method: Linear Static and Dynamic Finite Element Analysis, §9. [free-vibration modal analysis, M-orthonormalisation, rigid-body mode treatment]
Wilkinson, J. H. The Algebraic Eigenvalue Problem, Oxford (1965). [reference text for the symmetric generalized eigenvalue problem solved by
scipy.linalg.eighon the dense branch (LAPACK’s DSYGVD)]
- class femorph_solver.solvers.modal.ModalResult(omega_sq: ndarray, frequency: ndarray, mode_shapes: ndarray, free_mask: ndarray)[source]#
Bases:
objectResult of a modal solve.
- omega_sq#
(n_modes,)eigenvalues ω² = (2π f)².- Type:
- frequency#
(n_modes,)cyclic frequencies f [Hz].- Type:
- mode_shapes#
(N, n_modes)DOF-indexed mode shapes — each column is one mode in the global DOF ordering produced byfemorph_solver.model.Model.dof_map().- Type:
- free_mask#
(N,)bool — DOFs kept in the eigenproblem after Dirichlet reduction.- Type:
- save(path: str | Path, model: Model, *, unit_system: str = 'SI', extra_field_data: dict[str, np.ndarray] | None = None) Path[source]#
Serialise this modal result to a disk-backed
.pvfile.Re-projects the DOF-indexed
mode_shapesonto per-node(n_points, n_dof_per_node)arrays using the model’sdof_map(), then hands off towrite_modal_result().- Parameters:
path (str or pathlib.Path) – Destination file (
.pvextension conventional).model (femorph_solver.model.Model) – The model whose K/M matrices this result was computed from — supplies the mesh geometry and DOF layout needed to reshape
mode_shapes.extra_field_data (mapping, optional) – Extra
field_dataentries (e.g. solver statistics).
- Returns:
Canonicalised absolute path to the written file.
- Return type:
Notes
After saving, load the file back via
femorph_solver.result.read(); the resulting object is aModalResult(disk-backed) with the same frequencies and per-mode displacements plus the full plotting / animation surface.
- femorph_solver.solvers.modal.solve_modal(K: csr_array, M: csr_array, prescribed: dict[int, float], n_modes: int = 10, *, eigen_solver: str = 'arpack', linear_solver: str = 'auto', sigma: float = 0.0, tol: float = 1e-12, thread_limit: int | None = None, mixed_precision: bool | None = False, v0: ndarray | None = None) ModalResult[source]#
Solve the generalized eigenproblem
K φ = ω² M φ.- Parameters:
K (scipy.sparse.csr_array) – Global stiffness and mass matrices, same shape.
M (scipy.sparse.csr_array) – Global stiffness and mass matrices, same shape.
prescribed (mapping
{global_dof_index: 0}) – Indices to drop from the eigenproblem (clamped / zero-stiffness DOFs).n_modes (int) – Number of lowest modes to return.
eigen_solver (str) – Name of a registered eigen backend (
"arpack","lobpcg","primme","dense"), or"auto"to let the solver pick based on problem size. The autotune prefers PRIMME forn_modes > 20when the package is installed (faster on the clustered eigenvalue spectra typical of rotor modal analyses); it falls back to ARPACK otherwise. Seefemorph_solver.solvers.eigen.list_eigen_solvers().linear_solver (str) – Name of the registered linear backend used by ARPACK’s shift-invert step. Pass
"auto"(the default) to pick the fastest available SPD direct solver (pardiso → cholmod → umfpack → superlu). Seefemorph_solver.solvers.linear.list_linear_solvers()for the full registry.tol (float) – Convergence tolerance passed to the sparse eigensolver. Default
1e-12— tight enough that the returned frequencies agree with a full machine-epsilon (tol=0) solve to at least 13 significant digits on typical modal problems, while saving ~25 % of ARPACK iterations. Pass0.0to force ARPACK to converge to full machine precision; pass a looser value (1e-10,1e-8) when wall-time matters more than the last couple of digits.thread_limit (int or None, optional) – Cap on BLAS / OpenMP threads used inside the dense and sparse eigenpaths.
None(default) defers to the global limit set viafemorph_solver.set_thread_limit()or theFEMORPH_SOLVER_NUM_THREADSenvironment variable; pass an explicit integer to override for a single call.v0 (numpy.ndarray or None, optional) – Starting vector for ARPACK’s Lanczos iteration. Default is
None— ARPACK seeds from a random vector. Pass a previous mode shape (full-system lengthNor reduced-system lengthn_free) to warm-start a parametric sweep — ARPACK’s convergence iterations typically drop 1.5–3× when the new eigenvectors are close to the old ones (e.g. material sensitivity studies, mistuning identification). Ignored by non-ARPACK eigen backends.
- femorph_solver.solvers.modal.solve_modal_reduced(K_ff: csr_array, M_ff: csr_array, free_mask: ndarray, n_modes: int = 10, *, eigen_solver: str = 'arpack', linear_solver: str = 'auto', sigma: float = 0.0, tol: float = 1e-12, thread_limit: int | None = None, mixed_precision: bool | None = False, v0: ndarray | None = None) ModalResult[source]#
Solve the eigenproblem directly on pre-reduced
K_ff/M_ff.Skips the full-matrix BC slicing that
solve_modal()does, so the caller can buildK_ff/M_ffvia_bc_reduce(), release the originalK/M(Model cache + their own refs), then call this to run the eigensolve with a peak RSS roughlyK_ff + M_ff + triu + MKL factorinstead of the default path’sK + M + K_ff + M_ff + triu + MKL factor.
Cyclic-symmetry modal analysis for a single base sector.
A rotor with N identical sectors spanning 360° satisfies, at each
harmonic index (nodal diameter) k ∈ {0, 1, …, N/2},
u_sector_{j+1}(global) = e^{i k α} · R(α) · u_sector_j(global)
with α = 2π/N and R(α) the spatial rotation about the symmetry
axis. Applied at the base-sector’s high-angle face (which is identified
with sector 1’s low-angle face), this gives the constraint
u_high(global) = e^{i k α} · R(α) · u_low(global)
relating paired low-/high-face DOFs. That constraint lets a modal solve work on one sector and produce the full-rotor spectrum one k at a time — an N-times speedup relative to meshing the whole rotor.
The reduction here mirrors the standard cyclic-symmetry reduction in its complex form.
Base-sector DOFs are partitioned into interior I and low-face L
/ high-face H sets, with a one-to-one (low-node, high-node)
correspondence given by the user. High DOFs are eliminated via the
constraint above; the remaining [u_I; u_L] vector satisfies a
Hermitian generalised eigenproblem
K_red φ = ω² M_red φ
with K_red = P^H K P and M_red = P^H M P (K, M real SPD; P the
complex constraint matrix that embeds the reduced vector back into the
full one). For k=0 and k=N/2 (even N) the phase is ±1 and the reduced
problem can be real; for intermediate k it is complex Hermitian.
Per-pair rotation#
Stiffness / mass are usually assembled in a global XYZ frame, which
means the cyclic phase relation picks up the same rotation that maps
sector j to sector j+1. Pass pair_rotation as the (d, d) matrix
R(α) acting on each node pair’s d translational DOFs (3 for a 3D
solid sweeping about z). Omit it (None) when face DOFs are already
in a cylindrical / per-sector local frame, in which case R(α) reduces to
the identity.
Harmonic-index counting#
k=0 — single real eigenmodes (in-phase family)
k=N/2 (even N) — single real eigenmodes (anti-phase family)
0 < k < N/2 — each eigenvalue corresponds to a degenerate pair in the full rotor (travelling-wave directions). The complex reduced solve returns them once per k; the full-rotor multiset is recovered by counting them twice.
References
Thomas, D. L. “Dynamics of rotationally periodic structures.” International Journal for Numerical Methods in Engineering 14 (1), 81-102 (1979). [the foundational complex-valued cyclic reduction applied here — one base sector factored at each harmonic index
krecovers the full-rotor spectrum]Wildheim, S. J. “Vibrations of rotationally periodic structures.” Ph.D. dissertation, Linköping University (1979). [companion treatment of the constraint matrix
Pand its Hermitian reductionP^H K P]Bathe, K.-J. Finite Element Procedures, Prentice Hall (1996), §10.3.4 (cyclic symmetry). [textbook derivation matching the partitioned
[u_I; u_L]variable convention used below]
- class femorph_solver.solvers.cyclic.CyclicModalResult(harmonic_index: int, n_sectors: int, omega_sq: ndarray, frequency: ndarray, mode_shapes: ndarray, axis_point: ndarray | None = None, axis_dir: ndarray | None = None)[source]#
Bases:
objectModes for one harmonic index of a cyclic-symmetry modal solve.
- axis_dir: ndarray | None = None#
(3,) — unit vector along the rotor axis.
Nonemeans “not recorded”; writers default to+Z. Populated bysolve_cyclic_modal()from the suppliedpair_rotation.
- axis_point: ndarray | None = None#
(3,) — point on the rotor rotation axis.
Nonemeans “not recorded by the caller”; writers default to the origin in that case. Populated bysolve_cyclic_modal()when apair_rotationis supplied.
- femorph_solver.solvers.cyclic.save_cyclic_modal_results(results: Sequence[CyclicModalResult], path: str | Path, model: Model, *, unit_system: str = 'SI', extra_field_data: dict[str, np.ndarray] | None = None) Path[source]#
Serialise a cyclic-modal sweep to a single disk-backed
.pvfile.- Parameters:
results (sequence of CyclicModalResult) – Per-harmonic-index results as returned by
solve_cyclic_modal(). All entries must share the samen_sectors.path (str or pathlib.Path) – Destination file.
model (femorph_solver.model.Model) – Model whose K / M matrices this sweep was computed on; supplies the mesh and DOF layout.
extra_field_data (mapping, optional) – Extra
field_dataentries (e.g. solver statistics).
- Returns:
Canonicalised absolute path to the written file.
- Return type:
Notes
The DOF-indexed
(N, n_modes)complex mode shapes on each per-kresult get re-projected onto per-(k, mode), per-node(n_modes, n_points, 6)complex arrays — the layoutwrite_cyclic_modal_result()expects.
- femorph_solver.solvers.cyclic.solve_cyclic_modal(K: spmatrix | sparray, M: spmatrix | sparray, low_node_dofs: ndarray, high_node_dofs: ndarray, n_sectors: int, n_modes: int = 10, *, pair_rotation: ndarray | None = None, harmonic_indices: Sequence[int] | None = None, prescribed: dict[int, float] | None = None, thread_limit: int | None = None, linear_solver: str = 'auto') list[CyclicModalResult][source]#
Solve the cyclic-symmetry modal problem on a single base sector.
- Parameters:
K (scipy.sparse) – Global stiffness / mass of the base sector, before cyclic or Dirichlet reduction. Assumed real SPD.
M (scipy.sparse) – Global stiffness / mass of the base sector, before cyclic or Dirichlet reduction. Assumed real SPD.
low_node_dofs ((P, d) int arrays) – Global DOF indices at
Ppaired boundary nodes, each withdDOFs (typically 3 for a 3D solid).low_node_dofs[i, :]andhigh_node_dofs[i, :]must address the same physical DOFs (same labels, same order) at the node pair that’s identified by the cyclic BC. A 1-D array is accepted for scalar (d=1) problems.high_node_dofs ((P, d) int arrays) – Global DOF indices at
Ppaired boundary nodes, each withdDOFs (typically 3 for a 3D solid).low_node_dofs[i, :]andhigh_node_dofs[i, :]must address the same physical DOFs (same labels, same order) at the node pair that’s identified by the cyclic BC. A 1-D array is accepted for scalar (d=1) problems.n_sectors (int) – Number of repetitions that close the rotor (
N).n_modes (int, default 10) – Number of lowest modes per harmonic index.
pair_rotation ((d, d) array, optional) – Spatial rotation R(α) that maps sector j’s local frame to sector j+1’s, applied per node pair. Pass
Nonewhen face DOFs are already in a per-sector local frame (then R=I implied).harmonic_indices (sequence of int, optional) – Harmonic indices to solve. Defaults to
0, 1, …, N//2.prescribed (mapping, optional) – Dirichlet BCs on the sector (global DOF index → 0). Currently only zero-value Dirichlet is supported; if a cyclic-face DOF is prescribed its partner must also be prescribed (so the cyclic constraint is trivially satisfied at that DOF).
thread_limit (int or None, optional) – Cap on BLAS / OpenMP threads used inside the per-harmonic dense Hermitian eigensolve.
Nonedefers to the global limit (seefemorph_solver.set_thread_limit()).linear_solver (str, default
"auto") – Name of the registered linear backend used for the inner shift-invert factorisation in the sparse path."auto"picks the fastest available SPD direct solver in the priority chainpardiso → cholmod → umfpack → superluand emits a one-shot warning if pardiso is missing on a problem large enough to benefit from it. Seefemorph_solver.solvers.linear.list_linear_solvers().
- Returns:
One
CyclicModalResultper requested harmonic index, in the order requested.- Return type:
Linear transient solver (Newmark-β time integration).
Integrates M ü + C u̇ + K u = F(t) over a uniform timestep grid.
Dirichlet constraints fix both the displacement and (implicitly) the
velocity / acceleration at those DOFs to zero.
Newmark-β recurrence (standard form with parameters β, γ):
u_{n+1} = u_n + dt · u̇_n + dt² · [(1/2 − β) ü_n + β ü_{n+1}]
u̇_{n+1} = u̇_n + dt · [(1 − γ) ü_n + γ ü_{n+1}]
Rearranging M ü_{n+1} + C u̇_{n+1} + K u_{n+1} = F_{n+1} in terms of
u_{n+1} gives the effective system:
A u_{n+1} = b_{n+1}
A = a0·M + a1·C + K
b = F_{n+1} + M(a0 u_n + a2 u̇_n + a3 ü_n) + C(a1 u_n + a4 u̇_n + a5 ü_n)
with the usual coefficient shorthands a0 = 1/(β dt²),
a1 = γ/(β dt), a2 = 1/(β dt), a3 = 1/(2β) − 1,
a4 = γ/β − 1, a5 = dt/2 · (γ/β − 2).
Defaults (β, γ) = (1/4, 1/2) are the unconditionally-stable
average-acceleration method. This implementation is implicit and
requires β > 0 — explicit (β=0) central-difference would need a
different solver path that factors the mass matrix directly.
References
Newmark, N. M. “A method of computation for structural dynamics.” J. Eng. Mech. Div. (ASCE) 85 (EM3), 67-94 (1959). [original Newmark-β integration scheme]
Hughes, T. J. R. The Finite Element Method: Linear Static and Dynamic Finite Element Analysis, Dover (2000), §9.1-9.2. [stability + accuracy analysis of the (β, γ) family; conditions for second-order accuracy and unconditional stability]
Bathe, K.-J. Finite Element Procedures, 2nd ed., §9.2. [implementation form of the effective-stiffness recurrence used above and the reduction to a per-step linear solve]
- class femorph_solver.solvers.transient.TransientResult(time: ndarray, displacement: ndarray, velocity: ndarray, acceleration: ndarray)[source]#
Bases:
objectResult of a linear transient solve, sampled on the time grid.
- time#
(n_steps + 1,)sample times [s].- Type:
- displacement#
(n_steps + 1, N)DOF-indexed displacement; row per step.- Type:
- velocity#
(n_steps + 1, N)DOF-indexed velocity.- Type:
- acceleration#
(n_steps + 1, N)DOF-indexed acceleration.- Type:
- save(path: str | Path, model: Model, *, unit_system: str = 'SI', extra_field_data: dict[str, np.ndarray] | None = None) Path[source]#
Serialise this transient result to a disk-backed
.pvfile.Re-projects the DOF-indexed
displacement/velocity/accelerationonto per-step, per-node(n_steps, n_points, n_dof_per_node)arrays viadof_map(), then hands off towrite_transient_result().- Parameters:
path (str or pathlib.Path) – Destination file.
model (femorph_solver.model.Model) – Model whose K / M matrices this result was integrated on; supplies the mesh + DOF layout.
extra_field_data (mapping, optional) – Extra
field_dataentries (e.g. solver statistics).
- Returns:
Canonicalised absolute path to the written file.
- Return type:
- femorph_solver.solvers.transient.solve_transient(K: csr_array, M: csr_array, F: ndarray | Callable[[float], ndarray], *, dt: float, n_steps: int, prescribed: dict[int, float] | None = None, C: csr_array | None = None, u0: ndarray | None = None, v0: ndarray | None = None, beta: float = 0.25, gamma: float = 0.5, thread_limit: int | None = None) TransientResult[source]#
Integrate
M ü + C u̇ + K u = F(t)with Newmark-β.- Parameters:
K – Global stiffness and mass matrices (
(N, N)sparse).M – Global stiffness and mass matrices (
(N, N)sparse).F – Load vector. Either a fixed
(N,)array (step-constant load) or a callablet -> (N,).dt – Uniform timestep.
n_steps – Number of steps to take. Returns
n_steps + 1samples including the initial state att = 0.prescribed – Dirichlet constraints
{global_dof_index: prescribed_value}. Constrained DOFs are held atu_cwith zero velocity and acceleration; their rows/cols are dropped fromA.C – Damping matrix.
Nonemeans no damping.u0 – Initial displacement and velocity. Default to zero.
v0 – Initial displacement and velocity. Default to zero.
beta – Newmark parameters. Defaults
(1/4, 1/2)give the unconditionally-stable average-acceleration method.(0, 1/2)reduces to central-difference (explicit, conditionally stable).gamma – Newmark parameters. Defaults
(1/4, 1/2)give the unconditionally-stable average-acceleration method.(0, 1/2)reduces to central-difference (explicit, conditionally stable).thread_limit (int or None, optional) – Cap on BLAS / OpenMP threads used inside the factorisation and per-step back-solve.
Nonedefers to the global limit (seefemorph_solver.set_thread_limit()).
Linear solver backends#
Pluggable linear solvers.
femorph-solver provides several backends for solving A x = b with a sparse SPD
or general sparse A. The default is SciPy’s built-in SuperLU; the
rest are optional and require extra dependencies listed in the class’s
install_hint.
Usage:
from femorph_solver.solvers.linear import get_linear_solver
Solver = get_linear_solver("cholmod") # or "superlu", "umfpack", "pardiso", ...
s = Solver(K_csc)
x = s.solve(b)
list_linear_solvers() reports every registered backend with an
available flag so UIs / benchmarks can enumerate them.
- class femorph_solver.solvers.linear.CGSolver(A, *, assume_spd: bool = True, rtol: float = 1e-10, atol: float = 0.0, maxiter: int | None = None)[source]#
Bases:
LinearSolverBasePreconditioned Conjugate Gradient for SPD
A.
- class femorph_solver.solvers.linear.CholmodSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseCHOLMOD supernodal sparse Cholesky (SPD only).
- class femorph_solver.solvers.linear.GMRESSolver(A, *, assume_spd: bool = False, rtol: float = 1e-10, atol: float = 0.0, restart: int = 50, maxiter: int | None = None)[source]#
Bases:
LinearSolverBasePreconditioned GMRES (general sparse).
- class femorph_solver.solvers.linear.LinearSolverBase(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
objectProtocol every linear-solver backend implements.
Concrete subclasses set the class attributes (name, kind, spd_only, install_hint), override
available(), and implement_factor()andsolve().
- class femorph_solver.solvers.linear.MUMPSSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False, ooc: bool = False, ordering: str = 'auto')[source]#
Bases:
LinearSolverBaseMUMPS sparse direct solver via python-mumps (multifrontal).
- class femorph_solver.solvers.linear.PardisoSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseIntel MKL Pardiso via pypardiso (multi-threaded).
- install_hint: ClassVar[str] = '`pip install pypardiso` (pulls in MKL)'#
User-facing hint printed when the backend is unavailable.
- release() None[source]#
Release MKL’s internal factor memory.
MKL Pardiso holds the numerical factor on a 64-slot
pthandle array that survives Python-side garbage collection — dropping ourPardisoSolverreference does not free it.pypardiso.free_memory(everything=True)runs phase-1, which tears down the MKL side. On a 200×200×2 HEX8 plate that’s ~390 MB of permanent factor that would otherwise live until process exit; for workflows that run multiple solves (modal + static + modal, notebooks iterating over meshes) the leak accumulates linearly.Idempotent — safe to call multiple times. Safe to call mid-interpreter-shutdown because
pypardiso.free_memoryconstructs a tiny dummyA/blocally and the MKL library handle is already resolved.
- class femorph_solver.solvers.linear.PyAMGSolver(A, *, assume_spd: bool = True, rtol: float = 1e-10, maxiter: int = 200)[source]#
Bases:
LinearSolverBaseSmoothed-aggregation AMG preconditioned CG (SPD).
Bases:
RuntimeErrorRaised when a registered solver’s optional dependency is missing.
- class femorph_solver.solvers.linear.SuperLUSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseSciPy’s bundled SuperLU (always available).
- class femorph_solver.solvers.linear.UMFPACKSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseUMFPACK LU factorization via scikit-umfpack.
- femorph_solver.solvers.linear.get_linear_solver(name: str, *, n_dof: int | None = None) type[LinearSolverBase][source]#
Look up a registered linear-solver class by name.
Passing
"auto"resolves viaselect_default_linear_solver(); passn_dofso the auto picker can prefer Pardiso on large problems where its parallel factorisation dominates, and skip it on small ones where its fixed overhead erases the gain.Raises
KeyErrorif the name is unknown, orSolverUnavailableErrorif the backend is registered but its optional dependency is not installed.
- femorph_solver.solvers.linear.list_linear_solvers() list[dict[str, object]][source]#
Return a list of
{name, available, kind, spd_only, install_hint}rows.
- femorph_solver.solvers.linear.select_default_linear_solver(*, spd: bool = True, n_dof: int | None = None) str[source]#
Return the best available backend name for the requested workload.
Size-aware: when
n_dof >= 50_000the preference ispardiso → cholmod → umfpack → superlubecause Pardiso’s threaded factor dominates. Below the threshold (or whenn_dofis not known) Pardiso is demoted because its fixed setup cost erases its factor-time lead on small systems.superluis always available as the final fallback.If Pardiso would have been chosen by size but is not installed, a one-shot
UserWarningpoints at the install hint.
Common base class for pluggable linear solvers.
- class femorph_solver.solvers.linear._base.LinearSolverBase(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
objectProtocol every linear-solver backend implements.
Concrete subclasses set the class attributes (name, kind, spd_only, install_hint), override
available(), and implement_factor()andsolve().
Bases:
RuntimeErrorRaised when a registered solver’s optional dependency is missing.
Intel MKL Pardiso via pypardiso.
Pardiso is multi-threaded and wins big on problems ≳30k DOF. Requires Intel MKL, which pypardiso pulls in as a dependency.
The import pypardiso call eagerly loads MKL (~200 ms on first touch),
so this module defers the import until a PardisoSolver is actually
constructed. That keeps import femorph_solver snappy for users who don’t
touch Pardiso, and avoids MKL init penalising the CHOLMOD/SuperLU paths
on small models where the auto-selector never reaches Pardiso.
When the caller asserts assume_spd=True we switch to MKL’s real-SPD
mode (mtype=2) which runs a sparse Cholesky factor. On a
57 k-DOF ptr.cdb K_ff: factor 0.39 s / solve 43 ms vs the default
unsymmetric-LU path’s 0.51 s / 94 ms — roughly 2× faster solves
with identical output (max abs diff 5 × 10⁻¹⁶).
References
Schenk, O. & Gärtner, K. “Solving unsymmetric sparse systems of linear equations with PARDISO.” Future Generation Computer Systems 20 (3), 475-487 (2004). [the PARDISO algorithm Intel MKL ships]
Schenk, O. & Gärtner, K. “On fast factorization pivoting methods for sparse symmetric indefinite systems.” ETNA 23, 158-179 (2006). [supernodal + Bunch-Kaufman pivoting paths used for indefinite systems (
mtype=-2)]Intel. Intel oneAPI Math Kernel Library Developer Reference, “Intel oneMKL PARDISO — Parallel Direct Sparse Solver Interface.” [
iparmflag semantics theDirectMklPardisoSolversibling wrapper drives directly via ctypes]
- class femorph_solver.solvers.linear._pardiso.PardisoSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseIntel MKL Pardiso via pypardiso (multi-threaded).
- install_hint: ClassVar[str] = '`pip install pypardiso` (pulls in MKL)'#
User-facing hint printed when the backend is unavailable.
- release() None[source]#
Release MKL’s internal factor memory.
MKL Pardiso holds the numerical factor on a 64-slot
pthandle array that survives Python-side garbage collection — dropping ourPardisoSolverreference does not free it.pypardiso.free_memory(everything=True)runs phase-1, which tears down the MKL side. On a 200×200×2 HEX8 plate that’s ~390 MB of permanent factor that would otherwise live until process exit; for workflows that run multiple solves (modal + static + modal, notebooks iterating over meshes) the leak accumulates linearly.Idempotent — safe to call multiple times. Safe to call mid-interpreter-shutdown because
pypardiso.free_memoryconstructs a tiny dummyA/blocally and the MKL library handle is already resolved.
Direct ctypes wrapper around MKL Pardiso.
Why not keep using pypardiso?
iparm(28)=1 silently no-ops on this MKL build. pypardiso sets the mixed-precision flag but after
factorizethe solver reportsiparm[27]=0— MKL chose to keep a double factor anyway. We want a direct path so we can verify (and, where the build allows, actually engage) single-precision factorisation that halves factor memory.Per-solve ``A.indptr.astype(int32) + 1`` + ``A.indices.astype + 1`` churn. PR #112 patched this with a cached
_call_pardisooverride; doing it in-house from the start avoids the monkey-patch entirely and lets us tell MKL we ship 0-based indices (iparm[34]=1) so no+1copy ever happens.Cleanup.
pypardisodoes not callphase=-1on GC, so the MKL factor leaks across repeated solves (see agent-1’s #107). We own__del__here and release explicitly.Phase control. pypardiso bundles analyse + factorise into
factorize(); for user code that wants to re-solve with the same pattern but updated values (modal sweeps over perturbed matrices), we can splitphase=11(analyse) fromphase=22(factor only) and amortise the analyse cost.
The MKL library load path piggybacks on pypardiso’s discovery — we
defer to its already-loaded handle when available so we don’t
duplicate the mkl_rt search heuristic. Users who set the
PYPARDISO_MKL_RT environment variable get the same resolution on
this backend.
References
Schenk, O. & Gärtner, K. “Solving unsymmetric sparse systems of linear equations with PARDISO.” Future Generation Computer Systems 20 (3), 475-487 (2004). [the Pardiso algorithm implemented by MKL]
Schenk, O. & Gärtner, K. “On fast factorization pivoting methods for sparse symmetric indefinite systems.” ETNA 23, 158-179 (2006).
Intel. Intel oneAPI Math Kernel Library Developer Reference, “Intel oneMKL PARDISO — Parallel Direct Sparse Solver Interface.” [authoritative
iparm/phase/mtypespec — the set of flags this wrapper drives directly]Intel. Intel MKL PARDISO out-of-core user guide. [
iparm(60)OOC mode +MKL_PARDISO_OOC_MAX_CORE_SIZEheuristic exercised by theooc=Truepath]
- class femorph_solver.solvers.linear._mkl_pardiso.DirectMklPardisoSolver(A, *, assume_spd: bool = False, mixed_precision: bool | None = None, ooc: bool = False, msglvl: int = 0)[source]#
Bases:
LinearSolverBasectypes-level MKL Pardiso direct solver.
- Parameters:
A (scipy.sparse.csr_array) – Real-valued SPD (
assume_spd=True) or general square sparse matrix. Whenassume_spd=TrueandA.femorph_triuis set, we use the supplied triu storage directly. Otherwise we extractsp.triu(A)ourselves before handing to MKL.assume_spd (bool, default False) – Flip to MKL’s
mtype=2real-SPD Cholesky path.mixed_precision (bool or None, default None) –
Trueasks MKL for a single-precision factor with double- precision iterative refinement (iparm(28)=1).None(auto) stays double-precision — the MKL builds we test against silently decline this flag, and even when honoured the iterative-refinement buffers often undo the factor-memory win.Falseforces double precision regardless.ooc (bool, default False) –
Trueenables out-of-core factorisation (iparm(60)=2 — spill factor to disk). Trades wall-time for the ability to factor K that would otherwise exceed RAM.msglvl (int, default 0) – Pass
1to dump MKL’s factor statistics to stdout — useful when diagnosing whether a flag (MP / OOC / parallel) actually engaged.
CHOLMOD sparse Cholesky via scikit-sparse.
For SPD A (stiffness matrices after BCs, for example), CHOLMOD’s
supernodal Cholesky is ~2-3× faster than SuperLU’s LU and uses about
half the memory.
References
Chen, Y., Davis, T. A., Hager, W. W., & Rajamanickam, S. “Algorithm 887: CHOLMOD, Supernodal Sparse Cholesky Factorization and Update/Downdate.” ACM Trans. Math. Softw. 35 (3), 22:1-22:14 (2008). [the shipped supernodal algorithm + its update paths]
Davis, T. A. & Hager, W. W. “Dynamic supernodes in sparse Cholesky update/downdate and triangular solves.” ACM Trans. Math. Softw. 35 (4), 27:1-27:23 (2009). [the row-and-column modification path used when updating factorisations across parametric sweeps]
- class femorph_solver.solvers.linear._cholmod.CholmodSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseCHOLMOD supernodal sparse Cholesky (SPD only).
MUMPS multifrontal sparse direct solver via python-mumps.
MUMPS is the go-to direct solver when Pardiso is unavailable or when the redistributable-license / out-of-core story matters:
Licence — CeCILL-C (LGPL-like). Unlike MKL Pardiso, MUMPS can be shipped with a commercial redistribution without the MKL EULA.
Out-of-core — mature OOC mode via
factor(ooc=True)/ MUMPS’sICNTL(22); spills the factor to disk so RAM stops being the ceiling on single-node solves. Pardiso has an OOC mode too but it is less battle-tested.Distributed memory — MUMPS is the on-ramp to MPI-distributed solves across nodes, which neither Pardiso nor SuperLU offer.
On a single shared-memory node at ptr.cdb scale (≤1 M DOFs, SPD) MUMPS
is usually 1.5–3× slower than Pardiso and uses ~10–30 % more factor
memory. Keep it available, but auto still prefers Pardiso > CHOLMOD
> MUMPS on SPD workloads.
Implementation notes for the python-mumps binding (pip install
python-mumps, import mumps):
The
Contextconstructor takes nosym=argument; symmetry is declared onContext.set_matrix()assymmetric=True, which internally maps to MUMPS’ssym=2(general-symmetric LDLᵀ). The wrapper does not expose MUMPS’ssym=1“strictly SPD” mode because python-mumps 0.0.6 doesn’t — sym=2 handles SPD input correctly, just without the small memory win of skipping pivoting.The Context docstring warns “only complex numbers supported”, but the underlying code dispatches on the input dtype (
dmumpsfor float64,zmumpsfor complex128). Float64 works fine.
References
Amestoy, P. R., Duff, I. S., L’Excellent, J.-Y., & Koster, J. “A Fully Asynchronous Multifrontal Solver Using Distributed Dynamic Scheduling.” SIAM J. Matrix Anal. Appl. 23 (1), 15-41 (2001). [the MUMPS multifrontal + distributed-scheduling algorithm]
Amestoy, P. R., Guermouche, A., L’Excellent, J.-Y., & Pralet, S. “Hybrid scheduling for the parallel solution of linear systems.” Parallel Computing 32 (2), 136-156 (2006). [hybrid static/dynamic scheduler shipped in MUMPS 4.x+]
Agullo, E., Guermouche, A., & L’Excellent, J.-Y. “Reducing the I/O volume in sparse out-of-core multifrontal methods.” SIAM J. Scientific Computing 31 (6), 4774-4794 (2010). [the OOC path exposed here via
ICNTL(22)]
- class femorph_solver.solvers.linear._mumps.MUMPSSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False, ooc: bool = False, ordering: str = 'auto')[source]#
Bases:
LinearSolverBaseMUMPS sparse direct solver via python-mumps (multifrontal).
UMFPACK sparse LU via scikit-umfpack.
UMFPACK tends to be faster than SuperLU on unsymmetric sparse problems with moderate fill-in. Ships as a separate pip package because it links against the SuiteSparse system library.
References
Davis, T. A. “Algorithm 832: UMFPACK V4.3 — An Unsymmetric-Pattern Multifrontal Method.” ACM Trans. Math. Softw. 30 (2), 196-199 (2004). [the shipped multifrontal algorithm]
Davis, T. A. & Duff, I. S. “An Unsymmetric-Pattern Multifrontal Method for Sparse LU Factorization.” SIAM J. Matrix Anal. Appl. 18 (1), 140-158 (1997). [foundational method paper]
- class femorph_solver.solvers.linear._umfpack.UMFPACKSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseUMFPACK LU factorization via scikit-umfpack.
SuperLU direct sparse solver via scipy.sparse.linalg.splu.
Default backend — always available because SciPy ships SuperLU. Handles
general (nonsymmetric) sparse A; uses the Sherman-family permutation
MMD_AT_PLUS_A by default — the standard heuristic for
SPD problems.
References
Demmel, J. W., Eisenstat, S. C., Gilbert, J. R., Li, X. S., & Liu, J. W. H. “A Supernodal Approach to Sparse Partial Pivoting.” SIAM J. Matrix Anal. Appl. 20 (3), 720-755 (1999). [right-looking supernodal LU — the core SuperLU algorithm]
Li, X. S. “An Overview of SuperLU: Algorithms, Implementation, and User Interface.” ACM Trans. Math. Softw. 31 (3), 302-325 (2005). [shipped implementation wrapped by SciPy]
- class femorph_solver.solvers.linear._superlu.SuperLUSolver(A: spmatrix | sparray, *, assume_spd: bool = False, mixed_precision: bool | None = False)[source]#
Bases:
LinearSolverBaseSciPy’s bundled SuperLU (always available).
Conjugate Gradient iterative solver (SPD).
Uses scipy.sparse.linalg.cg() with a diagonal (Jacobi)
preconditioner. Always available; best for very large sparse SPD
systems where factorization memory would be prohibitive.
References
Hestenes, M. R. & Stiefel, E. “Methods of Conjugate Gradients for Solving Linear Systems.” J. Res. Natl. Bur. Stand. 49 (6), 409-436 (1952). [the original CG algorithm]
Saad, Y. Iterative Methods for Sparse Linear Systems, 2nd ed. SIAM (2003), §6. [modern treatment + convergence theory]
- class femorph_solver.solvers.linear._cg.CGSolver(A, *, assume_spd: bool = True, rtol: float = 1e-10, atol: float = 0.0, maxiter: int | None = None)[source]#
Bases:
LinearSolverBasePreconditioned Conjugate Gradient for SPD
A.
GMRES iterative solver (general).
Nonsymmetric Krylov solver — use when A is not SPD. Jacobi
preconditioner by default.
References
Saad, Y. & Schultz, M. H. “GMRES: A Generalized Minimal Residual Algorithm for Solving Nonsymmetric Linear Systems.” SIAM J. Sci. Stat. Comput. 7 (3), 856-869 (1986). [the original GMRES algorithm implemented by
scipy.sparse.linalg.gmres]Saad, Y. Iterative Methods for Sparse Linear Systems, 2nd ed. SIAM (2003), §6.5. [modern treatment including restart + the restart-length trade-off scipy exposes via its
restartkwarg]
- class femorph_solver.solvers.linear._gmres.GMRESSolver(A, *, assume_spd: bool = False, rtol: float = 1e-10, atol: float = 0.0, restart: int = 50, maxiter: int | None = None)[source]#
Bases:
LinearSolverBasePreconditioned GMRES (general sparse).
Algebraic multigrid (AMG) preconditioned CG via pyamg.
AMG is the standard industrial preconditioner for elliptic problems like structural stiffness — often 10-100× fewer iterations than Jacobi-CG on large unstructured SPD systems.
References
Ruge, J. W. & Stüben, K. “Algebraic multigrid.” In Multigrid Methods (S. F. McCormick, ed.), SIAM Frontiers in Applied Mathematics, Chapter 4, pp. 73-130 (1987). [the foundational classical-AMG algorithm]
Vaněk, P., Mandel, J., & Brezina, M. “Algebraic multigrid by smoothed aggregation for second and fourth order elliptic problems.” Computing 56 (3), 179-196 (1996). [smoothed-aggregation variant, pyamg’s default
smoothed_aggregation_solver]Bell, N., Olson, L. N., & Schroder, J. “PyAMG: Algebraic Multigrid Solvers in Python v5.0.” Journal of Open Source Software 8 (81), 5495 (2023). [the shipped implementation]
- class femorph_solver.solvers.linear._pyamg.PyAMGSolver(A, *, assume_spd: bool = True, rtol: float = 1e-10, maxiter: int = 200)[source]#
Bases:
LinearSolverBaseSmoothed-aggregation AMG preconditioned CG (SPD).
Eigen solver backends#
Pluggable generalized-eigenvalue solvers.
All backends solve K φ = λ M φ for the n_modes smallest-λ
eigenpairs. The default backend ("arpack") uses
scipy.sparse.linalg.eigsh() in shift-invert mode with σ = 0 —
identical to femorph-solver’s behaviour up to this release. Alternative backends
let callers trade accuracy / convergence / memory against speed and
dependency footprint.
Usage:
from femorph_solver.solvers.eigen import get_eigen_solver
Solver = get_eigen_solver("arpack")
w, v = Solver.solve(K, M, n_modes=10, linear_solver="cholmod")
- class femorph_solver.solvers.eigen.ArpackSolver[source]#
Bases:
EigenSolverBasescipy.sparse.linalg.eigsh with shift-invert, pluggable inner solver.
- class femorph_solver.solvers.eigen.EigenSolverBase[source]#
Bases:
objectEvery eigen solver implements
solve(K, M, n_modes, ...).
- class femorph_solver.solvers.eigen.LapackDenseSolver[source]#
Bases:
EigenSolverBaseDense LAPACK eigh — correct at any size, but O(N^3).
- class femorph_solver.solvers.eigen.LobpcgSolver[source]#
Bases:
EigenSolverBasescipy.sparse.linalg.lobpcgwith pluggable preconditioner.- static solve(K: spmatrix | sparray, M: spmatrix | sparray, n_modes: int, *, sigma: float = 0.0, linear_solver: str = 'auto', tol: float = 1e-08, maxiter: int = 400, preconditioner: str | LinearOperator = 'factor') tuple[ndarray, ndarray][source]#
Solve the generalised eigenproblem
K φ = ω² M φwith LOBPCG.- Parameters:
preconditioner (
"factor"(default),"jacobi","none",) – or an explicitscipy.sparse.linalg.LinearOperator."factor"reproduces the historical behaviour (full direct factor of K)."jacobi"is the memory-friendly path — diagonal inverse of K, no factor,O(n)storage."none"disables preconditioning. When an explicit LinearOperator is suppliedlinear_solveris ignored.
- class femorph_solver.solvers.eigen.PrimmeSolver[source]#
Bases:
EigenSolverBaseprimme.eigsh for generalized SPD eigenproblems.
- femorph_solver.solvers.eigen.get_eigen_solver(name: str) type[EigenSolverBase][source]#
Look up a registered eigen-solver class by name.
- femorph_solver.solvers.eigen.list_eigen_solvers() list[dict[str, object]][source]#
Return a list of
{name, available, kind, spd_only, install_hint}rows.
Common base class for eigen-solver backends.
- class femorph_solver.solvers.eigen._base.EigenSolverBase[source]#
Bases:
objectEvery eigen solver implements
solve(K, M, n_modes, ...).
ARPACK Implicitly Restarted Lanczos via scipy.sparse.linalg.eigsh.
Shift-invert mode at σ = 0 finds the n_modes eigenvalues closest to
zero — exactly the lowest natural frequencies we want for modal
analysis. The inner (K - σM)^-1 apply goes through the pluggable
femorph-solver linear-solver registry so callers can pick SuperLU (default),
CHOLMOD, Pardiso, or anything else registered.
K·v and M·v inside ARPACK’s generalized-eigenvalue iteration are
wrapped in a custom OpenMP parallel CSR matvec (_core.csr_matvec)
when the matrix is sufficiently large — scipy’s built-in
csr_matvec is single-threaded and costs ~13 % of wall time on a
600 k-DOF ptr-sized system.
References
Lehoucq, R. B. & Sorensen, D. C. “Deflation Techniques for an Implicitly Restarted Arnoldi Iteration.” SIAM J. Matrix Anal. Appl. 17 (4), 789-821 (1996). [implicit-restart Arnoldi algorithm at the core of ARPACK’s
eigsh]Lehoucq, R. B., Sorensen, D. C., & Yang, C. ARPACK Users’ Guide: Solution of Large-Scale Eigenvalue Problems with Implicitly Restarted Arnoldi Methods. SIAM (1998). [shipped reference for the ARPACK library scipy wraps, including the shift-invert mode used here]
Ericsson, T. & Ruhe, A. “The Spectral Transformation Lanczos Method for the Numerical Solution of Large Sparse Generalized Symmetric Eigenvalue Problems.” Math. Comp. 35 (152), 1251-1268 (1980). [spectral-transformation theory justifying the
(K - σM)^-1shift-invert applied on top of Lanczos]
- class femorph_solver.solvers.eigen._arpack.ArpackSolver[source]#
Bases:
EigenSolverBasescipy.sparse.linalg.eigsh with shift-invert, pluggable inner solver.
LOBPCG (Locally Optimal Block Preconditioned Conjugate Gradient).
Iterative eigensolver — works well when an efficient preconditioner is available (e.g. AMG). Without one it generally loses to shift-invert Lanczos on structural problems; the preconditioner is the whole game.
This wrapper offers three preconditioner modes:
"factor"(default) — run a full direct solver onKand use the factor as a preconditioner. Convergent but has the same memory footprint as the shift-invert path; useful when the problem is too ill-conditioned for a cheaper preconditioner but you need LOBPCG’s convergence pattern on clustered eigenvalues."jacobi"— diagonal Jacobi preconditioner (M = diag(K)). Free. Memory-friendly: storage isO(n)floats, no factor. On well-conditioned structural problems it typically converges in 2–5× more iterations than"factor"but uses ~10× less memory at the factor stage. The right default whenKis too big to factor."none"— no preconditioner. Occasionally wins on tiny problems where the preconditioner setup dominates; mostly for diagnostics.
Pass an explicit scipy.sparse.linalg.LinearOperator via the
preconditioner= argument to override entirely.
References
Knyazev, A. V. “Toward the Optimal Preconditioned Eigensolver: Locally Optimal Block Preconditioned Conjugate Gradient Method.” SIAM J. Sci. Comput. 23 (2), 517-541 (2001). [the LOBPCG algorithm implemented by
scipy.sparse.linalg.lobpcg]Knyazev, A. V., Argentati, M. E., Lashuk, I., & Ovtchinnikov, E. E. “Block Locally Optimal Preconditioned Eigenvalue Xolvers (BLOPEX) in hypre and PETSc.” SIAM J. Sci. Comput. 29 (5), 2224-2239 (2007). [block-variant stability fixes carried forward into scipy’s implementation]
- class femorph_solver.solvers.eigen._lobpcg.LobpcgSolver[source]#
Bases:
EigenSolverBasescipy.sparse.linalg.lobpcgwith pluggable preconditioner.- static solve(K: spmatrix | sparray, M: spmatrix | sparray, n_modes: int, *, sigma: float = 0.0, linear_solver: str = 'auto', tol: float = 1e-08, maxiter: int = 400, preconditioner: str | LinearOperator = 'factor') tuple[ndarray, ndarray][source]#
Solve the generalised eigenproblem
K φ = ω² M φwith LOBPCG.- Parameters:
preconditioner (
"factor"(default),"jacobi","none",) – or an explicitscipy.sparse.linalg.LinearOperator."factor"reproduces the historical behaviour (full direct factor of K)."jacobi"is the memory-friendly path — diagonal inverse of K, no factor,O(n)storage."none"disables preconditioning. When an explicit LinearOperator is suppliedlinear_solveris ignored.
Dense LAPACK fallback via scipy.linalg.eigh.
For very small problems (N ≲ 500) a dense generalized eigenproblem is faster than any sparse solver and numerically bullet-proof. Serves as our reference path in tests.
References
Anderson, E. et al. LAPACK Users’ Guide, 3rd ed., SIAM (1999), Chapter 2. [generalized symmetric-definite eigenproblem — the
DSYGVDandDSYGVXdriversscipy.linalg.eighcalls]Parlett, B. N. The Symmetric Eigenvalue Problem, SIAM Classics in Applied Mathematics (1998). [the bisection + Rayleigh-quotient + divide-and-conquer algorithms underlying LAPACK’s symmetric routines]
Golub, G. H. & Van Loan, C. F. Matrix Computations, 4th ed., JHU Press (2013), §8. [reference treatment of the symmetric eigenproblem used for the dense path]
- class femorph_solver.solvers.eigen._lapack_dense.LapackDenseSolver[source]#
Bases:
EigenSolverBaseDense LAPACK eigh — correct at any size, but O(N^3).
PRIMME via primme.eigsh — a state-of-the-art block-Davidson solver.
Often beats ARPACK on large SPD generalized eigenproblems because of its restart + soft-locking scheme. Pip-installable and self-contained (no extra system libs).
References
Stathopoulos, A. & McCombs, J. R. “PRIMME: PReconditioned Iterative MultiMethod Eigensolver — Methods and software description.” ACM Trans. Math. Softw. 37 (2), 21:1-21:30 (2010). [the shipped PRIMME library: block-JD + GD+k restart + the soft-locking convergence machinery]
Stathopoulos, A. “Nearly Optimal Preconditioned Methods for Hermitian Eigenproblems Under Limited Memory. Part I: Seeking One Eigenvalue.” SIAM J. Sci. Comput. 29 (2), 481-514 (2007). [the GD+k restart scheme inside PRIMME]
Davidson, E. R. “The Iterative Calculation of a Few of the Lowest Eigenvalues and Corresponding Eigenvectors of Large Real-Symmetric Matrices.” J. Comp. Phys. 17 (1), 87-94 (1975). [the original Davidson method PRIMME descends from]
- class femorph_solver.solvers.eigen._primme.PrimmeSolver[source]#
Bases:
EigenSolverBaseprimme.eigsh for generalized SPD eigenproblems.
Elements#
femorph-solver element library.
Each element class implements ElementBase and registers itself via
register(). The registry is how the assembler (and anything else
walking a mesh) dispatches per-element math from the per-cell element-type
identifier on a model’s grid.
Kernel modules carry topology-first names (hex8, hex20, tet10,
wedge15 / pyr13 from _wedge15_pyr13, beam2, truss2,
quad4_shell, quad4_plane, spring, point_mass).
Foreign-deck nomenclature (e.g. MAPDL SOLID185 / NASTRAN CHEXA /
Abaqus C3D8) is translated to neutral kernel names at each interop
boundary and never reaches the public API of this package.
- class femorph_solver.elements.ElementBase[source]#
Bases:
ABC- abstractmethod static ke(coords: ndarray, material: dict[str, float], real: ndarray) ndarray[source]#
Return the element stiffness matrix in global DOF ordering.
- Parameters:
coords ((n_nodes, 3) float64)
material (mapping with property names as keys (
"EX","PRXY","DENS", …))real (1-D array of real constants)
- femorph_solver.elements.get(name: str) type[ElementBase][source]#
Return the kernel class registered under its neutral
name.Only canonical kernel names (
"HEX8","TET10", …) are accepted. Foreign-deck names must be translated at the interop boundary (e.g.femorph_solver.interop.mapdl.APDL) before being passed here.
- femorph_solver.elements.is_element_spec(obj: object) bool[source]#
Return
Trueifobjis an element-spec class or instance.Used by
Model.assign()to detect the spec form vs. the legacy string /ElementTypeform.
Abstract element interface.
- class femorph_solver.elements._base.ElementBase[source]#
Bases:
ABC- abstractmethod static ke(coords: ndarray, material: dict[str, float], real: ndarray) ndarray[source]#
Return the element stiffness matrix in global DOF ordering.
- Parameters:
coords ((n_nodes, 3) float64)
material (mapping with property names as keys (
"EX","PRXY","DENS", …))real (1-D array of real constants)
Hex8 — 8-node linear hexahedral solid.
Tri-linear shape functions on the reference cube ξ, η, ζ ∈ [-1, 1]
with 2×2×2 Gauss integration. For each node i = 0…7 with natural
coordinates (ξᵢ, ηᵢ, ζᵢ) ∈ {±1}³:
Nᵢ(ξ, η, ζ) = (1/8)(1 + ξᵢ ξ)(1 + ηᵢ η)(1 + ζᵢ ζ)
Node ordering matches VTK_HEXAHEDRON — the eight cube corners
traversed (-,-,-), (+,-,-), (+,+,-), (-,+,-), (-,-,+),
(+,-,+), (+,+,+), (-,+,+).
Stiffness / mass#
K_e = ∫_V Bᵀ C B dV ≈ Σ_g Bᵀ(ξ_g) C B(ξ_g) det J(ξ_g) M_e = ρ ∫_V Nᵀ N dV ≈ Σ_g ρ Nᵀ(ξ_g) N(ξ_g) det J(ξ_g)
with isotropic linear elasticity in Voigt form (engineering shears):
C = λ·[111,000] ⊕ 2G·[diag(1,1,1,½,½,½)]
where λ = Eν/((1+ν)(1-2ν)) and G = E/(2(1+ν)).
Real constants#
None required — geometry comes from node coordinates.
Material#
EX, PRXY : elastic moduli (both mandatory) DENS : density (required for M_e)
Lumped mass#
Row-sum (HRZ) lumping distributes each row of the consistent mass onto its diagonal. For 8-node hex this gives m_total / 8 per corner node — the standard and physically correct lumped form.
Integration flag#
material["_HEX8_INTEGRATION"] selects the stiffness formulation:
"full"(default) — 2×2×2 Gauss full integration with Hughes volumetric B-bar. The dilatation part ofBat each Gauss point is replaced by its element-volume-weighted average so near-incompressible regimes do not lock."plain_gauss"— plain 2×2×2 Gauss without the volumetric correction. Useful for cross-checks against codes that don’t ship B-bar (e.g. the scikit-fem cross-FEM verification fixture); not recommended for production runs because it locks under near-incompressibility."enhanced_strain"— Simo-Rifai 9-parameter enhanced assumed strain (EAS) with Pian-Tong Jacobian scaling. Suppresses shear locking in bending-dominated regular hex meshes.
References
Shape functions — trilinear Lagrange on the 8-node hexahedron: Zienkiewicz, O.C. and Taylor, R.L. The Finite Element Method: Its Basis and Fundamentals, 7th ed., Butterworth-Heinemann, 2013, Ch. 6. Equivalent form in Cook, Malkus, Plesha, Witt, Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, 2002, §6.2.
2×2×2 Gauss quadrature rule (product rule, 3 integration points → 8 product points): Zienkiewicz & Taylor §5.10 Table 5.3.
B-bar formulation (
"full", default — volumetric dilatation replaced by its element-volume-weighted mean to avoid near-incompressible locking): Hughes, T.J.R. The Finite Element Method: Linear Static and Dynamic Finite Element Analysis, Dover, 2000 (reprint of Prentice-Hall 1987), §4.4 (“B-bar method”). Original formulation in Hughes, T.J.R., “Generalization of selective integration procedures to anisotropic and nonlinear media,” IJNME 15 (1980), pp. 1413–1418.Enhanced assumed strain (
"enhanced_strain"— 9 internal parameters, Pian-Tong Jacobian scaling): Simo, J.C. and Rifai, M.S., “A class of mixed assumed strain methods and the method of incompatible modes,” IJNME 29 (1990), pp. 1595–1638.Row-sum / HRZ lumped mass: Hinton, E., Rock, T. and Zienkiewicz, O.C., “A note on mass lumping and related processes in the finite element method,” Earthquake Engng. Struct. Dyn. 4 (1976), pp. 245–249. Zienkiewicz & Taylor §16.2.2.
- class femorph_solver.elements.hex8.Hex8[source]#
Bases:
ElementBase- static eel_batch(coords: ndarray, u_e: ndarray, material: dict[str, float] | None = None) ndarray | None[source]#
Per-element elastic strain at every element node.
coordsis(n_elem, 8, 3)andu_eis(n_elem, 24)— the element DOF vector laid out as[ux0, uy0, uz0, ux1, …]. Returns(n_elem, 8, 6)with Voigt components[εxx, εyy, εzz, γxy, γyz, γxz](engineering shears), orNoneif the C++ extension is unavailable.materialis accepted for signature uniformity with plane kernels but is unused by the 3D-solid path (full 6-component Voigt strain is recovered directly fromu_e).
- static ke(coords: ndarray, material: dict[str, float], real: ndarray) ndarray[source]#
Return the element stiffness matrix in global DOF ordering.
- Parameters:
coords ((n_nodes, 3) float64)
material (mapping with property names as keys (
"EX","PRXY","DENS", …))real (1-D array of real constants)
- static ke_batch(coords: ndarray, material: dict[str, float], real: ndarray) ndarray | None[source]#
Vectorized Ke over
(n_elem, 8, 3)coords.Routes to the C++ B-bar kernel on the default path, to plain Gauss when the
plain_gaussflag is set, and returnsNonefor EAS (Python-only for now) so the assembler falls back to per-elementke()dispatch.
Hex20 — 20-node quadratic hexahedral solid (serendipity).
Node ordering matches VTK_QUADRATIC_HEXAHEDRON: the 8 corner nodes come first in Hex8 order, then the 4 bottom-face mid-edge nodes, the 4 top-face mid-edge nodes, and finally the 4 vertical mid-edge nodes connecting the bottom and top faces.
Shape functions on the reference cube ξ, η, ζ ∈ [-1, 1]:
Corner node with
(ξᵢ, ηᵢ, ζᵢ) ∈ {±1}³:Nᵢ = (1/8)(1 + ξᵢ ξ)(1 + ηᵢ η)(1 + ζᵢ ζ) · (ξᵢ ξ + ηᵢ η + ζᵢ ζ - 2)Mid-edge node with one natural coordinate zero (e.g.
ξᵢ = 0):Nᵢ = (1/4)(1 - ξ²)(1 + ηᵢ η)(1 + ζᵢ ζ)
Integration rule#
material["_HEX20_INTEGRATION"] selects the stiffness integration
rule:
"reduced"(default) — 2×2×2 Gauss-Legendre (8 points). Single- element Ke has 6 extra zero-energy hourglass modes; on a connected mesh those modes almost always die under the assembly because a hex whose midside is shared with a neighbour has no free hourglass direction to excite. Make sure your mesh has no dangling midside nodes before using the default; if in doubt switch to"full"."full"— 3×3×3 Gauss-Legendre (27 points). Ke is fully rank (54 non-zero eigenvalues + 6 rigid-body zeros) on a single hex.
Mass integration uses the 14-point Irons rule by default; pass
material["_HEX20_MASS"] = "consistent" for the textbook 3×3×3
consistent rule. The wedge / pyramid degenerate forms also use the
Irons 14-point rule (see _wedge15_pyr13.py); the rule is
documented in the References section below.
Real constants#
None required — geometry comes from node coordinates.
Material#
EX, PRXY : elastic moduli (both mandatory) DENS : density (required for M_e)
References
Shape functions — 20-node serendipity hexahedron (corner nodes weighted by
(ξᵢξ + ηᵢη + ζᵢζ − 2)correction, mid-edge nodes on zero-coordinate axes): Zienkiewicz, O.C. and Taylor, R.L. The Finite Element Method: Its Basis and Fundamentals, 7th ed., Butterworth-Heinemann, 2013, §8.7.3. Original derivation in Ergatoudis, I., Irons, B.M. and Zienkiewicz, O.C., “Curved, isoparametric, ‘quadrilateral’ elements for finite element analysis,” Int. J. Solids Struct. 4 (1968), pp. 31–42, extended to 20-node hex in Zienkiewicz, O.C., The Finite Element Method in Engineering Science, 2nd ed., McGraw-Hill, 1971.2×2×2 reduced Gauss integration (
"reduced", default): Bedrosian, G., “Shape functions and integration formulas for three-dimensional finite element analysis,” IJNME 35 (1992), pp. 95–108 — the canonical reference for the uniform-reduced rule on the 20-node hex and its degenerate wedge / pyramid forms. Hourglass-mode analysis of the reduced rule on a single hex: Belytschko, T., Liu, W.K., Moran, B. and Elkhodary, K., Nonlinear Finite Elements for Continua and Structures, 2nd ed., Wiley, 2014, §8.6.3×3×3 full Gauss-Legendre rule (
"full", 27 points): Zienkiewicz & Taylor §5.10 Table 5.3.14-point Irons integration rule (used for the consistent mass and in the degenerate wedge / pyramid forms): Irons, B.M., “Quadrature rules for brick based finite elements,” IJNME 3 (1971), pp. 293–294. Originally derived for the 20-node hex with degenerate wedge / pyramid as special cases, integrating polynomials through degree 5 exactly.
Voigt elastic matrix (isotropic linear elasticity, engineering shear convention): Cook, Malkus, Plesha, Witt Concepts and Applications of FEA, 4th ed., Wiley, 2002, §3.7.
- class femorph_solver.elements.hex20.Hex20[source]#
Bases:
ElementBase- static eel_batch(coords: ndarray, u_e: ndarray, material: dict[str, float] | None = None) ndarray | None[source]#
Per-element elastic strain at every element node.
coords:(n_elem, 20, 3);u_e:(n_elem, 60). Returns(n_elem, 20, 6)— engineering-shear Voigt strain.materialis accepted for signature uniformity with plane kernels but is unused.
Degenerate Hex20 shapes — 15-node wedge and 13-node pyramid.
These are the wedge and pyramid topologies expressed as VTK
QUADRATIC_WEDGE (15 pts) and QUADRATIC_PYRAMID (13 pts).
Foreign-deck readers (CDB, BDF, INP) translate their respective
catalogue spellings (collapsed-corner Hex20, NASTRAN CPENTA15 /
CPYRAM, Abaqus C3D15 / C3D13) at the boundary.
Two implementations live here:
Wedge15— dedicated 15-node quadratic serendipity wedge kernel with its own shape functions and Gauss rules (9-pt for stiffness, 21-pt for consistent mass). Numerically equivalent to the 20-node-hex collapse on the meshes we have tested (the wedge formulation is mathematically the same hex with K=L and O=P collapsed — the dedicated kernel is just the compact path).Pyr13— currently a hex-fold wrapperK = Tᵀ K_hex Tpending a dedicated 13-node pyramid implementation. Pyramids are the long pole on cross-solver parity studies (~5e-4 rel modal error, all from this path); closing the gap requires proper apex-singular shape functions (Bedrosian / Zgainski-Bossavit / Wachspress) plus a pyramid-domain Gauss rule — tracked as a dedicated follow-up.
References
Degenerate hex → wedge / pyramid topology collapse: Zienkiewicz, O.C. and Taylor, R.L. The Finite Element Method: Its Basis and Fundamentals, 7th ed., Butterworth-Heinemann, 2013, §8.8.1 (discussion of collapsed hex nodes producing wedge / pyramid shapes).
15-node serendipity wedge shape functions (base corners on barycentric
(ξ₁, ξ₂, ξ₃) = L₁, L₂, L₃withξ₃ = 1 − ξ₁ − ξ₂, quadratic through thickness inζ): Bedrosian, G., “Shape functions and integration formulas for three-dimensional finite element analysis,” IJNME 35 (1992), pp. 95–108. Equivalent derivation in Zienkiewicz & Taylor §8.8.3 Table 8.8-1.9-point wedge Gauss rule (3-point triangular rule × 3-point Gauss-Legendre through thickness): triangular rule from Dunavant, D.A., “High degree efficient symmetrical Gaussian quadrature rules for the triangle,” IJNME 21 (1985), pp. 1129–1148 (degree-4 triangle rule on 3 symmetric points); tensor-product with the 1-D Gauss-Legendre rule follows Zienkiewicz & Taylor §5.10.
21-point wedge mass rule (6-point triangular rule × 3-point through-thickness, integrates the degree-4 Bᵀ B polynomial on the wedge): Dunavant degree-4 triangle rule × Gauss-Legendre 3.
13-node pyramid shape functions (
Pyr13, currently a hex-fold wrapper pending a dedicated kernel): Bedrosian 1992 (as above) for the apex-singular polynomial basis, alternative formulations in Zgainski, F.X., Coulomb, J.L., and Marechal, Y., “A new family of finite elements: the pyramidal elements,” IEEE Trans. Magn. 32 (1996), pp. 1393–1396 and Wachspress, E.L., A Rational Finite Element Basis, Academic Press, 1975.
- class femorph_solver.elements._wedge15_pyr13.Pyr13[source]#
Bases:
ElementBase13-node degenerate Hex20 pyramid.
Default: dedicated Bedrosian apex-singular serendipity kernel integrated with a 2×2×2 Duffy collapsed-hex Gauss rule. The 2×2×2 rule (not 3×3×3) is the under-integrated quadrature that matches the foreign-deck convention; the higher-order
"consistent"rule is mathematically more accurate but ~13 % apart in Frobenius norm on a single element.The hex-fold wrapper (
T^T · K_20-node · T) is retained asmaterial["_PYR13_INTEGRATION"] = "hex_fold"for parity tests against foreign decks that emit hex-folded pyramids.
- class femorph_solver.elements._wedge15_pyr13.Wedge15[source]#
Bases:
ElementBase15-node degenerate Hex20 wedge (K=L, O=P collapse).
Dedicated quadratic serendipity kernel with shape functions in area coords
(ξ₁, ξ₂, ζ), stiffness at 9-pt Gauss (3-pt triangle × 3-pt line), consistent mass at 21-pt Gauss (7-pt triangle × 3-pt line).
Tet10 — 10-node quadratic tetrahedral solid.
Node ordering matches VTK_QUADRATIC_TETRA:
0-3 : corner nodes I, J, K, L 4 : mid-edge I-J 5 : mid-edge J-K 6 : mid-edge K-I 7 : mid-edge I-L 8 : mid-edge J-L 9 : mid-edge K-L
Reference tetrahedron with natural coordinates (ξ, η, ζ), volume
coordinates (L₁, L₂, L₃, L₄) = (1-ξ-η-ζ, ξ, η, ζ):
Corner node
i(Lᵢ = 1 at corneri): Nᵢ = Lᵢ(2Lᵢ - 1)Mid-edge
(i, j): N_{ij} = 4 Lᵢ Lⱼ
Both stiffness and mass use the 4-point Gauss rule on the reference
tet (degree-2 exact, weights 1/24 at the 4 symmetric Keast points).
The 4-pt rule integrates K exactly (degree-2 integrand from BᵀCB
on quadratic shape functions); the per-element M is degree-4 and
therefore rank 12 of 30 (PSD, not PD) under the same rule. That’s
fine: once two elements share nodes the global M becomes PD, and
lumped M is diagonal regardless.
Real constants#
None required — geometry comes from node coordinates.
Material#
EX, PRXY : elastic moduli (both mandatory) DENS : density (required for M_e)
References
Shape functions — 10-node quadratic Lagrange tetrahedron in volume coordinates (
L₁, L₂, L₃, L₄): Zienkiewicz, O.C. and Taylor, R.L. The Finite Element Method: Its Basis and Fundamentals, 7th ed., Butterworth-Heinemann, 2013, §8.8.2. Also Cook, Malkus, Plesha, Witt, Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, 2002, §6.5 Table 6.5-1.4-point Keast Gauss rule (degree-2 exact on the unit tetrahedron, weights
1/24at the four symmetric points): Keast, P., “Moderate-degree tetrahedral quadrature formulas,” Comput. Methods Appl. Mech. Engrg. 55 (1986), pp. 339–348. The 4-point rule integrates K exactly (degree-2 integrand fromBᵀCBon quadratic shape functions) and M with rank deficiency 12 of 30 that the global assembly absorbs.Voigt elastic matrix: Cook et al. §3.7.
- class femorph_solver.elements.tet10.Tet10[source]#
Bases:
ElementBase- static eel_batch(coords: ndarray, u_e: ndarray, material: dict[str, float] | None = None) ndarray | None[source]#
Per-element elastic strain at every element node.
coords:(n_elem, 10, 3);u_e:(n_elem, 30). Returns(n_elem, 10, 6)— engineering-shear Voigt strain.materialis accepted for signature uniformity with plane kernels but is unused.
Beam2 — 3D 2-node linear beam.
femorph-solver implements the slender-beam (Euler–Bernoulli) form of Beam2: axial, torsional, and two independent bending planes combine into a 12×12 local stiffness matrix rotated into global coordinates. This is exactly the limit Beam2 converges to when the Timoshenko shear parameter Φ → 0, i.e. for slender sections where bending dominates over shear deformation. A future revision can introduce Φ from an explicit shear area or section integration.
Local DOF ordering at each node:
(u, v, w, θx, θy, θz)
so global assembly fills rows/cols 0..5 (node i) and 6..11 (node j) in
the canonical (UX, UY, UZ, ROTX, ROTY, ROTZ) DOF order.
Real constants#
real[0]: AREA — cross-sectional area A real[1]: IZZ — bending inertia about local z (resists y-deflection) real[2]: IYY — bending inertia about local y (resists z-deflection) real[3]: J — Saint-Venant torsion constant
Material#
EX : Young’s modulus PRXY : Poisson’s ratio (→ G = E/(2(1+ν)) for torsion) DENS : mass density (required for M_e)
Orientation#
The local x-axis points from node I → node J. Without an explicit
orientation node, the local y-axis is chosen so that local z stays
“up” (world +Z) whenever the beam is not parallel to the global Z-axis,
falling back to world +Y for vertical beams. For axisymmetric sections
(IYY == IZZ) the orientation is immaterial.
Stiffness (slender-beam limit)#
- K_local = [ K_axial · · · ]
[ · K_bend_xy · · ] [ · · K_bend_xz · ] [ · · · K_torsion ]
with the familiar Hermite-cubic 4×4 bending blocks. A block-diagonal
direction-cosine matrix Λ rotates it: K_global = Λᵀ K_local Λ.
Mass (consistent, Hermite cubic)#
Axial / torsional: ρ A L / 6 · [[2,1],[1,2]] (resp. ρ I_p L / 6
with I_p ≈ I_y + I_z). Bending uses the standard 4×4 block
ρ A L / 420 · (…) in both planes.
Lumped mass puts ρ A L / 2 on each translational DOF and zeros the
rotational ones — a simple diagonal approximation useful for explicit
dynamics but less accurate for modal work than the consistent form.
References
Euler–Bernoulli beam (slender-beam limit, shear neglected): Timoshenko, S.P., Vibration Problems in Engineering, 4th ed., Wiley, 1974, §5.3. Beam kinematics and derivation of the 12-DOF stiffness / consistent-mass blocks: Cook, Malkus, Plesha, Witt, Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, 2002, §2.4 (axial), §2.5 (bending Hermite cubics), §2.6 (torsion).
Hermite cubic shape functions for transverse displacement and slope continuity at the nodes: Zienkiewicz, O.C. and Taylor, R.L. The Finite Element Method: Its Basis and Fundamentals, 7th ed., Butterworth-Heinemann, 2013, §2.5.1 eqs. (2.26)–(2.27).
Consistent mass matrix from Hermite cubics (coefficients
ρAL/420on the 4×4 bending block andρAL/6on the axial / torsional block): Cook et al. Table 16.3-1.Timoshenko-beam form (non-zero shear, Φ = 12EI/(κAGL²) — future revision) and comparison with the Euler-Bernoulli limit: Bathe, K.J., Finite Element Procedures, 2nd ed., Prentice Hall, 2014, §5.4.
- class femorph_solver.elements.beam2.Beam2[source]#
Bases:
ElementBase
Quad4Shell — 4-node flat Mindlin–Reissner shell.
Kinematics (first-order shear-deformation theory):
u_point = u + z · θ_y v_point = v − z · θ_x w_point = w
giving a split into membrane / bending / transverse-shear energy after through-thickness integration. Node ordering matches VTK_QUAD:
(-,-), (+,-), (+,+), (-,+) in natural
(ξ, η) ∈ [-1, 1].
The element is built in a local 2-D frame (ex along edge 1→2,
ez normal to the best-fit plane, ey = ez × ex). Coordinates are
projected into this frame, the plane stiffness is assembled, then a
block-diagonal 3×3 direction-cosine matrix rotates every node’s
six-DOF block back to global coordinates.
Integration#
Membrane + bending: 2×2 Gauss (full)
Transverse shear: 1×1 Gauss (selective reduced) — suppresses shear locking for thin shells without needing MITC4 tying.
Drilling DOF (θ_z, local) has no true stiffness for a flat shell;
femorph-solver adds a small stabilization α · G · t · A_elem per corner so
the 24×24 local matrix stays non-singular when every DOF is free.
Real constants#
real[0]: THICKNESS — shell thickness (mandatory)
Material#
EX, PRXY : plane-stress elastic moduli (mandatory) DENS : mass density (required for M_e)
References
Mindlin–Reissner first-order shear-deformation theory (transverse shear included so rotations are independent of in-plane slope): Mindlin, R.D., “Influence of rotatory inertia and shear on flexural motions of isotropic elastic plates,” J. Appl. Mech. 18 (1951), pp. 31–38. Reissner, E., “The effect of transverse shear deformation on the bending of elastic plates,” J. Appl. Mech. 12 (1945), pp. A69–A77.
Bilinear isoparametric 4-node shell element with 6 DOF / node: Zienkiewicz, O.C. and Taylor, R.L., The Finite Element Method, Vol. 2: Solid Mechanics, 5th ed., Butterworth-Heinemann, 2000, Ch. 8 (plate & shell elements).
Selective reduced integration for transverse shear (1×1 on shear, 2×2 on membrane + bending — suppresses shear locking for thin shells): Malkus, D.S. and Hughes, T.J.R., “Mixed finite element methods — reduced and selective integration techniques: a unification of concepts,” Comput. Methods Appl. Mech. Engrg. 15 (1978), pp. 63–81.
Alternative MITC4 (Mixed-Interpolated Tensorial Components) shear-locking treatment — roadmap for a future variant: Bathe, K.J. and Dvorkin, E.N., “A four-node plate bending element based on Mindlin/Reissner plate theory and a mixed interpolation,” IJNME 21 (1985), pp. 367–383.
Drilling-DOF stabilisation (
α·G·t·Aper corner to keep the local 24×24 non-singular when θ_z is free): Allman, D.J., “A compatible triangular element including vertex rotations for plane elasticity analysis,” Comput. Struct. 19 (1984), pp. 1–8, and Hughes, T.J.R. and Brezzi, F., “On drilling degrees of freedom,” Comput. Methods Appl. Mech. Engrg. 72 (1989), pp. 105–121.
- class femorph_solver.elements.quad4_shell.Quad4Shell[source]#
Bases:
ElementBase
Quad4Plane — 2D 4-node structural solid.
Quad element with bilinear shape functions on the reference square
ξ, η ∈ [-1, 1]:
N_i(ξ, η) = (1/4)(1 + ξ_i ξ)(1 + η_i η)
Node ordering matches VTK_QUAD — the four corners traversed
counter-clockwise: (-,-), (+,-), (+,+), (-,+). Two
translational DOFs per node (UX, UY); element lives in the global
(X, Y) plane.
material["_QUAD4_PLANE_MODE"] selects the constitutive regime:
"stress"(default) — plane stress (σ_zz = 0)"strain"— plane strain (ε_zz = 0)axisymmetric mode is roadmap and not yet implemented.
Stiffness / mass#
2×2 Gauss integration for both K and M:
K_e = ∫_A Bᵀ C B t dA ≈ Σ_g Bᵀ(ξ_g) C B(ξ_g) det J(ξ_g) t_g
M_e = ρ t ∫_A Nᵀ N dA ≈ Σ_g ρ t Nᵀ(ξ_g) N(ξ_g) det J(ξ_g)
where t is the out-of-plane thickness (real constant, default 1.0
for plane strain) and C is the 3×3 plane elastic matrix in
engineering shears.
Real constants#
real[0] : THK — out-of-plane thickness (default 1.0)
Material#
EX, PRXY : elastic moduli (both mandatory) DENS : density (required for M_e)
Lumped mass#
Row-sum (HRZ) lumping distributes each row of the consistent mass onto its diagonal.
References
Shape functions — bilinear Lagrange on the 4-node quadrilateral (Q4 / tensor-product Lagrange on the reference square): Zienkiewicz, O.C. and Taylor, R.L. The Finite Element Method: Its Basis and Fundamentals, 7th ed., Butterworth-Heinemann, 2013, §6.3.2. Cook, Malkus, Plesha, Witt, Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, 2002, §6.3.
2×2 Gauss-Legendre quadrature (product rule): Zienkiewicz & Taylor §5.10 Table 5.3.
Plane-stress / plane-strain elastic matrix: Cook et al. §3.5 (plane stress) and §3.6 (plane strain).
Row-sum / HRZ lumped mass: Hinton, Rock & Zienkiewicz, “A note on mass lumping and related processes in the finite element method,” Earthquake Engng. Struct. Dyn. 4 (1976), pp. 245–249.
- class femorph_solver.elements.quad4_plane.Quad4Plane[source]#
Bases:
ElementBase- static eel_batch(coords: ndarray, u_e: ndarray, material: dict[str, float] | None = None) ndarray | None[source]#
Per-element engineering-shear Voigt strain at element nodes.
coords:(n_elem, 4, 3)or(n_elem, 4, 2).u_e:(n_elem, 8)— DOF layout[ux0, uy0, ux1, …].Returns
(n_elem, 4, 6)Voigt strain ordered[εxx, εyy, εzz, γxy, γyz, γxz]— matches the 3D-solid Voigt convention socompute_nodal_stress()can drive the sameσ = C·εcontraction with the standard isotropic(6, 6)C.For plane stress (default) we fill
εzz = -ν/(1-ν)·(εxx+εyy)soσ_zzrecovered via the 6×6Cis identically zero (the defining condition of plane stress). For plane strain we fillεzz = 0(the defining condition of plane strain);σ_zzis thenν·(σ_xx + σ_yy)per Hooke’s law.materialmust be provided when the kernel is operating in plane stress (the default) soνis available; for plane strain thematerialargument is unused.Strain is evaluated at each corner node (ξ_i, η_i) via the bilinear B-matrix — the same basis used in
ke. This is the standard Q4 nodal recovery; the recovered strain is exact at the centroid and accurate toO(h²)at corner nodes for smooth solutions.
Truss2 — 3D 2-node spar (truss).
Linear shape functions in the natural coordinate s ∈ [-1, 1] for
all three translations. Only axial strain contributes to internal
work:
B = (1/L) [-l, -m, -n, l, m, n] (1 × 6)
K_e = (E·A/L) · [[ C, -C],
[-C, C]] where C_ij = d_i·d_j (3 × 3)
with (l, m, n) the unit vector along the element axis.
Consistent mass:
M_e = ρ·A·L / 6 · [[2 I₃, I₃],
[ I₃, 2 I₃]]
Lumped mass: M_e = ρ·A·L/2 · I₆.
Real constants#
real[0]: AREA — cross-sectional area (mandatory) real[1]: ADDMAS — added mass per unit length (optional; unused here) real[2]: ISTRN — initial strain (optional; unused here — stress path only)
Material properties#
EX : Young’s modulus (mandatory) DENS : mass density (required for M_e)
References
Shape functions — linear Lagrange on a 1-D 2-node element (
N₁ = ½(1 − s),N₂ = ½(1 + s)ons ∈ [−1, 1]): Zienkiewicz, O.C. and Taylor, R.L. The Finite Element Method: Its Basis and Fundamentals, 7th ed., Butterworth-Heinemann, 2013, §2.3. Cook, Malkus, Plesha, Witt, Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, 2002, §2.4.Axial-only strain (spar / truss element, no bending / shear contribution, only longitudinal stiffness): Cook et al. §2.3.
Consistent mass
ρAL/6 · [[2, 1], [1, 2]]on the two axial DOFs (and its 6×6 extension to 3-D for the three translational pairs): Cook et al. Table 16.3-1; Zienkiewicz & Taylor §16.2.1.Row-sum lumped mass
ρAL/2per translational DOF: Hinton, Rock & Zienkiewicz, Earthquake Engng. Struct. Dyn. 4 (1976), pp. 245–249.
- class femorph_solver.elements.truss2.Truss2[source]#
Bases:
ElementBase
Spring — 3D 2-node spring-damper (longitudinal mode only).
Default mode: longitudinal spring oriented along the I→J axis. Real constants:
real[0] : K — spring stiffness (force / length) real[1] : CV1 — linear damping (force · time / length, unused for K_e) real[2] : CV2 — non-linear (cubic) damping (unused here) real[3] : IL — initial length (unused; reference length is the nodal distance)
Stiffness:
K_e = K · [[ C, -C],
[-C, C]] with C_ij = d_i · d_j (3 × 3)
where (d_x, d_y, d_z) is the unit vector along I→J.
Spring is massless by convention (modal codes treat it as a
stiffness-only contribution); me returns a 6×6 zero matrix so
that assembly loops stay uniform.
Only the longitudinal mode is implemented; torsional and 2-D planar variants need separate code paths and will be added alongside their verification datasets.
References
Discrete spring / lumped connector element (3-D longitudinal mode): Cook, Malkus, Plesha, Witt, Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, 2002, §2.3 and §2.10 (direction-cosine rotation of a scalar spring between two nodes in 3-D). Stiffness
K · (d ⊗ d)on the I→J axis is the standard result.Direction-cosine transformation for a bar / spring aligned with an arbitrary 3-D axis: Zienkiewicz, O.C. and Taylor, R.L., The Finite Element Method, 7th ed., 2013, §2.4.4.
- class femorph_solver.elements.spring.Spring[source]#
Bases:
ElementBase
PointMass — concentrated point mass.
femorph-solver implements the translational-only variant: a
single-node element carrying three translational DOFs UX, UY, UZ.
The rotary-inertia variant requires 6 DOFs/node and will be added
alongside Beam2 once mixed-DOF assembly is in place.
Real constants#
real[0]: MASSX — translational mass along x (mandatory) real[1]: MASSY — translational mass along y (defaults to MASSX) real[2]: MASSZ — translational mass along z (defaults to MASSX)
Matrices#
K_e = 0₃×₃ (a point mass has no stiffness contribution) M_e = diag(MASSX, MASSY, MASSZ)
Lumped and consistent formulations coincide for a one-node element.
References
Concentrated / point-mass element (lumped nodal mass, no stiffness contribution): Cook, Malkus, Plesha, Witt, Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, 2002, §16.3 (discussion of lumped nodal masses). Bathe, K.J., Finite Element Procedures, 2nd ed., Prentice Hall, 2014, §4.2.4.
Anisotropic diagonal mass tensor (
diag(MASSX, MASSY, MASSZ), supports different masses on each translational axis): standard generalisation of the isotropicm·I₃lumped mass.
- class femorph_solver.elements.point_mass.PointMass[source]#
Bases:
ElementBase
Post-processing recovery#
Public post-processing helpers — strain, stress, invariants.
A stable, non-underscore home for the per-node recovery helpers a
gateway / downstream consumer needs to expose results without
reaching into internal modules. Every name re-exported here is the
canonical public binding; the underscore paths
(femorph_solver.result._stress_recovery,
femorph_solver.elements._stress) remain for backwards
compatibility but should not be imported by new code.
Examples
>>> import femorph_solver as fs
>>> from femorph_solver.recover import (
... compute_nodal_strain,
... compute_nodal_stress,
... stress_invariants,
... )
>>> # m: fs.Model, u: (n_dof,) DOF-indexed displacement
>>> eps = compute_nodal_strain(m, u) # (n_points, 6) Voigt strain
>>> sig = compute_nodal_stress(m, u) # (n_points, 6) Voigt stress
>>> inv = stress_invariants(sig) # von_mises, s1/s2/s3, ...
- femorph_solver.recover.compute_nodal_strain(model: Model, displacement: np.ndarray) np.ndarray[source]#
Compute
(n_points, 6)Voigt strain at every mesh node.Per-element strain comes from
Model.strain(), which evaluates \(\varepsilon = B(\xi_\text{node})\,u_e\) directly at each element’s own nodes. Where multiple elements touch a node the strains are averaged with equal weight (the same convention as the stress recovery incompute_nodal_stress()and as MAPDL’s PRNSOL).- Parameters:
model (femorph_solver.model.Model) – Source of the strain kernel (
model.strain(u)). Element cells / nodes are read offmodel.grid.displacement (numpy.ndarray) –
(N,)DOF-indexed displacement inModel.dof_map()ordering. Typically a static-solve displacement or a modal mode shape.
- Returns:
(n_points, 6)engineering-shear Voigt strain[ε_xx, ε_yy, ε_zz, γ_xy, γ_yz, γ_xz], averaged across every element that touches a node. Nodes with no incident elements are zero.- Return type:
See also
compute_nodal_stressSame averaging pattern, but contracted with the per-material elasticity matrix to produce Voigt stress.
femorph_solver.model.Model.strainPer-element source of the strain rows averaged here.
- femorph_solver.recover.compute_nodal_stress(model: Model, displacement: np.ndarray) np.ndarray[source]#
Compute
(n_points, 6)Voigt stress at every mesh node.The caller is responsible for passing a Model with complete materials (at least
EX+PRXYon every referenced MAT id) and element-type registrations matching the grid’sansys_elem_type_numvalues.- Parameters:
model (femorph_solver.model.Model) – Source of both the strain kernel (
model.strain(u)) and the materials (model.materials). Element cells / nodes are read offmodel.grid.displacement (numpy.ndarray) –
(N,)DOF-indexed displacement inModel.dof_map()ordering. Typically a static-solve displacement or a modal mode shape.
- Returns:
(n_points, 6)Voigt stress, averaged across every element that touches a node. Nodes with no incident elements are zero.- Return type:
See also
compute_nodal_strainSame averaging pattern on the underlying strain field.
- femorph_solver.recover.nodal_to_dof_vector(nodal: np.ndarray, model: Model) np.ndarray[source]#
Gather a
(n_points, 6)per-node array into a DOF-ordered vector.Inverse of the scatter that each solver-result
.save()applies when projecting a DOF vector onto per-node per-component rows. Used by the stress-recovery path to feed a DOF vector intoModel.strain().- Parameters:
nodal (numpy.ndarray) –
(n_points, ≥ 3)per-node displacement. Extra columns (e.g. rotations) are indexed byModel.dof_map().model (femorph_solver.model.Model) – Provides the DOF layout and the mapping from node numbers back to grid point indices.
- Returns:
(n_dof,)DOF-indexed float64 vector.- Return type:
- femorph_solver.recover.stress_invariants(stress: ndarray) dict[str, ndarray][source]#
Scalar invariants of a Voigt stress field.
- Parameters:
stress (numpy.ndarray) –
(..., 6)Voigt stress[σxx, σyy, σzz, τxy, τyz, τxz].- Returns:
Each value has the same leading shape as
stressminus the trailing6axis. Keys:"von_mises"— \(\sigma_{vm} = \sqrt{\tfrac{3}{2}\,s_{ij}s_{ij}}\)."s1","s2","s3"— principal stresses, sorted descending (s1 >= s2 >= s3)."max_shear"— \((s_1 - s_3)/2\)."hydrostatic"— \((\sigma_{xx}+\sigma_{yy}+\sigma_{zz})/3\)."stress_intensity"— \(s_1 - s_3\).
- Return type:
Validation framework#
The public surface is re-exported from femorph_solver.validation;
the private _benchmark, _convergence, _report modules
are not separately autodoc’d to avoid duplicate cross-reference
targets in the rendered docs.
Validation framework — canonical FEA problems with published references.
femorph-solver’s validation pillar is a catalogue of benchmark problems whose expected result is a published value — a closed-form solution from a textbook, a handbook table, or a peer-reviewed paper — not another solver’s output. Each benchmark carries its source citation in-line so a reviewer can trace every tolerance back to the page it was derived on.
The framework provides three things:
A declarative
BenchmarkProbleminterface that couples a mesh-building recipe to a catalogue ofPublishedValuereferences.A
ConvergenceStudyrunner that sweeps a problem across mesh refinements, fits a convergence rate, and emits aConvergenceRecordsuitable for regression testing, verification-gallery rendering, or JSON output.A
_reportmodule that writes JSON + Markdown reports consumable by the docs pipeline and by CI regression guards.
Usage:
from femorph_solver.validation import ConvergenceStudy
from femorph_solver.validation.problems import CantileverEulerBernoulli
study = ConvergenceStudy(
problem=CantileverEulerBernoulli(),
refinements=[(20, 3, 3), (40, 3, 3), (80, 3, 3)],
)
records = study.run()
for r in records:
print(r.quantity_name, r.rel_error, r.convergence_rate)
The problems submodule is the catalogue; new problems are
registered there. Every problem mirrors a regression test under
tests/analytical/ so the verification pillar stays green when
the docs-facing catalogue does.
- class femorph_solver.validation.BenchmarkProblem[source]#
Bases:
ABCDeclarative canonical FEA problem with published reference(s).
Subclasses declare:
name— short identifier,snake_case.description— one-sentence summary.published_values— tuple ofPublishedValue.build_model()— construct a ready-to-solve model.extract()— pull a named quantity from the solve output.
The base class provides
validate()which composesbuild_model→solve/modal_solve→extractand returns a list ofValidationResult.- abstractmethod extract(model: Model, result: Any, name: str) float[source]#
Pull the quantity named
namefrom the solve output.resultis whatevermodel.solve()/model.modal_solve()returned, unboxed.nameis thePublishedValuename.
- validate(**mesh_params: Any) list[ValidationResult][source]#
Build, solve, compare against every published value.
Returns one
ValidationResultperpublished_valuesentry.
- class femorph_solver.validation.ConvergenceRecord(quantity_name: str, results: list[ValidationResult], convergence_rate: float | None = None)[source]#
Bases:
objectMeasured convergence of one quantity across refinements.
resultsis the per-refinement list.convergence_rateis the fitted \(p\) in \(|e| = C \cdot n_\text{dof}^{-p}\) over the last two refinements (coarsest pair drops out so the rate reflects the fine-mesh regime).Noneif fewer than two refinements completed or the error did not monotonically decrease.
- class femorph_solver.validation.ConvergenceStudy(problem: BenchmarkProblem, refinements: list[dict[str, Any]] = <factory>)[source]#
Bases:
objectRun a benchmark at multiple refinements, capture + fit.
- Parameters:
problem (BenchmarkProblem) – A
BenchmarkProbleminstance.refinements (list[dict[str, Any]]) – Sequence of mesh-parameter dicts. Each dict is forwarded verbatim to
problem.build_model. The order is coarse → fine; the final entry is the reference mesh for acceptance.
- run() list[ConvergenceRecord][source]#
Run every refinement; return one record per published quantity.
- class femorph_solver.validation.PublishedValue(name: str, value: float, unit: str = '', source: str = '', formula: str = '', tolerance: float = 0.05)[source]#
Bases:
objectOne published reference value associated with a benchmark.
The
sourcestring must uniquely identify the publication:"Timoshenko 1955 §5.4","Rao 2019 Table 8.1","Irons & Razzaque 1972"— enough that a reviewer can find the citation in the reference list without further lookup.toleranceis the acceptance tolerance for this quantity at the recommended mesh — looser on coarse meshes, tighter on finer ones. Convergence studies may use a strictertolerance_fn(n_dof)later; the scalar is the floor.
- class femorph_solver.validation.ValidationResult(problem_name: str, published: ~femorph_solver.validation._benchmark.PublishedValue, computed: float, mesh_params: dict[str, ~typing.Any] = <factory>, n_dof: int | None = None, wall_s: float | None = None, peak_rss_mb: float | None = None)[source]#
Bases:
objectOne validation attempt at a specific mesh refinement.
rel_erroris the signed relative error ((computed - published) / published).passedis set when the absolute relative error is within thePublishedValue’stolerance. All other fields are the raw inputs so the caller can re-render the comparison without re-solving.wall_sandpeak_rss_mbcapture the wall-clock time and peak resident-set size of the solve that produced this row, so validation outputs can feed thefemorph_solver.estimatorstraining loader — the same way benchmark rows do.
Public alias for the problem catalogue.
Importers should use:
from femorph_solver.validation.problems import CantileverEulerBernoulli
not the private _problems path. The catalogue re-exports
everything at this module level so the validation framework has a
single canonical public surface.
- class femorph_solver.validation.problems.AxialRodNaturalFrequency(name: str = 'axial_rod_nf', description: str = 'Fixed-free slender rod, fundamental axial natural frequency f_1 = (1/4L) sqrt(E/rho) (Rao 2017 §8.2).', analysis: str = 'modal', default_n_modes: int = 1, L: float = 1.0, area: float = 0.0001, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0)[source]#
Bases:
BenchmarkProblemFixed-free axial-rod fundamental natural frequency.
- area: float = 0.0001#
Cross-sectional area [m²] (any positive value works — the axial natural frequency is independent of
A).
- class femorph_solver.validation.problems.CantileverEulerBernoulli(name: str = 'cantilever_eb', description: str = 'Slender clamped cantilever under transverse tip load — Euler-Bernoulli closed-form (Timoshenko 1955 §5.4).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, P: float = 1000.0)[source]#
Bases:
BenchmarkProblemCantilever tip deflection / root stress vs Euler-Bernoulli.
- extract(model: Model, result: Any, name: str) float[source]#
Pull the quantity named
namefrom the solve output.resultis whatevermodel.solve()/model.modal_solve()returned, unboxed.nameis thePublishedValuename.
- class femorph_solver.validation.problems.CantileverHigherModes(name: str = 'cantilever_higher_modes', description: str = 'Clamped-free prismatic beam — 2nd and 3rd transverse bending modes. Euler-Bernoulli closed-form with β_n L from the characteristic equation (Rao 2017 §8.5).', analysis: str = 'modal', default_n_modes: int = 12, L: float = 4.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0)[source]#
Bases:
BenchmarkProblemCantilever second + third bending natural frequencies.
- L: float = 4.0#
Cantilever length [m]. Set to 4.0 (h/L = 1/80) so the Euler-Bernoulli closed form is asymptotically exact for the first four bending modes — the FE result tracks the EB prediction to within ~0.2 % across all four modes at the default mesh, instead of drifting up to ~4 % from the Timoshenko shear correction that a stockier beam picks up.
- class femorph_solver.validation.problems.CantileverNaturalFrequency(name: str = 'cantilever_nf', description: str = 'First transverse bending frequency of a clamped-free prismatic beam — Rao 2017 §8.5 Table 8.1.', analysis: str = 'modal', default_n_modes: int = 10, L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0)[source]#
Bases:
BenchmarkProblemCantilever first bending natural frequency (Rao 2017 Table 8.1).
- class femorph_solver.validation.problems.CantileverTipMoment(name: str = 'cantilever_tip_moment', description: str = 'Slender clamped cantilever under a pure tip moment — Euler-Bernoulli closed-form (Timoshenko 1955 §5.4).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, M: float = 50.0)[source]#
Bases:
BenchmarkProblemCantilever tip deflection + rotation under a pure tip moment.
- extract(model: Model, result: Any, name: str) float[source]#
Pull the quantity named
namefrom the solve output.resultis whatevermodel.solve()/model.modal_solve()returned, unboxed.nameis thePublishedValuename.
- class femorph_solver.validation.problems.CantileverTorsion(name: str = 'cantilever_torsion', description: str = 'Cantilever beam with a pure tip torque — Saint-Venant closed-form tip-twist φ = T L / (G J).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, T: float = 10.0)[source]#
Bases:
BenchmarkProblemCantilever tip twist under a pure tip torque.
- class femorph_solver.validation.problems.CantileverUDL(name: str = 'cantilever_udl', description: str = 'Slender clamped cantilever under uniformly-distributed transverse load — Euler-Bernoulli closed-form (Timoshenko 1955 §5.4).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, q: float = 1000.0)[source]#
Bases:
BenchmarkProblemCantilever tip deflection under uniform distributed load.
- extract(model: Model, result: Any, name: str) float[source]#
Pull the quantity named
namefrom the solve output.resultis whatevermodel.solve()/model.modal_solve()returned, unboxed.nameis thePublishedValuename.
- class femorph_solver.validation.problems.ClampedClampedBeamCentralLoad(name: str = 'cc_beam_central_load', description: str = 'Prismatic clamped-clamped beam with central transverse point load — Euler-Bernoulli closed-form (Timoshenko 1955 §5.7).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, P: float = 1000.0)[source]#
Bases:
BenchmarkProblemClamped-clamped beam mid-span deflection under central point load.
- class femorph_solver.validation.problems.ClampedClampedBeamModes(name: str = 'cc_beam_modes', description: str = 'Clamped-clamped prismatic beam, first three transverse bending natural frequencies — Rao 2017 §8.5 Table 8.1.', analysis: str = 'modal', default_n_modes: int = 8, L: float = 4.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0)[source]#
Bases:
BenchmarkProblemCC beam first three bending natural frequencies.
- L: float = 4.0#
Beam span [m]. Set to 4.0 (h/L = 1/80) so the Euler- Bernoulli closed form is asymptotically exact for the first three bending modes — at the original L = 1 (h/L = 1/20) the 3D solid mesh truthfully captures a Timoshenko shear / rotary-inertia correction that grows up to ~4 % at mode 3.
- class femorph_solver.validation.problems.ClampedPlateStatic(name: str = 'clamped_plate_static', description: str = 'Clamped square Kirchhoff plate under uniform pressure — NAFEMS LE5 / Timoshenko & Woinowsky-Krieger 1959 §32.', analysis: str = 'static', a: float = 1.0, h: float = 0.02, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, q: float = 100000.0)[source]#
Bases:
BenchmarkProblemClamped square plate under uniform pressure (NAFEMS LE5).
- class femorph_solver.validation.problems.FreeFreeRodModes(name: str = 'free_free_rod_modes', description: str = 'Free-free uniform rod axial modes — f_n = n / (2L) sqrt(E/ρ) (Rao 2017 §8.2 Table 8.1).', analysis: str = 'modal', default_n_modes: int = 4, L: float = 1.0, area: float = 0.0001, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0)[source]#
Bases:
BenchmarkProblemFree-free axial rod first elastic + second elastic frequencies.
- class femorph_solver.validation.problems.IronsPatchTest(name: str = 'patch_test', description: str = 'Irons constant-strain patch test: prescribed linear boundary displacement must reproduce a uniform strain to machine precision.', analysis: str = 'static', L: float = 1.0, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, eps0: float = 0.0001)[source]#
Bases:
BenchmarkProblem2×2×2 hex patch under imposed uniform strain (Irons 1972).
- class femorph_solver.validation.problems.KirchhoffSSPlateModes(name: str = 'ss_plate_modes', description: str = 'Simply-supported rectangular Kirchhoff plate fundamental bending frequency — Timoshenko & Woinowsky-Krieger 1959 §63.', analysis: str = 'modal', default_n_modes: int = 10, a: float = 1.0, b: float = 1.0, h: float = 0.01, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0)[source]#
Bases:
BenchmarkProblemSimply-supported Kirchhoff plate fundamental frequency.
- class femorph_solver.validation.problems.LameCylinder(name: str = 'lame_cylinder', description: str = 'Thick-walled cylinder under internal pressure — Lamé closed-form radial displacement + hoop stress at r = a (Timoshenko & Goodier 1970 §33).', analysis: str = 'static', a: float = 0.01, b: float = 0.02, t_axial: float = 0.001, E: float = 210000000000.0, nu: float = 0.3, rho: float = 7850.0, p_i: float = 100000000.0)[source]#
Bases:
BenchmarkProblemThick-walled cylinder under internal pressure (plane strain).
- class femorph_solver.validation.problems.NafemsLE1(name: str = 'nafems_le1', description: str = 'NAFEMS R0015 §2.1 LE1 — elliptic membrane under outward pressure, σ_yy at point D (stub of the inner ellipse).', analysis: str = 'static', a_in: float = 2.0, b_in: float = 1.0, a_out: float = 3.25, b_out: float = 2.75, thickness: float = 0.1, E: float = 210000000000.0, nu: float = 0.3, rho: float = 7800.0, pressure: float = 10000000.0)[source]#
Bases:
BenchmarkProblemNAFEMS LE1 — elliptic membrane, plane stress.
Reports σ_yy at point D = (2, 0), the “stub” of the inner ellipse. NAFEMS converged reference: 92.7 MPa.
- class femorph_solver.validation.problems.PinchedRing(name: str = 'pinched_ring', description: str = 'Thin circular ring under two opposed point loads — Castigliano closed-form diametrical deflection (Timoshenko & Young 1968 §79).', analysis: str = 'static', R: float = 0.1, width: float = 0.01, height: float = 0.005, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, P: float = 10.0)[source]#
Bases:
BenchmarkProblemPinched-ring diametrical deflection (quarter-symmetry).
- class femorph_solver.validation.problems.PlateWithHole(name: str = 'plate_with_hole', description: str = 'Quarter-symmetry plane-stress plate with a circular hole, remote uniaxial tension σ_∞. Kirsch (1898) stress concentration K_t = 3 at the hole edge, perpendicular to the remote tension axis.', analysis: str = 'static', a: float = 0.1, W: float = 1.0, thickness: float = 0.01, E: float = 210000000000.0, nu: float = 0.3, rho: float = 7850.0, sigma_infty: float = 10000000.0)[source]#
Bases:
BenchmarkProblemPlate with a circular hole under uniaxial tension — Kirsch K_t = 3.
- class femorph_solver.validation.problems.ProppedCantileverCentralLoad(name: str = 'propped_cantilever_central_load', description: str = 'Fixed-pinned beam with central transverse point load — Euler-Bernoulli closed-form (Gere & Goodno 2018 §10.3 Table 10-1 Case 6).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, P: float = 1000.0)[source]#
Bases:
BenchmarkProblemPropped cantilever mid-span deflection under central point load.
- class femorph_solver.validation.problems.SSPlateStatic(name: str = 'ss_plate_static', description: str = 'Simply-supported square Kirchhoff plate under uniform pressure; checks the centre deflection against the Navier series — Timoshenko & Woinowsky-Krieger 1959 §30-§31.', analysis: str = 'static', a: float = 1.0, h: float = 0.02, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, q: float = 100000.0)[source]#
Bases:
BenchmarkProblemSS square plate under uniform pressure — centre-deflection check.
- class femorph_solver.validation.problems.SimplySupportedBeamCentralLoad(name: str = 'ss_beam_central_load', description: str = 'Prismatic simply-supported beam with central transverse point load — Euler-Bernoulli closed-form (Timoshenko 1955 §5.6).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, P: float = 1000.0)[source]#
Bases:
BenchmarkProblemSimply-supported beam mid-span deflection under central point load.
- class femorph_solver.validation.problems.SimplySupportedBeamModes(name: str = 'ss_beam_modes', description: str = 'Prismatic simply-supported beam, first three transverse bending natural frequencies — Euler-Bernoulli closed-form (Rao 2017 §8.5).', analysis: str = 'modal', default_n_modes: int = 6, L: float = 4.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0)[source]#
Bases:
BenchmarkProblemSimply-supported beam first three bending natural frequencies.
- L: float = 4.0#
Beam length [m]. Set to 4.0 (h/L = 1/80) so the Euler- Bernoulli closed form is asymptotically exact across the first three bending modes — at L = 1 (h/L = 1/20) the 3D solid mesh truthfully captured a Timoshenko shear correction up to ~2.6 % at mode 3.
- class femorph_solver.validation.problems.SimplySupportedBeamUDL(name: str = 'ss_beam_udl', description: str = 'Prismatic simply-supported beam with uniformly-distributed transverse load — Euler-Bernoulli closed-form (Timoshenko 1955 §5.6).', analysis: str = 'static', L: float = 1.0, width: float = 0.05, height: float = 0.05, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, q: float = 1000.0)[source]#
Bases:
BenchmarkProblemSS beam mid-span deflection under uniformly-distributed load.
- class femorph_solver.validation.problems.SingleHexUniaxial(name: str = 'single_hex_uniaxial', description: str = "Single 8-node hex under uniaxial tension — verifies 1-D Hooke's law and Poisson contraction to machine precision.", analysis: str = 'static', L: float = 1.0, E: float = 200000000000.0, nu: float = 0.3, rho: float = 7850.0, P_total: float = 1000000.0)[source]#
Bases:
BenchmarkProblemUnit hex under uniaxial traction — Hooke’s law + Poisson.
Estimators#
Time + memory estimator for femorph-solver modal / static solves.
Model.estimate_solve(n_modes=...) returns predicted
(wall_s, peak_rss_mb, factor_gb) for the current mesh on the
current host, with p50 / p95 confidence bands. The
estimator reads its training set from every
femorph-benchmark-*.json file it can find (see the TA-6
benchmark module) plus an in-repo baseline so users get useful
predictions out of the box.
Public API:
HostSpec— per-machine feature vector.ProblemSpec— per-solve feature vector.Estimate— return type.Estimator— fit + predict (usually accessed via theModel.estimate_solveconvenience method).load_training_rows()— parse TA-6 JSON files into estimator training rows.
- class femorph_solver.estimators.Estimate(wall_s_p50: float, wall_s_p95: float, peak_rss_mb_p50: float, peak_rss_mb_p95: float, n_training_rows: int, bucket: str)[source]#
Bases:
objectPrediction return type.
- class femorph_solver.estimators.Estimator(fits: dict[str, dict[str, tuple[float, float, int]]] | None = None)[source]#
Bases:
objectFit per-(host, backend) log-log linear models from training rows.
Usage:
>>> from femorph_solver.estimators import ( ... Estimator, HostSpec, ProblemSpec, load_training_rows ... ) >>> est = Estimator.fit(load_training_rows()) >>> est.predict(ProblemSpec(n_dof=100_000, n_modes=10), ... host=HostSpec.auto()) Estimate(wall_s=p50 3.47 / p95 ..., peak_rss_mb=p50 ..., bucket='Intel(R) ...|94101.0|Linux 6.12.66 (x86_64)|mkl_direct', n_training=5)
- classmethod fit(rows: list[TrainingRow]) Estimator[source]#
Fit log-log linear coefficients on every (host, backend) bucket with ≥
_MIN_ROWS_PER_BUCKETrows. Unbucketed training data feeds a"shared"bucket.- Parameters:
rows – Training rows loaded via
femorph_solver.estimators.load_training_rows().
- predict(problem: ProblemSpec, *, host: HostSpec | None = None) Estimate[source]#
Predict
(wall_s, peak_rss_mb)for a single solve.- Parameters:
problem –
ProblemSpecdescribing the solve —n_dofis the dominant feature,linear_solverpicks the bucket.host –
HostSpecof the target machine.HostSpec.auto()by default.
- Returns:
p50/p95confidence bands on wall-time and peak RSS. The bands widen when the bucket had fewer training rows or when we fell back to the shared / prior fit.- Return type:
- class femorph_solver.estimators.HostSpec(cpu_model: str = 'unknown', n_cores_total: int = 1, n_cores_affinity: int = 1, ram_mb: float = 0.0, os_name: str = 'unknown', arch: str = 'unknown', has_mkl: bool = False, mkl_version: str = '', extras: dict[str, str] = <factory>)[source]#
Bases:
objectPer-host feature vector — stable across solves on one box.
The fields are what we can introspect cheaply from a running process + the kernel’s
/procinterfaces. Missing fields use sensible defaults so anHostSpec.auto()call can’t fail.- classmethod auto() HostSpec[source]#
Introspect the current machine — never raises.
Every branch has a graceful fallback; in a sandbox or on non-Linux kernels the values that can’t be probed stay at their defaults instead of crashing the estimator.
- extras: dict[str, str]#
Whatever additional fields the TA-6 benchmark shipped that the current estimator doesn’t use. Keeping the payload round-trippable means later retrain passes can pick up extras as new features without schema migration.
- signature() str[source]#
Stable string identifier for “same host” checks.
Two rows with matching signatures can train each other’s estimator coefficients; rows with different signatures go into the shared prior. The signature intentionally ignores transient things like affinity or MKL patch level — those are fields the estimator can regress on, but they don’t change the fundamental silicon.
- class femorph_solver.estimators.ProblemSpec(n_dof: int, n_modes: int, nnz: int = 0, linear_solver: str = 'auto', eigen_solver: str = 'arpack', ooc: bool = False)[source]#
Bases:
objectPer-solve feature vector fed to the estimator.
- n_dof#
Number of free DOFs after BC reduction. The dominant feature — factor cost scales ~``n_dof^(4/3)`` on 3D meshes with nested dissection, solve cost ~``n_dof × log(n_dof)``.
- Type:
- n_modes#
Modes requested. eigsh iteration count scales roughly linearly with this at fixed tolerance.
- Type:
- nnz#
Non-zeros in the reduced K (upper triangle). Captures mesh connectivity density — a 3D plate has denser fill per DOF than a 1D beam.
- Type:
- linear_solver#
Backend name.
mkl_directvspardisovscholmodhave measurably different factor + solve constants; we regress them separately rather than lumping.- Type:
- classmethod from_model(model, n_modes: int, *, linear_solver: str = 'auto', eigen_solver: str = 'arpack', ooc: bool = False) ProblemSpec[source]#
Extract a ProblemSpec from a
Model.Uses the model’s
_dof_layoutto count DOFs (cheap — doesn’t factor K).nnzis not computed here (would require a full K assembly); the caller can supply it if they have a cached matrix.
- femorph_solver.estimators.load_training_rows(paths: list[Path | str] | None = None) list[TrainingRow][source]#
Scan a list of benchmark JSON files into training rows.
- Parameters:
paths – Explicit list of JSON file paths. When
None, globs the current working directory forfemorph-benchmark-*.json— useful when a user dumps their last few runs alongside the script they’re running.- Returns:
Every successful benchmark row, one per entry. Failed rows (
ok != True) are skipped so the estimator doesn’t train on timeouts / crashes.- Return type:
list[TrainingRow]
- femorph_solver.estimators.load_validation_rows(paths: list[Path | str] | None = None, *, host_signature: str = 'unknown-host') list[TrainingRow][source]#
Scan validation JSON files into estimator training rows.
Validation runs (via
femorph_solver.validation) write their convergence records throughfemorph_solver.validation._report.render_json(). Each record carries per-refinementwall_s+peak_rss_mbfields (added in TA-10h-8) — exactly what the estimator training fit consumes.Because the validation reports have one file per study and don’t embed a host report, the caller passes an explicit
host_signature— typically derived from a companionfemorph_solver.Reportsnapshot. If omitted the loader uses"unknown-host"; the estimator’s per-host bucket handling gracefully degrades in that case.- Parameters:
paths – Explicit list of JSON file paths. When
None, globs the current working directory forfemorph-validation-*.jsonandvalidation.json.host_signature – A stable signature for the host these runs came from.
- Returns:
One row per refinement × quantity. When the same (problem, refinement) appears in multiple published quantities, the identical wall / peak row is emitted for each quantity — the estimator treats them as duplicate observations of the same solve cost, which is desired.
- Return type:
list[TrainingRow]
MAPDL compatibility#
MAPDL compatibility layer — deprecated alias for femorph_solver.interop.mapdl.
Deprecated since version 2026-04-24: This subpackage moved to femorph_solver.interop.mapdl as part
of the TA-10 native-API refactor. MAPDL is now one interop backend
among several (interop.nastran, interop.abaqus,
interop.optistruct are placeholders for the roadmap). Update
imports::
# Old (still works, emits DeprecationWarning): from femorph_solver.mapdl_api import from_cdb from femorph_solver.mapdl_api.io import read_rst_result_header
# New: from femorph_solver.interop.mapdl import from_cdb from femorph_solver.interop.mapdl.io import read_rst_result_header
The shim will be removed in a future release. See
femorph_solver.interop for the full interop package tree.
- femorph_solver.mapdl_api.from_cdb(path: str, **kwargs: Any) Model[source]#
Load a MAPDL CDB file via
mapdl_archive.Requires the
mapdlextra:pip install femorph_solver[mapdl].The deck’s
/UNITScommand (if present) is parsed and stamped ontoModel.unit_system. Decks without a/UNITSline getUnitSystem.UNSPECIFIED.
Load MAPDL CDB input decks into an femorph-solver Model.
Thin wrapper around mapdl_archive. Only the file-format reader
is involved — MAPDL itself is never invoked.
The CDB’s ET table (MAPDL element-type declarations such as
ET, 1, 186) is translated on load so the returned Model already
knows each ET id’s kernel — callers don’t have to re-declare them.
References
CDB format definition (MAPDL input deck, produced by the
CDWRITEcommand): Ansys, Inc., Ansys Mechanical APDL Command Reference, Release 2022R2, entryCDWRITE; and Ansys Mechanical APDL Basic Analysis Guide, chapter on the database file.Open-source parser:
mapdl-archive(akaszynski/mapdl-archive, MIT) — the production parser we delegate to. Does not invoke MAPDL, only reads the text grammar.
- femorph_solver.mapdl_api.cdb.from_cdb(path: str, **kwargs: Any) Model[source]#
Load a MAPDL CDB file via
mapdl_archive.Requires the
mapdlextra:pip install femorph_solver[mapdl].The deck’s
/UNITScommand (if present) is parsed and stamped ontoModel.unit_system. Decks without a/UNITSline getUnitSystem.UNSPECIFIED.
Binary IO (MAPDL formats)#
Low-level reader for MAPDL binary files (RST / FULL / EMAT / …).
MAPDL binary files use Fortran-style sequential records. Each record is laid out as:
[ size:int32 ] [ flags:int32 ] [ payload : size * 4 bytes ] [ pad:int32 ]
sizecounts 4-byte words of payload (so a double array of length n givessize = 2 * n).flagsonly uses its least-significant byte (byte 7 from the start of the record header). femorph-solver exposes the raw byte but does not use it to infer payload dtype or compression state. pymapdl-reader’s Python and C++ backends disagree on the bit positions and neither matches files produced by recent MAPDL — e.g. bit 3 (declared “sparse” by both) is routinely set on perfectly dense records emitted under/FCOMP,RST,0. Callers must pass the dtype appropriate to the record they expect (int32 for index tables, float64 for coordinates and results, etc.).the trailing
padint32 is a Fortran record-length repeater.
femorph-solver’s decoder supports uncompressed, dense records in the four standard
dtypes (int32, int16, float64, float32). Compression (bsparse, wsparse,
zlib) is not attempted — run MAPDL with /FCOMP,<file>,0 to disable.
- class femorph_solver.mapdl_api.io.binary.RecordInfo(size: int, dtype: dtype, flags: int, offset: int)[source]#
Bases:
objectMetadata for a single MAPDL record.
- femorph_solver.mapdl_api.io.binary.decompress_bsparse(payload: ndarray, dtype: dtype | str = '<f8') ndarray[source]#
Decompress a bit-sparse (bsparse) record payload.
MAPDL’s bsparse encoding for a vector of length
N:word[0] = N # decompressed length word[1] = bitcode # 32-bit mask; bit i set -> position i nonzero payload = K packed values # K = number of set bits in bitcode
Output is a dense ndarray of length
Nwith zeros in unset slots. OnlyN <= 32is supported — MAPDL uses a different encoding for longer vectors.
- femorph_solver.mapdl_api.io.binary.decompress_wsparse(payload: ndarray, dtype: dtype | str = '<f8') ndarray[source]#
Decompress a windowed-sparse (wsparse) record payload.
MAPDL’s wsparse encoding for a vector of length
N:word[0] = N # decompressed length word[1] = NWin # number of windows for each window: iLoc = int32 if iLoc > 0: # isolated single value at position iLoc one value (dtype-sized) else: # window starts at position -iLoc iLen = int32 if iLen > 0: # iLen explicit values iLen values else: # constant run of (-iLen) copies one value
All missing positions are zero.
- femorph_solver.mapdl_api.io.binary.iter_records(fp: BinaryIO, start: int = 0, dtype: dtype | str = '<i4')[source]#
Yield
(offset_bytes, RecordInfo, ndarray)tuples sequentially.Stops at EOF or when the size field is non-positive (MAPDL marks the end of the sequential stream with a -1 sentinel or zero padding).
- femorph_solver.mapdl_api.io.binary.open_for_write(path: str | Path) BinaryIO[source]#
Open
pathfor writing as a new MAPDL binary file.
- femorph_solver.mapdl_api.io.binary.read_record(fp: BinaryIO, offset: int | None = None, dtype: dtype | str = '<i4') tuple[ndarray, RecordInfo][source]#
Read one MAPDL record starting at
offset(bytes) or current position.dtypeselects how to cast the payload. MAPDL doesn’t encode dtype reliably in the flag byte, so callers must pass the dtype appropriate to the record they expect (int32 for index tables, float64 for coordinates and results, etc.). The default is little-endian int32.Returns
(array, info)wherearrayis the dense payload andinfocarries the header metadata. Advances the file pointer past the trailing Fortran pad word.
- femorph_solver.mapdl_api.io.binary.read_record_at(path: str | Path, word_offset: int, dtype: dtype | str = '<i4') tuple[ndarray, RecordInfo][source]#
Convenience: open, seek to a 4-byte-word offset, read one record.
- femorph_solver.mapdl_api.io.binary.read_standard_header(path: str | Path) dict[source]#
Parse the MAPDL standard file header.
Every MAPDL binary file begins with a 100-word standard header whose fields live at fixed int32 word offsets. Numeric fields occupy one int32 word followed by a single padding word; string fields are packed 4 chars per word with the bytes of each word reversed (MAPDL stores them with the Fortran byte order flipped).
The returned mapping covers the canonical fields:
file_number,file_format,time,date,units,verstring,mainver,subver,machine,jobname,product,username,machine_identifier,system_record_size,jobname2,title,subtitle,split_point.
- femorph_solver.mapdl_api.io.binary.write_record(fp: BinaryIO, payload: ndarray, flags: int | None = None) int[source]#
Append one MAPDL record to
fpand return its byte offset.payloadis written verbatim as bytes.sizeis derived fromlen(payload.tobytes()) // 4so any numpy dtype whose itemsize is a multiple of 4 works.flagsdefaults to 0x80 for int32 payloads and 0x00 for float64; any other dtype defaults to 0x00.
- femorph_solver.mapdl_api.io.binary.write_standard_header(fp: BinaryIO, *, file_format: int, jobname: str = 'file', time: str = '00:00:00', date: str = '', units: int = 0, verstring: str = '22.2', machine: str = 'LINUX x64', product: str = 'FULL', username: str = '', title: str = '') None[source]#
Write the fixed-layout standard-header record to
fp.Mirrors
read_standard_header(). The record holds a 100-word payload; the payload indices below are payload-relative. Read side sees these words at file-absolute positions[i+2]because the record framer prepends the 2-word(size, flags)header.
Typed record schemas for MAPDL RST / FULL / EMAT files.
The key lists mirror those in fdresu.inc (the Fortran include file
that defines the layout of result-file records). They are reproduced
verbatim from pymapdl-reader’s _rst_keys.py.
parse_header maps a flat int32 table onto a named dict, handling two
conventions that MAPDL uses inside these tables:
Array fields — keys that appear multiple times (e.g.
DOFS,title) accumulate into a list.Split 64-bit pointers — pairs named
ptrXXXl/ptrXXXhare reassembled into a single 64-bitptrXXXvalue.
References
Ansys MAPDL result-file layout (primary compatibility source): Ansys, Inc., Ansys Mechanical APDL Theory Reference, Release 2022R2 — binary-file chapter describing the block / record / pointer structure of
.rst,.full, and.emat.Open-source cross-reference used to port the Fortran record keys into Python:
pymapdl-reader(MIT licence, ansys/pymapdl-reader) — the key tables here (solution_header_keys,result_header_keys, …) are reproduced from its_rst_keys.py. The underlying layout originates in MAPDL’s ownfdresu.incFortran include file.
- femorph_solver.mapdl_api.io.rst_schema.find_record(path: str | Path, index: int, dtype: dtype | str = '<i4') ndarray[source]#
Return the payload of record
index(0-based) as an ndarray.
- femorph_solver.mapdl_api.io.rst_schema.parse_header(table: ndarray, keys: list[str]) dict[source]#
Map a flat int32 table onto a named header dict.
Array-valued keys (those that appear more than once in
keys) are gathered into a list. PairsptrXXXl/ptrXXXhare reassembled into a single 64-bitptrXXXentry.
- femorph_solver.mapdl_api.io.rst_schema.read_nodal_displacement(path: str | Path, set_index: int = 0) ndarray[source]#
Return the decompressed nodal displacement vector for result set 0.
Output shape is
(nnod, numdof). Entries for DOFs with no stored value (bits unset in the bsparse bitmask) come back as zero.
- femorph_solver.mapdl_api.io.rst_schema.read_rst_geometry_header(path: str | Path) dict[source]#
Read the geometry header from an RST file using
ptrGEO.The result header’s
ptrGEOis a byte-offset expressed in 4-byte words from the start of the file. Seek there and read one record.
- femorph_solver.mapdl_api.io.rst_schema.read_rst_result_header(path: str | Path) dict[source]#
Read record #1 of an RST file and parse it as a result header.
- femorph_solver.mapdl_api.io.rst_schema.read_solution_data_header(path: str | Path, set_index: int = 0) tuple[dict, int][source]#
Parse the solution-data header for result set
set_index.Returns
(header_dict, solution_word_offset). The word offset is the absolute file position of the solution header’s 8-byte record frame — useful becauseptrNSL/ptrESL/ptrBCinside the header are relative to that position.
Schema + reader for MAPDL .full files (assembled K/M/C matrices).
A FULL file stores, for each column of the upper triangle, two framed
records: one holding the 1-based row indices of that column’s nonzero
entries, and one holding the matching double-precision values. The
record pointers ptrSTF / ptrMAS / ptrDMP address those
column blocks; ptrDOF addresses a (ndof, const) pair of records
that describes which equations are constrained.
Layout sketch (for one column c of an ntermK-nonzero stiffness matrix, upper triangle only):
[ size=nitems | flags | rows(nitems int32, 1-based) | pad4 ]
[ size=2·nitems | flags | vals(nitems doubles) | pad4 ]
The constrained-DOF mask (const[i] < 0) is applied at read time to
drop fixed rows and columns, matching pymapdl-reader’s behavior.
References
Ansys MAPDL
.fullfile layout (compatibility source): Ansys, Inc., Ansys Mechanical APDL Theory Reference, Release 2022R2 — binary-file chapter, section on.full(assembled global K / M / C matrices).Open-source cross-reference:
pymapdl-reader(MIT, ansys/pymapdl-reader)full_file.py— used here only as a key-map yardstick; the underlying CSC-like column-indexed layout comes from MAPDL’s own Fortran code.
- femorph_solver.mapdl_api.io.full_schema.read_full_header(path: str | Path) dict[source]#
Read the FULL file header (record index 1) as a named dict.
- femorph_solver.mapdl_api.io.full_schema.read_full_km(path: str | Path, reduce: bool = True) tuple[csc_matrix | None, csc_matrix | None, ndarray][source]#
Read stiffness + mass matrices from a MAPDL FULL file.
- Parameters:
path – Path to the
.fullfile.reduce – If True (the default), drop rows and columns whose
const[i] < 0(i.e. the constrained DOFs MAPDL still includes in the assembled matrix but which the user almost always wants eliminated). This matches the behavior ofpymapdl_reader.FullFile.load_km.
- Returns:
kandmare scipy CSC matrices (orNoneif the file doesn’t store that matrix).dof_refis an(neqn_out, 2)int32 array of(node, dof_id)pairs aligned with the matrix rows;dof_iduses the 1-based MAPDL DOF numbering (seefemorph_solver.mapdl_api.io.rst_schema.DOF_REF).- Return type:
k, m, dof_ref
Schema + reader for MAPDL .emat (element matrix) files.
An EMAT file stores the per-element stiffness, mass, damping, stress-
stiffening, applied-force, Newton–Raphson, and imaginary-load
contributions that MAPDL summed to build the global K/M/C matrices in
the matching FULL file. Layout (per pymapdl-reader’s emat.py and
fdemat.inc):
Record 0: standard header
Record 1 (at word 103): EMAT header (40 words; see
emat_header_keys)Record 2: time-info record (20 doubles)
Record at
ptrDOF: DOF identifier list (numdofints)Record at
ptrBAC: nodal equivalence (lenbacints)Record at
ptrElm: element equivalence (numeints)Record at
ptrBIT: DOF bits (lenuints)Record at
ptrIDX: element-matrix index table (2 * numeints, (lo, hi) pairs pointing to each element’s block)
Per-element block (repeated nume times, addressed via the index
table):
[ element matrix header (10 ints) ]
[ DOF index table (nmrow ints, 1-based global DOF numbers) ]
[ K matrix if stkey ] ← symmetric stored lower triangular
[ M matrix if mkey ]
[ D matrix if dkey ]
[ SS matrix if sskey ]
[ applied force (2*nmrow doubles) if akey ]
[ NR load (nmrow doubles) if nrkey ]
[ imaginary (nmrow doubles) if ikey ]
Symmetric element matrices are packed lower-triangular in column-major
order (n(n+1)/2 doubles). If nmrow is positive the matrix is
stored unsymmetric full (n*n doubles).
References
Ansys MAPDL element-matrix file layout (compatibility source): Ansys, Inc., Ansys Mechanical APDL Theory Reference, Release 2022R2 — binary-file chapter, section on
.emat.Fortran include reference used as the source for key names and record ordering: MAPDL’s internal
fdemat.inc.Open-source cross-reference:
pymapdl-reader(MIT, ansys/pymapdl-reader)emat.py. We rely on it as a compatibility yardstick for record offsets, not for any algorithmic content.
- femorph_solver.mapdl_api.io.emat_schema.read_emat_element(path: str | Path, index: int) tuple[ndarray, dict[str, ndarray]][source]#
Read one element’s block from an EMAT file.
- Parameters:
path – Path to the
.ematfile.index – 0-based element index (into the element equivalence table; not the MAPDL element number unless the file is already sorted).
- Returns:
dof_idxis a zero-based array of the global DOF positions this element contributes to ((N-1)*numdof + dof).matricesmaps the keys{'k', 'm', 'd', 'ss', 'applied_force', 'newton_raphson', 'imaginary_load'}to dense(nmrow, nmrow)matrices (for K/M/D/SS) or length-nmrow/ length-2·nmrowvectors (for the force terms), whichever are present in the file.- Return type:
dof_idx, matrices