Fields, Ranks, and Schemas#

A field is a named tensor of values attached to a mesh. Examples are the pressure at each vertex, the velocity at each cell, and a Reynolds number for the whole mesh. This chapter explains where fields live and why the tensor rank of each field matters. It also shows how a model declares the fields it needs with RankSpec and FieldSchema. The fields API reference lists every method.

Two rows of three panels. Top: a square triangle mesh colored by pressure, one with arrows pointing mostly right, one with flat ellipses. Bottom: the same mesh rotated 30 degrees inside a dashed outline of the original square. Colors stay on their vertices, and the arrows and ellipses tilt by 30 degrees.

Fig. 21 Three fields declared with FieldSchema on a planar mesh, before and after mesh.rotate(math.radians(30), transform_point_data=True). The pressure keeps its values, the velocity turns as \(v' = R v\) with the rotation matrix \(R\), and the symmetric stress turns as \(T' = R T R^\top\).#

Point, Cell, and Global Data#

A Mesh stores its fields in three TensorDicts. Each one has a different batch size, the shape of its leading dimensions:

Table 10 Field Containers#

Container

Batch Size

Holds

point_data

(n_points,)

One value for each vertex, for example a nodal solution

cell_data

(n_cells,)

One value for each cell, for example a finite-volume solution

global_data

()

One value for the whole mesh, for example a Reynolds number

The type physicsnemo.mesh.MeshFieldAssociation names these three containers as a literal type, and MESH_FIELD_ASSOCIATIONS is the tuple ("point_data", "cell_data", "global_data"). Code that loops over the containers can use the tuple with getattr(mesh, name).

import torch
from physicsnemo.mesh import MESH_FIELD_ASSOCIATIONS
from physicsnemo.mesh.primitives.planar import unit_square

mesh = unit_square.load(subdivisions=2)          # Mesh[2, 2]
d = mesh.n_spatial_dims                          # 2

mesh.point_data["temperature"] = torch.rand(mesh.n_points)
mesh.point_data["velocity"] = torch.rand(mesh.n_points, d)
mesh.cell_data["stress"] = torch.rand(mesh.n_cells, d, d)
mesh.global_data["Re"] = torch.tensor(1.5e6)

for name in MESH_FIELD_ASSOCIATIONS:
    print(name, tuple(getattr(mesh, name).batch_size))

TensorDict checks the batch size on assignment. A point_data tensor whose first dimension is not n_points raises a RuntimeError.

Tensor Fields of Any Rank#

The dimensions after the batch dimensions carry the rank of the field. On a mesh with n_spatial_dims equal to \(d\), a field of rank \(k\) has \(k\) trailing dimensions of size \(d\):

Table 11 Field Shapes by Rank#

Rank

Shape in point_data

Examples

0

(n_points,)

Pressure, temperature, turbulent viscosity

1

(n_points, d)

Velocity, displacement, wall shear stress

2

(n_points, d, d)

Stress, strain, velocity gradient

\(k\)

(n_points, d, ..., d)

Elasticity tensor (rank 4)

The __repr__ of a mesh prints the trailing shape of each field:

print(mesh)
Listing 20 Output#
Mesh[n_manifold_dims=2, n_spatial_dims=2](n_points=25, n_cells=32)
    point_data : {temperature: (), velocity: (2,)}
    cell_data  : {stress: (2, 2)}
    global_data: {Re: ()}

You can nest keys to group related fields. A nested key is a tuple of strings. The nesting only organizes the data. The shape of each leaf still carries its rank. The following example groups two fields under "inlet":

mesh.point_data["inlet", "velocity"] = torch.rand(mesh.n_points, d)
mesh.point_data["inlet", "temperature"] = torch.rand(mesh.n_points)

print(list(mesh.point_data["inlet"].keys()))    # ['velocity', 'temperature']

Why Rank Matters#

