Python API¶
The problem¶
- class dualmesh.Problem(mesh, method='dmcdm', coordinates='cartesian', boundary_gradient='first_order')[source]¶
A boundary value problem discretized on a primal mesh and its dual mesh.
- Parameters:
mesh – The primal mesh of finite elements.
method (str) – The discretization.
"dmcdm"is the dual mesh control domain method (the default);"fem"is the Galerkin finite element method;"hfvm"is the vertex-centred finite volume method, which uses the same control domains as the dual mesh method but two-point gradients at their interfaces (the half-control volume formulation of Reddy, Chapter 3);"zfvm"is the cell-centred finite volume method, with one unknown per element and one per boundary face (the zero-thickness control volume formulation, and the layout used by OpenFOAM). The rest of the problem definition is identical for all four, which makes them directly comparable.boundary_gradient (str) – Only for
"zfvm":"first_order"(the two-point difference between the cell and the boundary node, the default) or"second_order"(the one-sided quadratic of Eq. (3.2.14) of the book, through the boundary node and the two nearest cells).coordinates (str) –
"cartesian","axisymmetric"(the integrals carry the factor \(2 \pi r\), with \(r\) the first coordinate), or"spherical"(factor \(4 \pi r^2\), one-dimensional meshes).
- set_num_threads(num_threads)[source]¶
Number of threads used by the assembly loops.
0means the OpenMP default, which is the value of theOMP_NUM_THREADSenvironment variable or, if that is unset, the number of cores. The setting is a request: the assembly falls back to one thread when the library was built without OpenMP, when the mesh is too small for threading to pay for itself, or when any kernel, boundary condition, material or function of the problem is defined in Python, because calling back into the interpreter needs the global interpreter lock.effective_threads()reports the number actually used.- Parameters:
num_threads (int)
- Return type:
None
- property thread_safe: bool¶
False when some object of the problem is defined in Python, which forces the assembly onto one thread.
- property is_cell_centered: bool¶
True when the unknowns sit at cell centroids instead of mesh nodes.
- entity_points()[source]¶
Positions of the degrees of freedom, one row each, in the order of
values().For the dual mesh, finite element and vertex-centred finite volume methods these are the mesh nodes. For the cell-centred finite volume method they are the cell centroids first and then the boundary face centroids.
- Return type:
- add_variable(name, blocks=(), initial_condition=None)[source]¶
Add a nodal unknown; returns its index.
- add_function(name, function)[source]¶
Register a named function of
(x, y, z, t)(or a constant).- Parameters:
name (str)
- Return type:
None
- add_kernel(kernel, name=None, **parameters)[source]¶
Add a kernel by registered type name, or an object built in Python.
- Parameters:
name (str | None)
- add_boundary_condition(condition, name=None, **parameters)[source]¶
Add a boundary condition (essential or natural).
- Parameters:
name (str | None)
- add_material(material, name=None, **parameters)[source]¶
Add a material (a provider of named properties).
- Parameters:
name (str | None)
- add_point_source(source='PointSource', name=None, **parameters)[source]¶
Add a concentrated nodal source (point force or point heat source).
- Parameters:
name (str | None)
- add_nodal_load(source='PointSource', name=None, **parameters)¶
Add a concentrated nodal source (point force or point heat source).
- Parameters:
name (str | None)
- solve(**options)[source]¶
Solve the steady problem.
Keyword arguments are solver options:
nonlinear_solver("newton","picard", or"linear"),max_iterations,relative_tolerance,absolute_tolerance,step_tolerance,relaxation(the acceleration parameter of direct iteration),load_factors(load stepping),linear_solver("automatic", the default,"lu","bicgstab","gmres"or"cg"),preconditioner("ilu", the default,"ilut","jacobi"or"none"),linear_tolerance,linear_max_iterations,gmres_restart,verbose, anderror_on_divergence."automatic"factorises the system directly where that is cheap, which is always in one dimension, up to \(10^5\) unknowns in two and up to a few thousand in three, and otherwise uses BiCGSTAB preconditioned by an incomplete LU factorisation, falling back to the direct solver if the iteration does not converge. On a three-dimensional mesh the iteration is typically ten to fifty times faster than the direct solver, because the direct factors of a 3D problem fill in far more.- Return type:
- solve_transient(end_time, dt, start_time=0.0, theta=1.0, output_interval=0, output_file_base='', time_stepper='fixed', dt_min=0.0, dt_max=0.0, growth_factor=2.0, cutback_factor=0.5, error_tolerance=0.001, optimal_iterations=4, iteration_window=2, max_rejected_steps=10, **options)[source]¶
March the solution forward in time with the theta method.
The theta method weights the steady part of the residual between the old state and the new one,
\[R_{\text{time}}(U^{n+1}) + \theta R_{\text{steady}}(U^{n+1}) + (1 - \theta) R_{\text{steady}}(U^{n}) = 0,\]so
theta=1is backward Euler, which is unconditionally stable and first-order accurate;theta=0.5is the Crank-Nicolson, or midpoint, rule, which is unconditionally stable and second-order accurate but can ring on a sharp transient; andtheta=0is forward Euler, which is explicit in the steady terms and is stable only below a critical step.- Parameters:
end_time (float) – The interval to cover and the step to take. With an adaptive stepper
dtis the first step rather than every step.dt (float) – The interval to cover and the step to take. With an adaptive stepper
dtis the first step rather than every step.start_time (float) – The interval to cover and the step to take. With an adaptive stepper
dtis the first step rather than every step.theta (float) – The weight above.
output_interval (int) – Write a
.vtufile every so many accepted steps.output_file_base (str) – Write a
.vtufile every so many accepted steps.time_stepper (str) –
"fixed"keeps the step, shortening only the last one so that the run lands exactly onend_time."error"chooses the step from an estimate of the local truncation error. Each interval is advanced twice, once with one step and once with two half steps, and the difference between the two answers estimates the error of the coarse one by Richardson extrapolation. A step whose relative error exceedserror_toleranceis discarded and retried with a smaller step; an accepted step is followed by the largest step the estimate allows. The answer that is kept is the accurate one, from the two half steps. The estimate costs three nonlinear solves per accepted step, so use this when accuracy in time is what matters."iteration"chooses the step from how hard the nonlinear solver worked. A step that converged in fewer thanoptimal_iterations - iteration_windowiterations is followed by a larger one, a step that needed more thanoptimal_iterations + iteration_windowby a smaller one, and a step that failed to converge is discarded and retried. It costs nothing beyond the solve and is the right choice when the difficulty is the nonlinearity rather than the accuracy.dt_min (float) – Bounds on the step. A run that has to go below
dt_minis reported as a failure rather than grinding to a halt. Zero meansdtdivided by one million, and the whole interval, respectively.dt_max (float) – Bounds on the step. A run that has to go below
dt_minis reported as a failure rather than grinding to a halt. Zero meansdtdivided by one million, and the whole interval, respectively.growth_factor (float) – The most the step may grow between accepted steps, and the factor applied after a rejected one.
cutback_factor (float) – The most the step may grow between accepted steps, and the factor applied after a rejected one.
error_tolerance (float) – The target for the relative local error of one step.
optimal_iterations (int) – The iteration count the
"iteration"stepper aims for, and the half-width of the band around it inside which the step is left alone.iteration_window (int) – The iteration count the
"iteration"stepper aims for, and the half-width of the band around it inside which the step is left alone.max_rejected_steps (int) – How many times in a row a step may be rejected before the run is declared a failure.
**options – Passed to the nonlinear solver of every step; see
solve().
- Returns:
Besides the usual fields,
time_stepscounts the accepted steps,rejected_stepsthe discarded ones, andstep_historyholds the time reached and the step taken for each accepted step.- Return type:
- set_time_step_callback(callback)[source]¶
Call
callback(time, problem)after every converged time step.- Return type:
None
- property mesh¶
- values(variable)[source]¶
Values of a variable, one per degree of freedom entity.
For every method but
"zfvm"these are nodal values indexed by node; for"zfvm"they are cell values followed by boundary face values. Useentity_points()for the matching coordinates.- Parameters:
variable (str)
- Return type:
- reactions(variable, boundary)[source]¶
Secondary variables at the nodes of a boundary:
[(node, value), ...].For each node the value is the integral of the normal flux over the part of the boundary that belongs to that node’s control domain, which is the quantity Reddy denotes \(Q_I\) (a reaction, a heat flow, a force).
A node that lies on two boundaries (a corner) carries one reaction that covers its whole boundary portion, so it appears in both lists. When summing over several boundaries, collect the nodes first (
dict(problem.reactions(...))) instead of adding the totals, or corner nodes are counted twice.
- sample(variable, points)[source]¶
Interpolate a variable at arbitrary points (NaN outside the mesh).
- Parameters:
variable (str)
- Return type:
- node_at(point, tolerance=1e-09)[source]¶
Index of the node closest to
point(an error if none is withintolerance).
- error_indicator(variable)[source]¶
One error indicator per element, from gradient recovery.
The gradient of the computed solution jumps between elements. A smoother gradient is recovered by averaging the element gradients onto the nodes, weighted by the share of each element that belongs to the node’s control domain, and interpolating that nodal field back over the element. The indicator of an element is the square root of the integral over it of the squared difference between the two gradients. The recovered gradient is the more accurate of the two, so their difference measures the error in the computed one; this is the estimator of Zienkiewicz and Zhu (1987).
It is an indicator, not a bound. It says which elements carry most of the error, which is what
mark_by_fraction()and its relatives need, and it does not certify the size of the error.- Parameters:
variable (str)
- Return type:
- error_norms(variable, exact, exact_gradient=None, quadrature_points=0)[source]¶
The error of the computed field against a known exact solution.
Returns
(l2, h1_seminorm): the \(L^2\) norm of \(u_h - u\) and the \(H^1\) seminorm, the \(L^2\) norm of \(\nabla u_h - \nabla u\). These are the norms in which the convergence theory of every method in the library is stated, so they are what a convergence study should measure.exactis anything a parameter accepts: a number, an expression string, aParsedFunctionor a Python callable of(x, y, z, t).exact_gradientis a sequence of up to three of the same, one per component; missing components are taken as zero, and without it the seminorm is returned asnan.\(u_h\) is the field the method actually represents: the element interpolation of the nodal values for
dmcdm,femandhfvm, and forzfvmthe linear reconstruction \(U_c + G_c \\cdot (x - x_c)\) in every cell, whose gradient is the reconstructed cell gradient. The integrals use a Gauss rule ofquadrature_pointsper direction on every element (zero chooses the polynomial order plus two, which over-integrates the leading term of the error) and include the coordinate factor. The sum runs on several threads unless one of the functions is a Python callable.
- linear_system()[source]¶
The residual and the Jacobian of the steady problem at the current solution, as
(residual, jacobian).This is the system one Newton step of
solve()solves, \(J\,\delta U = -R\): the prescribed boundary values are written into the solution first, and the rows (and columns) of the prescribed degrees of freedom are replaced by those of the identity.residualis a NumPy array andjacobiana SciPy compressed sparse column matrix, so the system can be handed to any solver or preconditioner that works with SciPy, for instance to compare linear solvers or to study the spectrum of a discretisation. It needs SciPy. The degree of freedom of variablevon entityiisi * num_variables + v.
- write_vtu(filename, cell_properties=())[source]¶
Write a VTK unstructured grid (readable by ParaView and VisIt).
- write_mesh_file(filename, file_format=None)[source]¶
Write the mesh and all nodal fields through meshio (Exodus, VTU, …).
Meshes¶
- class dualmesh.Mesh(*args, **kwargs)¶
- dualmesh.generate_line_mesh(start=None, end=None, num_elements=None, bias=1.0, coordinates=None, element_type='Edge2')[source]¶
One-dimensional mesh of
Edge2orEdge3elements.Side sets and node sets
"left"and"right"are created automatically.num_elementsalways counts elements, so anEdge3mesh withnum_elements=4has four elements and nine nodes.
- dualmesh.generate_rectangle_mesh(x_min=None, x_max=None, y_min=None, y_max=None, num_x_elements=None, num_y_elements=None, element_type='Quad4', diagonal='right', x_bias=1.0, y_bias=1.0, x_coordinates=None, y_coordinates=None)[source]¶
Structured two-dimensional mesh of
Quad4,Tri3,Quad8,Quad9orTri6elements.Quad8works withmethod="fem"andmethod="zfvm"only.Side sets
"left","right","bottom"and"top"are created automatically. Passx_coordinates/y_coordinatesfor a fully non-uniform grid, or the bounds together with the element counts and an optional geometricbias. The element counts always count elements, so asking for a quadratic type gives the same number of elements and more nodes, not fewer elements.- Parameters:
- Return type:
- dualmesh.generate_box_mesh(x_min=None, x_max=None, y_min=None, y_max=None, z_min=None, z_max=None, num_x_elements=None, num_y_elements=None, num_z_elements=None, element_type='Hex8', x_bias=1.0, y_bias=1.0, z_bias=1.0, x_coordinates=None, y_coordinates=None, z_coordinates=None)[source]¶
Structured three-dimensional mesh of
Hex8,Tet4,Wedge6,Pyramid5,Hex20,Hex27orTet10elements. ATet4mesh has six tetrahedra per cell, aWedge6mesh two prisms split on the base diagonal, and aPyramid5mesh six pyramids meeting at an added centre node.Pyramid5andHex20work withmethod="fem"andmethod="zfvm"only.Side sets
"left","right","bottom","top","back"and"front"are created automatically.
- dualmesh.generate_annulus_mesh(inner_radius, outer_radius, num_radial_elements, num_angular_elements, start_angle=0.0, end_angle=90.0, element_type='Quad4', radial_bias=1.0, radial_coordinates=None)[source]¶
Mesh of an annular sector (angles in degrees, measured from the x axis).
Side sets:
"inner"(r = inner_radius),"outer"(r = outer_radius),"start"(the start_angle edge) and"end"(the end_angle edge). This is the mesh used for thick pressurized cylinders and for the classical plate-with-a-hole problem.
- dualmesh.read_mesh(filename, file_format=None, boundary_names=None, add_bounding_box_sidesets=False)[source]¶
Read a mesh file through meshio and convert it to a dualmesh mesh.
Physical groups (Gmsh) or element blocks (Exodus) become subdomain ids, and lower-dimensional cell groups become side sets named after their physical name (or
"boundary_<id>"when they are unnamed).boundary_namescan rename them, for example{"boundary_1": "inlet"}.- Parameters:
- Return type:
- dualmesh.write_mesh(mesh, filename, file_format=None, **point_data)[source]¶
Write a mesh (and optional nodal fields) through meshio.
- Parameters:
mesh (dualmesh._core.Mesh)
filename (str)
file_format (str | None)
- Return type:
None
- dualmesh.mesh_from_arrays(points, cells, element_type=None, blocks=None, dimension=None)[source]¶
Build a mesh from a node array and a connectivity array.
pointshas shape(num_nodes, 1..3).cellsis either an array of shape(num_elements, nodes_per_element)with a singleelement_type, or a list of(element_type, connectivity)pairs.blocksoptionally gives one subdomain id per element.- Parameters:
- Return type:
- dualmesh.graded_coordinates(start, end, num_elements, bias=1.0)[source]¶
Node coordinates from
starttoendwith a geometric grading.biasis the ratio between the lengths of successive elements: 1.0 gives a uniform mesh, values larger than one make the elements grow towardsend, values smaller than one refine towardsend.
- dualmesh.annulus_coordinates(inner_radius, outer_radius, num_elements, bias=1.0)[source]¶
Radial node coordinates for an annulus (a convenience alias).
- dualmesh.meshing.coordinates_from_spacings(start, spacings)[source]¶
Node coordinates built by accumulating the given element sizes.
- dualmesh.meshing.mesh_summary(mesh)[source]¶
Human-readable description of a mesh.
- Parameters:
mesh (dualmesh._core.Mesh)
- Return type:
Results and expressions¶
- class dualmesh.SolveResult(*args, **kwargs)¶
- class dualmesh.ParsedFunction(*args, **kwargs)¶
Parallel execution¶
- class dualmesh.DistributedProblem(mesh, method='dmcdm', coordinates='cartesian', partitioner='graph', linear_solver='bicgstab', preconditioner='two_level_schwarz', overlap=1, subdomain_solver='ilu', linear_tolerance=1e-10, linear_max_iterations=5000, verbose=False)[source]¶
A problem split across MPI ranks.
Build it, define the physics on
localexactly as for a serialdualmesh.Problem, then callsolve()orsolve_transient()on this object rather than on the local one.- Parameters:
mesh – The whole mesh. Every rank reads it and then keeps only its own part, so the mesh itself is replicated; the degrees of freedom are not.
method (str) – As for
dualmesh.Problem.coordinates (str) – As for
dualmesh.Problem.partitioner (str) – Which partitioner to use; see
partition_mesh().linear_solver (str) –
"bicgstab"(the default, for any matrix) or"cg"(symmetric positive definite matrices only, about twice as cheap per iteration). Both are distributed: the matrix-vector product multiplies by each rank’s local matrix and adds the results across the partition boundary, and the inner products count every degree of freedom once. The conjugate gradient method needs a symmetric preconditioner, so with it the Schwarz options are applied in their classical, unrestricted form (every subdomain’s whole correction is added) and the coarse level additively; this is weaker than the restricted form, and on the 48 by 48 Poisson problem"cg"takes 64 to 69 iterations on four to eight ranks with any of the three preconditioners.preconditioner (str) –
"two_level_schwarz", the default, is restricted additive Schwarz on overlapping subdomains with a coarse level. Every rank’s subdomain is its own elements extended byoverlaplayers of its neighbours’ elements. The subdomain matrix is the global matrix restricted to the subdomain, \(R_\delta A R_\delta^T\), with every row fully assembled (the ranks send each other the entries they hold). Each rank solves its subdomain problem approximately and keeps the correction only on the degrees of freedom it owns (Cai and Sarkis, 1999). The coarse level has one unknown per subdomain and variable (Nicolaides, 1987) and is applied before the subdomain solves (the operator called A-DEF1 by Tang, Nabben, Vuik and Erlangga, 2009), so that information crosses the whole mesh in one application."additive_schwarz"is the same without the coarse level."jacobi"divides by the diagonal of the global matrix.On the Poisson problem of a 48 by 48 mesh, BiCGSTAB to a relative tolerance of \(10^{-10}\) takes the following numbers of iterations (measured with this library):
ranks
1
2
4
8
16
two_level_schwarz24
25
23
25
24
additive_schwarz24
26
29
33
33
jacobi50
49
49
50
49
On a 128 by 128 mesh the two-level counts are 66, 60 and 64 on one, four and sixteen ranks, the one-level counts 66, 76 and 86, and Jacobi’s 137, 131 and 132. The iteration count is reported as
linear_iterationson the result ofsolve(), and it is the number to watch: a preconditioner whose count grows in proportion to the number of ranks cancels the benefit of the extra ranks.overlap (int) – Layers of elements by which each Schwarz subdomain reaches into its neighbours (default 1). Zero gives non-overlapping subdomains. More overlap lowers the iteration count and raises the cost of each subdomain solve and of the setup exchange.
subdomain_solver (str) –
"ilu"(the default: incomplete LU without fill, cheap to build and apply) or"lu"(an exact sparse LU of each subdomain matrix, which roughly halves the iteration count in two dimensions and is expensive for large three-dimensional subdomains).linear_tolerance (float) – As for the serial solver.
linear_max_iterations (int) – As for the serial solver.
verbose (bool) – As for the serial solver.
- local¶
The rank-local problem; define the physics on it.
- solve(**options)[source]¶
Solve the steady problem. Takes the same options as
dualmesh.Problem.solve(), except that the linear solver is configured in the constructor.
- solve_transient(end_time, dt, start_time=0.0, theta=1.0, output_interval=0, output_file_base='', time_stepper='fixed', dt_min=0.0, dt_max=0.0, growth_factor=2.0, cutback_factor=0.5, error_tolerance=0.001, optimal_iterations=4, iteration_window=2, max_rejected_steps=10, **options)[source]¶
Advance the problem in time, with the same arguments and the same time steppers as
dualmesh.Problem.solve_transient().The adaptive controllers are driven by quantities that are already global reductions – the residual norm and the norm of the difference between the coarse and the fine step – so every rank reaches the same decision and they step in lockstep without any extra communication. With
output_file_baseset, each output step writes one.vtuper rank and one.pvtuindex.- Parameters:
- gathered_values(variable)[source]¶
Values of a variable at every node of the whole mesh, assembled on every rank.
This allocates one vector of the global size per rank, so it is meant for testing and for small problems; large runs should write the result with
write_vtu()instead.- Parameters:
variable (str)
- dualmesh.partition_mesh(mesh, num_parts, method='recursive_coordinate_bisection')[source]¶
Split a mesh into
num_partsgroups of elements.- Parameters:
mesh – The mesh to split.
num_parts (int) – How many parts to make. It must not exceed the number of elements.
method (str) –
"recursive_coordinate_bisection"repeatedly halves the set of element centroids along its longest axis. It is fast and deterministic and needs no connectivity, but it cuts more faces than necessary on an unstructured mesh."graph"grows each part outward from a seed element through the face connectivity, so the parts follow the mesh topology."metis"calls METIS on the dual graph of the mesh and normally gives the smallest cut; it falls back to"graph"when the extension was built without METIS.
- Returns:
element_part(the part of every element),node_owner(the part that owns every node, which is the smallest part index among those that touch it),edge_cut(the number of faces whose two elements are in different parts, the quantity a partitioner tries to minimise), andlargest_partandsmallest_part(element counts, a measure of load balance).- Return type:
- dualmesh.have_metis()[source]¶
Whether the extension was built against METIS.
METIS usually cuts fewer faces than the built-in partitioners on unstructured meshes. It is the partitioner libMesh, and therefore MOOSE, uses.
- Return type:
Adaptive refinement¶
- dualmesh.solve_with_adaptive_refinement(build_problem, mesh, variable, num_cycles=3, marker=<function mark_by_error_fraction>, max_elements=None, callback=None)[source]¶
Solve, estimate, mark and refine,
num_cyclestimes.build_problemis called with a mesh and must return a solved-readyProblemdefined on it: the whole problem is rebuilt on every mesh rather than transferred, which keeps the boundary conditions and the material state exactly as the user wrote them.markerturns the vector of indicators into a boolean array of elements to refine.max_elementsstops the loop early once the mesh has grown past that size, andcallback(cycle, problem, indicators)is called after every solve, which is where a calculation writes output or records a convergence history.Returns
(problem, mesh)for the last, finest mesh.- Parameters:
variable (str)
num_cycles (int)
marker (Callable[[numpy.ndarray], numpy.ndarray])
max_elements (int | None)
callback (Callable[[int, Problem, numpy.ndarray], None] | None)
- dualmesh.refine_marked(mesh, marked)[source]¶
Refine the marked elements, keeping the mesh conforming.
markedholds one flag per element. More elements than those marked are bisected, because the neighbours of a bisected edge have to be bisected too. Returns(refined_mesh, parents), whereparents[i]is the index inmeshof the element that elementiof the refined mesh came from; a field stored per element is carried over with it.
- dualmesh.mark_by_fraction(indicators, fraction=0.3)[source]¶
Mark the given fraction of the elements with the largest indicators.
This is the simplest marking rule: with
fraction=0.3the worst thirty per cent of the elements are refined at every cycle, so the mesh grows at a predictable rate whatever the shape of the error distribution.- Parameters:
fraction (float)
- Return type:
- dualmesh.mark_by_error_fraction(indicators, fraction=0.5)[source]¶
Mark the smallest set of elements that carries the given share of the total squared error.
This is bulk, or Dörfler, marking: with
fraction=0.5just enough elements are refined to account for half of the total error. It refines few elements when the error is concentrated in a few of them and many when the error is spread out, which is the behaviour that proofs of convergence for adaptive methods rely on.W. Dörfler, “A convergent adaptive algorithm for Poisson’s equation”, SIAM Journal on Numerical Analysis 33 (1996) 1106-1124.
- Parameters:
fraction (float)
- Return type:
Manufactured solutions¶
Verification by the method of manufactured solutions.
Background¶
A code is verified when it is shown to solve its equations correctly, which is a different question from whether the equations describe nature. The standard tool is the method of manufactured solutions (Roache, 2002): choose any smooth function as the exact solution, substitute it into the governing equations, and whatever is left over is the source that function needs. Adding that source to the problem and imposing the chosen function on the boundary gives a problem whose exact solution is known, however nonlinear or coupled the equations are. Solving it on a sequence of refined meshes then measures the order at which the error falls, and a discretisation that is correctly implemented must show its theoretical order. An error in the implementation almost always shows up as a lower order, often as no convergence at all.
The weak point of the method is keeping the two descriptions of the equations
in step: the one the code solves, and the one the source is derived from. A
hand-derived source for a nonlinear, axisymmetric, coupled problem is itself an
invitation to error. This module removes that step. Each Term knows
both which dualmesh objects it adds and, in SymPy, which flux and source those
objects contribute, so the source is derived from exactly the equations the
code assembles, by the same canonical form
with the divergence taken in the problem’s coordinate system.
Example
from dualmesh import mms
study = mms.ManufacturedSolution(
fields={"u": "sin(pi*x)*cos(pi*y)"},
terms=[mms.Diffusion("u", diffusivity="1 + x*y", polynomial=(1, 0.5)),
mms.Advection("u", velocity=(1, 0.5, 0))],
dimension=2,
)
result = study.convergence_study(
lambda n: dm.generate_rectangle_mesh(x_min=0, x_max=1, y_min=0, y_max=1,
num_x_elements=n, num_y_elements=n),
levels=[4, 8, 16, 32], method="dmcdm")
print(result.table())
result.rates("u", "l2") # tends to 2 for linear elements
SymPy is needed only for this module, which imports it lazily.
References: P. J. Roache, “Code verification by the method of manufactured solutions”, Journal of Fluids Engineering 124 (2002) 4-10; K. Salari and P. Knupp, Code Verification by the Method of Manufactured Solutions, Sandia Report SAND2000-1444, 2000.
- class dualmesh.mms.Advection(variable, velocity=(1.0, 0.0, 0.0), form='non_conservative')[source]¶
Advectionkernel with a constant velocity. The non-conservative form contributes the source \(\mathbf{v} \cdot \nabla u\), the conservative form the flux \(-\mathbf{v} u\).
- class dualmesh.mms.ConvergenceResult(method, sizes=<factory>, num_dofs=<factory>, errors=<factory>)[source]¶
Errors on a sequence of meshes, and the observed orders.
sizesholds the characteristic element size \(h\) of every mesh, taken as \((|\Omega| / N_e)^{1/d}\), which reduces to the element size for a uniform mesh and is well defined for any mesh.
- class dualmesh.mms.Diffusion(variable, diffusivity=1.0, polynomial=(1.0,))[source]¶
Diffusionkernel: \(\mathbf{F} = k(\mathbf{x}, t)\, p(u)\, \nabla u\) with \(p(u) = c_0 + c_1 u + c_2 u^2 + \dots\).A non-constant
polynomialmakes the problem nonlinear.
- class dualmesh.mms.LinearElasticity(displacements, youngs_modulus, poissons_ratio, formulation='plane_strain')[source]¶
LinearElasticStressmaterial and oneStressDivergencekernel per displacement component. The flux of component \(i\) is row \(i\) of the stress; in the axisymmetric formulation the radial equation also carries the hoop source \(\sigma_{\theta\theta} / r\).- Parameters:
- class dualmesh.mms.ManufacturedSolution(fields, terms, dimension, coordinates='cartesian')[source]¶
A problem whose exact solution is chosen, not computed.
fieldsmaps each variable name to its exact solution, as text or as a SymPy expression inx,y,zandt.termsare the pieces of the governing equations.coordinatesis the problem’s coordinate system, which decides the form of the divergence:Cartesian: \(\nabla \cdot \mathbf{F} = \sum_d \partial F_d / \partial x_d\);
axisymmetric, with \(x = r\) and \(y = z\): \(r^{-1} \partial (r F_r) / \partial r + \partial F_z / \partial z\);
spherical, with \(x = r\): \(r^{-2} \partial (r^2 F_r) / \partial r\).
- forcing()[source]¶
The source each equation needs for the chosen fields to satisfy it: \(f = -\nabla \cdot \mathbf{F}(u) + S(u)\), which a
BodyForceof intensity \(f\) (whose own source is \(-f\)) cancels.- Return type:
- build(mesh, method='dmcdm', boundary=None, start_time=0.0, **problem_options)[source]¶
A dualmesh problem for this manufactured solution on
mesh: the variables, the terms, oneBodyForceper equation carrying the manufactured source, and the exact solution imposed onboundary(every side set when omitted). The unknowns start at the exact solution atstart_time, which is the initial condition of a transient study and a good first iterate for a nonlinear one.
- errors(problem)[source]¶
{(variable, "l2"): value, (variable, "h1"): value}for a solved problem, at the problem’s current time.
- convergence_study(mesh_factory, levels, method='dmcdm', transient=None, solve_options=None, **problem_options)[source]¶
Solve on
mesh_factory(n)for everyninlevelsand record the errors.transient, when given, holds the arguments ofsolve_transient(); itsdtmay be a callable of the element size \(h\), so that the time step is refined together with the mesh, which is what a study of the combined space-time order needs.
- class dualmesh.mms.Reaction(variable, coefficient=1.0, exponent=1.0)[source]¶
Reactionkernel: the source \(c\, u^p\).
- class dualmesh.mms.Term[source]¶
One piece of the governing equations.
A term does two things, and the point of the class is that the two cannot drift apart:
add_to()adds the dualmesh objects, andcontributions()returns, in SymPy, the flux and the source those objects put into the canonical form.
Physics helpers¶
Ready-made physics: one call adds all the kernels of a model.
The models of the solid mechanics and fluids modules need one kernel per equation (and, for shear-deformable plates, a second kernel for the transverse shear terms so that they can be integrated with a reduced rule). These helpers add them consistently, in the spirit of MOOSE’s Physics syntax:
import dualmesh as dm
problem = dm.Problem(mesh)
dm.physics.add_plane_elasticity(
problem, youngs_modulus=30e6, poissons_ratio=0.25, thickness=0.036)
- dualmesh.physics.add_plane_elasticity(problem, displacements=('displacement_x', 'displacement_y'), youngs_modulus=1.0, poissons_ratio=0.0, formulation='plane_stress', thickness=1.0, body_force=None, name='elasticity', **material_parameters)[source]¶
Add the two (or three) equilibrium equations of linear elasticity.
Creates the displacement variables if they do not exist yet, a
LinearElasticStressmaterial, and oneStressDivergencekernel per component.formulationis"plane_stress","plane_strain","axisymmetric", or"three_dimensional".
- dualmesh.physics.add_incompressible_flow(problem, velocities=('velocity_x', 'velocity_y'), dynamic_viscosity=1.0, density=0.0, penalty_parameter=100000000.0, body_force=None, name='flow')[source]¶
Add the penalty momentum equations of an incompressible flow.
With
density = 0the equations are the Stokes equations; a positive density adds the convective term of the Navier-Stokes equations. APenaltyPressurematerial provides the recovered pressure as the material property"pressure".
- dualmesh.physics.add_boussinesq_buoyancy(problem, temperature, velocities, gravity, density=1.0, thermal_expansion=1.0, reference_temperature=0.0, scale_with_load=False, name='buoyancy')[source]¶
Add the Boussinesq buoyancy force to the momentum equations.
One
BoussinesqBuoyancykernel is added for every velocity component along which gravity acts, giving the body force \(\mathbf{f} = -\rho_0 \beta (T - T_0) \mathbf{g}\). Withscale_with_load=Trueload stepping ramps the buoyancy, which is how a high Rayleigh number is reached from rest. Together with aHeatConvectionkernel on the temperature this makes a natural convection problem; seeexamples/natural_convection.py.
- dualmesh.physics.add_beam(problem, model='BeamEulerBernoulliMixed', axial_displacement='axial_displacement', transverse_displacement='deflection', third_variable=None, name='beam', **parameters)[source]¶
Add a beam model (three kernels, one per variable).
modelis"BeamEulerBernoulliMixed","BeamTimoshenkoMixed", or"BeamTimoshenkoDisplacement". For the displacement Timoshenko model the shear term of the rotation equation is integrated with a reduced rule, which is what prevents shear locking.
- dualmesh.physics.add_circular_plate(problem, radial_displacement='radial_displacement', transverse_displacement='deflection', rotation='rotation', bending_moment='bending_moment', theory='first_order', name='plate', **parameters)[source]¶
Add an axisymmetric circular plate model on a radial mesh.
The problem must use
coordinates="axisymmetric", so that every integral carries the factor \(2 \pi r\).With
theory="first_order"(the default) this adds the first-order shear deformation, or Mindlin, model in terms of the radial displacement \(u\), the deflection \(w\), and the rotation \(\phi_r\). Two kernels are added per variable: one for the bending and membrane terms, and one for the transverse shear force, which is integrated at the centre of the element so that thin plates do not lock.With
theory="classical"this adds the mixed classical, or Kirchhoff, model in terms of \(u\), \(w\), and the radial bending moment \(M_{rr}\). The classical theory gives a fourth-order equation in \(w\), which the dual mesh control domain method cannot discretize, so the bending moment is carried as a third unknown and the system becomes three second-order equations. There is no shear term to under-integrate, so one kernel per variable is enough. The natural boundary quantity of the moment equation is the slope \(dw/dr\), which means a clamped edge needs no condition at all on that equation, while a simply supported edge prescribes \(M_{rr} = 0\).
- dualmesh.physics.add_plate(problem, in_plane_displacements=('displacement_x', 'displacement_y'), transverse_displacement='deflection', rotations=('rotation_x', 'rotation_y'), name='plate', **parameters)[source]¶
Add the first-order (Mindlin) rectangular plate model (five variables).
Two kernels are added per variable: one for the bending and membrane terms and one for the transverse shear forces, the latter with reduced integration, which removes shear locking in thin plates.
Objects written in Python¶
Base classes for kernels, boundary conditions, and materials written in Python.
Subclass these to add physics without touching C++. Inside the callbacks the
solution is delivered as dualmesh.ADReal numbers, so the Jacobian used
by Newton’s method remains exact:
import dualmesh as dm
class NonlinearBar(dm.PythonKernel):
"Axial bar with a = EA (1 + 1.5 u' + 0.5 u'^2)."
def setup(self, problem):
self.axial_stiffness = self.parameters["axial_stiffness"]
def has_flux(self):
return True
def compute_flux(self, ctx):
strain = ctx.gradient(self.variable)[0]
a = self.axial_stiffness * (1.0 + 1.5 * strain + 0.5 * strain * strain)
return [a * strain]
- class dualmesh.objects.PythonKernel(*args, **kwargs)[source]¶
Bases:
Kernel,_PythonObjectMixinA kernel implemented in Python (flux and/or source).
- class dualmesh.objects.PythonBoundaryCondition(*args, **kwargs)[source]¶
Bases:
IntegratedBC,_PythonObjectMixinAn integrated boundary condition implemented in Python.
compute_boundary_fluxreturns the outward normal fluxq = n . F.
- class dualmesh.objects.PythonNodalBoundaryCondition(*args, **kwargs)[source]¶
Bases:
NodalBC,_PythonObjectMixinAn essential (Dirichlet) boundary condition implemented in Python.
- class dualmesh.objects.PythonMaterial(*args, **kwargs)[source]¶
Bases:
Material,_PythonObjectMixinA material implemented in Python.
Declare properties in
declare_propertiesand fill them incompute_properties:class TemperatureDependentConductivity(dm.PythonMaterial): def declare_properties(self, registry): self.conductivity_id = registry.declare("thermal_conductivity") def setup(self, problem): self.temperature = problem.variable_index("temperature") def compute_properties(self, ctx): T = ctx.coefficient_value(self.temperature) ctx.set_property(self.conductivity_id, 20.0 + 0.2 * T)
- Parameters:
name (str | None)
Functionally graded sections¶
Stiffness coefficients of functionally graded beams and plates.
A functionally graded material (FGM) varies continuously through the thickness. Following Reddy, the modulus follows the power law
so that \(E(h/2) = E_1\) (top) and \(E(-h/2) = E_2\) (bottom); the power-law index \(n = 0\) gives a homogeneous beam with \(E = E_1\).
The stress resultants of a beam of width \(b\) use
with \(K_s = 5/6\) the shear correction factor of a rectangular section. For plates every coefficient is divided by \(1 - \nu^2\) (plane-stress reduced stiffness) and \(b = 1\) is taken per unit width.
- class dualmesh.fgm.BeamStiffness(extensional, coupling, bending, shear)[source]¶
Extensional, coupling, bending, and shear stiffnesses of a beam.
- property reduced_bending: float¶
The reduced bending stiffness \(D^{*} = D_{xx} A_{xx} - B_{xx}^2\).
- dualmesh.fgm.beam_stiffness(modulus_top, modulus_bottom, power_law_index, height, width=1.0, poisson_ratio=0.3, shear_correction_factor=0.8333333333333334, plate=False)[source]¶
Closed-form stiffnesses of a power-law functionally graded section.
modulus_topis \(E_1\) (at \(z = +h/2\)) andmodulus_bottomis \(E_2\) (at \(z = -h/2\)). Withplate=Truethe coefficients are divided by \(1 - \nu^2\), as required by the plate theories.
- dualmesh.fgm.modulus(z, modulus_top, modulus_bottom, power_law_index, height)[source]¶
The through-thickness modulus \(E(z)\) of the power-law profile.
Expressions¶
Expressions given as text, turned into functions of (x, y, z, t).
This is the counterpart of MOOSE’s ParsedFunction. A coefficient, a source or a boundary value can be written as an expression:
import dualmesh as dm
top = dm.parsed_function("500*(1 - 10*x^2)")
problem.add_boundary_condition(
"DirichletBC", variable="temperature", boundary="top", value=top)
or, more briefly, passed as the text itself, which is compiled the same way:
problem.add_kernel("BodyForce", variable="u", value="sin(pi*x)*exp(-t)")
The expression is compiled once, to a short program evaluated in C++. That matters for speed and for threading: a Python callable would be called back at every quadrature point of every element on every iteration, and because calling into Python needs the interpreter lock it would also force the assembly onto a single thread. A compiled expression does neither.
The grammar is the usual one. + - * / have their usual precedence, ^
and ** both mean exponentiation and bind tighter than a unary minus, and
comparisons < > <= >= == != give 1 or 0. The names in scope are x,
y, z, t, the constants pi and e, and the functions sin,
cos, tan, asin, acos, atan, sinh, cosh, tanh,
exp, log, log10, sqrt, abs, floor, ceil, erf,
sign, atan2, pow, hypot, min, max and
if(condition, a, b). SymPy’s printed form (E, Abs, **) is
accepted unchanged, so a manufactured source derived symbolically can be
passed straight in.
- dualmesh.expressions.parsed_function(expression)[source]¶
Compile an expression in
x,y,zandt.The result is a
dualmesh.ParsedFunction, which every parameter that takes a function accepts, and which can also be called from Python asf(x, y, z, t)to check it. A syntax error is reported with its position in the text.- Parameters:
expression (str)
- Return type:
Post-processing¶
Small helpers for extracting and comparing results.
- dualmesh.postprocess.sample_line(problem, variable, start, end, num_points=21)[source]¶
Sample a variable along a straight line; returns
(arc_length, values).
- dualmesh.postprocess.values_on_line(problem, variable, coordinates, axis=0, other=0.0)[source]¶
Sample a variable at given coordinates along one axis.
- dualmesh.postprocess.relative_error(computed, reference)[source]¶
Element-wise relative error, using the reference magnitude as the scale.
- Return type:
Automatic differentiation¶
Elementary functions that work for both floats and AD numbers.
Kernels written in Python receive dualmesh.ADReal values, whose
derivatives with respect to the local degrees of freedom are carried along so
that the Jacobian used by Newton’s method stays exact. Use these functions
instead of math inside such kernels:
import dualmesh as dm
class ArrheniusReaction(dm.PythonKernel):
def compute_source(self, ctx):
temperature = ctx.value(self.temperature_index)
return self.pre_exponential * dm.exp(-self.activation / temperature)
- dualmesh.ad.sqrt(value)¶
sqrt(x) for floats and AD numbers.
- dualmesh.ad.exp(value)¶
exp(x) for floats and AD numbers.
- dualmesh.ad.log(value)¶
log(x) for floats and AD numbers.
- dualmesh.ad.sin(value)¶
sin(x) for floats and AD numbers.
- dualmesh.ad.cos(value)¶
cos(x) for floats and AD numbers.
- dualmesh.ad.tanh(value)¶
tanh(x) for floats and AD numbers.
Command line¶
Command-line driver: run a problem described in an input file.
Input files are YAML and mirror the block structure of the Python API (and of MOOSE input files):
mesh:
type: rectangle
x_min: 0.0
x_max: 0.1
y_min: 0.0
y_max: 0.05
num_x_elements: 10
num_y_elements: 5
problem:
method: dmcdm # or fem, hfvm, zfvm
coordinates: cartesian # or axisymmetric, spherical
threads: 4 # optional; the default uses every core
variables:
temperature: {initial_condition: 0.0}
functions:
ambient: "40 + 10*x" # an expression in x, y, z, t
kernels:
conduction:
type: HeatConduction
variable: temperature
thermal_conductivity: 20.0
heating:
type: HeatSource
variable: temperature
heat_source: 1.0e6
boundary_conditions:
left: {type: DirichletBC, variable: temperature, boundary: left, value: 40}
right: {type: DirichletBC, variable: temperature, boundary: right, value: 10}
top:
type: ConvectiveHeatFluxBC
variable: temperature
boundary: top
heat_transfer_coefficient: 75.0
executioner:
type: steady # or transient
nonlinear_solver: newton
outputs:
vtu: bus_bar.vtu
csv: bus_bar.csv
reactions: [[temperature, left]]
Usage:
dualmesh run input.yaml
mpirun -n 4 dualmesh run input.yaml # distributed, when built with MPI
dualmesh list --category Kernel
dualmesh describe HeatConduction
Under mpirun with more than one process the problem is solved by
DistributedProblem, configured by an optional parallel
block (partitioner, linear_solver, preconditioner, overlap,
subdomain_solver, linear_tolerance, linear_max_iterations); the
same input file runs unchanged on one process.
- dualmesh.cli.build_problem(document)[source]¶
Build the problem described by a parsed input file.
Returns the
Problem, or, when the program runs on more than one MPI process, theDistributedProblemwhoselocalproblem carries the objects.