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=0withcomponents={"los"}keeps only the direct path.export_paths=Truereturns aPathTable, whosedelay_shas one row per receiver, sorted by receiver. - The differentiable input.
txis a tensor leaf withrequires_grad=True, passed as theAntennaStateposition. Withad_mode="vjp"in theConfig,PathTable.delay_scarries a gradient back to it. The target is computed once withad_mode="none". - Scale. Delays are a few nanoseconds; multiplying the error by
1e9keeps the loss near 1 so Adam's default settings work. - Rebuilding the scene.
make_scenebuilds a newSceneevery step from the currenttx. 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_gainin 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.