A change of frame changes the components of a field according to its rank. Under a rotation by an orthogonal matrix \(R\), the components change as follows:

  • A scalar does not change: \(s' = s\).

  • A vector rotates: \(v' = R v\).

  • A rank-2 tensor rotates on both indices: \(T' = R T R^\top\).

  • A rank-\(k\) tensor gets one factor of \(R\) for each index.

This rule is called the transformation law of the field. A physical answer does not depend on the frame. A model gives the same answer in every frame only if its outputs follow these laws. An equivariant model follows them by construction. Other models can learn them approximately from data augmentation. For details, refer to Data Augmentation.

The transformation methods of Mesh (rotate, transform, scale) apply these laws to the fields you select with the transform_point_data, transform_cell_data, and transform_global_data arguments:

import math

rotated = mesh.rotate(
    angle=math.pi / 6,
    transform_point_data=True,
    transform_cell_data=True,
)

R = torch.tensor([[math.cos(math.pi / 6), -math.sin(math.pi / 6)],
                  [math.sin(math.pi / 6), math.cos(math.pi / 6)]])
T = mesh.cell_data["stress"]

torch.testing.assert_close(rotated.point_data["temperature"], mesh.point_data["temperature"])
torch.testing.assert_close(rotated.point_data["velocity"], mesh.point_data["velocity"] @ R.T)
torch.testing.assert_close(rotated.cell_data["stress"], R @ T @ R.T)

The methods infer the rank of each field from its shape. With True, they transform every field. They raise ValueError for a field whose trailing dimensions are not all n_spatial_dims, such as a learned embedding of shape (n_points, 128). A field can also have the shape of a vector without being one. For example, in 3D, an RGB color has shape (n_points, 3). To transform only some fields, pass a dict of booleans, nested like the keys:

rotated = mesh.rotate(
    angle=math.pi / 6,
    transform_point_data={"velocity": True, "inlet": {"velocity": True}},
)

Reflections and Pseudovectors#

A reflection is an orthogonal matrix \(M\) with determinant \(-1\), also called an improper rotation. True vectors, such as velocity, transform under a reflection by the same law as under a rotation. Pseudovectors, such as vorticity, angular velocity, and the normal of an oriented surface, get one extra factor of \(\det M = -1\). A pseudovector comes from a cross product or a curl, and a reflection reverses the handedness of a cross product.

Three columns, each with a mesh above a dashed mirror line and its mirror image below it. Left, pressure in viridis, mirrored top to bottom. Middle, velocity arrows that point up and to the right above the line and down and to the right below it. Right, vorticity on a blue to red scale. It is blue on the left and red on the right above the line, and the reverse below it. The column titles give RankSpec(rank=0), RankSpec(rank=1), and RankSpec(rank=0, parity='odd'), with the laws p' = p, v' = M v, and omega' = det(M) omega.

Fig. 22 A scalar, a vector, and a pseudoscalar under the mirror \(y \to -y\). The pressure values mirror with the points. transform() mirrors the velocity, so its \(y\) component flips. The vorticity of a planar flow is a pseudoscalar, so it also flips sign by the extra factor \(\det M = -1\). You apply that factor by hand, as the next example shows.#

The same rule holds in 3D. Under \(y \to -y\), the vorticity of a flow that is symmetric about the plane \(y = 0\) is the mirror image with \(\omega_x\) and \(\omega_z\) negated, which is the vector law times \(-1\).

The Mesh transformation methods treat every field as a true tensor. To reflect a pseudovector field, leave it out of the mask and apply the extra sign yourself:

from physicsnemo.mesh.primitives.volumes import cube_volume

volume = cube_volume.load(subdivisions=2)                    # Mesh[3, 3]
volume.point_data["velocity"] = torch.rand(volume.n_points, 3)
volume.point_data["vorticity"] = torch.rand(volume.n_points, 3)

M = torch.diag(torch.tensor([1.0, -1.0, 1.0]))               # y -> -y
mirrored = volume.transform(M, transform_point_data={"velocity": True})
mirrored.point_data["vorticity"] = (
    torch.linalg.det(M) * volume.point_data["vorticity"] @ M.T
)

A reflection also reverses the orientation of every cell. validate(check_inverted_cells=True) reports cells that passed before the reflection as inverted after it.

A model that knows which of its fields are pseudovectors can apply this rule itself. The next section shows how a model records that knowledge.

Why Not One Flat Feature Vector#

Many pipelines concatenate every field into one feature vector for each point, for example, [p, vx, vy, vz, wx, wy, wz]. After the concatenation, the model cannot tell which channels form a vector, which form a pseudovector, and which are scalars. It cannot apply a different law to each group, so it loses access to equivariance. Keeping each field as its own tensor, with its rank in its shape, keeps that information until the model needs it.

Declaring Fields: RankSpec and FieldSchema#

The shape of a tensor gives its rank, but not everything about its law. A shape cannot say whether a rank-2 tensor is symmetric or whether a vector is a pseudovector. The module physicsnemo.mesh.fields adds two types for this:

  • RankSpec is the law of one field: its rank, whether it is symmetric (only for rank 2 or more), and its parity. The parity is "even" for a true tensor and "odd" for a pseudotensor. A pseudotensor gets the extra factor \(\det R\) under a reflection, as a pseudovector does.

  • FieldSchema maps field names to RankSpec values. A model uses one to declare the fields it reads or writes.

Both types are read-only subclasses of dict. A RankSpec holds only the attributes that differ from their defaults:

from physicsnemo.mesh import FieldSchema, RankSpec

stress = RankSpec(2, symmetric=True)
print(stress)                          # RankSpec(rank=2, symmetric=True)
print(stress.shape(3), stress.numel(3))  # (3, 3) 9
print(stress == {"rank": 2, "symmetric": True})  # True

vorticity = RankSpec(1, parity="odd")

FieldSchema.parse reads a declaration. Each field is a RankSpec or a mapping such as {"rank": 0}. A group of fields is a mapping of fields. A dotted name is the same as a nested group, so both forms below give the field "surface.shear":

schema = FieldSchema.parse({
    "pressure": {"rank": 0},
    "velocity": {"rank": 1},
    "vorticity": {"rank": 1, "parity": "odd"},
    "surface": {"shear": {"rank": 1}},
    "surface.heat_flux": {"rank": 0},
    "stress": stress,
})

print(list(schema))
# ['pressure', 'velocity', 'vorticity', 'surface.shear', 'surface.heat_flux', 'stress']
print(schema.ranks["surface.shear"], schema.count(1))   # 1 3
print(FieldSchema.key("surface.shear"))                 # ('surface', 'shear')

FieldSchema.key converts a dotted name to the key that a TensorDict uses. The parser refuses the following:

  • A bare integer, where {"rank": 0} is required

  • Unknown attributes

  • A name declared twice

  • A name that is both a field and a group

You cannot construct an invalid schema.

A schema serializes to JSON as a flat mapping from dotted names to declarations, and parse reads that JSON back to an equal schema:

import json

text = json.dumps(schema)
assert FieldSchema.parse(json.loads(text)) == schema

FieldSchema.from_tensordict derives a schema from the shapes of the leaves of a TensorDict. It reads only ranks. Symmetry and parity take their defaults. Nested keys become dotted names:

print(FieldSchema.from_tensordict(mesh.point_data))
# FieldSchema({'temperature': RankSpec(rank=0), 'velocity': RankSpec(rank=1),
#              'inlet.velocity': RankSpec(rank=1), 'inlet.temperature': RankSpec(rank=0)})

FieldSchema.check makes sure that a TensorDict holds every declared field at its declared rank. Extra fields are allowed. It reports all missing fields and rank mismatches in one ValueError:

inputs = FieldSchema.parse({"temperature": {"rank": 0}, "velocity": {"rank": 0}})
try:
    inputs.check(mesh.point_data, label="point_data")
except ValueError as error:
    print(error)
Listing 21 Output#
point_data does not contain its declared fields:
  - rank mismatch for 'velocity': declared 0, got 1

Note

PhysicsNeMo 2.3 replaced the integer-valued declarations of earlier releases ({"pressure": 0}) with {"pressure": {"rank": 0}}. Importing a removed helper, such as flatten_rank_spec, raises an ImportError that names its replacement.

How a Model Uses a Schema: GLOBE#

GLOBE (physicsnemo.experimental.models.globe.GLOBE) is a neural surrogate for boundary-driven elliptic partial differential equations (PDEs), built from learned kernels that act like Green’s functions. It is the model in PhysicsNeMo that uses FieldSchema today, and it shows a pattern that other models can follow:

  1. Take each schema as a constructor argument, in the declaration grammar. PhysicsNeMo models record their constructor arguments as JSON in checkpoints, and a declaration is plain JSON.

  2. Parse each argument with FieldSchema.parse in the constructor, with a label that names the argument in error messages.

  3. Refuse declarations that the model does not implement, when it is constructed, not later during training.

  4. In forward, call check on the input data, then select the declared fields with FieldSchema.key.

The AirFRANS training script in examples/cfd/external_aerodynamics/globe/airfrans declares its fields as follows, with the other arguments of the script left at their defaults:

import warnings

with warnings.catch_warnings():
    warnings.simplefilter("ignore")          # experimental-namespace warning
    from physicsnemo.experimental.models.globe import GLOBE

model = GLOBE(
    n_spatial_dims=2,
    output_schema={
        "ΔU/|U_inf|": {"rank": 1},
        "C_p": {"rank": 0},
        "C_pt": {"rank": 0},
        "ln(1+nut/nu)": {"rank": 0},
        "C_F,shear": {"rank": 1},
    },
    boundary_source_schemas={"no_slip": {}},
    reference_length_names=["chord", "delta_FS"],
    reference_area=1.0,
    global_schema={"U_inf / U_inf_magnitude": {"rank": 1}},
)

The schemas tell GLOBE three things:

  • output_schema lists the fields that GLOBE predicts. GLOBE builds scalar outputs from invariant features, and vector outputs on a basis of true vectors. So each output keeps the law that it declares.

  • boundary_source_schemas gives, for each boundary condition type, the cell_data fields that GLOBE reads from that boundary mesh. Here the no-slip wall needs no fields beyond its geometry (an empty group {}). GLOBE adds the cell normals itself.

  • global_schema lists the global fields, here the direction of the freestream velocity, a vector.

GLOBE implements true scalars and vectors only. A field of rank 2 or more, or a field with parity="odd", raises NotImplementedError in the constructor:

try:
    GLOBE(
        n_spatial_dims=2,
        output_schema={"stress": {"rank": 2, "symmetric": True}},
        boundary_source_schemas={"no_slip": {}},
        reference_length_names=["chord"],
        reference_area=1.0,
    )
except NotImplementedError as error:
    print(error)
Listing 22 Output#
output_schema['stress']: rank-2 fields are not supported; GLOBE implements scalars (rank 0) and vectors (rank 1)

At each forward pass outside torch.compile, GLOBE calls check on the cell_data of each boundary mesh and on global_data, and then selects the declared fields. A missing field or a field of the wrong rank stops the call with a message that names the field. Extra fields pass through the check and are ignored, so a dataset can carry more fields than the model reads.

The same pattern fits a model of your own:

from physicsnemo.mesh import FieldSchemaLike, Mesh

class PointwiseModel(torch.nn.Module):
    def __init__(self, input_schema: FieldSchemaLike):
        super().__init__()
        self.input_schema = FieldSchema.parse(input_schema, label="input_schema")
        if any(spec.parity == "odd" for spec in self.input_schema.values()):
            raise NotImplementedError("pseudotensor inputs are not supported")

    def forward(self, mesh: Mesh) -> torch.Tensor:
        self.input_schema.check(mesh.point_data, label="point_data")
        fields = [mesh.point_data[FieldSchema.key(name)] for name in self.input_schema]
        ...  # combine the fields by their ranks

model = PointwiseModel({"temperature": {"rank": 0}, "velocity": {"rank": 1}})
model(mesh)