Skip to content

Commit 893dc48

Browse files
committed
Add RFAmplifier and RFMixer blocks with tests
1 parent f5735a2 commit 893dc48

5 files changed

Lines changed: 335 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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
# BLOCKS ================================================================================
15+
16+
class RFAmplifier(Function):
17+
"""Ideal RF amplifier with optional output saturation.
18+
19+
In the linear regime the amplifier simply scales the input signal:
20+
21+
.. math::
22+
23+
y(t) = G \\cdot x(t)
24+
25+
When a saturation level is specified, soft compression is modelled
26+
with a hyperbolic tangent:
27+
28+
.. math::
29+
30+
y(t) = V_{\\mathrm{sat}} \\tanh\\!\\left(\\frac{G \\cdot x(t)}{V_{\\mathrm{sat}}}\\right)
31+
32+
Parameters
33+
----------
34+
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.
39+
"""
40+
41+
input_port_labels = {
42+
"rf_in": 0,
43+
}
44+
45+
output_port_labels = {
46+
"rf_out": 0,
47+
}
48+
49+
def __init__(self, gain=10.0, saturation=None):
50+
51+
# 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}")
56+
57+
self.gain = gain
58+
self.saturation = saturation
59+
60+
super().__init__(func=self._eval)
61+
62+
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)

