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#
| Input | How to make it a leaf |
|---|---|
| Transmitter or receiver position | AntennaState(..., position=leaf) with a 3-element tensor |
| Mesh vertices | Mesh(vertices=leaf, faces, ...) |
eps_r, sigma_e, gain, thickness_m | Pass a 0-d tensor to PhysicalMaterial (or MaterialLayer) |
| Carrier frequency | Pass 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#
| Solver | ad_mode values | What 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" accepted | Experimental; 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"andad_mode="vjp"still runs, butbackward()raisesNotImplementedError. Solve scattering withad_mode="none", or leave it out ofcomponentswhen 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.