Skip to content
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
2 changes: 2 additions & 0 deletions src/pathsim_rf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
__all__ = ["__version__"]

from .transmission_line import *
from .amplifier import *
from .mixer import *

try:
from .network import *
Expand Down
110 changes: 110 additions & 0 deletions src/pathsim_rf/amplifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#########################################################################################
##
## RF Amplifier Block
##
#########################################################################################

# IMPORTS ===============================================================================

import numpy as np

from pathsim.blocks.function import Function


# HELPERS ===============================================================================

def _dbm_to_vpeak(p_dbm, z0):
"""Convert power in dBm to peak voltage amplitude."""
p_watts = 10.0 ** (p_dbm / 10.0) * 1e-3
return np.sqrt(2.0 * z0 * p_watts)


# BLOCKS ================================================================================

class RFAmplifier(Function):
"""RF amplifier with optional nonlinearity (IP3 / P1dB compression).

In the linear regime the amplifier scales the input signal by the
voltage gain derived from the specified gain in dB:

.. math::

y(t) = a_1 \\cdot x(t)

When nonlinearity is specified via IIP3 or P1dB, a third-order
polynomial model is used:

.. math::

y(t) = a_1 x(t) + a_3 x^3(t)

where :math:`a_3 = -a_1 / A_{\\mathrm{IIP3}}^2` and
:math:`A_{\\mathrm{IIP3}}` is the input-referred IP3 voltage
amplitude. The output is hard-clipped at the gain compression
peak to prevent unphysical sign reversal.

Parameters
----------
gain : float
Small-signal voltage gain [dB]. Default 20.0.
P1dB : float or None
Input-referred 1 dB compression point [dBm]. If given without
*IIP3*, the intercept is estimated as IIP3 = P1dB + 9.6 dB.
IIP3 : float or None
Input-referred third-order intercept point [dBm]. Takes
precedence over *P1dB* if both are given.
Z0 : float
Reference impedance [Ohm]. Default 50.0.
"""

input_port_labels = {
"rf_in": 0,
}

output_port_labels = {
"rf_out": 0,
}

def __init__(self, gain=20.0, P1dB=None, IIP3=None, Z0=50.0):

# input validation
if Z0 <= 0:
raise ValueError(f"'Z0' must be positive but is {Z0}")

# store user-facing parameters
self.gain = gain
self.Z0 = Z0

# linear voltage gain
self._a1 = 10.0 ** (gain / 20.0)

# resolve nonlinearity specification
if IIP3 is not None:
self.IIP3 = float(IIP3)
self.P1dB = self.IIP3 - 9.6
elif P1dB is not None:
self.P1dB = float(P1dB)
self.IIP3 = self.P1dB + 9.6
else:
self.IIP3 = None
self.P1dB = None

# derive polynomial coefficients
if self.IIP3 is not None:
A_iip3 = _dbm_to_vpeak(self.IIP3, Z0)
self._a3 = -self._a1 / A_iip3 ** 2
# clip at gain compression peak (dy/dx = 0)
self._x_sat = A_iip3 / np.sqrt(3.0)
self._y_sat = 2.0 * self._a1 * A_iip3 / (3.0 * np.sqrt(3.0))
else:
self._a3 = 0.0
self._x_sat = None
self._y_sat = None

super().__init__(func=self._eval)

def _eval(self, rf_in):
x = rf_in
if self._x_sat is not None and abs(x) > self._x_sat:
return np.copysign(self._y_sat, x)
return self._a1 * x + self._a3 * x ** 3
56 changes: 56 additions & 0 deletions src/pathsim_rf/mixer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#########################################################################################
##
## RF Mixer Block
##
#########################################################################################

# IMPORTS ===============================================================================

from pathsim.blocks.function import Function


# BLOCKS ================================================================================

class RFMixer(Function):
"""Ideal RF mixer (frequency converter).

Performs time-domain multiplication of the RF and local-oscillator
(LO) signals, which corresponds to frequency translation:

.. math::

y(t) = G_{\\mathrm{conv}} \\cdot x_{\\mathrm{RF}}(t) \\cdot x_{\\mathrm{LO}}(t)

Parameters
----------
conversion_gain : float
Conversion gain [dB]. Default 0.0. Negative values represent
conversion loss (typical for passive mixers).
Z0 : float
Reference impedance [Ohm]. Default 50.0.
"""

input_port_labels = {
"rf": 0,
"lo": 1,
}

output_port_labels = {
"if_out": 0,
}

def __init__(self, conversion_gain=0.0, Z0=50.0):

if Z0 <= 0:
raise ValueError(f"'Z0' must be positive but is {Z0}")

self.conversion_gain = conversion_gain
self.Z0 = Z0

# linear voltage gain (can be < 1 for conversion loss)
self._gain_linear = 10.0 ** (conversion_gain / 20.0)

super().__init__(func=self._eval)

def _eval(self, rf, lo):
return self._gain_linear * rf * lo
163 changes: 163 additions & 0 deletions tests/test_amplifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
########################################################################################
##
## TESTS FOR
## 'amplifier.py'
##
########################################################################################

# IMPORTS ==============================================================================

import unittest
import numpy as np

from pathsim_rf import RFAmplifier
from pathsim_rf.amplifier import _dbm_to_vpeak


# TESTS ================================================================================

class TestRFAmplifier(unittest.TestCase):
"""Test the RFAmplifier block."""

# -- initialisation ----------------------------------------------------------------

