Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add raster() and eye() #97

Merged
merged 4 commits into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ View all available classes and functions in the [API Reference](https://mhostett
additive white Gaussian noise (AWGN), frequency offset, sample rate offset, IQ imbalance.
- **Link budgets**: Channel capacities, free-space path loss, antenna gains.
- **Data manipulation**: Packing and unpacking binary data, hexdumping binary data.
- **Plotting**: Time-domain, periodogram, spectrogram, BER, SER, constellation, symbol map, impulse response,
step response, magnitude response, phase response, phase delay, group delay, and zeros/poles.
- **Plotting**: Time-domain, raster, periodogram, spectrogram, constellation, symbol map, eye diagram,
bit error rate (BER), symbol error rate (SER), impulse response, step response, magnitude response, phase response,
phase delay, group delay, and zeros/poles.

## Examples

Expand Down
5 changes: 3 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ View all available classes and functions in the `API Reference <https://mhostett
additive white Gaussian noise (AWGN), frequency offset, sample rate offset, IQ imbalance.
- **Link budgets**: Channel capacities, free-space path loss, antenna gains.
- **Data manipulation**: Packing and unpacking binary data, hexdumping binary data.
- **Plotting**: Time-domain, periodogram, spectrogram, BER, SER, constellation, symbol map, impulse response,
step response, magnitude response, phase response, phase delay, group delay, and zeros/poles.
- **Plotting**: Time-domain, raster, periodogram, spectrogram, constellation, symbol map, eye diagram,
bit error rate (BER), symbol error rate (SER), impulse response, step response, magnitude response, phase response,
phase delay, group delay, and zeros/poles.

.. toctree::
:caption: Examples
Expand Down
69 changes: 69 additions & 0 deletions src/sdr/plot/_modulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .._helper import export
from ._rc_params import RC_PARAMS
from ._time_domain import raster


@export
Expand Down Expand Up @@ -182,6 +183,74 @@ def symbol_map(
plt.tight_layout()


@export
def eye(
x: npt.ArrayLike,
sps: int,
span: int = 2,
sample_rate: float | None = None,
color: Literal["index"] | str = "index",
**kwargs,
):
r"""
Plots the eye diagram of the real baseband signal $x[k]$.

Arguments:
x: The real baseband signal $x[k]$.
sps: The number of samples per symbol.
span: The number of symbols per raster.
sample_rate: The sample rate $f_s$ of the signal in samples/s. If `None`, the x-axis will
be labeled as "Samples".
color: Indicates how to color the rasters. If `"index"`, the rasters are colored based on their index.
If a valid Matplotlib color, the rasters are all colored with that color.
kwargs: Additional keyword arguments to pass to :func:`sdr.plot.raster()`.

Note:
To plot an eye diagram for I and Q, call this function twice, passing `x.real` and then `x.imag`.

Example:
Modulate 100 BPSK symbols.

.. ipython:: python

psk = sdr.PSK(2); \
s = np.random.randint(0, psk.order, 100); \
a = psk.modulate(s)

Apply a raised cosine pulse shape and examine the eye diagram of the I channel. Since the raised
cosine pulse shape is a Nyquist filter, there is no intersymbol interference (ISI) at the symbol decisions.

.. ipython:: python

sps = 25; \
h = sdr.raised_cosine(0.5, 6, sps); \
fir = sdr.Interpolator(sps, h); \
x = fir(a)

@savefig sdr_plot_eye_1.png
plt.figure(figsize=(8, 4)); \
sdr.plot.eye(x.real, sps)

Apply a root raised cosine pulse shape and examine the eye diagram of the I channel. The root raised
cosine filter is not a Nyquist filter, and ISI can be observed.

.. ipython:: python

sps = 25; \
h = sdr.root_raised_cosine(0.5, 6, sps); \
fir = sdr.Interpolator(sps, h); \
x = fir(a)

@savefig sdr_plot_eye_2.png
plt.figure(figsize=(8, 4)); \
sdr.plot.eye(x.real, sps)

Group:
plot-modulation
"""
raster(x, span * sps + 1, stride=sps, sample_rate=sample_rate, color=color, **kwargs)


@export
def ber(
ebn0: npt.ArrayLike,
Expand Down
124 changes: 116 additions & 8 deletions src/sdr/plot/_time_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import matplotlib.pyplot as plt
import numpy as np
import numpy.typing as npt
from matplotlib.collections import LineCollection
from typing_extensions import Literal

from .._helper import export
Expand All @@ -15,7 +16,7 @@
@export
def time_domain(
x: npt.ArrayLike,
sample_rate: float = 1.0,
sample_rate: float | None = None,
centered: bool = False,
offset: float = 0,
diff: Literal["color", "line"] = "color",
Expand All @@ -26,8 +27,8 @@ def time_domain(

Arguments:
x: The time-domain signal $x[n]$.
sample_rate: The sample rate $f_s$ of the signal in samples/s. If the sample rate is 1, the x-axis will
be label as "Samples".
sample_rate: The sample rate $f_s$ of the signal in samples/s. If `None`, the x-axis will
be labeled as "Samples".
centered: Indicates whether to center the x-axis about 0. This argument is mutually exclusive with
`offset`.
offset: The x-axis offset to apply to the first sample. The units of the offset are $1/f_s$.
Expand Down Expand Up @@ -76,10 +77,17 @@ def time_domain(
Group:
plot-time-domain
"""
if not isinstance(sample_rate, (int, float)):
raise TypeError(f"Argument 'sample_rate' must be a number, not {type(sample_rate)}.")

x = np.asarray(x)
if not x.ndim == 1:
raise ValueError(f"Argument 'x' must be 1-D, not {x.ndim}-D.")

if sample_rate is None:
sample_rate_provided = False
sample_rate = 1
else:
sample_rate_provided = True
if not isinstance(sample_rate, (int, float)):
raise TypeError(f"Argument 'sample_rate' must be a number, not {type(sample_rate)}.")

if centered:
if x.size % 2 == 0:
Expand Down Expand Up @@ -112,9 +120,109 @@ def time_domain(

if label:
plt.legend()
if sample_rate == 1:
plt.xlabel("Samples")
if sample_rate_provided:
plt.xlabel("Time (s)")
else:
plt.xlabel("Samples")
plt.ylabel("Amplitude")
plt.tight_layout()


@export
def raster(
x: npt.ArrayLike,
length: int,
stride: int | None = None,
sample_rate: float | None = None,
color: Literal["index"] | str = "index",
colorbar: bool = False,
**kwargs,
):
"""
Plots a raster of the time-domain signal $x[n]$.

Arguments:
x: The time-domain signal $x[n]$.
length: The length of each raster in samples.
stride: The stride between each raster in samples. If `None`, the stride is set to `length`.
sample_rate: The sample rate $f_s$ of the signal in samples/s. If `None`, the x-axis will
be labeled as "Samples".
color: Indicates how to color the rasters. If `"index"`, the rasters are colored based on their index.
If a valid Matplotlib color, the rasters are all colored with that color.
colorbar: Indicates whether to add a colorbar to the plot.
kwargs: Additional keyword arguments to pass to :obj:`matplotlib.collections.LineCollection`.
The following keyword arguments are set by default. The defaults may be overwritten.

- `"linewidths"`: `(0.5, 1, 1.5, 2)`
- `"linestyles"`: `"solid"`

Group:
plot-time-domain
"""
x = np.asarray(x)
if not np.isrealobj(x):
raise TypeError(f"Argument 'x' must be real, not {x.dtype}.")
if not x.ndim == 1:
raise ValueError(f"Argument 'x' must be 1-D, not {x.ndim}-D.")

if not isinstance(length, int):
raise TypeError(f"Argument 'length' must be an integer, not {type(length)}.")
if not 1 <= length <= x.size:
raise ValueError(f"Argument 'length' must be at least 1 and less than the length of 'x', not {length}.")

if stride is None:
stride = length
elif not isinstance(stride, int):
raise TypeError(f"Argument 'stride' must be an integer, not {type(stride)}.")
elif not 1 <= stride <= x.size:
raise ValueError(f"Argument 'stride' must be at least 1 and less than the length of 'x', not {stride}.")

if sample_rate is None:
sample_rate_provided = False
t = np.arange(length)
else:
sample_rate_provided = True
if not isinstance(sample_rate, (int, float)):
raise TypeError(f"Argument 'sample_rate' must be a number, not {type(sample_rate)}.")
t = np.arange(length) / sample_rate

# Compute the strided data and format into segments for LineCollection
N_rasters = (x.size - length) // stride + 1
x_strided = np.lib.stride_tricks.as_strided(
x, shape=(N_rasters, length), strides=(x.strides[0] * stride, x.strides[0]), writeable=False
)
segs = [np.column_stack([t, x_raster]) for x_raster in x_strided]

# Set the default keyword arguments and override with user-specified keyword arguments
default_kwargs = {
"linewidths": (0.5, 1, 1.5, 2),
"linestyles": "solid",
}
if color == "index":
default_kwargs["array"] = np.arange(N_rasters)
else:
default_kwargs["colors"] = color
kwargs = {**default_kwargs, **kwargs}

line_collection = LineCollection(
segs,
**kwargs,
)

with plt.rc_context(RC_PARAMS):
ax = plt.gca()
ax.add_collection(line_collection)
ax.set_xlim(t.min(), t.max())
ax.set_ylim(x.min(), x.max())

if colorbar:
axcb = plt.colorbar(line_collection)
axcb.set_label("Raster Index")

plt.grid(True)
if sample_rate_provided:
plt.xlabel("Time (s)")
else:
plt.xlabel("Samples")
plt.ylabel("Amplitude")
plt.tight_layout()