Locate a transmitter

Recover an unknown transmitter position from path delays at four receivers by gradient descent through the deterministic solver.

This tutorial solves a small inverse problem: four receivers measure the delay of the direct path from a transmitter, and the transmitter position is unknown. Because Channel's delays are differentiable with respect to the transmitter position, a PyTorch optimizer can find the position that reproduces the measured delays. It runs in about 25 s on an RTX 5080.

The problem#

Four receivers sit at known positions around the origin. The true transmitter is at (0.4, -0.7, 0.6). The optimizer starts about 50 cm away, at (0.75, -1.0, 0.85), and minimizes the squared difference between predicted and measured line-of-sight delays, in nanoseconds.

The scene has no structures, so each delay is just distance divided by the speed of light. The same pattern applies unchanged to scenes with walls, where the delays also include reflected paths.

The script#

import torch
 
from witwin.core import AntennaState, Scene
from witwin.channel.deterministic import Config, solve
 
FREQUENCY_HZ = 3.0e9
RX_POSITIONS = [(3.0, 1.0, 0.2), (-2.0, 2.0, 1.0), (1.5, -3.0, 1.4), (-1.0, -2.0, -0.5)]
 
 
def make_scene(tx_position: torch.Tensor) -> Scene:
    endpoints = [AntennaState(1, "tx", tx_position, power_w=1.0)]
    for index, position in enumerate(RX_POSITIONS):
        endpoints.append(AntennaState(10 + index, "rx", torch.tensor(position)))
    return Scene(structures=[], endpoints=endpoints)
 
 
def delays(tx_position: torch.Tensor, ad_mode: str) -> torch.Tensor:
    result = solve(
        make_scene(tx_position),
        Config(max_depth=0, components={"los"}, export_paths=True, ad_mode=ad_mode),
        reference_frequency_hz=FREQUENCY_HZ,
    )
    return result.paths.delay_s
 
 
tx_true = torch.tensor([0.4, -0.7, 0.6], device="cuda")
target = delays(tx_true, "none")
 
tx = (tx_true + torch.tensor([0.35, -0.3, 0.25], device="cuda")).requires_grad_(True)
optimizer = torch.optim.Adam([tx], lr=2.0e-2)
for step in range(200):
    optimizer.zero_grad()
    loss = ((delays(tx, "vjp") - target) * 1.0e9).square().sum()
    loss.backward()
    optimizer.step()
    if step % 50 == 0 or step == 199:
        error = (tx.detach() - tx_true).norm().item()
        print(f"step {step:3d}  loss {loss.item():.3e}  error {error * 100:.2f} cm")
 
print("recovered", [round(v, 4) for v in tx.detach().tolist()])

Output:

step   0  loss 5.130e+00  error 49.01 cm
step  50  loss 5.102e-03  error 2.09 cm
step 100  loss 1.138e-04  error 0.24 cm
step 150  loss 1.595e-07  error 0.01 cm
step 199  loss 2.917e-09  error 0.00 cm
recovered [0.4, -0.7, 0.6]

The optimizer recovers the position to well under a millimetre.

How it works#

  • The measurement. max_depth=0 with components={"los"} keeps only the direct path. export_paths=True returns a PathTable, whose delay_s has one row per receiver, sorted by receiver.
  • The differentiable input. tx is a tensor leaf with requires_grad=True, passed as the AntennaState position. With ad_mode="vjp" in the Config, PathTable.delay_s carries a gradient back to it. The target is computed once with ad_mode="none".
  • Scale. Delays are a few nanoseconds; multiplying the error by 1e9 keeps the loss near 1 so Adam's default settings work.
  • Rebuilding the scene. make_scene builds a new Scene every step from the current tx. Scenes are immutable; Channel reuses what it can from the previous solve.

See Differentiability for the inputs and outputs that carry gradients, and Optimize with gradients for pitfalls.

Try next#

  • Add a wall and "reflection" to the components, and fit to the delays of all paths. Keep the starting point close enough that the set of paths does not change during the fit.
  • Fit to result.path_gain in dB instead of delays, which is closer to what a received-signal-strength system measures. The loss is less well conditioned, because gain changes slowly with distance.
  • Use three receivers. With three distances, two mirror-image positions fit equally well, and the start point decides which one the optimizer finds.