Skip to content

Commit 5f8cb87

Browse files
authored
Merge pull request #2 from pathsim/feature/rf-active-blocks
Add RFAmplifier and RFMixer blocks
2 parents fb8f92a + 6da8356 commit 5f8cb87

5 files changed

Lines changed: 457 additions & 0 deletions

File tree

src/pathsim_rf/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
__all__ = ["__version__"]
1414

1515
from .transmission_line import *
16+
from .amplifier import *
17+
from .mixer import *
1618

1719
try:
1820
from .network import *

src/pathsim_rf/amplifier.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#########################################################################################
2+
##
3+
## RF Amplifier Block
4+
##
5+
#########################################################################################
6+
7+
# IMPORTS ===============================================================================
8+
9+
import numpy as np
10+
11+
from pathsim.blocks.function import Function
12+
13+
14+
# HELPERS ===============================================================================
15+
16+
def _dbm_to_vpeak(p_dbm, z0):
17+
"""Convert power in dBm to peak voltage amplitude."""
18+
p_watts = 10.0 ** (p_dbm / 10.0) * 1e-3
19+
return np.sqrt(2.0 * z0 * p_watts)
20+
21+
22+
# BLOCKS ================================================================================
23+
24+
class RFAmplifier(Function):
25+
"""RF amplifier with optional nonlinearity (IP3 / P1dB compression).
26+
27+
In the linear regime the amplifier scales the input signal by the
28+
voltage gain derived from the specified gain in dB:
29+
30+
.. math::
31+
32+
y(t) = a_1 \\cdot x(t)
33+
34+
When nonlinearity is specified via IIP3 or P1dB, a third-order
35+
polynomial model is used:
36+
37+
.. math::
38+
39+
y(t) = a_1 x(t) + a_3 x^3(t)
40+
41+
where :math:`a_3 = -a_1 / A_{\\mathrm{IIP3}}^2` and
42+
:math:`A_{\\mathrm{IIP3}}` is the input-referred IP3 voltage
43+
amplitude. The output is hard-clipped at the gain compression
44+
peak to prevent unphysical sign reversal.
45+
46+
Parameters
47+
----------
48+
gain : float
49+
Small-signal voltage gain [dB]. Default 20.0.
50+
P1dB : float or None
51+
Input-referred 1 dB compression point [dBm]. If given without
52+
*IIP3*, the intercept is estimated as IIP3 = P1dB + 9.6 dB.
53+
IIP3 : float or None
54+
Input-referred third-order intercept point [dBm]. Takes
55+
precedence over *P1dB* if both are given.
56+
Z0 : float
57+
Reference impedance [Ohm]. Default 50.0.
58+
"""
59+
60+
input_port_labels = {
61+
"rf_in": 0,
62+
}
63+
64+
output_port_labels = {
65+
"rf_out": 0,
66+
}
67+
68+
def __init__(self, gain=20.0, P1dB=None, IIP3=None, Z0=50.0):
69+
70+
# input validation
71+
if Z0 <= 0:
72+
raise ValueError(f"'Z0' must be positive but is {Z0}")
73+
74+
# store user-facing parameters
75+
self.gain = gain
76+
self.Z0 = Z0
77+
78+
# linear voltage gain
79+
self._a1 = 10.0 ** (gain / 20.0)
80+
81+
# resolve nonlinearity specification
82+
if IIP3 is not None:
83+
self.IIP3 = float(IIP3)
84+
self.P1dB = self.IIP3 - 9.6
85+
elif P1dB is not None:
86+
self.P1dB = float(P1dB)
87+
self.IIP3 = self.P1dB + 9.6
88+
else:
89+
self.IIP3 = None
90+
self.P1dB = None
91+
92+
# derive polynomial coefficients
93+
if self.IIP3 is not None:
94+
A_iip3 = _dbm_to_vpeak(self.IIP3, Z0)
95+
self._a3 = -self._a1 / A_iip3 ** 2
96+
# clip at gain compression peak (dy/dx = 0)
97+
self._x_sat = A_iip3 / np.sqrt(3.0)
98+
self._y_sat = 2.0 * self._a1 * A_iip3 / (3.0 * np.sqrt(3.0))
99+
else:
100+
self._a3 = 0.0
101+
self._x_sat = None
102+
self._y_sat = None
103+
104+
super().__init__(func=self._eval)
105+
106+
def _eval(self, rf_in):
107+
x = rf_in
108+
if self._x_sat is not None and abs(x) > self._x_sat:
109+
return np.copysign(self._y_sat, x)
110+
return self._a1 * x + self._a3 * x ** 3