def test_init_default(self):
"""Test default initialization."""
amp = RFAmplifier()
self.assertEqual(amp.gain, 20.0)
self.assertIsNone(amp.IIP3)
self.assertIsNone(amp.P1dB)
self.assertEqual(amp.Z0, 50.0)

def test_init_custom(self):
"""Test custom initialization with IIP3."""
amp = RFAmplifier(gain=15.0, IIP3=10.0, Z0=75.0)
self.assertEqual(amp.gain, 15.0)
self.assertEqual(amp.IIP3, 10.0)
self.assertAlmostEqual(amp.P1dB, 10.0 - 9.6)
self.assertEqual(amp.Z0, 75.0)

def test_init_P1dB_derives_IIP3(self):
"""P1dB without IIP3 derives IIP3 = P1dB + 9.6."""
amp = RFAmplifier(P1dB=0.0)
self.assertAlmostEqual(amp.IIP3, 9.6)
self.assertAlmostEqual(amp.P1dB, 0.0)

def test_IIP3_takes_precedence(self):
"""IIP3 takes precedence over P1dB when both given."""
amp = RFAmplifier(P1dB=0.0, IIP3=15.0)
self.assertEqual(amp.IIP3, 15.0)
self.assertAlmostEqual(amp.P1dB, 15.0 - 9.6)

def test_init_validation(self):
"""Test input validation."""
with self.assertRaises(ValueError):
RFAmplifier(Z0=0)
with self.assertRaises(ValueError):
RFAmplifier(Z0=-50)

def test_port_labels(self):
"""Test port label definitions."""
self.assertEqual(RFAmplifier.input_port_labels["rf_in"], 0)
self.assertEqual(RFAmplifier.output_port_labels["rf_out"], 0)

# -- linear mode -------------------------------------------------------------------

def test_linear_gain_dB(self):
"""20 dB gain = voltage factor of 10."""
amp = RFAmplifier(gain=20.0)
amp.inputs[0] = 0.1
amp.update(None)
self.assertAlmostEqual(amp.outputs[0], 1.0)

def test_linear_6dB(self):
"""6 dB gain ≈ voltage factor of ~2."""
amp = RFAmplifier(gain=6.0)
amp.inputs[0] = 1.0
amp.update(None)
expected = 10.0 ** (6.0 / 20.0) # 1.9953
self.assertAlmostEqual(amp.outputs[0], expected, places=4)

def test_linear_negative_input(self):
"""Linear mode works with negative inputs."""
amp = RFAmplifier(gain=20.0)
amp.inputs[0] = -0.05
amp.update(None)
self.assertAlmostEqual(amp.outputs[0], -0.5)

def test_linear_zero_input(self):
"""Zero input produces zero output."""
amp = RFAmplifier(gain=20.0)
amp.inputs[0] = 0.0
amp.update(None)
self.assertAlmostEqual(amp.outputs[0], 0.0)

# -- nonlinear (IP3) mode ----------------------------------------------------------

def test_ip3_small_signal_linear(self):
"""Small signals are approximately linear even with IP3."""
amp = RFAmplifier(gain=20.0, IIP3=10.0)
# tiny input well below compression
amp.inputs[0] = 1e-6
amp.update(None)
expected_linear = amp._a1 * 1e-6
self.assertAlmostEqual(amp.outputs[0], expected_linear, places=10)

def test_ip3_compression(self):
"""Near IP3 the output compresses below linear gain."""
amp = RFAmplifier(gain=20.0, IIP3=10.0)
A_iip3 = _dbm_to_vpeak(10.0, 50.0)
# drive at half the IIP3 voltage — should see compression
x_in = A_iip3 * 0.5
amp.inputs[0] = x_in
amp.update(None)
linear_out = amp._a1 * x_in
self.assertLess(amp.outputs[0], linear_out)

def test_ip3_saturation_clip(self):
"""Output is clipped at the gain compression peak for large signals."""
amp = RFAmplifier(gain=20.0, IIP3=10.0)
amp.inputs[0] = 1e3 # way beyond saturation
amp.update(None)
self.assertAlmostEqual(amp.outputs[0], amp._y_sat)

def test_ip3_symmetry(self):
"""Nonlinear response is odd-symmetric."""
amp = RFAmplifier(gain=20.0, IIP3=10.0)

amp.inputs[0] = 1e3
amp.update(None)
pos = amp.outputs[0]

amp.inputs[0] = -1e3
amp.update(None)
neg = amp.outputs[0]

self.assertAlmostEqual(pos, -neg)

def test_ip3_zero(self):
"""Zero input gives zero output with IP3."""
amp = RFAmplifier(gain=20.0, IIP3=10.0)
amp.inputs[0] = 0.0
amp.update(None)
self.assertAlmostEqual(amp.outputs[0], 0.0)

# -- helper ------------------------------------------------------------------------

def test_dbm_to_vpeak(self):
"""Verify dBm to peak voltage conversion."""
# 0 dBm = 1 mW into 50 Ohm -> V_rms = sqrt(0.001*50) = 0.2236
# V_peak = V_rms * sqrt(2) = 0.3162
v = _dbm_to_vpeak(0.0, 50.0)
self.assertAlmostEqual(v, np.sqrt(2.0 * 50.0 * 1e-3), places=6)

def test_dbm_to_vpeak_30dBm(self):
"""30 dBm = 1 W -> V_peak = sqrt(2*50*1) = 10.0 V."""
v = _dbm_to_vpeak(30.0, 50.0)
self.assertAlmostEqual(v, 10.0, places=4)


# RUN TESTS LOCALLY ====================================================================

if __name__ == '__main__':
unittest.main(verbosity=2)
Loading