Extract paths, CIR and CFR
List every propagation path with the path solver - delay, gain, angles, interactions - then compute delay spread, the CIR, the CFR and taps.
The path solver returns every path between every antenna pair. This guide lists the paths of one link over a floor with a metal pillar, then derives the usual channel quantities.
Solve and list the paths#
import math
import torch
from witwin.core import AntennaState, Box, Mesh, PhysicalMaterial, Scene, Structure
from witwin.channel import path
from witwin.channel.path import InteractionType
FREQUENCY_HZ = 3.5e9
floor = Structure(
Mesh(
torch.tensor([[-5.0, -5.0, 0.0], [15.0, -5.0, 0.0], [-5.0, 5.0, 0.0], [15.0, 5.0, 0.0]]),
torch.tensor([[0, 1, 2], [1, 3, 2]]),
recenter=False,
fill_mode="surface",
),
PhysicalMaterial(eps_r=5.24, sigma_e=0.0462, name="concrete"),
name="floor",
)
pillar = Structure(
Box(position=(5.0, 1.0, 1.5), size=(0.6, 0.6, 3.0)),
PhysicalMaterial.perfect_conductor(),
name="pillar",
)
tx = AntennaState(1, "tx", torch.tensor([0.0, 0.0, 2.0]), power_w=1.0)
rx = AntennaState(2, "rx", torch.tensor([10.0, 0.0, 1.5]))
scene = Scene(structures=[floor, pillar], endpoints=[tx, rx])
paths = path.solve(
scene,
path.Config(max_depth=2, components={"los", "reflection", "diffraction"}),
reference_frequency_hz=FREQUENCY_HZ,
)
print("paths:", paths.num_paths.item(), "shape of a:", tuple(paths.a.shape))
# One link: receiver 0, element 0, transmitter 0, element 0.
valid = paths.valid[0, 0, 0, 0]
a = paths.a[0, 0, 0, 0, valid, 0]
tau = paths.tau[0, 0, 0, 0, valid]
types = paths.interaction_type[0, 0, 0, 0, valid]
phi_t = paths.phi_t[0, 0, 0, 0, valid]
for k in torch.argsort(tau).tolist():
labels = [InteractionType(int(t)).name for t in types[k] if int(t) != 0] or ["LOS"]
print(f"{tau[k].item() * 1e9:7.3f} ns {20 * math.log10(abs(a[k].item())):6.1f} dB "
f"az {math.degrees(phi_t[k].item()):6.1f} deg {' + '.join(labels)}")Output:
paths: 10 shape of a: (1, 1, 1, 1, 10, 1)
33.398 ns -63.4 dB az 0.0 deg LOS
33.723 ns -63.4 dB az 8.0 deg REFLECTION
33.724 ns -72.0 dB az 8.5 deg DIFFRACTION
33.724 ns -72.0 dB az 7.5 deg DIFFRACTION
35.340 ns -87.7 dB az 0.0 deg REFLECTION
35.648 ns -87.4 dB az 8.0 deg REFLECTION + REFLECTION
48.622 ns -103.8 dB az -44.6 deg DIFFRACTION
48.622 ns -103.3 dB az 44.6 deg DIFFRACTION
67.890 ns -113.1 dB az 0.0 deg DIFFRACTION
68.247 ns -109.5 dB az 180.0 deg DIFFRACTIONEach line is one path. The 8-degree reflection is the metal pillar face, as strong as the direct path. The two diffractions next to it are the pillar's vertical edges. The 35.34 ns reflection is the floor bounce (10.595 m); it is weak because a vertically polarized wave at this grazing angle is close to the Brewster angle of concrete. The later diffractions come from the far edges of the finite floor: geometry you model is geometry Channel diffracts around.
Arrays of the result#
PathResult keeps antenna dimensions explicit: a is (R, Ra, T, Ta, P, time), where R and T count receivers and transmitters, Ra and Ta their array elements, and P is the padded path width. Slots beyond num_paths are padding; use valid to select real paths, as above. Angles are in radians: theta_t, phi_t are the departure zenith and azimuth, theta_r, phi_r the arrival angles. position holds every interaction point, primitive_id and material_id the hit triangle and its material.
a is the unit-excitation coefficient: it does not include power_w. Its magnitude squared is the path gain.
Delay spread#
power = a.abs() ** 2
mean_delay = (power * tau).sum() / power.sum()
rms = torch.sqrt((power * (tau - mean_delay) ** 2).sum() / power.sum())
print(f"RMS delay spread: {rms.item() * 1e9:.2f} ns")RMS delay spread: 0.28 nsThe spread is small because nearly all power arrives within 0.4 ns: the direct path and the pillar reflection.
CIR, CFR and taps#
coefficients, delays = paths.cir() # delays shifted so the first path is at 0
offsets = torch.linspace(-50e6, 50e6, 201, device=coefficients.device)
h = paths.cfr(offsets) # (R, Ra, T, Ta, time, F)
h_db = 20 * torch.log10(h[0, 0, 0, 0, 0].abs())
print(f"CFR over 100 MHz: {h_db.min().item():.1f} dB to {h_db.max().item():.1f} dB")
taps = paths.taps(100e6, 8) # (R, Ra, T, Ta, time, num_taps)
print("tap magnitudes:", [round(v, 6) for v in taps[0, 0, 0, 0, 0].abs().tolist()])CFR over 100 MHz: -65.0 dB to -64.5 dB
tap magnitudes: [0.000582, 0.0, 1.3e-05, 4e-06, 0.0, 0.0, 0.0, 0.0]cir()returns the coefficients and delays. With the defaultnormalize_delays=True, each link's first path arrives at 0.cfr(frequencies)sumsa * exp(-j * 2 * pi * tau * f)over paths. Pass offsets from the carrier, since the coefficients already contain the carrier phase.taps(bandwidth, num_taps)puts each path into tapround(tau * bandwidth)and sums. At 100 MHz one tap is 10 ns, so the first six paths (all within 2.3 ns of the first) fall into tap 0.
Filter by interaction#
diffracted = paths.filter_by_type(InteractionType.DIFFRACTION)
print("diffracted paths:", diffracted.num_paths.item()) # 6filter_by_type keeps the paths that contain any of the given interactions and returns a new, compacted PathResult. Pass InteractionType.NONE for line of sight. To test a flag yourself, use a bitwise AND: (paths.interaction_type & int(InteractionType.REFLECTION)) != 0.
Many receivers#
Every receiver and transmitter in the scene gets its own index along R and T, in the order of scene.endpoints. A ReceiverGrid becomes one receiver per grid sample, which is useful for path statistics over an area but uses much more memory than a deterministic radio map.
Paths from the deterministic solver#
If you only need the path list of a deterministic solve, deterministic.Config(export_paths=True) gives a compact PathTable with one row per path, no padding and no antenna dimensions. See the deterministic API.