Skip to content

Filter and analyze datasets

Choose a filter by what should happen to the cells. Keeping a whole cell, cutting a cell, and extracting its intersection with a surface answer different questions, even when all three use the same scalar threshold.

The running example samples x and the velocity [-y, x, 0] on a grid covering [-1, 1]³. The simple analytic fields make the results independently checkable.

Prepare named inputs

@tack.kernel
def sample(xyz, scalar, velocity):
    for p in range(scalar.shape[0]):
        x = xyz.get(p)
        scalar[p] = x.x
        velocity[p] = [-x.y, x.x, 0.0]
    data = td.uniform_grid((9, 5, 5), origin=(-1, -1, -1), spacing=(0.25, 0.5, 0.5))
    x = tack.empty((data.num_points,))
    velocity = tack.empty((data.num_points,), tack.vec3(tack.f32))
    sample(data.geometry.values, x, velocity)
    data.fields["x"] = td.Field(td.H1(data), x)
    data.fields["velocity"] = td.Field(td.H1(data), velocity)

The complete script supplies the imports and initializes the backend. All following fragments use this dataset. A field argument can be its name or the td.Field itself; a raw value buffer needs a space wrapper first.

Select cells, cut cells, or extract a surface

Goal Operation Output
Keep cells whose scalar lies in an interval threshold Whole cells, preserving their shapes
Keep cells inside a region described by a function extract_geometry Whole cells
Keep a named or explicit cell selection extract_cells Whole cells in input order
Keep a box of a structured grid, every n-th point extract_extent A structured grid of the same kind (VTK's vtkExtractGrid/vtkExtractVOI); a one-point-thick box is a grid of one dimension less
Normals of a surface, its polygons wound alike surface_normals The surface with normals on points and cell_normals on cells (VTK's vtkPolyDataNormals without splitting)
Keep the part on one side of a level clip Whole cells and pieces of cut cells
Extract a scalar level set, or several contour Triangles in 3D; line cells in 2D; vertices on lines; dimension= picks which cells of a mixed mesh (the highest by default, never mixed); with several values, all of them, each cell's in contour_value
Intersect with a plane or another implicit function, or several parallel ones slice_plane, slice Intersection geometry
Keep original points by scalar or region threshold_points, extract_points Vertex cells, without cell connectivity

For point data, threshold checks every corner by default. With all_points=False, one in-range corner suffices. Neither option interpolates a cut boundary. For cell data, the cell's one value makes the selection.

    whole = td.threshold(data, "x", 0.125, 1.0)                  # all corners in range
    touching = td.threshold(data, "x", 0.125, 1.0, all_points=False)
    plane = td.Plane(origin=(0.125, 0, 0), normal=(1, 0, 0))
    clipped = td.clip(data, plane, fields=["x"])                # keep x >= 0.125
    surface = td.contour(data, "x", 0.125, fields=["velocity"])
    section = td.slice_plane(data, origin=(0.125, 0, 0), normal=(1, 0, 0))

Whole-cell selection, clipped cells, and an extracted level set

At x = 0.125, no grid plane coincides with the requested boundary. The threshold keeps 48 cells starting at x = 0.25, with total volume 3. The clipped region starts at x = 0.125 and has volume 3.5. The contour is a plane of area 4. all_points=False keeps 64 whole cells, including a layer that crosses the threshold. These different measures are intentional.

Understand the sign of an implicit function

Plane, Sphere, Cylinder, Box and Planes expose a value(position) that is negative inside, zero on the boundary and positive outside. For the plane in the example it is x - 0.125. Union, Intersection and Difference combine them (Difference(box, sphere) is the box with a ball taken out), as VTK's vtkImplicitBoolean does, on the device.

clip(data, plane) keeps values at or above zero, here the positive-x side. Pass inside=True to keep the other side, inside the function's region. extract_geometry instead defaults to inside=True, the negative side, and keeps whole cells. State which region you mean before choosing flags; the words “inside” and “keep” do not by themselves specify the same default for both functions.

Decide which fields follow the geometry

Filters creating datasets accept fields=: all named fields by default, a name or list to select them, and [] for geometry alone. Geometry is always handled separately. An unknown requested name raises KeyError.

Input association Typical propagation
Floating point data Gathered for kept points; interpolated onto edge crossings
Cell data Copied to each retained cell or each piece of an input cell
L2 data Retained cell by cell when whole cells survive on a shape-based output
Face values Retained only when the filter has a face map; on an extracted surface, mapped to output cells where supported
Side traces or entities with no propagation map Left behind

For example, the contour above carries only velocity, interpolated at its new points; the clip carries only x. Computing a derived field does not automatically name it: put it in data.fields before extraction if it should travel with the result.

Integer point labels are not interpolated as continuous data. Use cell labels when that is the intended association. To preserve provenance of cell pieces, attach td.cell_ids(data) as a named cell field before filtering; its ids then refer to the source mesh, while output cell ids refer to the new mesh.

Sets follow provenance, not geometric guesses

Cell sets survive selections and follow pieces of selected cells. A face set can become a cell set when its faces become surface cells. Newly generated points do not automatically inherit a point set merely because they lie near an old selected point. The result follows the filter's entity maps.

The mixed-mesh example names a roof cell and a boundary face set, then filters:

    mesh.sets["boundary"] = td.boundary_faces(mesh)  # a face Set
    mesh.sets["roof"] = td.Set(mesh, "cells", [1])   # a cell Set
    kept = td.threshold(mesh, "material", 1.5, 3.0)  # sets come along
    print(f"boundary: {len(mesh.sets['boundary'])} faces; the roof is cell "
          f"{kept.sets['roof'].numpy()} of the threshold")

Its returned roof id is zero after thresholding because the output cells have been renumbered. Use the returned set with the returned dataset.

Derived fields and summaries

    speed = td.magnitude(data, "velocity")
    data.fields["speed"] = speed                               # keep the returned field
    velocity_gradient = td.gradients(data, "velocity")
    flow = td.flow_quantities(data, velocity_gradient)
    summary = td.statistics(data, "x")

magnitude returns a field in the velocity's space. gradients returns cell gradients by default; at="points" evaluates and averages onto points. For a 3-vector field its gradient has nine components, in row order d(u_i)/d(x_j). flow_quantities derives divergence, vorticity and Q from it. The solid rotation in this example has divergence 0, vorticity [0, 0, 2] and Q = 1 in every cell.

statistics summarizes stored field samples, not a volume integral. An arithmetic mean of point temperatures is not a volume-weighted mean on a stretched mesh. Choose cell measures and a suitable quadrature when the quantity you need is a physical integral. The function returns Python values and therefore waits for the device work it reads.

Cell quality

cell_quality(data, measures) measures how well shaped each cell is, a cell field per measure, each 1 on the ideal cell of its shape (equilateral; a cube, a regular tetrahedron):

Measure What it says
scaled_jacobian the worst corner's edges' determinant, edges scaled to unit length, relative to the ideal cell's: 1 ideal, 0 flat, negative inverted
jacobian_ratio the least corner determinant over the greatest: 1 for every simplex, below 1 as a cell tapers
condition the worst corner's condition number against the ideal cell's: 1 ideal, growing as it distorts, the largest float when inverted
edge_ratio, min_edge, max_edge the longest edge over the shortest; the shortest and longest, in the spatial unit

They are Verdict's definitions (VTK's vtkMeshQuality), matched to VTK where it defines them, and extended to every shape Tack has: the corners' edges are the geometry's Jacobian applied to the reference cell's edge directions, so order-2 cells are measured along their curved edges, and wedges and pyramids get every measure. Polyhedra and polygons, which have no reference cell, are measured by the fan tetrahedra and triangles Tack evaluates them in.

import numpy as np

import tack
import tack.dataset as td

tack.set_default_device("cpu")

mesh = td.tetrahedralize(td.uniform_grid((4, 4, 4)))
quality = td.cell_quality(mesh, ["scaled_jacobian", "condition", "edge_ratio"])
sj = quality["scaled_jacobian"].values.numpy()
print(f"scaled Jacobian {sj.min():.3f} to {sj.max():.3f}")
mesh.fields["sj"] = quality["scaled_jacobian"]
worst = td.threshold(mesh, "sj", -1.0, 0.75)          # the right-corner tetrahedra
assert np.isclose(sj.max(), 1.0) and np.isclose(sj.min(), 1 / np.sqrt(2))
assert worst.num_cells == np.count_nonzero(sj < 0.75)

Feature edges

feature_edges(data, angle=30.0) finds a surface's edges by how many cells meet there and at what angle, as VTK's vtkFeatureEdges: boundary edges (one cell), feature edges (two cells whose normals are at least angle degrees apart), non-manifold edges (three or more cells) and, if asked, manifold edges (the rest of the two-cell edges). The result is line cells on the same points with a kind cell field (EdgeKind), point fields carried. A volume mesh's surface comes from extract_surface first.

import numpy as np

import tack
import tack.dataset as td

tack.set_default_device("cpu")

box = td.uniform_grid((4, 3, 3))
surface = td.extract_surface(box, td.boundary_faces(box))
edges = td.feature_edges(surface)                 # the box's 12 edges, in segments
kinds = edges.fields["kind"].values.numpy()
print(edges.num_cells, "feature edges")
assert (kinds == td.EdgeKind.FEATURE).all() and edges.num_cells == 4 * 3 + 4 * 2 + 4 * 2
open_top = td.uniform_grid((4, 4))
assert td.feature_edges(open_top).num_cells == 12           # its boundary

Check algorithm support before building a pipeline

Task Shape-based Polyhedral / polygonal Polyline
Select whole cells; measure cells Yes Yes Yes
Extract boundaries Yes, subject to line-cell limitations Yes Chain ends
Contour or slice Yes, of order-1 and L2 fields (an order-2 H1 field is refused) Yes Contour: vertices, of point data
Clip cells H1 geometry required Polyhedra: yes, into polyhedra (faces clipped, iso-polygons as caps); polygons not yet Currently unsupported
Project between point and cell data Yes Yes Yes
Gradients and flow quantities Yes (2D cells in their plane) Yes: Green–Gauss over each polyhedron's faces, and over each polygon's edges in its plane (exact for linear fields) Yes (zero)
Locate cells and probe Yes Yes: in sub-tetrahedra or fanned triangles; cells must be star-shaped from their points' mean Yes, on segments
Advection and streamlines Yes, 2D and 3D cells Yes, as probing locates Currently unsupported
Carry ghost marks over pieces Yes Yes Yes

An empty supported filter result is a valid dataset. Check num_cells before assuming an extraction contains visible geometry. Algorithms requiring a reference element reject unsupported topologies; storing H1 values alone does not change that support. The reference matrix has the details.

Run and check

uv run python docs/examples/data_analysis.py --arch cpu --check
uv run --with matplotlib python docs/examples/data_analysis.py --check --output filters.png

The checks include counts, independently calculated volumes and areas, interpolated velocity, flow derivatives, probe validity and analytic rotation. Download the complete program.

Continue with Mesh Kernels to implement an operation, or Sample and Connect Simulations to connect stages.