1.a Mesh Variables¶
Like Underworld, quagmire provides the concept of a “variable” which is associated with a mesh. These are parallel data structures on distributed meshes that support various capabilities such as interpolation, gradients, save and load, as well as supporting a number of mathematical operators through the quagmire.function
interface (examples in the next notebook).
from quagmire.tools import meshtools
from quagmire import QuagMesh
from quagmire.mesh import MeshVariable
import numpy as np
Working mesh¶
First we create a basic mesh so that we can define mesh variables and obtain gradients etc.
minX, maxX = -5.0, 5.0
minY, maxY = -5.0, 5.0,
dx, dy = 0.02, 0.02
x,y, bound = meshtools.generate_elliptical_points(minX, maxX, minY, maxY, dx, dy, 60000, 300)
DM = meshtools.create_DMPlex_from_points(x, y, bmask=bound)
mesh = QuagMesh(DM, downhill_neighbours=1)
Origin = 0.0 0.0 Radius = 5.0 Aspect = 1.0
Underlying Mesh type: TriMesh
0 - Delaunay triangulation 0.08604300000000009s
0 - Calculate node weights and area 0.008444875000000351s
0 - Find boundaries 0.006860791999999449s
0 - cKDTree 0.004818958000001317s
0 - Construct neighbour cloud arrays 0.18289795899999994s, (0.12143208400000027s + 0.06143454200000065s)
0 - Construct rbf weights 0.018642417000000577s
Basic usage¶
Mesh variables can be instantiated directly or by adding a new variable to an existing mesh.
print
will display and expanded description of the variable.
Note, for use in jupyter notebooks (etc), you can add a latex description (see below)and it
will be displayed “nicely”. The coordinates are added automatically because many things depend
on the geometrical context (spherical v. flat). Note that the use of rstrings (r"\LaTeX"
) to make sure the
names are not corrupted due to unexpected special characters.
phi = mesh.add_variable(name="PHI(X,Y)", lname=r"\phi")
psi = mesh.add_variable(name="PSI(X,Y)", lname=r"\psi")
# is equivalent to
phi1 = MeshVariable(name="PSI(X,Y)", mesh=mesh)
psi1 = MeshVariable(name="PHI(X,Y)", mesh=mesh)
print(psi)
quagmire.MeshVariable: PSI(X,Y) - RW
## latex / jupyter additions:
phi = mesh.add_variable(name="PHI(X,Y)", lname=r"\phi")
psi = mesh.add_variable(name="PSI(X,Y)", lname=r"\psi")
print("Printed version:")
print(phi)
print("\n")
print("Displayed version (latex)")
phi.display()
print("\n")
# Or just display the object using jupyter
print("Automatically displayed version (latex in notebook, printed in python)")
phi
Printed version:
quagmire.MeshVariable: PHI(X,Y) - RW
Displayed version (latex)
Automatically displayed version (latex in notebook, printed in python)
Mesh variables store their data in a PETSc distributed vector with values on the local mesh accessible through a numpy interface (via to petsc4py). For consistency with underworld
, the numpy array is accessed as the data
property on the variable as follows
phi.data = np.sin(mesh.coords[:,0])**2.0
psi.data = np.cos(mesh.coords[:,0])**2.0 * np.sin(mesh.coords[:,1])**2.0
Note that the following is not allowed
phi.data[0] = 1.0
and nor is any other change to a single value in the array. This is done so that we can be sure that the values in the array are always synchronised across processors at the end of an assignment. It is also done to control cases where there are dependencies on the variable that go beyond synchronisation (for example, changing the topography variable rebuilds the flow pathways in a surface process context).
You can work with a local copy of the vector and update all at once if you need to build incremental changes to values, work without synchronisation across processors or avoid rebuilding of dependent quantities.
A MeshVariable object responds to a print
statement by stating what it is and its name. To print the contents of the variable (locally), access the values through the data property:
print(phi, "|", psi)
print(phi.data)
quagmire.MeshVariable: PHI(X,Y) - RW | quagmire.MeshVariable: PSI(X,Y) - RW
[0.07053991 0.04597266 0.033714 ... 0.01094639 0.00487537 0.00122039]
Mesh variables can be read only (locked). The RO (read only) and RW (read / write) markers are shown when the variable is printed.
phi.lock()
print(phi)
phi.unlock()
print(phi)
Generally locking is done to prevent changes to a variable’s data because additional updates depend on controlling when changes are made. Access to lock
and unlock
is
phi.lock()
print(phi)
phi.unlock()
print(phi)
quagmire.MeshVariable: PHI(X,Y) - RO
quagmire.MeshVariable: PHI(X,Y) - RW
Parallel support¶
The MeshVariable
class has a sync
method that, when called, will replace shadow information with values from adjacent sections of the decomposition (or optionally, merge values in the shadow zone - an operation that should be used with caution for global reduction type operations).
If you alter data in the shadow zone in a way that cannot be guaranteed to be the same on another processor, then some form of synchronisation is required when you are done. This is not fully automated as there may be several stages to your updates that you only want to synchronise at the end. But, still, be careful !
phi.sync()
phi.sync(mergeShadow=True) # this will add the values from each processor in parallel
These kinds of parallel operations must be called on every processor or they will block while waiting for everyone to finish. Be careful not to call sync inside a conditional which may be executed differently
import quagmire
# Don't do this (obviously)
if quagmire.rank == 0:
phi.sync()
# or something a little bit less obvious
if delta_phi > 0.0:
phi.sync()
# This might be OK but it is not required
if quagmire.size > 1:
phi.sync()
Evaluate method and fn_gradient¶
MeshVariables support the evaluate
method (because they are quagmire.functions
) which is useful as it generalises various interfaces that are available to access the data. If a mesh is supplied, then evaluate checks to see if this corresponds to the mesh associated with the mesh variable and returns the raw data if it does. Otherwise the mesh coordinates are used for interpolation. If two coordinate arrays are supplied then these are passed to the interpolator.
NOTE: the interpolator will also extrapolate and may (is likely to) produce poor results for off-processor coordinates. If this is a problem, the MeshVariable.interpolate
method can be accessed directly with the err
optional argument.
## Raw nodal point data for the local domain
print(phi.data)
print(phi.evaluate(mesh))
print(phi.evaluate(phi._mesh))
print(phi.evaluate())
## interpolation at a point
print(phi.interpolate(0.01,1.0))
print(phi.evaluate((0.01, 1.0)))
[0.14107982 0.09194532 0.067428 ... 0.02189278 0.00975074 0.00244078]
[0.14107982 0.09194532 0.067428 ... 0.02189278 0.00975074 0.00244078]
[0.14107982 0.09194532 0.067428 ... 0.02189278 0.00975074 0.00244078]
[0.14107982 0.09194532 0.067428 ... 0.02189278 0.00975074 0.00244078]
[[0.00143384]]
[0.00143384]
Derivatives / gradients¶
Mesh based variables can be differentiated in (X,Y). There is a _gradient
method inherited from the stripy
package that supplies the coefficients of the derivative surface at the nodal points (these may then need to be interpolated). A more general interface is also provided in the form of functions which not only compute the gradient but also handle interpolation between meshes and are also evaluated on demand (i.e. can be composed into functions).
dpsi = psi._gradient()
dpsidx_nodes = dpsi[:,0]
dpsidy_nodes = dpsi[:,1]
print("dPSIdx - N: ", dpsidx_nodes)
print("dPSIdy - N: ", dpsidy_nodes)
dpsidx_fn = psi.fn_gradient(0) # (0) for X derivative, (1) for Y
print("dPSIdx - F: ", dpsidx_fn.evaluate(mesh))
print("dPSIdx - point evaluation ", dpsidx_fn.evaluate((0.01, 1.0)))
dPSIdx - N: [0.47060129 0.38795006 0.33238187 ... 0.18969753 0.13065599 0.06281495]
dPSIdy - N: [ 0.48726764 0.50251126 0.49707913 ... -0.53015333 -0.52665526
-0.52292377]
dPSIdx - F: [0.47060129 0.38795006 0.33238187 ... 0.18969753 0.13065599 0.06281495]
dPSIdx - point evaluation [-0.01712131]
Higher derivatives¶
The easiest way to form higher derivatives for a mesh variable is to use the nesting properties of the gradient functions. These form gradients and use the same mesh to find their gradients. However, the do so in a way that does not require defining and handling intermediary mesh variables.
Let’s take a look to see how these methods work:
## This way uses the underlying mesh structure to extract gradients and store the result
dpsidx_var = mesh.add_variable("dpsidx")
dpsidx_var.data = dpsidx_nodes
dpsidy_var = mesh.add_variable("dpsidy")
dpsidy_var.data = dpsidy_nodes
print( dpsidx_var._gradient()[:,0] )
## And this way is function based (two equivalent interfaces)
d2psidx2_fn = dpsidx_fn.fn_gradient(0)
d2psidx2_fn2 = dpsidx_fn.derivative(0)
print( d2psidx2_fn.evaluate(mesh))
print( d2psidx2_fn2.evaluate(mesh))
[-1.63959694 -1.74894834 -1.692487 ... -1.72293305 -1.80926263
-1.916224 ]
[-1.63959694 -1.74894834 -1.692487 ... -1.72293305 -1.80926263
-1.916224 ]
[-1.63959694 -1.74894834 -1.692487 ... -1.72293305 -1.80926263
-1.916224 ]
The function based method is more simple and has some advantages if the data in the original variable change - the gradient function handles those updates automatically. Note how the mesh variable version does not update whereas the function based version does. We will see in the next example notebook how the function system works in detail. For now, though, simply note that the function is not evaluated until it is needed and so can be defined in an abstract manner independent of the data in the variable.
## change the data in the PSI variable:
psi.data = 2.0 * np.cos(mesh.coords[:,0])**2.0 * np.sin(mesh.coords[:,1])**2.0
print( dpsidx_var._gradient()[:,0] )
print( 0.5 * d2psidx2_fn.evaluate(mesh) )
print( (0.5 * d2psidx2_fn).evaluate(mesh))
[-1.63959694 -1.74894834 -1.692487 ... -1.72293305 -1.80926263
-1.916224 ]
[-1.63959694 -1.74894834 -1.692487 ... -1.72293305 -1.80926263
-1.916224 ]
[-1.63959694 -1.74894834 -1.692487 ... -1.72293305 -1.80926263
-1.916224 ]
This is how we can define a Laplacian of the variable (in Cartesian geometry) using the function interface. This approach allows the operator to be a self-updating variable that can be passed around as though it was a simple mesh variable. Note that the functions have some helpful associated descriptive text that explains what they are.
Also note, this is not a generic operator as it is specific to this variable but more general operators can be constructed with very little overhead because the interface is very lightweight.
laplace_phi_xy = phi.derivative(0).derivative(0) + phi.derivative(1).derivative(1)
print(laplace_phi_xy)
print(laplace_phi_xy.evaluate(mesh))
quagmire.fn: d(grad(PHI(X,Y))|x0)/d0 + d(grad(PHI(X,Y))|x1)/dY
[2.16267824 2.82868171 3.58311781 ... 3.21086287 2.8393122 3.5194788 ]
Visualisation¶
The following should all evaluate to zero everywhere and so act as a test on the accuracy of the gradient operator
import k3d
plot = k3d.plot(camera_auto_fit=True, grid_visible=False)
mesh0 = mesh
indices = mesh0.tri.simplices.astype(np.uint32)
points = np.column_stack((mesh0.coords, mesh0.coords[:,0]*0.0)).astype(np.float32)
plt_phi = k3d.mesh(points, indices, wireframe=False, attribute=phi.evaluate(mesh0), color_map=k3d.basic_color_maps.Greens)
plt_psi = k3d.mesh(points, indices, wireframe=False, attribute=psi.evaluate(mesh0), color_map=k3d.basic_color_maps.Blues)
plot += plt_phi
plot += plt_psi
plot.display()
Saving and loading mesh variables¶
There are 2 equivalent ways to save a mesh (PETSc DM) to a file. The first is to call meshtools.save_DM_to_hdf5
and the second is to call the mesh object’s own method mesh.save_mesh_to_hdf5(filename)
. Mesh variables have a similar save method
mesh.save_mesh_to_hdf5("Ex1a-circular_mesh.h5")
psi.save("Ex1a-circular_mesh_psi.h5")
phi.save("Ex1a-circular_mesh_phi.h5")
We can then use these files to:
Build a new copy of the mesh
Add new mesh variables to that mesh
read the values back in
DM2 = meshtools.create_DMPlex_from_hdf5("Ex1a-circular_mesh.h5")
mesh2 = QuagMesh(DM2)
print(mesh.npoints, mesh2.npoints)
phi2 = mesh2.add_variable(name="PHI(X,Y)")
psi2 = mesh2.add_variable(name="PSI(X,Y)")
psi2.load("Ex1a-circular_mesh_psi.h5")
phi2.load("Ex1a-circular_mesh_phi.h5")
Underlying Mesh type: TriMesh
0 - Delaunay triangulation 0.19128104199990048s
0 - Calculate node weights and area 0.0076949170006628265s
0 - Find boundaries 0.005896167000173591s
0 - cKDTree 0.005034333999901719s
0 - Construct neighbour cloud arrays 0.19924345800063747s, (0.13756191700031195s + 0.06164820799949666s)
0 - Construct rbf weights 0.016812374999972235s
47362 47362
Mesh variable save / load and names¶
The names that are stored in the mesh variable hdf5 file are needed to retrieve the information again. That means the mesh variable that is loaded needs to match the one that was saved Exactly. This will not work:
psi3 = mesh2.add_variable(name="PSI")
psi3.load("Ex1a-circular_mesh_psi.h5")
but, as long as you know the name of the original MeshVariable, you can do this:
psi3.load("Ex1a-circular_mesh_psi.h5", name="PSI(X,Y)")
psi3 = mesh2.add_variable(name="PSI")
psi3.load("Ex1a-circular_mesh_psi.h5", name="PSI(X,Y)")
psi3.data
array([1.72745546, 1.77106786, 1.79824692, ..., 1.81471569, 1.82518599,
1.83148632])
print(phi.data[0], psi.data[0])
0.14107981550434526 1.7274554571245162