Time-varying channel
Discover paths once, replay them for a moving receiver over many time slots in one call, and read each path's Doppler shift from its phase.
This tutorial follows a receiver moving at 10 m/s past a wall. Instead of solving the scene from scratch for every time slot, it discovers the paths once and replays them for all slots in one call. From the per-slot phases it then reads the Doppler shift of each path and checks it against the geometry.
It uses the propagation consumer (witwin.channel.propagation.consumer), the low-level interface behind the solvers. See Replay a fixed topology for the ideas and Propagation consumer for the reference.
The scene#
A wall (eps_r = 4.0, sigma_e = 0.02 S/m) stands in the plane x = 2.5. The transmitter is fixed at (0, -1, 0.5). The receiver starts at (0, 1, 0.5) and moves along +y, directly away from the transmitter, at 10 m/s. Eight slots are 1 ms apart, so the receiver moves 7 cm in total: not enough to change which paths exist.
The script#
import math
import torch
from witwin.core import AntennaState, Mesh, PhysicalMaterial, Scene, Structure
from witwin.channel.propagation import consumer
from witwin.channel.scene import compile
FREQUENCY_HZ = 3.0e9
SPEED_M_S = 10.0 # receiver speed along +y
SLOT_S = 1.0e-3 # time between slots
SLOTS = 8
VERTICAL = [0.0, 0.0, 1.0]
vertices = torch.tensor([[2.5, -3.0, -1.0], [2.5, 3.0, -1.0], [2.5, -3.0, 2.0], [2.5, 3.0, 2.0]])
faces = torch.tensor([[0, 1, 2], [1, 3, 2]])
wall = Structure(Mesh(vertices, faces, recenter=False, fill_mode="surface"), PhysicalMaterial(eps_r=4.0, sigma_e=0.02), name="wall")
tx = AntennaState(1, "tx", torch.tensor([0.0, -1.0, 0.5]), power_w=1.0)
rx = AntennaState(2, "rx", torch.tensor([0.0, 1.0, 0.5]))
compiled = compile(Scene(structures=[wall], endpoints=[tx, rx]), reference_frequency_hz=FREQUENCY_HZ)
def batch(stable_id, positions, powers=None):
count = positions.shape[0]
return consumer.EndpointBatch(
stable_ids=torch.full((count,), stable_id, dtype=torch.int64, device="cuda"),
positions_m=positions,
polarizations=torch.tensor([VERTICAL] * count, device="cuda"),
powers_w=powers,
)
# 1. Discover the paths once, at the start position.
source = batch(1, torch.tensor([[0.0, -1.0, 0.5]], device="cuda"), torch.ones(1, device="cuda"))
start = consumer.evaluate(compiled, consumer.PropagationRequest(
sources=source,
sinks=batch(2, torch.tensor([[0.0, 1.0, 0.5]], device="cuda")),
reference_frequency_hz=FREQUENCY_HZ,
components=frozenset({"los", "reflection"}),
max_depth=1,
response="scalar_transport",
topology_mode="discover",
ad_mode="none",
))
prepared = consumer.prepare_fixed_topology(start.paths.topology)
print("rows per slot", prepared.row_count)
# 2. Stack S slots slot-major: the transmitter stays, the receiver moves.
times_s = torch.arange(SLOTS, dtype=torch.float64) * SLOT_S
rx_positions = torch.tensor([[0.0, 1.0 + SPEED_M_S * t, 0.5] for t in times_s.tolist()], device="cuda")
sources = batch(1, source.positions_m.repeat(SLOTS, 1), torch.ones(SLOTS, device="cuda"))
sinks = batch(2, rx_positions)
# 3. Replay the topology for every slot in one call.
channel = consumer.evaluate_time_varying(compiled, consumer.TimeVaryingRequest(
sources=sources,
sinks=sinks,
reference_frequency_hz=FREQUENCY_HZ,
topology=prepared,
times_s=times_s,
response="scalar_transport",
ad_mode="none",
))
print("delay_s", tuple(channel.delay_s.shape), "coefficient", tuple(channel.transport.coefficient.shape))
for k, name in enumerate(["los", "reflection"]):
delays_ns = [round(v * 1e9, 4) for v in channel.delay_s[:4, k].tolist()]
print(f"{name:10s} delay ns, first 4 slots: {delays_ns}")
# 4. Doppler shift from the phase change between slots.
coefficient = channel.transport.coefficient # (S, K) complex
phase_step = torch.angle(coefficient[1:] * coefficient[:-1].conj())
doppler_hz = phase_step.mean(dim=0) / (2 * math.pi * SLOT_S)
wavelength_m = 299_792_458.0 / FREQUENCY_HZ
# Geometric Doppler: minus the rate of change of the path length, in wavelengths per second.
length_rate = (channel.path_length_m[1:] - channel.path_length_m[:-1]).mean(dim=0) / SLOT_S
geometric_hz = -length_rate / wavelength_m
for k, name in enumerate(["los", "reflection"]):
print(f"{name:10s} Doppler from phase {doppler_hz[k].item():8.2f} Hz from path length {geometric_hz[k].item():8.2f} Hz")Output (RTX 5080):
rows per slot 2
delay_s (8, 2) coefficient (8, 2)
los delay ns, first 4 slots: [6.6713, 6.7046, 6.738, 6.7714]
reflection delay ns, first 4 slots: [17.963, 17.9754, 17.9879, 18.0004]
los Doppler from phase -100.07 Hz from path length -100.07 Hz
reflection Doppler from phase -38.07 Hz from path length -37.72 HzStep by step#
1. Discover. consumer.evaluate with topology_mode="discover" searches for paths between one source and one sink and returns K = 2 rows: line of sight and the wall reflection. prepare_fixed_topology freezes that discrete topology (which surfaces each path touches) for replay.
2. Batch the slots. A TimeVaryingRequest takes all slots in one pair of endpoint batches, stacked slot-major: rows s * n to (s + 1) * n - 1 belong to slot s. Here each slot has one source and one sink, so both batches have 8 rows. The same stable_ids repeat in every slot, because it is the same antenna at a different time. times_s must be a 1-D float64 tensor; it labels the slots and does not drive any computation, so you set the positions for each time yourself.
3. Replay. evaluate_time_varying takes the per-slot prepared topology (not one built with replicate_over_slots), replicates it over the slots and evaluates them all in one replay. The results are (S, K) views: delay_s, path_length_m and transport.coefficient have one row per slot and one column per path.
4. Doppler. The coefficient of a path varies as exp(-1j * k * d), where d is its length, so its phase falls as the path gets longer. The phase step between slots divided by 2 * pi * SLOT_S is the Doppler shift.
- Line of sight: the receiver moves straight away from the transmitter at 10 m/s, so
-10 / 0.0999 m = -100.07 Hz. The phase and the geometry agree exactly. - Reflection: the reflected path grows at only 3.77 m/s, because the receiver moves at an angle to the path from the mirrored transmitter, giving -37.72 Hz. The phase gives 0.35 Hz more. The difference is the wall's reflection coefficient: as the receiver moves, the angle of incidence on the wall changes, and with it the phase of the Fresnel coefficient.
Rules for time-varying replay#
- Only endpoints move. All slots share one
CompiledScene, so structures must stay fixed. To move a wall, see Replay a fixed topology. - Short motion only. Replay keeps the discovered paths and never searches for new ones.
consumer.rediscovery_requiredreports changes to the compiled scene, not endpoint motion. When endpoints move far enough that a path could appear, disappear or slide off its wall, discover again withconsumer.evaluate, for example every N slots. - Sample fast enough. The phase step between slots must stay below half a cycle, or the Doppler estimate aliases. At 3 GHz and 10 m/s the line-of-sight phase turns 0.1 cycles per millisecond.