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.
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:
Container |
Batch Size |
Holds |
|---|---|---|
|
|
One value for each vertex, for example a nodal solution |
|
|
One value for each cell, for example a finite-volume solution |
|
|
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\):
Rank |
Shape in |
Examples |
|---|---|---|
0 |
|
Pressure, temperature, turbulent viscosity |
1 |
|
Velocity, displacement, wall shear stress |
2 |
|
Stress, strain, velocity gradient |
\(k\) |
|
Elasticity tensor (rank 4) |
The __repr__ of a mesh prints the trailing shape of each field:
print(mesh)
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.
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:
RankSpecis the law of one field: itsrank, whether it issymmetric(only for rank 2 or more), and itsparity. 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.FieldSchemamaps field names toRankSpecvalues. 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 requiredUnknown 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)
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:
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.
Parse each argument with
FieldSchema.parsein the constructor, with alabelthat names the argument in error messages.Refuse declarations that the model does not implement, when it is constructed, not later during training.
In
forward, callcheckon the input data, then select the declared fields withFieldSchema.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_schemalists 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_schemasgives, for each boundary condition type, thecell_datafields 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_schemalists 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)
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)