Skip to content

Commit 6da8356

Browse files
committed
Use standard RF conventions: gain in dB, P1dB/IIP3 in dBm, add IP3 nonlinearity model
1 parent 893dc48 commit 6da8356

4 files changed

Lines changed: 210 additions & 88 deletions

File tree

src/pathsim_rf/amplifier.py

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,50 @@
1111
from pathsim.blocks.function import Function
1212

1313

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+
1422
# BLOCKS ================================================================================
1523

1624
class RFAmplifier(Function):
17-
"""Ideal RF amplifier with optional output saturation.
25+
"""RF amplifier with optional nonlinearity (IP3 / P1dB compression).
1826
19-
In the linear regime the amplifier simply scales the input signal:
27+
In the linear regime the amplifier scales the input signal by the
28+
voltage gain derived from the specified gain in dB:
2029
2130
.. math::
2231
23-
y(t) = G \\cdot x(t)
32+
y(t) = a_1 \\cdot x(t)
2433
25-
When a saturation level is specified, soft compression is modelled
26-
with a hyperbolic tangent:
34+
When nonlinearity is specified via IIP3 or P1dB, a third-order
35+
polynomial model is used:
2736
2837
.. math::
2938
30-
y(t) = V_{\\mathrm{sat}} \\tanh\\!\\left(\\frac{G \\cdot x(t)}{V_{\\mathrm{sat}}}\\right)
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.
3145
3246
Parameters
3347
----------
3448
gain : float
35-
Linear voltage gain (dimensionless). Default 10.0.
36-
saturation : float or None
37-
Output saturation amplitude. If *None* (default) the amplifier
38-
operates in purely linear mode.
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.
3958
"""
4059

4160
input_port_labels = {
@@ -46,20 +65,46 @@ class RFAmplifier(Function):
4665
"rf_out": 0,
4766
}
4867

49-
def __init__(self, gain=10.0, saturation=None):
68+
def __init__(self, gain=20.0, P1dB=None, IIP3=None, Z0=50.0):
5069

5170
# input validation
52-
if gain <= 0:
53-
raise ValueError(f"'gain' must be positive but is {gain}")
54-
if saturation is not None and saturation <= 0:
55-
raise ValueError(f"'saturation' must be positive but is {saturation}")
71+
if Z0 <= 0:
72+
raise ValueError(f"'Z0' must be positive but is {Z0}")
5673

74+
# store user-facing parameters
5775
self.gain = gain
58-
self.saturation = saturation
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
59103

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

62106
def _eval(self, rf_in):
63-
if self.saturation is None:
64-
return self.gain * rf_in
65-
return self.saturation * np.tanh(self.gain * rf_in / self.saturation)
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: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ class RFMixer(Function):
2424
Parameters
2525
----------
2626
conversion_gain : float
27-
Linear conversion gain (dimensionless). Default 1.0.
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.
2831
"""
2932

3033
input_port_labels = {
@@ -36,16 +39,18 @@ class RFMixer(Function):
3639
"if_out": 0,
3740
}
3841

39-
def __init__(self, conversion_gain=1.0):
42+
def __init__(self, conversion_gain=0.0, Z0=50.0):
4043

41-
if conversion_gain <= 0:
42-
raise ValueError(
43-
f"'conversion_gain' must be positive but is {conversion_gain}"
44-
)
44+
if Z0 <= 0:
45+
raise ValueError(f"'Z0' must be positive but is {Z0}")
4546

4647
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)
4752

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

5055
def _eval(self, rf, lo):
51-
return self.conversion_gain * rf * lo
56+
return self._gain_linear * rf * lo

tests/test_amplifier.py

Lines changed: 93 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -11,99 +11,151 @@
1111
import numpy as np
1212

1313
from pathsim_rf import RFAmplifier
14+
from pathsim_rf.amplifier import _dbm_to_vpeak
1415

1516

1617
# TESTS ================================================================================
1718

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

22+
# -- initialisation ----------------------------------------------------------------
23+
2124
def test_init_default(self):
2225
"""Test default initialization."""
2326
amp = RFAmplifier()
24-
self.assertEqual(amp.gain, 10.0)
25-
self.assertIsNone(amp.saturation)
27+
self.assertEqual(amp.gain, 20.0)
28+
self.assertIsNone(amp.IIP3)
29+
self.assertIsNone(amp.P1dB)
30+
self.assertEqual(amp.Z0, 50.0)
2631

