adsb-generator 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adsb_generator/__init__.py +9 -0
- adsb_generator/algorithms.py +84 -0
- adsb_generator/channel.py +248 -0
- adsb_generator/encoder.py +155 -0
- adsb_generator/generator.py +85 -0
- adsb_generator/message.py +198 -0
- adsb_generator-0.1.0.dist-info/METADATA +223 -0
- adsb_generator-0.1.0.dist-info/RECORD +11 -0
- adsb_generator-0.1.0.dist-info/WHEEL +5 -0
- adsb_generator-0.1.0.dist-info/licenses/LICENSE +21 -0
- adsb_generator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .generator import ADSBSample, ADSBGenerator
|
|
4
|
+
from .channel import ChannelParams, ADSBChannel
|
|
5
|
+
from .encoder import TXParams, ADSBEncoder
|
|
6
|
+
from .message import MessageType, ADSBMessage
|
|
7
|
+
|
|
8
|
+
__all__ = ["ADSBSample", "ADSBGenerator", "ChannelParams", "ADSBChannel",
|
|
9
|
+
"TXParams", "ADSBEncoder", "MessageType", "ADSBMessage"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
class ADSBAlgorithms():
|
|
4
|
+
|
|
5
|
+
CRC_POLY = 0xFFF409
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def calculate_crc(cls, data: int) -> int:
|
|
9
|
+
|
|
10
|
+
packet = data << 24
|
|
11
|
+
remainder = packet
|
|
12
|
+
|
|
13
|
+
for bit in range(111, 23, -1):
|
|
14
|
+
if (remainder >> bit) & 1:
|
|
15
|
+
remainder ^= cls.CRC_POLY << (bit - 24)
|
|
16
|
+
|
|
17
|
+
crc = remainder & 0xFFFFFF
|
|
18
|
+
|
|
19
|
+
return packet | crc
|
|
20
|
+
|
|
21
|
+
_NL_TABLE = [
|
|
22
|
+
(87, 59), (83, 58), (79, 57), (76, 56), (73, 55),
|
|
23
|
+
(70, 54), (66, 53), (63, 52), (60, 51), (57, 50),
|
|
24
|
+
(53, 49), (50, 48), (46, 47), (43, 46), (40, 45),
|
|
25
|
+
(37, 44), (34, 43), (31, 42), (28, 41), (25, 40),
|
|
26
|
+
(22, 39), (19, 38), (16, 37), (13, 36), (9, 35),
|
|
27
|
+
(6, 34), (3, 33), (0, 32),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def _nl(cls, lat: float) -> int:
|
|
32
|
+
if abs(lat) >= 87:
|
|
33
|
+
return 1
|
|
34
|
+
for lat_min, nl in cls._NL_TABLE:
|
|
35
|
+
if abs(lat) >= lat_min:
|
|
36
|
+
return nl
|
|
37
|
+
return 32
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def encode_cpr(cls, lat: float, lon: float, odd: bool) -> tuple[int, int]:
|
|
41
|
+
lat = max(-90.0, min(90.0, lat))
|
|
42
|
+
lon = max(-180.0, min(180.0, lon))
|
|
43
|
+
|
|
44
|
+
NB = 17
|
|
45
|
+
factor = 1 << NB
|
|
46
|
+
|
|
47
|
+
nz_lat = 59 if odd else 60
|
|
48
|
+
dlat = 360.0 / nz_lat
|
|
49
|
+
|
|
50
|
+
zone_lat = math.floor(lat / dlat)
|
|
51
|
+
rem_lat = lat - zone_lat * dlat
|
|
52
|
+
yz = int(round((rem_lat / dlat) * factor)) & (factor - 1)
|
|
53
|
+
|
|
54
|
+
rlat = dlat * (yz / factor + zone_lat)
|
|
55
|
+
rlat = max(-90.0, min(90.0, rlat))
|
|
56
|
+
|
|
57
|
+
nl = cls._nl(rlat)
|
|
58
|
+
|
|
59
|
+
dlon = 360.0 / nl
|
|
60
|
+
zone_lon = math.floor(lon / dlon)
|
|
61
|
+
rem_lon = lon - zone_lon * dlon
|
|
62
|
+
xz = int(round((rem_lon / dlon) * factor)) & (factor - 1)
|
|
63
|
+
|
|
64
|
+
return yz, xz
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def encode_altitude(alt: int) -> int:
|
|
68
|
+
alt_clamped = max(-1000, min(50100, alt))
|
|
69
|
+
n = (alt_clamped + 1000) // 25
|
|
70
|
+
|
|
71
|
+
top_bits = (n >> 4) & 0x7F
|
|
72
|
+
bottom_bits = n & 0x0F
|
|
73
|
+
|
|
74
|
+
return (top_bits << 5) | (1 << 4) | bottom_bits
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def encode_ground_track(degrees: float, valid: bool = True) -> tuple[int, int]:
|
|
78
|
+
if not valid or degrees is None:
|
|
79
|
+
return 0, 0
|
|
80
|
+
|
|
81
|
+
degrees = degrees % 360.0
|
|
82
|
+
trk_code = round(degrees * 128.0 / 360.0)
|
|
83
|
+
|
|
84
|
+
return 1, trk_code
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import numpy as np
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
class ChannelParams(Enum):
|
|
6
|
+
"""Supported channel impairment parameters for ADS-B signal simulation."""
|
|
7
|
+
SNR_DB = "snr_db"
|
|
8
|
+
NOISE_CORRELATION = "noise_correlation"
|
|
9
|
+
|
|
10
|
+
FREQUENCY_OFFSET = "frequency_offset"
|
|
11
|
+
PHASE_OFFSET = "phase_offset"
|
|
12
|
+
DC_OFFSET_I = "dc_offset_i"
|
|
13
|
+
DC_OFFSET_Q = "dc_offset_q"
|
|
14
|
+
|
|
15
|
+
IQ_GAIN_IMBALANCE = "iq_gain_imbalance"
|
|
16
|
+
IQ_PHASE_IMBALANCE = "iq_phase_imbalance"
|
|
17
|
+
|
|
18
|
+
class ADSBChannel:
|
|
19
|
+
"""
|
|
20
|
+
Simulates realistic RF channel impairments on ADS-B baseband I/Q signals.
|
|
21
|
+
|
|
22
|
+
This class applies a series of channel effects—including IQ imbalance, DC offset,
|
|
23
|
+
frequency offset, phase offset, and additive white Gaussian noise (AWGN)—to
|
|
24
|
+
simulate the degradations encountered in real-world ADS-B reception. Each
|
|
25
|
+
impairment parameter is sampled from configurable probability distributions.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
sample_rate (float): Sampling rate in samples per second.
|
|
29
|
+
channel_params_distributions (dict[ChannelParams | str, list[list[float]]]):
|
|
30
|
+
Mapping of channel parameters to their probability distributions defined
|
|
31
|
+
as intervals with associated weights.
|
|
32
|
+
seed (int | None): Seed for the internal random number generator to ensure
|
|
33
|
+
reproducible channel simulation.
|
|
34
|
+
"""
|
|
35
|
+
def __init__(self, sample_rate: float = 2e6, channel_params_distributions: (dict[ChannelParams | str, list[list[float]]]) | None = None, seed: int | None = None):
|
|
36
|
+
"""
|
|
37
|
+
Initializes the channel simulator with sampling parameters and impairment distributions.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
sample_rate: Sampling rate in samples per second. Defaults to 2 MHz.
|
|
41
|
+
channel_params_distributions: A mapping of ChannelParams to probability
|
|
42
|
+
distributions defined as lists of [min_val, max_val, weight] intervals.
|
|
43
|
+
If None, defaults to distributions covering typical ADS-B reception
|
|
44
|
+
conditions for all eight channel parameters.
|
|
45
|
+
seed: Seed for the internal random number generator to ensure reproducible
|
|
46
|
+
channel simulation. If None, the generator is seeded from system randomness.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If `channel_params_distributions` contains invalid keys, invalid
|
|
50
|
+
intervals (min > max), weights that do not sum to 1.0, or noise correlation
|
|
51
|
+
values outside [-1.0, 1.0] (validated via `_validate_distributions`).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
self.sample_rate = sample_rate
|
|
55
|
+
|
|
56
|
+
default_dists = {
|
|
57
|
+
ChannelParams.SNR_DB: [
|
|
58
|
+
[3.0, 8.0, 0.20],
|
|
59
|
+
[8.0, 15.0, 0.55],
|
|
60
|
+
[15.0, 25.0, 0.25],
|
|
61
|
+
],
|
|
62
|
+
ChannelParams.NOISE_CORRELATION: [
|
|
63
|
+
[0.0, 0.0, 1.0],
|
|
64
|
+
],
|
|
65
|
+
ChannelParams.FREQUENCY_OFFSET: [
|
|
66
|
+
[-1000.0, 1000.0, 0.80],
|
|
67
|
+
[-3000.0, 3000.0, 0.20],
|
|
68
|
+
],
|
|
69
|
+
ChannelParams.PHASE_OFFSET: [
|
|
70
|
+
[-0.10, 0.10, 0.80],
|
|
71
|
+
[-0.30, 0.30, 0.20],
|
|
72
|
+
],
|
|
73
|
+
ChannelParams.DC_OFFSET_I: [
|
|
74
|
+
[-0.01, 0.01, 0.90],
|
|
75
|
+
[-0.03, 0.03, 0.10],
|
|
76
|
+
],
|
|
77
|
+
ChannelParams.DC_OFFSET_Q: [
|
|
78
|
+
[-0.01, 0.01, 0.90],
|
|
79
|
+
[-0.03, 0.03, 0.10],
|
|
80
|
+
],
|
|
81
|
+
ChannelParams.IQ_GAIN_IMBALANCE: [
|
|
82
|
+
[0.00, 0.02, 0.80],
|
|
83
|
+
[0.02, 0.05, 0.20],
|
|
84
|
+
],
|
|
85
|
+
ChannelParams.IQ_PHASE_IMBALANCE: [
|
|
86
|
+
[-1.0, 1.0, 0.80],
|
|
87
|
+
[-3.0, 3.0, 0.20],
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
self.channel_params_dists = channel_params_distributions or default_dists
|
|
92
|
+
|
|
93
|
+
self._validate_distributions
|
|
94
|
+
|
|
95
|
+
self._seed = seed if seed is not None else random.randint(0, 2**32 - 1)
|
|
96
|
+
self._rng = random.Random(self._seed)
|
|
97
|
+
self._np_rng = np.random.default_rng(self._seed)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def seed(self) -> int:
|
|
101
|
+
"""Gets the seed value."""
|
|
102
|
+
return self._seed
|
|
103
|
+
|
|
104
|
+
def _validate_distributions(self):
|
|
105
|
+
valid_types = set(ChannelParams)
|
|
106
|
+
valid_values = {item.value for item in ChannelParams}
|
|
107
|
+
|
|
108
|
+
for channel_param, intervals in self.channel_params_dists.items():
|
|
109
|
+
if channel_param not in valid_types and channel_param not in valid_values:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Invalid channel param key: '{channel_param}'. "
|
|
112
|
+
f"Valid options are the ChannelParams enums or: {valid_values}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
enum_key = channel_param if isinstance(channel_param, ChannelParams) else ChannelParams(channel_param)
|
|
116
|
+
|
|
117
|
+
total_weights = 0.0
|
|
118
|
+
for min_val, max_val, weight in intervals:
|
|
119
|
+
if min_val > max_val:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Invalid range {min_val} > {max_val} in key '{channel_param}'"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if enum_key == ChannelParams.NOISE_CORRELATION:
|
|
125
|
+
if not (-1.0 <= min_val <= 1.0) or not (-1.0 <= max_val <= 1.0):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Noise correlation must be in range [-1.0, 1.0]. "
|
|
128
|
+
f"Got range [{min_val}, {max_val}] for key '{channel_param}'"
|
|
129
|
+
)
|
|
130
|
+
if min_val > max_val:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Invalid noise correlation range: {min_val} > {max_val}"
|
|
133
|
+
)
|
|
134
|
+
total_weights += weight
|
|
135
|
+
|
|
136
|
+
if not (0.99 <= total_weights <= 1.01):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Sum of weights for key '{channel_param}' must equal 1.0, got {total_weights:.2f}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _sample_channel_params(self) -> dict[ChannelParams, float]:
|
|
142
|
+
sampled_params = {}
|
|
143
|
+
|
|
144
|
+
default_values = {
|
|
145
|
+
ChannelParams.SNR_DB: 15.0,
|
|
146
|
+
ChannelParams.NOISE_CORRELATION: 0.0,
|
|
147
|
+
ChannelParams.FREQUENCY_OFFSET: 0.0,
|
|
148
|
+
ChannelParams.PHASE_OFFSET: 0.0,
|
|
149
|
+
ChannelParams.DC_OFFSET_I: 0.0,
|
|
150
|
+
ChannelParams.DC_OFFSET_Q: 0.0,
|
|
151
|
+
ChannelParams.IQ_GAIN_IMBALANCE: 0.0,
|
|
152
|
+
ChannelParams.IQ_PHASE_IMBALANCE: 0.0,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for param_key, intervals in self.channel_params_dists.items():
|
|
156
|
+
ranges = [(low, high) for low, high, _ in intervals]
|
|
157
|
+
weights = [w for _, _, w in intervals]
|
|
158
|
+
|
|
159
|
+
selected_range = self._rng.choices(ranges, weights, k=1)[0]
|
|
160
|
+
|
|
161
|
+
sampled_val = self._rng.uniform(selected_range[0], selected_range[1])
|
|
162
|
+
|
|
163
|
+
enum_key = param_key if isinstance(param_key, ChannelParams) else ChannelParams(param_key)
|
|
164
|
+
sampled_params[enum_key] = sampled_val
|
|
165
|
+
|
|
166
|
+
for param in ChannelParams:
|
|
167
|
+
if param not in sampled_params:
|
|
168
|
+
sampled_params[param] = default_values[param]
|
|
169
|
+
|
|
170
|
+
return sampled_params
|
|
171
|
+
|
|
172
|
+
def _apply_dc_offset(self, signal: np.ndarray, dc_offset_i: float, dc_offset_q: float) -> np.ndarray:
|
|
173
|
+
dc_offset = dc_offset_i + 1j * dc_offset_q
|
|
174
|
+
return signal + dc_offset
|
|
175
|
+
|
|
176
|
+
def _apply_freq_offset(self, signal: np.ndarray, freq_offset: float, sample_rate: float) -> np.ndarray:
|
|
177
|
+
t = np.arange(len(signal)) / sample_rate
|
|
178
|
+
return signal * np.exp(1j * 2 * np.pi * freq_offset * t)
|
|
179
|
+
|
|
180
|
+
def _apply_phase_offset(self, signal: np.ndarray, phase_offset: float) -> np.ndarray:
|
|
181
|
+
return signal * np.exp(1j * phase_offset)
|
|
182
|
+
|
|
183
|
+
def _apply_iq_imbalance(self, signal: np.ndarray, gain_imbalance: float, phase_imbalance: float) -> np.ndarray:
|
|
184
|
+
phi = np.deg2rad(phase_imbalance)
|
|
185
|
+
|
|
186
|
+
i = signal.real * (1 + gain_imbalance / 2)
|
|
187
|
+
q = signal.imag * (1 - gain_imbalance / 2)
|
|
188
|
+
|
|
189
|
+
i_out = i * np.cos(phi) + q * np.sin(phi)
|
|
190
|
+
q_out = i * np.sin(phi) + q * np.cos(phi)
|
|
191
|
+
|
|
192
|
+
return i_out + 1j * q_out
|
|
193
|
+
|
|
194
|
+
def _apply_gaussian_noise(self, signal: np.ndarray, snr_db: float, noise_correlation: float = 0) -> np.ndarray:
|
|
195
|
+
if len(signal) == 0:
|
|
196
|
+
return signal.copy()
|
|
197
|
+
signal_power = np.mean(np.abs(signal) ** 2)
|
|
198
|
+
|
|
199
|
+
snr_linear = 10 ** (snr_db / 10)
|
|
200
|
+
|
|
201
|
+
noise_power = signal_power / snr_linear
|
|
202
|
+
noise_std = np.sqrt(noise_power / 2)
|
|
203
|
+
|
|
204
|
+
if noise_correlation > 0:
|
|
205
|
+
cov_matrix = noise_power / 2 * np.array([
|
|
206
|
+
[1.0, noise_correlation],
|
|
207
|
+
[noise_correlation, 1.0]
|
|
208
|
+
])
|
|
209
|
+
|
|
210
|
+
noise = self._np_rng.multivariate_normal(
|
|
211
|
+
mean=[0, 0],
|
|
212
|
+
cov=cov_matrix,
|
|
213
|
+
size=len(signal)
|
|
214
|
+
)
|
|
215
|
+
noise_complex = noise[:, 0] + 1j * noise[:, 1]
|
|
216
|
+
else:
|
|
217
|
+
noise_i = self._np_rng.normal(0, noise_std, len(signal))
|
|
218
|
+
noise_q = self._np_rng.normal(0, noise_std, len(signal))
|
|
219
|
+
noise_complex = noise_i + 1j * noise_q
|
|
220
|
+
|
|
221
|
+
return signal + noise_complex
|
|
222
|
+
|
|
223
|
+
def apply(self, signal: np.ndarray) -> tuple[np.ndarray, dict[ChannelParams, float]]:
|
|
224
|
+
"""
|
|
225
|
+
Applies simulated channel impairments to an ADS-B baseband I/Q signal.
|
|
226
|
+
|
|
227
|
+
This method samples channel parameters from configured distributions and
|
|
228
|
+
applies impairments in the following order: IQ imbalance, DC offset,
|
|
229
|
+
frequency offset, phase offset, and finally additive Gaussian noise.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
signal: Complex I/Q samples of the baseband signal (dtype=np.complex64).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
A tuple containing:
|
|
236
|
+
- np.ndarray: The impaired complex I/Q signal (dtype=np.complex64)
|
|
237
|
+
- dict[ChannelParams, float]: The channel parameters used for this operation
|
|
238
|
+
"""
|
|
239
|
+
params = self._sample_channel_params()
|
|
240
|
+
|
|
241
|
+
signal = self._apply_iq_imbalance(signal, params[ChannelParams.IQ_GAIN_IMBALANCE], params[ChannelParams.IQ_PHASE_IMBALANCE])
|
|
242
|
+
signal = self._apply_dc_offset(signal, params[ChannelParams.DC_OFFSET_I], params[ChannelParams.DC_OFFSET_Q])
|
|
243
|
+
signal = self._apply_freq_offset(signal, params[ChannelParams.FREQUENCY_OFFSET], self.sample_rate)
|
|
244
|
+
signal = self._apply_phase_offset(signal, params[ChannelParams.PHASE_OFFSET])
|
|
245
|
+
signal = self._apply_gaussian_noise(signal, params[ChannelParams.SNR_DB], params[ChannelParams.NOISE_CORRELATION])
|
|
246
|
+
|
|
247
|
+
return signal, params
|
|
248
|
+
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import numpy as np
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
class TXParams(Enum):
|
|
6
|
+
AMPLITUDE = "amplitude"
|
|
7
|
+
|
|
8
|
+
class ADSBEncoder:
|
|
9
|
+
"""
|
|
10
|
+
Converts ADS-B raw frames into baseband I/Q samples with randomized transmission parameters.
|
|
11
|
+
|
|
12
|
+
This class handles the encoding of 112-bit ADS-B messages into a complex baseband
|
|
13
|
+
signal representation, following the pulse-position modulation (PPM) encoding
|
|
14
|
+
scheme defined in the Mode S/ADS-B standard. The encoder adds configurable
|
|
15
|
+
random variations to transmission parameters (e.g., amplitude) to simulate
|
|
16
|
+
real-world signal variability.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
sample_rate (float): Sampling rate in samples per second.
|
|
20
|
+
tx_params_distributions (dict[TXParams | str, list[list[float]]]): Mapping
|
|
21
|
+
of transmission parameters to their probability distributions defined
|
|
22
|
+
as intervals with associated weights.
|
|
23
|
+
seed (int | None): Seed for the internal random number generator to ensure
|
|
24
|
+
reproducible signal generation.
|
|
25
|
+
"""
|
|
26
|
+
def __init__(self, sample_rate: float = 2e6, tx_params_distributions: (dict[TXParams | str, list[list[float]]]) | None = None, seed: int | None = None):
|
|
27
|
+
"""
|
|
28
|
+
Initializes the encoder with sampling parameters and transmission parameter distributions.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
sample_rate: Sampling rate in samples per second. Defaults to 2 MHz.
|
|
32
|
+
tx_params_distributions: A mapping of TXParams to probability distributions
|
|
33
|
+
defined as lists of [min_val, max_val, weight] intervals. If None,
|
|
34
|
+
defaults to a single amplitude distribution with three intervals
|
|
35
|
+
covering the range [0.05, 1.00].
|
|
36
|
+
seed: Seed for the internal random number generator to ensure reproducible
|
|
37
|
+
signal generation. If None, the generator is seeded from system randomness.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If `tx_params_distributions` contains invalid keys, invalid
|
|
41
|
+
intervals (min > max), or weights that do not sum to 1.0 (validated
|
|
42
|
+
via `_validate_distributions`).
|
|
43
|
+
"""
|
|
44
|
+
self.sample_rate = sample_rate
|
|
45
|
+
|
|
46
|
+
default_dists = {
|
|
47
|
+
TXParams.AMPLITUDE: [
|
|
48
|
+
[0.05, 0.25, 0.5],
|
|
49
|
+
[0.25, 0.65, 0.3],
|
|
50
|
+
[0.65, 1.00, 0.2]
|
|
51
|
+
]}
|
|
52
|
+
|
|
53
|
+
self.tx_params_dists = tx_params_distributions or default_dists
|
|
54
|
+
|
|
55
|
+
self._validate_distributions
|
|
56
|
+
|
|
57
|
+
self._seed = seed if seed is not None else random.randint(0, 2**32 - 1)
|
|
58
|
+
self._rng = random.Random(self._seed)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def seed(self) -> int:
|
|
62
|
+
"""Gets the seed value."""
|
|
63
|
+
return self._seed
|
|
64
|
+
|
|
65
|
+
def _validate_distributions(self):
|
|
66
|
+
valid_types = set(TXParams)
|
|
67
|
+
valid_values = {item.value for item in TXParams}
|
|
68
|
+
|
|
69
|
+
for tx_param, intervals in self.tx_params_dists.items():
|
|
70
|
+
if tx_param not in valid_types and tx_param not in valid_values:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Invalid tx param key: '{tx_param}'. "
|
|
73
|
+
f"Valid options are the TXParams enums or: {valid_values}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
total_weights = 0.0
|
|
77
|
+
for min_val, max_val, weight in intervals:
|
|
78
|
+
if min_val > max_val:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Invalid range {min_val} > {max_val} in key '{tx_param}'"
|
|
81
|
+
)
|
|
82
|
+
total_weights += weight
|
|
83
|
+
|
|
84
|
+
if not (0.99 <= total_weights <= 1.01):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Sum of weights for key '{tx_param}' must equal 1.0, got {total_weights:.2f}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _sample_tx_params(self) -> dict[TXParams, float]:
|
|
90
|
+
sampled_params = {}
|
|
91
|
+
for param_key, intervals in self.tx_params_dists.items():
|
|
92
|
+
ranges = [(low, high) for low, high, _ in intervals]
|
|
93
|
+
weights = [w for _, _, w in intervals]
|
|
94
|
+
|
|
95
|
+
selected_range = self._rng.choices(ranges, weights, k=1)[0]
|
|
96
|
+
|
|
97
|
+
sampled_val = self._rng.uniform(selected_range[0], selected_range[1])
|
|
98
|
+
|
|
99
|
+
enum_key = param_key if isinstance(param_key, TXParams) else TXParams(param_key)
|
|
100
|
+
sampled_params[enum_key] = sampled_val
|
|
101
|
+
|
|
102
|
+
return sampled_params
|
|
103
|
+
|
|
104
|
+
def encode(self, msg: int) -> tuple[np.ndarray, dict[TXParams, float]]:
|
|
105
|
+
"""
|
|
106
|
+
Encodes a 112-bit ADS-B message into a complex baseband I/Q signal.
|
|
107
|
+
|
|
108
|
+
This method samples transmission parameters from configured distributions and
|
|
109
|
+
generates a 120 μs signal containing the preamble and 112 data bits encoded
|
|
110
|
+
using PPM at 1 Mbps.
|
|
111
|
+
|
|
112
|
+
The timing follows the ADS-B standard:
|
|
113
|
+
- Preamble: 8.0 μs with pulses at specific positions
|
|
114
|
+
- Data bits: 112 bits at 1 μs per bit starting at 8.0 μs
|
|
115
|
+
- Pulse width: 0.5 μs for both preamble and data pulses
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
msg: The 112-bit ADS-B message as an integer (LSB alignment).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A tuple containing:
|
|
122
|
+
- np.ndarray: Complex I/Q samples of the baseband signal (dtype=np.complex64)
|
|
123
|
+
- dict[TXParams, float]: The transmission parameters used for this encode operation
|
|
124
|
+
"""
|
|
125
|
+
params = self._sample_tx_params()
|
|
126
|
+
amplitude = params[TXParams.AMPLITUDE]
|
|
127
|
+
|
|
128
|
+
samples_per_us = self.sample_rate / 1e6
|
|
129
|
+
total_samples = int(round(120.0 * samples_per_us))
|
|
130
|
+
|
|
131
|
+
signal = np.zeros(total_samples, dtype=np.float32)
|
|
132
|
+
|
|
133
|
+
for start_us, end_us in ((0.0, 0.5), (1.0, 1.5), (3.5, 4.0), (4.5, 5.0)):
|
|
134
|
+
signal[
|
|
135
|
+
int(round(start_us * samples_per_us)):
|
|
136
|
+
int(round(end_us * samples_per_us))
|
|
137
|
+
] = amplitude
|
|
138
|
+
|
|
139
|
+
bit_start_us = 8.0
|
|
140
|
+
|
|
141
|
+
for shift in range(111, -1, -1):
|
|
142
|
+
bit = (msg >> shift) & 1
|
|
143
|
+
|
|
144
|
+
pulse_offset_us = 0.0 if bit else 0.5
|
|
145
|
+
|
|
146
|
+
p_start = round((bit_start_us + pulse_offset_us) * samples_per_us)
|
|
147
|
+
p_end = round((bit_start_us + pulse_offset_us + 0.5) * samples_per_us)
|
|
148
|
+
|
|
149
|
+
signal[p_start:p_end] = amplitude
|
|
150
|
+
|
|
151
|
+
bit_start_us += 1.0
|
|
152
|
+
|
|
153
|
+
iq_samples = signal.astype(np.complex64)
|
|
154
|
+
|
|
155
|
+
return iq_samples, params
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import numpy as np
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from .message import ADSBMessage, MessageType
|
|
5
|
+
from .encoder import ADSBEncoder, TXParams
|
|
6
|
+
from .channel import ADSBChannel, ChannelParams
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ADSBSample:
|
|
10
|
+
"""A single ADS-B sample containing the raw message, encoded signal, and impaired signal."""
|
|
11
|
+
message: int
|
|
12
|
+
message_type: MessageType
|
|
13
|
+
|
|
14
|
+
clean_signal: np.ndarray
|
|
15
|
+
tx_params: dict[TXParams, float]
|
|
16
|
+
|
|
17
|
+
channel_signal: np.ndarray
|
|
18
|
+
channel_params: dict[ChannelParams, float]
|
|
19
|
+
|
|
20
|
+
class ADSBGenerator():
|
|
21
|
+
"""
|
|
22
|
+
End-to-end ADS-B signal generator that produces synthetic I/Q samples with channel impairments.
|
|
23
|
+
|
|
24
|
+
This class orchestrates the full ADS-B signal generation pipeline: building random
|
|
25
|
+
112-bit ADS-B messages, encoding them into baseband I/Q signals using PPM, and
|
|
26
|
+
applying realistic RF channel impairments. It is implemented as an infinite iterator
|
|
27
|
+
that yields ADSBSample instances on each iteration.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
seed (int): Seed used for the internal random number generators.
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, message_type_probs: dict[MessageType | str, float] | None = None , tx_params_distributions: dict[TXParams | str, list[list[float]]] | None = None,
|
|
33
|
+
channel_params_distributions: dict[ChannelParams | str, list[list[float]]] | None = None, sample_rate: float = 2e6, seed: int | None = None):
|
|
34
|
+
"""
|
|
35
|
+
Initializes the generator with message, transmission, and channel distributions.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
message_type_probs: Mapping of MessageType to their emission probabilities.
|
|
39
|
+
If None, defaults to an equal 25% distribution across all message types.
|
|
40
|
+
tx_params_distributions: Mapping of TXParams to probability distributions
|
|
41
|
+
defined as lists of [min_val, max_val, weight] intervals. If None,
|
|
42
|
+
defaults to a single amplitude distribution.
|
|
43
|
+
channel_params_distributions: Mapping of ChannelParams to probability
|
|
44
|
+
distributions defined as lists of [min_val, max_val, weight] intervals.
|
|
45
|
+
If None, defaults to distributions covering typical ADS-B reception
|
|
46
|
+
conditions.
|
|
47
|
+
sample_rate: Sampling rate in samples per second. Defaults to 2 MHz.
|
|
48
|
+
seed: Seed for the internal random number generators to ensure reproducible
|
|
49
|
+
output. If None, the generator is seeded from system randomness.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
self._seed = seed if seed is not None else random.randint(0, 2**32 - 1)
|
|
53
|
+
|
|
54
|
+
self.builder = ADSBMessage(message_type_probs, self._seed)
|
|
55
|
+
self.encoder = ADSBEncoder(sample_rate, tx_params_distributions, self._seed)
|
|
56
|
+
self.channel = ADSBChannel(sample_rate, channel_params_distributions, self._seed)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def seed(self) -> int:
|
|
60
|
+
"""Gets the seed value."""
|
|
61
|
+
return self._seed
|
|
62
|
+
|
|
63
|
+
def __iter__(self):
|
|
64
|
+
"""
|
|
65
|
+
Yields an infinite stream of ADS-B samples.
|
|
66
|
+
|
|
67
|
+
Each iteration builds a random ADS-B message, encodes it into a baseband
|
|
68
|
+
I/Q signal, applies channel impairments, and yields an ADSBSample containing
|
|
69
|
+
the raw message, clean signal, and impaired signal along with their parameters.
|
|
70
|
+
"""
|
|
71
|
+
while True:
|
|
72
|
+
message, message_type = self.builder.build()
|
|
73
|
+
|
|
74
|
+
clean_signal, tx_params = self.encoder.encode(message)
|
|
75
|
+
|
|
76
|
+
channel_signal, channel_params = self.channel.apply(clean_signal)
|
|
77
|
+
|
|
78
|
+
yield ADSBSample(
|
|
79
|
+
message=message,
|
|
80
|
+
message_type=message_type,
|
|
81
|
+
clean_signal=clean_signal,
|
|
82
|
+
tx_params=tx_params,
|
|
83
|
+
channel_signal=channel_signal,
|
|
84
|
+
channel_params=channel_params,
|
|
85
|
+
)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import string
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from .algorithms import ADSBAlgorithms
|
|
5
|
+
|
|
6
|
+
class MessageType(Enum):
|
|
7
|
+
"""Supported Automatic Dependent Surveillance-Broadcast (ADS-B) message types."""
|
|
8
|
+
IDENTIFICATION = "identification"
|
|
9
|
+
SURFACE_POSITION = "surface_position"
|
|
10
|
+
AIRBORNE_POSITION = "airborne_position"
|
|
11
|
+
AIRBORNE_VELOCITY = "airborne_velocity"
|
|
12
|
+
|
|
13
|
+
class ADSBMessage():
|
|
14
|
+
"""
|
|
15
|
+
Generates synthetic 112-bit ADS-B (Mode S Downlink Format 17) raw frames.
|
|
16
|
+
|
|
17
|
+
This class handles the probabilistic generation and bit-level encoding of
|
|
18
|
+
various ADS-B message types—including aircraft identification, surface position,
|
|
19
|
+
airborne position, and airborne velocity—complete with 24-bit CRC parity generation.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
message_type_probs (dict[MessageType | str, float]): Mapping of message
|
|
23
|
+
types to their selection probabilities.
|
|
24
|
+
seed: int | None = None: Seed for the internal random number generator to ensure reproducible
|
|
25
|
+
message output.
|
|
26
|
+
"""
|
|
27
|
+
def __init__(self, message_type_probs: dict = None, seed: int | None = None):
|
|
28
|
+
"""
|
|
29
|
+
Initializes the instance with message type probabilities.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
message_type_probs: A mapping of MessageType to their respective
|
|
33
|
+
emission probabilities. If None, defaults to an equal 25% distribution
|
|
34
|
+
across IDENTIFICATION, SURFACE_POSITION, AIRBORNE_POSITION, and
|
|
35
|
+
AIRBORNE_VELOCITY.
|
|
36
|
+
seed: Seed for the internal random number generator to ensure reproducible
|
|
37
|
+
message output. If None, the generator is seeded from system randomness.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If `message_type_probs` contains invalid probability values
|
|
41
|
+
or does not sum to 1.0 (validated via `_validate_probabilities`).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
default_probs = {
|
|
45
|
+
MessageType.IDENTIFICATION: 0.25,
|
|
46
|
+
MessageType.SURFACE_POSITION: 0.25,
|
|
47
|
+
MessageType.AIRBORNE_POSITION: 0.25,
|
|
48
|
+
MessageType.AIRBORNE_VELOCITY: 0.25
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
self.message_type_probs = message_type_probs or default_probs
|
|
52
|
+
self._validate_probabilities()
|
|
53
|
+
|
|
54
|
+
self._seed = seed if seed is not None else random.randint(0, 2**32 - 1)
|
|
55
|
+
self._rng = random.Random(self._seed)
|
|
56
|
+
|
|
57
|
+
self._CALLSIGN_CHARSET = {
|
|
58
|
+
" ": 0,
|
|
59
|
+
**{chr(ord("A") + i): i + 1 for i in range(26)},
|
|
60
|
+
**{str(i): 48 + i for i in range(10)},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def seed(self) -> int:
|
|
65
|
+
"""Gets the seed value."""
|
|
66
|
+
return self._seed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_probabilities(self):
|
|
70
|
+
valid_types = set(item for item in MessageType)
|
|
71
|
+
valid_values = set(item.value for item in MessageType)
|
|
72
|
+
|
|
73
|
+
for msg_type in self.message_type_probs.keys():
|
|
74
|
+
if msg_type not in valid_types and msg_type not in valid_values:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Invalid message type key: '{msg_type}'. "
|
|
77
|
+
f"Valid options are the MessageType enums or: {valid_values}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
total= sum(self.message_type_probs.values())
|
|
81
|
+
if not (0.99 <= total <= 1.01):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Sum of probabilities must equal 1.0, got {total}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _build_identification_message(self) -> int:
|
|
87
|
+
tc = self._rng.randint(1, 4)
|
|
88
|
+
ca = self._rng.randint(0, 7)
|
|
89
|
+
|
|
90
|
+
prefix = "".join(self._rng.choices(string.ascii_uppercase, k=3))
|
|
91
|
+
digits = str(self._rng.randint(10, 99999))
|
|
92
|
+
callsign = (prefix + digits).ljust(8, " ")[:8]
|
|
93
|
+
|
|
94
|
+
callsign_48bit = 0
|
|
95
|
+
for char in callsign:
|
|
96
|
+
code = self._CALLSIGN_CHARSET.get(char, 0)
|
|
97
|
+
callsign_48bit = (callsign_48bit << 6) | (code & 0x3F)
|
|
98
|
+
|
|
99
|
+
return (tc << 51) | (ca << 48) | callsign_48bit
|
|
100
|
+
|
|
101
|
+
def _build_surface_position_message(self) -> int:
|
|
102
|
+
tc = self._rng.randint(5, 8)
|
|
103
|
+
mov = self._rng.randint(0, 127)
|
|
104
|
+
s, trk = ADSBAlgorithms.encode_ground_track(self._rng.uniform(0, 360), self._rng.choice([True, False]))
|
|
105
|
+
t = self._rng.choices([1, 0], [0.9, 0.1], k=1)[0]
|
|
106
|
+
|
|
107
|
+
f = self._rng.randint(0, 1)
|
|
108
|
+
lat_cpr, lon_cpr = ADSBAlgorithms.encode_cpr(self._rng.uniform(-90, 90), self._rng.uniform(-180, 180), (f == 1))
|
|
109
|
+
|
|
110
|
+
return ((tc << 51) | (mov << 44) | (s << 43) | (trk << 36) |
|
|
111
|
+
(t << 35) | (f << 34) | (lat_cpr << 17) | lon_cpr)
|
|
112
|
+
|
|
113
|
+
def _build_airborne_position_message(self) -> int:
|
|
114
|
+
tc = self._rng.choice([self._rng.randint(9, 18), self._rng.randint(20, 22)])
|
|
115
|
+
ss = self._rng.randint(0 ,3)
|
|
116
|
+
saf = self._rng.choice([0, 1])
|
|
117
|
+
|
|
118
|
+
alt = ADSBAlgorithms.encode_altitude(self._rng.randint(500, 45000))
|
|
119
|
+
|
|
120
|
+
t = self._rng.choices([1, 0], [0.9, 0.1], k=1)[0]
|
|
121
|
+
|
|
122
|
+
f = self._rng.randint(0, 1)
|
|
123
|
+
lat_cpr, lon_cpr = ADSBAlgorithms.encode_cpr(self._rng.uniform(-90, 90), self._rng.uniform(-180, 180), (f == 1))
|
|
124
|
+
|
|
125
|
+
return ((tc << 51) | (ss << 49) | (saf << 48) | (alt << 36) |
|
|
126
|
+
(t << 35) | (f << 34) | (lat_cpr << 17) | lon_cpr)
|
|
127
|
+
|
|
128
|
+
def _build_airborne_velocity_message(self) -> int:
|
|
129
|
+
tc = 19
|
|
130
|
+
st = self._rng.randint(1, 4)
|
|
131
|
+
ic = self._rng.choice([0, 1])
|
|
132
|
+
ifr = self._rng.choice([0, 1])
|
|
133
|
+
nucv = self._rng.randint(0, 4)
|
|
134
|
+
|
|
135
|
+
subtype = 0
|
|
136
|
+
if st in [1, 2]:
|
|
137
|
+
dew = self._rng.choice([0, 1])
|
|
138
|
+
vew = (self._rng.randint(0, 1021) + 1) if st == 1 else (int(self._rng.choice([x for x in range(0, 4085, 4)]) / 4) + 1)
|
|
139
|
+
dns = self._rng.choice([0, 1])
|
|
140
|
+
vns = (self._rng.randint(0, 1021) + 1) if st == 1 else (int(self._rng.choice([x for x in range(0, 4085, 4)]) / 4) + 1)
|
|
141
|
+
|
|
142
|
+
subtype = (dew << 21) | (vew << 11) | (dns << 10) | vns
|
|
143
|
+
else:
|
|
144
|
+
sh = self._rng.choice([0, 1])
|
|
145
|
+
hdg = round((self._rng.uniform(0.0, 360.0) * 1024) / 360) % 1024
|
|
146
|
+
t = self._rng.choice([0, 1])
|
|
147
|
+
ais = (self._rng.randint(0, 1021) + 1) if st == 3 else (int(self._rng.choice([x for x in range(0, 4085, 4)]) / 4) + 1)
|
|
148
|
+
|
|
149
|
+
subtype = (sh << 21) | (hdg << 11) | (t << 10) | ais
|
|
150
|
+
|
|
151
|
+
vrsrc = self._rng.choice([0, 1])
|
|
152
|
+
svr = self._rng.choice([0, 1])
|
|
153
|
+
vr = int(self._rng.choice([x for x in range(0, 32641, 64)]) / 64) + 1
|
|
154
|
+
res = 0
|
|
155
|
+
sdif = self._rng.choice([0, 1])
|
|
156
|
+
dalt = int(self._rng.choice([x for x in range(0, 3151, 25)]) / 25) + 1
|
|
157
|
+
|
|
158
|
+
return ((tc << 51) | (st << 48) | (ic << 47) | (ifr << 46) | (nucv << 43) |
|
|
159
|
+
(subtype << 21) | (vrsrc << 20) | (svr << 19) | (vr << 10) | (res << 8) | (sdif << 7) | dalt)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build(self) -> tuple[int, MessageType]:
|
|
163
|
+
"""
|
|
164
|
+
Generates a self._rng ADS-B message based on configured probabilities and calculates its CRC.
|
|
165
|
+
|
|
166
|
+
self._rngly selects an ADS-B message type using the configured probability distribution,
|
|
167
|
+
constructs the message fields (Downlink Format, Capability, ICAO address, and Message payload),
|
|
168
|
+
and computes the final parity checksum.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The complete 112-bit ADS-B message encoded as an integer, including
|
|
172
|
+
the 24-bit Parity/Interrogation ID (CRC).
|
|
173
|
+
|
|
174
|
+
Message type.
|
|
175
|
+
"""
|
|
176
|
+
types = list(self.message_type_probs.keys())
|
|
177
|
+
weights = list(self.message_type_probs.values())
|
|
178
|
+
selected_type = self._rng.choices(types, weights, k=1)[0]
|
|
179
|
+
|
|
180
|
+
selected_type = MessageType(selected_type) if isinstance(selected_type, str) else selected_type
|
|
181
|
+
|
|
182
|
+
df = 17
|
|
183
|
+
ca = self._rng.randint(0, 7)
|
|
184
|
+
icao = self._rng.getrandbits(24)
|
|
185
|
+
me = 0
|
|
186
|
+
|
|
187
|
+
generators = {
|
|
188
|
+
MessageType.IDENTIFICATION: self._build_identification_message,
|
|
189
|
+
MessageType.SURFACE_POSITION: self._build_surface_position_message,
|
|
190
|
+
MessageType.AIRBORNE_POSITION: self._build_airborne_position_message,
|
|
191
|
+
MessageType.AIRBORNE_VELOCITY: self._build_airborne_velocity_message,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
me = generators[selected_type]()
|
|
195
|
+
|
|
196
|
+
data = (df << 83) | (ca << 80) | (icao << 56) | me
|
|
197
|
+
|
|
198
|
+
return ADSBAlgorithms.calculate_crc(data), selected_type
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adsb-generator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: `adsb-generator` produces realistic I/Q (In-phase/Quadrature) samples of clean and impaired ADS-B (Mode S Downlink Format 17) baseband signals, along with the raw 112-bit message and all applied parameters. It implements an infinite iterator that streams reproducible samples with full control over message type distributions, transmission parameters, and channel impairments.
|
|
5
|
+
Author-email: Adam Hamri <adamhamri9@proton.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/adamhamri9/adsb-datagen
|
|
8
|
+
Project-URL: Repository, https://github.com/adamhamri9/adsb-datagen
|
|
9
|
+
Project-URL: Issues, https://github.com/adamhamri9/adsb-datagen/issues
|
|
10
|
+
Keywords: adsb,mode-s,aviation,signal-generation,iq-samples,baseband
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Classifier: Topic :: Communications :: Ham Radio
|
|
22
|
+
Requires-Python: <3.14,>=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy>=1.26.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# adsb-generator
|
|
31
|
+
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](https://www.python.org/downloads/)
|
|
34
|
+
|
|
35
|
+
**Synthetic ADS-B data generator.**
|
|
36
|
+
|
|
37
|
+
`adsb-generator` produces realistic I/Q (In-phase/Quadrature) samples of clean and impaired ADS-B (Mode S Downlink Format 17) baseband signals, along with the raw 112-bit message and all applied parameters. It implements an infinite iterator that streams reproducible samples with full control over message type distributions, transmission parameters, and channel impairments.
|
|
38
|
+
|
|
39
|
+
## Key Features
|
|
40
|
+
|
|
41
|
+
- **End-to-end pipeline**: random message generation, PPM encoding, and RF channel simulation in a single call.
|
|
42
|
+
- **Four ADS-B message types**: identification, surface position, airborne position, and airborne velocity with configurable emission probabilities.
|
|
43
|
+
- **Realistic channel impairments**: Gaussian Noise(both AWGN and Correlated) , frequency & phase offset, IQ imbalance, and DC offset, all sampled from configurable probability distributions.
|
|
44
|
+
- **Reproducibility**: deterministic output via a shared seed across all pipeline stages.
|
|
45
|
+
- **Configurable distributions**: override any transmission or channel parameter distribution to model specific receiver conditions or hardware behavior.
|
|
46
|
+
- **NumPy-native**: all signals are `np.complex64` arrays, ready for direct use with any downstream processing tool.
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- Python 3.10+
|
|
51
|
+
- NumPy
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install adsb-generator
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from adsb_generator import ADSBGenerator
|
|
63
|
+
|
|
64
|
+
# Create a generator with a fixed seed for reproducibility
|
|
65
|
+
gen = ADSBGenerator(seed=42)
|
|
66
|
+
|
|
67
|
+
# Each iteration yields an ADSBSample with the raw message,
|
|
68
|
+
# clean signal, and channel-impaired signal
|
|
69
|
+
for sample in gen:
|
|
70
|
+
print(f"Message type : {sample.message_type.value}")
|
|
71
|
+
print(f"Raw message : {sample.message:#028x}")
|
|
72
|
+
print(f"Clean signal : {sample.clean_signal.shape} complex64 samples")
|
|
73
|
+
print(f"Noisy signal : {sample.channel_signal.shape} complex64 samples")
|
|
74
|
+
print(f"SNR (dB) : {sample.channel_params['snr_db']:.1f}")
|
|
75
|
+
print(f"Amplitude : {sample.tx_params['amplitude']:.3f}")
|
|
76
|
+
break
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Customizing Distributions
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from adsb_generator import ADSBGenerator, MessageType, ChannelParams
|
|
83
|
+
|
|
84
|
+
# Favor airborne positions, restrict SNR to low-moderate range
|
|
85
|
+
gen = ADSBGenerator(
|
|
86
|
+
message_type_probs={
|
|
87
|
+
MessageType.AIRBORNE_POSITION: 0.60,
|
|
88
|
+
MessageType.AIRBORNE_VELOCITY: 0.20,
|
|
89
|
+
MessageType.IDENTIFICATION: 0.10,
|
|
90
|
+
MessageType.SURFACE_POSITION: 0.10,
|
|
91
|
+
},
|
|
92
|
+
channel_params_distributions={
|
|
93
|
+
ChannelParams.SNR_DB: [
|
|
94
|
+
[3.0, 8.0, 0.70],
|
|
95
|
+
[8.0, 15.0, 0.30],
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
sample_rate=2e6,
|
|
99
|
+
seed=12345,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
sample = next(gen)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## API Reference
|
|
106
|
+
|
|
107
|
+
### `ADSBGenerator`
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
ADSBGenerator(
|
|
111
|
+
message_type_probs: dict[MessageType | str, float] | None = None,
|
|
112
|
+
tx_params_distributions: dict[TXParams | str, list[list[float]]] | None = None,
|
|
113
|
+
channel_params_distributions: dict[ChannelParams | str, list[list[float]]] | None = None,
|
|
114
|
+
sample_rate: float = 2e6,
|
|
115
|
+
seed: int | None = None,
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Parameter | Type | Default | Description |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| `message_type_probs` | `dict` or `None` | Equal 25% per type | Mapping of `MessageType` to emission probabilities (must sum to 1.0). |
|
|
122
|
+
| `tx_params_distributions` | `dict` or `None` | Single amplitude band | Mapping of `TXParams` to `[[min, max, weight], ...]` intervals. |
|
|
123
|
+
| `channel_params_distributions` | `dict` or `None` | Typical ADS-B conditions | Mapping of `ChannelParams` to `[[min, max, weight], ...]` intervals. |
|
|
124
|
+
| `sample_rate` | `float` | `2e6` | Sampling rate in samples per second. |
|
|
125
|
+
| `seed` | `int` or `None` | Random | Seed for deterministic output across all pipeline stages. |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### `ADSBSample`
|
|
130
|
+
|
|
131
|
+
Dataclass returned by each iteration of `ADSBGenerator`.
|
|
132
|
+
|
|
133
|
+
| Field | Type | Description |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `message` | `int` | Complete 112-bit ADS-B message (including 24-bit CRC) as an integer. |
|
|
136
|
+
| `message_type` | `MessageType` | The type of ADS-B message generated. |
|
|
137
|
+
| `clean_signal` | `np.ndarray` | Complex baseband I/Q signal before channel impairments (`complex64`). |
|
|
138
|
+
| `tx_params` | `dict[TXParams, float]` | Transmission parameters applied during encoding. |
|
|
139
|
+
| `channel_signal` | `np.ndarray` | Complex baseband I/Q signal after channel impairments (`complex64`). |
|
|
140
|
+
| `channel_params` | `dict[ChannelParams, float]` | Channel parameters applied to the signal. |
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### `MessageType`
|
|
145
|
+
|
|
146
|
+
| Value | Description |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `IDENTIFICATION` | Aircraft identification (callsign) messages (TC 1--4). |
|
|
149
|
+
| `SURFACE_POSITION` | Surface position messages (TC 5--8). |
|
|
150
|
+
| `AIRBORNE_POSITION` | Airborne position messages (TC 9--18, 20--22). |
|
|
151
|
+
| `AIRBORNE_VELOCITY` | Airborne velocity messages (TC 19). |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
### `ADSBEncoder`
|
|
156
|
+
|
|
157
|
+
Encodes 112-bit messages into complex baseband I/Q samples using PPM per the Mode S standard.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
ADSBEncoder(
|
|
161
|
+
sample_rate: float = 2e6,
|
|
162
|
+
tx_params_distributions: dict[TXParams | str, list[list[float]]] | None = None,
|
|
163
|
+
seed: int | None = None,
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
| Method | Returns | Description |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `encode(msg: int)` | `tuple[np.ndarray, dict[TXParams, float]]` | Encodes a 112-bit message into a 120-us baseband I/Q signal. |
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
### `ADSBChannel`
|
|
174
|
+
|
|
175
|
+
Simulates realistic RF channel impairments on baseband I/Q signals.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
ADSBChannel(
|
|
179
|
+
sample_rate: float = 2e6,
|
|
180
|
+
channel_params_distributions: dict[ChannelParams | str, list[list[float]]] | None = None,
|
|
181
|
+
seed: int | None = None,
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Method | Returns | Description |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `apply(signal: np.ndarray)` | `tuple[np.ndarray, dict[ChannelParams, float]]` | Applies impairments: IQ imbalance, DC offset, frequency offset, phase offset, AWGN. |
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### `ChannelParams`
|
|
192
|
+
|
|
193
|
+
| Value | Description |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `SNR_DB` | Signal-to-noise ratio in dB. |
|
|
196
|
+
| `NOISE_CORRELATION` | I/Q noise correlation coefficient (-1.0 to 1.0). |
|
|
197
|
+
| `FREQUENCY_OFFSET` | Carrier frequency offset in Hz. |
|
|
198
|
+
| `PHASE_OFFSET` | Phase offset in radians. |
|
|
199
|
+
| `DC_OFFSET_I` | DC offset on the in-phase component. |
|
|
200
|
+
| `DC_OFFSET_Q` | DC offset on the quadrature component. |
|
|
201
|
+
| `IQ_GAIN_IMBALANCE` | Gain imbalance between I and Q channels. |
|
|
202
|
+
| `IQ_PHASE_IMBALANCE` | Phase imbalance between I and Q channels (degrees). |
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
### `ADSBAlgorithms`
|
|
207
|
+
|
|
208
|
+
Static utility class providing ADS-B encoding algorithms.
|
|
209
|
+
|
|
210
|
+
| Method | Returns | Description |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `calculate_crc(data: int)` | `int` | Computes 24-bit CRC parity using polynomial `0xFFF409`. |
|
|
213
|
+
| `encode_cpr(lat, lon, odd)` | `tuple[int, int]` | Encodes lat/lon into CPR 17-bit values. |
|
|
214
|
+
| `encode_altitude(alt: int)` | `int` | Encodes altitude in feet into 12-bit Gillham-coded format. |
|
|
215
|
+
| `encode_ground_track(degrees, valid)` | `tuple[int, int]` | Encodes ground track heading into the 7-bit format. |
|
|
216
|
+
|
|
217
|
+
## Distribution Format
|
|
218
|
+
|
|
219
|
+
All configurable distributions use the format `[[min_val, max_val, weight], ...]` where weights for a given parameter must sum to `1.0` (+/- 0.01 tolerance). Keys can be enum members or their string values (e.g., `ChannelParams.SNR_DB` or `"snr_db"`).
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
[MIT](LICENSE) - Copyright (c) 2026 Adam Hamri (adamhamri9)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
adsb_generator/__init__.py,sha256=AU84UFFLrdWoMK_uix9uCYodx7bh5YUJNrQf6IHrN54,352
|
|
2
|
+
adsb_generator/algorithms.py,sha256=LBcTkbK94gouSkFmDo48ZiZaC_-4mY9TtFJwes3shxw,2265
|
|
3
|
+
adsb_generator/channel.py,sha256=_5hUjqhpJyu_n8T8y1OdQycydrJUFEq11nTGZo1x79E,10416
|
|
4
|
+
adsb_generator/encoder.py,sha256=X4zUdVVU2ixI7x4xi9g5QFXV2r4CN9vFUcLMJDnLXNk,6235
|
|
5
|
+
adsb_generator/generator.py,sha256=RTdwA0DcWVc6P2IriJIfSNIqEz9bgDPrN9nV3xd8nU4,3774
|
|
6
|
+
adsb_generator/message.py,sha256=x9zIOKdtV-uikBkC1KtqiWnj-w61OTgvZuHQl2N2mqQ,8209
|
|
7
|
+
adsb_generator-0.1.0.dist-info/licenses/LICENSE,sha256=0Zc5Y-ihUySF2-U3su0-ERhEq-pYJPvcbfUMkONdrWY,1079
|
|
8
|
+
adsb_generator-0.1.0.dist-info/METADATA,sha256=chG4asTjaw4k5sUR0sOm4l0Nu1uK9nQD0d-q6whIPIE,8851
|
|
9
|
+
adsb_generator-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
adsb_generator-0.1.0.dist-info/top_level.txt,sha256=Dqxs5s64BNiCODLScEChyq31fa8AKNVZxQDDv6BDmKM,15
|
|
11
|
+
adsb_generator-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Hamri (adamhamri9)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
adsb_generator
|