From 893dc4814b1fe295006cfc36b27dd7334347cf51 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 4 Mar 2026 09:50:19 +0100 Subject: [PATCH 1/2] Add RFAmplifier and RFMixer blocks with tests --- src/pathsim_rf/__init__.py | 2 + src/pathsim_rf/amplifier.py | 65 +++++++++++++++++++++ src/pathsim_rf/mixer.py | 51 +++++++++++++++++ tests/test_amplifier.py | 111 ++++++++++++++++++++++++++++++++++++ tests/test_mixer.py | 106 ++++++++++++++++++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 src/pathsim_rf/amplifier.py create mode 100644 src/pathsim_rf/mixer.py create mode 100644 tests/test_amplifier.py create mode 100644 tests/test_mixer.py diff --git a/src/pathsim_rf/__init__.py b/src/pathsim_rf/__init__.py index 6019c5e..dec100a 100644 --- a/src/pathsim_rf/__init__.py +++ b/src/pathsim_rf/__init__.py @@ -13,6 +13,8 @@ __all__ = ["__version__"] from .transmission_line import * +from .amplifier import * +from .mixer import * try: from .network import * diff --git a/src/pathsim_rf/amplifier.py b/src/pathsim_rf/amplifier.py new file mode 100644 index 0000000..1d2a1fe --- /dev/null +++ b/src/pathsim_rf/amplifier.py @@ -0,0 +1,65 @@ +######################################################################################### +## +## RF Amplifier Block +## +######################################################################################### + +# IMPORTS =============================================================================== + +import numpy as np + +from pathsim.blocks.function import Function + + +# BLOCKS ================================================================================ + +class RFAmplifier(Function): + """Ideal RF amplifier with optional output saturation. + + In the linear regime the amplifier simply scales the input signal: + + .. math:: + + y(t) = G \\cdot x(t) + + When a saturation level is specified, soft compression is modelled + with a hyperbolic tangent: + + .. math:: + + y(t) = V_{\\mathrm{sat}} \\tanh\\!\\left(\\frac{G \\cdot x(t)}{V_{\\mathrm{sat}}}\\right) + + Parameters + ---------- + gain : float + Linear voltage gain (dimensionless). Default 10.0. + saturation : float or None + Output saturation amplitude. If *None* (default) the amplifier + operates in purely linear mode. + """ + + input_port_labels = { + "rf_in": 0, + } + + output_port_labels = { + "rf_out": 0, + } + + def __init__(self, gain=10.0, saturation=None): + + # input validation + if gain <= 0: + raise ValueError(f"'gain' must be positive but is {gain}") + if saturation is not None and saturation <= 0: + raise ValueError(f"'saturation' must be positive but is {saturation}") + + self.gain = gain + self.saturation = saturation + + super().__init__(func=self._eval) + + def _eval(self, rf_in): + if self.saturation is None: + return self.gain * rf_in + return self.saturation * np.tanh(self.gain * rf_in / self.saturation) diff --git a/src/pathsim_rf/mixer.py b/src/pathsim_rf/mixer.py new file mode 100644 index 0000000..9f724a8 --- /dev/null +++ b/src/pathsim_rf/mixer.py @@ -0,0 +1,51 @@ +######################################################################################### +## +## 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 + Linear conversion gain (dimensionless). Default 1.0. + """ + + input_port_labels = { + "rf": 0, + "lo": 1, + } + + output_port_labels = { + "if_out": 0, + } + + def __init__(self, conversion_gain=1.0): + + if conversion_gain <= 0: + raise ValueError( + f"'conversion_gain' must be positive but is {conversion_gain}" + ) + + self.conversion_gain = conversion_gain + + super().__init__(func=self._eval) + + def _eval(self, rf, lo): + return self.conversion_gain * rf * lo diff --git a/tests/test_amplifier.py b/tests/test_amplifier.py new file mode 100644 index 0000000..b165fe2 --- /dev/null +++ b/tests/test_amplifier.py @@ -0,0 +1,111 @@ +######################################################################################## +## +## TESTS FOR +## 'amplifier.py' +## +######################################################################################## + +# IMPORTS ============================================================================== + +import unittest +import numpy as np + +from pathsim_rf import RFAmplifier + + +# TESTS ================================================================================ + +class TestRFAmplifier(unittest.TestCase): + """Test the RFAmplifier block.""" + + def test_init_default(self): + """Test default initialization.""" + amp = RFAmplifier() + self.assertEqual(amp.gain, 10.0) + self.assertIsNone(amp.saturation) + + def test_init_custom(self): + """Test custom initialization.""" + amp = RFAmplifier(gain=20.0, saturation=5.0) + self.assertEqual(amp.gain, 20.0) + self.assertEqual(amp.saturation, 5.0) + + def test_init_validation(self): + """Test input validation.""" + with self.assertRaises(ValueError): + RFAmplifier(gain=0) + with self.assertRaises(ValueError): + RFAmplifier(gain=-1) + with self.assertRaises(ValueError): + RFAmplifier(saturation=0) + with self.assertRaises(ValueError): + RFAmplifier(saturation=-1) + + 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) + + def test_linear_gain(self): + """Linear mode: output = gain * input.""" + amp = RFAmplifier(gain=5.0) + amp.inputs[0] = 2.0 + amp.update(None) + self.assertAlmostEqual(amp.outputs[0], 10.0) + + def test_linear_negative_input(self): + """Linear mode works with negative inputs.""" + amp = RFAmplifier(gain=3.0) + amp.inputs[0] = -4.0 + amp.update(None) + self.assertAlmostEqual(amp.outputs[0], -12.0) + + def test_linear_zero_input(self): + """Zero input produces zero output.""" + amp = RFAmplifier(gain=10.0) + amp.inputs[0] = 0.0 + amp.update(None) + self.assertAlmostEqual(amp.outputs[0], 0.0) + + def test_saturation_small_signal(self): + """With saturation, small signals are approximately linear.""" + amp = RFAmplifier(gain=10.0, saturation=100.0) + amp.inputs[0] = 0.01 # small signal: gain*input/sat = 0.001 + amp.update(None) + # tanh(x) ≈ x for small x, so output ≈ gain * input + self.assertAlmostEqual(amp.outputs[0], 10.0 * 0.01, places=3) + + def test_saturation_large_signal(self): + """With saturation, large signals are clipped to saturation level.""" + amp = RFAmplifier(gain=100.0, saturation=5.0) + amp.inputs[0] = 1000.0 # heavily driven + amp.update(None) + # tanh(large) ≈ 1, so output ≈ saturation + self.assertAlmostEqual(amp.outputs[0], 5.0, places=3) + + def test_saturation_symmetry(self): + """Saturation is symmetric for positive and negative inputs.""" + amp = RFAmplifier(gain=100.0, saturation=5.0) + + amp.inputs[0] = 1000.0 + amp.update(None) + pos = amp.outputs[0] + + amp.inputs[0] = -1000.0 + amp.update(None) + neg = amp.outputs[0] + + self.assertAlmostEqual(pos, -neg) + + def test_saturation_zero(self): + """Zero input gives zero output even with saturation.""" + amp = RFAmplifier(gain=10.0, saturation=5.0) + amp.inputs[0] = 0.0 + amp.update(None) + self.assertAlmostEqual(amp.outputs[0], 0.0) + + +# RUN TESTS LOCALLY ==================================================================== + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_mixer.py b/tests/test_mixer.py new file mode 100644 index 0000000..4e3b6bf --- /dev/null +++ b/tests/test_mixer.py @@ -0,0 +1,106 @@ +######################################################################################## +## +## TESTS FOR +## 'mixer.py' +## +######################################################################################## + +# IMPORTS ============================================================================== + +import unittest +import numpy as np + +from pathsim_rf import RFMixer + + +# TESTS ================================================================================ + +class TestRFMixer(unittest.TestCase): + """Test the RFMixer block.""" + + def test_init_default(self): + """Test default initialization.""" + mx = RFMixer() + self.assertEqual(mx.conversion_gain, 1.0) + + def test_init_custom(self): + """Test custom initialization.""" + mx = RFMixer(conversion_gain=0.5) + self.assertEqual(mx.conversion_gain, 0.5) + + def test_init_validation(self): + """Test input validation.""" + with self.assertRaises(ValueError): + RFMixer(conversion_gain=0) + with self.assertRaises(ValueError): + RFMixer(conversion_gain=-1) + + def test_port_labels(self): + """Test port label definitions.""" + self.assertEqual(RFMixer.input_port_labels["rf"], 0) + self.assertEqual(RFMixer.input_port_labels["lo"], 1) + self.assertEqual(RFMixer.output_port_labels["if_out"], 0) + + def test_multiplication(self): + """Output is product of RF and LO signals.""" + mx = RFMixer() + mx.inputs[0] = 3.0 # rf + mx.inputs[1] = 4.0 # lo + mx.update(None) + self.assertAlmostEqual(mx.outputs[0], 12.0) + + def test_conversion_gain(self): + """Conversion gain scales the output.""" + mx = RFMixer(conversion_gain=2.0) + mx.inputs[0] = 3.0 + mx.inputs[1] = 5.0 + mx.update(None) + self.assertAlmostEqual(mx.outputs[0], 30.0) + + def test_zero_rf(self): + """Zero RF input gives zero output.""" + mx = RFMixer() + mx.inputs[0] = 0.0 + mx.inputs[1] = 5.0 + mx.update(None) + self.assertAlmostEqual(mx.outputs[0], 0.0) + + def test_zero_lo(self): + """Zero LO input gives zero output.""" + mx = RFMixer() + mx.inputs[0] = 3.0 + mx.inputs[1] = 0.0 + mx.update(None) + self.assertAlmostEqual(mx.outputs[0], 0.0) + + def test_negative_signals(self): + """Mixer handles negative signals correctly.""" + mx = RFMixer() + mx.inputs[0] = -2.0 + mx.inputs[1] = 3.0 + mx.update(None) + self.assertAlmostEqual(mx.outputs[0], -6.0) + + def test_sinusoidal_mixing(self): + """Verify frequency mixing with sinusoids (trig identity).""" + mx = RFMixer() + f_rf = 1e9 # 1 GHz + f_lo = 0.9e9 # 900 MHz + t = 1e-10 # sample time + + rf_val = np.cos(2 * np.pi * f_rf * t) + lo_val = np.cos(2 * np.pi * f_lo * t) + + mx.inputs[0] = rf_val + mx.inputs[1] = lo_val + mx.update(None) + + # cos(a)*cos(b) = 0.5*[cos(a-b) + cos(a+b)] + expected = rf_val * lo_val + self.assertAlmostEqual(mx.outputs[0], expected) + + +# RUN TESTS LOCALLY ==================================================================== + +if __name__ == '__main__': + unittest.main(verbosity=2) From 6da83564f81a96df7f196d1feac48e62e0c377e2 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 4 Mar 2026 10:02:12 +0100 Subject: [PATCH 2/2] Use standard RF conventions: gain in dB, P1dB/IIP3 in dBm, add IP3 nonlinearity model --- src/pathsim_rf/amplifier.py | 83 +++++++++++++++++----- src/pathsim_rf/mixer.py | 19 +++-- tests/test_amplifier.py | 134 +++++++++++++++++++++++++----------- tests/test_mixer.py | 62 +++++++++++------ 4 files changed, 210 insertions(+), 88 deletions(-) diff --git a/src/pathsim_rf/amplifier.py b/src/pathsim_rf/amplifier.py index 1d2a1fe..c6344a6 100644 --- a/src/pathsim_rf/amplifier.py +++ b/src/pathsim_rf/amplifier.py @@ -11,31 +11,50 @@ 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): - """Ideal RF amplifier with optional output saturation. + """RF amplifier with optional nonlinearity (IP3 / P1dB compression). - In the linear regime the amplifier simply scales the input signal: + In the linear regime the amplifier scales the input signal by the + voltage gain derived from the specified gain in dB: .. math:: - y(t) = G \\cdot x(t) + y(t) = a_1 \\cdot x(t) - When a saturation level is specified, soft compression is modelled - with a hyperbolic tangent: + When nonlinearity is specified via IIP3 or P1dB, a third-order + polynomial model is used: .. math:: - y(t) = V_{\\mathrm{sat}} \\tanh\\!\\left(\\frac{G \\cdot x(t)}{V_{\\mathrm{sat}}}\\right) + 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 - Linear voltage gain (dimensionless). Default 10.0. - saturation : float or None - Output saturation amplitude. If *None* (default) the amplifier - operates in purely linear mode. + 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 = { @@ -46,20 +65,46 @@ class RFAmplifier(Function): "rf_out": 0, } - def __init__(self, gain=10.0, saturation=None): + def __init__(self, gain=20.0, P1dB=None, IIP3=None, Z0=50.0): # input validation - if gain <= 0: - raise ValueError(f"'gain' must be positive but is {gain}") - if saturation is not None and saturation <= 0: - raise ValueError(f"'saturation' must be positive but is {saturation}") + if Z0 <= 0: + raise ValueError(f"'Z0' must be positive but is {Z0}") + # store user-facing parameters self.gain = gain - self.saturation = saturation + 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): - if self.saturation is None: - return self.gain * rf_in - return self.saturation * np.tanh(self.gain * rf_in / self.saturation) + 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 diff --git a/src/pathsim_rf/mixer.py b/src/pathsim_rf/mixer.py index 9f724a8..9db86b3 100644 --- a/src/pathsim_rf/mixer.py +++ b/src/pathsim_rf/mixer.py @@ -24,7 +24,10 @@ class RFMixer(Function): Parameters ---------- conversion_gain : float - Linear conversion gain (dimensionless). Default 1.0. + 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 = { @@ -36,16 +39,18 @@ class RFMixer(Function): "if_out": 0, } - def __init__(self, conversion_gain=1.0): + def __init__(self, conversion_gain=0.0, Z0=50.0): - if conversion_gain <= 0: - raise ValueError( - f"'conversion_gain' must be positive but is {conversion_gain}" - ) + 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.conversion_gain * rf * lo + return self._gain_linear * rf * lo diff --git a/tests/test_amplifier.py b/tests/test_amplifier.py index b165fe2..54f5e78 100644 --- a/tests/test_amplifier.py +++ b/tests/test_amplifier.py @@ -11,6 +11,7 @@ import numpy as np from pathsim_rf import RFAmplifier +from pathsim_rf.amplifier import _dbm_to_vpeak # TESTS ================================================================================ @@ -18,92 +19,143 @@ class TestRFAmplifier(unittest.TestCase): """Test the RFAmplifier block.""" + # -- initialisation ---------------------------------------------------------------- + def test_init_default(self): """Test default initialization.""" amp = RFAmplifier() - self.assertEqual(amp.gain, 10.0) - self.assertIsNone(amp.saturation) + 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.""" - amp = RFAmplifier(gain=20.0, saturation=5.0) - self.assertEqual(amp.gain, 20.0) - self.assertEqual(amp.saturation, 5.0) + """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(gain=0) - with self.assertRaises(ValueError): - RFAmplifier(gain=-1) + RFAmplifier(Z0=0) with self.assertRaises(ValueError): - RFAmplifier(saturation=0) - with self.assertRaises(ValueError): - RFAmplifier(saturation=-1) + 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) - def test_linear_gain(self): - """Linear mode: output = gain * input.""" - amp = RFAmplifier(gain=5.0) - amp.inputs[0] = 2.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], 10.0) + 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=3.0) - amp.inputs[0] = -4.0 + amp = RFAmplifier(gain=20.0) + amp.inputs[0] = -0.05 amp.update(None) - self.assertAlmostEqual(amp.outputs[0], -12.0) + self.assertAlmostEqual(amp.outputs[0], -0.5) def test_linear_zero_input(self): """Zero input produces zero output.""" - amp = RFAmplifier(gain=10.0) + amp = RFAmplifier(gain=20.0) amp.inputs[0] = 0.0 amp.update(None) self.assertAlmostEqual(amp.outputs[0], 0.0) - def test_saturation_small_signal(self): - """With saturation, small signals are approximately linear.""" - amp = RFAmplifier(gain=10.0, saturation=100.0) - amp.inputs[0] = 0.01 # small signal: gain*input/sat = 0.001 + # -- 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) - # tanh(x) ≈ x for small x, so output ≈ gain * input - self.assertAlmostEqual(amp.outputs[0], 10.0 * 0.01, places=3) + linear_out = amp._a1 * x_in + self.assertLess(amp.outputs[0], linear_out) - def test_saturation_large_signal(self): - """With saturation, large signals are clipped to saturation level.""" - amp = RFAmplifier(gain=100.0, saturation=5.0) - amp.inputs[0] = 1000.0 # heavily driven + 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) - # tanh(large) ≈ 1, so output ≈ saturation - self.assertAlmostEqual(amp.outputs[0], 5.0, places=3) + self.assertAlmostEqual(amp.outputs[0], amp._y_sat) - def test_saturation_symmetry(self): - """Saturation is symmetric for positive and negative inputs.""" - amp = RFAmplifier(gain=100.0, saturation=5.0) + def test_ip3_symmetry(self): + """Nonlinear response is odd-symmetric.""" + amp = RFAmplifier(gain=20.0, IIP3=10.0) - amp.inputs[0] = 1000.0 + amp.inputs[0] = 1e3 amp.update(None) pos = amp.outputs[0] - amp.inputs[0] = -1000.0 + amp.inputs[0] = -1e3 amp.update(None) neg = amp.outputs[0] self.assertAlmostEqual(pos, -neg) - def test_saturation_zero(self): - """Zero input gives zero output even with saturation.""" - amp = RFAmplifier(gain=10.0, saturation=5.0) + 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 ==================================================================== diff --git a/tests/test_mixer.py b/tests/test_mixer.py index 4e3b6bf..4d70d7a 100644 --- a/tests/test_mixer.py +++ b/tests/test_mixer.py @@ -18,22 +18,33 @@ class TestRFMixer(unittest.TestCase): """Test the RFMixer block.""" + # -- initialisation ---------------------------------------------------------------- + def test_init_default(self): """Test default initialization.""" mx = RFMixer() - self.assertEqual(mx.conversion_gain, 1.0) + self.assertEqual(mx.conversion_gain, 0.0) + self.assertEqual(mx.Z0, 50.0) + self.assertAlmostEqual(mx._gain_linear, 1.0) def test_init_custom(self): """Test custom initialization.""" - mx = RFMixer(conversion_gain=0.5) - self.assertEqual(mx.conversion_gain, 0.5) + mx = RFMixer(conversion_gain=-6.0, Z0=75.0) + self.assertEqual(mx.conversion_gain, -6.0) + self.assertEqual(mx.Z0, 75.0) + + def test_init_negative_gain(self): + """Negative conversion gain (loss) is valid.""" + mx = RFMixer(conversion_gain=-10.0) + expected = 10.0 ** (-10.0 / 20.0) + self.assertAlmostEqual(mx._gain_linear, expected) def test_init_validation(self): """Test input validation.""" with self.assertRaises(ValueError): - RFMixer(conversion_gain=0) + RFMixer(Z0=0) with self.assertRaises(ValueError): - RFMixer(conversion_gain=-1) + RFMixer(Z0=-50) def test_port_labels(self): """Test port label definitions.""" @@ -41,21 +52,31 @@ def test_port_labels(self): self.assertEqual(RFMixer.input_port_labels["lo"], 1) self.assertEqual(RFMixer.output_port_labels["if_out"], 0) - def test_multiplication(self): - """Output is product of RF and LO signals.""" - mx = RFMixer() - mx.inputs[0] = 3.0 # rf - mx.inputs[1] = 4.0 # lo + # -- functionality ----------------------------------------------------------------- + + def test_unity_gain_multiplication(self): + """0 dB conversion gain: output is product of RF and LO.""" + mx = RFMixer(conversion_gain=0.0) + mx.inputs[0] = 3.0 + mx.inputs[1] = 4.0 mx.update(None) self.assertAlmostEqual(mx.outputs[0], 12.0) - def test_conversion_gain(self): - """Conversion gain scales the output.""" - mx = RFMixer(conversion_gain=2.0) - mx.inputs[0] = 3.0 - mx.inputs[1] = 5.0 + def test_conversion_gain_dB(self): + """Conversion gain in dB scales the product.""" + mx = RFMixer(conversion_gain=20.0) # 10x voltage + mx.inputs[0] = 1.0 + mx.inputs[1] = 2.0 + mx.update(None) + self.assertAlmostEqual(mx.outputs[0], 10.0 * 1.0 * 2.0) + + def test_conversion_loss(self): + """Negative dB represents conversion loss.""" + mx = RFMixer(conversion_gain=-20.0) # 0.1x voltage + mx.inputs[0] = 5.0 + mx.inputs[1] = 2.0 mx.update(None) - self.assertAlmostEqual(mx.outputs[0], 30.0) + self.assertAlmostEqual(mx.outputs[0], 0.1 * 5.0 * 2.0, places=4) def test_zero_rf(self): """Zero RF input gives zero output.""" @@ -82,11 +103,11 @@ def test_negative_signals(self): self.assertAlmostEqual(mx.outputs[0], -6.0) def test_sinusoidal_mixing(self): - """Verify frequency mixing with sinusoids (trig identity).""" + """Verify frequency mixing with sinusoids.""" mx = RFMixer() - f_rf = 1e9 # 1 GHz - f_lo = 0.9e9 # 900 MHz - t = 1e-10 # sample time + f_rf = 1e9 + f_lo = 0.9e9 + t = 1e-10 rf_val = np.cos(2 * np.pi * f_rf * t) lo_val = np.cos(2 * np.pi * f_lo * t) @@ -95,7 +116,6 @@ def test_sinusoidal_mixing(self): mx.inputs[1] = lo_val mx.update(None) - # cos(a)*cos(b) = 0.5*[cos(a-b) + cos(a+b)] expected = rf_val * lo_val self.assertAlmostEqual(mx.outputs[0], expected)