diff --git a/docs/api/conversions.rst b/docs/api/conversions.rst index c988f8ab9..bd1d3b555 100644 --- a/docs/api/conversions.rst +++ b/docs/api/conversions.rst @@ -6,11 +6,6 @@ Decibels .. python-apigen-group:: conversions-decibels -Ratios ------- - -.. python-apigen-group:: conversions-ratios - Signal-to-noise ratios ---------------------- diff --git a/src/sdr/_conversion.py b/src/sdr/_conversion.py index 63e95380e..98448c5ab 100644 --- a/src/sdr/_conversion.py +++ b/src/sdr/_conversion.py @@ -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 ############################################################################## diff --git a/src/sdr/_signal.py b/src/sdr/_signal.py index f23e20352..1b44a02ad 100644 --- a/src/sdr/_signal.py +++ b/src/sdr/_signal.py @@ -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""" diff --git a/src/sdr/_simulation/_impairment.py b/src/sdr/_simulation/_impairment.py index 7d1c9cf3c..11ece67fb 100644 --- a/src/sdr/_simulation/_impairment.py +++ b/src/sdr/_simulation/_impairment.py @@ -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]$. @@ -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. @@ -208,27 +215,25 @@ 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 @@ -236,7 +241,15 @@ def sample_rate_offset(x: npt.NDArray, ppm: float) -> npt.NDArray: 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: @@ -251,18 +264,18 @@ 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. @@ -270,40 +283,41 @@ def frequency_offset( 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 @@ -311,20 +325,20 @@ def frequency_offset( 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 diff --git a/src/sdr/_synchronization/_phase_error.py b/src/sdr/_synchronization/_phase_error.py index 6f3a094fd..a3d466658 100644 --- a/src/sdr/_synchronization/_phase_error.py +++ b/src/sdr/_synchronization/_phase_error.py @@ -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. @@ -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)}.") @@ -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)}.") diff --git a/src/sdr/plot/_filter.py b/src/sdr/plot/_filter.py index 7f56aeedf..2e7fdd72c 100644 --- a/src/sdr/plot/_filter.py +++ b/src/sdr/plot/_filter.py @@ -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, ): diff --git a/src/sdr/plot/_time_domain.py b/src/sdr/plot/_time_domain.py index 5cd737bdd..d0bf62b36 100644 --- a/src/sdr/plot/_time_domain.py +++ b/src/sdr/plot/_time_domain.py @@ -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, ): ... @@ -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, diff --git a/tests/conversions/test_percent.py b/tests/conversions/test_percent.py deleted file mode 100644 index a8d1817c6..000000000 --- a/tests/conversions/test_percent.py +++ /dev/null @@ -1,15 +0,0 @@ -import numpy as np - -import sdr - - -def test_0_5(): - assert sdr.percent(0.5) == 50 - - -def test_0_25(): - assert sdr.percent(0.25) == 25 - - -def test_array(): - assert np.array_equal(sdr.percent(np.array([0.5, 0.25])), np.array([50, 25])) diff --git a/tests/conversions/test_ppb.py b/tests/conversions/test_ppb.py deleted file mode 100644 index 26633a4a1..000000000 --- a/tests/conversions/test_ppb.py +++ /dev/null @@ -1,15 +0,0 @@ -import numpy as np - -import sdr - - -def test_0_00005(): - assert sdr.ppb(0.000005) == 5000 - - -def test_0_00000025(): - assert sdr.ppb(0.000000025) == 25 - - -def test_array(): - assert np.array_equal(sdr.ppb(np.array([0.000005, 0.000000025])), np.array([5000, 25])) diff --git a/tests/conversions/test_ppm.py b/tests/conversions/test_ppm.py deleted file mode 100644 index 994bf44e6..000000000 --- a/tests/conversions/test_ppm.py +++ /dev/null @@ -1,15 +0,0 @@ -import numpy as np - -import sdr - - -def test_0_005(): - assert sdr.ppm(0.005) == 5000 - - -def test_0_000025(): - assert sdr.ppm(0.000025) == 25 - - -def test_array(): - assert np.array_equal(sdr.ppm(np.array([0.005, 0.000025])), np.array([5000, 25]))