2732
def test_init_custom(self):
28-
"""Test custom initialization."""
29-
amp = RFAmplifier(gain=20.0, saturation=5.0)
30-
self.assertEqual(amp.gain, 20.0)
31-
self.assertEqual(amp.saturation, 5.0)
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)
3251

3352
def test_init_validation(self):
3453
"""Test input validation."""
3554
with self.assertRaises(ValueError):
36-
RFAmplifier(gain=0)
37-
with self.assertRaises(ValueError):
38-
RFAmplifier(gain=-1)
55+
RFAmplifier(Z0=0)
3956
with self.assertRaises(ValueError):
40-
RFAmplifier(saturation=0)
41-
with self.assertRaises(ValueError):
42-
RFAmplifier(saturation=-1)
57+
RFAmplifier(Z0=-50)
4358

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

49-
def test_linear_gain(self):
50-
"""Linear mode: output = gain * input."""
51-
amp = RFAmplifier(gain=5.0)
52-
amp.inputs[0] = 2.0
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
5370
amp.update(None)
54-
self.assertAlmostEqual(amp.outputs[0], 10.0)
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)
5580

5681
def test_linear_negative_input(self):
5782
"""Linear mode works with negative inputs."""
58-
amp = RFAmplifier(gain=3.0)
59-
amp.inputs[0] = -4.0
83+
amp = RFAmplifier(gain=20.0)
84+
amp.inputs[0] = -0.05
6085
amp.update(None)
61-
self.assertAlmostEqual(amp.outputs[0], -12.0)
86+
self.assertAlmostEqual(amp.outputs[0], -0.5)
6287

6388
def test_linear_zero_input(self):
6489
"""Zero input produces zero output."""
65-
amp = RFAmplifier(gain=10.0)
90+
amp = RFAmplifier(gain=20.0)
6691
amp.inputs[0] = 0.0
6792
amp.update(None)
6893
self.assertAlmostEqual(amp.outputs[0], 0.0)
6994

70-
def test_saturation_small_signal(self):
71-
"""With saturation, small signals are approximately linear."""
72-
amp = RFAmplifier(gain=10.0, saturation=100.0)
73-
amp.inputs[0] = 0.01 # small signal: gain*input/sat = 0.001
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
74113
amp.update(None)
75-
# tanh(x) ≈ x for small x, so output ≈ gain * input
76-
self.assertAlmostEqual(amp.outputs[0], 10.0 * 0.01, places=3)
114+
linear_out = amp._a1 * x_in
115+
self.assertLess(amp.outputs[0], linear_out)
77116

78-
def test_saturation_large_signal(self):
79-
"""With saturation, large signals are clipped to saturation level."""
80-
amp = RFAmplifier(gain=100.0, saturation=5.0)
81-
amp.inputs[0] = 1000.0 # heavily driven
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
82121
amp.update(None)
83-
# tanh(large) ≈ 1, so output ≈ saturation
84-
self.assertAlmostEqual(amp.outputs[0], 5.0, places=3)
122+
self.assertAlmostEqual(amp.outputs[0], amp._y_sat)
85123

86-
def test_saturation_symmetry(self):
87-
"""Saturation is symmetric for positive and negative inputs."""
88-
amp = RFAmplifier(gain=100.0, saturation=5.0)
124+
def test_ip3_symmetry(self):
125+
"""Nonlinear response is odd-symmetric."""
126+
amp = RFAmplifier(gain=20.0, IIP3=10.0)
89127

90-
amp.inputs[0] = 1000.0
128+
amp.inputs[0] = 1e3
91129
amp.update(None)
92130
pos = amp.outputs[0]
93131

94-
amp.inputs[0] = -1000.0
132+
amp.inputs[0] = -1e3
95133
amp.update(None)
96134
neg = amp.outputs[0]
97135

98136
self.assertAlmostEqual(pos, -neg)
99137

100-
def test_saturation_zero(self):
101-
"""Zero input gives zero output even with saturation."""
102-
amp = RFAmplifier(gain=10.0, saturation=5.0)
138+
def test_ip3_zero(self):
139+
"""Zero input gives zero output with IP3."""
140+
amp = RFAmplifier(gain=20.0, IIP3=10.0)
103141
amp.inputs[0] = 0.0
104142
amp.update(None)
105143
self.assertAlmostEqual(amp.outputs[0], 0.0)
106144

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+
107159

108160
# RUN TESTS LOCALLY ====================================================================
109161

0 commit comments

Comments
 (0)