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

Clean up sample_rate_offset() and frequency_offset() #368

Merged
merged 5 commits into from
Jun 1, 2024
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: 0 additions & 5 deletions docs/api/conversions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ Decibels

.. python-apigen-group:: conversions-decibels

Ratios
------

.. python-apigen-group:: conversions-ratios

Signal-to-noise ratios
----------------------

Expand Down
98 changes: 0 additions & 98 deletions src/sdr/_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,104 +137,6 @@ def linear(
raise ValueError(f"Argument 'type' must be 'value', 'power', or 'voltage', not {type!r}.")


##############################################################################
# Ratios
##############################################################################


@export
def percent(x: npt.ArrayLike) -> npt.NDArray[np.float64]:
r"""
Converts from a ratio to a percentage.

Arguments:
x: The input ratio.

Returns:
The percentage.

Examples:
Convert 0.5 to 50%.

.. ipython:: python

sdr.percent(0.5)

Convert 0.25 to 25%.

.. ipython:: python

sdr.percent(0.25)

Group:
conversions-ratios
"""
x = np.asarray(x)
return 100 * x


@export
def ppm(x: npt.ArrayLike) -> npt.NDArray[np.float64]:
r"""
Converts from a ratio to parts per million (ppm).

Arguments:
x: The input ratio.

Returns:
The parts per million (ppm).

Examples:
Convert 0.005 to 5000 ppm.

.. ipython:: python

sdr.ppm(0.005)

Convert 0.000025 to 25 ppm.

.. ipython:: python

sdr.ppm(0.000025)

Group:
conversions-ratios
"""
x = np.asarray(x)
return 1e6 * x


@export
def ppb(x: npt.ArrayLike) -> npt.NDArray[np.float64]:
r"""
Converts from a ratio to parts per billion (ppb).

Arguments:
x: The input ratio.

Returns:
The parts per billion (ppb).

Examples:
Convert 0.000005 to 5000 ppb.

.. ipython:: python

sdr.ppb(0.000005)

Convert 0.000000025 to 25 ppb.

.. ipython:: python

sdr.ppb(0.000000025)

Group:
conversions-ratios
"""
x = np.asarray(x)
return 1e9 * x


##############################################################################
# From Eb/N0
##############################################################################
Expand Down
6 changes: 3 additions & 3 deletions src/sdr/_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
@export
def mix(
x: npt.NDArray,
freq: float = 0,
phase: float = 0,
sample_rate: float = 1,
freq: float = 0.0,
phase: float = 0.0,
sample_rate: float = 1.0,
complex: bool = True,
) -> npt.NDArray:
r"""
Expand Down
92 changes: 53 additions & 39 deletions src/sdr/_simulation/_impairment.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def awgn(


@export
def iq_imbalance(x: npt.NDArray, amplitude: float, phase: float = 0) -> npt.NDArray:
def iq_imbalance(x: npt.NDArray, amplitude: float, phase: float = 0.0) -> npt.NDArray:
r"""
Applies IQ imbalance to the complex time-domain signal $x[n]$.

Expand Down Expand Up @@ -184,13 +184,20 @@ def iq_imbalance(x: npt.NDArray, amplitude: float, phase: float = 0) -> npt.NDAr


@export
def sample_rate_offset(x: npt.NDArray, ppm: float) -> npt.NDArray:
def sample_rate_offset(
x: npt.NDArray,
offset: float,
# offset: npt.ArrayLike,
# offset_rate: npt.ArrayLike = 0.0,
sample_rate: float = 1.0,
) -> npt.NDArray:
r"""
Applies a sample rate offset to the time-domain signal $x[n]$.

Arguments:
x: The time-domain signal $x[n]$ to which the sample rate offset is applied.
ppm: The sample rate offset $f_{s,\text{new}} / f_s$ in parts per million (ppm).
offset: The sample rate offset $\Delta f_s = f_{s,\text{new}} - f_{s,\text{old}}$ in samples/s.
sample_rate: The sample rate $f_s$ in samples/s.

Returns:
The signal $x[n]$ with sample rate offset applied.
Expand All @@ -208,35 +215,41 @@ def sample_rate_offset(x: npt.NDArray, ppm: float) -> npt.NDArray:

.. ipython:: python

ppm = 10; \
y = sdr.sample_rate_offset(x, ppm)
y = sdr.sample_rate_offset(x, 10e-6)

@savefig sdr_sample_rate_offset_1.png
plt.figure(); \
sdr.plot.constellation(x, label="$x[n]$", zorder=2); \
sdr.plot.constellation(y, label="$y[n]$", zorder=1); \
plt.title(f"{ppm} ppm sample rate offset");
plt.title("10 ppm sample rate offset");

Add 100 ppm of sample rate offset.

.. ipython:: python

ppm = 100; \
y = sdr.sample_rate_offset(x, ppm)
y = sdr.sample_rate_offset(x, 100e-6)

@savefig sdr_sample_rate_offset_2.png
plt.figure(); \
sdr.plot.constellation(x, label="$x[n]$", zorder=2); \
sdr.plot.constellation(y, label="$y[n]$", zorder=1); \
plt.title(f"{ppm} ppm sample rate offset");
plt.title("100 ppm sample rate offset");

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

rate = 1 + ppm * 1e-6
# offset = np.asarray(offset)
# if not (offset.ndim == 0 or offset.shape == x.shape):
# raise ValueError(f"Argument 'offset' must be scalar or have shape {x.shape}, not {offset.shape}.")

# offset_rate = np.asarray(offset_rate)
# if not (offset_rate.ndim == 0 or offset_rate.shape == x.shape):
# raise ValueError(f"Argument 'offset_rate' must be scalar or have shape {x.shape}, not {offset_rate.shape}.")

rate = (sample_rate + offset) / sample_rate

# TODO: Add ppm_rate
# if ppm_rate:
Expand All @@ -251,80 +264,81 @@ def sample_rate_offset(x: npt.NDArray, ppm: float) -> npt.NDArray:
@export
def frequency_offset(
x: npt.NDArray,
freq: npt.ArrayLike,
freq_rate: npt.ArrayLike = 0,
phase: npt.ArrayLike = 0,
sample_rate: float = 1,
offset: npt.ArrayLike,
offset_rate: npt.ArrayLike = 0.0,
phase: npt.ArrayLike = 0.0,
sample_rate: float = 1.0,
) -> npt.NDArray:
r"""
Applies a frequency and phase offset to the time-domain signal $x[n]$.

Arguments:
x: The time-domain signal $x[n]$ to which the frequency offset is applied.
freq: The frequency offset $f$ in Hz (or in cycles/sample if `sample_rate=1`).
freq_rate: The frequency offset rate $f_{\text{rate}}$ in Hz/s (or in cycles/sample^2 if `sample_rate=1`).
offset: The frequency offset $\Delta f_c = f_{c,\text{new}} - f_{c,\text{old}}$ in Hz.
offset_rate: The frequency offset rate $\Delta f_c / \Delta t$ in Hz/s.
phase: The phase offset $\phi$ in degrees.
sample_rate: The sample rate $f_s$ in samples/s.

Returns:
The signal $x[n]$ with frequency offset applied.

Examples:
Create a QPSK reference signal.
Create a reference signal with a constant frequency of 1 cycle per 100 samples.

.. ipython:: python

psk = sdr.PSK(4, phase_offset=45); \
s = np.random.randint(0, psk.order, 1_000); \
x = psk.map_symbols(s)
x = np.exp(1j * 2 * np.pi / 100 * np.arange(100))

Add a frequency offset of 1 cycle per 10,000 symbols.
Add a frequency offset of 1 cycle per 100 samples (the length of the signal). Notice that the signal now
rotates through 2 cycles instead of 1.

.. ipython:: python

freq = 1e-4; \
freq = 1 / 100
y = sdr.frequency_offset(x, freq)

@savefig sdr_frequency_offset_1.png
plt.figure(); \
sdr.plot.constellation(x, label="$x[n]$", zorder=2); \
sdr.plot.constellation(y, label="$y[n]$", zorder=1); \
plt.title(f"{freq} cycles/sample frequency offset");
sdr.plot.time_domain(np.unwrap(np.angle(x)) / (2 * np.pi), label="$x[n]$"); \
sdr.plot.time_domain(np.unwrap(np.angle(y)) / (2 * np.pi), label="$y[n]$"); \
plt.ylabel("Absolute phase (cycles)"); \
plt.title("Constant frequency offset (linear phase)");

Add a frequency offset of -1 cycle per 20,000 symbols and a phase offset of -45 degrees.
Add a frequency rate of change of 2 cycles per 100^2 samples. Notice that the signal now rotates through
4 cycles instead of 2.

.. ipython:: python

freq = -5e-5; \
phase = -45; \
y = sdr.frequency_offset(x, freq, phase=phase)
freq_rate = 2 / 100**2
y = sdr.frequency_offset(x, freq, freq_rate)

@savefig sdr_frequency_offset_2.png
plt.figure(); \
sdr.plot.constellation(x, label="$x[n]$", zorder=2); \
sdr.plot.constellation(y, label="$y[n]$", zorder=1); \
plt.title(f"{freq} cycles/sample frequency and {phase} deg offset");
sdr.plot.time_domain(np.unwrap(np.angle(x)) / (2 * np.pi), label="$x[n]$"); \
sdr.plot.time_domain(np.unwrap(np.angle(y)) / (2 * np.pi), label="$y[n]$"); \
plt.ylabel("Absolute phase (cycles)"); \
plt.title("Linear frequency offset (quadratic phase)");

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

freq = np.asarray(freq)
if not (freq.ndim == 0 or freq.shape == x.shape):
raise ValueError(f"Argument 'freq' must be scalar or have shape {x.shape}, not {freq.shape}.")
offset = np.asarray(offset)
if not (offset.ndim == 0 or offset.shape == x.shape):
raise ValueError(f"Argument 'offset' must be scalar or have shape {x.shape}, not {offset.shape}.")

freq_rate = np.asarray(freq_rate)
if not (freq_rate.ndim == 0 or freq_rate.shape == x.shape):
raise ValueError(f"Argument 'freq_rate' must be scalar or have shape {x.shape}, not {freq_rate.shape}.")
offset_rate = np.asarray(offset_rate)
if not (offset_rate.ndim == 0 or offset_rate.shape == x.shape):
raise ValueError(f"Argument 'offset_rate' must be scalar or have shape {x.shape}, not {offset_rate.shape}.")

phase = np.asarray(phase)
if not (phase.ndim == 0 or phase.shape == x.shape):
raise ValueError(f"Argument 'phase' must be scalar or have shape {x.shape}, not {phase.shape}.")

t = np.arange(x.size) / sample_rate # Time vector in seconds
f = freq + freq_rate * t # Frequency vector in Hz
f = offset + offset_rate * t # Frequency vector in Hz
lo = np.exp(1j * (2 * np.pi * f * t + np.deg2rad(phase))) # Local oscillator
y = x * lo # Apply frequency offset

Expand Down
6 changes: 3 additions & 3 deletions src/sdr/_synchronization/_phase_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ class MLPED(PED):
synchronization-ped
"""

def __init__(self, A_received: float = 1, A_reference: float = 1) -> None:
def __init__(self, A_received: float = 1.0, A_reference: float = 1.0) -> None:
"""
Initializes the ML-PED.

Expand Down Expand Up @@ -240,7 +240,7 @@ def A_reference(self, A_reference: float) -> None:


def _data_aided_error(
ped: PED, modem: LinearModulation, n_points: int = 1000, A_received: float = 1, A_reference: float = 1
ped: PED, modem: LinearModulation, n_points: int = 1000, A_received: float = 1.0, A_reference: float = 1
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
if not isinstance(ped, PED):
raise TypeError(f"Argument 'ped' must be a PED, not {type(ped)}.")
Expand All @@ -260,7 +260,7 @@ def _data_aided_error(


def _decision_directed_error(
ped: PED, modem: LinearModulation, n_points: int = 1000, A_received: float = 1, A_reference: float = 1
ped: PED, modem: LinearModulation, n_points: int = 1000, A_received: float = 1.0, A_reference: float = 1
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
if not isinstance(ped, PED):
raise TypeError(f"Argument 'ped' must be a PED, not {type(ped)}.")
Expand Down
2 changes: 1 addition & 1 deletion src/sdr/plot/_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def _convert_to_taps(
def impulse_response(
filter: FIR | IIR | npt.ArrayLike | tuple[npt.ArrayLike, npt.ArrayLike],
N: int | None = None,
offset: float = 0,
offset: float = 0.0,
ax: plt.Axes | None = None,
**kwargs,
):
Expand Down
4 changes: 2 additions & 2 deletions src/sdr/plot/_time_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def time_domain(
*,
sample_rate: float | None = None,
centered: bool = False,
offset: float = 0,
offset: float = 0.0,
diff: Literal["color", "line"] = "color",
**kwargs,
): ...
Expand All @@ -46,7 +46,7 @@ def time_domain( # noqa: D417
*args,
sample_rate: float | None = None,
centered: bool = False,
offset: float = 0,
offset: float = 0.0,
diff: Literal["color", "line"] = "color",
ax: plt.Axes | None = None,
**kwargs,
Expand Down
15 changes: 0 additions & 15 deletions tests/conversions/test_percent.py

This file was deleted.

Loading
Loading