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  DIFFRACTION

Each 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 ns

The 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 default normalize_delays=True, each link's first path arrives at 0.
  • cfr(frequencies) sums a * 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 tap round(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())   # 6

filter_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.