src/pathsim_rf/mixer.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#########################################################################################
2+
##
3+
## RF Mixer Block
4+
##
5+
#########################################################################################
6+
7+
# IMPORTS ===============================================================================
8+
9+
from pathsim.blocks.function import Function
10+
11+
12+
# BLOCKS ================================================================================
13+
14+
class RFMixer(Function):
15+
"""Ideal RF mixer (frequency converter).
16+
17+
Performs time-domain multiplication of the RF and local-oscillator
18+
(LO) signals, which corresponds to frequency translation:
19+
20+
.. math::
21+
22+
y(t) = G_{\\mathrm{conv}} \\cdot x_{\\mathrm{RF}}(t) \\cdot x_{\\mathrm{LO}}(t)
23+
24+
Parameters
25+
----------
26+
conversion_gain : float
27+
Conversion gain [dB]. Default 0.0. Negative values represent
28+
conversion loss (typical for passive mixers).
29+
Z0 : float
30+
Reference impedance [Ohm]. Default 50.0.
31+
"""
32+
33+
input_port_labels = {
34+
"rf": 0,
35+
"lo": 1,
36+
}
37+
38+
output_port_labels = {
39+
"if_out": 0,
40+
}
41+
42+
def __init__(self, conversion_gain=0.0, Z0=50.0):
43+
44+
if Z0 <= 0:
45+
raise ValueError(f"'Z0' must be positive but is {Z0}")
46+
47+
self.conversion_gain = conversion_gain
48+
self.Z0 = Z0
49+
50+
# linear voltage gain (can be < 1 for conversion loss)
51+
self._gain_linear = 10.0 ** (conversion_gain / 20.0)
52+
53+
super().__init__(func=self._eval)
54+
55+
def _eval(self, rf, lo):
56+
return self._gain_linear * rf * lo

