Tensors and gradients

How Core stores tensors without copying, picks devices and dtypes, keeps autograd intact, and turns signed distances into soft occupancy.

Every continuous value in Core is a PyTorch tensor, and Core keeps the tensor you pass. That is what makes a Core scene differentiable: a solver reads the same tensor objects that your optimizer updates.

Core keeps your tensors#

When you pass a tensor, Core stores that exact object. It does not copy, cast, detach or move it.

import torch
 
from witwin.core import Box, PhysicalMaterial
 
position = torch.nn.Parameter(torch.tensor([0.0, 1.0, 2.0], dtype=torch.float64))
eps_r = torch.nn.Parameter(torch.tensor(3.0))
 
box = Box(position=position)
material = PhysicalMaterial(eps_r=eps_r)
print(box.position is position, material.eps_r is eps_r, box.position.dtype)
True True torch.float64

When you pass a Python number, tuple or list instead, Core converts it to a float32 tensor (faces of a Mesh become int64). Material scalars are the exception: PhysicalMaterial(eps_r=2.0) keeps the Python float.

Devices#

A geometry resolves one device from its arguments:

  1. the device= argument, if you give one;
  2. otherwise the device of the first tensor among its arguments.

Python values are then created on that device. Two tensors on different devices raise ValueError (Geometry tensor device cpu conflicts with resolved device cuda:0.). Core never moves a tensor to another device for you. Put your tensors on the target device before you build the geometry.

Antennas, receiver grids, trajectories and motion check the same way: their tensor fields must share one device. Trajectories and RigidMotion also require one dtype.

Material evaluation is the one place Core combines your tensors with each other: evaluate_at_frequency requires eps_r, sigma_e and a tensor frequency to share a device and dtype.

Gradients#

Any field that holds a tensor can carry a gradient. The ones you typically train:

ObjectDifferentiable fields
Primitivesposition, rotation, size, radius, height, radii, base_size, major_radius, minor_radius, outer_size, inner_size
Meshvertices, position, rotation, scale, surface_thickness
PhysicalMaterialeps_r, mu_r, sigma_e, thickness_m, gain, scattering_coefficient, xpd_coefficient
MaterialLayerthickness_m, eps_r, sigma_e, mu_r
SurfaceRoughnessrms_height_m, correlation_length_x_m, correlation_length_y_m, principal_axis_rad
PhaseScreenheight
AntennaStateposition, orientation, polarization, element_positions, weights, power_w
ReceiverGridorigin, x_axis, y_axis, spacing
MotionLinearTrajectory.origin, velocity, and the time_s you pass to at()

Core's own computations keep the graph: to_mesh, signed_distance, to_mask, world_vertices, evaluate_at_frequency, orientation_matrix, polarization_world, ReceiverGrid.points and LinearTrajectory.at. Whether a solver differentiates through a field is the solver's decision; check its documentation.

Note

Core validates Python numbers (for example eps_r must be greater than 0), but it never reads a tensor's value on the host. A tensor with a negative eps_r is accepted. This keeps construction free of GPU synchronisation.

From distance to occupancy#

Built-in geometries answer signed_distance(x, y, z): negative inside, zero on the surface, positive outside, in metres. to_mask(x, y, z, offset=0.0, beta=None) turns it into a soft occupancy:

occupancy = 0.5 * (1 - tanh((signed_distance - offset) / beta)),  clamped to [0, 1]

offset grows (positive) or shrinks (negative) the shape. beta sets the width of the transition band, in metres. When beta is None, Core uses 5 % of the smallest grid spacing it can read from the coordinates, or 1 mm when it cannot read a spacing.

Gradients exist only where the occupancy is between 0 and 1, which is a band a few beta wide around the surface. With the default beta, that band is thinner than one grid cell, so an optimizer only sees a shape edge once it is close. Pass a wider beta (for example a few centimetres) while fitting, as in the Fit a box tutorial.

Mesh distances#

Mesh.signed_distance computes the unsigned distance to the nearest triangle and takes its sign from the winding number of the mesh, smoothed with tanh so that the sign is differentiable. Everything runs in PyTorch, in chunks of points and triangles, on the device of the query points. The mesh's fill_mode decides whether a mesh is treated as a solid or as a thin surface; see Load a mesh.

Core contains no native code. A solver package can install a faster mesh-distance backend for its own use. Only then, and only for a static CPU-authored mesh of 1024 or more triangles queried on CUDA in float32, does Core also build a bounding-volume hierarchy for it. Without such a backend every query takes the PyTorch path.