Understand a dataset¶
A scientific visualization pipeline starts with a mesh and values that mean
something on that mesh: temperature at points, material per cell, flux through
faces. tack.dataset keeps those relationships explicit so the same field can be
interpolated, filtered and sampled without each stage inventing its
own connectivity and indexing rules.
Start with the visualization quick start if you want to run a filter first. This chapter explains the objects it uses. The function-space fundamentals explain H1, L2, polynomial order and degrees of freedom before the code examples below.
Four parts of one dataset¶
flowchart LR
D[DataSet] --> T[Topology: cell shapes and corner incidence]
D --> G[Geometry field]
D --> U[Temperature field]
D --> P[Pressure field]
D --> S[Named sets]
G --> GS[H1 order 1 plus coordinate values]
U --> US[H1 order 2 plus temperature coefficients]
P --> PS[L2 order 1 plus pressure coefficients]
GS -. defined on .-> T
US -. defined on .-> T
PS -. defined on .-> T
Topology describes cell shapes, their corner vertices and how cells meet. It does not prescribe one coefficient array shared by every field. Faces, edges and incidence tables are derived when needed and retained for later use; spaces can use these entities to construct their own DOF layouts.
Geometry maps reference-cell coordinates to physical positions through its own space and coordinate values. A structured topology alone does not imply equally spaced or axis-aligned coordinates. A uniform grid can compute its nodal positions from dimensions, origin and spacing; a curved mesh can store coordinate coefficients at additional geometry nodes. Even a planar mesh uses 3-vectors for positions.
Fields combine a space with values. The space determines which entities own the values and how to evaluate them between sample locations. Sets name selections such as a boundary or a material region, without creating another mesh. A cell or face set can also be a kernel's iteration domain.
Array storage and field meaning¶
| Object | Responsibility | Example |
|---|---|---|
tack.Array |
Storage on a backend | A buffer of temperatures or velocities |
td.Field |
Meaning of values on a topology | Temperature in td.H1(data) |
Here td is the conventional alias for tack.dataset. A raw buffer knows its
element dtype and shape; it does not know whether its entries are point
temperatures or cell averages. For example, tack.empty((space.size,),
tack.vec3()) allocates one vector per DOF. td.Field(td.H1(data), values) supplies that information.
A NumPy array is useful input, but it is not itself a valid td.Field value
array: use tack.array(host, dtype=...) to copy it into backend storage.
The DataSet constructor also accepts a host array of positions and creates
H1 geometry for you. Build Meshes first constructs topology
and geometry explicitly, then explains this shorter form.
The MFEM model: mesh, space, grid function¶
In MFEM, a Mesh supplies elements and their incidence. A finite-element
collection specifies a family, order and basis; a FiniteElementSpace on the
mesh determines the local-to-global DOF maps and component layout. A
GridFunction couples that space with coefficients. This is why an MFEM field
is more than an array of values attached to the mesh's vertices. See
MFEM's grid-function explanation.
Tack follows that separation for the supported parts of its data model:
| MFEM concept | Tack counterpart | Responsibility |
|---|---|---|
| Mesh element types and vertex connectivity | data.topology |
Corner incidence and derived entities |
| Finite-element family, order and layout on the mesh | Each field's space |
Basis, DOF ownership and local-to-global addressing |
| Grid function | td.Field(space, values) |
Coefficients interpreted through that space |
| Mesh nodes describing geometry | data.geometry, itself a td.Field |
Reference-to-physical coordinate mapping |
This is a conceptual correspondence, not a claim that Tack implements all MFEM finite-element families or its parallel true-DOF and constraint machinery. The present importer supports the H1/L2 families and orders described below.
Corners are not all of a field's nodes¶
A linear hexahedron's topology names eight corners. A quadratic H1 field on that same cell needs 27 nodal coefficients: corners, edge midpoints, face centers and a cell-interior node. Its space obtains shared edge and face incidence from the topology and builds the field's DOF map. The topology still has the original eight corners for that cell; the field has not refined it.
Consequently, data.num_points counts topological vertices. It need not equal
field.space.size, and it need not be the number of geometry coefficients
on a curved or discontinuous mesh. Use the field's space when allocating its
values or interpreting their layout.
Each field has its own space¶
One mesh can carry all of the following at once:
| Field on two adjacent hexahedra | Space | Logical values |
|---|---|---|
| Geometry | H1 order 1, 3-vector positions | 12 vectors |
| Temperature | H1 order 2 | 45 scalars |
| Pressure | L2 order 1 | 16 scalars |
| Material parameter | Constant | 2 scalars |
The fields share topology, not necessarily a space, order, coefficient count or storage representation. Compatible fields may deliberately share one interned space object; “each field has its own space” means that the choice belongs to the field, rather than being fixed once for the dataset.
For step-by-step recipes and node diagrams, see
Construct an L2 field and
Construct a quadratic H1 field.
The program below combines them on one dataset, using uniform_grid as a
shortcut for the mesh setup so we can focus on independent field spaces.
Temperature represents
x² + 2y + 3z; pressure is x in cell 0 and x + 2 in cell 1, with a jump
at their shared face. Geometry remains an implicit uniform coordinate array
in first-order H1:
@tack.func
def quadratic_temperature(x):
return x.x * x.x + 2.0 * x.y + 3.0 * x.z
@tack.kernel
def initialize_pressure(cells, offsets, values):
for c in cells:
cell_id = cells.entity_id(c)
first = offsets[cell_id]
for j in range(cells.NUM_POINTS):
x = cells.corner_position(c, j)
values[first + j] = x.x + 2.0 * cell_id
# Shortcut mesh setup: two adjacent hexes in the unit cube.
data = td.uniform_grid((3, 2, 2), spacing=(0.5, 1.0, 1.0))
temperature_space = td.H1(data, order=2)
# The function at all 45 temperature nodes, not just the 12 corners.
data.fields["temperature"] = td.interpolate(data, quadratic_temperature,
temperature_space)
# Where those nodes are, for the checks and the drawing below.
temperature_nodes = td.project(data, data.geometry, temperature_space)
pressure_space = td.L2(data, order=1)
pressure = tack.empty((pressure_space.size,))
td.for_each(initialize_pressure, data, "cells", pressure_space.offsets, pressure)
data.fields["pressure"] = td.Field(pressure_space, pressure)
material_space = td.Constant(data)
data.fields["material"] = td.Field(
material_space, tack.arrays.ConstantArray(2.0, material_space.size))
# Geometry keeps its own order-1 H1 space and UniformCoordinates values.
interpolate evaluates the temperature function at the physical positions
of that space's interpolation nodes, through the geometry; it does not
replace data.geometry. The original geometry, temperature and pressure
continue to use their separate spaces.
An evaluation combines two maps. The geometry gives physical position from
reference coordinates, while the solution field uses its own basis and DOFs
at those reference coordinates. A field view supplied to for_each preserves
that distinction even when geometry and solution orders differ.
at_centers = td.values_at_centers(data, "temperature")
gradient = td.gradients(data, "temperature")
linearized = td.project(data, "temperature", td.H1(data, order=1))
linear_centers = td.values_at_centers(data, linearized)
The quadratic field's center values are [2.5625, 3.0625]. Replacing it by an
order-1 projection gives [2.625, 3.125]: the topology and geometry are unchanged,
but the field approximation changed. The complete example also checks the
physical gradients against [0.5, 2, 3] and [1.5, 2, 3].
uv run python docs/examples/data_spaces.py --arch cpu --check
Download the independent-spaces program.
Geometry is a field, too¶
The coordinate mapping has a family, order and values just as a solution does. Straight cells can use order-1 H1 geometry with explicit corner positions or implicit coordinates. A supported curved mesh uses H1 order-2 coordinate coefficients; a solution on it may still use a different order.
With L2 geometry, neighbouring cells can assign separate positions to their local corners while sharing corner ids in the topology. This can represent gaps or detached cells without changing the incidence. Contour and slice respect each cell's geometry; not every algorithm supports that representation.
The geometry's values can be a UniformCoordinates, a CartesianProduct,
or explicit position storage. Those are value-array representations, not
different finite-element spaces. Image and rectilinear geometry are explained
in Build Meshes.
The geometry's values are relative to the dataset's origin, three doubles
kept on the host. Survey data far from zero keeps its precision in single
precision that way: a point cloud at UTM coordinates stores millimetres as
offsets from a nearby origin, which single precision resolves, where its raw
eastings would be rounded to a quarter metre.
cloud = td.point_cloud(lidar_xyz, fields={"classification": classes}) # float64 xyz
cloud.origin # the lower corner of the box, rounded down
cloud.positions() # float32 offsets from it, what kernels see
cloud.world_positions() # float64 again
ground = td.threshold_points(cloud, "classification", 2, 2) # keeps the origin
Filters keep the origin, and arguments that are positions -- implicit functions, probe points, seeds -- are given in world coordinates and moved by the origin in double precision. The dataset API reference lists what converts.
Importing MFEM preserves the separation¶
mfem_to_dataset(mesh, fields={...}) constructs topology from the elements'
corner lists, then converts geometry and each supplied grid function through
their own spaces. It does not flatten every solution onto the mesh vertices.
| MFEM input | Current Tack mapping |
|---|---|
| Straight geometry | H1 order-1 coordinate field |
| Supported order-2 curved mesh nodes | H1 order-2 coordinate field |
| H1 order-1 grid function | H1 order 1 |
| H1 order-2 grid function | H1 order 2, evaluated at supported quadratic nodes |
| L2 order-0 grid function | Constant |
| L2 order-1 grid function | L2 order 1, evaluated at each cell's corners |
| Element attributes | An integer Constant field named attribute |
Basis conversion matters: MFEM's L2 interpolation nodes need not be its cell corners. The importer evaluates the MFEM field to obtain Tack's corner-based coefficients rather than treating the source coefficient vector as corner data. The supported order-1 spaces agree on tetrahedra, hexahedra and wedges; on pyramids the corner representation interpolates MFEM's different basis. Unsupported collections or orders are refused. Current order-2 pyramid bases and geometry above order 2 are not imported.
The pipeline chapter and reference describe the entry points.
A simpler first-order field¶
The smaller data_fields.py example uses the same two adjacent half-width
cubes, but temperature is now the affine function x + 2y + 3z in order-1
H1. The two cells carry material values 1 and 3. This is the example used
for the projection and discontinuity figures later in the guide.
The following kernel evaluates temperature directly from the grid's implicit coordinates. No host coordinate array is needed for this step:
@tack.kernel
def temperature_at_points(xyz, out):
for p in range(out.shape[0]):
x = xyz.get(p)
out[p] = x.x + 2.0 * x.y + 3.0 * x.z
Allocate the values and attach them to their spaces:
data = td.uniform_grid((3, 2, 2), spacing=(0.5, 1.0, 1.0))
temperature = tack.empty((data.num_points,))
temperature_at_points(data.geometry.values, temperature)
data.fields["temperature"] = td.Field(td.H1(data), temperature)
material = tack.array([1.0, 3.0], dtype=tack.f32)
data.fields["material"] = td.Field(td.Constant(data), material)
H1 shares one value at a point among its incident cells. Constant gives
each cell its own value, constant throughout that cell. They refer to the
topology of data, so a field from another dataset's topology is rejected even
when it has the same number of values. The field length must also match the
space's size; matching a shape alone does not establish the association.
The example assumes the imports numpy as np, tack, and tack.dataset as td,
with tack.set_default_device("cpu") before construction. The complete source below
includes them and runs independently.
Inspect without losing track of transfers¶
| Expression | What you get |
|---|---|
data.num_points, data.num_cells |
Counts for the mesh |
data.fields["temperature"] |
The associated td.Field |
data.fields["temperature"].space |
Its space, including the topology |
data.fields["temperature"].values |
The stored or implicit value array |
data.positions() |
A host array of corner positions for H1 geometry |
tack.arrays.to_host(field.values) |
A host snapshot; vectors have one row per value |
tack.arrays.materialize(field.values) |
Device storage; computes implicit values if necessary |
positions() returns the topological corners, including when H1 geometry has
higher-order nodes; it refuses L2 geometry. Use
tack.arrays.to_host(data.geometry.values) to inspect all of geometry's
coefficients in their space's ordering.
Use host snapshots for diagnostics, independent checks and file output. Keep values in fields between filters and kernels. On GPU backends a host read waits for the queued work it needs, so repeated inspection in a timestep loop can dominate its cost.
What a pipeline changes¶
Algorithms return new datasets, fields or summaries; they do not edit the input dataset in place. A returned dataset may share topology or value storage with its input when no copy is needed. Treat it as a new pipeline result, not as a deep copy of every buffer. Editing an existing values buffer can therefore be visible through more than one dataset that refers to it.
For a time-dependent simulation, retain the mesh and update its working fields. Run the analysis stages again for the new values; an old contour is a previous result, not a live view that recomputes itself. Rebuild spatial locators after moving geometry. The pipeline chapter makes these boundaries explicit.
Run and check¶
uv run python docs/examples/data_fields.py --arch cpu --check
The check compares point values with the analytic formula, cell temperatures
with [2.75, 3.25], and gradients with [1, 2, 3]. It also checks projection
and face jumps, developed in Fields and Spaces.
Download the complete example. Continue with Building Meshes, or move directly to Fields and Spaces if your mesh already comes from a reader.