tests/test_amplifier.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
########################################################################################
2+
##
3+
## TESTS FOR
4+
## 'amplifier.py'
5+
##
6+
########################################################################################
7+
8+
# IMPORTS ==============================================================================
9+
10+
import unittest
11+
import numpy as np
12+
13+
from pathsim_rf import RFAmplifier
14+
from pathsim_rf.amplifier import _dbm_to_vpeak
15+
16+
17+
# TESTS ================================================================================
18+
19+
class TestRFAmplifier(unittest.TestCase):
20+
"""Test the RFAmplifier block."""
21+
22+
# -- initialisation ----------------------------------------------------------------
23+
24+
def test_init_default(self):
25+
"""Test default initialization."""
26+
amp = RFAmplifier()
27+
self.assertEqual(amp.gain, 20.0)
28+
self.assertIsNone(amp.IIP3)
29+
self.assertIsNone(amp.P1dB)
30+
self.assertEqual(amp.Z0, 50.0)
31+
32+
def test_init_custom(self):
33+
"""Test custom initialization with IIP3."""
34+
amp = RFAmplifier(gain=15.0, IIP3=10.0, Z0=75.0)
35+
self.assertEqual(amp.gain, 15.0)
36+
self.assertEqual(amp.IIP3, 10.0)
37+
self.assertAlmostEqual(amp.P1dB, 10.0 - 9.6)
38+
self.assertEqual(amp.Z0, 75.0)
39+
40+
def test_init_P1dB_derives_IIP3(self):
41+
"""P1dB without IIP3 derives IIP3 = P1dB + 9.6."""
42+
amp = RFAmplifier(P1dB=0.0)
43+
self.assertAlmostEqual(amp.IIP3, 9.6)
44+
self.assertAlmostEqual(amp.P1dB, 0.0)
45+
46+
def test_IIP3_takes_precedence(self):
47+
"""IIP3 takes precedence over P1dB when both given."""
48+
amp = RFAmplifier(P1dB=0.0, IIP3=15.0)
49+
self.assertEqual(amp.IIP3, 15.0)
50+
self.assertAlmostEqual(amp.P1dB, 15.0 - 9.6)
51+
52+
def test_init_validation(self):
53+
"""Test input validation."""
54+
with self.assertRaises(ValueError):
55+
RFAmplifier(Z0=0)
56+
with self.assertRaises(ValueError):
57+
RFAmplifier(Z0=-50)
58+
59+
def test_port_labels(self):
60+
"""Test port label definitions."""
61+
self.assertEqual(RFAmplifier.input_port_labels["rf_in"], 0)
62+
self.assertEqual(RFAmplifier.output_port_labels["rf_out"], 0)
63+
64+
# -- linear mode -------------------------------------------------------------------
65+
66+
def test_linear_gain_dB(self):
67+
"""20 dB gain = voltage factor of 10."""
68+
amp = RFAmplifier(gain=20.0)
69+
amp.inputs[0] = 0.1
70+
amp.update(None)
71+
self.assertAlmostEqual(amp.outputs[0], 1.0)
72+
73+
def test_linear_6dB(self):
74+
"""6 dB gain ≈ voltage factor of ~2."""
75+
amp = RFAmplifier(gain=6.0)
76+
amp.inputs[0] = 1.0
77+
amp.update(None)
78+
expected = 10.0 ** (6.0 / 20.0) # 1.9953
79+
self.assertAlmostEqual(amp.outputs[0], expected, places=4)
80+
81+
def test_linear_negative_input(self):
82+
"""Linear mode works with negative inputs."""
83+
amp = RFAmplifier(gain=20.0)
84+
amp.inputs[0] = -0.05
85+
amp.update(None)
86+
self.assertAlmostEqual(amp.outputs[0], -0.5)
87+
88+
def test_linear_zero_input(self):
89+
"""Zero input produces zero output."""
90+
amp = RFAmplifier(gain=20.0)
91+
amp.inputs[0] = 0.0
92+
amp.update(None)
93+
self.assertAlmostEqual(amp.outputs[0], 0.0)
94+
95+
# -- nonlinear (IP3) mode ----------------------------------------------------------
96+
97+
def test_ip3_small_signal_linear(self):
98+
"""Small signals are approximately linear even with IP3."""
99+
amp = RFAmplifier(gain=20.0, IIP3=10.0)
100+
# tiny input well below compression
101+
amp.inputs[0] = 1e-6
102+
amp.update(None)
103+
expected_linear = amp._a1 * 1e-6
104+
self.assertAlmostEqual(amp.outputs[0], expected_linear, places=10)
105+
106+
def test_ip3_compression(self):
107+
"""Near IP3 the output compresses below linear gain."""
108+
amp = RFAmplifier(gain=20.0, IIP3=10.0)
109+
A_iip3 = _dbm_to_vpeak(10.0, 50.0)
110+
# drive at half the IIP3 voltage — should see compression
111+
x_in = A_iip3 * 0.5
112+
amp.inputs[0] = x_in
113+
amp.update(None)
114+
linear_out = amp._a1 * x_in
115+
self.assertLess(amp.outputs[0], linear_out)
116+
117+
def test_ip3_saturation_clip(self):
118+
"""Output is clipped at the gain compression peak for large signals."""
119+
amp = RFAmplifier(gain=20.0, IIP3=10.0)
120+
amp.inputs[0] = 1e3 # way beyond saturation
121+
amp.update(None)
122+
self.assertAlmostEqual(amp.outputs[0], amp._y_sat)
123+
124+
def test_ip3_symmetry(self):
125+
"""Nonlinear response is odd-symmetric."""
126+
amp = RFAmplifier(gain=20.0, IIP3=10.0)
127+
128+
amp.inputs[0] = 1e3
129+
amp.update(None)
130+
pos = amp.outputs[0]
131+
132+
amp.inputs[0] = -1e3
133+
amp.update(None)
134+
neg = amp.outputs[0]
135+
136+
self.assertAlmostEqual(pos, -neg)
137+
138+
def test_ip3_zero(self):
139+
"""Zero input gives zero output with IP3."""
140+
amp = RFAmplifier(gain=20.0, IIP3=10.0)
141+
amp.inputs[0] = 0.0
142+
amp.update(None)
143+
self.assertAlmostEqual(amp.outputs[0], 0.0)
144+
145+
# -- helper ------------------------------------------------------------------------
146+
147+
def test_dbm_to_vpeak(self):
148+
"""Verify dBm to peak voltage conversion."""
149+
# 0 dBm = 1 mW into 50 Ohm -> V_rms = sqrt(0.001*50) = 0.2236
150+
# V_peak = V_rms * sqrt(2) = 0.3162
151+
v = _dbm_to_vpeak(0.0, 50.0)
152+
self.assertAlmostEqual(v, np.sqrt(2.0 * 50.0 * 1e-3), places=6)
153+
154+
def test_dbm_to_vpeak_30dBm(self):
155+
"""30 dBm = 1 W -> V_peak = sqrt(2*50*1) = 10.0 V."""
156+
v = _dbm_to_vpeak(30.0, 50.0)
157+
self.assertAlmostEqual(v, 10.0, places=4)
158+
159+
160+
# RUN TESTS LOCALLY ====================================================================
161+
162+
if __name__ == '__main__':
163+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)