src/pathsim_rf/mixer.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
Linear conversion gain (dimensionless). Default 1.0.
28+
"""
29+
30+
input_port_labels = {
31+
"rf": 0,
32+
"lo": 1,
33+
}
34+
35+
output_port_labels = {
36+
"if_out": 0,
37+
}
38+
39+
def __init__(self, conversion_gain=1.0):
40+
41+
if conversion_gain <= 0:
42+
raise ValueError(
43+
f"'conversion_gain' must be positive but is {conversion_gain}"
44+
)
45+
46+
self.conversion_gain = conversion_gain
47+
48+
super().__init__(func=self._eval)
49+
50+
def _eval(self, rf, lo):
51+
return self.conversion_gain * rf * lo

tests/test_amplifier.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
15+
16+
# TESTS ================================================================================
17+
18+
class TestRFAmplifier(unittest.TestCase):
19+
"""Test the RFAmplifier block."""
20+
21+
def test_init_default(self):
22+
"""Test default initialization."""
23+
amp = RFAmplifier()
24+
self.assertEqual(amp.gain, 10.0)
25+
self.assertIsNone(amp.saturation)
26+
27+
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)
32+
33+
def test_init_validation(self):
34+
"""Test input validation."""
35+
with self.assertRaises(ValueError):
36+
RFAmplifier(gain=0)
37+
with self.assertRaises(ValueError):
38+
RFAmplifier(gain=-1)
39+
with self.assertRaises(ValueError):
40+
RFAmplifier(saturation=0)
41+
with self.assertRaises(ValueError):
42+
RFAmplifier(saturation=-1)
43+
44+
def test_port_labels(self):
45+
"""Test port label definitions."""
46+
self.assertEqual(RFAmplifier.input_port_labels["rf_in"], 0)
47+
self.assertEqual(RFAmplifier.output_port_labels["rf_out"], 0)
48+
49+
def test_linear_gain(self):
50+
"""Linear mode: output = gain * input."""
51+
amp = RFAmplifier(gain=5.0)
52+
amp.inputs[0] = 2.0
53+
amp.update(None)
54+
self.assertAlmostEqual(amp.outputs[0], 10.0)
55+
56+
def test_linear_negative_input(self):
57+
"""Linear mode works with negative inputs."""
58+
amp = RFAmplifier(gain=3.0)
59+
amp.inputs[0] = -4.0
60+
amp.update(None)
61+
self.assertAlmostEqual(amp.outputs[0], -12.0)
62+
63+
def test_linear_zero_input(self):
64+
"""Zero input produces zero output."""
65+
amp = RFAmplifier(gain=10.0)
66+
amp.inputs[0] = 0.0
67+
amp.update(None)
68+
self.assertAlmostEqual(amp.outputs[0], 0.0)
69+
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
74+
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)
77+
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
82+
amp.update(None)
83+
# tanh(large) ≈ 1, so output ≈ saturation
84+
self.assertAlmostEqual(amp.outputs[0], 5.0, places=3)
85+
86+
def test_saturation_symmetry(self):
87+
"""Saturation is symmetric for positive and negative inputs."""
88+
amp = RFAmplifier(gain=100.0, saturation=5.0)
89+
90+
amp.inputs[0] = 1000.0
91+
amp.update(None)
92+
pos = amp.outputs[0]
93+
94+
amp.inputs[0] = -1000.0
95+
amp.update(None)
96+
neg = amp.outputs[0]
97+
98+
self.assertAlmostEqual(pos, -neg)
99+
100+
def test_saturation_zero(self):
101+
"""Zero input gives zero output even with saturation."""
102+
amp = RFAmplifier(gain=10.0, saturation=5.0)
103+
amp.inputs[0] = 0.0
104+
amp.update(None)
105+
self.assertAlmostEqual(amp.outputs[0], 0.0)
106+
107+
108+
# RUN TESTS LOCALLY ====================================================================
109+
110+
if __name__ == '__main__':
111+
unittest.main(verbosity=2)

tests/test_mixer.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
########################################################################################
2+
##
3+
## TESTS FOR
4+
## 'mixer.py'
5+
##
6+
########################################################################################
7+
8+
# IMPORTS ==============================================================================
9+
10+
import unittest
11+
import numpy as np
12+
13+
from pathsim_rf import RFMixer
14+
15+
16+
# TESTS ================================================================================
17+
18+
class TestRFMixer(unittest.TestCase):
19+
"""Test the RFMixer block."""
20+
21+
def test_init_default(self):
22+
"""Test default initialization."""
23+
mx = RFMixer()
24+
self.assertEqual(mx.conversion_gain, 1.0)
25+
26+
def test_init_custom(self):
27+
"""Test custom initialization."""
28+
mx = RFMixer(conversion_gain=0.5)
29+
self.assertEqual(mx.conversion_gain, 0.5)
30+
31+
def test_init_validation(self):
32+
"""Test input validation."""
33+
with self.assertRaises(ValueError):
34+
RFMixer(conversion_gain=0)
35+
with self.assertRaises(ValueError):
36+
RFMixer(conversion_gain=-1)
37+
38+
def test_port_labels(self):
39+
"""Test port label definitions."""
40+
self.assertEqual(RFMixer.input_port_labels["rf"], 0)
41+
self.assertEqual(RFMixer.input_port_labels["lo"], 1)
42+
self.assertEqual(RFMixer.output_port_labels["if_out"], 0)
43+
44+
def test_multiplication(self):
45+
"""Output is product of RF and LO signals."""
46+
mx = RFMixer()
47+
mx.inputs[0] = 3.0 # rf
48+
mx.inputs[1] = 4.0 # lo
49+
mx.update(None)
50+
self.assertAlmostEqual(mx.outputs[0], 12.0)
51+
52+
def test_conversion_gain(self):
53+
"""Conversion gain scales the output."""
54+
mx = RFMixer(conversion_gain=2.0)
55+
mx.inputs[0] = 3.0
56+
mx.inputs[1] = 5.0
57+
mx.update(None)
58+
self.assertAlmostEqual(mx.outputs[0], 30.0)
59+
60+
def test_zero_rf(self):
61+
"""Zero RF input gives zero output."""
62+
mx = RFMixer()
63+
mx.inputs[0] = 0.0
64+
mx.inputs[1] = 5.0
65+
mx.update(None)
66+
self.assertAlmostEqual(mx.outputs[0], 0.0)
67+
68+
def test_zero_lo(self):
69+
"""Zero LO input gives zero output."""
70+
mx = RFMixer()
71+
mx.inputs[0] = 3.0
72+
mx.inputs[1] = 0.0
73+
mx.update(None)
74+
self.assertAlmostEqual(mx.outputs[0], 0.0)
75+
76+
def test_negative_signals(self):
77+
"""Mixer handles negative signals correctly."""
78+
mx = RFMixer()
79+
mx.inputs[0] = -2.0
80+
mx.inputs[1] = 3.0
81+
mx.update(None)
82+
self.assertAlmostEqual(mx.outputs[0], -6.0)
83+
84+
def test_sinusoidal_mixing(self):
85+
"""Verify frequency mixing with sinusoids (trig identity)."""
86+
mx = RFMixer()
87+
f_rf = 1e9 # 1 GHz
88+
f_lo = 0.9e9 # 900 MHz
89+
t = 1e-10 # sample time
90+
91+
rf_val = np.cos(2 * np.pi * f_rf * t)
92+
lo_val = np.cos(2 * np.pi * f_lo * t)
93+
94+
mx.inputs[0] = rf_val
95+
mx.inputs[1] = lo_val
96+
mx.update(None)
97+
98+
# cos(a)*cos(b) = 0.5*[cos(a-b) + cos(a+b)]
99+
expected = rf_val * lo_val
100+
self.assertAlmostEqual(mx.outputs[0], expected)
101+
102+
103+
# RUN TESTS LOCALLY ====================================================================
104+
105+
if __name__ == '__main__':
106+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)