Differentiability

What Channel can differentiate, how ad_mode works, the fixed-topology contract, and which combinations raise instead of returning a gradient.

Channel results are PyTorch tensors that can carry an autograd graph. You can differentiate a path gain, a field, a delay or a radio map with respect to antenna positions, mesh vertices, material parameters and the carrier frequency, and optimize them with any PyTorch optimizer.

Turning gradients on#

Two things are needed: an input tensor with requires_grad=True, and ad_mode="vjp" (reverse mode, for .backward()) or ad_mode="jvp" (forward mode) in the solver Config. The default is ad_mode="none", which runs the plain forward solve and returns tensors without a graph even if an input requires a gradient.

import torch
 
from witwin.core import AntennaState, Scene
from witwin.channel import deterministic
 
tx_position = torch.tensor([0.0, -1.0, 0.5], device="cuda", requires_grad=True)
scene = Scene(
    structures=[wall],
    endpoints=[
        AntennaState(1, "tx", tx_position, power_w=1.0),
        AntennaState(2, "rx", torch.tensor([0.0, 1.0, 0.5])),
    ],
)
result = deterministic.solve(
    scene,
    deterministic.Config(max_depth=1, components={"los", "reflection"}, ad_mode="vjp"),
    reference_frequency_hz=3.0e9,
)
result.path_gain.sum().backward()
print(tx_position.grad)

wall is the structure from the quickstart. For forward mode, use torch.autograd.forward_ad dual tensors or torch.func.jvp with ad_mode="jvp".

Differentiable inputs#

InputHow to make it a leaf
Transmitter or receiver positionAntennaState(..., position=leaf) with a 3-element tensor
Mesh verticesMesh(vertices=leaf, faces, ...)
eps_r, sigma_e, gain, thickness_mPass a 0-d tensor to PhysicalMaterial (or MaterialLayer)
Carrier frequencyPass a 0-d float64 tensor as reference_frequency_hz

mu_r is not differentiable. A ReceiverGrid has no per-sample position leaf: its samples are generated from the origin, axes and spacing.

Differentiable outputs#

Solverad_mode valuesWhat is differentiated
deterministic"none", "jvp", "vjp"Complex fields, path_gain, component powers, and the PathTable geometry (delay_s, path length)
path"none", "jvp", "vjp"Path coefficients a, delays tau, and everything derived from them (CIR, CFR, taps)
montecarlo.basic"none", "jvp", "vjp"The real power map and component powers
montecarlo.bdpt"none", "jvp", "vjp" acceptedExperimental; see below

Because delays are differentiable, a time-of-arrival loss works. The transmitter localization tutorial recovers a transmitter position from four delays this way.

The fixed-topology contract#

Channel differentiates through the paths it found, not through the search for them. During a solve the discrete choices are frozen: which paths exist, which triangle each bounce hits, which edge diffracts, and the validity masks. Gradients then flow through the continuous quantities of those paths: hit points, lengths, angles and the electromagnetic coefficients.

So a gradient is exact for small changes that keep the same set of paths. When a change would make a path appear or disappear (a reflection point slides off the edge of a wall, or a receiver moves into a shadow), the gradient does not see that event. Channel has no estimator for these visibility discontinuities. For optimization this means:

  • start from a configuration with the paths you care about;
  • keep steps small compared to the scene features, or re-solve each step (which re-runs path discovery, as the solvers do by default);
  • watch for sudden jumps in the loss, which signal a topology change.

Some gradients are exactly zero by construction. A line-of-sight path touches no surface, so it has zero gradient with respect to mesh vertices.

Combinations that raise#

Channel refuses a gradient it cannot compute correctly instead of returning a wrong one.

  • Scattering. capabilities() excludes the "scattering" component from gradients in every solver. In 0.5.0 a solve with "scattering" and ad_mode="vjp" still runs, but backward() raises NotImplementedError. Solve scattering with ad_mode="none", or leave it out of components when you need gradients.

These combinations are also refused:

  • coupled reflection-diffraction paths (coupled_paths=True) together with mesh-vertex gradients, in the deterministic and path solvers;
  • a frequency gradient in a scene with dispersive materials, because materials are evaluated once at the compile frequency;
  • second-order gradients (create_graph=True).

BDPT gradients#

witwin.channel.capabilities() lists the differentiable solvers as path, deterministic and montecarlo_basic, and reports BDPT as primal only. In 0.5.0 the BDPT Config nevertheless accepts ad_mode="jvp" and "vjp" and returns gradients for material, frequency and transmit-power parameters through the sampled paths; it refuses geometry gradients. This path is newer than the capability manifest. Treat BDPT gradients as experimental and check them against the deterministic solver before you rely on them.

Cost#

With ad_mode="none" there is no graph, no extra kernel launch and no extra memory. With "vjp", Channel stores a tape for the backward pass, and the backward runs native CUDA kernels. Pure diffraction and coupled paths are re-evaluated under AD, so their forward values can differ from the "none" solve in the last bits; line of sight, reflection and transmission are bit-identical.